Skip to content

Commit a6571a4

Browse files
authored
Merge pull request #698 from Emantor/topic/tasmota
mqtt: support for tasmota power ports
2 parents 1e64b55 + b8f1327 commit a6571a4

File tree

10 files changed

+262
-2
lines changed

10 files changed

+262
-2
lines changed

dev-requirements.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,3 +15,4 @@ psutil==5.6.6
1515
-r docker-requirements.txt
1616
-r pyvisa-requirements.txt
1717
-r vxi11-requirements.txt
18+
-r mqtt-requirements.txt

doc/configuration.rst

Lines changed: 46 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -253,6 +253,32 @@ The example describes port 1 on the hub with the ID_PATH
253253
Used by:
254254
- `SiSPMPowerDriver`_
255255

256+
TasmotaPowerPort
257+
++++++++++++++++
258+
A :any:`TasmotaPowerPort` resource describes a switchable Tasmora power outlet
259+
accessed over MQTT.
260+
261+
.. code-block:: yaml
262+
263+
TasmotaPowerPort:
264+
host: this.is.an.example.host.com
265+
status_topic: stat/tasmota_575A2B/POWER
266+
power_topic: cmnd/tasmota_575A2B/POWER
267+
avail_topic: tele/tasmota_575A2B/LWT
268+
269+
The example uses a mosquitto server at "this.is.an.example.host.com" and has the
270+
topics setup for a tasmota power port that has the ID 575A2B.
271+
272+
- host (str): hostname of the MQTT server
273+
- status_topic (str): topic that signals the current status as "ON" of "OFF"
274+
- power_topic (str): topic that allows switchting the status between "ON" and
275+
"OFF"
276+
- avail_topic (str): topic that signals the availability of the Tasmota power
277+
outlet
278+
279+
Used by:
280+
- `TasmotaPowerDriver`_
281+
256282
Digital Outputs
257283
~~~~~~~~~~~~~~~
258284

@@ -1499,7 +1525,7 @@ Arguments:
14991525
- delay (float): optional delay in seconds between off and on
15001526

15011527
SiSPMPowerDriver
1502-
~~~~~~~~~~~~~~~~~~~
1528+
~~~~~~~~~~~~~~~~
15031529
A SiSPMPowerDriver controls a `SiSPMPowerPort`, allowing control of the target
15041530
power state without user interaction.
15051531

@@ -1517,6 +1543,25 @@ Implements:
15171543
Arguments:
15181544
- delay (float): optional delay in seconds between off and on
15191545

1546+
TasmotaPowerDriver
1547+
~~~~~~~~~~~~~~~~~~
1548+
A TasmotaPowerDriver contols a `TasmotaPowerPort`, allowing the outlet to be
1549+
switched on and off.
1550+
1551+
Binds to:
1552+
- `TasmotaPowerPort`_
1553+
1554+
Implements:
1555+
- :any:`PowerProtocol`
1556+
1557+
.. code-block:: yaml
1558+
1559+
TasmotaPowerDriver:
1560+
delay: 5.0
1561+
1562+
Arguments:
1563+
- delay (float): optional delay in seconds between off and on
1564+
15201565
GpioDigitalOutputDriver
15211566
~~~~~~~~~~~~~~~~~~~~~~~
15221567
The GpioDigitalOutputDriver writes a digital signal to a GPIO line.

labgrid/driver/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,3 +36,4 @@
3636
from .usbaudiodriver import USBAudioInputDriver
3737
from .networkinterfacedriver import NetworkInterfaceDriver
3838
from .provider import TFTPProviderDriver
39+
from .mqtt import TasmotaPowerDriver

