From 2fcd623c51dd7c4b9eae88b4a6317fe8b2a202ef Mon Sep 17 00:00:00 2001
From: Fabio Manganiello <fabio@manganiello.tech>
Date: Sat, 16 Sep 2023 03:58:19 +0200
Subject: [PATCH] Migrated zwave.mqtt integration.

Merged the zwave.mqtt plugin with the listener and removed the
leftovers of the legacy zwave plugin.
---
 platypush/plugins/zwave/__init__.py       | 1265 ---------------------
 platypush/plugins/zwave/_base.py          |  911 ---------------
 platypush/plugins/zwave/manifest.yaml     |    7 -
 platypush/plugins/zwave/mqtt/__init__.py  |  780 ++++++-------
 platypush/plugins/zwave/mqtt/_listener.py |  197 ----
 platypush/plugins/zwave/mqtt/_state.py    |   97 ++
 6 files changed, 443 insertions(+), 2814 deletions(-)
 delete mode 100644 platypush/plugins/zwave/_base.py
 delete mode 100644 platypush/plugins/zwave/manifest.yaml
 delete mode 100644 platypush/plugins/zwave/mqtt/_listener.py
 create mode 100644 platypush/plugins/zwave/mqtt/_state.py

diff --git a/platypush/plugins/zwave/__init__.py b/platypush/plugins/zwave/__init__.py
index c2d3ee8e1..e69de29bb 100644
--- a/platypush/plugins/zwave/__init__.py
+++ b/platypush/plugins/zwave/__init__.py
@@ -1,1265 +0,0 @@
-from typing import Any, Dict, Optional, List, Union
-
-from platypush.backend.zwave import ZwaveBackend
-from platypush.context import get_backend
-from platypush.plugins import action
-from platypush.plugins.zwave._base import ZwaveBasePlugin
-
-
-class ZwavePlugin(ZwaveBasePlugin):
-    """
-    This plugin interacts with the devices on a Z-Wave network started through the
-    :class:`platypush.backend.zwave.ZwaveBackend` backend.
-
-    .. note::
-
-        This plugin is deprecated, since the underlying ``python-openzwave`` is
-        quite buggy and largely unmaintained.
-
-        Use the `zwave.mqtt` plugin instead
-        (:class:`platypush.plugins.zwave.mqtt.ZwaveMqttPlugin`).
-
-    Requires:
-
-        * **python-openzwave** (``pip install python-openzwave``)
-        * The :class:`platypush.backend.zwave.ZwaveBackend` backend configured and running.
-
-    """
-
-    @staticmethod
-    def _get_backend() -> ZwaveBackend:
-        backend = get_backend('zwave')
-        if not backend:
-            raise AssertionError('Z-Wave backend not configured')
-
-        return backend
-
-    @classmethod
-    def _get_network(cls):
-        backend = cls._get_backend()
-        if not backend.network:
-            backend.start_network()
-
-        assert backend.network
-        return backend.network
-
-    @classmethod
-    def _get_controller(cls):
-        return cls._get_network().controller
-
-    @action
-    def start_network(self):
-        backend = self._get_backend()
-        backend.start_network()
-
-    @action
-    def stop_network(self):
-        backend = self._get_backend()
-        backend.stop_network()
-
-    @action
-    def status(self) -> Dict[str, Any]:
-        """
-        Get the status of the controller.
-
-        :return: dict
-        """
-        backend = self._get_backend()
-        network = self._get_network()
-        controller = self._get_controller()
-
-        return {
-            'device': backend.device,
-            'state': network.state_str,
-            'stats': controller.stats,
-        }
-
-    @action
-    def add_node(self, do_security=False, **_):
-        """
-        Start the inclusion process to add a node to the network.
-
-        :param do_security: Whether to initialize the Network Key on the device if it supports the Security CC
-        """
-        controller = self._get_controller()
-        controller.add_node(do_security)
-
-    @action
-    def remove_node(self, **_):
-        """
-        Remove a node from the network.
-        """
-        controller = self._get_controller()
-        controller.remove_node()
-        self.write_config()
-
-    @action
-    def remove_failed_node(
-        self, node_id: Optional[int] = None, node_name: Optional[str] = None
-    ):
-        """
-        Remove a failed node from the network.
-
-        :param node_id: Filter by node_id.
-        :param node_name: Filter by node name.
-        """
-        controller = self._get_controller()
-        node = self._get_node(node_id=node_id, node_name=node_name)
-        controller.remove_failed_node(node.node_id)
-        self.write_config()
-
-    @action
-    def replace_failed_node(
-        self, node_id: Optional[int] = None, node_name: Optional[str] = None
-    ):
-        """
-        Replace a failed node on the network.
-
-        :param node_id: Filter by node_id.
-        :param node_name: Filter by node name.
-        """
-        controller = self._get_controller()
-        node = self._get_node(node_id=node_id, node_name=node_name)
-        controller.replace_failed_node(node.node_id)
-        self.write_config()
-
-    @action
-    def replication_send(
-        self, node_id: Optional[int] = None, node_name: Optional[str] = None
-    ):
-        """
-        Send node information from the primary to the secondary controller.
-
-        :param node_id: Filter by node_id.
-        :param node_name: Filter by node name.
-        """
-        controller = self._get_controller()
-        node = self._get_node(node_id=node_id, node_name=node_name)
-        controller.replication_send(node.node_id)
-
-    @action
-    def request_network_update(
-        self, node_id: Optional[int] = None, node_name: Optional[str] = None
-    ):
-        """
-        Request a network update to a node.
-
-        :param node_id: Filter by node_id.
-        :param node_name: Filter by node name.
-        """
-        controller = self._get_controller()
-        node = self._get_node(node_id=node_id, node_name=node_name)
-        controller.request_network_update(node.node_id)
-
-    @action
-    def request_node_neighbour_update(
-        self, node_id: Optional[int] = None, node_name: Optional[str] = None
-    ):
-        """
-        Request a neighbours list update to a node.
-
-        :param node_id: Filter by node_id.
-        :param node_name: Filter by node name.
-        """
-        controller = self._get_controller()
-        node = self._get_node(node_id=node_id, node_name=node_name)
-        controller.request_node_neighbor_update(node.node_id)
-
-    @staticmethod
-    def value_to_dict(value) -> Dict[str, Any]:
-        if not value:
-            return {}
-
-        return {
-            'command_class': value.node.get_command_class_as_string(value.command_class)
-            if value.command_class
-            else None,
-            'data': value.data,
-            'data_as_string': value.data_as_string,
-            'data_items': list(value.data_items)
-            if isinstance(value.data_items, set)
-            else value.data_items,
-            'genre': value.genre,
-            'help': value.help,
-            'home_id': value.home_id,
-            'id_on_network': value.id_on_network
-            if value.parent_id is not None
-            and value.command_class is not None
-            and value.instance is not None
-            and value.index is not None
-            else None,
-            'index': value.index,
-            'instance': value.instance,
-            'is_polled': value.is_polled,
-            'is_read_only': value.is_read_only,
-            'is_set': value.is_set,
-            'is_write_only': value.is_write_only,
-            'label': value.label,
-            'last_update': value.last_update,
-            'min': value.min,
-            'max': value.max,
-            'object_id': value.object_id,
-            'outdated': value.outdated,
-            'parent_id': value.parent_id,
-            'poll_intensity': value.poll_intensity,
-            'precision': value.precision,
-            'type': value.type,
-            'units': value.units,
-            'use_cache': value.use_cache,
-            'value_id': value.value_id,
-        }
-
-    @staticmethod
-    def group_to_dict(group) -> Dict[str, Any]:
-        if not group:
-            return {}
-
-        return {
-            'index': group.index,
-            'label': group.label,
-            'max_associations': group.max_associations,
-            'associations': group.associations,
-        }
-
-    @classmethod
-    def node_to_dict(cls, node) -> Dict[str, Any]:
-        if not node:
-            return {}
-
-        # noinspection PyProtectedMember
-        return {
-            'node_id': node.node_id,
-            'home_id': node.home_id,
-            'capabilities': list(node.capabilities),
-            'command_classes': [
-                node.get_command_class_as_string(cc)
-                for cc in node.command_classes
-                if cc
-            ]
-            if hasattr(node, 'command_classes')
-            else [],
-            'device_type': node.device_type if hasattr(node, 'device_type') else '',
-            'groups': {
-                group_id: cls.group_to_dict(group)
-                for group_id, group in node.groups.items()
-            },
-            'is_awake': node.is_awake if hasattr(node, 'is_awake') else False,
-            'is_failed': node.is_failed if hasattr(node, 'is_failed') else False,
-            'is_beaming_device': node.is_beaming_device
-            if hasattr(node, 'is_beaming_device')
-            else False,
-            'is_frequent_listening_device': node.is_frequent_listening_device
-            if hasattr(node, 'is_frequent_listening_device')
-            else False,
-            'is_info_received': node.is_info_received
-            if hasattr(node, 'is_info_received')
-            else False,
-            'is_listening_device': node.is_listening_device
-            if hasattr(node, 'is_listening_device')
-            else False,
-            'is_locked': node.is_locked if hasattr(node, 'is_locked') else False,
-            'is_ready': node.is_ready if hasattr(node, 'is_ready') else False,
-            'is_routing_device': node.is_routing_device
-            if hasattr(node, 'is_routing_device')
-            else False,
-            'is_security_device': node.is_security_device
-            if hasattr(node, 'is_security_device')
-            else False,
-            'is_sleeping': node.is_sleeping if hasattr(node, 'is_sleeping') else False,
-            'last_update': node.last_update if hasattr(node, 'last_update') else None,
-            'location': node.location if hasattr(node, 'location') else None,
-            'manufacturer_id': node.manufacturer_id
-            if hasattr(node, 'manufacturer_id')
-            else None,
-            'manufacturer_name': node.manufacturer_name
-            if hasattr(node, 'manufacturer_name')
-            else None,
-            'max_baud_rate': node.max_baud_rate
-            if hasattr(node, 'max_baud_rate')
-            else None,
-            'neighbours': list(node.neighbors or [])
-            if hasattr(node, 'neighbours')
-            else [],
-            'name': node.name if hasattr(node, 'name') else None,
-            'outdated': node.outdated if hasattr(node, 'outdated') else False,
-            'product_id': node.product_id if hasattr(node, 'product_id') else None,
-            'product_name': node.product_name
-            if hasattr(node, 'product_name')
-            else None,
-            'product_type': node.product_type
-            if hasattr(node, 'product_type')
-            else None,
-            'query_stage': node.query_stage if hasattr(node, 'query_stage') else None,
-            'role': node.role if hasattr(node, 'role') else None,
-            'security': node.security if hasattr(node, 'security') else None,
-            'specific': node.specific if hasattr(node, 'specific') else None,
-            'type': node.type if hasattr(node, 'type') else None,
-            'use_cache': node.use_cache if hasattr(node, 'use_cache') else False,
-            'version': node.version if hasattr(node, 'version') else None,
-            'values': {
-                value.id_on_network: cls.value_to_dict(value)
-                for _, value in (node.values or {}).items()
-                if value.index is not None
-                and value.instance is not None
-                and value.command_class
-                and value.parent_id is not None
-                and value._network
-                and value._network.home_id is not None
-            }
-            if hasattr(node, 'values')
-            else {},
-        }
-
-    def _get_node(self, node_id: Optional[int] = None, node_name: Optional[str] = None):
-        assert (
-            node_id is not None or node_name is not None
-        ), 'Specify either node_id or name'
-        nodes = self._get_network().nodes
-
-        if node_id is not None:
-            assert node_id in nodes, 'No such node_id: {}'.format(node_id)
-            return nodes[node_id]
-
-        nodes = [n for n in nodes.values() if n.name == node_name]
-        assert nodes, 'No such node name: {}'.format(node_name)
-        return nodes[0]
-
-    def _get_groups(self) -> dict:
-        return {
-            group_index: group
-            for node in self._get_network().nodes.values()
-            for group_index, group in node.groups.items()
-        }
-
-    def _get_group(
-        self, group_index: Optional[int] = None, group_label: Optional[str] = None
-    ):
-        assert (
-            group_index is not None or group_label is not None
-        ), 'Specify either group_index or label'
-        groups = self._get_groups()
-
-        if group_index is not None:
-            assert group_index in groups, 'No such group_index: {}'.format(group_index)
-            return groups[group_index]
-
-        groups = [g for g in groups.values() if g.label == group_label]
-        assert groups, 'No such group label: {}'.format(group_label)
-        return groups[0]
-
-    def _get_scene(
-        self, scene_id: Optional[int] = None, scene_label: Optional[str] = None
-    ):
-        assert (
-            scene_id is not None or scene_label is not None
-        ), 'Specify either scene_id or label'
-        scenes = self._get_network().get_scenes()
-
-        if scene_id is not None:
-            assert scene_id in scenes, 'No such scene_id: {}'.format(scene_id)
-            return scenes[scene_id]
-
-        scenes = [s for s in scenes.values() if s['label'] == scene_label]
-        assert scenes, 'No such scene label: {}'.format(scene_label)
-        return scenes[0]
-
-    @action
-    def get_nodes(
-        self, node_id: Optional[int] = None, node_name: Optional[str] = None
-    ) -> Dict[str, Any]:
-        """
-        Get the nodes associated to the network.
-
-        :param node_id: Filter by node_id.
-        :param node_name: Filter by node name.
-        """
-        if node_id is not None or node_name is not None:
-            return self.node_to_dict(
-                self._get_node(node_id=node_id, node_name=node_name)
-            )
-
-        return {
-            node_id: self.node_to_dict(node)
-            for node_id, node in self._get_network().nodes.items()
-        }
-
-    @action
-    def get_node_stats(
-        self, node_id: Optional[int] = None, node_name: Optional[str] = None
-    ) -> Dict[str, Any]:
-        """
-        Get the statistics of a node on the network.
-
-        :param node_id: Filter by node_id.
-        :param node_name: Filter by node name.
-        """
-        node = self._get_node(node_id=node_id, node_name=node_name)
-        return node.stats
-
-    @action
-    def set_node_name(
-        self,
-        new_name: str,
-        node_id: Optional[int] = None,
-        node_name: Optional[str] = None,
-    ):
-        """
-        Rename a node on the network.
-
-        :param new_name: New name for the node.
-        :param node_id: Filter by node_id.
-        :param node_name: Filter by current node name.
-        """
-        node = self._get_node(node_id=node_id, node_name=node_name)
-        node.name = new_name
-        self.write_config()
-
-    @action
-    def set_node_product_name(
-        self,
-        product_name: str,
-        node_id: Optional[int] = None,
-        node_name: Optional[str] = None,
-    ):
-        """
-        Set the product name of a node.
-
-        :param product_name: Product name.
-        :param node_id: Filter by node_id.
-        :param node_name: Filter by current node name.
-        """
-        node = self._get_node(node_id=node_id, node_name=node_name)
-        node.product_name = product_name
-        self.write_config()
-
-    @action
-    def set_node_manufacturer_name(
-        self,
-        manufacturer_name: str,
-        node_id: Optional[int] = None,
-        node_name: Optional[str] = None,
-    ):
-        """
-        Set the manufacturer name of a node.
-
-        :param manufacturer_name: Manufacturer name.
-        :param node_id: Filter by node_id.
-        :param node_name: Filter by current node name.
-        """
-        node = self._get_node(node_id=node_id, node_name=node_name)
-        node.manufacturer_name = manufacturer_name
-        self.write_config()
-
-    @action
-    def set_node_location(
-        self,
-        location: str,
-        node_id: Optional[int] = None,
-        node_name: Optional[str] = None,
-    ):
-        """
-        Set the location of a node.
-
-        :param location: Node location.
-        :param node_id: Filter by node_id.
-        :param node_name: Filter by current node name.
-        """
-        node = self._get_node(node_id=node_id, node_name=node_name)
-        node.location = location
-        self.write_config()
-
-    @action
-    def cancel_command(self):
-        """
-        Cancel the current running command.
-        """
-        self._get_controller().cancel_command()
-
-    @action
-    def kill_command(self):
-        """
-        Immediately terminate any running command on the controller and release the lock.
-        """
-        self._get_controller().kill_command()
-
-    @action
-    def set_controller_name(self, name: str):
-        """
-        Set the name of the controller on the network.
-
-        :param name: New controller name.
-        """
-        self._get_controller().name = name
-        self.write_config()
-
-    @action
-    def get_capabilities(self) -> List[str]:
-        """
-        Get the capabilities of the controller.
-        """
-        return list(self._get_controller().capabilities)
-
-    @action
-    def receive_configuration(self):
-        """
-        Receive the configuration from the primary controller on the network. Requires a primary controller active.
-        """
-        self._get_controller().receive_configuration()
-
-    @action
-    def transfer_primary_role(self):
-        """
-        Add a new controller to the network and make it the primary.
-        The existing primary will become a secondary controller.
-        """
-        self._get_controller().transfer_primary_role()
-
-    @action
-    def heal(self, refresh_routes: bool = False):
-        """
-        Heal network by requesting nodes rediscover their neighbors.
-
-        :param refresh_routes: Whether to perform return routes initialization (default: ``False``).
-        """
-        self._get_network().heal(refresh_routes)
-
-    @action
-    def switch_all(self, state: bool):
-        """
-        Switch all the connected devices on/off.
-
-        :param state: True (switch on) or False (switch off).
-        """
-        self._get_network().switch_all(state)
-
-    @action
-    def test(self, count: int = 1):
-        """
-        Send a number of test messages to every node and record results.
-
-        :param count: The number of test messages to send.
-        """
-        self._get_network().test(count)
-
-    def _get_value(
-        self,
-        value_id: Optional[int] = None,
-        id_on_network: Optional[str] = None,
-        node_id: Optional[int] = None,
-        node_name: Optional[str] = None,
-        value_label: Optional[str] = None,
-    ):
-        assert (value_id is not None or id_on_network is not None) or (
-            (node_id is not None or node_name is not None) and value_label is not None
-        ), 'Specify either value_id, id_on_network, or [node_id/node_name, value_label]'
-
-        if value_id is not None:
-            return self._get_network().get_value(value_id)
-        if id_on_network is not None:
-            values = [
-                value
-                for node in self._get_network().nodes.values()
-                for value in node.values.values()
-                if value.id_on_network == id_on_network
-            ]
-
-            assert values, 'No such value ID: {}'.format(id_on_network)
-            return values[0]
-
-        node = self._get_node(node_id=node_id, node_name=node_name)
-        values = [v for v in node.values.values() if v.label == value_label]
-        assert values, 'No such value on node "{}": "{}"'.format(node.name, value_label)
-        return values[0]
-
-    @action
-    def get_value(
-        self,
-        value_id: Optional[int] = None,
-        id_on_network: Optional[str] = None,
-        value_label: Optional[str] = None,
-        node_id: Optional[int] = None,
-        node_name: Optional[str] = None,
-    ) -> Dict[str, Any]:
-        """
-        Get a value on the network.
-
-        :param value_id: Select by value_id.
-        :param id_on_network: Select value by id_on_network.
-        :param value_label: Select value by [node_id/node_name, value_label]
-        :param node_id: Select value by [node_id/node_name, value_label]
-        :param node_name: Select value by [node_id/node_name, value_label]
-        """
-        return self._get_value(
-            value_id=value_id,
-            id_on_network=id_on_network,
-            node_id=node_id,
-            node_name=node_name,
-            value_label=value_label,
-        ).to_dict()
-
-    @action
-    def set_value(
-        self,
-        data,
-        value_id: Optional[int] = None,
-        id_on_network: Optional[str] = None,
-        value_label: Optional[str] = None,
-        node_id: Optional[int] = None,
-        node_name: Optional[str] = None,
-    ):
-        """
-        Set a value.
-
-        :param data: Data to set for the value.
-        :param value_id: Select value by value_id.
-        :param id_on_network: Select value by id_on_network.
-        :param value_label: Select value by [node_id/node_name, value_label]
-        :param node_id: Select value by [node_id/node_name, value_label]
-        :param node_name: Select value by [node_id/node_name, value_label]
-        """
-        from openzwave.node import ZWaveNode
-
-        value = self._get_value(
-            value_id=value_id,
-            id_on_network=id_on_network,
-            node_id=node_id,
-            node_name=node_name,
-            value_label=value_label,
-        )
-        new_val = value.check_data(data)
-        assert new_val is not None, 'Invalid value passed to the property'
-        node: ZWaveNode = self._get_network().nodes[value.node.node_id]
-        node.values[value.value_id].data = new_val
-        self.write_config()
-
-    @action
-    def set_value_label(
-        self,
-        new_label: str,
-        value_id: Optional[int] = None,
-        id_on_network: Optional[str] = None,
-        value_label: Optional[str] = None,
-        node_id: Optional[int] = None,
-        node_name: Optional[str] = None,
-    ):
-        """
-        Change the label/name of a value.
-
-        :param new_label: New value label.
-        :param value_id: Select value by value_id.
-        :param id_on_network: Select value by id_on_network.
-        :param value_label: Select value by [node_id/node_name, value_label]
-        :param node_id: Select value by [node_id/node_name, value_label]
-        :param node_name: Select value by [node_id/node_name, value_label]
-        """
-        value = self._get_value(
-            value_id=value_id,
-            id_on_network=id_on_network,
-            node_id=node_id,
-            node_name=node_name,
-            value_label=value_label,
-        )
-        value.label = new_label
-        self.write_config()
-
-    @action
-    def node_add_value(
-        self,
-        value_id: Optional[int] = None,
-        id_on_network: Optional[str] = None,
-        value_label: Optional[str] = None,
-        node_id: Optional[int] = None,
-        node_name: Optional[str] = None,
-    ):
-        """
-        Add a value to a node.
-
-        :param value_id: Select value by value_id.
-        :param id_on_network: Select value by id_on_network.
-        :param value_label: Select value by label.
-        :param node_id: Select node by node_id.
-        :param node_name: Select node by label.
-        """
-        node = self._get_node(node_id=node_id, node_name=node_name)
-        value = self._get_value(
-            value_id=value_id,
-            id_on_network=id_on_network,
-            node_id=node.node_id,
-            value_label=value_label,
-        )
-        node.add_value(value.value_id)
-        self.write_config()
-
-    @action
-    def node_remove_value(
-        self,
-        value_id: Optional[int] = None,
-        id_on_network: Optional[str] = None,
-        value_label: Optional[str] = None,
-        node_id: Optional[int] = None,
-        node_name: Optional[str] = None,
-    ):
-        """
-        Remove a value from a node.
-
-        :param value_id: Select value by value_id.
-        :param id_on_network: Select value by id_on_network.
-        :param value_label: Select value by [node_id/node_name, value_label]
-        :param node_id: Select node by node_id.
-        :param node_name: Select node by label.
-        """
-        node = self._get_node(node_id=node_id, node_name=node_name)
-        value = self._get_value(
-            value_id=value_id,
-            id_on_network=id_on_network,
-            node_id=node.node_id,
-            value_label=value_label,
-        )
-        node.remove_value(value.value_id)
-        self.write_config()
-
-    @action
-    def node_heal(
-        self,
-        node_id: Optional[int] = None,
-        node_name: Optional[str] = None,
-        refresh_routes: bool = False,
-    ):
-        """
-        Heal network node by requesting the node to rediscover their neighbours.
-
-        :param node_id: Select node by node_id.
-        :param node_name: Select node by label.
-        :param refresh_routes: Whether to perform return routes initialization. (default: ``False``).
-        """
-        node = self._get_node(node_id=node_id, node_name=node_name)
-        node.heal(refresh_routes)
-
-    @action
-    def node_update_neighbours(
-        self, node_id: Optional[int] = None, node_name: Optional[str] = None
-    ):
-        """
-        Ask a node to update its neighbours table.
-
-        :param node_id: Select node by node_id.
-        :param node_name: Select node by label.
-        """
-        node = self._get_node(node_id=node_id, node_name=node_name)
-        node.neighbor_update()
-
-    @action
-    def node_network_update(
-        self, node_id: Optional[int] = None, node_name: Optional[str] = None
-    ):
-        """
-        Update the controller with network information.
-
-        :param node_id: Select node by node_id.
-        :param node_name: Select node by label.
-        """
-        node = self._get_node(node_id=node_id, node_name=node_name)
-        node.network_update()
-
-    @action
-    def node_refresh_info(
-        self, node_id: Optional[int] = None, node_name: Optional[str] = None
-    ):
-        """
-        Fetch up-to-date information about the node.
-
-        :param node_id: Select node by node_id.
-        :param node_name: Select node by label.
-        """
-        node = self._get_node(node_id=node_id, node_name=node_name)
-        node.refresh_info()
-
-    def _get_values(
-        self, item: str, node_id: Optional[int] = None, node_name: Optional[str] = None
-    ) -> Dict[int, Any]:
-        nodes = (
-            [self._get_node(node_id=node_id, node_name=node_name)]
-            if node_id or node_name
-            else self._get_network().nodes.values()
-        )
-
-        return {
-            value.id_on_network: {
-                'node_id': node.node_id,
-                'node_name': node.name,
-                **self.value_to_dict(value),
-            }
-            for node in nodes
-            for value in getattr(node, 'get_' + item)().values()
-        }
-
-    @action
-    def get_dimmers(
-        self, node_id: Optional[int] = None, node_name: Optional[str] = None
-    ) -> Dict[int, Any]:
-        """
-        Get the dimmers on the network or associated to a node.
-
-        :param node_id: Select node by node_id.
-        :param node_name: Select node by label.
-        """
-        return self._get_values('dimmers', node_id=node_id, node_name=node_name)
-
-    @action
-    def get_node_config(
-        self, node_id: Optional[int] = None, node_name: Optional[str] = None
-    ) -> Dict[int, Any]:
-        """
-        Get the configuration values of a node or of all the nodes on the network.
-
-        :param node_id: Select node by node_id.
-        :param node_name: Select node by label.
-        """
-        return self._get_values('configs', node_id=node_id, node_name=node_name)
-
-    @action
-    def get_battery_levels(
-        self, node_id: Optional[int] = None, node_name: Optional[str] = None
-    ) -> Dict[int, Any]:
-        """
-        Get the battery levels of a node or of all the nodes on the network.
-
-        :param node_id: Select node by node_id.
-        :param node_name: Select node by name.
-        """
-        return self._get_values('battery_levels', node_id=node_id, node_name=node_name)
-
-    @action
-    def get_power_levels(
-        self, node_id: Optional[int] = None, node_name: Optional[str] = None
-    ) -> Dict[int, Any]:
-        """
-        Get the power levels of this node.
-
-        :param node_id: Select node by node_id.
-        :param node_name: Select node by name.
-        """
-        return self._get_values('power_levels', node_id=node_id, node_name=node_name)
-
-    @action
-    def get_bulbs(
-        self, node_id: Optional[int] = None, node_name: Optional[str] = None
-    ) -> Dict[int, Any]:
-        """
-        Get the bulbs/LEDs on the network or associated to a node.
-
-        :param node_id: Select node by node_id.
-        :param node_name: Select node by name.
-        """
-        return self._get_values('rgbbulbs', node_id=node_id, node_name=node_name)
-
-    @action
-    def get_switches(
-        self, node_id: Optional[int] = None, node_name: Optional[str] = None
-    ) -> Dict[int, Any]:
-        """
-        Get the switches on the network or associated to a node.
-
-        :param node_id: Select node by node_id.
-        :param node_name: Select node by name.
-        """
-        return self._get_values('switches', node_id=node_id, node_name=node_name)
-
-    @action
-    def get_sensors(
-        self, node_id: Optional[int] = None, node_name: Optional[str] = None
-    ) -> Dict[int, Any]:
-        """
-        Get the sensors on the network or associated to a node.
-
-        :param node_id: Select node by node_id.
-        :param node_name: Select node by name.
-        """
-        return self._get_values('sensors', node_id=node_id, node_name=node_name)
-
-    @action
-    def get_doorlocks(
-        self, node_id: Optional[int] = None, node_name: Optional[str] = None
-    ) -> Dict[int, Any]:
-        """
-        Get the doorlocks on the network or associated to a node.
-
-        :param node_id: Select node by node_id.
-        :param node_name: Select node by name.
-        """
-        return self._get_values('doorlocks', node_id=node_id, node_name=node_name)
-
-    @action
-    def get_usercodes(
-        self, node_id: Optional[int] = None, node_name: Optional[str] = None
-    ) -> Dict[int, Any]:
-        """
-        Get the usercodes on the network or associated to a node.
-
-        :param node_id: Select node by node_id.
-        :param node_name: Select node by name.
-        """
-        return self._get_values('usercodes', node_id=node_id, node_name=node_name)
-
-    @action
-    def get_thermostats(
-        self, node_id: Optional[int] = None, node_name: Optional[str] = None
-    ) -> Dict[int, Any]:
-        """
-        Get the thermostats on the network or associated to a node.
-
-        :param node_id: Select node by node_id.
-        :param node_name: Select node by name.
-        """
-        return self._get_values('thermostats', node_id=node_id, node_name=node_name)
-
-    @action
-    def get_protections(
-        self, node_id: Optional[int] = None, node_name: Optional[str] = None
-    ) -> Dict[int, Any]:
-        """
-        Get the protection-compatible devices on the network or associated to a node.
-
-        :param node_id: Select node by node_id.
-        :param node_name: Select node by name.
-        """
-        return self._get_values('protections', node_id=node_id, node_name=node_name)
-
-    @action
-    def get_groups(self) -> Dict[int, Any]:
-        """
-        Get the groups on the network.
-        """
-        return {
-            group_index: self.group_to_dict(group)
-            for group_index, group in self._get_groups().items()
-        }
-
-    @action
-    def get_scenes(self) -> Dict[str, Any]:
-        """
-        Get the scenes configured on the network.
-        """
-        network = self._get_network()
-        if not network.get_scenes():
-            return {}
-
-        return network.scenes_to_dict()
-
-    @action
-    def create_scene(self, label: str):
-        """
-        Create a new scene.
-
-        :param label: Scene label.
-        """
-        self._get_network().create_scene(label)
-        self.write_config()
-
-    @action
-    def remove_scene(
-        self, scene_id: Optional[int] = None, scene_label: Optional[str] = None
-    ):
-        """
-        Remove a scene.
-
-        :param scene_id: Select by scene_id.
-        :param scene_label: Select by scene label.
-        """
-        scene = self._get_scene(scene_id=scene_id, scene_label=scene_label)
-        self._get_network().remove_scene(scene.scene_id)
-        self.write_config()
-
-    @action
-    def activate_scene(
-        self, scene_id: Optional[int] = None, scene_label: Optional[str] = None
-    ):
-        """
-        Activate a scene.
-
-        :param scene_id: Select by scene_id.
-        :param scene_label: Select by scene label.
-        """
-        scene = self._get_scene(scene_id=scene_id, scene_label=scene_label)
-        scene.activate()
-
-    @action
-    def set_scene_label(
-        self,
-        new_label: str,
-        scene_id: Optional[int] = None,
-        scene_label: Optional[str] = None,
-    ):
-        """
-        Rename a scene/set the scene label.
-
-        :param new_label: New label.
-        :param scene_id: Select by scene_id.
-        :param scene_label: Select by current scene label.
-        """
-        scene = self._get_scene(scene_id=scene_id, scene_label=scene_label)
-        scene.label = new_label
-        self.write_config()
-
-    @action
-    def scene_add_value(
-        self,
-        data: Optional[Any] = None,
-        value_id: Optional[int] = None,
-        id_on_network: Optional[str] = None,
-        value_label: Optional[str] = None,
-        scene_id: Optional[int] = None,
-        scene_label: Optional[str] = None,
-        node_id: Optional[int] = None,
-        node_name: Optional[str] = None,
-    ):
-        """
-        Add a value to a scene.
-
-        WARNING: This method actually doesn't work, by own admission of the
-        `OpenZWave developer
-        <https://github.com/OpenZWave/python-openzwave/blob/master/src-lib/libopenzwave/libopenzwave.pyx#L4730>`_.
-
-
-        :param data: Data to set for the value (default: current value data).
-        :param value_id: Select value by value_id.
-        :param id_on_network: Select value by id_on_network.
-        :param value_label: Select value by [node_id/node_name, value_label]
-        :param node_id: Select value by [node_id/node_name, value_label]
-        :param node_name: Select value by [node_id/node_name, value_label]
-        :param scene_id: Select scene by scene_id.
-        :param scene_label: Select scene by scene label.
-        """
-        value = self._get_value(
-            value_id=value_id,
-            id_on_network=id_on_network,
-            node_id=node_id,
-            node_name=node_name,
-            value_label=value_label,
-        )
-        scene = self._get_scene(scene_id=scene_id, scene_label=scene_label)
-        data = data if data is not None else value.data
-        data = value.check_data(data)
-        assert data is not None, 'Invalid value passed to the property'
-
-        scene.add_value(value.value_id, data)
-        self.write_config()
-
-    @action
-    def scene_remove_value(
-        self,
-        value_id: Optional[int] = None,
-        id_on_network: Optional[str] = None,
-        value_label: Optional[str] = None,
-        scene_id: Optional[int] = None,
-        scene_label: Optional[str] = None,
-        node_id: Optional[int] = None,
-        node_name: Optional[str] = None,
-    ):
-        """
-        Remove a value from a scene.
-
-        :param value_id: Select value by value_id.
-        :param id_on_network: Select value by id_on_network.
-        :param value_label: Select value by [node_id/node_name, value_label]
-        :param node_id: Select value by [node_id/node_name, value_label]
-        :param node_name: Select value by [node_id/node_name, value_label]
-        :param scene_id: Select scene by scene_id.
-        :param scene_label: Select scene by scene label.
-        """
-        value = self._get_value(
-            value_id=value_id,
-            id_on_network=id_on_network,
-            node_id=node_id,
-            node_name=node_name,
-            value_label=value_label,
-        )
-        scene = self._get_scene(scene_id=scene_id, scene_label=scene_label)
-        scene.remove_value(value.value_id)
-        self.write_config()
-
-    @action
-    def get_scene_values(
-        self, scene_id: Optional[int] = None, scene_label: Optional[str] = None
-    ) -> dict:
-        """
-        Get the values associated to a scene.
-
-        :param scene_id: Select by scene_id.
-        :param scene_label: Select by scene label.
-        :return: value_id -> value (as a dict) mapping.
-        """
-        scene = self._get_scene(scene_id=scene_id, scene_label=scene_label)
-        return {v.value_id: v.to_dict() for v in scene.get_values().items()}
-
-    @action
-    def create_button(
-        self,
-        button_id: Union[int, str],
-        node_id: Optional[int] = None,
-        node_name: Optional[str] = None,
-    ):
-        """
-        Create a handheld button on a device. Only intended for bridge firmware controllers.
-
-        :param button_id: The ID of the button.
-        :param node_id: Filter by node_id.
-        :param node_name: Filter by current node name.
-        """
-        node = self._get_node(node_id=node_id, node_name=node_name)
-        self._get_controller().create_button(node.node_id, button_id)
-        self.write_config()
-
-    @action
-    def delete_button(
-        self,
-        button_id: Union[int, str],
-        node_id: Optional[int] = None,
-        node_name: Optional[str] = None,
-    ):
-        """
-        Delete a button association from a device. Only intended for bridge firmware controllers.
-
-        :param button_id: The ID of the button.
-        :param node_id: Filter by node_id.
-        :param node_name: Filter by current node name.
-        """
-        node = self._get_node(node_id=node_id, node_name=node_name)
-        self._get_controller().delete_button(node.node_id, button_id)
-        self.write_config()
-
-    @action
-    def add_node_to_group(
-        self,
-        group_index: Optional[int] = None,
-        group_label: Optional[str] = None,
-        node_id: Optional[int] = None,
-        node_name: Optional[str] = None,
-    ):
-        """
-        Add a node to a group.
-
-        :param group_index: Select group by group index.
-        :param group_label: Select group by group label.
-        :param node_id: Select node by node_id.
-        :param node_name: Select node by node name.
-        :return:
-        """
-        node = self._get_node(node_id=node_id, node_name=node_name)
-        group = self._get_group(group_index=group_index, group_label=group_label)
-        group.add_association(node.node_id)
-        self.write_config()
-
-    @action
-    def remove_node_from_group(
-        self,
-        group_index: Optional[int] = None,
-        group_label: Optional[str] = None,
-        node_id: Optional[int] = None,
-        node_name: Optional[str] = None,
-    ):
-        """
-        Remove a node from a group.
-
-        :param group_index: Select group by group index.
-        :param group_label: Select group by group label.
-        :param node_id: Select node by node_id.
-        :param node_name: Select node by node name.
-        :return:
-        """
-        node = self._get_node(node_id=node_id, node_name=node_name)
-        group = self._get_group(group_index=group_index, group_label=group_label)
-        group.remove_association(node.node_id)
-        self.write_config()
-
-    @action
-    def create_new_primary(self):
-        """
-        Create a new primary controller on the network when the previous primary fails.
-        """
-        self._get_controller().create_new_primary()
-        self.write_config()
-
-    @action
-    def hard_reset(self):
-        """
-        Perform a hard reset of the controller. It erases its network configuration settings.
-        The controller becomes a primary controller ready to add devices to a new network.
-        """
-        self._get_controller().hard_reset()
-
-    @action
-    def soft_reset(self):
-        """
-        Perform a soft reset of the controller.
-        Resets a controller without erasing its network configuration settings.
-        """
-        self._get_controller().soft_reset()
-
-    @action
-    def write_config(self):
-        """
-        Store the current configuration of the network to the user directory.
-        """
-        self._get_network().write_config()
-
-    @action
-    def on(self, device: str, *_, **__):
-        """
-        Turn on a switch on a device.
-
-        :param device: ``id_on_network`` of the value to be switched on.
-        """
-        # noinspection PyUnresolvedReferences
-        switches = self.get_switches().output
-        assert (
-            device in switches
-        ), 'No such id_on_network associated to a switch: {}'.format(device)
-        return self.set_value(data=True, id_on_network=device)
-
-    @action
-    def off(self, device: str, *_, **__):
-        """
-        Turn off a switch on a device.
-
-        :param device: ``id_on_network`` of the value to be switched off.
-        """
-        # noinspection PyUnresolvedReferences
-        switches = self.get_switches().output
-        assert (
-            device in switches
-        ), 'No such id_on_network associated to a switch: {}'.format(device)
-        return self.set_value(data=False, id_on_network=device)
-
-    @action
-    def toggle(self, device: str, *_, **__):
-        """
-        Toggle a switch on a device.
-
-        :param device: ``id_on_network`` of the value to be toggled.
-        """
-        # noinspection PyUnresolvedReferences
-        switches = self.get_switches().output
-        assert (
-            device in switches
-        ), 'No such id_on_network associated to a switch: {}'.format(device)
-        data = switches[device]['data']
-        return self.set_value(data=not data, id_on_network=device)
-
-    @property
-    def switches(self) -> List[dict]:
-        # noinspection PyUnresolvedReferences
-        devices = self.get_switches().output.values()
-        return [
-            {
-                'name': '{} - {}'.format(
-                    dev.get('node_name', '[No Name]'), dev.get('label', '[No Label]')
-                ),
-                'on': dev.get('data'),
-                'id': dev.get('id_on_network'),
-            }
-            for dev in devices
-        ]
-
-
-# vim:sw=4:ts=4:et:
diff --git a/platypush/plugins/zwave/_base.py b/platypush/plugins/zwave/_base.py
deleted file mode 100644
index 8f5356f31..000000000
--- a/platypush/plugins/zwave/_base.py
+++ /dev/null
@@ -1,911 +0,0 @@
-from abc import ABC, abstractmethod
-from typing import Any, Dict, Optional, List, Union
-
-from platypush.entities import (
-    DimmerEntityManager,
-    EnumSwitchEntityManager,
-    LightEntityManager,
-    SensorEntityManager,
-    SwitchEntityManager,
-)
-from platypush.plugins import Plugin, action
-
-
-class ZwaveBasePlugin(
-    DimmerEntityManager,
-    EnumSwitchEntityManager,
-    LightEntityManager,
-    SensorEntityManager,
-    SwitchEntityManager,
-    Plugin,
-    ABC,
-):
-    """
-    Base class for Z-Wave plugins.
-    """
-
-    @abstractmethod
-    @action
-    def start_network(self):
-        raise NotImplementedError
-
-    @abstractmethod
-    @action
-    def stop_network(self):
-        raise NotImplementedError
-
-    @abstractmethod
-    @action
-    def status(self) -> Dict[str, Any]:  # pylint: disable=arguments-differ
-        """
-        Get the status of the controller.
-        """
-        raise NotImplementedError
-
-    @abstractmethod
-    @action
-    def add_node(self, *args, **kwargs):
-        """
-        Start the inclusion process to add a node to the network.
-        """
-        raise NotImplementedError
-
-    @abstractmethod
-    @action
-    def remove_node(self):
-        """
-        Remove a node from the network.
-        """
-        raise NotImplementedError
-
-    @abstractmethod
-    @action
-    def remove_failed_node(
-        self, node_id: Optional[int] = None, node_name: Optional[str] = None, **kwargs
-    ):
-        """
-        Remove a failed node from the network.
-
-        :param node_id: Filter by node_id.
-        :param node_name: Filter by node name.
-        """
-        raise NotImplementedError
-
-    @abstractmethod
-    @action
-    def replace_failed_node(
-        self, node_id: Optional[int] = None, node_name: Optional[str] = None, **kwargs
-    ):
-        """
-        Replace a failed node on the network.
-
-        :param node_id: Filter by node_id.
-        :param node_name: Filter by node name.
-        """
-        raise NotImplementedError
-
-    @abstractmethod
-    @action
-    def replication_send(
-        self, node_id: Optional[int] = None, node_name: Optional[str] = None, **kwargs
-    ):
-        """
-        Send node information from the primary to the secondary controller.
-
-        :param node_id: Filter by node_id.
-        :param node_name: Filter by node name.
-        """
-        raise NotImplementedError
-
-    @abstractmethod
-    @action
-    def request_network_update(
-        self, node_id: Optional[int] = None, node_name: Optional[str] = None, **kwargs
-    ):
-        """
-        Request a network update to a node.
-
-        :param node_id: Filter by node_id.
-        :param node_name: Filter by node name.
-        """
-        raise NotImplementedError
-
-    @abstractmethod
-    @action
-    def request_node_neighbour_update(
-        self, node_id: Optional[int] = None, node_name: Optional[str] = None, **kwargs
-    ):
-        """
-        Request a neighbours list update to a node.
-
-        :param node_id: Filter by node_id.
-        :param node_name: Filter by node name.
-        """
-        raise NotImplementedError
-
-    @abstractmethod
-    @action
-    def get_nodes(
-        self, node_id: Optional[int] = None, node_name: Optional[str] = None, **kwargs
-    ) -> Dict[str, Any]:
-        """
-        Get the nodes associated to the network.
-
-        :param node_id: Filter by node_id.
-        :param node_name: Filter by node name.
-        """
-        raise NotImplementedError
-
-    @abstractmethod
-    @action
-    def get_node_stats(
-        self, node_id: Optional[int] = None, node_name: Optional[str] = None, **kwargs
-    ) -> Dict[str, Any]:
-        """
-        Get the statistics of a node on the network.
-
-        :param node_id: Filter by node_id.
-        :param node_name: Filter by node name.
-        """
-        raise NotImplementedError
-
-    @abstractmethod
-    @action
-    def set_node_name(
-        self,
-        new_name: str,
-        node_id: Optional[int] = None,
-        node_name: Optional[str] = None,
-    ):
-        """
-        Rename a node on the network.
-
-        :param new_name: New name for the node.
-        :param node_id: Filter by node_id.
-        :param node_name: Filter by current node name.
-        """
-        raise NotImplementedError
-
-    @abstractmethod
-    @action
-    def set_node_product_name(
-        self,
-        product_name: str,
-        node_id: Optional[int] = None,
-        node_name: Optional[str] = None,
-        **kwargs
-    ):
-        """
-        Set the product name of a node.
-
-        :param product_name: Product name.
-        :param node_id: Filter by node_id.
-        :param node_name: Filter by current node name.
-        """
-        raise NotImplementedError
-
-    @abstractmethod
-    @action
-    def set_node_manufacturer_name(
-        self,
-        manufacturer_name: str,
-        node_id: Optional[int] = None,
-        node_name: Optional[str] = None,
-        **kwargs
-    ):
-        """
-        Set the manufacturer name of a node.
-
-        :param manufacturer_name: Manufacturer name.
-        :param node_id: Filter by node_id.
-        :param node_name: Filter by current node name.
-        """
-        raise NotImplementedError
-
-    @abstractmethod
-    @action
-    def set_node_location(
-        self,
-        location: str,
-        node_id: Optional[int] = None,
-        node_name: Optional[str] = None,
-        **kwargs
-    ):
-        """
-        Set the location of a node.
-
-        :param location: Node location.
-        :param node_id: Filter by node_id.
-        :param node_name: Filter by current node name.
-        """
-        raise NotImplementedError
-
-    @abstractmethod
-    @action
-    def cancel_command(self):
-        """
-        Cancel the current running command.
-        """
-        raise NotImplementedError
-
-    @abstractmethod
-    @action
-    def kill_command(self):
-        """
-        Immediately terminate any running command on the controller and release the lock.
-        """
-        raise NotImplementedError
-
-    @abstractmethod
-    @action
-    def set_controller_name(self, name: str, **kwargs):
-        """
-        Set the name of the controller on the network.
-
-        :param name: New controller name.
-        """
-        raise NotImplementedError
-
-    @abstractmethod
-    @action
-    def get_capabilities(self, **kwargs) -> List[str]:
-        """
-        Get the capabilities of the controller.
-        """
-        raise NotImplementedError
-
-    @abstractmethod
-    @action
-    def receive_configuration(self, **kwargs):
-        """
-        Receive the configuration from the primary controller on the network. Requires a primary controller active.
-        """
-        raise NotImplementedError
-
-    @abstractmethod
-    @action
-    def transfer_primary_role(self, **kwargs):
-        """
-        Add a new controller to the network and make it the primary.
-        The existing primary will become a secondary controller.
-        """
-        raise NotImplementedError
-
-    @abstractmethod
-    @action
-    def heal(self, refresh_routes: bool = False, **kwargs):
-        """
-        Heal network by requesting nodes rediscover their neighbors.
-
-        :param refresh_routes: Whether to perform return routes initialization (default: ``False``).
-        """
-        raise NotImplementedError
-
-    @abstractmethod
-    @action
-    def switch_all(self, state: bool, **kwargs):
-        """
-        Switch all the connected devices on/off.
-
-        :param state: True (switch on) or False (switch off).
-        """
-        raise NotImplementedError
-
-    @abstractmethod
-    @action
-    def test(self, count: int = 1, **kwargs):
-        """
-        Send a number of test messages to every node and record results.
-
-        :param count: The number of test messages to send.
-        """
-        raise NotImplementedError
-
-    @abstractmethod
-    @action
-    def get_value(
-        self,
-        value_id: Optional[int] = None,
-        id_on_network: Optional[str] = None,
-        value_label: Optional[str] = None,
-        node_id: Optional[int] = None,
-        node_name: Optional[str] = None,
-        **kwargs
-    ) -> Dict[str, Any]:
-        """
-        Get a value on the network.
-
-        :param value_id: Select by value_id.
-        :param id_on_network: Select value by id_on_network.
-        :param value_label: Select value by [node_id/node_name, value_label]
-        :param node_id: Select value by [node_id/node_name, value_label]
-        :param node_name: Select value by [node_id/node_name, value_label]
-        """
-        raise NotImplementedError
-
-    @abstractmethod
-    @action
-    def set_value(
-        self,
-        data,
-        value_id: Optional[int] = None,
-        id_on_network: Optional[str] = None,
-        value_label: Optional[str] = None,
-        node_id: Optional[int] = None,
-        node_name: Optional[str] = None,
-        **kwargs
-    ):
-        """
-        Set a value.
-
-        :param data: Data to set for the value.
-        :param value_id: Select value by value_id.
-        :param id_on_network: Select value by id_on_network.
-        :param value_label: Select value by [node_id/node_name, value_label]
-        :param node_id: Select value by [node_id/node_name, value_label]
-        :param node_name: Select value by [node_id/node_name, value_label]
-        """
-        raise NotImplementedError
-
-    @action
-    def set(self, entity: str, value: Any, **kwargs):
-        return self.set_value(
-            value_id=entity, id_on_network=entity, data=value, **kwargs
-        )
-
-    @abstractmethod
-    @action
-    def set_value_label(
-        self,
-        new_label: str,
-        value_id: Optional[int] = None,
-        id_on_network: Optional[str] = None,
-        value_label: Optional[str] = None,
-        node_id: Optional[int] = None,
-        node_name: Optional[str] = None,
-        **kwargs
-    ):
-        """
-        Change the label/name of a value.
-
-        :param new_label: New value label.
-        :param value_id: Select value by value_id.
-        :param id_on_network: Select value by id_on_network.
-        :param value_label: Select value by [node_id/node_name, value_label]
-        :param node_id: Select value by [node_id/node_name, value_label]
-        :param node_name: Select value by [node_id/node_name, value_label]
-        """
-        raise NotImplementedError
-
-    @abstractmethod
-    @action
-    def node_add_value(
-        self,
-        value_id: Optional[int] = None,
-        id_on_network: Optional[str] = None,
-        value_label: Optional[str] = None,
-        node_id: Optional[int] = None,
-        node_name: Optional[str] = None,
-        **kwargs
-    ):
-        """
-        Add a value to a node.
-
-        :param value_id: Select value by value_id.
-        :param id_on_network: Select value by id_on_network.
-        :param value_label: Select value by label.
-        :param node_id: Select node by node_id.
-        :param node_name: Select node by label.
-        """
-        raise NotImplementedError
-
-    @abstractmethod
-    @action
-    def node_remove_value(
-        self,
-        value_id: Optional[int] = None,
-        id_on_network: Optional[str] = None,
-        value_label: Optional[str] = None,
-        node_id: Optional[int] = None,
-        node_name: Optional[str] = None,
-        **kwargs
-    ):
-        """
-        Remove a value from a node.
-
-        :param value_id: Select value by value_id.
-        :param id_on_network: Select value by id_on_network.
-        :param value_label: Select value by [node_id/node_name, value_label]
-        :param node_id: Select node by node_id.
-        :param node_name: Select node by label.
-        """
-        raise NotImplementedError
-
-    @abstractmethod
-    @action
-    def node_heal(
-        self,
-        node_id: Optional[int] = None,
-        node_name: Optional[str] = None,
-        refresh_routes: bool = False,
-        **kwargs
-    ):
-        """
-        Heal network node by requesting the node to rediscover their neighbours.
-
-        :param node_id: Select node by node_id.
-        :param node_name: Select node by label.
-        :param refresh_routes: Whether to perform return routes initialization. (default: ``False``).
-        """
-        raise NotImplementedError
-
-    @abstractmethod
-    @action
-    def node_update_neighbours(
-        self, node_id: Optional[int] = None, node_name: Optional[str] = None, **kwargs
-    ):
-        """
-        Ask a node to update its neighbours table.
-
-        :param node_id: Select node by node_id.
-        :param node_name: Select node by label.
-        """
-        raise NotImplementedError
-
-    @abstractmethod
-    @action
-    def node_network_update(
-        self, node_id: Optional[int] = None, node_name: Optional[str] = None, **kwargs
-    ):
-        """
-        Update the controller with network information.
-
-        :param node_id: Select node by node_id.
-        :param node_name: Select node by label.
-        """
-        raise NotImplementedError
-
-    @abstractmethod
-    @action
-    def node_refresh_info(
-        self, node_id: Optional[int] = None, node_name: Optional[str] = None, **kwargs
-    ):
-        """
-        Fetch up-to-date information about the node.
-
-        :param node_id: Select node by node_id.
-        :param node_name: Select node by label.
-        """
-        raise NotImplementedError
-
-    @abstractmethod
-    @action
-    def get_dimmers(
-        self, node_id: Optional[int] = None, node_name: Optional[str] = None, **kwargs
-    ) -> Dict[int, Any]:
-        """
-        Get the dimmers on the network or associated to a node.
-
-        :param node_id: Select node by node_id.
-        :param node_name: Select node by label.
-        """
-        raise NotImplementedError
-
-    @abstractmethod
-    @action
-    def get_node_config(
-        self, node_id: Optional[int] = None, node_name: Optional[str] = None, **kwargs
-    ) -> Dict[int, Any]:
-        """
-        Get the configuration values of a node or of all the nodes on the network.
-
-        :param node_id: Select node by node_id.
-        :param node_name: Select node by label.
-        """
-        raise NotImplementedError
-
-    @abstractmethod
-    @action
-    def get_battery_levels(
-        self, node_id: Optional[int] = None, node_name: Optional[str] = None, **kwargs
-    ) -> Dict[int, Any]:
-        """
-        Get the battery levels of a node or of all the nodes on the network.
-
-        :param node_id: Select node by node_id.
-        :param node_name: Select node by name.
-        """
-        raise NotImplementedError
-
-    @abstractmethod
-    @action
-    def get_power_levels(
-        self, node_id: Optional[int] = None, node_name: Optional[str] = None, **kwargs
-    ) -> Dict[int, Any]:
-        """
-        Get the power levels of this node.
-
-        :param node_id: Select node by node_id.
-        :param node_name: Select node by name.
-        """
-        raise NotImplementedError
-
-    @abstractmethod
-    @action
-    def get_bulbs(
-        self, node_id: Optional[int] = None, node_name: Optional[str] = None, **kwargs
-    ) -> Dict[int, Any]:
-        """
-        Get the bulbs/LEDs on the network or associated to a node.
-
-        :param node_id: Select node by node_id.
-        :param node_name: Select node by name.
-        """
-        raise NotImplementedError
-
-    @abstractmethod
-    @action
-    def get_switches(
-        self, node_id: Optional[int] = None, node_name: Optional[str] = None, **kwargs
-    ) -> Dict[int, Any]:
-        """
-        Get the switches on the network or associated to a node.
-
-        :param node_id: Select node by node_id.
-        :param node_name: Select node by name.
-        """
-        raise NotImplementedError
-
-    @abstractmethod
-    @action
-    def get_sensors(
-        self, node_id: Optional[int] = None, node_name: Optional[str] = None, **kwargs
-    ) -> Dict[int, Any]:
-        """
-        Get the sensors on the network or associated to a node.
-
-        :param node_id: Select node by node_id.
-        :param node_name: Select node by name.
-        """
-        raise NotImplementedError
-
-    @abstractmethod
-    @action
-    def get_doorlocks(
-        self, node_id: Optional[int] = None, node_name: Optional[str] = None, **kwargs
-    ) -> Dict[int, Any]:
-        """
-        Get the doorlocks on the network or associated to a node.
-
-        :param node_id: Select node by node_id.
-        :param node_name: Select node by name.
-        """
-        raise NotImplementedError
-
-    @abstractmethod
-    @action
-    def get_usercodes(
-        self, node_id: Optional[int] = None, node_name: Optional[str] = None, **kwargs
-    ) -> Dict[int, Any]:
-        """
-        Get the usercodes on the network or associated to a node.
-
-        :param node_id: Select node by node_id.
-        :param node_name: Select node by name.
-        """
-        raise NotImplementedError
-
-    @abstractmethod
-    @action
-    def get_thermostats(
-        self, node_id: Optional[int] = None, node_name: Optional[str] = None, **kwargs
-    ) -> Dict[int, Any]:
-        """
-        Get the thermostats on the network or associated to a node.
-
-        :param node_id: Select node by node_id.
-        :param node_name: Select node by name.
-        """
-        raise NotImplementedError
-
-    @abstractmethod
-    @action
-    def get_protections(
-        self, node_id: Optional[int] = None, node_name: Optional[str] = None, **kwargs
-    ) -> Dict[int, Any]:
-        """
-        Get the protection-compatible devices on the network or associated to a node.
-
-        :param node_id: Select node by node_id.
-        :param node_name: Select node by name.
-        """
-        raise NotImplementedError
-
-    @abstractmethod
-    @action
-    def get_groups(self, **kwargs) -> Dict[int, Any]:
-        """
-        Get the groups on the network.
-        """
-        raise NotImplementedError
-
-    @abstractmethod
-    @action
-    def get_scenes(self, **kwargs) -> Dict[str, Any]:
-        """
-        Get the scenes configured on the network.
-        """
-        raise NotImplementedError
-
-    @abstractmethod
-    @action
-    def create_scene(self, label: str, **kwargs):
-        """
-        Create a new scene.
-
-        :param label: Scene label.
-        """
-        raise NotImplementedError
-
-    @abstractmethod
-    @action
-    def remove_scene(
-        self,
-        scene_id: Optional[int] = None,
-        scene_label: Optional[str] = None,
-        **kwargs
-    ):
-        """
-        Remove a scene.
-
-        :param scene_id: Select by scene_id.
-        :param scene_label: Select by scene label.
-        """
-        raise NotImplementedError
-
-    @abstractmethod
-    @action
-    def activate_scene(
-        self,
-        scene_id: Optional[int] = None,
-        scene_label: Optional[str] = None,
-        **kwargs
-    ):
-        """
-        Activate a scene.
-
-        :param scene_id: Select by scene_id.
-        :param scene_label: Select by scene label.
-        """
-        raise NotImplementedError
-
-    @abstractmethod
-    @action
-    def set_scene_label(
-        self,
-        new_label: str,
-        scene_id: Optional[int] = None,
-        scene_label: Optional[str] = None,
-        **kwargs
-    ):
-        """
-        Rename a scene/set the scene label.
-
-        :param new_label: New label.
-        :param scene_id: Select by scene_id.
-        :param scene_label: Select by current scene label.
-        """
-        raise NotImplementedError
-
-    @abstractmethod
-    @action
-    def scene_add_value(
-        self,
-        data: Optional[Any] = None,
-        value_id: Optional[int] = None,
-        id_on_network: Optional[str] = None,
-        value_label: Optional[str] = None,
-        scene_id: Optional[int] = None,
-        scene_label: Optional[str] = None,
-        node_id: Optional[int] = None,
-        node_name: Optional[str] = None,
-        **kwargs
-    ):
-        """
-        Add a value to a scene.
-
-        :param data: Data to set for the value (default: current value data).
-        :param value_id: Select value by value_id.
-        :param id_on_network: Select value by id_on_network.
-        :param value_label: Select value by [node_id/node_name, value_label]
-        :param node_id: Select value by [node_id/node_name, value_label]
-        :param node_name: Select value by [node_id/node_name, value_label]
-        :param scene_id: Select scene by scene_id.
-        :param scene_label: Select scene by scene label.
-        """
-        raise NotImplementedError
-
-    @abstractmethod
-    @action
-    def scene_remove_value(
-        self,
-        value_id: Optional[int] = None,
-        id_on_network: Optional[str] = None,
-        value_label: Optional[str] = None,
-        scene_id: Optional[int] = None,
-        scene_label: Optional[str] = None,
-        node_id: Optional[int] = None,
-        node_name: Optional[str] = None,
-        **kwargs
-    ):
-        """
-        Remove a value from a scene.
-
-        :param value_id: Select value by value_id.
-        :param id_on_network: Select value by id_on_network.
-        :param value_label: Select value by [node_id/node_name, value_label]
-        :param node_id: Select value by [node_id/node_name, value_label]
-        :param node_name: Select value by [node_id/node_name, value_label]
-        :param scene_id: Select scene by scene_id.
-        :param scene_label: Select scene by scene label.
-        """
-        raise NotImplementedError
-
-    @abstractmethod
-    @action
-    def get_scene_values(
-        self,
-        scene_id: Optional[int] = None,
-        scene_label: Optional[str] = None,
-        **kwargs
-    ) -> dict:
-        """
-        Get the values associated to a scene.
-
-        :param scene_id: Select by scene_id.
-        :param scene_label: Select by scene label.
-        """
-        raise NotImplementedError
-
-    @abstractmethod
-    @action
-    def create_button(
-        self,
-        button_id: Union[int, str],
-        node_id: Optional[int] = None,
-        node_name: Optional[str] = None,
-        **kwargs
-    ):
-        """
-        Create a handheld button on a device. Only intended for bridge firmware controllers.
-
-        :param button_id: The ID of the button.
-        :param node_id: Filter by node_id.
-        :param node_name: Filter by current node name.
-        """
-        raise NotImplementedError
-
-    @abstractmethod
-    @action
-    def delete_button(
-        self,
-        button_id: Union[int, str],
-        node_id: Optional[int] = None,
-        node_name: Optional[str] = None,
-        **kwargs
-    ):
-        """
-        Delete a button association from a device. Only intended for bridge firmware controllers.
-
-        :param button_id: The ID of the button.
-        :param node_id: Filter by node_id.
-        :param node_name: Filter by current node name.
-        """
-        raise NotImplementedError
-
-    @abstractmethod
-    @action
-    def add_node_to_group(
-        self,
-        group_index: Optional[int] = None,
-        group_label: Optional[str] = None,
-        node_id: Optional[int] = None,
-        node_name: Optional[str] = None,
-        **kwargs
-    ):
-        """
-        Add a node to a group.
-
-        :param group_index: Select group by group index.
-        :param group_label: Select group by group label.
-        :param node_id: Select node by node_id.
-        :param node_name: Select node by node name.
-        """
-        raise NotImplementedError
-
-    @abstractmethod
-    @action
-    def remove_node_from_group(
-        self,
-        group_index: Optional[int] = None,
-        group_label: Optional[str] = None,
-        node_id: Optional[int] = None,
-        node_name: Optional[str] = None,
-        **kwargs
-    ):
-        """
-        Remove a node from a group.
-
-        :param group_index: Select group by group index.
-        :param group_label: Select group by group label.
-        :param node_id: Select node by node_id.
-        :param node_name: Select node by node name.
-        """
-        raise NotImplementedError
-
-    @abstractmethod
-    @action
-    def create_new_primary(self, **kwargs):
-        """
-        Create a new primary controller on the network when the previous primary fails.
-        """
-        raise NotImplementedError
-
-    @abstractmethod
-    @action
-    def hard_reset(self, **kwargs):
-        """
-        Perform a hard reset of the controller. It erases its network configuration settings.
-        The controller becomes a primary controller ready to add devices to a new network.
-        """
-        raise NotImplementedError
-
-    @abstractmethod
-    @action
-    def soft_reset(self, **kwargs):
-        """
-        Perform a soft reset of the controller.
-        Resets a controller without erasing its network configuration settings.
-        """
-        raise NotImplementedError
-
-    @abstractmethod
-    @action
-    def write_config(self, **kwargs):
-        """
-        Store the current configuration of the network to the user directory.
-        """
-        raise NotImplementedError
-
-    @abstractmethod
-    @action
-    def on(self, device: str, *args, **kwargs):  # pylint: disable=arguments-differ
-        """
-        Turn on a switch on a device.
-
-        :param device: ``id_on_network`` of the value to be switched on.
-        """
-        raise NotImplementedError
-
-    @abstractmethod
-    @action
-    def off(self, device: str, *args, **kwargs):  # pylint: disable=arguments-differ
-        """
-        Turn off a switch on a device.
-
-        :param device: ``id_on_network`` of the value to be switched off.
-        """
-        raise NotImplementedError
-
-    @abstractmethod
-    @action
-    def toggle(self, device: str, *args, **kwargs):  # pylint: disable=arguments-differ
-        """
-        Toggle a switch on a device.
-
-        :param device: ``id_on_network`` of the value to be toggled.
-        """
-        raise NotImplementedError
-
-
-# vim:sw=4:ts=4:et:
diff --git a/platypush/plugins/zwave/manifest.yaml b/platypush/plugins/zwave/manifest.yaml
deleted file mode 100644
index 101c95c40..000000000
--- a/platypush/plugins/zwave/manifest.yaml
+++ /dev/null
@@ -1,7 +0,0 @@
-manifest:
-  events: {}
-  install:
-    pip:
-    - python-openzwave
-  package: platypush.plugins.zwave
-  type: plugin
diff --git a/platypush/plugins/zwave/mqtt/__init__.py b/platypush/plugins/zwave/mqtt/__init__.py
index 58999b73c..77ad17445 100644
--- a/platypush/plugins/zwave/mqtt/__init__.py
+++ b/platypush/plugins/zwave/mqtt/__init__.py
@@ -1,3 +1,4 @@
+import contextlib
 import json
 import queue
 import re
