innergrid 0.1.0__py3-none-any.whl
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/__init__.py +63 -0
- innergrid/const.py +50 -0
- innergrid/exceptions.py +17 -0
- innergrid/innergrid.py +292 -0
- innergrid/models.py +714 -0
- innergrid/py.typed +0 -0
- innergrid-0.1.0.dist-info/METADATA +112 -0
- innergrid-0.1.0.dist-info/RECORD +10 -0
- innergrid-0.1.0.dist-info/WHEEL +4 -0
- innergrid-0.1.0.dist-info/licenses/LICENSE +21 -0
innergrid/__init__.py
ADDED
|
@@ -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
|
+
]
|
innergrid/const.py
ADDED
|
@@ -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})
|
innergrid/exceptions.py
ADDED
|
@@ -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."""
|
innergrid/innergrid.py
ADDED
|
@@ -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)
|
innergrid/models.py
ADDED
|
@@ -0,0 +1,714 @@
|
|
|
1
|
+
# pylint: disable=too-many-instance-attributes
|
|
2
|
+
"""Models of the data that Innergrid publishes."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import math
|
|
7
|
+
from dataclasses import dataclass, field
|
|
8
|
+
from datetime import UTC, datetime, timedelta
|
|
9
|
+
from typing import Any, Self, TypeAlias
|
|
10
|
+
|
|
11
|
+
from .const import (
|
|
12
|
+
CO2_RANGE,
|
|
13
|
+
CONNECTED_STATUSES,
|
|
14
|
+
HUMIDITY_RANGE,
|
|
15
|
+
ILLUMINANCE_RANGE,
|
|
16
|
+
TEMPERATURE_RANGE,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def get_path(data: Any, path: str) -> Any:
|
|
21
|
+
"""Return the value at a dotted path, or None if a part is missing."""
|
|
22
|
+
value = data
|
|
23
|
+
for part in path.split("."):
|
|
24
|
+
if not isinstance(value, dict):
|
|
25
|
+
return None
|
|
26
|
+
value = value.get(part)
|
|
27
|
+
return value
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _number(value: Any) -> float | None:
|
|
31
|
+
"""Return the value if it is a finite number.
|
|
32
|
+
|
|
33
|
+
JSON payloads can hold NaN and Infinity, which are not valid values.
|
|
34
|
+
"""
|
|
35
|
+
if isinstance(value, bool) or not isinstance(value, int | float):
|
|
36
|
+
return None
|
|
37
|
+
return value if math.isfinite(value) else None
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _int(value: Any) -> int | None:
|
|
41
|
+
"""Return the value if it is a whole number."""
|
|
42
|
+
number = _number(value)
|
|
43
|
+
return None if number is None else int(number)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _text(value: Any) -> str | None:
|
|
47
|
+
"""Return a non-empty string, without surrounding whitespace."""
|
|
48
|
+
if not isinstance(value, str) or not (text := value.strip()):
|
|
49
|
+
return None
|
|
50
|
+
return text
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _bool(value: Any) -> bool | None:
|
|
54
|
+
"""Return a boolean, or a 0/1 number, as a boolean."""
|
|
55
|
+
if isinstance(value, bool | int):
|
|
56
|
+
return bool(value)
|
|
57
|
+
return None
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _timestamp(value: Any) -> datetime | None:
|
|
61
|
+
"""Parse an ISO 8601 timestamp."""
|
|
62
|
+
if not isinstance(value, str) or not value:
|
|
63
|
+
return None
|
|
64
|
+
try:
|
|
65
|
+
parsed = datetime.fromisoformat(value)
|
|
66
|
+
except ValueError:
|
|
67
|
+
return None
|
|
68
|
+
return parsed if parsed.tzinfo else parsed.replace(tzinfo=UTC)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _epoch(value: Any) -> datetime | None:
|
|
72
|
+
"""Return a Unix timestamp as a datetime; 0 means never."""
|
|
73
|
+
if not (seconds := _number(value)):
|
|
74
|
+
return None
|
|
75
|
+
try:
|
|
76
|
+
return datetime.fromtimestamp(seconds, tz=UTC)
|
|
77
|
+
except (OverflowError, OSError, ValueError):
|
|
78
|
+
return None
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _seconds(value: Any) -> timedelta | None:
|
|
82
|
+
"""Return a number of seconds as a timedelta."""
|
|
83
|
+
if (seconds := _number(value)) is None:
|
|
84
|
+
return None
|
|
85
|
+
try:
|
|
86
|
+
return timedelta(seconds=seconds)
|
|
87
|
+
except OverflowError:
|
|
88
|
+
return None
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def plausible(value: Any, valid_range: tuple[float, float]) -> float | None:
|
|
92
|
+
"""Return the value if it is a number within the range, otherwise None."""
|
|
93
|
+
number = _number(value)
|
|
94
|
+
if number is None or not valid_range[0] <= number <= valid_range[1]:
|
|
95
|
+
return None
|
|
96
|
+
return number
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _id(data: dict[str, Any], default: str) -> str:
|
|
100
|
+
"""Return the ID in the data, or the default (the ID from the topic)."""
|
|
101
|
+
value = data.get("id")
|
|
102
|
+
return default if value is None else str(value)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
@dataclass
|
|
106
|
+
class SiteMeter:
|
|
107
|
+
"""A meter of a site: the grid connection, production or consumption."""
|
|
108
|
+
|
|
109
|
+
id: str | None
|
|
110
|
+
name: str | None
|
|
111
|
+
active_power: float | None
|
|
112
|
+
energy_imported_24h: float | None
|
|
113
|
+
energy_exported_24h: float | None
|
|
114
|
+
max_power_quarter_hour_import: float | None
|
|
115
|
+
max_power_quarter_hour_export: float | None
|
|
116
|
+
continuous_import_limit: float | None
|
|
117
|
+
continuous_export_limit: float | None
|
|
118
|
+
|
|
119
|
+
@classmethod
|
|
120
|
+
def from_dict(cls, data: Any) -> SiteMeter | None:
|
|
121
|
+
"""Return the meter, or None if the site does not have it."""
|
|
122
|
+
if not isinstance(data, dict):
|
|
123
|
+
return None
|
|
124
|
+
meter_id = data.get("id")
|
|
125
|
+
return cls(
|
|
126
|
+
id=None if meter_id is None else str(meter_id),
|
|
127
|
+
name=_text(data.get("name")),
|
|
128
|
+
active_power=_number(data.get("activePower")),
|
|
129
|
+
energy_imported_24h=_number(data.get("energyImported24h")),
|
|
130
|
+
energy_exported_24h=_number(data.get("energyExported24h")),
|
|
131
|
+
max_power_quarter_hour_import=_number(
|
|
132
|
+
data.get("maxActivePowerQuarterHourImport")
|
|
133
|
+
),
|
|
134
|
+
max_power_quarter_hour_export=_number(
|
|
135
|
+
data.get("maxActivePowerQuarterHourExport")
|
|
136
|
+
),
|
|
137
|
+
continuous_import_limit=_number(
|
|
138
|
+
data.get("continuousActivePowerImportLimit")
|
|
139
|
+
),
|
|
140
|
+
continuous_export_limit=_number(
|
|
141
|
+
data.get("continuousActivePowerExportLimit")
|
|
142
|
+
),
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
@dataclass
|
|
147
|
+
class Site:
|
|
148
|
+
"""The overview of a customer site (customersites/<id>/live).
|
|
149
|
+
|
|
150
|
+
Energy and money are totals of the last 24 hours, which go up and down.
|
|
151
|
+
"""
|
|
152
|
+
|
|
153
|
+
id: str
|
|
154
|
+
grid: SiteMeter | None
|
|
155
|
+
production: SiteMeter | None
|
|
156
|
+
consumption: SiteMeter | None
|
|
157
|
+
battery_storage: SiteMeter | None
|
|
158
|
+
expense_24h: float | None
|
|
159
|
+
revenue_24h: float | None
|
|
160
|
+
energy_price_import: float | None
|
|
161
|
+
energy_price_export: float | None
|
|
162
|
+
main_circuit_load_factor: float | None
|
|
163
|
+
|
|
164
|
+
@classmethod
|
|
165
|
+
def from_dict(cls, data: dict[str, Any], site_id: str = "") -> Self:
|
|
166
|
+
"""Parse the payload of a site."""
|
|
167
|
+
return cls(
|
|
168
|
+
id=_id(data, site_id),
|
|
169
|
+
grid=SiteMeter.from_dict(get_path(data, "electricityMeters.main")),
|
|
170
|
+
production=SiteMeter.from_dict(
|
|
171
|
+
get_path(data, "electricityMeters.production")
|
|
172
|
+
),
|
|
173
|
+
consumption=SiteMeter.from_dict(
|
|
174
|
+
get_path(data, "electricityMeters.consumption")
|
|
175
|
+
),
|
|
176
|
+
battery_storage=SiteMeter.from_dict(
|
|
177
|
+
get_path(data, "electricityMeters.batteryStorage")
|
|
178
|
+
),
|
|
179
|
+
expense_24h=_number(data.get("expense24h")),
|
|
180
|
+
revenue_24h=_number(data.get("revenue24h")),
|
|
181
|
+
energy_price_import=_number(get_path(data, "energyPrices.import")),
|
|
182
|
+
energy_price_export=_number(get_path(data, "energyPrices.export")),
|
|
183
|
+
main_circuit_load_factor=_number(
|
|
184
|
+
get_path(data, "mainCircuit.currentLoadFactor1Minute")
|
|
185
|
+
),
|
|
186
|
+
)
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
@dataclass
|
|
190
|
+
class Category:
|
|
191
|
+
"""The energy of a category of a site, such as lighting or the kitchen."""
|
|
192
|
+
|
|
193
|
+
key: str
|
|
194
|
+
name: str
|
|
195
|
+
active_power: float | None
|
|
196
|
+
energy_imported_24h: float | None
|
|
197
|
+
energy_exported_24h: float | None
|
|
198
|
+
expense_24h: float | None
|
|
199
|
+
revenue_24h: float | None
|
|
200
|
+
|
|
201
|
+
@classmethod
|
|
202
|
+
def from_dict(cls, key: str, data: dict[str, Any]) -> Self:
|
|
203
|
+
"""Parse a category."""
|
|
204
|
+
return cls(
|
|
205
|
+
key=key,
|
|
206
|
+
name=_text(data.get("name")) or key,
|
|
207
|
+
active_power=_number(data.get("activePower")),
|
|
208
|
+
energy_imported_24h=_number(data.get("energyImported24h")),
|
|
209
|
+
energy_exported_24h=_number(data.get("energyExported24h")),
|
|
210
|
+
expense_24h=_number(data.get("expense24h")),
|
|
211
|
+
revenue_24h=_number(data.get("revenue24h")),
|
|
212
|
+
)
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
@dataclass
|
|
216
|
+
class Categories:
|
|
217
|
+
"""The energy categories of a site (categories/<id>/live)."""
|
|
218
|
+
|
|
219
|
+
site_id: str
|
|
220
|
+
categories: dict[str, Category]
|
|
221
|
+
|
|
222
|
+
@classmethod
|
|
223
|
+
def from_dict(cls, data: dict[str, Any], site_id: str = "") -> Self:
|
|
224
|
+
"""Parse the payload of the categories of a site."""
|
|
225
|
+
categories = data.get("categories")
|
|
226
|
+
if not isinstance(categories, dict):
|
|
227
|
+
categories = {}
|
|
228
|
+
return cls(
|
|
229
|
+
site_id=site_id,
|
|
230
|
+
categories={
|
|
231
|
+
key: Category.from_dict(key, values)
|
|
232
|
+
for key, values in categories.items()
|
|
233
|
+
if isinstance(values, dict)
|
|
234
|
+
},
|
|
235
|
+
)
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
@dataclass(frozen=True)
|
|
239
|
+
class Quantity:
|
|
240
|
+
"""A quantity that a meter measures, with its name and unit.
|
|
241
|
+
|
|
242
|
+
The unit is as the platform names it, for example power.watt.
|
|
243
|
+
"""
|
|
244
|
+
|
|
245
|
+
name: str
|
|
246
|
+
unit: str
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
@dataclass
|
|
250
|
+
class ElectricityMeter:
|
|
251
|
+
"""An electricity meter (electricitymeters/<id>).
|
|
252
|
+
|
|
253
|
+
The measurements are by quantity, for example activePower or
|
|
254
|
+
activePowerL1; the quantities describe their names and units.
|
|
255
|
+
"""
|
|
256
|
+
|
|
257
|
+
id: str
|
|
258
|
+
name: str
|
|
259
|
+
module_name: str | None
|
|
260
|
+
location: str | None
|
|
261
|
+
circuit_id: str | None
|
|
262
|
+
is_three_phase: bool | None
|
|
263
|
+
measurements: dict[str, float | None]
|
|
264
|
+
quantities: dict[str, Quantity]
|
|
265
|
+
|
|
266
|
+
@property
|
|
267
|
+
def manufacturer(self) -> str | None:
|
|
268
|
+
"""Return the manufacturer: the first word of the module name."""
|
|
269
|
+
return self.module_name.split()[0] if self.module_name else None
|
|
270
|
+
|
|
271
|
+
@classmethod
|
|
272
|
+
def from_dict(cls, data: dict[str, Any], meter_id: str = "") -> Self:
|
|
273
|
+
"""Parse the payload of an electricity meter."""
|
|
274
|
+
measurements = data.get("measurements")
|
|
275
|
+
if not isinstance(measurements, dict):
|
|
276
|
+
measurements = {}
|
|
277
|
+
quantities = data.get("loggedQuantities")
|
|
278
|
+
if not isinstance(quantities, dict):
|
|
279
|
+
quantities = {}
|
|
280
|
+
circuit_id = data.get("circuitId")
|
|
281
|
+
meter_id = _id(data, meter_id)
|
|
282
|
+
return cls(
|
|
283
|
+
id=meter_id,
|
|
284
|
+
name=_text(data.get("name")) or meter_id,
|
|
285
|
+
module_name=_text(data.get("moduleName")),
|
|
286
|
+
location=_text(data.get("location")),
|
|
287
|
+
circuit_id=None if circuit_id is None else str(circuit_id),
|
|
288
|
+
is_three_phase=_bool(data.get("isThreePhase")),
|
|
289
|
+
measurements={key: _number(value) for key, value in measurements.items()},
|
|
290
|
+
quantities={
|
|
291
|
+
key: Quantity(
|
|
292
|
+
name=_text(quantity.get("name")) or key,
|
|
293
|
+
unit=str(quantity.get("unit") or ""),
|
|
294
|
+
)
|
|
295
|
+
for key, quantity in quantities.items()
|
|
296
|
+
if isinstance(quantity, dict) and key in measurements
|
|
297
|
+
},
|
|
298
|
+
)
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
@dataclass
|
|
302
|
+
class Circuit:
|
|
303
|
+
"""A circuit, measured by an electricity meter (circuits/<id>/live).
|
|
304
|
+
|
|
305
|
+
Load factors are fractions of the circuit's limit, for example 0.25.
|
|
306
|
+
"""
|
|
307
|
+
|
|
308
|
+
id: str
|
|
309
|
+
name: str
|
|
310
|
+
electricity_meter_id: str | None
|
|
311
|
+
is_three_phase: bool | None
|
|
312
|
+
active_power: float | None
|
|
313
|
+
current_l1: float | None
|
|
314
|
+
current_l2: float | None
|
|
315
|
+
current_l3: float | None
|
|
316
|
+
load_factor_1m: float | None
|
|
317
|
+
load_factor_5m: float | None
|
|
318
|
+
load_factor_15m: float | None
|
|
319
|
+
average_power_import_15m: float | None
|
|
320
|
+
average_power_export_15m: float | None
|
|
321
|
+
current_limit: float | None
|
|
322
|
+
warnings: int | None
|
|
323
|
+
|
|
324
|
+
@classmethod
|
|
325
|
+
def from_dict(cls, data: dict[str, Any], circuit_id: str = "") -> Self:
|
|
326
|
+
"""Parse the payload of a circuit."""
|
|
327
|
+
meter_id = data.get("electricityMeterId")
|
|
328
|
+
circuit_id = _id(data, circuit_id)
|
|
329
|
+
return cls(
|
|
330
|
+
id=circuit_id,
|
|
331
|
+
name=_text(data.get("name")) or circuit_id,
|
|
332
|
+
electricity_meter_id=None if meter_id is None else str(meter_id),
|
|
333
|
+
is_three_phase=_bool(data.get("isThreePhase")),
|
|
334
|
+
active_power=_number(data.get("activePower")),
|
|
335
|
+
current_l1=_number(data.get("currentL1")),
|
|
336
|
+
current_l2=_number(data.get("currentL2")),
|
|
337
|
+
current_l3=_number(data.get("currentL3")),
|
|
338
|
+
load_factor_1m=_number(get_path(data, "currentLoadFactor.1-minute")),
|
|
339
|
+
load_factor_5m=_number(get_path(data, "currentLoadFactor.5-minute")),
|
|
340
|
+
load_factor_15m=_number(get_path(data, "currentLoadFactor.15-minute")),
|
|
341
|
+
average_power_import_15m=_number(
|
|
342
|
+
get_path(data, "averageActivePower.15-minute.import")
|
|
343
|
+
),
|
|
344
|
+
average_power_export_15m=_number(
|
|
345
|
+
get_path(data, "averageActivePower.15-minute.export")
|
|
346
|
+
),
|
|
347
|
+
current_limit=_number(get_path(data, "instantaneousLimits.current")),
|
|
348
|
+
warnings=_int(data.get("warnings")),
|
|
349
|
+
)
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
@dataclass
|
|
353
|
+
class ChargePoint:
|
|
354
|
+
"""A charge point and its (last) session (chargepoints/<id>).
|
|
355
|
+
|
|
356
|
+
The session fields keep the values of the last session after it ended.
|
|
357
|
+
"""
|
|
358
|
+
|
|
359
|
+
id: str
|
|
360
|
+
name: str
|
|
361
|
+
status: str | None
|
|
362
|
+
charging_state: str | None
|
|
363
|
+
charge_strategy: str | None
|
|
364
|
+
username: str | None
|
|
365
|
+
started_at: datetime | None
|
|
366
|
+
finished_at: datetime | None
|
|
367
|
+
departure_at: datetime | None
|
|
368
|
+
target_energy: float | None
|
|
369
|
+
minimum_power: float | None
|
|
370
|
+
active_power: float | None
|
|
371
|
+
energy_charged: float | None
|
|
372
|
+
charge_current: float | None
|
|
373
|
+
phases_enabled: int | None
|
|
374
|
+
power_instruction: float | None
|
|
375
|
+
|
|
376
|
+
@property
|
|
377
|
+
def is_plugged_in(self) -> bool | None:
|
|
378
|
+
"""Return whether a vehicle is connected."""
|
|
379
|
+
return None if self.status is None else self.status in CONNECTED_STATUSES
|
|
380
|
+
|
|
381
|
+
@property
|
|
382
|
+
def is_charging(self) -> bool | None:
|
|
383
|
+
"""Return whether the vehicle is charging.
|
|
384
|
+
|
|
385
|
+
The charging state is CHARGING also while the vehicle limits the power
|
|
386
|
+
(status THROTTLED_BY_EV); without it, the status tells.
|
|
387
|
+
"""
|
|
388
|
+
if self.charging_state is not None:
|
|
389
|
+
return self.charging_state == "CHARGING"
|
|
390
|
+
return None if self.status is None else self.status == "CHARGING"
|
|
391
|
+
|
|
392
|
+
@property
|
|
393
|
+
def current_user(self) -> str | None:
|
|
394
|
+
"""Return who is charging; None when no vehicle is connected."""
|
|
395
|
+
return self.username if self.is_plugged_in else None
|
|
396
|
+
|
|
397
|
+
@property
|
|
398
|
+
def session_finished_at(self) -> datetime | None:
|
|
399
|
+
"""Return the end of the session, unless a newer session started."""
|
|
400
|
+
if self.finished_at is None or (
|
|
401
|
+
self.started_at is not None and self.finished_at < self.started_at
|
|
402
|
+
):
|
|
403
|
+
return None
|
|
404
|
+
return self.finished_at
|
|
405
|
+
|
|
406
|
+
@classmethod
|
|
407
|
+
def from_dict(cls, data: dict[str, Any], charge_point_id: str = "") -> Self:
|
|
408
|
+
"""Parse the payload of a charge point."""
|
|
409
|
+
charge_point_id = _id(data, charge_point_id)
|
|
410
|
+
return cls(
|
|
411
|
+
id=charge_point_id,
|
|
412
|
+
name=_text(data.get("name")) or charge_point_id,
|
|
413
|
+
status=_text(get_path(data, "session.status")),
|
|
414
|
+
charging_state=_text(get_path(data, "session.chargingState")),
|
|
415
|
+
charge_strategy=_text(get_path(data, "session.chargeStrategy")),
|
|
416
|
+
username=_text(get_path(data, "session.username")),
|
|
417
|
+
started_at=_timestamp(get_path(data, "session.startedAt")),
|
|
418
|
+
finished_at=_timestamp(get_path(data, "session.finishedAt")),
|
|
419
|
+
departure_at=_timestamp(get_path(data, "session.departureAt")),
|
|
420
|
+
target_energy=_number(get_path(data, "session.targetEnergyRequest")),
|
|
421
|
+
minimum_power=_number(get_path(data, "session.minimumPower")),
|
|
422
|
+
active_power=_number(get_path(data, "measurements.activePower")),
|
|
423
|
+
energy_charged=_number(get_path(data, "measurements.energyCharged")),
|
|
424
|
+
charge_current=_number(get_path(data, "powerManagement.chargeCurrent")),
|
|
425
|
+
phases_enabled=_int(get_path(data, "powerManagement.phasesEnabled")),
|
|
426
|
+
power_instruction=_number(
|
|
427
|
+
get_path(data, "powerManagement.powerInstruction")
|
|
428
|
+
),
|
|
429
|
+
)
|
|
430
|
+
|
|
431
|
+
|
|
432
|
+
@dataclass
|
|
433
|
+
class EnergyManager:
|
|
434
|
+
"""The state of the energy manager (energymanagers/<id>/state)."""
|
|
435
|
+
|
|
436
|
+
id: str
|
|
437
|
+
name: str
|
|
438
|
+
is_abnormal_condition: bool | None
|
|
439
|
+
is_temporary_over_limit_applied: bool | None
|
|
440
|
+
is_solar_charging_only: bool | None
|
|
441
|
+
grid_power: float | None
|
|
442
|
+
solar_power: float | None
|
|
443
|
+
charge_point_power: float | None
|
|
444
|
+
climate_power: float | None
|
|
445
|
+
baseload_power: float | None
|
|
446
|
+
inflexible_power: float | None
|
|
447
|
+
battery_power: float | None
|
|
448
|
+
optimized_import_power: float | None
|
|
449
|
+
flexible_power: float | None
|
|
450
|
+
continuous_import_limit: float | None
|
|
451
|
+
instantaneous_import_limit: float | None
|
|
452
|
+
continuous_export_limit: float | None
|
|
453
|
+
instantaneous_export_limit: float | None
|
|
454
|
+
peak_shaving_limit: float | None
|
|
455
|
+
optimization_goal: str | None
|
|
456
|
+
binding_limit: str | None
|
|
457
|
+
strategy: str | None
|
|
458
|
+
cheapest_period_start: datetime | None
|
|
459
|
+
cheapest_period_price: float | None
|
|
460
|
+
baseline_price: float | None
|
|
461
|
+
export_price: float | None
|
|
462
|
+
|
|
463
|
+
@classmethod
|
|
464
|
+
def from_dict(cls, data: dict[str, Any], manager_id: str = "") -> Self:
|
|
465
|
+
"""Parse the payload of the energy manager's state."""
|
|
466
|
+
manager = data.get("energyManager")
|
|
467
|
+
if not isinstance(manager, dict):
|
|
468
|
+
manager = {}
|
|
469
|
+
manager_id = _id(manager, manager_id)
|
|
470
|
+
|
|
471
|
+
def power(name: str) -> float | None:
|
|
472
|
+
return _number(get_path(manager, f"powerValues.{name}"))
|
|
473
|
+
|
|
474
|
+
def strategy(path: str) -> Any:
|
|
475
|
+
return get_path(manager, f"strategy.{path}")
|
|
476
|
+
|
|
477
|
+
return cls(
|
|
478
|
+
id=manager_id,
|
|
479
|
+
name=_text(manager.get("name")) or manager_id,
|
|
480
|
+
is_abnormal_condition=_bool(manager.get("isAbnormalCondition")),
|
|
481
|
+
is_temporary_over_limit_applied=_bool(
|
|
482
|
+
manager.get("isTemporaryOverLimitApplied")
|
|
483
|
+
),
|
|
484
|
+
is_solar_charging_only=_bool(manager.get("isSolarChargingOnly")),
|
|
485
|
+
grid_power=power("gridActivePower"),
|
|
486
|
+
solar_power=power("solarPower"),
|
|
487
|
+
charge_point_power=power("chargePointPower"),
|
|
488
|
+
climate_power=power("climatePower"),
|
|
489
|
+
baseload_power=power("baseloadPower"),
|
|
490
|
+
inflexible_power=power("inflexiblePower"),
|
|
491
|
+
battery_power=power("batteryPower"),
|
|
492
|
+
optimized_import_power=power("optimizedImportPower"),
|
|
493
|
+
flexible_power=power("appliedFlexibleResourcePower"),
|
|
494
|
+
continuous_import_limit=power("continuousActivePowerImportLimit"),
|
|
495
|
+
instantaneous_import_limit=power("instantaneousActivePowerImportLimit"),
|
|
496
|
+
continuous_export_limit=power("continuousActivePowerExportLimit"),
|
|
497
|
+
instantaneous_export_limit=power("instantaneousActivePowerExportLimit"),
|
|
498
|
+
peak_shaving_limit=power("peakShavingResourcePowerLimit"),
|
|
499
|
+
optimization_goal=_text(strategy("optimizationGoal")),
|
|
500
|
+
binding_limit=_text(strategy("bindingLimit")),
|
|
501
|
+
strategy=_text(strategy("description")),
|
|
502
|
+
cheapest_period_start=_timestamp(
|
|
503
|
+
strategy("optimizationStrategy.cheapestPeriodStart")
|
|
504
|
+
),
|
|
505
|
+
cheapest_period_price=_number(
|
|
506
|
+
strategy("optimizationStrategy.cheapestPeriodPrice")
|
|
507
|
+
),
|
|
508
|
+
baseline_price=_number(strategy("optimizationStrategy.baselinePrice")),
|
|
509
|
+
export_price=_number(strategy("optimizationStrategy.currentExportPrice")),
|
|
510
|
+
)
|
|
511
|
+
|
|
512
|
+
|
|
513
|
+
# The values of a space, by attribute name and name in the payload.
|
|
514
|
+
SPACE_VALUES = {
|
|
515
|
+
"temperature": "temperature",
|
|
516
|
+
"humidity": "relativeHumidity",
|
|
517
|
+
"co2": "co2Concentration",
|
|
518
|
+
"illuminance": "illuminance",
|
|
519
|
+
"air_quality": "airQuality",
|
|
520
|
+
"presence": "presence",
|
|
521
|
+
"occupancy": "occupancy",
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
|
|
525
|
+
@dataclass
|
|
526
|
+
class Space:
|
|
527
|
+
"""The climate and presence of a space (spaces/<id>/live).
|
|
528
|
+
|
|
529
|
+
The payload does not include the space's name. Values that are stale or
|
|
530
|
+
not plausible (for example -1001.1 °C) are None. `measured` holds the
|
|
531
|
+
names of the values that the space has sensors for.
|
|
532
|
+
"""
|
|
533
|
+
|
|
534
|
+
id: str
|
|
535
|
+
temperature: float | None
|
|
536
|
+
humidity: float | None
|
|
537
|
+
co2: float | None
|
|
538
|
+
illuminance: float | None
|
|
539
|
+
air_quality: str | None
|
|
540
|
+
presence: bool | None
|
|
541
|
+
occupancy: bool | None
|
|
542
|
+
low_battery_count: int | None
|
|
543
|
+
measured: frozenset[str] = field(default_factory=frozenset)
|
|
544
|
+
|
|
545
|
+
@classmethod
|
|
546
|
+
def from_dict(cls, data: dict[str, Any], space_id: str = "") -> Self:
|
|
547
|
+
"""Parse the payload of a space."""
|
|
548
|
+
values = data.get("values")
|
|
549
|
+
if not isinstance(values, dict):
|
|
550
|
+
values = {}
|
|
551
|
+
|
|
552
|
+
def value(name: str, key: str = "value") -> Any:
|
|
553
|
+
entry = values.get(SPACE_VALUES[name])
|
|
554
|
+
if not isinstance(entry, dict) or entry.get("isStale"):
|
|
555
|
+
return None
|
|
556
|
+
return entry.get(key)
|
|
557
|
+
|
|
558
|
+
return cls(
|
|
559
|
+
id=_id(data, space_id),
|
|
560
|
+
temperature=plausible(value("temperature"), TEMPERATURE_RANGE),
|
|
561
|
+
humidity=plausible(value("humidity"), HUMIDITY_RANGE),
|
|
562
|
+
co2=plausible(value("co2"), CO2_RANGE),
|
|
563
|
+
illuminance=plausible(value("illuminance"), ILLUMINANCE_RANGE),
|
|
564
|
+
air_quality=(
|
|
565
|
+
None
|
|
566
|
+
if value("air_quality") is None
|
|
567
|
+
else _text(value("air_quality", "label"))
|
|
568
|
+
),
|
|
569
|
+
presence=_bool(value("presence")),
|
|
570
|
+
occupancy=_bool(value("occupancy")),
|
|
571
|
+
low_battery_count=_int(data.get("lowBatteryCount")),
|
|
572
|
+
measured=frozenset(
|
|
573
|
+
name
|
|
574
|
+
for name, key in SPACE_VALUES.items()
|
|
575
|
+
if get_path(values, f"{key}.updatedAt") is not None
|
|
576
|
+
),
|
|
577
|
+
)
|
|
578
|
+
|
|
579
|
+
|
|
580
|
+
@dataclass
|
|
581
|
+
class SystemStatus:
|
|
582
|
+
"""The status of the server that publishes the data (system).
|
|
583
|
+
|
|
584
|
+
Memory and disk sizes are in MiB.
|
|
585
|
+
"""
|
|
586
|
+
|
|
587
|
+
cpu_usage: float | None
|
|
588
|
+
memory_available: float | None
|
|
589
|
+
memory_total: float | None
|
|
590
|
+
disk_free: float | None
|
|
591
|
+
disk_total: float | None
|
|
592
|
+
last_backup_at: datetime | None
|
|
593
|
+
backup_file_count: int | None
|
|
594
|
+
system_uptime: timedelta | None
|
|
595
|
+
symcon_uptime: timedelta | None
|
|
596
|
+
symcon_version: str | None
|
|
597
|
+
module_library_version: str | None
|
|
598
|
+
io_connection_faults: int | None
|
|
599
|
+
instances_not_active: int | None
|
|
600
|
+
overdue_timers: int | None
|
|
601
|
+
api_request_count: int | None
|
|
602
|
+
|
|
603
|
+
@classmethod
|
|
604
|
+
def from_dict(cls, data: dict[str, Any], _system_id: str = "") -> Self:
|
|
605
|
+
"""Parse the payload of the system status."""
|
|
606
|
+
return cls(
|
|
607
|
+
cpu_usage=_number(data.get("cpuUsage")),
|
|
608
|
+
memory_available=_number(data.get("availableMemory")),
|
|
609
|
+
memory_total=_number(data.get("totalMemory")),
|
|
610
|
+
disk_free=_number(data.get("filesystemFreeSpace")),
|
|
611
|
+
disk_total=_number(data.get("filesystemTotalSpace")),
|
|
612
|
+
last_backup_at=_epoch(data.get("lastBackupAt")),
|
|
613
|
+
backup_file_count=_int(data.get("backupFileCount")),
|
|
614
|
+
system_uptime=_seconds(data.get("systemUptimeSeconds")),
|
|
615
|
+
symcon_uptime=_seconds(data.get("symconUptimeSeconds")),
|
|
616
|
+
symcon_version=_text(data.get("symconVersion")),
|
|
617
|
+
module_library_version=_text(data.get("moduleLibraryVersion")),
|
|
618
|
+
io_connection_faults=_int(data.get("ioConnectionFaults")),
|
|
619
|
+
instances_not_active=_int(data.get("instancesNotActive")),
|
|
620
|
+
overdue_timers=_int(data.get("overdueTimers")),
|
|
621
|
+
api_request_count=_int(data.get("apiRequestCount")),
|
|
622
|
+
)
|
|
623
|
+
|
|
624
|
+
|
|
625
|
+
# The values of a Milesight sensor, by attribute name and name in the payload.
|
|
626
|
+
MILESIGHT_VALUES = {
|
|
627
|
+
"temperature": "temperature",
|
|
628
|
+
"humidity": "humidity",
|
|
629
|
+
"co2": "co2",
|
|
630
|
+
"illuminance": "light",
|
|
631
|
+
"motion": "motion",
|
|
632
|
+
"battery_voltage": "vdd",
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
|
|
636
|
+
@dataclass
|
|
637
|
+
class MilesightSensor:
|
|
638
|
+
"""A Milesight LoRaWAN sensor (milesight/uplink/<devEUI>).
|
|
639
|
+
|
|
640
|
+
Values that are not plausible are None. `measured` holds the names of
|
|
641
|
+
the values in the payload, which depend on the sensor's model. The
|
|
642
|
+
illuminance is in lux and the battery voltage in mV. Motion is true when
|
|
643
|
+
the sensor counted any motion (the count can be higher than 1).
|
|
644
|
+
"""
|
|
645
|
+
|
|
646
|
+
eui: str
|
|
647
|
+
temperature: float | None
|
|
648
|
+
humidity: float | None
|
|
649
|
+
co2: float | None
|
|
650
|
+
illuminance: float | None
|
|
651
|
+
motion: bool | None
|
|
652
|
+
battery_voltage: float | None
|
|
653
|
+
measured: frozenset[str] = field(default_factory=frozenset)
|
|
654
|
+
|
|
655
|
+
@classmethod
|
|
656
|
+
def from_dict(cls, data: dict[str, Any], eui: str = "") -> Self:
|
|
657
|
+
"""Parse the payload of a Milesight sensor."""
|
|
658
|
+
return cls(
|
|
659
|
+
eui=eui,
|
|
660
|
+
temperature=plausible(data.get("temperature"), TEMPERATURE_RANGE),
|
|
661
|
+
humidity=plausible(data.get("humidity"), HUMIDITY_RANGE),
|
|
662
|
+
co2=plausible(data.get("co2"), CO2_RANGE),
|
|
663
|
+
illuminance=plausible(data.get("light"), ILLUMINANCE_RANGE),
|
|
664
|
+
motion=_bool(data.get("motion")),
|
|
665
|
+
battery_voltage=_number(data.get("vdd")),
|
|
666
|
+
measured=frozenset(
|
|
667
|
+
name for name, key in MILESIGHT_VALUES.items() if key in data
|
|
668
|
+
),
|
|
669
|
+
)
|
|
670
|
+
|
|
671
|
+
|
|
672
|
+
@dataclass
|
|
673
|
+
class ChargePointEvent:
|
|
674
|
+
"""An event of a charge point (events/chargepoints/<id>/<event>).
|
|
675
|
+
|
|
676
|
+
The charge point publishes `sessionchanged` when its session changes, for
|
|
677
|
+
example when charging starts or stops. `charge_point` holds the session
|
|
678
|
+
after the change; its other fields are None.
|
|
679
|
+
"""
|
|
680
|
+
|
|
681
|
+
charge_point_id: str
|
|
682
|
+
event: str
|
|
683
|
+
time: datetime | None
|
|
684
|
+
charge_point: ChargePoint
|
|
685
|
+
|
|
686
|
+
@classmethod
|
|
687
|
+
def from_dict(cls, data: dict[str, Any], charge_point_id: str = "") -> Self:
|
|
688
|
+
"""Parse the payload of a charge point event."""
|
|
689
|
+
payload = data.get("payload")
|
|
690
|
+
if not isinstance(payload, dict):
|
|
691
|
+
payload = {}
|
|
692
|
+
return cls(
|
|
693
|
+
charge_point_id=charge_point_id,
|
|
694
|
+
event=_text(data.get("name")) or "",
|
|
695
|
+
time=_timestamp(data.get("time")),
|
|
696
|
+
charge_point=ChargePoint.from_dict(
|
|
697
|
+
{"session": payload.get("session")}, charge_point_id
|
|
698
|
+
),
|
|
699
|
+
)
|
|
700
|
+
|
|
701
|
+
|
|
702
|
+
# Python 3.11 does not support the type statement.
|
|
703
|
+
Model: TypeAlias = (
|
|
704
|
+
Site
|
|
705
|
+
| Categories
|
|
706
|
+
| ElectricityMeter
|
|
707
|
+
| Circuit
|
|
708
|
+
| ChargePoint
|
|
709
|
+
| ChargePointEvent
|
|
710
|
+
| EnergyManager
|
|
711
|
+
| Space
|
|
712
|
+
| SystemStatus
|
|
713
|
+
| MilesightSensor
|
|
714
|
+
)
|
innergrid/py.typed
ADDED
|
File without changes
|
|
@@ -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,10 @@
|
|
|
1
|
+
innergrid/__init__.py,sha256=I24Uv-RJEYhW0eTsbDN44EI40LPPEy5D0epMVwnyZn8,1190
|
|
2
|
+
innergrid/const.py,sha256=cGRFie17dhWzauNR6F07QhW9Uo13gogXkrg6nflKPcQ,1346
|
|
3
|
+
innergrid/exceptions.py,sha256=_8a2ldiENfbpcPpvAChryKREdvk1bEsn1p85TUDloyQ,428
|
|
4
|
+
innergrid/innergrid.py,sha256=GpYIoM2GETG-1YfAIeh2fcMOv1gHO_DQWI0V3mpQvK4,9547
|
|
5
|
+
innergrid/models.py,sha256=L_HgeSdc2z9i6gF3UmlAOGhuansXxx9tVBUpgzeBoH8,25174
|
|
6
|
+
innergrid/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
7
|
+
innergrid-0.1.0.dist-info/licenses/LICENSE,sha256=vl3VaKxSsSbvL4FwpSLQB8uLQACsbBGFgi4MCYk8mMo,1071
|
|
8
|
+
innergrid-0.1.0.dist-info/WHEEL,sha256=MWx7psoF7KNST9G72pGtiBYuaXRXQXBk9pw5R6FzYko,80
|
|
9
|
+
innergrid-0.1.0.dist-info/METADATA,sha256=cJ6Fy9EvzGNOp4UxUgBgdygx4pn3skT9aXg2OVvSV1U,3732
|
|
10
|
+
innergrid-0.1.0.dist-info/RECORD,,
|
|
@@ -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.
|