garo-entity-pro 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 dodg3r
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,62 @@
1
+ Metadata-Version: 2.4
2
+ Name: garo-entity-pro
3
+ Version: 0.1.0
4
+ Summary: Async client for the Garo Entity Pro charger local HTTPS API (/status/...).
5
+ License: MIT
6
+ Keywords: garo,evcharger,ocpp,aiohttp,home-assistant
7
+ Classifier: Development Status :: 4 - Beta
8
+ Classifier: Intended Audience :: Developers
9
+ Classifier: License :: OSI Approved :: MIT License
10
+ Classifier: Programming Language :: Python :: 3
11
+ Classifier: Programming Language :: Python :: 3.10
12
+ Classifier: Programming Language :: Python :: 3.11
13
+ Classifier: Programming Language :: Python :: 3.12
14
+ Classifier: Programming Language :: Python :: 3.13
15
+ Classifier: Topic :: Home Automation
16
+ Requires-Python: >=3.10
17
+ Description-Content-Type: text/markdown
18
+ License-File: LICENSE
19
+ Requires-Dist: aiohttp>=3.9.0
20
+ Requires-Dist: yarl>=1.9.0
21
+ Provides-Extra: ha
22
+ Requires-Dist: homeassistant>=2024.1.0; extra == "ha"
23
+ Requires-Dist: voluptuous>=0.13; extra == "ha"
24
+ Dynamic: license-file
25
+
26
+ # garo-entity-pro
27
+
28
+ Async Python client for the **Garo Entity Pro** wallbox local HTTPS API (`/status/energy-meter`, `/status/temperatures`, etc.).
29
+
30
+ ## Install
31
+
32
+ ```bash
33
+ pip install garo-entity-pro
34
+ ```
35
+
36
+ ## Library usage
37
+
38
+ ```python
39
+ from garo_entity_pro import GaroApiClient
40
+
41
+ async def main():
42
+ async with GaroApiClient("https://192.168.1.10", "user", "pass", verify_ssl=False) as client:
43
+ data = await client.get_status()
44
+ ```
45
+
46
+ Parsing helpers live under `garo_entity_pro.parsing` (OCPP-style meter payloads, state labels).
47
+
48
+ ## Home Assistant
49
+
50
+ The custom integration in `custom_components/garo_entity_pro/` depends on this package (see `manifest.json`). Install the integration and ensure Home Assistant can install requirements, or run `pip install -e .` from this repository when developing.
51
+
52
+ ## Development
53
+
54
+ ```bash
55
+ pip install -e ".[ha]"
56
+ ```
57
+
58
+ Build a wheel:
59
+
60
+ ```bash
61
+ pip install build && python -m build
62
+ ```
@@ -0,0 +1,37 @@
1
+ # garo-entity-pro
2
+
3
+ Async Python client for the **Garo Entity Pro** wallbox local HTTPS API (`/status/energy-meter`, `/status/temperatures`, etc.).
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ pip install garo-entity-pro
9
+ ```
10
+
11
+ ## Library usage
12
+
13
+ ```python
14
+ from garo_entity_pro import GaroApiClient
15
+
16
+ async def main():
17
+ async with GaroApiClient("https://192.168.1.10", "user", "pass", verify_ssl=False) as client:
18
+ data = await client.get_status()
19
+ ```
20
+
21
+ Parsing helpers live under `garo_entity_pro.parsing` (OCPP-style meter payloads, state labels).
22
+
23
+ ## Home Assistant
24
+
25
+ The custom integration in `custom_components/garo_entity_pro/` depends on this package (see `manifest.json`). Install the integration and ensure Home Assistant can install requirements, or run `pip install -e .` from this repository when developing.
26
+
27
+ ## Development
28
+
29
+ ```bash
30
+ pip install -e ".[ha]"
31
+ ```
32
+
33
+ Build a wheel:
34
+
35
+ ```bash
36
+ pip install build && python -m build
37
+ ```
@@ -0,0 +1,39 @@
1
+ [project]
2
+ name = "garo-entity-pro"
3
+ version = "0.1.0"
4
+ description = "Async client for the Garo Entity Pro charger local HTTPS API (/status/...)."
5
+ readme = "README.md"
6
+ license = { text = "MIT" }
7
+ requires-python = ">=3.10"
8
+ keywords = ["garo", "evcharger", "ocpp", "aiohttp", "home-assistant"]
9
+ classifiers = [
10
+ "Development Status :: 4 - Beta",
11
+ "Intended Audience :: Developers",
12
+ "License :: OSI Approved :: MIT License",
13
+ "Programming Language :: Python :: 3",
14
+ "Programming Language :: Python :: 3.10",
15
+ "Programming Language :: Python :: 3.11",
16
+ "Programming Language :: Python :: 3.12",
17
+ "Programming Language :: Python :: 3.13",
18
+ "Topic :: Home Automation",
19
+ ]
20
+ dependencies = [
21
+ "aiohttp>=3.9.0",
22
+ "yarl>=1.9.0",
23
+ ]
24
+
25
+ [project.optional-dependencies]
26
+ ha = [
27
+ "homeassistant>=2024.1.0",
28
+ "voluptuous>=0.13",
29
+ ]
30
+
31
+ [build-system]
32
+ requires = ["setuptools>=61.0"]
33
+ build-backend = "setuptools.build_meta"
34
+
35
+ [tool.setuptools.packages.find]
36
+ where = ["src"]
37
+
38
+ [tool.setuptools.package-data]
39
+ garo_entity_pro = ["py.typed"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,19 @@
1
+ """Garo Entity Pro local API — async client and response parsing.
2
+
3
+ Install from PyPI: ``pip install garo-entity-pro`` (import name ``garo_entity_pro``).
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ from .client import GaroApiClient
9
+ from .exceptions import GaroApiError, GaroAuthError, GaroConnectionError, GaroHttpError
10
+ from .parsing import CHARGING_STATE_LABELS
11
+
12
+ __all__ = [
13
+ "CHARGING_STATE_LABELS",
14
+ "GaroApiClient",
15
+ "GaroApiError",
16
+ "GaroAuthError",
17
+ "GaroConnectionError",
18
+ "GaroHttpError",
19
+ ]
@@ -0,0 +1,239 @@
1
+ """Async HTTPS client for the Garo Entity Pro local REST API (aiohttp)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import ssl
7
+ from typing import Any, Final
8
+
9
+ import aiohttp
10
+ from aiohttp import ClientTimeout, helpers
11
+ from yarl import URL
12
+
13
+ from .exceptions import GaroAuthError, GaroConnectionError, GaroHttpError
14
+
15
+ _DEFAULT_TIMEOUT: Final = ClientTimeout(total=20, connect=10)
16
+ # Entity Pro uses /status/... (not legacy /api/v1/... on older chargers).
17
+ _DEFAULT_STATUS_PATH: Final = "/status/energy-meter"
18
+ _DEFAULT_METER_PATH: Final = "/status/energy-meter"
19
+ _DEFAULT_TEMPERATURES_PATH: Final = "/status/temperatures"
20
+ _DEFAULT_METERVALUE_SAMPLE_PATH: Final = "/status/metervalue-sample"
21
+ _DEFAULT_CHARGING_STATE_PATH: Final = "/status/charging-state"
22
+
23
+
24
+ class GaroApiClient:
25
+ """HTTPS client using HTTP Basic authentication for Garo Entity Pro REST API."""
26
+
27
+ def __init__(
28
+ self,
29
+ host: str,
30
+ username: str,
31
+ password: str,
32
+ *,
33
+ port: int | None = None,
34
+ verify_ssl: bool = True,
35
+ timeout: ClientTimeout | None = None,
36
+ status_path: str = _DEFAULT_STATUS_PATH,
37
+ meter_path: str = _DEFAULT_METER_PATH,
38
+ temperatures_path: str = _DEFAULT_TEMPERATURES_PATH,
39
+ metervalue_sample_path: str = _DEFAULT_METERVALUE_SAMPLE_PATH,
40
+ charging_state_path: str = _DEFAULT_CHARGING_STATE_PATH,
41
+ ) -> None:
42
+ self._username = username.strip()
43
+ self._password = password.strip()
44
+ self._timeout = timeout or _DEFAULT_TIMEOUT
45
+ self._status_path = status_path
46
+ self._meter_path = meter_path
47
+ self._temperatures_path = temperatures_path
48
+ self._metervalue_sample_path = metervalue_sample_path
49
+ self._charging_state_path = charging_state_path
50
+ self._session: aiohttp.ClientSession | None = None
51
+
52
+ base = host.strip()
53
+ if not base.lower().startswith(("http://", "https://")):
54
+ base = f"https://{base}"
55
+ url = URL(base)
56
+ if port is not None:
57
+ url = url.with_port(port)
58
+ self._base_url: Final[URL] = url
59
+
60
+ if verify_ssl:
61
+ self._ssl: bool | ssl.SSLContext = True
62
+ else:
63
+ self._ssl = False
64
+
65
+ @property
66
+ def base_url(self) -> str:
67
+ """Configured base URL (scheme + host + optional port)."""
68
+ return str(self._base_url)
69
+
70
+ @property
71
+ def status_path(self) -> str:
72
+ """Configured GET path for primary telemetry (e.g. energy meter)."""
73
+ return self._status_path
74
+
75
+ @property
76
+ def meter_path(self) -> str:
77
+ """Configured GET path for meter data (may match status on Entity Pro)."""
78
+ return self._meter_path
79
+
80
+ @property
81
+ def temperatures_path(self) -> str:
82
+ """Configured GET path for CPU / baseboard temperatures."""
83
+ return self._temperatures_path
84
+
85
+ @property
86
+ def metervalue_sample_path(self) -> str:
87
+ """GET path for latest OCPP sampledValue snapshot."""
88
+ return self._metervalue_sample_path
89
+
90
+ @property
91
+ def charging_state_path(self) -> str:
92
+ """GET path for charging state (JSON string or object)."""
93
+ return self._charging_state_path
94
+
95
+ async def __aenter__(self) -> GaroApiClient:
96
+ await self._ensure_session()
97
+ return self
98
+
99
+ async def __aexit__(
100
+ self,
101
+ exc_type: type[BaseException] | None,
102
+ exc: BaseException | None,
103
+ tb: object | None,
104
+ ) -> None:
105
+ await self.close()
106
+
107
+ async def close(self) -> None:
108
+ """Close the underlying HTTP session."""
109
+ if self._session is not None and not self._session.closed:
110
+ await self._session.close()
111
+ self._session = None
112
+
113
+ async def _ensure_session(self) -> aiohttp.ClientSession:
114
+ if self._session is None or self._session.closed:
115
+ connector = aiohttp.TCPConnector(limit=10, ssl=self._ssl)
116
+ self._session = aiohttp.ClientSession(
117
+ timeout=self._timeout,
118
+ connector=connector,
119
+ raise_for_status=False,
120
+ )
121
+ return self._session
122
+
123
+ async def get_status(self) -> dict[str, Any]:
124
+ """GET primary telemetry (default: /status/energy-meter)."""
125
+ return await self.get_json(self._status_path)
126
+
127
+ async def get_meter(self) -> dict[str, Any]:
128
+ """GET meter data (default: /status/energy-meter; may equal status on Entity Pro)."""
129
+ return await self.get_json(self._meter_path)
130
+
131
+ async def get_temperatures(self) -> dict[str, Any]:
132
+ """GET /status/temperatures (CPU and baseboard; JSON object or array)."""
133
+ session = await self._ensure_session()
134
+ url = str(self._base_url.join(URL(self._temperatures_path)))
135
+ return await self._request_json(session, "GET", url, json_body=None)
136
+
137
+ async def get_metervalue_sample(self) -> dict[str, Any]:
138
+ """GET /status/metervalue-sample (single OCPP block with sampledValue)."""
139
+ return await self.get_json(self._metervalue_sample_path)
140
+
141
+ async def get_charging_state(self) -> dict[str, Any]:
142
+ """GET /status/charging-state (may be a JSON string, number, or object)."""
143
+ return await self.get_json(self._charging_state_path)
144
+
145
+ async def get_json(self, path: str) -> dict[str, Any]:
146
+ """GET request; returns parsed JSON object."""
147
+ session = await self._ensure_session()
148
+ url = str(self._base_url.join(URL(path)))
149
+ return await self._request_json(session, "GET", url, json_body=None)
150
+
151
+ def _basic_header(self) -> str:
152
+ return helpers.BasicAuth(self._username, self._password).encode()
153
+
154
+ async def _request_json(
155
+ self,
156
+ session: aiohttp.ClientSession,
157
+ method: str,
158
+ url: str,
159
+ *,
160
+ json_body: dict[str, Any] | None,
161
+ ) -> dict[str, Any]:
162
+ text, status = await self._send_with_auth(session, method, url, json_body=json_body)
163
+ self._raise_for_status(status, text)
164
+ if not text.strip():
165
+ return {}
166
+ try:
167
+ data = json.loads(text)
168
+ except json.JSONDecodeError as err:
169
+ msg = "Response is not valid JSON"
170
+ raise GaroHttpError(msg, status=status, body=text) from err
171
+ if isinstance(data, dict):
172
+ return data
173
+ if data is None:
174
+ return {}
175
+ # Entity Pro /status/energy-meter can return [] before the meter is ready.
176
+ if isinstance(data, list):
177
+ return {"_list": data}
178
+ # /status/charging-state may return a bare JSON string, number, or boolean.
179
+ if isinstance(data, str):
180
+ return {"state": data, "charging_state": data}
181
+ if isinstance(data, (int, float)):
182
+ return {"state": data, "charging_state": data}
183
+ if isinstance(data, bool):
184
+ return {"state": data, "charging_state": data, "charging": data}
185
+ msg = "Unexpected JSON value in response"
186
+ raise GaroHttpError(msg, status=status, body=text)
187
+
188
+ async def _send_with_auth(
189
+ self,
190
+ session: aiohttp.ClientSession,
191
+ method: str,
192
+ url: str,
193
+ *,
194
+ json_body: dict[str, Any] | None,
195
+ ) -> tuple[str, int]:
196
+ """Send request with HTTP Basic Authorization header."""
197
+ req_kwargs: dict[str, Any] = {
198
+ "method": method,
199
+ "url": url,
200
+ "headers": {"Authorization": self._basic_header()},
201
+ "ssl": self._ssl,
202
+ }
203
+ if json_body is not None:
204
+ req_kwargs["json"] = json_body
205
+
206
+ try:
207
+ async with session.request(**req_kwargs) as resp:
208
+ text = await resp.text(encoding="utf-8")
209
+ return text, resp.status
210
+ except aiohttp.ClientConnectorError as err:
211
+ msg = f"Connection failed: {err}"
212
+ raise GaroConnectionError(msg) from err
213
+ except aiohttp.ServerDisconnectedError as err:
214
+ msg = f"Server disconnected: {err}"
215
+ raise GaroConnectionError(msg) from err
216
+ except TimeoutError as err:
217
+ msg = "Request timed out"
218
+ raise GaroConnectionError(msg) from err
219
+ except aiohttp.ClientError as err:
220
+ msg = f"HTTP client error: {err}"
221
+ raise GaroConnectionError(msg) from err
222
+
223
+ @staticmethod
224
+ def _raise_for_status(status: int, body: str) -> None:
225
+ if status == 401:
226
+ detail = (body or "").strip()[:300]
227
+ msg = "Authentication failed (check username/password and that the account is enabled)"
228
+ if detail:
229
+ msg = f"{msg}. Server response: {detail}"
230
+ raise GaroAuthError(msg)
231
+ if status == 403:
232
+ detail = (body or "").strip()[:300]
233
+ msg = "Forbidden (wrong role or API disabled)"
234
+ if detail:
235
+ msg = f"{msg}. Server response: {detail}"
236
+ raise GaroAuthError(msg)
237
+ if 400 <= status < 600:
238
+ msg = f"HTTP {status}"
239
+ raise GaroHttpError(msg, status=status, body=body or None)
@@ -0,0 +1,24 @@
1
+ """Exceptions raised by the Garo Entity Pro local API client."""
2
+
3
+ from __future__ import annotations
4
+
5
+
6
+ class GaroApiError(Exception):
7
+ """Base error for Garo API failures."""
8
+
9
+
10
+ class GaroAuthError(GaroApiError):
11
+ """Invalid credentials or unsupported auth scheme."""
12
+
13
+
14
+ class GaroConnectionError(GaroApiError):
15
+ """Network-level failure (timeout, refused, TLS, etc.)."""
16
+
17
+
18
+ class GaroHttpError(GaroApiError):
19
+ """Non-success HTTP status from the device."""
20
+
21
+ def __init__(self, message: str, *, status: int, body: str | None = None) -> None:
22
+ super().__init__(message)
23
+ self.status = status
24
+ self.body = body
@@ -0,0 +1,386 @@
1
+ """Normalize common charger fields from heterogeneous Garo JSON responses."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Mapping
6
+ from typing import Any
7
+
8
+ # /status/charging-state returns short codes; map to display labels (extend as needed).
9
+ CHARGING_STATE_LABELS: dict[str, str] = {
10
+ "A1": "Available",
11
+ }
12
+
13
+
14
+ def _as_float(value: Any) -> float | None:
15
+ if value is None:
16
+ return None
17
+ if isinstance(value, bool):
18
+ return None
19
+ if isinstance(value, (int, float)):
20
+ return float(value)
21
+ try:
22
+ return float(str(value))
23
+ except (TypeError, ValueError):
24
+ return None
25
+
26
+
27
+ def _list_is_ocpp_meter_values(lst: list[Any]) -> bool:
28
+ """True for OCPP MeterValue arrays (timestamp + sampledValue with measurand)."""
29
+ for item in lst:
30
+ if isinstance(item, dict) and isinstance(item.get("sampledValue"), list):
31
+ return True
32
+ return False
33
+
34
+
35
+ def merge_layers(*layers: Mapping[str, Any]) -> dict[str, Any]:
36
+ """Later mappings override earlier keys.
37
+
38
+ If the API returns a JSON array it is wrapped as ``{"_list": [...]}``.
39
+ OCPP-style meter arrays (``sampledValue`` / ``measurand``) are **not** merged
40
+ per-element into the top level (that would overwrite ``sampledValue``); they
41
+ are left under ``_list`` for :func:`_enrich_from_ocpp_meter_list`.
42
+ Other list shapes are merged dict-by-dict as before.
43
+ """
44
+ merged: dict[str, Any] = {}
45
+ for m in layers:
46
+ merged.update(m)
47
+ lst = merged.get("_list")
48
+ if isinstance(lst, list) and not _list_is_ocpp_meter_values(lst):
49
+ for item in lst:
50
+ if isinstance(item, dict):
51
+ merged.update(item)
52
+ return merged
53
+
54
+
55
+ def _location_matches(
56
+ sv: dict[str, Any],
57
+ location: str | None,
58
+ *,
59
+ location_must_be_absent: bool,
60
+ ) -> bool:
61
+ """When location_must_be_absent is True, only rows without a location field match."""
62
+ loc = sv.get("location")
63
+ if location_must_be_absent:
64
+ return loc is None or loc == ""
65
+ if location is not None:
66
+ return loc == location
67
+ return True
68
+
69
+
70
+ def get_ocpp_sample_float(
71
+ *layers: Mapping[str, Any],
72
+ measurand: str,
73
+ phase: str | None = None,
74
+ location: str | None = None,
75
+ location_must_be_absent: bool = False,
76
+ ) -> float | None:
77
+ """Last matching sampledValue wins (metervalue-sample overrides energy-meter when merged later).
78
+
79
+ Use location_must_be_absent=True for plain L1/L2/L3/N currents without a ``location`` field.
80
+ """
81
+ merged = merge_layers(*layers)
82
+ val: float | None = None
83
+ for block in _iter_ocpp_blocks(merged):
84
+ sampled = block.get("sampledValue")
85
+ if not isinstance(sampled, list):
86
+ continue
87
+ for sv in sampled:
88
+ if not isinstance(sv, dict):
89
+ continue
90
+ if sv.get("measurand") != measurand:
91
+ continue
92
+ if phase is not None and sv.get("phase") != phase:
93
+ continue
94
+ if not _location_matches(sv, location, location_must_be_absent=location_must_be_absent):
95
+ continue
96
+ v = _as_float(sv.get("value"))
97
+ if v is not None:
98
+ val = v
99
+ return val
100
+
101
+
102
+ def _iter_ocpp_blocks(merged: dict[str, Any]) -> list[dict[str, Any]]:
103
+ """Collect OCPP MeterValue blocks from a top-level array and/or a single sampledValue object."""
104
+ blocks: list[dict[str, Any]] = []
105
+ lst = merged.get("_list")
106
+ if isinstance(lst, list) and _list_is_ocpp_meter_values(lst):
107
+ blocks.extend(b for b in lst if isinstance(b, dict))
108
+ sv = merged.get("sampledValue")
109
+ if isinstance(sv, list) and sv and isinstance(sv[0], dict):
110
+ blocks.append({"sampledValue": sv, "timestamp": merged.get("timestamp")})
111
+ return blocks
112
+
113
+
114
+ def _enrich_from_ocpp_meter_list(merged: dict[str, Any]) -> dict[str, Any]:
115
+ """Map OCPP MeterValue sampledValue entries to flat keys used by pick_first."""
116
+ blocks = _iter_ocpp_blocks(merged)
117
+ if not blocks:
118
+ return merged
119
+
120
+ updates: dict[str, Any] = {}
121
+ currents: dict[str, float] = {}
122
+
123
+ for block in blocks:
124
+ sampled = block.get("sampledValue")
125
+ if not isinstance(sampled, list):
126
+ continue
127
+ for sv in sampled:
128
+ if not isinstance(sv, dict):
129
+ continue
130
+ measurand = sv.get("measurand", "")
131
+ val_f = _as_float(sv.get("value"))
132
+ if val_f is None:
133
+ continue
134
+ if measurand == "Power.Active.Import":
135
+ updates["power"] = val_f
136
+ updates["activePower"] = val_f
137
+ elif measurand == "Energy.Active.Import.Register":
138
+ # Cumulative register in Wh (used by parse_energy_kwh)
139
+ updates["energy_wh"] = val_f
140
+ updates["energy"] = val_f
141
+ updates["totalEnergy"] = val_f
142
+ elif measurand == "Current.Import":
143
+ phase = sv.get("phase", "")
144
+ if phase in ("L1", "L2", "L3"):
145
+ currents[phase] = val_f
146
+
147
+ if currents:
148
+ l123 = [currents[p] for p in ("L1", "L2", "L3") if p in currents]
149
+ if l123:
150
+ updates["current"] = max(l123)
151
+
152
+ out = dict(merged)
153
+ out.update(updates)
154
+ return out
155
+
156
+
157
+ def _flatten_nested_leaves(d: Any) -> dict[str, Any]:
158
+ """Pull nested dict leaves into top-level keys so pick_first finds ``power`` inside ``meter: {power: …}``."""
159
+ flat: dict[str, Any] = {}
160
+ if not isinstance(d, dict):
161
+ return flat
162
+ for k, v in d.items():
163
+ if k.startswith("_"):
164
+ continue
165
+ if isinstance(v, dict):
166
+ flat.update(_flatten_nested_leaves(v))
167
+ elif isinstance(v, list):
168
+ for item in v:
169
+ if isinstance(item, dict):
170
+ flat.update(_flatten_nested_leaves(item))
171
+ else:
172
+ flat[k] = v
173
+ return flat
174
+
175
+
176
+ def merge_layers_for_parse(*layers: Mapping[str, Any]) -> dict[str, Any]:
177
+ """merge_layers, OCPP MeterValue extraction, then flatten nested dicts for key lookup."""
178
+ merged = merge_layers(*layers)
179
+ merged = _enrich_from_ocpp_meter_list(merged)
180
+ extra = _flatten_nested_leaves(merged)
181
+ out = dict(merged)
182
+ out.update(extra)
183
+ return out
184
+
185
+
186
+ def pick_first(merged: Mapping[str, Any], keys: tuple[str, ...]) -> Any:
187
+ for key in keys:
188
+ if key in merged and merged[key] is not None:
189
+ return merged[key]
190
+ return None
191
+
192
+
193
+ def parse_power_w(*layers: Mapping[str, Any]) -> float | None:
194
+ merged = merge_layers_for_parse(*layers)
195
+ raw = pick_first(
196
+ merged,
197
+ (
198
+ "power",
199
+ "activePower",
200
+ "ActivePower",
201
+ "active_power",
202
+ "powerW",
203
+ "power_w",
204
+ "totalPower",
205
+ "total_power",
206
+ "instantaneousPower",
207
+ "InstantaneousPower",
208
+ "chargePower",
209
+ "ChargePower",
210
+ "evsePower",
211
+ "EvsePower",
212
+ "measuredPower",
213
+ "MeasuredPower",
214
+ "P",
215
+ "p",
216
+ "W",
217
+ "w",
218
+ "value",
219
+ "Value",
220
+ "reading",
221
+ "Reading",
222
+ ),
223
+ )
224
+ val = _as_float(raw)
225
+ return val
226
+
227
+
228
+ def parse_current_a(*layers: Mapping[str, Any]) -> float | None:
229
+ merged = merge_layers_for_parse(*layers)
230
+ raw = pick_first(
231
+ merged,
232
+ (
233
+ "current",
234
+ "Current",
235
+ "rmsCurrent",
236
+ "RmsCurrent",
237
+ "rms_current",
238
+ "currentL1",
239
+ "CurrentL1",
240
+ "current_l1",
241
+ "currentPhase1",
242
+ "I",
243
+ "i",
244
+ "measuredCurrent",
245
+ "MeasuredCurrent",
246
+ "phaseCurrent",
247
+ "L1",
248
+ "importCurrent",
249
+ "value",
250
+ "Value",
251
+ "reading",
252
+ "Reading",
253
+ ),
254
+ )
255
+ return _as_float(raw)
256
+
257
+
258
+ def parse_energy_kwh(*layers: Mapping[str, Any]) -> float | None:
259
+ merged = merge_layers_for_parse(*layers)
260
+ if merged.get("energy_wh") is not None:
261
+ v = _as_float(merged["energy_wh"])
262
+ if v is not None:
263
+ return v / 1000.0
264
+ raw = pick_first(
265
+ merged,
266
+ (
267
+ "energy",
268
+ "Energy",
269
+ "sessionEnergy",
270
+ "SessionEnergy",
271
+ "session_energy",
272
+ "totalEnergy",
273
+ "TotalEnergy",
274
+ "total_energy",
275
+ "energyKwh",
276
+ "energy_kwh",
277
+ "meterEnergy",
278
+ "MeterEnergy",
279
+ "importedEnergy",
280
+ "ImportedEnergy",
281
+ "absoluteEnergy",
282
+ "AbsoluteEnergy",
283
+ "accumulatedEnergy",
284
+ "AccumulatedEnergy",
285
+ "total_import_energy",
286
+ "TotalImportEnergy",
287
+ "value",
288
+ "Value",
289
+ "reading",
290
+ "Reading",
291
+ ),
292
+ )
293
+ val = _as_float(raw)
294
+ if val is None:
295
+ return None
296
+ # Heuristic: large values are often Wh → kWh
297
+ if val > 10000:
298
+ return val / 1000.0
299
+ return val
300
+
301
+
302
+ def parse_state_text(*layers: Mapping[str, Any]) -> str | None:
303
+ merged = merge_layers_for_parse(*layers)
304
+ raw = pick_first(
305
+ merged,
306
+ (
307
+ "state",
308
+ "State",
309
+ "chargingState",
310
+ "ChargingState",
311
+ "charging_state",
312
+ "status",
313
+ "Status",
314
+ "cpStatus",
315
+ "CpStatus",
316
+ "cp_status",
317
+ "chargerState",
318
+ "ChargerState",
319
+ "evseStatus",
320
+ "EvseStatus",
321
+ "operationalState",
322
+ "OperationalState",
323
+ "chargerStatus",
324
+ "ChargerStatus",
325
+ ),
326
+ )
327
+ if raw is not None:
328
+ if isinstance(raw, bool):
329
+ return "Charging" if raw else "Idle"
330
+ code = str(raw).strip()
331
+ return CHARGING_STATE_LABELS.get(code, code)
332
+ # OCPP meter payloads often omit a text state; infer from power/current
333
+ p = _as_float(merged.get("power"))
334
+ c = _as_float(merged.get("current"))
335
+ if p is not None and p > 1.0:
336
+ return "Charging"
337
+ if c is not None and c > 0.01:
338
+ return "Charging"
339
+ if p is not None or c is not None:
340
+ return "Idle"
341
+ return None
342
+
343
+
344
+ def parse_cpu_temperature_c(temperatures: Mapping[str, Any]) -> float | None:
345
+ """CPU temperature from /status/temperatures (°C)."""
346
+ merged = merge_layers_for_parse(temperatures)
347
+ raw = pick_first(
348
+ merged,
349
+ (
350
+ "cpu",
351
+ "cpuTemperature",
352
+ "cpu_temperature",
353
+ "CPU",
354
+ "processor",
355
+ "ProcessorTemperature",
356
+ ),
357
+ )
358
+ return _as_float(raw)
359
+
360
+
361
+ def parse_baseboard_temperature_c(temperatures: Mapping[str, Any]) -> float | None:
362
+ """Baseboard temperature from /status/temperatures (°C)."""
363
+ merged = merge_layers_for_parse(temperatures)
364
+ raw = pick_first(
365
+ merged,
366
+ (
367
+ "baseboard",
368
+ "baseboardTemperature",
369
+ "baseboard_temperature",
370
+ "BaseboardTemperature",
371
+ "board",
372
+ "Board",
373
+ "mb",
374
+ "MB",
375
+ "pcb",
376
+ "Pcb",
377
+ "PCB",
378
+ "motherboard",
379
+ "Motherboard",
380
+ "ambient",
381
+ "Ambient",
382
+ "base_board",
383
+ "BaseBoard",
384
+ ),
385
+ )
386
+ return _as_float(raw)
File without changes
@@ -0,0 +1,62 @@
1
+ Metadata-Version: 2.4
2
+ Name: garo-entity-pro
3
+ Version: 0.1.0
4
+ Summary: Async client for the Garo Entity Pro charger local HTTPS API (/status/...).
5
+ License: MIT
6
+ Keywords: garo,evcharger,ocpp,aiohttp,home-assistant
7
+ Classifier: Development Status :: 4 - Beta
8
+ Classifier: Intended Audience :: Developers
9
+ Classifier: License :: OSI Approved :: MIT License
10
+ Classifier: Programming Language :: Python :: 3
11
+ Classifier: Programming Language :: Python :: 3.10
12
+ Classifier: Programming Language :: Python :: 3.11
13
+ Classifier: Programming Language :: Python :: 3.12
14
+ Classifier: Programming Language :: Python :: 3.13
15
+ Classifier: Topic :: Home Automation
16
+ Requires-Python: >=3.10
17
+ Description-Content-Type: text/markdown
18
+ License-File: LICENSE
19
+ Requires-Dist: aiohttp>=3.9.0
20
+ Requires-Dist: yarl>=1.9.0
21
+ Provides-Extra: ha
22
+ Requires-Dist: homeassistant>=2024.1.0; extra == "ha"
23
+ Requires-Dist: voluptuous>=0.13; extra == "ha"
24
+ Dynamic: license-file
25
+
26
+ # garo-entity-pro
27
+
28
+ Async Python client for the **Garo Entity Pro** wallbox local HTTPS API (`/status/energy-meter`, `/status/temperatures`, etc.).
29
+
30
+ ## Install
31
+
32
+ ```bash
33
+ pip install garo-entity-pro
34
+ ```
35
+
36
+ ## Library usage
37
+
38
+ ```python
39
+ from garo_entity_pro import GaroApiClient
40
+
41
+ async def main():
42
+ async with GaroApiClient("https://192.168.1.10", "user", "pass", verify_ssl=False) as client:
43
+ data = await client.get_status()
44
+ ```
45
+
46
+ Parsing helpers live under `garo_entity_pro.parsing` (OCPP-style meter payloads, state labels).
47
+
48
+ ## Home Assistant
49
+
50
+ The custom integration in `custom_components/garo_entity_pro/` depends on this package (see `manifest.json`). Install the integration and ensure Home Assistant can install requirements, or run `pip install -e .` from this repository when developing.
51
+
52
+ ## Development
53
+
54
+ ```bash
55
+ pip install -e ".[ha]"
56
+ ```
57
+
58
+ Build a wheel:
59
+
60
+ ```bash
61
+ pip install build && python -m build
62
+ ```
@@ -0,0 +1,13 @@
1
+ LICENSE
2
+ README.md
3
+ pyproject.toml
4
+ src/garo_entity_pro/__init__.py
5
+ src/garo_entity_pro/client.py
6
+ src/garo_entity_pro/exceptions.py
7
+ src/garo_entity_pro/parsing.py
8
+ src/garo_entity_pro/py.typed
9
+ src/garo_entity_pro.egg-info/PKG-INFO
10
+ src/garo_entity_pro.egg-info/SOURCES.txt
11
+ src/garo_entity_pro.egg-info/dependency_links.txt
12
+ src/garo_entity_pro.egg-info/requires.txt
13
+ src/garo_entity_pro.egg-info/top_level.txt
@@ -0,0 +1,6 @@
1
+ aiohttp>=3.9.0
2
+ yarl>=1.9.0
3
+
4
+ [ha]
5
+ homeassistant>=2024.1.0
6
+ voluptuous>=0.13
@@ -0,0 +1 @@
1
+ garo_entity_pro