@@ -20,7 +21,16 @@ from typing import (
 )
 from urllib.parse import parse_qs, urlparse
 
-from platypush.entities import Entity
+from typing_extensions import override
+
+from platypush.entities import (
+    DimmerEntityManager,
+    EnumSwitchEntityManager,
+    Entity,
+    LightEntityManager,
+    SensorEntityManager,
+    SwitchEntityManager,
+)
 from platypush.entities.batteries import Battery
 from platypush.entities.devices import Device
 from platypush.entities.dimmers import Dimmer
@@ -35,24 +45,43 @@ from platypush.entities.lights import Light
 from platypush.entities.sensors import BinarySensor, EnumSensor, NumericSensor
 from platypush.entities.switches import EnumSwitch, Switch
 from platypush.entities.temperature import TemperatureSensor
-from platypush.message.event.zwave import ZwaveNodeRenamedEvent, ZwaveNodeEvent
+from platypush.message.event.zwave import (
+    ZwaveEvent,
+    ZwaveNodeAddedEvent,
+    ZwaveNodeAsleepEvent,
+    ZwaveNodeAwakeEvent,
+    ZwaveNodeEvent,
+    ZwaveNodeReadyEvent,
+    ZwaveNodeRemovedEvent,
+    ZwaveNodeRenamedEvent,
+    ZwaveValueChangedEvent,
+    ZwaveValueRemovedEvent,
+)
 
 from platypush.context import get_bus
 from platypush.message.response import Response
 from platypush.plugins import RunnablePlugin
 from platypush.plugins.mqtt import MqttPlugin, action
