PyPlumIO 0.5.21__py3-none-any.whl → 0.5.22__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.
@@ -3,18 +3,23 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  import asyncio
6
- from collections.abc import Awaitable, Callable
7
- from typing import Any
6
+ from collections.abc import Callable, Coroutine
7
+ from typing import Any, TypeVar
8
8
 
9
9
  from pyplumio.helpers.task_manager import TaskManager
10
10
 
11
+ Callback = Callable[[Any], Coroutine[Any, Any, Any]]
12
+ CallbackT = TypeVar("CallbackT", bound=Callback)
13
+
11
14
 
12
15
  class EventManager(TaskManager):
13
16
  """Represents an event manager."""
14
17
 
18
+ __slots__ = ("data", "_events", "_callbacks")
19
+
15
20
  data: dict[str, Any]
16
21
  _events: dict[str, asyncio.Event]
17
- _callbacks: dict[str, list[Callable[[Any], Awaitable[Any]]]]
22
+ _callbacks: dict[str, list[Callback]]
18
23
 
19
24
  def __init__(self) -> None:
20
25
  """Initialize a new event manager."""
@@ -75,23 +80,23 @@ class EventManager(TaskManager):
75
80
  except KeyError:
76
81
  return default
77
82
 
78
- def subscribe(self, name: str, callback: Callable[[Any], Awaitable[Any]]) -> None:
83
+ def subscribe(self, name: str, callback: CallbackT) -> CallbackT:
79
84
  """Subscribe a callback to the event.
80
85
 
81
86
  :param name: Event name or ID
82
87
  :type name: str
83
88
  :param callback: A coroutine callback function, that will be
84
89
  awaited on the with the event data as an argument.
85
- :type callback: Callable[[Any], Awaitable[Any]]
90
+ :type callback: Callback
91
+ :return: A reference to the callback, that can be used
92
+ with `EventManager.unsubscribe()`.
93
+ :rtype: Callback
86
94
  """
87
- if name not in self._callbacks:
88
- self._callbacks[name] = []
95
+ callbacks = self._callbacks.setdefault(name, [])
96
+ callbacks.append(callback)
97
+ return callback
89
98
 
90
- self._callbacks[name].append(callback)
91
-
92
- def subscribe_once(
93
- self, name: str, callback: Callable[[Any], Awaitable[Any]]
94
- ) -> None:
99
+ def subscribe_once(self, name: str, callback: Callback) -> Callback:
95
100
  """Subscribe a callback to the event once.
96
101
 
97
102
  Callback will be unsubscribed after single event.
@@ -100,17 +105,20 @@ class EventManager(TaskManager):
100
105
  :type name: str
101
106
  :param callback: A coroutine callback function, that will be
102
107
  awaited on the with the event data as an argument.
103
- :type callback: Callable[[Any], Awaitable[Any]]
108
+ :type callback: Callback
109
+ :return: A reference to the callback, that can be used
110
+ with `EventManager.unsubscribe()`.
111
+ :rtype: Callback
104
112
  """
105
113
 
106
- async def _callback(value: Any) -> Any:
114
+ async def _call_once(value: Any) -> Any:
107
115
  """Unsubscribe callback from the event and calls it."""
108
- self.unsubscribe(name, _callback)
116
+ self.unsubscribe(name, _call_once)
109
117
  return await callback(value)
110
118
 
111
- self.subscribe(name, _callback)
119
+ return self.subscribe(name, _call_once)
112
120
 
113
- def unsubscribe(self, name: str, callback: Callable[[Any], Awaitable[Any]]) -> None:
121
+ def unsubscribe(self, name: str, callback: Callback) -> bool:
114
122
  """Usubscribe a callback from the event.
115
123
 
116
124
  :param name: Event name or ID
@@ -118,18 +126,22 @@ class EventManager(TaskManager):
118
126
  :param callback: A coroutine callback function, previously
119
127
  subscribed to an event using ``subscribe()`` or
120
128
  ``subscribe_once()`` methods.
121
- :type callback: Callable[[Any], Awaitable[Any]]
129
+ :type callback: Callback
130
+ :return: `True` if callback is found, `False` otherwise.
131
+ :rtype: bool
122
132
  """
123
133
  if name in self._callbacks and callback in self._callbacks[name]:
124
134
  self._callbacks[name].remove(callback)
135
+ return True
136
+
137
+ return False
125
138
 
126
139
  async def dispatch(self, name: str, value: Any) -> None:
127
140
  """Call registered callbacks and dispatch the event."""
