|
| 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