-from platypush.plugins.zwave._base import ZwaveBasePlugin
 from platypush.plugins.zwave._constants import command_class_by_name
 
-_NOT_IMPLEMENTED_ERR = NotImplementedError('Not implemented by zwave.mqtt')
+from ._state import IdType, State
 
 
-class ZwaveMqttPlugin(MqttPlugin, RunnablePlugin, ZwaveBasePlugin):
+# pylint: disable=too-many-ancestors
+class ZwaveMqttPlugin(
+    MqttPlugin,
+    RunnablePlugin,
+    DimmerEntityManager,
+    EnumSwitchEntityManager,
+    LightEntityManager,
+    SensorEntityManager,
+    SwitchEntityManager,
+):
     """
     This plugin allows you to manage a Z-Wave network over MQTT through
     `zwave-js-ui <https://github.com/zwave-js/zwave-js-ui>`_.
 
-    For historical reasons, it is advised to enabled this plugin together
+    For historical reasons, it is advised to enable this plugin together
     with the ``zwave.mqtt`` backend, or you may lose the ability to listen
     to asynchronous events.
 
@@ -80,15 +109,14 @@ class ZwaveMqttPlugin(MqttPlugin, RunnablePlugin, ZwaveBasePlugin):
 
     Triggers:
 
-        * :class:`platypush.message.event.zwave.ZwaveNodeEvent` when a node attribute changes.
-        * :class:`platypush.message.event.zwave.ZwaveNodeAddedEvent` when a node is added to the network.
-        * :class:`platypush.message.event.zwave.ZwaveNodeRemovedEvent` when a node is removed from the network.
-        * :class:`platypush.message.event.zwave.ZwaveNodeRenamedEvent` when a node is renamed.
-        * :class:`platypush.message.event.zwave.ZwaveNodeReadyEvent` when a node is ready.
-        * :class:`platypush.message.event.zwave.ZwaveValueChangedEvent` when the value of a node on the network
-          changes.
-        * :class:`platypush.message.event.zwave.ZwaveNodeAsleepEvent` when a node goes into sleep mode.
-        * :class:`platypush.message.event.zwave.ZwaveNodeAwakeEvent` when a node goes back into awake mode.
+        * :class:`platypush.message.event.zwave.ZwaveNodeEvent`
+        * :class:`platypush.message.event.zwave.ZwaveNodeAddedEvent`
+        * :class:`platypush.message.event.zwave.ZwaveNodeRemovedEvent`
+        * :class:`platypush.message.event.zwave.ZwaveNodeRenamedEvent`
+        * :class:`platypush.message.event.zwave.ZwaveNodeReadyEvent`
+        * :class:`platypush.message.event.zwave.ZwaveValueChangedEvent`
+        * :class:`platypush.message.event.zwave.ZwaveNodeAsleepEvent`
+        * :class:`platypush.message.event.zwave.ZwaveNodeAwakeEvent`
 
     """
 
