powersensor-local 1.0.2__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- powersensor_local-1.0.2/.gitignore +2 -0
- powersensor_local-1.0.2/LICENSE +10 -0
- powersensor_local-1.0.2/PKG-INFO +33 -0
- powersensor_local-1.0.2/README.md +17 -0
- powersensor_local-1.0.2/pyproject.toml +29 -0
- powersensor_local-1.0.2/src/powersensor_local/__init__.py +17 -0
- powersensor_local-1.0.2/src/powersensor_local/devices.py +321 -0
- powersensor_local-1.0.2/src/powersensor_local/events.py +60 -0
- powersensor_local-1.0.2/src/powersensor_local/listener.py +116 -0
- powersensor_local-1.0.2/src/powersensor_local/rawfirehose.py +60 -0
|
@@ -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
|
+
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
Metadata-Version: 2.3
|
|
2
|
+
Name: powersensor-local
|
|
3
|
+
Version: 1.0.2
|
|
4
|
+
Summary: Network-local (non-cloud) interface for Powersensor devices
|
|
5
|
+
Project-URL: Repository, https://github.com/DiUS/python-powersensor_local.git
|
|
6
|
+
Project-URL: Homepage, https://github.com/DiUS/python-powersensor_local
|
|
7
|
+
Project-URL: Issues, https://github.com/DiUS/python-powersensor_local/issues
|
|
8
|
+
Author-email: Jade Mattsson <jmattsson@dius.com.au>
|
|
9
|
+
Classifier: Intended Audience :: Developers
|
|
10
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
11
|
+
Classifier: Operating System :: OS Independent
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: Topic :: Home Automation
|
|
14
|
+
Requires-Python: >=3.11
|
|
15
|
+
Description-Content-Type: text/markdown
|
|
16
|
+
|
|
17
|
+
# Powersensor (local)
|
|
18
|
+
|
|
19
|
+
A small package to interface with the network-local event streams available on
|
|
20
|
+
Powersensor devices. Specifically, this package abstracts away the connections
|
|
21
|
+
to all Powersensor gateway devices (plugs) on the network, and provides a
|
|
22
|
+
uniform event stream from all devices (including sensors relaying their data
|
|
23
|
+
via the gateways).
|
|
24
|
+
|
|
25
|
+
The main API is in `powersensor_local.devices' via the PowersensorDevices
|
|
26
|
+
class, which provides an abstracted view of the discovered Powersensor devices
|
|
27
|
+
on the local network.
|
|
28
|
+
|
|
29
|
+
There are also two small utilities included, `ps-events` and `ps-rawfirehose`.
|
|
30
|
+
The former is effectively a consumer of the the PowersensorDevices event
|
|
31
|
+
stream which dumps all events to standard out. The latter, `ps-rawfirehose`
|
|
32
|
+
is a debugging aid which dumps the lower-level event streams from each
|
|
33
|
+
Powersensor gateway.
|
|
@@ -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.
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "powersensor-local"
|
|
3
|
+
version = "1.0.2"
|
|
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/python-powersensor_local.git"
|
|
20
|
+
Homepage = "https://github.com/DiUS/python-powersensor_local"
|
|
21
|
+
Issues = "https://github.com/DiUS/python-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"
|
|
@@ -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
|
|
@@ -0,0 +1,321 @@
|
|
|
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
|
+
The start function returns the number of found gateway plugs.
|
|
129
|
+
Powersensor devices aren't found directly as they are typically not
|
|
130
|
+
on the network, but are instead detected when they relay data through
|
|
131
|
+
a plug via long-range radio.
|
|
132
|
+
"""
|
|
133
|
+
self._event_cb = async_event_cb
|
|
134
|
+
await self._on_scanned(await self._ps.scan())
|
|
135
|
+
self._timer = self._Timer(EXPIRY_CHECK_INTERVAL_S, self._on_timer)
|
|
136
|
+
return len(self._ips)
|
|
137
|
+
|
|
138
|
+
async def rescan(self):
|
|
139
|
+
"""Performs a fresh scan of the network to discover added devices,
|
|
140
|
+
or devices which have changed their IP address for some reason."""
|
|
141
|
+
await self._on_scanned(await self._ps.scan())
|
|
142
|
+
|
|
143
|
+
async def stop(self):
|
|
144
|
+
"""Stops the event streaming and disconnects from the devices.
|
|
145
|
+
To restart the event streaming, call start() again."""
|
|
146
|
+
await self._ps.unsubscribe()
|
|
147
|
+
await self._ps.stop()
|
|
148
|
+
self._event_cb = None
|
|
149
|
+
if self._timer:
|
|
150
|
+
self._timer.terminate()
|
|
151
|
+
self._timer = None
|
|
152
|
+
|
|
153
|
+
def subscribe(self, mac):
|
|
154
|
+
"""Subscribes to events from the device with the given MAC address."""
|
|
155
|
+
device = self._devices.get(mac)
|
|
156
|
+
if device:
|
|
157
|
+
device.subscribed = True
|
|
158
|
+
|
|
159
|
+
def unsubscribe(self, mac):
|
|
160
|
+
"""Unsubscribes from events from the given MAC address."""
|
|
161
|
+
device = self._devices.get(mac)
|
|
162
|
+
if device:
|
|
163
|
+
device.subscribed = False
|
|
164
|
+
|
|
165
|
+
async def _on_scanned(self, ips):
|
|
166
|
+
self._ips = ips
|
|
167
|
+
if self._event_cb:
|
|
168
|
+
ev = {
|
|
169
|
+
'event': 'scan_complete',
|
|
170
|
+
'gateway_count': len(ips),
|
|
171
|
+
}
|
|
172
|
+
await self._event_cb(ev)
|
|
173
|
+
|
|
174
|
+
await self._ps.subscribe(self._on_msg)
|
|
175
|
+
|
|
176
|
+
async def _on_msg(self, obj):
|
|
177
|
+
mac = obj.get('mac')
|
|
178
|
+
if mac and not self._devices.get(mac):
|
|
179
|
+
typ = obj.get('device')
|
|
180
|
+
via = obj.get('via')
|
|
181
|
+
await self._add_device(mac, typ, via)
|
|
182
|
+
|
|
183
|
+
device = self._devices[mac]
|
|
184
|
+
device.mark_active()
|
|
185
|
+
|
|
186
|
+
if self._event_cb and device.subscribed:
|
|
187
|
+
evs = self._mk_events(obj)
|
|
188
|
+
if len(evs) > 0:
|
|
189
|
+
for ev in evs:
|
|
190
|
+
await self._event_cb(ev)
|
|
191
|
+
|
|
192
|
+
async def _on_timer(self):
|
|
193
|
+
devices = list(self._devices.values())
|
|
194
|
+
for device in devices:
|
|
195
|
+
if device.has_expired():
|
|
196
|
+
await self._remove_device(device.mac)
|
|
197
|
+
|
|
198
|
+
async def _add_device(self, mac, typ, via):
|
|
199
|
+
self._devices[mac] = self._Device(mac, typ, via)
|
|
200
|
+
if self._event_cb:
|
|
201
|
+
ev = {
|
|
202
|
+
'event': 'device_found',
|
|
203
|
+
'device_type': typ,
|
|
204
|
+
'mac': mac,
|
|
205
|
+
}
|
|
206
|
+
if via:
|
|
207
|
+
ev['via'] = via
|
|
208
|
+
await self._event_cb(ev)
|
|
209
|
+
|
|
210
|
+
async def _remove_device(self, mac):
|
|
211
|
+
if self._devices.get(mac):
|
|
212
|
+
self._devices.pop(mac)
|
|
213
|
+
if self._event_cb:
|
|
214
|
+
ev = {
|
|
215
|
+
'event': 'device_lost',
|
|
216
|
+
'mac': mac
|
|
217
|
+
}
|
|
218
|
+
await self._event_cb(ev)
|
|
219
|
+
|
|
220
|
+
### Event formatting ###
|
|
221
|
+
|
|
222
|
+
def _mk_events(self, obj):
|
|
223
|
+
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
|
+
pass # TODO, cl/min?
|
|
232
|
+
elif unit == 'U':
|
|
233
|
+
evs.append(self._mk_uncalib_power_event(obj))
|
|
234
|
+
elif unit == 'I':
|
|
235
|
+
pass # invalid data / sample failed
|
|
236
|
+
|
|
237
|
+
if obj.get('voltage') is not None:
|
|
238
|
+
evs.append(self._mk_voltage_event(obj))
|
|
239
|
+
|
|
240
|
+
if obj.get('batteryMicrovolt') is not None:
|
|
241
|
+
evs.append(self._mk_battery_event(obj))
|
|
242
|
+
else:
|
|
243
|
+
print(obj)
|
|
244
|
+
|
|
245
|
+
for ev in evs:
|
|
246
|
+
ev['mac'] = obj.get('mac')
|
|
247
|
+
if obj.get('starttime'):
|
|
248
|
+
ev['starttime_utc'] = obj.get('starttime')
|
|
249
|
+
if obj.get('via'):
|
|
250
|
+
ev['via'] = obj.get('via')
|
|
251
|
+
|
|
252
|
+
return evs
|
|
253
|
+
|
|
254
|
+
def _mk_average_power_event(self, obj):
|
|
255
|
+
ev = {
|
|
256
|
+
'event': 'average_power',
|
|
257
|
+
'watts': obj.get('power'),
|
|
258
|
+
'duration_s': obj.get('duration'),
|
|
259
|
+
'summation_joules': obj.get('summation'),
|
|
260
|
+
}
|
|
261
|
+
if obj.get('device') == 'plug':
|
|
262
|
+
ev['volts'] = obj.get('voltage')
|
|
263
|
+
ev['current'] = obj.get('current')
|
|
264
|
+
ev['active_current'] = obj.get('active_current')
|
|
265
|
+
ev['reactive_current'] = obj.get('reactive_current')
|
|
266
|
+
return ev
|
|
267
|
+
|
|
268
|
+
def _mk_uncalib_power_event(self, obj):
|
|
269
|
+
ev = {
|
|
270
|
+
'event': 'uncalibrated_power',
|
|
271
|
+
'value': obj.get('power'),
|
|
272
|
+
'duration_s': obj.get('duration'),
|
|
273
|
+
}
|
|
274
|
+
return ev
|
|
275
|
+
|
|
276
|
+
def _mk_voltage_event(self, obj):
|
|
277
|
+
return {
|
|
278
|
+
'event': 'voltage',
|
|
279
|
+
'volts': obj.get('voltage'),
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
def _mk_battery_event(self, obj):
|
|
283
|
+
return {
|
|
284
|
+
'event': 'battery_level',
|
|
285
|
+
'volts': float(obj.get('batteryMicrovolt'))/1000000.0,
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
### Supporting classes ###
|
|
290
|
+
|
|
291
|
+
class _Device:
|
|
292
|
+
def __init__(self, mac, typ, via):
|
|
293
|
+
self.mac = mac
|
|
294
|
+
self.type = typ
|
|
295
|
+
self.via = via
|
|
296
|
+
self.subscribed = False
|
|
297
|
+
self._last_active = datetime.now(timezone.utc)
|
|
298
|
+
|
|
299
|
+
def mark_active(self):
|
|
300
|
+
self._last_active = datetime.now(timezone.utc)
|
|
301
|
+
|
|
302
|
+
def has_expired(self):
|
|
303
|
+
now = datetime.now(timezone.utc)
|
|
304
|
+
delta = now - self._last_active
|
|
305
|
+
return delta.total_seconds() > EXPIRY_TIMEOUT_S
|
|
306
|
+
|
|
307
|
+
class _Timer:
|
|
308
|
+
def __init__(self, interval_s, callback):
|
|
309
|
+
self._terminate = False
|
|
310
|
+
self._interval = interval_s
|
|
311
|
+
self._callback = callback
|
|
312
|
+
self._task = asyncio.create_task(self._run())
|
|
313
|
+
|
|
314
|
+
def terminate(self):
|
|
315
|
+
self._terminate = True
|
|
316
|
+
self._task.cancel()
|
|
317
|
+
|
|
318
|
+
async def _run(self):
|
|
319
|
+
while not self._terminate:
|
|
320
|
+
await asyncio.sleep(self._interval)
|
|
321
|
+
await self._callback()
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
|
|
3
|
+
"""Utility script for accessing the full event stream from all network-local
|
|
4
|
+
Powersensor devices. Intended for debugging use only. Please use the proper
|
|
5
|
+
interface in devices.py rather than parsing the output from this script."""
|
|
6
|
+
|
|
7
|
+
import asyncio
|
|
8
|
+
import os
|
|
9
|
+
import signal
|
|
10
|
+
import sys
|
|
11
|
+
|
|
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
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
exiting = False
|
|
22
|
+
devices = None
|
|
23
|
+
|
|
24
|
+
async def do_exit():
|
|
25
|
+
global exiting
|
|
26
|
+
global devices
|
|
27
|
+
if devices != None:
|
|
28
|
+
await devices.stop()
|
|
29
|
+
exiting = True
|
|
30
|
+
|
|
31
|
+
async def on_msg(obj):
|
|
32
|
+
print(obj)
|
|
33
|
+
global devices
|
|
34
|
+
if obj['event'] == 'device_found':
|
|
35
|
+
devices.subscribe(obj['mac'])
|
|
36
|
+
|
|
37
|
+
async def main():
|
|
38
|
+
global devices
|
|
39
|
+
devices = PowersensorDevices()
|
|
40
|
+
|
|
41
|
+
# Signal handler for Ctrl+C
|
|
42
|
+
def handle_sigint(signum, frame):
|
|
43
|
+
signal.signal(signal.SIGINT, signal.SIG_DFL)
|
|
44
|
+
asyncio.create_task(do_exit())
|
|
45
|
+
|
|
46
|
+
signal.signal(signal.SIGINT, handle_sigint)
|
|
47
|
+
|
|
48
|
+
await devices.start(on_msg)
|
|
49
|
+
|
|
50
|
+
# Keep the event loop running until Ctrl+C is pressed
|
|
51
|
+
while not exiting:
|
|
52
|
+
await asyncio.sleep(1)
|
|
53
|
+
|
|
54
|
+
def app():
|
|
55
|
+
loop = asyncio.new_event_loop()
|
|
56
|
+
loop.run_until_complete(main())
|
|
57
|
+
loop.stop()
|
|
58
|
+
|
|
59
|
+
if __name__ == "__main__":
|
|
60
|
+
app()
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import json
|
|
3
|
+
import socket
|
|
4
|
+
import time
|
|
5
|
+
|
|
6
|
+
PORT = 49476
|
|
7
|
+
DISCOVERY_TIMEOUT_S = 2
|
|
8
|
+
|
|
9
|
+
class PowersensorListener(asyncio.DatagramProtocol):
|
|
10
|
+
def __init__(self, bcast_addr='<broadcast>'):
|
|
11
|
+
"""Initialises a listener object.
|
|
12
|
+
Optionally takes the broadcast address to use.
|
|
13
|
+
"""
|
|
14
|
+
self._known_addresses = dict()
|
|
15
|
+
self._connections = {}
|
|
16
|
+
self._tasks = dict()
|
|
17
|
+
self._callback = None
|
|
18
|
+
self._exiting = False
|
|
19
|
+
self._bcast = bcast_addr
|
|
20
|
+
|
|
21
|
+
async def scan(self):
|
|
22
|
+
"""Scans the local network for discoverable devices with a timeout.
|
|
23
|
+
Returns the list of IP addresses of the discovered gateways (plugs).
|
|
24
|
+
"""
|
|
25
|
+
self._known_addresses.clear()
|
|
26
|
+
loop = asyncio.get_running_loop()
|
|
27
|
+
transport, _ = await loop.create_datagram_endpoint(
|
|
28
|
+
self.protocol_factory,
|
|
29
|
+
family=socket.AF_INET,
|
|
30
|
+
local_addr=('0.0.0.0', 0))
|
|
31
|
+
sock = transport.get_extra_info('socket')
|
|
32
|
+
sock.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1)
|
|
33
|
+
message = 'discover()\n'.encode('utf-8')
|
|
34
|
+
|
|
35
|
+
timeout = DISCOVERY_TIMEOUT_S
|
|
36
|
+
while timeout > 0:
|
|
37
|
+
transport.sendto(message, (self._bcast, PORT))
|
|
38
|
+
await asyncio.sleep(0.5)
|
|
39
|
+
timeout -= 0.5
|
|
40
|
+
|
|
41
|
+
transport.close()
|
|
42
|
+
return self._known_addresses.keys()
|
|
43
|
+
|
|
44
|
+
def protocol_factory(self):
|
|
45
|
+
return self
|
|
46
|
+
|
|
47
|
+
def datagram_received(self, data, addr):
|
|
48
|
+
try:
|
|
49
|
+
response = json.loads(data.decode('utf-8'))
|
|
50
|
+
ip = response['ip']
|
|
51
|
+
self._known_addresses[ip] = response['mac']
|
|
52
|
+
except (json.JSONDecodeError, KeyError):
|
|
53
|
+
pass
|
|
54
|
+
|
|
55
|
+
async def subscribe(self, callback):
|
|
56
|
+
"""Subscribes to updates from known devices."""
|
|
57
|
+
self._callback = callback
|
|
58
|
+
|
|
59
|
+
for ip, mac in self._known_addresses.items():
|
|
60
|
+
if not self._tasks.get(ip):
|
|
61
|
+
self._tasks[ip] = asyncio.create_task(
|
|
62
|
+
self._connect_to_device(ip, mac))
|
|
63
|
+
|
|
64
|
+
async def _send_subscribe(self, writer):
|
|
65
|
+
writer.write(b'subscribe(60)\n')
|
|
66
|
+
await writer.drain()
|
|
67
|
+
|
|
68
|
+
async def _processline(self, ip, mac, reader, writer):
|
|
69
|
+
data = await reader.readline()
|
|
70
|
+
if data != b'' and data != b'\n':
|
|
71
|
+
try:
|
|
72
|
+
message = json.loads(data.decode('utf-8'))
|
|
73
|
+
typ = message['type']
|
|
74
|
+
if typ == 'subscription':
|
|
75
|
+
if message['subtype'] == 'warning':
|
|
76
|
+
await self._send_subscribe(writer)
|
|
77
|
+
elif typ == 'discovery':
|
|
78
|
+
pass
|
|
79
|
+
else:
|
|
80
|
+
if message.get('device') == 'sensor':
|
|
81
|
+
message['via'] = mac
|
|
82
|
+
await self._callback(message)
|
|
83
|
+
except (json.decoder.JSONDecodeError) as ex:
|
|
84
|
+
print(f"JSON error {ex} from {data}")
|
|
85
|
+
|
|
86
|
+
async def _connect_to_device(self, ip, mac, backoff=0):
|
|
87
|
+
"""Connects to a single device and handles reconnections."""
|
|
88
|
+
backoff += 1
|
|
89
|
+
try:
|
|
90
|
+
reader, writer = await asyncio.open_connection(ip, PORT)
|
|
91
|
+
self._connections[ip] = (reader, writer)
|
|
92
|
+
|
|
93
|
+
await self._send_subscribe(writer)
|
|
94
|
+
backoff = 1
|
|
95
|
+
|
|
96
|
+
while not self._exiting:
|
|
97
|
+
await self._processline(ip, mac, reader, writer)
|
|
98
|
+
|
|
99
|
+
except (ConnectionResetError, asyncio.TimeoutError):
|
|
100
|
+
# Handle disconnection and retry with exponential backoff
|
|
101
|
+
del self._connections[ip]
|
|
102
|
+
await asyncio.sleep(min(5 * 60, 2**backoff * 1))
|
|
103
|
+
return await self._connect_to_device(ip, mac, backoff)
|
|
104
|
+
|
|
105
|
+
async def unsubscribe(self):
|
|
106
|
+
"""Unsubscribes from all devices."""
|
|
107
|
+
for (reader, writer) in self._connections.values():
|
|
108
|
+
writer.close()
|
|
109
|
+
|
|
110
|
+
self._connections = {}
|
|
111
|
+
|
|
112
|
+
async def stop(self):
|
|
113
|
+
"""Disconnects from all devices."""
|
|
114
|
+
self._exiting = True
|
|
115
|
+
for task in self._tasks.values():
|
|
116
|
+
await task
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
|
|
3
|
+
"""Utility script for accessing the raw plug subscription data from all
|
|
4
|
+
network-local Powersensor devices. Intended for advanced debugging use only.
|
|
5
|
+
For all other uses, please see the API in devices.py"""
|
|
6
|
+
|
|
7
|
+
import asyncio
|
|
8
|
+
import os
|
|
9
|
+
import signal
|
|
10
|
+
import sys
|
|
11
|
+
|
|
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 .listener import PowersensorListener
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
exiting = False
|
|
22
|
+
ps = None
|
|
23
|
+
|
|
24
|
+
async def do_exit():
|
|
25
|
+
global exiting
|
|
26
|
+
global ps
|
|
27
|
+
if ps != None:
|
|
28
|
+
await ps.unsubscribe()
|
|
29
|
+
await ps.stop()
|
|
30
|
+
exiting = True
|
|
31
|
+
|
|
32
|
+
async def on_msg(data):
|
|
33
|
+
print(data)
|
|
34
|
+
|
|
35
|
+
async def main():
|
|
36
|
+
global ps
|
|
37
|
+
ps = PowersensorListener()
|
|
38
|
+
|
|
39
|
+
# Signal handler for Ctrl+C
|
|
40
|
+
def handle_sigint(signum, frame):
|
|
41
|
+
signal.signal(signal.SIGINT, signal.SIG_DFL)
|
|
42
|
+
asyncio.create_task(do_exit())
|
|
43
|
+
|
|
44
|
+
signal.signal(signal.SIGINT, handle_sigint)
|
|
45
|
+
|
|
46
|
+
# Scan for devices and subscribe upon completion
|
|
47
|
+
await ps.scan()
|
|
48
|
+
await ps.subscribe(on_msg)
|
|
49
|
+
|
|
50
|
+
# Keep the event loop running until Ctrl+C is pressed
|
|
51
|
+
while not exiting:
|
|
52
|
+
await asyncio.sleep(1)
|
|
53
|
+
|
|
54
|
+
def app():
|
|
55
|
+
loop = asyncio.new_event_loop()
|
|
56
|
+
loop.run_until_complete(main())
|
|
57
|
+
loop.stop()
|
|
58
|
+
|
|
59
|
+
if __name__ == "__main__":
|
|
60
|
+
app()
|