128
- if name in self._callbacks:
129
- callbacks = self._callbacks[name].copy()
130
- for callback in callbacks:
131
- return_value = await callback(value)
132
- value = return_value if return_value is not None else value
141
+ if callbacks := self._callbacks.get(name, None):
142
+ for callback in list(callbacks):
143
+ result = await callback(value)
144
+ value = result if result is not None else value
133
145
 
134
146
  self.data[name] = value
135
147
  self.set_event(name)
@@ -140,9 +152,8 @@ class EventManager(TaskManager):
140
152
 
141
153
  async def load(self, data: dict[str, Any]) -> None:
142
154
  """Load event data."""
143
- self.data = data
144
155
  await asyncio.gather(
145
- *[self.dispatch(name, value) for name, value in data.items()]
156
+ *(self.dispatch(name, value) for name, value in data.items())
146
157
  )
147
158
 
148
159
  def load_nowait(self, data: dict[str, Any]) -> None:
@@ -6,11 +6,10 @@ from abc import ABC
6
6
  import asyncio
7
7
  from dataclasses import dataclass
8
8
  import logging
9
- from typing import TYPE_CHECKING, Any, Final, Literal
9
+ from typing import TYPE_CHECKING, Any, Final, Literal, TypeVar, Union
10
10
 
11
11
  from pyplumio.const import BYTE_UNDEFINED, STATE_OFF, STATE_ON, UnitOfMeasurement
12
12
  from pyplumio.frames import Request
13
- from pyplumio.helpers.typing import ParameterValueType
14
13
 
15
14
  if TYPE_CHECKING:
16
15
  from pyplumio.devices import Device
@@ -20,6 +19,8 @@ _LOGGER = logging.getLogger(__name__)
20
19
  SET_TIMEOUT: Final = 5
21
20
  SET_RETRIES: Final = 5
22
21
 
22
+ ParameterValueType = Union[int, float, bool, Literal["off"], Literal["on"]]
23
+
23
24
 
24
25
  def unpack_parameter(
25
26
  data: bytearray, offset: int = 0, size: int = 1
@@ -82,42 +83,42 @@ class BinaryParameterDescription(ParameterDescription, ABC):
82
83
  class Parameter(ABC):
83
84
  """Represents a parameter."""
84
85
 
85
- __slots__ = ("device", "values", "description", "_pending_update", "_index")
86
+ __slots__ = ("device", "description", "_pending_update", "_index", "_values")
86
87
 
87
88
  device: Device
88
- values: ParameterValues
89
89
  description: ParameterDescription
90
90
  _pending_update: bool
91
91
  _index: int
92
+ _values: ParameterValues
92
93
 
93
94
  def __init__(
94
95
  self,
95
96
  device: Device,
96
- values: ParameterValues,
97
97
  description: ParameterDescription,
98
+ values: ParameterValues | None = None,
98
99
  index: int = 0,
99
100
  ):
100
101
  """Initialize a new parameter."""
101
102
  self.device = device
102
- self.values = values
103
103
  self.description = description
104
104
  self._pending_update = False
105
105
  self._index = index
106
+ self._values = values if values else ParameterValues(0, 0, 0)
106
107
 
107
108
  def __repr__(self) -> str:
108
109
  """Return a serializable string representation."""
109
110
  return (
110
111
  f"{self.__class__.__name__}("
111
112
  f"device={self.device.__class__.__name__}, "
112
- f"values={self.values}, "
113
113
  f"description={self.description}, "
114
+ f"values={self.values}, "
114
115
  f"index={self._index})"
115
116
  )
116
117
 
117
118
  def _call_relational_method(self, method_to_call: str, other: Any) -> Any:
118
119
  """Call a specified relational method."""
119
- func = getattr(self.values.value, method_to_call)
120
- return func(_normalize_parameter_value(other))
120
+ handler = getattr(self.values.value, method_to_call)
121
+ return handler(_normalize_parameter_value(other))
121
122
 
122
123
  def __int__(self) -> int:
123
124
  """Return an integer representation of parameter's value."""
@@ -163,10 +164,6 @@ class Parameter(ABC):
163
164
  """Compare if parameter value is less that other."""
164
165
  return self._call_relational_method("__lt__", other)
165
166
 
166
- async def _confirm_update(self, parameter: Parameter) -> None:
167
- """Set parameter as no longer pending update."""
168
- self._pending_update = False
169
-
170
167
  async def create_request(self) -> Request:
171
168
  """Create a request to change the parameter."""
172
169
  raise NotImplementedError
@@ -181,16 +178,14 @@ class Parameter(ABC):
181
178
  f"Value must be between '{self.min_value}' and '{self.max_value}'"
182
179
  )
