diff --git a/.github/workflows/deploy-docs.yml b/.github/workflows/deploy-docs.yml new file mode 100755 index 0000000..b9bc791 --- /dev/null +++ b/.github/workflows/deploy-docs.yml @@ -0,0 +1,56 @@ +name: Deploy Documentation to GitHub Pages + +on: + push: + branches: [ "formatting-changes" ] + + workflow_dispatch: + +permissions: + contents: read + pages: write + id-token: write + +concurrency: + group: "pages" + cancel-in-progress: false + +jobs: + build: + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v5 + + - name: Set up Python + uses: actions/setup-python@v6 + with: + python-version: '3.12' + + - name: Install Hatch + run: pip install hatch + + - name: Install dependencies + run: | + pip install -e ".[docs]" + + - name: Build documentation + run: | + cd docs + make html + + - name: Setup Pages + uses: actions/configure-pages@v4 + + - name: Upload artifact + uses: actions/upload-pages-artifact@v3 + with: + path: 'docs/build/html' + + deploy: + runs-on: ubuntu-latest + needs: build + steps: + - name: Deploy to GitHub Pages + id: deployment + uses: actions/deploy-pages@v4 diff --git a/.gitignore b/.gitignore index d22b40f..362faf8 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,4 @@ __pycache__ .*.swp +.idea/ +*.DS_Store diff --git a/docs/.gitignore b/docs/.gitignore new file mode 100644 index 0000000..567609b --- /dev/null +++ b/docs/.gitignore @@ -0,0 +1 @@ +build/ diff --git a/docs/Makefile b/docs/Makefile new file mode 100644 index 0000000..73bd483 --- /dev/null +++ b/docs/Makefile @@ -0,0 +1,14 @@ +# Minimal makefile for Sphinx documentation + +SPHINXOPTS ?= +SPHINXBUILD ?= sphinx-build +SOURCEDIR = source +BUILDDIR = build + +help: + @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) + +.PHONY: help Makefile + +%: Makefile + @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) diff --git a/docs/make.bat b/docs/make.bat new file mode 100644 index 0000000..a787cd5 --- /dev/null +++ b/docs/make.bat @@ -0,0 +1,35 @@ +@ECHO OFF + +pushd %~dp0 + +REM Command file for Sphinx documentation + +if "%SPHINXBUILD%" == "" ( + set SPHINXBUILD=sphinx-build +) +set SOURCEDIR=source +set BUILDDIR=build + +if "%1" == "" goto help + +%SPHINXBUILD% >NUL 2>NUL +if errorlevel 9009 ( + echo. + echo.The 'sphinx-build' command was not found. Make sure you have Sphinx + echo.installed, then set the SPHINXBUILD environment variable to point + echo.to the full path of the 'sphinx-build' executable. Alternatively you + echo.may add the Sphinx directory to PATH. + echo. + echo.If you don't have Sphinx installed, grab it from + echo.https://sphinx-doc.org/ + exit /b 1 +) + +%SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% +goto end + +:help +%SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% + +:end +popd diff --git a/docs/source/_static/powersensor-logo.png b/docs/source/_static/powersensor-logo.png new file mode 100644 index 0000000..86b5df5 Binary files /dev/null and b/docs/source/_static/powersensor-logo.png differ diff --git a/docs/source/api.rst b/docs/source/api.rst new file mode 100644 index 0000000..4acfc61 --- /dev/null +++ b/docs/source/api.rst @@ -0,0 +1,17 @@ +API Reference +============= + +.. automodule:: powersensor_local + :members: + :undoc-members: + :show-inheritance: + +Submodules +__________ + +.. autosummary:: + :toctree: submodules + :recursive: + + devices + listener diff --git a/docs/source/conf.py b/docs/source/conf.py new file mode 100644 index 0000000..d777346 --- /dev/null +++ b/docs/source/conf.py @@ -0,0 +1,86 @@ +"""Sphinx configuration file for powersensor_local documentation.""" + +import sys +from pathlib import Path + +# Add the package to the Python path +project_root = Path(__file__).parent.parent.parent +sys.path.insert(0, str(project_root / "src")) +print(project_root) + +# Project information +project = "powersensor_local" +copyright = "2025, DiUS" # noqa A001 +author = "Powersensor Team!" + +html_favicon = "_static/powersensor-logo.png" +html_logo = "_static/powersensor-logo.png" + +# The full version, including alpha/beta/rc tags +try: + from powersensor_local import __version__ as release +except ImportError: + release = "0.1.0" + +version = ".".join(release.split(".")[:2]) + +# Extensions +extensions = [ + "sphinx.ext.autodoc", + "sphinx.ext.viewcode", + "sphinx.ext.napoleon", + "sphinx.ext.intersphinx", + "sphinx_autodoc_typehints", + "sphinx.ext.autosummary" +] + +# Napoleon settings (for Google and NumPy style docstrings) +napoleon_google_docstring = True +napoleon_numpy_docstring = True +napoleon_include_init_with_doc = False +napoleon_include_private_with_doc = False + +# Autodoc settings +autodoc_default_options = { + "members": True, + "undoc-members": True, + "show-inheritance": True, +} + +# Intersphinx mapping +intersphinx_mapping = { + "python": ("https://docs.python.org/3", None), +} + +# HTML theme +html_theme = "sphinx_rtd_theme" +html_static_path = ["_static"] +html_css_files = [] + + +# Output file base name for HTML help builder +htmlhelp_basename = "powersensor_localdoc" + +# Options for LaTeX output +latex_elements = {} +latex_documents = [ + ("index", "powersensor_local.tex", "powersensor_local Documentation", "Your Name", "manual"), +] + +# Options for manual page output +man_pages = [ + ("index", "powersensor_local", "powersensor_local Documentation", [author], 1) +] + +# Options for Texinfo output +texinfo_documents = [ + ( + "index", + "powersensor_local", + "powersensor_local Documentation", + author, + "powersensor_local", + "One line description of project.", + "Miscellaneous", + ), +] diff --git a/docs/source/contributing.rst b/docs/source/contributing.rst new file mode 100644 index 0000000..01be185 --- /dev/null +++ b/docs/source/contributing.rst @@ -0,0 +1,38 @@ +Contributing +============ + +We welcome contributions! Here's how you can help: + +Development Setup +----------------- + +1. Fork the repository +2. Clone your fork: + + .. code-block:: bash + + git clone https://github.com/yourusername/powersensor_local.git + +3. Install development dependencies: + + .. code-block:: bash + + pip install -e ".[docs]" + + +Building Documentation +---------------------- + +.. code-block:: bash + + cd docs + make html + +The documentation will be built in `docs/build/html/`. + +Submitting Changes +------------------ + +1. Create a new branch for your changes +2. Make your changes and add tests +3. Submit a pull request diff --git a/docs/source/index.rst b/docs/source/index.rst new file mode 100644 index 0000000..ab2bd0d --- /dev/null +++ b/docs/source/index.rst @@ -0,0 +1,18 @@ +Welcome to powersensor_local's documentation! +================================================= + +.. toctree:: + :maxdepth: 2 + :caption: Contents: + + installation + usage + api + contributing + +Indices and tables +================== + +* :ref:`genindex` +* :ref:`modindex` +* :ref:`search` diff --git a/docs/source/installation.rst b/docs/source/installation.rst new file mode 100644 index 0000000..d94f44b --- /dev/null +++ b/docs/source/installation.rst @@ -0,0 +1,27 @@ +Installation +============ + +From PyPI +--------- + +.. code-block:: bash + + pip install powersensor_local + +From Source +----------- + +.. code-block:: bash + + git clone https://github.com/yourusername/powersensor_local.git + cd powersensor_local + pip install -e . + +Development Installation +------------------------ + +.. code-block:: bash + + git clone https://github.com/yourusername/powersensor_local.git + cd powersensor_local + pip install -e ".[docs]" diff --git a/docs/source/usage.rst b/docs/source/usage.rst new file mode 100644 index 0000000..0cb8c8c --- /dev/null +++ b/docs/source/usage.rst @@ -0,0 +1,18 @@ +Usage +===== + +Basic Usage +----------- + +Here's a simple example of how to use powersensor_local: + +.. code-block:: python + + import powersensor_local + + # Add your usage examples here + +Advanced Usage +-------------- + +More detailed examples and advanced features will be documented here. diff --git a/pyproject.toml b/pyproject.toml index 9a55ce3..65c5883 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,9 +1,10 @@ [project] name = "powersensor-local" -version = "2.0.1" +dynamic = ["version"] 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" }, ] readme = "README.md" requires-python = ">=3.11" @@ -28,3 +29,13 @@ ps-plugevents = "powersensor_local.plugevents:app" [build-system] requires = [ "hatchling" ] build-backend = "hatchling.build" + +[project.optional-dependencies] +docs = [ + "sphinx>=7.0.0", + "sphinx-rtd-theme>=1.3.0", + "sphinx-autodoc-typehints>=1.24.0", +] + +[tool.hatch.version] +path = "src/powersensor_local/__init__.py" diff --git a/src/powersensor_local/__init__.py b/src/powersensor_local/__init__.py index 972cb34..b4d1a9c 100644 --- a/src/powersensor_local/__init__.py +++ b/src/powersensor_local/__init__.py @@ -18,7 +18,7 @@ Lower-level interfaces are available in the PlugListenerUdp and PlugListenerTcp classes, though they are not recommended for general use. -Additionally a convience abstraction for translating some of the events into +Additionally, a convenience abstraction for translating some of the events into a household view is available in VirtualHousehold. Quick overview: @@ -26,7 +26,7 @@ • 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 -• LegadyDiscovery provides access to the legacy discovery mechanism +• LegacyDiscovery provides access to the legacy discovery mechanism • VirtualHousehold can be used to translate events into a household view The 'plugevents' and 'rawplug' modules are helper utilities provided as @@ -35,13 +35,13 @@ nder the names ps-events, and offers up the events from PowersensorDevices. """ __all__ = [ - 'devices', - 'legacy_discovery', - 'plug_api', - 'plug_listener_tcp', - 'plug_listener_udp', - 'virtual_household' + 'VirtualHousehold', + 'PlugApi', + '__version__', + 'PlugListenerTcp', + 'PlugListenerUdp' ] +__version__ = "2.0.1" from .devices import PowersensorDevices from .legacy_discovery import LegacyDiscovery from .plug_api import PlugApi diff --git a/src/powersensor_local/abstract_event_handler.py b/src/powersensor_local/abstract_event_handler.py new file mode 100644 index 0000000..d3ea063 --- /dev/null +++ b/src/powersensor_local/abstract_event_handler.py @@ -0,0 +1,90 @@ +"""Common helper for small commandline utils.""" +import asyncio +import signal +from abc import ABC, abstractmethod + +class AbstractEventHandler(ABC): + """Base class to handle signals and the asyncio loop. + + Subclasses must implement :py:meth:`on_exit` and :py:meth:`main`. The + ``run`` method starts an event loop, registers a SIGINT handler and + executes the :py:meth:`main` coroutine. The loop is stopped when a + SIGINT is received and the :py:meth:`on_exit` coroutine has finished. + """ + exiting: bool = False + @abstractmethod + async def on_exit(self): + """Called when a SIGINT is received. + + Subclasses should override this method to perform any cleanup + (e.g. closing connections, flushing buffers). It is awaited before + the handler sets :pyattr:`exiting` to ``True``. + """ + + async def _do_exit(self): + """Internal helper that runs ``on_exit`` and marks the handler as + exiting. This coroutine is scheduled by :py:meth:`__handle_sigint` + when a SIGINT signal arrives. + """ + await self.on_exit() + self.exiting = True + + @abstractmethod + async def main(self): + """Main coroutine to be executed by the event loop. + + Subclasses must implement this method. It should contain the + application's primary logic and can call :py:meth:`wait` to keep + the loop alive until a SIGINT is received. + """ + + # Signal handler for Ctrl+C + def register_sigint_handler(self): + """Register the SIGINT (Ctrl‑C) handler. + + This method sets :py:meth:`__handle_sigint` as the callback for + ``signal.SIGINT``. It should be called before :py:meth:`run` if + a custom handler is required. + """ + signal.signal(signal.SIGINT, self.__handle_sigint) + + def __handle_sigint(self, signum, frame): + """Internal SIGINT callback. + + Prints diagnostic information and schedules :py:meth:`_do_exit` + as a task in the running event loop. After the first SIGINT + the default handler is restored to allow a second Ctrl‑C to + terminate immediately. + """ + print(f"\nReceived signal: {signum}") + print(f"Signal name: {signal.Signals(signum).name}") + print(f"Interrupted at: {frame.f_code.co_filename}:{frame.f_lineno}") + signal.signal(signal.SIGINT, signal.SIG_DFL) + asyncio.create_task(self._do_exit()) + + def run(self): + """Start the event loop and execute :py:meth:`main`. + + A new event loop is created, the SIGINT handler is registered, + and :py:meth:`main` is run with ``asyncio.run``. The loop is + stopped once the coroutine completes (normally or after a SIGINT). + """ + loop = asyncio.new_event_loop() + asyncio.run(self.main()) + loop.stop() + + async def wait(self, seconds=1): + """Keep the event loop alive until a SIGINT is received. + + Parameters + ---------- + seconds : int, optional + Number of seconds to sleep between checks. The default is + ``1`` which balances responsiveness with CPU usage. + + This coroutine can be awaited by subclasses in their + :py:meth:`main` implementation to block until the handler + exits. + """ + while not self.exiting: + await asyncio.sleep(seconds) diff --git a/src/powersensor_local/async_event_emitter.py b/src/powersensor_local/async_event_emitter.py index bc9fc04..0d607f3 100644 --- a/src/powersensor_local/async_event_emitter.py +++ b/src/powersensor_local/async_event_emitter.py @@ -1,33 +1,36 @@ +"""Small helper class for pub/sub functionality with async handlers.""" +from typing import Callable + class AsyncEventEmitter: """Small helper class for pub/sub functionality with async handlers.""" def __init__(self): self._listeners = {} - def subscribe(self, evstr, cb): + def subscribe(self, event_name: str, callback: Callable): """Registers an event handler for the given event key. The handler must be async. Duplicate registrations are ignored.""" - if self._listeners.get(evstr) is None: - self._listeners[evstr] = [] - if not cb in self._listeners[evstr]: - self._listeners[evstr].append(cb) + if self._listeners.get(event_name) is None: + self._listeners[event_name] = [] + if not callback in self._listeners[event_name]: + self._listeners[event_name].append(callback) - def unsubscribe(self, evstr, cb): + def unsubscribe(self, event_name: str, callback: Callable): """Unregisters the given event handler from the given event type.""" - if self._listeners.get(evstr) is None: + if self._listeners.get(event_name) is None: return - if cb in self._listeners[evstr]: - self._listeners[evstr].remove(cb) + if callback in self._listeners[event_name]: + self._listeners[event_name].remove(callback) - async def emit(self, evstr, *args): + async def emit(self, event_name: str, *args): """Emits an event to all registered listeners for that event type. Additional arguments may be supplied with event as appropriate. Each event handler is awaited before delivering the event to the next. If an event handler raises an exception, this is funneled through to an 'exception' event being emitted. This can chain.""" - if self._listeners.get(evstr) is None: + if self._listeners.get(event_name) is None: return - for cb in self._listeners[evstr]: + for callback in self._listeners[event_name]: try: - await cb(evstr, *args) - except BaseException as e: + await callback(event_name, *args) + except BaseException as e: # pylint: disable=W0718 await self.emit('exception', e) diff --git a/src/powersensor_local/devices.py b/src/powersensor_local/devices.py index 680c026..ac42d15 100644 --- a/src/powersensor_local/devices.py +++ b/src/powersensor_local/devices.py @@ -1,16 +1,16 @@ +"""Abstraction interface for unified event stream from Powersensor devices""" import asyncio -import json import sys from datetime import datetime, timezone from pathlib import Path -project_root = str(Path(__file__).parents[1]) -if project_root not in sys.path: - sys.path.append(project_root) +PROJECT_ROOT = str(Path(__file__).parents[1]) +if PROJECT_ROOT not in sys.path: + sys.path.append(PROJECT_ROOT) +# pylint: disable=C0413 from powersensor_local.legacy_discovery import LegacyDiscovery from powersensor_local.plug_api import PlugApi -from powersensor_local.xlatemsg import translate_raw_message EXPIRY_CHECK_INTERVAL_S = 30 EXPIRY_TIMEOUT_S = 5 * 60 @@ -24,9 +24,9 @@ def __init__(self, bcast_addr=''): """Creates a fresh instance, without scanning for devices.""" self._event_cb = None self._discovery = LegacyDiscovery(bcast_addr) - self._devices = dict() + self._devices = {} self._timer = None - self._plug_apis = dict() + self._plug_apis = {} async def start(self, async_event_cb): """Registers the async event callback function and starts the scan @@ -83,7 +83,7 @@ async def stop(self): To restart the event streaming, call start() again.""" for plug in self._plug_apis.values(): await plug.disconnect() - self._plug_apis = dict() + self._plug_apis = {} self._event_cb = None if self._timer: self._timer.terminate() @@ -177,14 +177,16 @@ def __init__(self, mac): self._last_active = datetime.now(timezone.utc) def mark_active(self): + """Updates the last activity time to prevent expiry.""" self._last_active = datetime.now(timezone.utc) def has_expired(self): + """Checks whether the last activity time is past the expiry.""" now = datetime.now(timezone.utc) delta = now - self._last_active return delta.total_seconds() > EXPIRY_TIMEOUT_S - class _Timer: + class _Timer: # pylint: disable=R0903 def __init__(self, interval_s, callback): self._terminate = False self._interval = interval_s @@ -192,6 +194,7 @@ def __init__(self, interval_s, callback): self._task = asyncio.create_task(self._run()) def terminate(self): + """Disables the timer and cancels the associated task.""" self._terminate = True self._task.cancel() diff --git a/src/powersensor_local/event_buffer.py b/src/powersensor_local/event_buffer.py new file mode 100644 index 0000000..610b992 --- /dev/null +++ b/src/powersensor_local/event_buffer.py @@ -0,0 +1,74 @@ +"""A simple fixed‑size buffer that stores event dictionaries.""" +from typing import Any + + +class EventBuffer: + """A simple fixed‑size buffer that stores event dictionaries. + + Parameters + ---------- + keep : int + The maximum number of events to retain in the buffer. When a new event + is appended and this limit would be exceeded, the oldest event (the + one at index 0) is removed. + """ + def __init__(self, keep: int): + self._keep = keep + self._evs = [] + + def find_by_key(self, key: str, value: Any): + """Return the first event that contains ``key`` with the given ``value``. + + Parameters + ---------- + key : str + The dictionary key to search for. + value : Any + The value that the key must match. + + Returns + ------- + dict | None + The matching event dictionary, or ``None`` if no match is found. + """ + for ev in self._evs: + if key in ev and ev[key] == value: + return ev + return None + + def append(self, ev: dict): + """Add an event to the buffer. + + If adding the new event would exceed ``self._keep``, the oldest event + is removed to keep the buffer size bounded. + + Parameters + ---------- + ev : dict + The event dictionary to append. + """ + self._evs.append(ev) + if len(self._evs) > self._keep: + del self._evs[0] + + def evict_older(self, key: str, value: float): + """Remove events that are older than a given timestamp. + + Events are considered *older* if they contain ``key`` and its value is + less than or equal to the provided ``value``. Eviction stops as soon + as an event that does not satisfy this condition is encountered (the + buffer is ordered by insertion time). + + Parameters + ---------- + key : str + The timestamp key to inspect in each event. + value : float + The cutoff timestamp; events with timestamps <= this value are removed. + """ + while len(self._evs) > 0: + ev = self._evs[0] + if key in ev and ev[key] <= value: + del self._evs[0] + else: + return diff --git a/src/powersensor_local/events.py b/src/powersensor_local/events.py index b70c926..73bdc31 100755 --- a/src/powersensor_local/events.py +++ b/src/powersensor_local/events.py @@ -3,56 +3,48 @@ """Utility script for accessing the full event stream from all network-local Powersensor devices. Intended for debugging use only. Please use the proper interface in devices.py rather than parsing the output from this script.""" - -import asyncio -import os -import signal +import typing import sys - from pathlib import Path -project_root = str(Path(__file__).parents[1]) -if project_root not in sys.path: - sys.path.append(project_root) -from powersensor_local.devices import PowersensorDevices +PROJECT_ROOT = str(Path(__file__).parents[1]) +if PROJECT_ROOT not in sys.path: + sys.path.append(PROJECT_ROOT) -exiting = False -devices = None +# pylint: disable=C0413 +from powersensor_local.devices import PowersensorDevices +from powersensor_local.abstract_event_handler import AbstractEventHandler -async def do_exit(): - global exiting - global devices - if devices != None: - await devices.stop() - exiting = True +class EventLoopRunner(AbstractEventHandler): + """Main logic wrapper.""" + def __init__(self): + self.devices: typing.Union[PowersensorDevices, None] = PowersensorDevices() -async def on_msg(obj): - print(obj) - global devices - if obj['event'] == 'device_found': - devices.subscribe(obj['mac']) + async def on_exit(self): + if self.devices is not None: + await self.devices.stop() -async def main(): - global devices - devices = PowersensorDevices() + async def on_message(self, obj): + """Callback for printing received events.""" + print(obj) + if obj['event'] == 'device_found': + self.devices.subscribe(obj['mac']) - # Signal handler for Ctrl+C - def handle_sigint(signum, frame): - signal.signal(signal.SIGINT, signal.SIG_DFL) - asyncio.create_task(do_exit()) + async def main(self): + if self.devices is None: + self.devices = PowersensorDevices() - signal.signal(signal.SIGINT, handle_sigint) + # Signal handler for Ctrl+C + self.register_sigint_handler() - await devices.start(on_msg) + await self.devices.start(self.on_message) - # Keep the event loop running until Ctrl+C is pressed - while not exiting: - await asyncio.sleep(1) + # Keep the event loop running until Ctrl+C is pressed + await self.wait() def app(): - loop = asyncio.new_event_loop() - loop.run_until_complete(main()) - loop.stop() + """Application entry point.""" + EventLoopRunner().run() if __name__ == "__main__": app() diff --git a/src/powersensor_local/legacy_discovery.py b/src/powersensor_local/legacy_discovery.py index bb3f051..2d6cb27 100644 --- a/src/powersensor_local/legacy_discovery.py +++ b/src/powersensor_local/legacy_discovery.py @@ -1,3 +1,4 @@ +"""The legacy alternative to using mDNS discovery.""" import asyncio import json import socket @@ -13,6 +14,7 @@ def __init__(self, broadcast_addr = ''): """ super().__init__() self._dst_addr = broadcast_addr + self._found = {} async def scan(self, timeout_sec = 2.0): """Scans the local network for discoverable devices. @@ -24,7 +26,7 @@ async def scan(self, timeout_sec = 2.0): "id": "aabbccddeeff", } """ - self._found = dict() + self._found = {} loop = asyncio.get_running_loop() transport, _ = await loop.create_datagram_endpoint( @@ -44,6 +46,7 @@ async def scan(self, timeout_sec = 2.0): return list(self._found.values()) def protocol_factory(self): + """UDP protocol factory.""" return self def datagram_received(self, data, addr): diff --git a/src/powersensor_local/plug_api.py b/src/powersensor_local/plug_api.py index 7592039..3a7fed0 100644 --- a/src/powersensor_local/plug_api.py +++ b/src/powersensor_local/plug_api.py @@ -1,9 +1,11 @@ +"""Interface abstraction for Powersensor plugs.""" import sys from pathlib import Path -project_root = str(Path(__file__).parents[1]) -if project_root not in sys.path: - sys.path.append(project_root) +PROJECT_ROOT = str(Path(__file__).parents[1]) +if PROJECT_ROOT not in sys.path: + sys.path.append(PROJECT_ROOT) +# pylint: disable=C0413 from powersensor_local.async_event_emitter import AsyncEventEmitter from powersensor_local.plug_listener_tcp import PlugListenerTcp from powersensor_local.plug_listener_udp import PlugListenerUdp @@ -21,15 +23,25 @@ class PlugApi(AsyncEventEmitter): """ def __init__(self, mac, ip, port=49476, proto='udp'): - """ - Instantiates a new PlugApi for the given plug. - - Args: - - mac: The MAC address of the plug (typically found in the "id" field - in the mDNS/ZeroConf discovery). - - ip: The IP address of the plug. - - port: The port number of the API service on the plug. - - proto: One of 'udp' or 'tcp'. + """Create a :class:`PlugApi` instance for a single plug. + + Parameters + ---------- + mac : str + MAC address of the plug (usually found in the ``id`` field of mDNS/ZeroConf discovery). + ip : str + IP address assigned to the plug. + port : int, optional + Port number of the plug’s API service. Defaults to ``49476``. + proto : {'udp', 'tcp'}, optional + Protocol used for communication. ``'udp'`` selects :class:`PlugListenerUdp`, + while ``'tcp'`` selects :class:`PlugListenerTcp`. Any other value raises a + :class:`ValueError`. + + Raises + ------ + ValueError + If *proto* is not ``'udp'`` or ``'tcp'``. """ super().__init__() self._mac = mac @@ -59,9 +71,8 @@ async def disconnect(self): async def _on_message(self, _, message): """Translates the raw message and emits the resulting messages, if any. - Also synthesises 'now_relaying_for' messages as needed. + Also synthesizes 'now_relaying_for' messages as needed. """ - evs = None try: evs = translate_raw_message(message, self._mac) except KeyError: @@ -85,3 +96,27 @@ async def _on_message(self, _, message): async def _on_exception(self, _, e): """Propagates exceptions from the plug listener.""" await self.emit('exception', e) + + @property + def ip_address(self): + """ + Return the IP address provided on construction. + + Returns + ------- + str + The IP address configured for the listener. + """ + return self._listener.ip + + @property + def port(self): + """ + Return the port number provided on construction. + + Returns + ------- + int + The TCP/UDP port configured for the listener. + """ + return self._listener.port diff --git a/src/powersensor_local/plug_listener_tcp.py b/src/powersensor_local/plug_listener_tcp.py index 5eae519..6d7db7e 100644 --- a/src/powersensor_local/plug_listener_tcp.py +++ b/src/powersensor_local/plug_listener_tcp.py @@ -1,12 +1,14 @@ +"""An interface for accessing the event stream from a Powersensor plug.""" import asyncio import json import sys from pathlib import Path -project_root = str(Path(__file__).parents[1]) -if project_root not in sys.path: - sys.path.append(project_root) +PROJECT_ROOT = str(Path(__file__).parents[1]) +if PROJECT_ROOT not in sys.path: + sys.path.append(PROJECT_ROOT) +# pylint: disable=C0413 from powersensor_local.async_event_emitter import AsyncEventEmitter class PlugListenerTcp(AsyncEventEmitter): @@ -25,8 +27,16 @@ class PlugListenerTcp(AsyncEventEmitter): """ def __init__(self, ip, port=49476): - """Initialises a PlugListenerTcp object, bound to the given IP address. - The port number may be overridden if necessary.""" + """ + Create a :class:`PlugListenerTcp` bound to the given IP address. + + Parameters + ---------- + ip : str + The IPv4 or IPv6 address of the plug to listen to. + port : int, optional + TCP port used by the plug (default ``49476``). + """ super().__init__() self._ip = ip self._port = port @@ -59,7 +69,7 @@ async def disconnect(self): async def _close_connection(self): if self._connection is not None: - (reader, writer) = self._connection + (_, writer) = self._connection self._connection = None writer.close() @@ -69,7 +79,7 @@ async def _close_connection(self): async def _do_connection(self, backoff = 0): if self._disconnecting: - return + return None if backoff < 9: backoff += 1 try: @@ -89,7 +99,7 @@ async def _do_connection(self, backoff = 0): # Handle disconnection and retry with exponential backoff await self._close_connection() if self._disconnecting: - return + return None await asyncio.sleep(min(5 * 60, 2**backoff * 1)) return await self._do_connection(backoff) @@ -108,9 +118,20 @@ async def _process_line(self, reader, writer): pass else: await self.emit('message', message) - except (json.decoder.JSONDecodeError) as ex: + except json.decoder.JSONDecodeError: await self.emit('malformed', data) - async def _send_subscribe(self, writer): + @staticmethod + async def _send_subscribe(writer): writer.write(b'subscribe(60)\n') await writer.drain() + + @property + def port(self): + """Return the TCP port this listener is bound to.""" + return self._port + + @property + def ip(self): + """Return the IP address this listener is bound to.""" + return self._ip diff --git a/src/powersensor_local/plug_listener_udp.py b/src/powersensor_local/plug_listener_udp.py index 7f58291..67685ac 100644 --- a/src/powersensor_local/plug_listener_udp.py +++ b/src/powersensor_local/plug_listener_udp.py @@ -1,15 +1,19 @@ +"""An interface for accessing the event stream from a Powersensor plug.""" import asyncio import json import socket import sys from pathlib import Path -project_root = str(Path(__file__).parents[1]) -if project_root not in sys.path: - sys.path.append(project_root) +PROJECT_ROOT = str(Path(__file__).parents[1]) +if PROJECT_ROOT not in sys.path: + sys.path.append(PROJECT_ROOT) +# pylint: disable=C0413 from powersensor_local.async_event_emitter import AsyncEventEmitter +# pylint: disable=R0902 +# @todo: dream up a base class for PlugListener that TCP/UDP subclass class PlugListenerUdp(AsyncEventEmitter, asyncio.DatagramProtocol): """An interface class for accessing the event stream from a single plug. The following events may be emitted: @@ -26,8 +30,16 @@ class PlugListenerUdp(AsyncEventEmitter, asyncio.DatagramProtocol): """ def __init__(self, ip, port=49476): - """Initialises a PlugListener object, bound to the given IP address. - The port number may be overridden if necessary.""" + """ + Create a :class:`PlugListenerUdp` bound to the given IP address. + + Parameters + ---------- + ip : str + The IPv4 or IPv6 address of the plug to listen to. + port : int, optional + UDP port used by the plug (default ``49476``). + """ super().__init__() self._ip = ip self._port = port @@ -92,7 +104,7 @@ async def _do_connection(self): family = socket.AF_INET, remote_addr = (self._ip, self._port)) self._reconnect = loop.call_later( - min(5*60, 2**self._backoff + 2), self._retry) + min(5*60, 2**self._backoff + 2), self._retry) # noqa def _send_subscribe(self): if self._transport is not None: @@ -104,6 +116,7 @@ def _on_inactivity(self): # DatagramProtocol support below def protocol_factory(self): + """UDP protocol factory for self.""" return self def connection_made(self, transport): @@ -123,7 +136,7 @@ def datagram_received(self, data, addr): if self._inactive is not None: self._inactive.cancel() loop = asyncio.get_running_loop() - self._inactive = loop.call_later(60, self._on_inactivity) + self._inactive = loop.call_later(60, self._on_inactivity) # noqa lines = data.decode('utf-8').splitlines() for line in lines: @@ -137,7 +150,7 @@ def datagram_received(self, data, addr): pass else: asyncio.create_task(self.emit('message', message)) - except (json.decoder.JSONDecodeError) as ex: + except json.decoder.JSONDecodeError: asyncio.create_task(self.emit('malformed', data)) def error_received(self, exc): @@ -146,3 +159,13 @@ def error_received(self, exc): def connection_lost(self, exc): if self._transport is not None: asyncio.create_task(self._close_connection(False)) + + @property + def port(self): + """Return the TCP port this listener is bound to.""" + return self._port + + @property + def ip(self): + """Return the IP address this listener is bound to.""" + return self._ip diff --git a/src/powersensor_local/plugevents.py b/src/powersensor_local/plugevents.py index ed1c58f..375b2cb 100755 --- a/src/powersensor_local/plugevents.py +++ b/src/powersensor_local/plugevents.py @@ -3,62 +3,63 @@ """Utility script for accessing the plug api from a single network-local Powersensor device. Intended for advanced debugging use only.""" -import asyncio -import os -import signal import sys -from plug_api import PlugApi +from typing import Union +from pathlib import Path -exiting = False -plug = None +PROJECT_ROOT = str(Path(__file__).parents[ 1]) +if PROJECT_ROOT not in sys.path: + sys.path.append(PROJECT_ROOT) -async def do_exit(): - global exiting - global plug - if plug != None: - await plug.disconnect() - del plug - exiting = True +# pylint: disable=C0413 +from powersensor_local.plug_api import PlugApi +from powersensor_local.abstract_event_handler import AbstractEventHandler -async def on_evt_msg(evt, msg): - print(evt, msg) +async def print_event_and_message(event, message): + """Callback for printing event data.""" + print(event, message) -async def main(): - if len(sys.argv) < 3: - print(f"Syntax: {sys.argv[0]} [port] [proto]") - sys.exit(1) +class PlugEvents(AbstractEventHandler): + """Main logic wrapper.""" + def __init__(self): + self.plug: Union[PlugApi, None] = None - # Signal handler for Ctrl+C - def handle_sigint(signum, frame): - signal.signal(signal.SIGINT, signal.SIG_DFL) - asyncio.create_task(do_exit()) + async def on_exit(self): + if self.plug is not None: + await self.plug.disconnect() + self.plug = None - signal.signal(signal.SIGINT, handle_sigint) + async def main(self): + if len(sys.argv) < 3: + print(f"Syntax: {sys.argv[0]} [port]") + sys.exit(1) - global plug - plug = PlugApi(sys.argv[1], sys.argv[2], *sys.argv[3:5]) - known_evs = [ - 'exception', - 'average_flow', - 'average_power', - 'average_power_components', - 'battery_level', - 'now_relaying_for', - 'radio_signal_quality', - 'summation_energy', - 'summation_volume', - 'uncalibrated_instant_reading', - ] - for ev in known_evs: - plug.subscribe(ev, on_evt_msg) - plug.connect() + # Signal handler for Ctrl+C + self.register_sigint_handler() - # Keep the event loop running until Ctrl+C is pressed - while not exiting: - await asyncio.sleep(1) + plug = PlugApi(sys.argv[1], sys.argv[2], *sys.argv[3:3]) + known_evs = [ + 'exception', + 'average_flow', + 'average_power', + 'average_power_components', + 'battery_level', + 'now_relaying_for', + 'radio_signal_quality', + 'summation_energy', + 'summation_volume', + 'uncalibrated_instant_reading', + ] + for ev in known_evs: + plug.subscribe(ev, print_event_and_message) + plug.connect() + + # Keep the event loop running until Ctrl+C is pressed + await self.wait() def app(): - asyncio.run(main()) + """Application entry point.""" + PlugEvents().run() if __name__ == "__main__": app() diff --git a/src/powersensor_local/rawplug.py b/src/powersensor_local/rawplug.py index 0058a6d..6235b28 100755 --- a/src/powersensor_local/rawplug.py +++ b/src/powersensor_local/rawplug.py @@ -3,68 +3,71 @@ """Utility script for accessing the raw plug subscription data from a single network-local Powersensor device. Intended for advanced debugging use only.""" -import asyncio -import os -import signal +from typing import Union import sys -from plug_listener_tcp import PlugListenerTcp -from plug_listener_udp import PlugListenerUdp -exiting = False -plug = None +from pathlib import Path -async def do_exit(): - global exiting - global plug - if plug != None: - await plug.disconnect() - del plug - exiting = True +PROJECT_ROOT = str(Path(__file__).parents[ 1]) +if PROJECT_ROOT not in sys.path: + sys.path.append(PROJECT_ROOT) -async def on_evt_msg(_, msg): - print(msg) +# pylint: disable=C0413 +from powersensor_local import PlugListenerTcp,PlugListenerUdp +from powersensor_local.abstract_event_handler import AbstractEventHandler -async def on_evt(evt): - print(evt) +async def print_message_ignore_event(_, message): + """Callback for printing event data withou the event name.""" + print(message) -async def main(): - if len(sys.argv) < 2: - print(f"Syntax: {sys.argv[0]} [port] [proto]") - sys.exit(1) +async def print_event(event): + """Callback for printing an event.""" + print(event) - # Signal handler for Ctrl+C - def handle_sigint(signum, frame): - signal.signal(signal.SIGINT, signal.SIG_DFL) - asyncio.create_task(do_exit()) +class RawPlug(AbstractEventHandler): + """Main logic wrapper.""" + def __init__(self, protocol=None): + self.plug: Union[PlugListenerTcp, PlugListenerUdp, None] = None + if protocol is None: + self._protocol = 'udp' + else: + self._protocol = 'tcp' - signal.signal(signal.SIGINT, handle_sigint) + async def on_exit(self): + if self.plug is not None: + await self.plug.disconnect() + self.plug = None - proto='udp' - if len(sys.argv) >= 4: - proto = sys.argv[3] + async def main(self): + if len(sys.argv) < 2: + print(f"Syntax: {sys.argv[0]} [port]") + sys.exit(1) - global plug - if proto == 'udp': - plug = PlugListenerUdp(sys.argv[1], *sys.argv[2:3]) - elif proto == 'tcp': - plug = PlugListenerTcp(sys.argv[1], *sys.argv[2:3]) - else: - print('Unsupported protocol:', proto) - sys.exit(1) - plug.subscribe('exception', on_evt_msg) - plug.subscribe('message', on_evt_msg) - plug.subscribe('connecting', on_evt) - plug.subscribe('connecting', on_evt) - plug.subscribe('connected', on_evt) - plug.subscribe('disconnected', on_evt) - plug.connect() + # Signal handler for Ctrl+C + self.register_sigint_handler() + if len(sys.argv) >= 4: + self._protocol = sys.argv[3] + plug = None + if self._protocol == 'udp': + plug = PlugListenerUdp(sys.argv[1], *sys.argv[2:3]) + elif self._protocol == 'tcp': + plug = PlugListenerTcp(sys.argv[1], *sys.argv[2:3]) + else: + print('Unsupported protocol:', self._protocol) + plug.subscribe('exception', print_message_ignore_event) + plug.subscribe('message', print_message_ignore_event) + plug.subscribe('connecting', print_event) + plug.subscribe('connecting', print_event) + plug.subscribe('connected', print_event) + plug.subscribe('disconnected', print_event) + plug.connect() - # Keep the event loop running until Ctrl+C is pressed - while not exiting: - await asyncio.sleep(1) + # Keep the event loop running until Ctrl+C is pressed + await self.wait() def app(): - asyncio.run(main()) + """Application entry point.""" + RawPlug().run() if __name__ == "__main__": app() diff --git a/src/powersensor_local/virtual_household.py b/src/powersensor_local/virtual_household.py index f19b120..ce38795 100644 --- a/src/powersensor_local/virtual_household.py +++ b/src/powersensor_local/virtual_household.py @@ -2,14 +2,17 @@ import sys from pathlib import Path -project_root = str(Path(__file__).parents[1]) -if project_root not in sys.path: - sys.path.append(project_root) - -from powersensor_local.async_event_emitter import AsyncEventEmitter from dataclasses import dataclass from typing import Optional +PROJECT_ROOT = str(Path(__file__).parents[1]) +if PROJECT_ROOT not in sys.path: + sys.path.append(PROJECT_ROOT) + +# pylint: disable=C0413 +from powersensor_local.async_event_emitter import AsyncEventEmitter +from powersensor_local.event_buffer import EventBuffer + KEY_DUR_S = 'duration_s' KEY_RESET = 'summation_resettime_utc' KEY_START = 'starttime_utc' @@ -17,14 +20,14 @@ KEY_WATTS = 'watts' @dataclass -class InstantaneousValues: +class InstantaneousValues: # pylint: disable=C0115 starttime_utc: int solar_watts: float housenet_watts: float duration_s: int @dataclass -class SummationValues: +class SummationValues: # pylint: disable=C0115 starttime_utc: int solar_summation: float solar_resettime: int @@ -32,7 +35,7 @@ class SummationValues: housenet_resettime: int @dataclass -class SummationDeltas: +class SummationDeltas: # pylint: disable=C0115 solar_generation: float to_grid: float from_grid: float @@ -48,7 +51,10 @@ def same_duration(ev1: dict, ev2: dict): d2 = round(ev2[dur], 0) return d1 == d2 -def matching_instants(starttime_utc: int, solar_events: list, housenet_events: list) -> Optional[InstantaneousValues]: +def matching_instants( + starttime_utc: int, + solar_events: EventBuffer, + housenet_events: EventBuffer) -> Optional[InstantaneousValues]: """Attempts to match and merge solar+housenet average_power events.""" solar = solar_events.find_by_key(KEY_START, starttime_utc) housenet = housenet_events.find_by_key(KEY_START, starttime_utc) @@ -59,8 +65,7 @@ def matching_instants(starttime_utc: int, solar_events: list, housenet_events: l housenet_watts = housenet[KEY_WATTS], duration_s = round(solar[KEY_DUR_S], 0), ) - else: - return None + return None def make_instant_housenet(ev: dict) -> Optional[InstantaneousValues]: """Helper for case where no solar merge is expected.""" @@ -73,7 +78,10 @@ def make_instant_housenet(ev: dict) -> Optional[InstantaneousValues]: duration_s = round(ev[KEY_DUR_S], 0) ) -def matching_summations(starttime_utc: int, solar_events: list, housenet_events: list) -> Optional[SummationValues]: +def matching_summations( + starttime_utc: int, + solar_events: EventBuffer, + housenet_events: EventBuffer) -> Optional[SummationValues]: """Attempts to match and merge solar+housenet summation events.""" solar = solar_events.find_by_key(KEY_START, starttime_utc) housenet = housenet_events.find_by_key(KEY_START, starttime_utc) @@ -85,8 +93,7 @@ def matching_summations(starttime_utc: int, solar_events: list, housenet_events: housenet_summation = housenet[KEY_SUM_J], housenet_resettime = housenet[KEY_RESET], ) - else: - return None + return None def make_summation_housenet(ev: dict) -> Optional[SummationValues]: """Helper for case where no solar merge is expected.""" @@ -148,10 +155,10 @@ def __init__(self, with_solar: bool): self._expect_solar = with_solar self._summation = self.SummationInfo(0, 0, 0, 0) self._counters = self.Counters(0, 0, 0, 0, 0) - self._solar_instants = self.EventBuffer(31) - self._housenet_instants = self.EventBuffer(31) - self._solar_summations = self.EventBuffer(5) - self._housenet_summations = self.EventBuffer(5) + self._solar_instants = EventBuffer(31) + self._housenet_instants = EventBuffer(31) + self._solar_summations = EventBuffer(5) + self._housenet_summations = EventBuffer(5) async def process_average_power_event(self, ev: dict): """Ingests an event of type 'average_power'.""" @@ -186,7 +193,6 @@ async def process_summation_event(self, ev: dict): await self._process_summations(starttime_utc) async def _process_instants(self, starttime_utc: int): - v = None if self._expect_solar: v = matching_instants(starttime_utc, self._solar_instants, self._housenet_instants) else: @@ -216,11 +222,14 @@ async def _process_instants(self, starttime_utc: int): }) async def _process_summations(self, starttime_utc: int): - v = None if self._expect_solar: - v = matching_summations(starttime_utc, self._solar_summations, self._housenet_summations) + v = matching_summations( + starttime_utc, + self._solar_summations, + self._housenet_summations) else: - v = make_summation_housenet(self._housenet_summations.find_by_key(KEY_START, starttime_utc)) + v = make_summation_housenet( + self._housenet_summations.find_by_key(KEY_START, starttime_utc)) if v is None: return @@ -295,39 +304,15 @@ def _increment_counters(self, d: SummationDeltas): self._counters.from_grid += d.from_grid self._counters.home_use += d.home_use - class EventBuffer: - def __init__(self, keep: int): - self._keep = keep - self._evs = [] - - def find_by_key(self, key: str, value: any): - for ev in self._evs: - if key in ev and ev[key] == value: - return ev - return None - - def append(self, ev: dict): - self._evs.append(ev) - if len(self._evs) > self._keep: - del self._evs[0] - - def evict_older(self, key: str, value: float): - while len(self._evs) > 0: - ev = self._evs[0] - if key in ev and ev[key] <= value: - del self._evs[0] - else: - return - @dataclass - class SummationInfo: + class SummationInfo: # pylint: disable=C0115 solar_resettime: int solar_last: float housenet_resettime: int housenet_last: float @dataclass - class Counters: + class Counters: # pylint: disable=C0115 resettime_utc: int solar_generation: float to_grid: float diff --git a/src/powersensor_local/xlatemsg.py b/src/powersensor_local/xlatemsg.py index 7ba8c02..8f97e2d 100644 --- a/src/powersensor_local/xlatemsg.py +++ b/src/powersensor_local/xlatemsg.py @@ -1,13 +1,16 @@ +"""Common message translation support.""" _MAC_TS_ROLE = [ ('mac', 'mac', True), ('role', 'role', False), ('starttime', 'starttime_utc', True, 3), ] + +# pylint: disable=R0913,R0917 def _pick_item(out: dict, message: dict, key: str, dstkey: str, req: bool, decis: int = None): val = message.get(key) if val is not None: - if type(val) == float and decis is not None: + if isinstance(val, float) and decis is not None: val = round(val, decis) out[dstkey] = val elif req: @@ -94,7 +97,7 @@ def _make_rssi_event(message: dict): def _maybe_make_instant_power_events(out: dict, message: dict, dev: str): unit = message.get('unit') - if unit == 'W' or unit == 'w': + if unit in ('W', 'w'): out['average_power'] = _make_average_power_event(message) try: out['summation_energy'] = _make_summation_energy_event(message) @@ -103,7 +106,7 @@ def _maybe_make_instant_power_events(out: dict, message: dict, dev: str): if dev == 'plug': out['average_power_components'] = \ _make_average_power_components_event(message) - elif unit == 'L' or unit == 'l': + elif unit in ('L', 'l'): out['average_flow'] = _make_average_flow_event(message) out['summation_volume'] = _make_summation_volume_event(message) elif unit == 'U':