Repository: jziolkowski/tdm (language: python)

Issue:
Incompatibility with paho 2
After an upgrade to Debian trixie tdm fails to start:
```
$ /usr/bin/tdmgr.py
Exception ignored in: <function Client.__del__ at 0x7f46798ea980>
Traceback (most recent call last):
  File "/usr/lib/python3/dist-packages/paho/mqtt/client.py", line 874, in __del__
    self._reset_sockets()
  File "/usr/lib/python3/dist-packages/paho/mqtt/client.py", line 1133, in _reset_sockets
    self._sock_close()
  File "/usr/lib/python3/dist-packages/paho/mqtt/client.py", line 1119, in _sock_close
    if not self._sock:
           ^^^^^^^^^^
AttributeError: 'Client' object has no attribute '_sock'
```
The likely cause is that paho has moved to version 2:
```
$ dpkg -l python3-paho-mqtt 
...
ii  python3-paho-mqtt 2.0.0-1      all          MQTT client class (Python 3)
```
This is a typical symptom which is described in this [python paho issue](https://github.com/eclipse/paho.mqtt.python/issues/814).
Use wildcards in Autodiscovery patterns?
Is it possible to use wildcards in the Autodiscovery patterns?
I've tried \*, # or %, but none of them work?
My fulltopics are %prefix%/tasmota/**\*room**\*/%topic%/. 
I can configure each room in the pattern match, but using a wildcard could be a lot easier (like %prefix%/tasmota/*/%topic%/).


Discussion / hints:

That kinda makes sense. Let me test something

Fix patch:
diff --git a/.github/workflows/format.yml b/.github/workflows/format.yml
index 4f2e682..c525dc4 100644
--- a/.github/workflows/format.yml
+++ b/.github/workflows/format.yml
@@ -21,6 +21,6 @@ jobs:
 
             -   uses: psf/black@stable
                 with:
-                    options: "--check --verbose -S -l 100"
+                    options: "--check -S -l 100"
 
             -   uses: py-actions/flake8@v2
diff --git a/Makefile b/Makefile
index 8f7778a..4974a46 100644
--- a/Makefile
+++ b/Makefile
@@ -17,6 +17,6 @@ check-and-fix: isort black flake8
 dev-setup:
 	python3 -m venv $(VENV_DIR)
 	$(VENV_DIR)/bin/pip install --upgrade pip wheel setuptools
-	$(VENV_DIR)/bin/pip install -r requirements_dev.txt -r requirements.txt
+	$(VENV_DIR)/bin/pip install -r requirements_dev.txt
 
 
diff --git a/requirements.txt b/requirements.txt
index 434a667..4ae24e2 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -1,2 +1,3 @@
-paho_mqtt>=1.4
+paho_mqtt>=1.4,<2
 PyQt5>=5.14.2,<6
+pydantic==2.5.2
diff --git a/requirements_dev.txt b/requirements_dev.txt
index 4f16743..0179ff8 100644
--- a/requirements_dev.txt
+++ b/requirements_dev.txt
@@ -1,3 +1,5 @@
-black==22.10.0
+-r requirements.txt
+black==24.3.0
 flake8==5.0.4
 isort==5.10.1
+pytest==7.4.3
diff --git a/tdmgr/GUI/console.py b/tdmgr/GUI/console.py
index f80e90d..e05f42f 100644
--- a/tdmgr/GUI/console.py
+++ b/tdmgr/GUI/console.py
@@ -1,17 +1,7 @@
 import os
 from datetime import datetime
 
-from PyQt5.QtCore import (
-    QDir,
-    QEvent,
-    QRegExp,
-    QSize,
-    QStringListModel,
-    Qt,
-    QTime,
-    pyqtSignal,
-    pyqtSlot,
-)
+from PyQt5.QtCore import QDir, QEvent, QRegExp, QSize, QStringListModel, Qt, pyqtSignal, pyqtSlot
 from PyQt5.QtGui import QColor, QFont, QIcon, QSyntaxHighlighter, QTextCharFormat
 from PyQt5.QtWidgets import (
     QComboBox,
@@ -28,6 +18,7 @@
 )
 
 from tdmgr.GUI.widgets import GroupBoxV, HLayout, VLayout, console_font
+from tdmgr.util import Message
 from tdmgr.util.commands import commands
 
 
@@ -120,11 +111,12 @@ def command_changed(self, text):
         if text == "":
             self.command.completer().setModel(QStringListModel(sorted(commands)))
 
-    @pyqtSlot(str, str, bool)
-    def consoleAppend(self, topic, msg, retained=False):
-        if self.device.matches(topic):
-            tstamp = QTime.currentTime().toString("HH:mm:ss")
-            self.console.appendPlainText(f"[{tstamp}] {topic} {msg}")
+    @pyqtSlot(Message)
+    def consoleAppend(self, msg: Message):
+        if self.device.matches(msg.topic):
+            self.console.appendPlainText(
+                f"[{msg.timestamp.strftime('%X')}] {msg.topic} {msg.payload}"
+            )
 
     def eventFilter(self, obj, e):
         if obj == self.command and e.type() == QEvent.KeyPress:
diff --git a/tdmgr/GUI/delegates/devices.py b/tdmgr/GUI/delegates/devices.py
index c8d9b08..8412ef8 100644
--- a/tdmgr/GUI/delegates/devices.py
+++ b/tdmgr/GUI/delegates/devices.py
@@ -15,7 +15,7 @@
     RSSI_LOW,
     RSSI_MEDIUM,
 )
-from tdmgr.models.common import DeviceRoles
+from tdmgr.models.roles import DeviceRoles
 
 
 @dataclass
@@ -270,9 +270,7 @@ def draw_shutters_state(self, p: QPainter, target_rect: QRect, shutter_pos_data:
             position = (
                 'CLOSED'
                 if shutter_state['Position'] == 0
-                else 'OPEN'
-                if shutter_state['Position'] == 100
-                else shutter_state['Position']
+                else 'OPEN' if shutter_state['Position'] == 100 else shutter_state['Position']
             )
 
             arrow_direction = {-1: ARROW_DN, 1: ARROW_UP}
diff --git a/tdmgr/GUI/dialogs/main.py b/tdmgr/GUI/dialogs/main.py
index a7e4f71..4919c33 100644
--- a/tdmgr/GUI/dialogs/main.py
+++ b/tdmgr/GUI/dialogs/main.py
@@ -1,7 +1,6 @@
 import csv
 import logging
 import re
-from json import loads
 from logging.handlers import TimedRotatingFileHandler
 
 from PyQt5.QtCore import QDir, QFileInfo, QSettings, QSize, Qt, QTimer, QUrl, pyqtSlot
@@ -31,16 +30,9 @@
 from tdmgr.GUI.telemetry import TelemetryWidget
 from tdmgr.GUI.widgets import Toolbar
 from tdmgr.models.devices import TasmotaDevicesModel
-from tdmgr.util import (
-    TasmotaDevice,
-    TasmotaEnvironment,
-    custom_patterns,
-    default_patterns,
-    expand_fulltopic,
-    initial_commands,
-    parse_topic,
-)
-from tdmgr.util.mqtt import MqttClient
+from tdmgr.util import MQTT_PATH_REGEX, TasmotaDevice, TasmotaEnvironment, initial_commands
+from tdmgr.util.discovery import lwt_discovery_stage2
+from tdmgr.util.mqtt import DEFAULT_PATTERNS, Message, MqttClient, expand_fulltopic
 
 
 class MainWindow(QMainWindow):
@@ -56,12 +48,14 @@ def __init__(
     ):
         super(MainWindow, self).__init__(*args, **kwargs)
         self._version = version
+        self.debug = debug
         self.setWindowIcon(QIcon(":/logo.png"))
         self.setWindowTitle(f"Tasmota Device Manager {self._version}")
 
         self.menuBar().setNativeMenuBar(False)
 
         self.unknown = []
+        self.custom_patterns = []
         self.env = TasmotaEnvironment()
         self.device = None
 
@@ -69,6 +63,8 @@ def __init__(
         self.mqtt_queue = []
         self.fulltopic_queue = []
 
+        self.lwt = dict()
+
         self.settings = settings
         self.devices = devices
         self.setMinimumSize(QSize(1000, 600))
@@ -199,14 +195,14 @@ def build_toolbars(self):
         main_toolbar.setObjectName("main_toolbar")
 
     def initial_query(self, device, queued=False):
-        for c in initial_commands():
-            cmd, payload = c
-            cmd = device.cmnd_topic(cmd)
+        cmds = [" ".join(c) for c in initial_commands()]
+        backlog = device.cmnd_topic("backlog")
+        backlog_payload = ";".join(cmds)
 
-            if queued:
-                self.mqtt_queue.append([cmd, payload])
-            else:
-                self.mqtt.publish(cmd, payload, 1)
+        if queued:
+            self.mqtt_queue.append([backlog, backlog_payload])
+        else:
+            self.mqtt.publish(backlog, backlog_payload, 1)
 
     def setup_broker(self):
         brokers_dlg = BrokerDialog(self.settings)
@@ -279,20 +275,21 @@ def mqtt_connected(self):
     def mqtt_subscribe(self):
         # clear old topics
         self.topics.clear()
-        custom_patterns.clear()
+        self.custom_patterns.clear()
 
         # load custom autodiscovery patterns
         self.settings.beginGroup("Patterns")
         for k in self.settings.childKeys():
-            custom_patterns.append(self.settings.value(k))
+            self.custom_patterns.append(self.settings.value(k))
         self.settings.endGroup()
 
+        # TODO: move to TasmotaEnvironment, add unit tests
         # expand fulltopic patterns to subscribable topics
-        for pat in default_patterns:  # tasmota default and SO19
+        for pat in DEFAULT_PATTERNS:  # tasmota default and SO19
             self.topics += expand_fulltopic(pat)
 
         # check if custom patterns can be matched by default patterns
-        for pat in custom_patterns:
+        for pat in self.custom_patterns:
             if pat.startswith("%prefix%") or pat.split("/")[1] == "%prefix%":
                 continue  # do nothing, default subcriptions will match this topic
             else:
@@ -301,13 +298,18 @@ def mqtt_subscribe(self):
         for d in self.env.devices:
             # if device has a non-standard pattern, check if the pattern is found in
             # the custom patterns
-            if not d.is_default() and d.p["FullTopic"] not in custom_patterns:
-                # if pattern is not found then add the device topics to subscription list.
-                # if the pattern is found, it will be matched without implicit subscription
-                self.topics += expand_fulltopic(d.p["FullTopic"])
+            for custom_pattern in self.custom_patterns:
+                custom_pattern_match = re.match(
+                    custom_pattern.replace("+", f"({MQTT_PATH_REGEX})"), d.p["FullTopic"]
+                )
+                if not d.is_default() and not custom_pattern_match:
+                    # if pattern is not found then add the device topics to subscription list.
+                    # if the pattern is found, it will be matched without implicit subscription
+                    self.topics += expand_fulltopic(d.p["FullTopic"])
 
         # passing a list of tuples as recommended by paho
-        self.mqtt.subscribe([(topic, 0) for topic in self.topics])
+        _topics = [("tasmota/discovery/+/config", 0)] + [(topic, 0) for topic in self.topics]
+        self.mqtt.subscribe(_topics)
 
     @pyqtSlot(str, str)
     def mqtt_publish(self, t, p):
@@ -335,100 +337,103 @@ def mqtt_connectError(self, rc):
         self.statusBar().showMessage(f"Connection error: {reason[rc]}")
         self.actToggleConnect.setChecked(False)
 
-    def mqtt_message(self, topic, msg, retained):
-        # try to find a device by matching known FullTopics against the MQTT topic of the message
-        if retained:
-            self.env.retained.add(topic)
-        device = self.env.find_device(topic)
-        if device:
-            if topic.endswith("LWT"):
-                if not msg:
-                    msg = "Offline"
-                device.update_property("LWT", msg)
-
-                if msg == "Online":
+    def mqtt_message(self, msg: Message):
+        if msg.retained:
+            self.env.retained.add(msg.topic)
+
+        # if msg.is_lwt:
+        #     self.env.lwts[msg.topic] = msg.payload
+        # TODO: make native discovery work (#255)
+        # discovery_mode = self.settings.value("discovery_mode", 0, int)
+        # if msg.topic.startswith("tasmota/discovery") and discovery_mode != DiscoveryMode.LEGACY:
+        #     # Add device using native Tasmota discovery message
+        #     if msg.endpoint == "config":
+        #         obj = None
+        #         try:
+        #             obj = DiscoverySchema.model_validate_json(msg.payload)
+        #         except ValueError:
+        #             logging.error("Unable to parse Tasmota discovery message: %s", msg.payload)
+        #
+        #         if obj and not self.env.find_device(obj.t):
+        #             device = TasmotaDevice.from_discovery(obj)
+        #             for sub_topic in device.subscribable_topics:
+        #                 if sub_topic not in self.topics:
+        #                     self.topics.append(sub_topic)
+        #                     self.mqtt.subscribe([(sub_topic, 0)])
+        #
+        #             self.env.devices.append(device)
+        #             self.device_model.addDevice(device)
+        #             logging.info(
+        #                 "DISCOVERY(NATIVE): Discovered topic=%s with fulltopic=%s",
+        #                 obj.t,
+        #                 device.p["FullTopic"],
+        #             )
+        #             self.initial_query(device, True)
+        #
+        #             lwt = self.env.lwts.pop(f"{obj.ft}/LWT", obj.ofln)
+        #             device.update_property("LWT", lwt)
+
+        if device := self.env.find_device(msg.topic):
+            if msg.is_lwt:
+                logging.debug("MQTT: LWT message for %s: %s", device.p["Topic"], msg.payload)
+                device.update_property("LWT", msg.payload)
+
+                if msg.payload == device.p["Online"]:
                     # known device came online, query initial state
                     self.initial_query(device, True)
 
             else:
+                _prefix = re.match(device.fulltopic_regex, msg.topic)
+                if _prefix:
+                    msg.prefix = _prefix.groupdict()["prefix"]
                 # forward the message for processing
-                device.parse_message(topic, msg)
-                if device.debug:
-                    logging.debug("MQTT: %s %s", topic, msg)
+                device.update_property("LWT", device.p["Online"])
+                device.process_message(msg)
 
-        else:  # unknown device, start autodiscovery process
-            if topic.endswith("LWT"):
-                self.env.lwts.append(topic)
-                logging.info("DISCOVERY: LWT from an unknown device %s", topic)
+        # TODO: ditto
+        # elif discovery_mode != DiscoveryMode.NATIVE:
+        else:
+            # unknown device, start autodiscovery process
+            if msg.is_lwt:
+                self.env.lwts[msg.topic] = msg.payload
+                logging.info("DISCOVERY(LEGACY): LWT from an unknown device %s", msg.topic)
 
                 # STAGE 1
                 # load default and user-provided FullTopic patterns and for all the patterns,
                 # try matching the LWT topic (it follows the device's FullTopic syntax
 
-                for p in default_patterns + custom_patterns:
-                    match = re.fullmatch(
-                        p.replace("%topic%", "(?P<topic>.*?)").replace(
-                            "%prefix%", "(?P<prefix>.*?)"
-                        )
-                        + ".*$",
-                        topic,
-                    )
+                for p in DEFAULT_PATTERNS + self.custom_patterns:
+                    match = msg.match_fulltopic(p)
                     if match:
                         # assume that the matched topic is the one configured in device settings
                         if (
                             possible_topic := match.groupdict().get("topic")
-                        ) and possible_topic not in ("tele", "stat"):
+                        ) and possible_topic not in ("tele", "stat", "cmnd"):
                             # if the assumed topic is different from tele or stat, there is a chance
                             # that it's a valid topic. query the assumed device for its FullTopic.
                             # False positives won't reply.
+                            prf_start, prf_end = match.regs[match.re.groupindex['prefix']]
                             possible_topic_cmnd = (
-                                p.replace("%prefix%", "cmnd").replace("%topic%", possible_topic)
-                                + "FullTopic"
+                                f"{msg.topic[:prf_start]}cmnd{msg.topic[prf_end:]}".replace(
+                                    "/LWT", "/FullTopic"
+                                )
                             )
-
                             logging.debug(
-                                "DISCOVERY: Asking an unknown device for FullTopic at %s",
+                                "DISCOVERY(LEGACY): Asking an unknown device for FullTopic at %s",
                                 possible_topic_cmnd,
                             )
                             self.mqtt_queue.append([possible_topic_cmnd, ""])
 
-            elif topic.endswith("RESULT") or topic.endswith(
-                "FULLTOPIC"
-            ):  # reply from an unknown device
-                # STAGE 2
-                full_topic = loads(msg).get("FullTopic")
-                if full_topic:
-                    # the device replies with its FullTopic
-                    # here the Topic is extracted using the returned FullTopic, identifying the
-                    # device
-                    parsed = parse_topic(full_topic, topic)
-                    if parsed:
-                        # got a match, we query the device's MAC address in case it's a known device
-                        # that had its topic changed
-                        logging.debug(
-                            "DISCOVERY: topic %s is matched by fulltopic %s", topic, full_topic
-                        )
-
-                        d = self.env.find_device(topic=parsed["topic"])
-                        if d:
-                            d.update_property("FullTopic", full_topic)
-                        else:
-                            logging.info(
-                                "DISCOVERY: Discovered topic=%s with fulltopic=%s",
-                                parsed["topic"],
-                                full_topic,
-                            )
-                            d = TasmotaDevice(parsed["topic"], full_topic)
-                            self.env.devices.append(d)
-                            self.device_model.addDevice(d)
-                            logging.debug(
-                                "DISCOVERY: Sending initial query to topic %s", parsed["topic"]
-                            )
-                            self.initial_query(d, True)
-                            tele_topic = d.tele_topic("LWT")
-                            if tele_topic in self.env.lwts:
-                                self.env.lwts.remove(tele_topic)
-                        d.update_property("LWT", "Online")
+            elif msg.endpoint in ("RESULT", "FULLTOPIC"):
+                # reply from an unknown device
+                if d := lwt_discovery_stage2(self.env, msg):
+                    self.env.devices.append(d)
+                    self.device_model.addDevice(d)
+                    logging.debug("DISCOVERY: Sending initial query to topic %s", d.p["Topic"])
+                    self.initial_query(d, True)
+                    tele_topic = d.tele_topic("LWT")
+                    self.env.lwts.pop(tele_topic, None)
+                    d.update_property("LWT", "Online")
 
     def export(self):
         fname, _ = QFileDialog.getSaveFileName(
@@ -479,9 +484,6 @@ def bssid(self):
     def patterns(self):
         PatternsDialog(self.settings).exec_()
 
-    # def openhab(self):
-    #     OpenHABDialog(self.env).exec_()
-
     def showSubs(self):
         QMessageBox.information(self, "Subscriptions", "\n".join(sorted(self.topics)))
 
@@ -499,6 +501,7 @@ def clear_retained_topics(self):
     def prefs(self):
         dlg = PrefsDialog(self.settings)
         if dlg.exec_() == QDialog.Accepted:
+            # TODO: move saving to dialog accept() event
             devices_short_version = self.settings.value("devices_short_version", True, bool)
             if devices_short_version != dlg.cbDevShortVersion.isChecked():
                 self.settings.setValue("devices_short_version", dlg.cbDevShortVersion.isChecked())
@@ -522,6 +525,9 @@ def prefs(self):
                     new_font.setPointSize(dlg.sbConsFontSize.value())
                     c.console.setFont(new_font)
 
+            if dlg.bgDiscovery.checkedId() != self.settings.value("discovery_mode", 0, int):
+                self.settings.setValue("discovery_mode", dlg.bgDiscovery.checkedId())
+
         self.settings.sync()
 
     def open_config_file(self):
@@ -610,21 +616,22 @@ def closeEvent(self, e):
 
         self.settings.sync()
 
-        for d in self.env.devices:
-            mac = d.p.get("Mac")
-            topic = d.p["Topic"]
-            full_topic = d.p["FullTopic"]
-            device_name = d.name
-
-            if mac:
-                self.devices.beginGroup(mac.replace(":", "-"))
-                self.devices.setValue("topic", topic)
-                self.devices.setValue("full_topic", full_topic)
-                self.devices.setValue("device_name", device_name)
-
-                for i, h in enumerate(d.history):
-                    self.devices.setValue(f"history/{i}", h)
-                self.devices.endGroup()
-        self.devices.sync()
+        if not self.debug:
+            for d in self.env.devices:
+                mac = d.p.get("Mac")
+                topic = d.p["Topic"]
+                full_topic = d.p["FullTopic"]
+                device_name = d.name
+
+                if mac:
+                    self.devices.beginGroup(mac.replace(":", "-"))
+                    self.devices.setValue("topic", topic)
+                    self.devices.setValue("full_topic", full_topic)
+                    self.devices.setValue("device_name", device_name)
+
+                    for i, h in enumerate(d.history):
+                        self.devices.setValue(f"history/{i}", h)
+                    self.devices.endGroup()
+            self.devices.sync()
 
         e.accept()
diff --git a/tdmgr/GUI/dialogs/patterns.py b/tdmgr/GUI/dialogs/patterns.py
index 7cf42ca..2e994a8 100644
--- a/tdmgr/GUI/dialogs/patterns.py
+++ b/tdmgr/GUI/dialogs/patterns.py
@@ -1,6 +1,7 @@
 from PyQt5.QtWidgets import QDialog, QInputDialog, QLabel, QListWidget, QMessageBox, QPushButton
 
 from tdmgr.GUI.widgets import HLayout, VLayout
+from tdmgr.util import DEFAULT_PATTERNS
 
 
 class PatternsDialog(QDialog):
@@ -26,6 +27,7 @@ def __init__(self, settings, *args, **kwargs):
             QLabel(
                 "Add your modified FullTopic patterns to enable auto-discovery of such devices"
                 "\nPatterns MUST include %prefix%, %topic% and trailing /\n"
+                "+ wildcard is allowed in the topic\n"
                 "Default Tasmota FullTopics are built-in\n\n"
                 "You have to reconnect to your Broker after topic changes."
             ),
@@ -65,6 +67,9 @@ def validate_pattern(pattern):
             if wrong_token in pattern:
                 errors.append(f"Wrong character in pattern: {wrong_token}.")
 
+        if pattern in DEFAULT_PATTERNS:
+            errors.append(f"Pattern {pattern} already included in default topics.")
+
         return errors
 
     def select(self, idx):
diff --git a/tdmgr/GUI/dialogs/power.py b/tdmgr/GUI/dialogs/power.py
index 3b82999..401a391 100644
--- a/tdmgr/GUI/dialogs/power.py
+++ b/tdmgr/GUI/dialogs/power.py
@@ -38,7 +38,7 @@ def __init__(self, device, *args, **kwargs):
         )
         vl_cmd.addWidget(self.ci)
 
-        self.cpt = PulseTime("PulseTime", commands["PulseTime"], self.device.pulsetime())
+        self.cpt = PulseTime("PulseTime", commands["PulseTime"], self.device)
         vl_cmd.addWidget(self.cpt)
 
         vl_cmd.addStretch(1)
diff --git a/tdmgr/GUI/dialogs/prefs.py b/tdmgr/GUI/dialogs/prefs.py
index 06304ce..bcccbb2 100644
--- a/tdmgr/GUI/dialogs/prefs.py
+++ b/tdmgr/GUI/dialogs/prefs.py
@@ -1,7 +1,15 @@
 from PyQt5.QtCore import Qt
-from PyQt5.QtWidgets import QCheckBox, QDialog, QDialogButtonBox, QFormLayout, QGroupBox
+from PyQt5.QtWidgets import (
+    QButtonGroup,
+    QCheckBox,
+    QDialog,
+    QDialogButtonBox,
+    QFormLayout,
+    QGroupBox,
+    QRadioButton,
+)
 
-from tdmgr.GUI.widgets import SpinBox, VLayout
+from tdmgr.GUI.widgets import GroupBoxV, SpinBox, VLayout
 
 
 class PrefsDialog(QDialog):
@@ -31,6 +39,21 @@ def __init__(self, settings, *args, **kwargs):
 
         gbDevices.setLayout(fl_dev)
 
+        gbDiscovery = GroupBoxV("Discovery")
+
+        discovery_mode = self.settings.value("discovery_mode", 0, int)
+        self.bgDiscovery = QButtonGroup()
+        btns = [
+            QRadioButton("Both"),
+            QRadioButton("Native Tasmota discovery"),
+            QRadioButton("Legacy (LWT-based) discovery"),
+        ]
+        for id, btn in enumerate(btns):
+            self.bgDiscovery.addButton(btn, id)
+            btn.setChecked(id == discovery_mode)
+
+        gbDiscovery.addElements(*self.bgDiscovery.buttons())
+
         gbConsole = QGroupBox("Console")
         fl_cons = QFormLayout()
 
diff --git a/tdmgr/GUI/dialogs/timers.py b/tdmgr/GUI/dialogs/timers.py
index 08a5ade..3ddb376 100644
--- a/tdmgr/GUI/dialogs/timers.py
+++ b/tdmgr/GUI/dialogs/timers.py
@@ -1,5 +1,4 @@
-import logging
-from json import JSONDecodeError, dumps, loads
+from json import dumps
 
 from PyQt5.QtCore import Qt, QTime, pyqtSignal, pyqtSlot
 from PyQt5.QtWidgets import (
@@ -15,6 +14,7 @@
 )
 
 from tdmgr.GUI.widgets import GroupBoxH, GroupBoxV, HLayout, VLayout
+from tdmgr.util import Message
 
 
 class TimersDialog(QDialog):
@@ -238,8 +238,9 @@ def saveTimer(self):
             self, "Timer saved", f"{self.cbTimer.currentText()} data sent to device."
         )
 
-    @pyqtSlot(str, str)
-    def parseMessage(self, topic, msg):
+    # TODO: use schema
+    @pyqtSlot(Message)
+    def parseMessage(self, msg: Message):
         """
         Tasmota < 9.4.0.5 : There are a total of 4 messages in reply to `Timers` command:
             {"Timers": "ON" }
@@ -256,28 +257,19 @@ def parseMessage(self, topic, msg):
             "Days":"0000000","Repeat":0,
             "Action":0}, ....
         """
-        if self.device.matches(topic):
-            if self.device.reply == "RESULT" or self.device.reply == "TIMERS":
-                try:
-                    payload = loads(msg)
-                    all = list(payload)
-                    first = all[0]
-
-                except JSONDecodeError as e:
-                    error = "Timer loading error", f"Can't load the timer from device.\n{e}"
-                    logging.critical(error)
-                    QMessageBox.critical(self, error)
-
-                else:
-                    if first == "Timers":
-                        self.gbTimers.setChecked(payload[first] == "ON")
-
-                        if len(all) > 1:
-                            payload.pop("Timers")
-                            self.timers.update(payload)
-                            self.loadTimer(self.cbTimer.currentText())
-
-                    elif first.startswith("Timers"):
-                        self.timers.update(payload[first])
-                        if first == "Timers4":
-                            self.loadTimer(self.cbTimer.currentText())
+        if self.device.matches(msg.topic):
+            if msg.is_result or msg.endpoint == "TIMERS":
+                payload = msg.dict()
+                all = list(payload)
+                if msg.first_key == "Timers":
+                    self.gbTimers.setChecked(payload[msg.first_key] == "ON")
+
+                    if len(all) > 1:
+                        payload.pop("Timers")
+                        self.timers.update(payload)
+                        self.loadTimer(self.cbTimer.currentText())
+
+                elif msg.first_key.startswith("Timers"):
+                    self.timers.update(payload[msg.first_key])
+                    if msg.first_key == "Timers4":
+                        self.loadTimer(self.cbTimer.currentText())
diff --git a/tdmgr/GUI/rules.py b/tdmgr/GUI/rules.py
index f92f11d..5355fa1 100644
--- a/tdmgr/GUI/rules.py
+++ b/tdmgr/GUI/rules.py
@@ -1,7 +1,5 @@
 import os
 import re
-from copy import deepcopy
-from json import JSONDecodeError, loads
 
 from PyQt5.QtCore import QDir, Qt, QTimer, pyqtSignal, pyqtSlot
 from PyQt5.QtGui import QColor, QFont, QIcon, QSyntaxHighlighter, QTextCharFormat
@@ -17,7 +15,9 @@
 )
 
 from tdmgr.GUI.widgets import CheckableAction, GroupBoxH, GroupBoxV, HLayout, Toolbar, VLayout
-from tdmgr.util import TasmotaDevice
+from tdmgr.util import Message, TasmotaDevice
+
+RE_RULE = re.compile(r"^RULE\d", re.IGNORECASE)
 
 # TODO: triggers list
 
@@ -268,69 +268,42 @@ def unfold_rule(self, rules: str):
             .rstrip(" ")
         )
 
-    @pyqtSlot(str, str)
-    def parseMessage(self, topic, msg):
-        if self.device.matches(topic):
-            payload = None
-            if msg.startswith("{"):
-                try:
-                    payload = loads(msg)
-                except JSONDecodeError:
-                    # JSON parse exception means that most likely the rule contains an unescaped
-                    # JSON payload.  TDM will attempt parsing using a regex instead of json.loads()
-                    parsed_rule = re.match(
-                        r"{\"(?P<rule>Rule(\d))\":\"(?P<enabled>ON|OFF)\",\"Once\":\""
-                        r"(?P<Once>ON|OFF)\",\"StopOnError"
-                        r"\":\"(?P<StopOnError>ON|OFF)"
-                        r"\",\"Free\":\d+,\"Rules\":\"(?P<Rules>.*?)\"}$",
-                        msg,
-                        re.IGNORECASE,
-                    )
-                    # modify the resulting matchdict to follow the keys of original JSON payload
-                    payload = deepcopy(parsed_rule.groupdict())
-                    rule = payload.pop("rule")
-                    payload[rule] = payload.pop("enabled")
-                    self.display_rule(payload, rule)
-                else:
-                    if payload:
-                        keys = list(payload.keys())
-                        fk = keys[0]
-
-                        if (
-                            self.device.reply == "RESULT"
-                            and fk == "T1"
-                            or self.device.reply == "RULETIMER"
-                        ):
-                            for i, rt in enumerate(payload.keys()):
-                                self.lwRTs.item(i).setText(f"RuleTimer{i + 1}: {payload[rt]}")
-                                self.rts[i] = payload[rt]
-
-                        elif (
-                            self.device.reply == "RESULT"
-                            and fk.startswith("Var")
-                            or self.device.reply.startswith("VAR")
-                        ):
-                            for k, v in payload.items():
-                                row = int(k.replace("Var", "")) - 1
-                                self.lwVars.item(row).setText(f"VAR{row + 1}: {v}")
-                                self.vars[row] = v
-
-                        elif (
-                            self.device.reply == "RESULT"
-                            and fk.startswith("Mem")
-                            or self.device.reply.startswith("MEM")
-                        ):
-                            for k, v in payload.items():
-                                row = int(k.replace("Mem", "")) - 1
-                                self.lwMems.item(row).setText(f"MEM{row + 1}: {v}")
-                                self.mems[row] = v
-
-                        elif (
-                            self.device.reply == "RESULT"
-                            and fk.startswith("Rule")
-                            or self.device.reply.startswith("RULE")
-                        ):
-                            self.display_rule(payload, fk)
+    @pyqtSlot(Message)
+    def parseMessage(self, msg: Message):
+        if self.device.matches(msg.topic):
+            payload = msg.dict()
+            if payload:
+                if msg.is_result and msg.first_key == "T1" or msg.endpoint == "RULETIMER":
+                    for i, rt in enumerate(payload.keys()):
+                        self.lwRTs.item(i).setText(f"RuleTimer{i + 1}: {payload[rt]}")
+                        self.rts[i] = payload[rt]
+
+                elif (
+                    msg.is_result
+                    and msg.first_key.startswith("Var")
+                    or msg.endpoint.startswith("VAR")
+                ):
+                    for k, v in payload.items():
+                        row = int(k.replace("Var", "")) - 1
+                        self.lwVars.item(row).setText(f"VAR{row + 1}: {v}")
+                        self.vars[row] = v
+
+                elif (
+                    msg.is_result
+                    and msg.first_key.startswith("Mem")
+                    or msg.endpoint.startswith("MEM")
+                ):
+                    for k, v in payload.items():
+                        row = int(k.replace("Mem", "")) - 1
+                        self.lwMems.item(row).setText(f"MEM{row + 1}: {v}")
+                        self.mems[row] = v
+
+                elif (
+                    msg.is_result
+                    and msg.first_key.startswith("Rule")
+                    or msg.endpoint.startswith("RULE")
+                ):
+                    self.display_rule(payload, msg.first_key)
 
 
 class RuleHighLighter(QSyntaxHighlighter):
diff --git a/tdmgr/GUI/widgets.py b/tdmgr/GUI/widgets.py
index a9c22b7..9c7f506 100644
--- a/tdmgr/GUI/widgets.py
+++ b/tdmgr/GUI/widgets.py
@@ -23,9 +23,12 @@
     QWidgetAction,
 )
 
+from tdmgr.schemas.result import PulseTimeLegacyResultSchema
+from tdmgr.util import TasmotaDevice
+
 base_view = ["Device"]
 default_views = {
-    "Home": base_view + ["Module", "Power", "Color", "LoadAvg", "LinkCount", "Uptime"],
+    "Home": base_view + ["Module", "LoadAvg", "LinkCount", "Uptime"],
     "Health": base_view
     + [
         "Uptime",
@@ -403,7 +406,7 @@ def __init__(self, command, meta, value=None, *args, **kwargs):
                 )
             cb.setCurrentIndex(val)
             hl_input = HLayout(0)
-            hl_input.addElements(QLabel("{}: ".format(i + 1)), cb)
+            hl_input.addElements(QLabel(f"{i + 1}:"), cb)
 
             self.inputs.append(cb)
             vl.addLayout(hl_input)
@@ -474,8 +477,8 @@ def __init__(self, command, meta, value=None, *args, **kwargs):
 
 
 class PulseTime(QWidget):
-    def __init__(self, command, meta, value=None, *args, **kwargs):
-        super(PulseTime, self).__init__(*args, **kwargs)
+    def __init__(self, command: str, meta: dict, device: TasmotaDevice):
+        super(PulseTime, self).__init__()
         self.setMinimumWidth(250)
         self.setSizePolicy(QSizePolicy(QSizePolicy.Expanding, QSizePolicy.Maximum))
         self.inputs = []
@@ -493,13 +496,19 @@ def __init__(self, command, meta, value=None, *args, **kwargs):
         vl.addWidget(desc)
 
         vl_groups = VLayout(0)
-        for k in sorted(list(value.keys())):
+
+        if isinstance(device._pulsetime, PulseTimeLegacyResultSchema):
+            values = [pt[1].Set for pt in device._pulsetime]
+        else:
+            values = device._pulsetime.PulseTime.Set
+
+        for idx, value in enumerate(values, start=1):
             sb = SpinBox(
                 minimum=int(meta["parameters"]["min"]), maximum=int(meta["parameters"]["max"])
             )
-            sb.setValue(value[k])
+            sb.setValue(value)
             hl_group = HLayout(0)
-            hl_group.addElements(QLabel(k), sb)
+            hl_group.addElements(QLabel(f"Pulsetime{idx}"), sb)
             vl_groups.addLayout(hl_group)
             self.inputs.append(sb)
         vl.addLayout(vl_groups)
diff --git a/tdmgr/models/devices.py b/tdmgr/models/devices.py
index 7066ec7..0a4399e 100644
--- a/tdmgr/models/devices.py
+++ b/tdmgr/models/devices.py
@@ -3,7 +3,7 @@
 
 from PyQt5.QtCore import QAbstractTableModel, QModelIndex, Qt
 
-from tdmgr.models.common import DeviceRoles
+from tdmgr.models.roles import DeviceRoles
 from tdmgr.util import TasmotaDevice
 
 
@@ -84,7 +84,7 @@ def data(self, idx, role=Qt.DisplayRole):
 
                 if col_name == "Module":
                     if val == 0:
-                        return d.p["Template"].get("NAME", "Fetching template name...")
+                        return d.p["Template"]
                     else:
                         return d.module()
 
diff --git a/tdmgr/models/common.py b/tdmgr/models/roles.py
similarity index 100%
rename from tdmgr/models/common.py
rename to tdmgr/models/roles.py
diff --git a/tdmgr/run.py b/tdmgr/run.py
index 94fbacf..22b0156 100644
--- a/tdmgr/run.py
+++ b/tdmgr/run.py
@@ -12,7 +12,7 @@
 from tdmgr.GUI.dialogs.main import MainWindow
 
 try:
-    from tdmgr.__version__ import version
+    from tdmgr._version import version
 except ImportError:
     version = ""
 
diff --git a/tdmgr/schemas/__init__.py b/tdmgr/schemas/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/tdmgr/schemas/common.py b/tdmgr/schemas/common.py
new file mode 100644
index 0000000..6c0d3a1
--- /dev/null
+++ b/tdmgr/schemas/common.py
@@ -0,0 +1,6 @@
+from enum import Enum
+
+
+class OnOffEnum(str, Enum):
+    ON = "ON"
+    OFF = "OFF"
diff --git a/tdmgr/schemas/discovery.py b/tdmgr/schemas/discovery.py
new file mode 100644
index 0000000..3d5b8a8
--- /dev/null
+++ b/tdmgr/schemas/discovery.py
@@ -0,0 +1,79 @@
+from collections import namedtuple
+from enum import Enum
+from ipaddress import IPv4Address
+from typing import Dict, List, Union
+
+from pydantic import BaseModel
+
+TopicPrefixes = namedtuple("TopicPrefixes", "cmnd, stat, tele")
+
+
+class LightType(int, Enum):
+    NONE = 0
+    SINGLE = 1
+    COLDWARM = 2
+    RGB = 3
+    RGBW = 4
+    RGBCW = 5
+
+
+class DiscoverySchema(BaseModel):
+    ip: IPv4Address
+    """IPv4 address"""
+
+    dn: str
+    """Device name"""
+
+    fn: List[Union[str, None]]
+    """List of FriendlyNames"""
+
+    hn: str
+    """Hostname"""
+
+    mac: str
+    """MAC address"""
+
+    md: str
+    """Module"""
+
+    ofln: str
+    """Offline LWT payload"""
+
+    onln: str
+    """Online LWT payload"""
+
+    state: List[str]
+    """States"""
+
+    sw: str
+    """Tasmota firmware version"""
+
+    t: str
+    """Topic"""
+
+    ft: str
+    """FullTopic"""
+
+    tp: TopicPrefixes
+    """Topic prefixes"""
+
+    rl: List[Union[int, None]]
+    """Relays"""
+
+    swc: List[int]
+    """Switches"""
+
+    btn: List[int]
+    """Buttons"""
+
+    so: Dict[str, int]
+    """Setoptions"""
+
+    lk: int
+    """Light CTRGB linked"""
+
+    lt_st: LightType
+    """Light type"""
+
+    ver: int
+    """Discovery version"""
diff --git a/tdmgr/schemas/result.py b/tdmgr/schemas/result.py
new file mode 100644
index 0000000..16be8f6
--- /dev/null
+++ b/tdmgr/schemas/result.py
@@ -0,0 +1,35 @@
+from typing import List
+
+from pydantic import BaseModel
+
+
+class TemplateResultSchema(BaseModel):
+    NAME: str
+    GPIO: List[int]
+    FLAG: int
+    BASE: int
+
+
+class PulseTimeLegacySchema(BaseModel):
+    Set: int
+    Remaining: int
+
+
+class PulseTimeLegacyResultSchema(BaseModel):
+    PulseTime1: PulseTimeLegacySchema
+    PulseTime2: PulseTimeLegacySchema
+    PulseTime3: PulseTimeLegacySchema
+    PulseTime4: PulseTimeLegacySchema
+    PulseTime5: PulseTimeLegacySchema
+    PulseTime6: PulseTimeLegacySchema
+    PulseTime7: PulseTimeLegacySchema
+    PulseTime8: PulseTimeLegacySchema
+
+
+class PulseTimeSchema(BaseModel):
+    Set: List[int]
+    Remaining: List[int]
+
+
+class PulseTimeResultSchema(BaseModel):
+    PulseTime: PulseTimeSchema
diff --git a/tdmgr/schemas/rules.py b/tdmgr/schemas/rules.py
new file mode 100644
index 0000000..e44f42a
--- /dev/null
+++ b/tdmgr/schemas/rules.py
@@ -0,0 +1,17 @@
+from typing import Dict
+
+from pydantic import BaseModel, Json, RootModel
+
+from tdmgr.schemas.common import OnOffEnum
+
+
+class RuleSchema(BaseModel):
+    State: OnOffEnum
+    Once: OnOffEnum
+    StopOnError: OnOffEnum
+    Length: int
+    Free: int
+    Rules: Json
+
+
+RuleResponseSchema = RootModel[Dict[str, RuleSchema]]
diff --git a/tdmgr/schemas/status.py b/tdmgr/schemas/status.py
new file mode 100644
index 0000000..eca9d65
--- /dev/null
+++ b/tdmgr/schemas/status.py
@@ -0,0 +1,250 @@
+from typing import List, Optional, Union
+
+from pydantic import BaseModel, create_model
+
+
+class StatusSchema(BaseModel):
+    Module: int
+    DeviceName: Optional[str] = None
+    FriendlyName: List[str]
+    Topic: str
+    ButtonTopic: str
+    Power: int
+    PowerOnState: int
+    LedState: int
+    LedMask: Optional[str] = None
+    SaveData: int
+    SaveState: int
+    SwitchTopic: str
+    SwitchMode: List[int]
+    ButtonRetain: int
+    SwitchRetain: int
+    SensorRetain: int
+    PowerRetain: int
+
+
+class StatusPRMSchema(BaseModel):
+    Baudrate: int
+    GroupTopic: str
+    OtaUrl: str
+    RestartReason: str
+    Uptime: str
+    StartupUTC: str
+    Sleep: int
+    CfgHolder: Optional[int] = None
+    BootCount: int
+    SaveCount: int
+    SaveAddress: Optional[str] = None
+
+
+class StatusFWRSchema(BaseModel):
+    Version: str
+    BuildDateTime: str
+    Boot: Optional[int] = None
+    Core: Optional[str] = None
+    SDK: str
+    Hardware: Optional[str] = None
+
+
+class StatusLOGSchema(BaseModel):
+    SerialLog: int
+    WebLog: int
+    MqttLog: int
+    SysLog: int
+    LogHost: str
+    LogPort: int
+    SSId: List[str]
+    TelePeriod: int
+    Resolution: str
+    SetOption: List[str]
+
+
+class StatusMEMSchema(BaseModel):
+    ProgramSize: int
+    Free: int
+    Heap: int
+    ProgramFlashSize: int
+    FlashSize: int
+    FlashChipId: str
+    FlashMode: Optional[Union[int, str]] = None
+    Features: List[str]
+    Drivers: str
+    Sensors: str
+
+
+class StatusNETSchema(BaseModel):
+    Hostname: str
+    IPAddress: str
+    Gateway: str
+    Subnetmask: str
+    DNSServer: Optional[str] = None
+    Mac: str
+    Webserver: int
+    WifiConfig: int
+
+
+class StatusMQTSchema(BaseModel):
+    MqttHost: str
+    MqttPort: int
+    MqttClientMask: str
+    MqttClient: str
+    MqttUser: str
+    MqttCount: int
+    MAX_PACKET_SIZE: int
+    KEEPALIVE: int
+
+
+class StatusPTHSchema(BaseModel):
+    PowerDelta: Union[int, List[int]]
+    PowerLow: Union[int, List[int]]
+    PowerHigh: Union[int, List[int]]
+    VoltageLow: Union[int, List[int]]
+    CurrentLow: Union[int, List[int]]
+    CurrentHigh: Union[int, List[int]]
+
+
+class StatusSTKSchema(BaseModel):
+    Exception: int
+    Reason: str
+    EPC: List[str]
+    EXCVADDR: str
+    DEPC: str
+    CallChain: List[str]
+
+
+class StatusTIMSchema(BaseModel):
+    UTC: str
+    Local: str
+    StartDST: str
+    EndDST: str
+    Timezone: Union[str, int]
+    Sunrise: Optional[str] = None
+    Sunset: Optional[str] = None
+
+
+class WifiSchema(BaseModel):
+    AP: int
+    SSId: str
+    BSSId: str
+    Channel: Union[int, List[int]]
+    RSSI: int
+    Signal: Optional[int] = None
+    LinkCount: int
+    Downtime: str
+
+
+class BerrySchema(BaseModel):
+    HeapUsed: int
+    Objects: int
+
+
+class StateSTSBaseSchema(BaseModel):
+    Time: str
+    Uptime: str
+    UptimeSec: int
+    Heap: int
+    SleepMode: str
+    Sleep: int
+    LoadAvg: int
+    MqttCount: int
+    Wifi: WifiSchema
+
+
+class StateBaseSchema(StateSTSBaseSchema):
+    Vcc: Optional[float] = None
+    Dimmer: Optional[int] = None
+    Color: Optional[str] = None
+    HSBColor: Optional[str] = None
+    Channel: Optional[Union[int, List[int]]] = None
+    Scheme: Optional[int] = None
+    Fade: Optional[str] = None
+    Speed: Optional[int] = None
+    LedTable: Optional[str] = None
+    Berry: Optional[BerrySchema] = None
+
+
+StateSchema = create_model(
+    'StateSchema',
+    __base__=StateBaseSchema,
+    **{f"POWER{idx}": (Optional[str], None) for idx in list(map(str, range(1, 33))) + [""]},
+)
+
+StatusSTSSchema = create_model(
+    'StatusSTSSchema',
+    __base__=StateSTSBaseSchema,
+    **{f"POWER{idx}": (Optional[str], None) for idx in list(map(str, range(1, 33))) + [""]},
+)
+
+
+class StatusResponseSchema(BaseModel):
+    Status: StatusSchema
+
+
+class Status1ResponseSchema(BaseModel):
+    StatusPRM: StatusPRMSchema
+
+
+class Status2ResponseSchema(BaseModel):
+    StatusFWR: StatusFWRSchema
+
+
+class Status3ResponseSchema(BaseModel):
+    StatusLOG: StatusLOGSchema
+
+
+class Status4ResponseSchema(BaseModel):
+    StatusMEM: StatusMEMSchema
+
+
+class Status5ResponseSchema(BaseModel):
+    StatusNET: StatusNETSchema
+
+
+class Status6ResponseSchema(BaseModel):
+    StatusMQT: StatusMQTSchema
+
+
+class Status7ResponseSchema(BaseModel):
+    StatusTIM: StatusTIMSchema
+
+
+class Status9ResponseSchema(BaseModel):
+    StatusPTH: StatusPTHSchema
+
+
+class Status11ResponseSchema(BaseModel):
+    StatusSTS: StatusSTSSchema
+
+
+class Status12ResponseSchema(BaseModel):
+    StatusSTK: StatusSTKSchema
+
+
+class Status0ResponseSchema(StatusResponseSchema):
+    StatusPRM: StatusPRMSchema
+    StatusFWR: StatusFWRSchema
+    StatusLOG: StatusLOGSchema
+    StatusMEM: StatusMEMSchema
+    StatusNET: StatusNETSchema
+    StatusMQT: Optional[StatusMQTSchema]
+    # StatusSNS: Optional[Status10ResponseSchema]
+    # StatusSTS: Optional[Json]
+
+
+STATUS_SCHEMA_MAP: [str, BaseModel] = {
+    'STATE': StateSchema,
+    'STATUS': StatusResponseSchema,
+    'STATUS0': Status0ResponseSchema,
+    'STATUS1': Status1ResponseSchema,
+    'STATUS2': Status2ResponseSchema,
+    'STATUS3': Status3ResponseSchema,
+    'STATUS4': Status4ResponseSchema,
+    'STATUS5': Status5ResponseSchema,
+    'STATUS6': Status6ResponseSchema,
+    'STATUS7': Status7ResponseSchema,
+    'STATUS9': Status9ResponseSchema,
+    'STATUS11': Status11ResponseSchema,
+    'STATUS12': Status12ResponseSchema,
+}
+
+StatusSchemaType = STATUS_SCHEMA_MAP.values()
diff --git a/tdmgr/util/__init__.py b/tdmgr/util/__init__.py
index 5373b41..d0a7119 100644
--- a/tdmgr/util/__init__.py
+++ b/tdmgr/util/__init__.py
@@ -1,19 +1,26 @@
+import json
 import logging
 import re
-from json import JSONDecodeError, loads
+from enum import Enum
+from functools import lru_cache
+from typing import Callable, Optional, Union
 
 from pkg_resources import parse_version
+from pydantic import BaseModel, ValidationError
 from PyQt5.QtCore import QObject, pyqtSignal
 
-prefixes = ["tele", "stat", "cmnd"]
-default_patterns = [
-    "%prefix%/%topic%/",  # = %prefix%/%topic% (Tasmota default)
-    "%topic%/%prefix%/",  # = %topic%/%prefix% (Tasmota with SetOption19 enabled for
-    # HomeAssistant AutoDiscovery)
-]
+from tdmgr.schemas.discovery import DiscoverySchema, TopicPrefixes
+from tdmgr.schemas.result import (
+    PulseTimeLegacyResultSchema,
+    PulseTimeResultSchema,
+    TemplateResultSchema,
+)
+from tdmgr.schemas.status import STATUS_SCHEMA_MAP
+from tdmgr.util.mqtt import DEFAULT_PATTERNS, MQTT_PATH_REGEX, Message
 
-custom_patterns = []
+# TODO: extract from __init__
 
+# TODO: extract to common
 resets = [
     "1: reset device settings to firmware defaults",
     "2: erase flash, reset device settings to firmware defaults",
@@ -35,22 +42,26 @@
 }
 
 
+COMMAND_UNKNOWN = {"Command": "Unknown"}
+
+
+# TODO: extract to mqtt
 def initial_commands():
     commands = [
-        ["status", 0],
-        ["template", ""],
-        ["modules", ""],
-        ["gpio", ""],
-        ["gpios", "255"],
-        ["buttondebounce", ""],
-        ["switchdebounce", ""],
-        ["interlock", ""],
-        ["blinktime", ""],
-        ["blinkcount", ""],
-        ["mqttlog", ""],  # will be removed after MqttLog will be added to Status 3
+        "template",
+        "modules",
+        "gpio",
+        "buttondebounce",
+        "switchdebounce",
+        "interlock",
+        "blinktime",
+        "blinkcount",
+        "pulsetime",
     ]
-    for pt in range(8):
-        commands.append([f"pulsetime{pt + 1}", ""])
+
+    commands = [(command, "") for command in commands]
+    commands += [("status", "0"), ("gpios", "255")]
+
     for sht in range(4):
         commands.append([f"shutterrelay{sht + 1}", ""])
         commands.append([f"shutterposition{sht + 1}", ""])
@@ -58,22 +69,6 @@ def initial_commands():
     return commands
 
 
-def parse_topic(full_topic, topic):
-    """
-    :param full_topic: FullTopic to match against
-    :param topic: MQTT topic from which the reply arrived
-    :return: If match is found, returns dictionary including device Topic,
-        prefix (cmnd/tele/stat) and reply endpoint
-    """
-    full_topic = f"{full_topic}(?P<reply>.*)".replace("%topic%", "(?P<topic>.*?)").replace(
-        "%prefix%", "(?P<prefix>.*?)"
-    )
-    match = re.fullmatch(full_topic, topic)
-    if match:
-        return match.groupdict()
-    return {}
-
-
 def parse_payload(payload):
     match = re.match(r"(\d+) \((.*)\)", payload)
     if match:
@@ -81,24 +76,19 @@ def parse_payload(payload):
     return {}
 
 
-def expand_fulltopic(fulltopic):
-    fulltopics = []
-    for prefix in prefixes:
-        topic = (
-            fulltopic.replace("%prefix%", prefix).replace("%topic%", "+") + "#"
-        )  # expand prefix and topic
-        topic = topic.replace("+/#", "#")  # simplify wildcards
-        fulltopics.append(topic)
-    return fulltopics
+class DiscoveryMode(int, Enum):
+    BOTH = 0
+    NATIVE = 1
+    LEGACY = 2
 
 
-class TasmotaEnvironment(object):
+class TasmotaEnvironment:
     def __init__(self):
         self.devices = []
-        self.lwts = []
+        self.lwts = dict()
         self.retained = set()
 
-    def find_device(self, topic):
+    def find_device(self, topic) -> 'TasmotaDevice':
         for d in self.devices:
             if d.matches(topic):
                 return d
@@ -107,7 +97,7 @@ def find_device(self, topic):
 class TasmotaDevice(QObject):
     update_telemetry = pyqtSignal()
 
-    def __init__(self, topic, fulltopic, devicename=""):
+    def __init__(self, topic: str, fulltopic: str = "%prefix%/%topic%/", devicename: str = ""):
         super(TasmotaDevice, self).__init__()
         self.p = {
             "LWT": "undefined",
@@ -115,12 +105,18 @@ def __init__(self, topic, fulltopic, devicename=""):
             "FullTopic": fulltopic,
             "DeviceName": devicename,
             "Template": {},
+            "Online": "Online",
+            "Offline": "Offline",
         }
 
+        # self.props = Properties(Topic=topic, FullTopic=fulltopic, DeviceName=devicename)
+
         self.debug = False
         self.env = None
         self.history = []
 
+        self.topic_prefixes = TopicPrefixes("cmnd", "stat", "tele")
+
         self.property_changed = None  # property changed callback pointer
 
         self.t = None
@@ -131,8 +127,43 @@ def __init__(self, topic, fulltopic, devicename=""):
         self.gpios = {}  # supported GPIOs
         self.gpio = {}  # gpio config
 
-        self.reply = ""
-        self.prefix = ""
+        # self._status_processors = self._get_status_processors()
+        self._result_processors = self._get_result_processors()
+
+        self._pulsetime: Optional[Union[PulseTimeLegacyResultSchema, PulseTimeResultSchema]] = None
+
+        self.processor_map = {
+            r"PulseTime\d?": self._process_pulsetime,
+            r"NAME": self._process_template,
+            r"Module$": self._process_module,
+            r"Modules\d?": self._process_modules,
+            r"GPIO$": self._process_gpio,
+            r"GPIO\d?$": self._process_gpio,
+            r"GPIOs\d?": self._process_gpios,
+        }
+
+    @classmethod
+    def from_discovery(cls, obj: DiscoverySchema):
+        logging.debug(obj)
+        _ft = obj.ft.replace(obj.t, "%topic%").replace(obj.tp.tele, "%prefix%")
+        device = TasmotaDevice(obj.t, _ft, obj.dn)
+        device.topic_prefixes = obj.tp
+        device.p["Online"] = obj.onln
+        device.p["Offline"] = obj.ofln
+        device.p["Module"] = obj.md
+        return device
+
+    def _get_result_processors(self):
+        PROCESSOR_METHOD_PREFIX = '_process_response_'
+        method_names = [
+            method_name
+            for method_name in self.__class__.__dict__
+            if method_name.startswith(PROCESSOR_METHOD_PREFIX)
+        ]
+        return {
+            method_name.replace(PROCESSOR_METHOD_PREFIX, ''): getattr(self, method_name)
+            for method_name in method_names
+        }
 
     def build_topic(self, prefix):
         return (
@@ -143,157 +174,144 @@ def build_topic(self, prefix):
         )
 
     def cmnd_topic(self, command=""):
-        if command:
-            return f"{self.build_topic('cmnd')}/{command}"
-        return self.build_topic("cmnd")
+        return f"{self.build_topic(self.topic_prefixes.cmnd)}/{command}".rstrip("/")
 
     def stat_topic(self, command=""):
-        if command:
-            return f"{self.build_topic('stat')}/{command}"
-        return self.build_topic("stat")
+        return f"{self.build_topic(self.topic_prefixes.stat)}/{command}".rstrip("/")
 
     def tele_topic(self, endpoint=""):
-        if endpoint:
-            return f"{self.build_topic('tele')}/{endpoint}"
-        return self.build_topic("tele")
+        return f"{self.build_topic(self.topic_prefixes.tele)}/{endpoint}".rstrip("/")
 
     def is_default(self):
-        return self.p["FullTopic"] in ["%prefix%/%topic%/", "%topic%/%prefix%/"]
+        return self.p["FullTopic"] in DEFAULT_PATTERNS
+
+    @property
+    def subscribable_topics(self):
+        topics = []
+        for topic_prefix in (self.topic_prefixes.tele, self.topic_prefixes.stat):
+            _ft = self.p["FullTopic"].replace("%prefix%", topic_prefix).replace("%topic%", "+")
+            if _ft[-1] != "/":
+                _ft = f"{_ft}/"
+            _ft = f"{_ft}+"
+            _ft.replace("+/+", "#")
+        return topics
 
     def update_property(self, k, v):
-        old = self.p.get(k)  # safely get the old value
-        if self.property_changed and (
-            not old or old != v
-        ):  # If property_changed callback is set then check previous value presence and
-            self.property_changed(
-                self, k
-            )  # compare with new value. Trigger the callback if value has changed
-        self.p[k] = v  # store the new value
+        old = self.p.get(k)
+        if not old or old != v:
+            self.p[k] = v
+            if self.property_changed:
+                self.property_changed(self, k)
 
     def module(self):
         if mdl := self.p.get("Module"):
-            return self.modules.get(str(mdl), '')
-
-        if self.p["LWT"] == "Online":
-            return "Fetching module name..."
-
-    def matches(self, topic):
-        if topic == self.p["Topic"]:
-            return True
-        parsed = parse_topic(self.p["FullTopic"], topic)
-        self.reply = parsed.get("reply")
-        self.prefix = parsed.get("prefix")
-        return parsed.get("topic") == self.p["Topic"]
-
-    def parse_message(self, topic, msg):
-        parse_statuses = [f"STATUS{s}" for s in [1, 2, 3, 4, 5, 6, 7, 9]]
-        if self.prefix in ("stat", "tele"):
-            payload = None
-
-            if self.reply == "STATUS":
-                try:
-                    payload = loads(msg)
-                except JSONDecodeError as e:
-                    logging.critical("PARSER: Can't parse STATUS (%s): %s", e, msg)
-
-                if payload:
-                    payload = payload.get("Status", {})
-                    for k, v in payload.items():
-                        if k == "FriendlyName":
-                            for fnk, fnv in enumerate(v, start=1):
-                                self.update_property(f"FriendlyName{fnk}", fnv)
-                        else:
-                            self.update_property(k, v)
-
-            elif self.reply in parse_statuses:
-                try:
-                    payload = loads(msg)
-                except JSONDecodeError as e:
-                    logging.critical("PARSER: Can't parse %s (%s): %s", self.reply, e, msg)
-
-                if payload:
-                    payload = payload[list(payload.keys())[0]]
-                    for k, v in payload.items():
-                        self.update_property(k, v)
-
-            elif self.reply in ("STATE", "STATUS11"):
-                try:
-                    payload = loads(msg)
-                except JSONDecodeError as e:
-                    logging.critical("PARSER: Can't parse %s (%s): %s", self.reply, e, msg)
-
-                if payload:
-                    if self.reply == "STATUS11":
-                        payload = payload["StatusSTS"]
-
-                    for k, v in payload.items():
-                        if isinstance(v, dict):
-                            for kk, vv in v.items():
-                                self.update_property(kk, vv)
-                        else:
-                            self.update_property(k, v)
-
-            elif self.reply in ("SENSOR", "STATUS8", "STATUS10"):
-                try:
-                    payload = loads(msg)
-                except JSONDecodeError as e:
-                    logging.critical("PARSER: Can't parse %s (%s): %s", self.reply, e, msg)
-
-                if payload:
-                    if self.reply in ("STATUS8", "STATUS10"):
-                        payload = payload["StatusSNS"]
-
-                    self.t = payload
-                    self.update_telemetry.emit()
-
-            elif msg.startswith("{"):
-                try:
-                    payload = loads(msg)
-                except JSONDecodeError as e:
-                    logging.critical("PARSER: Can't parse %s (%s): %s", self.reply, e, msg)
-
-                if payload:
-                    keys = list(payload.keys())
-                    fk = keys[0]
-
-                    if (
-                        self.reply == "RESULT"
-                        and fk.startswith("Modules")
-                        or self.reply == "MODULES"
-                    ):
-                        for k, v in payload.items():
-                            if isinstance(v, list):
-                                for mdl in v:
-                                    self.modules.update(parse_payload(mdl))
-                            elif isinstance(v, dict):
-                                self.modules.update(v)
-                            self.module_changed(self)
-
-                    elif self.reply == "RESULT" and fk == "NAME" or self.reply == "TEMPLATE":
-                        self.p["Template"] = payload
-                        if self.module_changed:
-                            self.module_changed(self)
-
-                    elif self.reply == "RESULT" and fk.startswith("GPIOs") or self.reply == "GPIOS":
-                        for k, v in payload.items():
-                            if isinstance(v, list):
-                                for gp in v:
-                                    self.gpios.update(parse_payload(gp))
-                            elif isinstance(v, dict):
-                                self.gpios.update(v)
-
-                    elif self.reply == "RESULT" and fk.startswith("GPIO") or self.reply == "GPIO":
-                        for gp, gp_val in payload.items():
-                            if not gp == "GPIO":
-                                if isinstance(gp_val, str):
-                                    gp_id = gp_val.split(" (")[0]
-                                    self.gpio[gp] = gp_id
-                                elif isinstance(gp_val, dict):
-                                    self.gpio[gp] = list(gp_val.keys())[0]
-
-                    else:
-                        for k, v in payload.items():
-                            self.update_property(k, v)
+            return self.modules.get(str(mdl), mdl)
+
+        if self.p["LWT"] == self.p["Online"]:
+            return "(unknown)"
+
+    @property
+    def fulltopic_regex(self) -> str:
+        _ft_pattern = (
+            self.p["FullTopic"]
+            .replace("%prefix%", f"(?P<prefix>{MQTT_PATH_REGEX})")
+            .replace("%topic%", self.p["Topic"])
+        )
+        return f'^{_ft_pattern}'
+
+    def matches(self, topic: str) -> bool:
+        return re.match(self.fulltopic_regex, topic) is not None
+
+    def _process_module(self, msg: Message):
+        print(msg.payload)
+
+    def _process_modules(self, msg: Message):
+        self.modules.update(**msg.dict()[msg.first_key])
+
+    def _process_gpio(self, msg: Message):
+        self.gpio.update(**msg.dict())
+
+    def _process_gpios(self, msg: Message):
+        self.gpios.update(**msg.dict()[msg.first_key])
+
+    def _process_pulsetime(self, msg: Message):
+        # PulseTime returns all timers since 6.6.0.15
+        if msg.first_key == "PulseTime1":
+            schema = PulseTimeLegacyResultSchema
+        else:
+            schema = PulseTimeResultSchema
+        self._pulsetime = schema.model_validate(msg.dict())
+
+    def _process_template(self, msg: Message):
+        _template = TemplateResultSchema.model_validate(msg.dict())
+        self.update_property("Template", _template.NAME)
+
+    def _process_logging(self, msg: Message):
+        pass
+
+    @lru_cache
+    def get_result_processor(self, key) -> Callable:
+        for pattern, func in self.processor_map.items():
+            if re.match(pattern, key):
+                return func
+
+    def _process_result(self, msg: Message):
+        # TODO: move this check as a Message property
+        if msg.dict() != COMMAND_UNKNOWN:
+            if func := self.get_result_processor(msg.first_key):
+                func(msg)
+            else:
+                for k, v in msg.dict().items():
+                    self.update_property(k, v)
+
+    def process_status(self, schema: BaseModel, payload: dict):
+        try:
+            processed = schema.model_validate(payload).model_dump(exclude_none=True)
+            first_key = next(iter(processed.keys()))
+            items = (
+                processed[first_key].items()
+                if schema.__name__ != 'StateSchema'
+                else processed.items()
+            )
+
+            # TODO: needs to be reworked
+            for k, v in items:
+                if k == "Wifi":
+                    for wk, wv in v.items():
+                        self.update_property(wk, wv)
+                else:
+                    self.update_property(k, v)
+
+        except ValidationError as e:
+            logging.critical("MQTT: Cannot parse %s", e)
+
+    def process_sensor(self, payload: str):
+        sensor_data = json.loads(payload)
+        if "StatusSNS" in sensor_data:
+            sensor_data = sensor_data["StatusSNS"]
+        self.t = sensor_data
+        self.update_telemetry.emit()
+
+    def process_message(self, msg: Message):
+        if self.debug:
+            logging.debug("MQTT: %s %s", msg.topic, msg.payload)
+
+        if msg.prefix in (self.topic_prefixes.stat, self.topic_prefixes.tele):
+            # /STATE or /STATUS<x> response
+            if status_parse_schema := STATUS_SCHEMA_MAP.get(msg.endpoint):
+                self.process_status(status_parse_schema, msg.dict())
+
+            # /STATUS8, /STATUS10, /SENSOR are fully dynamic and parsed as-is
+            elif msg.endpoint in ('STATUS8', 'STATUS10', 'SENSOR'):
+                self.process_sensor(msg.payload)
+
+            # /LOGGING response
+            elif msg.endpoint == "LOGGING":
+                self._process_logging(msg)
+
+            # /RESULT or SetOption4
+            else:
+                self._process_result(msg)
 
     def power(self):
         power_dict = {k: v for k, v in self.p.items() if k.startswith("POWER")}
@@ -322,22 +340,6 @@ def shutter_positions(self) -> dict:
         x = {k: self.p[f"Shutter{k}"] for k in range(1, 5) if f"Shutter{k}" in self.p}
         return x
 
-    def pulsetime(self):
-        ptime = {}
-        for k, v in self.p.items():
-            if k.startswith("PulseTime"):
-                val = 0
-                if isinstance(v, dict):
-                    first_key = list(v.keys())[0]
-                    if first_key == "Set":
-                        val = v["Set"]
-                    else:
-                        val = first_key
-                elif isinstance(v, str):
-                    val = v.split(" ")[0]
-                ptime[k] = int(val)
-        return ptime
-
     def pwm(self):
         return {
             k: v
@@ -379,11 +381,17 @@ def setoption(self, o):
 
     @property
     def name(self):
-        return self.p.get("DeviceName") or self.p.get("FriendlyName1", self.p["Topic"])
+        if name := self.p.get("DeviceName"):
+            return name
+        if fnl := self.p.get("FriendlyName"):
+            return fnl[0]
+        if fn1 := self.p.get("FriendlyName1"):
+            return fn1
+        return self.p["Topic"]
 
     @property
     def is_online(self):
-        return self.p.get("LWT", "Offline") == 'Online'
+        return self.p.get("LWT", self.p["Offline"]) == self.p["Online"]
 
     @property
     def url(self):
@@ -400,4 +408,4 @@ def version_above(self, target_version: str):
         return (version := self.version()) and version >= parse_version(target_version) or False
 
     def __repr__(self):
-        return f"<TasmotaDevice {self.name}: {self.p['Topic']}>"
+        return f"<TasmotaDevice {self.name}: {self.p['FullTopic']}>"
diff --git a/tdmgr/util/discovery.py b/tdmgr/util/discovery.py
new file mode 100644
index 0000000..e5a192e
--- /dev/null
+++ b/tdmgr/util/discovery.py
@@ -0,0 +1,30 @@
+import logging
+
+from tdmgr.util import Message, TasmotaDevice, TasmotaEnvironment
+
+
+def native_discovery():
+    pass
+
+
+def lwt_discovery_stage1():
+    pass
+
+
+def lwt_discovery_stage2(env: TasmotaEnvironment, message: Message):
+    if full_topic := message.dict().get("FullTopic"):
+        # the device replies with its FullTopic
+        # here the Topic is extracted using the returned FullTopic, identifying the device
+        if match := message.match_fulltopic(full_topic):
+            _match_topic = match.groupdict()["topic"]
+
+            if d := env.find_device(topic=_match_topic):
+                d.update_property("FullTopic", full_topic)
+
+            else:
+                logging.info(
+                    "DISCOVERY(LEGACY): Discovered topic=%s with fulltopic=%s",
+                    match["topic"],
+                    full_topic,
+                )
+                return TasmotaDevice(_match_topic, full_topic)
diff --git a/tdmgr/util/mqtt.py b/tdmgr/util/mqtt.py
index be31c08..94541fe 100644
--- a/tdmgr/util/mqtt.py
+++ b/tdmgr/util/mqtt.py
@@ -1,13 +1,90 @@
+import json
 import logging
+import re
 import socket
 import ssl
+from datetime import datetime
+from functools import lru_cache
+from json import JSONDecodeError
+from typing import Match, Union
 
 import paho.mqtt.client as mqtt
-from PyQt5 import QtCore
-from PyQt5.QtCore import pyqtSlot
+from PyQt5.QtCore import QObject, pyqtProperty, pyqtSignal, pyqtSlot
+
+MQTT_PATH_REGEX = "[^+#*>$/]+"  # all symbols accepted except forbidden by MQTT topic spec
+
+DEFAULT_PREFIXES = ["tele", "stat"]
+
+DEFAULT_PATTERNS = [
+    "%prefix%/%topic%/",  # = %prefix%/%topic% (Tasmota default)
+    "%topic%/%prefix%/",  # = %topic%/%prefix% (Tasmota with SetOption19 enabled for
+    # HomeAssistant AutoDiscovery)
+]
+
+
+def expand_fulltopic(fulltopic):
+    if fulltopic[-1] != '/':
+        fulltopic = f"{fulltopic}/"
+    fulltopics = []
+    for prefix in DEFAULT_PREFIXES:
+        topic = (
+            fulltopic.replace("%prefix%", prefix).replace("%topic%", "+") + "#"
+        )  # expand prefix and topic
+        topic = topic.replace("+/#", "#")  # simplify wildcards
+        fulltopics.append(topic)
+    return fulltopics
+
+
+class Message:
+    def __init__(self, topic: str, payload: bytes = b"", retained: bool = False, **kwargs):
+        self.topic: str = topic
+        self.prefix: str = kwargs.get("prefix", "")
+        self.payload: str = payload.decode('utf-8')
+        self.retained: bool = retained
+        self.payload: Union[dict, str]
+        self.timestamp: datetime = datetime.now()
+
+    @property
+    def endpoint(self) -> str:
+        return self.topic.split('/')[-1]
+
+    @property
+    def is_lwt(self) -> bool:
+        return self.endpoint == "LWT"
+
+    @property
+    def is_result(self) -> bool:
+        return self.endpoint == "RESULT"
+
+    @property
+    def first_key(self) -> str:
+        return next(iter(self.dict().keys())) if self.dict() else ""
+
+    @lru_cache
+    def dict(self) -> dict:
+        if self.payload.startswith("{"):
+            try:
+                return json.loads(self.payload)
+            except JSONDecodeError as e:
+                logging.critical("MQTT: Cannot parse %s: %s (%s)", self.endpoint, self.payload, e)
+        return {}
+
+    def match_fulltopic(self, pattern: str) -> Union[Match, None]:
+        _replaced_pattern = (
+            pattern.replace("+", f"({MQTT_PATH_REGEX})")
+            .replace("%topic%", f"(?P<topic>{MQTT_PATH_REGEX})")
+            .replace("%prefix%", f"(?P<prefix>{MQTT_PATH_REGEX})")
+        )
+        _match = re.fullmatch(
+            f"^{_replaced_pattern}{MQTT_PATH_REGEX}$",
+            self.topic,
+        )
+        if _match:
+            self.prefix = _match.groupdict()["prefix"]
+        return _match
 
 
-class MqttClient(QtCore.QObject):
+class MqttClient(QObject):
     Disconnected = 0
     Connecting = 1
     Connected = 2
@@ -15,19 +92,19 @@ class MqttClient(QtCore.QObject):
     MQTT_3_1 = mqtt.MQTTv31
     MQTT_3_1_1 = mqtt.MQTTv311
 
-    connected = QtCore.pyqtSignal()
-    connecting = QtCore.pyqtSignal()
-    connectError = QtCore.pyqtSignal(int)
-    disconnected = QtCore.pyqtSignal()
+    connected = pyqtSignal()
+    connecting = pyqtSignal()
+    connectError = pyqtSignal(int)
+    disconnected = pyqtSignal()
 
-    stateChanged = QtCore.pyqtSignal(int)
-    hostnameChanged = QtCore.pyqtSignal(str)
-    portChanged = QtCore.pyqtSignal(int)
-    keepAliveChanged = QtCore.pyqtSignal(int)
-    cleanSessionChanged = QtCore.pyqtSignal(bool)
-    protocolVersionChanged = QtCore.pyqtSignal(int)
+    stateChanged = pyqtSignal(int)
+    hostnameChanged = pyqtSignal(str)
+    portChanged = pyqtSignal(int)
+    keepAliveChanged = pyqtSignal(int)
+    cleanSessionChanged = pyqtSignal(bool)
+    protocolVersionChanged = pyqtSignal(int)
 
-    messageSignal = QtCore.pyqtSignal(str, str, bool)
+    messageSignal = pyqtSignal(Message)
 
     def __init__(self, parent=None):
         super(MqttClient, self).__init__(parent)
@@ -53,7 +130,7 @@ def __init__(self, parent=None):
         self.m_client.on_message = self.on_message
         self.m_client.on_disconnect = self.on_disconnect
 
-    @QtCore.pyqtProperty(int, notify=stateChanged)
+    @pyqtProperty(int, notify=stateChanged)
     def state(self):
         return self.m_state
 
@@ -64,7 +141,7 @@ def state(self, state):
         self.m_state = state
         self.stateChanged.emit(state)
 
-    @QtCore.pyqtProperty(str, notify=hostnameChanged)
+    @pyqtProperty(str, notify=hostnameChanged)
     def hostname(self):
         return self.m_hostname
 
@@ -75,7 +152,7 @@ def hostname(self, hostname):
         self.m_hostname = hostname
         self.hostnameChanged.emit(hostname)
 
-    @QtCore.pyqtProperty(int, notify=portChanged)
+    @pyqtProperty(int, notify=portChanged)
     def port(self):
         return self.m_port
 
@@ -89,7 +166,7 @@ def port(self, port):
     def setAuth(self, username, password):
         self.m_client.username_pw_set(username, password)
 
-    @QtCore.pyqtProperty(int, notify=keepAliveChanged)
+    @pyqtProperty(int, notify=keepAliveChanged)
     def keepAlive(self):
         return self.m_keepAlive
 
@@ -100,7 +177,7 @@ def keepAlive(self, keepAlive):
         self.m_keepAlive = keepAlive
         self.keepAliveChanged.emit(keepAlive)
 
-    @QtCore.pyqtProperty(bool, notify=cleanSessionChanged)
+    @pyqtProperty(bool, notify=cleanSessionChanged)
     def cleanSession(self):
         return self.m_cleanSession
 
@@ -111,7 +188,7 @@ def cleanSession(self, cleanSession):
         self.m_cleanSession = cleanSession
         self.cleanSessionChanged.emit(cleanSession)
 
-    @QtCore.pyqtProperty(int, notify=protocolVersionChanged)
+    @pyqtProperty(int, notify=protocolVersionChanged)
     def protocolVersion(self):
         return self.m_protocolVersion
 
@@ -124,7 +201,7 @@ def protocolVersion(self, protocolVersion):
             self.protocolVersionChanged.emit(protocolVersion)
 
     #################################################################
-    @QtCore.pyqtSlot()
+    @pyqtSlot()
     def connectToHost(self):
         if self.m_hostname:
             self.connecting.emit()
@@ -146,18 +223,18 @@ def connectToHost(self):
             except socket.timeout:
                 self.connectError.emit(3)
 
-    @QtCore.pyqtSlot()
+    @pyqtSlot()
     def setSSL(self, broker_tls_file, broker_tls_insecure, broker_tls_version):
         self.ssl = True
         self.m_tls_insecure = broker_tls_insecure
         self.m_tls_version = broker_tls_version
         self.m_cert_file = broker_tls_file
 
-    @QtCore.pyqtSlot()
+    @pyqtSlot()
     def unsetSSL(self):
         self.ssl = False
 
-    @QtCore.pyqtSlot()
+    @pyqtSlot()
     def disconnectFromHost(self):
         self.m_client.loop_stop()
         self.m_client.disconnect()
@@ -175,11 +252,9 @@ def publish(self, topic, payload=None, qos=0, retain=False):
     #################################################################
     # callbacks
     def on_message(self, mqttc, obj, msg):
-        topic = msg.topic
         try:
-            mstr = msg.payload.decode("utf8")
-            retained = msg.retain
-            self.messageSignal.emit(topic, mstr, retained)
+            message = Message(msg.topic, msg.payload, msg.retain)
+            self.messageSignal.emit(message)
         except UnicodeDecodeError as e:
             logging.error(
                 "MQTT MESSAGE DECODE ERROR: %s (%s=%s)", e, msg.topic, msg.payload.__repr__()


Test patch:
diff --git a/tests/__init__.py b/tests/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/tests/conftest.py b/tests/conftest.py
new file mode 100644
index 0000000..9e42777
--- /dev/null
+++ b/tests/conftest.py
@@ -0,0 +1,16 @@
+import pytest
+
+from tdmgr.util import Message, TasmotaDevice
+
+
+@pytest.fixture
+def device(request):
+    topic = "device"
+    if request.param:
+        topic = request.param
+    yield TasmotaDevice(topic)
+
+
+@pytest.fixture
+def state_message(request, device):
+    yield Message(f"tele/{device.p['Topic']}/STATE", request.param, prefix="stat")
diff --git a/tests/status_parsing/__init__.py b/tests/status_parsing/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/tests/status_parsing/jsonfiles/12.2.0.2/STATUS.json b/tests/status_parsing/jsonfiles/12.2.0.2/STATUS.json
new file mode 100644
index 0000000..ce8cfa6
--- /dev/null
+++ b/tests/status_parsing/jsonfiles/12.2.0.2/STATUS.json
@@ -0,0 +1,1 @@
+{"Status":{"Module":6,"DeviceName":"Tasmota","FriendlyName":["Tasmota"],"Topic":"tasmota_1A704F","ButtonTopic":"0","Power":1,"PowerOnState":3,"LedState":1,"LedMask":"FFFF","SaveData":1,"SaveState":1,"SwitchTopic":"0","SwitchMode":[0,0,0,0,0,0,0,0],"ButtonRetain":0,"SwitchRetain":0,"SensorRetain":0,"PowerRetain":0,"InfoRetain":0,"StateRetain":0,"StatusRetain":0}}
\ No newline at end of file
diff --git a/tests/status_parsing/jsonfiles/12.2.0.2/STATUS1.json b/tests/status_parsing/jsonfiles/12.2.0.2/STATUS1.json
new file mode 100644
index 0000000..a7c6a6c
--- /dev/null
+++ b/tests/status_parsing/jsonfiles/12.2.0.2/STATUS1.json
@@ -0,0 +1,1 @@
+{"StatusPRM":{"Baudrate":115200,"SerialConfig":"8N1","GroupTopic":"tasmotas","OtaUrl":"http://ota.tasmota.com/tasmota/release/tasmota.bin.gz","RestartReason":"Power On","Uptime":"94T04:19:39","StartupUTC":"2023-10-09T15:15:19","Sleep":50,"CfgHolder":4617,"BootCount":12,"BCResetTime":"2022-11-05T18:47:52","SaveCount":896,"SaveAddress":"FB000"}}
diff --git a/tests/status_parsing/jsonfiles/12.2.0.2/STATUS2.json b/tests/status_parsing/jsonfiles/12.2.0.2/STATUS2.json
new file mode 100644
index 0000000..6aa5d7f
--- /dev/null
+++ b/tests/status_parsing/jsonfiles/12.2.0.2/STATUS2.json
@@ -0,0 +1,1 @@
+{"StatusFWR":{"Version":"12.2.0.2(tasmota)","BuildDateTime":"2022-11-03T16:54:39","Boot":31,"Core":"2_7_4_9","SDK":"2.2.2-dev(38a443e)","CpuFrequency":80,"Hardware":"ESP8266EX","CR":"369/699"}}
diff --git a/tests/status_parsing/jsonfiles/6.3.0.12/STATE.json b/tests/status_parsing/jsonfiles/6.3.0.12/STATE.json
new file mode 100644
index 0000000..95cee06
--- /dev/null
+++ b/tests/status_parsing/jsonfiles/6.3.0.12/STATE.json
@@ -0,0 +1,1 @@
+{"Time":"2024-01-12T06:29:01","Uptime":"2T05:25:48","Vcc":3.258,"POWER1":"OFF","Wifi":{"AP":1,"SSId":"xxx","BSSId":"F0:9F:C2:03:CD:73","Channel":6,"RSSI":100}}
diff --git a/tests/status_parsing/jsonfiles/6.3.0.12/STATUS.json b/tests/status_parsing/jsonfiles/6.3.0.12/STATUS.json
new file mode 100644
index 0000000..70f2201
--- /dev/null
+++ b/tests/status_parsing/jsonfiles/6.3.0.12/STATUS.json
@@ -0,0 +1,1 @@
+{"Status":{"Module":1,"FriendlyName":["Sonoff_1"],"Topic":"sonoff_1","ButtonTopic":"0","Power":1,"PowerOnState":3,"LedState":1,"SaveData":1,"SaveState":1,"SwitchTopic":"0","SwitchMode":[0,0,0,0,0,0,0,0],"ButtonRetain":0,"SwitchRetain":0,"SensorRetain":0,"PowerRetain":0}}
\ No newline at end of file
diff --git a/tests/status_parsing/jsonfiles/6.3.0.12/STATUS1.json b/tests/status_parsing/jsonfiles/6.3.0.12/STATUS1.json
new file mode 100644
index 0000000..955c8f1
--- /dev/null
+++ b/tests/status_parsing/jsonfiles/6.3.0.12/STATUS1.json
@@ -0,0 +1,1 @@
+{"StatusPRM":{"Baudrate":115200,"GroupTopic":"sonoffs","OtaUrl":"http://sonoff.maddox.co.uk/tasmota/sonoff.ino.bin","RestartReason":"Hardware Watchdog","Uptime":"1T19:36:45","StartupUTC":"2024-01-10T00:03:13","Sleep":0,"BootCount":420,"SaveCount":10693,"SaveAddress":"F6000"}}
diff --git a/tests/status_parsing/jsonfiles/6.3.0.12/STATUS10.json b/tests/status_parsing/jsonfiles/6.3.0.12/STATUS10.json
new file mode 100644
index 0000000..91cf7a4
--- /dev/null
+++ b/tests/status_parsing/jsonfiles/6.3.0.12/STATUS10.json
@@ -0,0 +1,1 @@
+{"StatusSNS":{"Time":"2024-01-11T20:39:58","AM2301":{"Temperature":23.9,"Humidity":24.4},"TempUnit":"C"}}
diff --git a/tests/status_parsing/jsonfiles/6.3.0.12/STATUS11.json b/tests/status_parsing/jsonfiles/6.3.0.12/STATUS11.json
new file mode 100644
index 0000000..1a9cf4d
--- /dev/null
+++ b/tests/status_parsing/jsonfiles/6.3.0.12/STATUS11.json
@@ -0,0 +1,1 @@
+{"StatusSTS":{"Time":"2024-01-11T20:39:58","Uptime":"1T19:36:45","Vcc":3.231,"POWER1":"ON","Wifi":{"AP":1,"SSId":"xxx","BSSId":"F0:9F:C2:03:CD:73","Channel":6,"RSSI":100}}}
diff --git a/tests/status_parsing/jsonfiles/6.3.0.12/STATUS2.json b/tests/status_parsing/jsonfiles/6.3.0.12/STATUS2.json
new file mode 100644
index 0000000..6c2039c
--- /dev/null
+++ b/tests/status_parsing/jsonfiles/6.3.0.12/STATUS2.json
@@ -0,0 +1,1 @@
+{"StatusFWR":{"Version":"6.3.0.12(sonoff)","BuildDateTime":"2018-11-23T14:07:50","Boot":4,"Core":"2_3_0","SDK":"1.5.3(aec24ac9)"}}
diff --git a/tests/status_parsing/jsonfiles/6.3.0.12/STATUS3.json b/tests/status_parsing/jsonfiles/6.3.0.12/STATUS3.json
new file mode 100644
index 0000000..52e4b0f
--- /dev/null
+++ b/tests/status_parsing/jsonfiles/6.3.0.12/STATUS3.json
@@ -0,0 +1,1 @@
+{"StatusLOG":{"SerialLog":2,"WebLog":2,"SysLog":0,"LogHost":"192.168.0.130","LogPort":514,"SSId":["xxx","xxx-2"],"TelePeriod":300,"SetOption":["54000009","55818000","00000001"]}}
diff --git a/tests/status_parsing/jsonfiles/6.3.0.12/STATUS4.json b/tests/status_parsing/jsonfiles/6.3.0.12/STATUS4.json
new file mode 100644
index 0000000..4e16b31
--- /dev/null
+++ b/tests/status_parsing/jsonfiles/6.3.0.12/STATUS4.json
@@ -0,0 +1,1 @@
+{"StatusMEM":{"ProgramSize":498,"Free":504,"Heap":18,"ProgramFlashSize":1024,"FlashSize":1024,"FlashChipId":"1440E0","FlashMode":3,"Features":["00000809","0FDAE794","240383A0","23B617CE","00003BC0"]}}
diff --git a/tests/status_parsing/jsonfiles/6.3.0.12/STATUS5.json b/tests/status_parsing/jsonfiles/6.3.0.12/STATUS5.json
new file mode 100644
index 0000000..c3e5d28
--- /dev/null
+++ b/tests/status_parsing/jsonfiles/6.3.0.12/STATUS5.json
@@ -0,0 +1,1 @@
+{"StatusNET":{"Hostname":"sonoff_1-3937","IPAddress":"192.168.1.200","Gateway":"192.168.1.1","Subnetmask":"255.255.255.0","DNSServer":"192.168.1.130","Mac":"5C:CF:7F:E7:4F:61","Webserver":2,"WifiConfig":3}}
diff --git a/tests/status_parsing/jsonfiles/6.3.0.12/STATUS6.json b/tests/status_parsing/jsonfiles/6.3.0.12/STATUS6.json
new file mode 100644
index 0000000..65eac83
--- /dev/null
+++ b/tests/status_parsing/jsonfiles/6.3.0.12/STATUS6.json
@@ -0,0 +1,1 @@
+{"StatusMQT":{"MqttHost":"192.168.1.130","MqttPort":1883,"MqttClientMask":"DVES_%06X","MqttClient":"DVES_E74F61","MqttUser":"DVES_USER","MqttType":1,"MAX_PACKET_SIZE":1000,"KEEPALIVE":15}}
diff --git a/tests/status_parsing/jsonfiles/6.3.0.12/STATUS7.json b/tests/status_parsing/jsonfiles/6.3.0.12/STATUS7.json
new file mode 100644
index 0000000..a36d56b
--- /dev/null
+++ b/tests/status_parsing/jsonfiles/6.3.0.12/STATUS7.json
@@ -0,0 +1,1 @@
+{"StatusTIM":{"UTC":"Thu Jan 11 19:39:58 2024","Local":"Thu Jan 11 20:39:58 2024","StartDST":"Sun Mar 31 02:00:00 2024","EndDST":"Sun Oct 27 03:00:00 2024","Timezone":"+01:00","Sunrise":"08:41","Sunset":"17:14"}}
diff --git a/tests/status_parsing/jsonfiles/7.0.0.4/STATE.json b/tests/status_parsing/jsonfiles/7.0.0.4/STATE.json
new file mode 100644
index 0000000..27e500d
--- /dev/null
+++ b/tests/status_parsing/jsonfiles/7.0.0.4/STATE.json
@@ -0,0 +1,1 @@
+{"Time":"2024-01-11T11:42:21","Uptime":"44T15:13:31","UptimeSec":3856411,"Heap":26,"SleepMode":"Dynamic","Sleep":50,"LoadAvg":19,"MqttCount":12,"Wifi":{"AP":1,"SSId":"asdqwe","BSSId":"AA:BB:CC:DD:EE:FF","Channel":6,"RSSI":66,"LinkCount":19,"Downtime":"0T02:15:08"}}
\ No newline at end of file
diff --git a/tests/status_parsing/test_status_schemas.py b/tests/status_parsing/test_status_schemas.py
new file mode 100644
index 0000000..86995b5
--- /dev/null
+++ b/tests/status_parsing/test_status_schemas.py
@@ -0,0 +1,31 @@
+import os
+
+import pytest
+from pydantic import ValidationError
+
+from tdmgr.schemas.status import STATUS_SCHEMA_MAP
+
+PATH = os.path.join('status_parsing', 'jsonfiles')
+
+
+def get_status_jsonfiles():
+    _files = []
+    for _, dirs, _ in os.walk(PATH):
+        for dir in dirs:
+            for _, _, files in os.walk(os.path.join(PATH, dir)):
+                for file in files:
+                    _files.append(os.path.join(PATH, dir, file))
+    return _files
+
+
+@pytest.mark.skip(reason="WIP")
+@pytest.mark.parametrize("jsonfile", get_status_jsonfiles())
+def test_status_parsing(jsonfile):
+    status_type = jsonfile.split(os.path.sep)[-1].split('.')[0]
+    schema = STATUS_SCHEMA_MAP.get(status_type)
+
+    with open(jsonfile, "r") as payload:
+        try:
+            schema.model_validate_json(payload.read())
+        except ValidationError as e:
+            pytest.fail(str(e))
diff --git a/tests/test_device.py b/tests/test_device.py
new file mode 100644
index 0000000..18cedce
--- /dev/null
+++ b/tests/test_device.py
@@ -0,0 +1,29 @@
+import pytest
+
+from tdmgr.util import Message, TasmotaDevice
+
+
+@pytest.mark.parametrize(
+    "fulltopic, topic, expected",
+    [
+        ("%prefix%/%topic%/", "tele/some_topic/SENSOR", True),
+        ("%topic%/%prefix%/", "tele/some_topic/SENSOR", False),
+        ("%prefix%/office/%topic%/", "tele/office/some_topic/SENSOR", True),
+        ("%prefix%/%topic%/", "tele/office/some_topic/SENSOR", False),
+    ],
+)
+def test_matches(fulltopic, topic, expected):
+    device = TasmotaDevice("some_topic", fulltopic)
+    assert device.matches(topic) is expected
+
+
+@pytest.mark.parametrize("topic", ["stat/device/RESULT", "stat/device/TEMPLATE"])
+def test_process_template(topic):
+    device = TasmotaDevice("device")
+    message = Message(topic, b'{"NAME":"NodeMCU","GPIO":[1],"FLAG":0,"BASE":18}')
+    message.prefix = "stat"
+
+    device.process_message(message)
+
+    assert message.first_key == "NAME"
+    assert device.p.get("Template") == "NodeMCU"
diff --git a/tests/test_environment.py b/tests/test_environment.py
new file mode 100644
index 0000000..30906c8
--- /dev/null
+++ b/tests/test_environment.py
@@ -0,0 +1,24 @@
+import pytest
+
+from tdmgr.util import TasmotaDevice, TasmotaEnvironment
+
+
+@pytest.mark.parametrize(
+    "full_topic, test_topic, expected",
+    [
+        ("%prefix%/%topic%/", "tele/device/POWER", True),
+        ("%prefix%/%topic%/", "stat/device/POWER", True),
+        ("office/%prefix%/%topic%/", "office/stat/device/POWER", True),
+        ("office/%prefix%/%topic%/", "stat/device/POWER", False),
+        ("%prefix%/office/%topic%/", "stat/office/device/POWER", True),
+        ("%prefix%/office/%topic%/", "office/stat/device/POWER", False),
+    ],
+)
+def test_find_device(full_topic, test_topic, expected):
+    env = TasmotaEnvironment()
+    dev = TasmotaDevice(topic='device', fulltopic=full_topic)
+    env.devices.append(dev)
+
+    device = env.find_device(test_topic)
+
+    assert (device == dev) is expected
diff --git a/tests/test_message.py b/tests/test_message.py
new file mode 100644
index 0000000..4e6876d
--- /dev/null
+++ b/tests/test_message.py
@@ -0,0 +1,66 @@
+import pytest
+
+from tdmgr.util import Message
+
+
+def test_message():
+    message = Message("some/topic", b"some_payload", True)
+
+    assert message.topic == "some/topic"
+    assert message.payload == "some_payload"
+    assert message.retained
+    assert not message.is_lwt
+
+
+def test_message_is_lwt():
+    message = Message("office/device/LWT")
+    assert message.is_lwt
+
+
+@pytest.mark.parametrize(
+    "topic, endpoint",
+    [
+        ("some/endpoint", "endpoint"),
+        ("office/stat/device/RESULT", "RESULT"),
+    ],
+)
+def test_message_endpoint(topic, endpoint):
+    message = Message(topic)
+    assert message.endpoint == endpoint
+
+
+@pytest.mark.parametrize(
+    "pattern, topic",
+    [
+        ("%prefix%/%topic%/", "cmnd/topic/POWER"),
+        ("%topic%/%prefix%/", "topic/cmnd/POWER"),
+        ("%prefix%/tasmota/%topic%/", "cmnd/tasmota/topic/POWER"),
+        ("+/%prefix%/%topic%/", "office/cmnd/topic/POWER"),
+        ("+/%prefix%/+/%topic%/", "basement/cmnd/office/topic/POWER"),
+        ("+/+/%prefix%/+/%topic%/", "basement/wine_room/cmnd/fans/topic/POWER1"),
+    ],
+)
+def test_topic_matches_pattern(pattern, topic):
+    message = Message(topic)
+    result = message.match_fulltopic(pattern)
+
+    assert result
+    result_groupdict = result.groupdict()
+
+    assert "prefix" in result_groupdict
+    assert "topic" in result_groupdict
+    assert result_groupdict["prefix"] == "cmnd"
+    assert result_groupdict["topic"] == "topic"
+
+
+def test_message_dict():
+    message = Message("topic", b'{"key": "value", "second_key": 42}')
+
+    result = message.dict()
+    assert "key" in result
+    assert result["second_key"] == 42
+
+
+def test_message_dict_failed(caplog):
+    message = Message("topic/RESULT", b"not json")
+    assert message.dict() == {}
diff --git a/tests/test_mqtt.py b/tests/test_mqtt.py
new file mode 100644
index 0000000..8bbf5bc
--- /dev/null
+++ b/tests/test_mqtt.py
@@ -0,0 +1,18 @@
+import pytest
+
+from tdmgr.util.mqtt import expand_fulltopic
+
+
+@pytest.mark.parametrize(
+    "fulltopic, expected",
+    [
+        ("%prefix%/%topic%/", ['tele/#', 'stat/#']),
+        ("%prefix%/%topic%", ['tele/#', 'stat/#']),
+        ("+/%prefix%/%topic%/", ['+/tele/#', '+/stat/#']),
+        ("+/%prefix%/%topic%", ['+/tele/#', '+/stat/#']),
+    ],
+)
+def test_expand_fulltopic(fulltopic, expected):
+    result = expand_fulltopic(fulltopic)
+
+    assert result == expected