labgrid/driver/mqtt.py

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
#!/usr/bin/env python3
2+
3+
import threading
4+
import time
5+
6+
import attr
7+
8+
from .common import Driver
9+
from ..factory import target_factory
10+
from ..protocol import PowerProtocol
11+
from ..step import step
12+
from ..util import Timeout
13+
14+
15+
class MQTTError(Exception):
16+
pass
17+
18+
@target_factory.reg_driver
19+
@attr.s(eq=False)
20+
class TasmotaPowerDriver(Driver, PowerProtocol):
21+
bindings = {
22+
"power": {"TasmotaPowerPort"}
23+
}
24+
delay = attr.ib(default=2.0, validator=attr.validators.instance_of(float))
25+
_client = attr.ib(default=None)
26+
_status = attr.ib(default=None)
27+
28+
def __attrs_post_init__(self):
29+
super().__attrs_post_init__()
30+
import paho.mqtt.client as mqtt
31+
self._client = mqtt.Client()
32+
33+
def on_activate(self):
34+
self._client.on_message = self._on_message
35+
self._client.on_connect = self._on_connect
36+
self._client.connect(self.power.host)
37+
self._client.loop_start()
38+
39+
def on_deactivate(self):
40+
self._client.loop_stop()
41+
42+
def _on_message(self, client, userdata, msg):
43+
if msg.payload == b'ON':
44+
status = True
45+
elif msg.payload == b'OFF':
46+
status = False
47+
self._status = status
48+
49+
def _on_connect(self, client, userdata, flags, rc):
50+
client.subscribe(self.power.status_topic)
51+
52+
def _publish(self, topic, payload):
53+
msg = self._client.publish(topic, payload=payload)
54+
timeout = Timeout(3.0)
55+
while not msg.is_published:
56+
time.sleep(0.1)
57+
if timeout.expired:
58+
raise MQTTError("publish timed out")
59+
return msg
60+
61+
@Driver.check_active
62+
@step()
63+
def on(self):
64+
self._publish(self.power.power_topic, "ON")
65+
timeout = Timeout(3.0)
66+
while self._status is False:
67+
time.sleep(0.1)
68+
if timeout.expired:
69+
raise MQTTError("Port did not change status within 3 seconds")
70+
71+
@Driver.check_active
72+
@step()
73+
def off(self):
74+
self._publish(self.power.power_topic, "OFF")
75+
timeout = Timeout(3.0)
76+
while self._status is True:
77+
time.sleep(0.1)
78+
if timeout.expired:
79+
raise MQTTError("Port did not change status within 3 seconds")
80+
81+
@Driver.check_active
82+
@step()
83+
def cycle(self):
84+
self.off()
85+
time.sleep(self.delay)
86+
self.on()
87+
88+
@Driver.check_active
89+
@step()
90+
def get(self):
91+
self._client.publish(self.power.power_topic)
92+
timeout = Timeout(3.0)
93+
while self._status is None:
94+
time.sleep(0.1)
95+
if timeout.expired:
96+
raise MQTTError("Could not get initial status")
97+
return self._status

labgrid/remote/client.py

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -721,8 +721,10 @@ def power(self):
721721
target = self._get_target(place)
722722
from ..driver.powerdriver import (NetworkPowerDriver, PDUDaemonDriver,
723723
USBPowerDriver, SiSPMPowerDriver)
724+
from ..driver.mqtt import TasmotaPowerDriver
724725
from ..resource.power import NetworkPowerPort, PDUDaemonPort
725-
from ..resource.remote import NetworkUSBPowerPort, NetworkSiSPMPowerPort
726+
from ..resource.remote import (NetworkUSBPowerPort, NetworkSiSPMPowerPort)
727+
from ..resource.mqtt import TasmotaPowerPort
726728

727729
drv = None
728730
try:
@@ -753,6 +755,12 @@ def power(self):
753755
except NoDriverFoundError:
754756
drv = PDUDaemonDriver(target, name=None)
755757
break
758+
elif isinstance(resource, TasmotaPowerPort):
759+
try:
760+
drv = target.get_driver(TasmotaPowerDriver)
761+
except NoDriverFoundError:
762+
drv = TasmotaPowerDriver(target, name=None)
763+
break
756764
if not drv:
757765
raise UserError("target has no compatible resource available")
758766
if delay is not None:

labgrid/resource/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,3 +19,4 @@
1919
from .lxaiobus import LXAIOBusPIO
2020
from .pyvisa import PyVISADevice
2121
from .provider import TFTPProvider
22+
from .mqtt import TasmotaPowerPort

