mygrid-voltbridge 0.3.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,12 @@
1
+ .venv/
2
+ __pycache__/
3
+ *.py[cod]
4
+ .pytest_cache/
5
+ .mypy_cache/
6
+ .ruff_cache/
7
+ .coverage*
8
+ coverage.xml
9
+ htmlcov/
10
+ *.egg-info/
11
+ dist/
12
+ build/
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 mygrid-voltbridge contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,88 @@
1
+ Metadata-Version: 2.5
2
+ Name: mygrid-voltbridge
3
+ Version: 0.3.0
4
+ Summary: Typed async HTTP client for the MyGrid VoltBridge P1 meter
5
+ Project-URL: Source, https://github.com/tjorim/python-mygrid-voltbridge
6
+ Project-URL: Issues, https://github.com/tjorim/python-mygrid-voltbridge/issues
7
+ Author: mygrid-voltbridge contributors
8
+ License-Expression: MIT
9
+ License-File: LICENSE
10
+ Keywords: asyncio,dsmr,mygrid,p1,voltbridge
11
+ Classifier: Development Status :: 3 - Alpha
12
+ Classifier: Framework :: AsyncIO
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: Programming Language :: Python :: 3 :: Only
15
+ Classifier: Programming Language :: Python :: 3.12
16
+ Classifier: Programming Language :: Python :: 3.13
17
+ Classifier: Programming Language :: Python :: 3.14
18
+ Classifier: Typing :: Typed
19
+ Requires-Python: >=3.12
20
+ Requires-Dist: aiohttp<4,>=3.10
21
+ Description-Content-Type: text/markdown
22
+
23
+ # mygrid-voltbridge
24
+
25
+ A small typed async Python client for the local HTTP API of the **MyGrid VoltBridge** P1 meter adapter. Requires Python **3.12+** and `aiohttp`. Version 0.1.0 has not been published to PyPI.
26
+
27
+ The API is not documented by the manufacturer. It was reverse-engineered from the device's own status page and verified against one device (firmware `voltbridge` build 15.6); see [docs/api.md](docs/api.md). It may change between firmware versions.
28
+
29
+ ## Query a meter
30
+
31
+ The helper is read-only:
32
+
33
+ ```sh
34
+ uv sync
35
+ uv run mygrid-voltbridge 192.168.0.45
36
+ uv run mygrid-voltbridge 192.168.0.45 --power
37
+ ```
38
+
39
+ ## Library
40
+
41
+ ```python
42
+ import asyncio
43
+
44
+ import aiohttp
45
+
46
+ from mygrid_voltbridge import VoltBridgeClient
47
+
48
+
49
+ async def main() -> None:
50
+ async with aiohttp.ClientSession() as session:
51
+ client = VoltBridgeClient("192.168.0.45", session)
52
+ power = await client.async_get_power() # W, positive = import
53
+ print(power.net_power, power.tariff)
54
+ data = await client.async_get_p1_data() # full snapshot
55
+ print(data.energy_import_t1, data.phases[1].voltage)
56
+
57
+
58
+ asyncio.run(main())
59
+ ```
60
+
61
+ | API | Contract |
62
+ | --- | --- |
63
+ | `async_get_power()` | `PowerReading` from `/api/power`; cheap, suitable for ~1 s polling |
64
+ | `async_get_p1_data()` | `P1Data` from `/api/p1data`; poll every few seconds at most |
65
+ | `async_listen(callback, host=...)` | Push updates: UDP broadcasts on 49491, about once a second; see below |
66
+ | `parse_datagram(bytes)` | Decode one UDP datagram into a `P1Broadcast` |
67
+ | `VoltBridgeInfo.from_properties(txt)` | Identify a device from its `_mygrid._tcp` mDNS TXT record (`device_id` is the MAC) |
68
+ | `async_get_telegram()` | `Telegram` from `/api/telegram`: M-Bus devices (gas, **water**), breaker, limiter, monthly peak history, CRC check |
69
+ | `async_get_telegram_text()` | The raw telegram string |
70
+ | `async_reboot()` | `POST /reboot`; never retried |
71
+
72
+ ## Push updates
73
+
74
+ The device also broadcasts its telegram over UDP roughly once per second, so you do not have to poll:
75
+
76
+ ```python
77
+ from mygrid_voltbridge import async_listen
78
+
79
+ transport = await async_listen(lambda b: print(b.data.net_power), host="192.168.0.45")
80
+ # ...
81
+ transport.close()
82
+ ```
83
+
84
+ `host` filters by the device's IP; invalid datagrams are dropped. Use `port=49492` for the one-minute average stream. `uv run mygrid-voltbridge 192.168.0.45 --watch` prints the live stream as JSON lines. The broadcast carries no gas reading; use `async_get_p1_data()` for that. Broadcasts must reach the host, so a container or VLAN may need host networking or a broadcast relay. See [docs/api.md](docs/api.md) for the format.
85
+
86
+ Power is in **W**, energy in **Wh**, voltage in **V**, current in **A**; the device reports kW/kWh and the library converts. Fields the meter does not report are `None`. Errors derive from `VoltBridgeError`: `VoltBridgeConnectionError`, `VoltBridgeResponseError` and `VoltBridgeNoDataError` (no P1 telegram received yet).
87
+
88
+ Pass your own `aiohttp` session, or omit it: the client then creates one limited to a single connection (the device has very little memory) and closes it in `close()` or when leaving `async with VoltBridgeClient(host) as client`. Reads are retried twice with exponential backoff; the reboot is never retried. `VoltBridgeUnsupportedError` means the firmware lacks an endpoint (HTTP 404). There is no authentication or TLS on the device; keep it on the trusted LAN. Per-phase fields other than L1, and export power/energy on the UDP stream, are unverified.
@@ -0,0 +1,66 @@
1
+ # mygrid-voltbridge
2
+
3
+ A small typed async Python client for the local HTTP API of the **MyGrid VoltBridge** P1 meter adapter. Requires Python **3.12+** and `aiohttp`. Version 0.1.0 has not been published to PyPI.
4
+
5
+ The API is not documented by the manufacturer. It was reverse-engineered from the device's own status page and verified against one device (firmware `voltbridge` build 15.6); see [docs/api.md](docs/api.md). It may change between firmware versions.
6
+
7
+ ## Query a meter
8
+
9
+ The helper is read-only:
10
+
11
+ ```sh
12
+ uv sync
13
+ uv run mygrid-voltbridge 192.168.0.45
14
+ uv run mygrid-voltbridge 192.168.0.45 --power
15
+ ```
16
+
17
+ ## Library
18
+
19
+ ```python
20
+ import asyncio
21
+
22
+ import aiohttp
23
+
24
+ from mygrid_voltbridge import VoltBridgeClient
25
+
26
+
27
+ async def main() -> None:
28
+ async with aiohttp.ClientSession() as session:
29
+ client = VoltBridgeClient("192.168.0.45", session)
30
+ power = await client.async_get_power() # W, positive = import
31
+ print(power.net_power, power.tariff)
32
+ data = await client.async_get_p1_data() # full snapshot
33
+ print(data.energy_import_t1, data.phases[1].voltage)
34
+
35
+
36
+ asyncio.run(main())
37
+ ```
38
+
39
+ | API | Contract |
40
+ | --- | --- |
41
+ | `async_get_power()` | `PowerReading` from `/api/power`; cheap, suitable for ~1 s polling |
42
+ | `async_get_p1_data()` | `P1Data` from `/api/p1data`; poll every few seconds at most |
43
+ | `async_listen(callback, host=...)` | Push updates: UDP broadcasts on 49491, about once a second; see below |
44
+ | `parse_datagram(bytes)` | Decode one UDP datagram into a `P1Broadcast` |
45
+ | `VoltBridgeInfo.from_properties(txt)` | Identify a device from its `_mygrid._tcp` mDNS TXT record (`device_id` is the MAC) |
46
+ | `async_get_telegram()` | `Telegram` from `/api/telegram`: M-Bus devices (gas, **water**), breaker, limiter, monthly peak history, CRC check |
47
+ | `async_get_telegram_text()` | The raw telegram string |
48
+ | `async_reboot()` | `POST /reboot`; never retried |
49
+
50
+ ## Push updates
51
+
52
+ The device also broadcasts its telegram over UDP roughly once per second, so you do not have to poll:
53
+
54
+ ```python
55
+ from mygrid_voltbridge import async_listen
56
+
57
+ transport = await async_listen(lambda b: print(b.data.net_power), host="192.168.0.45")
58
+ # ...
59
+ transport.close()
60
+ ```
61
+
62
+ `host` filters by the device's IP; invalid datagrams are dropped. Use `port=49492` for the one-minute average stream. `uv run mygrid-voltbridge 192.168.0.45 --watch` prints the live stream as JSON lines. The broadcast carries no gas reading; use `async_get_p1_data()` for that. Broadcasts must reach the host, so a container or VLAN may need host networking or a broadcast relay. See [docs/api.md](docs/api.md) for the format.
63
+
64
+ Power is in **W**, energy in **Wh**, voltage in **V**, current in **A**; the device reports kW/kWh and the library converts. Fields the meter does not report are `None`. Errors derive from `VoltBridgeError`: `VoltBridgeConnectionError`, `VoltBridgeResponseError` and `VoltBridgeNoDataError` (no P1 telegram received yet).
65
+
66
+ Pass your own `aiohttp` session, or omit it: the client then creates one limited to a single connection (the device has very little memory) and closes it in `close()` or when leaving `async with VoltBridgeClient(host) as client`. Reads are retried twice with exponential backoff; the reboot is never retried. `VoltBridgeUnsupportedError` means the firmware lacks an endpoint (HTTP 404). There is no authentication or TLS on the device; keep it on the trusted LAN. Per-phase fields other than L1, and export power/energy on the UDP stream, are unverified.
@@ -0,0 +1,116 @@
1
+ [build-system]
2
+ requires = ["hatchling>=1.27.0", "hatch-vcs"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "mygrid-voltbridge"
7
+ dynamic = ["version"]
8
+ description = "Typed async HTTP client for the MyGrid VoltBridge P1 meter"
9
+ readme = "README.md"
10
+ requires-python = ">=3.12"
11
+ license = "MIT"
12
+ license-files = ["LICENSE"]
13
+ authors = [{name = "mygrid-voltbridge contributors"}]
14
+ keywords = ["mygrid", "voltbridge", "p1", "dsmr", "asyncio"]
15
+ classifiers = [
16
+ "Development Status :: 3 - Alpha",
17
+ "Framework :: AsyncIO",
18
+ "Intended Audience :: Developers",
19
+ "Programming Language :: Python :: 3 :: Only",
20
+ "Programming Language :: Python :: 3.12",
21
+ "Programming Language :: Python :: 3.13",
22
+ "Programming Language :: Python :: 3.14",
23
+ "Typing :: Typed",
24
+ ]
25
+ dependencies = ["aiohttp>=3.10,<4"]
26
+
27
+ [project.scripts]
28
+ mygrid-voltbridge = "mygrid_voltbridge.cli:main"
29
+
30
+ [project.urls]
31
+ Source = "https://github.com/tjorim/python-mygrid-voltbridge"
32
+ Issues = "https://github.com/tjorim/python-mygrid-voltbridge/issues"
33
+
34
+ [dependency-groups]
35
+ dev = [
36
+ "pytest>=9,<10",
37
+ "pytest-asyncio>=1.4,<2",
38
+ "pytest-cov>=7,<8",
39
+ "ruff>=0.17,<0.18",
40
+ "ty>=0.0.90",
41
+ "build>=1.3,<2",
42
+ "twine>=7,<8",
43
+ ]
44
+
45
+ [tool.hatch.version]
46
+ source = "vcs"
47
+ raw-options = { fallback_version = "0.0.0" }
48
+
49
+ [tool.hatch.build.targets.sdist]
50
+ include = ["/src", "/README.md", "/LICENSE"]
51
+
52
+ [tool.hatch.build.targets.wheel]
53
+ packages = ["src/mygrid_voltbridge"]
54
+
55
+ [tool.pytest.ini_options]
56
+ asyncio_mode = "auto"
57
+ asyncio_default_fixture_loop_scope = "function"
58
+ testpaths = ["tests"]
59
+ pythonpath = ["."]
60
+ addopts = "-ra -m 'not live' --strict-markers --strict-config"
61
+ markers = ["live: smoke tests against a real device (set VOLTBRIDGE_HOST); excluded from ordinary runs"]
62
+ filterwarnings = ["error"]
63
+
64
+ [tool.coverage.run]
65
+ branch = true
66
+ source = ["mygrid_voltbridge"]
67
+
68
+ [tool.coverage.report]
69
+ show_missing = true
70
+ skip_covered = true
71
+ fail_under = 95
72
+
73
+ [tool.ruff]
74
+ line-length = 120
75
+ target-version = "py312"
76
+
77
+ [tool.ruff.lint]
78
+ select = ["ALL"]
79
+ ignore = [
80
+ "ANN401",
81
+ "COM812",
82
+ "CPY001",
83
+ "D203",
84
+ "D213",
85
+ "E501",
86
+ "ISC001",
87
+ "PLR0913",
88
+ "PLR0917",
89
+ "TC001",
90
+ "TC002",
91
+ "TC003",
92
+ ]
93
+
94
+ [tool.ruff.lint.per-file-ignores]
95
+ "tests/**" = [
96
+ "ARG001",
97
+ "ARG005",
98
+ "D",
99
+ "INP001",
100
+ "PLC0415",
101
+ "PLR2004",
102
+ "S101",
103
+ "SLF001",
104
+ ]
105
+
106
+ [tool.ruff.lint.isort]
107
+ force-sort-within-sections = true
108
+ combine-as-imports = true
109
+ split-on-trailing-comma = false
110
+
111
+ [tool.ty.rules]
112
+ all = "error"
113
+
114
+ [tool.ty.analysis]
115
+ strict-equality-semantics = true
116
+ strict-generic-narrowing = true
@@ -0,0 +1,50 @@
1
+ """Typed async client for the MyGrid VoltBridge P1 meter."""
2
+
3
+ from importlib.metadata import PackageNotFoundError, version
4
+ import logging
5
+
6
+ from .client import VoltBridgeClient
7
+ from .discovery import SERVICE_TYPE, VoltBridgeInfo
8
+ from .exceptions import (
9
+ VoltBridgeConnectionError,
10
+ VoltBridgeError,
11
+ VoltBridgeNoDataError,
12
+ VoltBridgeResponseError,
13
+ VoltBridgeUnsupportedError,
14
+ )
15
+ from .models import P1Data, PhaseData, PowerReading
16
+ from .telegram import GAS, HEAT, WATER, MBusChannel, MonthlyPeak, Telegram, parse_telegram
17
+ from .udp import BroadcastStream, P1Broadcast, async_listen, parse_datagram
18
+
19
+ logging.getLogger(__name__).addHandler(logging.NullHandler())
20
+
21
+ try:
22
+ __version__ = version("mygrid-voltbridge")
23
+ except PackageNotFoundError: # pragma: no cover
24
+ __version__ = "0.0.0"
25
+
26
+ __all__ = [
27
+ "GAS",
28
+ "HEAT",
29
+ "SERVICE_TYPE",
30
+ "WATER",
31
+ "BroadcastStream",
32
+ "MBusChannel",
33
+ "MonthlyPeak",
34
+ "P1Broadcast",
35
+ "P1Data",
36
+ "PhaseData",
37
+ "PowerReading",
38
+ "Telegram",
39
+ "VoltBridgeClient",
40
+ "VoltBridgeConnectionError",
41
+ "VoltBridgeError",
42
+ "VoltBridgeInfo",
43
+ "VoltBridgeNoDataError",
44
+ "VoltBridgeResponseError",
45
+ "VoltBridgeUnsupportedError",
46
+ "__version__",
47
+ "async_listen",
48
+ "parse_datagram",
49
+ "parse_telegram",
50
+ ]
@@ -0,0 +1,67 @@
1
+ """Read-only command line helper: ``mygrid-voltbridge <host>``."""
2
+
3
+ import argparse
4
+ import asyncio
5
+ import contextlib
6
+ import dataclasses
7
+ from datetime import datetime
8
+ import json
9
+ from typing import Any
10
+
11
+ import aiohttp
12
+
13
+ from .client import HTTP_PORT, VoltBridgeClient
14
+ from .exceptions import VoltBridgeError
15
+ from .udp import P1_PORT, P1Broadcast, async_listen
16
+
17
+
18
+ def _default(value: Any) -> Any:
19
+ if isinstance(value, datetime):
20
+ return value.isoformat()
21
+ raise TypeError(type(value))
22
+
23
+
24
+ async def _run(host: str, port: int, *, power_only: bool) -> str:
25
+ async with aiohttp.ClientSession() as session:
26
+ client = VoltBridgeClient(host, session, port=port)
27
+ reading = await (client.async_get_power() if power_only else client.async_get_p1_data())
28
+ return json.dumps(dataclasses.asdict(reading), default=_default, indent=2)
29
+
30
+
31
+ async def _watch(host: str, port: int) -> None:
32
+ queue: asyncio.Queue[P1Broadcast] = asyncio.Queue()
33
+ transport = await async_listen(queue.put_nowait, host=host, port=port)
34
+ try:
35
+ while True:
36
+ broadcast = await queue.get()
37
+ print( # noqa: T201 - the CLI exists to print
38
+ json.dumps(dataclasses.asdict(broadcast), default=_default), flush=True
39
+ )
40
+ finally:
41
+ transport.close()
42
+
43
+
44
+ def main(argv: list[str] | None = None) -> int:
45
+ """Run the command line helper and return the exit code."""
46
+ parser = argparse.ArgumentParser(description="Read a MyGrid VoltBridge (read-only).")
47
+ parser.add_argument("host")
48
+ parser.add_argument("--port", type=int, default=HTTP_PORT)
49
+ parser.add_argument("--watch", action="store_true", help="stream UDP broadcasts as JSON lines")
50
+ parser.add_argument("--power", action="store_true", help="only instantaneous power")
51
+ args = parser.parse_args(argv)
52
+ try:
53
+ if args.watch:
54
+ with contextlib.suppress(KeyboardInterrupt):
55
+ asyncio.run(_watch(args.host, args.port if args.port != HTTP_PORT else P1_PORT))
56
+ return 0
57
+ print(asyncio.run(_run(args.host, args.port, power_only=args.power))) # noqa: T201
58
+ except BrokenPipeError: # e.g. piped into head
59
+ return 0
60
+ except VoltBridgeError as err:
61
+ print(f"error: {err}") # noqa: T201
62
+ return 1
63
+ return 0
64
+
65
+
66
+ if __name__ == "__main__":
67
+ raise SystemExit(main())
@@ -0,0 +1,167 @@
1
+ """Async client for the VoltBridge's local, unauthenticated HTTP API."""
2
+
3
+ import asyncio
4
+ from http import HTTPStatus
5
+ import json
6
+ import logging
7
+ import time
8
+ from types import TracebackType
9
+ from typing import Any, Self
10
+
11
+ import aiohttp
12
+
13
+ from .exceptions import VoltBridgeConnectionError, VoltBridgeResponseError, VoltBridgeUnsupportedError
14
+ from .models import P1Data, PowerReading
15
+ from .telegram import Telegram, parse_telegram
16
+
17
+ _LOGGER = logging.getLogger(__name__)
18
+ HTTP_PORT = 80
19
+ DEFAULT_TIMEOUT = 5.0
20
+ DEFAULT_RETRIES = 2
21
+ # The device is a microcontroller with very little free memory: space requests out.
22
+ DEFAULT_MIN_INTERVAL = 0.1
23
+
24
+
25
+ class _ServerError(aiohttp.ClientError):
26
+ """HTTP 5xx, retried like a network failure."""
27
+
28
+
29
+ class VoltBridgeClient:
30
+ """Talks to one VoltBridge.
31
+
32
+ Pass your own ``aiohttp`` session (Home Assistant's, for example) or let
33
+ the client create one; it then allows one connection at a time, because
34
+ the device is a small microcontroller with very little free memory, and
35
+ closes it in ``close()`` or when leaving ``async with``. Requests are
36
+ serialised and spaced at least ``min_request_interval`` seconds apart.
37
+ Reads are retried with exponential backoff after a network error, timeout
38
+ or HTTP 5xx; writes never are. Poll ``/api/p1data`` every few seconds at
39
+ most and prefer ``async_get_power`` or UDP for fast updates.
40
+
41
+ Raises ``ValueError`` for a negative ``retries`` or ``min_request_interval``
42
+ and for a ``timeout`` that is not positive.
43
+ """
44
+
45
+ def __init__(
46
+ self,
47
+ host: str,
48
+ session: aiohttp.ClientSession | None = None,
49
+ *,
50
+ port: int = HTTP_PORT,
51
+ timeout: float = DEFAULT_TIMEOUT,
52
+ retries: int = DEFAULT_RETRIES,
53
+ min_request_interval: float = DEFAULT_MIN_INTERVAL,
54
+ ) -> None:
55
+ """Create a client; see the class docstring for the arguments."""
56
+ if retries < 0:
57
+ msg = "retries must be 0 or greater"
58
+ raise ValueError(msg)
59
+ if timeout <= 0:
60
+ msg = "timeout must be greater than 0"
61
+ raise ValueError(msg)
62
+ if min_request_interval < 0:
63
+ msg = "min_request_interval must be 0 or greater"
64
+ raise ValueError(msg)
65
+ self._base = f"http://{host}:{port}"
66
+ self._session = session
67
+ self._owns_session = session is None
68
+ self._timeout = aiohttp.ClientTimeout(total=timeout)
69
+ self._retries = retries
70
+ self._min_interval = min_request_interval
71
+ self._lock = asyncio.Lock()
72
+ self._last_request = 0.0
73
+
74
+ async def async_get_power(self) -> PowerReading:
75
+ """Read instantaneous power (``/api/power``)."""
76
+ return PowerReading.from_payload(await self._json("/api/power"))
77
+
78
+ async def async_get_p1_data(self) -> P1Data:
79
+ """Read the full P1 snapshot (``/api/p1data``)."""
80
+ return P1Data.from_payload(await self._json("/api/p1data"))
81
+
82
+ async def async_get_telegram_text(self) -> str:
83
+ """Read the raw DSMR telegram (``/api/telegram``), unmodified."""
84
+ return (await self._request("GET", "/api/telegram")).decode("latin-1")
85
+
86
+ async def async_get_telegram(self) -> Telegram:
87
+ """Read and decode the telegram: M-Bus devices, breaker, peak history."""
88
+ return parse_telegram(await self.async_get_telegram_text())
89
+
90
+ async def async_reboot(self) -> None:
91
+ """Reboot the device (``POST /reboot``). Not retried."""
92
+ await self._request("POST", "/reboot", retries=0)
93
+
94
+ async def close(self) -> None:
95
+ """Close the session if this client created it."""
96
+ if self._session is not None and self._owns_session:
97
+ await self._session.close()
98
+ self._session = None
99
+
100
+ async def __aenter__(self) -> Self:
101
+ """Enter the async context; the client is ready to use."""
102
+ return self
103
+
104
+ async def __aexit__(
105
+ self,
106
+ exc_type: type[BaseException] | None,
107
+ exc: BaseException | None,
108
+ traceback: TracebackType | None,
109
+ ) -> None:
110
+ """Leave the async context and close an owned session."""
111
+ await self.close()
112
+
113
+ async def _json(self, path: str) -> Any:
114
+ body = await self._request("GET", path)
115
+ try:
116
+ return json.loads(body)
117
+ except ValueError as err:
118
+ msg = f"{path} did not return JSON"
119
+ raise VoltBridgeResponseError(msg) from err
120
+
121
+ async def _request(self, method: str, path: str, *, retries: int | None = None) -> bytes:
122
+ attempts = (self._retries if retries is None else retries) + 1
123
+ for attempt in range(attempts):
124
+ try:
125
+ async with self._lock:
126
+ await self._space_out()
127
+ return await self._request_once(method, path)
128
+ except VoltBridgeConnectionError:
129
+ if attempt == attempts - 1:
130
+ raise
131
+ delay = 0.25 * 2**attempt
132
+ _LOGGER.debug("%s %s failed, retrying in %.2fs", method, path, delay)
133
+ await asyncio.sleep(delay)
134
+ msg = "unreachable"
135
+ raise AssertionError(msg) # pragma: no cover
136
+
137
+ async def _space_out(self) -> None:
138
+ """Wait until ``min_request_interval`` has passed since the last request."""
139
+ wait = self._last_request + self._min_interval - time.monotonic()
140
+ if wait > 0:
141
+ await asyncio.sleep(wait)
142
+ self._last_request = time.monotonic()
143
+
144
+ async def _request_once(self, method: str, path: str) -> bytes:
145
+ if self._session is None:
146
+ self._session = aiohttp.ClientSession(connector=aiohttp.TCPConnector(limit_per_host=1))
147
+ try:
148
+ async with self._session.request(method, self._base + path, timeout=self._timeout) as response:
149
+ if response.status == HTTPStatus.NOT_FOUND:
150
+ msg = f"{path} is not available on this firmware"
151
+ raise VoltBridgeUnsupportedError(msg)
152
+ if response.status >= HTTPStatus.INTERNAL_SERVER_ERROR:
153
+ msg = f"HTTP {response.status}"
154
+ raise _ServerError(msg)
155
+ if response.status != HTTPStatus.OK:
156
+ msg = f"{path} returned HTTP {response.status}"
157
+ raise VoltBridgeResponseError(
158
+ msg,
159
+ status=response.status,
160
+ )
161
+ return await response.read()
162
+ except TimeoutError as err:
163
+ msg = f"{self._base} timed out"
164
+ raise VoltBridgeConnectionError(msg) from err
165
+ except aiohttp.ClientError as err:
166
+ msg = f"cannot reach {self._base}{path}: {err}"
167
+ raise VoltBridgeConnectionError(msg) from err
@@ -0,0 +1,58 @@
1
+ """Identify a VoltBridge from its mDNS record.
2
+
3
+ The device advertises ``_mygrid._tcp`` with TXT properties such as
4
+ ``model=Voltbridge P1 Meter`` and ``device_id=907069D02D80`` (its MAC address).
5
+ Resolving the service is left to the caller (for example Home Assistant's
6
+ zeroconf integration), so this module needs no extra dependency.
7
+ """
8
+
9
+ from collections.abc import Mapping
10
+ from dataclasses import dataclass
11
+ import re
12
+
13
+ from .exceptions import VoltBridgeResponseError
14
+
15
+ SERVICE_TYPE = "_mygrid._tcp.local."
16
+ _DEVICE_ID = re.compile(r"[0-9A-Fa-f]{12}")
17
+
18
+
19
+ @dataclass(frozen=True)
20
+ class VoltBridgeInfo:
21
+ """Identity advertised over mDNS."""
22
+
23
+ device_id: str
24
+ model: str | None
25
+ firmware_version: str | None
26
+ hardware_version: str | None
27
+ api_path: str | None
28
+
29
+ @property
30
+ def mac_address(self) -> str:
31
+ """The device id formatted as a lower-case MAC address."""
32
+ return ":".join(self.device_id[i : i + 2] for i in range(0, 12, 2)).lower()
33
+
34
+ @classmethod
35
+ def from_properties(cls, properties: Mapping[str | bytes, str | bytes | None]) -> "VoltBridgeInfo":
36
+ """Build from mDNS TXT properties; raises if this is not a VoltBridge."""
37
+ text = {_str(key): _str(value) for key, value in properties.items()}
38
+ device_id = text.get("device_id") or ""
39
+ model = text.get("model")
40
+ if text.get("manufacturer") != "MyGrid" or not _DEVICE_ID.fullmatch(device_id):
41
+ msg = "not a MyGrid device advertisement"
42
+ raise VoltBridgeResponseError(msg)
43
+ if model is None or "voltbridge" not in model.lower():
44
+ msg = f"not a VoltBridge: {model!r}"
45
+ raise VoltBridgeResponseError(msg)
46
+ return cls(
47
+ device_id=device_id.upper(),
48
+ model=model,
49
+ firmware_version=text.get("firmware_version"),
50
+ hardware_version=text.get("hardware_version"),
51
+ api_path=text.get("api_path"),
52
+ )
53
+
54
+
55
+ def _str(value: str | bytes | None) -> str | None:
56
+ if isinstance(value, bytes):
57
+ return value.decode("utf-8", errors="replace")
58
+ return value
@@ -0,0 +1,32 @@
1
+ """Errors raised by the VoltBridge client."""
2
+
3
+
4
+ class VoltBridgeError(Exception):
5
+ """Base class for all VoltBridge errors."""
6
+
7
+
8
+ class VoltBridgeConnectionError(VoltBridgeError):
9
+ """The device could not be reached, timed out or kept answering HTTP 5xx.
10
+
11
+ Raised only after every retry; the request may succeed later.
12
+ """
13
+
14
+
15
+ class VoltBridgeResponseError(VoltBridgeError):
16
+ """The device answered with an unusable status or body.
17
+
18
+ ``status`` is the HTTP status when the failure is tied to one.
19
+ """
20
+
21
+ def __init__(self, message: str = "Invalid VoltBridge response", *, status: int | None = None) -> None:
22
+ """Create the error with an optional HTTP ``status``."""
23
+ super().__init__(message)
24
+ self.status = status
25
+
26
+
27
+ class VoltBridgeNoDataError(VoltBridgeError):
28
+ """The device is up but has not received a P1 telegram yet."""
29
+
30
+
31
+ class VoltBridgeUnsupportedError(VoltBridgeError):
32
+ """The firmware does not provide this endpoint (HTTP 404)."""
@@ -0,0 +1,157 @@
1
+ """Immutable readings in watts, watt-hours, volts and amperes.
2
+
3
+ The device reports power in kW and energy in kWh; both are converted to
4
+ integer W and Wh so values compare and sum exactly.
5
+ """
6
+
7
+ from collections.abc import Mapping
8
+ from dataclasses import dataclass, field
9
+ from datetime import datetime
10
+ from typing import Any, cast
11
+
12
+ from .exceptions import VoltBridgeNoDataError, VoltBridgeResponseError
13
+
14
+
15
+ def _number(payload: Mapping[str, Any], key: str) -> float | None:
16
+ value = payload.get(key)
17
+ if value is None:
18
+ return None
19
+ if isinstance(value, bool) or not isinstance(value, int | float):
20
+ msg = f"{key} is not a number: {value!r}"
21
+ raise VoltBridgeResponseError(msg)
22
+ return float(value)
23
+
24
+
25
+ def _milli(payload: Mapping[str, Any], key: str) -> int | None:
26
+ """Convert a kW/kWh field to W/Wh."""
27
+ value = _number(payload, key)
28
+ return None if value is None else round(value * 1000)
29
+
30
+
31
+ def _require_data(payload: object) -> Mapping[str, Any]:
32
+ if not isinstance(payload, dict):
33
+ msg = "response is not a JSON object"
34
+ raise VoltBridgeResponseError(msg)
35
+ if not payload.get("hasData"):
36
+ msg = "no P1 telegram received yet"
37
+ raise VoltBridgeNoDataError(msg)
38
+ return cast("Mapping[str, Any]", payload)
39
+
40
+
41
+ @dataclass(frozen=True)
42
+ class PowerReading:
43
+ """Instantaneous power from ``/api/power``; positive net power is import."""
44
+
45
+ net_power: int
46
+ import_power: int
47
+ export_power: int
48
+ tariff: int | None
49
+
50
+ @classmethod
51
+ def from_payload(cls, payload: object) -> "PowerReading":
52
+ """Build from a ``/api/power`` payload."""
53
+ data = _require_data(payload)
54
+ net = _milli(data, "netPowerW")
55
+ imported = _milli(data, "importPowerW")
56
+ exported = _milli(data, "exportPowerW")
57
+ if net is None or imported is None or exported is None:
58
+ msg = "power response is missing fields"
59
+ raise VoltBridgeResponseError(msg)
60
+ tariff = _number(data, "tariff")
61
+ return cls(
62
+ net_power=net,
63
+ import_power=imported,
64
+ export_power=exported,
65
+ tariff=None if tariff is None else int(tariff),
66
+ )
67
+
68
+
69
+ @dataclass(frozen=True)
70
+ class PhaseData:
71
+ """Per-phase values; any field the meter does not report is ``None``."""
72
+
73
+ import_power: int | None
74
+ export_power: int | None
75
+ voltage: float | None
76
+ current: float | None
77
+
78
+
79
+ @dataclass(frozen=True)
80
+ class P1Data:
81
+ """A full P1 snapshot from ``/api/p1data``; unreported fields are ``None``."""
82
+
83
+ meter_serial: str | None
84
+ meter_version: str | None
85
+ import_power: int | None
86
+ export_power: int | None
87
+ net_power: int | None
88
+ avg_import_1min: int | None
89
+ avg_export_1min: int | None
90
+ avg_demand: int | None
91
+ peak_demand: int | None
92
+ peak_demand_at: datetime | None
93
+ tariff: int | None
94
+ energy_import_t1: int | None
95
+ energy_import_t2: int | None
96
+ energy_export_t1: int | None
97
+ energy_export_t2: int | None
98
+ gas: float | None
99
+ phases: Mapping[int, PhaseData] = field(default_factory=dict)
100
+
101
+ @classmethod
102
+ def from_payload(cls, payload: object) -> "P1Data":
103
+ """Build from a ``/api/p1data`` payload."""
104
+ data = _require_data(payload)
105
+ tariff = _number(data, "tariff")
106
+ return cls(
107
+ meter_serial=_text(data, "meterSerial"),
108
+ meter_version=_text(data, "meterVersion"),
109
+ import_power=_milli(data, "importPower"),
110
+ export_power=_milli(data, "exportPower"),
111
+ net_power=_milli(data, "activePowerNet"),
112
+ avg_import_1min=_milli(data, "avgImport1min"),
113
+ avg_export_1min=_milli(data, "avgExport1min"),
114
+ avg_demand=_milli(data, "avgDemand"),
115
+ peak_demand=_milli(data, "peakDemand"),
116
+ peak_demand_at=_timestamp(data, "peakDemandAt"),
117
+ tariff=None if tariff is None else int(tariff),
118
+ energy_import_t1=_milli(data, "energyImportT1"),
119
+ energy_import_t2=_milli(data, "energyImportT2"),
120
+ energy_export_t1=_milli(data, "energyExportT1"),
121
+ energy_export_t2=_milli(data, "energyExportT2"),
122
+ gas=_number(data, "gasM3"),
123
+ phases=_phases(data),
124
+ )
125
+
126
+
127
+ def _text(payload: Mapping[str, Any], key: str) -> str | None:
128
+ value = payload.get(key)
129
+ return None if value is None else str(value)
130
+
131
+
132
+ def _timestamp(payload: Mapping[str, Any], key: str) -> datetime | None:
133
+ value = payload.get(key)
134
+ if value is None:
135
+ return None
136
+ try:
137
+ return datetime.fromisoformat(str(value))
138
+ except ValueError as err:
139
+ msg = f"{key} is not a timestamp: {value!r}"
140
+ raise VoltBridgeResponseError(msg) from err
141
+
142
+
143
+ def _phases(payload: Mapping[str, Any]) -> dict[int, PhaseData]:
144
+ """Collect ``<field>L<n>`` keys. Only L1 is verified on real hardware."""
145
+ phases: dict[int, PhaseData] = {}
146
+ for number in (1, 2, 3):
147
+ suffix = f"L{number}"
148
+ keys = ("importPower", "exportPower", "voltage", "current")
149
+ if not any(f"{key}{suffix}" in payload for key in keys):
150
+ continue
151
+ phases[number] = PhaseData(
152
+ import_power=_milli(payload, f"importPower{suffix}"),
153
+ export_power=_milli(payload, f"exportPower{suffix}"),
154
+ voltage=_number(payload, f"voltage{suffix}"),
155
+ current=_number(payload, f"current{suffix}"),
156
+ )
157
+ return phases
File without changes
@@ -0,0 +1,214 @@
1
+ """Parser for the raw DSMR/Fluvius P1 telegram served at ``/api/telegram``.
2
+
3
+ The JSON API only exposes the electricity values and gas. The telegram also
4
+ carries every M-Bus device (water, heat), the breaker and limiter state and
5
+ the monthly peak history. Unknown OBIS codes stay available in ``objects``.
6
+ """
7
+
8
+ from collections.abc import Mapping
9
+ from dataclasses import dataclass, field
10
+ from datetime import UTC, datetime, timedelta, timezone
11
+ import re
12
+
13
+ from .exceptions import VoltBridgeResponseError
14
+
15
+ _OBIS = re.compile(r"^(\d+-\d+:\d+\.\d+\.\d+)((?:\([^)]*\))*)$")
16
+ _GROUP = re.compile(r"\(([^)]*)\)")
17
+ _TIMESTAMP = re.compile(r"^(\d{12})([SW])$")
18
+ _VALUE = re.compile(r"^(-?\d+(?:\.\d+)?)\*(\S+)$")
19
+ _MBUS = re.compile(r"^0-(\d+):24\.(\d+)\.\d+$")
20
+
21
+ # DSMR M-Bus device type codes.
22
+ GAS = 3
23
+ HEAT = 4
24
+ WATER = 7
25
+
26
+
27
+ @dataclass(frozen=True)
28
+ class MBusChannel:
29
+ """One device on the meter's M-Bus (``0-n:24.*``)."""
30
+
31
+ channel: int
32
+ device_type: int | None
33
+ equipment_id: str | None
34
+ reading_at: datetime | None
35
+ reading: float | None
36
+ unit: str | None
37
+ valve_state: int | None
38
+
39
+ @property
40
+ def kind(self) -> str:
41
+ """Return ``gas``, ``water``, ``heat`` or ``unknown``."""
42
+ return {GAS: "gas", HEAT: "heat", WATER: "water"}.get(self.device_type or 0, "unknown")
43
+
44
+
45
+ @dataclass(frozen=True)
46
+ class MonthlyPeak:
47
+ """One entry of the monthly peak-demand history (OBIS ``0-0:98.1.0``)."""
48
+
49
+ month_end: datetime
50
+ peak_at: datetime
51
+ power: int
52
+
53
+
54
+ @dataclass(frozen=True)
55
+ class Telegram:
56
+ """A decoded telegram; power in W, current in A, volumes as reported."""
57
+
58
+ header: str
59
+ crc_valid: bool
60
+ timestamp: datetime | None
61
+ breaker_state: int | None
62
+ limiter_threshold: int | None
63
+ fuse_threshold: int | None
64
+ avg_demand: int | None
65
+ peak_demand: int | None
66
+ peak_demand_at: datetime | None
67
+ monthly_peaks: tuple[MonthlyPeak, ...]
68
+ channels: Mapping[int, MBusChannel]
69
+ objects: Mapping[str, tuple[tuple[str, ...], ...]] = field(default_factory=dict)
70
+
71
+ def channel_of(self, kind: str) -> MBusChannel | None:
72
+ """Return the first M-Bus device of a kind (``gas``, ``water``, ``heat``)."""
73
+ return next((c for _, c in sorted(self.channels.items()) if c.kind == kind), None)
74
+
75
+
76
+ def _crc16(data: bytes) -> int:
77
+ crc = 0
78
+ for byte in data:
79
+ crc ^= byte
80
+ for _ in range(8):
81
+ crc = (crc >> 1) ^ 0xA001 if crc & 1 else crc >> 1
82
+ return crc
83
+
84
+
85
+ def _timestamp(text: str) -> datetime | None:
86
+ """``YYMMDDhhmmssS|W``: local time with a summer/winter marker (CET/CEST)."""
87
+ match = _TIMESTAMP.match(text)
88
+ if not match:
89
+ return None
90
+ digits, season = match.groups()
91
+ zone = timezone(timedelta(hours=2 if season == "S" else 1))
92
+ try:
93
+ return datetime(
94
+ 2000 + int(digits[0:2]),
95
+ int(digits[2:4]),
96
+ int(digits[4:6]),
97
+ int(digits[6:8]),
98
+ int(digits[8:10]),
99
+ int(digits[10:12]),
100
+ tzinfo=zone,
101
+ ).astimezone(UTC)
102
+ except ValueError:
103
+ return None
104
+
105
+
106
+ def _quantity(text: str, unit: str) -> float | None:
107
+ """Return a value in the given unit, or ``None`` if absent or malformed."""
108
+ match = _VALUE.match(text)
109
+ return float(match.group(1)) if match and match.group(2) == unit else None
110
+
111
+
112
+ def _kilo(text: str, unit: str) -> int | None:
113
+ value = _quantity(text, unit)
114
+ return None if value is None else round(value * 1000)
115
+
116
+
117
+ def _first(objects: Mapping[str, tuple[tuple[str, ...], ...]], code: str) -> tuple[str, ...]:
118
+ return objects.get(code, ((),))[0]
119
+
120
+
121
+ def _int(text: str | None) -> int | None:
122
+ return int(text) if text is not None and text.lstrip("-").isdigit() else None
123
+
124
+
125
+ def _hex_text(text: str) -> str:
126
+ """Equipment ids are hex-encoded ASCII; fall back to the raw hex."""
127
+ try:
128
+ decoded = bytes.fromhex(text).decode("ascii")
129
+ except ValueError:
130
+ return text
131
+ return decoded if decoded.isprintable() else text
132
+
133
+
134
+ def _peaks(groups: tuple[str, ...]) -> tuple[MonthlyPeak, ...]:
135
+ triples = groups[3:]
136
+ peaks = []
137
+ for index in range(0, len(triples) - 2, 3):
138
+ month_end = _timestamp(triples[index])
139
+ peak_at = _timestamp(triples[index + 1])
140
+ power = _kilo(triples[index + 2], "kW")
141
+ if month_end and peak_at and power is not None:
142
+ peaks.append(MonthlyPeak(month_end, peak_at, power))
143
+ return tuple(peaks)
144
+
145
+
146
+ def _channels(
147
+ objects: Mapping[str, tuple[tuple[str, ...], ...]],
148
+ ) -> dict[int, MBusChannel]:
149
+ numbers = {int(m.group(1)) for code in objects if (m := _MBUS.match(code))}
150
+ channels = {}
151
+ for number in sorted(numbers):
152
+ reading_groups: tuple[str, ...] = ()
153
+ for code, values in objects.items():
154
+ match = _MBUS.match(code)
155
+ if match and int(match.group(1)) == number and match.group(2) == "2":
156
+ reading_groups = values[0]
157
+ reading_at = _timestamp(reading_groups[0]) if reading_groups else None
158
+ quantity = reading_groups[1] if len(reading_groups) > 1 else ""
159
+ match = _VALUE.match(quantity)
160
+ type_group = _first(objects, f"0-{number}:24.1.0")
161
+ id_group = _first(objects, f"0-{number}:96.1.1")
162
+ valve_group = _first(objects, f"0-{number}:24.4.0")
163
+ channels[number] = MBusChannel(
164
+ channel=number,
165
+ device_type=_int(type_group[0]) if type_group else None,
166
+ equipment_id=_hex_text(id_group[0]) if id_group and id_group[0] else None,
167
+ reading_at=reading_at,
168
+ reading=float(match.group(1)) if match else None,
169
+ unit=match.group(2) if match else None,
170
+ valve_state=_int(valve_group[0]) if valve_group else None,
171
+ )
172
+ return channels
173
+
174
+
175
+ def parse_telegram(text: str) -> Telegram:
176
+ """Decode a telegram; raises ``VoltBridgeResponseError`` if it is not one."""
177
+ start, bang = text.find("/"), text.find("!")
178
+ if start < 0 or bang < start:
179
+ msg = "not a P1 telegram"
180
+ raise VoltBridgeResponseError(msg)
181
+ body = text[start : bang + 1]
182
+ expected = text[bang + 1 : bang + 5]
183
+ crc_valid = f"{_crc16(body.encode('latin-1')):04X}" == expected.upper()
184
+
185
+ lines = body.splitlines()
186
+ header = lines[0].strip()
187
+ objects: dict[str, tuple[tuple[str, ...], ...]] = {}
188
+ for line in lines[1:]:
189
+ match = _OBIS.match(line.strip())
190
+ if match:
191
+ code, rest = match.groups()
192
+ groups = tuple(_GROUP.findall(rest))
193
+ objects[code] = (*objects.get(code, ()), groups)
194
+
195
+ timestamp = _first(objects, "0-0:1.0.0")
196
+ peak = _first(objects, "1-0:1.6.0")
197
+ limiter = _first(objects, "0-0:17.0.0")
198
+ fuse = _first(objects, "1-0:31.4.0")
199
+ demand = _first(objects, "1-0:1.4.0")
200
+ history = _first(objects, "0-0:98.1.0")
201
+ return Telegram(
202
+ header=header,
203
+ crc_valid=crc_valid,
204
+ timestamp=_timestamp(timestamp[0]) if timestamp else None,
205
+ breaker_state=_int(_first(objects, "0-0:96.3.10")[0]) if "0-0:96.3.10" in objects else None,
206
+ limiter_threshold=_kilo(limiter[0], "kW") if limiter else None,
207
+ fuse_threshold=round(q) if fuse and (q := _quantity(fuse[0], "A")) is not None else None,
208
+ avg_demand=_kilo(demand[0], "kW") if demand else None,
209
+ peak_demand=_kilo(peak[1], "kW") if len(peak) > 1 else None,
210
+ peak_demand_at=_timestamp(peak[0]) if peak else None,
211
+ monthly_peaks=_peaks(history),
212
+ channels=_channels(objects),
213
+ objects=objects,
214
+ )
@@ -0,0 +1,268 @@
1
+ """Decoder and listener for the VoltBridge's UDP broadcasts (push updates).
2
+
3
+ Every datagram is ``P1`` + version + stream + flags + payload length + CRC-16
4
+ (ARC, big endian) + a protobuf payload. Stream 0 (port 49491) carries the live
5
+ telegram about once a second; stream 1 (port 49492) carries one-minute averages
6
+ less often. The schema was reverse-engineered, so it is decoded by hand against
7
+ a fixed field map instead of with generated protobuf code.
8
+ """
9
+
10
+ import asyncio
11
+ from collections.abc import Callable
12
+ from dataclasses import dataclass
13
+ from datetime import UTC, datetime
14
+ from enum import IntEnum
15
+ import logging
16
+ import struct
17
+ from typing import Any, override
18
+
19
+ from .exceptions import VoltBridgeResponseError
20
+ from .models import P1Data, PhaseData
21
+
22
+ _LOGGER = logging.getLogger(__name__)
23
+
24
+ P1_PORT = 49491
25
+ AGGREGATED_PORT = 49492
26
+ _MAGIC = b"P1"
27
+ _HEADER = 8
28
+ _MAX_VARINT_SHIFT = 63
29
+ _LENGTH_DELIMITED = 2 # protobuf wire type
30
+ _PEAK_FIELD = 2 # field 2 of the demand message
31
+
32
+ _Fields = dict[int, list[Any]]
33
+
34
+
35
+ class BroadcastStream(IntEnum):
36
+ """The stream a datagram belongs to (header byte 3)."""
37
+
38
+ P1 = 0
39
+ AGGREGATED = 1
40
+
41
+
42
+ @dataclass(frozen=True)
43
+ class P1Broadcast:
44
+ """One decoded datagram.
45
+
46
+ ``data`` uses the same units as the HTTP client. On the aggregated stream
47
+ the import/export fields hold one-minute averages in ``avg_*`` and the
48
+ instantaneous fields are ``None``.
49
+ """
50
+
51
+ stream: BroadcastStream
52
+ timestamp: datetime
53
+ rssi: int | None
54
+ country: str | None
55
+ software_version: str | None
56
+ data: P1Data
57
+
58
+
59
+ def _crc16_arc(data: bytes) -> int:
60
+ crc = 0
61
+ for byte in data:
62
+ crc ^= byte
63
+ for _ in range(8):
64
+ crc = (crc >> 1) ^ 0xA001 if crc & 1 else crc >> 1
65
+ return crc
66
+
67
+
68
+ def _varint(buf: bytes, pos: int) -> tuple[int, int]:
69
+ result = shift = 0
70
+ while True:
71
+ if pos >= len(buf) or shift > _MAX_VARINT_SHIFT:
72
+ msg = "truncated varint"
73
+ raise VoltBridgeResponseError(msg)
74
+ byte = buf[pos]
75
+ pos += 1
76
+ result |= (byte & 0x7F) << shift
77
+ shift += 7
78
+ if not byte & 0x80:
79
+ return result, pos
80
+
81
+
82
+ def _read(buf: bytes) -> _Fields:
83
+ """Split a protobuf message into ``{field: [value, ...]}`` (wire types 0, 1, 2, 5)."""
84
+ fields: _Fields = {}
85
+ pos = 0
86
+ value: Any
87
+ while pos < len(buf):
88
+ key, pos = _varint(buf, pos)
89
+ number, wire = key >> 3, key & 7
90
+ if wire == 0:
91
+ value, pos = _varint(buf, pos)
92
+ elif wire in (1, 5):
93
+ size = 8 if wire == 1 else 4
94
+ if pos + size > len(buf):
95
+ msg = "truncated fixed-width field"
96
+ raise VoltBridgeResponseError(msg)
97
+ fmt = "<d" if wire == 1 else "<f"
98
+ (value,) = struct.unpack_from(fmt, buf, pos)
99
+ pos += size
100
+ elif wire == _LENGTH_DELIMITED:
101
+ size, pos = _varint(buf, pos)
102
+ if pos + size > len(buf):
103
+ msg = "truncated length-delimited field"
104
+ raise VoltBridgeResponseError(msg)
105
+ value = buf[pos : pos + size]
106
+ pos += size
107
+ else:
108
+ msg = f"unsupported wire type {wire}"
109
+ raise VoltBridgeResponseError(msg)
110
+ fields.setdefault(number, []).append(value)
111
+ return fields
112
+
113
+
114
+ def _msg(fields: _Fields, number: int) -> _Fields | None:
115
+ values = fields.get(number)
116
+ if values is None or not isinstance(values[-1], bytes):
117
+ return None
118
+ return _read(values[-1])
119
+
120
+
121
+ def _num(fields: _Fields | None, number: int) -> float:
122
+ """Proto3 omits zero, so a missing scalar inside a present message is 0."""
123
+ if fields is None:
124
+ return 0.0
125
+ value = fields.get(number, [0.0])[-1]
126
+ if isinstance(value, bytes):
127
+ msg = f"field {number} is not a number"
128
+ raise VoltBridgeResponseError(msg)
129
+ return float(value)
130
+
131
+
132
+ def _text(fields: _Fields | None, number: int) -> str | None:
133
+ values = ({} if fields is None else fields).get(number)
134
+ if values is None or not isinstance(values[-1], bytes):
135
+ return None
136
+ return values[-1].decode("utf-8", errors="replace") or None
137
+
138
+
139
+ def _milli(value: float) -> int:
140
+ return round(value * 1000)
141
+
142
+
143
+ def _phase(fields: _Fields | None) -> PhaseData | None:
144
+ if fields is None or len(fields) == 0:
145
+ return None
146
+ return PhaseData(
147
+ import_power=_milli(_num(fields, 1)),
148
+ export_power=_milli(_num(fields, 2)),
149
+ voltage=round(_num(fields, 3), 2),
150
+ current=round(_num(fields, 4), 2),
151
+ )
152
+
153
+
154
+ def parse_datagram(datagram: bytes) -> P1Broadcast:
155
+ """Decode one UDP datagram, raising ``VoltBridgeResponseError`` if invalid."""
156
+ if len(datagram) < _HEADER or datagram[:2] != _MAGIC or datagram[2] != 1:
157
+ msg = "not a VoltBridge datagram"
158
+ raise VoltBridgeResponseError(msg)
159
+ try:
160
+ stream = BroadcastStream(datagram[3])
161
+ except ValueError as err:
162
+ msg = f"unknown stream {datagram[3]}"
163
+ raise VoltBridgeResponseError(msg) from err
164
+ payload = datagram[_HEADER:]
165
+ if datagram[5] != len(payload):
166
+ msg = "payload length mismatch"
167
+ raise VoltBridgeResponseError(msg)
168
+ if int.from_bytes(datagram[6:8], "big") != _crc16_arc(payload):
169
+ msg = "checksum mismatch"
170
+ raise VoltBridgeResponseError(msg)
171
+
172
+ envelope = _read(payload)
173
+ seconds = _num(_msg(envelope, 1), 1)
174
+ if not seconds:
175
+ msg = "missing timestamp"
176
+ raise VoltBridgeResponseError(msg)
177
+ rssi_raw = _msg(envelope, 5)
178
+ rssi = None
179
+ if rssi_raw is not None:
180
+ raw = rssi_raw.get(1, [0])[-1]
181
+ if not isinstance(raw, int):
182
+ msg = "rssi is not an integer"
183
+ raise VoltBridgeResponseError(msg)
184
+ rssi = raw - (1 << 64) if raw >= 1 << 63 else raw
185
+ meter = _msg(envelope, 6)
186
+ body = _msg(envelope, 10 if stream is BroadcastStream.P1 else 11) or {}
187
+
188
+ totals = _msg(body, 10)
189
+ demand = _msg(body, 11)
190
+ peak_at = _msg(demand, 3) if demand is not None else None
191
+ power = _msg(body, 1)
192
+ imported, exported = _milli(_num(power, 1)), _milli(_num(power, 2))
193
+ live = stream is BroadcastStream.P1
194
+ phases = {n: p for n in (1, 2, 3) if live and (p := _phase(_msg(body, n + 1)))}
195
+
196
+ data = P1Data(
197
+ meter_serial=_text(meter, 1),
198
+ meter_version=_text(meter, 2),
199
+ import_power=imported if live else None,
200
+ export_power=exported if live else None,
201
+ net_power=imported - exported if live else None,
202
+ avg_import_1min=None if live else imported,
203
+ avg_export_1min=None if live else exported,
204
+ avg_demand=_milli(_num(demand, 1)) if demand is not None else None,
205
+ peak_demand=_milli(_num(demand, 2)) if demand is not None and _PEAK_FIELD in demand else None,
206
+ peak_demand_at=(datetime.fromtimestamp(int(_num(peak_at, 1)), UTC) if peak_at is not None else None),
207
+ tariff=int(_num(totals, 5)) if totals is not None else None,
208
+ energy_import_t1=_milli(_num(totals, 1)) if totals is not None else None,
209
+ energy_import_t2=_milli(_num(totals, 2)) if totals is not None else None,
210
+ energy_export_t1=_milli(_num(totals, 3)) if totals is not None else None,
211
+ energy_export_t2=_milli(_num(totals, 4)) if totals is not None else None,
212
+ gas=None,
213
+ phases=phases,
214
+ )
215
+ return P1Broadcast(
216
+ stream=stream,
217
+ timestamp=datetime.fromtimestamp(int(seconds), UTC),
218
+ rssi=rssi,
219
+ country=_text(envelope, 4),
220
+ software_version=_text(envelope, 2),
221
+ data=data,
222
+ )
223
+
224
+
225
+ class _Protocol(asyncio.DatagramProtocol):
226
+ def __init__(self, callback: Callable[[P1Broadcast], None], host: str | None) -> None:
227
+ self._callback = callback
228
+ self._host = host
229
+
230
+ @override
231
+ def datagram_received(self, data: bytes, addr: tuple[str, int]) -> None:
232
+ if self._host is not None and addr[0] != self._host:
233
+ return
234
+ try:
235
+ broadcast = parse_datagram(data)
236
+ except VoltBridgeResponseError as err:
237
+ _LOGGER.debug("Dropping datagram from %s: %s", addr[0], err)
238
+ return
239
+ self._callback(broadcast)
240
+
241
+
242
+ async def async_listen(
243
+ callback: Callable[[P1Broadcast], None],
244
+ *,
245
+ host: str | None = None,
246
+ port: int = P1_PORT,
247
+ bind: str = "0.0.0.0", # noqa: S104 - broadcasts arrive on any interface
248
+ ) -> asyncio.DatagramTransport:
249
+ """Listen for broadcasts; ``host`` filters by the device's IP.
250
+
251
+ Call ``close()`` on the returned transport to stop. Invalid datagrams are
252
+ dropped. ``callback`` runs in the event loop and must not block.
253
+ """
254
+ loop = asyncio.get_running_loop()
255
+ transport, _ = await loop.create_datagram_endpoint(
256
+ lambda: _Protocol(callback, host), local_addr=(bind, port), reuse_port=True
257
+ )
258
+ return transport
259
+
260
+
261
+ __all__ = [
262
+ "AGGREGATED_PORT",
263
+ "P1_PORT",
264
+ "BroadcastStream",
265
+ "P1Broadcast",
266
+ "async_listen",
267
+ "parse_datagram",
268
+ ]