Skip to content

Commit 2360ef2

Browse files
committed
Merge remote-tracking branch 'origin/main' into formatting-changes
# Conflicts: # src/powersensor_local/__init__.py # src/powersensor_local/devices.py # src/powersensor_local/events.py # src/powersensor_local/listener.py # src/powersensor_local/plugevents.py # src/powersensor_local/rawfirehose.py # src/powersensor_local/rawplug.py
2 parents 46aff27 + 5605815 commit 2360ef2

File tree

10 files changed

+338
-318
lines changed

10 files changed

+338
-318
lines changed

README.md

Lines changed: 18 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -3,28 +3,25 @@
33
A small package to interface with the network-local event streams available on
44
Powersensor devices.
55

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
8-
to all Powersensor gateway devices (plugs) on the network, and provides a
9-
uniform event stream from all devices (including sensors relaying their data
10-
via the gateways).
6+
Two different high-level abstractions are provided. The first is the PlugApi,
7+
which provides access to the event stream from a single Powersensor Plug. The
8+
plug may be relaying data for sensors as well, which will also be included
9+
in the said event stream. The PlugApi abstraction is ideal when used together
10+
with Zeroconf/mDNS discovery (services '_powersensor._udp.local' and
11+
'_powersensor._tcp.local'). Note that actual Zeroconf/mDNS discovery
12+
functionality is not included here.
1113