@@ -121,7 +149,7 @@ class ZwaveMqttPlugin(MqttPlugin, RunnablePlugin, ZwaveBasePlugin):
     def __init__(
         self,
         name: str,
-        host: str = 'localhost',
+        host: str,
         port: int = 1883,
         topic_prefix: str = 'zwave',
         timeout: int = 10,
@@ -134,75 +162,73 @@ class ZwaveMqttPlugin(MqttPlugin, RunnablePlugin, ZwaveBasePlugin):
         **kwargs,
     ):
         """
-        :param name: Gateway name, as configured from the zwavejs2mqtt web panel from Mqtt -> Name.
-        :param host: MQTT broker host, as configured from the zwavejs2mqtt web panel from Mqtt -> Host
-            (default: ``localhost``).
-        :param port: MQTT broker listen port, as configured from the zwavejs2mqtt web panel from Mqtt -> Port
-            (default: 1883).
-        :param topic_prefix: MQTT topic prefix, as specified from the zwavejs2mqtt web panel from Mqtt -> Prefix
-            (default: ``zwave``).
-        :param timeout: If the command expects from a response, then this timeout value will be used
-            (default: 60 seconds).
-        :param tls_cafile: If the connection requires TLS/SSL, specify the certificate authority file
-            (default: None)
-        :param tls_certfile: If the connection requires TLS/SSL, specify the certificate file (default: None)
-        :param tls_keyfile: If the connection requires TLS/SSL, specify the key file (default: None)
-        :param tls_version: If the connection requires TLS/SSL, specify the minimum TLS supported version
-            (default: None)
-        :param tls_ciphers: If the connection requires TLS/SSL, specify the supported ciphers (default: None)
-        :param username: If the connection requires user authentication, specify the username (default: None)
-        :param password: If the connection requires user authentication, specify the password (default: None)
+        :param name: Gateway name, as configured from the zwavejs2mqtt web
+            panel from Mqtt -> Name.
+        :param host: MQTT broker host, as configured from the zwavejs2mqtt web
+            panel from Mqtt -> Host
+        :param port: MQTT broker listen port, as configured from the
+            zwavejs2mqtt web panel from Mqtt -> Port (default: 1883).
+        :param topic_prefix: MQTT topic prefix, as specified from the
+            zwavejs2mqtt web panel from Mqtt -> Prefix (default: ``zwave``).
+        :param timeout: If the command expects from a response, then this
+            timeout value will be used (default: 10 seconds).
+        :param tls_cafile: If the connection requires TLS/SSL, specify the
+            certificate authority file (default: None)
+        :param tls_certfile: If the connection requires TLS/SSL, specify the
+            certificate file (default: None)
+        :param tls_keyfile: If the connection requires TLS/SSL, specify the key
+            file (default: None)
+        :param tls_version: If the connection requires TLS/SSL, specify the
+            minimum TLS supported version (default: None)
+        :param tls_ciphers: If the connection requires TLS/SSL, specify the
+            supported ciphers (default: None)
+        :param username: If the connection requires user authentication,
+            specify the username (default: None)
+        :param password: If the connection requires user authentication,
+            specify the password (default: None)
         """
 
