innergrid 0.1.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.
- innergrid-0.1.0/LICENSE +21 -0
- innergrid-0.1.0/PKG-INFO +112 -0
- innergrid-0.1.0/README.md +82 -0
- innergrid-0.1.0/innergrid/__init__.py +63 -0
- innergrid-0.1.0/innergrid/const.py +50 -0
- innergrid-0.1.0/innergrid/exceptions.py +17 -0
- innergrid-0.1.0/innergrid/innergrid.py +292 -0
- innergrid-0.1.0/innergrid/models.py +714 -0
- innergrid-0.1.0/innergrid/py.typed +0 -0
- innergrid-0.1.0/pyproject.toml +161 -0
- innergrid-0.1.0/pyproject.toml.orig +152 -0
innergrid-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Robbin Janssen
|
|
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.
|
innergrid-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: innergrid
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Asynchronous Python client for the energy and building data that Innergrid publishes over MQTT.
|
|
5
|
+
Keywords: async,client,energy,mqtt,innergrid
|
|
6
|
+
Author: Robbin Janssen
|
|
7
|
+
Author-email: Robbin Janssen <robbinjanssen@gmail.com>
|
|
8
|
+
License-Expression: MIT
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Classifier: Development Status :: 3 - Alpha
|
|
11
|
+
Classifier: Framework :: AsyncIO
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: Natural Language :: English
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
18
|
+
Classifier: Programming Language :: Python :: 3
|
|
19
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
20
|
+
Requires-Dist: aiomqtt>=2.0.0
|
|
21
|
+
Maintainer: Robbin Janssen
|
|
22
|
+
Maintainer-email: Robbin Janssen <robbinjanssen@gmail.com>
|
|
23
|
+
Requires-Python: >=3.11
|
|
24
|
+
Project-URL: Homepage, https://github.com/robbinjanssen/python-innergrid
|
|
25
|
+
Project-URL: Repository, https://github.com/robbinjanssen/python-innergrid
|
|
26
|
+
Project-URL: Documentation, https://github.com/robbinjanssen/python-innergrid
|
|
27
|
+
Project-URL: Bug Tracker, https://github.com/robbinjanssen/python-innergrid/issues
|
|
28
|
+
Project-URL: Changelog, https://github.com/robbinjanssen/python-innergrid/releases
|
|
29
|
+
Description-Content-Type: text/markdown
|
|
30
|
+
|
|
31
|
+
# Python: Innergrid
|
|
32
|
+
|
|
33
|
+
Asynchronous Python client that reads the energy and building data that the
|
|
34
|
+
Innergrid energy management platform publishes over MQTT for a customer site:
|
|
35
|
+
the grid connection, energy categories, electricity meters and circuits,
|
|
36
|
+
charge points, the energy manager, spaces, the server and Milesight LoRaWAN
|
|
37
|
+
sensors.
|
|
38
|
+
|
|
39
|
+
## Installation
|
|
40
|
+
|
|
41
|
+
```sh
|
|
42
|
+
pip install innergrid
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
## Usage
|
|
46
|
+
|
|
47
|
+
```python
|
|
48
|
+
import asyncio
|
|
49
|
+
|
|
50
|
+
from innergrid import Innergrid, Update
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def on_update(update: Update) -> None:
|
|
54
|
+
print(update.key, update.data)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
async def main() -> None:
|
|
58
|
+
client = Innergrid("192.0.2.10", 1883, topic_prefix="mysite")
|
|
59
|
+
# Check the settings: wait for the first message below the prefix.
|
|
60
|
+
await client.wait_for_data()
|
|
61
|
+
# Runs until the connection is lost; connect again to continue.
|
|
62
|
+
await client.listen(on_update)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
asyncio.run(main())
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
`listen()` and `wait_for_data()` raise `InnergridAuthError` when the
|
|
69
|
+
broker rejects the login and `InnergridConnectionError` when the connection
|
|
70
|
+
fails or is lost. `wait_for_data()` raises `InnergridTimeoutError` when no
|
|
71
|
+
data arrives in time.
|
|
72
|
+
|
|
73
|
+
## Topics
|
|
74
|
+
|
|
75
|
+
| Topic | Model |
|
|
76
|
+
| --- | --- |
|
|
77
|
+
| `<prefix>/customersites/<id>/live` | `Site` |
|
|
78
|
+
| `<prefix>/categories/<id>/live` | `Categories` |
|
|
79
|
+
| `<prefix>/electricitymeters/<id>` | `ElectricityMeter` |
|
|
80
|
+
| `<prefix>/circuits/<id>/live` | `Circuit` |
|
|
81
|
+
| `<prefix>/chargepoints/<id>` | `ChargePoint` |
|
|
82
|
+
| `<prefix>/energymanagers/<id>/state` | `EnergyManager` |
|
|
83
|
+
| `<prefix>/spaces/<id>/live` | `Space` |
|
|
84
|
+
| `<prefix>/system` | `SystemStatus` |
|
|
85
|
+
| `<prefix>/events/chargepoints/<id>/<event>` | `ChargePointEvent` |
|
|
86
|
+
| `milesight/uplink/<devEUI>` | `MilesightSensor` |
|
|
87
|
+
|
|
88
|
+
Every update has a key (`<kind>/<id>`, for example `meter/10005`); the
|
|
89
|
+
latest update of a key replaces the earlier ones. The topics are not
|
|
90
|
+
retained, so the data arrives as it is published, every 10 seconds to 3
|
|
91
|
+
minutes.
|
|
92
|
+
|
|
93
|
+
`parse_data(kind, id, raw)` parses a payload that was stored earlier, for
|
|
94
|
+
example `update.raw`, into an `Update` again.
|
|
95
|
+
|
|
96
|
+
Climate values that are not plausible, such as -1001.1 °C or 0% humidity,
|
|
97
|
+
are `None`; some sensors report those instead of an error.
|
|
98
|
+
|
|
99
|
+
## Development
|
|
100
|
+
|
|
101
|
+
The project uses [uv](https://docs.astral.sh/uv/) and
|
|
102
|
+
[prek](https://github.com/j178/prek).
|
|
103
|
+
|
|
104
|
+
```sh
|
|
105
|
+
uv sync
|
|
106
|
+
uv run prek install
|
|
107
|
+
uv run pytest
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
## License
|
|
111
|
+
|
|
112
|
+
MIT, see [LICENSE](LICENSE).
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
# Python: Innergrid
|
|
2
|
+
|
|
3
|
+
Asynchronous Python client that reads the energy and building data that the
|
|
4
|
+
Innergrid energy management platform publishes over MQTT for a customer site:
|
|
5
|
+
the grid connection, energy categories, electricity meters and circuits,
|
|
6
|
+
charge points, the energy manager, spaces, the server and Milesight LoRaWAN
|
|
7
|
+
sensors.
|
|
8
|
+
|
|
9
|
+
## Installation
|
|
10
|
+
|
|
11
|
+
```sh
|
|
12
|
+
pip install innergrid
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
## Usage
|
|
16
|
+
|
|
17
|
+
```python
|
|
18
|
+
import asyncio
|
|
19
|
+
|
|
20
|
+
from innergrid import Innergrid, Update
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def on_update(update: Update) -> None:
|
|
24
|
+
print(update.key, update.data)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
async def main() -> None:
|
|
28
|
+
client = Innergrid("192.0.2.10", 1883, topic_prefix="mysite")
|
|
29
|
+
# Check the settings: wait for the first message below the prefix.
|
|
30
|
+
await client.wait_for_data()
|
|
31
|
+
# Runs until the connection is lost; connect again to continue.
|
|
32
|
+
await client.listen(on_update)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
asyncio.run(main())
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
`listen()` and `wait_for_data()` raise `InnergridAuthError` when the
|
|
39
|
+
broker rejects the login and `InnergridConnectionError` when the connection
|
|
40
|
+
fails or is lost. `wait_for_data()` raises `InnergridTimeoutError` when no
|
|
41
|
+
data arrives in time.
|
|
42
|
+
|
|
43
|
+
## Topics
|
|
44
|
+
|
|
45
|
+
| Topic | Model |
|
|
46
|
+
| --- | --- |
|
|
47
|
+
| `<prefix>/customersites/<id>/live` | `Site` |
|
|
48
|
+
| `<prefix>/categories/<id>/live` | `Categories` |
|
|
49
|
+
| `<prefix>/electricitymeters/<id>` | `ElectricityMeter` |
|
|
50
|
+
| `<prefix>/circuits/<id>/live` | `Circuit` |
|
|
51
|
+
| `<prefix>/chargepoints/<id>` | `ChargePoint` |
|
|
52
|
+
| `<prefix>/energymanagers/<id>/state` | `EnergyManager` |
|
|
53
|
+
| `<prefix>/spaces/<id>/live` | `Space` |
|
|
54
|
+
| `<prefix>/system` | `SystemStatus` |
|
|
55
|
+
| `<prefix>/events/chargepoints/<id>/<event>` | `ChargePointEvent` |
|
|
56
|
+
| `milesight/uplink/<devEUI>` | `MilesightSensor` |
|
|
57
|
+
|
|
58
|
+
Every update has a key (`<kind>/<id>`, for example `meter/10005`); the
|
|
59
|
+
latest update of a key replaces the earlier ones. The topics are not
|
|
60
|
+
retained, so the data arrives as it is published, every 10 seconds to 3
|
|
61
|
+
minutes.
|
|
62
|
+
|
|
63
|
+
`parse_data(kind, id, raw)` parses a payload that was stored earlier, for
|
|
64
|
+
example `update.raw`, into an `Update` again.
|
|
65
|
+
|
|
66
|
+
Climate values that are not plausible, such as -1001.1 °C or 0% humidity,
|
|
67
|
+
are `None`; some sensors report those instead of an error.
|
|
68
|
+
|
|
69
|
+
## Development
|
|
70
|
+
|
|
71
|
+
The project uses [uv](https://docs.astral.sh/uv/) and
|
|
72
|
+
[prek](https://github.com/j178/prek).
|
|
73
|
+
|
|
74
|
+
```sh
|
|
75
|
+
uv sync
|
|
76
|
+
uv run prek install
|
|
77
|
+
uv run pytest
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
## License
|
|
81
|
+
|
|
82
|
+
MIT, see [LICENSE](LICENSE).
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"""Asynchronous Python client for the energy data that Innergrid publishes."""
|
|
2
|
+
|
|
3
|
+
from .const import CONNECTED_STATUSES, DEFAULT_PORT, Kind
|
|
4
|
+
from .exceptions import (
|
|
5
|
+
InnergridAuthError,
|
|
6
|
+
InnergridConnectionError,
|
|
7
|
+
InnergridError,
|
|
8
|
+
InnergridTimeoutError,
|
|
9
|
+
)
|
|
10
|
+
from .innergrid import (
|
|
11
|
+
Innergrid,
|
|
12
|
+
Update,
|
|
13
|
+
parse_data,
|
|
14
|
+
parse_message,
|
|
15
|
+
topic_kind,
|
|
16
|
+
valid_topic_prefix,
|
|
17
|
+
)
|
|
18
|
+
from .models import (
|
|
19
|
+
Categories,
|
|
20
|
+
Category,
|
|
21
|
+
ChargePoint,
|
|
22
|
+
ChargePointEvent,
|
|
23
|
+
Circuit,
|
|
24
|
+
ElectricityMeter,
|
|
25
|
+
EnergyManager,
|
|
26
|
+
MilesightSensor,
|
|
27
|
+
Model,
|
|
28
|
+
Quantity,
|
|
29
|
+
Site,
|
|
30
|
+
SiteMeter,
|
|
31
|
+
Space,
|
|
32
|
+
SystemStatus,
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
__all__ = [
|
|
36
|
+
"CONNECTED_STATUSES",
|
|
37
|
+
"DEFAULT_PORT",
|
|
38
|
+
"Categories",
|
|
39
|
+
"Category",
|
|
40
|
+
"ChargePoint",
|
|
41
|
+
"ChargePointEvent",
|
|
42
|
+
"Circuit",
|
|
43
|
+
"ElectricityMeter",
|
|
44
|
+
"EnergyManager",
|
|
45
|
+
"Innergrid",
|
|
46
|
+
"InnergridAuthError",
|
|
47
|
+
"InnergridConnectionError",
|
|
48
|
+
"InnergridError",
|
|
49
|
+
"InnergridTimeoutError",
|
|
50
|
+
"Kind",
|
|
51
|
+
"MilesightSensor",
|
|
52
|
+
"Model",
|
|
53
|
+
"Quantity",
|
|
54
|
+
"Site",
|
|
55
|
+
"SiteMeter",
|
|
56
|
+
"Space",
|
|
57
|
+
"SystemStatus",
|
|
58
|
+
"Update",
|
|
59
|
+
"parse_data",
|
|
60
|
+
"parse_message",
|
|
61
|
+
"topic_kind",
|
|
62
|
+
"valid_topic_prefix",
|
|
63
|
+
]
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"""Constants for the Innergrid client."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from enum import StrEnum
|
|
6
|
+
|
|
7
|
+
DEFAULT_PORT = 1883
|
|
8
|
+
|
|
9
|
+
# The Milesight LoRaWAN sensors publish under their own fixed prefix.
|
|
10
|
+
MILESIGHT_TOPIC = "milesight/uplink"
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class Kind(StrEnum):
|
|
14
|
+
"""The kinds of data, derived from the topic."""
|
|
15
|
+
|
|
16
|
+
SITE = "site"
|
|
17
|
+
CATEGORIES = "categories"
|
|
18
|
+
METER = "meter"
|
|
19
|
+
CIRCUIT = "circuit"
|
|
20
|
+
CHARGE_POINT = "chargepoint"
|
|
21
|
+
ENERGY_MANAGER = "energymanager"
|
|
22
|
+
CHARGE_POINT_EVENT = "chargepointevent"
|
|
23
|
+
SPACE = "space"
|
|
24
|
+
SYSTEM = "system"
|
|
25
|
+
MILESIGHT = "milesight"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
# Charge point statuses (OCPP) in which a vehicle is connected.
|
|
29
|
+
# THROTTLED_BY_EV: the vehicle charges, but limits the power itself.
|
|
30
|
+
CONNECTED_STATUSES = frozenset(
|
|
31
|
+
{
|
|
32
|
+
"PREPARING",
|
|
33
|
+
"CHARGING",
|
|
34
|
+
"THROTTLED_BY_EV",
|
|
35
|
+
"SUSPENDED_BY_EV",
|
|
36
|
+
"SUSPENDED_BY_EVSE",
|
|
37
|
+
"FINISHING",
|
|
38
|
+
}
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
# Ranges outside which a climate value is not plausible. Some sensors report
|
|
42
|
+
# impossible values, such as -1001.1 °C or 0% humidity, instead of an error.
|
|
43
|
+
TEMPERATURE_RANGE = (-40.0, 80.0)
|
|
44
|
+
HUMIDITY_RANGE = (0.1, 100.0)
|
|
45
|
+
CO2_RANGE = (1.0, 10000.0)
|
|
46
|
+
ILLUMINANCE_RANGE = (0.0, 200000.0)
|
|
47
|
+
|
|
48
|
+
# CONNACK return codes (MQTT 3.1.1) and reason codes (MQTT 5) for a login
|
|
49
|
+
# that the broker rejected.
|
|
50
|
+
AUTH_FAILED_CODES = frozenset({4, 5, 134, 135})
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"""Exceptions for the Innergrid client."""
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class InnergridError(Exception):
|
|
5
|
+
"""Generic Innergrid exception."""
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class InnergridConnectionError(InnergridError):
|
|
9
|
+
"""The connection to the MQTT broker failed or was lost."""
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class InnergridAuthError(InnergridConnectionError):
|
|
13
|
+
"""The MQTT broker rejected the username or password."""
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class InnergridTimeoutError(InnergridError):
|
|
17
|
+
"""No data arrived in time."""
|
|
@@ -0,0 +1,292 @@
|
|
|
1
|
+
"""Asynchronous client for the data that Innergrid publishes over MQTT."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import json
|
|
7
|
+
import logging
|
|
8
|
+
import re
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
from typing import TYPE_CHECKING, Any
|
|
11
|
+
|
|
12
|
+
import aiomqtt
|
|
13
|
+
|
|
14
|
+
from .const import (
|
|
15
|
+
AUTH_FAILED_CODES,
|
|
16
|
+
DEFAULT_PORT,
|
|
17
|
+
MILESIGHT_TOPIC,
|
|
18
|
+
Kind,
|
|
19
|
+
)
|
|
20
|
+
from .exceptions import (
|
|
21
|
+
InnergridAuthError,
|
|
22
|
+
InnergridConnectionError,
|
|
23
|
+
InnergridTimeoutError,
|
|
24
|
+
)
|
|
25
|
+
from .models import (
|
|
26
|
+
Categories,
|
|
27
|
+
ChargePoint,
|
|
28
|
+
ChargePointEvent,
|
|
29
|
+
Circuit,
|
|
30
|
+
ElectricityMeter,
|
|
31
|
+
EnergyManager,
|
|
32
|
+
MilesightSensor,
|
|
33
|
+
Site,
|
|
34
|
+
Space,
|
|
35
|
+
SystemStatus,
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
if TYPE_CHECKING:
|
|
39
|
+
from collections.abc import Callable
|
|
40
|
+
|
|
41
|
+
from .models import Model
|
|
42
|
+
|
|
43
|
+
_LOGGER = logging.getLogger(__name__)
|
|
44
|
+
|
|
45
|
+
# Topics below the prefix and the kind of data they carry. The first group
|
|
46
|
+
# is the ID of the device or site.
|
|
47
|
+
TOPIC_KINDS: list[tuple[re.Pattern[str], Kind]] = [
|
|
48
|
+
(re.compile(r"customersites/(\d+)/live"), Kind.SITE),
|
|
49
|
+
(re.compile(r"categories/(\d+)/live"), Kind.CATEGORIES),
|
|
50
|
+
(re.compile(r"electricitymeters/(\d+)"), Kind.METER),
|
|
51
|
+
(re.compile(r"circuits/(\d+)/live"), Kind.CIRCUIT),
|
|
52
|
+
(re.compile(r"chargepoints/(\d+)"), Kind.CHARGE_POINT),
|
|
53
|
+
(re.compile(r"energymanagers/(\d+)/state"), Kind.ENERGY_MANAGER),
|
|
54
|
+
(re.compile(r"events/chargepoints/(\d+)/\w+"), Kind.CHARGE_POINT_EVENT),
|
|
55
|
+
(re.compile(r"spaces/(\d+)/live"), Kind.SPACE),
|
|
56
|
+
(re.compile(r"(system)"), Kind.SYSTEM),
|
|
57
|
+
]
|
|
58
|
+
MILESIGHT_PATTERN = re.compile(re.escape(MILESIGHT_TOPIC) + r"/([0-9a-fA-F]+)")
|
|
59
|
+
|
|
60
|
+
PARSERS: dict[Kind, Callable[[dict[str, Any], str], Model]] = {
|
|
61
|
+
Kind.SITE: Site.from_dict,
|
|
62
|
+
Kind.CATEGORIES: Categories.from_dict,
|
|
63
|
+
Kind.METER: ElectricityMeter.from_dict,
|
|
64
|
+
Kind.CIRCUIT: Circuit.from_dict,
|
|
65
|
+
Kind.CHARGE_POINT: ChargePoint.from_dict,
|
|
66
|
+
Kind.ENERGY_MANAGER: EnergyManager.from_dict,
|
|
67
|
+
Kind.CHARGE_POINT_EVENT: ChargePointEvent.from_dict,
|
|
68
|
+
Kind.SPACE: Space.from_dict,
|
|
69
|
+
Kind.SYSTEM: SystemStatus.from_dict,
|
|
70
|
+
Kind.MILESIGHT: MilesightSensor.from_dict,
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
@dataclass(frozen=True)
|
|
75
|
+
class Update:
|
|
76
|
+
"""The data of a message.
|
|
77
|
+
|
|
78
|
+
The key ("<kind>/<id>", for example "meter/10005") identifies the topic;
|
|
79
|
+
the latest update of a key replaces the earlier ones.
|
|
80
|
+
"""
|
|
81
|
+
|
|
82
|
+
kind: Kind
|
|
83
|
+
id: str
|
|
84
|
+
data: Model
|
|
85
|
+
raw: dict[str, Any]
|
|
86
|
+
|
|
87
|
+
@property
|
|
88
|
+
def key(self) -> str:
|
|
89
|
+
"""Return the key of the topic."""
|
|
90
|
+
return f"{self.kind}/{self.id}"
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def valid_topic_prefix(prefix: str) -> bool:
|
|
94
|
+
"""Return whether a topic prefix is valid: not empty, without wildcards."""
|
|
95
|
+
prefix = prefix.strip().strip("/")
|
|
96
|
+
return bool(prefix) and "#" not in prefix and "+" not in prefix
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def topic_kind(prefix: str, topic: str) -> tuple[Kind, str] | None:
|
|
100
|
+
"""Return the kind and ID of a topic, or None for topics that are not used."""
|
|
101
|
+
if match := MILESIGHT_PATTERN.fullmatch(topic):
|
|
102
|
+
return Kind.MILESIGHT, match.group(1).lower()
|
|
103
|
+
if not topic.startswith(f"{prefix}/"):
|
|
104
|
+
return None
|
|
105
|
+
subtopic = topic.removeprefix(f"{prefix}/")
|
|
106
|
+
for pattern, kind in TOPIC_KINDS:
|
|
107
|
+
if match := pattern.fullmatch(subtopic):
|
|
108
|
+
return kind, match.group(1)
|
|
109
|
+
return None
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def parse_message(prefix: str, topic: str, payload: Any) -> Update | None:
|
|
113
|
+
"""Parse a message, or return None if it is not used or not valid.
|
|
114
|
+
|
|
115
|
+
Args:
|
|
116
|
+
----
|
|
117
|
+
prefix: The topic prefix, for example mysite.
|
|
118
|
+
topic: The topic the message was published on.
|
|
119
|
+
payload: The payload of the message, a JSON object.
|
|
120
|
+
|
|
121
|
+
Returns:
|
|
122
|
+
-------
|
|
123
|
+
The parsed data, or None.
|
|
124
|
+
|
|
125
|
+
"""
|
|
126
|
+
if (kind_id := topic_kind(prefix, topic)) is None:
|
|
127
|
+
return None
|
|
128
|
+
kind, idx = kind_id
|
|
129
|
+
try:
|
|
130
|
+
data = json.loads(payload)
|
|
131
|
+
except (TypeError, ValueError):
|
|
132
|
+
_LOGGER.debug("Ignoring message on %s that is not JSON", topic)
|
|
133
|
+
return None
|
|
134
|
+
if not isinstance(data, dict):
|
|
135
|
+
_LOGGER.debug("Ignoring message on %s that is not a JSON object", topic)
|
|
136
|
+
return None
|
|
137
|
+
return parse_data(kind, idx, data)
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def parse_data(kind: Kind | str, idx: str, data: dict[str, Any]) -> Update:
|
|
141
|
+
"""Parse the payload of a topic, for example one that was stored earlier.
|
|
142
|
+
|
|
143
|
+
Args:
|
|
144
|
+
----
|
|
145
|
+
kind: The kind of data, as in Update.kind.
|
|
146
|
+
idx: The ID from the topic, as in Update.id.
|
|
147
|
+
data: The payload, as in Update.raw.
|
|
148
|
+
|
|
149
|
+
Returns:
|
|
150
|
+
-------
|
|
151
|
+
The parsed data.
|
|
152
|
+
|
|
153
|
+
"""
|
|
154
|
+
kind = Kind(kind)
|
|
155
|
+
return Update(kind=kind, id=idx, data=PARSERS[kind](data, idx), raw=data)
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
class Innergrid:
|
|
159
|
+
"""Receive the energy and building data that Innergrid publishes over MQTT."""
|
|
160
|
+
|
|
161
|
+
def __init__( # noqa: PLR0913 # pylint: disable=too-many-arguments
|
|
162
|
+
self,
|
|
163
|
+
host: str,
|
|
164
|
+
port: int = DEFAULT_PORT,
|
|
165
|
+
*,
|
|
166
|
+
username: str | None = None,
|
|
167
|
+
password: str | None = None,
|
|
168
|
+
topic_prefix: str,
|
|
169
|
+
timeout: float = 10.0,
|
|
170
|
+
) -> None:
|
|
171
|
+
"""Initialise the client.
|
|
172
|
+
|
|
173
|
+
Args:
|
|
174
|
+
----
|
|
175
|
+
host: The MQTT broker.
|
|
176
|
+
port: The port of the MQTT broker.
|
|
177
|
+
username: The username, if the broker requires a login.
|
|
178
|
+
password: The password, if the broker requires a login.
|
|
179
|
+
topic_prefix: The first part of the topics: the customer site, for
|
|
180
|
+
example mysite.
|
|
181
|
+
timeout: Seconds to wait for the broker to respond.
|
|
182
|
+
|
|
183
|
+
Raises:
|
|
184
|
+
------
|
|
185
|
+
ValueError: The topic prefix is empty or has MQTT wildcards.
|
|
186
|
+
|
|
187
|
+
"""
|
|
188
|
+
self.host = host
|
|
189
|
+
self.port = port
|
|
190
|
+
self.username = username
|
|
191
|
+
self.password = password
|
|
192
|
+
self.topic_prefix = topic_prefix.strip().strip("/")
|
|
193
|
+
if not valid_topic_prefix(self.topic_prefix):
|
|
194
|
+
msg = f"Invalid topic prefix: {topic_prefix!r}"
|
|
195
|
+
raise ValueError(msg)
|
|
196
|
+
self.timeout = timeout
|
|
197
|
+
|
|
198
|
+
def _client(self) -> aiomqtt.Client:
|
|
199
|
+
"""Create an MQTT client."""
|
|
200
|
+
return aiomqtt.Client(
|
|
201
|
+
self.host,
|
|
202
|
+
self.port,
|
|
203
|
+
username=self.username or None,
|
|
204
|
+
password=self.password or None,
|
|
205
|
+
timeout=self.timeout,
|
|
206
|
+
)
|
|
207
|
+
|
|
208
|
+
def _connection_error(self, err: aiomqtt.MqttError) -> InnergridConnectionError:
|
|
209
|
+
"""Return the exception for an MQTT error."""
|
|
210
|
+
if isinstance(err, aiomqtt.MqttCodeError):
|
|
211
|
+
code = getattr(err.rc, "value", err.rc)
|
|
212
|
+
if code in AUTH_FAILED_CODES:
|
|
213
|
+
return InnergridAuthError(
|
|
214
|
+
f"{self.host} rejected the username or password"
|
|
215
|
+
)
|
|
216
|
+
return InnergridConnectionError(f"Connection to {self.host} failed: {err}")
|
|
217
|
+
|
|
218
|
+
async def listen(
|
|
219
|
+
self,
|
|
220
|
+
callback: Callable[[Update], None],
|
|
221
|
+
on_connect: Callable[[], None] | None = None,
|
|
222
|
+
) -> None:
|
|
223
|
+
"""Connect and call the callback with the data of every message.
|
|
224
|
+
|
|
225
|
+
Runs until the connection fails or is lost, or the task is cancelled.
|
|
226
|
+
|
|
227
|
+
Args:
|
|
228
|
+
----
|
|
229
|
+
callback: Called with the data of every message.
|
|
230
|
+
on_connect: Called after connecting and subscribing.
|
|
231
|
+
|
|
232
|
+
Raises:
|
|
233
|
+
------
|
|
234
|
+
InnergridAuthError: The broker rejected the login.
|
|
235
|
+
InnergridConnectionError: The connection failed or was lost.
|
|
236
|
+
|
|
237
|
+
"""
|
|
238
|
+
try:
|
|
239
|
+
async with self._client() as client:
|
|
240
|
+
await client.subscribe(f"{self.topic_prefix}/#")
|
|
241
|
+
await client.subscribe(f"{MILESIGHT_TOPIC}/#")
|
|
242
|
+
if on_connect is not None:
|
|
243
|
+
on_connect()
|
|
244
|
+
async for message in client.messages:
|
|
245
|
+
update = parse_message(
|
|
246
|
+
self.topic_prefix, message.topic.value, message.payload
|
|
247
|
+
)
|
|
248
|
+
if update is not None:
|
|
249
|
+
callback(update)
|
|
250
|
+
except aiomqtt.MqttError as err:
|
|
251
|
+
raise self._connection_error(err) from err
|
|
252
|
+
# The broker closed the connection without an error.
|
|
253
|
+
msg = f"Connection to {self.host} was closed"
|
|
254
|
+
raise InnergridConnectionError(msg)
|
|
255
|
+
|
|
256
|
+
async def wait_for_data(self, wait_seconds: float = 30.0) -> Update:
|
|
257
|
+
"""Connect and wait for the first message below the topic prefix.
|
|
258
|
+
|
|
259
|
+
Use this to check the settings.
|
|
260
|
+
|
|
261
|
+
Args:
|
|
262
|
+
----
|
|
263
|
+
wait_seconds: Seconds to wait for a message.
|
|
264
|
+
|
|
265
|
+
Returns:
|
|
266
|
+
-------
|
|
267
|
+
The data of the first message.
|
|
268
|
+
|
|
269
|
+
Raises:
|
|
270
|
+
------
|
|
271
|
+
InnergridAuthError: The broker rejected the login.
|
|
272
|
+
InnergridConnectionError: The connection failed.
|
|
273
|
+
InnergridTimeoutError: No message arrived in time.
|
|
274
|
+
|
|
275
|
+
"""
|
|
276
|
+
try:
|
|
277
|
+
async with self._client() as client:
|
|
278
|
+
await client.subscribe(f"{self.topic_prefix}/#")
|
|
279
|
+
async with asyncio.timeout(wait_seconds):
|
|
280
|
+
async for message in client.messages:
|
|
281
|
+
update = parse_message(
|
|
282
|
+
self.topic_prefix, message.topic.value, message.payload
|
|
283
|
+
)
|
|
284
|
+
if update is not None:
|
|
285
|
+
return update
|
|
286
|
+
except aiomqtt.MqttError as err:
|
|
287
|
+
raise self._connection_error(err) from err
|
|
288
|
+
except TimeoutError as err:
|
|
289
|
+
msg = f"No data below {self.topic_prefix}/ within {wait_seconds} seconds"
|
|
290
|
+
raise InnergridTimeoutError(msg) from err
|
|
291
|
+
msg = f"Connection to {self.host} was closed"
|
|
292
|
+
raise InnergridConnectionError(msg)
|