aiopapouch 0.2.4__py3-none-any.whl
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.
- aiopapouch/__init__.py +22 -0
- aiopapouch/client.py +268 -0
- aiopapouch/devices/__init__.py +119 -0
- aiopapouch/devices/base.py +333 -0
- aiopapouch/devices/papago.py +1018 -0
- aiopapouch/devices/quido.py +810 -0
- aiopapouch/devices/th2e.py +408 -0
- aiopapouch/devices/tht2.py +253 -0
- aiopapouch/devices/tme.py +432 -0
- aiopapouch/exceptions.py +25 -0
- aiopapouch/py.typed +0 -0
- aiopapouch/utils.py +24 -0
- aiopapouch-0.2.4.dist-info/METADATA +254 -0
- aiopapouch-0.2.4.dist-info/RECORD +16 -0
- aiopapouch-0.2.4.dist-info/WHEEL +4 -0
- aiopapouch-0.2.4.dist-info/licenses/LICENSE +21 -0
aiopapouch/__init__.py
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"""This file is used as a hub for imports."""
|
|
2
|
+
|
|
3
|
+
from .client import PapouchHTTPClient, PapouchSerialClient
|
|
4
|
+
from .devices import (
|
|
5
|
+
PapouchDevice,
|
|
6
|
+
create_network_device,
|
|
7
|
+
create_serial_device,
|
|
8
|
+
is_device_supported,
|
|
9
|
+
)
|
|
10
|
+
from .utils import parse_device_location, parse_device_name, parse_device_serial_number
|
|
11
|
+
|
|
12
|
+
__all__ = [
|
|
13
|
+
"PapouchDevice",
|
|
14
|
+
"PapouchHTTPClient",
|
|
15
|
+
"PapouchSerialClient",
|
|
16
|
+
"create_network_device",
|
|
17
|
+
"create_serial_device",
|
|
18
|
+
"is_device_supported",
|
|
19
|
+
"parse_device_location",
|
|
20
|
+
"parse_device_name",
|
|
21
|
+
"parse_device_serial_number",
|
|
22
|
+
]
|
aiopapouch/client.py
ADDED
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
"""This file is used for communicating with the device."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import base64
|
|
5
|
+
import logging
|
|
6
|
+
import re
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
import aiohttp
|
|
10
|
+
import defusedxml.ElementTree as defused_ET
|
|
11
|
+
|
|
12
|
+
from pap_spinel import Packet97, SpinelClient, SpinelError
|
|
13
|
+
|
|
14
|
+
from .exceptions import DeviceAuthError, DeviceConnectionError, DeviceLogicError
|
|
15
|
+
|
|
16
|
+
INFO_URL = "is.xml"
|
|
17
|
+
DATA_URL = "fresh.xml"
|
|
18
|
+
SETTINGS_URL = "settings.xml"
|
|
19
|
+
SET_URL = "set.xml"
|
|
20
|
+
SAVE_URL = "save.xml"
|
|
21
|
+
|
|
22
|
+
ENCODING = "iso-8859-2"
|
|
23
|
+
WEB_MODE_INDEX = 3
|
|
24
|
+
TIMEOUT_REQUEST = 10
|
|
25
|
+
DEFAULT_WEB_PORT = 80
|
|
26
|
+
|
|
27
|
+
_LOGGER = logging.getLogger(__name__)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class PapouchHTTPClient:
|
|
31
|
+
"""API client for communicating with a device via network."""
|
|
32
|
+
|
|
33
|
+
def __init__(
|
|
34
|
+
self,
|
|
35
|
+
ip_address: str,
|
|
36
|
+
session: aiohttp.ClientSession,
|
|
37
|
+
password: str = "",
|
|
38
|
+
web_port: int = DEFAULT_WEB_PORT,
|
|
39
|
+
) -> None:
|
|
40
|
+
"""Constructor for API client."""
|
|
41
|
+
self.base_url = f"http://{ip_address}:{web_port}/"
|
|
42
|
+
self.session = session
|
|
43
|
+
self.ip_address = ip_address
|
|
44
|
+
self.web_port = web_port
|
|
45
|
+
|
|
46
|
+
auth_string = f"admin:{password}" if password != "" else ":"
|
|
47
|
+
b64_auth = base64.b64encode(auth_string.encode("utf-8")).decode("ascii")
|
|
48
|
+
self._auth_headers = {"Authorization": f"Basic {b64_auth}"}
|
|
49
|
+
|
|
50
|
+
async def _fetch(self, endpoint: str) -> str:
|
|
51
|
+
raw_xml = await self._send_request("GET", endpoint, context=endpoint)
|
|
52
|
+
return re.sub(r'\s+xmlns="[^"]+"', "", raw_xml)
|
|
53
|
+
|
|
54
|
+
async def fetch_info(self) -> str:
|
|
55
|
+
"""Fetching information about a device."""
|
|
56
|
+
return await self._fetch(INFO_URL)
|
|
57
|
+
|
|
58
|
+
async def fetch_data(self) -> str:
|
|
59
|
+
"""Fetching data about a device."""
|
|
60
|
+
return await self._fetch(DATA_URL)
|
|
61
|
+
|
|
62
|
+
async def fetch_settings(self) -> str:
|
|
63
|
+
"""Fetching settings about a device."""
|
|
64
|
+
return await self._fetch(SETTINGS_URL)
|
|
65
|
+
|
|
66
|
+
async def get_device_info(self) -> tuple[str | None, str | None]:
|
|
67
|
+
info = await self.fetch_info()
|
|
68
|
+
|
|
69
|
+
try:
|
|
70
|
+
root = defused_ET.fromstring(info)
|
|
71
|
+
except defused_ET.ParseError:
|
|
72
|
+
return (None, None)
|
|
73
|
+
|
|
74
|
+
heartbeat = None
|
|
75
|
+
for element in root.iter():
|
|
76
|
+
if element.tag.endswith("heartbeat"):
|
|
77
|
+
heartbeat = element
|
|
78
|
+
break
|
|
79
|
+
|
|
80
|
+
if heartbeat is None:
|
|
81
|
+
return (None, None)
|
|
82
|
+
|
|
83
|
+
device_name = heartbeat.attrib.get("device")
|
|
84
|
+
device_location = heartbeat.attrib.get("location")
|
|
85
|
+
|
|
86
|
+
return (device_name, device_location)
|
|
87
|
+
|
|
88
|
+
async def get_device_mac(self) -> str:
|
|
89
|
+
settings = await self.fetch_settings()
|
|
90
|
+
root = defused_ET.fromstring(settings)
|
|
91
|
+
box = root.find(".//set[@box='12']")
|
|
92
|
+
|
|
93
|
+
if box is not None:
|
|
94
|
+
return str(box.attrib.get("mac", ""))
|
|
95
|
+
|
|
96
|
+
raise DeviceLogicError(
|
|
97
|
+
f"Device: {self.ip_address} doesn't have a box 12 with MAC address"
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
async def _send_request(
|
|
101
|
+
self, method: str, endpoint: str, context: str, **kwargs: Any
|
|
102
|
+
) -> str:
|
|
103
|
+
|
|
104
|
+
timeout = aiohttp.ClientTimeout(total=TIMEOUT_REQUEST)
|
|
105
|
+
|
|
106
|
+
headers = kwargs.get("headers", {})
|
|
107
|
+
if self._auth_headers:
|
|
108
|
+
headers.update(self._auth_headers)
|
|
109
|
+
kwargs["headers"] = headers
|
|
110
|
+
|
|
111
|
+
try:
|
|
112
|
+
async with self.session.request(
|
|
113
|
+
method,
|
|
114
|
+
self.base_url + endpoint,
|
|
115
|
+
timeout=timeout,
|
|
116
|
+
**kwargs,
|
|
117
|
+
) as response:
|
|
118
|
+
if response.status != 200:
|
|
119
|
+
if response.status == 401:
|
|
120
|
+
raise DeviceAuthError("Invalid password")
|
|
121
|
+
|
|
122
|
+
raise DeviceConnectionError(
|
|
123
|
+
f"Failed to send command: {response.status}"
|
|
124
|
+
)
|
|
125
|
+
return await response.text(encoding=ENCODING)
|
|
126
|
+
|
|
127
|
+
except (aiohttp.ClientError, TimeoutError) as exception:
|
|
128
|
+
raise DeviceConnectionError(
|
|
129
|
+
f"Failed to connect to {context} - {self.ip_address}: {exception}"
|
|
130
|
+
) from exception
|
|
131
|
+
|
|
132
|
+
async def read_command(
|
|
133
|
+
self, params: dict, context: str, endpoint: str = SET_URL
|
|
134
|
+
) -> str:
|
|
135
|
+
"""Command for communicating with any device by using GET request.
|
|
136
|
+
|
|
137
|
+
Parameters are GET queries that will be added to the request.
|
|
138
|
+
"""
|
|
139
|
+
|
|
140
|
+
return await self._send_request("GET", endpoint, context, params=params)
|
|
141
|
+
|
|
142
|
+
async def write_command(
|
|
143
|
+
self, payload: str, context: str, endpoint: str = SAVE_URL
|
|
144
|
+
) -> str:
|
|
145
|
+
"""Command for communicating with any device by using POST request.
|
|
146
|
+
|
|
147
|
+
Data contains the POST payload that will be sent in the request body.
|
|
148
|
+
Context is a information about the device:
|
|
149
|
+
|
|
150
|
+
e.g. f"{self.name} ({self.location})"
|
|
151
|
+
|
|
152
|
+
Return response text.
|
|
153
|
+
"""
|
|
154
|
+
|
|
155
|
+
return await self._send_request("POST", endpoint, context, data=payload)
|
|
156
|
+
|
|
157
|
+
async def get_device_mode(self) -> int:
|
|
158
|
+
"""Function is used for the resolving the mode of the device."""
|
|
159
|
+
info_xml = await self.fetch_info()
|
|
160
|
+
root = defused_ET.fromstring(info_xml)
|
|
161
|
+
|
|
162
|
+
heartbeat_tag = None
|
|
163
|
+
for element in root.iter():
|
|
164
|
+
if element.tag.endswith("heartbeat"):
|
|
165
|
+
heartbeat_tag = element
|
|
166
|
+
break
|
|
167
|
+
|
|
168
|
+
if heartbeat_tag is not None:
|
|
169
|
+
mode = heartbeat_tag.attrib.get("mode")
|
|
170
|
+
if mode is not None:
|
|
171
|
+
return int(mode)
|
|
172
|
+
|
|
173
|
+
device_name = heartbeat_tag.attrib.get("device")
|
|
174
|
+
if self._check_exceptions_device_web_mode(device_name):
|
|
175
|
+
return WEB_MODE_INDEX
|
|
176
|
+
|
|
177
|
+
_LOGGER.error("Heartbeat tag found, but 'mode' attribute is missing!")
|
|
178
|
+
return -1
|
|
179
|
+
|
|
180
|
+
_LOGGER.error("Response doesn't have heartbeat tag!")
|
|
181
|
+
return -1
|
|
182
|
+
|
|
183
|
+
def _check_exceptions_device_web_mode(self, device_name: str) -> bool:
|
|
184
|
+
if device_name == "TME":
|
|
185
|
+
return True
|
|
186
|
+
return "Papago" in device_name and "ETH" in device_name
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
class PapouchSerialClient:
|
|
190
|
+
"""API client for communicating with a device via RS485."""
|
|
191
|
+
|
|
192
|
+
def __init__(self, spinel_client: SpinelClient) -> None:
|
|
193
|
+
"""Constructor for serial API client."""
|
|
194
|
+
self._spinel_client = spinel_client
|
|
195
|
+
self.lock = asyncio.Lock()
|
|
196
|
+
|
|
197
|
+
async def open(self) -> None:
|
|
198
|
+
"""Open port."""
|
|
199
|
+
await self._spinel_client.open()
|
|
200
|
+
|
|
201
|
+
async def close(self) -> None:
|
|
202
|
+
"""Close port."""
|
|
203
|
+
await self._spinel_client.close()
|
|
204
|
+
|
|
205
|
+
async def write_command(
|
|
206
|
+
self,
|
|
207
|
+
addr: int,
|
|
208
|
+
inst: int,
|
|
209
|
+
context: str,
|
|
210
|
+
data: bytes = b"",
|
|
211
|
+
timeout: float = 2.0,
|
|
212
|
+
) -> Packet97:
|
|
213
|
+
"""Write command. Return Spinel97 Packet"""
|
|
214
|
+
async with self.lock:
|
|
215
|
+
try:
|
|
216
|
+
return await self._spinel_client.request(
|
|
217
|
+
addr=addr, inst=inst, data=data, timeout=timeout
|
|
218
|
+
)
|
|
219
|
+
except SpinelError as err:
|
|
220
|
+
raise DeviceConnectionError(
|
|
221
|
+
f"Device: {context} returned: {err}"
|
|
222
|
+
) from err
|
|
223
|
+
|
|
224
|
+
async def get_info(self, address: int, context: str) -> Packet97:
|
|
225
|
+
"""Get info in Spinel97 packet. Context is used for error message."""
|
|
226
|
+
try:
|
|
227
|
+
return await self._spinel_client.info(address)
|
|
228
|
+
except SpinelError as err:
|
|
229
|
+
raise DeviceConnectionError(f"Device: {context} returned: {err}") from err
|
|
230
|
+
|
|
231
|
+
async def get_man_data(self, address: int, context: str) -> Packet97:
|
|
232
|
+
"""Get manufacturing data in Spinel97 packet. Context is used for error message."""
|
|
233
|
+
try:
|
|
234
|
+
return await self._spinel_client.man_data(address)
|
|
235
|
+
except SpinelError as err:
|
|
236
|
+
raise DeviceConnectionError(f"Device: {context} returned: {err}") from err
|
|
237
|
+
|
|
238
|
+
async def get_location(self, address: int, context: str) -> Packet97:
|
|
239
|
+
"""Get location in Spinel97 packet. Context is used for error message."""
|
|
240
|
+
try:
|
|
241
|
+
return await self._spinel_client.user_data(address)
|
|
242
|
+
except SpinelError as err:
|
|
243
|
+
raise DeviceConnectionError(f"Device: {context} returned: {err}") from err
|
|
244
|
+
|
|
245
|
+
async def set_address(
|
|
246
|
+
self, new_address: int, serial_number: str, context: str
|
|
247
|
+
) -> None:
|
|
248
|
+
"""Set a new address using serial number. Serial number should have format 0123/45678. Context is used for error message."""
|
|
249
|
+
|
|
250
|
+
request_data = new_address.to_bytes(1, byteorder="big")
|
|
251
|
+
|
|
252
|
+
prod_part, ser_part = serial_number.split("/")
|
|
253
|
+
|
|
254
|
+
product_number = int(prod_part)
|
|
255
|
+
serial_number = int(ser_part)
|
|
256
|
+
|
|
257
|
+
prod_bytes = product_number.to_bytes(2, byteorder="big")
|
|
258
|
+
ser_bytes = serial_number.to_bytes(2, byteorder="big")
|
|
259
|
+
|
|
260
|
+
request_data += prod_bytes
|
|
261
|
+
request_data += ser_bytes
|
|
262
|
+
|
|
263
|
+
try:
|
|
264
|
+
await self._spinel_client.request(addr=0xFE, inst=0xEB, data=request_data)
|
|
265
|
+
except SpinelError as err:
|
|
266
|
+
raise DeviceConnectionError(
|
|
267
|
+
f"Failed setting a new address to the device: {context} "
|
|
268
|
+
) from err
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
"""This file is used as a hub for imports."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
from collections.abc import Callable
|
|
5
|
+
from typing import NamedTuple
|
|
6
|
+
|
|
7
|
+
from ..client import PapouchHTTPClient, PapouchSerialClient
|
|
8
|
+
from ..utils import parse_device_location, parse_device_name, parse_device_serial_number
|
|
9
|
+
from .base import PapouchDevice
|
|
10
|
+
from .papago import async_setup_network_papago
|
|
11
|
+
from .quido import async_setup_network_quido, async_setup_serial_quido
|
|
12
|
+
from .th2e import async_setup_network_th2e
|
|
13
|
+
from .tht2 import async_setup_serial_tht2
|
|
14
|
+
from .tme import async_setup_network_tme
|
|
15
|
+
|
|
16
|
+
SERIAL = "serial"
|
|
17
|
+
NETWORK = "network"
|
|
18
|
+
|
|
19
|
+
_LOGGER = logging.getLogger()
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class DeviceHandler(NamedTuple):
|
|
23
|
+
"""Represents device handler containing a dictionary of setup functions keyed by connection type."""
|
|
24
|
+
|
|
25
|
+
setup_funcs: dict[str, Callable]
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
DEVICE_SETUP_HANDLERS = {
|
|
29
|
+
"Quido": DeviceHandler({
|
|
30
|
+
NETWORK: async_setup_network_quido,
|
|
31
|
+
SERIAL: async_setup_serial_quido,
|
|
32
|
+
}),
|
|
33
|
+
"TH2E": DeviceHandler({
|
|
34
|
+
NETWORK: async_setup_network_th2e,
|
|
35
|
+
}),
|
|
36
|
+
"TME": DeviceHandler({
|
|
37
|
+
NETWORK: async_setup_network_tme,
|
|
38
|
+
}),
|
|
39
|
+
"Papago": DeviceHandler({
|
|
40
|
+
NETWORK: async_setup_network_papago,
|
|
41
|
+
}),
|
|
42
|
+
"THT2": DeviceHandler({
|
|
43
|
+
SERIAL: async_setup_serial_tht2,
|
|
44
|
+
}),
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _get_device_handler(
|
|
49
|
+
device_name: str | None, device_type: str
|
|
50
|
+
) -> DeviceHandler | None:
|
|
51
|
+
if not device_name:
|
|
52
|
+
return None
|
|
53
|
+
|
|
54
|
+
for prefix, handler in DEVICE_SETUP_HANDLERS.items():
|
|
55
|
+
if prefix in device_name and device_type in handler.setup_funcs:
|
|
56
|
+
return handler
|
|
57
|
+
|
|
58
|
+
return None
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def is_device_supported(device_name: str | None, device_type: str) -> bool:
|
|
62
|
+
"""Check if the extracted device name matches any supported prefix and type of the communication."""
|
|
63
|
+
return _get_device_handler(device_name, device_type) is not None
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
async def create_network_device(api_client: PapouchHTTPClient) -> PapouchDevice | None:
|
|
67
|
+
"""Create a proper device instance dynamically based on the fetched info.
|
|
68
|
+
|
|
69
|
+
Returns None if the device is not supported.
|
|
70
|
+
"""
|
|
71
|
+
|
|
72
|
+
device_name, _ = await api_client.get_device_info()
|
|
73
|
+
|
|
74
|
+
handler = _get_device_handler(device_name, NETWORK)
|
|
75
|
+
if not handler:
|
|
76
|
+
return None
|
|
77
|
+
|
|
78
|
+
setup_func = handler.setup_funcs[NETWORK]
|
|
79
|
+
return await setup_func(api_client)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
async def create_serial_device(
|
|
83
|
+
api_client: PapouchSerialClient, address: int
|
|
84
|
+
) -> PapouchDevice | None:
|
|
85
|
+
"""Create a proper serial device instance dynamically based on the fetched info.
|
|
86
|
+
|
|
87
|
+
Returns None if the device is not supported.
|
|
88
|
+
|
|
89
|
+
Raises DeviceConnectionError.
|
|
90
|
+
"""
|
|
91
|
+
|
|
92
|
+
pkt_man_data = await api_client.get_man_data(
|
|
93
|
+
address, f"Unknown device with {address} address"
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
serial_number = parse_device_serial_number(pkt_man_data.data)
|
|
97
|
+
|
|
98
|
+
pkt_info = await api_client.get_info(address, serial_number)
|
|
99
|
+
device_name = parse_device_name(pkt_info.data)
|
|
100
|
+
|
|
101
|
+
raw_location = await api_client.get_location(
|
|
102
|
+
address, f"{device_name} - SN: {serial_number}"
|
|
103
|
+
)
|
|
104
|
+
location = parse_device_location(raw_location.data)
|
|
105
|
+
|
|
106
|
+
handler = _get_device_handler(device_name, SERIAL)
|
|
107
|
+
if not handler:
|
|
108
|
+
return None
|
|
109
|
+
|
|
110
|
+
setup_func = handler.setup_funcs[SERIAL]
|
|
111
|
+
return await setup_func(api_client, address, serial_number, device_name, location)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
__all__ = [
|
|
115
|
+
"PapouchDevice",
|
|
116
|
+
"create_network_device",
|
|
117
|
+
"create_serial_device",
|
|
118
|
+
"is_device_supported",
|
|
119
|
+
]
|