+        self.topic_prefix = topic_prefix
+        self.base_topic = topic_prefix + '/{}/ZWAVE_GATEWAY-' + name
+        self.events_topic = self.base_topic.format('_EVENTS')
         super().__init__(
             host=host,
             port=port,
+            topics=[
+                self.events_topic + '/node/' + topic
+                for topic in [
+                    'node_ready',
+                    'node_sleep',
+                    'node_value_updated',
+                    'node_metadata_updated',
+                    'node_wakeup',
+                ]
+            ],
             tls_certfile=tls_certfile,
             tls_keyfile=tls_keyfile,
             tls_version=tls_version,
             tls_ciphers=tls_ciphers,
             username=username,
             password=password,
+            timeout=timeout,
             **kwargs,
         )
 
-        self.topic_prefix = topic_prefix
-        self.base_topic = topic_prefix + '/{}/ZWAVE_GATEWAY-' + name
-        self.events_topic = self.base_topic.format('_EVENTS')
-        self.timeout = timeout
-        self._info: Mapping[str, dict] = {
-            'devices': {},
-            'groups': {},
-        }
-
-        self._nodes_cache: Dict[str, dict] = {
-            'by_id': {},
-            'by_name': {},
-        }
-
-        self._values_cache: Dict[str, dict] = {
-            'by_id': {},
-            'by_label': {},
-        }
-
-        self._scenes_cache: Dict[str, dict] = {
-            'by_id': {},
-            'by_label': {},
-        }
-
-        self._groups_cache: Dict[str, dict] = {}
+        self._state = State()
 
     def _api_topic(self, api: str) -> str:
         return self.base_topic.format('_CLIENTS') + f'/api/{api}'
 
     @staticmethod
     def _parse_response(response: Union[dict, Response]) -> dict:
+        if isinstance(response, Response):
+            assert not response.is_error(), response.errors[0]
+
         rs: dict = (
             response.output if isinstance(response, Response) else response
-        )  # type: ignore[reportGeneralTypeIssues]
+        )  # type: ignore
 
         assert rs.get('success') is True, rs.get('message', 'zwavejs2mqtt error')
