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.
- garo_entity_pro-0.1.0/LICENSE +21 -0
- garo_entity_pro-0.1.0/PKG-INFO +62 -0
- garo_entity_pro-0.1.0/README.md +37 -0
- garo_entity_pro-0.1.0/pyproject.toml +39 -0
- garo_entity_pro-0.1.0/setup.cfg +4 -0
- garo_entity_pro-0.1.0/src/garo_entity_pro/__init__.py +19 -0
- garo_entity_pro-0.1.0/src/garo_entity_pro/client.py +239 -0
- garo_entity_pro-0.1.0/src/garo_entity_pro/exceptions.py +24 -0
- garo_entity_pro-0.1.0/src/garo_entity_pro/parsing.py +386 -0
- garo_entity_pro-0.1.0/src/garo_entity_pro/py.typed +0 -0
- garo_entity_pro-0.1.0/src/garo_entity_pro.egg-info/PKG-INFO +62 -0
- garo_entity_pro-0.1.0/src/garo_entity_pro.egg-info/SOURCES.txt +13 -0
- garo_entity_pro-0.1.0/src/garo_entity_pro.egg-info/dependency_links.txt +1 -0
- garo_entity_pro-0.1.0/src/garo_entity_pro.egg-info/requires.txt +6 -0
- garo_entity_pro-0.1.0/src/garo_entity_pro.egg-info/top_level.txt +1 -0
|
@@ -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,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 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
garo_entity_pro
|