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 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
+ ]