-
         return rs
 
     def _api_request(self, api: str, *args: Any, **kwargs):
@@ -234,25 +260,16 @@ class ZwaveMqttPlugin(MqttPlugin, RunnablePlugin, ZwaveBasePlugin):
         scene_label: Optional[str] = None,
         **kwargs,
     ) -> dict:
-        assert scene_id or scene_label, 'No scene_id/scene_label specified'
-        if scene_id in self._scenes_cache['by_id']:
-            return self._scenes_cache['by_id'][scene_id]
-        if scene_label in self._scenes_cache['by_label']:
-            return self._scenes_cache['by_label'][scene_label]
+        assert not (
+            scene_id is None and scene_label is None
+        ), 'No scene_id/scene_label specified'
 
-        # noinspection PyUnresolvedReferences
-        scenes = self.get_scenes(**kwargs).output  # type: ignore[reportGeneralTypeIssues]
-        scene = None
-
-        if scene_id in scenes:
-            scene = scenes[scene_id]
-            self._scenes_cache['by_id'][scene_id] = scene
-        else:
-            scenes = [s for s in scenes if s['label'] == scene_label]
-            if scenes:
-                scene = scenes[0]
-                self._scenes_cache['by_label'][scene_label] = scene
+        scene = self._state.scenes.get((scene_id, scene_label))
+        if scene:
+            return scene
 
+        self._refresh_scenes(**kwargs)
+        scene = self._state.scenes.get((scene_id, scene_label))
         assert scene, f'No such scene: scene_id={scene_id}, scene_label={scene_label}'
         return scene
 
@@ -367,6 +384,7 @@ class ZwaveMqttPlugin(MqttPlugin, RunnablePlugin, ZwaveBasePlugin):
         device_id = node.get('hexId')
         if not device_id:
             if db_link:
+                # noinspection PyTypeChecker
                 device_id = ':'.join(
                     parse_qs(urlparse(db_link).query)
                     .get('jumpTo', '')[0]
@@ -437,44 +455,96 @@ class ZwaveMqttPlugin(MqttPlugin, RunnablePlugin, ZwaveBasePlugin):
             },
         }
 
+    def _refresh_groups(self, **kwargs):
+        nodes = self._get_nodes(**kwargs)
+        groups = {
+            group['group_id']: {
+                **group,
+                'associations': [
+                    assoc['nodeId']
+                    for assoc in (
+                        self._api_request('getAssociations', node_id, group['index'])
+                        or []
+                    )
+                ],
+            }
+            for node_id, node in nodes.items()
+            for group in node.get('groups', {}).values()
+        }
+
+        self._state.groups.add(*groups.values(), overwrite=True)
+
+    def _get_groups(self, **kwargs) -> Dict[IdType, dict]:
+        self._refresh_groups(**kwargs)
+        return self._state.groups.by_id
+
+    def _refresh_scenes(self, **kwargs):
+        scenes = {
+            scene.get('sceneid'): self.scene_to_dict(scene)
+            for scene in (self._api_request('_getScenes', **kwargs) or [])
+        }
+
+        self._state.scenes.add(*scenes.values(), overwrite=True)
+
+    def _get_scenes(self, **kwargs) -> Dict[IdType, dict]:
+        self._refresh_scenes(**kwargs)
+        return self._state.scenes.by_id
+
+    def _refresh_nodes(self, **kwargs):
+        nodes = {
+            node['id']: {'id': node['id'], **self.node_to_dict(node)}
+            for node in (self._api_request('getNodes', **kwargs) or [])
+        }
+
+        values = [
+            value
+            for node in nodes.values()
+            for value in node.get('values', {}).values()
+        ]
+
+        # Process events for the newly received nodes
+        for node_id, node in nodes.items():
+            if node_id not in self._state.nodes:
+                self._dispatch_event(ZwaveNodeAddedEvent, node=node, fetch_node=False)
+            elif node['name'] != self._state.nodes.get('name'):
+                self._dispatch_event(ZwaveNodeRenamedEvent, node=node, fetch_node=False)
+
+        # Check if any previous node is now missing
+        for node_id, node in self._state.nodes.by_id.items():
+            if node_id not in nodes:
+                self._dispatch_event(ZwaveNodeRemovedEvent, node=node, fetch_node=False)
+
+        self._state.nodes.add(*nodes.values(), overwrite=True)
+        self._state.values.add(*values, overwrite=True)
+        self.publish_entities([self._to_current_value(v) for v in values])
+
+    def _get_nodes(self, **kwargs) -> Dict[IdType, dict]:
+        self._refresh_nodes(**kwargs)
+        return self._state.nodes.by_id
+
     def _get_node(
         self,
         node_id: Optional[int] = None,
         node_name: Optional[str] = None,
         use_cache: bool = True,
+        make_request: bool = True,
         **kwargs,
-    ) -> Optional[dict]:
+    ) -> dict:
         assert node_id or node_name, 'Please provide either a node_id or node_name'
         if use_cache:
-            if node_id and node_id in self._nodes_cache['by_id']:
-                return self._nodes_cache['by_id'][node_id]
-            if node_name and node_name in self._nodes_cache['by_name']:
-                return self._nodes_cache['by_name'][node_name]
+            node = self._state.nodes.get((node_id, node_name))
+            if node:
+                return node
 
-        response = {
-            node['id']: self.node_to_dict(node)
-            for node in (self._api_request('getNodes', **kwargs) or [])
-        }
-
-        node = None
-        if node_id:
-            node = response.get(node_id)
-        else:
-            ret = [node for node in response.values() if node['name'] == node_name]
-            if ret:
-                node = ret[0]
-
-        if node:
-            self._nodes_cache['by_id'][node['node_id']] = node
-            if node['name']:
-                self._nodes_cache['by_name'][node['name']] = node
-
-            for value in node.get('values', {}).values():
-                self._values_cache['by_id'][value['id']] = value
-                if value['label']:
-                    self._values_cache['by_label'][value['label']] = value
-
-        return node
+        assert make_request, f'No such node: {node_id or node_name}'
+        self._refresh_nodes(**kwargs)
+        return self._get_node(
+            node_id=node_id,
+            node_name=node_name,
+            use_cache=True,
+            make_request=False,
+            **kwargs,
+        )
 
     def _get_value(
         self,
@@ -484,7 +554,8 @@ class ZwaveMqttPlugin(MqttPlugin, RunnablePlugin, ZwaveBasePlugin):
         node_id: Optional[int] = None,
         node_name: Optional[str] = None,
         use_cache: bool = True,
-        **_,
+        make_request: bool = True,
+        **kwargs,
     ) -> Dict[str, Any]:
         # Unlike python-openzwave, value_id and id_on_network are the same on zwavejs2mqtt
         value_id = value_id or id_on_network
@@ -493,44 +564,29 @@ class ZwaveMqttPlugin(MqttPlugin, RunnablePlugin, ZwaveBasePlugin):
         ), 'Please provide either value_id, id_on_network or value_label'
 
         if use_cache:
-            if value_id and value_id in self._values_cache['by_id']:
-                return self._values_cache['by_id'][value_id]
-            if value_label and value_label in self._values_cache['by_label']:
-                return self._values_cache['by_label'][value_label]
+            value = self._state.values.get((value_id, value_label))
+            if value:
+                if node_id or node_name:
+                    node = self._state.nodes.get((node_id, node_name))
+                    assert node, f'No such node: {node_id or node_name}'
+                    assert (
+                        value['node_id'] == node['id']
+                    ), f'No such value on node {node_id or node_name}: {value_id or value_label}'
 
-        nodes = []
-        if node_id or node_name:
-            nodes = [
-                self._get_node(node_id=node_id, node_name=node_name, use_cache=False)
-            ]
+                return value
 
-        if not nodes:
-            nodes = self.get_nodes().output  # type: ignore[reportGeneralTypeIssues]
-            assert nodes, 'No nodes found on the network'
-            nodes = nodes.values()  # type: ignore
-
-        if value_id:
-            values = [
-                node['values'][value_id]
-                for node in nodes
-                if node and value_id in node.get('values', {})
-            ]
-        else:
-            values = [
-                value
-                for node in nodes
-                for value in (node or {}).get('values', {}).values()
-                if node and value.get('label') == value_label
-            ]
-
-        assert values, f'No such value: {value_id or value_label}'
-        value = values[0]
-        self._values_cache['by_id'][value['id']] = value
-        if value['label']:
-            self._values_cache['by_label'][value['label']] = value
-
-        self.publish_entities([self._to_current_value(value)])
-        return value
+        assert make_request, f'No such value: {value_id or value_label}'
+        self._refresh_nodes(**kwargs)
+        return self._get_value(
+            value_id=value_id,
+            id_on_network=id_on_network,
+            value_label=value_label,
+            node_id=node_id,
+            node_name=node_name,
+            use_cache=True,
+            make_request=False,
+            **kwargs,
+        )
 
     @staticmethod
     def _is_target_value(value: Mapping):
@@ -563,14 +619,7 @@ class ZwaveMqttPlugin(MqttPlugin, RunnablePlugin, ZwaveBasePlugin):
             + [value['value_id'].split('-')[-1].replace(*replace_args)]
         )
 
-        associated_value = self._values_cache['by_id'].get(
-            associated_value_id,
-            self._nodes_cache['by_id']
-            .get(value['node_id'], {})
-            .get('values', {})
-            .get(associated_value_id),
-        )
-
+        associated_value = self._state.values.get(associated_value_id)
         if associated_value:
             value = associated_value
 
@@ -763,7 +812,7 @@ class ZwaveMqttPlugin(MqttPlugin, RunnablePlugin, ZwaveBasePlugin):
             if parent_id is None:
                 continue
 
-            node = self._nodes_cache['by_id'].get(parent_id)
+            node = self._state.nodes.get(parent_id)
             if not node:
                 continue
 
@@ -808,7 +857,6 @@ class ZwaveMqttPlugin(MqttPlugin, RunnablePlugin, ZwaveBasePlugin):
             if not value_id:
                 continue
 
-            associated_value_id = None
             associated_value = None
             associated_property_id = None
             current_property_id = None