12-
The main API is in `powersensor_local.devices' via the PowersensorDevices
13-
class, which provides an abstracted view of the discovered Powersensor devices
14-
on the local network.
14+
The second abstraction is the PowersensorDevices class, which uses the legacy
15+
discovery mechanism (as opposed to mDNS) to discover the plugs, and then
16+
aggregates all the event streams into a single callback. Internally it
17+
relies on the PlugApi as well.
1518

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.
19+
There are also some small utilities included,`ps-plugevents` and `ps-rawplug`
20+
showcasing the use of the first interface approach, and `ps-events` the latter.
2421
.
2522
The `ps-events` is effectively a consumer of the the PowersensorDevices event
26-
stream which dumps all events to standard out, while, `ps-rawfirehose`
27-
is a debugging aid which dumps the lower-level event streams from each
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.
23+
stream and dumps all events to standard out. Similary, `ps-plugevents` shows
24+
the event stream from a single plug (plus whatever it might be relaying for),
25+
and `ps-rawplug` shows the raw event stream from the plug. Note that the format
26+
of the raw events is not guaranteed to be stable; only the interface provided
27+
by PlugApi is.

pyproject.toml

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,6 @@ Issues = "https://github.com/DiUS/python-powersensor_local/issues"
2222

2323
[project.scripts]
2424
ps-events = "powersensor_local.events:app"
25-
ps-rawfirehose = "powersensor_local.rawfirehose:app"
2625
ps-rawplug = "powersensor_local.rawplug:app"
2726
ps-plugevents = "powersensor_local.plugevents:app"
2827

src/powersensor_local/__init__.py

Lines changed: 26 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -4,34 +4,47 @@
44
devices on the local network.
55
66
The recommended approach is to use mDNS to discover plugs via their service
7-
"_powersensor._tcp.local", and then instantiate a PlugApi to obtain the event
8-
stream from each plug.
7+
"_powersensor._udp.local" (or "_powersensor._tcp.local" for TCP transport), and
8+
then instantiate a PlugApi to obtain the event stream from each plug. Note
9+
that the plugs are only capable of handling a single TCP connection at a time,
10+
so UDP is the preferred transport. Up to 5 concurrent subscriptions are
11+
supported over UDP. The interfaces provided by PlugListenerUdp and
12+
PlugListenerTCP are identical; switching between them should be trivial.
913
1014
A legacy abstraction is also provided via PowersensorDevices, which uses
11-
an older way of discovering plugs.
15+
an older way of discovering plugs, and then funnels all the event streams
16+
through a single callback.
1217
13-
Lower-level interfaces are available in the PlugListener and
14-
PowersensorListener classed, though they are not recommended for general use.
18+
Lower-level interfaces are available in the PlugListenerUdp and PlugListenerTcp
19+
classes, though they are not recommended for general use.
1520
1621
Additionally a convience abstraction for translating some of the events into
1722
a household view is available in VirtualHousehold.
1823
1924
Quick overview:
2025
• PlugApi is the recommended API layer
21-
• PlugListener is the lower-level abstraction used by PlugApi
26+
• PlugListenerUdp is the UDP lower-level abstraction used by PlugApi
27+
• PlugListenerTcp is the TCP lower-level abstraction used by PlugApi
2228
• PowersensorDevices is the legacy main API layer
23-
PowersensorListener provides a (legacy) lower-level abstraction
29+
LegadyDiscovery provides access to the legacy discovery mechanism
2430
• VirtualHousehold can be used to translate events into a household view
2531
2632
The 'plugevents' and 'rawplug' modules are helper utilities provided as
2733
debug aids, which get installed under the names ps-plugevents and ps-rawplug
28-
respectively. There are also the legacy 'events' and 'rawfirehose' debug aids
29-
which get installed under the names ps-events and ps-rawfirehose respectively.
34+
respectively. There is also the legacy 'events' debug aid which get installed
35+
nder the names ps-events, and offers up the events from PowersensorDevices.
3036
"""
31-
__all__ = [ 'devices', 'listener', 'plug_api', 'plug_listener', 'virtual_household', 'PlugApi' ]
32-
__version__ = "2.0.0"
37+
__all__ = [
38+
'devices',
39+
'legacy_discovery',
40+
'plug_api',
41+
'plug_listener_tcp',
42+
'plug_listener_udp',
43+
'virtual_household'
44+
]
3345
from .devices import PowersensorDevices
34-
from .listener import PowersensorListener
46+
from .legacy_discovery import LegacyDiscovery
3547
from .plug_api import PlugApi
36-
from .plug_listener import PlugListener
48+
from .plug_listener_tcp import PlugListenerTcp
49+
from .plug_listener_udp import PlugListenerUdp
3750
from .virtual_household import VirtualHousehold

src/powersensor_local/devices.py

Lines changed: 90 additions & 104 deletions
Original file line numberDiff line numberDiff line change
@@ -1,86 +1,63 @@
11
import asyncio
2-
from datetime import datetime, timezone
3-
2+
import json
43
import sys
5-
from pathlib import Path
64

5+
from datetime import datetime, timezone
6+
from pathlib import Path
77
project_root = str(Path(__file__).parents[1])
88
if project_root not in sys.path:
99
sys.path.append(project_root)
1010

11-
12-
from powersensor_local.listener import PowersensorListener
11+
from powersensor_local.legacy_discovery import LegacyDiscovery
12+
from powersensor_local.plug_api import PlugApi
1313
from powersensor_local.xlatemsg import translate_raw_message
14+
1415
EXPIRY_CHECK_INTERVAL_S = 30
1516
EXPIRY_TIMEOUT_S = 5 * 60
1617

17-
18-
def _make_events(obj, relayer):
19-
evs = []
20-
kvs = translate_raw_message(obj, relayer)
21-
for key, ev in kvs.items():
22-
ev['event'] = key
23-
evs.append(ev)
24-
25-
return evs
26-
27-
2818
class PowersensorDevices:
2919
"""Abstraction interface for the unified event stream from all Powersensor
3020
devices on the local network.
3121
"""
3222

33-
def __init__(self, broadcast_address='<broadcast>'):
23+
def __init__(self, bcast_addr='<broadcast>'):
3424
"""Creates a fresh instance, without scanning for devices."""
35-
3625
self._event_cb = None
37-
self._ps = PowersensorListener(broadcast_address)
26+
self._discovery = LegacyDiscovery(bcast_addr)
3827
self._devices = dict()
3928
self._timer = None
29+
self._plug_apis = dict()
4030

41-
async def start(self, async_event_callback):
31+
async def start(self, async_event_cb):
4232
"""Registers the async event callback function and starts the scan
4333
of the local network to discover present devices. The callback is
4434
of the form
4535
46-
Parameters:
47-
-----------
48-
async_event_callback : Callable
49-
50-
A callable asynchronous method for handling json messages. Example::
36+
async def yourcallback(event: dict)
5137
52-
async def your_callback(event: dict):
53-
pass
38+
Known events:
5439
40+
scan_complete:
41+
Indicates the discovery of Powersensor devices has completed.
42+
Emitted in response to start() and rescan() calls.
43+
The number of found gateways (plugs) is reported.
5544
56-
Known Events:
57-
-------------
58-
* scan_complete
45+
{ event: "scan_complete", gateway_count: N }
5946
60-
Indicates the discovery of Powersensor devices has completed.
61-
Emitted in response to start() and rescan() calls.
62-
The number of found gateways (plugs) is reported.::
47+
device_found:
48+
A new device found on the network.
49+
The order found devices are announced is not fixed.
6350
64-
{ event: "scan_complete", gateway_count: N }
65-
66-
* device_found
67-
68-
A new device found on the network.
69-
The order found devices are announced is not fixed.::
70-
71-
{ event: "device_found",
72-
device_type: "plug" or "sensor",
73-
mac: "...",
74-
}
51+
{ event: "device_found",
52+
device_type: "plug" or "sensor",
53+
mac: "...",
54+
}
7555
76-
An optional field named "via" is present for sensor devices, and
77-
shows the MAC address of the gateway the sensor is communicating
78-
via.
56+
device_lost:
57+
A device appears to no longer be present on the network.
7958
80-
* device_lost
81-
A device appears to no longer be present on the network.::
59+
{ event: "device_lost", mac: "..." }
8260
83-
{ event: "device_lost", mac: "..." }
8461
8562
Additionally, all events described in xlatemsg.translate_raw_message
8663
may be issued. The event name is inserted into the field 'event'.
@@ -91,22 +68,22 @@ async def your_callback(event: dict):
9168
on the network, but are instead detected when they relay data through
9269
a plug via long-range radio.
9370
"""
94-
95-
self._event_cb = async_event_callback
96-
await self._on_scanned(await self._ps.scan())
71+
self._event_cb = async_event_cb
72+
await self._on_scanned(await self._discovery.scan())
9773
self._timer = self._Timer(EXPIRY_CHECK_INTERVAL_S, self._on_timer)
98-
return len(self._ips)
74+
return len(self._plug_apis)
9975

10076
async def rescan(self):
10177
"""Performs a fresh scan of the network to discover added devices,
10278
or devices which have changed their IP address for some reason."""
103-
await self._on_scanned(await self._ps.scan())
79+
await self._on_scanned(await self._discovery.scan())
10480

10581
async def stop(self):
10682
"""Stops the event streaming and disconnects from the devices.
10783
To restart the event streaming, call start() again."""
108-
await self._ps.unsubscribe()
109-
await self._ps.stop()
84+
for plug in self._plug_apis.values():
85+
await plug.disconnect()
86+
self._plug_apis = dict()
11087
self._event_cb = None
11188
if self._timer:
11289
self._timer.terminate()
@@ -124,69 +101,78 @@ def unsubscribe(self, mac):
124101
if device:
125102
device.subscribed = False
126103

127-
async def _on_scanned(self, ips):
128-
self._ips = ips
129-
if self._event_cb:
130-
ev = {
131-
'event': 'scan_complete',
132-
'gateway_count': len(ips),
133-
}
134-
await self._event_cb(ev)
135-
136-
await self._ps.subscribe(self._on_msg)
104+
async def _emit_if_subscribed(self, ev, obj):
105+
if self._event_cb is None:
106+
return
107+
device = self._devices.get(obj.get('mac'))
108+
if device is not None and device.subscribed:
109+
obj['event'] = ev
110+
await self._event_cb(obj)
137111

138-
async def _on_msg(self, obj):
139-
mac = obj.get('mac')
140-
if mac and not self._devices.get(mac):
141-
typ = obj.get('device')
142-
via = obj.get('via')
143-
await self._add_device(mac, typ, via)
144-
145-
device = self._devices[mac]
146-
device.mark_active()
147-
148-
if self._event_cb and device.subscribed:
149-
relayer = obj.get('via') or mac
150-
evs = _make_events(obj, relayer)
151-
if len(evs) > 0:
152-
for ev in evs:
153-
await self._event_cb(ev)
112+
async def _reemit(self, ev, obj):
113+
mac = obj['mac']
114+
device = self._devices.get(mac)
115+
if device is not None:
116+
device.mark_active()
117+
118+
if ev == 'now_relaying_for':
119+
await self._add_device(mac, 'sensor')
120+
else:
121+
await self._emit_if_subscribed(ev, obj)
122+
123+
async def _on_scanned(self, found):
124+
for device in found:
125+
mac = device['id']
126+
ip = device['ip']
127+
if not mac in self._devices:
128+
await self._add_device(mac, 'plug')
129+
api = PlugApi(mac, ip)
130+
self._plug_apis[mac] = api
131+
api.subscribe('average_flow', self._reemit)
132+
api.subscribe('average_power', self._reemit)
133+
api.subscribe('average_power_components', self._reemit)
134+
api.subscribe('battery_level', self._reemit)
135+
api.subscribe('exception', self._reemit)
136+
api.subscribe('now_relaying_for', self._reemit)
137+
api.subscribe('radio_signal_quality', self._reemit)
138+
api.subscribe('summation_energy', self._reemit)
139+
api.subscribe('summation_volume', self._reemit)
140+
api.connect()
141+
142+
await self._event_cb({
143+
'event': 'scan_complete',
144+
'gateway_count': len(found),
145+
})
154146

155147
async def _on_timer(self):
156148
devices = list(self._devices.values())
157149
for device in devices:
158150
if device.has_expired():
159151
await self._remove_device(device.mac)
160152

161-
async def _add_device(self, mac, typ, via):
162-
self._devices[mac] = self._Device(mac, typ, via)
163-
if self._event_cb:
164-
ev = {
165-
'event': 'device_found',
166-
'device_type': typ,
167-
'mac': mac,
168-
}
169-
if via:
170-
ev['via'] = via
171-
await self._event_cb(ev)
153+
async def _add_device(self, mac, typ):
154+
if mac in self._devices:
155+
return
156+
self._devices[mac] = self._Device(mac)
157+
await self._event_cb({
158+
'event': 'device_found',
159+
'mac': mac,
160+
'device_type:': typ,
161+
})
172162

173163
async def _remove_device(self, mac):
174-
if self._devices.get(mac):
164+
if mac in self._devices:
175165
self._devices.pop(mac)
176-
if self._event_cb:
177-
ev = {
178-
'event': 'device_lost',
179-
'mac': mac
180-
}
181-
await self._event_cb(ev)
166+
await self._event_cb({
167+
'event': 'device_lost',
168+
'mac': mac,
169+
})
182170

183171
### Supporting classes ###
184172

185173
class _Device:
186-
def __init__(self, mac, typ, via):
174+
def __init__(self, mac):
187175
self.mac = mac
188-
self.type = typ
189-
self.via = via
190176
self.subscribed = False
191177
self._last_active = datetime.now(timezone.utc)
192178

0 commit comments

Comments
 (0)