aiorexense 0.1.1__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.
- aiorexense-0.1.1/LICENSE +21 -0
- aiorexense-0.1.1/PKG-INFO +47 -0
- aiorexense-0.1.1/README.md +7 -0
- aiorexense-0.1.1/aiorexense.egg-info/PKG-INFO +47 -0
- aiorexense-0.1.1/aiorexense.egg-info/SOURCES.txt +16 -0
- aiorexense-0.1.1/aiorexense.egg-info/dependency_links.txt +1 -0
- aiorexense-0.1.1/aiorexense.egg-info/requires.txt +1 -0
- aiorexense-0.1.1/aiorexense.egg-info/top_level.txt +4 -0
- aiorexense-0.1.1/pyproject.toml +19 -0
- aiorexense-0.1.1/setup.cfg +4 -0
- aiorexense-0.1.1/setup.py +34 -0
- aiorexense-0.1.1/src/__init__.py +86 -0
- aiorexense-0.1.1/src/api.py +61 -0
- aiorexense-0.1.1/src/const.py +42 -0
- aiorexense-0.1.1/src/ws_client.py +197 -0
- aiorexense-0.1.1/tests/test_api.py +53 -0
- aiorexense-0.1.1/tests/test_const.py +13 -0
- aiorexense-0.1.1/tests/test_ws_client.py +85 -0
aiorexense-0.1.1/LICENSE
ADDED
@@ -0,0 +1,21 @@
|
|
1
|
+
MIT License
|
2
|
+
|
3
|
+
Copyright (c) 2025 Zhejiang Rexense IoT Technology Co., Ltd
|
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,47 @@
|
|
1
|
+
Metadata-Version: 2.4
|
2
|
+
Name: aiorexense
|
3
|
+
Version: 0.1.1
|
4
|
+
Summary: Rexense device client library: HTTP API + WebSocket
|
5
|
+
Home-page: https://github.com/RexenseIoT/aiorexense.git
|
6
|
+
Author: Zhejiang Rexense IoT Technology Co., Ltd
|
7
|
+
Author-email: RexenseIoT <yuxiaoqiang@rexense.com>
|
8
|
+
License: MIT License
|
9
|
+
|
10
|
+
Copyright (c) 2025 Zhejiang Rexense IoT Technology Co., Ltd
|
11
|
+
|
12
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
13
|
+
of this software and associated documentation files (the “Software”), to deal
|
14
|
+
in the Software without restriction, including without limitation the rights
|
15
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
16
|
+
copies of the Software, and to permit persons to whom the Software is
|
17
|
+
furnished to do so, subject to the following conditions:
|
18
|
+
|
19
|
+
The above copyright notice and this permission notice shall be included in all
|
20
|
+
copies or substantial portions of the Software.
|
21
|
+
|
22
|
+
THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
23
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
24
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
25
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
26
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
27
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
28
|
+
SOFTWARE.
|
29
|
+
|
30
|
+
Project-URL: Homepage, https://github.com/RexenseIoT/aiorexense.git
|
31
|
+
Project-URL: Repository, https://github.com/RexenseIoT/aiorexense.git
|
32
|
+
Requires-Python: >=3.8
|
33
|
+
Description-Content-Type: text/markdown
|
34
|
+
License-File: LICENSE
|
35
|
+
Requires-Dist: aiohttp>=3.8.0
|
36
|
+
Dynamic: author
|
37
|
+
Dynamic: home-page
|
38
|
+
Dynamic: license-file
|
39
|
+
Dynamic: requires-python
|
40
|
+
|
41
|
+
# aiorexense
|
42
|
+
|
43
|
+
A Rexense device client library featuring an HTTP API for retrieving basic information and WebSocket-based status push functionality.
|
44
|
+
|
45
|
+
## Install
|
46
|
+
```bash
|
47
|
+
pip install aiorexense
|
@@ -0,0 +1,47 @@
|
|
1
|
+
Metadata-Version: 2.4
|
2
|
+
Name: aiorexense
|
3
|
+
Version: 0.1.1
|
4
|
+
Summary: Rexense device client library: HTTP API + WebSocket
|
5
|
+
Home-page: https://github.com/RexenseIoT/aiorexense.git
|
6
|
+
Author: Zhejiang Rexense IoT Technology Co., Ltd
|
7
|
+
Author-email: RexenseIoT <yuxiaoqiang@rexense.com>
|
8
|
+
License: MIT License
|
9
|
+
|
10
|
+
Copyright (c) 2025 Zhejiang Rexense IoT Technology Co., Ltd
|
11
|
+
|
12
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
13
|
+
of this software and associated documentation files (the “Software”), to deal
|
14
|
+
in the Software without restriction, including without limitation the rights
|
15
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
16
|
+
copies of the Software, and to permit persons to whom the Software is
|
17
|
+
furnished to do so, subject to the following conditions:
|
18
|
+
|
19
|
+
The above copyright notice and this permission notice shall be included in all
|
20
|
+
copies or substantial portions of the Software.
|
21
|
+
|
22
|
+
THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
23
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
24
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
25
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
26
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
27
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
28
|
+
SOFTWARE.
|
29
|
+
|
30
|
+
Project-URL: Homepage, https://github.com/RexenseIoT/aiorexense.git
|
31
|
+
Project-URL: Repository, https://github.com/RexenseIoT/aiorexense.git
|
32
|
+
Requires-Python: >=3.8
|
33
|
+
Description-Content-Type: text/markdown
|
34
|
+
License-File: LICENSE
|
35
|
+
Requires-Dist: aiohttp>=3.8.0
|
36
|
+
Dynamic: author
|
37
|
+
Dynamic: home-page
|
38
|
+
Dynamic: license-file
|
39
|
+
Dynamic: requires-python
|
40
|
+
|
41
|
+
# aiorexense
|
42
|
+
|
43
|
+
A Rexense device client library featuring an HTTP API for retrieving basic information and WebSocket-based status push functionality.
|
44
|
+
|
45
|
+
## Install
|
46
|
+
```bash
|
47
|
+
pip install aiorexense
|
@@ -0,0 +1,16 @@
|
|
1
|
+
LICENSE
|
2
|
+
README.md
|
3
|
+
pyproject.toml
|
4
|
+
setup.py
|
5
|
+
aiorexense.egg-info/PKG-INFO
|
6
|
+
aiorexense.egg-info/SOURCES.txt
|
7
|
+
aiorexense.egg-info/dependency_links.txt
|
8
|
+
aiorexense.egg-info/requires.txt
|
9
|
+
aiorexense.egg-info/top_level.txt
|
10
|
+
src/__init__.py
|
11
|
+
src/api.py
|
12
|
+
src/const.py
|
13
|
+
src/ws_client.py
|
14
|
+
tests/test_api.py
|
15
|
+
tests/test_const.py
|
16
|
+
tests/test_ws_client.py
|
@@ -0,0 +1 @@
|
|
1
|
+
|
@@ -0,0 +1 @@
|
|
1
|
+
aiohttp>=3.8.0
|
@@ -0,0 +1,19 @@
|
|
1
|
+
[build-system]
|
2
|
+
requires = ["setuptools>=61.0", "wheel"]
|
3
|
+
build-backend = "setuptools.build_meta"
|
4
|
+
|
5
|
+
[project]
|
6
|
+
name = "aiorexense"
|
7
|
+
version = "0.1.1"
|
8
|
+
description = "Rexense device client library: HTTP API + WebSocket"
|
9
|
+
readme = "README.md"
|
10
|
+
license = { file = "LICENSE" }
|
11
|
+
authors = [ { name = "RexenseIoT", email = "yuxiaoqiang@rexense.com" } ]
|
12
|
+
requires-python = ">=3.8"
|
13
|
+
dependencies = [
|
14
|
+
"aiohttp>=3.8.0"
|
15
|
+
]
|
16
|
+
|
17
|
+
[project.urls]
|
18
|
+
"Homepage" = "https://github.com/RexenseIoT/aiorexense.git"
|
19
|
+
"Repository" = "https://github.com/RexenseIoT/aiorexense.git"
|
@@ -0,0 +1,34 @@
|
|
1
|
+
#!/usr/bin/env python
|
2
|
+
# setup.py
|
3
|
+
|
4
|
+
import pathlib
|
5
|
+
from setuptools import setup, find_packages
|
6
|
+
|
7
|
+
HERE = pathlib.Path(__file__).parent
|
8
|
+
|
9
|
+
# long_description from your README
|
10
|
+
long_description = (HERE / "README.md").read_text(encoding="utf-8")
|
11
|
+
|
12
|
+
setup(
|
13
|
+
name="aiorexense",
|
14
|
+
version="0.1.0",
|
15
|
+
description="Rexense device client library with HTTP API and WebSocket support",
|
16
|
+
long_description=long_description,
|
17
|
+
long_description_content_type="text/markdown",
|
18
|
+
author="Zhejiang Rexense IoT Technology Co., Ltd",
|
19
|
+
license="MIT License",
|
20
|
+
package_dir={"aiorexense": "src"},
|
21
|
+
packages=["aiorexense"],
|
22
|
+
url="https://github.com/RexenseIoT/aiorexense.git", # adjust as needed
|
23
|
+
python_requires=">=3.8",
|
24
|
+
install_requires=[
|
25
|
+
"aiohttp>=3.8.0",
|
26
|
+
],
|
27
|
+
# since your modules live at the top level:
|
28
|
+
py_modules=["api", "const", "ws_client"],
|
29
|
+
classifiers=[
|
30
|
+
"Programming Language :: Python :: 3",
|
31
|
+
"License :: OSI Approved :: MIT License",
|
32
|
+
"Operating System :: OS Independent",
|
33
|
+
],
|
34
|
+
)
|
@@ -0,0 +1,86 @@
|
|
1
|
+
"""
|
2
|
+
Rexense WS client library init.
|
3
|
+
"""
|
4
|
+
|
5
|
+
__version__ = "0.1.1"
|
6
|
+
|
7
|
+
from .api import get_basic_info
|
8
|
+
from .const import (
|
9
|
+
DEFAULT_PORT,
|
10
|
+
VENDOR_CODE,
|
11
|
+
API_VERSION,
|
12
|
+
FUNCTION_GET_BASIC_INFO,
|
13
|
+
FUNCTION_NOTIFY_STATUS,
|
14
|
+
FUNCTION_INVOKE_CMD,
|
15
|
+
REXENSE_SENSOR_CURRENT,
|
16
|
+
REXENSE_SENSOR_VOLTAGE,
|
17
|
+
REXENSE_SENSOR_POWER_FACTOR,
|
18
|
+
REXENSE_SENSOR_ACTIVE_POWER,
|
19
|
+
REXENSE_SENSOR_APPARENT_POWER,
|
20
|
+
REXENSE_SENSOR_B_CURRENT,
|
21
|
+
REXENSE_SENSOR_B_VOLTAGE,
|
22
|
+
REXENSE_SENSOR_B_POWER_FACTOR,
|
23
|
+
REXENSE_SENSOR_B_ACTIVE_POWER,
|
24
|
+
REXENSE_SENSOR_B_APPARENT_POWER,
|
25
|
+
REXENSE_SENSOR_C_CURRENT,
|
26
|
+
REXENSE_SENSOR_C_VOLTAGE,
|
27
|
+
REXENSE_SENSOR_C_POWER_FACTOR,
|
28
|
+
REXENSE_SENSOR_C_ACTIVE_POWER,
|
29
|
+
REXENSE_SENSOR_C_APPARENT_POWER,
|
30
|
+
REXENSE_SENSOR_TOTAL_ACTIVE_POWER,
|
31
|
+
REXENSE_SENSOR_TOTAL_APPARENT_POWER,
|
32
|
+
REXENSE_SENSOR_CEI,
|
33
|
+
REXENSE_SENSOR_CEE,
|
34
|
+
REXENSE_SENSOR_A_CEI,
|
35
|
+
REXENSE_SENSOR_A_CEE,
|
36
|
+
REXENSE_SENSOR_B_CEI,
|
37
|
+
REXENSE_SENSOR_B_CEE,
|
38
|
+
REXENSE_SENSOR_C_CEI,
|
39
|
+
REXENSE_SENSOR_C_CEE,
|
40
|
+
REXENSE_SENSOR_TEMPERATURE,
|
41
|
+
REXENSE_SENSOR_BATTERY_PERCENTAGE,
|
42
|
+
REXENSE_SENSOR_BATTERY_VOLTAGE,
|
43
|
+
REXENSE_SWITCH_ONOFF,
|
44
|
+
)
|
45
|
+
from .ws_client import RexenseWebsocketClient
|
46
|
+
|
47
|
+
__all__ = [
|
48
|
+
"get_basic_info",
|
49
|
+
"RexenseWebsocketClient",
|
50
|
+
# constants
|
51
|
+
"DEFAULT_PORT",
|
52
|
+
"VENDOR_CODE",
|
53
|
+
"API_VERSION",
|
54
|
+
"FUNCTION_GET_BASIC_INFO",
|
55
|
+
"FUNCTION_NOTIFY_STATUS",
|
56
|
+
"FUNCTION_INVOKE_CMD",
|
57
|
+
"REXENSE_SENSOR_CURRENT",
|
58
|
+
"REXENSE_SENSOR_VOLTAGE",
|
59
|
+
"REXENSE_SENSOR_POWER_FACTOR",
|
60
|
+
"REXENSE_SENSOR_ACTIVE_POWER",
|
61
|
+
"REXENSE_SENSOR_APPARENT_POWER",
|
62
|
+
"REXENSE_SENSOR_B_CURRENT",
|
63
|
+
"REXENSE_SENSOR_B_VOLTAGE",
|
64
|
+
"REXENSE_SENSOR_B_POWER_FACTOR",
|
65
|
+
"REXENSE_SENSOR_B_ACTIVE_POWER",
|
66
|
+
"REXENSE_SENSOR_B_APPARENT_POWER",
|
67
|
+
"REXENSE_SENSOR_C_CURRENT",
|
68
|
+
"REXENSE_SENSOR_C_VOLTAGE",
|
69
|
+
"REXENSE_SENSOR_C_POWER_FACTOR",
|
70
|
+
"REXENSE_SENSOR_C_ACTIVE_POWER",
|
71
|
+
"REXENSE_SENSOR_C_APPARENT_POWER",
|
72
|
+
"REXENSE_SENSOR_TOTAL_ACTIVE_POWER",
|
73
|
+
"REXENSE_SENSOR_TOTAL_APPARENT_POWER",
|
74
|
+
"REXENSE_SENSOR_CEI",
|
75
|
+
"REXENSE_SENSOR_CEE",
|
76
|
+
"REXENSE_SENSOR_A_CEI",
|
77
|
+
"REXENSE_SENSOR_A_CEE",
|
78
|
+
"REXENSE_SENSOR_B_CEI",
|
79
|
+
"REXENSE_SENSOR_B_CEE",
|
80
|
+
"REXENSE_SENSOR_C_CEI",
|
81
|
+
"REXENSE_SENSOR_C_CEE",
|
82
|
+
"REXENSE_SENSOR_TEMPERATURE",
|
83
|
+
"REXENSE_SENSOR_BATTERY_PERCENTAGE",
|
84
|
+
"REXENSE_SENSOR_BATTERY_VOLTAGE",
|
85
|
+
"REXENSE_SWITCH_ONOFF",
|
86
|
+
]
|
@@ -0,0 +1,61 @@
|
|
1
|
+
"""
|
2
|
+
HTTP API for Rexense device basic info
|
3
|
+
"""
|
4
|
+
import asyncio
|
5
|
+
import logging
|
6
|
+
from typing import Any, Tuple
|
7
|
+
|
8
|
+
from aiohttp import ClientSession, ClientError
|
9
|
+
|
10
|
+
from .const import (
|
11
|
+
API_VERSION,
|
12
|
+
VENDOR_CODE,
|
13
|
+
FUNCTION_GET_BASIC_INFO,
|
14
|
+
)
|
15
|
+
|
16
|
+
_LOGGER = logging.getLogger(__name__)
|
17
|
+
|
18
|
+
|
19
|
+
async def get_basic_info(
|
20
|
+
host: str,
|
21
|
+
port: int,
|
22
|
+
session: ClientSession,
|
23
|
+
timeout: int = 5,
|
24
|
+
) -> Tuple[str, str, str, list[dict[str, Any]]]:
|
25
|
+
"""
|
26
|
+
Send an HTTP request to the device, query device_id, model, sw_build_id, feature_map。
|
27
|
+
"""
|
28
|
+
url = f"http://{host}:{port}/rex/device/v1/operate"
|
29
|
+
payload = {
|
30
|
+
"Version": API_VERSION,
|
31
|
+
"VendorCode": VENDOR_CODE,
|
32
|
+
"Timestamp": "0",
|
33
|
+
"Seq": "0",
|
34
|
+
"DeviceId": "",
|
35
|
+
"FunctionCode": FUNCTION_GET_BASIC_INFO,
|
36
|
+
"Payload": {},
|
37
|
+
}
|
38
|
+
try:
|
39
|
+
async with asyncio.timeout(timeout):
|
40
|
+
resp = await session.get(url, json=payload)
|
41
|
+
except (asyncio.TimeoutError, ClientError) as err:
|
42
|
+
_LOGGER.error("HTTP get_basic_info failed: %s", err)
|
43
|
+
raise
|
44
|
+
|
45
|
+
if resp.status != 200:
|
46
|
+
_LOGGER.error("Device %s:%s HTTP status %s", host, port, resp.status)
|
47
|
+
raise ClientError(f"Status {resp.status}")
|
48
|
+
|
49
|
+
data = await resp.json()
|
50
|
+
|
51
|
+
if data.get("FunctionCode") != FUNCTION_GET_BASIC_INFO.replace("GetBasic", "ReportBasic") or not data.get("Payload"):
|
52
|
+
_LOGGER.error("Invalid response format: %s", data)
|
53
|
+
raise ClientError("Invalid response format")
|
54
|
+
|
55
|
+
device_id = data.get("DeviceId", "")
|
56
|
+
payload = data["Payload"]
|
57
|
+
model = payload.get("ModelId", "")
|
58
|
+
sw_build_id = payload.get("SwBuildId", "")
|
59
|
+
feature_map = payload.get("FeatureMap", [])
|
60
|
+
|
61
|
+
return device_id, model, sw_build_id, feature_map
|
@@ -0,0 +1,42 @@
|
|
1
|
+
"""
|
2
|
+
Rexense const & sensor config
|
3
|
+
"""
|
4
|
+
|
5
|
+
DEFAULT_PORT = 80
|
6
|
+
|
7
|
+
API_VERSION = "1.0"
|
8
|
+
VENDOR_CODE = "Rexense"
|
9
|
+
FUNCTION_GET_BASIC_INFO = "GetBasicInfo"
|
10
|
+
FUNCTION_NOTIFY_STATUS = "NotifyStatus"
|
11
|
+
FUNCTION_INVOKE_CMD = "InvokeCmd"
|
12
|
+
|
13
|
+
REXENSE_SENSOR_CURRENT = {"name":"Current","unit":"A"}
|
14
|
+
REXENSE_SENSOR_VOLTAGE = {"name":"Voltage","unit":"V"}
|
15
|
+
REXENSE_SENSOR_POWER_FACTOR = {"name":"PowerFactor","unit":"%"}
|
16
|
+
REXENSE_SENSOR_ACTIVE_POWER = {"name":"ActivePower","unit":"W"}
|
17
|
+
REXENSE_SENSOR_APPARENT_POWER = {"name":"AprtPower","unit":"VA"}
|
18
|
+
REXENSE_SENSOR_B_CURRENT = {"name":"B_Current","unit":"A"}
|
19
|
+
REXENSE_SENSOR_B_VOLTAGE = {"name":"B_Voltage","unit":"V"}
|
20
|
+
REXENSE_SENSOR_B_POWER_FACTOR = {"name":"B_PowerFactor","unit":"%"}
|
21
|
+
REXENSE_SENSOR_B_ACTIVE_POWER = {"name":"B_ActivePower","unit":"W"}
|
22
|
+
REXENSE_SENSOR_B_APPARENT_POWER = {"name":"B_AprtPower","unit":"VA"}
|
23
|
+
REXENSE_SENSOR_C_CURRENT = {"name":"C_Current","unit":"A"}
|
24
|
+
REXENSE_SENSOR_C_VOLTAGE = {"name":"C_Voltage","unit":"V"}
|
25
|
+
REXENSE_SENSOR_C_POWER_FACTOR = {"name":"C_PowerFactor","unit":"%"}
|
26
|
+
REXENSE_SENSOR_C_ACTIVE_POWER = {"name":"C_ActivePower","unit":"W"}
|
27
|
+
REXENSE_SENSOR_C_APPARENT_POWER = {"name":"C_AprtPower","unit":"VA"}
|
28
|
+
REXENSE_SENSOR_TOTAL_ACTIVE_POWER = {"name":"TotalActivePower","unit":"W"}
|
29
|
+
REXENSE_SENSOR_TOTAL_APPARENT_POWER = {"name":"TotalAprtPower","unit":"VA"}
|
30
|
+
REXENSE_SENSOR_CEI = {"name":"CEI","unit":"Wh"}
|
31
|
+
REXENSE_SENSOR_CEE = {"name":"CEE","unit":"Wh"}
|
32
|
+
REXENSE_SENSOR_A_CEI = {"name":"A_CEI","unit":"Wh"}
|
33
|
+
REXENSE_SENSOR_A_CEE = {"name":"A_CEE","unit":"Wh"}
|
34
|
+
REXENSE_SENSOR_B_CEI = {"name":"B_CEI","unit":"Wh"}
|
35
|
+
REXENSE_SENSOR_B_CEE = {"name":"B_CEE","unit":"Wh"}
|
36
|
+
REXENSE_SENSOR_C_CEI = {"name":"C_CEI","unit":"Wh"}
|
37
|
+
REXENSE_SENSOR_C_CEE = {"name":"C_CEE","unit":"Wh"}
|
38
|
+
REXENSE_SENSOR_TEMPERATURE = {"name":"Temperature","unit":"°C"}
|
39
|
+
REXENSE_SENSOR_BATTERY_PERCENTAGE = {"name":"BatteryPercentage","unit":"%"}
|
40
|
+
REXENSE_SENSOR_BATTERY_VOLTAGE = {"name":"BatteryVoltage","unit":"V"}
|
41
|
+
|
42
|
+
REXENSE_SWITCH_ONOFF = {"name":"PowerSwitch","unit":""}
|
@@ -0,0 +1,197 @@
|
|
1
|
+
"""
|
2
|
+
WebSocket client for Rexense devices, independent of Home Assistant.
|
3
|
+
"""
|
4
|
+
import asyncio
|
5
|
+
import logging
|
6
|
+
from typing import Any, Callable, Optional
|
7
|
+
|
8
|
+
from aiohttp import ClientSession, ClientWebSocketResponse, ClientWSTimeout, WSMsgType
|
9
|
+
from .const import REXENSE_SWITCH_ONOFF
|
10
|
+
|
11
|
+
_LOGGER = logging.getLogger(__name__)
|
12
|
+
|
13
|
+
|
14
|
+
class RexenseWebsocketClient:
|
15
|
+
"""
|
16
|
+
Manages WebSocket connection to a Rexense device.
|
17
|
+
"""
|
18
|
+
|
19
|
+
def __init__(
|
20
|
+
self,
|
21
|
+
device_id: str,
|
22
|
+
model: str,
|
23
|
+
url: str,
|
24
|
+
sw_build_id: str,
|
25
|
+
feature_map: list[dict[str, Any]],
|
26
|
+
session: Optional[ClientSession] = None,
|
27
|
+
on_update: Optional[Callable[[], None]] = None,
|
28
|
+
) -> None:
|
29
|
+
"""Initialize the WebSocket client."""
|
30
|
+
self.device_id = device_id
|
31
|
+
self.model = model
|
32
|
+
self.sw_build_id = sw_build_id
|
33
|
+
self.feature_map = feature_map
|
34
|
+
self.url = url
|
35
|
+
self.ws: Optional[ClientWebSocketResponse] = None
|
36
|
+
self.connected: bool = False
|
37
|
+
self._running: bool = False
|
38
|
+
self._task: Optional[asyncio.Task] = None
|
39
|
+
self.last_values: dict[str, Any] = {}
|
40
|
+
self.switch_state: Optional[bool] = None
|
41
|
+
self.ping_interval: int = 30
|
42
|
+
|
43
|
+
self.on_update = on_update
|
44
|
+
self._session = session
|
45
|
+
self.signal_update = f"{device_id}_update"
|
46
|
+
|
47
|
+
async def connect(self) -> None:
|
48
|
+
"""Connect to the device and start listening."""
|
49
|
+
if self._running:
|
50
|
+
return
|
51
|
+
# Prepare session
|
52
|
+
if self._session is None:
|
53
|
+
self._session = ClientSession()
|
54
|
+
|
55
|
+
_LOGGER.debug("Attempting WebSocket connection to %s", self.url)
|
56
|
+
try:
|
57
|
+
ws = await self._session.ws_connect(
|
58
|
+
self.url,
|
59
|
+
timeout=ClientWSTimeout(ws_close=10),
|
60
|
+
heartbeat=self.ping_interval,
|
61
|
+
autoping=True,
|
62
|
+
)
|
63
|
+
except Exception as err:
|
64
|
+
_LOGGER.error(
|
65
|
+
"Initial WebSocket connect failed for %s: %s", self.device_id, err
|
66
|
+
)
|
67
|
+
self._running = False
|
68
|
+
raise
|
69
|
+
else:
|
70
|
+
self._running = True
|
71
|
+
self.ws = ws
|
72
|
+
self.connected = True
|
73
|
+
_LOGGER.info("WebSocket connected to device %s", self.device_id)
|
74
|
+
self._task = asyncio.create_task(self._run_loop())
|
75
|
+
|
76
|
+
async def _run_loop(self) -> None:
|
77
|
+
"""Run the WebSocket listen and auto-reconnect loop."""
|
78
|
+
first_try = True
|
79
|
+
while self._running:
|
80
|
+
try:
|
81
|
+
if not first_try:
|
82
|
+
_LOGGER.info("Reconnecting to device %s", self.device_id)
|
83
|
+
ws = await self._session.ws_connect(
|
84
|
+
self.url,
|
85
|
+
timeout=ClientWSTimeout(ws_close=10),
|
86
|
+
heartbeat=self.ping_interval,
|
87
|
+
autoping=True,
|
88
|
+
)
|
89
|
+
self.ws = ws
|
90
|
+
self.connected = True
|
91
|
+
_LOGGER.info("WebSocket reconnected to device %s", self.device_id)
|
92
|
+
else:
|
93
|
+
first_try = False
|
94
|
+
|
95
|
+
assert self.ws is not None
|
96
|
+
async for msg in self.ws:
|
97
|
+
if msg.type == WSMsgType.TEXT:
|
98
|
+
try:
|
99
|
+
data = msg.json()
|
100
|
+
except ValueError as e:
|
101
|
+
_LOGGER.error("Received invalid JSON: %s, data: %s", e, msg.data)
|
102
|
+
continue
|
103
|
+
_LOGGER.debug("Received message: %s", data)
|
104
|
+
self._handle_message(data)
|
105
|
+
elif msg.type == WSMsgType.ERROR:
|
106
|
+
assert self.ws is not None
|
107
|
+
_LOGGER.error(
|
108
|
+
"WebSocket error for %s: %s",
|
109
|
+
self.device_id,
|
110
|
+
self.ws.exception(),
|
111
|
+
)
|
112
|
+
break
|
113
|
+
elif msg.type in (WSMsgType.CLOSED, WSMsgType.CLOSING):
|
114
|
+
_LOGGER.warning(
|
115
|
+
"WebSocket connection closed for %s", self.device_id
|
116
|
+
)
|
117
|
+
break
|
118
|
+
except Exception as err:
|
119
|
+
_LOGGER.error(
|
120
|
+
"WebSocket connection failed for %s: %s", self.device_id, err
|
121
|
+
)
|
122
|
+
# Clean up and maybe reconnect
|
123
|
+
self.connected = False
|
124
|
+
if self.ws is not None:
|
125
|
+
try:
|
126
|
+
await self.ws.close()
|
127
|
+
except Exception:
|
128
|
+
pass
|
129
|
+
finally:
|
130
|
+
self.ws = None
|
131
|
+
|
132
|
+
if self._running:
|
133
|
+
await asyncio.sleep(5)
|
134
|
+
continue
|
135
|
+
|
136
|
+
def _handle_message(self, data: dict[str, Any]) -> None:
|
137
|
+
"""Process incoming message from WebSocket."""
|
138
|
+
func = (data.get("FunctionCode") or data.get("function") or data.get("func"))
|
139
|
+
if isinstance(func, str):
|
140
|
+
func = func.lower()
|
141
|
+
|
142
|
+
if func == "notifystatus":
|
143
|
+
payload = data.get("Payload") or {}
|
144
|
+
_LOGGER.debug("Received payload: %s", payload)
|
145
|
+
for k, v in payload.items():
|
146
|
+
key = k.replace("_1", "")
|
147
|
+
if key == REXENSE_SWITCH_ONOFF['name']:
|
148
|
+
self.switch_state = v not in ("0", False)
|
149
|
+
_LOGGER.debug("Update switch state: %s", self.switch_state)
|
150
|
+
else:
|
151
|
+
_LOGGER.debug("Update sensor %s: %s", key, v)
|
152
|
+
self.last_values[key] = v
|
153
|
+
# Trigger update callback
|
154
|
+
if self.on_update:
|
155
|
+
try:
|
156
|
+
self.on_update()
|
157
|
+
except Exception as e:
|
158
|
+
_LOGGER.error("Error in on_update callback: %s", e)
|
159
|
+
else:
|
160
|
+
_LOGGER.debug("Unhandled function %s: %s", func, data)
|
161
|
+
|
162
|
+
async def async_set_switch(self, on: bool) -> None:
|
163
|
+
"""Send ON/OFF command to device via WebSocket."""
|
164
|
+
if not self.connected or self.ws is None:
|
165
|
+
raise RuntimeError("WebSocket is not connected.")
|
166
|
+
control = "On" if on else "Off"
|
167
|
+
payload = {
|
168
|
+
"FunctionCode": "InvokeCmd",
|
169
|
+
"Payload": {control: {}},
|
170
|
+
}
|
171
|
+
try:
|
172
|
+
await self.ws.send_json(payload)
|
173
|
+
except Exception as err:
|
174
|
+
_LOGGER.error("Failed to send switch command: %s", err)
|
175
|
+
raise
|
176
|
+
|
177
|
+
async def disconnect(self) -> None:
|
178
|
+
"""Disconnect and stop the WebSocket client."""
|
179
|
+
_LOGGER.info("Disconnecting WebSocket for device %s", self.device_id)
|
180
|
+
self._running = False
|
181
|
+
if self.ws is not None:
|
182
|
+
await self.ws.close()
|
183
|
+
if self._task:
|
184
|
+
await self._task
|
185
|
+
self.ws = None
|
186
|
+
self.connected = False
|
187
|
+
|
188
|
+
async def disconnect(self) -> None:
|
189
|
+
"""Disconnect and stop the WebSocket client."""
|
190
|
+
_LOGGER.info("Disconnecting WebSocket for device %s", self.device_id)
|
191
|
+
self._running = False
|
192
|
+
if self.ws is not None:
|
193
|
+
await self.ws.close()
|
194
|
+
if self._task:
|
195
|
+
await self._task
|
196
|
+
self.ws = None
|
197
|
+
self.connected = False
|
@@ -0,0 +1,53 @@
|
|
1
|
+
|
2
|
+
import pytest
|
3
|
+
from aiohttp import ClientError
|
4
|
+
import aiorexense.api as api
|
5
|
+
|
6
|
+
class DummyResponse:
|
7
|
+
def __init__(self, status, data):
|
8
|
+
self.status = status
|
9
|
+
self._data = data
|
10
|
+
|
11
|
+
async def json(self):
|
12
|
+
return self._data
|
13
|
+
|
14
|
+
class DummySession:
|
15
|
+
def __init__(self, response):
|
16
|
+
self._response = response
|
17
|
+
|
18
|
+
async def get(self, url, json):
|
19
|
+
return self._response
|
20
|
+
|
21
|
+
@pytest.mark.asyncio
|
22
|
+
async def test_get_basic_info_success():
|
23
|
+
data = {
|
24
|
+
"FunctionCode": "ReportBasicInfo",
|
25
|
+
"DeviceId": "dev123",
|
26
|
+
"Payload": {
|
27
|
+
"ModelId": "modelX",
|
28
|
+
"SwBuildId": "build1",
|
29
|
+
"FeatureMap": [{"feat":1}],
|
30
|
+
}
|
31
|
+
}
|
32
|
+
resp = DummyResponse(200, data)
|
33
|
+
session = DummySession(resp)
|
34
|
+
device_id, model, sw_build_id, feature_map = await api.get_basic_info("host", 80, session)
|
35
|
+
assert device_id == "dev123"
|
36
|
+
assert model == "modelX"
|
37
|
+
assert sw_build_id == "build1"
|
38
|
+
assert feature_map == [{"feat":1}]
|
39
|
+
|
40
|
+
@pytest.mark.asyncio
|
41
|
+
async def test_get_basic_info_http_error():
|
42
|
+
resp = DummyResponse(404, {})
|
43
|
+
session = DummySession(resp)
|
44
|
+
with pytest.raises(ClientError):
|
45
|
+
await api.get_basic_info("host", 80, session)
|
46
|
+
|
47
|
+
@pytest.mark.asyncio
|
48
|
+
async def test_get_basic_info_invalid_format():
|
49
|
+
data = {"FunctionCode": "WrongCode", "Payload": {}}
|
50
|
+
resp = DummyResponse(200, data)
|
51
|
+
session = DummySession(resp)
|
52
|
+
with pytest.raises(ClientError):
|
53
|
+
await api.get_basic_info("host", 80, session)
|
@@ -0,0 +1,13 @@
|
|
1
|
+
|
2
|
+
import aiorexense.const as const
|
3
|
+
|
4
|
+
def test_constants():
|
5
|
+
assert const.DEFAULT_PORT == 80
|
6
|
+
assert const.API_VERSION == "1.0"
|
7
|
+
assert const.VENDOR_CODE == "Rexense"
|
8
|
+
assert const.FUNCTION_GET_BASIC_INFO == "GetBasicInfo"
|
9
|
+
assert isinstance(const.REXENSE_SENSOR_CURRENT, dict)
|
10
|
+
# test some sensor fields
|
11
|
+
assert const.REXENSE_SENSOR_CURRENT["name"] == "Current"
|
12
|
+
assert const.REXENSE_SENSOR_TEMPERATURE["unit"] == "°C"
|
13
|
+
assert const.REXENSE_SWITCH_ONOFF["name"] == "PowerSwitch"
|
@@ -0,0 +1,85 @@
|
|
1
|
+
|
2
|
+
import pytest
|
3
|
+
import asyncio
|
4
|
+
from aiorexense.ws_client import RexenseWebsocketClient
|
5
|
+
|
6
|
+
from aiorexense.const import REXENSE_SWITCH_ONOFF
|
7
|
+
|
8
|
+
def test_handle_message_notify_status():
|
9
|
+
updates = []
|
10
|
+
def on_update():
|
11
|
+
updates.append(True)
|
12
|
+
|
13
|
+
client = RexenseWebsocketClient(
|
14
|
+
device_id="dev123",
|
15
|
+
model="modelX",
|
16
|
+
url="ws://example",
|
17
|
+
sw_build_id="build1",
|
18
|
+
feature_map=[],
|
19
|
+
on_update=on_update,
|
20
|
+
)
|
21
|
+
# simulate payload
|
22
|
+
payload = {
|
23
|
+
REXENSE_SWITCH_ONOFF['name']: "1",
|
24
|
+
"TestSensor": 123,
|
25
|
+
}
|
26
|
+
data = {"FunctionCode": "NotifyStatus", "Payload": payload}
|
27
|
+
client._handle_message(data)
|
28
|
+
|
29
|
+
assert client.switch_state is True
|
30
|
+
assert client.last_values.get("TestSensor") == 123
|
31
|
+
assert updates == [True]
|
32
|
+
|
33
|
+
def test_handle_message_unhandled(caplog):
|
34
|
+
client = RexenseWebsocketClient("dev", "model", "url", "sw", [])
|
35
|
+
caplog.set_level("DEBUG")
|
36
|
+
client._handle_message({"FunctionCode": "UnknownFunc", "Payload": {}})
|
37
|
+
assert "Unhandled function" in caplog.text
|
38
|
+
|
39
|
+
@pytest.mark.asyncio
|
40
|
+
async def test_async_set_switch_not_connected():
|
41
|
+
client = RexenseWebsocketClient("dev", "model", "url", "sw", [])
|
42
|
+
client.connected = False
|
43
|
+
client.ws = None
|
44
|
+
with pytest.raises(RuntimeError):
|
45
|
+
await client.async_set_switch(True)
|
46
|
+
|
47
|
+
@pytest.mark.asyncio
|
48
|
+
async def test_async_set_switch_success():
|
49
|
+
sent = []
|
50
|
+
class DummyWS:
|
51
|
+
async def send_json(self, payload):
|
52
|
+
sent.append(payload)
|
53
|
+
|
54
|
+
client = RexenseWebsocketClient("dev", "model", "url", "sw", [])
|
55
|
+
client.connected = True
|
56
|
+
client.ws = DummyWS()
|
57
|
+
|
58
|
+
await client.async_set_switch(False)
|
59
|
+
assert sent[-1] == {"FunctionCode": "InvokeCmd", "Payload": {"Off": {}}}
|
60
|
+
|
61
|
+
await client.async_set_switch(True)
|
62
|
+
assert sent[-1] == {"FunctionCode": "InvokeCmd", "Payload": {"On": {}}}
|
63
|
+
|
64
|
+
@pytest.mark.asyncio
|
65
|
+
async def test_disconnect(monkeypatch):
|
66
|
+
client = RexenseWebsocketClient("dev", "model", "url", "sw", [])
|
67
|
+
# Attach dummy ws and task
|
68
|
+
class DummyWS:
|
69
|
+
async def close(self):
|
70
|
+
pass
|
71
|
+
|
72
|
+
client.ws = DummyWS()
|
73
|
+
client.connected = True
|
74
|
+
client._running = True
|
75
|
+
|
76
|
+
# Create and cancel a dummy task
|
77
|
+
async def dummy_task():
|
78
|
+
await asyncio.sleep(0)
|
79
|
+
task = asyncio.create_task(dummy_task())
|
80
|
+
client._task = task
|
81
|
+
|
82
|
+
await client.disconnect()
|
83
|
+
|
84
|
+
assert client.ws is None
|
85
|
+
assert client.connected is False
|