diff --git a/docs/source/events.rst b/docs/source/events.rst
index 5ca337d9d..8fdb115e9 100644
--- a/docs/source/events.rst
+++ b/docs/source/events.rst
@@ -20,6 +20,7 @@ Events
     platypush/events/custom.rst
     platypush/events/dbus.rst
     platypush/events/distance.rst
+    platypush/events/entities.rst
     platypush/events/file.rst
     platypush/events/foursquare.rst
     platypush/events/geo.rst
diff --git a/docs/source/platypush/events/entities.rst b/docs/source/platypush/events/entities.rst
new file mode 100644
index 000000000..206b44d4c
--- /dev/null
+++ b/docs/source/platypush/events/entities.rst
@@ -0,0 +1,5 @@
+``entities``
+============
+
+.. automodule:: platypush.message.event.entities
+    :members:
diff --git a/docs/source/platypush/plugins/entities.rst b/docs/source/platypush/plugins/entities.rst
new file mode 100644
index 000000000..a17e2e298
--- /dev/null
+++ b/docs/source/platypush/plugins/entities.rst
@@ -0,0 +1,5 @@
+``entities``
+============
+
+.. automodule:: platypush.plugins.entities
+    :members:
diff --git a/docs/source/plugins.rst b/docs/source/plugins.rst
index b848e5930..8c2f0a260 100644
--- a/docs/source/plugins.rst
+++ b/docs/source/plugins.rst
@@ -32,6 +32,7 @@ Plugins
     platypush/plugins/db.rst
     platypush/plugins/dbus.rst
     platypush/plugins/dropbox.rst
+    platypush/plugins/entities.rst
     platypush/plugins/esp.rst
     platypush/plugins/ffmpeg.rst
     platypush/plugins/file.rst
diff --git a/platypush/__init__.py b/platypush/__init__.py
index 7de379c26..c50e7e94e 100644
--- a/platypush/__init__.py
+++ b/platypush/__init__.py
@@ -9,11 +9,13 @@ import argparse
 import logging
 import os
 import sys
+from typing import Optional
 
 from .bus.redis import RedisBus
 from .config import Config
 from .context import register_backends, register_plugins
 from .cron.scheduler import CronScheduler
+from .entities import init_entities_engine, EntitiesEngine
 from .event.processor import EventProcessor
 from .logger import Logger
 from .message.event import Event
@@ -96,6 +98,7 @@ class Daemon:
         self.no_capture_stdout = no_capture_stdout
         self.no_capture_stderr = no_capture_stderr
         self.event_processor = EventProcessor()
+        self.entities_engine: Optional[EntitiesEngine] = None
         self.requests_to_process = requests_to_process
         self.processed_requests = 0
         self.cron_scheduler = None
@@ -199,16 +202,25 @@ class Daemon:
         """Stops the backends and the bus"""
         from .plugins import RunnablePlugin
 
-        for backend in self.backends.values():
-            backend.stop()
+        if self.backends:
+            for backend in self.backends.values():
+                backend.stop()
 
         for plugin in get_enabled_plugins().values():
             if isinstance(plugin, RunnablePlugin):
                 plugin.stop()
 
-        self.bus.stop()
+        if self.bus:
+            self.bus.stop()
+            self.bus = None
+
         if self.cron_scheduler:
             self.cron_scheduler.stop()
+            self.cron_scheduler = None
+
+        if self.entities_engine:
+            self.entities_engine.stop()
+            self.entities_engine = None
 
     def run(self):
         """Start the daemon"""
@@ -230,6 +242,9 @@ class Daemon:
         # Initialize the plugins
         register_plugins(bus=self.bus)
 
+        # Initialize the entities engine
+        self.entities_engine = init_entities_engine()
+
         # Start the cron scheduler
         if Config.get_cronjobs():
             self.cron_scheduler = CronScheduler(jobs=Config.get_cronjobs())
diff --git a/platypush/backend/covid19/__init__.py b/platypush/backend/covid19/__init__.py
index 598f10871..1be1db016 100644
--- a/platypush/backend/covid19/__init__.py
+++ b/platypush/backend/covid19/__init__.py
@@ -3,8 +3,7 @@ import os
 from typing import Optional, Union, List, Dict, Any
 
 from sqlalchemy import create_engine, Column, Integer, String, DateTime
-from sqlalchemy.orm import sessionmaker, scoped_session
-from sqlalchemy.ext.declarative import declarative_base
+from sqlalchemy.orm import sessionmaker, scoped_session, declarative_base
 
 from platypush.backend import Backend
 from platypush.config import Config
@@ -17,10 +16,10 @@ Session = scoped_session(sessionmaker())
 
 
 class Covid19Update(Base):
-    """ Models the Covid19Data table """
+    """Models the Covid19Data table"""
 
     __tablename__ = 'covid19data'
-    __table_args__ = ({'sqlite_autoincrement': True})
+    __table_args__ = {'sqlite_autoincrement': True}
 
     country = Column(String, primary_key=True)
     confirmed = Column(Integer, nullable=False, default=0)
@@ -40,7 +39,12 @@ class Covid19Backend(Backend):
     """
 
     # noinspection PyProtectedMember
-    def __init__(self, country: Optional[Union[str, List[str]]], poll_seconds: Optional[float] = 3600.0, **kwargs):
+    def __init__(
+        self,
+        country: Optional[Union[str, List[str]]],
+        poll_seconds: Optional[float] = 3600.0,
+        **kwargs
+    ):
         """
         :param country: Default country (or list of countries) to retrieve the stats for. It can either be the full
             country name or the country code. Special values:
@@ -56,7 +60,9 @@ class Covid19Backend(Backend):
         super().__init__(poll_seconds=poll_seconds, **kwargs)
         self._plugin: Covid19Plugin = get_plugin('covid19')
         self.country: List[str] = self._plugin._get_countries(country)
-        self.workdir = os.path.join(os.path.expanduser(Config.get('workdir')), 'covid19')
+        self.workdir = os.path.join(
+            os.path.expanduser(Config.get('workdir')), 'covid19'
+        )
         self.dbfile = os.path.join(self.workdir, 'data.db')
         os.makedirs(self.workdir, exist_ok=True)
 
@@ -67,22 +73,30 @@ class Covid19Backend(Backend):
         self.logger.info('Stopped Covid19 backend')
 
     def _process_update(self, summary: Dict[str, Any], session: Session):
-        update_time = datetime.datetime.fromisoformat(summary['Date'].replace('Z', '+00:00'))
+        update_time = datetime.datetime.fromisoformat(
+            summary['Date'].replace('Z', '+00:00')
+        )
 
-        self.bus.post(Covid19UpdateEvent(
-            country=summary['Country'],
-            country_code=summary['CountryCode'],
-            confirmed=summary['TotalConfirmed'],
-            deaths=summary['TotalDeaths'],
-            recovered=summary['TotalRecovered'],
-            update_time=update_time,
-        ))
+        self.bus.post(
+            Covid19UpdateEvent(
+                country=summary['Country'],
+                country_code=summary['CountryCode'],
+                confirmed=summary['TotalConfirmed'],
+                deaths=summary['TotalDeaths'],
+                recovered=summary['TotalRecovered'],
+                update_time=update_time,
+            )
+        )
 
-        session.merge(Covid19Update(country=summary['CountryCode'],
-                                    confirmed=summary['TotalConfirmed'],
-                                    deaths=summary['TotalDeaths'],
-                                    recovered=summary['TotalRecovered'],
-                                    last_updated_at=update_time))
+        session.merge(
+            Covid19Update(
+                country=summary['CountryCode'],
+                confirmed=summary['TotalConfirmed'],
+                deaths=summary['TotalDeaths'],
+                recovered=summary['TotalRecovered'],
+                last_updated_at=update_time,
+            )
+        )
 
     def loop(self):
         # noinspection PyUnresolvedReferences
@@ -90,23 +104,30 @@ class Covid19Backend(Backend):
         if not summaries:
             return
 
-        engine = create_engine('sqlite:///{}'.format(self.dbfile), connect_args={'check_same_thread': False})
+        engine = create_engine(
+            'sqlite:///{}'.format(self.dbfile),
+            connect_args={'check_same_thread': False},
+        )
         Base.metadata.create_all(engine)
         Session.configure(bind=engine)
         session = Session()
 
         last_records = {
             record.country: record
-            for record in session.query(Covid19Update).filter(Covid19Update.country.in_(self.country)).all()
+            for record in session.query(Covid19Update)
+            .filter(Covid19Update.country.in_(self.country))
+            .all()
         }
 
         for summary in summaries:
             country = summary['CountryCode']
             last_record = last_records.get(country)
-            if not last_record or \
-                    summary['TotalConfirmed'] != last_record.confirmed or \
-                    summary['TotalDeaths'] != last_record.deaths or \
-                    summary['TotalRecovered'] != last_record.recovered:
+            if (
+                not last_record
+                or summary['TotalConfirmed'] != last_record.confirmed
+                or summary['TotalDeaths'] != last_record.deaths
+                or summary['TotalRecovered'] != last_record.recovered
+            ):
                 self._process_update(summary=summary, session=session)
 
         session.commit()
diff --git a/platypush/backend/github/__init__.py b/platypush/backend/github/__init__.py
index ad49b73d4..0a1bc3e67 100644
--- a/platypush/backend/github/__init__.py
+++ b/platypush/backend/github/__init__.py
@@ -6,15 +6,28 @@ from typing import Optional, List
 
 import requests
 from sqlalchemy import create_engine, Column, String, DateTime
-from sqlalchemy.ext.declarative import declarative_base
-from sqlalchemy.orm import sessionmaker, scoped_session
+from sqlalchemy.orm import sessionmaker, scoped_session, declarative_base
 
 from platypush.backend import Backend
 from platypush.config import Config
-from platypush.message.event.github import GithubPushEvent, GithubCommitCommentEvent, GithubCreateEvent, \
-    GithubDeleteEvent, GithubEvent, GithubForkEvent, GithubWikiEvent, GithubIssueCommentEvent, GithubIssueEvent, \
-    GithubMemberEvent, GithubPublicEvent, GithubPullRequestEvent, GithubPullRequestReviewCommentEvent, \
-    GithubReleaseEvent, GithubSponsorshipEvent, GithubWatchEvent
+from platypush.message.event.github import (
+    GithubPushEvent,
+    GithubCommitCommentEvent,
+    GithubCreateEvent,
+    GithubDeleteEvent,
+    GithubEvent,
+    GithubForkEvent,
+    GithubWikiEvent,
+    GithubIssueCommentEvent,
+    GithubIssueEvent,
+    GithubMemberEvent,
+    GithubPublicEvent,
+    GithubPullRequestEvent,
+    GithubPullRequestReviewCommentEvent,
+    GithubReleaseEvent,
+    GithubSponsorshipEvent,
+    GithubWatchEvent,
+)
 
 Base = declarative_base()
 Session = scoped_session(sessionmaker())
@@ -71,8 +84,17 @@ class GithubBackend(Backend):
 
     _base_url = 'https://api.github.com'
 
-    def __init__(self, user: str, user_token: str, repos: Optional[List[str]] = None, org: Optional[str] = None,
-                 poll_seconds: int = 60, max_events_per_scan: Optional[int] = 10, *args, **kwargs):
+    def __init__(
+        self,
+        user: str,
+        user_token: str,
+        repos: Optional[List[str]] = None,
+        org: Optional[str] = None,
+        poll_seconds: int = 60,
+        max_events_per_scan: Optional[int] = 10,
+        *args,
+        **kwargs
+    ):
         """
         If neither ``repos`` nor ``org`` is specified then the backend will monitor all new events on user level.
 
@@ -102,17 +124,23 @@ class GithubBackend(Backend):
 
     def _request(self, uri: str, method: str = 'get') -> dict:
         method = getattr(requests, method.lower())
-        return method(self._base_url + uri, auth=(self.user, self.user_token),
-                      headers={'Accept': 'application/vnd.github.v3+json'}).json()
+        return method(
+            self._base_url + uri,
+            auth=(self.user, self.user_token),
+            headers={'Accept': 'application/vnd.github.v3+json'},
+        ).json()
 
     def _init_db(self):
-        engine = create_engine('sqlite:///{}'.format(self.dbfile), connect_args={'check_same_thread': False})
+        engine = create_engine(
+            'sqlite:///{}'.format(self.dbfile),
+            connect_args={'check_same_thread': False},
+        )
         Base.metadata.create_all(engine)
         Session.configure(bind=engine)
 
     @staticmethod
     def _to_datetime(time_string: str) -> datetime.datetime:
-        """ Convert ISO 8061 string format with leading 'Z' into something understandable by Python """
+        """Convert ISO 8061 string format with leading 'Z' into something understandable by Python"""
         return datetime.datetime.fromisoformat(time_string[:-1] + '+00:00')
 
     @staticmethod
@@ -128,7 +156,11 @@ class GithubBackend(Backend):
     def _get_last_event_time(self, uri: str):
         with self.db_lock:
             record = self._get_or_create_resource(uri=uri, session=Session())
-            return record.last_updated_at.replace(tzinfo=datetime.timezone.utc) if record.last_updated_at else None
+            return (
+                record.last_updated_at.replace(tzinfo=datetime.timezone.utc)
+                if record.last_updated_at
+                else None
+            )
 
     def _update_last_event_time(self, uri: str, last_updated_at: datetime.datetime):
         with self.db_lock:
@@ -158,9 +190,18 @@ class GithubBackend(Backend):
             'WatchEvent': GithubWatchEvent,
         }
 
-        event_type = event_mapping[event['type']] if event['type'] in event_mapping else GithubEvent
-        return event_type(event_type=event['type'], actor=event['actor'], repo=event.get('repo', {}),
-                          payload=event['payload'], created_at=cls._to_datetime(event['created_at']))
+        event_type = (
+            event_mapping[event['type']]
+            if event['type'] in event_mapping
+            else GithubEvent
+        )
+        return event_type(
+            event_type=event['type'],
+            actor=event['actor'],
+            repo=event.get('repo', {}),
+            payload=event['payload'],
+            created_at=cls._to_datetime(event['created_at']),
+        )
 
     def _events_monitor(self, uri: str, method: str = 'get'):
         def thread():
@@ -175,7 +216,10 @@ class GithubBackend(Backend):
                     fired_events = []
 
                     for event in events:
-                        if self.max_events_per_scan and len(fired_events) >= self.max_events_per_scan:
+                        if (
+                            self.max_events_per_scan
+                            and len(fired_events) >= self.max_events_per_scan
+                        ):
                             break
 
                         event_time = self._to_datetime(event['created_at'])
@@ -189,14 +233,19 @@ class GithubBackend(Backend):
                     for event in fired_events:
                         self.bus.post(event)
 
-                    self._update_last_event_time(uri=uri, last_updated_at=new_last_event_time)
+                    self._update_last_event_time(
+                        uri=uri, last_updated_at=new_last_event_time
+                    )
                 except Exception as e:
-                    self.logger.warning('Encountered exception while fetching events from {}: {}'.format(
-                        uri, str(e)))
+                    self.logger.warning(
+                        'Encountered exception while fetching events from {}: {}'.format(
+                            uri, str(e)
+                        )
+                    )
                     self.logger.exception(e)
-                finally:
-                    if self.wait_stop(timeout=self.poll_seconds):
-                        break
+
+                if self.wait_stop(timeout=self.poll_seconds):
+                    break
 
         return thread
 
@@ -206,12 +255,30 @@ class GithubBackend(Backend):
 
         if self.repos:
             for repo in self.repos:
-                monitors.append(threading.Thread(target=self._events_monitor('/networks/{repo}/events'.format(repo=repo))))
+                monitors.append(
+                    threading.Thread(
+                        target=self._events_monitor(
+                            '/networks/{repo}/events'.format(repo=repo)
+                        )
+                    )
+                )
         if self.org:
-            monitors.append(threading.Thread(target=self._events_monitor('/orgs/{org}/events'.format(org=self.org))))
+            monitors.append(
+                threading.Thread(
+                    target=self._events_monitor(
+                        '/orgs/{org}/events'.format(org=self.org)
+                    )
+                )
+            )
 
         if not (self.repos or self.org):
-            monitors.append(threading.Thread(target=self._events_monitor('/users/{user}/events'.format(user=self.user))))
+            monitors.append(
+                threading.Thread(
+                    target=self._events_monitor(
+                        '/users/{user}/events'.format(user=self.user)
+                    )
+                )
+            )
 
         for monitor in monitors:
             monitor.start()
@@ -222,4 +289,5 @@ class GithubBackend(Backend):
 
         self.logger.info('Github backend terminated')
 
+
 # vim:sw=4:ts=4:et:
diff --git a/platypush/backend/http/request/rss/__init__.py b/platypush/backend/http/request/rss/__init__.py
index b16565dc5..7ca6d9c64 100644
--- a/platypush/backend/http/request/rss/__init__.py
+++ b/platypush/backend/http/request/rss/__init__.py
@@ -2,11 +2,17 @@ import datetime
 import enum
 import os
 
-from sqlalchemy import create_engine, Column, Integer, String, DateTime, \
-    Enum, ForeignKey
+from sqlalchemy import (
+    create_engine,
+    Column,
+    Integer,
+    String,
+    DateTime,
+    Enum,
+    ForeignKey,
+)
 
-from sqlalchemy.orm import sessionmaker, scoped_session
-from sqlalchemy.ext.declarative import declarative_base
+from sqlalchemy.orm import sessionmaker, scoped_session, declarative_base
 from sqlalchemy.sql.expression import func
 
 from platypush.backend.http.request import HttpRequest
@@ -44,18 +50,31 @@ class RssUpdates(HttpRequest):
 
     """
 
-    user_agent = 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) ' + \
-                 'Chrome/62.0.3202.94 Safari/537.36'
+    user_agent = (
+        'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) '
+        + 'Chrome/62.0.3202.94 Safari/537.36'
+    )
 
-    def __init__(self, url, title=None, headers=None, params=None, max_entries=None,
-                 extract_content=False, digest_format=None, user_agent: str = user_agent,
-                 body_style: str = 'font-size: 22px; ' +
-                                   'font-family: "Merriweather", Georgia, "Times New Roman", Times, serif;',
-                 title_style: str = 'margin-top: 30px',
-                 subtitle_style: str = 'margin-top: 10px; page-break-after: always',
-                 article_title_style: str = 'page-break-before: always',
-                 article_link_style: str = 'color: #555; text-decoration: none; border-bottom: 1px dotted',
-                 article_content_style: str = '', *argv, **kwargs):
+    def __init__(
+        self,
+        url,
+        title=None,
+        headers=None,
+        params=None,
+        max_entries=None,
+        extract_content=False,
+        digest_format=None,
+        user_agent: str = user_agent,
+        body_style: str = 'font-size: 22px; '
+        + 'font-family: "Merriweather", Georgia, "Times New Roman", Times, serif;',
+        title_style: str = 'margin-top: 30px',
+        subtitle_style: str = 'margin-top: 10px; page-break-after: always',
+        article_title_style: str = 'page-break-before: always',
+        article_link_style: str = 'color: #555; text-decoration: none; border-bottom: 1px dotted',
+        article_content_style: str = '',
+        *argv,
+        **kwargs,
+    ):
         """
         :param url: URL to the RSS feed to be monitored.
         :param title: Optional title for the feed.
@@ -91,7 +110,9 @@ class RssUpdates(HttpRequest):
         # If true, then the http.webpage plugin will be used to parse the content
         self.extract_content = extract_content
 
-        self.digest_format = digest_format.lower() if digest_format else None  # Supported formats: html, pdf
+        self.digest_format = (
+            digest_format.lower() if digest_format else None
+        )  # Supported formats: html, pdf
 
         os.makedirs(os.path.expanduser(os.path.dirname(self.dbfile)), exist_ok=True)
 
@@ -119,7 +140,11 @@ class RssUpdates(HttpRequest):
 
     @staticmethod
     def _get_latest_update(session, source_id):
-        return session.query(func.max(FeedEntry.published)).filter_by(source_id=source_id).scalar()
+        return (
+            session.query(func.max(FeedEntry.published))
+            .filter_by(source_id=source_id)
+            .scalar()
+        )
 
     def _parse_entry_content(self, link):
         self.logger.info('Extracting content from {}'.format(link))
@@ -130,14 +155,20 @@ class RssUpdates(HttpRequest):
         errors = response.errors
 
         if not output:
-            self.logger.warning('Mercury parser error: {}'.format(errors or '[unknown error]'))
+            self.logger.warning(
+                'Mercury parser error: {}'.format(errors or '[unknown error]')
+            )
             return
 
         return output.get('content')
 
     def get_new_items(self, response):
         import feedparser
-        engine = create_engine('sqlite:///{}'.format(self.dbfile), connect_args={'check_same_thread': False})
+
+        engine = create_engine(
+            'sqlite:///{}'.format(self.dbfile),
+            connect_args={'check_same_thread': False},
+        )
 
         Base.metadata.create_all(engine)
         Session.configure(bind=engine)
@@ -157,12 +188,16 @@ class RssUpdates(HttpRequest):
 
         content = u'''
             <h1 style="{title_style}">{title}</h1>
-            <h2 style="{subtitle_style}">Feeds digest generated on {creation_date}</h2>'''.\
-            format(title_style=self.title_style, title=self.title, subtitle_style=self.subtitle_style,
-                   creation_date=datetime.datetime.now().strftime('%d %B %Y, %H:%M'))
+            <h2 style="{subtitle_style}">Feeds digest generated on {creation_date}</h2>'''.format(
+            title_style=self.title_style,
+            title=self.title,
+            subtitle_style=self.subtitle_style,
+            creation_date=datetime.datetime.now().strftime('%d %B %Y, %H:%M'),
+        )
 
-        self.logger.info('Parsed {:d} items from RSS feed <{}>'
-                         .format(len(feed.entries), self.url))
+        self.logger.info(
+            'Parsed {:d} items from RSS feed <{}>'.format(len(feed.entries), self.url)
+        )
 
         for entry in feed.entries:
             if not entry.published_parsed:
@@ -171,9 +206,10 @@ class RssUpdates(HttpRequest):
             try:
                 entry_timestamp = datetime.datetime(*entry.published_parsed[:6])
 
-                if latest_update is None \
-                        or entry_timestamp > latest_update:
-                    self.logger.info('Processed new item from RSS feed <{}>'.format(self.url))
+                if latest_update is None or entry_timestamp > latest_update:
+                    self.logger.info(
+                        'Processed new item from RSS feed <{}>'.format(self.url)
+                    )
                     entry.summary = entry.summary if hasattr(entry, 'summary') else None
 
                     if self.extract_content:
@@ -188,9 +224,13 @@ class RssUpdates(HttpRequest):
                             <a href="{link}" target="_blank" style="{article_link_style}">{title}</a>
                         </h1>
                         <div class="_parsed-content" style="{article_content_style}">{content}</div>'''.format(
-                        article_title_style=self.article_title_style, article_link_style=self.article_link_style,
-                        article_content_style=self.article_content_style, link=entry.link, title=entry.title,
-                        content=entry.content)
+                        article_title_style=self.article_title_style,
+                        article_link_style=self.article_link_style,
+                        article_content_style=self.article_content_style,
+                        link=entry.link,
+                        title=entry.title,
+                        content=entry.content,
+                    )
 
                     e = {
                         'entry_id': entry.id,
@@ -207,21 +247,32 @@ class RssUpdates(HttpRequest):
                     if self.max_entries and len(entries) > self.max_entries:
                         break
             except Exception as e:
-                self.logger.warning('Exception encountered while parsing RSS ' +
-                                    'RSS feed {}: {}'.format(entry.link, str(e)))
+                self.logger.warning(
+                    'Exception encountered while parsing RSS '
+                    + f'RSS feed {entry.link}: {e}'
+                )
                 self.logger.exception(e)
 
         source_record.last_updated_at = parse_start_time
         digest_filename = None
 
         if entries:
-            self.logger.info('Parsed {} new entries from the RSS feed {}'.format(
-                len(entries), self.title))
+            self.logger.info(
+                'Parsed {} new entries from the RSS feed {}'.format(
+                    len(entries), self.title
+                )
+            )
 
             if self.digest_format:
-                digest_filename = os.path.join(self.workdir, 'cache', '{}_{}.{}'.format(
-                    datetime.datetime.now().strftime('%Y-%m-%dT%H:%M:%S'),
-                    self.title, self.digest_format))
+                digest_filename = os.path.join(
+                    self.workdir,
+                    'cache',
+                    '{}_{}.{}'.format(
+                        datetime.datetime.now().strftime('%Y-%m-%dT%H:%M:%S'),
+                        self.title,
+                        self.digest_format,
+                    ),
+                )
 
                 os.makedirs(os.path.dirname(digest_filename), exist_ok=True)
 
@@ -233,12 +284,15 @@ class RssUpdates(HttpRequest):
                             </head>
                             <body style="{body_style}">{content}</body>
                         </html>
-                    '''.format(title=self.title, body_style=self.body_style, content=content)
+                    '''.format(
+                        title=self.title, body_style=self.body_style, content=content
+                    )
 
                     with open(digest_filename, 'w', encoding='utf-8') as f:
                         f.write(content)
                 elif self.digest_format == 'pdf':
                     from weasyprint import HTML, CSS
+
                     try:
                         from weasyprint.fonts import FontConfiguration
                     except ImportError:
@@ -246,37 +300,47 @@ class RssUpdates(HttpRequest):
 
                     body_style = 'body { ' + self.body_style + ' }'
                     font_config = FontConfiguration()
-                    css = [CSS('https://fonts.googleapis.com/css?family=Merriweather'),
-                           CSS(string=body_style, font_config=font_config)]
+                    css = [
+                        CSS('https://fonts.googleapis.com/css?family=Merriweather'),
+                        CSS(string=body_style, font_config=font_config),
+                    ]
 
                     HTML(string=content).write_pdf(digest_filename, stylesheets=css)
                 else:
-                    raise RuntimeError('Unsupported format: {}. Supported formats: ' +
-                                       'html or pdf'.format(self.digest_format))
+                    raise RuntimeError(
+                        f'Unsupported format: {self.digest_format}. Supported formats: html, pdf'
+                    )
 
-                digest_entry = FeedDigest(source_id=source_record.id,
-                                          format=self.digest_format,
-                                          filename=digest_filename)
+                digest_entry = FeedDigest(
+                    source_id=source_record.id,
+                    format=self.digest_format,
+                    filename=digest_filename,
+                )
 
                 session.add(digest_entry)
-                self.logger.info('{} digest ready: {}'.format(self.digest_format, digest_filename))
+                self.logger.info(
+                    '{} digest ready: {}'.format(self.digest_format, digest_filename)
+                )
 
         session.commit()
         self.logger.info('Parsing RSS feed {}: completed'.format(self.title))
 
-        return NewFeedEvent(request=dict(self), response=entries,
-                            source_id=source_record.id,
-                            source_title=source_record.title,
-                            title=self.title,
-                            digest_format=self.digest_format,
-                            digest_filename=digest_filename)
+        return NewFeedEvent(
+            request=dict(self),
+            response=entries,
+            source_id=source_record.id,
+            source_title=source_record.title,
+            title=self.title,
+            digest_format=self.digest_format,
+            digest_filename=digest_filename,
+        )
 
 
 class FeedSource(Base):
-    """ Models the FeedSource table, containing RSS sources to be parsed """
+    """Models the FeedSource table, containing RSS sources to be parsed"""
 
     __tablename__ = 'FeedSource'
-    __table_args__ = ({'sqlite_autoincrement': True})
+    __table_args__ = {'sqlite_autoincrement': True}
 
     id = Column(Integer, primary_key=True)
     title = Column(String)
@@ -285,10 +349,10 @@ class FeedSource(Base):
 
 
 class FeedEntry(Base):
-    """ Models the FeedEntry table, which contains RSS entries """
+    """Models the FeedEntry table, which contains RSS entries"""
 
     __tablename__ = 'FeedEntry'
-    __table_args__ = ({'sqlite_autoincrement': True})
+    __table_args__ = {'sqlite_autoincrement': True}
 
     id = Column(Integer, primary_key=True)
     entry_id = Column(String)
@@ -301,15 +365,15 @@ class FeedEntry(Base):
 
 
 class FeedDigest(Base):
-    """ Models the FeedDigest table, containing feed digests either in HTML
-        or PDF format """
+    """Models the FeedDigest table, containing feed digests either in HTML
+    or PDF format"""
 
     class DigestFormat(enum.Enum):
         html = 1
         pdf = 2
 
     __tablename__ = 'FeedDigest'
-    __table_args__ = ({'sqlite_autoincrement': True})
+    __table_args__ = {'sqlite_autoincrement': True}
 
     id = Column(Integer, primary_key=True)
     source_id = Column(Integer, ForeignKey('FeedSource.id'), nullable=False)
@@ -317,4 +381,5 @@ class FeedDigest(Base):
     filename = Column(String, nullable=False)
     created_at = Column(DateTime, nullable=False, default=datetime.datetime.utcnow)
 
+
 # vim:sw=4:ts=4:et:
diff --git a/platypush/backend/http/webapp/package-lock.json b/platypush/backend/http/webapp/package-lock.json
index c27e627e6..d4c538cf9 100644
--- a/platypush/backend/http/webapp/package-lock.json
+++ b/platypush/backend/http/webapp/package-lock.json
@@ -8,7 +8,7 @@
       "name": "platypush",
       "version": "0.1.0",
       "dependencies": {
-        "@fortawesome/fontawesome-free": "^5.15.4",
+        "@fortawesome/fontawesome-free": "^6.1.1",
         "axios": "^0.21.4",
         "core-js": "^3.21.1",
         "lato-font": "^3.0.0",
@@ -1731,9 +1731,9 @@
       }
     },
     "node_modules/@fortawesome/fontawesome-free": {
-      "version": "5.15.4",
-      "resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-free/-/fontawesome-free-5.15.4.tgz",
-      "integrity": "sha512-eYm8vijH/hpzr/6/1CJ/V/Eb1xQFW2nnUKArb3z+yUWv7HTwj6M7SP957oMjfZjAHU6qpoNc2wQvIxBLWYa/Jg==",
+      "version": "6.1.1",
+      "resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-free/-/fontawesome-free-6.1.1.tgz",
+      "integrity": "sha512-J/3yg2AIXc9wznaVqpHVX3Wa5jwKovVF0AMYSnbmcXTiL3PpRPfF58pzWucCwEiCJBp+hCNRLWClTomD8SseKg==",
       "hasInstallScript": true,
       "engines": {
         "node": ">=6"
@@ -12917,9 +12917,9 @@
       }
     },
     "@fortawesome/fontawesome-free": {
-      "version": "5.15.4",
-      "resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-free/-/fontawesome-free-5.15.4.tgz",
-      "integrity": "sha512-eYm8vijH/hpzr/6/1CJ/V/Eb1xQFW2nnUKArb3z+yUWv7HTwj6M7SP957oMjfZjAHU6qpoNc2wQvIxBLWYa/Jg=="
+      "version": "6.1.1",
+      "resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-free/-/fontawesome-free-6.1.1.tgz",
+      "integrity": "sha512-J/3yg2AIXc9wznaVqpHVX3Wa5jwKovVF0AMYSnbmcXTiL3PpRPfF58pzWucCwEiCJBp+hCNRLWClTomD8SseKg=="
     },
     "@humanwhocodes/config-array": {
       "version": "0.5.0",
diff --git a/platypush/backend/http/webapp/package.json b/platypush/backend/http/webapp/package.json
index 531f041d0..d62b06598 100644
--- a/platypush/backend/http/webapp/package.json
+++ b/platypush/backend/http/webapp/package.json
@@ -8,7 +8,7 @@
     "lint": "vue-cli-service lint"
   },
   "dependencies": {
-    "@fortawesome/fontawesome-free": "^5.15.4",
+    "@fortawesome/fontawesome-free": "^6.1.1",
     "axios": "^0.21.4",
     "core-js": "^3.21.1",
     "lato-font": "^3.0.0",
diff --git a/platypush/backend/http/webapp/public/icons/smartthings.png b/platypush/backend/http/webapp/public/icons/smartthings.png
new file mode 100644
index 000000000..9110a99c5
Binary files /dev/null and b/platypush/backend/http/webapp/public/icons/smartthings.png differ
diff --git a/platypush/backend/http/webapp/public/img/spinner.gif b/platypush/backend/http/webapp/public/img/spinner.gif
new file mode 100644
index 000000000..0b3ba6284
Binary files /dev/null and b/platypush/backend/http/webapp/public/img/spinner.gif differ
diff --git a/platypush/backend/http/webapp/src/Utils.vue b/platypush/backend/http/webapp/src/Utils.vue
index 50ce3fe4b..455785424 100644
--- a/platypush/backend/http/webapp/src/Utils.vue
+++ b/platypush/backend/http/webapp/src/Utils.vue
@@ -5,10 +5,11 @@ import DateTime from "@/utils/DateTime";
 import Events from "@/utils/Events";
 import Notification from "@/utils/Notification";
 import Screen from "@/utils/Screen";
+import Text from "@/utils/Text";
 import Types from "@/utils/Types";
 
 export default {
   name: "Utils",
-  mixins: [Api, Cookies, Notification, Events, DateTime, Screen, Types],
+  mixins: [Api, Cookies, Notification, Events, DateTime, Screen, Text, Types],
 }
 </script>
diff --git a/platypush/backend/http/webapp/src/assets/icons.json b/platypush/backend/http/webapp/src/assets/icons.json
index ee45a8013..93e8886c0 100644
--- a/platypush/backend/http/webapp/src/assets/icons.json
+++ b/platypush/backend/http/webapp/src/assets/icons.json
@@ -17,6 +17,9 @@
   "camera.pi": {
     "class": "fas fa-camera"
   },
+  "entities": {
+    "class": "fa fa-home"
+  },
   "execute": {
     "class": "fa fa-play"
   },
@@ -59,9 +62,21 @@
   "rtorrent": {
     "class": "fa fa-magnet"
   },
+  "smartthings": {
+    "imgUrl": "/icons/smartthings.png"
+  },
   "switches": {
     "class": "fas fa-toggle-on"
   },
+  "switch.switchbot": {
+    "class": "fas fa-toggle-on"
+  },
+  "switch.tplink": {
+    "class": "fas fa-toggle-on"
+  },
+  "switchbot": {
+    "class": "fas fa-toggle-on"
+  },
   "sound": {
     "class": "fa fa-microphone"
   },
diff --git a/platypush/backend/http/webapp/src/components/Nav.vue b/platypush/backend/http/webapp/src/components/Nav.vue
index 649bf6ad1..92531e2f7 100644
--- a/platypush/backend/http/webapp/src/components/Nav.vue
+++ b/platypush/backend/http/webapp/src/components/Nav.vue
@@ -6,7 +6,7 @@
     </div>
 
     <ul class="plugins">
-      <li v-for="name in Object.keys(panels).sort()" :key="name" class="entry" :class="{selected: name === selectedPanel}"
+      <li v-for="name in panelNames" :key="name" class="entry" :class="{selected: name === selectedPanel}"
           :title="name" @click="onItemClick(name)">
         <a :href="`/#${name}`">
         <span class="icon">
@@ -14,7 +14,7 @@
           <img :src="icons[name].imgUrl" v-else-if="icons[name]?.imgUrl"  alt="name"/>
           <i class="fas fa-puzzle-piece" v-else />
         </span>
-        <span class="name" v-if="!collapsed" v-text="name" />
+        <span class="name" v-if="!collapsed" v-text="name == 'entities' ? 'Home' : name" />
         </a>
       </li>
     </ul>
@@ -66,6 +66,16 @@ export default {
     },
   },
 
+  computed: {
+    panelNames() {
+      let panelNames = Object.keys(this.panels)
+      const homeIdx = panelNames.indexOf('entities')
+      if (homeIdx >= 0)
+        return ['entities'].concat((panelNames.slice(0, homeIdx).concat(panelNames.slice(homeIdx+1))).sort())
+      return panelNames.sort()
+    },
+  },
+
   methods: {
     onItemClick(name) {
       this.$emit('select', name)
@@ -80,11 +90,6 @@ export default {
       host: null,
     }
   },
-
-  mounted() {
-    if (this.isMobile() && !this.$root.$route.hash.length)
-      this.collapsed = false
-  },
 }
 </script>
 
diff --git a/platypush/backend/http/webapp/src/components/elements/ConfirmDialog.vue b/platypush/backend/http/webapp/src/components/elements/ConfirmDialog.vue
new file mode 100644
index 000000000..54368aac7
--- /dev/null
+++ b/platypush/backend/http/webapp/src/components/elements/ConfirmDialog.vue
@@ -0,0 +1,84 @@
+<template>
+  <Modal ref="modal" :title="title">
+    <div class="dialog-content">
+      <slot />
+    </div>
+
+    <form class="buttons" @submit.prevent="onConfirm">
+      <button type="submit" class="ok-btn" @click="onConfirm" @touch="onConfirm">
+        <i class="fas fa-check" /> &nbsp; {{ confirmText }}
+      </button>
+      <button type="button" class="cancel-btn" @click="close" @touch="close">
+        <i class="fas fa-xmark" /> &nbsp; {{ cancelText }}
+      </button>
+    </form>
+  </Modal>
+</template>
+
+<script>
+import Modal from "@/components/Modal";
+
+export default {
+  emits: ['input', 'click', 'touch'],
+  components: {Modal},
+  props: {
+    title: {
+      type: String,
+    },
+
+    confirmText: {
+      type: String,
+      default: "OK",
+    },
+
+    cancelText: {
+      type: String,
+      default: "Cancel",
+    },
+  },
+
+  methods: {
+    onConfirm() {
+      this.$emit('input')
+      this.close()
+    },
+
+    show() {
+      this.$refs.modal.show()
+    },
+
+    close() {
+      this.$refs.modal.hide()
+    },
+  },
+}
+</script>
+
+<style lang="scss" scoped>
+:deep(.modal) {
+  .dialog-content {
+    padding: 1em;
+  }
+
+  .buttons {
+    display: flex;
+    flex-direction: row;
+    justify-content: right;
+    padding: 1em 0 1em 1em;
+    border: 0;
+    border-radius: 0;
+    box-shadow: 0 -1px 2px 0 $default-shadow-color;
+
+    button {
+      margin-right: 1em;
+      padding: 0.5em 1em;
+      border: 1px solid $border-color-2;
+      border-radius: 1em;
+
+      &:hover {
+        background: $hover-bg;
+      }
+    }
+  }
+}
+</style>
diff --git a/platypush/backend/http/webapp/src/components/elements/Dropdown.vue b/platypush/backend/http/webapp/src/components/elements/Dropdown.vue
index 0ab113595..fd6de368b 100644
--- a/platypush/backend/http/webapp/src/components/elements/Dropdown.vue
+++ b/platypush/backend/http/webapp/src/components/elements/Dropdown.vue
@@ -37,6 +37,11 @@ export default {
     title: {
       type: String,
     },
+
+    keepOpenOnItemClick: {
+      type: Boolean,
+      default: false,
+    },
   },
 
   data() {
diff --git a/platypush/backend/http/webapp/src/components/elements/DropdownItem.vue b/platypush/backend/http/webapp/src/components/elements/DropdownItem.vue
index ac8c8f4d7..4698b7042 100644
--- a/platypush/backend/http/webapp/src/components/elements/DropdownItem.vue
+++ b/platypush/backend/http/webapp/src/components/elements/DropdownItem.vue
@@ -1,20 +1,27 @@
 <template>
   <div class="row item" :class="itemClass" @click="clicked">
-    <div class="col-1 icon" v-if="iconClass">
-      <i :class="iconClass" />
+    <div class="col-1 icon" v-if="iconClass?.length || iconUrl?.length">
+      <Icon :class="iconClass" :url="iconUrl" />
     </div>
     <div class="text" :class="{'col-11': iconClass != null}" v-text="text" />
   </div>
 </template>
 
 <script>
+import Icon from "@/components/elements/Icon";
+
 export default {
   name: "DropdownItem",
+  components: {Icon},
   props: {
     iconClass: {
       type: String,
     },
 
+    iconUrl: {
+      type: String,
+    },
+
     text: {
       type: String,
     },
@@ -31,8 +38,12 @@ export default {
 
   methods: {
     clicked(event) {
+      if (this.disabled)
+        return false
+
       this.$parent.$emit('click', event)
-      this.$parent.visible = false
+      if (!this.$parent.keepOpenOnItemClick)
+        this.$parent.visible = false
     }
   }
 }
@@ -55,7 +66,18 @@ export default {
   }
 
   .icon {
-    margin: 0 .5em;
+    display: inline-flex;
+    align-items: center;
+  }
+
+  ::v-deep(.icon-container) {
+    width: 2em;
+    display: inline-flex;
+    align-items: center;
+
+    .icon {
+      margin: 0 1.5em 0 .5em;
+    }
   }
 }
 </style>
diff --git a/platypush/backend/http/webapp/src/components/elements/EditButton.vue b/platypush/backend/http/webapp/src/components/elements/EditButton.vue
new file mode 100644
index 000000000..f24bdde68
--- /dev/null
+++ b/platypush/backend/http/webapp/src/components/elements/EditButton.vue
@@ -0,0 +1,33 @@
+<template>
+  <button class="edit-btn"
+    @click="proxy($event)" @touch="proxy($event)" @input="proxy($event)"
+  >
+    <i class="fas fa-pen-to-square" />
+  </button>
+</template>
+
+<script>
+export default {
+  emits: ['input', 'click', 'touch'],
+  methods: {
+    proxy(e) {
+      this.$emit(e.type, e)
+    },
+  },
+}
+</script>
+
+<style lang="scss" scoped>
+.edit-btn {
+  border: 0;
+  background: none;
+  padding: 0 0.25em;
+  margin-left: 0.25em;
+  border: 1px solid rgba(0, 0, 0, 0);
+
+  &:hover {
+    background: $hover-bg;
+    border: 1px solid $selected-fg;
+  }
+}
+</style>
diff --git a/platypush/backend/http/webapp/src/components/elements/Icon.vue b/platypush/backend/http/webapp/src/components/elements/Icon.vue
new file mode 100644
index 000000000..e61c7a7d9
--- /dev/null
+++ b/platypush/backend/http/webapp/src/components/elements/Icon.vue
@@ -0,0 +1,48 @@
+<template>
+  <div class="icon-container">
+    <img class="icon" :src="url" :alt="alt" v-if="url?.length">
+    <i class="icon" :class="className" :style="{color: color}"
+      v-else-if="className?.length" />
+  </div>
+</template>
+
+<script>
+export default {
+  props: {
+    class: {
+      type: String,
+    },
+    url: {
+      type: String,
+    },
+    color: {
+      type: String,
+      default: '',
+    },
+    alt: {
+      type: String,
+      default: '',
+    },
+  },
+
+  computed: {
+    className() {
+      return this.class
+    }
+  }
+}
+</script>
+
+<style lang="scss" scoped>
+.icon-container {
+  display: inline-flex;
+  width: $icon-container-size;
+  justify-content: center;
+  text-align: center;
+
+  .icon {
+    width: 1em;
+    height: 1em;
+  }
+}
+</style>
diff --git a/platypush/backend/http/webapp/src/components/elements/NameEditor.vue b/platypush/backend/http/webapp/src/components/elements/NameEditor.vue
new file mode 100644
index 000000000..73cf72899
--- /dev/null
+++ b/platypush/backend/http/webapp/src/components/elements/NameEditor.vue
@@ -0,0 +1,75 @@
+<template>
+  <form @submit.prevent="submit" class="name-editor">
+    <input type="text" v-model="text" :disabled="disabled">
+    <button type="submit">
+      <i class="fas fa-circle-check" />
+    </button>
+    <button class="cancel" @click="$emit('cancel')" @touch="$emit('cancel')">
+      <i class="fas fa-ban" />
+    </button>
+    <slot />
+  </form>
+</template>
+
+<script>
+export default {
+  emits: ['input', 'cancel'],
+  props: {
+    value: {
+      type: String,
+    },
+
+    disabled: {
+      type: Boolean,
+      deafult: false,
+    },
+  },
+
+  data() {
+    return {
+      text: null,
+    }
+  },
+
+  methods: {
+    proxy(e) {
+      this.$emit(e.type, e)
+    },
+
+    submit() {
+      this.$emit('input', this.text)
+      return false
+    },
+  },
+
+  mounted() {
+    this.text = this.value
+  },
+}
+</script>
+
+<style lang="scss" scoped>
+.name-editor {
+  background: #00000000;
+  display: inline-flex;
+  flex-direction: row;
+  padding: 0;
+  border: 0;
+  border-radius: 0;
+  box-shadow: none;
+
+  button {
+    border: none;
+    background: none;
+    padding: 0 0.5em;
+
+    &.confirm {
+      color: $selected-fg;
+    }
+
+    &.cancel {
+      color: $error-fg;
+    }
+  }
+}
+</style>
diff --git a/platypush/backend/http/webapp/src/components/elements/NoItems.vue b/platypush/backend/http/webapp/src/components/elements/NoItems.vue
new file mode 100644
index 000000000..db0278cb8
--- /dev/null
+++ b/platypush/backend/http/webapp/src/components/elements/NoItems.vue
@@ -0,0 +1,51 @@
+<template>
+<div class="no-items-container">
+  <div class="no-items fade-in">
+    <slot />
+  </div>
+</div>
+</template>
+
+<script>
+export default {
+  name: "NoItems",
+}
+</script>
+
+<style lang="scss" scoped>
+.no-items-container {
+  width: 100%;
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+
+  .no-items {
+    min-width: 100%;
+    max-width: 100%;
+
+    @include from($tablet) {
+      min-width: 80%;
+    }
+
+    @include from($desktop) {
+      min-width: 50%;
+      max-width: 35em;
+    }
+
+    @include from($fullhd) {
+      min-width: 33%;
+    }
+
+    background: $background-color;
+    margin: 1em;
+    padding: 1em;
+    font-size: 1.5em;
+    color: $no-items-color;
+    display: flex;
+    align-items: center;
+    justify-content: center;
+    border-radius: 1em;
+    box-shadow: $border-shadow-bottom;
+  }
+}
+</style>
diff --git a/platypush/backend/http/webapp/src/components/elements/Slider.vue b/platypush/backend/http/webapp/src/components/elements/Slider.vue
index 8cf7393e9..9969db470 100644
--- a/platypush/backend/http/webapp/src/components/elements/Slider.vue
+++ b/platypush/backend/http/webapp/src/components/elements/Slider.vue
@@ -62,7 +62,7 @@ export default {
     },
 
     update(value) {
-      const percent = (value * 100) / (this.range[1] - this.range[0])
+      const percent = ((value - this.range[0]) * 100) / (this.range[1] - this.range[0])
       this.$refs.thumb.style.left = `${percent}%`
       this.$refs.thumb.style.transform = `translate(-${percent}%, -50%)`
       this.$refs.track.style.width = `${percent}%`
diff --git a/platypush/backend/http/webapp/src/components/elements/ToggleSwitch.vue b/platypush/backend/http/webapp/src/components/elements/ToggleSwitch.vue
index a751861d4..43be6765f 100644
--- a/platypush/backend/http/webapp/src/components/elements/ToggleSwitch.vue
+++ b/platypush/backend/http/webapp/src/components/elements/ToggleSwitch.vue
@@ -59,7 +59,7 @@ export default {
     display: none;
     & + label {
       border-radius: 1em;
-      display: block;
+      display: inline-flex;
       cursor: pointer;
       position: relative;
       transition: box-shadow .4s;
diff --git a/platypush/backend/http/webapp/src/components/panels/Entities/Entity.vue b/platypush/backend/http/webapp/src/components/panels/Entities/Entity.vue
new file mode 100644
index 000000000..1e6eea805
--- /dev/null
+++ b/platypush/backend/http/webapp/src/components/panels/Entities/Entity.vue
@@ -0,0 +1,45 @@
+<template>
+  <div class="row item entity-container">
+    <component :is="component"
+      :value="value"
+      :loading="loading"
+      :error="error || value?.reachable == false"
+      @input="$emit('input', $event)"
+      @loading="$emit('loading', $event)"
+    />
+  </div>
+</template>
+
+<script>
+import { defineAsyncComponent } from 'vue'
+import EntityMixin from "./EntityMixin"
+
+export default {
+  name: "Entity",
+  mixins: [EntityMixin],
+  emits: ['input', 'loading'],
+
+  data() {
+    return {
+      component: null,
+    }
+  },
+
+  mounted() {
+    if (this.type !== 'Entity')
+      this.component = defineAsyncComponent(
+        () => import(`@/components/panels/Entities/${this.type}`)
+      )
+  },
+}
+</script>
+
+<style lang="scss" scoped>
+@import "common";
+
+.entity-container {
+  width: 100%;
+  position: relative;
+  padding: 0 !important;
+}
+</style>
diff --git a/platypush/backend/http/webapp/src/components/panels/Entities/EntityIcon.vue b/platypush/backend/http/webapp/src/components/panels/Entities/EntityIcon.vue
new file mode 100644
index 000000000..e977be11a
--- /dev/null
+++ b/platypush/backend/http/webapp/src/components/panels/Entities/EntityIcon.vue
@@ -0,0 +1,101 @@
+<template>
+  <div class="entity-icon-container"
+      :class="{'with-color-fill': !!colorFill}"
+      :style="colorFillStyle">
+    <img src="@/assets/img/spinner.gif" class="loading" v-if="loading">
+    <i class="fas fa-circle-exclamation error" v-else-if="error" />
+    <Icon v-bind="computedIcon" v-else />
+  </div>
+</template>
+
+<script>
+import Icon from "@/components/elements/Icon";
+
+export default {
+  name: "EntityIcon",
+  components: {Icon},
+  props: {
+    loading: {
+      type: Boolean,
+      default: false,
+    },
+
+    error: {
+      type: Boolean,
+      default: false,
+    },
+
+    icon: {
+      type: Object,
+      required: true,
+    },
+
+    hasColorFill: {
+      type: Boolean,
+      default: false,
+    },
+  },
+
+  data() {
+    return {
+      component: null,
+      modalVisible: false,
+    }
+  },
+
+  computed: {
+    colorFill() {
+      return (this.hasColorFill && this.icon.color) ? this.icon.color : null
+    },
+
+    colorFillStyle() {
+      return this.colorFill ? {'background': this.colorFill} : {}
+    },
+
+    computedIcon() {
+      const icon = {...this.icon}
+      if (this.colorFill)
+        delete icon.color
+      return icon
+    },
+
+    type() {
+      let entityType = (this.entity.type || '')
+      return entityType.charAt(0).toUpperCase() + entityType.slice(1)
+    },
+  },
+}
+</script>
+
+<style lang="scss" scoped>
+@import "vars";
+
+.entity-icon-container {
+  width: 1.625em;
+  height: 1.5em;
+  display: inline-flex;
+  margin-top: 0.25em;
+  margin-left: 0.25em;
+  position: relative;
+  text-align: center;
+  justify-content: center;
+  align-items: center;
+
+  &.with-color-fill {
+    border-radius: 1em;
+  }
+
+  .loading {
+    position: absolute;
+    bottom: 0;
+    transform: translate(0%, -50%);
+    width: 1em;
+    height: 1em;
+  }
+
+  .error {
+    color: $error-fg;
+    margin-left: .5em;
+  }
+}
+</style>
diff --git a/platypush/backend/http/webapp/src/components/panels/Entities/EntityMixin.vue b/platypush/backend/http/webapp/src/components/panels/Entities/EntityMixin.vue
new file mode 100644
index 000000000..74198d298
--- /dev/null
+++ b/platypush/backend/http/webapp/src/components/panels/Entities/EntityMixin.vue
@@ -0,0 +1,38 @@
+<script>
+import Utils from "@/Utils"
+
+export default {
+  name: "EntityMixin",
+  mixins: [Utils],
+  emits: ['input'],
+  props: {
+    loading: {
+      type: Boolean,
+      default: false,
+    },
+
+    error: {
+      type: Boolean,
+      default: false,
+    },
+
+    value: {
+      type: Object,
+      required: true,
+    },
+  },
+
+  data() {
+    return {
+      modalVisible: false,
+    }
+  },
+
+  computed: {
+    type() {
+      let entityType = (this.value.type || '')
+      return entityType.charAt(0).toUpperCase() + entityType.slice(1)
+    },
+  },
+}
+</script>
diff --git a/platypush/backend/http/webapp/src/components/panels/Entities/Index.vue b/platypush/backend/http/webapp/src/components/panels/Entities/Index.vue
new file mode 100644
index 000000000..9f1d03b56
--- /dev/null
+++ b/platypush/backend/http/webapp/src/components/panels/Entities/Index.vue
@@ -0,0 +1,451 @@
+<template>
+  <div class="row plugin entities-container">
+    <Loading v-if="loading" />
+
+    <header>
+      <div class="col-11 left">
+        <Selector :entity-groups="entityGroups" :value="selector" @input="selector = $event" />
+      </div>
+
+      <div class="col-1 right">
+        <button title="Refresh" @click="refresh(null)">
+          <i class="fa fa-sync-alt" />
+        </button>
+      </div>
+    </header>
+
+    <div class="groups-canvas">
+      <EntityModal :entity="entities[modalEntityId]"
+        :visible="modalVisible" @close="onEntityModal(null)"
+        v-if="modalEntityId"
+      />
+
+      <NoItems v-if="!Object.keys(displayGroups || {})?.length">No entities found</NoItems>
+
+      <div class="groups-container" v-else>
+        <div class="group fade-in" v-for="group in displayGroups" :key="group.name">
+          <div class="frame">
+            <div class="header">
+              <span class="section left">
+                <Icon v-bind="entitiesMeta[group.name].icon || {}"
+                  v-if="selector.grouping === 'type' && entitiesMeta[group.name]" />
+                <Icon :class="pluginIcons[group.name]?.class" :url="pluginIcons[group.name]?.imgUrl"
+                  v-else-if="selector.grouping === 'plugin' && pluginIcons[group.name]" />
+              </span>
+
+              <span class="section center">
+                <div class="title" v-text="entitiesMeta[group.name].name_plural"
+                  v-if="selector.grouping === 'type' && entitiesMeta[group.name]"/>
+                <div class="title" v-text="group.name" v-else-if="selector.grouping === 'plugin'"/>
+              </span>
+
+              <span class="section right">
+                <button title="Refresh" @click="refresh(group)">
+                  <i class="fa fa-sync-alt" />
+                </button>
+              </span>
+            </div>
+
+            <div class="body">
+              <div class="entity-frame" @click="onEntityModal(entity.id)"
+                  v-for="entity in group.entities" :key="entity.id">
+                <Entity
+                  :value="entity"
+                  @input="onEntityInput"
+                  :error="!!errorEntities[entity.id]"
+                  :loading="!!loadingEntities[entity.id]"
+                  @loading="loadingEntities[entity.id] = $event"
+                />
+              </div>
+            </div>
+          </div>
+        </div>
+      </div>
+    </div>
+  </div>
+</template>
+
+<script>
+import Utils from "@/Utils"
+import Loading from "@/components/Loading";
+import Icon from "@/components/elements/Icon";
+import NoItems from "@/components/elements/NoItems";
+import Entity from "./Entity.vue";
+import Selector from "./Selector.vue";
+import EntityModal from "./Modal"
+import icons from '@/assets/icons.json'
+import meta from './meta.json'
+
+export default {
+  name: "Entities",
+  components: {Loading, Icon, Entity, Selector, NoItems, EntityModal},
+  mixins: [Utils],
+
+  props: {
+    // Entity scan timeout in seconds
+    entityScanTimeout: {
+      type: Number,
+      default: 30,
+    },
+  },
+
+  data() {
+    return {
+      loading: false,
+      loadingEntities: {},
+      errorEntities: {},
+      entityTimeouts: {},
+      entities: {},
+      modalEntityId: null,
+      modalVisible: false,
+      selector: {
+        grouping: 'type',
+        selectedEntities: {},
+      },
+    }
+  },
+
+  computed: {
+    entitiesMeta() {
+      return meta
+    },
+
+    pluginIcons() {
+      return icons
+    },
+
+    entityGroups() {
+      return {
+        'id': Object.entries(this.groupEntities('id')).reduce((obj, [id, entities]) => {
+          obj[id] = entities[0]
+          return obj
+        }, {}),
+        'type': this.groupEntities('type'),
+        'plugin': this.groupEntities('plugin'),
+      }
+    },
+
+    displayGroups() {
+      return Object.entries(this.entityGroups[this.selector.grouping]).filter(
+        (entry) => entry[1].filter(
+          (e) => !!this.selector.selectedEntities[e.id]
+        ).length > 0
+      ).sort((a, b) => a[0].localeCompare(b[0])).map(
+        ([grouping, entities]) => {
+          return {
+            name: grouping,
+            entities: entities.filter(
+              (e) => e.id in this.selector.selectedEntities
+            ),
+          }
+        }
+      )
+    },
+  },
+
+  methods: {
+    groupEntities(attr) {
+      return Object.values(this.entities).reduce((obj, entity) => {
+        const entities = obj[entity[attr]] || {}
+        entities[entity.id] = entity
+        obj[entity[attr]] = Object.values(entities).sort((a, b) => {
+            return a.name.localeCompare(b.name)
+          })
+
+        return obj
+      }, {})
+    },
+
+    async refresh(group) {
+      const entities = (group ? group.entities : this.entities) || {}
+      const args = {}
+      if (group)
+        args.plugins = Object.keys(entities.reduce((obj, entity) => {
+          obj[entity.plugin] = true
+          return obj
+        }, {}))
+
+      this.loadingEntities = Object.values(entities).reduce((obj, entity) => {
+          const self = this
+          const id = entity.id
+          if (this.entityTimeouts[id])
+            clearTimeout(this.entityTimeouts[id])
+
+          this.entityTimeouts[id] = setTimeout(() => {
+              if (self.loadingEntities[id])
+                delete self.loadingEntities[id]
+              if (self.entityTimeouts[id])
+                delete self.entityTimeouts[id]
+
+              self.errorEntities[id] = entity
+              self.notify({
+                error: true,
+                title: entity.plugin,
+                text: `Scan timeout for ${entity.name}`,
+              })
+          }, this.entityScanTimeout * 1000)
+
+          obj[id] = true
+          return obj
+      }, {})
+
+      await this.request('entities.scan', args)
+    },
+
+    async sync() {
+      this.loading = true
+
+      try {
+        this.entities = (await this.request('entities.get')).reduce((obj, entity) => {
+          entity.name = entity?.meta?.name_override || entity.name
+          entity.meta = {
+            ...(meta[entity.type] || {}),
+            ...(entity.meta || {}),
+          }
+
+          obj[entity.id] = entity
+          return obj
+        }, {})
+
+        this.selector.selectedEntities = this.entityGroups.id
+      } finally {
+        this.loading = false
+      }
+    },
+
+    clearEntityTimeouts(entityId) {
+      if (this.errorEntities[entityId])
+        delete this.errorEntities[entityId]
+      if (this.loadingEntities[entityId])
+        delete this.loadingEntities[entityId]
+      if (this.entityTimeouts[entityId]) {
+        clearTimeout(this.entityTimeouts[entityId])
+        delete this.entityTimeouts[entityId]
+      }
+    },
+
+    onEntityInput(entity) {
+      this.entities[entity.id] = entity
+      this.clearEntityTimeouts(entity.id)
+      if (this.loadingEntities[entity.id])
+        delete this.loadingEntities[entity.id]
+    },
+
+    onEntityUpdate(event) {
+      const entityId = event.entity.id
+      if (entityId == null)
+        return
+
+      this.clearEntityTimeouts(entityId)
+      const entity = {...event.entity}
+      if (event.entity?.state == null)
+        entity.state = this.entities[entityId]?.state
+      if (entity.meta?.name_override?.length)
+        entity.name = entity.meta.name_override
+      else if (this.entities[entityId]?.meta?.name_override?.length)
+        entity.name = this.entities[entityId].meta.name_override
+      else
+        entity.name = event.entity?.name || this.entities[entityId]?.name
+
+      entity.meta = {
+        ...(meta[event.entity.type] || {}),
+        ...(this.entities[entityId]?.meta || {}),
+        ...(event.entity?.meta || {}),
+      }
+
+      this.entities[entityId] = entity
+    },
+
+    onEntityDelete(event) {
+      const entityId = event.entity?.id
+      if (entityId == null)
+        return
+      if (entityId === this.modalEntityId)
+        this.modalEntityId = null
+      if (this.entities[entityId])
+        delete this.entities[entityId]
+    },
+
+    onEntityModal(entityId) {
+      if (entityId) {
+        this.modalEntityId = entityId
+        this.modalVisible = true
+      } else {
+        this.modalEntityId = null
+        this.modalVisible = false
+      }
+    },
+  },
+
+  async mounted() {
+    this.subscribe(
+      this.onEntityUpdate,
+      'on-entity-update',
+      'platypush.message.event.entities.EntityUpdateEvent'
+    )
+
+    this.subscribe(
+      this.onEntityDelete,
+      'on-entity-delete',
+      'platypush.message.event.entities.EntityDeleteEvent'
+    )
+
+    await this.sync()
+    await this.refresh()
+  },
+}
+</script>
+
+<style lang="scss" scoped>
+@import "vars";
+@import "~@/style/items";
+
+.entities-container {
+  --groups-per-row: 1;
+
+  @include from($desktop) {
+    --groups-per-row: 2;
+  }
+
+  @include from($fullhd) {
+    --groups-per-row: 3;
+  }
+
+  width: 100%;
+  height: 100%;
+  overflow: auto;
+  color: $default-fg-2;
+  font-weight: 400;
+
+  button {
+    background: #ffffff00;
+    border: 0;
+
+    &:hover {
+      color: $default-hover-fg;
+    }
+  }
+
+  header {
+    width: 100%;
+    height: $selector-height;
+    display: flex;
+    background: $default-bg-2;
+    box-shadow: $border-shadow-bottom;
+    position: relative;
+
+    .right {
+      position: absolute;
+      right: 0;
+      text-align: right;
+      margin-right: 0.5em;
+      padding-right: 0.5em;
+
+      button {
+        padding: 0.5em 0;
+      }
+    }
+  }
+
+  .groups-canvas {
+    width: 100%;
+    height: calc(100% - #{$selector-height});
+    overflow: auto;
+  }
+
+  .groups-container {
+    @include from($desktop) {
+      column-count: var(--groups-per-row);
+    }
+  }
+
+  .group {
+    width: 100%;
+    max-height: 100%;
+    position: relative;
+    padding: $main-margin 0;
+    display: flex;
+    break-inside: avoid;
+
+    @include from ($tablet) {
+      padding: $main-margin;
+    }
+
+    .frame {
+      @include from($desktop) {
+        max-height: calc(100vh - #{$header-height} - #{$main-margin});
+      }
+
+      display: flex;
+      flex-direction: column;
+      flex-grow: 1;
+      position: relative;
+      box-shadow: $group-shadow;
+      border-radius: 1em;
+    }
+
+    .header {
+      width: 100%;
+      height: $header-height;
+      display: table;
+      background: $header-bg;
+      box-shadow: $header-shadow;
+      border-radius: 1em 1em 0 0;
+
+      .section {
+        height: 100%;
+        display: table-cell;
+        vertical-align: middle;
+
+        &.left, &.right {
+          width: 10%;
+        }
+
+        &.right {
+          text-align: right;
+        }
+
+        &.center {
+          width: 80%;
+          text-align: center;
+        }
+      }
+    }
+
+    .body {
+      background: $default-bg-2;
+      max-height: calc(100% - #{$header-height});
+      overflow: auto;
+      flex-grow: 1;
+
+      .entity-frame:last-child {
+        border-radius: 0 0 1em 1em;
+      }
+    }
+  }
+
+  :deep(.modal) {
+    @include until($tablet) {
+      width: 95%;
+    }
+
+    .content {
+      @include until($tablet) {
+        width: 100%;
+      }
+
+      @include from($tablet) {
+        min-width: 30em;
+      }
+
+      .body {
+        padding: 0;
+
+        .table-row {
+          padding: 0.5em;
+        }
+      }
+    }
+  }
+}
+</style>
diff --git a/platypush/backend/http/webapp/src/components/panels/Entities/Light.vue b/platypush/backend/http/webapp/src/components/panels/Entities/Light.vue
new file mode 100644
index 000000000..ae85e59bc
--- /dev/null
+++ b/platypush/backend/http/webapp/src/components/panels/Entities/Light.vue
@@ -0,0 +1,222 @@
+<template>
+  <div class="entity light-container">
+    <div class="head" :class="{expanded: expanded}">
+      <div class="col-1 icon">
+        <EntityIcon :icon="icon" :hasColorFill="true"
+          :loading="loading" :error="error" />
+      </div>
+
+      <div class="col-s-8 col-m-9 label">
+        <div class="name" v-text="value.name" />
+      </div>
+
+      <div class="col-s-3 col-m-2 buttons pull-right">
+        <ToggleSwitch :value="value.on" @input="toggle"
+          @click.stop :disabled="loading || value.is_read_only" />
+
+        <button @click.stop="expanded = !expanded">
+          <i class="fas"
+            :class="{'fa-angle-up': expanded, 'fa-angle-down': !expanded}" />
+        </button>
+      </div>
+    </div>
+
+    <div class="body" v-if="expanded" @click.stop="prevent">
+      <div class="row" v-if="cssColor">
+        <div class="icon">
+          <i class="fas fa-palette" />
+        </div>
+        <div class="input">
+          <input type="color" :value="cssColor" @change="setLight({color: $event.target.value})" />
+        </div>
+      </div>
+
+      <div class="row" v-if="value.brightness">
+        <div class="icon">
+          <i class="fas fa-sun" />
+        </div>
+        <div class="input">
+          <Slider :range="[value.brightness_min, value.brightness_max]"
+            :value="value.brightness" @input="setLight({brightness: $event.target.value})" />
+        </div>
+      </div>
+
+      <div class="row" v-if="value.saturation">
+        <div class="icon">
+          <i class="fas fa-droplet" />
+        </div>
+        <div class="input">
+          <Slider :range="[value.saturation_min, value.saturation_max]"
+            :value="value.saturation" @input="setLight({saturation: $event.target.value})" />
+        </div>
+      </div>
+
+      <div class="row" v-if="value.temperature">
+        <div class="icon">
+          <i class="fas fa-temperature-half" />
+        </div>
+        <div class="input">
+          <Slider :range="[value.temperature_min, value.temperature_max]"
+            :value="value.temperature" @input="setLight({temperature: $event.target.value})"/>
+        </div>
+      </div>
+    </div>
+  </div>
+</template>
+
+<script>
+import Slider from "@/components/elements/Slider"
+import ToggleSwitch from "@/components/elements/ToggleSwitch"
+import EntityMixin from "./EntityMixin"
+import EntityIcon from "./EntityIcon"
+import {ColorConverter} from "@/components/panels/Light/color";
+
+export default {
+  name: 'Light',
+  components: {ToggleSwitch, Slider, EntityIcon},
+  mixins: [EntityMixin],
+
+  data() {
+    return {
+      expanded: false,
+      colorConverter: null,
+    }
+  },
+
+  computed: {
+    rgbColor() {
+      if (this.value.meta?.icon?.color)
+        return this.value.meta.icon.color
+
+      if (
+        !this.colorConverter || (
+          this.value.hue == null &&
+          (this.value.x == null || this.value.y == null)
+        )
+      )
+        return
+
+      if (this.value.x && this.value.y)
+        return this.colorConverter.xyToRgb(
+          this.value.x,
+          this.value.y,
+          this.value.brightness
+        )
+
+      return this.colorConverter.hslToRgb(
+        this.value.hue,
+        this.value.saturation,
+        this.value.brightness
+      )
+    },
+
+    cssColor() {
+      const rgb = this.rgbColor
+      if (rgb)
+        return this.colorConverter.rgbToHex(rgb)
+      return null
+    },
+
+    icon() {
+      const icon = {...(this.value.meta?.icon || {})}
+      if (!icon.color && this.cssColor)
+        icon.color = this.cssColor
+      return icon
+    },
+  },
+
+  methods: {
+    prevent(event) {
+      event.stopPropagation()
+      return false
+    },
+
+    async toggle(event) {
+      event.stopPropagation()
+      this.$emit('loading', true)
+
+      try {
+        await this.request('entities.execute', {
+          id: this.value.id,
+          action: 'toggle',
+        })
+      } finally {
+        this.$emit('loading', false)
+      }
+    },
+
+    async setLight(attrs) {
+      if (attrs.color) {
+        const rgb = this.colorConverter.hexToRgb(attrs.color)
+        if (this.value.x != null && this.value.y != null) {
+          attrs.xy = this.colorConverter.rgbToXY(...rgb)
+          delete attrs.color
+        } else if (this.value.hue != null) {
+          [attrs.hue, attrs.saturation, attrs.brightness] = this.colorConverter.rgbToHsl(...rgb)
+          delete attrs.color
+        }
+      }
+
+      this.execute({
+        type: 'request',
+        action: this.value.plugin + '.set_lights',
+        args: {
+          lights: [this.value.external_id],
+          ...attrs,
+        }
+      })
+    },
+  },
+
+  mounted() {
+    const ranges = {}
+    if (this.value.hue)
+      ranges.hue = [this.value.hue_min, this.value.hue_max]
+    if (this.value.saturation)
+      ranges.sat = [this.value.saturation_min, this.value.saturation_max]
+    if (this.value.brightness)
+      ranges.bri = [this.value.brightness_min, this.value.brightness_max]
+    if (this.value.temperature)
+      ranges.ct = [this.value.temperature_min, this.value.temperature_max]
+
+    this.colorConverter = new ColorConverter(ranges)
+  },
+}
+</script>
+
+<style lang="scss" scoped>
+@import "common";
+
+.light-container {
+  .head {
+    .buttons {
+      button {
+        margin-right: 0.5em;
+      }
+    }
+  }
+
+  .body {
+    .row {
+      display: flex;
+
+      .icon {
+        width: 2em;
+        text-align: center;
+      }
+
+      .input {
+        width: calc(100% - 2em);
+
+        [type=color] {
+          width: 100%;
+        }
+
+        :deep(.slider) {
+          margin-top: 0.5em;
+        }
+      }
+    }
+  }
+}
+</style>
diff --git a/platypush/backend/http/webapp/src/components/panels/Entities/Modal.vue b/platypush/backend/http/webapp/src/components/panels/Entities/Modal.vue
new file mode 100644
index 000000000..e130adc7b
--- /dev/null
+++ b/platypush/backend/http/webapp/src/components/panels/Entities/Modal.vue
@@ -0,0 +1,252 @@
+<template>
+  <Modal :visible="visible" :title="entity.name || entity.external_id">
+    <ConfirmDialog ref="deleteConfirmDiag" title="Confirm entity deletion" @input="onDelete">
+      Are you <b>sure</b> that you want to delete this entity? <br/><br/>
+      Note: you should only delete an entity if its plugin has been disabled
+      or the entity is no longer reachable.<br/><br/>
+      Otherwise, the entity will simply be created again upon the next scan.
+    </ConfirmDialog>
+
+    <div class="table-row">
+      <div class="title">
+        Name
+        <EditButton @click="editName = true" v-if="!editName" />
+      </div>
+      <div class="value">
+        <NameEditor :value="entity.name" @input="onRename"
+          @cancel="editName = false" :disabled="loading" v-if="editName" />
+        <span v-text="entity.name" v-else />
+      </div>
+    </div>
+
+    <div class="table-row">
+      <div class="title">
+        Icon
+        <EditButton @click="editIcon = true" v-if="!editIcon" />
+      </div>
+      <div class="value icon-canvas">
+        <span class="icon-editor" v-if="editIcon">
+          <NameEditor :value="entity.meta?.icon?.class || entity.meta?.icon?.url" @input="onIconEdit"
+            @cancel="editIcon = false" :disabled="loading">
+            <button type="button" title="Reset" @click="onIconEdit(null)"
+                @touch="onIconEdit(null)">
+              <i class="fas fa-rotate-left" />
+            </button>
+          </NameEditor>
+          <span class="help">
+            Supported: image URLs or
+            <a href="https://fontawesome.com/icons" target="_blank">FontAwesome icon classes</a>.
+          </span>
+        </span>
+
+        <Icon v-bind="entity?.meta?.icon || {}" v-else />
+      </div>
+    </div>
+
+    <div class="table-row">
+      <div class="title">
+        Icon color
+      </div>
+      <div class="value icon-color-picker">
+        <input type="color" :value="entity.meta?.icon?.color" @change="onIconColorEdit">
+        <button type="button" title="Reset" @click="onIconColorEdit(null)"
+            @touch="onIconColorEdit(null)">
+          <i class="fas fa-rotate-left" />
+        </button>
+      </div>
+    </div>
+
+    <div class="table-row">
+      <div class="title">Plugin</div>
+      <div class="value" v-text="entity.plugin" />
+    </div>
+
+    <div class="table-row">
+      <div class="title">Internal ID</div>
+      <div class="value" v-text="entity.id" />
+    </div>
+
+    <div class="table-row" v-if="entity.external_id">
+      <div class="title">External ID</div>
+      <div class="value" v-text="entity.external_id" />
+    </div>
+
+    <div class="table-row" v-if="entity.description">
+      <div class="title">Description</div>
+      <div class="value" v-text="entity.description" />
+    </div>
+
+    <div v-for="value, attr in entity.data || {}" :key="attr">
+      <div class="table-row" v-if="value != null">
+        <div class="title" v-text="prettify(attr)" />
+        <div class="value" v-text="'' + value" />
+      </div>
+    </div>
+
+    <div class="table-row" v-if="entity.created_at">
+      <div class="title">Created at</div>
+      <div class="value" v-text="formatDateTime(entity.created_at)" />
+    </div>
+
+    <div class="table-row" v-if="entity.updated_at">
+      <div class="title">Updated at</div>
+      <div class="value" v-text="formatDateTime(entity.updated_at)" />
+    </div>
+
+    <div class="table-row delete-entity-container">
+      <div class="title">Delete Entity</div>
+      <div class="value">
+        <button @click="$refs.deleteConfirmDiag.show()">
+          <i class="fas fa-trash" />
+        </button>
+      </div>
+    </div>
+  </Modal>
+</template>
+
+<script>
+import Modal from "@/components/Modal";
+import Icon from "@/components/elements/Icon";
+import ConfirmDialog from "@/components/elements/ConfirmDialog";
+import EditButton from "@/components/elements/EditButton";
+import NameEditor from "@/components/elements/NameEditor";
+import Utils from "@/Utils";
+import meta from './meta.json'
+
+export default {
+  name: "Entity",
+  components: {Modal, EditButton, NameEditor, Icon, ConfirmDialog},
+  mixins: [Utils],
+  emits: ['input', 'loading'],
+  props: {
+    entity: {
+      type: Object,
+      required: true,
+    },
+
+    visible: {
+      type: Boolean,
+      default: false,
+    },
+  },
+
+  data() {
+    return {
+      loading: false,
+      editName: false,
+      editIcon: false,
+    }
+  },
+
+  methods: {
+    async onRename(newName) {
+      this.loading = true
+
+      try {
+        const req = {}
+        req[this.entity.id] = newName
+        await this.request('entities.rename', req)
+      } finally {
+        this.loading = false
+        this.editName = false
+      }
+    },
+
+    async onDelete() {
+      this.loading = true
+
+      try {
+        await this.request('entities.delete', [this.entity.id])
+      } finally {
+        this.loading = false
+      }
+    },
+
+    async onIconEdit(newIcon) {
+      this.loading = true
+
+      try {
+        const icon = {url: null, class: null}
+        if (newIcon?.length) {
+          if (newIcon.startsWith('http'))
+            icon.url = newIcon
+          else
+            icon.class = newIcon
+        } else {
+          icon.url = (meta[this.entity.type] || {})?.icon?.url
+          icon.class = (meta[this.entity.type] || {})?.icon?.['class']
+        }
+
+        const req = {}
+        req[this.entity.id] = {icon: icon}
+        await this.request('entities.set_meta', req)
+      } finally {
+        this.loading = false
+        this.editIcon = false
+      }
+    },
+
+    async onIconColorEdit(event) {
+      this.loading = true
+
+      try {
+        const icon = this.entity.meta?.icon || {}
+        if (event)
+          icon.color = event.target.value
+        else
+          icon.color = null
+
+        const req = {}
+        req[this.entity.id] = {icon: icon}
+        await this.request('entities.set_meta', req)
+      } finally {
+        this.loading = false
+        this.editIcon = false
+      }
+    },
+  },
+}
+</script>
+
+<style lang="scss" scoped>
+:deep(.modal) {
+  .icon-canvas {
+    display: inline-flex;
+    align-items: center;
+
+    @include until($tablet) {
+      .icon-container {
+        justify-content: left;
+      }
+    }
+
+    @include from($tablet) {
+      .icon-container {
+        justify-content: right;
+      }
+    }
+  }
+
+  .icon-editor {
+    display: flex;
+    flex-direction: column;
+  }
+
+  button {
+    border: none;
+    background: none;
+    padding: 0 0.5em;
+  }
+
+  .help {
+    font-size: 0.75em;
+  }
+
+  .delete-entity-container {
+    color: $error-fg;
+    button {
+      color: $error-fg;
+    }
+  }
+}
+</style>
diff --git a/platypush/backend/http/webapp/src/components/panels/Entities/Selector.vue b/platypush/backend/http/webapp/src/components/panels/Entities/Selector.vue
new file mode 100644
index 000000000..d8abd90b4
--- /dev/null
+++ b/platypush/backend/http/webapp/src/components/panels/Entities/Selector.vue
@@ -0,0 +1,233 @@
+<template>
+  <div class="entities-selectors-container">
+    <div class="selector">
+      <Dropdown title="Group by" icon-class="fas fa-eye" ref="groupingSelector">
+        <DropdownItem v-for="g in visibleGroupings" :key="g" :text="prettifyGroupingName(g)"
+          :item-class="{selected: value?.grouping === g}"
+          @click="onGroupingChanged(g)" />
+      </Dropdown>
+    </div>
+
+    <div class="selector" :class="{active: isGroupFilterActive}" v-if="value?.grouping">
+      <Dropdown title="Filter by" icon-class="fas fa-filter" ref="groupSelector"
+          keep-open-on-item-click>
+        <DropdownItem v-for="g in sortedGroups" :key="g" :text="g"
+          v-bind="iconForGroup(g)" :item-class="{selected: !!selectedGroups[g]}"
+          @click.stop="toggleGroup(g)" />
+      </Dropdown>
+    </div>
+
+    <div class="selector" v-if="Object.keys(entityGroups.id || {}).length">
+      <input ref="search" type="text" class="search-bar" placeholder="🔎" v-model="searchTerm">
+    </div>
+  </div>
+</template>
+
+<script>
+import Utils from '@/Utils'
+import Dropdown from "@/components/elements/Dropdown";
+import DropdownItem from "@/components/elements/DropdownItem";
+import meta from './meta.json'
+import pluginIcons from '@/assets/icons.json'
+
+export default {
+  name: "Selector",
+  emits: ['input'],
+  mixins: [Utils],
+  components: {Dropdown, DropdownItem},
+  props: {
+    entityGroups: {
+      type: Object,
+      required: true,
+    },
+
+    value: {
+      type: Object,
+      required: true,
+    },
+  },
+
+  data() {
+    return {
+      selectedGroups: {},
+      searchTerm: '',
+    }
+  },
+
+  computed: {
+    visibleGroupings() {
+      return Object.keys(this.entityGroups).filter(
+        (grouping) => grouping !== 'id'
+      )
+    },
+
+    sortedGroups() {
+      return Object.keys(this.entityGroups[this.value?.grouping] || {}).sort()
+    },
+
+    typesMeta() {
+      return meta
+    },
+
+    isGroupFilterActive() {
+      return Object.keys(this.selectedGroups).length !== this.sortedGroups.length
+    },
+
+    selectedEntities() {
+      return Object.values(this.entityGroups.id).filter((entity) => {
+        if (!this.selectedGroups[entity[this.value?.grouping]])
+         return false
+
+        if (this.searchTerm?.length) {
+          const searchTerm = this.searchTerm.toLowerCase()
+          return (
+            ((entity.name || '').toLowerCase()).indexOf(searchTerm) >= 0 ||
+            ((entity.plugin || '').toLowerCase()).indexOf(searchTerm) >= 0 ||
+            ((entity.external_id || '').toLowerCase()).indexOf(searchTerm) >= 0 ||
+            (entity.id || 0).toString() == searchTerm
+          )
+        }
+
+        return true
+      }).reduce((obj,  entity) => {
+        obj[entity.id] = entity
+        return obj
+      }, {})
+    },
+  },
+
+  methods: {
+    prettifyGroupingName(name) {
+      return name ? this.prettify(name) + 's' : ''
+    },
+
+    iconForGroup(group) {
+      if (this.value.grouping === 'plugin' && pluginIcons[group]) {
+        const icon = pluginIcons[group]
+        return {
+          'icon-class': icon['class']?.length || !icon.imgUrl?.length ?
+              icon['class'] : 'fas fa-gears',
+          'icon-url': icon.imgUrl,
+        }
+      }
+
+      return {}
+    },
+
+    synchronizeSelectedEntities() {
+      const value = {...this.value}
+      value.selectedEntities = this.selectedEntities
+      this.$emit('input', value)
+    },
+
+    updateSearchTerm() {
+      const value = {...this.value}
+      value.searchTerm = this.searchTerm
+      value.selectedEntities = this.selectedEntities
+      this.$emit('input', value)
+    },
+
+    refreshGroupFilter(reset) {
+      if (reset)
+        this.selectedGroups = Object.keys(
+          this.entityGroups[this.value?.grouping] || {}
+        ).reduce(
+          (obj, group) => {
+            obj[group] = true
+            return obj
+          }, {}
+        )
+      else {
+        for (const group of Object.keys(this.entityGroups[this.value?.grouping]))
+          if (this.selectedGroups[group] == null)
+            this.selectedGroups[group] = true
+      }
+
+      this.synchronizeSelectedEntities()
+    },
+
+    toggleGroup(group) {
+      this.selectedGroups[group] = !this.selectedGroups[group]
+      this.synchronizeSelectedEntities()
+    },
+
+    onGroupingChanged(grouping) {
+      if (!this.entityGroups[grouping] || grouping === this.value?.grouping)
+        return false
+
+      const value = {...this.value}
+      value.grouping = grouping
+      this.$emit('input', value)
+    },
+  },
+
+  mounted() {
+    this.refreshGroupFilter(true)
+    this.$watch(() => this.value?.grouping, () => { this.refreshGroupFilter(true) })
+    this.$watch(() => this.searchTerm, this.updateSearchTerm)
+    this.$watch(() => this.entityGroups, () => { this.refreshGroupFilter(false) })
+  },
+}
+</script>
+
+<style lang="scss" scoped>
+.entities-selectors-container {
+  width: 100%;
+  display: flex;
+  align-items: center;
+
+  .selector {
+    height: 100%;
+    display: inline-flex;
+
+    &.active {
+      :deep(.dropdown-container) {
+        button {
+          color: $default-hover-fg;
+        }
+      }
+    }
+  }
+
+  @media (max-width: 330px) {
+    .search-bar {
+      display: none;
+    }
+  }
+
+  :deep(.dropdown-container) {
+    height: 100%;
+    display: flex;
+
+    button {
+      height: 100%;
+      background: $default-bg-2;
+      border: 0;
+      padding: 0.5em;
+
+      &:hover {
+        color: $default-hover-fg;
+      }
+    }
+
+    .item {
+      padding: 0.5em 4em 0.5em 0.5em;
+      border: 0;
+      box-shadow: none;
+
+      .col-1.icon {
+        width: 1.5em;
+      }
+
+      &.selected {
+        font-weight: bold;
+        background: #ffffff00;
+      }
+
+      &:hover {
+        background: $hover-bg;
+      }
+    }
+  }
+}
+</style>
diff --git a/platypush/backend/http/webapp/src/components/panels/Entities/Switch.vue b/platypush/backend/http/webapp/src/components/panels/Entities/Switch.vue
new file mode 100644
index 000000000..899436572
--- /dev/null
+++ b/platypush/backend/http/webapp/src/components/panels/Entities/Switch.vue
@@ -0,0 +1,57 @@
+<template>
+  <div class="entity switch-container">
+    <div class="head">
+      <div class="col-1 icon">
+        <EntityIcon :icon="value.meta?.icon || {}"
+          :loading="loading" :error="error" />
+      </div>
+
+      <div class="col-9 label">
+        <div class="name" v-text="value.name" />
+      </div>
+
+      <div class="col-2 switch pull-right">
+        <ToggleSwitch :value="value.state" @input="toggle"
+          @click.stop :disabled="loading || value.is_read_only" />
+      </div>
+    </div>
+  </div>
+</template>
+
+<script>
+import ToggleSwitch from "@/components/elements/ToggleSwitch"
+import EntityIcon from "./EntityIcon"
+import EntityMixin from "./EntityMixin"
+
+export default {
+  name: 'Switch',
+  components: {ToggleSwitch, EntityIcon},
+  mixins: [EntityMixin],
+
+  methods: {
+    async toggle(event) {
+      event.stopPropagation()
+      this.$emit('loading', true)
+
+      try {
+        await this.request('entities.execute', {
+          id: this.value.id,
+          action: 'toggle',
+        })
+      } finally {
+        this.$emit('loading', false)
+      }
+    },
+  },
+}
+</script>
+
+<style lang="scss" scoped>
+@import "common";
+
+.switch-container {
+  .switch {
+    direction: rtl;
+  }
+}
+</style>
diff --git a/platypush/backend/http/webapp/src/components/panels/Entities/common.scss b/platypush/backend/http/webapp/src/components/panels/Entities/common.scss
new file mode 100644
index 000000000..289b1ec85
--- /dev/null
+++ b/platypush/backend/http/webapp/src/components/panels/Entities/common.scss
@@ -0,0 +1,55 @@
+@import "vars";
+
+.entity {
+  width: 100%;
+  display: flex;
+  flex-direction: column;
+
+  .head {
+    height: 100%;
+    display: flex;
+    align-items: center;
+    padding: 0.75em 0.25em;
+
+    .label {
+      margin-top: 0.25em;
+    }
+
+    &.expanded {
+      background: $selected-bg;
+      font-weight: bold;
+    }
+
+    .pull-right {
+      display: inline-flex;
+      align-items: center;
+      direction: rtl;
+      padding-right: 0.5em;
+
+      :deep(.power-switch) {
+        margin-top: 0.25em;
+      }
+    }
+  }
+
+  .body {
+    @extend .fade-in;
+    display: flex;
+    flex-direction: column;
+    padding: 0.5em;
+    background: linear-gradient(0deg, $default-bg-5, $default-bg-2);
+    border-top: 1px solid $border-color-1;
+    box-shadow: $border-shadow-bottom;
+  }
+
+  button {
+    height: 2em;
+    background: none;
+    border: none;
+    padding: 0 0 0 1em;
+
+    &:hover {
+      color: $default-hover-fg;
+    }
+  }
+}
diff --git a/platypush/backend/http/webapp/src/components/panels/Entities/meta.json b/platypush/backend/http/webapp/src/components/panels/Entities/meta.json
new file mode 100644
index 000000000..e4eafd8f0
--- /dev/null
+++ b/platypush/backend/http/webapp/src/components/panels/Entities/meta.json
@@ -0,0 +1,33 @@
+{
+  "entity": {
+    "name": "Entity",
+    "name_plural": "Entities",
+    "icon": {
+      "class": "fas fa-circle-question"
+    }
+  },
+
+  "device": {
+    "name": "Device",
+    "name_plural": "Devices",
+    "icon": {
+      "class": "fas fa-gear"
+    }
+  },
+
+  "switch": {
+    "name": "Switch",
+    "name_plural": "Switches",
+    "icon": {
+      "class": "fas fa-toggle-on"
+    }
+  },
+
+  "light": {
+    "name": "Light",
+    "name_plural": "Lights",
+    "icon": {
+      "class": "fas fa-lightbulb"
+    }
+  }
+}
diff --git a/platypush/backend/http/webapp/src/components/panels/Entities/vars.scss b/platypush/backend/http/webapp/src/components/panels/Entities/vars.scss
new file mode 100644
index 000000000..388ce2595
--- /dev/null
+++ b/platypush/backend/http/webapp/src/components/panels/Entities/vars.scss
@@ -0,0 +1,2 @@
+$main-margin: 1em;
+$selector-height: 2.5em;
diff --git a/platypush/backend/http/webapp/src/components/panels/Light/color.js b/platypush/backend/http/webapp/src/components/panels/Light/color.js
index f74464346..775df1b94 100644
--- a/platypush/backend/http/webapp/src/components/panels/Light/color.js
+++ b/platypush/backend/http/webapp/src/components/panels/Light/color.js
@@ -211,4 +211,21 @@ export class ColorConverter {
         console.debug('Could not determine color space')
         console.debug(color)
     }
+
+    hexToRgb(hex) {
+        return [
+          hex.slice(1, 3),
+          hex.slice(3, 5),
+          hex.slice(5, 7),
+        ].map(_ => parseInt(_, 16))
+    }
+
+    rgbToHex(rgb) {
+      return '#' + rgb.map((x) => {
+          let hex = x.toString(16)
+          if (hex.length < 2)
+            hex = '0' + hex
+          return hex
+        }).join('')
+    }
 }
diff --git a/platypush/backend/http/webapp/src/components/panels/Zwave/Zwave.vue b/platypush/backend/http/webapp/src/components/panels/Zwave/Zwave.vue
index e0466ba98..516e8687b 100644
--- a/platypush/backend/http/webapp/src/components/panels/Zwave/Zwave.vue
+++ b/platypush/backend/http/webapp/src/components/panels/Zwave/Zwave.vue
@@ -356,7 +356,7 @@ export default {
     async refreshStatus() {
       this.loading.status = true
       try {
-        this.status = await this.zrequest('status')
+        this.status = await this.zrequest('controller_status')
       } finally {
         this.loading.status = false
       }
diff --git a/platypush/backend/http/webapp/src/style/icons.scss b/platypush/backend/http/webapp/src/style/icons.scss
index fb599674e..a2cb7ef1a 100644
--- a/platypush/backend/http/webapp/src/style/icons.scss
+++ b/platypush/backend/http/webapp/src/style/icons.scss
@@ -1,3 +1,5 @@
+$icon-container-size: 3em;
+
 @mixin icon {
   content: ' ';
   background-size: 1em 1em;
diff --git a/platypush/backend/http/webapp/src/style/items.scss b/platypush/backend/http/webapp/src/style/items.scss
index ae1adf67c..ba88c4ae5 100644
--- a/platypush/backend/http/webapp/src/style/items.scss
+++ b/platypush/backend/http/webapp/src/style/items.scss
@@ -1,3 +1,5 @@
+$header-height: 3.5em;
+
 .item {
   display: flex;
   align-items: center;
@@ -78,3 +80,43 @@
     }
   }
 }
+
+:deep(.table-row) {
+  width: 100%;
+  display: flex;
+  flex-direction: column;
+  box-shadow: $row-shadow;
+
+  &:hover {
+    background: $hover-bg;
+  }
+
+  @include from($tablet) {
+    flex-direction: row;
+    align-items: center;
+  }
+
+  .title,
+  .value {
+    width: 100%;
+    display: flex;
+
+    @include from($tablet) {
+      display: inline-flex;
+    }
+  }
+
+  .title {
+    font-weight: bold;
+
+    @include from($tablet) {
+      width: 30%;
+    }
+  }
+
+  .value {
+    @include from($tablet) {
+      justify-content: right;
+    }
+  }
+}
diff --git a/platypush/backend/http/webapp/src/style/themes/light.scss b/platypush/backend/http/webapp/src/style/themes/light.scss
index c7f621765..c75b45050 100644
--- a/platypush/backend/http/webapp/src/style/themes/light.scss
+++ b/platypush/backend/http/webapp/src/style/themes/light.scss
@@ -6,10 +6,13 @@ $default-bg-4: #f1f3f2 !default;
 $default-bg-5: #edf0ee !default;
 $default-bg-6: #e4eae8 !default;
 $default-bg-7: #e4e4e4 !default;
+$error-fg: #ad1717 !default;
 
 $default-fg: black !default;
 $default-fg-2: #23513a !default;
 $default-fg-3: #195331b3 !default;
+$header-bg: linear-gradient(0deg, #c0e8e4, #e4f8f4) !default;
+$no-items-color: #555555;
 
 //// Notifications
 $notification-bg: rgba(185, 255, 193, 0.9) !default;
@@ -51,6 +54,8 @@ $border-shadow-bottom: 0 3px 2px -1px $default-shadow-color;
 $border-shadow-left: -2.5px 0 4px 0 $default-shadow-color;
 $border-shadow-right: 2.5px 0 4px 0 $default-shadow-color;
 $border-shadow-bottom-right: 2.5px 2.5px 3px 0 $default-shadow-color;
+$header-shadow: 0px 1px 3px 1px #bbb !default;
+$group-shadow: 3px -2px 6px 1px #98b0a0;
 
 //// Modals
 $modal-header-bg: #e0e0e0 !default;
@@ -141,5 +146,7 @@ $dropdown-shadow: 1px 1px 1px #bbb !default;
 //// Scrollbars
 $scrollbar-track-bg: $slider-bg !default;
 $scrollbar-track-shadow: inset 1px 0px 3px 0 $slider-track-shadow !default;
-$scrollbar-thumb-bg: #50ca80 !default;
+$scrollbar-thumb-bg: #a5a2a2 !default;
 
+//// Rows
+$row-shadow: 0 0 1px 0.5px #cfcfcf !default;
diff --git a/platypush/backend/http/webapp/src/utils/Text.vue b/platypush/backend/http/webapp/src/utils/Text.vue
new file mode 100644
index 000000000..26aee37a7
--- /dev/null
+++ b/platypush/backend/http/webapp/src/utils/Text.vue
@@ -0,0 +1,17 @@
+<script>
+export default {
+  name: "Text",
+  methods: {
+    capitalize(text) {
+      if (!text?.length)
+        return text
+
+      return text.charAt(0).toUpperCase() + text.slice(1)
+    },
+
+    prettify(text) {
+      return text.split('_').map((t) => this.capitalize(t)).join(' ')
+    },
+  },
+}
+</script>
diff --git a/platypush/backend/http/webapp/src/views/Panel.vue b/platypush/backend/http/webapp/src/views/Panel.vue
index e143b04a9..56a72e7ff 100644
--- a/platypush/backend/http/webapp/src/views/Panel.vue
+++ b/platypush/backend/http/webapp/src/views/Panel.vue
@@ -45,10 +45,7 @@ export default {
   methods: {
     initSelectedPanel() {
       const match = this.$route.hash.match('#?([a-zA-Z0-9.]+)[?]?(.*)')
-      if (!match)
-        return
-
-      const plugin = match[1]
+      const plugin = match ? match[1] : 'entities'
       if (plugin?.length)
         this.selectedPanel = plugin
     },
@@ -90,7 +87,7 @@ export default {
 
     initializeDefaultViews() {
       this.plugins.execute = {}
-      this.plugins.switches = {}
+      this.plugins.entities = {}
     },
   },
 
@@ -113,7 +110,7 @@ main {
   height: 100%;
   display: flex;
 
-  @media screen and (max-width: $tablet) {
+  @include until($tablet) {
     flex-direction: column;
   }
 
diff --git a/platypush/backend/light/hue/__init__.py b/platypush/backend/light/hue/__init__.py
index 95cbed6ff..686e1a51b 100644
--- a/platypush/backend/light/hue/__init__.py
+++ b/platypush/backend/light/hue/__init__.py
@@ -1,100 +1,24 @@
-from threading import Thread
+import warnings
 
 from platypush.backend import Backend
-from platypush.context import get_plugin
-from platypush.message.event.light import LightStatusChangeEvent
 
 
 class LightHueBackend(Backend):
     """
-    This backend will periodically check for the status of your configured
-    Philips Hue light devices and trigger events when the status of a device
-    (power, saturation, brightness or hue) changes.
+    **DEPRECATED**
 
-    Triggers:
-
-        * :class:`platypush.message.event.light.LightStatusChangeEvent` when the
-            status of a lightbulb changes
-
-    Requires:
-
-        * The :class:`platypush.plugins.light.hue.LightHuePlugin` plugin to be
-            active and configured.
+    The polling logic of this backend has been moved to the ``light.hue`` plugin itself.
     """
 
-    _DEFAULT_POLL_SECONDS = 10
-
-    def __init__(self, poll_seconds=_DEFAULT_POLL_SECONDS, *args, **kwargs):
-        """
-        :param poll_seconds: How often the backend will poll the Hue plugin for
-            status updates. Default: 10 seconds
-        :type poll_seconds: float
-        """
-
+    def __init__(self, *args, **kwargs):
         super().__init__(*args, **kwargs)
-        self.poll_seconds = poll_seconds
-
-    @staticmethod
-    def _get_lights():
-        plugin = get_plugin('light.hue')
-        if not plugin:
-            plugin = get_plugin('light.hue', reload=True)
-
-        return plugin.get_lights().output
-
-    def _listener(self):
-        def _thread():
-            lights = self._get_lights()
-
-            while not self.should_stop():
-                try:
-                    lights_new = self._get_lights()
-
-                    for light_id, light in lights_new.items():
-                        event_args = {}
-                        state = light.get('state')
-                        prev_state = lights.get(light_id, {}).get('state', {})
-
-                        if 'on' in state and state.get('on') != prev_state.get('on'):
-                            event_args['on'] = state.get('on')
-                        if 'bri' in state and state.get('bri') != prev_state.get('bri'):
-                            event_args['bri'] = state.get('bri')
-                        if 'sat' in state and state.get('sat') != prev_state.get('sat'):
-                            event_args['sat'] = state.get('sat')
-                        if 'hue' in state and state.get('hue') != prev_state.get('hue'):
-                            event_args['hue'] = state.get('hue')
-                        if 'ct' in state and state.get('ct') != prev_state.get('ct'):
-                            event_args['ct'] = state.get('ct')
-                        if 'xy' in state and state.get('xy') != prev_state.get('xy'):
-                            event_args['xy'] = state.get('xy')
-
-                        if event_args:
-                            event_args['plugin_name'] = 'light.hue'
-                            event_args['light_id'] = light_id
-                            event_args['light_name'] = light.get('name')
-                            self.bus.post(LightStatusChangeEvent(**event_args))
-
-                    lights = lights_new
-                except Exception as e:
-                    self.logger.exception(e)
-                finally:
-                    self.wait_stop(self.poll_seconds)
-
-        return _thread
+        warnings.warn(
+            'The light.hue backend is deprecated. All of its logic '
+            'has been moved to the light.hue plugin itself.'
+        )
 
     def run(self):
         super().run()
-        self.logger.info('Starting Hue lights backend')
-
-        while not self.should_stop():
-            try:
-                poll_thread = Thread(target=self._listener())
-                poll_thread.start()
-                poll_thread.join()
-            except Exception as e:
-                self.logger.exception(e)
-                self.wait_stop(self.poll_seconds)
-
         self.logger.info('Stopped Hue lights backend')
 
 
diff --git a/platypush/backend/light/hue/manifest.yaml b/platypush/backend/light/hue/manifest.yaml
index 5d5e4ed03..8103a7866 100644
--- a/platypush/backend/light/hue/manifest.yaml
+++ b/platypush/backend/light/hue/manifest.yaml
@@ -1,7 +1,5 @@
 manifest:
   events:
-    platypush.message.event.light.LightStatusChangeEvent: when thestatus of a lightbulb
-      changes
   install:
     pip: []
   package: platypush.backend.light.hue
diff --git a/platypush/backend/mail/__init__.py b/platypush/backend/mail/__init__.py
index 686075286..84f1f8ab6 100644
--- a/platypush/backend/mail/__init__.py
+++ b/platypush/backend/mail/__init__.py
@@ -8,15 +8,18 @@ from queue import Queue, Empty
 from threading import Thread, RLock
 from typing import List, Dict, Any, Optional, Tuple
 
-from sqlalchemy import create_engine, Column, Integer, String, DateTime
-import sqlalchemy.engine as engine
-from sqlalchemy.orm import sessionmaker, scoped_session
-from sqlalchemy.ext.declarative import declarative_base
+from sqlalchemy import engine, create_engine, Column, Integer, String, DateTime
+from sqlalchemy.orm import sessionmaker, scoped_session, declarative_base
 
 from platypush.backend import Backend
 from platypush.config import Config
 from platypush.context import get_plugin
-from platypush.message.event.mail import MailReceivedEvent, MailSeenEvent, MailFlaggedEvent, MailUnflaggedEvent
+from platypush.message.event.mail import (
+    MailReceivedEvent,
+    MailSeenEvent,
+    MailFlaggedEvent,
+    MailUnflaggedEvent,
+)
 from platypush.plugins.mail import MailInPlugin, Mail
 
 # <editor-fold desc="Database tables">
@@ -25,7 +28,8 @@ Session = scoped_session(sessionmaker())
 
 
 class MailboxStatus(Base):
-    """ Models the MailboxStatus table, containing information about the state of a monitored mailbox. """
+    """Models the MailboxStatus table, containing information about the state of a monitored mailbox."""
+
     __tablename__ = 'MailboxStatus'
 
     mailbox_id = Column(Integer, primary_key=True)
@@ -64,8 +68,13 @@ class MailBackend(Backend):
 
     """
 
-    def __init__(self, mailboxes: List[Dict[str, Any]], timeout: Optional[int] = 60, poll_seconds: Optional[int] = 60,
-                 **kwargs):
+    def __init__(
+        self,
+        mailboxes: List[Dict[str, Any]],
+        timeout: Optional[int] = 60,
+        poll_seconds: Optional[int] = 60,
+        **kwargs
+    ):
         """
         :param mailboxes: List of mailboxes to be monitored. Each mailbox entry contains a ``plugin`` attribute to
             identify the :class:`platypush.plugins.mail.MailInPlugin` plugin that will be used (e.g. ``mail.imap``)
@@ -128,9 +137,13 @@ class MailBackend(Backend):
 
         # Parse mailboxes
         for i, mbox in enumerate(mailboxes):
-            assert 'plugin' in mbox, 'No plugin attribute specified for mailbox n.{}'.format(i)
+            assert (
+                'plugin' in mbox
+            ), 'No plugin attribute specified for mailbox n.{}'.format(i)
             plugin = get_plugin(mbox.pop('plugin'))
-            assert isinstance(plugin, MailInPlugin), '{} is not a MailInPlugin'.format(plugin)
+            assert isinstance(plugin, MailInPlugin), '{} is not a MailInPlugin'.format(
+                plugin
+            )
             name = mbox.pop('name') if 'name' in mbox else 'Mailbox #{}'.format(i + 1)
             self.mailboxes.append(Mailbox(plugin=plugin, name=name, args=mbox))
 
@@ -144,7 +157,10 @@ class MailBackend(Backend):
 
     # <editor-fold desc="Database methods">
     def _db_get_engine(self) -> engine.Engine:
-        return create_engine('sqlite:///{}'.format(self.dbfile), connect_args={'check_same_thread': False})
+        return create_engine(
+            'sqlite:///{}'.format(self.dbfile),
+            connect_args={'check_same_thread': False},
+        )
 
     def _db_load_mailboxes_status(self) -> None:
         mailbox_ids = list(range(len(self.mailboxes)))
@@ -153,12 +169,18 @@ class MailBackend(Backend):
             session = Session()
             records = {
                 record.mailbox_id: record
-                for record in session.query(MailboxStatus).filter(MailboxStatus.mailbox_id.in_(mailbox_ids)).all()
+                for record in session.query(MailboxStatus)
+                .filter(MailboxStatus.mailbox_id.in_(mailbox_ids))
+                .all()
             }
 
-            for mbox_id, mbox in enumerate(self.mailboxes):
+            for mbox_id, _ in enumerate(self.mailboxes):
                 if mbox_id not in records:
-                    record = MailboxStatus(mailbox_id=mbox_id, unseen_message_ids='[]', flagged_message_ids='[]')
+                    record = MailboxStatus(
+                        mailbox_id=mbox_id,
+                        unseen_message_ids='[]',
+                        flagged_message_ids='[]',
+                    )
                     session.add(record)
                 else:
                     record = records[mbox_id]
@@ -170,19 +192,25 @@ class MailBackend(Backend):
 
             session.commit()
 
-    def _db_get_mailbox_status(self, mailbox_ids: List[int]) -> Dict[int, MailboxStatus]:
+    def _db_get_mailbox_status(
+        self, mailbox_ids: List[int]
+    ) -> Dict[int, MailboxStatus]:
         with self._db_lock:
             session = Session()
             return {
                 record.mailbox_id: record
-                for record in session.query(MailboxStatus).filter(MailboxStatus.mailbox_id.in_(mailbox_ids)).all()
+                for record in session.query(MailboxStatus)
+                .filter(MailboxStatus.mailbox_id.in_(mailbox_ids))
+                .all()
             }
 
     # </editor-fold>
 
     # <editor-fold desc="Parse unread messages logic">
     @staticmethod
-    def _check_thread(unread_queue: Queue, flagged_queue: Queue, plugin: MailInPlugin, **args):
+    def _check_thread(
+        unread_queue: Queue, flagged_queue: Queue, plugin: MailInPlugin, **args
+    ):
         def thread():
             # noinspection PyUnresolvedReferences
             unread = plugin.search_unseen_messages(**args).output
@@ -194,8 +222,9 @@ class MailBackend(Backend):
 
         return thread
 
-    def _get_unread_seen_msgs(self, mailbox_idx: int, unread_msgs: Dict[int, Mail]) \
-            -> Tuple[Dict[int, Mail], Dict[int, Mail]]:
+    def _get_unread_seen_msgs(
+        self, mailbox_idx: int, unread_msgs: Dict[int, Mail]
+    ) -> Tuple[Dict[int, Mail], Dict[int, Mail]]:
         prev_unread_msgs = self._unread_msgs[mailbox_idx]
 
         return {
@@ -208,35 +237,51 @@ class MailBackend(Backend):
             if msg_id not in unread_msgs
         }
 
-    def _get_flagged_unflagged_msgs(self, mailbox_idx: int, flagged_msgs: Dict[int, Mail]) \
-            -> Tuple[Dict[int, Mail], Dict[int, Mail]]:
+    def _get_flagged_unflagged_msgs(
+        self, mailbox_idx: int, flagged_msgs: Dict[int, Mail]
+    ) -> Tuple[Dict[int, Mail], Dict[int, Mail]]:
         prev_flagged_msgs = self._flagged_msgs[mailbox_idx]
 
         return {
-                   msg_id: flagged_msgs[msg_id]
-                   for msg_id in flagged_msgs
-                   if msg_id not in prev_flagged_msgs
-               }, {
-                   msg_id: prev_flagged_msgs[msg_id]
-                   for msg_id in prev_flagged_msgs
-                   if msg_id not in flagged_msgs
-               }
+            msg_id: flagged_msgs[msg_id]
+            for msg_id in flagged_msgs
+            if msg_id not in prev_flagged_msgs
+        }, {
+            msg_id: prev_flagged_msgs[msg_id]
+            for msg_id in prev_flagged_msgs
+            if msg_id not in flagged_msgs
+        }
 
-    def _process_msg_events(self, mailbox_id: int, unread: List[Mail], seen: List[Mail],
-                            flagged: List[Mail], unflagged: List[Mail], last_checked_date: Optional[datetime] = None):
+    def _process_msg_events(
+        self,
+        mailbox_id: int,
+        unread: List[Mail],
+        seen: List[Mail],
+        flagged: List[Mail],
+        unflagged: List[Mail],
+        last_checked_date: Optional[datetime] = None,
+    ):
         for msg in unread:
             if msg.date and last_checked_date and msg.date < last_checked_date:
                 continue
-            self.bus.post(MailReceivedEvent(mailbox=self.mailboxes[mailbox_id].name, message=msg))
+            self.bus.post(
+                MailReceivedEvent(mailbox=self.mailboxes[mailbox_id].name, message=msg)
+            )
 
         for msg in seen:
-            self.bus.post(MailSeenEvent(mailbox=self.mailboxes[mailbox_id].name, message=msg))
+            self.bus.post(
+                MailSeenEvent(mailbox=self.mailboxes[mailbox_id].name, message=msg)
+            )
 
         for msg in flagged:
-            self.bus.post(MailFlaggedEvent(mailbox=self.mailboxes[mailbox_id].name, message=msg))
+            self.bus.post(
+                MailFlaggedEvent(mailbox=self.mailboxes[mailbox_id].name, message=msg)
+            )
 
         for msg in unflagged:
-            self.bus.post(MailUnflaggedEvent(mailbox=self.mailboxes[mailbox_id].name, message=msg))
+            self.bus.post(
+                MailUnflaggedEvent(mailbox=self.mailboxes[mailbox_id].name, message=msg)
+            )
 
     def _check_mailboxes(self) -> List[Tuple[Dict[int, Mail], Dict[int, Mail]]]:
         workers = []
@@ -245,8 +290,14 @@ class MailBackend(Backend):
 
         for mbox in self.mailboxes:
             unread_queue, flagged_queue = [Queue()] * 2
-            worker = Thread(target=self._check_thread(unread_queue=unread_queue, flagged_queue=flagged_queue,
-                                                      plugin=mbox.plugin, **mbox.args))
+            worker = Thread(
+                target=self._check_thread(
+                    unread_queue=unread_queue,
+                    flagged_queue=flagged_queue,
+                    plugin=mbox.plugin,
+                    **mbox.args
+                )
+            )
             worker.start()
             workers.append(worker)
             queues.append((unread_queue, flagged_queue))
@@ -260,7 +311,11 @@ class MailBackend(Backend):
                 flagged = flagged_queue.get(timeout=self.timeout)
                 results.append((unread, flagged))
             except Empty:
-                self.logger.warning('Checks on mailbox #{} timed out after {} seconds'.format(i + 1, self.timeout))
+                self.logger.warning(
+                    'Checks on mailbox #{} timed out after {} seconds'.format(
+                        i + 1, self.timeout
+                    )
+                )
                 continue
 
         return results
@@ -276,16 +331,25 @@ class MailBackend(Backend):
         for i, (unread, flagged) in enumerate(results):
             unread_msgs, seen_msgs = self._get_unread_seen_msgs(i, unread)
             flagged_msgs, unflagged_msgs = self._get_flagged_unflagged_msgs(i, flagged)
-            self._process_msg_events(i, unread=list(unread_msgs.values()), seen=list(seen_msgs.values()),
-                                     flagged=list(flagged_msgs.values()), unflagged=list(unflagged_msgs.values()),
-                                     last_checked_date=mailbox_statuses[i].last_checked_date)
+            self._process_msg_events(
+                i,
+                unread=list(unread_msgs.values()),
+                seen=list(seen_msgs.values()),
+                flagged=list(flagged_msgs.values()),
+                unflagged=list(unflagged_msgs.values()),
+                last_checked_date=mailbox_statuses[i].last_checked_date,
+            )
 
             self._unread_msgs[i] = unread
             self._flagged_msgs[i] = flagged
-            records.append(MailboxStatus(mailbox_id=i,
-                                         unseen_message_ids=json.dumps([msg_id for msg_id in unread.keys()]),
-                                         flagged_message_ids=json.dumps([msg_id for msg_id in flagged.keys()]),
-                                         last_checked_date=datetime.now()))
+            records.append(
+                MailboxStatus(
+                    mailbox_id=i,
+                    unseen_message_ids=json.dumps(list(unread.keys())),
+                    flagged_message_ids=json.dumps(list(flagged.keys())),
+                    last_checked_date=datetime.now(),
+                )
+            )
 
         with self._db_lock:
             session = Session()
diff --git a/platypush/backend/zigbee/mqtt/__init__.py b/platypush/backend/zigbee/mqtt/__init__.py
index fd805596e..7fca6a676 100644
--- a/platypush/backend/zigbee/mqtt/__init__.py
+++ b/platypush/backend/zigbee/mqtt/__init__.py
@@ -1,21 +1,38 @@
+import contextlib
 import json
-from typing import Optional
+from typing import Optional, Mapping
 
 from platypush.backend.mqtt import MqttBackend
 from platypush.context import get_plugin
-from platypush.message.event.zigbee.mqtt import ZigbeeMqttOnlineEvent, ZigbeeMqttOfflineEvent, \
-        ZigbeeMqttDevicePropertySetEvent, ZigbeeMqttDevicePairingEvent, ZigbeeMqttDeviceConnectedEvent, \
-        ZigbeeMqttDeviceBannedEvent, ZigbeeMqttDeviceRemovedEvent, ZigbeeMqttDeviceRemovedFailedEvent, \
-        ZigbeeMqttDeviceWhitelistedEvent, ZigbeeMqttDeviceRenamedEvent, ZigbeeMqttDeviceBindEvent, \
-        ZigbeeMqttDeviceUnbindEvent, ZigbeeMqttGroupAddedEvent, ZigbeeMqttGroupAddedFailedEvent, \
-        ZigbeeMqttGroupRemovedEvent, ZigbeeMqttGroupRemovedFailedEvent, ZigbeeMqttGroupRemoveAllEvent, \
-        ZigbeeMqttGroupRemoveAllFailedEvent, ZigbeeMqttErrorEvent
+from platypush.message.event.zigbee.mqtt import (
+    ZigbeeMqttOnlineEvent,
+    ZigbeeMqttOfflineEvent,
+    ZigbeeMqttDevicePropertySetEvent,
+    ZigbeeMqttDevicePairingEvent,
+    ZigbeeMqttDeviceConnectedEvent,
+    ZigbeeMqttDeviceBannedEvent,
+    ZigbeeMqttDeviceRemovedEvent,
+    ZigbeeMqttDeviceRemovedFailedEvent,
+    ZigbeeMqttDeviceWhitelistedEvent,
+    ZigbeeMqttDeviceRenamedEvent,
+    ZigbeeMqttDeviceBindEvent,
+    ZigbeeMqttDeviceUnbindEvent,
+    ZigbeeMqttGroupAddedEvent,
+    ZigbeeMqttGroupAddedFailedEvent,
+    ZigbeeMqttGroupRemovedEvent,
+    ZigbeeMqttGroupRemovedFailedEvent,
+    ZigbeeMqttGroupRemoveAllEvent,
+    ZigbeeMqttGroupRemoveAllFailedEvent,
+    ZigbeeMqttErrorEvent,
+)
 
 
 class ZigbeeMqttBackend(MqttBackend):
     """
     Listen for events on a zigbee2mqtt service.
 
+    For historical reasons, this backend should be enabled together with the `zigbee.mqtt` plugin.
+
     Triggers:
 
         * :class:`platypush.message.event.zigbee.mqtt.ZigbeeMqttOnlineEvent` when the service comes online.
@@ -59,11 +76,22 @@ class ZigbeeMqttBackend(MqttBackend):
 
     """
 
-    def __init__(self, host: Optional[str] = None, port: Optional[int] = None, base_topic='zigbee2mqtt',
-                 tls_cafile: Optional[str] = None, tls_certfile: Optional[str] = None,
-                 tls_keyfile: Optional[str] = None, tls_version: Optional[str] = None,
-                 tls_ciphers: Optional[str] = None, username: Optional[str] = None,
-                 password: Optional[str] = None, client_id: Optional[str] = None, *args, **kwargs):
+    def __init__(
+        self,
+        host: Optional[str] = None,
+        port: Optional[int] = None,
+        base_topic='zigbee2mqtt',
+        tls_cafile: Optional[str] = None,
+        tls_certfile: Optional[str] = None,
+        tls_keyfile: Optional[str] = None,
+        tls_version: Optional[str] = None,
+        tls_ciphers: Optional[str] = None,
+        username: Optional[str] = None,
+        password: Optional[str] = None,
+        client_id: Optional[str] = None,
+        *args,
+        **kwargs
+    ):
         """
         :param host: MQTT broker host (default: host configured on the ``zigbee.mqtt`` plugin).
         :param port: MQTT broker port (default: 1883).
@@ -87,6 +115,7 @@ class ZigbeeMqttBackend(MqttBackend):
         plugin = get_plugin('zigbee.mqtt')
         self.base_topic = base_topic or plugin.base_topic
         self._devices = {}
+        self._devices_info = {}
         self._groups = {}
         self._last_state = None
         self.server_info = {
@@ -106,17 +135,28 @@ class ZigbeeMqttBackend(MqttBackend):
             **self.server_info,
         }
 
-        listeners = [{
-            **self.server_info,
-            'topics': [
-                self.base_topic + '/' + topic
-                for topic in ['bridge/state', 'bridge/log', 'bridge/logging', 'bridge/devices', 'bridge/groups']
-            ],
-        }]
+        listeners = [
+            {
+                **self.server_info,
+                'topics': [
+                    self.base_topic + '/' + topic
+                    for topic in [
+                        'bridge/state',
+                        'bridge/log',
+                        'bridge/logging',
+                        'bridge/devices',
+                        'bridge/groups',
+                    ]
+                ],
+            }
+        ]
 
         super().__init__(
-            *args, subscribe_default_topic=False,
-            listeners=listeners, client_id=client_id, **kwargs
+            *args,
+            subscribe_default_topic=False,
+            listeners=listeners,
+            client_id=client_id,
+            **kwargs
         )
 
         if not client_id:
@@ -146,7 +186,7 @@ class ZigbeeMqttBackend(MqttBackend):
 
         if msg_type == 'devices':
             devices = {}
-            for dev in (text or []):
+            for dev in text or []:
                 devices[dev['friendly_name']] = dev
                 client.subscribe(self.base_topic + '/' + dev['friendly_name'])
         elif msg_type == 'pairing':
@@ -155,7 +195,9 @@ class ZigbeeMqttBackend(MqttBackend):
             self.bus.post(ZigbeeMqttDeviceBannedEvent(device=text, **args))
         elif msg_type in ['device_removed_failed', 'device_force_removed_failed']:
             force = msg_type == 'device_force_removed_failed'
-            self.bus.post(ZigbeeMqttDeviceRemovedFailedEvent(device=text, force=force, **args))
+            self.bus.post(
+                ZigbeeMqttDeviceRemovedFailedEvent(device=text, force=force, **args)
+            )
         elif msg_type == 'device_whitelisted':
             self.bus.post(ZigbeeMqttDeviceWhitelistedEvent(device=text, **args))
         elif msg_type == 'device_renamed':
@@ -181,7 +223,11 @@ class ZigbeeMqttBackend(MqttBackend):
             self.bus.post(ZigbeeMqttErrorEvent(error=text, **args))
         elif msg.get('level') in ['warning', 'error']:
             log = getattr(self.logger, msg['level'])
-            log('zigbee2mqtt {}: {}'.format(msg['level'], text or msg.get('error', msg.get('warning'))))
+            log(
+                'zigbee2mqtt {}: {}'.format(
+                    msg['level'], text or msg.get('error', msg.get('warning'))
+                )
+            )
 
     def _process_devices(self, client, msg):
         devices_info = {
@@ -191,10 +237,9 @@ class ZigbeeMqttBackend(MqttBackend):
 
         # noinspection PyProtectedMember
         event_args = {'host': client._host, 'port': client._port}
-        client.subscribe(*[
-            self.base_topic + '/' + device
-            for device in devices_info.keys()
-        ])
+        client.subscribe(
+            *[self.base_topic + '/' + device for device in devices_info.keys()]
+        )
 
         for name, device in devices_info.items():
             if name not in self._devices:
@@ -203,7 +248,7 @@ class ZigbeeMqttBackend(MqttBackend):
             exposes = (device.get('definition', {}) or {}).get('exposes', [])
             client.publish(
                 self.base_topic + '/' + name + '/get',
-                json.dumps(get_plugin('zigbee.mqtt').build_device_get_request(exposes))
+                json.dumps(self._plugin.build_device_get_request(exposes)),
             )
 
         devices_copy = [*self._devices.keys()]
@@ -213,13 +258,13 @@ class ZigbeeMqttBackend(MqttBackend):
                 del self._devices[name]
 
         self._devices = {device: {} for device in devices_info.keys()}
+        self._devices_info = devices_info
 
     def _process_groups(self, client, msg):
         # noinspection PyProtectedMember
         event_args = {'host': client._host, 'port': client._port}
         groups_info = {
-            group.get('friendly_name', group.get('id')): group
-            for group in msg
+            group.get('friendly_name', group.get('id')): group for group in msg
         }
 
         for name in groups_info.keys():
@@ -236,15 +281,13 @@ class ZigbeeMqttBackend(MqttBackend):
 
     def on_mqtt_message(self):
         def handler(client, _, msg):
-            topic = msg.topic[len(self.base_topic)+1:]
+            topic = msg.topic[len(self.base_topic) + 1 :]
             data = msg.payload.decode()
             if not data:
                 return
 
-            try:
+            with contextlib.suppress(ValueError, TypeError):
                 data = json.loads(data)
-            except (ValueError, TypeError):
-                pass
 
             if topic == 'bridge/state':
                 self._process_state_message(client, data)
@@ -260,17 +303,45 @@ class ZigbeeMqttBackend(MqttBackend):
                     return
 
                 name = suffix
-                changed_props = {k: v for k, v in data.items() if v != self._devices[name].get(k)}
+                changed_props = {
+                    k: v for k, v in data.items() if v != self._devices[name].get(k)
+                }
 
                 if changed_props:
-                    # noinspection PyProtectedMember
-                    self.bus.post(ZigbeeMqttDevicePropertySetEvent(host=client._host, port=client._port,
-                                                                   device=name, properties=changed_props))
+                    self._process_property_update(name, data)
+                    self.bus.post(
+                        ZigbeeMqttDevicePropertySetEvent(
+                            host=client._host,
+                            port=client._port,
+                            device=name,
+                            properties=changed_props,
+                        )
+                    )
 
                 self._devices[name].update(data)
 
         return handler
 
+    @property
+    def _plugin(self):
+        plugin = get_plugin('zigbee.mqtt')
+        assert plugin, 'The zigbee.mqtt plugin is not configured'
+        return plugin
+
+    def _process_property_update(self, device_name: str, properties: Mapping):
+        device_info = self._devices_info.get(device_name)
+        if not (device_info and properties):
+            return
+
+        self._plugin.publish_entities(
+            [
+                {
+                    **device_info,
+                    'state': properties,
+                }
+            ]
+        )
+
     def run(self):
         super().run()
 
diff --git a/platypush/backend/zwave/mqtt/__init__.py b/platypush/backend/zwave/mqtt/__init__.py
index 7d38c8e18..5a5d426c3 100644
--- a/platypush/backend/zwave/mqtt/__init__.py
+++ b/platypush/backend/zwave/mqtt/__init__.py
@@ -1,3 +1,4 @@
+import contextlib
 import json
 from queue import Queue, Empty
 from typing import Optional, Type
@@ -5,14 +6,24 @@ from typing import Optional, Type
 from platypush.backend.mqtt import MqttBackend
 from platypush.context import get_plugin
 
-from platypush.message.event.zwave import ZwaveEvent, ZwaveNodeAddedEvent, ZwaveValueChangedEvent, \
-    ZwaveNodeRemovedEvent, ZwaveNodeRenamedEvent, ZwaveNodeReadyEvent, ZwaveNodeEvent, ZwaveNodeAsleepEvent, \
-    ZwaveNodeAwakeEvent
+from platypush.message.event.zwave import (
+    ZwaveEvent,
+    ZwaveNodeAddedEvent,
+    ZwaveValueChangedEvent,
+    ZwaveNodeRemovedEvent,
+    ZwaveNodeRenamedEvent,
+    ZwaveNodeReadyEvent,
+    ZwaveNodeEvent,
+    ZwaveNodeAsleepEvent,
+    ZwaveNodeAwakeEvent,
+)
 
 
 class ZwaveMqttBackend(MqttBackend):
     """
     Listen for events on a `zwavejs2mqtt <https://github.com/zwave-js/zwavejs2mqtt>`_ service.
+    For historical reasons, this should be enabled together with the ``zwave.mqtt`` plugin,
+    even though the actual configuration is only specified on the plugin.
 
     Triggers:
 
@@ -41,6 +52,7 @@ class ZwaveMqttBackend(MqttBackend):
         """
 
         from platypush.plugins.zwave.mqtt import ZwaveMqttPlugin
+
         self.plugin: ZwaveMqttPlugin = get_plugin('zwave.mqtt')
         assert self.plugin, 'The zwave.mqtt plugin is not configured'
 
@@ -61,27 +73,48 @@ class ZwaveMqttBackend(MqttBackend):
             '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']
-            ],
-        }]
+        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, client_id=client_id, **kwargs)
+        super().__init__(
+            *args,
+            subscribe_default_topic=False,
+            listeners=listeners,
+            client_id=client_id,
+            **kwargs,
+        )
         if not client_id:
             self.client_id += '-zwavejs-mqtt'
 
-    def _dispatch_event(self, event_type: Type[ZwaveEvent], node: Optional[dict] = None, value: Optional[dict] = None,
-                        **kwargs):
+    def _dispatch_event(
+        self,
+        event_type: Type[ZwaveEvent],
+        node: Optional[dict] = None,
+        value: Optional[dict] = None,
+        **kwargs,
+    ):
         if value and 'id' not in value:
             value_id = f"{value['commandClass']}-{value.get('endpoint', 0)}-{value['property']}"
             if 'propertyKey' in value:
                 value_id += '-' + str(value['propertyKey'])
 
             if value_id not in node.get('values', {}):
-                self.logger.warning(f'value_id {value_id} not found on node {node["id"]}')
+                self.logger.warning(
+                    f'value_id {value_id} not found on node {node["id"]}'
+                )
                 return
 
             value = node['values'][value_id]
@@ -107,41 +140,47 @@ class ZwaveMqttBackend(MqttBackend):
         evt = event_type(**kwargs)
         self._events_queue.put(evt)
 
-        # zwavejs2mqtt currently treats some values (e.g. binary switches) in an inconsistent way,
-        # using two values - a read-only value called currentValue that gets updated on the
-        # node_value_updated topic, and a writable value called targetValue that doesn't get updated
-        # (see https://github.com/zwave-js/zwavejs2mqtt/blob/4a6a5c5f1274763fd3aced4cae2c72ea060716b5/docs/guide/migrating.md).
-        # To properly manage updates on writable values, propagate an event for both.
-        if event_type == ZwaveValueChangedEvent and kwargs.get('value', {}).get('property_id') == 'currentValue':
-            value = kwargs['value'].copy()
-            target_value_id = f'{kwargs["node"]["node_id"]}-{value["command_class"]}-{value.get("endpoint", 0)}' \
-                              f'-targetValue'
-            kwargs['value'] = kwargs['node'].get('values', {}).get(target_value_id)
+        if event_type == ZwaveValueChangedEvent:
+            # zwavejs2mqtt currently treats some values (e.g. binary switches) in an inconsistent way,
+            # using two values - a read-only value called currentValue that gets updated on the
+            # node_value_updated topic, and a writable value called targetValue that doesn't get updated
+            # (see https://github.com/zwave-js/zwavejs2mqtt/blob/4a6a5c5f1274763fd3aced4cae2c72ea060716b5 \
+            # /docs/guide/migrating.md).
+            # To properly manage updates on writable values, propagate an event for both.
+            if kwargs.get('value', {}).get('property_id') == 'currentValue':
+                value = kwargs['value'].copy()
+                target_value_id = (
+                    f'{kwargs["node"]["node_id"]}-{value["command_class"]}-{value.get("endpoint", 0)}'
+                    f'-targetValue'
+                )
+                kwargs['value'] = kwargs['node'].get('values', {}).get(target_value_id)
 
-            if kwargs['value']:
-                kwargs['value']['data'] = value['data']
-                kwargs['node']['values'][target_value_id] = kwargs['value']
-                evt = event_type(**kwargs)
-                self._events_queue.put(evt)
+                if kwargs['value']:
+                    kwargs['value']['data'] = value['data']
+                    kwargs['node']['values'][target_value_id] = kwargs['value']
+                    evt = event_type(**kwargs)
+                    self._events_queue.put(evt)
+
+            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()
+            topic = msg.topic[len(self.events_topic) + 1 :].split('/').pop()
             data = msg.payload.decode()
             if not data:
                 return
 
-            try:
+            with contextlib.suppress(ValueError, TypeError):
                 data = json.loads(data)['data']
-            except (ValueError, TypeError):
-                pass
 
             try:
                 if topic == 'node_value_updated':
-                    self._dispatch_event(ZwaveValueChangedEvent, node=data[0], value=data[1])
+                    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':
diff --git a/platypush/entities/__init__.py b/platypush/entities/__init__.py
new file mode 100644
index 000000000..361d67e5e
--- /dev/null
+++ b/platypush/entities/__init__.py
@@ -0,0 +1,38 @@
+import warnings
+from typing import Collection, Optional
+
+from ._base import Entity, get_entities_registry
+from ._engine import EntitiesEngine
+from ._registry import manages, register_entity_plugin, get_plugin_entity_registry
+
+_engine: Optional[EntitiesEngine] = None
+
+
+def init_entities_engine() -> EntitiesEngine:
+    from ._base import init_entities_db
+
+    global _engine
+    init_entities_db()
+    _engine = EntitiesEngine()
+    _engine.start()
+    return _engine
+
+
+def publish_entities(entities: Collection[Entity]):
+    if not _engine:
+        warnings.warn('No entities engine registered')
+        return
+
+    _engine.post(*entities)
+
+
+__all__ = (
+    'Entity',
+    'EntitiesEngine',
+    'init_entities_engine',
+    'publish_entities',
+    'register_entity_plugin',
+    'get_plugin_entity_registry',
+    'get_entities_registry',
+    'manages',
+)
diff --git a/platypush/entities/_base.py b/platypush/entities/_base.py
new file mode 100644
index 000000000..ea6da9164
--- /dev/null
+++ b/platypush/entities/_base.py
@@ -0,0 +1,131 @@
+import inspect
+import pathlib
+from datetime import datetime
+from typing import Mapping, Type, Tuple, Any
+
+import pkgutil
+from sqlalchemy import (
+    Boolean,
+    Column,
+    Index,
+    Integer,
+    String,
+    DateTime,
+    JSON,
+    UniqueConstraint,
+    inspect as schema_inspect,
+)
+from sqlalchemy.orm import declarative_base, ColumnProperty
+
+from platypush.message import JSONAble
+
+Base = declarative_base()
+entities_registry: Mapping[Type['Entity'], Mapping] = {}
+
+
+class Entity(Base):
+    """
+    Model for a general-purpose platform entity.
+    """
+
+    __tablename__ = 'entity'
+
+    id = Column(Integer, autoincrement=True, primary_key=True)
+    external_id = Column(String, nullable=True)
+    name = Column(String, nullable=False, index=True)
+    description = Column(String)
+    type = Column(String, nullable=False, index=True)
+    plugin = Column(String, nullable=False)
+    data = Column(JSON, default=dict)
+    meta = Column(JSON, default=dict)
+    is_read_only = Column(Boolean, default=False)
+    is_write_only = Column(Boolean, default=False)
+    created_at = Column(
+        DateTime(timezone=False), default=datetime.utcnow(), nullable=False
+    )
+    updated_at = Column(
+        DateTime(timezone=False), default=datetime.utcnow(), onupdate=datetime.utcnow()
+    )
+
+    UniqueConstraint(external_id, plugin)
+
+    __table_args__ = (Index(name, plugin), Index(name, type, plugin))
+
+    __mapper_args__ = {
+        'polymorphic_identity': __tablename__,
+        'polymorphic_on': type,
+    }
+
+    @classmethod
+    @property
+    def columns(cls) -> Tuple[ColumnProperty]:
+        inspector = schema_inspect(cls)
+        return tuple(inspector.mapper.column_attrs)
+
+    def _serialize_value(self, col: ColumnProperty) -> Any:
+        val = getattr(self, col.key)
+        if isinstance(val, datetime):
+            # All entity timestamps are in UTC
+            val = val.isoformat() + '+00:00'
+
+        return val
+
+    def to_json(self) -> dict:
+        return {col.key: self._serialize_value(col) for col in self.columns}
+
+    def get_plugin(self):
+        from platypush.context import get_plugin
+
+        plugin = get_plugin(self.plugin)
+        assert plugin, f'No such plugin: {plugin}'
+        return plugin
+
+    def run(self, action: str, *args, **kwargs):
+        plugin = self.get_plugin()
+        method = getattr(plugin, action, None)
+        assert method, f'No such action: {self.plugin}.{action}'
+        return method(self.external_id or self.name, *args, **kwargs)
+
+
+# Inject the JSONAble mixin (Python goes nuts if done through
+# standard multiple inheritance with an SQLAlchemy ORM class)
+Entity.__bases__ = Entity.__bases__ + (JSONAble,)
+
+
+def _discover_entity_types():
+    from platypush.context import get_plugin
+
+    logger = get_plugin('logger')
+    assert logger
+
+    for loader, modname, _ in pkgutil.walk_packages(
+        path=[str(pathlib.Path(__file__).parent.absolute())],
+        prefix=__package__ + '.',
+        onerror=lambda _: None,
+    ):
+        try:
+            mod_loader = loader.find_module(modname)  # type: ignore
+            assert mod_loader
+            module = mod_loader.load_module()  # type: ignore
+        except Exception as e:
+            logger.warning(f'Could not import module {modname}')
+            logger.exception(e)
+            continue
+
+        for _, obj in inspect.getmembers(module):
+            if inspect.isclass(obj) and issubclass(obj, Entity):
+                entities_registry[obj] = {}
+
+
+def get_entities_registry():
+    return entities_registry.copy()
+
+
+def init_entities_db():
+    from platypush.context import get_plugin
+
+    _discover_entity_types()
+    db = get_plugin('db')
+    assert db
+    engine = db.get_engine()
+    db.create_all(engine, Base)
diff --git a/platypush/entities/_engine.py b/platypush/entities/_engine.py
new file mode 100644
index 000000000..fef07a054
--- /dev/null
+++ b/platypush/entities/_engine.py
@@ -0,0 +1,269 @@
+import json
+from logging import getLogger
+from queue import Queue, Empty
+from threading import Thread, Event, RLock
+from time import time
+from typing import Iterable, List, Optional
+
+from sqlalchemy import and_, or_
+from sqlalchemy.orm import Session, make_transient
+
+from platypush.context import get_bus
+from platypush.message.event.entities import EntityUpdateEvent
+
+from ._base import Entity
+
+
+class EntitiesEngine(Thread):
+    # Processing queue timeout in seconds
+    _queue_timeout = 2.0
+
+    def __init__(self):
+        obj_name = self.__class__.__name__
+        super().__init__(name=obj_name)
+        self.logger = getLogger(name=obj_name)
+        self._queue = Queue()
+        self._should_stop = Event()
+        self._entities_awaiting_flush = set()
+        self._entities_cache_lock = RLock()
+        self._entities_cache = {
+            'by_id': {},
+            'by_external_id_and_plugin': {},
+            'by_name_and_plugin': {},
+        }
+
+    def _get_db(self):
+        from platypush.context import get_plugin
+
+        db = get_plugin('db')
+        assert db
+        return db
+
+    def _get_cached_entity(self, entity: Entity) -> Optional[dict]:
+        if entity.id:
+            e = self._entities_cache['by_id'].get(entity.id)
+            if e:
+                return e
+
+        if entity.external_id and entity.plugin:
+            e = self._entities_cache['by_external_id_and_plugin'].get(
+                (entity.external_id, entity.plugin)
+            )
+            if e:
+                return e
+
+        if entity.name and entity.plugin:
+            e = self._entities_cache['by_name_and_plugin'].get(
+                (entity.name, entity.plugin)
+            )
+            if e:
+                return e
+
+    @staticmethod
+    def _cache_repr(entity: Entity) -> dict:
+        repr_ = entity.to_json()
+        repr_.pop('data', None)
+        repr_.pop('meta', None)
+        repr_.pop('created_at', None)
+        repr_.pop('updated_at', None)
+        return repr_
+
+    def _cache_entities(self, *entities: Entity, overwrite_cache=False):
+        for entity in entities:
+            e = self._cache_repr(entity)
+            if not overwrite_cache:
+                existing_entity = self._entities_cache['by_id'].get(entity.id)
+                if existing_entity:
+                    for k, v in existing_entity.items():
+                        if e.get(k) is None:
+                            e[k] = v
+
+            if entity.id:
+                self._entities_cache['by_id'][entity.id] = e
+            if entity.external_id and entity.plugin:
+                self._entities_cache['by_external_id_and_plugin'][
+                    (entity.external_id, entity.plugin)
+                ] = e
+            if entity.name and entity.plugin:
+                self._entities_cache['by_name_and_plugin'][
+                    (entity.name, entity.plugin)
+                ] = e
+
+    def _populate_entity_id_from_cache(self, new_entity: Entity):
+        with self._entities_cache_lock:
+            cached_entity = self._get_cached_entity(new_entity)
+            if cached_entity and cached_entity.get('id'):
+                new_entity.id = cached_entity['id']
+            if new_entity.id:
+                self._cache_entities(new_entity)
+
+    def _init_entities_cache(self):
+        with self._get_db().get_session() as session:
+            entities = session.query(Entity).all()
+            for entity in entities:
+                make_transient(entity)
+
+        with self._entities_cache_lock:
+            self._cache_entities(*entities, overwrite_cache=True)
+
+        self.logger.info('Entities cache initialized')
+
+    def _process_event(self, entity: Entity):
+        self._populate_entity_id_from_cache(entity)
+        if entity.id:
+            get_bus().post(EntityUpdateEvent(entity=entity))
+        else:
+            self._entities_awaiting_flush.add(self._to_entity_awaiting_flush(entity))
+
+    @staticmethod
+    def _to_entity_awaiting_flush(entity: Entity):
+        e = entity.to_json()
+        return json.dumps(
+            {k: v for k, v in e.items() if k in {'external_id', 'name', 'plugin'}},
+            sort_keys=True,
+        )
+
+    def post(self, *entities: Entity):
+        for entity in entities:
+            self._queue.put(entity)
+
+    @property
+    def should_stop(self) -> bool:
+        return self._should_stop.is_set()
+
+    def stop(self):
+        self._should_stop.set()
+
+    def run(self):
+        super().run()
+        self.logger.info('Started entities engine')
+        self._init_entities_cache()
+
+        while not self.should_stop:
+            msgs = []
+            last_poll_time = time()
+
+            while not self.should_stop and (
+                time() - last_poll_time < self._queue_timeout
+            ):
+                try:
+                    msg = self._queue.get(block=True, timeout=0.5)
+                except Empty:
+                    continue
+
+                if msg:
+                    msgs.append(msg)
+                    # Trigger an EntityUpdateEvent if there has
+                    # been a change on the entity state
+                    self._process_event(msg)
+
+            if not msgs or self.should_stop:
+                continue
+
+            try:
+                self._process_entities(*msgs)
+            except Exception as e:
+                self.logger.error('Error while processing entity updates: ' + str(e))
+                self.logger.exception(e)
+
+        self.logger.info('Stopped entities engine')
+
+    def _get_if_exist(
+        self, session: Session, entities: Iterable[Entity]
+    ) -> Iterable[Entity]:
+        existing_entities = {
+            (
+                str(entity.external_id)
+                if entity.external_id is not None
+                else entity.name,
+                entity.plugin,
+            ): entity
+            for entity in session.query(Entity)
+            .filter(
+                or_(
+                    *[
+                        and_(
+                            Entity.external_id == entity.external_id,
+                            Entity.plugin == entity.plugin,
+                        )
+                        if entity.external_id is not None
+                        else and_(
+                            Entity.name == entity.name,
+                            Entity.type == entity.type,
+                            Entity.plugin == entity.plugin,
+                        )
+                        for entity in entities
+                    ]
+                )
+            )
+            .all()
+        }
+
+        return [
+            existing_entities.get(
+                (
+                    str(entity.external_id)
+                    if entity.external_id is not None
+                    else entity.name,
+                    entity.plugin,
+                ),
+                None,
+            )
+            for entity in entities
+        ]
+
+    def _merge_entities(
+        self, entities: List[Entity], existing_entities: List[Entity]
+    ) -> List[Entity]:
+        def merge(entity: Entity, existing_entity: Entity) -> Entity:
+            columns = [col.key for col in entity.columns]
+            for col in columns:
+                if col == 'meta':
+                    existing_entity.meta = {  # type: ignore
+                        **(existing_entity.meta or {}),  # type: ignore
+                        **(entity.meta or {}),  # type: ignore
+                    }
+                elif col not in ('id', 'created_at'):
+                    setattr(existing_entity, col, getattr(entity, col))
+
+            return existing_entity
+
+        new_entities = []
+        entities_map = {}
+
+        # Get the latest update for each ((id|name), plugin) record
+        for e in entities:
+            key = ((e.external_id or e.name), e.plugin)
+            entities_map[key] = e
+
+        # Retrieve existing records and merge them
+        for i, entity in enumerate(entities):
+            existing_entity = existing_entities[i]
+            if existing_entity:
+                entity = merge(entity, existing_entity)
+
+            new_entities.append(entity)
+
+        return new_entities
+
+    def _process_entities(self, *entities: Entity):
+        with self._get_db().get_session() as session:
+            # Ensure that the internal IDs are set to null before the merge
+            for e in entities:
+                e.id = None  # type: ignore
+
+            existing_entities = self._get_if_exist(session, entities)
+            entities = self._merge_entities(entities, existing_entities)  # type: ignore
+            session.add_all(entities)
+            session.commit()
+
+        with self._entities_cache_lock:
+            for entity in entities:
+                self._cache_entities(entity, overwrite_cache=True)
+
+        entities_awaiting_flush = {*self._entities_awaiting_flush}
+        for entity in entities:
+            e = self._to_entity_awaiting_flush(entity)
+            if e in entities_awaiting_flush:
+                self._process_event(entity)
+                self._entities_awaiting_flush.remove(e)
diff --git a/platypush/entities/_registry.py b/platypush/entities/_registry.py
new file mode 100644
index 000000000..1bee049d4
--- /dev/null
+++ b/platypush/entities/_registry.py
@@ -0,0 +1,135 @@
+import json
+from datetime import datetime
+from typing import Optional, Dict, Collection, Type
+
+from platypush.config import Config
+from platypush.plugins import Plugin
+from platypush.utils import get_plugin_name_by_class, get_redis
+
+from ._base import Entity
+
+_entity_registry_varname = '_platypush/plugin_entity_registry'
+
+
+def register_entity_plugin(entity_type: Type[Entity], plugin: Plugin):
+    """
+    Associates a plugin as a manager for a certain entity type.
+    If you use the `@manages` decorator then you usually don't have
+    to call this method directly.
+    """
+    plugin_name = get_plugin_name_by_class(plugin.__class__) or ''
+    entity_type_name = entity_type.__name__.lower()
+    redis = get_redis()
+    registry = get_plugin_entity_registry()
+    registry_by_plugin = set(registry['by_plugin'].get(plugin_name, []))
+
+    registry_by_entity_type = set(registry['by_entity_type'].get(entity_type_name, []))
+
+    registry_by_plugin.add(entity_type_name)
+    registry_by_entity_type.add(plugin_name)
+    registry['by_plugin'][plugin_name] = list(registry_by_plugin)
+    registry['by_entity_type'][entity_type_name] = list(registry_by_entity_type)
+    redis.mset({_entity_registry_varname: json.dumps(registry)})
+
+
+def get_plugin_entity_registry() -> Dict[str, Dict[str, Collection[str]]]:
+    """
+    Get the `plugin->entity_types` and `entity_type->plugin`
+    mappings supported by the current configuration.
+    """
+    redis = get_redis()
+    registry = redis.mget([_entity_registry_varname])[0]
+    try:
+        registry = json.loads((registry or b'').decode())
+    except (TypeError, ValueError):
+        return {'by_plugin': {}, 'by_entity_type': {}}
+
+    enabled_plugins = set(Config.get_plugins().keys())
+
+    return {
+        'by_plugin': {
+            plugin_name: entity_types
+            for plugin_name, entity_types in registry['by_plugin'].items()
+            if plugin_name in enabled_plugins
+        },
+        'by_entity_type': {
+            entity_type: [p for p in plugins if p in enabled_plugins]
+            for entity_type, plugins in registry['by_entity_type'].items()
+        },
+    }
+
+
+class EntityManagerMixin:
+    """
+    This mixin is injected on the fly into any plugin class declared with
+    the @manages decorator. The class will therefore implement the
+    `publish_entities` and `transform_entities` methods, which can be
+    overridden if required.
+    """
+
+    def transform_entities(self, entities):
+        """
+        This method takes a list of entities in any (plugin-specific)
+        format and converts them into a standardized collection of
+        `Entity` objects. Since this method is called by
+        :meth:`.publish_entities` before entity updates are published,
+        you may usually want to extend it to pre-process the entities
+        managed by your extension into the standard format before they
+        are stored and published to all the consumers.
+        """
+        entities = entities or []
+        for entity in entities:
+            if entity.id:
+                # Entity IDs can only refer to the internal primary key
+                entity.external_id = entity.id
+                entity.id = None  # type: ignore
+
+            entity.plugin = get_plugin_name_by_class(self.__class__)  # type: ignore
+            entity.updated_at = datetime.utcnow()
+
+        return entities
+
+    def publish_entities(self, entities: Optional[Collection[Entity]]):
+        """
+        Publishes a list of entities. The downstream consumers include:
+
+            - The entity persistence manager
+            - The web server
+            - Any consumer subscribed to
+                :class:`platypush.message.event.entities.EntityUpdateEvent`
+                events (e.g. web clients)
+
+        If your extension class uses the `@manages` decorator then you usually
+        don't need to override this class (but you may want to extend
+        :meth:`.transform_entities` instead if your extension doesn't natively
+        handle `Entity` objects).
+        """
+        from . import publish_entities
+
+        entities = self.transform_entities(entities)
+        publish_entities(entities)
+
+
+def manages(*entities: Type[Entity]):
+    """
+    This decorator is used to register a plugin/backend class as a
+    manager of one or more types of entities.
+    """
+
+    def wrapper(plugin: Type[Plugin]):
+        init = plugin.__init__
+
+        def __init__(self, *args, **kwargs):
+            for entity_type in entities:
+                register_entity_plugin(entity_type, self)
+
+            init(self, *args, **kwargs)
+
+        plugin.__init__ = __init__
+        # Inject the EntityManagerMixin
+        if EntityManagerMixin not in plugin.__bases__:
+            plugin.__bases__ = (EntityManagerMixin,) + plugin.__bases__
+
+        return plugin
+
+    return wrapper
diff --git a/platypush/entities/devices.py b/platypush/entities/devices.py
new file mode 100644
index 000000000..8d3d45230
--- /dev/null
+++ b/platypush/entities/devices.py
@@ -0,0 +1,14 @@
+from sqlalchemy import Column, Integer, Boolean, ForeignKey
+
+from ._base import Entity
+
+
+class Device(Entity):
+    __tablename__ = 'device'
+
+    id = Column(Integer, ForeignKey(Entity.id, ondelete='CASCADE'), primary_key=True)
+    reachable = Column(Boolean, default=True)
+
+    __mapper_args__ = {
+        'polymorphic_identity': __tablename__,
+    }
diff --git a/platypush/entities/lights.py b/platypush/entities/lights.py
new file mode 100644
index 000000000..ae65eae6e
--- /dev/null
+++ b/platypush/entities/lights.py
@@ -0,0 +1,30 @@
+from sqlalchemy import Column, Integer, String, ForeignKey, Boolean, Float
+
+from .devices import Device
+
+
+class Light(Device):
+    __tablename__ = 'light'
+
+    id = Column(Integer, ForeignKey(Device.id, ondelete='CASCADE'), primary_key=True)
+    on = Column(Boolean)
+    brightness = Column(Float)
+    saturation = Column(Float)
+    hue = Column(Float)
+    temperature = Column(Float)
+    x = Column(Float)
+    y = Column(Float)
+    colormode = Column(String)
+    effect = Column(String)
+    hue_min = Column(Float)
+    hue_max = Column(Float)
+    saturation_min = Column(Float)
+    saturation_max = Column(Float)
+    brightness_min = Column(Float)
+    brightness_max = Column(Float)
+    temperature_min = Column(Float)
+    temperature_max = Column(Float)
+
+    __mapper_args__ = {
+        'polymorphic_identity': __tablename__,
+    }
diff --git a/platypush/entities/switches.py b/platypush/entities/switches.py
new file mode 100644
index 000000000..3c7d6d100
--- /dev/null
+++ b/platypush/entities/switches.py
@@ -0,0 +1,14 @@
+from sqlalchemy import Column, Integer, ForeignKey, Boolean
+
+from .devices import Device
+
+
+class Switch(Device):
+    __tablename__ = 'switch'
+
+    id = Column(Integer, ForeignKey(Device.id, ondelete='CASCADE'), primary_key=True)
+    state = Column(Boolean)
+
+    __mapper_args__ = {
+        'polymorphic_identity': __tablename__,
+    }
diff --git a/platypush/message/event/entities.py b/platypush/message/event/entities.py
new file mode 100644
index 000000000..6f82f092a
--- /dev/null
+++ b/platypush/message/event/entities.py
@@ -0,0 +1,32 @@
+from abc import ABC
+from typing import Union
+
+from platypush.entities import Entity
+from platypush.message.event import Event
+
+
+class EntityEvent(Event, ABC):
+    def __init__(
+        self, entity: Union[Entity, dict], *args, disable_logging=True, **kwargs
+    ):
+        if isinstance(entity, Entity):
+            entity = entity.to_json()
+        super().__init__(
+            entity=entity, *args, disable_logging=disable_logging, **kwargs
+        )
+
+
+class EntityUpdateEvent(EntityEvent):
+    """
+    This event is triggered whenever an entity of any type (a switch, a light,
+    a sensor, a media player etc.) updates its state.
+    """
+
+
+class EntityDeleteEvent(EntityEvent):
+    """
+    This event is triggered whenever an entity is deleted.
+    """
+
+
+# vim:sw=4:ts=4:et:
diff --git a/platypush/plugins/__init__.py b/platypush/plugins/__init__.py
index 8338620ec..ff69c263c 100644
--- a/platypush/plugins/__init__.py
+++ b/platypush/plugins/__init__.py
@@ -19,12 +19,12 @@ def action(f):
         result = f(*args, **kwargs)
 
         if result and isinstance(result, Response):
-            result.errors = result.errors \
-                if isinstance(result.errors, list) else [result.errors]
+            result.errors = (
+                result.errors if isinstance(result.errors, list) else [result.errors]
+            )
             response = result
         elif isinstance(result, tuple) and len(result) == 2:
-            response.errors = result[1] \
-                if isinstance(result[1], list) else [result[1]]
+            response.errors = result[1] if isinstance(result[1], list) else [result[1]]
 
             if len(response.errors) == 1 and response.errors[0] is None:
                 response.errors = []
@@ -39,12 +39,14 @@ def action(f):
     return _execute_action
 
 
-class Plugin(EventGenerator, ExtensionWithManifest):   # lgtm [py/missing-call-to-init]
-    """ Base plugin class """
+class Plugin(EventGenerator, ExtensionWithManifest):  # lgtm [py/missing-call-to-init]
+    """Base plugin class"""
 
     def __init__(self, **kwargs):
         super().__init__()
-        self.logger = logging.getLogger('platypush:plugin:' + get_plugin_name_by_class(self.__class__))
+        self.logger = logging.getLogger(
+            'platypush:plugin:' + get_plugin_name_by_class(self.__class__)
+        )
         if 'logging' in kwargs:
             self.logger.setLevel(getattr(logging, kwargs['logging'].upper()))
 
@@ -53,8 +55,9 @@ class Plugin(EventGenerator, ExtensionWithManifest):   # lgtm [py/missing-call-t
         )
 
     def run(self, method, *args, **kwargs):
-        assert method in self.registered_actions, '{} is not a registered action on {}'.\
-            format(method, self.__class__.__name__)
+        assert (
+            method in self.registered_actions
+        ), '{} is not a registered action on {}'.format(method, self.__class__.__name__)
         return getattr(self, method)(*args, **kwargs)
 
 
@@ -62,6 +65,7 @@ class RunnablePlugin(Plugin):
     """
     Class for runnable plugins - i.e. plugins that have a start/stop method and can be started.
     """
+
     def __init__(self, poll_interval: Optional[float] = None, **kwargs):
         """
         :param poll_interval: How often the :meth:`.loop` function should be execute (default: None, no pause/interval).
@@ -78,6 +82,9 @@ class RunnablePlugin(Plugin):
     def should_stop(self):
         return self._should_stop.is_set()
 
+    def wait_stop(self, timeout=None):
+        return self._should_stop.wait(timeout)
+
     def start(self):
         set_thread_name(self.__class__.__name__)
         self._thread = threading.Thread(target=self._runner)
diff --git a/platypush/plugins/chat/irc/__init__.py b/platypush/plugins/chat/irc/__init__.py
index e7abdae21..681c59159 100644
--- a/platypush/plugins/chat/irc/__init__.py
+++ b/platypush/plugins/chat/irc/__init__.py
@@ -2,7 +2,11 @@ import os
 from typing import Sequence, Dict, Tuple, Union, Optional
 
 from platypush.plugins import RunnablePlugin, action
-from platypush.schemas.irc import IRCServerSchema, IRCServerStatusSchema, IRCChannelSchema
+from platypush.schemas.irc import (
+    IRCServerSchema,
+    IRCServerStatusSchema,
+    IRCChannelSchema,
+)
 
 from ._bot import IRCBot
 from .. import ChatPlugin
@@ -59,29 +63,19 @@ class ChatIrcPlugin(RunnablePlugin, ChatPlugin):
 
     @property
     def _bots_by_server(self) -> Dict[str, IRCBot]:
-        return {
-            bot.server: bot
-            for srv, bot in self._bots.items()
-        }
+        return {bot.server: bot for srv, bot in self._bots.items()}
 
     @property
     def _bots_by_server_and_port(self) -> Dict[Tuple[str, int], IRCBot]:
-        return {
-            (bot.server, bot.port): bot
-            for srv, bot in self._bots.items()
-        }
+        return {(bot.server, bot.port): bot for srv, bot in self._bots.items()}
 
     @property
     def _bots_by_alias(self) -> Dict[str, IRCBot]:
-        return {
-            bot.alias: bot
-            for srv, bot in self._bots.items()
-            if bot.alias
-        }
+        return {bot.alias: bot for srv, bot in self._bots.items() if bot.alias}
 
     def main(self):
         self._connect()
-        self._should_stop.wait()
+        self.wait_stop()
 
     def _connect(self):
         for srv, bot in self._bots.items():
@@ -109,7 +103,11 @@ class ChatIrcPlugin(RunnablePlugin, ChatPlugin):
 
     @action
     def send_file(
-            self, file: str, server: Union[str, Tuple[str, int]], nick: str, bind_address: Optional[str] = None
+        self,
+        file: str,
+        server: Union[str, Tuple[str, int]],
+        nick: str,
+        bind_address: Optional[str] = None,
     ):
         """
         Send a file to an IRC user over DCC connection.
@@ -127,7 +125,10 @@ class ChatIrcPlugin(RunnablePlugin, ChatPlugin):
 
     @action
     def send_message(
-            self, text: str, server: Union[str, Tuple[str, int]], target: Union[str, Sequence[str]]
+        self,
+        text: str,
+        server: Union[str, Tuple[str, int]],
+        target: Union[str, Sequence[str]],
     ):
         """
         Send a message to a channel or a nick.
@@ -139,15 +140,14 @@ class ChatIrcPlugin(RunnablePlugin, ChatPlugin):
         """
         bot = self._get_bot(server)
         method = (
-            bot.connection.privmsg if isinstance(target, str)
+            bot.connection.privmsg
+            if isinstance(target, str)
             else bot.connection.privmsg_many
         )
         method(target, text)
 
     @action
-    def send_notice(
-            self, text: str, server: Union[str, Tuple[str, int]], target: str
-    ):
+    def send_notice(self, text: str, server: Union[str, Tuple[str, int]], target: str):
         """
         Send a notice to a channel or a nick.
 
@@ -192,22 +192,28 @@ class ChatIrcPlugin(RunnablePlugin, ChatPlugin):
         channel_name = channel
         channel = bot.channels.get(channel)
         assert channel, f'Not connected to channel {channel}'
-        return IRCChannelSchema().dump({
-            'is_invite_only': channel.is_invite_only(),
-            'is_moderated': channel.is_moderated(),
-            'is_protected': channel.is_protected(),
-            'is_secret': channel.is_secret(),
-            'name': channel_name,
-            'modes': channel.modes,
-            'opers': list(channel.opers()),
-            'owners': channel.owners(),
-            'users': list(channel.users()),
-            'voiced': list(channel.voiced()),
-        })
+        return IRCChannelSchema().dump(
+            {
+                'is_invite_only': channel.is_invite_only(),
+                'is_moderated': channel.is_moderated(),
+                'is_protected': channel.is_protected(),
+                'is_secret': channel.is_secret(),
+                'name': channel_name,
+                'modes': channel.modes,
+                'opers': list(channel.opers()),
+                'owners': channel.owners(),
+                'users': list(channel.users()),
+                'voiced': list(channel.voiced()),
+            }
+        )
 
     @action
     def send_ctcp_message(
-            self, ctcp_type: str, body: str, server: Union[str, Tuple[str, int]], target: str
+        self,
+        ctcp_type: str,
+        body: str,
+        server: Union[str, Tuple[str, int]],
+        target: str,
     ):
         """
         Send a CTCP message to a target.
@@ -222,7 +228,7 @@ class ChatIrcPlugin(RunnablePlugin, ChatPlugin):
 
     @action
     def send_ctcp_reply(
-            self, body: str, server: Union[str, Tuple[str, int]], target: str
+        self, body: str, server: Union[str, Tuple[str, int]], target: str
     ):
         """
         Send a CTCP REPLY command.
@@ -235,7 +241,9 @@ class ChatIrcPlugin(RunnablePlugin, ChatPlugin):
         bot.connection.ctcp_reply(target, body)
 
     @action
-    def disconnect(self, server: Union[str, Tuple[str, int]], message: Optional[str] = None):
+    def disconnect(
+        self, server: Union[str, Tuple[str, int]], message: Optional[str] = None
+    ):
         """
         Disconnect from a server.
 
@@ -246,9 +254,7 @@ class ChatIrcPlugin(RunnablePlugin, ChatPlugin):
         bot.connection.disconnect(message or bot.stop_message)
 
     @action
-    def invite(
-            self, nick: str, channel: str, server: Union[str, Tuple[str, int]]
-    ):
+    def invite(self, nick: str, channel: str, server: Union[str, Tuple[str, int]]):
         """
         Invite a nick to a channel.
 
@@ -272,7 +278,11 @@ class ChatIrcPlugin(RunnablePlugin, ChatPlugin):
 
     @action
     def kick(
-            self, nick: str, channel: str, server: Union[str, Tuple[str, int]], reason: Optional[str] = None
+        self,
+        nick: str,
+        channel: str,
+        server: Union[str, Tuple[str, int]],
+        reason: Optional[str] = None,
     ):
         """
         Kick a nick from a channel.
@@ -286,9 +296,7 @@ class ChatIrcPlugin(RunnablePlugin, ChatPlugin):
         bot.connection.kick(channel, nick, reason)
 
     @action
-    def mode(
-            self, target: str, command: str, server: Union[str, Tuple[str, int]]
-    ):
+    def mode(self, target: str, command: str, server: Union[str, Tuple[str, int]]):
         """
         Send a MODE command on the selected target.
 
@@ -324,8 +332,10 @@ class ChatIrcPlugin(RunnablePlugin, ChatPlugin):
 
     @action
     def part(
-            self, channel: Union[str, Sequence[str]], server: Union[str, Tuple[str, int]],
-            message: Optional[str] = None
+        self,
+        channel: Union[str, Sequence[str]],
+        server: Union[str, Tuple[str, int]],
+        message: Optional[str] = None,
     ):
         """
         Parts/exits a channel.
@@ -339,9 +349,7 @@ class ChatIrcPlugin(RunnablePlugin, ChatPlugin):
         bot.connection.part(channels=channels, message=message or bot.stop_message)
 
     @action
-    def quit(
-            self, server: Union[str, Tuple[str, int]], message: Optional[str] = None
-    ):
+    def quit(self, server: Union[str, Tuple[str, int]], message: Optional[str] = None):
         """
         Send a QUIT command.
 
@@ -363,7 +371,12 @@ class ChatIrcPlugin(RunnablePlugin, ChatPlugin):
         bot.connection.send_raw(message)
 
     @action
-    def topic(self, channel: str, server: Union[str, Tuple[str, int]], topic: Optional[str] = None) -> str:
+    def topic(
+        self,
+        channel: str,
+        server: Union[str, Tuple[str, int]],
+        topic: Optional[str] = None,
+    ) -> str:
         """
         Get/set the topic of an IRC channel.
 
diff --git a/platypush/plugins/db/__init__.py b/platypush/plugins/db/__init__.py
index f4594f089..ab901b2e8 100644
--- a/platypush/plugins/db/__init__.py
+++ b/platypush/plugins/db/__init__.py
@@ -1,11 +1,11 @@
-"""
-.. moduleauthor:: Fabio Manganiello <blacklight86@gmail.com>
-"""
-
 import time
+from contextlib import contextmanager
+from multiprocessing import RLock
+from typing import Generator
 
 from sqlalchemy import create_engine, Table, MetaData
 from sqlalchemy.engine import Engine
+from sqlalchemy.orm import Session, sessionmaker, scoped_session
 
 from platypush.plugins import Plugin, action
 
@@ -30,22 +30,23 @@ class DbPlugin(Plugin):
         """
 
         super().__init__()
-        self.engine = self._get_engine(engine, *args, **kwargs)
+        self.engine = self.get_engine(engine, *args, **kwargs)
+        self._session_locks = {}
 
-    def _get_engine(self, engine=None, *args, **kwargs):
+    def get_engine(self, engine=None, *args, **kwargs) -> Engine:
         if engine:
             if isinstance(engine, Engine):
                 return engine
             if engine.startswith('sqlite://'):
                 kwargs['connect_args'] = {'check_same_thread': False}
 
-            return create_engine(engine, *args, **kwargs)
+            return create_engine(engine, *args, **kwargs)  # type: ignore
 
+        assert self.engine
         return self.engine
 
-    # noinspection PyUnusedLocal
     @staticmethod
-    def _build_condition(table, column, value):
+    def _build_condition(_, column, value):
         if isinstance(value, str):
             value = "'{}'".format(value)
         elif not isinstance(value, int) and not isinstance(value, float):
@@ -73,14 +74,14 @@ class DbPlugin(Plugin):
         :param kwargs: Extra kwargs that will be passed to ``sqlalchemy.create_engine`` (seehttps:///docs.sqlalchemy.org/en/latest/core/engines.html)
         """
 
-        engine = self._get_engine(engine, *args, **kwargs)
+        engine = self.get_engine(engine, *args, **kwargs)
 
         with engine.connect() as connection:
             connection.execute(statement)
 
     def _get_table(self, table, engine=None, *args, **kwargs):
         if not engine:
-            engine = self._get_engine(engine, *args, **kwargs)
+            engine = self.get_engine(engine, *args, **kwargs)
 
         db_ok = False
         n_tries = 0
@@ -98,7 +99,7 @@ class DbPlugin(Plugin):
                 self.logger.exception(e)
                 self.logger.info('Waiting {} seconds before retrying'.format(wait_time))
                 time.sleep(wait_time)
-                engine = self._get_engine(engine, *args, **kwargs)
+                engine = self.get_engine(engine, *args, **kwargs)
 
         if not db_ok and last_error:
             raise last_error
@@ -163,7 +164,7 @@ class DbPlugin(Plugin):
                 ]
         """
 
-        engine = self._get_engine(engine, *args, **kwargs)
+        engine = self.get_engine(engine, *args, **kwargs)
 
         if table:
             table, engine = self._get_table(table, engine=engine, *args, **kwargs)
@@ -234,7 +235,7 @@ class DbPlugin(Plugin):
         if key_columns is None:
             key_columns = []
 
-        engine = self._get_engine(engine, *args, **kwargs)
+        engine = self.get_engine(engine, *args, **kwargs)
 
         for record in records:
             table, engine = self._get_table(table, engine=engine, *args, **kwargs)
@@ -293,7 +294,7 @@ class DbPlugin(Plugin):
                 }
         """
 
-        engine = self._get_engine(engine, *args, **kwargs)
+        engine = self.get_engine(engine, *args, **kwargs)
 
         for record in records:
             table, engine = self._get_table(table, engine=engine, *args, **kwargs)
@@ -341,7 +342,7 @@ class DbPlugin(Plugin):
                 }
         """
 
-        engine = self._get_engine(engine, *args, **kwargs)
+        engine = self.get_engine(engine, *args, **kwargs)
 
         for record in records:
             table, engine = self._get_table(table, engine=engine, *args, **kwargs)
@@ -352,5 +353,22 @@ class DbPlugin(Plugin):
 
             engine.execute(delete)
 
+    def create_all(self, engine, base):
+        self._session_locks[engine.url] = self._session_locks.get(engine.url, RLock())
+        with self._session_locks[engine.url]:
+            base.metadata.create_all(engine)
+
+    @contextmanager
+    def get_session(self, engine=None, *args, **kwargs) -> Generator[Session, None, None]:
+        engine = self.get_engine(engine, *args, **kwargs)
+        self._session_locks[engine.url] = self._session_locks.get(engine.url, RLock())
+        with self._session_locks[engine.url]:
+            session = scoped_session(sessionmaker(expire_on_commit=False))
+            session.configure(bind=engine)
+            s = session()
+            yield s
+            s.commit()
+            s.close()
+
 
 # vim:sw=4:ts=4:et:
diff --git a/platypush/plugins/entities/__init__.py b/platypush/plugins/entities/__init__.py
new file mode 100644
index 000000000..e600a2d49
--- /dev/null
+++ b/platypush/plugins/entities/__init__.py
@@ -0,0 +1,251 @@
+from queue import Queue, Empty
+from threading import Thread
+from time import time
+from typing import Optional, Any, Collection, Mapping
+
+from sqlalchemy import or_
+from sqlalchemy.orm import make_transient
+
+from platypush.config import Config
+from platypush.context import get_plugin, get_bus
+from platypush.entities import Entity, get_plugin_entity_registry, get_entities_registry
+from platypush.message.event.entities import EntityUpdateEvent, EntityDeleteEvent
+from platypush.plugins import Plugin, action
+
+
+class EntitiesPlugin(Plugin):
+    """
+    This plugin is used to interact with native platform entities (e.g. switches, lights,
+    sensors etc.) through a consistent interface, regardless of the integration type.
+    """
+
+    def __init__(self, **kwargs):
+        super().__init__(**kwargs)
+
+    def _get_db(self):
+        db = get_plugin('db')
+        assert db
+        return db
+
+    @action
+    def get(
+        self,
+        types: Optional[Collection[str]] = None,
+        plugins: Optional[Collection[str]] = None,
+        **filter,
+    ):
+        """
+        Retrieve a list of entities.
+
+        :param types: Entity types, as specified by the (lowercase) class name and table name.
+            Default: all entities.
+        :param plugins: Filter by plugin IDs (default: all plugins).
+        :param filter: Filter entities with these criteria (e.g. `name`, `id`,
+            `state`, `type`, `plugin` etc.)
+        """
+        entity_registry = get_entities_registry()
+        selected_types = []
+        all_types = {e.__tablename__.lower(): e for e in entity_registry}
+
+        if types:
+            selected_types = {t.lower() for t in types}
+            entity_types = {t: et for t, et in all_types.items() if t in selected_types}
+            invalid_types = selected_types.difference(entity_types.keys())
+            assert not invalid_types, (
+                f'No such entity types: {invalid_types}. '
+                f'Supported types: {list(all_types.keys())}'
+            )
+
+            selected_types = entity_types.keys()
+
+        db = self._get_db()
+        enabled_plugins = list(
+            {
+                *Config.get_plugins().keys(),
+                *Config.get_backends().keys(),
+            }
+        )
+
+        with db.get_session() as session:
+            query = session.query(Entity).filter(
+                or_(Entity.plugin.in_(enabled_plugins), Entity.plugin.is_(None))
+            )
+
+            if selected_types:
+                query = query.filter(Entity.type.in_(selected_types))
+            if plugins:
+                query = query.filter(Entity.plugin.in_(plugins))
+            if filter:
+                query = query.filter_by(**filter)
+
+            return [e.to_json() for e in query.all()]
+
+    @action
+    def scan(
+        self,
+        types: Optional[Collection[str]] = None,
+        plugins: Optional[Collection[str]] = None,
+        timeout: Optional[float] = 30.0,
+    ):
+        """
+        (Re-)scan entities and return the updated results.
+
+        :param types: Filter by entity types (e.g. `switch`, `light`, `sensor` etc.).
+        :param plugins: Filter by plugin names (e.g. `switch.tplink` or `light.hue`).
+        :param timeout: Scan timeout in seconds. Default: 30.
+        """
+        filter = {}
+        plugin_registry = get_plugin_entity_registry()
+
+        if plugins:
+            filter['plugins'] = plugins
+            plugin_registry['by_plugin'] = {
+                plugin: plugin_registry['by_plugin'][plugin]
+                for plugin in plugins
+                if plugin in plugin_registry['by_plugin']
+            }
+
+        if types:
+            filter['types'] = types
+            filter_entity_types = set(types)
+            plugin_registry['by_plugin'] = {
+                plugin_name: entity_types
+                for plugin_name, entity_types in plugin_registry['by_plugin'].items()
+                if any(t for t in entity_types if t in filter_entity_types)
+            }
+
+        enabled_plugins = plugin_registry['by_plugin'].keys()
+
+        def worker(plugin_name: str, q: Queue):
+            try:
+                plugin = get_plugin(plugin_name)
+                assert plugin, f'No such configured plugin: {plugin_name}'
+                # Force a plugin scan by calling the `status` action
+                response = plugin.status()
+                assert not response.errors, response.errors
+                q.put((plugin_name, response.output))
+            except Exception as e:
+                q.put((plugin_name, e))
+
+        q = Queue()
+        start_time = time()
+        results = []
+        workers = [
+            Thread(target=worker, args=(plugin_name, q))
+            for plugin_name in enabled_plugins
+        ]
+
+        for w in workers:
+            w.start()
+
+        while len(results) < len(workers) and (
+            not timeout or (time() - start_time < timeout)
+        ):
+            try:
+                plugin_name, result = q.get(block=True, timeout=0.5)
+                if isinstance(result, Exception):
+                    self.logger.warning(
+                        f'Could not load results from plugin {plugin_name}: {result}'
+                    )
+                else:
+                    results.append(result)
+            except Empty:
+                continue
+
+        if len(results) < len(workers):
+            self.logger.warning('Scan timed out for some plugins')
+
+        for w in workers:
+            w.join(timeout=max(0, timeout - (time() - start_time)) if timeout else None)
+
+        return self.get(**filter)
+
+    @action
+    def execute(self, id: Any, action: str, *args, **kwargs):
+        """
+        Execute an action on an entity (for example `on`/`off` on a switch, or `get`
+        on a sensor).
+
+        :param id: Entity ID (i.e. the entity's db primary key, not the plugin's external
+            or "logical" key)
+        :param action: Action that should be run. It should be a method implemented
+            by the entity's class.
+        :param args: Action's extra positional arguments.
+        :param kwargs: Action's extra named arguments.
+        """
+        db = self._get_db()
+        with db.get_session() as session:
+            entity = session.query(Entity).filter_by(id=id).one_or_none()
+
+        assert entity, f'No such entity ID: {id}'
+        return entity.run(action, *args, **kwargs)
+
+    @action
+    def delete(self, *entities: int):  # type: ignore
+        """
+        Delete a set of entity IDs.
+
+        Note: this should only be done if the entity is no longer available or
+        the associated plugin has been disabled, otherwise the entities will be
+        re-created by the plugins on the next scan.
+
+        :param entities: IDs of the entities to be removed.
+        :return: The payload of the deleted entities.
+        """
+        with self._get_db().get_session() as session:
+            entities: Collection[Entity] = (
+                session.query(Entity).filter(Entity.id.in_(entities)).all()
+            )
+            for entity in entities:
+                session.delete(entity)
+            session.commit()
+
+        for entity in entities:
+            make_transient(entity)
+            get_bus().post(EntityDeleteEvent(entity))
+
+        return entities
+
+    @action
+    def rename(self, **entities: Mapping[str, str]):
+        """
+        Rename a sequence of entities.
+        Renaming, as of now, is actually done by setting the ``.meta.name_override``
+        property of an entity rather than fully renaming the entity (which may be owned
+        by a plugin that doesn't support renaming, therefore the next entity update may
+        overwrite the name).
+
+        :param entities: Entity `id` -> `new_name` mapping.
+        """
+        return self.set_meta(
+            **{
+                entity_id: {'name_override': name}
+                for entity_id, name in entities.items()
+            }
+        )
+
+    @action
+    def set_meta(self, **entities):
+        """
+        Update the metadata of a set of entities.
+
+        :param entities: Entity `id` -> `new_metadata_fields` mapping.
+        :return: The updated entities.
+        """
+        entities = {str(k): v for k, v in entities.items()}
+        with self._get_db().get_session() as session:
+            objs = session.query(Entity).filter(Entity.id.in_(entities.keys())).all()
+            for obj in objs:
+                obj.meta = {**(obj.meta or {}), **(entities.get(str(obj.id), {}))}
+                session.add(obj)
+
+            session.commit()
+
+        for obj in objs:
+            make_transient(obj)
+            get_bus().post(EntityUpdateEvent(obj))
+
+        return [obj.to_json() for obj in objs]
+
+
+# vim:sw=4:ts=4:et:
diff --git a/platypush/plugins/entities/manifest.yaml b/platypush/plugins/entities/manifest.yaml
new file mode 100644
index 000000000..83628bb5e
--- /dev/null
+++ b/platypush/plugins/entities/manifest.yaml
@@ -0,0 +1,4 @@
+manifest:
+  events: {}
+  package: platypush.plugins.entities
+  type: plugin
diff --git a/platypush/plugins/gpio/__init__.py b/platypush/plugins/gpio/__init__.py
index 53f677a68..d312bfc02 100644
--- a/platypush/plugins/gpio/__init__.py
+++ b/platypush/plugins/gpio/__init__.py
@@ -27,11 +27,11 @@ class GpioPlugin(RunnablePlugin):
     """
 
     def __init__(
-            self,
-            pins: Optional[Dict[str, int]] = None,
-            monitored_pins: Optional[Collection[Union[str, int]]] = None,
-            mode: str = 'board',
-            **kwargs
+        self,
+        pins: Optional[Dict[str, int]] = None,
+        monitored_pins: Optional[Collection[Union[str, int]]] = None,
+        mode: str = 'board',
+        **kwargs
     ):
         """
         :param mode: Specify ``board`` if you want to use the board PIN numbers,
@@ -64,8 +64,9 @@ class GpioPlugin(RunnablePlugin):
         self._initialized_pins = {}
         self._monitored_pins = monitored_pins or []
         self.pins_by_name = pins if pins else {}
-        self.pins_by_number = {number: name
-                               for (name, number) in self.pins_by_name.items()}
+        self.pins_by_number = {
+            number: name for (name, number) in self.pins_by_name.items()
+        }
 
     def _init_board(self):
         import RPi.GPIO as GPIO
@@ -98,6 +99,7 @@ class GpioPlugin(RunnablePlugin):
     def on_gpio_event(self):
         def callback(pin: int):
             import RPi.GPIO as GPIO
+
             value = GPIO.input(pin)
             pin = self.pins_by_number.get(pin, pin)
             get_bus().post(GPIOEvent(pin=pin, value=value))
@@ -106,23 +108,23 @@ class GpioPlugin(RunnablePlugin):
 
     def main(self):
         import RPi.GPIO as GPIO
+
         if not self._monitored_pins:
             return  # No need to start the monitor
 
         self._init_board()
-        monitored_pins = [
-            self._get_pin_number(pin) for pin in self._monitored_pins
-        ]
+        monitored_pins = [self._get_pin_number(pin) for pin in self._monitored_pins]
 
         for pin in monitored_pins:
             GPIO.setup(pin, GPIO.IN, pull_up_down=GPIO.PUD_DOWN)
             GPIO.add_event_detect(pin, GPIO.BOTH, callback=self.on_gpio_event())
 
-        self._should_stop.wait()
+        self.wait_stop()
 
     @action
-    def write(self, pin: Union[int, str], value: Union[int, bool],
-              name: Optional[str] = None) -> Dict[str, Any]:
+    def write(
+        self, pin: Union[int, str], value: Union[int, bool], name: Optional[str] = None
+    ) -> Dict[str, Any]:
         """
         Write a byte value to a pin.
 
diff --git a/platypush/plugins/light/__init__.py b/platypush/plugins/light/__init__.py
index 3943924d7..b683c884a 100644
--- a/platypush/plugins/light/__init__.py
+++ b/platypush/plugins/light/__init__.py
@@ -1,30 +1,52 @@
 from abc import ABC, abstractmethod
 
-from platypush.plugins import action
-from platypush.plugins.switch import SwitchPlugin
+from platypush.entities import manages
+from platypush.entities.lights import Light
+from platypush.plugins import Plugin, action
 
 
-class LightPlugin(SwitchPlugin, ABC):
+@manages(Light)
+class LightPlugin(Plugin, ABC):
     """
     Abstract plugin to interface your logic with lights/bulbs.
     """
 
     @action
     @abstractmethod
-    def on(self):
-        """ Turn the light on """
+    def on(self, lights=None, *args, **kwargs):
+        """Turn the light on"""
         raise NotImplementedError()
 
     @action
     @abstractmethod
-    def off(self):
-        """ Turn the light off """
+    def off(self, lights=None, *args, **kwargs):
+        """Turn the light off"""
         raise NotImplementedError()
 
     @action
     @abstractmethod
-    def toggle(self):
-        """ Toggle the light status (on/off) """
+    def toggle(self, lights=None, *args, **kwargs):
+        """Toggle the light status (on/off)"""
+        raise NotImplementedError()
+
+    @action
+    @abstractmethod
+    def set_lights(self, lights=None, *args, **kwargs):
+        """
+        Set a set of properties on a set of lights.
+
+        :param light: List of lights to set. Each item can represent a light
+            name or ID.
+        :param kwargs: key-value list of the parameters to set.
+        """
+        raise NotImplementedError()
+
+    @action
+    @abstractmethod
+    def status(self, *args, **kwargs):
+        """
+        Get the current status of the lights.
+        """
         raise NotImplementedError()
 
 
diff --git a/platypush/plugins/light/hue/__init__.py b/platypush/plugins/light/hue/__init__.py
index 06ec97652..9a79c7e3a 100644
--- a/platypush/plugins/light/hue/__init__.py
+++ b/platypush/plugins/light/hue/__init__.py
@@ -4,16 +4,22 @@ import time
 
 from enum import Enum
 from threading import Thread, Event
-from typing import List
+from typing import Iterable, Union, Mapping, Any, Set
 
 from platypush.context import get_bus
-from platypush.message.event.light import LightAnimationStartedEvent, LightAnimationStoppedEvent
-from platypush.plugins import action
+from platypush.entities import Entity
+from platypush.entities.lights import Light as LightEntity
+from platypush.message.event.light import (
+    LightAnimationStartedEvent,
+    LightAnimationStoppedEvent,
+    LightStatusChangeEvent,
+)
+from platypush.plugins import action, RunnablePlugin
 from platypush.plugins.light import LightPlugin
 from platypush.utils import set_thread_name
 
 
-class LightHuePlugin(LightPlugin):
+class LightHuePlugin(RunnablePlugin, LightPlugin):
     """
     Philips Hue lights plugin.
 
@@ -25,15 +31,20 @@ class LightHuePlugin(LightPlugin):
 
         - :class:`platypush.message.event.light.LightAnimationStartedEvent` when an animation is started.
         - :class:`platypush.message.event.light.LightAnimationStoppedEvent` when an animation is stopped.
+        - :class:`platypush.message.event.light.LightStatusChangeEvent` when the status of a lightbulb
+          changes.
 
     """
 
     MAX_BRI = 255
     MAX_SAT = 255
     MAX_HUE = 65535
+    MIN_CT = 154
+    MAX_CT = 500
     ANIMATION_CTRL_QUEUE_NAME = 'platypush/light/hue/AnimationCtrl'
     _BRIDGE_RECONNECT_SECONDS = 5
     _MAX_RECONNECT_TRIES = 5
+    _UNINITIALIZED_BRIDGE_ERR = 'The Hue bridge is not initialized'
 
     class Animation(Enum):
         COLOR_TRANSITION = 'color_transition'
@@ -45,7 +56,7 @@ class LightHuePlugin(LightPlugin):
             elif isinstance(other, self.__class__):
                 return self == other
 
-    def __init__(self, bridge, lights=None, groups=None):
+    def __init__(self, bridge, lights=None, groups=None, poll_seconds: float = 20.0):
         """
         :param bridge: Bridge address or hostname
         :type bridge: str
@@ -55,38 +66,54 @@ class LightHuePlugin(LightPlugin):
 
         :param groups Default groups to be controlled (default: all)
         :type groups: list[str]
+
+        :param poll_seconds: How often the plugin should check the bridge for light
+            updates (default: 20 seconds).
         """
 
         super().__init__()
 
         self.bridge_address = bridge
         self.bridge = None
-        self.logger.info('Initializing Hue lights plugin - bridge: "{}"'.format(self.bridge_address))
+        self.logger.info(
+            'Initializing Hue lights plugin - bridge: "{}"'.format(self.bridge_address)
+        )
 
         self.connect()
-        self.lights = []
-        self.groups = []
+        self.lights = set()
+        self.groups = set()
+        self.poll_seconds = poll_seconds
+        self._cached_lights = {}
 
         if lights:
-            self.lights = lights
+            self.lights = set(lights)
         elif groups:
-            self.groups = groups
-            self._expand_groups()
+            self.groups = set(groups)
+            self.lights.update(self._expand_groups(self.groups))
         else:
-            # noinspection PyUnresolvedReferences
-            self.lights = [light.name for light in self.bridge.lights]
+            self.lights = {light['name'] for light in self._get_lights().values()}
 
         self.animation_thread = None
         self.animations = {}
         self._animation_stop = Event()
         self._init_animations()
-        self.logger.info('Configured lights: "{}"'.format(self.lights))
+        self.logger.info(f'Configured lights: {self.lights}')
 
-    def _expand_groups(self):
-        groups = [g for g in self.bridge.groups if g.name in self.groups]
-        for group in groups:
-            for light in group.lights:
-                self.lights += [light.name]
+    def _expand_groups(self, groups: Iterable[str]) -> Set[str]:
+        lights = set()
+        light_id_to_name = {
+            light_id: light['name'] for light_id, light in self._get_lights().items()
+        }
+
+        groups_ = [g for g in self._get_groups().values() if g.get('name') in groups]
+
+        for group in groups_:
+            for light_id in group.get('lights', []):
+                light_name = light_id_to_name.get(light_id)
+                if light_name:
+                    lights.add(light_name)
+
+        return lights
 
     def _init_animations(self):
         self.animations = {
@@ -94,10 +121,10 @@ class LightHuePlugin(LightPlugin):
             'lights': {},
         }
 
-        for group in self.bridge.groups:
-            self.animations['groups'][group.group_id] = None
-        for light in self.bridge.lights:
-            self.animations['lights'][light.light_id] = None
+        for group_id in self._get_groups():
+            self.animations['groups'][group_id] = None
+        for light_id in self._get_lights():
+            self.animations['lights'][light_id] = None
 
     @action
     def connect(self):
@@ -110,6 +137,7 @@ class LightHuePlugin(LightPlugin):
         # Lazy init
         if not self.bridge:
             from phue import Bridge, PhueRegistrationException
+
             success = False
             n_tries = 0
 
@@ -119,12 +147,14 @@ class LightHuePlugin(LightPlugin):
                     self.bridge = Bridge(self.bridge_address)
                     success = True
                 except PhueRegistrationException as e:
-                    self.logger.warning('Bridge registration error: {}'.
-                                        format(str(e)))
+                    self.logger.warning('Bridge registration error: {}'.format(str(e)))
 
                     if n_tries >= self._MAX_RECONNECT_TRIES:
-                        self.logger.error(('Bridge registration failed after ' +
-                                           '{} attempts').format(n_tries))
+                        self.logger.error(
+                            (
+                                'Bridge registration failed after ' + '{} attempts'
+                            ).format(n_tries)
+                        )
                         break
 
                     time.sleep(self._BRIDGE_RECONNECT_SECONDS)
@@ -168,7 +198,7 @@ class LightHuePlugin(LightPlugin):
                 'id': id,
                 **scene,
             }
-            for id, scene in self.bridge.get_scene().items()
+            for id, scene in self._get_scenes().items()
         }
 
     @action
@@ -215,7 +245,7 @@ class LightHuePlugin(LightPlugin):
                 'id': id,
                 **light,
             }
-            for id, light in self.bridge.get_light().items()
+            for id, light in self._get_lights().items()
         }
 
     @action
@@ -273,7 +303,7 @@ class LightHuePlugin(LightPlugin):
                 'id': id,
                 **group,
             }
-            for id, group in self.bridge.get_group().items()
+            for id, group in self._get_groups().items()
         }
 
     @action
@@ -321,15 +351,22 @@ class LightHuePlugin(LightPlugin):
             self.bridge = None
             raise e
 
+        assert self.bridge, self._UNINITIALIZED_BRIDGE_ERR
         lights = []
         groups = []
 
         if 'lights' in kwargs:
-            lights = kwargs.pop('lights').split(',').strip() \
-                if isinstance(lights, str) else kwargs.pop('lights')
+            lights = (
+                kwargs.pop('lights').split(',').strip()
+                if isinstance(lights, str)
+                else kwargs.pop('lights')
+            )
         if 'groups' in kwargs:
-            groups = kwargs.pop('groups').split(',').strip() \
-                if isinstance(groups, str) else kwargs.pop('groups')
+            groups = (
+                kwargs.pop('groups').split(',').strip()
+                if isinstance(groups, str)
+                else kwargs.pop('groups')
+            )
 
         if not lights and not groups:
             lights = self.lights
@@ -340,34 +377,36 @@ class LightHuePlugin(LightPlugin):
 
         try:
             if attr == 'scene':
-                self.bridge.run_scene(groups[0], kwargs.pop('name'))
+                assert groups, 'No groups specified'
+                self.bridge.run_scene(list(groups)[0], kwargs.pop('name'))
             else:
                 if groups:
-                    self.bridge.set_group(groups, attr, *args, **kwargs)
+                    self.bridge.set_group(list(groups), attr, *args, **kwargs)
                 if lights:
-                    self.bridge.set_light(lights, attr, *args, **kwargs)
+                    self.bridge.set_light(list(lights), attr, *args, **kwargs)
         except Exception as e:
             # Reset bridge connection
             self.bridge = None
             raise e
 
-    @action
-    def set_light(self, light, **kwargs):
-        """
-        Set a light (or lights) property.
+        return self._get_lights()
 
-        :param light: Light or lights to set. Can be a string representing the light name,
-            a light object, a list of string, or a list of light objects.
-        :param kwargs: key-value list of parameters to set.
+    @action
+    def set_lights(self, lights, **kwargs):
+        """
+        Set a set of properties on a set of lights.
+
+        :param light: List of lights to set. Each item can represent a light
+            name or ID.
+        :param kwargs: key-value list of the parameters to set.
 
         Example call::
 
             {
                 "type": "request",
-                "target": "hostname",
                 "action": "light.hue.set_light",
                 "args": {
-                    "light": "Bulb 1",
+                    "lights": ["Bulb 1", "Bulb 2"],
                     "sat": 255
                 }
             }
@@ -375,21 +414,42 @@ class LightHuePlugin(LightPlugin):
         """
 
         self.connect()
-        self.bridge.set_light(light, **kwargs)
+        assert self.bridge, self._UNINITIALIZED_BRIDGE_ERR
+        all_lights = self._get_lights()
+
+        for i, l in enumerate(lights):
+            if str(l) in all_lights:
+                lights[i] = all_lights[str(l)]['name']
+
+        # Convert entity attributes to local attributes
+        if kwargs.get('saturation') is not None:
+            kwargs['sat'] = kwargs.pop('saturation')
+        if kwargs.get('brightness') is not None:
+            kwargs['bri'] = kwargs.pop('brightness')
+        if kwargs.get('temperature') is not None:
+            kwargs['ct'] = kwargs.pop('temperature')
+
+        # "Unroll" the map
+        args = []
+        for arg, value in kwargs.items():
+            args += [arg, value]
+
+        self.bridge.set_light(lights, *args)
+        return self._get_lights()
 
     @action
     def set_group(self, group, **kwargs):
         """
         Set a group (or groups) property.
 
-        :param group: Group or groups to set. Can be a string representing the group name, a group object, a list of strings, or a list of group objects.
+        :param group: Group or groups to set. It can be a string representing the
+            group name, a group object, a list of strings, or a list of group objects.
         :param kwargs: key-value list of parameters to set.
 
         Example call::
 
             {
                 "type": "request",
-                "target": "hostname",
                 "action": "light.hue.set_group",
                 "args": {
                     "light": "Living Room",
@@ -400,6 +460,7 @@ class LightHuePlugin(LightPlugin):
         """
 
         self.connect()
+        assert self.bridge, self._UNINITIALIZED_BRIDGE_ERR
         self.bridge.set_group(group, **kwargs)
 
     @action
@@ -451,15 +512,16 @@ class LightHuePlugin(LightPlugin):
         groups_off = []
 
         if groups:
-            all_groups = self.bridge.get_group().values()
-
+            all_groups = self._get_groups().values()
             groups_on = [
-                group['name'] for group in all_groups
+                group['name']
+                for group in all_groups
                 if group['name'] in groups and group['state']['any_on'] is True
             ]
 
             groups_off = [
-                group['name'] for group in all_groups
+                group['name']
+                for group in all_groups
                 if group['name'] in groups and group['state']['any_on'] is False
             ]
 
@@ -467,16 +529,20 @@ class LightHuePlugin(LightPlugin):
             lights = self.lights
 
         if lights:
-            all_lights = self.bridge.get_light().values()
+            all_lights = self._get_lights()
 
             lights_on = [
-                light['name'] for light in all_lights
-                if light['name'] in lights and light['state']['on'] is True
+                light['name']
+                for light_id, light in all_lights.items()
+                if (light_id in lights or light['name'] in lights)
+                and light['state']['on'] is True
             ]
 
             lights_off = [
-                light['name'] for light in all_lights
-                if light['name'] in lights and light['state']['on'] is False
+                light['name']
+                for light_id, light in all_lights.items()
+                if (light_id in lights or light['name'] in lights)
+                and light['state']['on'] is False
             ]
 
         if lights_on or groups_on:
@@ -499,8 +565,13 @@ class LightHuePlugin(LightPlugin):
             groups = []
         if lights is None:
             lights = []
-        return self._exec('bri', int(value) % (self.MAX_BRI + 1),
-                          lights=lights, groups=groups, **kwargs)
+        return self._exec(
+            'bri',
+            int(value) % (self.MAX_BRI + 1),
+            lights=lights,
+            groups=groups,
+            **kwargs,
+        )
 
     @action
     def sat(self, value, lights=None, groups=None, **kwargs):
@@ -516,8 +587,13 @@ class LightHuePlugin(LightPlugin):
             groups = []
         if lights is None:
             lights = []
-        return self._exec('sat', int(value) % (self.MAX_SAT + 1),
-                          lights=lights, groups=groups, **kwargs)
+        return self._exec(
+            'sat',
+            int(value) % (self.MAX_SAT + 1),
+            lights=lights,
+            groups=groups,
+            **kwargs,
+        )
 
     @action
     def hue(self, value, lights=None, groups=None, **kwargs):
@@ -533,8 +609,13 @@ class LightHuePlugin(LightPlugin):
             groups = []
         if lights is None:
             lights = []
-        return self._exec('hue', int(value) % (self.MAX_HUE + 1),
-                          lights=lights, groups=groups, **kwargs)
+        return self._exec(
+            'hue',
+            int(value) % (self.MAX_HUE + 1),
+            lights=lights,
+            groups=groups,
+            **kwargs,
+        )
 
     @action
     def xy(self, value, lights=None, groups=None, **kwargs):
@@ -557,7 +638,7 @@ class LightHuePlugin(LightPlugin):
         """
         Set lights/groups color temperature.
 
-        :param value: Temperature value (range: 0-255)
+        :param value: Temperature value (range: 154-500)
         :type value: int
         :param lights: List of lights.
         :param groups: List of groups.
@@ -584,25 +665,31 @@ class LightHuePlugin(LightPlugin):
             lights = []
 
         if lights:
-            bri = statistics.mean([
-                light['state']['bri']
-                for light in self.bridge.get_light().values()
-                if light['name'] in lights
-            ])
+            bri = statistics.mean(
+                [
+                    light['state']['bri']
+                    for light in self._get_lights().values()
+                    if light['name'] in lights
+                ]
+            )
         elif groups:
-            bri = statistics.mean([
-                group['action']['bri']
-                for group in self.bridge.get_group().values()
-                if group['name'] in groups
-            ])
+            bri = statistics.mean(
+                [
+                    group['action']['bri']
+                    for group in self._get_groups().values()
+                    if group['name'] in groups
+                ]
+            )
         else:
-            bri = statistics.mean([
-                light['state']['bri']
-                for light in self.bridge.get_light().values()
-                if light['name'] in self.lights
-            ])
+            bri = statistics.mean(
+                [
+                    light['state']['bri']
+                    for light in self._get_lights().values()
+                    if light['name'] in self.lights
+                ]
+            )
 
-        delta *= (self.MAX_BRI / 100)
+        delta *= self.MAX_BRI / 100
         if bri + delta < 0:
             bri = 0
         elif bri + delta > self.MAX_BRI:
@@ -628,25 +715,31 @@ class LightHuePlugin(LightPlugin):
             lights = []
 
         if lights:
-            sat = statistics.mean([
-                light['state']['sat']
-                for light in self.bridge.get_light().values()
-                if light['name'] in lights
-            ])
+            sat = statistics.mean(
+                [
+                    light['state']['sat']
+                    for light in self._get_lights().values()
+                    if light['name'] in lights
+                ]
+            )
         elif groups:
-            sat = statistics.mean([
-                group['action']['sat']
-                for group in self.bridge.get_group().values()
-                if group['name'] in groups
-            ])
+            sat = statistics.mean(
+                [
+                    group['action']['sat']
+                    for group in self._get_groups().values()
+                    if group['name'] in groups
+                ]
+            )
         else:
-            sat = statistics.mean([
-                light['state']['sat']
-                for light in self.bridge.get_light().values()
-                if light['name'] in self.lights
-            ])
+            sat = statistics.mean(
+                [
+                    light['state']['sat']
+                    for light in self._get_lights().values()
+                    if light['name'] in self.lights
+                ]
+            )
 
-        delta *= (self.MAX_SAT / 100)
+        delta *= self.MAX_SAT / 100
         if sat + delta < 0:
             sat = 0
         elif sat + delta > self.MAX_SAT:
@@ -672,25 +765,31 @@ class LightHuePlugin(LightPlugin):
             lights = []
 
         if lights:
-            hue = statistics.mean([
-                light['state']['hue']
-                for light in self.bridge.get_light().values()
-                if light['name'] in lights
-            ])
+            hue = statistics.mean(
+                [
+                    light['state']['hue']
+                    for light in self._get_lights().values()
+                    if light['name'] in lights
+                ]
+            )
         elif groups:
-            hue = statistics.mean([
-                group['action']['hue']
-                for group in self.bridge.get_group().values()
-                if group['name'] in groups
-            ])
+            hue = statistics.mean(
+                [
+                    group['action']['hue']
+                    for group in self._get_groups().values()
+                    if group['name'] in groups
+                ]
+            )
         else:
-            hue = statistics.mean([
-                light['state']['hue']
-                for light in self.bridge.get_light().values()
-                if light['name'] in self.lights
-            ])
+            hue = statistics.mean(
+                [
+                    light['state']['hue']
+                    for light in self._get_lights().values()
+                    if light['name'] in self.lights
+                ]
+            )
 
-        delta *= (self.MAX_HUE / 100)
+        delta *= self.MAX_HUE / 100
         if hue + delta < 0:
             hue = 0
         elif hue + delta > self.MAX_HUE:
@@ -734,10 +833,20 @@ class LightHuePlugin(LightPlugin):
             self._init_animations()
 
     @action
-    def animate(self, animation, duration=None,
-                hue_range=None, sat_range=None,
-                bri_range=None, lights=None, groups=None,
-                hue_step=1000, sat_step=2, bri_step=1, transition_seconds=1.0):
+    def animate(
+        self,
+        animation,
+        duration=None,
+        hue_range=None,
+        sat_range=None,
+        bri_range=None,
+        lights=None,
+        groups=None,
+        hue_step=1000,
+        sat_step=2,
+        bri_step=1,
+        transition_seconds=1.0,
+    ):
         """
         Run a lights animation.
 
@@ -747,28 +856,33 @@ class LightHuePlugin(LightPlugin):
         :param duration: Animation duration in seconds (default: None, i.e. continue until stop)
         :type duration: float
 
-        :param hue_range: If you selected a ``color_transition``, this will specify the hue range of your color ``color_transition``.
-            Default: [0, 65535]
+        :param hue_range: If you selected a ``color_transition``, this will
+            specify the hue range of your color ``color_transition``. Default: [0, 65535]
         :type hue_range: list[int]
 
-        :param sat_range: If you selected a color ``color_transition``, this will specify the saturation range of your color
-            ``color_transition``. Default: [0, 255]
+        :param sat_range: If you selected a color ``color_transition``, this
+            will specify the saturation range of your color ``color_transition``.
+            Default: [0, 255]
         :type sat_range: list[int]
 
-        :param bri_range: If you selected a color ``color_transition``, this will specify the brightness range of your color
-            ``color_transition``. Default: [254, 255] :type bri_range: list[int]
+        :param bri_range: If you selected a color ``color_transition``, this
+            will specify the brightness range of your color ``color_transition``.
+            Default: [254, 255] :type bri_range: list[int]
 
         :param lights: Lights to control (names, IDs or light objects). Default: plugin default lights
         :param groups: Groups to control (names, IDs or group objects). Default: plugin default groups
 
-        :param hue_step: If you selected a color ``color_transition``, this will specify by how much the color hue will change
-            between iterations. Default: 1000 :type hue_step: int
+        :param hue_step: If you selected a color ``color_transition``, this
+            will specify by how much the color hue will change between iterations.
+            Default: 1000 :type hue_step: int
 
-        :param sat_step: If you selected a color ``color_transition``, this will specify by how much the saturation will change
-            between iterations. Default: 2 :type sat_step: int
+        :param sat_step: If you selected a color ``color_transition``, this
+            will specify by how much the saturation will change between iterations.
+            Default: 2 :type sat_step: int
 
-        :param bri_step: If you selected a color ``color_transition``, this will specify by how much the brightness will change
-            between iterations. Default: 1 :type bri_step: int
+        :param bri_step: If you selected a color ``color_transition``, this
+            will specify by how much the brightness will change between iterations.
+            Default: 1 :type bri_step: int
 
         :param transition_seconds: Time between two transitions or blinks in seconds. Default: 1.0
         :type transition_seconds: float
@@ -776,20 +890,26 @@ class LightHuePlugin(LightPlugin):
 
         self.stop_animation()
         self._animation_stop.clear()
+        all_lights = self._get_lights()
+        bri_range = bri_range or [self.MAX_BRI - 1, self.MAX_BRI]
+        sat_range = sat_range or [0, self.MAX_SAT]
+        hue_range = hue_range or [0, self.MAX_HUE]
 
-        if bri_range is None:
-            bri_range = [self.MAX_BRI - 1, self.MAX_BRI]
-        if sat_range is None:
-            sat_range = [0, self.MAX_SAT]
-        if hue_range is None:
-            hue_range = [0, self.MAX_HUE]
         if groups:
-            groups = [g for g in self.bridge.groups if g.name in groups or g.group_id in groups]
-            lights = lights or []
-            for group in groups:
-                lights.extend([light.name for light in group.lights])
+            groups = {
+                group_id: group
+                for group_id, group in self._get_groups().items()
+                if group.get('name') in groups or group_id in groups
+            }
+
+            lights = set(lights or [])
+            lights.update(self._expand_groups([g['name'] for g in groups.values()]))
         elif lights:
-            lights = [light.name for light in self.bridge.lights if light.name in lights or light.light_id in lights]
+            lights = {
+                light['name']
+                for light_id, light in all_lights.items()
+                if light['name'] in lights or int(light_id) in lights
+            }
         else:
             lights = self.lights
 
@@ -806,26 +926,50 @@ class LightHuePlugin(LightPlugin):
         }
 
         if groups:
-            for group in groups:
-                self.animations['groups'][group.group_id] = info
+            for group_id in groups:
+                self.animations['groups'][group_id] = info
 
-        for light in self.bridge.lights:
-            if light.name in lights:
-                self.animations['lights'][light.light_id] = info
+        for light_id, light in all_lights.items():
+            if light['name'] in lights:
+                self.animations['lights'][light_id] = info
 
         def _initialize_light_attrs(lights):
+            lights_by_name = {
+                light['name']: light for light in self._get_lights().values()
+            }
+
             if animation == self.Animation.COLOR_TRANSITION:
-                return {light: {
-                    'hue': random.randint(hue_range[0], hue_range[1]),
-                    'sat': random.randint(sat_range[0], sat_range[1]),
-                    'bri': random.randint(bri_range[0], bri_range[1]),
-                } for light in lights}
+                return {
+                    light: {
+                        **(
+                            {'hue': random.randint(hue_range[0], hue_range[1])}  # type: ignore
+                            if 'hue' in lights_by_name.get(light, {}).get('state', {})
+                            else {}
+                        ),
+                        **(
+                            {'sat': random.randint(sat_range[0], sat_range[1])}  # type: ignore
+                            if 'sat' in lights_by_name.get(light, {}).get('state', {})
+                            else {}
+                        ),
+                        **(
+                            {'bri': random.randint(bri_range[0], bri_range[1])}  # type: ignore
+                            if 'bri' in lights_by_name.get(light, {}).get('state', {})
+                            else {}
+                        ),
+                    }
+                    for light in lights
+                }
             elif animation == self.Animation.BLINK:
-                return {light: {
-                    'on': True,
-                    'bri': self.MAX_BRI,
-                    'transitiontime': 0,
-                } for light in lights}
+                return {
+                    light: {
+                        'on': True,
+                        **({'bri': self.MAX_BRI} if 'bri' in light else {}),
+                        'transitiontime': 0,
+                    }
+                    for light in lights
+                }
+
+            raise AssertionError(f'Unknown animation type: {animation}')
 
         def _next_light_attrs(lights):
             if animation == self.Animation.COLOR_TRANSITION:
@@ -843,15 +987,19 @@ class LightHuePlugin(LightPlugin):
                         else:
                             continue
 
-                        lights[light][attr] = ((value - attr_range[0] + attr_step) %
-                                               (attr_range[1] - attr_range[0] + 1)) + \
-                                              attr_range[0]
+                        lights[light][attr] = (
+                            (value - attr_range[0] + attr_step)
+                            % (attr_range[1] - attr_range[0] + 1)
+                        ) + attr_range[0]
             elif animation == self.Animation.BLINK:
-                lights = {light: {
-                    'on': False if attrs['on'] else True,
-                    'bri': self.MAX_BRI,
-                    'transitiontime': 0,
-                } for (light, attrs) in lights.items()}
+                lights = {
+                    light: {
+                        'on': not attrs['on'],
+                        'bri': self.MAX_BRI,
+                        'transitiontime': 0,
+                    }
+                    for (light, attrs) in lights.items()
+                }
 
             return lights
 
@@ -860,13 +1008,23 @@ class LightHuePlugin(LightPlugin):
 
         def _animate_thread(lights):
             set_thread_name('HueAnimate')
-            get_bus().post(LightAnimationStartedEvent(lights=lights, groups=groups, animation=animation))
+            get_bus().post(
+                LightAnimationStartedEvent(
+                    lights=lights,
+                    groups=list((groups or {}).keys()),
+                    animation=animation,
+                )
+            )
 
             lights = _initialize_light_attrs(lights)
             animation_start_time = time.time()
             stop_animation = False
 
-            while not stop_animation and not (duration and time.time() - animation_start_time > duration):
+            while not stop_animation and not (
+                duration and time.time() - animation_start_time > duration
+            ):
+                assert self.bridge, self._UNINITIALIZED_BRIDGE_ERR
+
                 try:
                     if animation == self.Animation.COLOR_TRANSITION:
                         for (light, attrs) in lights.items():
@@ -877,7 +1035,9 @@ class LightHuePlugin(LightPlugin):
                         self.logger.debug('Setting lights to {}'.format(conf))
 
                         if groups:
-                            self.bridge.set_group([g.name for g in groups], conf)
+                            self.bridge.set_group(
+                                [g['name'] for g in groups.values()], conf
+                            )
                         else:
                             self.bridge.set_light(lights.keys(), conf)
 
@@ -891,57 +1051,164 @@ class LightHuePlugin(LightPlugin):
 
                 lights = _next_light_attrs(lights)
 
-            get_bus().post(LightAnimationStoppedEvent(lights=lights, groups=groups, animation=animation))
+            get_bus().post(
+                LightAnimationStoppedEvent(
+                    lights=list(lights.keys()),
+                    groups=list((groups or {}).keys()),
+                    animation=animation,
+                )
+            )
+
             self.animation_thread = None
 
-        self.animation_thread = Thread(target=_animate_thread,
-                                       name='HueAnimate',
-                                       args=(lights,))
+        self.animation_thread = Thread(
+            target=_animate_thread, name='HueAnimate', args=(lights,)
+        )
         self.animation_thread.start()
 
-    @property
-    def switches(self) -> List[dict]:
-        """
-        :returns: Implements :meth:`platypush.plugins.switch.SwitchPlugin.switches` and returns the status of the
-            configured lights. Example:
+    def _get_light_attr(self, light, attr: str):
+        try:
+            return getattr(light, attr, None)
+        except KeyError:
+            return None
 
-            .. code-block:: json
+    def transform_entities(
+        self, entities: Union[Iterable[Union[dict, Entity]], Mapping[Any, dict]]
+    ) -> Iterable[Entity]:
+        new_entities = []
+        if isinstance(entities, dict):
+            entities = [{'id': id, **e} for id, e in entities.items()]
 
-                [
-                    {
-                        "id": "3",
-                        "name": "Lightbulb 1",
-                        "on": true,
-                        "bri": 254,
-                        "hue": 1532,
-                        "sat": 215,
-                        "effect": "none",
-                        "xy": [
-                            0.6163,
-                            0.3403
-                        ],
-                        "ct": 153,
-                        "alert": "none",
-                        "colormode": "hs",
-                        "reachable": true
-                        "type": "Extended color light",
-                        "modelid": "LCT001",
-                        "manufacturername": "Philips",
-                        "uniqueid": "00:11:22:33:44:55:66:77-88",
-                        "swversion": "5.105.0.21169"
-                    }
-                ]
+        for entity in entities:
+            if isinstance(entity, Entity):
+                new_entities.append(entity)
+            elif isinstance(entity, dict):
+                new_entities.append(
+                    LightEntity(
+                        id=entity['id'],
+                        name=entity['name'],
+                        description=entity.get('type'),
+                        on=entity.get('state', {}).get('on', False),
+                        brightness=entity.get('state', {}).get('bri'),
+                        saturation=entity.get('state', {}).get('sat'),
+                        hue=entity.get('state', {}).get('hue'),
+                        temperature=entity.get('state', {}).get('ct'),
+                        colormode=entity.get('colormode'),
+                        reachable=entity.get('state', {}).get('reachable'),
+                        x=entity['state']['xy'][0]
+                        if entity.get('state', {}).get('xy')
+                        else None,
+                        y=entity['state']['xy'][1]
+                        if entity.get('state', {}).get('xy')
+                        else None,
+                        effect=entity.get('state', {}).get('effect'),
+                        **(
+                            {
+                                'hue_min': 0,
+                                'hue_max': self.MAX_HUE,
+                            }
+                            if entity.get('state', {}).get('hue') is not None
+                            else {
+                                'hue_min': None,
+                                'hue_max': None,
+                            }
+                        ),
+                        **(
+                            {
+                                'saturation_min': 0,
+                                'saturation_max': self.MAX_SAT,
+                            }
+                            if entity.get('state', {}).get('sat') is not None
+                            else {
+                                'saturation_min': None,
+                                'saturation_max': None,
+                            }
+                        ),
+                        **(
+                            {
+                                'brightness_min': 0,
+                                'brightness_max': self.MAX_BRI,
+                            }
+                            if entity.get('state', {}).get('bri') is not None
+                            else {
+                                'brightness_min': None,
+                                'brightness_max': None,
+                            }
+                        ),
+                        **(
+                            {
+                                'temperature_min': self.MIN_CT,
+                                'temperature_max': self.MAX_CT,
+                            }
+                            if entity.get('state', {}).get('ct') is not None
+                            else {
+                                'temperature_min': None,
+                                'temperature_max': None,
+                            }
+                        ),
+                    )
+                )
 
-        """
+        return super().transform_entities(new_entities)  # type: ignore
 
-        return [
-            {
-                'id': id,
-                **light.pop('state', {}),
-                **light,
-            }
-            for id, light in self.bridge.get_light().items()
-        ]
+    def _get_lights(self) -> dict:
+        assert self.bridge, self._UNINITIALIZED_BRIDGE_ERR
+        lights = self.bridge.get_light()
+        lights = {id: light for id, light in lights.items() if not light.get('recycle')}
+        self._cached_lights = lights
+        self.publish_entities(lights)  # type: ignore
+        return lights
+
+    def _get_groups(self) -> dict:
+        assert self.bridge, self._UNINITIALIZED_BRIDGE_ERR
+        groups = self.bridge.get_group() or {}
+        return {id: group for id, group in groups.items() if not group.get('recycle')}
+
+    def _get_scenes(self) -> dict:
+        assert self.bridge, self._UNINITIALIZED_BRIDGE_ERR
+        scenes = self.bridge.get_scene() or {}
+        return {id: scene for id, scene in scenes.items() if not scene.get('recycle')}
+
+    @action
+    def status(self) -> Iterable[LightEntity]:
+        lights = self.transform_entities(self._get_lights())
+        for light in lights:
+            light.id = light.external_id
+            for attr, value in (light.data or {}).items():
+                setattr(light, attr, value)
+
+            del light.external_id
+            del light.data
+
+        return lights
+
+    def main(self):
+        lights_prev = self._get_lights()  # Initialize the lights
+
+        while not self.should_stop():
+            try:
+                lights_new = self._get_lights()
+                for light_id, light in lights_new.items():
+                    event_args = {}
+                    new_state = light.get('state', {})
+                    prev_state = lights_prev.get(light_id, {}).get('state', {})
+
+                    for attr in ['on', 'bri', 'sat', 'hue', 'ct', 'xy']:
+                        if attr in new_state and new_state.get(attr) != prev_state.get(
+                            attr
+                        ):
+                            event_args[attr] = new_state.get(attr)
+
+                    if event_args:
+                        event_args['plugin_name'] = 'light.hue'
+                        event_args['light_id'] = light_id
+                        event_args['light_name'] = light.get('name')
+                        get_bus().post(LightStatusChangeEvent(**event_args))
+                        self.publish_entities([{'id': light_id, **light}])  # type: ignore
+
+                lights_prev = lights_new
+            finally:
+                self.wait_stop(self.poll_seconds)
 
 
 # vim:sw=4:ts=4:et:
diff --git a/platypush/plugins/light/hue/manifest.yaml b/platypush/plugins/light/hue/manifest.yaml
index cd18d5195..1a8581aa2 100644
--- a/platypush/plugins/light/hue/manifest.yaml
+++ b/platypush/plugins/light/hue/manifest.yaml
@@ -4,6 +4,8 @@ manifest:
       started.
     platypush.message.event.light.LightAnimationStoppedEvent: when an animation is
       stopped.
+    platypush.message.event.light.LightStatusChangeEvent: when the status of a
+      lightbulb changes.
   install:
     pip:
     - phue
diff --git a/platypush/plugins/media/search/local.py b/platypush/plugins/media/search/local.py
index 3298dfe17..76868aa4f 100644
--- a/platypush/plugins/media/search/local.py
+++ b/platypush/plugins/media/search/local.py
@@ -3,9 +3,16 @@ import os
 import re
 import time
 
-from sqlalchemy import create_engine, Column, Integer, String, DateTime, PrimaryKeyConstraint, ForeignKey
-from sqlalchemy.orm import sessionmaker, scoped_session
-from sqlalchemy.ext.declarative import declarative_base
+from sqlalchemy import (
+    create_engine,
+    Column,
+    Integer,
+    String,
+    DateTime,
+    PrimaryKeyConstraint,
+    ForeignKey,
+)
+from sqlalchemy.orm import sessionmaker, scoped_session, declarative_base
 from sqlalchemy.sql.expression import func
 
 from platypush.config import Config
@@ -38,7 +45,8 @@ class LocalMediaSearcher(MediaSearcher):
         if not self._db_engine:
             self._db_engine = create_engine(
                 'sqlite:///{}'.format(self.db_file),
-                connect_args={'check_same_thread': False})
+                connect_args={'check_same_thread': False},
+            )
 
             Base.metadata.create_all(self._db_engine)
             Session.configure(bind=self._db_engine)
@@ -57,27 +65,30 @@ class LocalMediaSearcher(MediaSearcher):
 
     @classmethod
     def _get_last_modify_time(cls, path, recursive=False):
-        return max([os.path.getmtime(p) for p, _, _ in os.walk(path)]) \
-            if recursive else os.path.getmtime(path)
+        return (
+            max([os.path.getmtime(p) for p, _, _ in os.walk(path)])
+            if recursive
+            else os.path.getmtime(path)
+        )
 
     @classmethod
-    def _has_directory_changed_since_last_indexing(self, dir_record):
+    def _has_directory_changed_since_last_indexing(cls, dir_record):
         if not dir_record.last_indexed_at:
             return True
 
-        return datetime.datetime.fromtimestamp(
-            self._get_last_modify_time(dir_record.path)) > dir_record.last_indexed_at
+        return (
+            datetime.datetime.fromtimestamp(cls._get_last_modify_time(dir_record.path))
+            > dir_record.last_indexed_at
+        )
 
     @classmethod
     def _matches_query(cls, filename, query):
         filename = filename.lower()
-        query_tokens = [_.lower() for _ in re.split(
-            cls._filename_separators, query.strip())]
+        query_tokens = [
+            _.lower() for _ in re.split(cls._filename_separators, query.strip())
+        ]
 
-        for token in query_tokens:
-            if token not in filename:
-                return False
-        return True
+        return all(token in filename for token in query_tokens)
 
     @classmethod
     def _sync_token_records(cls, session, *tokens):
@@ -85,9 +96,12 @@ class LocalMediaSearcher(MediaSearcher):
         if not tokens:
             return []
 
-        records = {record.token: record for record in
-                   session.query(MediaToken).filter(
-                       MediaToken.token.in_(tokens)).all()}
+        records = {
+            record.token: record
+            for record in session.query(MediaToken)
+            .filter(MediaToken.token.in_(tokens))
+            .all()
+        }
 
         for token in tokens:
             if token in records:
@@ -97,13 +111,11 @@ class LocalMediaSearcher(MediaSearcher):
             records[token] = record
 
         session.commit()
-        return session.query(MediaToken).filter(
-            MediaToken.token.in_(tokens)).all()
+        return session.query(MediaToken).filter(MediaToken.token.in_(tokens)).all()
 
     @classmethod
     def _get_file_records(cls, dir_record, session):
-        return session.query(MediaFile).filter_by(
-            directory_id=dir_record.id).all()
+        return session.query(MediaFile).filter_by(directory_id=dir_record.id).all()
 
     def scan(self, media_dir, session=None, dir_record=None):
         """
@@ -121,17 +133,19 @@ class LocalMediaSearcher(MediaSearcher):
             dir_record = self._get_or_create_dir_entry(session, media_dir)
 
         if not os.path.isdir(media_dir):
-            self.logger.info('Directory {} is no longer accessible, removing it'.
-                             format(media_dir))
-            session.query(MediaDirectory) \
-                .filter(MediaDirectory.path == media_dir) \
-                .delete(synchronize_session='fetch')
+            self.logger.info(
+                'Directory {} is no longer accessible, removing it'.format(media_dir)
+            )
+            session.query(MediaDirectory).filter(
+                MediaDirectory.path == media_dir
+            ).delete(synchronize_session='fetch')
             return
 
         stored_file_records = {
-            f.path: f for f in self._get_file_records(dir_record, session)}
+            f.path: f for f in self._get_file_records(dir_record, session)
+        }
 
-        for path, dirs, files in os.walk(media_dir):
+        for path, _, files in os.walk(media_dir):
             for filename in files:
                 filepath = os.path.join(path, filename)
 
@@ -142,26 +156,32 @@ class LocalMediaSearcher(MediaSearcher):
                     del stored_file_records[filepath]
                     continue
 
-                if not MediaPlugin.is_video_file(filename) and \
-                        not MediaPlugin.is_audio_file(filename):
+                if not MediaPlugin.is_video_file(
+                    filename
+                ) and not MediaPlugin.is_audio_file(filename):
                     continue
 
                 self.logger.debug('Syncing item {}'.format(filepath))
-                tokens = [_.lower() for _ in re.split(self._filename_separators,
-                                                      filename.strip())]
+                tokens = [
+                    _.lower()
+                    for _ in re.split(self._filename_separators, filename.strip())
+                ]
 
                 token_records = self._sync_token_records(session, *tokens)
-                file_record = MediaFile.build(directory_id=dir_record.id,
-                                              path=filepath)
+                file_record = MediaFile.build(directory_id=dir_record.id, path=filepath)
 
                 session.add(file_record)
                 session.commit()
-                file_record = session.query(MediaFile).filter_by(
-                    directory_id=dir_record.id, path=filepath).one()
+                file_record = (
+                    session.query(MediaFile)
+                    .filter_by(directory_id=dir_record.id, path=filepath)
+                    .one()
+                )
 
                 for token_record in token_records:
-                    file_token = MediaFileToken.build(file_id=file_record.id,
-                                                      token_id=token_record.id)
+                    file_token = MediaFileToken.build(
+                        file_id=file_record.id, token_id=token_record.id
+                    )
                     session.add(file_token)
 
         # stored_file_records should now only contain the records of the files
@@ -169,15 +189,20 @@ class LocalMediaSearcher(MediaSearcher):
         if stored_file_records:
             self.logger.info(
                 'Removing references to {} deleted media items from {}'.format(
-                    len(stored_file_records), media_dir))
+                    len(stored_file_records), media_dir
+                )
+            )
 
-            session.query(MediaFile).filter(MediaFile.id.in_(
-                [record.id for record in stored_file_records.values()]
-            )).delete(synchronize_session='fetch')
+            session.query(MediaFile).filter(
+                MediaFile.id.in_([record.id for record in stored_file_records.values()])
+            ).delete(synchronize_session='fetch')
 
         dir_record.last_indexed_at = datetime.datetime.now()
-        self.logger.info('Scanned {} in {} seconds'.format(
-            media_dir, int(time.time() - index_start_time)))
+        self.logger.info(
+            'Scanned {} in {} seconds'.format(
+                media_dir, int(time.time() - index_start_time)
+            )
+        )
 
         session.commit()
 
@@ -197,25 +222,30 @@ class LocalMediaSearcher(MediaSearcher):
             dir_record = self._get_or_create_dir_entry(session, media_dir)
 
             if self._has_directory_changed_since_last_indexing(dir_record):
-                self.logger.info('{} has changed since last indexing, '.format(
-                    media_dir) + 're-indexing')
+                self.logger.info(
+                    '{} has changed since last indexing, '.format(media_dir)
+                    + 're-indexing'
+                )
 
                 self.scan(media_dir, session=session, dir_record=dir_record)
 
-            query_tokens = [_.lower() for _ in re.split(
-                self._filename_separators, query.strip())]
+            query_tokens = [
+                _.lower() for _ in re.split(self._filename_separators, query.strip())
+            ]
 
-            for file_record in session.query(MediaFile.path). \
-                    join(MediaFileToken). \
-                    join(MediaToken). \
-                    filter(MediaToken.token.in_(query_tokens)). \
-                    group_by(MediaFile.path). \
-                    having(func.count(MediaFileToken.token_id) >= len(query_tokens)):
+            for file_record in (
+                session.query(MediaFile.path)
+                .join(MediaFileToken)
+                .join(MediaToken)
+                .filter(MediaToken.token.in_(query_tokens))
+                .group_by(MediaFile.path)
+                .having(func.count(MediaFileToken.token_id) >= len(query_tokens))
+            ):
                 if os.path.isfile(file_record.path):
                     results[file_record.path] = {
                         'url': 'file://' + file_record.path,
                         'title': os.path.basename(file_record.path),
-                        'size': os.path.getsize(file_record.path)
+                        'size': os.path.getsize(file_record.path),
                     }
 
         return results.values()
@@ -223,11 +253,12 @@ class LocalMediaSearcher(MediaSearcher):
 
 # --- Table definitions
 
+
 class MediaDirectory(Base):
-    """ Models the MediaDirectory table """
+    """Models the MediaDirectory table"""
 
     __tablename__ = 'MediaDirectory'
-    __table_args__ = ({'sqlite_autoincrement': True})
+    __table_args__ = {'sqlite_autoincrement': True}
 
     id = Column(Integer, primary_key=True)
     path = Column(String)
@@ -243,14 +274,15 @@ class MediaDirectory(Base):
 
 
 class MediaFile(Base):
-    """ Models the MediaFile table """
+    """Models the MediaFile table"""
 
     __tablename__ = 'MediaFile'
-    __table_args__ = ({'sqlite_autoincrement': True})
+    __table_args__ = {'sqlite_autoincrement': True}
 
     id = Column(Integer, primary_key=True)
-    directory_id = Column(Integer, ForeignKey(
-        'MediaDirectory.id', ondelete='CASCADE'), nullable=False)
+    directory_id = Column(
+        Integer, ForeignKey('MediaDirectory.id', ondelete='CASCADE'), nullable=False
+    )
     path = Column(String, nullable=False, unique=True)
     indexed_at = Column(DateTime)
 
@@ -265,10 +297,10 @@ class MediaFile(Base):
 
 
 class MediaToken(Base):
-    """ Models the MediaToken table """
+    """Models the MediaToken table"""
 
     __tablename__ = 'MediaToken'
-    __table_args__ = ({'sqlite_autoincrement': True})
+    __table_args__ = {'sqlite_autoincrement': True}
 
     id = Column(Integer, primary_key=True)
     token = Column(String, nullable=False, unique=True)
@@ -282,14 +314,16 @@ class MediaToken(Base):
 
 
 class MediaFileToken(Base):
-    """ Models the MediaFileToken table """
+    """Models the MediaFileToken table"""
 
     __tablename__ = 'MediaFileToken'
 
-    file_id = Column(Integer, ForeignKey('MediaFile.id', ondelete='CASCADE'),
-                     nullable=False)
-    token_id = Column(Integer, ForeignKey('MediaToken.id', ondelete='CASCADE'),
-                      nullable=False)
+    file_id = Column(
+        Integer, ForeignKey('MediaFile.id', ondelete='CASCADE'), nullable=False
+    )
+    token_id = Column(
+        Integer, ForeignKey('MediaToken.id', ondelete='CASCADE'), nullable=False
+    )
 
     __table_args__ = (PrimaryKeyConstraint(file_id, token_id), {})
 
@@ -301,4 +335,5 @@ class MediaFileToken(Base):
         record.token_id = token_id
         return record
 
+
 # vim:sw=4:ts=4:et:
diff --git a/platypush/plugins/ntfy/__init__.py b/platypush/plugins/ntfy/__init__.py
index 064275723..9684b2b15 100644
--- a/platypush/plugins/ntfy/__init__.py
+++ b/platypush/plugins/ntfy/__init__.py
@@ -241,7 +241,7 @@ class NtfyPlugin(RunnablePlugin):
             args['headers'] = {
                 'Filename': filename,
                 **({'X-Title': title} if title else {}),
-                **({'X-Click': click_url} if click_url else {}),
+                **({'X-Click': url} if url else {}),
                 **({'X-Email': email} if email else {}),
                 **({'X-Priority': priority} if priority else {}),
                 **({'X-Tags': ','.join(tags)} if tags else {}),
@@ -256,7 +256,7 @@ class NtfyPlugin(RunnablePlugin):
                 'topic': topic,
                 'message': message,
                 **({'title': title} if title else {}),
-                **({'click': click_url} if click_url else {}),
+                **({'click': url} if url else {}),
                 **({'email': email} if email else {}),
                 **({'priority': priority} if priority else {}),
                 **({'tags': tags} if tags else {}),
diff --git a/platypush/plugins/smartthings/__init__.py b/platypush/plugins/smartthings/__init__.py
index 4853f2533..36df8ae67 100644
--- a/platypush/plugins/smartthings/__init__.py
+++ b/platypush/plugins/smartthings/__init__.py
@@ -2,13 +2,17 @@ import asyncio
 import aiohttp
 
 from threading import RLock
-from typing import Optional, Dict, List, Union
+from typing import Optional, Dict, List, Union, Iterable
 
+from platypush.entities import manages
+from platypush.entities.lights import Light
+from platypush.entities.switches import Switch
 from platypush.plugins import action
-from platypush.plugins.switch import SwitchPlugin
+from platypush.plugins.switch import Plugin
 
 
-class SmartthingsPlugin(SwitchPlugin):
+@manages(Switch, Light)
+class SmartthingsPlugin(Plugin):
     """
     Plugin to interact with devices and locations registered to a Samsung SmartThings account.
 
@@ -18,7 +22,7 @@ class SmartthingsPlugin(SwitchPlugin):
 
     """
 
-    _timeout = aiohttp.ClientTimeout(total=20.)
+    _timeout = aiohttp.ClientTimeout(total=20.0)
 
     def __init__(self, access_token: str, **kwargs):
         """
@@ -43,46 +47,27 @@ class SmartthingsPlugin(SwitchPlugin):
 
     async def _refresh_locations(self, api):
         self._locations = await api.locations()
-
-        self._locations_by_id = {
-            loc.location_id: loc
-            for loc in self._locations
-        }
-
-        self._locations_by_name = {
-            loc.name: loc
-            for loc in self._locations
-        }
+        self._locations_by_id = {loc.location_id: loc for loc in self._locations}
+        self._locations_by_name = {loc.name: loc for loc in self._locations}
 
     async def _refresh_devices(self, api):
         self._devices = await api.devices()
-
-        self._devices_by_id = {
-            dev.device_id: dev
-            for dev in self._devices
-        }
-
-        self._devices_by_name = {
-            dev.label: dev
-            for dev in self._devices
-        }
+        self._devices_by_id = {dev.device_id: dev for dev in self._devices}
+        self._devices_by_name = {dev.label: dev for dev in self._devices}
 
     async def _refresh_rooms(self, api, location_id: str):
         self._rooms_by_location[location_id] = await api.rooms(location_id=location_id)
 
-        self._rooms_by_id.update(**{
-            room.room_id: room
-            for room in self._rooms_by_location[location_id]
-        })
+        self._rooms_by_id.update(
+            **{room.room_id: room for room in self._rooms_by_location[location_id]}
+        )
 
         self._rooms_by_location_and_id[location_id] = {
-            room.room_id: room
-            for room in self._rooms_by_location[location_id]
+            room.room_id: room for room in self._rooms_by_location[location_id]
         }
 
         self._rooms_by_location_and_name[location_id] = {
-            room.name: room
-            for room in self._rooms_by_location[location_id]
+            room.name: room for room in self._rooms_by_location[location_id]
         }
 
     async def _refresh_info(self):
@@ -127,7 +112,7 @@ class SmartthingsPlugin(SwitchPlugin):
             'rooms': {
                 room.room_id: self._room_to_dict(room)
                 for room in self._rooms_by_location.get(location.location_id, {})
-            }
+            },
         }
 
     @staticmethod
@@ -257,12 +242,18 @@ class SmartthingsPlugin(SwitchPlugin):
         """
         self.refresh_info()
         return {
-            'locations': {loc.location_id: self._location_to_dict(loc) for loc in self._locations},
-            'devices': {dev.device_id: self._device_to_dict(dev) for dev in self._devices},
+            'locations': {
+                loc.location_id: self._location_to_dict(loc) for loc in self._locations
+            },
+            'devices': {
+                dev.device_id: self._device_to_dict(dev) for dev in self._devices
+            },
         }
 
     @action
-    def get_location(self, location_id: Optional[str] = None, name: Optional[str] = None) -> dict:
+    def get_location(
+        self, location_id: Optional[str] = None, name: Optional[str] = None
+    ) -> dict:
         """
         Get the info of a location by ID or name.
 
@@ -296,20 +287,37 @@ class SmartthingsPlugin(SwitchPlugin):
 
         """
         assert location_id or name, 'Specify either location_id or name'
-        if location_id not in self._locations_by_id or name not in self._locations_by_name:
+        if (
+            location_id not in self._locations_by_id
+            or name not in self._locations_by_name
+        ):
             self.refresh_info()
 
-        location = self._locations_by_id.get(location_id, self._locations_by_name.get(name))
+        location = self._locations_by_id.get(
+            location_id, self._locations_by_name.get(name)
+        )
         assert location, 'Location {} not found'.format(location_id or name)
         return self._location_to_dict(location)
 
     def _get_device(self, device: str):
-        if device not in self._devices_by_id or device not in self._devices_by_name:
+        return self._get_devices(device)[0]
+
+    def _get_devices(self, *devices: str):
+        def get_found_and_missing_devs():
+            found_devs = [
+                self._devices_by_id.get(d, self._devices_by_name.get(d))
+                for d in devices
+            ]
+            missing_devs = [d for i, d in enumerate(devices) if not found_devs[i]]
+            return found_devs, missing_devs
+
+        devs, missing_devs = get_found_and_missing_devs()
+        if missing_devs:
             self.refresh_info()
 
-        device = self._devices_by_id.get(device, self._devices_by_name.get(device))
-        assert device, 'Device {} not found'.format(device)
-        return device
+        devs, missing_devs = get_found_and_missing_devs()
+        assert not missing_devs, f'Devices not found: {missing_devs}'
+        return devs
 
     @action
     def get_device(self, device: str) -> dict:
@@ -340,24 +348,41 @@ class SmartthingsPlugin(SwitchPlugin):
         device = self._get_device(device)
         return self._device_to_dict(device)
 
-    async def _execute(self, device_id: str, capability: str, command, component_id: str, args: Optional[list]):
+    async def _execute(
+        self,
+        device_id: str,
+        capability: str,
+        command,
+        component_id: str,
+        args: Optional[list],
+    ):
         import pysmartthings
 
         async with aiohttp.ClientSession(timeout=self._timeout) as session:
             api = pysmartthings.SmartThings(session, self._access_token)
             device = await api.device(device_id)
-            ret = await device.command(component_id=component_id, capability=capability, command=command, args=args)
+            ret = await device.command(
+                component_id=component_id,
+                capability=capability,
+                command=command,
+                args=args,
+            )
 
-        assert ret, 'The command {capability}={command} failed on device {device}'.format(
-            capability=capability, command=command, device=device_id)
+        assert (
+            ret
+        ), 'The command {capability}={command} failed on device {device}'.format(
+            capability=capability, command=command, device=device_id
+        )
 
     @action
-    def execute(self,
-                device: str,
-                capability: str,
-                command,
-                component_id: str = 'main',
-                args: Optional[list] = None):
+    def execute(
+        self,
+        device: str,
+        capability: str,
+        command,
+        component_id: str = 'main',
+        args: Optional[list] = None,
+    ):
         """
         Execute a command on a device.
 
@@ -388,16 +413,89 @@ class SmartthingsPlugin(SwitchPlugin):
             loop = asyncio.new_event_loop()
             try:
                 asyncio.set_event_loop(loop)
-                loop.run_until_complete(self._execute(
-                    device_id=device.device_id, capability=capability, command=command,
-                    component_id=component_id, args=args))
+                loop.run_until_complete(
+                    self._execute(
+                        device_id=device.device_id,
+                        capability=capability,
+                        command=command,
+                        component_id=component_id,
+                        args=args,
+                    )
+                )
             finally:
                 loop.stop()
 
     @staticmethod
-    async def _get_device_status(api, device_id: str) -> dict:
+    def _is_light(device):
+        if isinstance(device, dict):
+            capabilities = device.get('capabilities', [])
+        else:
+            capabilities = device.capabilities
+
+        return 'colorControl' in capabilities or 'colorTemperature' in capabilities
+
+    def transform_entities(self, entities):
+        from platypush.entities.switches import Switch
+
+        compatible_entities = []
+
+        for device in entities:
+            data = {
+                'location_id': getattr(device, 'location_id', None),
+                'room_id': getattr(device, 'room_id', None),
+            }
+
+            if self._is_light(device):
+                light_attrs = {
+                    'id': device.device_id,
+                    'name': device.label,
+                    'data': data,
+                }
+
+                if 'switch' in device.capabilities:
+                    light_attrs['on'] = device.status.switch
+                if getattr(device.status, 'level', None) is not None:
+                    light_attrs['brightness'] = device.status.level
+                    light_attrs['brightness_min'] = 0
+                    light_attrs['brightness_max'] = 100
+                if 'colorTemperature' in device.capabilities:
+                    # Color temperature range on SmartThings is expressed in Kelvin
+                    light_attrs['temperature_min'] = 2000
+                    light_attrs['temperature_max'] = 6500
+                    if (
+                        device.status.color_temperature
+                        >= light_attrs['temperature_min']
+                    ):
+                        light_attrs['temperature'] = (
+                            light_attrs['temperature_max']
+                            - light_attrs['temperature_min']
+                        ) / 2
+                if getattr(device.status, 'hue', None) is not None:
+                    light_attrs['hue'] = device.status.hue
+                    light_attrs['hue_min'] = 0
+                    light_attrs['hue_max'] = 100
+                if getattr(device.status, 'saturation', None) is not None:
+                    light_attrs['saturation'] = device.status.saturation
+                    light_attrs['saturation_min'] = 0
+                    light_attrs['saturation_max'] = 80
+
+                compatible_entities.append(Light(**light_attrs))
+            elif 'switch' in device.capabilities:
+                compatible_entities.append(
+                    Switch(
+                        id=device.device_id,
+                        name=device.label,
+                        state=device.status.switch,
+                        data=data,
+                    )
+                )
+
+        return super().transform_entities(compatible_entities)  # type: ignore
+
+    async def _get_device_status(self, api, device_id: str) -> dict:
         device = await api.device(device_id)
         await device.status.refresh()
+        self.publish_entities([device])  # type: ignore
 
         return {
             'device_id': device_id,
@@ -407,7 +505,7 @@ class SmartthingsPlugin(SwitchPlugin):
                 for cap in device.capabilities
                 if hasattr(device.status, cap)
                 and not callable(getattr(device.status, cap))
-            }
+            },
         }
 
     async def _refresh_status(self, devices: List[str]) -> List[dict]:
@@ -434,7 +532,9 @@ class SmartthingsPlugin(SwitchPlugin):
             parse_device_id(dev)
 
         # Fail if some devices haven't been found after refreshing
-        assert not missing_device_ids, 'Could not find the following devices: {}'.format(list(missing_device_ids))
+        assert (
+            not missing_device_ids
+        ), 'Could not find the following devices: {}'.format(list(missing_device_ids))
 
         async with aiohttp.ClientSession(timeout=self._timeout) as session:
             api = pysmartthings.SmartThings(session, self._access_token)
@@ -489,7 +589,7 @@ class SmartthingsPlugin(SwitchPlugin):
                 loop.stop()
 
     @action
-    def on(self, device: str, *args, **kwargs) -> dict:
+    def on(self, device: str, *_, **__) -> dict:
         """
         Turn on a device with ``switch`` capability.
 
@@ -497,11 +597,10 @@ class SmartthingsPlugin(SwitchPlugin):
         :return: Device status
         """
         self.execute(device, 'switch', 'on')
-        # noinspection PyUnresolvedReferences
-        return self.status(device).output[0]
+        return self.status(device).output[0]  # type: ignore
 
     @action
-    def off(self, device: str, *args, **kwargs) -> dict:
+    def off(self, device: str, *_, **__) -> dict:
         """
         Turn off a device with ``switch`` capability.
 
@@ -509,11 +608,10 @@ class SmartthingsPlugin(SwitchPlugin):
         :return: Device status
         """
         self.execute(device, 'switch', 'off')
-        # noinspection PyUnresolvedReferences
-        return self.status(device).output[0]
+        return self.status(device).output[0]  # type: ignore
 
     @action
-    def toggle(self, device: str, *args, **kwargs) -> dict:
+    def toggle(self, device: str, *args, **__) -> dict:
         """
         Toggle a device with ``switch`` capability.
 
@@ -529,22 +627,28 @@ class SmartthingsPlugin(SwitchPlugin):
             async with aiohttp.ClientSession(timeout=self._timeout) as session:
                 api = pysmartthings.SmartThings(session, self._access_token)
                 dev = await api.device(device_id)
-                assert 'switch' in dev.capabilities, 'The device {} has no switch capability'.format(dev.label)
+                assert (
+                    'switch' in dev.capabilities
+                ), 'The device {} has no switch capability'.format(dev.label)
 
                 await dev.status.refresh()
                 state = 'off' if dev.status.switch else 'on'
-                ret = await dev.command(component_id='main', capability='switch', command=state, args=args)
+                ret = await dev.command(
+                    component_id='main', capability='switch', command=state, args=args
+                )
 
-            assert ret, 'The command switch={state} failed on device {device}'.format(state=state, device=dev.label)
-            return not dev.status.switch
+            assert ret, 'The command switch={state} failed on device {device}'.format(
+                state=state, device=dev.label
+            )
 
         with self._refresh_lock:
             loop = asyncio.new_event_loop()
-            state = loop.run_until_complete(_toggle())
+            loop.run_until_complete(_toggle())
+            device = loop.run_until_complete(self._refresh_status([device_id]))[0]  # type: ignore
             return {
                 'id': device_id,
-                'name': device.label,
-                'on': state,
+                'name': device['name'],
+                'on': device['switch'],
             }
 
     @property
@@ -569,8 +673,7 @@ class SmartthingsPlugin(SwitchPlugin):
             ]
 
         """
-        # noinspection PyUnresolvedReferences
-        devices = self.status().output
+        devices = self.status().output  # type: ignore
         return [
             {
                 'name': device['name'],
@@ -578,8 +681,65 @@ class SmartthingsPlugin(SwitchPlugin):
                 'on': device['switch'],
             }
             for device in devices
-            if 'switch' in device
+            if 'switch' in device and not self._is_light(device)
         ]
 
+    @action
+    def set_level(self, device: str, level: int, **kwargs):
+        """
+        Set the level of a device with ``switchLevel`` capabilities (e.g. the
+        brightness of a lightbulb or the speed of a fan).
+
+        :param device: Device ID or name.
+        :param level: Level, usually a percentage value between 0 and 1.
+        :param kwarsg: Extra arguments that should be passed to :meth:`.execute`.
+        """
+        self.execute(device, 'switchLevel', 'setLevel', args=[int(level)], **kwargs)
+
+    @action
+    def set_lights(
+        self,
+        lights: Iterable[str],
+        on: Optional[bool] = None,
+        brightness: Optional[int] = None,
+        hue: Optional[int] = None,
+        saturation: Optional[int] = None,
+        hex: Optional[str] = None,
+        temperature: Optional[int] = None,
+        **_,
+    ):
+        err = None
+
+        with self._execute_lock:
+            for light in lights:
+                try:
+                    if on is not None:
+                        self.execute(light, 'switch', 'on' if on else 'off')
+                    if brightness is not None:
+                        self.execute(
+                            light, 'switchLevel', 'setLevel', args=[brightness]
+                        )
+                    if hue is not None:
+                        self.execute(light, 'colorControl', 'setHue', args=[hue])
+                    if saturation is not None:
+                        self.execute(
+                            light, 'colorControl', 'setSaturation', args=[saturation]
+                        )
+                    if temperature is not None:
+                        self.execute(
+                            light,
+                            'colorTemperature',
+                            'setColorTemperature',
+                            args=[temperature],
+                        )
+                    if hex is not None:
+                        self.execute(light, 'colorControl', 'setColor', args=[hex])
+                except Exception as e:
+                    self.logger.error('Could not set attributes on %s: %s', light, e)
+                    err = e
+
+            if err:
+                raise err
+
 
 # vim:sw=4:ts=4:et:
diff --git a/platypush/plugins/switch/__init__.py b/platypush/plugins/switch/__init__.py
index e73513d90..4b33f7e75 100644
--- a/platypush/plugins/switch/__init__.py
+++ b/platypush/plugins/switch/__init__.py
@@ -1,9 +1,12 @@
 from abc import ABC, abstractmethod
 from typing import List, Union
 
+from platypush.entities import manages
+from platypush.entities.switches import Switch
 from platypush.plugins import Plugin, action
 
 
+@manages(Switch)
 class SwitchPlugin(Plugin, ABC):
     """
     Abstract class for interacting with switch devices
@@ -46,7 +49,7 @@ class SwitchPlugin(Plugin, ABC):
         return devices
 
     @action
-    def status(self, device=None, *args, **kwargs) -> Union[dict, List[dict]]:
+    def status(self, device=None, *_, **__) -> Union[dict, List[dict]]:
         """
         Get the status of all the devices, or filter by device name or ID (alias for :meth:`.switch_status`).
 
diff --git a/platypush/plugins/switch/tplink/__init__.py b/platypush/plugins/switch/tplink/__init__.py
index 47470d357..1b1c646e1 100644
--- a/platypush/plugins/switch/tplink/__init__.py
+++ b/platypush/plugins/switch/tplink/__init__.py
@@ -1,7 +1,15 @@
-from typing import Union, Dict, List
+from typing import Union, Mapping, List, Collection, Optional
 
-from pyHS100 import SmartDevice, SmartPlug, SmartBulb, SmartStrip, Discover, SmartDeviceException
+from pyHS100 import (
+    SmartDevice,
+    SmartPlug,
+    SmartBulb,
+    SmartStrip,
+    Discover,
+    SmartDeviceException,
+)
 
+from platypush.entities import Entity
 from platypush.plugins import action
 from platypush.plugins.switch import SwitchPlugin
 
@@ -20,8 +28,13 @@ class SwitchTplinkPlugin(SwitchPlugin):
     _ip_to_dev = {}
     _alias_to_dev = {}
 
-    def __init__(self, plugs: Union[Dict[str, str], List[str]] = None, bulbs: Union[Dict[str, str], List[str]] = None,
-                 strips: Union[Dict[str, str], List[str]] = None, **kwargs):
+    def __init__(
+        self,
+        plugs: Optional[Union[Mapping[str, str], List[str]]] = None,
+        bulbs: Optional[Union[Mapping[str, str], List[str]]] = None,
+        strips: Optional[Union[Mapping[str, str], List[str]]] = None,
+        **kwargs
+    ):
         """
         :param plugs: Optional list of IP addresses or name->address mapping if you have a static list of
             TpLink plugs and you want to save on the scan time.
@@ -62,19 +75,44 @@ class SwitchTplinkPlugin(SwitchPlugin):
 
         self._update_devices()
 
-    def _update_devices(self, devices: Dict[str, SmartDevice] = None):
+    def _update_devices(self, devices: Optional[Mapping[str, SmartDevice]] = None):
         for (addr, info) in self._static_devices.items():
             try:
                 dev = info['type'](addr)
                 self._alias_to_dev[info.get('name', dev.alias)] = dev
                 self._ip_to_dev[addr] = dev
             except SmartDeviceException as e:
-                self.logger.warning('Could not communicate with device {}: {}'.format(addr, str(e)))
+                self.logger.warning(
+                    'Could not communicate with device {}: {}'.format(addr, str(e))
+                )
 
         for (ip, dev) in (devices or {}).items():
             self._ip_to_dev[ip] = dev
             self._alias_to_dev[dev.alias] = dev
 
+        if devices:
+            self.publish_entities(devices.values())  # type: ignore
+
+    def transform_entities(self, devices: Collection[SmartDevice]):
+        from platypush.entities.switches import Switch
+
+        return super().transform_entities(  # type: ignore
+            [
+                Switch(
+                    id=dev.host,
+                    name=dev.alias,
+                    state=dev.is_on,
+                    data={
+                        'current_consumption': dev.current_consumption(),
+                        'ip': dev.host,
+                        'host': dev.host,
+                        'hw_info': dev.hw_info,
+                    },
+                )
+                for dev in (devices or [])
+            ]
+        )
+
     def _scan(self):
         devices = Discover.discover()
         self._update_devices(devices)
@@ -84,6 +122,9 @@ class SwitchTplinkPlugin(SwitchPlugin):
         if not use_cache:
             self._scan()
 
+        if isinstance(device, Entity):
+            device = device.external_id or device.name
+
         if device in self._ip_to_dev:
             return self._ip_to_dev[device]
 
@@ -95,8 +136,15 @@ class SwitchTplinkPlugin(SwitchPlugin):
         else:
             raise RuntimeError('Device {} not found'.format(device))
 
+    def _set(self, device: SmartDevice, state: bool):
+        action_name = 'turn_on' if state else 'turn_off'
+        action = getattr(device, action_name)
+        action()
+        self.publish_entities([device])  # type: ignore
+        return self._serialize(device)
+
     @action
-    def on(self, device, **kwargs):
+    def on(self, device, **_):
         """
         Turn on a device
 
@@ -105,11 +153,10 @@ class SwitchTplinkPlugin(SwitchPlugin):
         """
 
         device = self._get_device(device)
-        device.turn_on()
-        return self.status(device)
+        return self._set(device, True)
 
     @action
-    def off(self, device, **kwargs):
+    def off(self, device, **_):
         """
         Turn off a device
 
@@ -118,11 +165,10 @@ class SwitchTplinkPlugin(SwitchPlugin):
         """
 
         device = self._get_device(device)
-        device.turn_off()
-        return self.status(device)
+        return self._set(device, False)
 
     @action
-    def toggle(self, device, **kwargs):
+    def toggle(self, device, **_):
         """
         Toggle the state of a device (on/off)
 
@@ -131,12 +177,10 @@ class SwitchTplinkPlugin(SwitchPlugin):
         """
 
         device = self._get_device(device)
+        return self._set(device, not device.is_on)
 
-        if device.is_on:
-            device.turn_off()
-        else:
-            device.turn_on()
-
+    @staticmethod
+    def _serialize(device: SmartDevice) -> dict:
         return {
             'current_consumption': device.current_consumption(),
             'id': device.host,
@@ -149,17 +193,7 @@ class SwitchTplinkPlugin(SwitchPlugin):
 
     @property
     def switches(self) -> List[dict]:
-        return [
-            {
-                'current_consumption': dev.current_consumption(),
-                'id': ip,
-                'ip': ip,
-                'host': dev.host,
-                'hw_info': dev.hw_info,
-                'name': dev.alias,
-                'on': dev.is_on,
-            } for (ip, dev) in self._scan().items()
-        ]
+        return [self._serialize(dev) for dev in self._scan().values()]
 
 
 # vim:sw=4:ts=4:et:
diff --git a/platypush/plugins/switch/wemo/__init__.py b/platypush/plugins/switch/wemo/__init__.py
index 312c1c31f..e879c0466 100644
--- a/platypush/plugins/switch/wemo/__init__.py
+++ b/platypush/plugins/switch/wemo/__init__.py
@@ -1,9 +1,11 @@
+import contextlib
 import ipaddress
-from typing import List
+from typing import List, Optional
 
 from platypush.plugins import action
 from platypush.plugins.switch import SwitchPlugin
 from platypush.utils.workers import Workers
+
 from .lib import WemoRunner
 from .scanner import Scanner
 
@@ -16,7 +18,13 @@ class SwitchWemoPlugin(SwitchPlugin):
 
     _default_port = 49153
 
-    def __init__(self, devices=None, netmask: str = None, port: int = _default_port, **kwargs):
+    def __init__(
+        self,
+        devices=None,
+        netmask: Optional[str] = None,
+        port: int = _default_port,
+        **kwargs
+    ):
         """
         :param devices: List of IP addresses or name->address map containing the WeMo Switch devices to control.
             This plugin previously used ouimeaux for auto-discovery but it's been dropped because
@@ -37,8 +45,11 @@ class SwitchWemoPlugin(SwitchPlugin):
 
     def _init_devices(self, devices):
         if devices:
-            self._devices.update(devices if isinstance(devices, dict) else
-                                 {addr: addr for addr in devices})
+            self._devices.update(
+                devices
+                if isinstance(devices, dict)
+                else {addr: addr for addr in devices}
+            )
         else:
             self._devices = {}
 
@@ -68,37 +79,53 @@ class SwitchWemoPlugin(SwitchPlugin):
         """
 
         return [
-                self.status(device).output
-                for device in self._devices.values()
+            self.status(device).output  # type: ignore
+            for device in self._devices.values()
         ]
 
     def _get_address(self, device: str) -> str:
         if device not in self._addresses:
-            try:
+            with contextlib.suppress(KeyError):
                 return self._devices[device]
-            except KeyError:
-                pass
 
         return device
 
     @action
-    def status(self, device: str = None, *args, **kwargs):
+    def status(self, device: Optional[str] = None, *_, **__):
         devices = {device: device} if device else self._devices.copy()
 
         ret = [
             {
-                'id': addr,
-                'ip': addr,
-                'name': name if name != addr else WemoRunner.get_name(addr),
-                'on': WemoRunner.get_state(addr),
+                "id": addr,
+                "ip": addr,
+                "name": name if name != addr else WemoRunner.get_name(addr),
+                "on": WemoRunner.get_state(addr),
             }
             for (name, addr) in devices.items()
         ]
 
+        self.publish_entities(ret)  # type: ignore
         return ret[0] if device else ret
 
+    def transform_entities(self, devices: List[dict]):
+        from platypush.entities.switches import Switch
+
+        return super().transform_entities(  # type: ignore
+            [
+                Switch(
+                    id=dev["id"],
+                    name=dev["name"],
+                    state=dev["on"],
+                    data={
+                        "ip": dev["ip"],
+                    },
+                )
+                for dev in (devices or [])
+            ]
+        )
+
     @action
-    def on(self, device: str, **kwargs):
+    def on(self, device: str, **_):
         """
         Turn a switch on
 
@@ -109,7 +136,7 @@ class SwitchWemoPlugin(SwitchPlugin):
         return self.status(device)
 
     @action
-    def off(self, device: str, **kwargs):
+    def off(self, device: str, **_):
         """
         Turn a switch off
 
@@ -120,7 +147,7 @@ class SwitchWemoPlugin(SwitchPlugin):
         return self.status(device)
 
     @action
-    def toggle(self, device: str, *args, **kwargs):
+    def toggle(self, device: str, *_, **__):
         """
         Toggle a device on/off state
 
@@ -151,19 +178,16 @@ class SwitchWemoPlugin(SwitchPlugin):
         return WemoRunner.get_name(device)
 
     @action
-    def scan(self, netmask: str = None):
+    def scan(self, netmask: Optional[str] = None):
         netmask = netmask or self.netmask
-        assert netmask, 'Scan not supported: No netmask specified'
+        assert netmask, "Scan not supported: No netmask specified"
 
         workers = Workers(10, Scanner, port=self.port)
         with workers:
             for addr in ipaddress.IPv4Network(netmask):
                 workers.put(addr.exploded)
 
-        devices = {
-            dev.name: dev.addr
-            for dev in workers.responses
-        }
+        devices = {dev.name: dev.addr for dev in workers.responses}
 
         self._init_devices(devices)
         return self.status()
diff --git a/platypush/plugins/switchbot/__init__.py b/platypush/plugins/switchbot/__init__.py
index 6545f8873..8d7b0e602 100644
--- a/platypush/plugins/switchbot/__init__.py
+++ b/platypush/plugins/switchbot/__init__.py
@@ -43,16 +43,21 @@ class SwitchbotPlugin(SwitchPlugin):
         return url
 
     def _run(self, method: str = 'get', *args, device=None, **kwargs):
-        response = getattr(requests, method)(self._url_for(*args, device=device), headers={
-            'Authorization': self._api_token,
-            'Accept': 'application/json',
-            'Content-Type': 'application/json; charset=utf-8',
-        }, **kwargs)
+        response = getattr(requests, method)(
+            self._url_for(*args, device=device),
+            headers={
+                'Authorization': self._api_token,
+                'Accept': 'application/json',
+                'Content-Type': 'application/json; charset=utf-8',
+            },
+            **kwargs,
+        )
 
         response.raise_for_status()
         response = response.json()
-        assert response.get('statusCode') == 100, \
-            f'Switchbot API request failed: {response.get("statusCode")}: {response.get("message")}'
+        assert (
+            response.get('statusCode') == 100
+        ), f'Switchbot API request failed: {response.get("statusCode")}: {response.get("message")}'
 
         return response.get('body')
 
@@ -77,16 +82,20 @@ class SwitchbotPlugin(SwitchPlugin):
         """
         devices = self._run('get', 'devices')
         devices = [
-            DeviceSchema().dump({
-                **device,
-                'is_virtual': False,
-            })
+            DeviceSchema().dump(
+                {
+                    **device,
+                    'is_virtual': False,
+                }
+            )
             for device in devices.get('deviceList', [])
         ] + [
-            DeviceSchema().dump({
-                **device,
-                'is_virtual': True,
-            })
+            DeviceSchema().dump(
+                {
+                    **device,
+                    'is_virtual': True,
+                }
+            )
             for device in devices.get('infraredRemoteList', [])
         ]
 
@@ -96,10 +105,44 @@ class SwitchbotPlugin(SwitchPlugin):
 
         return devices
 
-    def _worker(self, q: queue.Queue, method: str = 'get', *args, device: Optional[dict] = None, **kwargs):
+    def transform_entities(self, devices: List[dict]):
+        from platypush.entities.switches import Switch
+
+        return super().transform_entities(  # type: ignore
+            [
+                Switch(
+                    id=dev["id"],
+                    name=dev["name"],
+                    state=dev.get("on"),
+                    is_write_only=True,
+                    data={
+                        "device_type": dev.get("device_type"),
+                        "is_virtual": dev.get("is_virtual", False),
+                        "hub_id": dev.get("hub_id"),
+                    },
+                )
+                for dev in (devices or [])
+                if dev.get('device_type') == 'Bot'
+            ]
+        )
+
+    def _worker(
+        self,
+        q: queue.Queue,
+        method: str = 'get',
+        *args,
+        device: Optional[dict] = None,
+        **kwargs,
+    ):
         schema = DeviceStatusSchema()
         try:
-            if method == 'get' and args and args[0] == 'status' and device and device.get('is_virtual'):
+            if (
+                method == 'get'
+                and args
+                and args[0] == 'status'
+                and device
+                and device.get('is_virtual')
+            ):
                 res = schema.load(device)
             else:
                 res = self._run(method, *args, device=device, **kwargs)
@@ -121,7 +164,11 @@ class SwitchbotPlugin(SwitchPlugin):
         devices = self.devices().output
         if device:
             device_info = self._get_device(device)
-            status = {} if device_info['is_virtual'] else self._run('get', 'status', device=device_info)
+            status = (
+                {}
+                if device_info['is_virtual']
+                else self._run('get', 'status', device=device_info)
+            )
             return {
                 **device_info,
                 **status,
@@ -133,7 +180,7 @@ class SwitchbotPlugin(SwitchPlugin):
             threading.Thread(
                 target=self._worker,
                 args=(queues[i], 'get', 'status'),
-                kwargs={'device': dev}
+                kwargs={'device': dev},
             )
             for i, dev in enumerate(devices)
         ]
@@ -148,14 +195,17 @@ class SwitchbotPlugin(SwitchPlugin):
                 continue
 
             assert not isinstance(response, Exception), str(response)
-            results.append({
-                **devices_by_id.get(response.get('id'), {}),
-                **response,
-            })
+            results.append(
+                {
+                    **devices_by_id.get(response.get('id'), {}),
+                    **response,
+                }
+            )
 
         for worker in workers:
             worker.join()
 
+        self.publish_entities(results)  # type: ignore
         return results
 
     @action
@@ -200,9 +250,7 @@ class SwitchbotPlugin(SwitchPlugin):
     @property
     def switches(self) -> List[dict]:
         # noinspection PyUnresolvedReferences
-        return [
-            dev for dev in self.status().output if 'on' in dev
-        ]
+        return [dev for dev in self.status().output if 'on' in dev]
 
     @action
     def set_curtain_position(self, device: str, position: int):
@@ -213,11 +261,16 @@ class SwitchbotPlugin(SwitchPlugin):
         :param position: An integer between 0 (open) and 100 (closed).
         """
         device = self._get_device(device)
-        return self._run('post', 'commands', device=device, json={
-            'command': 'setPosition',
-            'commandType': 'command',
-            'parameter': f'0,ff,{position}',
-        })
+        return self._run(
+            'post',
+            'commands',
+            device=device,
+            json={
+                'command': 'setPosition',
+                'commandType': 'command',
+                'parameter': f'0,ff,{position}',
+            },
+        )
 
     @action
     def set_humidifier_efficiency(self, device: str, efficiency: Union[int, str]):
@@ -228,11 +281,16 @@ class SwitchbotPlugin(SwitchPlugin):
         :param efficiency: An integer between 0 (open) and 100 (closed) or `auto`.
         """
         device = self._get_device(device)
-        return self._run('post', 'commands', device=device, json={
-            'command': 'setMode',
-            'commandType': 'command',
-            'parameter': efficiency,
-        })
+        return self._run(
+            'post',
+            'commands',
+            device=device,
+            json={
+                'command': 'setMode',
+                'commandType': 'command',
+                'parameter': efficiency,
+            },
+        )
 
     @action
     def set_fan_speed(self, device: str, speed: int):
@@ -246,11 +304,16 @@ class SwitchbotPlugin(SwitchPlugin):
         status = self.status(device=device).output
         mode = status.get('mode')
         swing_range = status.get('swing_range')
-        return self._run('post', 'commands', device=device, json={
-            'command': 'set',
-            'commandType': 'command',
-            'parameter': ','.join(['on', str(mode), str(speed), str(swing_range)]),
-        })
+        return self._run(
+            'post',
+            'commands',
+            device=device,
+            json={
+                'command': 'set',
+                'commandType': 'command',
+                'parameter': ','.join(['on', str(mode), str(speed), str(swing_range)]),
+            },
+        )
 
     @action
     def set_fan_mode(self, device: str, mode: int):
@@ -264,11 +327,16 @@ class SwitchbotPlugin(SwitchPlugin):
         status = self.status(device=device).output
         speed = status.get('speed')
         swing_range = status.get('swing_range')
-        return self._run('post', 'commands', device=device, json={
-            'command': 'set',
-            'commandType': 'command',
-            'parameter': ','.join(['on', str(mode), str(speed), str(swing_range)]),
-        })
+        return self._run(
+            'post',
+            'commands',
+            device=device,
+            json={
+                'command': 'set',
+                'commandType': 'command',
+                'parameter': ','.join(['on', str(mode), str(speed), str(swing_range)]),
+            },
+        )
 
     @action
     def set_swing_range(self, device: str, swing_range: int):
@@ -282,11 +350,16 @@ class SwitchbotPlugin(SwitchPlugin):
         status = self.status(device=device).output
         speed = status.get('speed')
         mode = status.get('mode')
-        return self._run('post', 'commands', device=device, json={
-            'command': 'set',
-            'commandType': 'command',
-            'parameter': ','.join(['on', str(mode), str(speed), str(swing_range)]),
-        })
+        return self._run(
+            'post',
+            'commands',
+            device=device,
+            json={
+                'command': 'set',
+                'commandType': 'command',
+                'parameter': ','.join(['on', str(mode), str(speed), str(swing_range)]),
+            },
+        )
 
     @action
     def set_temperature(self, device: str, temperature: float):
@@ -300,11 +373,18 @@ class SwitchbotPlugin(SwitchPlugin):
         status = self.status(device=device).output
         mode = status.get('mode')
         fan_speed = status.get('fan_speed')
-        return self._run('post', 'commands', device=device, json={
-            'command': 'setAll',
-            'commandType': 'command',
-            'parameter': ','.join([str(temperature), str(mode), str(fan_speed), 'on']),
-        })
+        return self._run(
+            'post',
+            'commands',
+            device=device,
+            json={
+                'command': 'setAll',
+                'commandType': 'command',
+                'parameter': ','.join(
+                    [str(temperature), str(mode), str(fan_speed), 'on']
+                ),
+            },
+        )
 
     @action
     def set_ac_mode(self, device: str, mode: int):
@@ -325,11 +405,18 @@ class SwitchbotPlugin(SwitchPlugin):
         status = self.status(device=device).output
         temperature = status.get('temperature')
         fan_speed = status.get('fan_speed')
-        return self._run('post', 'commands', device=device, json={
-            'command': 'setAll',
-            'commandType': 'command',
-            'parameter': ','.join([str(temperature), str(mode), str(fan_speed), 'on']),
-        })
+        return self._run(
+            'post',
+            'commands',
+            device=device,
+            json={
+                'command': 'setAll',
+                'commandType': 'command',
+                'parameter': ','.join(
+                    [str(temperature), str(mode), str(fan_speed), 'on']
+                ),
+            },
+        )
 
     @action
     def set_ac_fan_speed(self, device: str, fan_speed: int):
@@ -349,11 +436,18 @@ class SwitchbotPlugin(SwitchPlugin):
         status = self.status(device=device).output
         temperature = status.get('temperature')
         mode = status.get('mode')
-        return self._run('post', 'commands', device=device, json={
-            'command': 'setAll',
-            'commandType': 'command',
-            'parameter': ','.join([str(temperature), str(mode), str(fan_speed), 'on']),
-        })
+        return self._run(
+            'post',
+            'commands',
+            device=device,
+            json={
+                'command': 'setAll',
+                'commandType': 'command',
+                'parameter': ','.join(
+                    [str(temperature), str(mode), str(fan_speed), 'on']
+                ),
+            },
+        )
 
     @action
     def set_channel(self, device: str, channel: int):
@@ -364,11 +458,16 @@ class SwitchbotPlugin(SwitchPlugin):
         :param channel: Channel number.
         """
         device = self._get_device(device)
-        return self._run('post', 'commands', device=device, json={
-            'command': 'SetChannel',
-            'commandType': 'command',
-            'parameter': [str(channel)],
-        })
+        return self._run(
+            'post',
+            'commands',
+            device=device,
+            json={
+                'command': 'SetChannel',
+                'commandType': 'command',
+                'parameter': [str(channel)],
+            },
+        )
 
     @action
     def volup(self, device: str):
@@ -378,10 +477,15 @@ class SwitchbotPlugin(SwitchPlugin):
         :param device: Device name or ID.
         """
         device = self._get_device(device)
-        return self._run('post', 'commands', device=device, json={
-            'command': 'volumeAdd',
-            'commandType': 'command',
-        })
+        return self._run(
+            'post',
+            'commands',
+            device=device,
+            json={
+                'command': 'volumeAdd',
+                'commandType': 'command',
+            },
+        )
 
     @action
     def voldown(self, device: str):
@@ -391,10 +495,15 @@ class SwitchbotPlugin(SwitchPlugin):
         :param device: Device name or ID.
         """
         device = self._get_device(device)
-        return self._run('post', 'commands', device=device, json={
-            'command': 'volumeSub',
-            'commandType': 'command',
-        })
+        return self._run(
+            'post',
+            'commands',
+            device=device,
+            json={
+                'command': 'volumeSub',
+                'commandType': 'command',
+            },
+        )
 
     @action
     def mute(self, device: str):
@@ -404,10 +513,15 @@ class SwitchbotPlugin(SwitchPlugin):
         :param device: Device name or ID.
         """
         device = self._get_device(device)
-        return self._run('post', 'commands', device=device, json={
-            'command': 'setMute',
-            'commandType': 'command',
-        })
+        return self._run(
+            'post',
+            'commands',
+            device=device,
+            json={
+                'command': 'setMute',
+                'commandType': 'command',
+            },
+        )
 
     @action
     def channel_next(self, device: str):
@@ -417,10 +531,15 @@ class SwitchbotPlugin(SwitchPlugin):
         :param device: Device name or ID.
         """
         device = self._get_device(device)
-        return self._run('post', 'commands', device=device, json={
-            'command': 'channelAdd',
-            'commandType': 'command',
-        })
+        return self._run(
+            'post',
+            'commands',
+            device=device,
+            json={
+                'command': 'channelAdd',
+                'commandType': 'command',
+            },
+        )
 
     @action
     def channel_prev(self, device: str):
@@ -430,10 +549,15 @@ class SwitchbotPlugin(SwitchPlugin):
         :param device: Device name or ID.
         """
         device = self._get_device(device)
-        return self._run('post', 'commands', device=device, json={
-            'command': 'channelSub',
-            'commandType': 'command',
-        })
+        return self._run(
+            'post',
+            'commands',
+            device=device,
+            json={
+                'command': 'channelSub',
+                'commandType': 'command',
+            },
+        )
 
     @action
     def play(self, device: str):
@@ -443,10 +567,15 @@ class SwitchbotPlugin(SwitchPlugin):
         :param device: Device name or ID.
         """
         device = self._get_device(device)
-        return self._run('post', 'commands', device=device, json={
-            'command': 'Play',
-            'commandType': 'command',
-        })
+        return self._run(
+            'post',
+            'commands',
+            device=device,
+            json={
+                'command': 'Play',
+                'commandType': 'command',
+            },
+        )
 
     @action
     def pause(self, device: str):
@@ -456,10 +585,15 @@ class SwitchbotPlugin(SwitchPlugin):
         :param device: Device name or ID.
         """
         device = self._get_device(device)
-        return self._run('post', 'commands', device=device, json={
-            'command': 'Pause',
-            'commandType': 'command',
-        })
+        return self._run(
+            'post',
+            'commands',
+            device=device,
+            json={
+                'command': 'Pause',
+                'commandType': 'command',
+            },
+        )
 
     @action
     def stop(self, device: str):
@@ -469,10 +603,15 @@ class SwitchbotPlugin(SwitchPlugin):
         :param device: Device name or ID.
         """
         device = self._get_device(device)
-        return self._run('post', 'commands', device=device, json={
-            'command': 'Stop',
-            'commandType': 'command',
-        })
+        return self._run(
+            'post',
+            'commands',
+            device=device,
+            json={
+                'command': 'Stop',
+                'commandType': 'command',
+            },
+        )
 
     @action
     def forward(self, device: str):
@@ -482,10 +621,15 @@ class SwitchbotPlugin(SwitchPlugin):
         :param device: Device name or ID.
         """
         device = self._get_device(device)
-        return self._run('post', 'commands', device=device, json={
-            'command': 'FastForward',
-            'commandType': 'command',
-        })
+        return self._run(
+            'post',
+            'commands',
+            device=device,
+            json={
+                'command': 'FastForward',
+                'commandType': 'command',
+            },
+        )
 
     @action
     def back(self, device: str):
@@ -495,10 +639,15 @@ class SwitchbotPlugin(SwitchPlugin):
         :param device: Device name or ID.
         """
         device = self._get_device(device)
-        return self._run('post', 'commands', device=device, json={
-            'command': 'Rewind',
-            'commandType': 'command',
-        })
+        return self._run(
+            'post',
+            'commands',
+            device=device,
+            json={
+                'command': 'Rewind',
+                'commandType': 'command',
+            },
+        )
 
     @action
     def next(self, device: str):
@@ -508,10 +657,15 @@ class SwitchbotPlugin(SwitchPlugin):
         :param device: Device name or ID.
         """
         device = self._get_device(device)
-        return self._run('post', 'commands', device=device, json={
-            'command': 'Next',
-            'commandType': 'command',
-        })
+        return self._run(
+            'post',
+            'commands',
+            device=device,
+            json={
+                'command': 'Next',
+                'commandType': 'command',
+            },
+        )
 
     @action
     def previous(self, device: str):
@@ -521,10 +675,15 @@ class SwitchbotPlugin(SwitchPlugin):
         :param device: Device name or ID.
         """
         device = self._get_device(device)
-        return self._run('post', 'commands', device=device, json={
-            'command': 'Previous',
-            'commandType': 'command',
-        })
+        return self._run(
+            'post',
+            'commands',
+            device=device,
+            json={
+                'command': 'Previous',
+                'commandType': 'command',
+            },
+        )
 
     @action
     def scenes(self) -> List[dict]:
@@ -544,7 +703,8 @@ class SwitchbotPlugin(SwitchPlugin):
         """
         # noinspection PyUnresolvedReferences
         scenes = [
-            s for s in self.scenes().output
+            s
+            for s in self.scenes().output
             if s.get('id') == scene or s.get('name') == scene
         ]
 
diff --git a/platypush/plugins/switchbot/bluetooth/__init__.py b/platypush/plugins/switchbot/bluetooth/__init__.py
index e441ef651..5da12b3d9 100644
--- a/platypush/plugins/switchbot/bluetooth/__init__.py
+++ b/platypush/plugins/switchbot/bluetooth/__init__.py
@@ -1,6 +1,6 @@
 import enum
 import time
-from typing import List
+from typing import List, Optional
 
 from platypush.message.response.bluetooth import BluetoothScanResponse
 from platypush.plugins import action
@@ -8,7 +8,9 @@ from platypush.plugins.bluetooth.ble import BluetoothBlePlugin
 from platypush.plugins.switch import SwitchPlugin
 
 
-class SwitchbotBluetoothPlugin(SwitchPlugin, BluetoothBlePlugin):   # lgtm [py/missing-call-to-init]
+class SwitchbotBluetoothPlugin(  # lgtm [py/missing-call-to-init]
+    SwitchPlugin, BluetoothBlePlugin
+):
     """
     Plugin to interact with a Switchbot (https://www.switch-bot.com/) device and
     programmatically control switches over a Bluetooth interface.
@@ -31,6 +33,7 @@ class SwitchbotBluetoothPlugin(SwitchPlugin, BluetoothBlePlugin):   # lgtm [py/m
         """
         Base64 encoded commands
         """
+
         # \x57\x01\x00
         PRESS = 'VwEA'
         # # \x57\x01\x01
@@ -38,8 +41,14 @@ class SwitchbotBluetoothPlugin(SwitchPlugin, BluetoothBlePlugin):   # lgtm [py/m
         # # \x57\x01\x02
         OFF = 'VwEC'
 
-    def __init__(self, interface=None, connect_timeout=None,
-                 scan_timeout=2, devices=None, **kwargs):
+    def __init__(
+        self,
+        interface=None,
+        connect_timeout=None,
+        scan_timeout=2,
+        devices=None,
+        **kwargs
+    ):
         """
         :param interface: Bluetooth interface to use (e.g. hci0) default: first available one
         :type interface: str
@@ -59,17 +68,21 @@ class SwitchbotBluetoothPlugin(SwitchPlugin, BluetoothBlePlugin):   # lgtm [py/m
         self.scan_timeout = scan_timeout if scan_timeout else 2
         self.configured_devices = devices or {}
         self.configured_devices_by_name = {
-            name: addr
-            for addr, name in self.configured_devices.items()
+            name: addr for addr, name in self.configured_devices.items()
         }
 
     def _run(self, device: str, command: Command):
-        if device in self.configured_devices_by_name:
-            device = self.configured_devices_by_name[device]
+        device = self.configured_devices_by_name.get(device, '')
         n_tries = 1
 
         try:
-            self.write(device, command.value, handle=self.handle, channel_type='random', binary=True)
+            self.write(
+                device,
+                command.value,
+                handle=self.handle,
+                channel_type='random',
+                binary=True,
+            )
         except Exception as e:
             self.logger.exception(e)
             n_tries -= 1
@@ -78,7 +91,7 @@ class SwitchbotBluetoothPlugin(SwitchPlugin, BluetoothBlePlugin):   # lgtm [py/m
                 raise e
             time.sleep(5)
 
-        return self.status(device)
+        return self.status(device)  # type: ignore
 
     @action
     def press(self, device):
@@ -91,11 +104,11 @@ class SwitchbotBluetoothPlugin(SwitchPlugin, BluetoothBlePlugin):   # lgtm [py/m
         return self._run(device, self.Command.PRESS)
 
     @action
-    def toggle(self, device, **kwargs):
+    def toggle(self, device, **_):
         return self.press(device)
 
     @action
-    def on(self, device, **kwargs):
+    def on(self, device, **_):
         """
         Send a press-on button command to a device
 
@@ -105,7 +118,7 @@ class SwitchbotBluetoothPlugin(SwitchPlugin, BluetoothBlePlugin):   # lgtm [py/m
         return self._run(device, self.Command.ON)
 
     @action
-    def off(self, device, **kwargs):
+    def off(self, device, **_):
         """
         Send a press-off button command to a device
 
@@ -115,7 +128,9 @@ class SwitchbotBluetoothPlugin(SwitchPlugin, BluetoothBlePlugin):   # lgtm [py/m
         return self._run(device, self.Command.OFF)
 
     @action
-    def scan(self, interface: str = None, duration: int = 10) -> BluetoothScanResponse:
+    def scan(
+        self, interface: Optional[str] = None, duration: int = 10
+    ) -> BluetoothScanResponse:
         """
         Scan for available Switchbot devices nearby.
 
@@ -129,9 +144,13 @@ class SwitchbotBluetoothPlugin(SwitchPlugin, BluetoothBlePlugin):   # lgtm [py/m
         for dev in devices:
             try:
                 characteristics = [
-                    chrc for chrc in self.discover_characteristics(
-                        dev['addr'], channel_type='random', wait=False,
-                        timeout=self.scan_timeout).characteristics
+                    chrc
+                    for chrc in self.discover_characteristics(
+                        dev['addr'],
+                        channel_type='random',
+                        wait=False,
+                        timeout=self.scan_timeout,
+                    ).characteristics
                     if chrc.get('uuid') == self.uuid
                 ]
 
@@ -140,10 +159,12 @@ class SwitchbotBluetoothPlugin(SwitchPlugin, BluetoothBlePlugin):   # lgtm [py/m
             except Exception as e:
                 self.logger.warning('Device scan error', e)
 
+        self.publish_entities(compatible_devices)  # type: ignore
         return BluetoothScanResponse(devices=compatible_devices)
 
     @property
     def switches(self) -> List[dict]:
+        self.publish_entities(self.configured_devices)  # type: ignore
         return [
             {
                 'address': addr,
@@ -154,5 +175,20 @@ class SwitchbotBluetoothPlugin(SwitchPlugin, BluetoothBlePlugin):   # lgtm [py/m
             for addr, name in self.configured_devices.items()
         ]
 
+    def transform_entities(self, devices: dict):
+        from platypush.entities.switches import Switch
+
+        return super().transform_entities(  # type: ignore
+            [
+                Switch(
+                    id=addr,
+                    name=name,
+                    state=False,
+                    is_write_only=True,
+                )
+                for addr, name in devices.items()
+            ]
+        )
+
 
 # vim:sw=4:ts=4:et:
diff --git a/platypush/plugins/zigbee/mqtt/__init__.py b/platypush/plugins/zigbee/mqtt/__init__.py
index 65cb2734b..b7a12cffc 100644
--- a/platypush/plugins/zigbee/mqtt/__init__.py
+++ b/platypush/plugins/zigbee/mqtt/__init__.py
@@ -4,12 +4,16 @@ import threading
 from queue import Queue
 from typing import Optional, List, Any, Dict, Union
 
+from platypush.entities import manages
+from platypush.entities.lights import Light
+from platypush.entities.switches import Switch
+from platypush.message import Mapping
 from platypush.message.response import Response
 from platypush.plugins.mqtt import MqttPlugin, action
-from platypush.plugins.switch import SwitchPlugin
 
 
-class ZigbeeMqttPlugin(MqttPlugin, SwitchPlugin):   # lgtm [py/missing-call-to-init]
+@manages(Light, Switch)
+class ZigbeeMqttPlugin(MqttPlugin):  # lgtm [py/missing-call-to-init]
     """
     This plugin allows you to interact with Zigbee devices over MQTT through any Zigbee sniffer and
     `zigbee2mqtt <https://www.zigbee2mqtt.io/>`_.
@@ -35,7 +39,8 @@ class ZigbeeMqttPlugin(MqttPlugin, SwitchPlugin):   # lgtm [py/missing-call-to-i
 
           .. code-block:: shell
 
-              wget https://github.com/Koenkk/Z-Stack-firmware/raw/master/coordinator/Z-Stack_Home_1.2/bin/default/CC2531_DEFAULT_20201127.zip
+              wget https://github.com/Koenkk/Z-Stack-firmware/raw/master\
+                      /coordinator/Z-Stack_Home_1.2/bin/default/CC2531_DEFAULT_20201127.zip
               unzip CC2531_DEFAULT_20201127.zip
               [sudo] cc-tool -e -w CC2531ZNP-Prod.hex
 
@@ -78,7 +83,8 @@ class ZigbeeMqttPlugin(MqttPlugin, SwitchPlugin):   # lgtm [py/missing-call-to-i
           configured your network, to prevent accidental/malignant joins from outer Zigbee devices.
 
         - Start the ``zigbee2mqtt`` daemon on your device (the
-          `official documentation <https://www.zigbee2mqtt.io/getting_started/running_zigbee2mqtt.html#5-optional-running-as-a-daemon-with-systemctl>`_
+          `official documentation <https://www.zigbee2mqtt.io/getting_started
+           /running_zigbee2mqtt.html#5-optional-running-as-a-daemon-with-systemctl>`_
           also contains instructions on how to configure it as a ``systemd`` service:
 
           .. code-block:: shell
@@ -103,10 +109,20 @@ class ZigbeeMqttPlugin(MqttPlugin, SwitchPlugin):   # lgtm [py/missing-call-to-i
 
     """
 
-    def __init__(self, host: str = 'localhost', port: int = 1883, base_topic: str = 'zigbee2mqtt', timeout: int = 10,
-                 tls_certfile: Optional[str] = None, tls_keyfile: Optional[str] = None,
-                 tls_version: Optional[str] = None, tls_ciphers: Optional[str] = None,
-                 username: Optional[str] = None, password: Optional[str] = None, **kwargs):
+    def __init__(
+        self,
+        host: str = 'localhost',
+        port: int = 1883,
+        base_topic: str = 'zigbee2mqtt',
+        timeout: int = 10,
+        tls_certfile: Optional[str] = None,
+        tls_keyfile: Optional[str] = None,
+        tls_version: Optional[str] = None,
+        tls_ciphers: Optional[str] = None,
+        username: Optional[str] = None,
+        password: Optional[str] = None,
+        **kwargs,
+    ):
         """
         :param host: Default MQTT broker where ``zigbee2mqtt`` publishes its messages (default: ``localhost``).
         :param port: Broker listen port (default: 1883).
@@ -124,17 +140,80 @@ class ZigbeeMqttPlugin(MqttPlugin, SwitchPlugin):   # lgtm [py/missing-call-to-i
         :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)
         """
-        super().__init__(host=host, port=port, tls_certfile=tls_certfile, tls_keyfile=tls_keyfile,
-                         tls_version=tls_version, tls_ciphers=tls_ciphers, username=username,
-                         password=password, **kwargs)
+        super().__init__(
+            host=host,
+            port=port,
+            tls_certfile=tls_certfile,
+            tls_keyfile=tls_keyfile,
+            tls_version=tls_version,
+            tls_ciphers=tls_ciphers,
+            username=username,
+            password=password,
+            **kwargs,
+        )
 
         self.base_topic = base_topic
         self.timeout = timeout
         self._info = {
             'devices': {},
             'groups': {},
+            'devices_by_addr': {},
         }
 
+    def transform_entities(self, devices):
+        from platypush.entities.switches import Switch
+
+        compatible_entities = []
+        for dev in devices:
+            if not dev:
+                continue
+
+            converted_entity = None
+            dev_def = dev.get("definition") or {}
+            dev_info = {
+                "type": dev.get("type"),
+                "date_code": dev.get("date_code"),
+                "ieee_address": dev.get("ieee_address"),
+                "network_address": dev.get("network_address"),
+                "power_source": dev.get("power_source"),
+                "software_build_id": dev.get("software_build_id"),
+                "model_id": dev.get("model_id"),
+                "model": dev_def.get("model"),
+                "vendor": dev_def.get("vendor"),
+                "supported": dev.get("supported"),
+            }
+
+            light_info = self._get_light_meta(dev)
+            switch_info = self._get_switch_meta(dev)
+
+            if light_info:
+                converted_entity = Light(
+                    id=dev['ieee_address'],
+                    name=dev.get('friendly_name'),
+                    on=dev.get('state', {}).get('state') == switch_info.get('value_on'),
+                    brightness=dev.get('state', {}).get('brightness'),
+                    brightness_min=light_info.get('brightness_min'),
+                    brightness_max=light_info.get('brightness_max'),
+                    temperature=dev.get('state', {}).get('temperature'),
+                    temperature_min=light_info.get('temperature_min'),
+                    temperature_max=light_info.get('temperature_max'),
+                    description=dev_def.get('description'),
+                    data=dev_info,
+                )
+            elif switch_info and dev.get('state', {}).get('state') is not None:
+                converted_entity = Switch(
+                    id=dev['ieee_address'],
+                    name=dev.get('friendly_name'),
+                    state=dev.get('state', {}).get('state') == switch_info['value_on'],
+                    description=dev_def.get("description"),
+                    data=dev_info,
+                )
+
+            if converted_entity:
+                compatible_entities.append(converted_entity)
+
+        return super().transform_entities(compatible_entities)  # type: ignore
+
     def _get_network_info(self, **kwargs):
         self.logger.info('Fetching Zigbee network information')
         client = None
@@ -157,7 +236,11 @@ class ZigbeeMqttPlugin(MqttPlugin, SwitchPlugin):   # lgtm [py/missing-call-to-i
             def callback(_, __, msg):
                 topic = msg.topic.split('/')[-1]
                 if topic in info:
-                    info[topic] = msg.payload.decode() if topic == 'state' else json.loads(msg.payload.decode())
+                    info[topic] = (
+                        msg.payload.decode()
+                        if topic == 'state'
+                        else json.loads(msg.payload.decode())
+                    )
                     info_ready_events[topic].set()
 
             return callback
@@ -174,7 +257,9 @@ class ZigbeeMqttPlugin(MqttPlugin, SwitchPlugin):   # lgtm [py/missing-call-to-i
             for event in info_ready_events.values():
                 info_ready = event.wait(timeout=timeout)
                 if not info_ready:
-                    raise TimeoutError('A timeout occurred while fetching the Zigbee network information')
+                    raise TimeoutError(
+                        'A timeout occurred while fetching the Zigbee network information'
+                    )
 
             # Cache the new results
             self._info['devices'] = {
@@ -182,9 +267,12 @@ class ZigbeeMqttPlugin(MqttPlugin, SwitchPlugin):   # lgtm [py/missing-call-to-i
                 for device in info.get('devices', [])
             }
 
+            self._info['devices_by_addr'] = {
+                device['ieee_address']: device for device in info.get('devices', [])
+            }
+
             self._info['groups'] = {
-                group.get('name'): group
-                for group in info.get('groups', [])
+                group.get('name'): group for group in info.get('groups', [])
             }
 
             self.logger.info('Zigbee network configuration updated')
@@ -194,7 +282,9 @@ class ZigbeeMqttPlugin(MqttPlugin, SwitchPlugin):   # lgtm [py/missing-call-to-i
                 client.loop_stop()
                 client.disconnect()
             except Exception as e:
-                self.logger.warning('Error on MQTT client disconnection: {}'.format(str(e)))
+                self.logger.warning(
+                    'Error on MQTT client disconnection: {}'.format(str(e))
+                )
 
     def _topic(self, topic):
         return self.base_topic + '/' + topic
@@ -204,7 +294,9 @@ class ZigbeeMqttPlugin(MqttPlugin, SwitchPlugin):   # lgtm [py/missing-call-to-i
         if isinstance(response, Response):
             response = response.output
 
-        assert response.get('status') != 'error', response.get('error', 'zigbee2mqtt error')
+        assert response.get('status') != 'error', response.get(
+            'error', 'zigbee2mqtt error'
+        )
         return response
 
     @action
@@ -291,7 +383,7 @@ class ZigbeeMqttPlugin(MqttPlugin, SwitchPlugin):   # lgtm [py/missing-call-to-i
                                         "value_min": 150
                                     },
                                     {
-                                        "description": "Color of this light in the CIE 1931 color space (x/y)",
+                                        "description": "Color of this light in the XY space",
                                         "features": [
                                             {
                                                 "access": 7,
@@ -315,7 +407,7 @@ class ZigbeeMqttPlugin(MqttPlugin, SwitchPlugin):   # lgtm [py/missing-call-to-i
                             },
                             {
                                 "access": 2,
-                                "description": "Triggers an effect on the light (e.g. make light blink for a few seconds)",
+                                "description": "Triggers an effect on the light",
                                 "name": "effect",
                                 "property": "effect",
                                 "type": "enum",
@@ -382,7 +474,9 @@ class ZigbeeMqttPlugin(MqttPlugin, SwitchPlugin):   # lgtm [py/missing-call-to-i
         return self._get_network_info(**kwargs).get('devices')
 
     @action
-    def permit_join(self, permit: bool = True, timeout: Optional[float] = None, **kwargs):
+    def permit_join(
+        self, permit: bool = True, timeout: Optional[float] = None, **kwargs
+    ):
         """
         Enable/disable devices from joining the network. This is not persistent (will not be saved to
         ``configuration.yaml``).
@@ -394,14 +488,19 @@ class ZigbeeMqttPlugin(MqttPlugin, SwitchPlugin):   # lgtm [py/missing-call-to-i
         """
         if timeout:
             return self._parse_response(
-                self.publish(topic=self._topic('bridge/request/permit_join'),
-                             msg={'value': permit, 'time': timeout},
-                             reply_topic=self._topic('bridge/response/permit_join'),
-                             **self._mqtt_args(**kwargs)))
+                self.publish(
+                    topic=self._topic('bridge/request/permit_join'),
+                    msg={'value': permit, 'time': timeout},
+                    reply_topic=self._topic('bridge/response/permit_join'),
+                    **self._mqtt_args(**kwargs),
+                )
+            )
 
-        return self.publish(topic=self._topic('bridge/request/permit_join'),
-                            msg={'value': permit},
-                            **self._mqtt_args(**kwargs))
+        return self.publish(
+            topic=self._topic('bridge/request/permit_join'),
+            msg={'value': permit},
+            **self._mqtt_args(**kwargs),
+        )
 
     @action
     def factory_reset(self, **kwargs):
@@ -413,7 +512,11 @@ class ZigbeeMqttPlugin(MqttPlugin, SwitchPlugin):   # lgtm [py/missing-call-to-i
         :param kwargs: Extra arguments to be passed to :meth:`platypush.plugins.mqtt.MqttPlugin.publish``
             (default: query the default configured device).
         """
-        self.publish(topic=self._topic('bridge/request/touchlink/factory_reset'), msg='', **self._mqtt_args(**kwargs))
+        self.publish(
+            topic=self._topic('bridge/request/touchlink/factory_reset'),
+            msg='',
+            **self._mqtt_args(**kwargs),
+        )
 
     @action
     def log_level(self, level: str, **kwargs):
@@ -425,9 +528,13 @@ class ZigbeeMqttPlugin(MqttPlugin, SwitchPlugin):   # lgtm [py/missing-call-to-i
             (default: query the default configured device).
         """
         return self._parse_response(
-            self.publish(topic=self._topic('bridge/request/config/log_level'), msg={'value': level},
-                         reply_topic=self._topic('bridge/response/config/log_level'),
-                         **self._mqtt_args(**kwargs)))
+            self.publish(
+                topic=self._topic('bridge/request/config/log_level'),
+                msg={'value': level},
+                reply_topic=self._topic('bridge/response/config/log_level'),
+                **self._mqtt_args(**kwargs),
+            )
+        )
 
     @action
     def device_set_option(self, device: str, option: str, value: Any, **kwargs):
@@ -441,14 +548,18 @@ class ZigbeeMqttPlugin(MqttPlugin, SwitchPlugin):   # lgtm [py/missing-call-to-i
             (default: query the default configured device).
         """
         return self._parse_response(
-            self.publish(topic=self._topic('bridge/request/device/options'),
-                         reply_topic=self._topic('bridge/response/device/options'),
-                         msg={
-                             'id': device,
-                             'options': {
-                                 option: value,
-                             }
-                         }, **self._mqtt_args(**kwargs)))
+            self.publish(
+                topic=self._topic('bridge/request/device/options'),
+                reply_topic=self._topic('bridge/response/device/options'),
+                msg={
+                    'id': device,
+                    'options': {
+                        option: value,
+                    },
+                },
+                **self._mqtt_args(**kwargs),
+            )
+        )
 
     @action
     def device_remove(self, device: str, force: bool = False, **kwargs):
@@ -463,10 +574,13 @@ class ZigbeeMqttPlugin(MqttPlugin, SwitchPlugin):   # lgtm [py/missing-call-to-i
             (default: query the default configured device).
         """
         return self._parse_response(
-            self.publish(topic=self._topic('bridge/request/device/remove'),
-                         msg={'id': device, 'force': force},
-                         reply_topic=self._topic('bridge/response/device/remove'),
-                         **self._mqtt_args(**kwargs)))
+            self.publish(
+                topic=self._topic('bridge/request/device/remove'),
+                msg={'id': device, 'force': force},
+                reply_topic=self._topic('bridge/response/device/remove'),
+                **self._mqtt_args(**kwargs),
+            )
+        )
 
     @action
     def device_ban(self, device: str, **kwargs):
@@ -478,10 +592,13 @@ class ZigbeeMqttPlugin(MqttPlugin, SwitchPlugin):   # lgtm [py/missing-call-to-i
             (default: query the default configured device).
         """
         return self._parse_response(
-            self.publish(topic=self._topic('bridge/request/device/ban'),
-                         reply_topic=self._topic('bridge/response/device/ban'),
-                         msg={'id': device},
-                         **self._mqtt_args(**kwargs)))
+            self.publish(
+                topic=self._topic('bridge/request/device/ban'),
+                reply_topic=self._topic('bridge/response/device/ban'),
+                msg={'id': device},
+                **self._mqtt_args(**kwargs),
+            )
+        )
 
     @action
     def device_whitelist(self, device: str, **kwargs):
@@ -494,10 +611,13 @@ class ZigbeeMqttPlugin(MqttPlugin, SwitchPlugin):   # lgtm [py/missing-call-to-i
             (default: query the default configured device).
         """
         return self._parse_response(
-            self.publish(topic=self._topic('bridge/request/device/whitelist'),
-                         reply_topic=self._topic('bridge/response/device/whitelist'),
-                         msg={'id': device},
-                         **self._mqtt_args(**kwargs)))
+            self.publish(
+                topic=self._topic('bridge/request/device/whitelist'),
+                reply_topic=self._topic('bridge/response/device/whitelist'),
+                msg={'id': device},
+                **self._mqtt_args(**kwargs),
+            )
+        )
 
     @action
     def device_rename(self, name: str, device: Optional[str] = None, **kwargs):
@@ -516,8 +636,9 @@ class ZigbeeMqttPlugin(MqttPlugin, SwitchPlugin):   # lgtm [py/missing-call-to-i
 
         # noinspection PyUnresolvedReferences
         devices = self.devices().output
-        assert not [dev for dev in devices if dev.get('friendly_name') == name], \
-            'A device named {} already exists on the network'.format(name)
+        assert not [
+            dev for dev in devices if dev.get('friendly_name') == name
+        ], 'A device named {} already exists on the network'.format(name)
 
         if device:
             req = {
@@ -531,10 +652,13 @@ class ZigbeeMqttPlugin(MqttPlugin, SwitchPlugin):   # lgtm [py/missing-call-to-i
             }
 
         return self._parse_response(
-            self.publish(topic=self._topic('bridge/request/device/rename'),
-                         msg=req,
-                         reply_topic=self._topic('bridge/response/device/rename'),
-                         **self._mqtt_args(**kwargs)))
+            self.publish(
+                topic=self._topic('bridge/request/device/rename'),
+                msg=req,
+                reply_topic=self._topic('bridge/response/device/rename'),
+                **self._mqtt_args(**kwargs),
+            )
+        )
 
     @staticmethod
     def build_device_get_request(values: List[Dict[str, Any]]) -> dict:
@@ -561,9 +685,16 @@ class ZigbeeMqttPlugin(MqttPlugin, SwitchPlugin):   # lgtm [py/missing-call-to-i
 
         return ret
 
+    def _get_device_info(self, device: str) -> Mapping:
+        return self._info['devices'].get(
+            device, self._info['devices_by_addr'].get(device, {})
+        )
+
     # noinspection PyShadowingBuiltins
     @action
-    def device_get(self, device: str, property: Optional[str] = None, **kwargs) -> Dict[str, Any]:
+    def device_get(
+        self, device: str, property: Optional[str] = None, **kwargs
+    ) -> Dict[str, Any]:
         """
         Get the properties of a device. The returned keys vary depending on the device. For example, a light bulb
         may have the "``state``" and "``brightness``" properties, while an environment sensor may have the
@@ -576,28 +707,59 @@ class ZigbeeMqttPlugin(MqttPlugin, SwitchPlugin):   # lgtm [py/missing-call-to-i
         :return: Key->value map of the device properties.
         """
         kwargs = self._mqtt_args(**kwargs)
+        device_info = self._get_device_info(device)
+        if device_info:
+            device = device_info.get('friendly_name') or device_info['ieee_address']
 
         if property:
-            properties = self.publish(topic=self._topic(device) + '/get/' + property, reply_topic=self._topic(device),
-                                      msg={property: ''}, **kwargs).output
+            properties = self.publish(
+                topic=self._topic(device) + f'/get/{property}',
+                reply_topic=self._topic(device),
+                msg={property: ''},
+                **kwargs,
+            ).output
 
-            assert property in properties, 'No such property: ' + property
+            assert property in properties, f'No such property: {property}'
             return {property: properties[property]}
 
         if device not in self._info.get('devices', {}):
             # Refresh devices info
             self._get_network_info(**kwargs)
 
-        assert self._info.get('devices', {}).get(device), 'No such device: ' + device
-        exposes = (self._info.get('devices', {}).get(device, {}).get('definition', {}) or {}).get('exposes', [])
+        dev = self._info.get('devices', {}).get(
+            device, self._info.get('devices_by_addr', {}).get(device)
+        )
+
+        assert dev, f'No such device: {device}'
+        exposes = (
+            self._info.get('devices', {}).get(device, {}).get('definition', {}) or {}
+        ).get('exposes', [])
         if not exposes:
             return {}
 
-        return self.publish(topic=self._topic(device) + '/get', reply_topic=self._topic(device),
-                            msg=self.build_device_get_request(exposes), **kwargs)
+        device_state = self.publish(
+            topic=self._topic(device) + '/get',
+            reply_topic=self._topic(device),
+            msg=self.build_device_get_request(exposes),
+            **kwargs,
+        ).output
+
+        if device_info:
+            self.publish_entities(  # type: ignore
+                [
+                    {
+                        **device_info,
+                        'state': device_state,
+                    }
+                ]
+            )
+
+        return device_state
 
     @action
-    def devices_get(self, devices: Optional[List[str]] = None, **kwargs) -> Dict[str, dict]:
+    def devices_get(
+        self, devices: Optional[List[str]] = None, **kwargs
+    ) -> Dict[str, dict]:
         """
         Get the properties of the devices connected to the network.
 
@@ -622,14 +784,12 @@ class ZigbeeMqttPlugin(MqttPlugin, SwitchPlugin):   # lgtm [py/missing-call-to-i
         kwargs = self._mqtt_args(**kwargs)
 
         if not devices:
-            # noinspection PyUnresolvedReferences
-            devices = set([
+            devices = {
                 device['friendly_name'] or device['ieee_address']
                 for device in self.devices(**kwargs).output
-            ])
+            }
 
         def worker(device: str, q: Queue):
-            # noinspection PyUnresolvedReferences
             q.put(self.device_get(device, **kwargs).output)
 
         queues = {}
@@ -638,7 +798,9 @@ class ZigbeeMqttPlugin(MqttPlugin, SwitchPlugin):   # lgtm [py/missing-call-to-i
 
         for device in devices:
             queues[device] = Queue()
-            workers[device] = threading.Thread(target=worker, args=(device, queues[device]))
+            workers[device] = threading.Thread(
+                target=worker, args=(device, queues[device])
+            )
             workers[device].start()
 
         for device in devices:
@@ -646,8 +808,11 @@ class ZigbeeMqttPlugin(MqttPlugin, SwitchPlugin):   # lgtm [py/missing-call-to-i
                 response[device] = queues[device].get(timeout=kwargs.get('timeout'))
                 workers[device].join(timeout=kwargs.get('timeout'))
             except Exception as e:
-                self.logger.warning('An error while getting the status of the device {}: {}'.format(
-                    device, str(e)))
+                self.logger.warning(
+                    'An error while getting the status of the device {}: {}'.format(
+                        device, str(e)
+                    )
+                )
 
         return response
 
@@ -658,7 +823,7 @@ class ZigbeeMqttPlugin(MqttPlugin, SwitchPlugin):   # lgtm [py/missing-call-to-i
 
         :param device: Device friendly name (default: get all devices).
         """
-        return self.devices_get([device], *args, **kwargs)
+        return self.devices_get([device] if device else None, *args, **kwargs)
 
     # noinspection PyShadowingBuiltins,DuplicatedCode
     @action
@@ -674,9 +839,12 @@ class ZigbeeMqttPlugin(MqttPlugin, SwitchPlugin):   # lgtm [py/missing-call-to-i
         :param kwargs: Extra arguments to be passed to :meth:`platypush.plugins.mqtt.MqttPlugin.publish``
             (default: query the default configured device).
         """
-        properties = self.publish(topic=self._topic(device + '/set'),
-                                  reply_topic=self._topic(device),
-                                  msg={property: value}, **self._mqtt_args(**kwargs)).output
+        properties = self.publish(
+            topic=self._topic(device + '/set'),
+            reply_topic=self._topic(device),
+            msg={property: value},
+            **self._mqtt_args(**kwargs),
+        ).output
 
         if property:
             assert property in properties, 'No such property: ' + property
@@ -705,9 +873,13 @@ class ZigbeeMqttPlugin(MqttPlugin, SwitchPlugin):   # lgtm [py/missing-call-to-i
 
         """
         ret = self._parse_response(
-            self.publish(topic=self._topic('bridge/request/device/ota_update/check'),
-                         reply_topic=self._topic('bridge/response/device/ota_update/check'),
-                         msg={'id': device}, **self._mqtt_args(**kwargs)))
+            self.publish(
+                topic=self._topic('bridge/request/device/ota_update/check'),
+                reply_topic=self._topic('bridge/response/device/ota_update/check'),
+                msg={'id': device},
+                **self._mqtt_args(**kwargs),
+            )
+        )
 
         return {
             'status': ret['status'],
@@ -725,9 +897,13 @@ class ZigbeeMqttPlugin(MqttPlugin, SwitchPlugin):   # lgtm [py/missing-call-to-i
             (default: query the default configured device).
         """
         return self._parse_response(
-            self.publish(topic=self._topic('bridge/request/device/ota_update/update'),
-                         reply_topic=self._topic('bridge/response/device/ota_update/update'),
-                         msg={'id': device}, **self._mqtt_args(**kwargs)))
+            self.publish(
+                topic=self._topic('bridge/request/device/ota_update/update'),
+                reply_topic=self._topic('bridge/response/device/ota_update/update'),
+                msg={'id': device},
+                **self._mqtt_args(**kwargs),
+            )
+        )
 
     @action
     def groups(self, **kwargs) -> List[dict]:
@@ -883,16 +1059,22 @@ class ZigbeeMqttPlugin(MqttPlugin, SwitchPlugin):   # lgtm [py/missing-call-to-i
         :param kwargs: Extra arguments to be passed to :meth:`platypush.plugins.mqtt.MqttPlugin.publish``
             (default: query the default configured device).
         """
-        payload = name if id is None else {
-            'id': id,
-            'friendly_name': name,
-        }
+        payload = (
+            name
+            if id is None
+            else {
+                'id': id,
+                'friendly_name': name,
+            }
+        )
 
         return self._parse_response(
-            self.publish(topic=self._topic('bridge/request/group/add'),
-                         reply_topic=self._topic('bridge/response/group/add'),
-                         msg=payload,
-                         **self._mqtt_args(**kwargs))
+            self.publish(
+                topic=self._topic('bridge/request/group/add'),
+                reply_topic=self._topic('bridge/response/group/add'),
+                msg=payload,
+                **self._mqtt_args(**kwargs),
+            )
         )
 
     @action
@@ -911,9 +1093,12 @@ class ZigbeeMqttPlugin(MqttPlugin, SwitchPlugin):   # lgtm [py/missing-call-to-i
         if property:
             msg = {property: ''}
 
-        properties = self.publish(topic=self._topic(group + '/get'),
-                                  reply_topic=self._topic(group),
-                                  msg=msg, **self._mqtt_args(**kwargs)).output
+        properties = self.publish(
+            topic=self._topic(group + '/get'),
+            reply_topic=self._topic(group),
+            msg=msg,
+            **self._mqtt_args(**kwargs),
+        ).output
 
         if property:
             assert property in properties, 'No such property: ' + property
@@ -935,9 +1120,12 @@ class ZigbeeMqttPlugin(MqttPlugin, SwitchPlugin):   # lgtm [py/missing-call-to-i
         :param kwargs: Extra arguments to be passed to :meth:`platypush.plugins.mqtt.MqttPlugin.publish``
             (default: query the default configured device).
         """
-        properties = self.publish(topic=self._topic(group + '/set'),
-                                  reply_topic=self._topic(group),
-                                  msg={property: value}, **self._mqtt_args(**kwargs)).output
+        properties = self.publish(
+            topic=self._topic(group + '/set'),
+            reply_topic=self._topic(group),
+            msg={property: value},
+            **self._mqtt_args(**kwargs),
+        ).output
 
         if property:
             assert property in properties, 'No such property: ' + property
@@ -961,13 +1149,18 @@ class ZigbeeMqttPlugin(MqttPlugin, SwitchPlugin):   # lgtm [py/missing-call-to-i
 
         # noinspection PyUnresolvedReferences
         groups = {group.get('friendly_name'): group for group in self.groups().output}
-        assert name not in groups, 'A group named {} already exists on the network'.format(name)
+        assert (
+            name not in groups
+        ), 'A group named {} already exists on the network'.format(name)
 
         return self._parse_response(
-            self.publish(topic=self._topic('bridge/request/group/rename'),
-                         reply_topic=self._topic('bridge/response/group/rename'),
-                         msg={'from': group, 'to': name} if group else name,
-                         **self._mqtt_args(**kwargs)))
+            self.publish(
+                topic=self._topic('bridge/request/group/rename'),
+                reply_topic=self._topic('bridge/response/group/rename'),
+                msg={'from': group, 'to': name} if group else name,
+                **self._mqtt_args(**kwargs),
+            )
+        )
 
     @action
     def group_remove(self, name: str, **kwargs):
@@ -979,10 +1172,13 @@ class ZigbeeMqttPlugin(MqttPlugin, SwitchPlugin):   # lgtm [py/missing-call-to-i
             (default: query the default configured device).
         """
         return self._parse_response(
-            self.publish(topic=self._topic('bridge/request/group/remove'),
-                         reply_topic=self._topic('bridge/response/group/remove'),
-                         msg=name,
-                         **self._mqtt_args(**kwargs)))
+            self.publish(
+                topic=self._topic('bridge/request/group/remove'),
+                reply_topic=self._topic('bridge/response/group/remove'),
+                msg=name,
+                **self._mqtt_args(**kwargs),
+            )
+        )
 
     @action
     def group_add_device(self, group: str, device: str, **kwargs):
@@ -995,12 +1191,16 @@ class ZigbeeMqttPlugin(MqttPlugin, SwitchPlugin):   # lgtm [py/missing-call-to-i
             (default: query the default configured device).
         """
         return self._parse_response(
-            self.publish(topic=self._topic('bridge/request/group/members/add'),
-                         reply_topic=self._topic('bridge/response/group/members/add'),
-                         msg={
-                             'group': group,
-                             'device': device,
-                         }, **self._mqtt_args(**kwargs)))
+            self.publish(
+                topic=self._topic('bridge/request/group/members/add'),
+                reply_topic=self._topic('bridge/response/group/members/add'),
+                msg={
+                    'group': group,
+                    'device': device,
+                },
+                **self._mqtt_args(**kwargs),
+            )
+        )
 
     @action
     def group_remove_device(self, group: str, device: Optional[str] = None, **kwargs):
@@ -1015,13 +1215,23 @@ class ZigbeeMqttPlugin(MqttPlugin, SwitchPlugin):   # lgtm [py/missing-call-to-i
         """
         return self._parse_response(
             self.publish(
-                topic=self._topic('bridge/request/group/members/remove{}'.format('_all' if device is None else '')),
+                topic=self._topic(
+                    'bridge/request/group/members/remove{}'.format(
+                        '_all' if device is None else ''
+                    )
+                ),
                 reply_topic=self._topic(
-                    'bridge/response/group/members/remove{}'.format('_all' if device is None else '')),
+                    'bridge/response/group/members/remove{}'.format(
+                        '_all' if device is None else ''
+                    )
+                ),
                 msg={
                     'group': group,
                     'device': device,
-                }, **self._mqtt_args(**kwargs)))
+                },
+                **self._mqtt_args(**kwargs),
+            )
+        )
 
     @action
     def bind_devices(self, source: str, target: str, **kwargs):
@@ -1040,9 +1250,13 @@ class ZigbeeMqttPlugin(MqttPlugin, SwitchPlugin):   # lgtm [py/missing-call-to-i
             (default: query the default configured device).
         """
         return self._parse_response(
-            self.publish(topic=self._topic('bridge/request/device/bind'),
-                         reply_topic=self._topic('bridge/response/device/bind'),
-                         msg={'from': source, 'to': target}, **self._mqtt_args(**kwargs)))
+            self.publish(
+                topic=self._topic('bridge/request/device/bind'),
+                reply_topic=self._topic('bridge/response/device/bind'),
+                msg={'from': source, 'to': target},
+                **self._mqtt_args(**kwargs),
+            )
+        )
 
     @action
     def unbind_devices(self, source: str, target: str, **kwargs):
@@ -1057,9 +1271,13 @@ class ZigbeeMqttPlugin(MqttPlugin, SwitchPlugin):   # lgtm [py/missing-call-to-i
             (default: query the default configured device).
         """
         return self._parse_response(
-            self.publish(topic=self._topic('bridge/request/device/unbind'),
-                         reply_topic=self._topic('bridge/response/device/unbind'),
-                         msg={'from': source, 'to': target}, **self._mqtt_args(**kwargs)))
+            self.publish(
+                topic=self._topic('bridge/request/device/unbind'),
+                reply_topic=self._topic('bridge/response/device/unbind'),
+                msg={'from': source, 'to': target},
+                **self._mqtt_args(**kwargs),
+            )
+        )
 
     @action
     def on(self, device, *args, **kwargs) -> dict:
@@ -1067,10 +1285,15 @@ class ZigbeeMqttPlugin(MqttPlugin, SwitchPlugin):   # lgtm [py/missing-call-to-i
         Implements :meth:`platypush.plugins.switch.plugin.SwitchPlugin.on` and turns on a Zigbee device with a writable
         binary property.
         """
-        switch_info = self._get_switches_info().get(device)
+        switch_info = self._get_switch_info(device)
         assert switch_info, '{} is not a valid switch'.format(device)
-        props = self.device_set(device, switch_info['property'], switch_info['value_on']).output
-        return self._properties_to_switch(device=device, props=props, switch_info=switch_info)
+        device = switch_info.get('friendly_name') or switch_info['ieee_address']
+        props = self.device_set(
+            device, switch_info['property'], switch_info['value_on']
+        ).output
+        return self._properties_to_switch(
+            device=device, props=props, switch_info=switch_info
+        )
 
     @action
     def off(self, device, *args, **kwargs) -> dict:
@@ -1078,10 +1301,15 @@ class ZigbeeMqttPlugin(MqttPlugin, SwitchPlugin):   # lgtm [py/missing-call-to-i
         Implements :meth:`platypush.plugins.switch.plugin.SwitchPlugin.off` and turns off a Zigbee device with a
         writable binary property.
         """
-        switch_info = self._get_switches_info().get(device)
+        switch_info = self._get_switch_info(device)
         assert switch_info, '{} is not a valid switch'.format(device)
-        props = self.device_set(device, switch_info['property'], switch_info['value_off']).output
-        return self._properties_to_switch(device=device, props=props, switch_info=switch_info)
+        device = switch_info.get('friendly_name') or switch_info['ieee_address']
+        props = self.device_set(
+            device, switch_info['property'], switch_info['value_off']
+        ).output
+        return self._properties_to_switch(
+            device=device, props=props, switch_info=switch_info
+        )
 
     @action
     def toggle(self, device, *args, **kwargs) -> dict:
@@ -1089,10 +1317,26 @@ class ZigbeeMqttPlugin(MqttPlugin, SwitchPlugin):   # lgtm [py/missing-call-to-i
         Implements :meth:`platypush.plugins.switch.plugin.SwitchPlugin.toggle` and toggles a Zigbee device with a
         writable binary property.
         """
-        switch_info = self._get_switches_info().get(device)
+        switch_info = self._get_switch_info(device)
         assert switch_info, '{} is not a valid switch'.format(device)
-        props = self.device_set(device, switch_info['property'], switch_info['value_toggle']).output
-        return self._properties_to_switch(device=device, props=props, switch_info=switch_info)
+        device = switch_info.get('friendly_name') or switch_info['ieee_address']
+        props = self.device_set(
+            device, switch_info['property'], switch_info['value_toggle']
+        ).output
+        return self._properties_to_switch(
+            device=device, props=props, switch_info=switch_info
+        )
+
+    def _get_switch_info(self, device: str):
+        switches_info = self._get_switches_info()
+        info = switches_info.get(device)
+        if info:
+            return info
+
+        device_info = self._get_device_info(device)
+        if device_info:
+            device = device_info.get('friendly_name') or device_info['ieee_address']
+            return switches_info.get(device)
 
     @staticmethod
     def _properties_to_switch(device: str, props: dict, switch_info: dict) -> dict:
@@ -1103,32 +1347,105 @@ class ZigbeeMqttPlugin(MqttPlugin, SwitchPlugin):   # lgtm [py/missing-call-to-i
             **props,
         }
 
-    def _get_switches_info(self) -> dict:
-        def switch_info(device_info: dict) -> dict:
-            exposes = (device_info.get('definition', {}) or {}).get('exposes', [])
-            for exposed in exposes:
-                for feature in exposed.get('features', []):
-                    if feature.get('type') == 'binary' and 'value_on' in feature and 'value_off' in feature and \
-                            feature.get('access', 0) & 2:
-                        return {
-                            'property': feature['property'],
+    @staticmethod
+    def _get_switch_meta(device_info: dict) -> dict:
+        exposes = (device_info.get('definition', {}) or {}).get('exposes', [])
+        for exposed in exposes:
+            for feature in exposed.get('features', []):
+                if (
+                    feature.get('property') == 'state'
+                    and feature.get('type') == 'binary'
+                    and 'value_on' in feature
+                    and 'value_off' in feature
+                ):
+                    return {
+                        'friendly_name': device_info.get('friendly_name'),
+                        'ieee_address': device_info.get('friendly_name'),
+                        'property': feature['property'],
+                        'value_on': feature['value_on'],
+                        'value_off': feature['value_off'],
+                        'value_toggle': feature.get('value_toggle', None),
+                        'is_read_only': not bool(feature.get('access', 0) & 2),
+                        'is_write_only': not bool(feature.get('access', 0) & 1),
+                    }
+
+        return {}
+
+    @staticmethod
+    def _get_light_meta(device_info: dict) -> dict:
+        exposes = (device_info.get('definition', {}) or {}).get('exposes', [])
+        for exposed in exposes:
+            if exposed.get('type') == 'light':
+                features = exposed.get('features', [])
+                switch = {}
+                brightness = {}
+                temperature = {}
+
+                for feature in features:
+                    if (
+                        feature.get('property') == 'state'
+                        and feature.get('type') == 'binary'
+                        and 'value_on' in feature
+                        and 'value_off' in feature
+                    ):
+                        switch = {
                             'value_on': feature['value_on'],
                             'value_off': feature['value_off'],
+                            'state_name': feature['name'],
                             'value_toggle': feature.get('value_toggle', None),
+                            'is_read_only': not bool(feature.get('access', 0) & 2),
+                            'is_write_only': not bool(feature.get('access', 0) & 1),
+                        }
+                    elif (
+                        feature.get('property') == 'brightness'
+                        and feature.get('type') == 'numeric'
+                        and 'value_min' in feature
+                        and 'value_max' in feature
+                    ):
+                        brightness = {
+                            'brightness_name': feature['name'],
+                            'brightness_min': feature['value_min'],
+                            'brightness_max': feature['value_max'],
+                            'is_read_only': not bool(feature.get('access', 0) & 2),
+                            'is_write_only': not bool(feature.get('access', 0) & 1),
+                        }
+                    elif (
+                        feature.get('property') == 'color_temp'
+                        and feature.get('type') == 'numeric'
+                        and 'value_min' in feature
+                        and 'value_max' in feature
+                    ):
+                        temperature = {
+                            'temperature_name': feature['name'],
+                            'temperature_min': feature['value_min'],
+                            'temperature_max': feature['value_max'],
+                            'is_read_only': not bool(feature.get('access', 0) & 2),
+                            'is_write_only': not bool(feature.get('access', 0) & 1),
                         }
 
-            return {}
+                return {
+                    'friendly_name': device_info.get('friendly_name'),
+                    'ieee_address': device_info.get('friendly_name'),
+                    **switch,
+                    **brightness,
+                    **temperature,
+                }
 
+        return {}
+
+    def _get_switches_info(self) -> dict:
         # noinspection PyUnresolvedReferences
         devices = self.devices().output
         switches_info = {}
 
         for device in devices:
-            info = switch_info(device)
+            info = self._get_switch_meta(device)
             if not info:
                 continue
 
-            switches_info[device.get('friendly_name', device.get('ieee_address'))] = info
+            switches_info[
+                device.get('friendly_name', device.get('ieee_address'))
+            ] = info
 
         return switches_info
 
@@ -1142,9 +1459,37 @@ class ZigbeeMqttPlugin(MqttPlugin, SwitchPlugin):   # lgtm [py/missing-call-to-i
         switches_info = self._get_switches_info()
         # noinspection PyUnresolvedReferences
         return [
-            self._properties_to_switch(device=name, props=switch, switch_info=switches_info[name])
-            for name, switch in self.devices_get(list(switches_info.keys())).output.items()
+            self._properties_to_switch(
+                device=name, props=switch, switch_info=switches_info[name]
+            )
+            for name, switch in self.devices_get(
+                list(switches_info.keys())
+            ).output.items()
         ]
 
+    @action
+    def set_lights(self, lights, **kwargs):
+        devices = [
+            dev
+            for dev in self._get_network_info().get('devices', [])
+            if dev.get('ieee_address') in lights or dev.get('friendly_name') in lights
+        ]
+
+        for dev in devices:
+            light_meta = self._get_light_meta(dev)
+            assert light_meta, f'{dev["name"]} is not a light'
+
+            for attr, value in kwargs.items():
+                if attr == 'on':
+                    attr = light_meta['state_name']
+                elif attr in {'brightness', 'bri'}:
+                    attr = light_meta['brightness_name']
+                elif attr in {'temperature', 'ct'}:
+                    attr = light_meta['temperature_name']
+
+                self.device_set(
+                    dev.get('friendly_name', dev.get('ieee_address')), attr, value
+                )
+
 
 # vim:sw=4:ts=4:et:
diff --git a/platypush/plugins/zwave/mqtt/__init__.py b/platypush/plugins/zwave/mqtt/__init__.py
index 63279b900..c07449c6b 100644
--- a/platypush/plugins/zwave/mqtt/__init__.py
+++ b/platypush/plugins/zwave/mqtt/__init__.py
@@ -3,8 +3,9 @@ import queue
 
 from datetime import datetime
 from threading import Timer
-from typing import Optional, List, Any, Dict, Union, Iterable, Callable
+from typing import Optional, List, Any, Dict, Union, Iterable, Mapping, Callable
 
+from platypush.entities.switches import Switch
 from platypush.message.event.zwave import ZwaveNodeRenamedEvent, ZwaveNodeEvent
 
 from platypush.context import get_backend, get_bus
@@ -21,6 +22,10 @@ class ZwaveMqttPlugin(MqttPlugin, ZwaveBasePlugin):
     This plugin allows you to manage a Z-Wave network over MQTT through
     `zwavejs2mqtt <https://github.com/zwave-js/zwavejs2mqtt>`_.
 
+    For historical reasons, it is advised to enabled this plugin together
+    with the ``zwave.mqtt`` backend, or you may lose the ability to listen
+    to asynchronous events.
+
     Configuration required on the zwavejs2mqtt gateway:
 
         * Install the gateway following the instructions reported
@@ -45,10 +50,21 @@ class ZwaveMqttPlugin(MqttPlugin, ZwaveBasePlugin):
 
     """
 
-    def __init__(self, name: str, host: str = 'localhost', port: int = 1883, topic_prefix: str = 'zwave',
-                 timeout: int = 10, tls_certfile: Optional[str] = None, tls_keyfile: Optional[str] = None,
-                 tls_version: Optional[str] = None, tls_ciphers: Optional[str] = None, username: Optional[str] = None,
-                 password: Optional[str] = None, **kwargs):
+    def __init__(
+        self,
+        name: str,
+        host: str = 'localhost',
+        port: int = 1883,
+        topic_prefix: str = 'zwave',
+        timeout: int = 10,
+        tls_certfile: Optional[str] = None,
+        tls_keyfile: Optional[str] = None,
+        tls_version: Optional[str] = None,
+        tls_ciphers: Optional[str] = None,
+        username: Optional[str] = None,
+        password: Optional[str] = None,
+        **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
@@ -70,9 +86,17 @@ class ZwaveMqttPlugin(MqttPlugin, ZwaveBasePlugin):
         :param password: If the connection requires user authentication, specify the password (default: None)
         """
 
-        super().__init__(host=host, port=port, tls_certfile=tls_certfile, tls_keyfile=tls_keyfile,
-                         tls_version=tls_version, tls_ciphers=tls_ciphers, username=username,
-                         password=password, **kwargs)
+        super().__init__(
+            host=host,
+            port=port,
+            tls_certfile=tls_certfile,
+            tls_keyfile=tls_keyfile,
+            tls_version=tls_version,
+            tls_ciphers=tls_ciphers,
+            username=username,
+            password=password,
+            **kwargs,
+        )
 
         self.topic_prefix = topic_prefix
         self.base_topic = topic_prefix + '/{}/ZWAVE_GATEWAY-' + name
@@ -118,14 +142,21 @@ class ZwaveMqttPlugin(MqttPlugin, ZwaveBasePlugin):
         if isinstance(response, Response):
             response = response.output
 
-        assert response.get('success') is True, response.get('message', 'zwavejs2mqtt error')
+        assert response.get('success') is True, response.get(
+            'message', 'zwavejs2mqtt error'
+        )
         return response
 
     def _api_request(self, api: str, *args, **kwargs):
         payload = json.dumps({'args': args})
         ret = self._parse_response(
-            self.publish(topic=self._api_topic(api) + '/set', msg=payload, reply_topic=self._api_topic(api),
-                         **self._mqtt_args(**kwargs)))
+            self.publish(
+                topic=self._api_topic(api) + '/set',
+                msg=payload,
+                reply_topic=self._api_topic(api),
+                **self._mqtt_args(**kwargs),
+            )
+        )
 
         assert not ret or ret.get('success') is True, ret.get('message')
         return ret.get('result')
@@ -135,7 +166,12 @@ class ZwaveMqttPlugin(MqttPlugin, ZwaveBasePlugin):
         if t:
             return datetime.fromtimestamp(t / 1000)
 
-    def _get_scene(self, scene_id: Optional[int] = None, scene_label: Optional[str] = None, **kwargs) -> dict:
+    def _get_scene(
+        self,
+        scene_id: Optional[int] = None,
+        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]
@@ -191,8 +227,10 @@ class ZwaveMqttPlugin(MqttPlugin, ZwaveBasePlugin):
             'id_on_network': value['id'],
             'value_id': value['id'],
             'data': value.get('value'),
-            'data_items': [state for state in value['states']] if len(value.get('states', [])) > 1 else None,
-            'label': value.get('label', value.get('propertyName', value.get('property'))),
+            'data_items': value['states'] if len(value.get('states', [])) > 1 else None,
+            'label': value.get(
+                'label', value.get('propertyName', value.get('property'))
+            ),
             'property_id': value.get('property'),
             'help': value.get('description'),
             'node_id': value.get('nodeId'),
@@ -209,8 +247,14 @@ class ZwaveMqttPlugin(MqttPlugin, ZwaveBasePlugin):
             'is_read_only': value['readable'] and not value['writeable'],
             'is_write_only': value['writeable'] and not value['readable'],
             'last_update': cls._convert_timestamp(value.get('lastUpdate')),
-            **({'property_key': value['propertyKey']} if 'propertyKey' in value else {}),
-            **({'property_key_name': value['propertyKeyName']} if 'propertyKeyName' in value else {}),
+            **(
+                {'property_key': value['propertyKey']} if 'propertyKey' in value else {}
+            ),
+            **(
+                {'property_key_name': value['propertyKeyName']}
+                if 'propertyKeyName' in value
+                else {}
+            ),
         }
 
     @staticmethod
@@ -250,7 +294,9 @@ class ZwaveMqttPlugin(MqttPlugin, ZwaveBasePlugin):
             'device_id': node['hexId'].replace('0x', ''),
             'name': node.get('name'),
             'capabilities': capabilities,
-            'manufacturer_id': '0x{:04x}'.format(node['manufacturerId']) if node.get('manufacturerId') else None,
+            'manufacturer_id': '0x{:04x}'.format(node['manufacturerId'])
+            if node.get('manufacturerId')
+            else None,
             'manufacturer_name': node.get('manufacturer'),
             'location': node.get('loc'),
             'status': node.get('status'),
@@ -268,9 +314,15 @@ class ZwaveMqttPlugin(MqttPlugin, ZwaveBasePlugin):
             'is_security_device': node.get('supportsSecurity'),
             'is_sleeping': node.get('ready') and node.get('status') == 'Asleep',
             'last_update': cls._convert_timestamp(node.get('lastActive')),
-            'product_id': '0x{:04x}'.format(node['productId']) if node.get('productId') else None,
-            'product_type': '0x{:04x}'.format(node['productType']) if node.get('productType') else None,
-            'product_name': ' '.join([node.get('productLabel', ''), node.get('productDescription', '')]),
+            'product_id': '0x{:04x}'.format(node['productId'])
+            if node.get('productId')
+            else None,
+            'product_type': '0x{:04x}'.format(node['productType'])
+            if node.get('productType')
+            else None,
+            'product_name': ' '.join(
+                [node.get('productLabel', ''), node.get('productDescription', '')]
+            ),
             'baud_rate': node.get('dataRate'),
             'max_baud_rate': node.get('maxBaudRate', node.get('dataRate')),
             'device_class': node.get('deviceClass'),
@@ -284,25 +336,28 @@ class ZwaveMqttPlugin(MqttPlugin, ZwaveBasePlugin):
             'zwave_plus_node_type': node.get('zwavePlusNodeType'),
             'zwave_plus_role_type': node.get('zwavePlusRoleType'),
             'neighbours': node.get('neighbors', []),
-
             'command_classes': {
                 value['commandClassName']
-                for value in node.get('values', {}).values() if value.get('commandClassName')
+                for value in node.get('values', {}).values()
+                if value.get('commandClassName')
             },
-
             'groups': {
                 group['value']: cls.group_to_dict(group, node_id=node['id'])
                 for group in node.get('groups', [])
             },
-
             'values': {
                 value['id']: cls.value_to_dict(value)
                 for value in node.get('values', {}).values()
             },
         }
 
-    def _get_node(self, node_id: Optional[int] = None, node_name: Optional[str] = None, use_cache: bool = True,
-                  **kwargs) -> Optional[dict]:
+    def _get_node(
+        self,
+        node_id: Optional[int] = None,
+        node_name: Optional[str] = None,
+        use_cache: bool = True,
+        **kwargs,
+    ) -> Optional[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']:
@@ -335,12 +390,21 @@ class ZwaveMqttPlugin(MqttPlugin, ZwaveBasePlugin):
 
         return node
 
-    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,
-                   use_cache: bool = True, **_) -> Dict[str, Any]:
+    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,
+        use_cache: bool = True,
+        **_,
+    ) -> 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
-        assert value_id or value_label, 'Please provide either value_id, id_on_network or value_label'
+        assert (
+            value_id or value_label
+        ), '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']:
@@ -350,7 +414,9 @@ class ZwaveMqttPlugin(MqttPlugin, ZwaveBasePlugin):
 
         nodes = []
         if node_id or node_name:
-            nodes = [self._get_node(node_id=node_id, node_name=node_name, use_cache=False)]
+            nodes = [
+                self._get_node(node_id=node_id, node_name=node_name, use_cache=False)
+            ]
 
         if not nodes:
             # noinspection PyUnresolvedReferences
@@ -376,8 +442,14 @@ class ZwaveMqttPlugin(MqttPlugin, ZwaveBasePlugin):
         value = values[0]
 
         if value.get('property_id') == 'targetValue':
-            cur_value_id = '-'.join(value['value_id'].split('-')[:-1] + ['currentValue'])
-            cur_value = self._nodes_cache['by_id'][value['node_id']].get('values', {}).get(cur_value_id)
+            cur_value_id = '-'.join(
+                value['value_id'].split('-')[:-1] + ['currentValue']
+            )
+            cur_value = (
+                self._nodes_cache['by_id'][value['node_id']]
+                .get('values', {})
+                .get(cur_value_id)
+            )
             if cur_value:
                 value['data'] = cur_value['data']
 
@@ -385,42 +457,96 @@ class ZwaveMqttPlugin(MqttPlugin, ZwaveBasePlugin):
         if value['label']:
             self._values_cache['by_label'][value['label']] = value
 
+        self.publish_entities([value])  # type: ignore
         return value
 
+    @staticmethod
+    def _is_switch(value: Mapping):
+        return (
+            value.get('command_class_name', '').endswith('Switch') if value else False
+        )
+
+    def transform_entities(self, values: Iterable[Mapping]):
+        entities = []
+
+        for value in values:
+            if self._is_switch(value):
+                entities.append(
+                    Switch(
+                        id=value['id'],
+                        name='{node_name} [{value_name}]'.format(
+                            node_name=self._nodes_cache['by_id'][value['node_id']].get(
+                                'name', f'[Node {value["node_id"]}]'
+                            ),
+                            value_name=value["label"],
+                        ),
+                        state=value['data'],
+                        description=value.get('help'),
+                        is_read_only=value.get('is_read_only'),
+                        is_write_only=value.get('is_write_only'),
+                        data={
+                            'label': value.get('label'),
+                            'node_id': value.get('node_id'),
+                        },
+                    )
+                )
+
+        return super().transform_entities(entities)  # type: ignore
+
     def _topic_by_value_id(self, value_id: str) -> str:
         return self.topic_prefix + '/' + '/'.join(value_id.split('-'))
 
-    def _filter_values(self, command_classes: Iterable[str], filter_callback: Optional[Callable[[dict], bool]] = None,
-                       node_id: Optional[int] = None, node_name: Optional[str] = None, **kwargs) -> Dict[str, Any]:
-        # noinspection PyUnresolvedReferences
-        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()
+    def _filter_values(
+        self,
+        command_classes: Optional[Iterable[str]] = None,
+        filter_callback: Optional[Callable[[dict], bool]] = None,
+        node_id: Optional[int] = None,
+        node_name: Optional[str] = None,
+        **kwargs,
+    ) -> Dict[str, Any]:
+        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()
+        )
 
         command_classes = {
             command_class_by_name[command_name]
-            for command_name in command_classes
+            for command_name in (command_classes or [])
         }
 
         values = {}
 
         for node in nodes:
             for value in node.get('values', {}).values():
-                if value.get('command_class') not in command_classes or (
-                        filter_callback and not filter_callback(value)):
+                if (
+                    command_classes
+                    and value.get('command_class') not in command_classes
+                ) or (filter_callback and not filter_callback(value)):
                     continue
 
                 value_id = value['id_on_network']
                 if value_id.split('-').pop() == 'targetValue':
                     value_id = '-'.join(value_id.split('-')[:-1]) + '-currentValue'
-                    cur_value = self._nodes_cache['by_id'][value['node_id']].get('values', {}).get(value_id)
+                    cur_value = (
+                        self._nodes_cache['by_id'][value['node_id']]
+                        .get('values', {})
+                        .get(value_id)
+                    )
                     if cur_value:
                         value['data'] = cur_value['data']
 
                 values[value['id_on_network']] = value
 
+        self.publish_entities(values.values())  # type: ignore
         return values
 
-    def _get_group(self, group_id: Optional[str] = None, group_index: Optional[int] = None, **kwargs) -> dict:
+    def _get_group(
+        self,
+        group_id: Optional[str] = None,
+        group_index: Optional[int] = None,
+        **kwargs,
+    ) -> dict:
         group_id = group_id or group_index
         assert group_id is not None, 'No group_id/group_index specified'
         group = self._groups_cache.get(group_id)
@@ -448,15 +574,24 @@ class ZwaveMqttPlugin(MqttPlugin, ZwaveBasePlugin):
 
     @action
     def status(self, **kwargs) -> Dict[str, Any]:
+        """
+        Get the current status of the Z-Wave values.
+
+        :param kwargs: Extra arguments to be passed to :meth:`platypush.plugins.mqtt.MqttPlugin.publish``
+            (default: query the default configured device).
+        """
+        return self._filter_values(**kwargs)
+
+    @action
+    def controller_status(self, **kwargs) -> Dict[str, Any]:
         """
         Get the status of the controller.
 
         :param kwargs: Extra arguments to be passed to :meth:`platypush.plugins.mqtt.MqttPlugin.publish``
             (default: query the default configured device).
-        :return: dict with the following fields: ``device`` and ``state``.
         """
         msg_queue = queue.Queue()
-        topic = f'{self.topic_prefix}/Controller/status'
+        topic = f'{self.topic_prefix}/driver/status'
         client = self._get_client(**kwargs)
 
         def on_message(_, __, msg):
@@ -465,23 +600,26 @@ class ZwaveMqttPlugin(MqttPlugin, 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(
+            kwargs.get('host', self.host),
+            kwargs.get('port', self.port),
+            keepalive=kwargs.get('timeout', self.timeout),
+        )
 
         client.subscribe(topic)
         client.loop_start()
 
         try:
-            status = msg_queue.get(block=True, timeout=kwargs.get('timeout', self.timeout))
+            status = msg_queue.get(
+                block=True, timeout=kwargs.get('timeout', self.timeout)
+            )
         except queue.Empty:
             raise TimeoutError('The request timed out')
         finally:
             client.loop_stop()
 
         return {
-            'device': status['nodeId'],
-            'state': status['status'],
-            'stats': {},
+            'state': status,
         }
 
     @action
@@ -510,7 +648,9 @@ class ZwaveMqttPlugin(MqttPlugin, ZwaveBasePlugin):
         raise _NOT_IMPLEMENTED_ERR
 
     @action
-    def remove_failed_node(self, node_id: Optional[int] = None, node_name: Optional[str] = None, **kwargs):
+    def remove_failed_node(
+        self, node_id: Optional[int] = None, node_name: Optional[str] = None, **kwargs
+    ):
         """
         Remove a failed node from the network.
 
@@ -526,7 +666,9 @@ class ZwaveMqttPlugin(MqttPlugin, ZwaveBasePlugin):
         self._api_request('removeFailedNode', node_id, **kwargs)
 
     @action
-    def replace_failed_node(self, node_id: Optional[int] = None, node_name: Optional[str] = None, **kwargs):
+    def replace_failed_node(
+        self, node_id: Optional[int] = None, node_name: Optional[str] = None, **kwargs
+    ):
         """
         Replace a failed node on the network.
 
@@ -549,7 +691,9 @@ class ZwaveMqttPlugin(MqttPlugin, ZwaveBasePlugin):
         raise _NOT_IMPLEMENTED_ERR
 
     @action
-    def request_network_update(self, node_id: Optional[int] = None, node_name: Optional[str] = None, **kwargs):
+    def request_network_update(
+        self, node_id: Optional[int] = None, node_name: Optional[str] = None, **kwargs
+    ):
         """
         Request a network update to a node.
 
@@ -575,8 +719,9 @@ class ZwaveMqttPlugin(MqttPlugin, ZwaveBasePlugin):
         self._api_request('refreshNeighbors', **kwargs)
 
     @action
-    def get_nodes(self, node_id: Optional[int] = None, node_name: Optional[str] = None, **kwargs) \
-            -> Optional[Dict[str, Any]]:
+    def get_nodes(
+        self, node_id: Optional[int] = None, node_name: Optional[str] = None, **kwargs
+    ) -> Optional[Dict[str, Any]]:
         """
         Get the nodes associated to the network.
 
@@ -740,7 +885,9 @@ class ZwaveMqttPlugin(MqttPlugin, ZwaveBasePlugin):
             (default: query the default configured device).
         """
         if node_id or node_name:
-            return self._get_node(node_id=node_id, node_name=node_name, use_cache=False, **kwargs)
+            return self._get_node(
+                node_id=node_id, node_name=node_name, use_cache=False, **kwargs
+            )
 
         nodes = {
             node['id']: self.node_to_dict(node)
@@ -748,10 +895,7 @@ class ZwaveMqttPlugin(MqttPlugin, ZwaveBasePlugin):
         }
 
         self._nodes_cache['by_id'] = nodes
-        self._nodes_cache['by_name'] = {
-            node['name']: node
-            for node in nodes.values()
-        }
+        self._nodes_cache['by_name'] = {node['name']: node for node in nodes.values()}
 
         return nodes
 
@@ -763,7 +907,13 @@ class ZwaveMqttPlugin(MqttPlugin, ZwaveBasePlugin):
         raise _NOT_IMPLEMENTED_ERR
 
     @action
-    def set_node_name(self, new_name: str, node_id: Optional[int] = None, node_name: Optional[str] = None, **kwargs):
+    def set_node_name(
+        self,
+        new_name: str,
+        node_id: Optional[int] = None,
+        node_name: Optional[str] = None,
+        **kwargs,
+    ):
         """
         Rename a node on the network.
 
@@ -778,10 +928,14 @@ class ZwaveMqttPlugin(MqttPlugin, ZwaveBasePlugin):
 
         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),
-            'name': new_name,
-        }))
+        get_bus().post(
+            ZwaveNodeRenamedEvent(
+                node={
+                    **self._get_node(node_id=node_id),
+                    'name': new_name,
+                }
+            )
+        )
 
     @action
     def set_node_product_name(self, **kwargs):
@@ -798,8 +952,13 @@ class ZwaveMqttPlugin(MqttPlugin, ZwaveBasePlugin):
         raise _NOT_IMPLEMENTED_ERR
 
     @action
-    def set_node_location(self, location: str, node_id: Optional[int] = None, node_name: Optional[str] = None,
-                          **kwargs):
+    def set_node_location(
+        self,
+        location: str,
+        node_id: Optional[int] = None,
+        node_name: Optional[str] = None,
+        **kwargs,
+    ):
         """
         Set the location of a node.
 
@@ -814,10 +973,14 @@ class ZwaveMqttPlugin(MqttPlugin, ZwaveBasePlugin):
 
         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),
-            'location': location,
-        }))
+        get_bus().post(
+            ZwaveNodeEvent(
+                node={
+                    **self._get_node(node_id=node_id),
+                    'location': location,
+                }
+            )
+        )
 
     @action
     def cancel_command(self, **kwargs):
@@ -877,7 +1040,9 @@ class ZwaveMqttPlugin(MqttPlugin, ZwaveBasePlugin):
         """
         self._api_request('beginHealingNetwork', **kwargs)
         if timeout:
-            Timer(timeout, lambda: self._api_request('stopHealingNetwork', **kwargs)).start()
+            Timer(
+                timeout, lambda: self._api_request('stopHealingNetwork', **kwargs)
+            ).start()
 
     @action
     def switch_all(self, **kwargs):
@@ -894,9 +1059,15 @@ class ZwaveMqttPlugin(MqttPlugin, ZwaveBasePlugin):
         raise _NOT_IMPLEMENTED_ERR
 
     @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]:
+    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.
 
@@ -908,13 +1079,27 @@ class ZwaveMqttPlugin(MqttPlugin, ZwaveBasePlugin):
         :param kwargs: Extra arguments to be passed to :meth:`platypush.plugins.mqtt.MqttPlugin.publish``
             (default: query the default configured device).
         """
-        return self._get_value(value_id=value_id, value_label=value_label, id_on_network=id_on_network, node_id=node_id,
-                               node_name=node_name, use_cache=False, **kwargs)
+        return self._get_value(
+            value_id=value_id,
+            value_label=value_label,
+            id_on_network=id_on_network,
+            node_id=node_id,
+            node_name=node_name,
+            use_cache=False,
+            **kwargs,
+        )
 
     @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):
+    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.
 
@@ -927,40 +1112,57 @@ class ZwaveMqttPlugin(MqttPlugin, ZwaveBasePlugin):
         :param kwargs: Extra arguments to be passed to :meth:`platypush.plugins.mqtt.MqttPlugin.publish``
             (default: query the default configured device).
         """
-        value = self._get_value(value_id=value_id, value_label=value_label, id_on_network=id_on_network,
-                                node_id=node_id, node_name=node_name, **kwargs)
+        value = self._get_value(
+            value_id=value_id,
+            value_label=value_label,
+            id_on_network=id_on_network,
+            node_id=node_id,
+            node_name=node_name,
+            **kwargs,
+        )
 
-        self._api_request('writeValue', {
-            'nodeId': value['node_id'],
-            'commandClass': value['command_class'],
-            'endpoint': value.get('endpoint', 0),
-            'property': value['property_id'],
-            **({'propertyKey': value['property_key']} if 'property_key' in value else {}),
-        }, data, **kwargs)
+        self._api_request(
+            'writeValue',
+            {
+                'nodeId': value['node_id'],
+                'commandClass': value['command_class'],
+                'endpoint': value.get('endpoint', 0),
+                'property': value['property_id'],
+                **(
+                    {'propertyKey': value['property_key']}
+                    if 'property_key' in value
+                    else {}
+                ),
+            },
+            data,
+            **kwargs,
+        )
 
     @action
-    def set_value_label(self, **kwargs):
+    def set_value_label(self, **_):
         """
         Change the label/name of a value (not implemented by zwavejs2mqtt).
         """
         raise _NOT_IMPLEMENTED_ERR
 
     @action
-    def node_add_value(self, **kwargs):
+    def node_add_value(self, **_):
         """
         Add a value to a node (not implemented by zwavejs2mqtt).
         """
         raise _NOT_IMPLEMENTED_ERR
 
     @action
-    def node_remove_value(self, **kwargs):
+    def node_remove_value(self, **_):
         """
         Remove a value from a node (not implemented by zwavejs2mqtt).
         """
         raise _NOT_IMPLEMENTED_ERR
 
     @action
-    def node_heal(self, node_id: Optional[int] = None, node_name: Optional[str] = None, **kwargs):
+    def node_heal(
+        self, node_id: Optional[int] = None, node_name: Optional[str] = None, **kwargs
+    ):
         """
         Heal network node by requesting the node to rediscover their neighbours.
 
@@ -974,7 +1176,9 @@ class ZwaveMqttPlugin(MqttPlugin, ZwaveBasePlugin):
         self._api_request('healNode', node_id, **kwargs)
 
     @action
-    def node_update_neighbours(self, node_id: Optional[int] = None, node_name: Optional[str] = None, **kwargs):
+    def node_update_neighbours(
+        self, node_id: Optional[int] = None, node_name: Optional[str] = None, **kwargs
+    ):
         """
         Ask a node to update its neighbours table
         (same as :meth:`platypush.plugins.zwave.mqtt.ZwaveMqttPlugin.node_heal`).
@@ -987,7 +1191,9 @@ class ZwaveMqttPlugin(MqttPlugin, ZwaveBasePlugin):
         self.node_heal(node_id=node_id, node_name=node_name, **kwargs)
 
     @action
-    def node_network_update(self, node_id: Optional[int] = None, node_name: Optional[str] = None, **kwargs):
+    def node_network_update(
+        self, node_id: Optional[int] = None, node_name: Optional[str] = None, **kwargs
+    ):
         """
         Update the controller with network information
         (same as :meth:`platypush.plugins.zwave.mqtt.ZwaveMqttPlugin.node_heal`).
@@ -1000,7 +1206,9 @@ class ZwaveMqttPlugin(MqttPlugin, ZwaveBasePlugin):
         self.node_heal(node_id=node_id, node_name=node_name, **kwargs)
 
     @action
-    def node_refresh_info(self, node_id: Optional[int] = None, node_name: Optional[str] = None, **kwargs):
+    def node_refresh_info(
+        self, node_id: Optional[int] = None, node_name: Optional[str] = None, **kwargs
+    ):
         """
         Fetch up-to-date information about the node.
 
@@ -1014,7 +1222,9 @@ class ZwaveMqttPlugin(MqttPlugin, ZwaveBasePlugin):
         self._api_request('refreshInfo', node_id, **kwargs)
 
     @action
-    def get_dimmers(self, node_id: Optional[int] = None, node_name: Optional[str] = None, **kwargs) -> Dict[str, Any]:
+    def get_dimmers(
+        self, node_id: Optional[int] = None, node_name: Optional[str] = None, **kwargs
+    ) -> Dict[str, Any]:
         """
         Get the dimmers on the network or associated to a node.
 
@@ -1023,12 +1233,17 @@ class ZwaveMqttPlugin(MqttPlugin, ZwaveBasePlugin):
         :param kwargs: Extra arguments to be passed to :meth:`platypush.plugins.mqtt.MqttPlugin.publish``
             (default: query the default configured device).
         """
-        return self._filter_values(['switch_multilevel', 'switch_toggle_multilevel'], node_id=node_id,
-                                   node_name=node_name, **kwargs)
+        return self._filter_values(
+            ['switch_multilevel', 'switch_toggle_multilevel'],
+            node_id=node_id,
+            node_name=node_name,
+            **kwargs,
+        )
 
     @action
-    def get_node_config(self, node_id: Optional[int] = None, node_name: Optional[str] = None,
-                        **kwargs) -> Dict[str, Any]:
+    def get_node_config(
+        self, node_id: Optional[int] = None, node_name: Optional[str] = None, **kwargs
+    ) -> Dict[str, Any]:
         """
         Get the configuration values of a node or of all the nodes on the network.
 
@@ -1037,12 +1252,22 @@ class ZwaveMqttPlugin(MqttPlugin, ZwaveBasePlugin):
         :param kwargs: Extra arguments to be passed to :meth:`platypush.plugins.mqtt.MqttPlugin.publish``
             (default: query the default configured device).
         """
-        return self._filter_values(['configuration', 'ip_configuration', 'association_command_configuration',
-                                    'sensor_configuration'], node_id=node_id, node_name=node_name, **kwargs)
+        return self._filter_values(
+            [
+                'configuration',
+                'ip_configuration',
+                'association_command_configuration',
+                'sensor_configuration',
+            ],
+            node_id=node_id,
+            node_name=node_name,
+            **kwargs,
+        )
 
     @action
-    def get_battery_levels(self, node_id: Optional[int] = None, node_name: Optional[str] = None,
-                           **kwargs) -> Dict[str, Any]:
+    def get_battery_levels(
+        self, node_id: Optional[int] = None, node_name: Optional[str] = None, **kwargs
+    ) -> Dict[str, Any]:
         """
         Get the battery levels of a node or of all the nodes on the network.
 
@@ -1051,11 +1276,14 @@ class ZwaveMqttPlugin(MqttPlugin, ZwaveBasePlugin):
         :param kwargs: Extra arguments to be passed to :meth:`platypush.plugins.mqtt.MqttPlugin.publish``
             (default: query the default configured device).
         """
-        return self._filter_values(['battery'], node_id=node_id, node_name=node_name, **kwargs)
+        return self._filter_values(
+            ['battery'], node_id=node_id, node_name=node_name, **kwargs
+        )
 
     @action
-    def get_power_levels(self, node_id: Optional[int] = None, node_name: Optional[str] = None,
-                         **kwargs) -> Dict[str, Any]:
+    def get_power_levels(
+        self, node_id: Optional[int] = None, node_name: Optional[str] = None, **kwargs
+    ) -> Dict[str, Any]:
         """
         Get the power levels of this node.
 
@@ -1064,10 +1292,14 @@ class ZwaveMqttPlugin(MqttPlugin, ZwaveBasePlugin):
         :param kwargs: Extra arguments to be passed to :meth:`platypush.plugins.mqtt.MqttPlugin.publish``
             (default: query the default configured device).
         """
-        return self._filter_values(['powerlevel'], node_id=node_id, node_name=node_name, **kwargs)
+        return self._filter_values(
+            ['powerlevel'], node_id=node_id, node_name=node_name, **kwargs
+        )
 
     @action
-    def get_bulbs(self, node_id: Optional[int] = None, node_name: Optional[str] = None, **kwargs) -> Dict[str, Any]:
+    def get_bulbs(
+        self, node_id: Optional[int] = None, node_name: Optional[str] = None, **kwargs
+    ) -> Dict[str, Any]:
         """
         Get the bulbs/LEDs on the network or associated to a node.
 
@@ -1076,11 +1308,18 @@ class ZwaveMqttPlugin(MqttPlugin, ZwaveBasePlugin):
         :param kwargs: Extra arguments to be passed to :meth:`platypush.plugins.mqtt.MqttPlugin.publish``
             (default: query the default configured device).
         """
-        return self._filter_values(['color'], filter_callback=lambda value: not value['is_read_only'], node_id=node_id,
-                                   node_name=node_name, **kwargs)
+        return self._filter_values(
+            ['color'],
+            filter_callback=lambda value: not value['is_read_only'],
+            node_id=node_id,
+            node_name=node_name,
+            **kwargs,
+        )
 
     @action
-    def get_switches(self, node_id: Optional[int] = None, node_name: Optional[str] = None, **kwargs) -> Dict[str, Any]:
+    def get_switches(
+        self, node_id: Optional[int] = None, node_name: Optional[str] = None, **kwargs
+    ) -> Dict[str, Any]:
         """
         Get the switches on the network or associated to a node.
 
@@ -1089,12 +1328,18 @@ class ZwaveMqttPlugin(MqttPlugin, ZwaveBasePlugin):
         :param kwargs: Extra arguments to be passed to :meth:`platypush.plugins.mqtt.MqttPlugin.publish``
             (default: query the default configured device).
         """
-        return self._filter_values(['switch_binary', 'switch_toggle_binary'],
-                                   filter_callback=lambda value: not value['is_read_only'],
-                                   node_id=node_id, node_name=node_name, **kwargs)
+        return self._filter_values(
+            ['switch_binary', 'switch_toggle_binary'],
+            filter_callback=lambda value: not value['is_read_only'],
+            node_id=node_id,
+            node_name=node_name,
+            **kwargs,
+        )
 
     @action
-    def get_sensors(self, node_id: Optional[int] = None, node_name: Optional[str] = None, **kwargs) -> Dict[str, Any]:
+    def get_sensors(
+        self, node_id: Optional[int] = None, node_name: Optional[str] = None, **kwargs
+    ) -> Dict[str, Any]:
         """
         Get the sensors on the network or associated to a node.
 
@@ -1103,12 +1348,18 @@ class ZwaveMqttPlugin(MqttPlugin, ZwaveBasePlugin):
         :param kwargs: Extra arguments to be passed to :meth:`platypush.plugins.mqtt.MqttPlugin.publish``
             (default: query the default configured device).
         """
-        return self._filter_values(['sensor_binary', 'sensor_multilevel', 'sensor_alarm', 'meter'],
-                                   filter_callback=lambda value: not value['is_write_only'],
-                                   node_id=node_id, node_name=node_name, **kwargs)
+        return self._filter_values(
+            ['sensor_binary', 'sensor_multilevel', 'sensor_alarm', 'meter'],
+            filter_callback=lambda value: not value['is_write_only'],
+            node_id=node_id,
+            node_name=node_name,
+            **kwargs,
+        )
 
     @action
-    def get_doorlocks(self, node_id: Optional[int] = None, node_name: Optional[str] = None, **kwargs) -> Dict[str, Any]:
+    def get_doorlocks(
+        self, node_id: Optional[int] = None, node_name: Optional[str] = None, **kwargs
+    ) -> Dict[str, Any]:
         """
         Get the doorlocks on the network or associated to a node.
 
@@ -1117,10 +1368,14 @@ class ZwaveMqttPlugin(MqttPlugin, ZwaveBasePlugin):
         :param kwargs: Extra arguments to be passed to :meth:`platypush.plugins.mqtt.MqttPlugin.publish``
             (default: query the default configured device).
         """
-        return self._filter_values(['door_lock'], node_id=node_id, node_name=node_name, **kwargs)
+        return self._filter_values(
+            ['door_lock'], node_id=node_id, node_name=node_name, **kwargs
+        )
 
     @action
-    def get_locks(self, node_id: Optional[int] = None, node_name: Optional[str] = None, **kwargs) -> Dict[str, Any]:
+    def get_locks(
+        self, node_id: Optional[int] = None, node_name: Optional[str] = None, **kwargs
+    ) -> Dict[str, Any]:
         """
         Get the locks on the network or associated to a node.
 
@@ -1129,10 +1384,14 @@ class ZwaveMqttPlugin(MqttPlugin, ZwaveBasePlugin):
         :param kwargs: Extra arguments to be passed to :meth:`platypush.plugins.mqtt.MqttPlugin.publish``
             (default: query the default configured device).
         """
-        return self._filter_values(['lock'], node_id=node_id, node_name=node_name, **kwargs)
+        return self._filter_values(
+            ['lock'], node_id=node_id, node_name=node_name, **kwargs
+        )
 
     @action
-    def get_usercodes(self, node_id: Optional[int] = None, node_name: Optional[str] = None, **kwargs) -> Dict[str, Any]:
+    def get_usercodes(
+        self, node_id: Optional[int] = None, node_name: Optional[str] = None, **kwargs
+    ) -> Dict[str, Any]:
         """
         Get the usercodes on the network or associated to a node.
 
@@ -1141,11 +1400,14 @@ class ZwaveMqttPlugin(MqttPlugin, ZwaveBasePlugin):
         :param kwargs: Extra arguments to be passed to :meth:`platypush.plugins.mqtt.MqttPlugin.publish``
             (default: query the default configured device).
         """
-        return self._filter_values(['user_code'], node_id=node_id, node_name=node_name, **kwargs)
+        return self._filter_values(
+            ['user_code'], node_id=node_id, node_name=node_name, **kwargs
+        )
 
     @action
-    def get_thermostats(self, node_id: Optional[int] = None, node_name: Optional[str] = None, **kwargs) \
-            -> Dict[str, Any]:
+    def get_thermostats(
+        self, node_id: Optional[int] = None, node_name: Optional[str] = None, **kwargs
+    ) -> Dict[str, Any]:
         """
         Get the thermostats on the network or associated to a node.
 
@@ -1154,13 +1416,25 @@ class ZwaveMqttPlugin(MqttPlugin, ZwaveBasePlugin):
         :param kwargs: Extra arguments to be passed to :meth:`platypush.plugins.mqtt.MqttPlugin.publish``
             (default: query the default configured device).
         """
-        return self._filter_values(['thermostat_heating', 'thermostat_mode', 'thermostat_operating_state',
-                                    'thermostat_setpoint', 'thermostat_fan_mode', 'thermostat_fan_state',
-                                    'thermostat_setback'], node_id=node_id, node_name=node_name, **kwargs)
+        return self._filter_values(
+            [
+                'thermostat_heating',
+                'thermostat_mode',
+                'thermostat_operating_state',
+                'thermostat_setpoint',
+                'thermostat_fan_mode',
+                'thermostat_fan_state',
+                'thermostat_setback',
+            ],
+            node_id=node_id,
+            node_name=node_name,
+            **kwargs,
+        )
 
     @action
-    def get_protections(self, node_id: Optional[int] = None, node_name: Optional[str] = None, **kwargs) \
-            -> Dict[str, Any]:
+    def get_protections(
+        self, node_id: Optional[int] = None, node_name: Optional[str] = None, **kwargs
+    ) -> Dict[str, Any]:
         """
         Get the protection-compatible devices on the network or associated to a node.
 
@@ -1169,7 +1443,9 @@ class ZwaveMqttPlugin(MqttPlugin, ZwaveBasePlugin):
         :param kwargs: Extra arguments to be passed to :meth:`platypush.plugins.mqtt.MqttPlugin.publish``
             (default: query the default configured device).
         """
-        return self._filter_values(['protection'], node_id=node_id, node_name=node_name, **kwargs)
+        return self._filter_values(
+            ['protection'], node_id=node_id, node_name=node_name, **kwargs
+        )
 
     @action
     def get_groups(self, **kwargs) -> Dict[str, dict]:
@@ -1218,7 +1494,10 @@ class ZwaveMqttPlugin(MqttPlugin, ZwaveBasePlugin):
             group['group_id']: {
                 **group,
                 'associations': [
-                    assoc['nodeId'] for assoc in self._api_request('getAssociations', node_id, group['index'])
+                    assoc['nodeId']
+                    for assoc in self._api_request(
+                        'getAssociations', node_id, group['index']
+                    )
                 ],
             }
             for node_id, node in nodes.items()
@@ -1228,7 +1507,7 @@ class ZwaveMqttPlugin(MqttPlugin, ZwaveBasePlugin):
         return self._groups_cache
 
     @action
-    def get_scenes(self, **kwargs) -> Dict[int, Dict[str, Any]]:
+    def get_scenes(self, **_) -> Dict[int, Dict[str, Any]]:
         """
         Get the scenes configured on the network.
 
@@ -1264,7 +1543,7 @@ class ZwaveMqttPlugin(MqttPlugin, ZwaveBasePlugin):
         }
 
     @action
-    def create_scene(self, label: str, **kwargs):
+    def create_scene(self, label: str, **_):
         """
         Create a new scene.
 
@@ -1275,7 +1554,12 @@ class ZwaveMqttPlugin(MqttPlugin, ZwaveBasePlugin):
         self._api_request('_createScene', label)
 
     @action
-    def remove_scene(self, scene_id: Optional[int] = None, scene_label: Optional[str] = None, **kwargs):
+    def remove_scene(
+        self,
+        scene_id: Optional[int] = None,
+        scene_label: Optional[str] = None,
+        **kwargs,
+    ):
         """
         Remove a scene.
 
@@ -1288,7 +1572,12 @@ class ZwaveMqttPlugin(MqttPlugin, ZwaveBasePlugin):
         self._api_request('_removeScene', scene['scene_id'])
 
     @action
-    def activate_scene(self, scene_id: Optional[int] = None, scene_label: Optional[str] = None, **kwargs):
+    def activate_scene(
+        self,
+        scene_id: Optional[int] = None,
+        scene_label: Optional[str] = None,
+        **kwargs,
+    ):
         """
         Activate a scene.
 
@@ -1301,8 +1590,13 @@ class ZwaveMqttPlugin(MqttPlugin, ZwaveBasePlugin):
         self._api_request('_activateScene', scene['scene_id'])
 
     @action
-    def set_scene_label(self, new_label: str, scene_id: Optional[int] = None, scene_label: Optional[str] = None,
-                        **kwargs):
+    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.
 
@@ -1315,10 +1609,18 @@ class ZwaveMqttPlugin(MqttPlugin, ZwaveBasePlugin):
         raise _NOT_IMPLEMENTED_ERR
 
     @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):
+    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.
 
@@ -1333,22 +1635,41 @@ class ZwaveMqttPlugin(MqttPlugin, ZwaveBasePlugin):
         :param kwargs: Extra arguments to be passed to :meth:`platypush.plugins.mqtt.MqttPlugin.publish``
             (default: query the default configured device).
         """
-        value = self._get_value(value_id=value_id, value_label=value_label, id_on_network=id_on_network,
-                                node_id=node_id, node_name=node_name, **kwargs)
+        value = self._get_value(
+            value_id=value_id,
+            value_label=value_label,
+            id_on_network=id_on_network,
+            node_id=node_id,
+            node_name=node_name,
+            **kwargs,
+        )
         scene = self._get_scene(scene_id=scene_id, scene_label=scene_label, **kwargs)
 
-        self._api_request('_addSceneValue', scene['scene_id'], {
-            'nodeId': value['node_id'],
-            'commandClass': value['command_class'],
-            'property': value['property_id'],
-            'endpoint': value['endpoint'],
-        }, data, kwargs.get('timeout', self.timeout))
+        self._api_request(
+            '_addSceneValue',
+            scene['scene_id'],
+            {
+                'nodeId': value['node_id'],
+                'commandClass': value['command_class'],
+                'property': value['property_id'],
+                'endpoint': value['endpoint'],
+            },
+            data,
+            kwargs.get('timeout', self.timeout),
+        )
 
     @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):
+    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.
 
@@ -1362,13 +1683,24 @@ class ZwaveMqttPlugin(MqttPlugin, ZwaveBasePlugin):
         :param kwargs: Extra arguments to be passed to :meth:`platypush.plugins.mqtt.MqttPlugin.publish``
             (default: query the default configured device).
         """
-        value = self._get_value(value_id=value_id, value_label=value_label, id_on_network=id_on_network,
-                                node_id=node_id, node_name=node_name, **kwargs)
+        value = self._get_value(
+            value_id=value_id,
+            value_label=value_label,
+            id_on_network=id_on_network,
+            node_id=node_id,
+            node_name=node_name,
+            **kwargs,
+        )
         scene = self._get_scene(scene_id=scene_id, scene_label=scene_label, **kwargs)
         self._api_request('_removeSceneValue', scene['scene_id'], value['value_id'])
 
     @action
-    def get_scene_values(self, scene_id: Optional[int] = None, scene_label: Optional[str] = None, **kwargs) -> dict:
+    def get_scene_values(
+        self,
+        scene_id: Optional[int] = None,
+        scene_label: Optional[str] = None,
+        **kwargs,
+    ) -> dict:
         """
         Get the values associated to a scene.
 
@@ -1381,8 +1713,13 @@ class ZwaveMqttPlugin(MqttPlugin, ZwaveBasePlugin):
         return scene.get('values', {})
 
     @action
-    def create_button(self, button_id: Union[int, str], node_id: Optional[int] = None, node_name: Optional[str] = None,
-                      **kwargs):
+    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
         (not implemented by zwavejs2mqtt).
@@ -1396,8 +1733,13 @@ class ZwaveMqttPlugin(MqttPlugin, ZwaveBasePlugin):
         raise _NOT_IMPLEMENTED_ERR
 
     @action
-    def delete_button(self, button_id: Union[int, str], node_id: Optional[int] = None, node_name: Optional[str] = None,
-                      **kwargs):
+    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.
         (not implemented by zwavejs2mqtt).
@@ -1411,8 +1753,13 @@ class ZwaveMqttPlugin(MqttPlugin, ZwaveBasePlugin):
         raise _NOT_IMPLEMENTED_ERR
 
     @action
-    def add_node_to_group(self, group_id: Optional[str] = None, node_id: Optional[int] = None,
-                          endpoint: Optional[int] = None, **kwargs):
+    def add_node_to_group(
+        self,
+        group_id: Optional[str] = None,
+        node_id: Optional[int] = None,
+        endpoint: Optional[int] = None,
+        **kwargs,
+    ):
         """
         Add a node to a group.
 
@@ -1430,8 +1777,13 @@ class ZwaveMqttPlugin(MqttPlugin, ZwaveBasePlugin):
         self._api_request('addAssociations', group['node_id'], group['index'], [assoc])
 
     @action
-    def remove_node_from_group(self, group_id: Optional[str] = None, node_id: Optional[int] = None,
-                               endpoint: Optional[int] = None, **kwargs):
+    def remove_node_from_group(
+        self,
+        group_id: Optional[str] = None,
+        node_id: Optional[int] = None,
+        endpoint: Optional[int] = None,
+        **kwargs,
+    ):
         """
         Remove a node from a group.
 
@@ -1446,10 +1798,12 @@ class ZwaveMqttPlugin(MqttPlugin, ZwaveBasePlugin):
         if endpoint is not None:
             assoc['endpoint'] = endpoint
 
-        self._api_request('removeAssociations', group['node_id'], group['index'], [assoc])
+        self._api_request(
+            'removeAssociations', group['node_id'], group['index'], [assoc]
+        )
 
     @action
-    def create_new_primary(self, **kwargs):
+    def create_new_primary(self, **_):
         """
         Create a new primary controller on the network when the previous primary fails
         (not implemented by zwavejs2mqtt).
@@ -1460,7 +1814,7 @@ class ZwaveMqttPlugin(MqttPlugin, ZwaveBasePlugin):
         raise _NOT_IMPLEMENTED_ERR
 
     @action
-    def hard_reset(self, **kwargs):
+    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.
@@ -1471,7 +1825,7 @@ class ZwaveMqttPlugin(MqttPlugin, ZwaveBasePlugin):
         self._api_request('hardReset')
 
     @action
-    def soft_reset(self, **kwargs):
+    def soft_reset(self, **_):
         """
         Perform a soft reset of the controller.
         Resets a controller without erasing its network configuration settings (not implemented by zwavejs2).
@@ -1482,7 +1836,7 @@ class ZwaveMqttPlugin(MqttPlugin, ZwaveBasePlugin):
         raise _NOT_IMPLEMENTED_ERR
 
     @action
-    def write_config(self, **kwargs):
+    def write_config(self, **_):
         """
         Store the current configuration of the network to the user directory (not implemented by zwavejs2mqtt).
 
@@ -1492,7 +1846,7 @@ class ZwaveMqttPlugin(MqttPlugin, ZwaveBasePlugin):
         raise _NOT_IMPLEMENTED_ERR
 
     @action
-    def on(self, device: str, *args, **kwargs):
+    def on(self, device: str, *_, **kwargs):
         """
         Turn on a switch on a device.
 
@@ -1503,7 +1857,7 @@ class ZwaveMqttPlugin(MqttPlugin, ZwaveBasePlugin):
         self.set_value(data=True, id_on_network=device, **kwargs)
 
     @action
-    def off(self, device: str, *args, **kwargs):
+    def off(self, device: str, *_, **kwargs):
         """
         Turn off a switch on a device.
 
@@ -1514,7 +1868,7 @@ class ZwaveMqttPlugin(MqttPlugin, ZwaveBasePlugin):
         self.set_value(data=False, id_on_network=device, **kwargs)
 
     @action
-    def toggle(self, device: str, *args, **kwargs) -> dict:
+    def toggle(self, device: str, *_, **kwargs) -> dict:
         """
         Toggle a switch on a device.
 
@@ -1527,8 +1881,10 @@ class ZwaveMqttPlugin(MqttPlugin, ZwaveBasePlugin):
         self.set_value(data=value['data'], id_on_network=device, **kwargs)
 
         return {
-            'name': '{} - {}'.format(self._nodes_cache['by_id'][value['node_id']]['name'],
-                                     value.get('label', '[No Label]')),
+            'name': '{} - {}'.format(
+                self._nodes_cache['by_id'][value['node_id']]['name'],
+                value.get('label', '[No Label]'),
+            ),
             'on': value['data'],
             'id': value['value_id'],
         }
@@ -1541,11 +1897,14 @@ class ZwaveMqttPlugin(MqttPlugin, ZwaveBasePlugin):
         devices = self.get_switches().output.values()
         return [
             {
-                'name': '{} - {}'.format(self._nodes_cache['by_id'][dev['node_id']]['name'],
-                                         dev.get('label', '[No Label]')),
+                'name': '{} - {}'.format(
+                    self._nodes_cache['by_id'][dev['node_id']]['name'],
+                    dev.get('label', '[No Label]'),
+                ),
                 'on': dev['data'],
                 'id': dev['value_id'],
-            } for dev in devices
+            }
+            for dev in devices
         ]
 
 
diff --git a/platypush/user/__init__.py b/platypush/user/__init__.py
index ccc2040aa..c6c2bcd22 100644
--- a/platypush/user/__init__.py
+++ b/platypush/user/__init__.py
@@ -13,11 +13,13 @@ except ImportError:
     from jwt import PyJWTError, encode as jwt_encode, decode as jwt_decode
 
 from sqlalchemy import Column, Integer, String, DateTime, ForeignKey
-from sqlalchemy.orm import sessionmaker, scoped_session
-from sqlalchemy.ext.declarative import declarative_base
+from sqlalchemy.orm import make_transient, declarative_base
 
 from platypush.context import get_plugin
-from platypush.exceptions.user import InvalidJWTTokenException, InvalidCredentialsException
+from platypush.exceptions.user import (
+    InvalidJWTTokenException,
+    InvalidCredentialsException,
+)
 from platypush.utils import get_or_generate_jwt_rsa_key_pair
 
 Base = declarative_base()
@@ -28,126 +30,144 @@ class UserManager:
     Main class for managing platform users
     """
 
-    # noinspection PyProtectedMember
     def __init__(self):
-        db_plugin = get_plugin('db')
-        if not db_plugin:
-            raise ModuleNotFoundError('Please enable/configure the db plugin for multi-user support')
+        self.db = get_plugin('db')
+        assert self.db
+        self._engine = self.db.get_engine()
+        self.db.create_all(self._engine, Base)
 
-        self._engine = db_plugin._get_engine()
-
-    def get_user(self, username):
-        session = self._get_db_session()
-        user = self._get_user(session, username)
-        if not user:
-            return None
-
-        # Hide password
+    @staticmethod
+    def _mask_password(user):
+        make_transient(user)
         user.password = None
         return user
 
+    def get_user(self, username):
+        with self.db.get_session() as session:
+            user = self._get_user(session, username)
+            if not user:
+                return None
+
+            session.expunge(user)
+            return self._mask_password(user)
+
     def get_user_count(self):
-        session = self._get_db_session()
-        return session.query(User).count()
+        with self.db.get_session() as session:
+            return session.query(User).count()
 
     def get_users(self):
-        session = self._get_db_session()
-        return session.query(User)
+        with self.db.get_session() as session:
+            return session.query(User)
 
     def create_user(self, username, password, **kwargs):
-        session = self._get_db_session()
         if not username:
             raise ValueError('Invalid or empty username')
         if not password:
             raise ValueError('Please provide a password for the user')
 
-        user = self._get_user(session, username)
-        if user:
-            raise NameError('The user {} already exists'.format(username))
+        with self.db.get_session() as session:
+            user = self._get_user(session, username)
+            if user:
+                raise NameError('The user {} already exists'.format(username))
 
-        record = User(username=username, password=self._encrypt_password(password),
-                      created_at=datetime.datetime.utcnow(), **kwargs)
+            record = User(
+                username=username,
+                password=self._encrypt_password(password),
+                created_at=datetime.datetime.utcnow(),
+                **kwargs
+            )
 
-        session.add(record)
-        session.commit()
-        user = self._get_user(session, username)
+            session.add(record)
+            session.commit()
+            user = self._get_user(session, username)
 
-        # Hide password
-        user.password = None
-        return user
+        return self._mask_password(user)
 
     def update_password(self, username, old_password, new_password):
-        session = self._get_db_session()
-        if not self._authenticate_user(session, username, old_password):
-            return False
+        with self.db.get_session() as session:
+            if not self._authenticate_user(session, username, old_password):
+                return False
 
-        user = self._get_user(session, username)
-        user.password = self._encrypt_password(new_password)
-        session.commit()
-        return True
+            user = self._get_user(session, username)
+            user.password = self._encrypt_password(new_password)
+            session.commit()
+            return True
 
     def authenticate_user(self, username, password):
-        session = self._get_db_session()
-        return self._authenticate_user(session, username, password)
+        with self.db.get_session() as session:
+            return self._authenticate_user(session, username, password)
 
     def authenticate_user_session(self, session_token):
-        session = self._get_db_session()
-        user_session = session.query(UserSession).filter_by(session_token=session_token).first()
+        with self.db.get_session() as session:
+            user_session = (
+                session.query(UserSession)
+                .filter_by(session_token=session_token)
+                .first()
+            )
 
-        if not user_session or (
-                user_session.expires_at and user_session.expires_at < datetime.datetime.utcnow()):
-            return None, None
+            if not user_session or (
+                user_session.expires_at
+                and user_session.expires_at < datetime.datetime.utcnow()
+            ):
+                return None, None
 
-        user = session.query(User).filter_by(user_id=user_session.user_id).first()
-
-        # Hide password
-        user.password = None
-        return user, session
+            user = session.query(User).filter_by(user_id=user_session.user_id).first()
+            return self._mask_password(user), user_session
 
     def delete_user(self, username):
-        session = self._get_db_session()
-        user = self._get_user(session, username)
-        if not user:
-            raise NameError('No such user: {}'.format(username))
+        with self.db.get_session() as session:
+            user = self._get_user(session, username)
+            if not user:
+                raise NameError('No such user: {}'.format(username))
 
-        user_sessions = session.query(UserSession).filter_by(user_id=user.user_id).all()
-        for user_session in user_sessions:
-            session.delete(user_session)
+            user_sessions = (
+                session.query(UserSession).filter_by(user_id=user.user_id).all()
+            )
+            for user_session in user_sessions:
+                session.delete(user_session)
 
-        session.delete(user)
-        session.commit()
-        return True
+            session.delete(user)
+            session.commit()
+            return True
 
     def delete_user_session(self, session_token):
-        session = self._get_db_session()
-        user_session = session.query(UserSession).filter_by(session_token=session_token).first()
+        with self.db.get_session() as session:
+            user_session = (
+                session.query(UserSession)
+                .filter_by(session_token=session_token)
+                .first()
+            )
 
-        if not user_session:
-            return False
+            if not user_session:
+                return False
 
-        session.delete(user_session)
-        session.commit()
-        return True
+            session.delete(user_session)
+            session.commit()
+            return True
 
     def create_user_session(self, username, password, expires_at=None):
-        session = self._get_db_session()
-        user = self._authenticate_user(session, username, password)
-        if not user:
-            return None
+        with self.db.get_session() as session:
+            user = self._authenticate_user(session, username, password)
+            if not user:
+                return None
 
-        if expires_at:
-            if isinstance(expires_at, int) or isinstance(expires_at, float):
-                expires_at = datetime.datetime.fromtimestamp(expires_at)
-            elif isinstance(expires_at, str):
-                expires_at = datetime.datetime.fromisoformat(expires_at)
+            if expires_at:
+                if isinstance(expires_at, (int, float)):
+                    expires_at = datetime.datetime.fromtimestamp(expires_at)
+                elif isinstance(expires_at, str):
+                    expires_at = datetime.datetime.fromisoformat(expires_at)
 
-        user_session = UserSession(user_id=user.user_id, session_token=self.generate_session_token(),
-                                   csrf_token=self.generate_session_token(), created_at=datetime.datetime.utcnow(),
-                                   expires_at=expires_at)
+            user_session = UserSession(
+                user_id=user.user_id,
+                session_token=self.generate_session_token(),
+                csrf_token=self.generate_session_token(),
+                created_at=datetime.datetime.utcnow(),
+                expires_at=expires_at,
+            )
 
-        session.add(user_session)
-        session.commit()
-        return user_session
+            session.add(user_session)
+            session.commit()
+            return user_session
 
     @staticmethod
     def _get_user(session, username):
@@ -180,10 +200,20 @@ class UserManager:
 
         :param session_token: Session token.
         """
-        session = self._get_db_session()
-        return session.query(User).join(UserSession).filter_by(session_token=session_token).first()
+        with self.db.get_session() as session:
+            return (
+                session.query(User)
+                .join(UserSession)
+                .filter_by(session_token=session_token)
+                .first()
+            )
 
-    def generate_jwt_token(self, username: str, password: str, expires_at: Optional[datetime.datetime] = None) -> str:
+    def generate_jwt_token(
+        self,
+        username: str,
+        password: str,
+        expires_at: Optional[datetime.datetime] = None,
+    ) -> str:
         """
         Create a user JWT token for API usage.
 
@@ -240,12 +270,6 @@ class UserManager:
 
         return payload
 
-    def _get_db_session(self):
-        Base.metadata.create_all(self._engine)
-        session = scoped_session(sessionmaker(expire_on_commit=False))
-        session.configure(bind=self._engine)
-        return session()
-
     def _authenticate_user(self, session, username, password):
         """
         :return: :class:`platypush.user.User` instance if the user exists and the password is valid, ``None`` otherwise.
@@ -261,10 +285,10 @@ class UserManager:
 
 
 class User(Base):
-    """ Models the User table """
+    """Models the User table"""
 
     __tablename__ = 'user'
-    __table_args__ = ({'sqlite_autoincrement': True})
+    __table_args__ = {'sqlite_autoincrement': True}
 
     user_id = Column(Integer, primary_key=True)
     username = Column(String, unique=True, nullable=False)
@@ -273,10 +297,10 @@ class User(Base):
 
 
 class UserSession(Base):
-    """ Models the UserSession table """
+    """Models the UserSession table"""
 
     __tablename__ = 'user_session'
-    __table_args__ = ({'sqlite_autoincrement': True})
+    __table_args__ = {'sqlite_autoincrement': True}
 
     session_id = Column(Integer, primary_key=True)
     session_token = Column(String, unique=True, nullable=False)
diff --git a/platypush/utils/__init__.py b/platypush/utils/__init__.py
index c75615c85..61f2fc23e 100644
--- a/platypush/utils/__init__.py
+++ b/platypush/utils/__init__.py
@@ -1,4 +1,5 @@
 import ast
+import contextlib
 import datetime
 import hashlib
 import importlib
@@ -20,8 +21,8 @@ logger = logging.getLogger('utils')
 
 
 def get_module_and_method_from_action(action):
-    """ Input  : action=music.mpd.play
-        Output : ('music.mpd', 'play') """
+    """Input  : action=music.mpd.play
+    Output : ('music.mpd', 'play')"""
 
     tokens = action.split('.')
     module_name = str.join('.', tokens[:-1])
@@ -30,7 +31,7 @@ def get_module_and_method_from_action(action):
 
 
 def get_message_class_by_type(msgtype):
-    """ Gets the class of a message type given as string """
+    """Gets the class of a message type given as string"""
 
     try:
         module = importlib.import_module('platypush.message.' + msgtype)
@@ -43,8 +44,7 @@ def get_message_class_by_type(msgtype):
     try:
         msgclass = getattr(module, cls_name)
     except AttributeError as e:
-        logger.warning('No such class in {}: {}'.format(
-            module.__name__, cls_name))
+        logger.warning('No such class in {}: {}'.format(module.__name__, cls_name))
         raise RuntimeError(e)
 
     return msgclass
@@ -52,13 +52,13 @@ def get_message_class_by_type(msgtype):
 
 # noinspection PyShadowingBuiltins
 def get_event_class_by_type(type):
-    """ Gets an event class by type name """
+    """Gets an event class by type name"""
     event_module = importlib.import_module('.'.join(type.split('.')[:-1]))
     return getattr(event_module, type.split('.')[-1])
 
 
 def get_plugin_module_by_name(plugin_name):
-    """ Gets the module of a plugin by name (e.g. "music.mpd" or "media.vlc") """
+    """Gets the module of a plugin by name (e.g. "music.mpd" or "media.vlc")"""
 
     module_name = 'platypush.plugins.' + plugin_name
     try:
@@ -69,22 +69,26 @@ def get_plugin_module_by_name(plugin_name):
 
 
 def get_plugin_class_by_name(plugin_name):
-    """ Gets the class of a plugin by name (e.g. "music.mpd" or "media.vlc") """
+    """Gets the class of a plugin by name (e.g. "music.mpd" or "media.vlc")"""
 
     module = get_plugin_module_by_name(plugin_name)
     if not module:
         return
 
-    class_name = getattr(module, ''.join([_.capitalize() for _ in plugin_name.split('.')]) + 'Plugin')
+    class_name = getattr(
+        module, ''.join([_.capitalize() for _ in plugin_name.split('.')]) + 'Plugin'
+    )
     try:
-        return getattr(module, ''.join([_.capitalize() for _ in plugin_name.split('.')]) + 'Plugin')
+        return getattr(
+            module, ''.join([_.capitalize() for _ in plugin_name.split('.')]) + 'Plugin'
+        )
     except Exception as e:
         logger.error('Cannot import class {}: {}'.format(class_name, str(e)))
         return None
 
 
 def get_plugin_name_by_class(plugin) -> Optional[str]:
-    """Gets the common name of a plugin (e.g. "music.mpd" or "media.vlc") given its class. """
+    """Gets the common name of a plugin (e.g. "music.mpd" or "media.vlc") given its class."""
 
     from platypush.plugins import Plugin
 
@@ -93,7 +97,8 @@ def get_plugin_name_by_class(plugin) -> Optional[str]:
 
     class_name = plugin.__name__
     class_tokens = [
-        token.lower() for token in re.sub(r'([A-Z])', r' \1', class_name).split(' ')
+        token.lower()
+        for token in re.sub(r'([A-Z])', r' \1', class_name).split(' ')
         if token.strip() and token != 'Plugin'
     ]
 
@@ -101,7 +106,7 @@ def get_plugin_name_by_class(plugin) -> Optional[str]:
 
 
 def get_backend_name_by_class(backend) -> Optional[str]:
-    """Gets the common name of a backend (e.g. "http" or "mqtt") given its class. """
+    """Gets the common name of a backend (e.g. "http" or "mqtt") given its class."""
 
     from platypush.backend import Backend
 
@@ -110,7 +115,8 @@ def get_backend_name_by_class(backend) -> Optional[str]:
 
     class_name = backend.__name__
     class_tokens = [
-        token.lower() for token in re.sub(r'([A-Z])', r' \1', class_name).split(' ')
+        token.lower()
+        for token in re.sub(r'([A-Z])', r' \1', class_name).split(' ')
         if token.strip() and token != 'Backend'
     ]
 
@@ -135,12 +141,12 @@ def set_timeout(seconds, on_timeout):
 
 
 def clear_timeout():
-    """ Clear any previously set timeout """
+    """Clear any previously set timeout"""
     signal.alarm(0)
 
 
 def get_hash(s):
-    """ Get the SHA256 hash hex digest of a string input """
+    """Get the SHA256 hash hex digest of a string input"""
     return hashlib.sha256(s.encode('utf-8')).hexdigest()
 
 
@@ -177,11 +183,8 @@ def get_decorators(cls, climb_class_hierarchy=False):
     node_iter.visit_FunctionDef = visit_FunctionDef
 
     for target in targets:
-        try:
+        with contextlib.suppress(TypeError):
             node_iter.visit(ast.parse(inspect.getsource(target)))
-        except TypeError:
-            # Ignore built-in classes
-            pass
 
     return decorators
 
@@ -195,45 +198,57 @@ def get_redis_queue_name_by_message(msg):
     return 'platypush/responses/{}'.format(msg.id) if msg.id else None
 
 
-def _get_ssl_context(context_type=None, ssl_cert=None, ssl_key=None,
-                     ssl_cafile=None, ssl_capath=None):
+def _get_ssl_context(
+    context_type=None, ssl_cert=None, ssl_key=None, ssl_cafile=None, ssl_capath=None
+):
     if not context_type:
-        ssl_context = ssl.create_default_context(cafile=ssl_cafile,
-                                                 capath=ssl_capath)
+        ssl_context = ssl.create_default_context(cafile=ssl_cafile, capath=ssl_capath)
     else:
         ssl_context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
 
     if ssl_cafile or ssl_capath:
-        ssl_context.load_verify_locations(
-            cafile=ssl_cafile, capath=ssl_capath)
+        ssl_context.load_verify_locations(cafile=ssl_cafile, capath=ssl_capath)
 
     ssl_context.load_cert_chain(
         certfile=os.path.abspath(os.path.expanduser(ssl_cert)),
-        keyfile=os.path.abspath(os.path.expanduser(ssl_key)) if ssl_key else None
+        keyfile=os.path.abspath(os.path.expanduser(ssl_key)) if ssl_key else None,
     )
 
     return ssl_context
 
 
-def get_ssl_context(ssl_cert=None, ssl_key=None, ssl_cafile=None,
-                    ssl_capath=None):
-    return _get_ssl_context(context_type=None,
-                            ssl_cert=ssl_cert, ssl_key=ssl_key,
-                            ssl_cafile=ssl_cafile, ssl_capath=ssl_capath)
+def get_ssl_context(ssl_cert=None, ssl_key=None, ssl_cafile=None, ssl_capath=None):
+    return _get_ssl_context(
+        context_type=None,
+        ssl_cert=ssl_cert,
+        ssl_key=ssl_key,
+        ssl_cafile=ssl_cafile,
+        ssl_capath=ssl_capath,
+    )
 
 
-def get_ssl_server_context(ssl_cert=None, ssl_key=None, ssl_cafile=None,
-                           ssl_capath=None):
-    return _get_ssl_context(context_type=ssl.PROTOCOL_TLS_SERVER,
-                            ssl_cert=ssl_cert, ssl_key=ssl_key,
-                            ssl_cafile=ssl_cafile, ssl_capath=ssl_capath)
+def get_ssl_server_context(
+    ssl_cert=None, ssl_key=None, ssl_cafile=None, ssl_capath=None
+):
+    return _get_ssl_context(
+        context_type=ssl.PROTOCOL_TLS_SERVER,
+        ssl_cert=ssl_cert,
+        ssl_key=ssl_key,
+        ssl_cafile=ssl_cafile,
+        ssl_capath=ssl_capath,
+    )
 
 
-def get_ssl_client_context(ssl_cert=None, ssl_key=None, ssl_cafile=None,
-                           ssl_capath=None):
-    return _get_ssl_context(context_type=ssl.PROTOCOL_TLS_CLIENT,
-                            ssl_cert=ssl_cert, ssl_key=ssl_key,
-                            ssl_cafile=ssl_cafile, ssl_capath=ssl_capath)
+def get_ssl_client_context(
+    ssl_cert=None, ssl_key=None, ssl_cafile=None, ssl_capath=None
+):
+    return _get_ssl_context(
+        context_type=ssl.PROTOCOL_TLS_CLIENT,
+        ssl_cert=ssl_cert,
+        ssl_key=ssl_key,
+        ssl_cafile=ssl_cafile,
+        ssl_capath=ssl_capath,
+    )
 
 
 def set_thread_name(name):
@@ -241,6 +256,7 @@ def set_thread_name(name):
 
     try:
         import prctl
+
         # noinspection PyUnresolvedReferences
         prctl.set_name(name)
     except ImportError:
@@ -251,9 +267,9 @@ def find_bins_in_path(bin_name):
     return [
         os.path.join(p, bin_name)
         for p in os.environ.get('PATH', '').split(':')
-        if os.path.isfile(os.path.join(p, bin_name)) and (
-            os.name == 'nt' or os.access(os.path.join(p, bin_name), os.X_OK)
-        )]
+        if os.path.isfile(os.path.join(p, bin_name))
+        and (os.name == 'nt' or os.access(os.path.join(p, bin_name), os.X_OK))
+    ]
 
 
 def find_files_by_ext(directory, *exts):
@@ -271,7 +287,7 @@ def find_files_by_ext(directory, *exts):
     max_len = len(max(exts, key=len))
     result = []
 
-    for root, dirs, files in os.walk(directory):
+    for _, __, files in os.walk(directory):
         for i in range(min_len, max_len + 1):
             result += [f for f in files if f[-i:] in exts]
 
@@ -302,8 +318,9 @@ def get_ip_or_hostname():
 
 def get_mime_type(resource):
     import magic
+
     if resource.startswith('file://'):
-        resource = resource[len('file://'):]
+        resource = resource[len('file://') :]
 
     # noinspection HttpUrlsUsage
     if resource.startswith('http://') or resource.startswith('https://'):
@@ -315,7 +332,9 @@ def get_mime_type(resource):
         elif hasattr(magic, 'from_file'):
             mime = magic.from_file(resource, mime=True)
         else:
-            raise RuntimeError('The installed magic version provides neither detect_from_filename nor from_file')
+            raise RuntimeError(
+                'The installed magic version provides neither detect_from_filename nor from_file'
+            )
 
         if mime:
             return mime.mime_type if hasattr(mime, 'mime_type') else mime
@@ -332,6 +351,7 @@ def grouper(n, iterable, fillvalue=None):
     grouper(3, 'ABCDEFG', 'x') --> ABC DEF Gxx
     """
     from itertools import zip_longest
+
     args = [iter(iterable)] * n
 
     if fillvalue:
@@ -355,6 +375,7 @@ def is_functional_cron(obj) -> bool:
 
 def run(action, *args, **kwargs):
     from platypush.context import get_plugin
+
     (module_name, method_name) = get_module_and_method_from_action(action)
     plugin = get_plugin(module_name)
     method = getattr(plugin, method_name)
@@ -366,7 +387,9 @@ def run(action, *args, **kwargs):
     return response.output
 
 
-def generate_rsa_key_pair(key_file: Optional[str] = None, size: int = 2048) -> Tuple[str, str]:
+def generate_rsa_key_pair(
+    key_file: Optional[str] = None, size: int = 2048
+) -> Tuple[str, str]:
     """
     Generate an RSA key pair.
 
@@ -390,27 +413,30 @@ def generate_rsa_key_pair(key_file: Optional[str] = None, size: int = 2048) -> T
 
     public_exp = 65537
     private_key = rsa.generate_private_key(
-        public_exponent=public_exp,
-        key_size=size,
-        backend=default_backend()
+        public_exponent=public_exp, key_size=size, backend=default_backend()
     )
 
     logger.info('Generating RSA {} key pair'.format(size))
     private_key_str = private_key.private_bytes(
         encoding=serialization.Encoding.PEM,
         format=serialization.PrivateFormat.TraditionalOpenSSL,
-        encryption_algorithm=serialization.NoEncryption()
+        encryption_algorithm=serialization.NoEncryption(),
     ).decode()
 
-    public_key_str = private_key.public_key().public_bytes(
-        encoding=serialization.Encoding.PEM,
-        format=serialization.PublicFormat.PKCS1,
-    ).decode()
+    public_key_str = (
+        private_key.public_key()
+        .public_bytes(
+            encoding=serialization.Encoding.PEM,
+            format=serialization.PublicFormat.PKCS1,
+        )
+        .decode()
+    )
 
     if key_file:
         logger.info('Saving private key to {}'.format(key_file))
-        with open(os.path.expanduser(key_file), 'w') as f1, \
-                open(os.path.expanduser(key_file) + '.pub', 'w') as f2:
+        with open(os.path.expanduser(key_file), 'w') as f1, open(
+            os.path.expanduser(key_file) + '.pub', 'w'
+        ) as f2:
             f1.write(private_key_str)
             f2.write(public_key_str)
             os.chmod(key_file, 0o600)
@@ -426,8 +452,7 @@ def get_or_generate_jwt_rsa_key_pair():
     pub_key_file = priv_key_file + '.pub'
 
     if os.path.isfile(priv_key_file) and os.path.isfile(pub_key_file):
-        with open(pub_key_file, 'r') as f1, \
-                open(priv_key_file, 'r') as f2:
+        with open(pub_key_file, 'r') as f1, open(priv_key_file, 'r') as f2:
             return f1.read(), f2.read()
 
     pathlib.Path(key_dir).mkdir(parents=True, exist_ok=True, mode=0o755)
@@ -439,7 +464,7 @@ def get_enabled_plugins() -> dict:
     from platypush.context import get_plugin
 
     plugins = {}
-    for name, config in Config.get_plugins().items():
+    for name in Config.get_plugins():
         try:
             plugin = get_plugin(name)
             if plugin:
@@ -453,11 +478,18 @@ def get_enabled_plugins() -> dict:
 
 def get_redis() -> Redis:
     from platypush.config import Config
-    return Redis(**(Config.get('backend.redis') or {}).get('redis_args', {}))
+
+    return Redis(
+        **(
+            (Config.get('backend.redis') or {}).get('redis_args', {})
+            or Config.get('redis')
+            or {}
+        )
+    )
 
 
 def to_datetime(t: Union[str, int, float, datetime.datetime]) -> datetime.datetime:
-    if isinstance(t, int) or isinstance(t, float):
+    if isinstance(t, (int, float)):
         return datetime.datetime.fromtimestamp(t, tz=tz.tzutc())
     if isinstance(t, str):
         return parser.parse(t)
diff --git a/setup.cfg b/setup.cfg
index ad67bf2fb..9ad8fe286 100644
--- a/setup.cfg
+++ b/setup.cfg
@@ -8,6 +8,7 @@ description-file = README.md
 
 [flake8]
 max-line-length = 120
-ignore = 
-	SIM105
+extend-ignore =
+    E203
 	W503
+	SIM105