labgrid/resource/mqtt.py

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
import logging
2+
import threading
3+
from time import monotonic
4+
5+
import attr
6+
7+
from .common import ManagedResource, ResourceManager
8+
from ..factory import target_factory
9+
10+
@attr.s(eq=False)
11+
class MQTTManager(ResourceManager):
12+
_available = attr.ib(default=attr.Factory(set), validator=attr.validators.instance_of(set))
13+
_avail_lock = attr.ib(default=threading.Lock())
14+
_clients = attr.ib(default=attr.Factory(dict), validator=attr.validators.instance_of(dict))
15+
_topics = attr.ib(default=attr.Factory(list), validator=attr.validators.instance_of(list))
16+
_topic_lock = attr.ib(default=threading.Lock())
17+
_last = attr.ib(default=0.0, validator=attr.validators.instance_of(float))
18+
19+
def __attrs_post_init__(self):
20+
super().__attrs_post_init__()
21+
self.log = logging.getLogger('MQTTManager')
22+
23+
def _create_mqtt_connection(self, host):
24+
import paho.mqtt.client as mqtt
25+
client = mqtt.Client()
26+
client.connect(host)
27+
client.on_message = self._on_message
28+
client.loop_start()
29+
return client
30+
31+
def on_resource_added(self, resource):
32+
host = resource.host
33+
if host not in self._clients:
34+
self._clients[host] = self._create_mqtt_connection(host)
35+
self._clients[host].subscribe(resource.avail_topic)
36+
37+
def _on_message(self, client, userdata, msg):
38+
payload = msg.payload.decode('utf-8')
39+
topic = msg.topic
40+
if payload == "Online":
41+
with self._avail_lock:
42+
self._available.add(topic)
43+
if payload == "Offline":
44+
with self._avail_lock:
45+
self._available.discard(topic)
46+
47+
def poll(self):
48+
if monotonic()-self._last < 2:
49+
return # ratelimit requests
50+
self._last = monotonic()
51+
with self._avail_lock:
52+
for resource in self.resources:
53+
resource.avail = resource.avail_topic in self._available
54+
55+
56+
@target_factory.reg_resource
57+
@attr.s(eq=False)
58+
class MQTTResource(ManagedResource):
59+
manager_cls = MQTTManager
60+
61+
host = attr.ib(validator=attr.validators.instance_of(str))
62+
avail_topic = attr.ib(validator=attr.validators.instance_of(str))
63+
64+
def __attrs_post_init__(self):
65+
self.timeout = 30.0
66+
super().__attrs_post_init__()
67+
68+
69+
@target_factory.reg_resource
70+
@attr.s(eq=False)
71+
class TasmotaPowerPort(MQTTResource):
72+
power_topic = attr.ib(default=None,
73+
validator=attr.validators.instance_of(str))
74+
status_topic = attr.ib(default=None,
75+
validator=attr.validators.instance_of(str))

labgrid/resource/remote.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -333,6 +333,14 @@ def __attrs_post_init__(self):
333333
@attr.s(eq=False)
334334
class NetworkUSBFlashableDevice(RemoteUSBResource):
335335
devnode = attr.ib(validator=attr.validators.optional(attr.validators.instance_of(str)))
336+
337+
@attr.s(eq=False)
338+
class NetworkMQTTResource(ManagedResource):
339+
manager_cls = RemotePlaceManager
340+
341+
host = attr.ib(validator=attr.validators.instance_of(str))
342+
avail_topic = attr.ib(validator=attr.validators.instance_of(str))
343+
336344
def __attrs_post_init__(self):
337345
self.timeout = 30.0
338346
super().__attrs_post_init__()

mqtt-requirements.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
paho-mqtt==1.5.1

tests/test_tasmota.py

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
from labgrid.resource.mqtt import TasmotaPowerPort
2+
from labgrid.driver.mqtt import TasmotaPowerDriver
3+
4+
import pytest
5+
6+
pytest.importorskip("paho.mqtt.client")
7+
8+
9+
def test_tasmota_resource(target, mocker):
10+
mocker.patch('paho.mqtt.client.Client.connect', return_value=None)
11+
mocker.patch('paho.mqtt.client.Client.loop_start', return_value=None)
12+
TasmotaPowerPort(target, name=None, host="localhost", avail_topic="test",
13+
power_topic="test", status_topic="test")
14+
15+
16+
def test_tasmota_driver(target, mocker):
17+
mocker.patch('paho.mqtt.client.Client.connect', return_value=None)
18+
mocker.patch('paho.mqtt.client.Client.loop_start', return_value=None)
19+
res = TasmotaPowerPort(target, name=None, host="localhost", avail_topic="test",
20+
power_topic="test", status_topic="test")
21+
res.manager._available.add("test")
22+
driver = TasmotaPowerDriver(target, name=None)
23+
target.activate(driver)

0 commit comments

Comments
 (0)