Skip to content

Commit 5605815

Browse files
committed
Switch PowersensorDevices to use PlugApi internally.
The legacy discovery process has been made standalone, and the old PowersensorListener class nuked. This will reduce the ongoing maintenance effort, hopefully.
1 parent 2acb13e commit 5605815

8 files changed

Lines changed: 160 additions & 286 deletions

File tree

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: 9 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -12,10 +12,11 @@
1212
PlugListenerTCP are identical; switching between them should be trivial.
1313
1414
A legacy abstraction is also provided via PowersensorDevices, which uses
15-
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.
1617
17-
Lower-level interfaces are available in the PlugListenerUdp, PlugListenerTcp and
18-
PowersensorListener classes, 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.
1920
2021
Additionally a convience abstraction for translating some of the events into
2122
a household view is available in VirtualHousehold.
@@ -25,24 +26,24 @@
2526
• PlugListenerUdp is the UDP lower-level abstraction used by PlugApi
2627
• PlugListenerTcp is the TCP lower-level abstraction used by PlugApi
2728
• PowersensorDevices is the legacy main API layer
28-
PowersensorListener provides a (legacy) lower-level abstraction
29+
LegadyDiscovery provides access to the legacy discovery mechanism
2930
• VirtualHousehold can be used to translate events into a household view
3031
3132
The 'plugevents' and 'rawplug' modules are helper utilities provided as
3233
debug aids, which get installed under the names ps-plugevents and ps-rawplug
33-
respectively. There are also the legacy 'events' and 'rawfirehose' debug aids
34-
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.
3536
"""
3637
__all__ = [
3738
'devices',
38-
'listener',
39+
'legacy_discovery',
3940
'plug_api',
4041
'plug_listener_tcp',
4142
'plug_listener_udp',
4243
'virtual_household'
4344
]
4445
from .devices import PowersensorDevices
45-
from .listener import PowersensorListener
46+
from .legacy_discovery import LegacyDiscovery
4647
from .plug_api import PlugApi
4748
from .plug_listener_tcp import PlugListenerTcp
4849
from .plug_listener_udp import PlugListenerUdp

src/powersensor_local/devices.py

Lines changed: 72 additions & 68 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,16 @@
11
import asyncio
22
import json
3+
import sys
34

45
from datetime import datetime, timezone
6+
from pathlib import Path
7+
project_root = str(Path(__file__).parents[1])
8+
if project_root not in sys.path:
9+
sys.path.append(project_root)
510

6-
from .listener import PowersensorListener
7-
from .xlatemsg import translate_raw_message
11+
from powersensor_local.legacy_discovery import LegacyDiscovery
12+
from powersensor_local.plug_api import PlugApi
13+
from powersensor_local.xlatemsg import translate_raw_message
814

915
EXPIRY_CHECK_INTERVAL_S = 30
1016
EXPIRY_TIMEOUT_S = 5 * 60
@@ -17,9 +23,10 @@ class PowersensorDevices:
1723
def __init__(self, bcast_addr='<broadcast>'):
1824
"""Creates a fresh instance, without scanning for devices."""
1925
self._event_cb = None
20-
self._ps = PowersensorListener(bcast_addr)
26+
self._discovery = LegacyDiscovery(bcast_addr)
2127
self._devices = dict()
2228
self._timer = None
29+
self._plug_apis = dict()
2330

2431
async def start(self, async_event_cb):
2532
"""Registers the async event callback function and starts the scan
@@ -46,10 +53,6 @@ async def yourcallback(event: dict)
4653
mac: "...",
4754
}
4855
49-
An optional field named "via" is present for sensor devices, and
50-
shows the MAC address of the gateway the sensor is communicating
51-
via.
52-
5356
device_lost:
5457
A device appears to no longer be present on the network.
5558
@@ -66,20 +69,21 @@ async def yourcallback(event: dict)
6669
a plug via long-range radio.
6770
"""
6871
self._event_cb = async_event_cb
69-
await self._on_scanned(await self._ps.scan())
72+
await self._on_scanned(await self._discovery.scan())
7073
self._timer = self._Timer(EXPIRY_CHECK_INTERVAL_S, self._on_timer)
71-
return len(self._ips)
74+
return len(self._plug_apis)
7275

7376
async def rescan(self):
7477
"""Performs a fresh scan of the network to discover added devices,
7578
or devices which have changed their IP address for some reason."""
76-
await self._on_scanned(await self._ps.scan())
79+
await self._on_scanned(await self._discovery.scan())
7780

7881
async def stop(self):
7982
"""Stops the event streaming and disconnects from the devices.
8083
To restart the event streaming, call start() again."""
81-
await self._ps.unsubscribe()
82-
await self._ps.stop()
84+
for plug in self._plug_apis.values():
85+
await plug.disconnect()
86+
self._plug_apis = dict()
8387
self._event_cb = None
8488
if self._timer:
8589
self._timer.terminate()
@@ -97,78 +101,78 @@ def unsubscribe(self, mac):
97101
if device:
98102
device.subscribed = False
99103

100-
async def _on_scanned(self, ips):
101-
self._ips = ips
102-
if self._event_cb:
103-
ev = {
104-
'event': 'scan_complete',
105-
'gateway_count': len(ips),
106-
}
107-
await self._event_cb(ev)
108-
109-
await self._ps.subscribe(self._on_msg)
110-
111-
async def _on_msg(self, obj):
112-
mac = obj.get('mac')
113-
if mac and not self._devices.get(mac):
114-
typ = obj.get('device')
115-
via = obj.get('via')
116-
await self._add_device(mac, typ, via)
117-
118-
device = self._devices[mac]
119-
device.mark_active()
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)
120111

121-
if self._event_cb and device.subscribed:
122-
relayer = obj.get('via') or mac
123-
evs = self._mk_events(obj, relayer)
124-
if len(evs) > 0:
125-
for ev in evs:
126-
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+
})
127146

128147
async def _on_timer(self):
129148
devices = list(self._devices.values())
130149
for device in devices:
131150
if device.has_expired():
132151
await self._remove_device(device.mac)
133152

134-
async def _add_device(self, mac, typ, via):
135-
self._devices[mac] = self._Device(mac, typ, via)
136-
if self._event_cb:
137-
ev = {
138-
'event': 'device_found',
139-
'device_type': typ,
140-
'mac': mac,
141-
}
142-
if via:
143-
ev['via'] = via
144-
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+
})
145162

146163
async def _remove_device(self, mac):
147-
if self._devices.get(mac):
164+
if mac in self._devices:
148165
self._devices.pop(mac)
149-
if self._event_cb:
150-
ev = {
151-
'event': 'device_lost',
152-
'mac': mac
153-
}
154-
await self._event_cb(ev)
155-
156-
def _mk_events(self, obj, relayer):
157-
evs = []
158-
kvs = translate_raw_message(obj, relayer)
159-
for key, ev in kvs.items():
160-
ev['event'] = key
161-
evs.append(ev)
162-
163-
return evs
166+
await self._event_cb({
167+
'event': 'device_lost',
168+
'mac': mac,
169+
})
164170

165171
### Supporting classes ###
166172

167173
class _Device:
168-
def __init__(self, mac, typ, via):
174+
def __init__(self, mac):
169175
self.mac = mac
170-
self.type = typ
171-
self.via = via
172176
self.subscribed = False
173177
self._last_active = datetime.now(timezone.utc)
174178

src/powersensor_local/events.py

Lines changed: 5 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -9,14 +9,12 @@
99
import signal
1010
import sys
1111

12-
if __name__ == "__main__":
13-
# Make CLI runnable from source tree
14-
package_source_path = os.path.dirname(os.path.dirname(__file__))
15-
sys.path.insert(0, package_source_path)
16-
__package__ = "powersensor_local"
17-
18-
from .devices import PowersensorDevices
12+
from pathlib import Path
13+
project_root = str(Path(__file__).parents[1])
14+
if project_root not in sys.path:
15+
sys.path.append(project_root)
1916

17+
from powersensor_local.devices import PowersensorDevices
2018

2119
exiting = False
2220
devices = None
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
import asyncio
2+
import json
3+
import socket
4+
5+
PORT = 49476
6+
7+
class LegacyDiscovery(asyncio.DatagramProtocol):
8+
"""The legacy alternative to using mDNS discovery."""
9+
10+
def __init__(self, broadcast_addr = '<broadcast>'):
11+
"""Initialises a new discovery object.
12+
Optionally takes a specific broadcast address to use.
13+
"""
14+
super().__init__()
15+
self._dst_addr = broadcast_addr
16+
17+
async def scan(self, timeout_sec = 2.0):
18+
"""Scans the local network for discoverable devices.
19+
Returns the list of devices found, with each device represented
20+
in the format:
21+
22+
{
23+
"ip": "n.n.n.n",
24+
"id": "aabbccddeeff",
25+
}
26+
"""
27+
self._found = dict()
28+
29+
loop = asyncio.get_running_loop()
30+
transport, _ = await loop.create_datagram_endpoint(
31+
self.protocol_factory,
32+
family = socket.AF_INET,
33+
local_addr=('0.0.0.0', 0)
34+
)
35+
sock = transport.get_extra_info('socket')
36+
sock.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1)
37+
message = b'discover()\n'
38+
while timeout_sec > 0:
39+
transport.sendto(message, (self._dst_addr, PORT))
40+
await asyncio.sleep(0.5)
41+
timeout_sec -= 0.5
42+
43+
transport.close()
44+
return list(self._found.values())
45+
46+
def protocol_factory(self):
47+
return self
48+
49+
def datagram_received(self, data, addr):
50+
try:
51+
response = json.loads(data.decode('utf-8'))
52+
ip = response['ip']
53+
mac = response['mac']
54+
self._found[mac] = { "ip": ip, "id": mac }
55+
except (json.JSONDecodeError, KeyError):
56+
pass

0 commit comments

Comments
 (0)