Skip to content

Commit 7ddd9df

Browse files
committed
Added plug-centric interface, to align with mDNS discovery.
Event data has changed sufficiently to not be fully backwards compatible, hence major version bump..
1 parent 344415a commit 7ddd9df

7 files changed

Lines changed: 441 additions & 152 deletions

File tree

README.md

Lines changed: 18 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,10 @@
11
# Powersensor (local)
22

33
A small package to interface with the network-local event streams available on
4-
Powersensor devices. Specifically, this package abstracts away the connections
4+
Powersensor devices.
5+
6+
Two different interfaces are provided. The first is suitable for using when
7+
relying on the legacy plug discovery method. It abstracts away the connections
58
to all Powersensor gateway devices (plugs) on the network, and provides a
69
uniform event stream from all devices (including sensors relaying their data
710
via the gateways).
@@ -10,8 +13,18 @@ The main API is in `powersensor_local.devices' via the PowersensorDevices
1013
class, which provides an abstracted view of the discovered Powersensor devices
1114
on the local network.
1215

13-
There are also two small utilities included, `ps-events` and `ps-rawfirehose`.
14-
The former is effectively a consumer of the the PowersensorDevices event
15-
stream which dumps all events to standard out. The latter, `ps-rawfirehose`
16+
The second interface is intended for use when mDNS based service discovery,
17+
also known as ZeroConf, is used. This abstraction provides an instantiation
18+
for each plug as they get discovered, with individual async events provided.
19+
Actual mDNS discovery is not included.
20+
21+
There are also some small utilities included, `ps-events` and `ps-rawfirehose`
22+
showcasing the use of the first interface approach, and `ps-plugevents` and
23+
`ps-rawplug` for the latter.
24+
.
25+
The `ps-events` is effectively a consumer of the the PowersensorDevices event
26+
stream which dumps all events to standard out, while, `ps-rawfirehose`
1627
is a debugging aid which dumps the lower-level event streams from each
17-
Powersensor gateway.
28+
Powersensor gateway. Similary, `ps-plugevents` shows the event stream from
29+
a single plug (plus whatever it might be relaying for), and `ps-rawplug`
30+
shows the raw event stream from the plug.

pyproject.toml

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[project]
22
name = "powersensor-local"
3-
version = "1.0.2"
3+
version = "2.0.0"
44
description = "Network-local (non-cloud) interface for Powersensor devices"
55
authors = [
66
{ name = "Jade Mattsson", email = "jmattsson@dius.com.au" },
@@ -23,7 +23,8 @@ Issues = "https://github.com/DiUS/python-powersensor_local/issues"
2323
[project.scripts]
2424
ps-events = "powersensor_local.events:app"
2525
ps-rawfirehose = "powersensor_local.rawfirehose:app"
26-
ps-rawplug = "powersensor.rawplug:app"
26+
ps-rawplug = "powersensor_local.rawplug:app"
27+
ps-plugevents = "powersensor_local.plugevents:app"
2728

2829
[build-system]
2930
requires = [ "hatchling" ]

src/powersensor_local/devices.py

Lines changed: 10 additions & 144 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
from datetime import datetime, timezone
55

66
from .listener import PowersensorListener
7+
from .xlatemsg import translate_raw_message
78

89
EXPIRY_CHECK_INTERVAL_S = 30
910
EXPIRY_TIMEOUT_S = 5 * 60
@@ -55,75 +56,9 @@ async def yourcallback(event: dict)
5556
{ event: "device_lost", mac: "..." }
5657
5758
59+
Additionally, all events described in xlatemsg.translate_raw_message
60+
may be issued. The event name is inserted into the field 'event'.
5861
59-
The events below all have the following common fields:
60-
61-
{ mac: "...", starttime_utc: X }
62-
63-
and where applicable, also:
64-
65-
{ via: "..." }
66-
67-
For brevity's sake they are not shown in the examples below, other
68-
then simply as ...
69-
70-
71-
battery_level:
72-
The battery level of a sensor.
73-
74-
{ ..., event: "battery_level", volts: X.Y }
75-
76-
voltage:
77-
The mains voltage as detected by a plug.
78-
79-
{ ..., event: "voltage", volts: X.Y }
80-
81-
average_power:
82-
Reports the average power observed over the reporting duration.
83-
May be negative for e.g. solar sensors and house sensors when
84-
exporting solar to the grid.
85-
86-
The summation_joules field is a summation style register which
87-
reports accumulated energy. This field is only useful for
88-
calculating the delta of energy between two events. The counter
89-
will reset to zero if the device is restarted, and is technically
90-
subject to overflow, though that is unlikely to be reached.
91-
The summation may be negative if solar export is present. The
92-
summation may increment or decrement depending on whether energy
93-
is being imported from or exported to the grid.
94-
95-
{ ..., event: "average_power",
96-
watts: X.Y,
97-
durations_s: N.M,
98-
summation_joules: J.K,
99-
}
100-
101-
For reports from plugs, the following fields will also be present:
102-
103-
{
104-
...,
105-
volts: X.Y,
106-
current: C.D,
107-
active_current: E.F,
108-
reactive_current: G.H,
109-
}
110-
111-
The (apparent) current, active_current and reactive_current fields
112-
are all reported in a unit of Amperes.
113-
114-
uncalibrated_power:
115-
Powersensors require calibrations of their readings before they
116-
are able to be converted into a proper power reading. This event
117-
is issued for sensor readings prior to such calibration completing.
118-
The reported value has no inherent meaning beyond being an
119-
indication of the strength of the signal seen by the sensor. It
120-
is most definitely NOT in Watts. For most purposes, this event
121-
can (and should be) ignored.
122-
123-
{ ..., event: "uncalibrated_power",
124-
value: Y.Z,
125-
durations_s: N.M,
126-
}
12762
12863
The start function returns the number of found gateway plugs.
12964
Powersensor devices aren't found directly as they are typically not
@@ -184,7 +119,8 @@ async def _on_msg(self, obj):
184119
device.mark_active()
185120

186121
if self._event_cb and device.subscribed:
187-
evs = self._mk_events(obj)
122+
relayer = obj.get('via') or mac
123+
evs = self._mk_events(obj, relayer)
188124
if len(evs) > 0:
189125
for ev in evs:
190126
await self._event_cb(ev)
@@ -217,85 +153,15 @@ async def _remove_device(self, mac):
217153
}
218154
await self._event_cb(ev)
219155

220-
### Event formatting ###
221-
222-
def _mk_events(self, obj):
156+
def _mk_events(self, obj, relayer):
223157
evs = []
224-
typ = obj.get('type')
225-
if typ == 'instant_power':
226-
unit = obj.get('unit')
227-
if unit == 'w' or unit == 'W':
228-
evs.append(self._mk_average_power_event(obj))
229-
elif unit == 'l' or unit == 'L':
230-
evs.append(self._mk_average_water_event(obj))
231-
elif unit == 'U':
232-
evs.append(self._mk_uncalib_power_event(obj))
233-
elif unit == 'I':
234-
pass # invalid data / sample failed
235-
236-
if obj.get('voltage') is not None:
237-
evs.append(self._mk_voltage_event(obj))
238-
239-
if obj.get('batteryMicrovolt') is not None:
240-
evs.append(self._mk_battery_event(obj))
241-
else:
242-
print(obj)
243-
244-
for ev in evs:
245-
ev['mac'] = obj.get('mac')
246-
if obj.get('starttime'):
247-
ev['starttime_utc'] = obj.get('starttime')
248-
if obj.get('via'):
249-
ev['via'] = obj.get('via')
158+
kvs = translate_raw_message(obj, relayer)
159+
for key, ev in kvs.items():
160+
ev['event'] = key
161+
evs.append(ev)
250162

251163
return evs
252164

253-
def _mk_average_power_event(self, obj):
254-
ev = {
255-
'event': 'average_power',
256-
'watts': obj.get('power'),
257-
'duration_s': obj.get('duration'),
258-
'summation_joules': obj.get('summation'),
259-
}
260-
if obj.get('device') == 'plug':
261-
ev['volts'] = obj.get('voltage')
262-
ev['current'] = obj.get('current')
263-
ev['active_current'] = obj.get('active_current')
264-
ev['reactive_current'] = obj.get('reactive_current')
265-
if obj.get('role'):
266-
ev['role'] = obj.get('role')
267-
return ev
268-
269-
def _mk_average_water_event(self, obj):
270-
ev = {
271-
'event': 'average_water',
272-
'litres': obj.get('power'),
273-
'duration_s': obj.get('duration'),
274-
'summation_litres': obj.get('summation'),
275-
}
276-
return ev
277-
278-
def _mk_uncalib_power_event(self, obj):
279-
ev = {
280-
'event': 'uncalibrated_power',
281-
'value': obj.get('power'),
282-
'duration_s': obj.get('duration'),
283-
}
284-
return ev
285-
286-
def _mk_voltage_event(self, obj):
287-
return {
288-
'event': 'voltage',
289-
'volts': obj.get('voltage'),
290-
}
291-
292-
def _mk_battery_event(self, obj):
293-
return {
294-
'event': 'battery_level',
295-
'volts': float(obj.get('batteryMicrovolt'))/1000000.0,
296-
}
297-
298-
299165
### Supporting classes ###
300166

301167
class _Device:

src/powersensor_local/plug_api.py

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
from async_event_emitter import AsyncEventEmitter
2+
from plug_listener import PlugListener
3+
from xlatemsg import translate_raw_message
4+
5+
class PlugApi(AsyncEventEmitter):
6+
"""
7+
The primary interface to access the interpreted event stream from a plug.
8+
9+
The plug may be relaying messages from one or more sensors, in addition
10+
to its own reports.
11+
12+
Acts as an AsyncEventEmitter. Events which can be registered for are
13+
documented in xlatemsg.translate_raw_message.
14+
"""
15+
16+
def __init__(self, mac, ip, port=49476):
17+
"""
18+
Instantiates a new PlugApi for the given plug.
19+
20+
Args:
21+
- mac: The MAC address of the plug (typically found in the "id" field
22+
in the mDNS/ZeroConf discovery).
23+
- ip: The IP address of the plug.
24+
- port: The TCP port number of the API service on the plug.
25+
"""
26+
super().__init__()
27+
self._mac = mac
28+
self._listener = PlugListener(ip, port)
29+
self._listener.subscribe('message', self._on_message)
30+
self._seen = set()
31+
32+
def connect(self):
33+
"""
34+
Initiates a connection to the plug.
35+
36+
Will automatically retry on failure or if the connection is lost,
37+
until such a time disconnect() is called.
38+
"""
39+
self._listener.connect()
40+
41+
async def disconnect(self):
42+
"""Disconnects from the plug and stops further connection attempts."""
43+
await self._listener.disconnect()
44+
45+
async def _on_message(self, _, message):
46+
evs = None
47+
try:
48+
evs = translate_raw_message(message, self._mac)
49+
except KeyError:
50+
# Ignore malformed messages
51+
return
52+
53+
msgmac = message.get('mac')
54+
if msgmac != self._mac and msgmac not in self._seen:
55+
self._seen.add(msgmac)
56+
# We want to emit this prior to events with data
57+
ev = {
58+
'mac': msgmac,
59+
'device_type': message.get('device'),
60+
'role': message.get('role'),
61+
}
62+
await self.emit('now_relaying_for', ev)
63+
64+
for name, ev in evs.items():
65+
await self.emit(name, ev)
66+
67+
# FIXME - we'll want to use mac:role for the unique ID in HA, so a reassigned
68+
# sensor shows up differently
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
#!/usr/bin/env python3
2+
3+
"""Utility script for accessing the plug api from a single network-local
4+
Powersensor device. Intended for advanced debugging use only."""
5+
6+
import asyncio
7+
import os
8+
import signal
9+
import sys
10+
from plug_api import PlugApi
11+
12+
exiting = False
13+
plug = None
14+
15+
async def do_exit():
16+
global exiting
17+
global plug
18+
if plug != None:
19+
await plug.disconnect()
20+
del plug
21+
exiting = True
22+
23+
async def on_evt_msg(evt, msg):
24+
print(evt, msg)
25+
26+
async def on_evt(evt):
27+
print(evt)
28+
29+
async def main():
30+
if len(sys.argv) < 3:
31+
print(f"Syntax: {sys.argv[0]} <id> <ip> [port]")
32+
sys.exit(1)
33+
34+
# Signal handler for Ctrl+C
35+
def handle_sigint(signum, frame):
36+
signal.signal(signal.SIGINT, signal.SIG_DFL)
37+
asyncio.create_task(do_exit())
38+
39+
signal.signal(signal.SIGINT, handle_sigint)
40+
41+
global plug
42+
plug = PlugApi(sys.argv[1], sys.argv[2], *sys.argv[3:3])
43+
known_evs = [
44+
'average_flow',
45+
'average_power',
46+
'average_power_components',
47+
'battery_level',
48+
'now_relaying_for',
49+
'radio_signal_quality',
50+
'summation_energy',
51+
'summation_volume',
52+
'uncalibrated_instant_reading',
53+
]
54+
for ev in known_evs:
55+
plug.subscribe(ev, on_evt_msg)
56+
plug.connect()
57+
58+
# Keep the event loop running until Ctrl+C is pressed
59+
while not exiting:
60+
await asyncio.sleep(1)
61+
62+
def app():
63+
asyncio.run(main())
64+
65+
if __name__ == "__main__":
66+
app()

src/powersensor_local/rawplug.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
#!/usr/bin/env python3
22

33
"""Utility script for accessing the raw plug subscription data from a single
4-
network-local Powersensor devices. Intended for advanced debugging use only."""
4+
network-local Powersensor device. Intended for advanced debugging use only."""
55

66
import asyncio
77
import os

0 commit comments

Comments
 (0)