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.
- aiomarstek-0.1.0/LICENSE +21 -0
- aiomarstek-0.1.0/PKG-INFO +70 -0
- aiomarstek-0.1.0/README.md +43 -0
- aiomarstek-0.1.0/pyproject.toml +53 -0
- aiomarstek-0.1.0/setup.cfg +4 -0
- aiomarstek-0.1.0/src/aiomarstek/__init__.py +43 -0
- aiomarstek-0.1.0/src/aiomarstek/command_builder.py +64 -0
- aiomarstek-0.1.0/src/aiomarstek/const.py +14 -0
- aiomarstek-0.1.0/src/aiomarstek/py.typed +1 -0
- aiomarstek-0.1.0/src/aiomarstek/udp_client.py +508 -0
- aiomarstek-0.1.0/src/aiomarstek.egg-info/PKG-INFO +70 -0
- aiomarstek-0.1.0/src/aiomarstek.egg-info/SOURCES.txt +15 -0
- aiomarstek-0.1.0/src/aiomarstek.egg-info/dependency_links.txt +1 -0
- aiomarstek-0.1.0/src/aiomarstek.egg-info/requires.txt +5 -0
- aiomarstek-0.1.0/src/aiomarstek.egg-info/top_level.txt +1 -0
- aiomarstek-0.1.0/tests/test_command_builder.py +114 -0
- aiomarstek-0.1.0/tests/test_udp_client.py +112 -0
aiomarstek-0.1.0/LICENSE
ADDED
|
@@ -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,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 @@
|
|
|
1
|
+
|
|
@@ -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"
|