From 26ba16a105219d39790cadb98f533d7afe5ba121 Mon Sep 17 00:00:00 2001 From: Lake Bookman Date: Tue, 16 Sep 2025 14:43:35 +1000 Subject: [PATCH 01/19] ignore ide files --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index d22b40f..1dbcbc7 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ __pycache__ .*.swp +.idea/ \ No newline at end of file From 87eccc534c69576f8ac7a335972172d378929b1a Mon Sep 17 00:00:00 2001 From: Lake Bookman Date: Wed, 17 Sep 2025 12:00:00 +1000 Subject: [PATCH 02/19] some formatting changes adding some docs --- docs/.gitignore | 1 + docs/Makefile | 14 +++ docs/make.bat | 35 ++++++ docs/source/_static/powersensor-logo.png | Bin 0 -> 3759 bytes docs/source/api.rst | 17 +++ docs/source/conf.py | 87 +++++++++++++++ docs/source/contributing.rst | 38 +++++++ docs/source/index.rst | 18 +++ docs/source/installation.rst | 27 +++++ docs/source/usage.rst | 18 +++ pyproject.toml | 12 +- src/powersensor_local/__init__.py | 3 + src/powersensor_local/_path_utils.py | 18 +++ .../abstract_event_handler.py | 38 +++++++ src/powersensor_local/async_event_emitter.py | 28 ++--- src/powersensor_local/devices.py | 94 +++++++++------- src/powersensor_local/events.py | 66 +++++------ src/powersensor_local/listener.py | 27 ++--- src/powersensor_local/plug_api.py | 24 ++-- src/powersensor_local/plug_listener.py | 29 +++-- src/powersensor_local/plugevents.py | 103 +++++++++--------- src/powersensor_local/rawfirehose.py | 66 +++++------ src/powersensor_local/rawplug.py | 95 ++++++++-------- src/powersensor_local/xlatemsg.py | 2 +- 24 files changed, 594 insertions(+), 266 deletions(-) create mode 100644 docs/.gitignore create mode 100644 docs/Makefile create mode 100644 docs/make.bat create mode 100644 docs/source/_static/powersensor-logo.png create mode 100644 docs/source/api.rst create mode 100644 docs/source/conf.py create mode 100644 docs/source/contributing.rst create mode 100644 docs/source/index.rst create mode 100644 docs/source/installation.rst create mode 100644 docs/source/usage.rst create mode 100644 src/powersensor_local/_path_utils.py create mode 100644 src/powersensor_local/abstract_event_handler.py 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 0000000000000000000000000000000000000000..86b5df580962f3d3002554e8c75d9bf6dda2d94b GIT binary patch literal 3759 zcmeHK>01)$7PqX3At`gl%0z42Ds#&%Z4`Gh6?0z@A(M0>aRHZHVp6lQQpXh6)X+4e zQX$I`7wl@5ST333PPw9_XbLI_UgrJ<_rv{oKb+_Hobx{Ceb0NIbAIQ2(=Wqeaz82k zBqJju=XlZ1T}Eb?xm4`-?~_^@uK!q)0x;sDA6`ZVr0|dIk|}z0RBGIXcZb=?)buDX zNR>Sy)-KjEGSn>4_LaRdGGIGLJ8RFlT}uLx-YadoL?ut*#89{8+pB2<^Yk4mY@rctB39e z2G|vveJZdUP*>jzYT$_y#6>)$ZY*0gxsk9TZWhM1ty*l$|BxFutlbcaw-Q@H4%po% zsBGXLGpXQS6tEy*B@VL>y)O?)0K6SDJ_t=l*=(7E~fSmOTK#W9dR|JXUnjIY_|lYq7^q4 zw~y)0KaPkUg1e*cZ@GhYmf3q5HYZa5t5ES@)tN(Wsxf&2PWdVo; zG1W~sNj$#uj8bP%gXJI38V!T|D=dt=j72?A%U|whD$Aj-+n84N4^!7h{GBK71$yfK z-ng{e=KIo&KyS@YO$|_a4QAU+>T%_haO^tqAp;=<9m(I{x`VCEtcOQ16Y}Klwfa76T@mEW;V7=$t{j7YgO^cV7i%4A z$Q?bw@9*q~cwE0B@v%;ynA8oJvf%?E9%mAwyo8$_V{ew%BeNdCIDbE;{rqB7LpekC zr%+9TGbQpi?iZ4Uy_0Vd5Rx-WeN~i=jnKC?Bv1c_Dbn7k_4aFo4tc^-`XWm$^qQ59 zeZMKOvN}n4G(WGW{NR9~`4r)Sym{y8r+K!Hd5IWcVCnS(Ht)~hO@sZKLrxMb95UBD zm1WWUB++5-e#mri({uDpSnms4&cz-(*hzv_yba)q)_(^eS!2@ z=e2n56iFCR`4D*7pVK{Z%BQG`yb~#k7Na3q;A>>(h@D%(*zq;gbMwQE>6xs&X6X6#jL}*K zU7H~Z2Pr`67f7R$F@_2~Y`hfFV3h15*lyL2AclQL9INlK1B9LO7*_+}{!<{`BLD$X zwOuRlfWH<&Ul#LB*e2mlH=6F8o^vW(RvC`9{eEkD^09GVO<#HZ%=H0Jvy)4En@~GA zar8>X#vTvDerr&?PL!NR4CA~|ETt5H&Z+K>!21F1u4y}+r4vS};3~JOs;%)H?7Q~b zNl!*zRu>TBOult8Vy}s~`z1yiIv*Wn1MoN{it=*9nc>}5rL$1?v0X&~(@g_Yhy=r> zjrw)BQhvCnBXA85ztXEl&`&=D$ICG|t0_5KC;riR(WDe$e(?k89i(&;HtQGF@_n`e za>+=+ya2t~vgm3%J4d{Gcz!>``7-fy_&d@p_rrouih&J4;lZQDPl%Rli<~ZHUWGy` zxF~lv(90E}wK$l7pHJzt&N({}U=Srl>pO5Bca7K`-wc_IJ=%YQ&^Pl@gcpUbrM#s^fC_6nzObdR(XL+eu;7Yzq~z4#y-T9 zBTf>+xQJ~)_MP-)WvL@Bj2K);XXr6gnICnUIQK?9^VJR|<-Y)S?nma(W)P3i6ohfz zucM@}GFk~HG0!S-iuOF&z{d3LqC}w9uRVkskp$C&SKnnjUihZ+w-M~bIi2?);MO{>LRqKI}2YoJek?r(LdMJkA9X7~B zg!jwi*%!_YHOMa?rc-zW$b>D^6ugv+g$LiOdc{%-Kha)a@z4<*`*_W~yA5pP5;F~hxMD55Vv^bl|0CkQ#Gx=W(5 zP!+FNvqQ(r6__H_MCYWp-s!h&qhU#K{OZ3Bdc{zVZx1&lucqSMK|y0kQwI{^dJ%SW z)P-e7nxNhk^w&;#h7=V)>O;0IO1h`>?rqoQ%cjCz@N$^48j4oktCBe-rf0*qiVZuj zvxxf7MzOo3!%gS)GqlMOb`L3>ee){pX=572<+WT%!JLvM;zG*{W~T(L!>nrT0#5wQ zeD^AAG8XHK!ntWS_9DeyoU|zIkp|xQQ7`wZ6}U70Q!8EAFRcI4%~bcvd+7}4RJmzZ z!gyoKE!Epr;Un8yoGM23ovYJmV_nnJS0;z<=;xna5Ew(3N5u4Vkznu8bPLtgn!Q{W zxFd-us^n;BL~Vo>7qbW6FeZnGAp7vow(qQe5H~n9gFfgp)swifimeG}o zoR6}i+N|ji_Jb{V!E~$sibXayyYDr*1NXisKMuQzN+7L^a!z%q{5O z+yVg>6?*z4tf}DQZaQtgewiuxd-gudbMa(>MqE?O!D!6Cw27LgL*Nt>{w79OBTh=^ zU#LLWujVigJ*Zxi0QpyyC(xuiaW>cU^VHEDaZ zY_^5>%$|65Go&1n@ikVcGTC@8N@&&mg9yFP3VWJ)LS!4vC}In7cG)=ba+nR^8;wrilSDbPHYmR;p{|+k5Q0MRqHj4 zYqK@?E04VV`Hk*jDI*;@AiDsB{8iew>;LdSPJtMDkzj+yt-*-W|D}whJ>0Iw2A%wG D>*#!y literal 0 HcmV?d00001 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..9adc735 --- /dev/null +++ b/docs/source/conf.py @@ -0,0 +1,87 @@ +"""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" +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__ + release = __version__ +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 f8637b0..b83b7c9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "powersensor-local" -version = "2.0.0" +dynamic = ["version"] description = "Network-local (non-cloud) interface for Powersensor devices" authors = [ { name = "Jade Mattsson", email = "jmattsson@dius.com.au" }, @@ -29,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" \ No newline at end of file diff --git a/src/powersensor_local/__init__.py b/src/powersensor_local/__init__.py index 15c4954..a0c8fcd 100644 --- a/src/powersensor_local/__init__.py +++ b/src/powersensor_local/__init__.py @@ -13,5 +13,8 @@ respectively. """ __all__ = [ 'devices', 'listener' ] + +__version__ = "2.0.0" + from .devices import PowersensorDevices from .listener import PowersensorListener diff --git a/src/powersensor_local/_path_utils.py b/src/powersensor_local/_path_utils.py new file mode 100644 index 0000000..333b82c --- /dev/null +++ b/src/powersensor_local/_path_utils.py @@ -0,0 +1,18 @@ +import sys +from pathlib import Path + + +def add_project_to_path(file_path, levels_up=1, insert_at_start=False): + """Add project root to sys.path if not already present. + + Args: + file_path: Pass __file__ from the calling script + levels_up: How many directory levels up to go + insert_at_start: If True, insert at beginning of sys.path + """ + project_root = str(Path(file_path).parents[levels_up - 1]) + if project_root not in sys.path: + if insert_at_start: + sys.path.insert(0, project_root) + else: + sys.path.append(project_root) diff --git a/src/powersensor_local/abstract_event_handler.py b/src/powersensor_local/abstract_event_handler.py new file mode 100644 index 0000000..8c56c04 --- /dev/null +++ b/src/powersensor_local/abstract_event_handler.py @@ -0,0 +1,38 @@ +import asyncio +import signal +from abc import ABC, abstractmethod + +class AbstractEventHandler(ABC): + exiting: bool = False + @abstractmethod + async def on_exit(self): + pass + + async def _do_exit(self): + await self.on_exit() + self.exiting = True + + @abstractmethod + async def main(self): + pass + + # Signal handler for Ctrl+C + def register_sigint_handler(self): + signal.signal(signal.SIGINT, self.__handle_sigint) + + def __handle_sigint(self, signum, frame): + 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): + loop = asyncio.new_event_loop() + asyncio.run(self.main()) + loop.stop() + + async def wait(self, seconds=1): + # Keep the event loop running until Ctrl+C is pressed + 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..5e51ed4 100644 --- a/src/powersensor_local/async_event_emitter.py +++ b/src/powersensor_local/async_event_emitter.py @@ -1,33 +1,35 @@ +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) + await callback(event_name, *args) except BaseException as e: await self.emit('exception', e) diff --git a/src/powersensor_local/devices.py b/src/powersensor_local/devices.py index 886e710..bc39533 100644 --- a/src/powersensor_local/devices.py +++ b/src/powersensor_local/devices.py @@ -1,60 +1,86 @@ import asyncio -import json - from datetime import datetime, timezone -from .listener import PowersensorListener -from .xlatemsg import translate_raw_message +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.listener import PowersensorListener +from powersensor_local.xlatemsg import translate_raw_message EXPIRY_CHECK_INTERVAL_S = 30 EXPIRY_TIMEOUT_S = 5 * 60 + +def _make_events(obj, relayer): + evs = [] + kvs = translate_raw_message(obj, relayer) + for key, ev in kvs.items(): + ev['event'] = key + evs.append(ev) + + return evs + + class PowersensorDevices: """Abstraction interface for the unified event stream from all Powersensor devices on the local network. """ - def __init__(self, bcast_addr=''): + def __init__(self, broadcast_address=''): """Creates a fresh instance, without scanning for devices.""" + self._event_cb = None - self._ps = PowersensorListener(bcast_addr) + self._ps = PowersensorListener(broadcast_address) self._devices = dict() self._timer = None - async def start(self, async_event_cb): + async def start(self, async_event_callback): """Registers the async event callback function and starts the scan of the local network to discover present devices. The callback is of the form - async def yourcallback(event: dict) + Parameters: + ----------- + async_event_callback : Callable - Known events: + A callable asynchronous method for handling json messages. Example:: - scan_complete: - Indicates the discovery of Powersensor devices has completed. - Emitted in response to start() and rescan() calls. - The number of found gateways (plugs) is reported. + async def your_callback(event: dict): + pass - { event: "scan_complete", gateway_count: N } - device_found: - A new device found on the network. - The order found devices are announced is not fixed. + Known Events: + ------------- + * scan_complete - { event: "device_found", - device_type: "plug" or "sensor", - mac: "...", - } + Indicates the discovery of Powersensor devices has completed. + Emitted in response to start() and rescan() calls. + The number of found gateways (plugs) is reported.:: + + { event: "scan_complete", gateway_count: N } + + * device_found + + A new device found on the network. + The order found devices are announced is not fixed.:: - An optional field named "via" is present for sensor devices, and - shows the MAC address of the gateway the sensor is communicating - via. + { event: "device_found", + device_type: "plug" or "sensor", + mac: "...", + } - device_lost: - A device appears to no longer be present on the network. + An optional field named "via" is present for sensor devices, and + shows the MAC address of the gateway the sensor is communicating + via. - { event: "device_lost", mac: "..." } + * device_lost + A device appears to no longer be present on the network.:: + { event: "device_lost", mac: "..." } Additionally, all events described in xlatemsg.translate_raw_message may be issued. The event name is inserted into the field 'event'. @@ -65,7 +91,8 @@ async def yourcallback(event: dict) on the network, but are instead detected when they relay data through a plug via long-range radio. """ - self._event_cb = async_event_cb + + self._event_cb = async_event_callback await self._on_scanned(await self._ps.scan()) self._timer = self._Timer(EXPIRY_CHECK_INTERVAL_S, self._on_timer) return len(self._ips) @@ -120,7 +147,7 @@ async def _on_msg(self, obj): if self._event_cb and device.subscribed: relayer = obj.get('via') or mac - evs = self._mk_events(obj, relayer) + evs = _make_events(obj, relayer) if len(evs) > 0: for ev in evs: await self._event_cb(ev) @@ -153,15 +180,6 @@ async def _remove_device(self, mac): } await self._event_cb(ev) - def _mk_events(self, obj, relayer): - evs = [] - kvs = translate_raw_message(obj, relayer) - for key, ev in kvs.items(): - ev['event'] = key - evs.append(ev) - - return evs - ### Supporting classes ### class _Device: diff --git a/src/powersensor_local/events.py b/src/powersensor_local/events.py index 6d487c2..589198c 100755 --- a/src/powersensor_local/events.py +++ b/src/powersensor_local/events.py @@ -3,58 +3,44 @@ """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 -if __name__ == "__main__": - # Make CLI runnable from source tree - package_source_path = os.path.dirname(os.path.dirname(__file__)) - sys.path.insert(0, package_source_path) - __package__ = "powersensor_local" - -from .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 +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): + 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): + 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() + EventLoopRunner().run() if __name__ == "__main__": app() diff --git a/src/powersensor_local/listener.py b/src/powersensor_local/listener.py index dad0bc8..b661231 100644 --- a/src/powersensor_local/listener.py +++ b/src/powersensor_local/listener.py @@ -1,13 +1,18 @@ import asyncio import json import socket -import time PORT = 49476 DISCOVERY_TIMEOUT_S = 2 + +async def _send_subscribe(writer): + writer.write(b'subscribe(60)\n') + await writer.drain() + + class PowersensorListener(asyncio.DatagramProtocol): - def __init__(self, bcast_addr=''): + def __init__(self, broadcast_address=''): """Initialises a listener object. Optionally takes the broadcast address to use. """ @@ -16,7 +21,7 @@ def __init__(self, bcast_addr=''): self._tasks = dict() self._callback = None self._exiting = False - self._bcast = bcast_addr + self._broadcast = broadcast_address async def scan(self): """Scans the local network for discoverable devices with a timeout. @@ -34,7 +39,7 @@ async def scan(self): timeout = DISCOVERY_TIMEOUT_S while timeout > 0: - transport.sendto(message, (self._bcast, PORT)) + transport.sendto(message, (self._broadcast, PORT)) await asyncio.sleep(0.5) timeout -= 0.5 @@ -61,11 +66,7 @@ async def subscribe(self, callback): self._tasks[ip] = asyncio.create_task( self._connect_to_device(ip, mac)) - async def _send_subscribe(self, writer): - writer.write(b'subscribe(60)\n') - await writer.drain() - - async def _processline(self, ip, mac, reader, writer): + async def _process_line(self, _, mac, reader, writer): data = await reader.readline() if data == b'': reader.feed_eof() @@ -78,14 +79,14 @@ async def _processline(self, ip, mac, reader, writer): typ = message['type'] if typ == 'subscription': if message['subtype'] == 'warning': - await self._send_subscribe(writer) + await _send_subscribe(writer) elif typ == 'discovery': pass else: if message.get('device') == 'sensor': message['via'] = mac await self._callback(message) - except (json.decoder.JSONDecodeError) as ex: + except json.decoder.JSONDecodeError as ex: print(f"JSON error {ex} from {data}") async def _connect_to_device(self, ip, mac, backoff=0): @@ -95,11 +96,11 @@ async def _connect_to_device(self, ip, mac, backoff=0): reader, writer = await asyncio.open_connection(ip, PORT) self._connections[ip] = (reader, writer) - await self._send_subscribe(writer) + await _send_subscribe(writer) backoff = 1 while not self._exiting: - await self._processline(ip, mac, reader, writer) + await self._process_line(ip, mac, reader, writer) except (ConnectionResetError, asyncio.TimeoutError): # Handle disconnection and retry with exponential backoff diff --git a/src/powersensor_local/plug_api.py b/src/powersensor_local/plug_api.py index 6642f85..c5b63cc 100644 --- a/src/powersensor_local/plug_api.py +++ b/src/powersensor_local/plug_api.py @@ -1,6 +1,13 @@ -from async_event_emitter import AsyncEventEmitter -from plug_listener import PlugListener -from xlatemsg import translate_raw_message +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 powersensor_local.plug_listener import PlugListener +from powersensor_local.xlatemsg import translate_raw_message class PlugApi(AsyncEventEmitter): """ @@ -46,21 +53,20 @@ 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: # Ignore malformed messages return - msgmac = message.get('mac') - if msgmac != self._mac and msgmac not in self._seen: - self._seen.add(msgmac) + message_mac = message.get('mac') + if message_mac != self._mac and message_mac not in self._seen: + self._seen.add(message_mac) # We want to emit this prior to events with data ev = { - 'mac': msgmac, + 'mac': message_mac, 'device_type': message.get('device'), 'role': message.get('role'), } diff --git a/src/powersensor_local/plug_listener.py b/src/powersensor_local/plug_listener.py index bc3943e..d3b7a3f 100644 --- a/src/powersensor_local/plug_listener.py +++ b/src/powersensor_local/plug_listener.py @@ -1,9 +1,20 @@ import asyncio import json -import socket -import time -from async_event_emitter import AsyncEventEmitter +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 + + +async def _send_subscribe(writer): + writer.write(b'subscribe(60)\n') + await writer.drain() + class PlugListener(AsyncEventEmitter): """An interface class for accessing the event stream from a single plug. @@ -68,7 +79,7 @@ async def _do_connection(self, backoff = 0): reader, writer = await asyncio.open_connection(self._ip, self._port) self._connection = (reader, writer) - await self._send_subscribe(writer) + await _send_subscribe(writer) backoff = 1 await self.emit('connected') @@ -80,7 +91,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) @@ -94,14 +105,10 @@ async def _process_line(self, reader, writer): typ = message['type'] if typ == 'subscription': if message['subtype'] == 'warning': - await self._send_subscribe(writer) + await _send_subscribe(writer) elif typ == 'discovery': pass else: await self.emit('message', message) - except (json.decoder.JSONDecodeError) as ex: + except json.decoder.JSONDecodeError as ex: print(f"JSON error {ex} from {data}") - - async def _send_subscribe(self, writer): - writer.write(b'subscribe(60)\n') - await writer.drain() diff --git a/src/powersensor_local/plugevents.py b/src/powersensor_local/plugevents.py index 5091835..adaa41d 100755 --- a/src/powersensor_local/plugevents.py +++ b/src/powersensor_local/plugevents.py @@ -3,62 +3,59 @@ """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 - -exiting = False -plug = None - -async def do_exit(): - global exiting - global plug - if plug != None: - await plug.disconnect() - del plug - exiting = True - -async def on_evt_msg(evt, msg): - print(evt, msg) - -async def main(): - if len(sys.argv) < 3: - print(f"Syntax: {sys.argv[0]} [port]") - sys.exit(1) - - # Signal handler for Ctrl+C - def handle_sigint(signum, frame): - signal.signal(signal.SIGINT, signal.SIG_DFL) - asyncio.create_task(do_exit()) - - signal.signal(signal.SIGINT, handle_sigint) - - global plug - 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, on_evt_msg) - plug.connect() - - # Keep the event loop running until Ctrl+C is pressed - while not exiting: - await asyncio.sleep(1) +from typing import Union +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.plug_api import PlugApi +from powersensor_local.abstract_event_handler import AbstractEventHandler + +async def print_event_and_message(event, message): + print(event, message) + +class PlugEvents(AbstractEventHandler): + def __init__(self): + self.plug: Union[PlugApi, None] = None + + async def on_exit(self): + if self.plug is not None: + await self.plug.disconnect() + self.plug = None + + async def main(self): + if len(sys.argv) < 3: + print(f"Syntax: {sys.argv[0]} [port]") + sys.exit(1) + + # Signal handler for Ctrl+C + self.register_sigint_handler() + + 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()) + PlugEvents().run() if __name__ == "__main__": app() diff --git a/src/powersensor_local/rawfirehose.py b/src/powersensor_local/rawfirehose.py index 75bf7c1..5d30da8 100755 --- a/src/powersensor_local/rawfirehose.py +++ b/src/powersensor_local/rawfirehose.py @@ -4,57 +4,47 @@ network-local Powersensor devices. Intended for advanced debugging use only. For all other uses, please see the API in devices.py""" -import asyncio -import os -import signal +import typing import sys +from pathlib import Path -if __name__ == "__main__": - # Make CLI runnable from source tree - package_source_path = os.path.dirname(os.path.dirname(__file__)) - sys.path.insert(0, package_source_path) - __package__ = "powersensor_local" - -from .listener import PowersensorListener +project_root = str(Path(__file__).parents[ 1]) +if project_root not in sys.path: + sys.path.append(project_root) +from powersensor_local.listener import PowersensorListener +from powersensor_local.abstract_event_handler import AbstractEventHandler -exiting = False -ps = None +async def print_message(obj): + print(obj) -async def do_exit(): - global exiting - global ps - if ps != None: - await ps.unsubscribe() - await ps.stop() - exiting = True -async def on_msg(data): - print(data) +class RawFirehose(AbstractEventHandler): + def __init__(self): + self.exiting: bool = False + self.ps: typing.Union[PowersensorListener, None] = PowersensorListener() -async def main(): - global ps - ps = PowersensorListener() + async def on_exit(self): + if self.ps is not None: + await self.ps.unsubscribe() + await self.ps.stop() - # 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.ps is None: + self.ps = PowersensorListener() - signal.signal(signal.SIGINT, handle_sigint) + # Signal handler for Ctrl+C + self.register_sigint_handler() - # Scan for devices and subscribe upon completion - await ps.scan() - await ps.subscribe(on_msg) + # Scan for devices and subscribe upon completion + await self.ps.scan() + await self.ps.subscribe(print_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() + RawFirehose().run() if __name__ == "__main__": app() diff --git a/src/powersensor_local/rawplug.py b/src/powersensor_local/rawplug.py index d5b6f53..459f201 100755 --- a/src/powersensor_local/rawplug.py +++ b/src/powersensor_local/rawplug.py @@ -3,57 +3,54 @@ """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 import PlugListener - -exiting = False -plug = None - -async def do_exit(): - global exiting - global plug - if plug != None: - await plug.disconnect() - del plug - exiting = True - -async def on_evt_msg(_, msg): - print(msg) - -async def on_evt(evt): - print(evt) - -async def main(): - if len(sys.argv) < 2: - print(f"Syntax: {sys.argv[0]} [port]") - sys.exit(1) - - # Signal handler for Ctrl+C - def handle_sigint(signum, frame): - signal.signal(signal.SIGINT, signal.SIG_DFL) - asyncio.create_task(do_exit()) - - signal.signal(signal.SIGINT, handle_sigint) - - global plug - plug = PlugListener(sys.argv[1], *sys.argv[2:2]) - 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() - - # Keep the event loop running until Ctrl+C is pressed - while not exiting: - await asyncio.sleep(1) -def app(): - asyncio.run(main()) +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.plug_listener import PlugListener +from powersensor_local.abstract_event_handler import AbstractEventHandler + +async def print_message_ignore_event(_, message): + print(message) + +async def print_event(event): + print(event) + +class RawPlug(AbstractEventHandler): + def __init__(self): + self.plug: Union[PlugListener, None] = None + async def on_exit(self): + if self.plug is not None: + await self.plug.disconnect() + self.plug = None + + async def main(self): + if len(sys.argv) < 2: + print(f"Syntax: {sys.argv[0]} [port]") + sys.exit(1) + + # Signal handler for Ctrl+C + self.register_sigint_handler() + + plug = PlugListener(sys.argv[1], *sys.argv[2:2]) + 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 + await self.wait() + +def app(): + RawPlug().run() if __name__ == "__main__": app() diff --git a/src/powersensor_local/xlatemsg.py b/src/powersensor_local/xlatemsg.py index 55f9c90..7ba8c02 100644 --- a/src/powersensor_local/xlatemsg.py +++ b/src/powersensor_local/xlatemsg.py @@ -210,7 +210,7 @@ def translate_raw_message(message: dict, relay_mac: str): 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 definietely NOT in Watts. For most purposes, this + 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. From 782f2f6bc6752b5edacef75077ac2e5fe345cb26 Mon Sep 17 00:00:00 2001 From: Lake Bookman Date: Wed, 17 Sep 2025 12:10:04 +1000 Subject: [PATCH 03/19] adding deploy-docs.yml --- .github/workflows/deploy-docs.yml | 60 +++++++++++++++++++++++++++++++ 1 file changed, 60 insertions(+) create mode 100755 .github/workflows/deploy-docs.yml diff --git a/.github/workflows/deploy-docs.yml b/.github/workflows/deploy-docs.yml new file mode 100755 index 0000000..cbf99a8 --- /dev/null +++ b/.github/workflows/deploy-docs.yml @@ -0,0 +1,60 @@ +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@v4 + + - name: Set up Python + uses: actions/setup-python@v4 + with: + python-version: '3.12' + + - name: Install Hatch + run: pip install hatch + + - name: Install dependencies + run: | + cd powersensor_local # Replace with your actual package name + pip install -e ".[docs]" + + - name: Build documentation + run: | + cd powersensor_local/docs # Replace with your actual package name + make html + + - name: Setup Pages + uses: actions/configure-pages@v4 + + - name: Upload artifact + uses: actions/upload-pages-artifact@v2 + with: + path: 'powersensor_local/docs/build/html' + + deploy: + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + runs-on: ubuntu-latest + needs: build + steps: + - name: Deploy to GitHub Pages + id: deployment + uses: actions/deploy-pages@v3 \ No newline at end of file From bb01121729580aade3533aa85f52d34b4a02299b Mon Sep 17 00:00:00 2001 From: Lake Bookman Date: Wed, 17 Sep 2025 12:21:13 +1000 Subject: [PATCH 04/19] updating upload artifact version --- .github/workflows/deploy-docs.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/deploy-docs.yml b/.github/workflows/deploy-docs.yml index cbf99a8..8adf57e 100755 --- a/.github/workflows/deploy-docs.yml +++ b/.github/workflows/deploy-docs.yml @@ -57,4 +57,4 @@ jobs: steps: - name: Deploy to GitHub Pages id: deployment - uses: actions/deploy-pages@v3 \ No newline at end of file + uses: actions/deploy-pages@v4 \ No newline at end of file From 35210c35dca8f3992e670c3b9d184373fc872690 Mon Sep 17 00:00:00 2001 From: Lake Bookman Date: Wed, 17 Sep 2025 12:25:49 +1000 Subject: [PATCH 05/19] updating action versions --- .github/workflows/deploy-docs.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/deploy-docs.yml b/.github/workflows/deploy-docs.yml index 8adf57e..1c06ce5 100755 --- a/.github/workflows/deploy-docs.yml +++ b/.github/workflows/deploy-docs.yml @@ -20,10 +20,10 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@v5 - name: Set up Python - uses: actions/setup-python@v4 + uses: actions/setup-python@v6 with: python-version: '3.12' @@ -44,7 +44,7 @@ jobs: uses: actions/configure-pages@v4 - name: Upload artifact - uses: actions/upload-pages-artifact@v2 + uses: actions/upload-pages-artifact@v3 with: path: 'powersensor_local/docs/build/html' From acaf99e2b775e9a445143dc4c83dbe86fd305a01 Mon Sep 17 00:00:00 2001 From: Lake Bookman Date: Wed, 17 Sep 2025 12:28:39 +1000 Subject: [PATCH 06/19] fixing paths --- .github/workflows/deploy-docs.yml | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/.github/workflows/deploy-docs.yml b/.github/workflows/deploy-docs.yml index 1c06ce5..aabcf57 100755 --- a/.github/workflows/deploy-docs.yml +++ b/.github/workflows/deploy-docs.yml @@ -32,12 +32,11 @@ jobs: - name: Install dependencies run: | - cd powersensor_local # Replace with your actual package name pip install -e ".[docs]" - name: Build documentation run: | - cd powersensor_local/docs # Replace with your actual package name + cd docs make html - name: Setup Pages @@ -46,7 +45,7 @@ jobs: - name: Upload artifact uses: actions/upload-pages-artifact@v3 with: - path: 'powersensor_local/docs/build/html' + path: 'docs/build/html' deploy: environment: From 2f418ed1a6b56f1e975a9b8051be8f1048eda948 Mon Sep 17 00:00:00 2001 From: Lake Bookman Date: Wed, 17 Sep 2025 12:43:34 +1000 Subject: [PATCH 07/19] one last try --- .github/workflows/deploy-docs.yml | 3 --- 1 file changed, 3 deletions(-) diff --git a/.github/workflows/deploy-docs.yml b/.github/workflows/deploy-docs.yml index aabcf57..2084325 100755 --- a/.github/workflows/deploy-docs.yml +++ b/.github/workflows/deploy-docs.yml @@ -48,9 +48,6 @@ jobs: path: 'docs/build/html' deploy: - environment: - name: github-pages - url: ${{ steps.deployment.outputs.page_url }} runs-on: ubuntu-latest needs: build steps: From 23645945eaad046187e7c8f0dd4375880d28d5ae Mon Sep 17 00:00:00 2001 From: Lake Bookman Date: Sun, 28 Sep 2025 21:01:45 +1000 Subject: [PATCH 08/19] small changes --- src/powersensor_local/__init__.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/powersensor_local/__init__.py b/src/powersensor_local/__init__.py index a0c8fcd..2b2eb2a 100644 --- a/src/powersensor_local/__init__.py +++ b/src/powersensor_local/__init__.py @@ -12,9 +12,10 @@ debug aids, which get installed under the names ps-events and ps-rawfirehose respectively. """ -__all__ = [ 'devices', 'listener' ] +__all__ = [ 'devices', 'listener', 'plug_api' ] __version__ = "2.0.0" from .devices import PowersensorDevices from .listener import PowersensorListener +from .plug_api import PlugApi From d0d4be204533abf2c5b28b1bfda9b7036868dc99 Mon Sep 17 00:00:00 2001 From: Lake Bookman Date: Wed, 1 Oct 2025 15:35:51 +1000 Subject: [PATCH 09/19] small changes --- src/powersensor_local/__init__.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/powersensor_local/__init__.py b/src/powersensor_local/__init__.py index 9565c6f..41548ed 100644 --- a/src/powersensor_local/__init__.py +++ b/src/powersensor_local/__init__.py @@ -13,6 +13,7 @@ respectively. """ __all__ = [ 'devices', 'listener', 'plug_api', 'plug_listener' ] +__version__ = "2.0.0" from .devices import PowersensorDevices from .listener import PowersensorListener from .plug_api import PlugApi From d010aab2aee42e8e8cae71a857d743bea8255888 Mon Sep 17 00:00:00 2001 From: Lake Bookman Date: Wed, 1 Oct 2025 15:39:30 +1000 Subject: [PATCH 10/19] merging in latest changes --- src/powersensor_local/plug_listener.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/powersensor_local/plug_listener.py b/src/powersensor_local/plug_listener.py index 11ff555..e247eba 100644 --- a/src/powersensor_local/plug_listener.py +++ b/src/powersensor_local/plug_listener.py @@ -1,7 +1,5 @@ import asyncio import json -import socket -import time import sys from pathlib import Path From 46aff275c1b8e4a5dc93265e9e26b9e7abc326f3 Mon Sep 17 00:00:00 2001 From: Lake Bookman Date: Thu, 9 Oct 2025 16:02:54 +1100 Subject: [PATCH 11/19] exposing ip? --- src/powersensor_local/plug_api.py | 4 ++++ src/powersensor_local/plug_listener.py | 4 ++++ 2 files changed, 8 insertions(+) diff --git a/src/powersensor_local/plug_api.py b/src/powersensor_local/plug_api.py index 18dab9c..3a430ca 100644 --- a/src/powersensor_local/plug_api.py +++ b/src/powersensor_local/plug_api.py @@ -78,3 +78,7 @@ 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 self._listener.ip diff --git a/src/powersensor_local/plug_listener.py b/src/powersensor_local/plug_listener.py index e247eba..ca0518f 100644 --- a/src/powersensor_local/plug_listener.py +++ b/src/powersensor_local/plug_listener.py @@ -114,3 +114,7 @@ async def _process_line(self, reader, writer): async def _send_subscribe(self, writer): writer.write(b'subscribe(60)\n') await writer.drain() + + @property + def ip(self): + return self._ip \ No newline at end of file From 17203b3cdbbf0501e8e5af0d10ea4ad407afd23a Mon Sep 17 00:00:00 2001 From: Lake Bookman Date: Thu, 9 Oct 2025 16:37:52 +1100 Subject: [PATCH 12/19] trying to add protocol switching --- src/powersensor_local/rawplug.py | 21 ++++++++++++++++----- 1 file changed, 16 insertions(+), 5 deletions(-) diff --git a/src/powersensor_local/rawplug.py b/src/powersensor_local/rawplug.py index 459f201..960b7cd 100755 --- a/src/powersensor_local/rawplug.py +++ b/src/powersensor_local/rawplug.py @@ -12,7 +12,7 @@ if project_root not in sys.path: sys.path.append(project_root) -from powersensor_local.plug_listener import PlugListener +from powersensor_local import PlugListenerTcp,PlugListenerUdp from powersensor_local.abstract_event_handler import AbstractEventHandler async def print_message_ignore_event(_, message): @@ -22,8 +22,12 @@ async def print_event(event): print(event) class RawPlug(AbstractEventHandler): - def __init__(self): - self.plug: Union[PlugListener, None] = None + def __init__(self, protocol=None): + self.plug: Union[PlugListenerTcp, PlugListenerUdp, None] = None + if protocol is None: + self._protocol = 'udp' + else: + self._protocol = 'tcp' async def on_exit(self): if self.plug is not None: @@ -37,8 +41,15 @@ async def main(self): # Signal handler for Ctrl+C self.register_sigint_handler() - - plug = PlugListener(sys.argv[1], *sys.argv[2:2]) + 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) From c5e217bdde124ebb0be208e766fc0312e3b448e8 Mon Sep 17 00:00:00 2001 From: Lake Bookman Date: Thu, 23 Oct 2025 15:59:59 +1100 Subject: [PATCH 13/19] add version tag --- src/powersensor_local/__init__.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/powersensor_local/__init__.py b/src/powersensor_local/__init__.py index 972cb34..bbb67b3 100644 --- a/src/powersensor_local/__init__.py +++ b/src/powersensor_local/__init__.py @@ -42,6 +42,7 @@ 'plug_listener_udp', 'virtual_household' ] +__version__ = "2.0.0" from .devices import PowersensorDevices from .legacy_discovery import LegacyDiscovery from .plug_api import PlugApi From b1ca6252f5dac4658a709a483d4acd57aaee4830 Mon Sep 17 00:00:00 2001 From: Lake Bookman Date: Mon, 17 Nov 2025 16:03:23 +1100 Subject: [PATCH 14/19] random unimportant cleanups --- docs/source/conf.py | 5 ++- src/powersensor_local/EventBuffer.py | 26 ++++++++++++++ src/powersensor_local/__init__.py | 17 +++++---- src/powersensor_local/devices.py | 2 -- src/powersensor_local/legacy_discovery.py | 1 + src/powersensor_local/plug_api.py | 3 +- src/powersensor_local/plug_listener_tcp.py | 9 ++--- src/powersensor_local/plug_listener_udp.py | 6 ++-- src/powersensor_local/virtual_household.py | 41 +++++----------------- 9 files changed, 55 insertions(+), 55 deletions(-) create mode 100644 src/powersensor_local/EventBuffer.py diff --git a/docs/source/conf.py b/docs/source/conf.py index 9adc735..d777346 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -10,7 +10,7 @@ # Project information project = "powersensor_local" -copyright = "2025, DiUS" +copyright = "2025, DiUS" # noqa A001 author = "Powersensor Team!" html_favicon = "_static/powersensor-logo.png" @@ -18,8 +18,7 @@ # The full version, including alpha/beta/rc tags try: - from powersensor_local import __version__ - release = __version__ + from powersensor_local import __version__ as release except ImportError: release = "0.1.0" diff --git a/src/powersensor_local/EventBuffer.py b/src/powersensor_local/EventBuffer.py new file mode 100644 index 0000000..af412c0 --- /dev/null +++ b/src/powersensor_local/EventBuffer.py @@ -0,0 +1,26 @@ +from typing import Any + + +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 \ No newline at end of file diff --git a/src/powersensor_local/__init__.py b/src/powersensor_local/__init__.py index bbb67b3..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,14 +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.0" +__version__ = "2.0.1" from .devices import PowersensorDevices from .legacy_discovery import LegacyDiscovery from .plug_api import PlugApi diff --git a/src/powersensor_local/devices.py b/src/powersensor_local/devices.py index 680c026..cf2d180 100644 --- a/src/powersensor_local/devices.py +++ b/src/powersensor_local/devices.py @@ -1,5 +1,4 @@ import asyncio -import json import sys from datetime import datetime, timezone @@ -10,7 +9,6 @@ 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 diff --git a/src/powersensor_local/legacy_discovery.py b/src/powersensor_local/legacy_discovery.py index bb3f051..c93a5b1 100644 --- a/src/powersensor_local/legacy_discovery.py +++ b/src/powersensor_local/legacy_discovery.py @@ -13,6 +13,7 @@ def __init__(self, broadcast_addr = ''): """ super().__init__() self._dst_addr = broadcast_addr + self._found = dict() async def scan(self, timeout_sec = 2.0): """Scans the local network for discoverable devices. diff --git a/src/powersensor_local/plug_api.py b/src/powersensor_local/plug_api.py index 2f193de..4ff700e 100644 --- a/src/powersensor_local/plug_api.py +++ b/src/powersensor_local/plug_api.py @@ -59,9 +59,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: diff --git a/src/powersensor_local/plug_listener_tcp.py b/src/powersensor_local/plug_listener_tcp.py index c7daebe..595a06f 100644 --- a/src/powersensor_local/plug_listener_tcp.py +++ b/src/powersensor_local/plug_listener_tcp.py @@ -69,7 +69,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 +89,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,10 +108,11 @@ 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() diff --git a/src/powersensor_local/plug_listener_udp.py b/src/powersensor_local/plug_listener_udp.py index 7f58291..8e8a180 100644 --- a/src/powersensor_local/plug_listener_udp.py +++ b/src/powersensor_local/plug_listener_udp.py @@ -92,7 +92,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: @@ -123,7 +123,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 +137,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): diff --git a/src/powersensor_local/virtual_household.py b/src/powersensor_local/virtual_household.py index f19b120..b0e6721 100644 --- a/src/powersensor_local/virtual_household.py +++ b/src/powersensor_local/virtual_household.py @@ -2,6 +2,9 @@ import sys from pathlib import Path + +from powersensor_local.EventBuffer import EventBuffer + project_root = str(Path(__file__).parents[1]) if project_root not in sys.path: sys.path.append(project_root) @@ -48,7 +51,7 @@ 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) @@ -73,7 +76,7 @@ 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) @@ -148,10 +151,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 +189,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,7 +218,6 @@ 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) else: @@ -295,30 +296,6 @@ 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: solar_resettime: int From b8b1a08f6096affe9188a4ff5c1c2e783a6d8e2b Mon Sep 17 00:00:00 2001 From: Lake Bookman Date: Thu, 4 Dec 2025 09:37:32 +1100 Subject: [PATCH 15/19] make port and ip publicly accessible (if read only). this will be required for HA integration --- src/powersensor_local/plug_api.py | 4 ++++ src/powersensor_local/plug_listener_tcp.py | 4 ++++ src/powersensor_local/plug_listener_udp.py | 9 +++++++++ 3 files changed, 17 insertions(+) diff --git a/src/powersensor_local/plug_api.py b/src/powersensor_local/plug_api.py index 4ff700e..b679049 100644 --- a/src/powersensor_local/plug_api.py +++ b/src/powersensor_local/plug_api.py @@ -88,3 +88,7 @@ async def _on_exception(self, _, e): @property def ip_address(self): return self._listener.ip + + @property + def port(self): + return self._listener.port diff --git a/src/powersensor_local/plug_listener_tcp.py b/src/powersensor_local/plug_listener_tcp.py index 595a06f..e538998 100644 --- a/src/powersensor_local/plug_listener_tcp.py +++ b/src/powersensor_local/plug_listener_tcp.py @@ -116,6 +116,10 @@ async def _send_subscribe(writer): writer.write(b'subscribe(60)\n') await writer.drain() + @property + def port(self): + return self._port + @property def ip(self): return self._ip \ No newline at end of file diff --git a/src/powersensor_local/plug_listener_udp.py b/src/powersensor_local/plug_listener_udp.py index 8e8a180..da4d4d5 100644 --- a/src/powersensor_local/plug_listener_udp.py +++ b/src/powersensor_local/plug_listener_udp.py @@ -10,6 +10,7 @@ from powersensor_local.async_event_emitter import AsyncEventEmitter +# @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: @@ -146,3 +147,11 @@ 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 self._port + + @property + def ip(self): + return self._ip From 2c096bb8938dfc978248ba874ea55d7ce7db0363 Mon Sep 17 00:00:00 2001 From: Jade Mattsson Date: Fri, 5 Dec 2025 12:56:30 +1100 Subject: [PATCH 16/19] Evict unused file --- src/powersensor_local/_path_utils.py | 18 ------------------ 1 file changed, 18 deletions(-) delete mode 100644 src/powersensor_local/_path_utils.py diff --git a/src/powersensor_local/_path_utils.py b/src/powersensor_local/_path_utils.py deleted file mode 100644 index 333b82c..0000000 --- a/src/powersensor_local/_path_utils.py +++ /dev/null @@ -1,18 +0,0 @@ -import sys -from pathlib import Path - - -def add_project_to_path(file_path, levels_up=1, insert_at_start=False): - """Add project root to sys.path if not already present. - - Args: - file_path: Pass __file__ from the calling script - levels_up: How many directory levels up to go - insert_at_start: If True, insert at beginning of sys.path - """ - project_root = str(Path(file_path).parents[levels_up - 1]) - if project_root not in sys.path: - if insert_at_start: - sys.path.insert(0, project_root) - else: - sys.path.append(project_root) From f90c0b7d61f7129fb9fb9b219f9430991a281302 Mon Sep 17 00:00:00 2001 From: Jade Mattsson Date: Fri, 5 Dec 2025 12:58:04 +1100 Subject: [PATCH 17/19] Add Lake as listed author --- pyproject.toml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index c9382c4..65c5883 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,6 +4,7 @@ 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" @@ -37,4 +38,4 @@ docs = [ ] [tool.hatch.version] -path = "src/powersensor_local/__init__.py" \ No newline at end of file +path = "src/powersensor_local/__init__.py" From 65dcc3843d578e9059d46bc0d992bab4d85d89c5 Mon Sep 17 00:00:00 2001 From: Jade Mattsson Date: Fri, 5 Dec 2025 13:29:46 +1100 Subject: [PATCH 18/19] Minor whitespace cleanup --- .github/workflows/deploy-docs.yml | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/.github/workflows/deploy-docs.yml b/.github/workflows/deploy-docs.yml index 2084325..b9bc791 100755 --- a/.github/workflows/deploy-docs.yml +++ b/.github/workflows/deploy-docs.yml @@ -2,8 +2,8 @@ name: Deploy Documentation to GitHub Pages on: push: - branches: [ "formatting-changes" ] - + branches: [ "formatting-changes" ] + workflow_dispatch: permissions: @@ -21,27 +21,27 @@ jobs: 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 + cd docs make html - + - name: Setup Pages uses: actions/configure-pages@v4 - + - name: Upload artifact uses: actions/upload-pages-artifact@v3 with: @@ -53,4 +53,4 @@ jobs: steps: - name: Deploy to GitHub Pages id: deployment - uses: actions/deploy-pages@v4 \ No newline at end of file + uses: actions/deploy-pages@v4 From 26f58f142908063ff36c5b34e11b5cabe0cc8c19 Mon Sep 17 00:00:00 2001 From: Jade Mattsson Date: Fri, 5 Dec 2025 16:06:12 +1100 Subject: [PATCH 19/19] Make pylint happier --- src/powersensor_local/EventBuffer.py | 26 ------- .../abstract_event_handler.py | 58 ++++++++++++++- src/powersensor_local/async_event_emitter.py | 3 +- src/powersensor_local/devices.py | 19 +++-- src/powersensor_local/event_buffer.py | 74 +++++++++++++++++++ src/powersensor_local/events.py | 10 ++- src/powersensor_local/legacy_discovery.py | 6 +- src/powersensor_local/plug_api.py | 52 ++++++++++--- src/powersensor_local/plug_listener_tcp.py | 26 +++++-- src/powersensor_local/plug_listener_udp.py | 24 ++++-- src/powersensor_local/plugevents.py | 10 ++- src/powersensor_local/rawplug.py | 12 ++- src/powersensor_local/virtual_household.py | 48 +++++++----- src/powersensor_local/xlatemsg.py | 9 ++- 14 files changed, 282 insertions(+), 95 deletions(-) delete mode 100644 src/powersensor_local/EventBuffer.py create mode 100644 src/powersensor_local/event_buffer.py diff --git a/src/powersensor_local/EventBuffer.py b/src/powersensor_local/EventBuffer.py deleted file mode 100644 index af412c0..0000000 --- a/src/powersensor_local/EventBuffer.py +++ /dev/null @@ -1,26 +0,0 @@ -from typing import Any - - -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 \ No newline at end of file diff --git a/src/powersensor_local/abstract_event_handler.py b/src/powersensor_local/abstract_event_handler.py index 8c56c04..d3ea063 100644 --- a/src/powersensor_local/abstract_event_handler.py +++ b/src/powersensor_local/abstract_event_handler.py @@ -1,26 +1,61 @@ +"""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): - pass + """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): - pass + """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}") @@ -28,11 +63,28 @@ def __handle_sigint(self, signum, frame): 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 running until Ctrl+C is pressed + """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 5e51ed4..0d607f3 100644 --- a/src/powersensor_local/async_event_emitter.py +++ b/src/powersensor_local/async_event_emitter.py @@ -1,3 +1,4 @@ +"""Small helper class for pub/sub functionality with async handlers.""" from typing import Callable class AsyncEventEmitter: @@ -31,5 +32,5 @@ async def emit(self, event_name: str, *args): for callback in self._listeners[event_name]: try: await callback(event_name, *args) - except BaseException as e: + 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 cf2d180..ac42d15 100644 --- a/src/powersensor_local/devices.py +++ b/src/powersensor_local/devices.py @@ -1,12 +1,14 @@ +"""Abstraction interface for unified event stream from Powersensor devices""" import asyncio 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 @@ -22,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 @@ -81,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() @@ -175,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 @@ -190,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 589198c..73bdc31 100755 --- a/src/powersensor_local/events.py +++ b/src/powersensor_local/events.py @@ -7,14 +7,16 @@ 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.devices import PowersensorDevices from powersensor_local.abstract_event_handler import AbstractEventHandler class EventLoopRunner(AbstractEventHandler): + """Main logic wrapper.""" def __init__(self): self.devices: typing.Union[PowersensorDevices, None] = PowersensorDevices() @@ -23,6 +25,7 @@ async def on_exit(self): await self.devices.stop() async def on_message(self, obj): + """Callback for printing received events.""" print(obj) if obj['event'] == 'device_found': self.devices.subscribe(obj['mac']) @@ -40,6 +43,7 @@ async def main(self): await self.wait() def app(): + """Application entry point.""" EventLoopRunner().run() if __name__ == "__main__": diff --git a/src/powersensor_local/legacy_discovery.py b/src/powersensor_local/legacy_discovery.py index c93a5b1..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,7 +14,7 @@ def __init__(self, broadcast_addr = ''): """ super().__init__() self._dst_addr = broadcast_addr - self._found = dict() + self._found = {} async def scan(self, timeout_sec = 2.0): """Scans the local network for discoverable devices. @@ -25,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( @@ -45,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 b679049..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 @@ -87,8 +99,24 @@ async def _on_exception(self, _, 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 e538998..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() @@ -118,8 +128,10 @@ async def _send_subscribe(writer): @property def port(self): + """Return the TCP port this listener is bound to.""" return self._port @property def ip(self): - return self._ip \ No newline at end of file + """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 da4d4d5..67685ac 100644 --- a/src/powersensor_local/plug_listener_udp.py +++ b/src/powersensor_local/plug_listener_udp.py @@ -1,15 +1,18 @@ +"""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. @@ -27,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 @@ -105,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): @@ -150,8 +162,10 @@ def connection_lost(self, exc): @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 adaa41d..375b2cb 100755 --- a/src/powersensor_local/plugevents.py +++ b/src/powersensor_local/plugevents.py @@ -7,17 +7,20 @@ from typing import Union 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.plug_api import PlugApi from powersensor_local.abstract_event_handler import AbstractEventHandler async def print_event_and_message(event, message): + """Callback for printing event data.""" print(event, message) class PlugEvents(AbstractEventHandler): + """Main logic wrapper.""" def __init__(self): self.plug: Union[PlugApi, None] = None @@ -55,6 +58,7 @@ async def main(self): await self.wait() def app(): + """Application entry point.""" PlugEvents().run() if __name__ == "__main__": diff --git a/src/powersensor_local/rawplug.py b/src/powersensor_local/rawplug.py index 960b7cd..6235b28 100755 --- a/src/powersensor_local/rawplug.py +++ b/src/powersensor_local/rawplug.py @@ -8,20 +8,24 @@ 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 import PlugListenerTcp,PlugListenerUdp from powersensor_local.abstract_event_handler import AbstractEventHandler async def print_message_ignore_event(_, message): + """Callback for printing event data withou the event name.""" print(message) async def print_event(event): + """Callback for printing an event.""" print(event) class RawPlug(AbstractEventHandler): + """Main logic wrapper.""" def __init__(self, protocol=None): self.plug: Union[PlugListenerTcp, PlugListenerUdp, None] = None if protocol is None: @@ -62,6 +66,8 @@ async def main(self): await self.wait() def app(): + """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 b0e6721..ce38795 100644 --- a/src/powersensor_local/virtual_household.py +++ b/src/powersensor_local/virtual_household.py @@ -2,16 +2,16 @@ import sys from pathlib import Path +from dataclasses import dataclass +from typing import Optional -from powersensor_local.EventBuffer import EventBuffer - -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 dataclasses import dataclass -from typing import Optional +from powersensor_local.event_buffer import EventBuffer KEY_DUR_S = 'duration_s' KEY_RESET = 'summation_resettime_utc' @@ -20,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 @@ -35,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 @@ -51,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: EventBuffer, housenet_events: EventBuffer) -> 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) @@ -62,8 +65,7 @@ def matching_instants(starttime_utc: int, solar_events: EventBuffer, housenet_ev 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.""" @@ -76,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: EventBuffer, housenet_events: EventBuffer) -> 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) @@ -88,8 +93,7 @@ def matching_summations(starttime_utc: int, solar_events: EventBuffer, housenet_ 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.""" @@ -219,9 +223,13 @@ async def _process_instants(self, starttime_utc: int): async def _process_summations(self, starttime_utc: int): 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 @@ -297,14 +305,14 @@ def _increment_counters(self, d: SummationDeltas): self._counters.home_use += d.home_use @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':