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.
@@ -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.
@@ -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)