adar-api 1.1.1__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.
adar_api/__init__.py ADDED
@@ -0,0 +1,27 @@
1
+ from .adar import Adar
2
+ from .coap_exceptions import CoapException, CoapErrorException
3
+ from .coap_observer import CoapObserver
4
+ from .coap_pointcloud import CoapPointCloud, Point, PointClassification
5
+ from .device_info import DeviceInfo
6
+ from .device_status import DeviceStatus, DeviceState, ZoneStatus
7
+ from .duration import Duration
8
+ from .network_config import NetworkConfig
9
+ from .statistics import Statistics
10
+
11
+ __all__ = [
12
+ "DeviceStatus",
13
+ "DeviceState",
14
+ "ZoneStatus",
15
+ "CoapPointCloud",
16
+ "CoapObserver",
17
+ "CoapException",
18
+ "CoapErrorException",
19
+ "Point",
20
+ "PointClassification",
21
+ "NetworkConfig",
22
+ "Adar",
23
+ "DeviceInfo",
24
+ "Duration",
25
+ "Statistics",
26
+ "coap_resources",
27
+ ]
adar_api/adar.py ADDED
@@ -0,0 +1,286 @@
1
+ import logging
2
+ from typing import AsyncGenerator
3
+ import struct
4
+ import math
5
+
6
+ from aiocoap import Context, Message, GET, PUT, DELETE
7
+
8
+ from .coap_exceptions import CoapErrorException, CoapException
9
+ from .coap_pointcloud import CoapPointCloud
10
+ from .device_errors import DeviceErrors
11
+ from .device_info import DeviceInfo
12
+ from .device_status import DeviceStatus, DeviceState
13
+ from .network_config import NetworkConfig
14
+ from .statistics import Statistics
15
+ from .coap_resources import (
16
+ NETWORK_CONFIG_V0,
17
+ FACTORY_RESET_V0,
18
+ ERRORS_V0,
19
+ DEVICE_INFO_V0,
20
+ STATISTICS_V0,
21
+ OBSERVERS_V0,
22
+ POINTCLOUD_V0,
23
+ STATUS_V0,
24
+ TRANSMISSION_CODE_V0,
25
+ )
26
+
27
+
28
+ class Adar:
29
+ """A class representing the Adar sensor."""
30
+
31
+ def __init__(self, ctx: Context, ip_address: str | None, device_tag: str | None = None):
32
+ """Initialize an ADAR device connection.
33
+
34
+ Args:
35
+ ctx: The CoAP context for communication
36
+ ip_address: The IP address of the ADAR
37
+ device_tag: Optional device tag for identification (defaults to "Adar")
38
+ """
39
+ self.device_tag = device_tag or "Adar"
40
+ self.ip_address = ip_address
41
+ self.ctx = ctx
42
+ # Reduce COAP Log details:
43
+ ctx.log.setLevel(logging.INFO)
44
+ self.logger = logging.getLogger(device_tag)
45
+
46
+ async def observe_point_cloud(
47
+ self, keep_running: bool = False, msg_count: int | None = None
48
+ ) -> AsyncGenerator[CoapPointCloud, None]:
49
+ """Observe the ADAR Point cloud.
50
+
51
+ Args:
52
+ keep_running: If True, the observer will ignore errors and attempt to automatically reconnect.
53
+ msg_count: If not None, the observer will be stopped once the requested number of messages are received.
54
+
55
+ Yields:
56
+ CoapPointCloud: Decoded point cloud instances.
57
+ """
58
+ # Need to import here in order to avoid circular dependency
59
+ from .coap_observer import CoapObserver
60
+
61
+ async with CoapObserver(self, POINTCLOUD_V0) as observer:
62
+ async for response in observer.messages(keep_running, msg_count):
63
+ try:
64
+ point_cloud = CoapPointCloud(response)
65
+ except Exception as e:
66
+ if keep_running:
67
+ self.logger.warning(
68
+ "Failed to decode point cloud: %s. Ignoring because keep_running is True",
69
+ e,
70
+ )
71
+ continue
72
+ raise
73
+ yield point_cloud
74
+
75
+ async def get_point_cloud(self) -> CoapPointCloud:
76
+ """Get one point cloud.
77
+
78
+ Returns:
79
+ CoapPointCloud: A single point cloud from the device.
80
+
81
+ Raises:
82
+ CoapException: If no response is received from the point cloud observer.
83
+ """
84
+ async for response in self.observe_point_cloud(msg_count=1):
85
+ return response
86
+
87
+ msg = "No response from point cloud info observer"
88
+ raise CoapException(msg)
89
+
90
+ async def get_network_config(self) -> NetworkConfig:
91
+ """Read the network config.
92
+
93
+ Returns:
94
+ NetworkConfig: The current network configuration of the device.
95
+
96
+ Raises:
97
+ ValueError: If the response payload cannot be decoded into NetworkConfig.
98
+ struct.error: If there's an error in the binary data structure.
99
+ """
100
+ uri = f"coap://{self.ip_address}{NETWORK_CONFIG_V0}"
101
+ request = Message(code=GET, uri=uri)
102
+ response = await self.send_request(request)
103
+ try:
104
+ return NetworkConfig(data=response.payload)
105
+ except (ValueError, struct.error) as e:
106
+ self.logger.exception(f"Failed to decode {response.payload} into NetworkConfig: {e}")
107
+ raise
108
+
109
+ async def set_network_config(self, network_config: NetworkConfig) -> None:
110
+ """Set the network config.
111
+
112
+ Args:
113
+ network_config: The network configuration to apply to the device.
114
+
115
+ Note:
116
+ The device will reboot to apply the new network configuration.
117
+ """
118
+ uri = f"coap://{self.ip_address}{NETWORK_CONFIG_V0}"
119
+ request = Message(code=PUT, uri=uri, payload=network_config.encode())
120
+ await self.send_request(request)
121
+ self.logger.warning("The device will now reboot to apply the new network config!!!")
122
+
123
+ async def factory_reset(self) -> None:
124
+ """Send factory reset command.
125
+
126
+ Note:
127
+ The device will reboot to apply factory settings.
128
+ """
129
+ uri = f"coap://{self.ip_address}{FACTORY_RESET_V0}"
130
+ request = Message(code=PUT, uri=uri, payload="")
131
+ await self.send_request(request)
132
+ self.logger.warning("The device will now reboot to apply factory settings!!!")
133
+
134
+ async def get_device_errors(self) -> DeviceErrors:
135
+ """Read the device errors.
136
+
137
+ Returns:
138
+ DeviceErrors: The current device error information.
139
+
140
+ Raises:
141
+ ValueError: If the response payload cannot be decoded into DeviceErrors.
142
+ struct.error: If there's an error in the binary data structure.
143
+ """
144
+ uri = f"coap://{self.ip_address}{ERRORS_V0}"
145
+ request = Message(code=GET, uri=uri)
146
+ response = await self.send_request(request)
147
+ try:
148
+ return DeviceErrors(data=response.payload)
149
+ except (ValueError, struct.error) as e:
150
+ self.logger.exception(f"Failed to decode {response.payload} into DeviceErrors: {e}")
151
+ raise
152
+
153
+ async def get_device_info(self) -> DeviceInfo:
154
+ """Read the device info.
155
+
156
+ Returns:
157
+ DeviceInfo: Information about the device including identification, name, and firmware version.
158
+ """
159
+ uri = f"coap://{self.ip_address}{DEVICE_INFO_V0}"
160
+ request = Message(code=GET, uri=uri)
161
+ response = await self.send_request(request)
162
+ return DeviceInfo(data=response.payload)
163
+
164
+ async def get_status(self) -> DeviceStatus:
165
+ """Read the status of the ADAR.
166
+
167
+ Returns:
168
+ DeviceStatus: The current status of the device including zone status, device state, and error information.
169
+
170
+ Raises:
171
+ ValueError: If the response payload cannot be decoded into DeviceStatus.
172
+ AssertionError: If the response payload has an unexpected format.
173
+ """
174
+ uri = f"coap://{self.ip_address}{STATUS_V0}"
175
+ request = Message(code=GET, uri=uri)
176
+ response = await self.send_request(request)
177
+ try:
178
+ status = DeviceStatus(response.payload)
179
+ # In the public API we have a Python IntEnum DeviceState which
180
+ # maps the integer DeviceState values reported. Here we do
181
+ # a translation from the raw integer value returned by the
182
+ # device to a more ergonomic enum type.
183
+ status.device_state = DeviceState(status.device_state)
184
+ self.logger.debug(f"Got status bytes {status}")
185
+ except (ValueError, AssertionError) as e:
186
+ self.logger.exception(f"Failed to decode {response.payload} into DeviceStatus: {e}")
187
+ raise
188
+ else:
189
+ return status
190
+
191
+ async def get_statistics(self) -> Statistics:
192
+ """Read the statistics.
193
+
194
+ Returns:
195
+ Statistics: Statistical information about the device.
196
+ """
197
+ uri = f"coap://{self.ip_address}{STATISTICS_V0}"
198
+ request = Message(code=GET, uri=uri)
199
+ response = await self.send_request(request)
200
+ return Statistics(data=response.payload)
201
+
202
+ async def get_transmission_code_id(self) -> int:
203
+ """Read the transmission code ID of the ADAR.
204
+
205
+ Returns:
206
+ int: The transmission code ID (1, 2, 4, or 8).
207
+
208
+ Raises:
209
+ CoapException: If the response payload is None or has incorrect length.
210
+ """
211
+ uri = f"coap://{self.ip_address}{TRANSMISSION_CODE_V0}"
212
+ self.logger.info(f"Executing GET {uri}")
213
+ request = Message(code=GET, uri=uri)
214
+ response = await self.send_request(request)
215
+ if response.payload is None:
216
+ raise CoapException("Response payload should not be None")
217
+ if len(response.payload) != 1:
218
+ raise CoapException("Response payload should have one byte")
219
+ code_id = (
220
+ 2 ** response.payload[0]
221
+ ) # Decode code ID from payload byte. The encoded payload represents N where 2^N is the code ID.
222
+ self.logger.info(f"Got transmission code bytes {response.payload}, corresponding to code ID {code_id}")
223
+ return code_id
224
+
225
+ async def set_transmission_code_id(self, code_id: int) -> None:
226
+ """Set the transmission code ID of the ADAR.
227
+
228
+ Args:
229
+ code_id: The transmission code ID to set. Must be one of 1, 2, 4, or 8.
230
+
231
+ Raises:
232
+ ValueError: If the code_id is not one of the valid values (1, 2, 4, 8).
233
+ CoapException: If the response payload is unexpected.
234
+ """
235
+ if code_id not in (1, 2, 4, 8):
236
+ raise ValueError(
237
+ f"Invalid transmission code ID {code_id}. Must be one of 1, 2, 4 or 8",
238
+ )
239
+ encoded_code_id = int(math.log2(code_id)) # Encode code ID
240
+ uri = f"coap://{self.ip_address}{TRANSMISSION_CODE_V0}"
241
+ self.logger.info(f"Executing PUT {uri}")
242
+ request = Message(code=PUT, uri=uri, payload=encoded_code_id.to_bytes(1))
243
+ response = await self.send_request(request)
244
+ # Check for empty payload (success)
245
+ if response.payload in (b"", None):
246
+ self.logger.info("Transmission code set successfully")
247
+ else:
248
+ raise CoapException(f"Unexpected response payload: {response.payload}")
249
+
250
+ async def delete_observers(self) -> None:
251
+ """Delete all registered observers on the device."""
252
+ uri = f"coap://{self.ip_address}{OBSERVERS_V0}"
253
+ request = Message(code=DELETE, uri=uri)
254
+ await self.send_request(request)
255
+
256
+ async def send_request(self, request: Message) -> Message:
257
+ """Send a coap request to the ADAR.
258
+
259
+ Args:
260
+ request: The CoAP message to send to the device.
261
+
262
+ Returns:
263
+ Message: The response from the device.
264
+
265
+ Raises:
266
+ CoapErrorException: If the request fails or returns an error response.
267
+ AssertionError: If the IP address of the ADAR device has not been set.
268
+ """
269
+ assert self.ip_address is not None, "The IP address of the ADAR device has not been set"
270
+ self.log_send_message(request)
271
+ response = await self.ctx.request(request).response
272
+ if response.code.is_successful():
273
+ return response
274
+
275
+ raise CoapErrorException(response=response)
276
+
277
+ def log_send_message(self, request: Message) -> None:
278
+ """Log outgoing CoAP request details.
279
+
280
+ Args:
281
+ request: The CoAP message being sent.
282
+ """
283
+ msg = f"Sending request {request.code} {request.opt.uri_path}"
284
+ if request.opt.observe is not None:
285
+ msg += f" (observe={request.opt.observe})"
286
+ self.logger.debug(msg)
@@ -0,0 +1,17 @@
1
+ from aiocoap import Message
2
+
3
+
4
+ class CoapException(Exception):
5
+ pass
6
+
7
+
8
+ class CoapErrorException(CoapException):
9
+ def __init__(self, *args, **kwargs):
10
+ """Initialize CoapErrorException with optional CoAP response.
11
+
12
+ Args:
13
+ *args: Variable length argument list passed to parent exception
14
+ **kwargs: Arbitrary keyword arguments, may include 'response' key for CoAP Message
15
+ """
16
+ super().__init__(*args)
17
+ self.response: Message = kwargs.get("response")
@@ -0,0 +1,169 @@
1
+ import asyncio
2
+ import logging
3
+ from enum import IntEnum
4
+
5
+ ####################
6
+ # This is a hack to get the Self type from typing, but it's not available in Python 3.10
7
+ # TODO: Fix this or update to Pyhton 3.11
8
+ from typing import AsyncGenerator, TYPE_CHECKING
9
+
10
+ if TYPE_CHECKING:
11
+ from typing import Self
12
+ else:
13
+ Self = "CoapObserver"
14
+ ####################
15
+
16
+ from aiocoap import Message, GET, Context
17
+ from aiocoap.protocol import BlockwiseRequest, Request
18
+
19
+ from .adar import Adar
20
+ from .coap_exceptions import CoapErrorException
21
+
22
+
23
+ class Observe(IntEnum):
24
+ Observe = 0
25
+ NoObserve = 1
26
+
27
+
28
+ class CoapObserver:
29
+ """
30
+ Class to register as observer to a CoAP server and receive messages
31
+
32
+ Typical usage:
33
+
34
+ async with CoapObserver(adar, "/point_cloud/v0") as observer:
35
+ async for msg in observer.messages():
36
+ points = PointCloud(msg)
37
+ print(points)
38
+ """
39
+
40
+ def __init__(self, adar: Adar, path: str):
41
+ """Initialize a CoAP observer for the given ADAR device and path.
42
+
43
+ Args:
44
+ adar: The ADAR device instance to observe
45
+ path: The CoAP path to observe (e.g., "/point_cloud/v0")
46
+ """
47
+ self._adar = adar
48
+ self.ipaddr = adar.ip_address
49
+ self.path = path
50
+ self._context: Context | None = None
51
+ self._cancelled = False
52
+ self._current_request = None
53
+ self.logger = logging.getLogger(f"{adar.device_tag}-Observe")
54
+
55
+ async def messages(self, keep_running: bool = False, msg_count: int | None = None) -> AsyncGenerator[bytes, None]:
56
+ """Listen for messages and yield them as they arrive.
57
+
58
+ Args:
59
+ keep_running: If True, the observer will attempt to keep running - i.e. ignore errors and keep trying to reconnect.
60
+ msg_count: The number of messages to receive before stopping. If None, the observer will run until cancelled.
61
+
62
+ Yields:
63
+ bytes: Raw message payload data
64
+ """
65
+ await self._ensure_coap_context()
66
+ try:
67
+ cnt = 0
68
+ connection_attempts = 0
69
+ while msg_count != cnt and not self._cancelled:
70
+ if connection_attempts > 0:
71
+ self.logger.warning("Try to re-register observer and continue")
72
+ start_observe_message = self._make_get_request(Observe.Observe)
73
+ try:
74
+ (self._current_request, _response) = await self._send_message(start_observe_message)
75
+ except CoapErrorException as e:
76
+ self.logger.warning(f"Failed to connect to server: {e}")
77
+ if keep_running or connection_attempts < 10:
78
+ connection_attempts += 1
79
+ self.logger.warning("Trying again...")
80
+ continue
81
+ raise
82
+
83
+ pr_iter = aiter(self._current_request.observation)
84
+ while msg_count != cnt and not self._cancelled:
85
+ try:
86
+ obs = await asyncio.wait_for(anext(pr_iter), timeout=2)
87
+ if not obs.code.is_successful():
88
+ self.logger.error(f"Error: {obs.code} received")
89
+ if keep_running:
90
+ break
91
+ raise CoapErrorException(response=obs)
92
+
93
+ yield obs.payload
94
+
95
+ cnt += 1
96
+ if msg_count == cnt:
97
+ self.logger.info(f"Received {cnt} messages, stopping.")
98
+ break
99
+ except asyncio.TimeoutError:
100
+ if self._cancelled:
101
+ break
102
+ self.logger.error(f"Timeout waiting for {cnt} messages")
103
+ if keep_running:
104
+ break
105
+ raise
106
+ finally:
107
+ if not self._cancelled:
108
+ await self.stop()
109
+
110
+ async def stop(self):
111
+ """Stop the observer and clean up resources."""
112
+ self._cancelled = True
113
+
114
+ try:
115
+ # Cancel current request if it exists
116
+ if self._current_request is not None:
117
+ self._current_request.cancelled = True
118
+
119
+ # Deregister the observer only if context exists
120
+ if self._context is not None:
121
+ self.logger.info("De-registering observer")
122
+ stop_observe_message = self._make_get_request(Observe.NoObserve)
123
+ pr = self._context.request(stop_observe_message)
124
+ await pr.response
125
+ except Exception as e:
126
+ self.logger.warning(f"Error during observer shutdown: {e}")
127
+ raise
128
+ finally:
129
+ if self._context is not None:
130
+ try:
131
+ await self._context.shutdown()
132
+ except Exception as e:
133
+ self.logger.warning(f"Error shutting down context: {e}")
134
+ self._context = None
135
+
136
+ async def _ensure_coap_context(self) -> None:
137
+ """Ensure we have a valid CoAP context."""
138
+ if self._context is None:
139
+ self._context = await Context.create_client_context()
140
+
141
+ def _make_get_request(self, observe: Observe) -> Message:
142
+ """Create a GET request with observe option.
143
+
144
+ Args:
145
+ observe: Observe option value
146
+
147
+ Returns:
148
+ CoAP message with observe option set
149
+ """
150
+ return Message(
151
+ code=GET,
152
+ uri=f"coap://{self._adar.ip_address}{self.path}",
153
+ observe=int(observe), # 0 = observe, 1 = no observe
154
+ )
155
+
156
+ async def _send_message(self, msg: Message) -> tuple[BlockwiseRequest | Request, Message]:
157
+ self._adar.log_send_message(msg)
158
+ request = self._context.request(msg)
159
+ response_message = await request.response
160
+ if not response_message.code.is_successful():
161
+ self.logger.error(f"Error: {response_message.code} received")
162
+ raise CoapErrorException(response=response_message)
163
+ return request, response_message
164
+
165
+ async def __aenter__(self) -> Self:
166
+ return self
167
+
168
+ async def __aexit__(self, *excinfo) -> None:
169
+ await self.stop()