From 6f40cfe9ba3c534502c499576761c803d663a242 Mon Sep 17 00:00:00 2001 From: Nick Cao Date: Thu, 24 Apr 2025 11:04:40 -0400 Subject: [PATCH] Implement TasmotaPower driver --- .../reference/package-apis/drivers/index.md | 2 + .../reference/package-apis/drivers/tasmota.md | 1 + packages/jumpstarter-driver-tasmota/README.md | 44 +++++++++ .../jumpstarter_driver_tasmota/__init__.py | 0 .../jumpstarter_driver_tasmota/driver.py | 85 ++++++++++++++++ .../jumpstarter_driver_tasmota/driver_test.py | 29 ++++++ .../jumpstarter_driver_tasmota/py.typed | 0 .../jumpstarter-driver-tasmota/pyproject.toml | 46 +++++++++ pyproject.toml | 2 + uv.lock | 96 +++++++++++++++++++ 10 files changed, 305 insertions(+) create mode 120000 docs/source/reference/package-apis/drivers/tasmota.md create mode 100644 packages/jumpstarter-driver-tasmota/README.md create mode 100644 packages/jumpstarter-driver-tasmota/jumpstarter_driver_tasmota/__init__.py create mode 100644 packages/jumpstarter-driver-tasmota/jumpstarter_driver_tasmota/driver.py create mode 100644 packages/jumpstarter-driver-tasmota/jumpstarter_driver_tasmota/driver_test.py create mode 100644 packages/jumpstarter-driver-tasmota/jumpstarter_driver_tasmota/py.typed create mode 100644 packages/jumpstarter-driver-tasmota/pyproject.toml diff --git a/docs/source/reference/package-apis/drivers/index.md b/docs/source/reference/package-apis/drivers/index.md index fe8a04a9a..1e5aebb7b 100644 --- a/docs/source/reference/package-apis/drivers/index.md +++ b/docs/source/reference/package-apis/drivers/index.md @@ -22,6 +22,7 @@ Drivers that control the power state and basic operation of devices: * **[DUT Link](dutlink.md)** (`jumpstarter-driver-dutlink`) - [DUT Link Board](https://github.com/jumpstarter-dev/dutlink-board) hardware control * **[Energenie PDU](energenie.md)** (`jumpstarter-driver-energenie`) - Energenie PDUs +* **[Tasmota](tasmota.md)** (`jumpstarter-driver-tasmota`) - Tasmota hardware control ### Communication Drivers @@ -93,6 +94,7 @@ raspberrypi.md sdwire.md shell.md snmp.md +tasmota.md tftp.md uboot.md ustreamer.md diff --git a/docs/source/reference/package-apis/drivers/tasmota.md b/docs/source/reference/package-apis/drivers/tasmota.md new file mode 120000 index 000000000..9c63e14c7 --- /dev/null +++ b/docs/source/reference/package-apis/drivers/tasmota.md @@ -0,0 +1 @@ +../../../../../packages/jumpstarter-driver-tasmota/README.md \ No newline at end of file diff --git a/packages/jumpstarter-driver-tasmota/README.md b/packages/jumpstarter-driver-tasmota/README.md new file mode 100644 index 000000000..84994909d --- /dev/null +++ b/packages/jumpstarter-driver-tasmota/README.md @@ -0,0 +1,44 @@ +# Tasmota driver + +`jumpstarter-driver-tasmota` provides functionality for interacting with tasmota compatible devices. + +## Installation + +```shell +pip3 install --extra-index-url https://pkg.jumpstarter.dev/simple/ jumpstarter-driver-tasmota +``` + +## Configuration + +Example configuration: + +```yaml +export: + power: + type: jumpstarter_driver_tasmota.driver.TasmotaPower +``` + +### Config parameters + +| Parameter | Description | Default | +|--------------|-----------------------------------------------------------------|----------| +| `host` | MQTT broker hostname or IP address | Required | +| `port` | MQTT broker port | 1883 | +| `tls` | MQTT broker TLS enabled | True | +| `client_id` | Client identifier for MQTT connection | | +| `transport` | Transport protocol, one of "tcp", "websockets", "unix" | "tcp" | +| `timeout` | Timeout in seconds for operations | | +| `username` | Username for MQTT authentication | | +| `password` | Password for MQTT authentication | | +| `cmnd_topic` | MQTT topic for sending commands to the Tasmota device | Required | +| `stat_topic` | MQTT topic for receiving status updates from the Tasmota device | Required | + +## API Reference + +The tasmota power driver provides a `PowerClient` with the following API: + +```{eval-rst} +.. autoclass:: jumpstarter_driver_power.client.PowerClient() + :no-index: + :members: on, off +``` diff --git a/packages/jumpstarter-driver-tasmota/jumpstarter_driver_tasmota/__init__.py b/packages/jumpstarter-driver-tasmota/jumpstarter_driver_tasmota/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/packages/jumpstarter-driver-tasmota/jumpstarter_driver_tasmota/driver.py b/packages/jumpstarter-driver-tasmota/jumpstarter_driver_tasmota/driver.py new file mode 100644 index 000000000..bca2cbda1 --- /dev/null +++ b/packages/jumpstarter-driver-tasmota/jumpstarter_driver_tasmota/driver.py @@ -0,0 +1,85 @@ +from dataclasses import dataclass, field +from threading import Condition +from typing import Literal + +import paho.mqtt.client as paho +from jumpstarter_driver_power.driver import PowerInterface +from paho.mqtt.enums import CallbackAPIVersion + +from jumpstarter.driver import Driver, export + + +@dataclass(kw_only=True) +class TasmotaPower(PowerInterface, Driver): + """driver for tasmota compatible power switches""" + + client_id: str | None = None + transport: Literal["tcp", "websockets", "unix"] = "tcp" + timeout: float | None = None + + host: str + port: int = 1883 + tls: bool = True + + username: str | None = None + password: str | None = None + + cmnd_topic: str + stat_topic: str + + mq: paho.Client = field(init=False) + state: str | None = field(init=False, default=None) + cond: Condition = field(init=False, default_factory=Condition) + + def __post_init__(self): + if hasattr(super(), "__post_init__"): + super().__post_init__() + + self.mq = paho.Client( + callback_api_version=CallbackAPIVersion.VERSION2, + client_id=self.client_id, + transport=self.transport, + ) + + def on_message(client, userdata, msg): + if msg.topic == self.stat_topic: + self.state = msg.payload.decode() + with self.cond: + self.cond.notify_all() + + self.mq.on_message = on_message + + if self.tls: + self.mq.tls_set() + + self.mq.username_pw_set(self.username, self.password) + self.mq.connect(self.host, self.port) + self.mq.loop_start() + + self.mq.subscribe(self.stat_topic) + + def publish(self, state): + self.mq.publish( + self.cmnd_topic, + payload=state, + qos=1, + ).wait_for_publish( + timeout=self.timeout, + ) + with self.cond: + self.cond.wait_for( + lambda: self.state == state, + timeout=self.timeout, + ) + + @export + def on(self): + self.publish("ON") + + @export + def off(self): + self.publish("OFF") + + @export + def read(self): + pass diff --git a/packages/jumpstarter-driver-tasmota/jumpstarter_driver_tasmota/driver_test.py b/packages/jumpstarter-driver-tasmota/jumpstarter_driver_tasmota/driver_test.py new file mode 100644 index 000000000..cf1a9992d --- /dev/null +++ b/packages/jumpstarter-driver-tasmota/jumpstarter_driver_tasmota/driver_test.py @@ -0,0 +1,29 @@ +import pytest +from pytest_mqtt.model import MqttMessage + +from .driver import TasmotaPower +from jumpstarter.common.utils import serve + + +@pytest.mark.skip("requires docker") +def test_tasmota_power(mosquitto, capmqtt): + cmnd_topic = "cmnd/tasmota_6990F2/POWER" + stat_topic = "stat/tasmota_6990F2/POWER" + + with serve( + TasmotaPower( + host=mosquitto[0], + port=int(mosquitto[1]), + tls=False, + transport="tcp", + cmnd_topic=cmnd_topic, + stat_topic=stat_topic, + ) + ) as client: + capmqtt.publish(topic=stat_topic, payload="ON") + client.on() + assert MqttMessage(topic=cmnd_topic, payload=b"ON", userdata=None) in capmqtt.messages + + capmqtt.publish(topic=stat_topic, payload="OFF") + client.off() + assert MqttMessage(topic=cmnd_topic, payload=b"OFF", userdata=None) in capmqtt.messages diff --git a/packages/jumpstarter-driver-tasmota/jumpstarter_driver_tasmota/py.typed b/packages/jumpstarter-driver-tasmota/jumpstarter_driver_tasmota/py.typed new file mode 100644 index 000000000..e69de29bb diff --git a/packages/jumpstarter-driver-tasmota/pyproject.toml b/packages/jumpstarter-driver-tasmota/pyproject.toml new file mode 100644 index 000000000..a8eda3dd8 --- /dev/null +++ b/packages/jumpstarter-driver-tasmota/pyproject.toml @@ -0,0 +1,46 @@ +[project] +name = "jumpstarter-driver-tasmota" +dynamic = ["version", "urls"] +description = "Jumpstarter driver for controlling Tasmota-compatible devices via MQTT" +readme = "README.md" +license = "Apache-2.0" +authors = [{ name = "Nick Cao", email = "nickcao@nichi.co" }] +requires-python = ">=3.11" +dependencies = [ + "anyio>=4.6.2.post1", + "jumpstarter_driver_power", + "jumpstarter", + "paho-mqtt>=2.1.0", +] + +[project.entry-points."jumpstarter.drivers"] +TasmotaPower = "jumpstarter_driver_tasmota.driver:TasmotaPower" + + +[tool.hatch.version] +source = "vcs" +raw-options = { 'root' = '../../' } + +[tool.hatch.metadata.hooks.vcs.urls] +Homepage = "https://jumpstarter.dev" +source_archive = "https://github.com/jumpstarter-dev/repo/archive/{commit_hash}.zip" + +[tool.pytest.ini_options] +addopts = "--cov --cov-report=html --cov-report=xml" +log_cli = true +log_cli_level = "INFO" +testpaths = ["jumpstarter_driver_tasmota"] + +[build-system] +requires = ["hatchling", "hatch-vcs", "hatch-pin-jumpstarter"] +build-backend = "hatchling.build" + +[tool.hatch.build.hooks.pin_jumpstarter] +name = "pin_jumpstarter" + +[dependency-groups] +dev = [ + "pytest-cov>=6.0.0", + "pytest>=8.3.3", + "pytest-mqtt>=0.5.0", +] diff --git a/pyproject.toml b/pyproject.toml index 4d12fdd89..e1c0227a1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -22,6 +22,7 @@ jumpstarter-driver-probe-rs = { workspace = true } jumpstarter-driver-pyserial = { workspace = true } jumpstarter-driver-qemu = { workspace = true } jumpstarter-driver-sdwire = { workspace = true } +jumpstarter-driver-tasmota = { workspace = true } jumpstarter-driver-tftp = { workspace = true } jumpstarter-driver-snmp = { workspace = true } jumpstarter-driver-shell = { workspace = true } @@ -75,6 +76,7 @@ locale = "en-us" [tool.typos.default.extend-words] ser = "ser" Pn = "Pn" +mosquitto = "mosquitto" [tool.coverage.run] omit = ["conftest.py", "test_*.py", "*_test.py", "*_pb2.py", "*_pb2_grpc.py"] diff --git a/uv.lock b/uv.lock index 30bc69b3c..d19f22408 100644 --- a/uv.lock +++ b/uv.lock @@ -28,6 +28,7 @@ members = [ "jumpstarter-driver-sdwire", "jumpstarter-driver-shell", "jumpstarter-driver-snmp", + "jumpstarter-driver-tasmota", "jumpstarter-driver-tftp", "jumpstarter-driver-uboot", "jumpstarter-driver-ustreamer", @@ -586,6 +587,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/91/a1/cf2472db20f7ce4a6be1253a81cfdf85ad9c7885ffbed7047fb72c24cf87/distlib-0.3.9-py2.py3-none-any.whl", hash = "sha256:47f8c22fd27c27e25a65601af709b38e4f0a45ea4fc2e710f65755fa8caaaf87", size = 468973, upload-time = "2024-10-09T18:35:44.272Z" }, ] +[[package]] +name = "docker" +version = "7.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pywin32", marker = "sys_platform == 'win32'" }, + { name = "requests" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/91/9b/4a2ea29aeba62471211598dac5d96825bb49348fa07e906ea930394a83ce/docker-7.1.0.tar.gz", hash = "sha256:ad8c70e6e3f8926cb8a92619b832b4ea5299e2831c14284663184e200546fa6c", size = 117834, upload-time = "2024-05-23T11:13:57.216Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e3/26/57c6fb270950d476074c087527a558ccb6f4436657314bfb6cdf484114c4/docker-7.1.0-py3-none-any.whl", hash = "sha256:c96b93b7f0a746f9e77d325bcfb87422a3d8bd4f03136ae8a85b37f1898d5fc0", size = 147774, upload-time = "2024-05-23T11:13:55.01Z" }, +] + [[package]] name = "docutils" version = "0.21.2" @@ -1786,6 +1801,38 @@ dev = [ { name = "pytest-cov", specifier = ">=6.0.0" }, ] +[[package]] +name = "jumpstarter-driver-tasmota" +source = { editable = "packages/jumpstarter-driver-tasmota" } +dependencies = [ + { name = "anyio" }, + { name = "jumpstarter" }, + { name = "jumpstarter-driver-power" }, + { name = "paho-mqtt" }, +] + +[package.dev-dependencies] +dev = [ + { name = "pytest" }, + { name = "pytest-cov" }, + { name = "pytest-mqtt" }, +] + +[package.metadata] +requires-dist = [ + { name = "anyio", specifier = ">=4.6.2.post1" }, + { name = "jumpstarter", editable = "packages/jumpstarter" }, + { name = "jumpstarter-driver-power", editable = "packages/jumpstarter-driver-power" }, + { name = "paho-mqtt", specifier = ">=2.1.0" }, +] + +[package.metadata.requires-dev] +dev = [ + { name = "pytest", specifier = ">=8.3.3" }, + { name = "pytest-cov", specifier = ">=6.0.0" }, + { name = "pytest-mqtt", specifier = ">=0.5.0" }, +] + [[package]] name = "jumpstarter-driver-tftp" source = { editable = "packages/jumpstarter-driver-tftp" } @@ -2488,6 +2535,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484", size = 66469, upload-time = "2025-04-19T11:48:57.875Z" }, ] +[[package]] +name = "paho-mqtt" +version = "2.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/39/15/0a6214e76d4d32e7f663b109cf71fb22561c2be0f701d67f93950cd40542/paho_mqtt-2.1.0.tar.gz", hash = "sha256:12d6e7511d4137555a3f6ea167ae846af2c7357b10bc6fa4f7c3968fc1723834", size = 148848, upload-time = "2024-04-29T19:52:55.591Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c4/cb/00451c3cf31790287768bb12c6bec834f5d292eaf3022afc88e14b8afc94/paho_mqtt-2.1.0-py3-none-any.whl", hash = "sha256:6db9ba9b34ed5bc6b6e3812718c7e06e2fd7444540df2455d2c51bd58808feee", size = 67219, upload-time = "2024-04-29T19:52:48.345Z" }, +] + [[package]] name = "paramiko" version = "3.5.1" @@ -2959,6 +3015,17 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/28/d0/def53b4a790cfb21483016430ed828f64830dd981ebe1089971cd10cab25/pytest_cov-6.1.1-py3-none-any.whl", hash = "sha256:bddf29ed2d0ab6f4df17b4c55b0a657287db8684af9c42ea546b21b1041b3dde", size = 23841, upload-time = "2025-04-05T14:07:49.641Z" }, ] +[[package]] +name = "pytest-docker-fixtures" +version = "1.3.19" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "docker" }, + { name = "pytest" }, + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/66/6e/cc5395a86f5ef3b71f5bcfcc35cfce734d6010bcc7ea57fcfa704ee94df5/pytest-docker-fixtures-1.3.19.tar.gz", hash = "sha256:016578a1b6a4dfc81e5a09286230cb56e323bdfe3f4dda20a1e7bc2c74492f4a", size = 10518, upload-time = "2024-04-03T14:39:10.925Z" } + [[package]] name = "pytest-httpserver" version = "1.1.3" @@ -2971,6 +3038,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/0d/d2/dfc2f25f3905921c2743c300a48d9494d29032f1389fc142e718d6978fb2/pytest_httpserver-1.1.3-py3-none-any.whl", hash = "sha256:5f84757810233e19e2bb5287f3826a71c97a3740abe3a363af9155c0f82fdbb9", size = 21000, upload-time = "2025-04-10T08:17:13.906Z" }, ] +[[package]] +name = "pytest-mqtt" +version = "0.5.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "paho-mqtt" }, + { name = "pytest-docker-fixtures" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/6f/87/33b9e943d95903be427f09210247352523e5065b8b5463f27743f7c4bac8/pytest_mqtt-0.5.0.tar.gz", hash = "sha256:0fbf15126c0d11c44e5a88def39df1d03cc984ce06e96c8b05ba5a0504c71b35", size = 10890, upload-time = "2025-01-07T01:01:14.084Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5c/3a/2ec7f49d1960417092f6d3c1246dfeba5478a54061d6900bfc72448dade0/pytest_mqtt-0.5.0-py3-none-any.whl", hash = "sha256:d97f07623442c5fb7bc3c82f27a249cc880b9c17eb85e28212d6c2d7fe20ba65", size = 9479, upload-time = "2025-01-07T01:01:12.694Z" }, +] + [[package]] name = "python-can" version = "4.5.0" @@ -3056,6 +3136,22 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/6c/58/7179fd6f87153f2e339171e8cfe9bf901398a89045eefd7a3911bb9b47ad/pywavelets-1.8.0-cp313-cp313t-win_amd64.whl", hash = "sha256:ec5d723c3335ff8aa630fd4b14097077f12cc02893c91cafd60dd7b1730e780f", size = 4265431, upload-time = "2024-12-04T19:54:16.928Z" }, ] +[[package]] +name = "pywin32" +version = "310" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f7/b1/68aa2986129fb1011dabbe95f0136f44509afaf072b12b8f815905a39f33/pywin32-310-cp311-cp311-win32.whl", hash = "sha256:1e765f9564e83011a63321bb9d27ec456a0ed90d3732c4b2e312b855365ed8bd", size = 8784284, upload-time = "2025-03-17T00:55:53.124Z" }, + { url = "https://files.pythonhosted.org/packages/b3/bd/d1592635992dd8db5bb8ace0551bc3a769de1ac8850200cfa517e72739fb/pywin32-310-cp311-cp311-win_amd64.whl", hash = "sha256:126298077a9d7c95c53823934f000599f66ec9296b09167810eb24875f32689c", size = 9520748, upload-time = "2025-03-17T00:55:55.203Z" }, + { url = "https://files.pythonhosted.org/packages/90/b1/ac8b1ffce6603849eb45a91cf126c0fa5431f186c2e768bf56889c46f51c/pywin32-310-cp311-cp311-win_arm64.whl", hash = "sha256:19ec5fc9b1d51c4350be7bb00760ffce46e6c95eaf2f0b2f1150657b1a43c582", size = 8455941, upload-time = "2025-03-17T00:55:57.048Z" }, + { url = "https://files.pythonhosted.org/packages/6b/ec/4fdbe47932f671d6e348474ea35ed94227fb5df56a7c30cbbb42cd396ed0/pywin32-310-cp312-cp312-win32.whl", hash = "sha256:8a75a5cc3893e83a108c05d82198880704c44bbaee4d06e442e471d3c9ea4f3d", size = 8796239, upload-time = "2025-03-17T00:55:58.807Z" }, + { url = "https://files.pythonhosted.org/packages/e3/e5/b0627f8bb84e06991bea89ad8153a9e50ace40b2e1195d68e9dff6b03d0f/pywin32-310-cp312-cp312-win_amd64.whl", hash = "sha256:bf5c397c9a9a19a6f62f3fb821fbf36cac08f03770056711f765ec1503972060", size = 9503839, upload-time = "2025-03-17T00:56:00.8Z" }, + { url = "https://files.pythonhosted.org/packages/1f/32/9ccf53748df72301a89713936645a664ec001abd35ecc8578beda593d37d/pywin32-310-cp312-cp312-win_arm64.whl", hash = "sha256:2349cc906eae872d0663d4d6290d13b90621eaf78964bb1578632ff20e152966", size = 8459470, upload-time = "2025-03-17T00:56:02.601Z" }, + { url = "https://files.pythonhosted.org/packages/1c/09/9c1b978ffc4ae53999e89c19c77ba882d9fce476729f23ef55211ea1c034/pywin32-310-cp313-cp313-win32.whl", hash = "sha256:5d241a659c496ada3253cd01cfaa779b048e90ce4b2b38cd44168ad555ce74ab", size = 8794384, upload-time = "2025-03-17T00:56:04.383Z" }, + { url = "https://files.pythonhosted.org/packages/45/3c/b4640f740ffebadd5d34df35fecba0e1cfef8fde9f3e594df91c28ad9b50/pywin32-310-cp313-cp313-win_amd64.whl", hash = "sha256:667827eb3a90208ddbdcc9e860c81bde63a135710e21e4cb3348968e4bd5249e", size = 9503039, upload-time = "2025-03-17T00:56:06.207Z" }, + { url = "https://files.pythonhosted.org/packages/b4/f4/f785020090fb050e7fb6d34b780f2231f302609dc964672f72bfaeb59a28/pywin32-310-cp313-cp313-win_arm64.whl", hash = "sha256:e308f831de771482b7cf692a1f308f8fca701b2d8f9dde6cc440c7da17e47b33", size = 8458152, upload-time = "2025-03-17T00:56:07.819Z" }, +] + [[package]] name = "pyyaml" version = "6.0.2"