183
180
 
184
- self.values.value = value
181
+ self._values.value = value
185
182
  self._pending_update = True
186
- self.device.subscribe_once(self.description.name, self._confirm_update)
187
183
  while self.pending_update:
188
184
  if retries <= 0:
189
185
  _LOGGER.error(
190
186
  "Timed out while trying to set '%s' parameter",
191
187
  self.description.name,
192
188
  )
193
- self.device.unsubscribe(self.description.name, self._confirm_update)
194
189
  return False
195
190
 
196
191
  await self.device.queue.put(await self.create_request())
@@ -203,11 +198,21 @@ class Parameter(ABC):
203
198
  """Set a parameter value without waiting."""
204
199
  self.device.create_task(self.set(value, retries))
205
200
 
201
+ def update(self, values: ParameterValues) -> None:
202
+ """Update the parameter values."""
203
+ self._values = values
204
+ self._pending_update = False
205
+
206
206
  @property
207
207
  def pending_update(self) -> bool:
208
208
  """Check if parameter is pending update on the device."""
209
209
  return self._pending_update
210
210
 
211
+ @property
212
+ def values(self) -> ParameterValues:
213
+ """Return the parameter values."""
214
+ return self._values
215
+
211
216
  @property
212
217
  def value(self) -> ParameterValueType:
213
218
  """Return the parameter value."""
@@ -228,6 +233,28 @@ class Parameter(ABC):
228
233
  """Return the unit of measurement."""
229
234
  return self.description.unit_of_measurement
230
235
 
236
+ @classmethod
237
+ def create_or_update(
238
+ cls: type[ParameterT],
239
+ device: Device,
240
+ description: ParameterDescription,
241
+ values: ParameterValues,
242
+ **kwargs: Any,
243
+ ) -> ParameterT:
244
+ """Create new parameter or update parameter values."""
245
+ parameter: ParameterT | None = device.get_nowait(description.name, None)
246
+ if parameter and isinstance(parameter, cls):
247
+ parameter.update(values)
248
+ else:
249
+ parameter = cls(
250
+ device=device, description=description, values=values, **kwargs
251
+ )
252
+
253
+ return parameter
254
+
255
+
256
+ ParameterT = TypeVar("ParameterT", bound=Parameter)
257
+
231
258
 
232
259
  class BinaryParameter(Parameter):
233
260
  """Represents binary device parameter."""
@@ -10,15 +10,20 @@ from typing import Any
10
10
  class TaskManager:
11
11
  """Represents a task manager."""
12
12
 
13
+ __slots__ = ("_tasks",)
14
+
13
15
  _tasks: set[asyncio.Task]
14
16
 
15
17
  def __init__(self) -> None:
16
18
  """Initialize a new task manager."""
19
+ super().__init__()
17
20
  self._tasks = set()
18
21
 
19
- def create_task(self, coro: Coroutine[Any, Any, Any]) -> asyncio.Task:
22
+ def create_task(
23
+ self, coro: Coroutine[Any, Any, Any], name: str | None = None
24
+ ) -> asyncio.Task:
20
25
  """Create asyncio task and store a reference for it."""
21
- task = asyncio.create_task(coro)
26
+ task = asyncio.create_task(coro, name=name)
22
27
  self._tasks.add(task)
23
28
  task.add_done_callback(self._tasks.discard)
24
29
  return task
@@ -5,7 +5,6 @@ from __future__ import annotations
5
5
  import asyncio
6
6
  from collections.abc import Awaitable, Callable, Coroutine
7
7
  from functools import wraps
8
- import logging
9
8
  from typing import Any, TypeVar
10
9
 
11
10
  from typing_extensions import ParamSpec
@@ -13,8 +12,6 @@ from typing_extensions import ParamSpec
13
12
  T = TypeVar("T")
14
13
  P = ParamSpec("P")
15
14
 
16
- _LOGGER = logging.getLogger(__name__)
17
-
18
15
 