@@ -856,7 +904,7 @@ class ZwaveMqttPlugin(MqttPlugin, RunnablePlugin, ZwaveBasePlugin):
 
     def _filter_values(
         self,
-        command_classes: Optional[Iterable[str]] = None,  # type: ignore[reportGeneralTypeIssues]
+        command_classes: Optional[Iterable[str]] = None,
         filter_callback: Optional[Callable[[dict], bool]] = None,
         node_id: Optional[int] = None,
         node_name: Optional[str] = None,
@@ -865,7 +913,7 @@ class ZwaveMqttPlugin(MqttPlugin, RunnablePlugin, ZwaveBasePlugin):
         nodes = (
             [self._get_node(node_name=node_name, use_cache=False, **kwargs)]
             if node_id or node_name
-            else self.get_nodes(**kwargs).output.values()  # type: ignore[reportGeneralTypeIssues]
+            else self._get_nodes(**kwargs).values()
         )
 
         classes: set = {
@@ -898,32 +946,127 @@ class ZwaveMqttPlugin(MqttPlugin, RunnablePlugin, ZwaveBasePlugin):
         group_index: Optional[int] = None,
         **kwargs,
     ) -> dict:
+        assert not (
+            group_id is None and group_index is None
+        ), 'No group_id/group_index specified'
         group_id = group_id or str(group_index)
-        assert group_id is not None, 'No group_id/group_index specified'
-        group = self._groups_cache.get(group_id)
+        group = self._state.groups.get(group_id)
         if group:
             return group
 
-        groups = self.get_groups(**kwargs).output  # type: ignore[reportGeneralTypeIssues]
+        groups = self._get_groups(**kwargs)
         assert group_id in groups, f'No such group_id: {group_id}'
         return groups[group_id]
 
-    @action
-    def start_network(self, **_):
-        """
-        Start the network (not implemented by zwavejs2mqtt).
-        """
-        raise _NOT_IMPLEMENTED_ERR
+    @override
+    def on_mqtt_message(self):
+        def handler(_, __, msg):
+            if not msg.topic.startswith(self.events_topic):
+                return
+
+            topic = (
+                msg.topic[(len(self.events_topic) + 1) :].split('/').pop()  # noqa: E203
+            )
+
+            data = msg.payload.decode()
+            if not data:
+                return
+
+            with contextlib.suppress(ValueError, TypeError):
+                data = json.loads(data)['data']
+
+            try:
+                if topic == 'node_value_updated':
+                    self._dispatch_event(
+                        ZwaveValueChangedEvent, node=data[0], value=data[1]
+                    )
+                elif topic == 'node_metadata_updated':
+                    self._dispatch_event(ZwaveNodeEvent, node=data[0])
+                elif topic == 'node_sleep':
+                    self._dispatch_event(ZwaveNodeAsleepEvent, node=data[0])
+                elif topic == 'node_wakeup':
+                    self._dispatch_event(ZwaveNodeAwakeEvent, node=data[0])
+                elif topic == 'node_ready':
+                    self._dispatch_event(ZwaveNodeReadyEvent, node=data[0])
+                elif topic == 'node_removed':
+                    self._dispatch_event(ZwaveNodeRemovedEvent, node=data[0])
+            except Exception as e:
+                self.logger.exception(e)
+
+        return handler
+
+    def _dispatch_event(
+        self,
+        event_type: Type[ZwaveEvent],
+        node: dict,
+        value: Optional[dict] = None,
+        fetch_node: bool = True,
+        **kwargs,
+    ):
+        node_id = node.get('id')
+        assert node_id is not None, 'No node ID specified'
+
+        if fetch_node:
+            node = kwargs['node'] = self._get_node(node_id)
+        else:
+            kwargs['node'] = node
+
+        node_values = node.get('values', {})
+
+        if node and value:
+            # Infer the value_id structure if it's not provided on the event
+            value_id = value.get('id')
+            if value_id is None:
+                value_id = f"{value['commandClass']}-{value.get('endpoint', 0)}-{value['property']}"
+                if 'propertyKey' in value:
+                    value_id += '-' + str(value['propertyKey'])
+
+                # Prepend the node_id to value_id if it's not available in node['values']
+                # (compatibility with more recent versions of ZwaveJS that don't provide
+                # the value_id on the events)
+                if value_id not in node_values:
+                    value_id = f"{node_id}-{value_id}"
+
+            if value_id not in node_values:
+                self.logger.warning(
+                    'value_id %s not found on node %s', value_id, node_id
+                )
+                return
+
+            if 'newValue' in value:
+                node_values[value_id]['data'] = value['newValue']
+
+            value = kwargs['value'] = node_values[value_id]
+
+        if issubclass(event_type, ZwaveNodeEvent):
+            # If this node_id wasn't cached before, then it's a new node
+            if node_id not in self._state.nodes:
+                event_type = ZwaveNodeAddedEvent
+            # If the name has changed, we have a rename event
+            elif node['name'] != (self._state.nodes.get(node_id) or {}).get('name'):
+                event_type = ZwaveNodeRenamedEvent
+
+        if event_type == ZwaveNodeRemovedEvent:
+            # If the node has been removed, remove it from the cache
+            self._state.nodes.pop(node_id, None)
+        else:
+            # Otherwise, update the cached instance
+            self._state.nodes.add(node)
+            values = node.get('values', {}).values()
+            self._state.values.add(*values)
+
+        evt = event_type(**kwargs)
+        get_bus().post(evt)
+
+        if (
+            value
+            and issubclass(event_type, ZwaveValueChangedEvent)
+            and event_type != ZwaveValueRemovedEvent
+        ):
+            self.publish_entities([value])
 
     @action
-    def stop_network(self, **_):
-        """
-        Stop the network (not implemented by zwavejs2mqtt).
-        """
-        raise _NOT_IMPLEMENTED_ERR
-
-    @action
-    def status(self, **kwargs) -> Dict[str, Any]:
+    def status(self, *_, **kwargs) -> Dict[str, Any]:
         """
         Get the current status of the Z-Wave values.
 
@@ -950,12 +1093,7 @@ class ZwaveMqttPlugin(MqttPlugin, RunnablePlugin, ZwaveBasePlugin):
             msg_queue.put(json.loads(msg.payload))
 
         client.on_message = on_message
-        client.connect(
-            kwargs.get('host', self.host),
-            kwargs.get('port', self.port),
-            keepalive=kwargs.get('timeout', self.timeout),
-        )
-
+        client.connect()
         client.subscribe(topic)
         client.loop_start()
 
@@ -1032,7 +1170,7 @@ class ZwaveMqttPlugin(MqttPlugin, RunnablePlugin, ZwaveBasePlugin):
             (default: query the default configured device).
         """
         if node_name:
-            node_id = (self._get_node(node_name=node_name) or {}).get('node_id')
+            node_id = self._get_node(node_name=node_name).get('node_id')
 
         assert node_id is not None, f'No such node_id: {node_id}'
         self._api_request('removeFailedNode', node_id, **kwargs)
@@ -1050,18 +1188,11 @@ class ZwaveMqttPlugin(MqttPlugin, RunnablePlugin, ZwaveBasePlugin):
             (default: query the default configured device).
         """
         if node_name:
-            node_id = (self._get_node(node_name=node_name) or {}).get('node_id')
+            node_id = self._get_node(node_name=node_name).get('node_id')
 
         assert node_id is not None, f'No such node_id: {node_id}'
         self._api_request('replaceFailedNode', node_id, **kwargs)
 
-    @action
-    def replication_send(self, **_):  # pylint: disable=arguments-differ
-        """
-        Send node information from the primary to the secondary controller (not implemented by zwavejs2mqtt).
-        """
-        raise _NOT_IMPLEMENTED_ERR
-
     @action
     def request_network_update(
         self, node_id: Optional[int] = None, node_name: Optional[str] = None, **kwargs
@@ -1075,7 +1206,7 @@ class ZwaveMqttPlugin(MqttPlugin, RunnablePlugin, ZwaveBasePlugin):
             (default: query the default configured device).
         """
         if node_name:
-            node_id = (self._get_node(node_name=node_name) or {}).get('node_id')
+            node_id = self._get_node(node_name=node_name).get('node_id')
 
         assert node_id is not None, f'No such node_id: {node_id}'
         self._api_request('refreshInfo', node_id, **kwargs)
@@ -1093,7 +1224,7 @@ class ZwaveMqttPlugin(MqttPlugin, RunnablePlugin, ZwaveBasePlugin):
     @action
     def get_nodes(
         self, node_id: Optional[int] = None, node_name: Optional[str] = None, **kwargs
-    ) -> Optional[Dict[str, Any]]:
+    ) -> Optional[Dict[IdType, dict]]:
         """
         Get the nodes associated to the network.
 
@@ -1167,7 +1298,7 @@ class ZwaveMqttPlugin(MqttPlugin, RunnablePlugin, ZwaveBasePlugin):
                       "zwave_plus"
                     ],
                     "manufacturer_id": "0x010f",
-                    "manufacturer_name": "Fibargroup",
+                    "manufacturer_name": "FibaroGroup",
                     "location": "Living Room",
                     "status": "Alive",
                     "is_available": true,
@@ -1261,22 +1392,7 @@ class ZwaveMqttPlugin(MqttPlugin, RunnablePlugin, ZwaveBasePlugin):
                 node_id=node_id, node_name=node_name, use_cache=False, **kwargs
             )
 
-        nodes = {
-            node['id']: self.node_to_dict(node)
-            for node in (self._api_request('getNodes', **kwargs) or [])
-        }
-
-        self._nodes_cache['by_id'] = nodes
-        self._nodes_cache['by_name'] = {node['name']: node for node in nodes.values()}
-
-        return nodes
-
-    @action
-    def get_node_stats(self, *_, **__):
-        """
-        Get the statistics of a node on the network (not implemented by zwavejs2mqtt).
-        """
-        raise _NOT_IMPLEMENTED_ERR
+        return self._get_nodes(**kwargs)
 
     @action
     def set_node_name(
@@ -1296,35 +1412,19 @@ class ZwaveMqttPlugin(MqttPlugin, RunnablePlugin, ZwaveBasePlugin):
             (default: query the default configured device).
         """
         if node_name:
-            node_id = (self._get_node(node_name=node_name, **kwargs) or {}).get(
-                'node_id'
-            )
+            node_id = self._get_node(node_name=node_name, **kwargs).get('node_id')
 
         assert node_id, f'No such node: {node_id}'
         self._api_request('setNodeName', node_id, new_name, **kwargs)
         get_bus().post(
             ZwaveNodeRenamedEvent(
                 node={
-                    **(self._get_node(node_id=node_id) or {}),
+                    **self._get_node(node_id=node_id, **kwargs),
                     'name': new_name,
                 }
             )
         )
 
-    @action
-    def set_node_product_name(self, **_):  # pylint: disable=arguments-differ
-        """
-        Set the product name of a node (not implemented by zwavejs2mqtt).
-        """
-        raise _NOT_IMPLEMENTED_ERR
-
-    @action
-    def set_node_manufacturer_name(self, **_):  # pylint: disable=arguments-differ
-        """
-        Set the manufacturer name of a node (not implemented by zwavejs2mqtt).
-        """
-        raise _NOT_IMPLEMENTED_ERR
-
     @action
     def set_node_location(
         self,
@@ -1343,68 +1443,19 @@ class ZwaveMqttPlugin(MqttPlugin, RunnablePlugin, ZwaveBasePlugin):
             (default: query the default configured device).
         """
         if node_name:
-            node = self._get_node(node_name=node_name, **kwargs)
-            assert node, f'No such node: {node_name}'
-            node_id = node.get('node_id')
+            node_id = self._get_node(node_name=node_name, **kwargs).get('node_id')
 
         assert node_id, f'No such node: {node_id}'
         self._api_request('setNodeLocation', node_id, location, **kwargs)
         get_bus().post(
             ZwaveNodeEvent(
                 node={
-                    **(self._get_node(node_id=node_id) or {}),
+                    **self._get_node(node_id=node_id, **kwargs),
                     'location': location,
                 }
             )
         )
 
-    @action
-    def cancel_command(self, **_):
-        """
-        Cancel the current running command (not implemented by zwavejs2mqtt).
-        """
-        raise _NOT_IMPLEMENTED_ERR
-
-    @action
-    def kill_command(self, **_):
-        """
-        Immediately terminate any running command on the controller and release the lock (not implemented by
-        zwavejs2mqtt).
-        """
-        raise _NOT_IMPLEMENTED_ERR
-
-    @action
-    def set_controller_name(self, **_):  # pylint: disable=arguments-differ
-        """
-        Set the name of the controller on the network (not implemented: use
-        :meth:`platypush.plugin.zwave.mqtt.ZwaveMqttPlugin.set_node_name` instead).
-        """
-        raise _NOT_IMPLEMENTED_ERR
-
-    @action
-    def get_capabilities(self, **_) -> List[str]:
-        """
-        Get the capabilities of the controller (not implemented: use
-        :meth:`platypush.plugin.zwave.mqtt.ZwaveMqttPlugin.get_nodes` instead).
-        """
-        raise _NOT_IMPLEMENTED_ERR
-
-    @action
-    def receive_configuration(self, **_):
-        """
-        Receive the configuration from the primary controller on the network. Requires a primary controller active
-        (not implemented by zwavejs2mqtt).
-        """
-        raise _NOT_IMPLEMENTED_ERR
-
-    @action
-    def transfer_primary_role(self, **_):
-        """
-        Add a new controller to the network and make it the primary.
-        The existing primary will become a secondary controller (not implemented by zwavejs2mqtt).
-        """
-        raise _NOT_IMPLEMENTED_ERR
-
     @action
     def heal(
         self, *_, timeout: Optional[int] = 60, **kwargs
@@ -1422,20 +1473,6 @@ class ZwaveMqttPlugin(MqttPlugin, RunnablePlugin, ZwaveBasePlugin):
                 timeout, lambda: self._api_request('stopHealingNetwork', **kwargs)
             ).start()
 
-    @action
-    def switch_all(self, **_):  # pylint: disable=arguments-differ
-        """
-        Switch all the connected devices on/off (not implemented).
-        """
-        raise _NOT_IMPLEMENTED_ERR
-
-    @action
-    def test(self, *_, **__):
-        """
-        Send a number of test messages to every node and record results (not implemented by zwavejs2mqtt).
-        """
-        raise _NOT_IMPLEMENTED_ERR
-
     @action
     def get_value(
         self,
@@ -1534,27 +1571,6 @@ class ZwaveMqttPlugin(MqttPlugin, RunnablePlugin, ZwaveBasePlugin):
         for light in lights:
             self.set_value(light, kwargs)
 
-    @action
-    def set_value_label(self, **_):  # pylint: disable=arguments-differ
-        """
-        Change the label/name of a value (not implemented by zwavejs2mqtt).
-        """
-        raise _NOT_IMPLEMENTED_ERR
-
-    @action
-    def node_add_value(self, **_):  # pylint: disable=arguments-differ
-        """
-        Add a value to a node (not implemented by zwavejs2mqtt).
-        """
-        raise _NOT_IMPLEMENTED_ERR
-
-    @action
-    def node_remove_value(self, **_):  # pylint: disable=arguments-differ
-        """
-        Remove a value from a node (not implemented by zwavejs2mqtt).
-        """
-        raise _NOT_IMPLEMENTED_ERR
-
     @action
     def node_heal(  # pylint: disable=arguments-differ
         self, node_id: Optional[int] = None, node_name: Optional[str] = None, **kwargs
@@ -1568,9 +1584,7 @@ class ZwaveMqttPlugin(MqttPlugin, RunnablePlugin, ZwaveBasePlugin):
             (default: query the default configured device).
         """
         if node_name:
-            node = self._get_node(node_name=node_name, **kwargs)
-            assert node, f'No such node: {node_name}'
-            node_id = node.get('node_id')
+            node_id = self._get_node(node_name=node_name, **kwargs).get('node_id')
         self._api_request('healNode', node_id, **kwargs)
 
     @action
@@ -1616,9 +1630,7 @@ class ZwaveMqttPlugin(MqttPlugin, RunnablePlugin, ZwaveBasePlugin):
             (default: query the default configured device).
         """
         if node_name:
-            node = self._get_node(node_name=node_name, **kwargs)
-            assert node, f'No such node: {node_name}'
-            node_id = node.get('node_id')
+            node_id = self._get_node(node_name=node_name, **kwargs).get('node_id')
         self._api_request('refreshInfo', node_id, **kwargs)
 
     @action
@@ -1848,7 +1860,7 @@ class ZwaveMqttPlugin(MqttPlugin, RunnablePlugin, ZwaveBasePlugin):
         )
 
     @action
-    def get_groups(self, **kwargs) -> Dict[str, dict]:
+    def get_groups(self, **kwargs) -> Dict[IdType, dict]:
         """
         Get the groups on the network.
 
@@ -1888,26 +1900,10 @@ class ZwaveMqttPlugin(MqttPlugin, RunnablePlugin, ZwaveBasePlugin):
             ]
 
         """
-        nodes = self.get_nodes(**kwargs).output  # type: ignore[reportGeneralTypeIssues]
-        self._groups_cache = {
-            group['group_id']: {
-                **group,
-                'associations': [
-                    assoc['nodeId']
-                    for assoc in (
-                        self._api_request('getAssociations', node_id, group['index'])
-                        or []
-                    )
-                ],
-            }
-            for node_id, node in nodes.items()
-            for group in node.get('groups', {}).values()
-        }
-
-        return self._groups_cache
+        return self._get_groups(**kwargs)
 
     @action
-    def get_scenes(self, **_) -> Dict[int, Dict[str, Any]]:
+    def get_scenes(self, **kwargs) -> Dict[IdType, dict]:
         """
         Get the scenes configured on the network.
 
@@ -1937,13 +1933,10 @@ class ZwaveMqttPlugin(MqttPlugin, RunnablePlugin, ZwaveBasePlugin):
             }
 
         """
-        return {
-            scene.get('sceneid'): self.scene_to_dict(scene)
-            for scene in (self._api_request('_getScenes') or [])
-        }
+        return self._get_scenes(**kwargs)
 
     @action
-    def create_scene(self, label: str, **_):
+    def create_scene(self, label: str, **kwargs):
         """
         Create a new scene.
 
