aiomarstek 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 Marstek
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,70 @@
1
+ Metadata-Version: 2.4
2
+ Name: aiomarstek
3
+ Version: 0.1.0
4
+ Summary: Async client library for local Marstek devices
5
+ Author: Marstek
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/Clarksonurge/aiomarstek
8
+ Project-URL: Issues, https://github.com/Clarksonurge/aiomarstek/issues
9
+ Keywords: asyncio,home-assistant,marstek,udp
10
+ Classifier: Development Status :: 3 - Alpha
11
+ Classifier: Framework :: AsyncIO
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: Operating System :: OS Independent
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Programming Language :: Python :: 3.11
16
+ Classifier: Programming Language :: Python :: 3.12
17
+ Classifier: Programming Language :: Python :: 3.13
18
+ Classifier: Typing :: Typed
19
+ Requires-Python: >=3.11
20
+ Description-Content-Type: text/markdown
21
+ License-File: LICENSE
22
+ Provides-Extra: test
23
+ Requires-Dist: pytest>=8.0; extra == "test"
24
+ Requires-Dist: pytest-asyncio>=0.24; extra == "test"
25
+ Requires-Dist: ruff>=0.8; extra == "test"
26
+ Dynamic: license-file
27
+
28
+ # aiomarstek
29
+
30
+ Async client library for local Marstek device communication.
31
+
32
+ `aiomarstek` contains the UDP protocol, command builder, discovery cache,
33
+ polling pause handling, and high-level status helpers used by the Home Assistant
34
+ Marstek integration.
35
+
36
+ ## Installation
37
+
38
+ ```bash
39
+ python -m pip install aiomarstek
40
+ ```
41
+
42
+ ## Example
43
+
44
+ ```python
45
+ import asyncio
46
+
47
+ from aiomarstek import MarstekUDPClient
48
+
49
+
50
+ async def main() -> None:
51
+ client = MarstekUDPClient()
52
+ await client.async_setup()
53
+ try:
54
+ devices = await client.discover_devices()
55
+ print(devices)
56
+ finally:
57
+ await client.async_cleanup()
58
+
59
+
60
+ asyncio.run(main())
61
+ ```
62
+
63
+ ## Development
64
+
65
+ ```bash
66
+ python -m pip install -e ".[test]"
67
+ python -m pytest
68
+ python -m ruff check .
69
+ python -m build
70
+ ```
@@ -0,0 +1,43 @@
1
+ # aiomarstek
2
+
3
+ Async client library for local Marstek device communication.
4
+
5
+ `aiomarstek` contains the UDP protocol, command builder, discovery cache,
6
+ polling pause handling, and high-level status helpers used by the Home Assistant
7
+ Marstek integration.
8
+
9
+ ## Installation
10
+
11
+ ```bash
12
+ python -m pip install aiomarstek
13
+ ```
14
+
15
+ ## Example
16
+
17
+ ```python
18
+ import asyncio
19
+
20
+ from aiomarstek import MarstekUDPClient
21
+
22
+
23
+ async def main() -> None:
24
+ client = MarstekUDPClient()
25
+ await client.async_setup()
26
+ try:
27
+ devices = await client.discover_devices()
28
+ print(devices)
29
+ finally:
30
+ await client.async_cleanup()
31
+
32
+
33
+ asyncio.run(main())
34
+ ```
35
+
36
+ ## Development
37
+
38
+ ```bash
39
+ python -m pip install -e ".[test]"
40
+ python -m pytest
41
+ python -m ruff check .
42
+ python -m build
43
+ ```
@@ -0,0 +1,53 @@
1
+ [build-system]
2
+ requires = ["setuptools>=77.0", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "aiomarstek"
7
+ version = "0.1.0"
8
+ description = "Async client library for local Marstek devices"
9
+ readme = "README.md"
10
+ requires-python = ">=3.11"
11
+ license = "MIT"
12
+ authors = [{ name = "Marstek" }]
13
+ keywords = ["asyncio", "home-assistant", "marstek", "udp"]
14
+ classifiers = [
15
+ "Development Status :: 3 - Alpha",
16
+ "Framework :: AsyncIO",
17
+ "Intended Audience :: Developers",
18
+ "Operating System :: OS Independent",
19
+ "Programming Language :: Python :: 3",
20
+ "Programming Language :: Python :: 3.11",
21
+ "Programming Language :: Python :: 3.12",
22
+ "Programming Language :: Python :: 3.13",
23
+ "Typing :: Typed",
24
+ ]
25
+
26
+ [project.optional-dependencies]
27
+ test = [
28
+ "pytest>=8.0",
29
+ "pytest-asyncio>=0.24",
30
+ "ruff>=0.8",
31
+ ]
32
+
33
+ [project.urls]
34
+ Homepage = "https://github.com/Clarksonurge/aiomarstek"
35
+ Issues = "https://github.com/Clarksonurge/aiomarstek/issues"
36
+
37
+ [tool.setuptools.package-data]
38
+ aiomarstek = ["py.typed"]
39
+
40
+ [tool.setuptools.packages.find]
41
+ where = ["src"]
42
+
43
+ [tool.ruff]
44
+ line-length = 120
45
+ target-version = "py311"
46
+
47
+ [tool.ruff.lint]
48
+ select = ["E", "F", "I", "UP", "B", "ASYNC", "RUF"]
49
+ ignore = ["ASYNC109"]
50
+
51
+ [tool.pytest.ini_options]
52
+ asyncio_mode = "auto"
53
+ testpaths = ["tests"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,43 @@
1
+ """Marstek client library."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from .command_builder import (
6
+ build_command,
7
+ discover,
8
+ get_battery_status,
9
+ get_es_mode,
10
+ get_es_status,
11
+ get_next_request_id,
12
+ get_pv_status,
13
+ reset_request_id,
14
+ )
15
+ from .const import (
16
+ CMD_BATTERY_STATUS,
17
+ CMD_DISCOVER,
18
+ CMD_ES_MODE,
19
+ CMD_ES_STATUS,
20
+ CMD_PV_GET_STATUS,
21
+ DEFAULT_UDP_PORT,
22
+ DISCOVERY_TIMEOUT,
23
+ )
24
+ from .udp_client import MarstekUDPClient
25
+
26
+ __all__ = [
27
+ "CMD_BATTERY_STATUS",
28
+ "CMD_DISCOVER",
29
+ "CMD_ES_MODE",
30
+ "CMD_ES_STATUS",
31
+ "CMD_PV_GET_STATUS",
32
+ "DEFAULT_UDP_PORT",
33
+ "DISCOVERY_TIMEOUT",
34
+ "MarstekUDPClient",
35
+ "build_command",
36
+ "discover",
37
+ "get_battery_status",
38
+ "get_es_mode",
39
+ "get_es_status",
40
+ "get_next_request_id",
41
+ "get_pv_status",
42
+ "reset_request_id",
43
+ ]
@@ -0,0 +1,64 @@
1
+ """Command builder for Marstek devices."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from typing import Any
7
+
8
+ from .const import (
9
+ CMD_BATTERY_STATUS,
10
+ CMD_DISCOVER,
11
+ CMD_ES_MODE,
12
+ CMD_ES_STATUS,
13
+ CMD_PV_GET_STATUS,
14
+ )
15
+
16
+ _request_id = 0
17
+
18
+
19
+ def get_next_request_id() -> int:
20
+ """Get next request ID."""
21
+ global _request_id
22
+ _request_id += 1
23
+ return _request_id
24
+
25
+
26
+ def reset_request_id() -> None:
27
+ """Reset request ID counter."""
28
+ global _request_id
29
+ _request_id = 0
30
+
31
+
32
+ def build_command(method: str, params: dict[str, Any] | None = None) -> str:
33
+ """Build command JSON string."""
34
+ command = {
35
+ "id": get_next_request_id(),
36
+ "method": method,
37
+ "params": params or {},
38
+ }
39
+ return json.dumps(command)
40
+
41
+
42
+ def discover() -> str:
43
+ """Device discovery command."""
44
+ return build_command(CMD_DISCOVER, {"ble_mac": "0"})
45
+
46
+
47
+ def get_battery_status(device_id: int = 0) -> str:
48
+ """Battery status query command."""
49
+ return build_command(CMD_BATTERY_STATUS, {"id": device_id})
50
+
51
+
52
+ def get_es_status(device_id: int = 0) -> str:
53
+ """Get device power status and statistics command."""
54
+ return build_command(CMD_ES_STATUS, {"id": device_id})
55
+
56
+
57
+ def get_es_mode(device_id: int = 0) -> str:
58
+ """Get device operating mode and battery info command."""
59
+ return build_command(CMD_ES_MODE, {"id": device_id})
60
+
61
+
62
+ def get_pv_status(device_id: int = 0) -> str:
63
+ """Get device PV status command."""
64
+ return build_command(CMD_PV_GET_STATUS, {"id": device_id})
@@ -0,0 +1,14 @@
1
+ """Constants for the Marstek client library."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Final
6
+
7
+ DEFAULT_UDP_PORT: Final = 30000
8
+ DISCOVERY_TIMEOUT: Final = 10.0
9
+
10
+ CMD_DISCOVER: Final = "Marstek.GetDevice"
11
+ CMD_BATTERY_STATUS: Final = "Bat.GetStatus"
12
+ CMD_ES_STATUS: Final = "ES.GetStatus"
13
+ CMD_ES_MODE: Final = "ES.GetMode"
14
+ CMD_PV_GET_STATUS: Final = "PV.GetStatus"
@@ -0,0 +1 @@
1
+
@@ -0,0 +1,508 @@
1
+ """UDP client for Marstek device communication."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import json
7
+ import logging
8
+ import socket
9
+ from collections.abc import Iterable
10
+ from contextlib import suppress
11
+ from typing import Any
12
+
13
+ from .command_builder import discover, get_es_mode, get_pv_status
14
+ from .const import DEFAULT_UDP_PORT, DISCOVERY_TIMEOUT
15
+
16
+ _LOGGER = logging.getLogger(__name__)
17
+ _SOCKET_FACTORY = socket.socket
18
+
19
+
20
+ class MarstekUDPClient:
21
+ """UDP client for Marstek device communication."""
22
+
23
+ def __init__(
24
+ self,
25
+ port: int = DEFAULT_UDP_PORT,
26
+ broadcast_addresses: Iterable[str] | None = None,
27
+ ) -> None:
28
+ """Initialize UDP client."""
29
+ self._port = port
30
+ self._socket: socket.socket | None = None
31
+ self._pending_requests: dict[int, asyncio.Future] = {}
32
+ self._response_cache: dict[int, dict[str, Any]] = {}
33
+ self._listen_task: asyncio.Task | None = None
34
+ self._discovery_cache: list[dict[str, Any]] | None = None
35
+ self._cache_timestamp: float = 0
36
+ self._cache_duration: float = 30.0
37
+ self._local_send_ip: str = "0.0.0.0"
38
+ self._polling_paused: dict[str, bool] = {}
39
+ self._polling_lock: asyncio.Lock = asyncio.Lock()
40
+ self._broadcast_addresses = list(broadcast_addresses or ["255.255.255.255"])
41
+
42
+ def set_broadcast_addresses(self, addresses: Iterable[str]) -> None:
43
+ """Set broadcast addresses used for discovery."""
44
+ self._broadcast_addresses = list(addresses)
45
+
46
+ def get_discovery_cache(self) -> list[dict[str, Any]] | None:
47
+ """Return a copy of the discovery cache."""
48
+ if self._discovery_cache is None:
49
+ return None
50
+ return self._discovery_cache.copy()
51
+
52
+ async def async_setup(self) -> None:
53
+ """Setup UDP socket."""
54
+ if self._socket is not None:
55
+ return
56
+
57
+ self._socket = _SOCKET_FACTORY(socket.AF_INET, socket.SOCK_DGRAM)
58
+ self._socket.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1)
59
+ self._socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
60
+ self._socket.setblocking(False)
61
+ self._socket.bind(("0.0.0.0", 30000))
62
+ _LOGGER.debug(
63
+ "UDP client bound to %s:%s",
64
+ self._socket.getsockname()[0],
65
+ self._socket.getsockname()[1],
66
+ )
67
+
68
+ async def async_cleanup(self) -> None:
69
+ """Cleanup UDP socket."""
70
+ if self._listen_task and not self._listen_task.done():
71
+ self._listen_task.cancel()
72
+ with suppress(asyncio.CancelledError):
73
+ await self._listen_task
74
+
75
+ if self._socket:
76
+ self._socket.close()
77
+ self._socket = None
78
+
79
+ def _is_cache_valid(self) -> bool:
80
+ """Check if cache is valid."""
81
+ if self._discovery_cache is None:
82
+ return False
83
+ current_time = asyncio.get_running_loop().time()
84
+ return (current_time - self._cache_timestamp) < self._cache_duration
85
+
86
+ def clear_discovery_cache(self) -> None:
87
+ """Clear discovery cache."""
88
+ self._discovery_cache = None
89
+ self._cache_timestamp = 0
90
+ _LOGGER.debug("Device discovery cache cleared")
91
+
92
+ async def _send_udp_message(
93
+ self, message: str, target_ip: str, target_port: int | None = None
94
+ ) -> None:
95
+ """Send UDP message to the specified target."""
96
+ if not self._socket:
97
+ await self.async_setup()
98
+
99
+ try:
100
+ data = message.encode("utf-8")
101
+ self._socket.sendto(data, (target_ip, target_port or self._port))
102
+ _LOGGER.debug(
103
+ "Send: %s:%d <- %s:%d | %s",
104
+ target_ip,
105
+ target_port or self._port,
106
+ self._local_send_ip,
107
+ self._port,
108
+ message,
109
+ )
110
+ except Exception as err:
111
+ _LOGGER.error("Failed to send UDP message: %s", err)
112
+ raise
113
+
114
+ async def send_request(
115
+ self,
116
+ message: str,
117
+ target_ip: str,
118
+ target_port: int | None = None,
119
+ timeout: float = 5.0,
120
+ *,
121
+ quiet_on_timeout: bool = False,
122
+ ) -> dict[str, Any]:
123
+ """Send unicast request and wait for response."""
124
+ if not self._socket:
125
+ await self.async_setup()
126
+
127
+ try:
128
+ message_obj = json.loads(message)
129
+ request_id = message_obj["id"]
130
+ except (json.JSONDecodeError, KeyError) as err:
131
+ _LOGGER.error("Invalid message format: %s", err)
132
+ raise ValueError(f"Invalid message format: {err}") from err
133
+
134
+ loop = asyncio.get_running_loop()
135
+ future = loop.create_future()
136
+ self._pending_requests[request_id] = future
137
+
138
+ try:
139
+ if not self._listen_task or self._listen_task.done():
140
+ self._listen_task = asyncio.create_task(self._listen_for_responses())
141
+
142
+ await self._send_udp_message(message, target_ip, target_port)
143
+
144
+ try:
145
+ return await asyncio.wait_for(future, timeout=timeout)
146
+ except TimeoutError as err:
147
+ if quiet_on_timeout or self.is_polling_paused(target_ip):
148
+ _LOGGER.debug(
149
+ "Request timeout: %s:%d (quiet)",
150
+ target_ip,
151
+ target_port or self._port,
152
+ )
153
+ else:
154
+ _LOGGER.warning(
155
+ "Request timeout: %s:%d",
156
+ target_ip,
157
+ target_port or self._port,
158
+ )
159
+ raise TimeoutError(
160
+ f"Request timeout to {target_ip}:{target_port or self._port}"
161
+ ) from err
162
+ finally:
163
+ self._pending_requests.pop(request_id, None)
164
+
165
+ async def _listen_for_responses(self) -> None:
166
+ """Listen for UDP responses."""
167
+ if not self._socket:
168
+ return
169
+
170
+ loop = asyncio.get_running_loop()
171
+ while True:
172
+ try:
173
+ data, addr = await loop.sock_recvfrom(self._socket, 4096)
174
+ response_text = data.decode("utf-8")
175
+ try:
176
+ response = json.loads(response_text)
177
+ except json.JSONDecodeError:
178
+ response = {"raw": response_text}
179
+ request_id = response.get("id") if isinstance(response, dict) else None
180
+
181
+ _LOGGER.debug(
182
+ "Recv: %s:%d -> %s:%d | %s",
183
+ addr[0],
184
+ addr[1],
185
+ self._local_send_ip,
186
+ self._port,
187
+ json.dumps(response, ensure_ascii=False),
188
+ )
189
+
190
+ if request_id:
191
+ self._response_cache[request_id] = {
192
+ "response": response,
193
+ "addr": addr,
194
+ "timestamp": loop.time(),
195
+ }
196
+
197
+ if request_id and request_id in self._pending_requests:
198
+ future = self._pending_requests.pop(request_id)
199
+ if not future.done():
200
+ future.set_result(response)
201
+ except asyncio.CancelledError:
202
+ break
203
+ except OSError as err:
204
+ _LOGGER.error("Error receiving UDP response: %s", err)
205
+ await asyncio.sleep(1)
206
+
207
+ async def send_broadcast_request(
208
+ self,
209
+ message: str,
210
+ timeout: float = DISCOVERY_TIMEOUT,
211
+ broadcast_addresses: Iterable[str] | None = None,
212
+ ) -> list[dict[str, Any]]:
213
+ """Send broadcast request and collect all responses."""
214
+ if not self._socket:
215
+ await self.async_setup()
216
+
217
+ try:
218
+ message_obj = json.loads(message)
219
+ request_id = message_obj["id"]
220
+ except (json.JSONDecodeError, KeyError) as err:
221
+ _LOGGER.error("Invalid message format: %s", err)
222
+ return []
223
+
224
+ responses: list[dict[str, Any]] = []
225
+ start_time = asyncio.get_running_loop().time()
226
+ loop = asyncio.get_running_loop()
227
+ future = loop.create_future()
228
+ self._pending_requests[request_id] = future
229
+
230
+ try:
231
+ if not self._listen_task or self._listen_task.done():
232
+ self._listen_task = asyncio.create_task(self._listen_for_responses())
233
+
234
+ targets = list(broadcast_addresses or self._broadcast_addresses)
235
+ _LOGGER.debug("Broadcast targets: %s", targets)
236
+
237
+ for address in targets:
238
+ await self._send_udp_message(message, address, self._port)
239
+ _LOGGER.debug("Sent to %s:%s", address, self._port)
240
+
241
+ _LOGGER.debug("Broadcast to %d interfaces", len(targets))
242
+ _LOGGER.debug("Broadcast payload: %s", message)
243
+ _LOGGER.debug("Target port: %s", self._port)
244
+
245
+ _LOGGER.debug("Start waiting for responses, timeout: %d s", timeout)
246
+ try:
247
+ while (loop.time() - start_time) < timeout:
248
+ if request_id in self._response_cache:
249
+ cached_response = self._response_cache[request_id]
250
+ responses.append(cached_response["response"])
251
+ _LOGGER.debug(
252
+ "Broadcast ID:%s received %d response(s)",
253
+ request_id,
254
+ len(responses),
255
+ )
256
+ del self._response_cache[request_id]
257
+
258
+ await asyncio.sleep(0.1)
259
+
260
+ if (loop.time() - start_time) >= timeout:
261
+ _LOGGER.debug("Broadcast ID:%s wait timeout", request_id)
262
+ break
263
+ except OSError as err:
264
+ _LOGGER.error("Error waiting for response: %s", err)
265
+ finally:
266
+ self._pending_requests.pop(request_id, None)
267
+
268
+ _LOGGER.info("Broadcast discovery finished, %d responses", len(responses))
269
+ return responses
270
+
271
+ async def discover_devices(
272
+ self,
273
+ use_cache: bool = True,
274
+ broadcast_addresses: Iterable[str] | None = None,
275
+ ) -> list[dict[str, Any]]:
276
+ """Discover Marstek devices on network and deduplicate results."""
277
+ if use_cache and self._is_cache_valid():
278
+ _LOGGER.debug("Using cached discovery results")
279
+ return self._discovery_cache.copy()
280
+
281
+ devices: list[dict[str, Any]] = []
282
+ seen_devices: set[str] = set()
283
+
284
+ try:
285
+ responses = await self.send_broadcast_request(
286
+ discover(),
287
+ broadcast_addresses=broadcast_addresses,
288
+ )
289
+
290
+ for response in responses:
291
+ if response.get("result"):
292
+ device_info = response["result"]
293
+ device_id = (
294
+ device_info.get("ip", "")
295
+ or device_info.get("ble_mac")
296
+ or device_info.get("wifi_mac")
297
+ or f"device_{int(asyncio.get_running_loop().time())}_{hash(str(device_info)) % 10000}"
298
+ )
299
+
300
+ if device_id in seen_devices:
301
+ _LOGGER.debug(
302
+ "Skip duplicate device: %s (IP: %s, BLE_MAC: %s, WiFi_MAC: %s)",
303
+ device_id,
304
+ device_info.get("ip"),
305
+ device_info.get("ble_mac"),
306
+ device_info.get("wifi_mac"),
307
+ )
308
+ continue
309
+
310
+ seen_devices.add(device_id)
311
+ _LOGGER.debug(
312
+ "Add device: %s (IP: %s, BLE_MAC: %s, WiFi_MAC: %s)",
313
+ device_id,
314
+ device_info.get("ip"),
315
+ device_info.get("ble_mac"),
316
+ device_info.get("wifi_mac"),
317
+ )
318
+
319
+ device = {
320
+ "id": device_info.get("id", 0),
321
+ "device_type": device_info.get("device", "Unknown"),
322
+ "version": device_info.get("ver", 0),
323
+ "wifi_name": device_info.get("wifi_name", ""),
324
+ "ip": device_info.get("ip", ""),
325
+ "wifi_mac": device_info.get("wifi_mac", ""),
326
+ "ble_mac": device_info.get("ble_mac", ""),
327
+ "mac": device_info.get("wifi_mac")
328
+ or device_info.get("ble_mac", ""),
329
+ "model": device_info.get("device", "Unknown"),
330
+ "firmware": str(device_info.get("ver", 0)),
331
+ }
332
+
333
+ devices.append(device)
334
+ _LOGGER.info(
335
+ "Discovered device: Type=%s, Version=%s, WiFi=%s, IP=%s, MAC=%s",
336
+ device["device_type"],
337
+ device["version"],
338
+ device["wifi_name"],
339
+ device["ip"],
340
+ device["mac"],
341
+ )
342
+ except OSError as err:
343
+ _LOGGER.error("Device discovery failed: %s", err)
344
+
345
+ self._discovery_cache = devices.copy()
346
+ self._cache_timestamp = asyncio.get_running_loop().time()
347
+
348
+ _LOGGER.info("Device discovery finished, %d unique devices", len(devices))
349
+ return devices
350
+
351
+ async def pause_polling(self, device_ip: str) -> None:
352
+ """Pause polling for a specific device."""
353
+ async with self._polling_lock:
354
+ self._polling_paused[device_ip] = True
355
+ _LOGGER.info("Polling paused for device: %s", device_ip)
356
+
357
+ async def resume_polling(self, device_ip: str) -> None:
358
+ """Resume polling for a specific device."""
359
+ async with self._polling_lock:
360
+ self._polling_paused[device_ip] = False
361
+ _LOGGER.info("Polling resumed for device: %s", device_ip)
362
+
363
+ def is_polling_paused(self, device_ip: str) -> bool:
364
+ """Check if polling is paused for a specific device."""
365
+ return self._polling_paused.get(device_ip, False)
366
+
367
+ async def send_request_with_polling_control(
368
+ self,
369
+ message: str,
370
+ target_ip: str,
371
+ target_port: int | None = None,
372
+ timeout: float = 5.0,
373
+ ) -> dict[str, Any]:
374
+ """Send request with polling control."""
375
+ await self.pause_polling(target_ip)
376
+
377
+ try:
378
+ return await self.send_request(
379
+ message,
380
+ target_ip,
381
+ target_port,
382
+ timeout,
383
+ quiet_on_timeout=True,
384
+ )
385
+ finally:
386
+ await self.resume_polling(target_ip)
387
+
388
+ async def get_device_status(
389
+ self,
390
+ device_ip: str,
391
+ *,
392
+ previous_data: dict[str, Any] | None = None,
393
+ timeout: float = 2.5,
394
+ delay_ms: int = 2000,
395
+ ) -> dict[str, Any]:
396
+ """Fetch device status and PV data."""
397
+ current_data = previous_data or {}
398
+ result_data = {
399
+ "battery_soc": current_data.get("battery_soc", 0),
400
+ "battery_power": current_data.get("battery_power", 0),
401
+ "device_mode": current_data.get("device_mode", "Unknown"),
402
+ "battery_status": current_data.get("battery_status", "Unknown"),
403
+ "device_ip": device_ip,
404
+ "last_update": asyncio.get_running_loop().time(),
405
+ "pv1_power": current_data.get("pv1_power", 0),
406
+ "pv1_voltage": current_data.get("pv1_voltage", 0),
407
+ "pv1_current": current_data.get("pv1_current", 0),
408
+ "pv1_state": current_data.get("pv1_state", 0),
409
+ "pv2_power": current_data.get("pv2_power", 0),
410
+ "pv2_voltage": current_data.get("pv2_voltage", 0),
411
+ "pv2_current": current_data.get("pv2_current", 0),
412
+ "pv2_state": current_data.get("pv2_state", 0),
413
+ "pv3_power": current_data.get("pv3_power", 0),
414
+ "pv3_voltage": current_data.get("pv3_voltage", 0),
415
+ "pv3_current": current_data.get("pv3_current", 0),
416
+ "pv3_state": current_data.get("pv3_state", 0),
417
+ "pv4_power": current_data.get("pv4_power", 0),
418
+ "pv4_voltage": current_data.get("pv4_voltage", 0),
419
+ "pv4_current": current_data.get("pv4_current", 0),
420
+ "pv4_state": current_data.get("pv4_state", 0),
421
+ }
422
+
423
+ async def delay(ms: int) -> None:
424
+ await asyncio.sleep(ms / 1000.0)
425
+
426
+ async def es_status_request() -> bool:
427
+ try:
428
+ mode_as_status_result = await self.send_request(
429
+ get_es_mode(0),
430
+ device_ip,
431
+ timeout=timeout,
432
+ )
433
+ status_data = mode_as_status_result.get("result", {})
434
+
435
+ battery_soc = status_data.get(
436
+ "bat_soc", result_data.get("battery_soc", 0)
437
+ )
438
+ result_data["battery_soc"] = battery_soc
439
+ ongrid_power = status_data.get(
440
+ "ongrid_power", result_data.get("battery_power", 0)
441
+ )
442
+ result_data["battery_power"] = abs(ongrid_power)
443
+ result_data["device_mode"] = status_data.get("mode", "Unknown")
444
+ if ongrid_power > 0:
445
+ result_data["battery_status"] = "Selling"
446
+ elif ongrid_power < 0:
447
+ result_data["battery_status"] = "Charging"
448
+ else:
449
+ result_data["battery_status"] = "Idle"
450
+ except (TimeoutError, OSError, ValueError):
451
+ return False
452
+ return True
453
+
454
+ async def pv_status_request() -> bool:
455
+ try:
456
+ pv_status_result = await self.send_request(
457
+ get_pv_status(0),
458
+ device_ip,
459
+ timeout=timeout,
460
+ )
461
+ pv_data = pv_status_result.get("result", {})
462
+
463
+ for key in (
464
+ "pv1_power",
465
+ "pv1_voltage",
466
+ "pv1_current",
467
+ "pv1_state",
468
+ "pv2_power",
469
+ "pv2_voltage",
470
+ "pv2_current",
471
+ "pv2_state",
472
+ "pv3_power",
473
+ "pv3_voltage",
474
+ "pv3_current",
475
+ "pv3_state",
476
+ "pv4_power",
477
+ "pv4_voltage",
478
+ "pv4_current",
479
+ "pv4_state",
480
+ ):
481
+ result_data[key] = pv_data.get(key, result_data.get(key, 0))
482
+ except (TimeoutError, OSError, ValueError):
483
+ return False
484
+ return True
485
+
486
+ try:
487
+ await es_status_request()
488
+ except (TimeoutError, OSError, ValueError) as err:
489
+ _LOGGER.error("Device %s ES.GetMode request failed: %s", device_ip, err)
490
+
491
+ await delay(delay_ms)
492
+
493
+ try:
494
+ await pv_status_request()
495
+ except (TimeoutError, OSError, ValueError) as err:
496
+ _LOGGER.error("Device %s PV.GetStatus request failed: %s", device_ip, err)
497
+
498
+ return result_data
499
+
500
+ async def get_device_info(
501
+ self, device_ip: str, *, timeout: float = 5.0
502
+ ) -> dict[str, Any]:
503
+ """Fetch device information from a specific host."""
504
+ response = await self.send_request(discover(), device_ip, timeout=timeout)
505
+ result = response.get("result") if isinstance(response, dict) else None
506
+ if not isinstance(result, dict):
507
+ raise TypeError("No device information returned")
508
+ return result
@@ -0,0 +1,70 @@
1
+ Metadata-Version: 2.4
2
+ Name: aiomarstek
3
+ Version: 0.1.0
4
+ Summary: Async client library for local Marstek devices
5
+ Author: Marstek
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/Clarksonurge/aiomarstek
8
+ Project-URL: Issues, https://github.com/Clarksonurge/aiomarstek/issues
9
+ Keywords: asyncio,home-assistant,marstek,udp
10
+ Classifier: Development Status :: 3 - Alpha
11
+ Classifier: Framework :: AsyncIO
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: Operating System :: OS Independent
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Programming Language :: Python :: 3.11
16
+ Classifier: Programming Language :: Python :: 3.12
17
+ Classifier: Programming Language :: Python :: 3.13
18
+ Classifier: Typing :: Typed
19
+ Requires-Python: >=3.11
20
+ Description-Content-Type: text/markdown
21
+ License-File: LICENSE
22
+ Provides-Extra: test
23
+ Requires-Dist: pytest>=8.0; extra == "test"
24
+ Requires-Dist: pytest-asyncio>=0.24; extra == "test"
25
+ Requires-Dist: ruff>=0.8; extra == "test"
26
+ Dynamic: license-file
27
+
28
+ # aiomarstek
29
+
30
+ Async client library for local Marstek device communication.
31
+
32
+ `aiomarstek` contains the UDP protocol, command builder, discovery cache,
33
+ polling pause handling, and high-level status helpers used by the Home Assistant
34
+ Marstek integration.
35
+
36
+ ## Installation
37
+
38
+ ```bash
39
+ python -m pip install aiomarstek
40
+ ```
41
+
42
+ ## Example
43
+
44
+ ```python
45
+ import asyncio
46
+
47
+ from aiomarstek import MarstekUDPClient
48
+
49
+
50
+ async def main() -> None:
51
+ client = MarstekUDPClient()
52
+ await client.async_setup()
53
+ try:
54
+ devices = await client.discover_devices()
55
+ print(devices)
56
+ finally:
57
+ await client.async_cleanup()
58
+
59
+
60
+ asyncio.run(main())
61
+ ```
62
+
63
+ ## Development
64
+
65
+ ```bash
66
+ python -m pip install -e ".[test]"
67
+ python -m pytest
68
+ python -m ruff check .
69
+ python -m build
70
+ ```
@@ -0,0 +1,15 @@
1
+ LICENSE
2
+ README.md
3
+ pyproject.toml
4
+ src/aiomarstek/__init__.py
5
+ src/aiomarstek/command_builder.py
6
+ src/aiomarstek/const.py
7
+ src/aiomarstek/py.typed
8
+ src/aiomarstek/udp_client.py
9
+ src/aiomarstek.egg-info/PKG-INFO
10
+ src/aiomarstek.egg-info/SOURCES.txt
11
+ src/aiomarstek.egg-info/dependency_links.txt
12
+ src/aiomarstek.egg-info/requires.txt
13
+ src/aiomarstek.egg-info/top_level.txt
14
+ tests/test_command_builder.py
15
+ tests/test_udp_client.py
@@ -0,0 +1,5 @@
1
+
2
+ [test]
3
+ pytest>=8.0
4
+ pytest-asyncio>=0.24
5
+ ruff>=0.8
@@ -0,0 +1 @@
1
+ aiomarstek
@@ -0,0 +1,114 @@
1
+ """Tests for the Marstek command builder."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+
7
+ from aiomarstek import (
8
+ CMD_BATTERY_STATUS,
9
+ CMD_DISCOVER,
10
+ CMD_ES_MODE,
11
+ CMD_ES_STATUS,
12
+ CMD_PV_GET_STATUS,
13
+ build_command,
14
+ discover,
15
+ get_battery_status,
16
+ get_es_mode,
17
+ get_es_status,
18
+ get_next_request_id,
19
+ get_pv_status,
20
+ reset_request_id,
21
+ )
22
+
23
+
24
+ def test_get_next_request_id() -> None:
25
+ """Test request ID generation."""
26
+ reset_request_id()
27
+ assert get_next_request_id() == 1
28
+ assert get_next_request_id() == 2
29
+ assert get_next_request_id() == 3
30
+
31
+
32
+ def test_reset_request_id() -> None:
33
+ """Test request ID reset."""
34
+ get_next_request_id()
35
+ get_next_request_id()
36
+ reset_request_id()
37
+ assert get_next_request_id() == 1
38
+
39
+
40
+ def test_build_command() -> None:
41
+ """Test building a command."""
42
+ reset_request_id()
43
+ command = build_command("test.method", {"param": "value"})
44
+ parsed = json.loads(command)
45
+
46
+ assert parsed["id"] == 1
47
+ assert parsed["method"] == "test.method"
48
+ assert parsed["params"] == {"param": "value"}
49
+
50
+
51
+ def test_build_command_without_params() -> None:
52
+ """Test building a command without parameters."""
53
+ reset_request_id()
54
+ command = build_command("test.method")
55
+ parsed = json.loads(command)
56
+
57
+ assert parsed["id"] == 1
58
+ assert parsed["method"] == "test.method"
59
+ assert parsed["params"] == {}
60
+
61
+
62
+ def test_discover() -> None:
63
+ """Test discover command."""
64
+ reset_request_id()
65
+ command = discover()
66
+ parsed = json.loads(command)
67
+
68
+ assert parsed["id"] == 1
69
+ assert parsed["method"] == CMD_DISCOVER
70
+ assert parsed["params"] == {"ble_mac": "0"}
71
+
72
+
73
+ def test_get_battery_status() -> None:
74
+ """Test battery status command."""
75
+ reset_request_id()
76
+ command = get_battery_status(5)
77
+ parsed = json.loads(command)
78
+
79
+ assert parsed["id"] == 1
80
+ assert parsed["method"] == CMD_BATTERY_STATUS
81
+ assert parsed["params"] == {"id": 5}
82
+
83
+
84
+ def test_get_es_status() -> None:
85
+ """Test ES status command."""
86
+ reset_request_id()
87
+ command = get_es_status(0)
88
+ parsed = json.loads(command)
89
+
90
+ assert parsed["id"] == 1
91
+ assert parsed["method"] == CMD_ES_STATUS
92
+ assert parsed["params"] == {"id": 0}
93
+
94
+
95
+ def test_get_es_mode() -> None:
96
+ """Test ES mode command."""
97
+ reset_request_id()
98
+ command = get_es_mode(0)
99
+ parsed = json.loads(command)
100
+
101
+ assert parsed["id"] == 1
102
+ assert parsed["method"] == CMD_ES_MODE
103
+ assert parsed["params"] == {"id": 0}
104
+
105
+
106
+ def test_get_pv_status() -> None:
107
+ """Test PV status command."""
108
+ reset_request_id()
109
+ command = get_pv_status(0)
110
+ parsed = json.loads(command)
111
+
112
+ assert parsed["id"] == 1
113
+ assert parsed["method"] == CMD_PV_GET_STATUS
114
+ assert parsed["params"] == {"id": 0}
@@ -0,0 +1,112 @@
1
+ """Tests for the Marstek UDP client."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from unittest.mock import MagicMock, patch
6
+
7
+ import pytest
8
+
9
+ from aiomarstek import MarstekUDPClient
10
+
11
+
12
+ @pytest.fixture
13
+ def socket_mock() -> MagicMock:
14
+ """Mock Marstek UDP sockets."""
15
+ socket_instance = MagicMock()
16
+ socket_instance.getsockname.return_value = ("0.0.0.0", 30000)
17
+ with patch("aiomarstek.udp_client._SOCKET_FACTORY", return_value=socket_instance):
18
+ yield socket_instance
19
+
20
+
21
+ async def test_async_setup(socket_mock: MagicMock) -> None:
22
+ """Test UDP client setup."""
23
+ client = MarstekUDPClient()
24
+
25
+ await client.async_setup()
26
+
27
+ socket_mock.setsockopt.assert_called()
28
+ socket_mock.setblocking.assert_called_once_with(False)
29
+ socket_mock.bind.assert_called_once_with(("0.0.0.0", 30000))
30
+
31
+
32
+ async def test_async_cleanup(socket_mock: MagicMock) -> None:
33
+ """Test UDP client cleanup."""
34
+ client = MarstekUDPClient()
35
+
36
+ await client.async_setup()
37
+ await client.async_cleanup()
38
+
39
+ socket_mock.close.assert_called_once()
40
+
41
+
42
+ async def test_send_udp_message(socket_mock: MagicMock) -> None:
43
+ """Test sending UDP messages."""
44
+ client = MarstekUDPClient()
45
+
46
+ await client._send_udp_message('{"id": 1, "method": "test"}', "192.0.2.1", 30000)
47
+
48
+ socket_mock.sendto.assert_called_once_with(
49
+ b'{"id": 1, "method": "test"}',
50
+ ("192.0.2.1", 30000),
51
+ )
52
+
53
+
54
+ async def test_discover_devices_cache(socket_mock: MagicMock) -> None:
55
+ """Test device discovery cache."""
56
+ client = MarstekUDPClient()
57
+ response = {
58
+ "id": 1,
59
+ "result": {
60
+ "id": 0,
61
+ "device": "ES5",
62
+ "ver": 1,
63
+ "wifi_name": "TestWiFi",
64
+ "ip": "192.0.2.1",
65
+ "wifi_mac": "AA:BB:CC:DD:EE:FF",
66
+ "ble_mac": "11:22:33:44:55:66",
67
+ },
68
+ }
69
+
70
+ with patch.object(client, "send_broadcast_request", return_value=[response]):
71
+ devices = await client.discover_devices(use_cache=False)
72
+ cached_devices = await client.discover_devices(use_cache=True)
73
+
74
+ assert devices == cached_devices
75
+ assert cached_devices[0]["device_type"] == "ES5"
76
+ assert cached_devices[0]["ip"] == "192.0.2.1"
77
+
78
+
79
+ async def test_polling_control() -> None:
80
+ """Test polling pause and resume."""
81
+ client = MarstekUDPClient()
82
+
83
+ assert not client.is_polling_paused("192.0.2.1")
84
+ await client.pause_polling("192.0.2.1")
85
+ assert client.is_polling_paused("192.0.2.1")
86
+ await client.resume_polling("192.0.2.1")
87
+ assert not client.is_polling_paused("192.0.2.1")
88
+
89
+
90
+ async def test_send_request_invalid_message(socket_mock: MagicMock) -> None:
91
+ """Test invalid request payload handling."""
92
+ client = MarstekUDPClient()
93
+
94
+ with pytest.raises(ValueError):
95
+ await client.send_request("not json", "192.0.2.1", timeout=0.1)
96
+
97
+
98
+ async def test_get_device_status_preserves_previous_data() -> None:
99
+ """Test status helper preserves previous data when requests fail."""
100
+ client = MarstekUDPClient()
101
+ previous_data = {"battery_soc": 42, "pv1_power": 100}
102
+
103
+ with patch.object(client, "send_request", side_effect=TimeoutError):
104
+ result = await client.get_device_status(
105
+ "192.0.2.1",
106
+ previous_data=previous_data,
107
+ delay_ms=0,
108
+ )
109
+
110
+ assert result["battery_soc"] == 42
111
+ assert result["pv1_power"] == 100
112
+ assert result["device_ip"] == "192.0.2.1"