diff --git a/docs/source/api.rst b/docs/source/api.rst index 1114e16..2641b65 100644 --- a/docs/source/api.rst +++ b/docs/source/api.rst @@ -5,15 +5,5 @@ API Reference :members: :undoc-members: :show-inheritance: + :inherited-members: -Submodules -__________ - -.. autosummary:: - :toctree: submodules - :recursive: - - devices - plug_api - virtual_household - xlatemsg diff --git a/docs/source/conf.py b/docs/source/conf.py index d777346..e7b02ca 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -84,3 +84,6 @@ "Miscellaneous", ), ] + +# We don't need module names prefixed everywhere; we're only one module +add_module_names = False diff --git a/docs/source/usage.rst b/docs/source/usage.rst index 0cb8c8c..5fcfb31 100644 --- a/docs/source/usage.rst +++ b/docs/source/usage.rst @@ -8,11 +8,62 @@ Here's a simple example of how to use powersensor_local: .. code-block:: python - import powersensor_local + import asyncio + import logging + import signal - # Add your usage examples here + from powersensor_local import PowersensorZeroconfDevices, Event + from types import FrameType -Advanced Usage --------------- + # Exceptions escaping the callback handler can be logged + logger = logging.getLogger(__name__) -More detailed examples and advanced features will be documented here. + # Create a zeroconf-based devices view, with logging enabled + devs = PowersensorZeroconfDevices(logger = logger) + + # Sentinel for exiting cleanly + exiting = False + + # All events from (and pertaining to) Powersensor devices end up here + async def handle_event(ev: Event) -> None: + event = ev.get("event") + mac = ev.get("mac") + match event: + case "device_found": + print(f"Device found: {mac}") + # Subscribe to events from this device + devs.subscribe(str(mac)) + case "device_lost": + print(f"Device disappeared: {mac}") + # Unsubscription happens automatically + case "average_power": + print(f"Power ({mac}): {ev['watts']}") + # other events... + + # Main sequence startup + async def main() -> None: + # Commence mDNS discovery + await devs.start(handle_event) + # Everything happen asynchronously, so wait until time to exit + while not exiting: + await asyncio.sleep(0.5) + # Be nice and unsubscribe and disconnect on exit + await devs.stop() + + # Signal handler to flag that it's time to exit + def flag_exit(signum: int, frame: FrameType) -> None: + global exiting + exiting = True + signal.signal(signal.SIGINT, signal.SIG_DFL) + + if __name__ == "__main__": + # Register ^C handler for graceful exit + signal.signal(signal.SIGINT, flag_exit) + # Run the main with an event loop + asyncio.run(main()) + +Further reading +--------------- + +Refer to the example utilities `zc_events.py`, `events.py`, `plugevents.py`, +and `rawplug.py` for additional usage examples. diff --git a/pyproject.toml b/pyproject.toml index c83b81a..7389c8d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,9 +5,10 @@ description = "Network-local (non-cloud) interface for Powersensor devices" authors = [ { name = "Jade Mattsson", email = "jmattsson@dius.com.au" }, { name = "Lake Bookman", email = "lbookman@dius.com.au" }, + { name = "Joo Aun Saw", email = "jasaw@dius.com.au" }, ] readme = "README.md" -requires-python = ">=3.11" +requires-python = ">=3.12" classifiers = [ "Programming Language :: Python :: 3", "Intended Audience :: Developers", @@ -36,9 +37,9 @@ zeroconf = [ "zeroconf>=0.38.0", ] docs = [ - "sphinx>=7.0.0", - "sphinx-rtd-theme>=1.3.0", - "sphinx-autodoc-typehints>=1.24.0", + "sphinx>=9.0.0", + "sphinx-rtd-theme>=3.1.0", + "sphinx-autodoc-typehints>=3.13.9", ] [tool.hatch.version] diff --git a/src/powersensor_local/__init__.py b/src/powersensor_local/__init__.py index 2668de6..1fa764a 100644 --- a/src/powersensor_local/__init__.py +++ b/src/powersensor_local/__init__.py @@ -1,33 +1,49 @@ -"""Direct (non-cloud) interface to Powersensor devices +"""Direct (non-cloud) interface to Powersensor devices. This package contains various abstractions for interacting with Powersensor devices on the local network. +The library provides two types of abstractions: per-plug event streams, and a +shared-across-all-devices event stream. Neither is "best", it depends on the +particular use case. Device discovery can be done in two ways. The preferred +way is via mDNS/Zeroconf. There is also a legacy method relying on a simpler, +custom, discovery protocol, but there should be no reason to use it for new +projects. + +The simplest approach is to use the shared event stream, and do discovery via +Zeroconf. This is available out-of-the-box in the +:class:`PowersensorZeroconfDevices` class, which encapsulates the Zeroconf +machinations and merging of plug event streams. + +A legacy abstraction is also provided via +:class:`PowersensorLegacyDevices`, which uses an older way of discovering plugs. +It is more fragile than Zeroconf, but may be slightly more responsive in +certain situations, as the user is fully in control over when to scan for +devices. In general it is not recommended for new use. + +If wanting individual event streams from each plug, the :class:`PlugApi` class +is the one to use. Device discovery is left up to the user in this case. + The recommended approach is to use mDNS to discover plugs via their service "_powersensor._udp.local" (or "_powersensor._tcp.local" for TCP transport), and then instantiate a PlugApi to obtain the event stream from each plug. Note that the plugs are only capable of handling a single TCP connection at a time, so UDP is the preferred transport. Up to 5 concurrent subscriptions are -supported over UDP. The interfaces provided by PlugListenerUdp and -PlugListenerTCP are identical; switching between them should be trivial. - -A legacy abstraction is also provided via PowersensorDevices, which uses -an older way of discovering plugs, and then funnels all the event streams -through a single callback. - -Lower-level interfaces are available in the PlugListenerUdp and PlugListenerTcp -classes, though they are not recommended for general use. +supported over UDP. Internally, the :class:`PlugApi` uses either +:class:`PlugListenerUdp` or :class:`PlugListenerTcp` for connecting to the +plugs. The interfaces are identical; switching between UDP and TCP should be +invisible to the user of the :class:`PlugApi`. Additionally, a convenience abstraction for translating some of the events into -a household view is available in VirtualHousehold. +a household view is available in :class:`VirtualHousehold`. Quick overview: - - PlugApi is the recommended API layer - - PlugListenerUdp is the UDP lower-level abstraction used by PlugApi - - PlugListenerTcp is the TCP lower-level abstraction used by PlugApi - - PowersensorDevices is the legacy main API layer - - LegacyDiscovery provides access to the legacy discovery mechanism - - VirtualHousehold can be used to translate events into a household view + - :class:`PowersensorZeroconfDevices` is the shared event stream abstraction with Zeroconf discovery. + - :class:`PowersensorLegacyDevices` provides the legacy discovery mechanism together with a shared event stream. + - :class:`PlugApi` is the recommended API layer for per-plug event streams. + - :class:`PlugListenerUdp` is the UDP lower-level abstraction used by PlugApi. + - :class:`PlugListenerTcp` is the TCP lower-level abstraction used by PlugApi. + - :class:`VirtualHousehold` can be used to translate events into a household view. The 'plugevents' and 'rawplug' modules are helper utilities provided as debug aids, which get installed under the names ps-plugevents and ps-rawplug @@ -36,7 +52,7 @@ Similarly, 'zc_events' offers up events from PowersensorZeroconfDevices, installed as 'ps-zcevents', again, for debug use only. -Type hints for emitted data are available in Event/Events/Message. +Type hints for emitted data are available in :type:`Event`/:type:`Events`/:type:`Message`. """ __all__ = [ 'VirtualHousehold', @@ -47,12 +63,18 @@ 'PowersensorDevices', 'PowersensorLegacyDevices', 'PowersensorZeroconfDevices', + 'AsyncEventCallback', 'Event', 'Events', 'Message', + 'CommonEventsDocs', ] __version__ = "2.4.0rc3" -from .devices import PowersensorDevices, PowersensorLegacyDevices +from .devices import ( + AsyncEventCallback, + PowersensorDevices, + PowersensorLegacyDevices +) from .legacy_discovery import LegacyDiscovery from .plug_api import PlugApi from .plug_listener_tcp import PlugListenerTcp @@ -60,3 +82,4 @@ from .virtual_household import VirtualHousehold from .zeroconf_devices import PowersensorZeroconfDevices from .xlatemsg import Event, Events, Message +from .common_events_docs import CommonEventsDocs diff --git a/src/powersensor_local/async_event_emitter.py b/src/powersensor_local/async_event_emitter.py index 6bc35b9..8129805 100644 --- a/src/powersensor_local/async_event_emitter.py +++ b/src/powersensor_local/async_event_emitter.py @@ -2,7 +2,7 @@ import logging from typing import Any, Awaitable, Callable -_OpenCallback = Callable[..., Awaitable[None]] +type _OpenCallback = Callable[..., Awaitable[None]] class AsyncEventEmitter: """Small helper class for pub/sub functionality with async handlers. diff --git a/src/powersensor_local/common_events_docs.py b/src/powersensor_local/common_events_docs.py new file mode 100644 index 0000000..bca5e5f --- /dev/null +++ b/src/powersensor_local/common_events_docs.py @@ -0,0 +1,168 @@ +class CommonEventsDocs: + """ + The following is a list of common events that may be emitted. + + The first few are discovery events, while the rest are data events. + + **device_found** + A device has been discovered or re-discovered. + + Note that due to device hardware limitations, role information is NOT + reliably available at this time, and therefore not included in this + message. There are situations where role information will never become + available, and therefore the API can make no promises otherwise. If a + user requires role information, they must manage that themselves and + also provide a mechanism for handling the situation of a device not + being able to provide role information in the first place. If a device + does supply a role at any point, it should be considered authoritative + and override any user provided value. The event data comprises: + + - "event": "device_found" + - "mac": The MAC address of the device. + - "device_type": One of "plug" or "sensor". + + **device_lost** + A device has definitively disappeared (plug) or been inactive long + enough to expire (sensor). The event data comprises: + + - "event": "device_lost" + - "mac": The MAC address of the device. + + **average_power** + An event denoting the average power seen over a short duration, such + as 1 or 30 seconds typically. Both plugs and powersensors may originate + these. The event data comprises: + + - "event": "average_power" + - "mac": The MAC address of the device. + - "role": The assigned role of the device, if known. + - "via": The id of the relaying plug, if from a sensor. + - "starttime_utc": Seconds since the Unix Epoch, in UTC. + - "duration_s": The number of seconds the reading is calculated over. + - "watts": The average power, in Watts. May be negative for e.g. + solar sensors and house sensors when exporting solar to the grid. + + **average_power_components** + An plug-only event providing additional information on the power + components feeding into the power measurement. To be read in + conjunction with the "average_power" message with the same + starttime_utc value. Comprises: + + - "event": "average_power_components" + - "mac": The MAC address of the device. + - "role": The assigned role of the device, if known. + - "starttime_utc": Seconds since the Unix Epoch, in UTC. + - "apparent_current": The apparent current, in Amperes. + - "active_current": The active current component, in Amperes. + - "reactive_current": The reactive current component, in Amperes. + - "volts": The mains voltage as seen by the plug, in Volts. + + **summation_energy** + An event reporting an energy summation value. Issued for both plugs + and sensors. Comprises: + + - "event": "summation_energy" + - "mac": The MAC address of the device. + - "role": The assigned role of the device, if known. + - "via": The id of the relaying plug, if from a sensor. + - "starttime_utc": Seconds since the Unix Epoch, in UTC. + - "summation_joules": The summation value, in Joules (Watt seconds). + This value may go backwards (and even become negative) if solar + export is present. The summation may increment or decrement + depending on whether energy is being imported from or exported + to the grid. + - "summation_resettime_utc": A timestamp denoting the last time the + summation value (may have) reset. The initial value on a reset is + NOT zero. Summation values from before the reset time can not be + safely compared or diffed with ones from after the reset time. + The format is the same as for "starttime_utc" — seconds since the + Unix Epoch, in UTC. + + **average_flow** + An event denoting the average flow rate seen over a short duration, + such as 1 or 30 seconds typically. Only originated by water sensors. + The event data comprises: + + - "event": "average_flow" + - "mac": The MAC address of the device. + - "role": The assigned role of the device, if known. + - "via": The id of the relaying plug. + - "starttime_utc": Seconds since the Unix Epoch, in UTC. + - "duration_s": The number of seconds the reading is calculated over. + - "litres_per_minute": The average flow detected, in litres/min.. + + **summation_volume** + An event reporting a volume summation value. Issued only for water + sensors. Comprises: + + - "event": "summation_volume" + - "mac": The MAC address of the device. + - "role": The assigned role of the device, if known. + - "via": The id of the relaying plug. + - "starttime_utc": Seconds since the Unix Epoch, in UTC. + - "summation_litres": The summation value, in litres. + - "summation_resettime_utc": A timestamp denoting the last time the + summation value (may have) reset. The initial value on a reset is + NOT zero. Summation values from before the reset time can not be + safely compared or diffed with ones from after the reset time. + The format is the same as for "starttime_utc" — seconds since the + Unix Epoch, in UTC. + + **uncalibrated_average_reading** + An event denoting an reading with unknown unit, for an average over a + short duration, such as 1 or 30 seconds typically. Issued by + powersensors, prior to calibration. The event data comprises: + + - "event": "uncalibrated_average_reading" + - "mac": The MAC address of the device. + - "role": The assigned role of the device, if known. + - "via": The id of the relaying plug. + - "starttime_utc": Seconds since the Unix Epoch, in UTC. + - "duration_s": The number of seconds the reading is calculated over. + - "value": The value, in unspecified units. The only valid thing + to use this value for is display it relative to other uncalibrated + values from the same device. The magnitude of the value is an + indication of the strength of the signal seen by the sensor, but + the unit is most definitely NOT in Watts. For most purposes, this + event can (and should be) ignored. Once the backend has successfully + calibrated the sensor against one or more plugs, these events + will be replaced by "average_power" events instead. + + **battery_level** + An event reporting the battery level of a sensor. The event data + comprises: + + - "event": "battery_level" + - "mac": The MAC address of the device. + - "role": The assigned role of the device, if known. + - "via": The id of the relaying plug. + - "starttime_utc": Seconds since the Unix Epoch, in UTC. + - "volts": The current battery level, in Volts. Sensors operate + on 3.7V nominally, with a fully charged battery at around 4.2V. + Precise battery curves vary individually. It is intentional that + we do not attempt to map these to a percentage value here, as + between individual differences and environmental conditions they + are bound to be inaccurate. Short of characterising each battery + in its environment, any such mapping will be inaccurate. It can + be argued that users wishing a simple percentage display are best + off using a simple linear extrapolation across the middle part of + the curve, e.g. 3.3V and 4.15V. It's not entirely accurate, but + it's also not useless. + + **radio_signal_quality** + An event reporting radio signal quality for a sensor. Note that this + is for the long-range radio comms with the plug, not for the WiFi + signal. The event comprises: + + - "event": "radio_signal_quality" + - "mac": The MAC address of the device. + - "role": The assigned role of the device, if known. + - "via": The id of the relaying plug. + - "starttime_utc": Seconds since the Unix Epoch, in UTC. + - "duration_s": The number of seconds the reading is calculated over. + - "average_rssi": The average of the RSSI in messages received + during the reporting window. Values below -90 are considered poor + reception. + - "last_rssi": The most recent RSSI value. + """ + pass diff --git a/src/powersensor_local/devices.py b/src/powersensor_local/devices.py index 86e37ab..1804035 100644 --- a/src/powersensor_local/devices.py +++ b/src/powersensor_local/devices.py @@ -11,7 +11,7 @@ from .plug_api import PlugApi from .xlatemsg import Event -_AsyncCallback = Callable[[Event], Coroutine[None, None, None]] +type AsyncEventCallback = Callable[[Event], Coroutine[None, None, None]] EXPIRY_CHECK_INTERVAL_S = 30 EXPIRY_TIMEOUT_S = 5 * 60 @@ -43,37 +43,7 @@ class _PowersensorDevicesBase: _plug_discovered(mac, ip, port) when a plug appears and _plug_lost(mac) when one disappears. - Known events - ------------ - All subclasses emit the following lifecycle events: - - **device_found** - A device has been discovered or re-discovered. - - Note that due to device hardware limitations, role information is NOT - reliably available at this time, and therefore not included in this - message. There are situations where role information will never become - available, and therefore the API can make no promises otherwise. If a - user requires role information, they must manage that themselves and - also provide a mechanism for handling the situation of a device not - being able to provide role information in the first place. If a device - does supply a role at any point, it should be considered authoritative - and override any user provided value. - - ``{ event: "device_found", device_type: "plug"|"sensor", mac: "..." }`` - - **device_lost** - A device has definitively disappeared (plug) or been inactive long - enough to expire (sensor). - ``{ event: "device_lost", mac: "..." }`` - - Additionally, all events from ``xlatemsg.translate_raw_message`` may be - issued for subscribed devices, with the event name inserted into the - ``event`` field. Known measurement events include: - - ``average_flow``, ``average_power``, ``average_power_components``, - ``battery_level``, ``now_relaying_for``, - ``radio_signal_quality``, ``summation_energy``, ``summation_volume``. + The common events described in :class:`CommonEventsDocs` may be issued. When ``relay_now_relaying_for=True`` the raw ``now_relaying_for`` wire message is forwarded to the callback immediately after the synthesised @@ -107,7 +77,7 @@ def __init__( library emits debug/warning/error messages via this logger. When None (default) the library is completely silent. """ - self._event_cb: _AsyncCallback | None = None + self._event_cb: AsyncEventCallback | None = None self._devices: dict[str, '_PowersensorDevicesBase._Device'] = {} self._plug_apis: dict[str, PlugApi] = {} self._timer: '_PowersensorDevicesBase._Timer | None' = None @@ -300,6 +270,13 @@ class PowersensorLegacyDevices(_PowersensorDevicesBase): This is the original PowersensorDevices implementation, renamed to make room for PowersensorZeroconfDevices. The name PowersensorDevices is kept as an alias for backwards compatibility. + + See :class:`CommonEventsDocs` for the full list of known events. + Additionally emits: + + **scan_complete** + Indicates discovery has completed. + ``{ event: "scan_complete", gateway_count: N }`` """ def __init__( @@ -312,21 +289,9 @@ def __init__( super().__init__(relay_now_relaying_for=relay_now_relaying_for, logger=logger) self._discovery = LegacyDiscovery(bcast_addr) - async def start(self, async_event_cb: _AsyncCallback) -> int: + async def start(self, async_event_cb: AsyncEventCallback) -> int: """Register the async event callback and scan the local network. - The callback has the form:: - - async def yourcallback(event: dict) -> None - - See _PowersensorDevicesBase for the full list of known events. - - Additionally emits: - - **scan_complete** - Indicates discovery has completed. - ``{ event: "scan_complete", gateway_count: N }`` - Returns the number of gateway plugs found. """ if self._timer is not None: diff --git a/src/powersensor_local/plugevents.py b/src/powersensor_local/plugevents.py index 862f7c3..ce6bf2c 100755 --- a/src/powersensor_local/plugevents.py +++ b/src/powersensor_local/plugevents.py @@ -14,11 +14,11 @@ # pylint: disable=C0413 from powersensor_local.plug_api import PlugApi from powersensor_local.abstract_event_handler import AbstractEventHandler -from powersensor_local.xlatemsg import Message +from powersensor_local import Event LOGGER = logging.getLogger(__name__) -async def print_event_and_message(event: str, message: Message) -> None: +async def print_event_and_message(event: str, message: Event) -> None: """Callback for printing event data.""" print(event, message) diff --git a/src/powersensor_local/xlatemsg.py b/src/powersensor_local/xlatemsg.py index 2a7ab0e..b5bb01a 100644 --- a/src/powersensor_local/xlatemsg.py +++ b/src/powersensor_local/xlatemsg.py @@ -1,10 +1,10 @@ """Common message translation support.""" -Message = dict[str, str|int|float|bool] -Event = Message -Events = dict[str, Event] -Selector = tuple[str, str, bool, int] | tuple[str, str, bool] -Selectors = list[Selector] +type Message = dict[str, str|int|float|bool] +type Event = dict[str, str|int|float|bool] +type Events = dict[str, Event] +type Selector = tuple[str, str, bool, int] | tuple[str, str, bool] +type Selectors = list[Selector] _MAC_TS_ROLE : Selectors = [ ('mac', 'mac', True), diff --git a/src/powersensor_local/zeroconf_devices.py b/src/powersensor_local/zeroconf_devices.py index 1f79ce0..a6afa56 100644 --- a/src/powersensor_local/zeroconf_devices.py +++ b/src/powersensor_local/zeroconf_devices.py @@ -42,9 +42,9 @@ from logging import Logger from typing import Any, Callable, Coroutine -from .devices import _AsyncCallback, _LogLevel, _PowersensorDevicesBase +from .devices import AsyncEventCallback, _LogLevel, _PowersensorDevicesBase -_InternalCallback = Coroutine[None, None, None] +type _InternalCallback = Coroutine[None, None, None] _SERVICE_TYPE_UDP = '_powersensor._udp.local.' @@ -77,13 +77,7 @@ class PowersensorZeroconfDevices(_PowersensorDevicesBase): The callback signature is the same as PowersensorLegacyDevices.start(): ``async def callback(event: dict) -> None`` - Lifecycle events emitted: - - **device_found** - ``{ event: "device_found", device_type: "plug"|"sensor", mac: "..." }`` - - **device_lost** - ``{ event: "device_lost", mac: "..." }`` + Refer to the :class:`CommonEventsDocs` for events that may be emitted. Note: ``scan_complete`` is NOT emitted — mDNS browsing is continuous and has no defined completion point. @@ -131,7 +125,7 @@ def __init__( # Lifecycle # ------------------------------------------------------------------ - async def start(self, async_event_cb: _AsyncCallback) -> None: + async def start(self, async_event_cb: AsyncEventCallback) -> None: """Register the event callback and start the mDNS service browser. The browser is event-driven; no polling loop is started here.