diff --git a/.gitignore b/.gitignore index 247d79a..3be3209 100644 --- a/.gitignore +++ b/.gitignore @@ -100,6 +100,8 @@ celerybeat-schedule .idea/ tdm.cfg +tdm.ini devices.cfg +devices.ini tdm*.log __version__.py diff --git a/tdmgr/GUI/console.py b/tdmgr/GUI/console.py index 48db278..0187065 100644 --- a/tdmgr/GUI/console.py +++ b/tdmgr/GUI/console.py @@ -250,7 +250,9 @@ def __init__(self, devices): self.lwCommands = QListWidget() vl.addElements( - gbxDevice, self.lwCommands, QLabel("Double-click a command to use it, ESC to close.") + gbxDevice, + self.lwCommands, + QLabel("Double-click a command to use it, ESC to close."), ) self.setLayout(vl) diff --git a/tdmgr/GUI/delegates/devices.py b/tdmgr/GUI/delegates/devices.py index 1022f03..c7b0f59 100644 --- a/tdmgr/GUI/delegates/devices.py +++ b/tdmgr/GUI/delegates/devices.py @@ -296,7 +296,12 @@ def sizeHint(self, option, index): return QStyledItemDelegate().sizeHint(option, index) def get_used_width(self, option, index) -> int: - return sum([self.get_devicename_width(option, index), self.get_alerts_width(option, index)]) + return sum( + [ + self.get_devicename_width(option, index), + self.get_alerts_width(option, index), + ] + ) @staticmethod def get_devicename_width(option, index) -> int: @@ -356,7 +361,10 @@ def paint(self, p: QPainter, option: QStyleOptionViewItem, index): alerts_width = self.get_alerts_width(option, index) exc_rect = QRect( - self.get_devicename_width(option, index), y, alerts_width, RECT_SIZE.height() + self.get_devicename_width(option, index), + y, + alerts_width, + RECT_SIZE.height(), ) if selected: diff --git a/tdmgr/GUI/devices.py b/tdmgr/GUI/devices.py index 9d975ef..3888c68 100644 --- a/tdmgr/GUI/devices.py +++ b/tdmgr/GUI/devices.py @@ -176,7 +176,10 @@ def create_actions(self): self.ctx_menu.addAction(QIcon(":/delete.png"), "Delete", self.ctx_menu_delete_device) self.agAllPower = QActionGroup(self) - for label, shortcut, fill in [("ON", "Ctrl+F1", True), ("OFF", "Ctrl+F2", False)]: + for label, shortcut, fill in [ + ("ON", "Ctrl+F1", True), + ("OFF", "Ctrl+F2", False), + ]: px = make_relay_pixmap(label, filled=fill) act = self.agAllPower.addAction(QIcon(px), f"All relays {label}") act.setShortcut(shortcut) @@ -278,7 +281,11 @@ def ctx_menu_restart(self): def ctx_menu_reset(self): if self.device: reset, ok = QInputDialog.getItem( - self, "Reset device and restart", "Select reset mode", resets, editable=False + self, + "Reset device and restart", + "Select reset mode", + resets, + editable=False, ) if ok: self.mqtt.publish(self.device.cmnd_topic("reset"), payload=reset.split(":")[0]) @@ -366,7 +373,7 @@ def select_device(self, idx): self.actColor.setEnabled(False) self.actChannels.setEnabled(False) if color := self.device.color(): - self.actColor.setEnabled(bool(color.hsbcolor and color.SO68 == 1)) + self.actColor.setEnabled(bool(color.hsbcolor) and color.SO68 == 0) self.actChannels.setEnabled(True) self.actChannels.menu().clear() diff --git a/tdmgr/GUI/dialogs/main.py b/tdmgr/GUI/dialogs/main.py index 560201f..dfd49e2 100644 --- a/tdmgr/GUI/dialogs/main.py +++ b/tdmgr/GUI/dialogs/main.py @@ -2,6 +2,7 @@ import logging import re +from paho.mqtt import MQTTException from PyQt5.QtCore import QDir, QFileInfo, QSettings, QSize, Qt, QTimer, QUrl, pyqtSlot from PyQt5.QtGui import QDesktopServices, QFont, QIcon from PyQt5.QtWidgets import ( @@ -15,7 +16,6 @@ QPushButton, QStatusBar, ) -from paho.mqtt import MQTTException from tdmgr.GUI.console import ConsoleWidget from tdmgr.GUI.devices import DevicesListWidget @@ -63,9 +63,13 @@ def __init__( self.menuBar().setNativeMenuBar(False) + self.mqtt = None + self.setup_mqtt() + self.unknown = [] self.custom_patterns = [] self.env = TasmotaEnvironment() + self.env.mqtt = self.mqtt self.device = None self.topics = [] @@ -88,8 +92,7 @@ def __init__( ) device.debug = self.devices.value("debug", False, bool) device.p["Mac"] = mac.replace("-", ":") - device.env = self.env - self.env.devices.append(device) + self.env.add_device(device) # load device command history self.devices.beginGroup("history") @@ -101,7 +104,6 @@ def __init__( self.device_model = TasmotaDevicesModel(self.settings, self.devices, self.env) - self.setup_mqtt() self.setup_main_layout() self.add_devices_tab() self.build_mainmenu() @@ -186,7 +188,9 @@ def build_mainmenu(self): def build_toolbars(self): main_toolbar = Toolbar( - orientation=Qt.Horizontal, iconsize=24, label_position=Qt.ToolButtonTextBesideIcon + orientation=Qt.Horizontal, + iconsize=24, + label_position=Qt.ToolButtonTextBesideIcon, ) main_toolbar.setObjectName("main_toolbar") @@ -304,7 +308,8 @@ def mqtt_subscribe(self): # the custom patterns for custom_pattern in self.custom_patterns: custom_pattern_match = re.match( - custom_pattern.replace("+", f"({MQTT_PATH_REGEX})"), d.p["FullTopic"] + 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. @@ -428,7 +433,7 @@ def mqtt_message(self, msg: Message): 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.env.add_device(d) self.device_model.addDevice(d) log.debug("DISCOVERY: Sending initial query to topic %s", d.p["Topic"]) self.initial_query(d, True) @@ -438,7 +443,10 @@ def mqtt_message(self, msg: Message): def export(self): fname, _ = QFileDialog.getSaveFileName( - self, "Export device list as...", directory=QDir.homePath(), filter="CSV files (*.csv)" + self, + "Export device list as...", + directory=QDir.homePath(), + filter="CSV files (*.csv)", ) if fname: if not fname.endswith(".csv"): @@ -564,7 +572,9 @@ def openTelemetry(self): self.mqtt_publish(self.device.cmnd_topic("STATUS"), "8") self.tele_docks.append(tele_widget) self.resizeDocks( - self.tele_docks, [100 // len(self.tele_docks) for _ in self.tele_docks], Qt.Vertical + self.tele_docks, + [100 // len(self.tele_docks) for _ in self.tele_docks], + Qt.Vertical, ) @pyqtSlot() @@ -577,7 +587,9 @@ def openConsole(self): console_widget.command.setFocus() self.consoles.append(console_widget) self.resizeDocks( - self.consoles, [100 // len(self.consoles) for _ in self.consoles], Qt.Horizontal + self.consoles, + [100 // len(self.consoles) for _ in self.consoles], + Qt.Horizontal, ) @pyqtSlot() diff --git a/tdmgr/GUI/dialogs/timers.py b/tdmgr/GUI/dialogs/timers.py index f937119..914b504 100644 --- a/tdmgr/GUI/dialogs/timers.py +++ b/tdmgr/GUI/dialogs/timers.py @@ -103,7 +103,12 @@ def __init__(self, device, *args, **kwargs): hl_tmr_time.addElements(self.cbxTimerPM, self.teTimerTime, lbWnd, self.cbxTimerWnd) self.gbTimers.addElements( - self.cbTimer, hl_tmr_arm_rpt, hl_tmr_out_act, gbTimerMode, hl_tmr_time, hl_tmr_days + self.cbTimer, + hl_tmr_arm_rpt, + hl_tmr_out_act, + gbTimerMode, + hl_tmr_time, + hl_tmr_days, ) btns = QDialogButtonBox(QDialogButtonBox.Save | QDialogButtonBox.Close) @@ -159,7 +164,11 @@ def loadTimer(self, timer=""): def describeTimer(self): if self.cbTimerArm.isChecked(): - desc = {"days": "", "repeat": "", "timer": self.cbTimer.currentText().upper()} + desc = { + "days": "", + "repeat": "", + "timer": self.cbTimer.currentText().upper(), + } repeat = self.cbTimerRpt.isChecked() out = self.cbxTimerOut.currentText() act = self.cbxTimerAction.currentText() diff --git a/tdmgr/GUI/rules.py b/tdmgr/GUI/rules.py index 7ca4a0b..cc4dbf0 100644 --- a/tdmgr/GUI/rules.py +++ b/tdmgr/GUI/rules.py @@ -163,7 +163,10 @@ def load_rule_from_file(self): def save_to_file(self): new_fname = f"{self.device.name} {self.cbRule.currentText()}.txt" file, ok = QFileDialog.getSaveFileName( - self, "Save rule", os.path.join(QDir.homePath(), new_fname), "Text files | *.txt" + self, + "Save rule", + os.path.join(QDir.homePath(), new_fname), + "Text files | *.txt", ) if ok: with open(file, "w") as f: @@ -262,7 +265,11 @@ def display_rule(self, payload, rule): self.actStopOnError.setChecked(payload["StopOnError"] == "ON") def unfold_rule(self, rules: str): - for pat, repl in [(r" on ", "\non "), (r" do ", " do\n\t"), (r" endon", "\nendon ")]: + for pat, repl in [ + (r" on ", "\non "), + (r" do ", " do\n\t"), + (r" endon", "\nendon "), + ]: rules = re.sub(pat, repl, rules, flags=re.IGNORECASE) return rules.rstrip(" ") diff --git a/tdmgr/GUI/widgets.py b/tdmgr/GUI/widgets.py index d3b6550..e80278c 100644 --- a/tdmgr/GUI/widgets.py +++ b/tdmgr/GUI/widgets.py @@ -146,7 +146,12 @@ def addElements(self, *elements): class GroupBoxV(GroupBoxBase): def __init__( - self, title: str, margin: Union[int, List[int]] = 3, spacing: int = 3, *args, **kwargs + self, + title: str, + margin: Union[int, List[int]] = 3, + spacing: int = 3, + *args, + **kwargs, ): super(GroupBoxV, self).__init__(title, *args, **kwargs) @@ -156,7 +161,12 @@ def __init__( class GroupBoxH(GroupBoxBase): def __init__( - self, title: str, margin: Union[int, List[int]] = 3, spacing: int = 3, *args, **kwargs + self, + title: str, + margin: Union[int, List[int]] = 3, + spacing: int = 3, + *args, + **kwargs, ): super(GroupBoxH, self).__init__(title, *args, **kwargs) @@ -387,7 +397,8 @@ def __init__(self, command, meta, value=None, *args, **kwargs): elif meta["type"] == "value": self.input = SpinBox( - minimum=int(meta["parameters"]["min"]), maximum=int(meta["parameters"]["max"]) + minimum=int(meta["parameters"]["min"]), + maximum=int(meta["parameters"]["max"]), ) self.input.setMinimumWidth(75) if value: @@ -538,7 +549,8 @@ def __init__(self, command: str, meta: dict, device: TasmotaDevice): for idx, value in enumerate(values, start=1): sb = SpinBox( - minimum=int(meta["parameters"]["min"]), maximum=int(meta["parameters"]["max"]) + minimum=int(meta["parameters"]["min"]), + maximum=int(meta["parameters"]["max"]), ) sb.setValue(value) hl_group = HLayout(0) diff --git a/tdmgr/mqtt.py b/tdmgr/mqtt.py index fe9f215..cb18db5 100644 --- a/tdmgr/mqtt.py +++ b/tdmgr/mqtt.py @@ -40,10 +40,6 @@ def initial_commands(): commands = [(command, "") for command in commands] commands += [("status", "0"), ("gpios", "255")] - for sht in range(8): - commands.append([f"shutterrelay{sht + 1}", ""]) - commands.append([f"shutterposition{sht + 1}", ""]) - return commands diff --git a/tdmgr/tasmota/commands.py b/tdmgr/tasmota/commands.py index a83998c..b3797e8 100644 --- a/tdmgr/tasmota/commands.py +++ b/tdmgr/tasmota/commands.py @@ -57,7 +57,10 @@ "0": {"description": "Keep relay(s) OFF after power up"}, "1": {"description": "Turn relay(s) ON after power up"}, "2": {"description": "Toggle relay(s) from last saved state"}, - "3": {"description": "Switch relay(s) to their last saved state", "default": "True"}, + "3": { + "description": "Switch relay(s) to their last saved state", + "default": "True", + }, "4": {"description": "Turn relay(s) ON and disable further relay control"}, "5": {"description": "Turn relay(s) ON after a PulseTime period"}, }, diff --git a/tdmgr/tasmota/device.py b/tdmgr/tasmota/device.py index cb19d0b..f13cf4b 100644 --- a/tdmgr/tasmota/device.py +++ b/tdmgr/tasmota/device.py @@ -17,7 +17,7 @@ ShutterResultSchema, TemplateResultSchema, ) -from tdmgr.schemas.status import STATUS_SCHEMA_MAP +from tdmgr.schemas.status import STATUS_SCHEMA_MAP, Status13ResponseSchema from tdmgr.tasmota.common import COMMAND_UNKNOWN, MAX_SHUTTERS, Color, DeviceProps, Relay, Shutter log = logging.getLogger(__name__) @@ -245,6 +245,17 @@ def process_status(self, schema: BaseModel, payload: dict): else: self.update_property(k, v) + if schema == Status13ResponseSchema: + if self.version_above("12.2.0.6"): # Support for single-response for all shutters + command = self.cmnd_topic("ShutterRelay") + payload = [] + else: + command = self.cmnd_topic("Backlog") + payload = [ + f"shutterrelay{sht + 1}" for sht in range(len(payload["StatusSHT"].keys())) + ] + self.env.mqtt.publish(command, ";".join(payload)) + except ValidationError as e: log.critical("MQTT: Cannot parse %s", e) @@ -356,7 +367,10 @@ def color(self): @property def ip_address(self) -> str: - for ip in [self.p.get("IPAddress"), self.p.get("Ethernet", {}).get("IPAddress")]: + for ip in [ + self.p.get("IPAddress"), + self.p.get("Ethernet", {}).get("IPAddress"), + ]: if ip != "0.0.0.0": return ip return "0.0.0.0" diff --git a/tdmgr/tasmota/environment.py b/tdmgr/tasmota/environment.py index 98e47a5..894e7b7 100644 --- a/tdmgr/tasmota/environment.py +++ b/tdmgr/tasmota/environment.py @@ -15,6 +15,11 @@ def __init__(self): self.devices: list[TasmotaDevice] = [] self.lwts = dict() self.retained = set() + self.mqtt = None + + def add_device(self, device: TasmotaDevice): + self.devices.append(device) + device.env = self def find_device(self, msg: Message) -> TasmotaDevice: for d in self.devices: diff --git a/tdmgr/tasmota/setoptions.py b/tdmgr/tasmota/setoptions.py index 12f5083..55b5fcb 100644 --- a/tdmgr/tasmota/setoptions.py +++ b/tdmgr/tasmota/setoptions.py @@ -27,7 +27,10 @@ "description": "Allow immediate action on single button press", "type": "select", "parameters": { - "0": {"description": "Single, multi-press and hold button actions", "default": "True"}, + "0": { + "description": "Single, multi-press and hold button actions", + "default": "True", + }, "1": {"description": "Only single press action for immediate response"}, }, },