@@ -1951,7 +1944,7 @@ class ZwaveMqttPlugin(MqttPlugin, RunnablePlugin, ZwaveBasePlugin):
         :param kwargs: Extra arguments to be passed to :meth:`platypush.plugins.mqtt.MqttPlugin.publish``
             (default: query the default configured device).
         """
-        self._api_request('_createScene', label)
+        self._api_request('_createScene', label, **kwargs)
 
     @action
     def remove_scene(
@@ -1989,19 +1982,6 @@ class ZwaveMqttPlugin(MqttPlugin, RunnablePlugin, ZwaveBasePlugin):
         scene = self._get_scene(scene_id=scene_id, scene_label=scene_label, **kwargs)
         self._api_request('_activateScene', scene['scene_id'])
 
-    @action
-    def set_scene_label(self, *_, **__):
-        """
-        Rename a scene/set the scene label.
-
-        :param new_label: New label.
-        :param scene_id: Select by scene_id.
-        :param scene_label: Select by current scene label.
-        :param kwargs: Extra arguments to be passed to :meth:`platypush.plugins.mqtt.MqttPlugin.publish``
-            (default: query the default configured device).
-        """
-        raise _NOT_IMPLEMENTED_ERR
-
     @action
     def scene_add_value(
         self,
@@ -2106,34 +2086,6 @@ class ZwaveMqttPlugin(MqttPlugin, RunnablePlugin, ZwaveBasePlugin):
         scene = self._get_scene(scene_id=scene_id, scene_label=scene_label, **kwargs)
         return scene.get('values', {})
 
-    @action
-    def create_button(self, *_, **__):
-        """
-        Create a handheld button on a device. Only intended for bridge firmware controllers
-        (not implemented by zwavejs2mqtt).
-
-        :param button_id: The ID of the button.
-        :param node_id: Filter by node_id.
-        :param node_name: Filter by current node name.
-        :param kwargs: Extra arguments to be passed to :meth:`platypush.plugins.mqtt.MqttPlugin.publish``
-            (default: query the default configured device).
-        """
-        raise _NOT_IMPLEMENTED_ERR
-
-    @action
-    def delete_button(self, *_, **__):
-        """
-        Delete a button association from a device. Only intended for bridge firmware controllers.
-        (not implemented by zwavejs2mqtt).
-
-        :param button_id: The ID of the button.
-        :param node_id: Filter by node_id.
-        :param node_name: Filter by current node name.
-        :param kwargs: Extra arguments to be passed to :meth:`platypush.plugins.mqtt.MqttPlugin.publish``
-            (default: query the default configured device).
-        """
-        raise _NOT_IMPLEMENTED_ERR
-
     @action
     def add_node_to_group(  # pylint: disable=arguments-differ
         self,
@@ -2184,51 +2136,16 @@ class ZwaveMqttPlugin(MqttPlugin, RunnablePlugin, ZwaveBasePlugin):
             'removeAssociations', group['node_id'], group['index'], [assoc]
         )
 
-    @action
-    def create_new_primary(self, **_):
-        """
-        Create a new primary controller on the network when the previous primary fails
-        (not implemented by zwavejs2mqtt).
-
-        :param kwargs: Extra arguments to be passed to :meth:`platypush.plugins.mqtt.MqttPlugin.publish``
-            (default: query the default configured device).
-        """
-        raise _NOT_IMPLEMENTED_ERR
-
     @action
     def hard_reset(self, **_):
         """
         Perform a hard reset of the controller. It erases its network configuration settings.
         The controller becomes a primary controller ready to add devices to a new network.
-
-        :param kwargs: Extra arguments to be passed to :meth:`platypush.plugins.mqtt.MqttPlugin.publish``
-            (default: query the default configured device).
         """
         self._api_request('hardReset')
 
     @action
-    def soft_reset(self, **_):
-        """
-        Perform a soft reset of the controller.
-        Resets a controller without erasing its network configuration settings (not implemented by zwavejs2).
-
-        :param kwargs: Extra arguments to be passed to :meth:`platypush.plugins.mqtt.MqttPlugin.publish``
-            (default: query the default configured device).
-        """
-        raise _NOT_IMPLEMENTED_ERR
-
-    @action
-    def write_config(self, **_):
-        """
-        Store the current configuration of the network to the user directory (not implemented by zwavejs2mqtt).
-
-        :param kwargs: Extra arguments to be passed to :meth:`platypush.plugins.mqtt.MqttPlugin.publish``
-            (default: query the default configured device).
-        """
-        raise _NOT_IMPLEMENTED_ERR
-
-    @action
-    def on(self, device: str, *_, **kwargs):
+    def on(self, device: str, *_, **kwargs):  # pylint: disable=arguments-differ
         """
         Turn on a switch on a device.
 
@@ -2239,7 +2156,7 @@ class ZwaveMqttPlugin(MqttPlugin, RunnablePlugin, ZwaveBasePlugin):
         self.set_value(data=True, id_on_network=device, **kwargs)
 
     @action
-    def off(self, device: str, *_, **kwargs):
+    def off(self, device: str, *_, **kwargs):  # pylint: disable=arguments-differ
         """
         Turn off a switch on a device.
 
@@ -2250,7 +2167,9 @@ class ZwaveMqttPlugin(MqttPlugin, RunnablePlugin, ZwaveBasePlugin):
         self.set_value(data=False, id_on_network=device, **kwargs)
 
     @action
-    def toggle(self, device: str, *_, **kwargs) -> dict:
+    def toggle(  # pylint: disable=arguments-differ
+        self, device: str, *_, **kwargs
+    ) -> dict:
         """
         Toggle a switch on a device.
 
@@ -2261,26 +2180,19 @@ class ZwaveMqttPlugin(MqttPlugin, RunnablePlugin, ZwaveBasePlugin):
         value = self._get_value(id_on_network=device, use_cache=False, **kwargs)
         value['data'] = not value['data']
         self.set_value(data=value['data'], id_on_network=device, **kwargs)
+        node = self._state.nodes.get(value['node_id'])
+        assert node, f'Node {value["node_id"]} not found'
 
         return {
-            'name': (
-                self._nodes_cache['by_id'][value['node_id']]['name']
-                + ' - '
-                + value.get('label', '[No Label]')
-            ),
+            'name': (node['name'] + ' - ' + value.get('label', '[No Label]')),
             'on': value['data'],
             'id': value['value_id'],
         }
 
+    @override
     def main(self):
-        from ._listener import ZwaveMqttListener
-
-        listener = ZwaveMqttListener()
-        listener.start()
-        self.wait_stop()
-
-        listener.stop()
-        listener.join()
+        self.get_nodes()
+        super().main()
 
 
 # vim:sw=4:ts=4:et:
diff --git a/platypush/plugins/zwave/mqtt/_listener.py b/platypush/plugins/zwave/mqtt/_listener.py
deleted file mode 100644
index d85f76cba..000000000
--- a/platypush/plugins/zwave/mqtt/_listener.py
+++ /dev/null
@@ -1,197 +0,0 @@
-import contextlib
-import json
-from queue import Queue, Empty
-from typing import Optional, Type
-
-from platypush.backend.mqtt import MqttBackend
-from platypush.context import get_bus, get_plugin
-
-from platypush.config import Config
-from platypush.message.event.zwave import (
-    ZwaveEvent,
-    ZwaveNodeAddedEvent,
-    ZwaveValueChangedEvent,
-    ZwaveNodeRemovedEvent,
-    ZwaveNodeRenamedEvent,
-    ZwaveNodeReadyEvent,
-    ZwaveNodeEvent,
-    ZwaveNodeAsleepEvent,
-    ZwaveNodeAwakeEvent,
-    ZwaveValueRemovedEvent,
-)
-
-
-class ZwaveMqttListener(MqttBackend):
-    """
-    Internal MQTT listener for ``zwave.mqtt`` events.
-    """
-
-    def __init__(self, *args, **kwargs):
-        self._nodes = {}
-        self._groups = {}
-        self._last_state = None
-        self._events_queue = Queue()
-        self.events_topic = self.plugin.events_topic
-        self.server_info = {
-            'host': self.plugin.host,
-            'port': self.plugin.port or self._default_mqtt_port,
-            'tls_cafile': self.plugin.tls_cafile,
-            'tls_certfile': self.plugin.tls_certfile,
-            'tls_ciphers': self.plugin.tls_ciphers,
-            'tls_keyfile': self.plugin.tls_keyfile,
-            'tls_version': self.plugin.tls_version,
-            'username': self.plugin.username,
-            'password': self.plugin.password,
-        }
-
-        listeners = [
-            {
-                **self.server_info,
-                'topics': [
-                    self.plugin.events_topic + '/node/' + topic
-                    for topic in [
-                        'node_ready',
-                        'node_sleep',
-                        'node_value_updated',
-                        'node_metadata_updated',
-                        'node_wakeup',
-                    ]
-                ],
-            }
-        ]
-
-        super().__init__(
-            *args,
-            subscribe_default_topic=False,
-            listeners=listeners,
-            **kwargs,
-        )
-
-        self.client_id = (
-            str(self.client_id or Config.get('device_id')) + '-zwavejs-mqtt'
-        )
-
-    @property
-    def plugin(self):
-        from platypush.plugins.zwave.mqtt import ZwaveMqttPlugin
-
-        plugin: Optional[ZwaveMqttPlugin] = get_plugin('zwave.mqtt')
-        assert plugin, 'The zwave.mqtt plugin is not configured'
-        return plugin
-
-    def _dispatch_event(
-        self,
-        event_type: Type[ZwaveEvent],
-        node: dict,
-        value: Optional[dict] = None,
-        **kwargs,
-    ):
-        node_id = node.get('id')
-        assert node_id is not None, 'No node ID specified'
-
-        # This is far from efficient (we are querying the latest version of the whole
-        # node for every event we receive), but this is the best we can do with recent
-        # versions of ZWaveJS that only transmit partial representations of the node and
-        # the value. The alternative would be to come up with a complex logic for merging
-        # cached and new values, with the risk of breaking back-compatibility with earlier
-        # implementations of zwavejs2mqtt.
-        node = kwargs['node'] = self.plugin.get_nodes(node_id).output  # type: ignore
-        node_values = node.get('values', {})
-
-        if node and value:
-            # Infer the value_id structure if it's not provided on the event
-            value_id = value.get('id')
-            if value_id is None:
-                value_id = f"{value['commandClass']}-{value.get('endpoint', 0)}-{value['property']}"
-                if 'propertyKey' in value:
-                    value_id += '-' + str(value['propertyKey'])
-
-                # Prepend the node_id to value_id if it's not available in node['values']
-                # (compatibility with more recent versions of ZwaveJS that don't provide
-                # the value_id on the events)
-                if value_id not in node_values:
-                    value_id = f"{node_id}-{value_id}"
-
-            if value_id not in node_values:
-                self.logger.warning(
-                    'value_id %s not found on node %s', value_id, node_id
-                )
-                return
-
-            value = kwargs['value'] = node_values[value_id]
-
-        if issubclass(event_type, ZwaveNodeEvent):
-            # If the node has been removed, remove it from the cache
-            if event_type == ZwaveNodeRemovedEvent:
-                self._nodes.pop(node_id, None)
-            # If this node_id wasn't cached before, then it's a new node
-            elif node_id not in self._nodes:
-                event_type = ZwaveNodeAddedEvent
-            # If the name has changed, we have a rename event
-            elif node['name'] != self._nodes[node_id]['name']:
-                event_type = ZwaveNodeRenamedEvent
-            # If nothing relevant has changed, update the cached instance and return
-            else:
-                self._nodes[node_id] = node
-                return
-
-        evt = event_type(**kwargs)
-        self._events_queue.put(evt)
-
-        if (
-            value
-            and issubclass(event_type, ZwaveValueChangedEvent)
-            and event_type != ZwaveValueRemovedEvent
-        ):
-            self.plugin.publish_entities([kwargs['value']])  # type: ignore
-
-    def on_mqtt_message(self):
-        def handler(_, __, msg):
-            if not msg.topic.startswith(self.events_topic):
-                return
-
-            topic = (
-                msg.topic[(len(self.events_topic) + 1) :].split('/').pop()  # noqa: E203
-            )
-            data = msg.payload.decode()
-            if not data:
-                return
-
-            with contextlib.suppress(ValueError, TypeError):
-                data = json.loads(data)['data']
-
-            try:
-                if topic == 'node_value_updated':
-                    self._dispatch_event(
-                        ZwaveValueChangedEvent, node=data[0], value=data[1]
-                    )
-                elif topic == 'node_metadata_updated':
-                    self._dispatch_event(ZwaveNodeEvent, node=data[0])
-                elif topic == 'node_sleep':
-                    self._dispatch_event(ZwaveNodeAsleepEvent, node=data[0])
-                elif topic == 'node_wakeup':
-                    self._dispatch_event(ZwaveNodeAwakeEvent, node=data[0])
-                elif topic == 'node_ready':
-                    self._dispatch_event(ZwaveNodeReadyEvent, node=data[0])
-                elif topic == 'node_removed':
-                    self._dispatch_event(ZwaveNodeRemovedEvent, node=data[0])
-            except Exception as e:
-                self.logger.exception(e)
-
-        return handler
-
-    def run(self):
-        super().run()
-        self.logger.debug('Refreshing Z-Wave nodes')
-        self._nodes = self.plugin.get_nodes().output  # type: ignore
-
-        while not self.should_stop():
-            try:
-                evt = self._events_queue.get(block=True, timeout=1)
-            except Empty:
-                continue
-
-            get_bus().post(evt)
-
-
-# vim:sw=4:ts=4:et:
diff --git a/platypush/plugins/zwave/mqtt/_state.py b/platypush/plugins/zwave/mqtt/_state.py
new file mode 100644
index 000000000..7cec26419
--- /dev/null
+++ b/platypush/plugins/zwave/mqtt/_state.py
@@ -0,0 +1,97 @@
+from dataclasses import dataclass, field
+from typing import Dict, Optional, Tuple, Union
+from threading import RLock
+
+IdType = Union[str, int]
+_CacheMap = Dict[IdType, dict]
+
+
+@dataclass
+class Cache:
+    """
+    Cache class for Z-Wave entities.
+    """
+
+    by_id: _CacheMap = field(default_factory=dict)
+    by_name: _CacheMap = field(default_factory=dict)
+    _lock: RLock = field(default_factory=RLock)
+
+    def add(self, *objs: dict, overwrite: bool = False) -> None:
+        """
+        Add objects to the cache.
+        """
+        with self._lock:
+            if overwrite:
+                self.clear()
+
+            for obj in objs:
+                obj_id = obj.get("id")
+                if obj_id:
+                    self.by_id[obj_id] = obj
+
+                name = self._get_name(obj)
+                if name:
+                    self.by_name[name] = obj
+
+    def get(
+        self,
+        obj: Optional[Union[IdType, Tuple[Optional[IdType], Optional[IdType]]]] = None,
+        default=None,
+    ) -> Optional[dict]:
+        """
+        Get an object from the cache, by ID or name/label.
+        """
+        if obj is None:
+            return None
+
+        if isinstance(obj, tuple):
+            return self.get(obj[0], self.get(obj[1], default))
+
+        return self.by_id.get(obj, self.by_name.get(obj, default))
+
+    def __contains__(self, obj: Optional[IdType]) -> bool:
+        """
+        Check if an object with the given ID/name is in the cache.
+        """
+        return obj in self.by_id or obj in self.by_name
+
+    def clear(self) -> None:
+        """
+        Clear the cache.
+        """
+        with self._lock:
+            self.by_id.clear()
+            self.by_name.clear()
+
+    def pop(self, obj_id: IdType, default=None) -> Optional[dict]:
+        """
+        Remove and return an object from the cache.
+        """
+        with self._lock:
+            obj = self.by_id.pop(obj_id, default)
+            if not obj:
+                return obj
+
+            name = self._get_name(obj)
+            if name:
+                self.by_name.pop(name, None)
+            return obj
+
+    @staticmethod
+    def _get_name(obj: dict) -> Optional[str]:
+        """
+        @return The name/label of an object.
+        """
+        return obj.get("name", obj.get("label"))
+
+
+@dataclass
+class State:
+    """
+    State of the Z-Wave network.
+    """
+
+    nodes: Cache = field(default_factory=Cache)
+    values: Cache = field(default_factory=Cache)
+    scenes: Cache = field(default_factory=Cache)
+    groups: Cache = field(default_factory=Cache)