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.
- mygrid_voltbridge-0.3.0/.gitignore +12 -0
- mygrid_voltbridge-0.3.0/LICENSE +21 -0
- mygrid_voltbridge-0.3.0/PKG-INFO +88 -0
- mygrid_voltbridge-0.3.0/README.md +66 -0
- mygrid_voltbridge-0.3.0/pyproject.toml +116 -0
- mygrid_voltbridge-0.3.0/src/mygrid_voltbridge/__init__.py +50 -0
- mygrid_voltbridge-0.3.0/src/mygrid_voltbridge/cli.py +67 -0
- mygrid_voltbridge-0.3.0/src/mygrid_voltbridge/client.py +167 -0
- mygrid_voltbridge-0.3.0/src/mygrid_voltbridge/discovery.py +58 -0
- mygrid_voltbridge-0.3.0/src/mygrid_voltbridge/exceptions.py +32 -0
- mygrid_voltbridge-0.3.0/src/mygrid_voltbridge/models.py +157 -0
- mygrid_voltbridge-0.3.0/src/mygrid_voltbridge/py.typed +0 -0
- mygrid_voltbridge-0.3.0/src/mygrid_voltbridge/telegram.py +214 -0
- mygrid_voltbridge-0.3.0/src/mygrid_voltbridge/udp.py +268 -0
|
@@ -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
|
+
]
|