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 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})
@@ -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,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: uv 0.13.7
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -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.