Skip to content

Commit 78163d8

Browse files
committed
First cut of LAN interface to Powersensor devices.
0 parents  commit 78163d8

9 files changed

Lines changed: 622 additions & 0 deletions

File tree

.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
__pycache__
2+
.*.swp

LICENSE

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
The MIT License (MIT)
2+
3+
Copyright © 2024 DiUS Computing Pty Ltd
4+
5+
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
6+
7+
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
8+
9+
THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
10+

README.md

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
# Powersensor (local)
2+
3+
A small package to interface with the network-local event streams available on
4+
Powersensor devices. Specifically, this package abstracts away the connections
5+
to all Powersensor gateway devices (plugs) on the network, and provides a
6+
uniform event stream from all devices (including sensors relaying their data
7+
via the gateways).
8+
9+
The main API is in `powersensor_local.devices' via the PowersensorDevices
10+
class, which provides an abstracted view of the discovered Powersensor devices
11+
on the local network.
12+
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+
is a debugging aid which dumps the lower-level event streams from each
17+
Powersensor gateway.

pyproject.toml

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
[project]
2+
name = "powersensor-local"
3+
version = "1.0.0"
4+
description = "Network-local (non-cloud) interface for Powersensor devices"
5+
authors = [
6+
{ name = "Jade Mattsson", email = "jmattsson@dius.com.au" },
7+
]
8+
readme = "README.md"
9+
requires-python = ">=3.11"
10+
classifiers = [
11+
"Programming Language :: Python :: 3",
12+
"Intended Audience :: Developers",
13+
"License :: OSI Approved :: MIT License",
14+
"Operating System :: OS Independent",
15+
"Topic :: Home Automation",
16+
]
17+
18+
[project.urls]
19+
Repository = "https://github.com/DiUS/powersensor-local.git"
20+
Homepage = "https://github.com/DiUS/powersensor-local"
21+
Issues = "https://github.com/DiUS/powersensor-local/issues"
22+
23+
[project.scripts]
24+
ps-events = "powersensor_local.events:app"
25+
ps-rawfirehose = "powersensor_local.rawfirehose:app"
26+
27+
[build-system]
28+
requires = [ "hatchling" ]
29+
build-backend = "hatchling.build"

src/powersensor_local/__init__.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
"""Direct (non-cloud) interface to Powersensor devices
2+
3+
This package contains two abstraction layers:
4+
5+
• PowersensorDevices is the main API layer
6+
• PowersensorListener provides a lower-level abstraction
7+
8+
These are both available within this namespace, or specifically as
9+
devices.PowersensorDevices and listener.PowersensorListener
10+
11+
The 'events' and 'rawfirehose' modules are helper utilities provided as
12+
debug aids, which get installed under the names ps-events and ps-rawfirehose
13+
respectively.
14+
"""
15+
__all__ = [ 'devices', 'listener' ]
16+
from .devices import PowersensorDevices
17+
from .listener import PowersensorListener

src/powersensor_local/devices.py

Lines changed: 314 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,314 @@
1+
import asyncio
2+
import json
3+
4+
from datetime import datetime, timezone
5+
6+
from .listener import PowersensorListener
7+
8+
EXPIRY_CHECK_INTERVAL_S = 30
9+
EXPIRY_TIMEOUT_S = 5 * 60
10+
11+
class PowersensorDevices:
12+
"""Abstraction interface for the unified event stream from all Powersensor
13+
devices on the local network.
14+
"""
15+
16+
def __init__(self, bcast_addr='<broadcast>'):
17+
"""Creates a fresh instance, without scanning for devices."""
18+
self._event_cb = None
19+
self._ps = PowersensorListener(bcast_addr)
20+
self._devices = dict()
21+
self._timer = None
22+
23+
async def start(self, async_event_cb):
24+
"""Registers the async event callback function and starts the scan
25+
of the local network to discover present devices. The callback is
26+
of the form
27+
28+
async def yourcallback(event: dict)
29+
30+
Known events:
31+
32+
scan_complete:
33+
Indicates the discovery of Powersensor devices has completed.
34+
Emitted in response to start() and rescan() calls.
35+
The number of found gateways (plugs) is reported.
36+
37+
{ event: "scan_complete", gateway_count: N }
38+
39+
device_found:
40+
A new device found on the network.
41+
The order found devices are announced is not fixed.
42+
43+
{ event: "device_found",
44+
device_type: "plug" or "sensor",
45+
mac: "...",
46+
}
47+
48+
An optional field named "via" is present for sensor devices, and
49+
shows the MAC address of the gateway the sensor is communicating
50+
via.
51+
52+
device_lost:
53+
A device appears to no longer be present on the network.
54+
55+
{ event: "device_lost", mac: "..." }
56+
57+
58+
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+
}
127+
"""
128+
self._event_cb = async_event_cb
129+
await self._ps.scan(self._on_scanned)
130+
self._timer = self._Timer(EXPIRY_CHECK_INTERVAL_S, self._on_timer)
131+
132+
async def rescan(self):
133+
"""Performs a fresh scan of the network to discover added devices,
134+
or devices which have changed their IP address for some reason."""
135+
await self._ps.scan(self._on_scanned)
136+
137+
async def stop(self):
138+
"""Stops the event streaming and disconnects from the devices.
139+
To restart the event streaming, call start() again."""
140+
await self._ps.unsubscribe()
141+
await self._ps.stop()
142+
self._event_cb = None
143+
if self._timer:
144+
self._timer.terminate()
145+
self._timer = None
146+
147+
def subscribe(self, mac):
148+
"""Subscribes to events from the device with the given MAC address."""
149+
device = self._devices.get(mac)
150+
if device:
151+
device.subscribed = True
152+
153+
def unsubscribe(self, mac):
154+
"""Unsubscribes from events from the given MAC address."""
155+
device = self._devices.get(mac)
156+
if device:
157+
device.subscribed = False
158+
159+
async def _on_scanned(self, ips):
160+
self._ips = ips
161+
if self._event_cb:
162+
ev = {
163+
'event': 'scan_complete',
164+
'gateway_count': len(ips),
165+
}
166+
await self._event_cb(ev)
167+
168+
asyncio.create_task(self._ps.subscribe(self._on_msg))
169+
170+
async def _on_msg(self, obj):
171+
mac = obj.get('mac')
172+
if mac and not self._devices.get(mac):
173+
typ = obj.get('device')
174+
via = obj.get('via')
175+
await self._add_device(mac, typ, via)
176+
177+
device = self._devices[mac]
178+
device.mark_active()
179+
180+
if self._event_cb and device.subscribed:
181+
evs = self._mk_events(obj)
182+
if len(evs) > 0:
183+
for ev in evs:
184+
await self._event_cb(ev)
185+
186+
async def _on_timer(self):
187+
devices = list(self._devices.values())
188+
for device in devices:
189+
if device.has_expired():
190+
await self._remove_device(device.mac)
191+
192+
async def _add_device(self, mac, typ, via):
193+
self._devices[mac] = self._Device(mac, typ, via)
194+
if self._event_cb:
195+
ev = {
196+
'event': 'device_found',
197+
'device_type': typ,
198+
'mac': mac,
199+
}
200+
if via:
201+
ev['via'] = via
202+
await self._event_cb(ev)
203+
204+
async def _remove_device(self, mac):
205+
if self._devices.get(mac):
206+
self._devices.pop(mac)
207+
if self._event_cb:
208+
ev = {
209+
'event': 'device_lost',
210+
'mac': mac
211+
}
212+
await self._event_cb(ev)
213+
214+
### Event formatting ###
215+
216+
def _mk_events(self, obj):
217+
evs = []
218+
typ = obj.get('type')
219+
if typ == 'instant_power':
220+
unit = obj.get('unit')
221+
if unit == 'w' or unit == 'W':
222+
evs.append(self._mk_average_power_event(obj))
223+
elif unit == 'l' or unit == 'L':
224+
evs.append(self.mk_average_water_event(obj))
225+
pass # TODO, cl/min?
226+
elif unit == 'U':
227+
evs.append(self._mk_uncalib_power_event(obj))
228+
elif unit == 'I':
229+
pass # invalid data / sample failed
230+
231+
if obj.get('voltage') is not None:
232+
evs.append(self._mk_voltage_event(obj))
233+
234+
if obj.get('batteryMicrovolt') is not None:
235+
evs.append(self._mk_battery_event(obj))
236+
else:
237+
print(obj)
238+
239+
for ev in evs:
240+
ev['mac'] = obj.get('mac')
241+
if obj.get('starttime'):
242+
ev['starttime_utc'] = obj.get('starttime')
243+
if obj.get('via'):
244+
ev['via'] = obj.get('via')
245+
246+
return evs
247+
248+
def _mk_average_power_event(self, obj):
249+
ev = {
250+
'event': 'average_power',
251+
'watts': obj.get('power'),
252+
'duration_s': obj.get('duration'),
253+
'summation_joules': obj.get('summation'),
254+
}
255+
if obj.get('device') == 'plug':
256+
ev['volts'] = obj.get('voltage')
257+
ev['current'] = obj.get('current')
258+
ev['active_current'] = obj.get('active_current')
259+
ev['reactive_current'] = obj.get('reactive_current')
260+
return ev
261+
262+
def _mk_uncalib_power_event(self, obj):
263+
ev = {
264+
'event': 'uncalibrated_power',
265+
'value': obj.get('power'),
266+
'duration_s': obj.get('duration'),
267+
}
268+
return ev
269+
270+
def _mk_voltage_event(self, obj):
271+
return {
272+
'event': 'voltage',
273+
'volts': obj.get('voltage'),
274+
}
275+
276+
def _mk_battery_event(self, obj):
277+
return {
278+
'event': 'battery_level',
279+
'volts': float(obj.get('batteryMicrovolt'))/1000000.0,
280+
}
281+
282+
283+
### Supporting classes ###
284+
285+
class _Device:
286+
def __init__(self, mac, typ, via):
287+
self.mac = mac
288+
self.type = typ
289+
self.via = via
290+
self.subscribed = False
291+
self._last_active = datetime.now(timezone.utc)
292+
293+
def mark_active(self):
294+
self._last_active = datetime.now(timezone.utc)
295+
296+
def has_expired(self):
297+
now = datetime.now(timezone.utc)
298+
delta = now - self._last_active
299+
return delta.total_seconds() > EXPIRY_TIMEOUT_S
300+
301+
class _Timer:
302+
def __init__(self, interval_s, callback):
303+
self._terminate = False
304+
self._interval = interval_s
305+
self._callback = callback
306+
asyncio.create_task(self._run())
307+
308+
def terminate(self):
309+
self._terminate = True
310+
311+
async def _run(self):
312+
while not self._terminate:
313+
await asyncio.sleep(self._interval)
314+
await self._callback()

0 commit comments

Comments
 (0)