19
16
  def timeout(
20
17
  seconds: int,
pyplumio/helpers/uid.py CHANGED
@@ -7,6 +7,7 @@ from typing import Final
7
7
 
8
8
  CRC: Final = 0xA3A3
9
9
  POLYNOMIAL: Final = 0xA001
10
+ BASE5_KEY: Final = "0123456789ABCDEFGHIJKLMNZPQRSTUV"
10
11
 
11
12
 
12
13
  def decode_uid(buffer: bytes) -> str:
@@ -16,11 +17,10 @@ def decode_uid(buffer: bytes) -> str:
16
17
 
17
18
  def _base5(buffer: bytes) -> str:
18
19
  """Encode bytes to a base5 encoded string."""
19
- key_string = "0123456789ABCDEFGHIJKLMNZPQRSTUV"
20
20
  number = int.from_bytes(buffer, byteorder="little")
21
21
  output = ""
22
22
  while number:
23
- output = key_string[number & 0b00011111] + output
23
+ output = BASE5_KEY[number & 0b00011111] + output
24
24
  number >>= 5
25
25
 
26
26
  return output
pyplumio/protocol.py CHANGED
@@ -10,7 +10,7 @@ import logging
10
10
 
11
11
  from pyplumio.const import ATTR_CONNECTED, DeviceType
12
12
  from pyplumio.devices import AddressableDevice
13
- from pyplumio.exceptions import FrameError, ReadError, UnknownDeviceError
13
+ from pyplumio.exceptions import ProtocolError
14
14
  from pyplumio.frames import Frame
15
15
  from pyplumio.frames.requests import StartMasterRequest
16
16
  from pyplumio.helpers.event_manager import EventManager
@@ -23,6 +23,8 @@ from pyplumio.structures.network_info import (
23
23
 
24
24
  _LOGGER = logging.getLogger(__name__)
25
25
 
26
+ _Callback = Callable[[], Awaitable[None]]
27
+
26
28
 
27
29
  class Protocol(ABC):
28
30
  """Represents a protocol."""
@@ -30,7 +32,7 @@ class Protocol(ABC):
30
32
  connected: asyncio.Event
31
33
  reader: FrameReader | None
32
34
  writer: FrameWriter | None
33
- _on_connection_lost: set[Callable[[], Awaitable[None]]]
35
+ _on_connection_lost: set[_Callback]
34
36
 
35
37
  def __init__(self) -> None:
36
38
  """Initialize a new protocol."""
@@ -47,7 +49,7 @@ class Protocol(ABC):
47
49
  self.writer = None
48
50
 
49
51
  @property
50
- def on_connection_lost(self) -> set[Callable[[], Awaitable[None]]]:
52
+ def on_connection_lost(self) -> set[_Callback]:
51
53
  """Return the callbacks that'll be called on connection lost."""
52
54
  return self._on_connection_lost
53
55
 
@@ -86,8 +88,7 @@ class DummyProtocol(Protocol):
86
88
  if self.connected.is_set():
87
89
  self.connected.clear()
88
90
  await self.close_writer()
89
- for callback in self.on_connection_lost:
90
- await callback()
91
+ await asyncio.gather(*(callback() for callback in self.on_connection_lost))
91
92
 
92
93
  async def shutdown(self) -> None:
93
94
  """Shutdown the protocol."""
@@ -102,8 +103,8 @@ class Queues:
102
103
 
103
104
  __slots__ = ("read", "write")
104
105
 
105
- read: asyncio.Queue
106
- write: asyncio.Queue
106
+ read: asyncio.Queue[Frame]
107
+ write: asyncio.Queue[Frame]
107
108
 
108
109
  async def join(self) -> None:
109
110
  """Wait for queues to finish."""
@@ -154,37 +155,42 @@ class AsyncProtocol(Protocol, EventManager):
154
155
  self.writer = FrameWriter(writer)
155
156
  self._queues.write.put_nowait(StartMasterRequest(recipient=DeviceType.ECOMAX))
156
157
  self.create_task(
157
- self.frame_producer(self._queues, reader=self.reader, writer=self.writer)
158
+ self.frame_producer(self._queues, reader=self.reader, writer=self.writer),
159
+ name="frame_producer_task",
158
160
  )
159
- for _ in range(self.consumers_count):
160
- self.create_task(self.frame_consumer(self._queues.read))
161
+ for consumer in range(self.consumers_count):
162
+ self.create_task(
163
+ self.frame_consumer(self._queues.read),
164
+ name=f"frame_consumer_task ({consumer})",
165
+ )
161
166
 
162
167
  for device in self.data.values():
163
168
  device.dispatch_nowait(ATTR_CONNECTED, True)
164
169
 
165
170
  self.connected.set()
166
171
 
167
- async def connection_lost(self) -> None:
168
- """Close the writer and call connection lost callbacks."""
169
- if not self.connected.is_set():
170
- return
171
-
172
+ async def _connection_close(self) -> None:
173
+ """Close the connection if it is established."""
172
174
  self.connected.clear()
173
- await self.close_writer()
174
175
  await asyncio.gather(
175
- *[device.dispatch(ATTR_CONNECTED, False) for device in self.data.values()]
176
+ *(device.dispatch(ATTR_CONNECTED, False) for device in self.data.values())
176
177
  )
177
- await asyncio.gather(*[callback() for callback in self.on_connection_lost])
178
+ await self.close_writer()
179
+
180
+ async def connection_lost(self) -> None:
181
+ """Close the connection and call connection lost callbacks."""
182
+ if self.connected.is_set():
183
+ await self._connection_close()
184
+ await asyncio.gather(*(callback() for callback in self.on_connection_lost))
178
185
 
179
186
  async def shutdown(self) -> None:
180
- """Shutdown protocol tasks."""
187
+ """Shutdown the protocol and close the connection."""
181
188
  await self._queues.join()
182
189
  self.cancel_tasks()
183
190
  await self.wait_until_done()
184
- await asyncio.gather(*[device.shutdown() for device in self.data.values()])
185
191
  if self.connected.is_set():
186
- self.connected.clear()
187
- await self.close_writer()
192
+ await self._connection_close()
193
+ await asyncio.gather(*(device.shutdown() for device in self.data.values()))
188
194
 
189
195
  async def frame_producer(
190
196
  self, queues: Queues, reader: FrameReader, writer: FrameWriter
@@ -200,7 +206,7 @@ class AsyncProtocol(Protocol, EventManager):
200
206
  if (response := await reader.read()) is not None:
201
207
  queues.read.put_nowait(response)
202
208
 
203
- except (ReadError, UnknownDeviceError, FrameError) as e:
209
+ except ProtocolError as e:
204
210
  _LOGGER.debug("Can't process received frame: %s", e)
205
211
  except (OSError, asyncio.TimeoutError):
206
212
  self.create_task(self.connection_lost())
@@ -208,11 +214,11 @@ class AsyncProtocol(Protocol, EventManager):
208
214
  except Exception:
209
215
  _LOGGER.exception("Unexpected exception")
210
216
 
211
- async def frame_consumer(self, queue: asyncio.Queue) -> None:
217
+ async def frame_consumer(self, queue: asyncio.Queue[Frame]) -> None:
212
218
  """Handle frame processing."""
213
219
  await self.connected.wait()
214
220
  while self.connected.is_set():
215
- frame: Frame = await queue.get()
221
+ frame = await queue.get()
216
222
  device = await self.get_device_entry(frame.sender)
217
223
  device.handle_frame(frame)
218
224
  queue.task_done()
@@ -225,7 +231,7 @@ class AsyncProtocol(Protocol, EventManager):
225
231
  device_type, queue=self._queues.write, network=self._network
226
232
  )
227
233
  device.dispatch_nowait(ATTR_CONNECTED, True)
228
- self.create_task(device.async_setup())
234
+ self.create_task(device.async_setup(), name=f"device_setup_task ({name})")
229
235
  self.set_event(name)
230
236
  self.data[name] = device
231
237
 
pyplumio/stream.py CHANGED
@@ -100,8 +100,8 @@ class FrameReader:
100
100
  """Read the frame and return corresponding handler object.
101
101
 
102
102
  Raise pyplumio.ReadError on unexpected frame length or
103
- incomplete frame and pyplumio.ChecksumError on incorrect frame
104
- checksum.
103
+ incomplete frame and pyplumio. Raise ChecksumError on incorrect
104
+ frame checksum.
105
105
  """
106
106
  (
107
107
  header_bytes,
@@ -3,10 +3,11 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  from collections.abc import Generator
6
+ from contextlib import suppress
6
7
  from dataclasses import dataclass
7
8
  from datetime import datetime
8
9
  from functools import lru_cache
9
- from typing import Any, Final
10
+ from typing import Any, Final, Literal, NamedTuple
10
11
 
11
12
  from pyplumio.const import AlertType
12
13
  from pyplumio.helpers.data_types import UnsignedInt
@@ -19,27 +20,40 @@ ATTR_TOTAL_ALERTS: Final = "total_alerts"
19
20
  MAX_UINT32: Final = 4294967295
20
21
 
21
22
 
22
- @lru_cache(maxsize=10)
23
- def _convert_to_datetime(seconds: int) -> datetime:
24
- """Convert timestamp to a datetime object."""
25
-
26
- def _seconds_to_datetime_args(seconds: int) -> Generator[Any, None, None]:
27
- """Convert seconds to a kwargs for a datetime class."""
28
- intervals: tuple[tuple[str, int, int], ...] = (
29
- ("year", 32140800, 2000), # 60sec * 60min * 24h * 31d * 12m
30
- ("month", 2678400, 1), # 60sec * 60min * 24h * 31d
31
- ("day", 86400, 1), # 60sec * 60min * 24h
32
- ("hour", 3600, 0), # 60sec * 60min
33
- ("minute", 60, 0),
34
- ("second", 1, 0),
35
- )
23
+ class DateTimeInterval(NamedTuple):
24
+ """Represents an alert time interval."""
25
+
26
+ name: Literal["year", "month", "day", "hour", "minute", "second"]
27
+ seconds: int
28
+ offset: int = 0
29
+
36
30
 
37
- for name, count, offset in intervals:
38
- value = seconds // count
39
- seconds -= value * count
31
+ DATETIME_INTERVALS: tuple[DateTimeInterval, ...] = (
32
+ DateTimeInterval("year", seconds=60 * 60 * 24 * 31 * 12, offset=2000),
33
+ DateTimeInterval("month", seconds=60 * 60 * 24 * 31, offset=1),
34
+ DateTimeInterval("day", seconds=60 * 60 * 24, offset=1),
35
+ DateTimeInterval("hour", seconds=60 * 60),
36
+ DateTimeInterval("minute", seconds=60),
37
+ DateTimeInterval("second", seconds=1),
38
+ )
39
+
40
+
41
+ @lru_cache(maxsize=10)
42
+ def _seconds_to_datetime(timestamp: int) -> datetime:
43
+ """Convert timestamp to a datetime object.
44
+
45
+ The ecoMAX controller stores alert time as a special timestamp value
46
+ in seconds counted from Jan 1st, 2000.
47
+ """
48
+
49
+ def _datetime_kwargs(timestamp: int) -> Generator[Any, None, None]:
50
+ """Yield a tuple, that represents a single datetime kwarg."""
51
+ for name, seconds, offset in DATETIME_INTERVALS:
52
+ value = timestamp // seconds
53
+ timestamp -= value * seconds
40
54
  yield name, (value + offset)
41
55
 
42
- return datetime(**dict(_seconds_to_datetime_args(seconds)))
56
+ return datetime(**dict(_datetime_kwargs(timestamp)))
43
57
 
44
58
 
45
59
  @dataclass
@@ -62,24 +76,20 @@ class AlertsStructure(StructureDecoder):
62
76
 
63
77
  def _unpack_alert(self, message: bytearray) -> Alert:
64
78
  """Unpack an alert."""
65
- try:
66
- code = message[self._offset]
67
- code = AlertType(code)
68
- except ValueError:
69
- pass
70
-
79
+ code = message[self._offset]
71
80
  self._offset += 1
72
81
  from_seconds = UnsignedInt.from_bytes(message, self._offset)
73
82
  self._offset += from_seconds.size
74
83
  to_seconds = UnsignedInt.from_bytes(message, self._offset)
75
84
  self._offset += to_seconds.size
76
-
77
- from_dt = _convert_to_datetime(from_seconds.value)
85
+ from_dt = _seconds_to_datetime(from_seconds.value)
78
86
  to_dt = (
79
87
  None
80
88
  if to_seconds.value == MAX_UINT32
81
- else _convert_to_datetime(to_seconds.value)
89
+ else _seconds_to_datetime(to_seconds.value)
82
90
  )
91
+ with suppress(ValueError):
92
+ code = AlertType(code)
83
93
 
84
94
  return Alert(code, from_dt, to_dt)
85
95
 
@@ -90,12 +100,11 @@ class AlertsStructure(StructureDecoder):
90
100
  total_alerts = message[offset + 0]
91
101
  start = message[offset + 1]
92
102
  end = message[offset + 2]
93
-
103
+ self._offset = offset + 3
94
104
  if end == 0:
95
105
  # No alerts found.
96
- return ensure_dict(data, {ATTR_TOTAL_ALERTS: total_alerts}), offset + 3
106
+ return ensure_dict(data, {ATTR_TOTAL_ALERTS: total_alerts}), self._offset
97
107
 
98
- self._offset = offset + 3
99
108
  return (
100
109
  ensure_dict(
101
110
  data,