pyg90alarm 2.3.0__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.
Files changed (42) hide show
  1. pyg90alarm/__init__.py +84 -0
  2. pyg90alarm/alarm.py +1274 -0
  3. pyg90alarm/callback.py +146 -0
  4. pyg90alarm/cloud/__init__.py +31 -0
  5. pyg90alarm/cloud/const.py +56 -0
  6. pyg90alarm/cloud/messages.py +593 -0
  7. pyg90alarm/cloud/notifications.py +410 -0
  8. pyg90alarm/cloud/protocol.py +518 -0
  9. pyg90alarm/const.py +273 -0
  10. pyg90alarm/definitions/__init__.py +3 -0
  11. pyg90alarm/definitions/base.py +247 -0
  12. pyg90alarm/definitions/devices.py +366 -0
  13. pyg90alarm/definitions/sensors.py +843 -0
  14. pyg90alarm/entities/__init__.py +3 -0
  15. pyg90alarm/entities/base_entity.py +93 -0
  16. pyg90alarm/entities/base_list.py +268 -0
  17. pyg90alarm/entities/device.py +97 -0
  18. pyg90alarm/entities/device_list.py +156 -0
  19. pyg90alarm/entities/sensor.py +891 -0
  20. pyg90alarm/entities/sensor_list.py +183 -0
  21. pyg90alarm/exceptions.py +63 -0
  22. pyg90alarm/local/__init__.py +0 -0
  23. pyg90alarm/local/base_cmd.py +293 -0
  24. pyg90alarm/local/config.py +157 -0
  25. pyg90alarm/local/discovery.py +103 -0
  26. pyg90alarm/local/history.py +272 -0
  27. pyg90alarm/local/host_info.py +89 -0
  28. pyg90alarm/local/host_status.py +52 -0
  29. pyg90alarm/local/notifications.py +117 -0
  30. pyg90alarm/local/paginated_cmd.py +132 -0
  31. pyg90alarm/local/paginated_result.py +135 -0
  32. pyg90alarm/local/targeted_discovery.py +162 -0
  33. pyg90alarm/local/user_data_crc.py +46 -0
  34. pyg90alarm/notifications/__init__.py +0 -0
  35. pyg90alarm/notifications/base.py +481 -0
  36. pyg90alarm/notifications/protocol.py +127 -0
  37. pyg90alarm/py.typed +0 -0
  38. pyg90alarm-2.3.0.dist-info/METADATA +277 -0
  39. pyg90alarm-2.3.0.dist-info/RECORD +42 -0
  40. pyg90alarm-2.3.0.dist-info/WHEEL +5 -0
  41. pyg90alarm-2.3.0.dist-info/licenses/LICENSE +21 -0
  42. pyg90alarm-2.3.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,3 @@
1
+ """
2
+ Implements various protocol entities for G90 alarm panel.
3
+ """
@@ -0,0 +1,93 @@
1
+ # Copyright (c) 2025 Ilia Sotnikov
2
+ #
3
+ # Permission is hereby granted, free of charge, to any person obtaining a copy
4
+ # of this software and associated documentation files (the "Software"), to deal
5
+ # in the Software without restriction, including without limitation the rights
6
+ # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
7
+ # copies of the Software, and to permit persons to whom the Software is
8
+ # furnished to do so, subject to the following conditions:
9
+ #
10
+ # The above copyright notice and this permission notice shall be included in
11
+ # all copies or substantial portions of the Software.
12
+ #
13
+ # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
14
+ # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
15
+ # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
16
+ # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
17
+ # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
18
+ # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
19
+ # SOFTWARE.
20
+ """
21
+ Base entity.
22
+ """
23
+ from __future__ import annotations
24
+ from abc import ABC, abstractmethod
25
+ # `Self` has been introduced in Python 3.11, need to use `typing_extensions`
26
+ # for earlier versions
27
+ try:
28
+ from typing import Self # type: ignore[attr-defined,unused-ignore]
29
+ except ImportError:
30
+ from typing_extensions import Self
31
+
32
+
33
+ class G90BaseEntity(ABC):
34
+ """
35
+ Base entity class.
36
+
37
+ Contains minimal set of method for :class:`.G90BaseList` class
38
+ """
39
+ @abstractmethod
40
+ def update(
41
+ self,
42
+ obj: Self # pylint: disable=used-before-assignment
43
+ ) -> None:
44
+ """
45
+ Update the entity from another one.
46
+
47
+ :param obj: Object to update from.
48
+ """
49
+
50
+ @property
51
+ @abstractmethod
52
+ def is_unavailable(self) -> bool:
53
+ """
54
+ Check if the entity is unavailable.
55
+
56
+ :return: True if the entity is unavailable.
57
+ """
58
+
59
+ @is_unavailable.setter
60
+ @abstractmethod
61
+ def is_unavailable(self, value: bool) -> None:
62
+ """
63
+ Set the entity as unavailable.
64
+
65
+ :param value: Value to set.
66
+ """
67
+
68
+ @property
69
+ @abstractmethod
70
+ def name(self) -> str:
71
+ """
72
+ Get the name of the entity.
73
+
74
+ :return: Name of the entity.
75
+ """
76
+
77
+ @property
78
+ @abstractmethod
79
+ def index(self) -> int:
80
+ """
81
+ Get the index of the entity.
82
+
83
+ :return: Index of the entity.
84
+ """
85
+
86
+ @property
87
+ @abstractmethod
88
+ def subindex(self) -> int:
89
+ """
90
+ Get the subindex of the entity.
91
+
92
+ :return: Subindex of the entity.
93
+ """
@@ -0,0 +1,268 @@
1
+ # Copyright (c) 2025 Ilia Sotnikov
2
+ #
3
+ # Permission is hereby granted, free of charge, to any person obtaining a copy
4
+ # of this software and associated documentation files (the "Software"), to deal
5
+ # in the Software without restriction, including without limitation the rights
6
+ # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
7
+ # copies of the Software, and to permit persons to whom the Software is
8
+ # furnished to do so, subject to the following conditions:
9
+ #
10
+ # The above copyright notice and this permission notice shall be included in
11
+ # all copies or substantial portions of the Software.
12
+ #
13
+ # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
14
+ # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
15
+ # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
16
+ # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
17
+ # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
18
+ # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
19
+ # SOFTWARE.
20
+
21
+ """
22
+ Base entity list.
23
+ """
24
+ from abc import ABC, abstractmethod
25
+ from typing import (
26
+ List, AsyncGenerator, Optional, TypeVar, Generic, cast, TYPE_CHECKING,
27
+ Callable, Coroutine, Union
28
+ )
29
+ import asyncio
30
+ import logging
31
+
32
+ from ..exceptions import G90Error
33
+ from .base_entity import G90BaseEntity
34
+ from ..callback import G90Callback
35
+
36
+ T = TypeVar('T', bound=G90BaseEntity)
37
+ ListChangeCallback = Union[
38
+ Callable[[T, bool], None],
39
+ Callable[[T, bool], Coroutine[None, None, None]]
40
+ ]
41
+
42
+ if TYPE_CHECKING:
43
+ from ..alarm import G90Alarm
44
+ else:
45
+ # Alias G90Alarm to object avoid circular imports
46
+ # (`G90Alarm` -> `G90SensorList` -> `G90BaseList` -> `G90Alarm`)
47
+ G90Alarm = object
48
+
49
+ _LOGGER = logging.getLogger(__name__)
50
+
51
+
52
+ class G90BaseList(Generic[T], ABC):
53
+ """
54
+ Base entity list class.
55
+
56
+ :param parent: Parent alarm panel instance.
57
+ """
58
+ def __init__(self, parent: G90Alarm) -> None:
59
+ self._entities: List[T] = []
60
+ self._lock = asyncio.Lock()
61
+ self._parent = parent
62
+ self._list_change_cb: Optional[ListChangeCallback[T]] = None
63
+
64
+ @abstractmethod
65
+ async def _fetch(self) -> AsyncGenerator[T, None]:
66
+ """
67
+ Fetch the list of entities from the panel.
68
+
69
+ :return: Async generator of entities
70
+ """
71
+ yield cast(T, None)
72
+
73
+ @property
74
+ async def entities(self) -> List[T]:
75
+ """
76
+ Return the list of entities.
77
+
78
+ :meth:`update` is called if the list is empty.
79
+
80
+ :return: List of entities
81
+ """
82
+ # Please see below for the explanation of the lock usage
83
+ async with self._lock:
84
+ entities = self._entities
85
+
86
+ if not entities:
87
+ return await self.update()
88
+
89
+ return entities
90
+
91
+ async def update(self) -> List[T]:
92
+ """
93
+ Update the list of entities from the panel.
94
+
95
+ :return: List of entities
96
+ """
97
+ # Use lock around the operation, to ensure no duplicated entries in the
98
+ # resulting list or redundant exchanges with panel are made when the
99
+ # method is called concurrently
100
+ async with self._lock:
101
+ entities = self._fetch()
102
+
103
+ non_existing_entities = self._entities.copy()
104
+ try:
105
+ async for entity in entities:
106
+ try:
107
+ existing_entity = next(
108
+ x for x in self._entities if x == entity
109
+ )
110
+ except StopIteration:
111
+ existing_entity = None
112
+
113
+ if existing_entity is not None:
114
+ # Update the existing entity with the new data
115
+ _LOGGER.debug(
116
+ "Updating existing entity '%s' from protocol"
117
+ " data '%s'", existing_entity, entity
118
+ )
119
+
120
+ existing_entity.update(entity)
121
+ non_existing_entities.remove(existing_entity)
122
+
123
+ # Invoke the list change callback for the existing
124
+ # entity to notify about the update
125
+ G90Callback.invoke(
126
+ self._list_change_cb, existing_entity, False
127
+ )
128
+ else:
129
+ # Add the new entity to the list
130
+ _LOGGER.debug('Adding new entity: %s', entity)
131
+ self._entities.append(entity)
132
+ # Invoke the list change callback for the new entity
133
+ G90Callback.invoke(self._list_change_cb, entity, True)
134
+ except TypeError as err:
135
+ _LOGGER.error(
136
+ 'Failed to fetch entities: %s', err
137
+ )
138
+ raise G90Error(err) from err
139
+
140
+ # Mark the entities that are no longer in the list
141
+ for unavailable_entity in non_existing_entities:
142
+ _LOGGER.debug(
143
+ 'Marking entity as unavailable: %s', unavailable_entity
144
+ )
145
+ unavailable_entity.is_unavailable = True
146
+
147
+ _LOGGER.debug(
148
+ 'Total number of entities: %s, unavailable: %s',
149
+ len(self._entities), len(non_existing_entities)
150
+ )
151
+
152
+ return self._entities
153
+
154
+ async def find_by_idx(
155
+ self, idx: int, exclude_unavailable: bool, subindex: int = 0
156
+ ) -> Optional[T]:
157
+ """
158
+ Finds entity by index.
159
+
160
+ :param idx: Entity index
161
+ :param exclude_unavailable: Exclude unavailable entities
162
+ :param subindex: Entity subindex
163
+ :return: Entity instance or None if not found
164
+ """
165
+ entities = await self.entities
166
+
167
+ found = None
168
+ if idx < len(entities):
169
+ entity = entities[idx]
170
+ if entity.index == idx and entity.subindex == subindex:
171
+ # Fast lookup by direct index
172
+ _LOGGER.debug('Found entity via fast lookup: %s', entity)
173
+ found = entity
174
+
175
+ if not found:
176
+ for entity in entities:
177
+ if entity.index == idx and entity.subindex == subindex:
178
+ _LOGGER.debug('Found entity: %s', entity)
179
+ found = entity
180
+
181
+ if found:
182
+ if not exclude_unavailable or not found.is_unavailable:
183
+ return found
184
+
185
+ _LOGGER.debug(
186
+ 'Entity is found but unavailable, will result in none returned'
187
+ )
188
+
189
+ _LOGGER.error(
190
+ 'Entity not found by index=%s and subindex=%s', idx, subindex
191
+ )
192
+ return None
193
+
194
+ async def find(
195
+ self, idx: int, name: str, exclude_unavailable: bool, subindex: int = 0
196
+ ) -> Optional[T]:
197
+ """
198
+ Finds entity by index, subindex and name.
199
+
200
+ :param idx: Entity index
201
+ :param name: Entity name
202
+ :param exclude_unavailable: Exclude unavailable entities
203
+ :param subindex: Entity subindex
204
+ :return: Entity instance or None if not found
205
+ """
206
+ found = await self.find_by_idx(idx, exclude_unavailable, subindex)
207
+ if not found:
208
+ return None
209
+
210
+ if found.name == name:
211
+ return found
212
+
213
+ _LOGGER.error(
214
+ 'Entity not found: index=%s, subindex=%s, name=%s',
215
+ idx, subindex, name
216
+ )
217
+ return None
218
+
219
+ async def find_free_idx(self) -> int:
220
+ """
221
+ Finds the first free index in the list.
222
+
223
+ The index is from protocol point of view (`.index` attribute of the
224
+ protocol data), not the index in the list. The index is required when
225
+ registering a new entity on the panel.
226
+
227
+ :return: Free index
228
+ """
229
+ entities = await self.entities
230
+
231
+ # Collect indexes in use by the existing entities
232
+ occupied_indexes = set(x.index for x in entities)
233
+ # Generate a set of possible indexes from 0 to the maximum index in
234
+ # use
235
+ possible_indexes = set(range(0, max(occupied_indexes)))
236
+
237
+ try:
238
+ # Find the first free index by taking difference between
239
+ # possible indexes and occupied ones, and then taking the minimum
240
+ # value off the difference
241
+ free_idx = min(
242
+ set(possible_indexes).difference(occupied_indexes)
243
+ )
244
+ _LOGGER.debug(
245
+ 'Found free index: %s out of occupied indexes: %s',
246
+ free_idx, occupied_indexes
247
+ )
248
+ return free_idx
249
+ except ValueError:
250
+ # If no gaps in existing indexes, then return the index next to
251
+ # the last existing entity
252
+ return len(entities)
253
+
254
+ @property
255
+ def list_change_callback(self) -> Optional[ListChangeCallback[T]]:
256
+ """
257
+ List change callback.
258
+
259
+ Invoked when the list of entities is changed, i.e. when a new entity is
260
+ added or an existing one is updated.
261
+
262
+ :return: Callback
263
+ """
264
+ return self._list_change_cb
265
+
266
+ @list_change_callback.setter
267
+ def list_change_callback(self, value: ListChangeCallback[T]) -> None:
268
+ self._list_change_cb = value
@@ -0,0 +1,97 @@
1
+ # Copyright (c) 2021 Ilia Sotnikov
2
+ #
3
+ # Permission is hereby granted, free of charge, to any person obtaining a copy
4
+ # of this software and associated documentation files (the "Software"), to deal
5
+ # in the Software without restriction, including without limitation the rights
6
+ # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
7
+ # copies of the Software, and to permit persons to whom the Software is
8
+ # furnished to do so, subject to the following conditions:
9
+ #
10
+ # The above copyright notice and this permission notice shall be included in
11
+ # all copies or substantial portions of the Software.
12
+ #
13
+ # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
14
+ # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
15
+ # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
16
+ # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
17
+ # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
18
+ # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
19
+ # SOFTWARE.
20
+
21
+ """
22
+ Provides interface to devices (switches) of G90 alarm panel.
23
+ """
24
+ from __future__ import annotations
25
+ from typing import Optional
26
+ import logging
27
+ from .sensor import G90Sensor
28
+ from ..const import G90Commands
29
+ from ..definitions.base import G90PeripheralDefinition
30
+ from ..definitions.devices import G90DeviceDefinitions
31
+ from ..exceptions import G90PeripheralDefinitionNotFound
32
+
33
+ _LOGGER = logging.getLogger(__name__)
34
+
35
+
36
+ class G90Device(G90Sensor):
37
+ """
38
+ Interacts with device (relay) on G90 alarm panel.
39
+ """
40
+ @property
41
+ def definition(self) -> Optional[G90PeripheralDefinition]:
42
+ """
43
+ Returns the definition for the device.
44
+
45
+ :return: Device definition
46
+ """
47
+ if not self._definition:
48
+ # No definition has been cached, try to find it by type, subtype
49
+ # and protocol
50
+ try:
51
+ self._definition = (
52
+ G90DeviceDefinitions.get_by_id(
53
+ self.type, self.subtype, self.protocol
54
+ )
55
+ )
56
+ except G90PeripheralDefinitionNotFound:
57
+ return None
58
+ return self._definition
59
+
60
+ async def turn_on(self) -> None:
61
+ """
62
+ Turns on the device (relay)
63
+ """
64
+ await self.parent.command(G90Commands.CONTROLDEVICE,
65
+ [self.index, 0, self.subindex])
66
+
67
+ async def turn_off(self) -> None:
68
+ """
69
+ Turns off the device (relay)
70
+ """
71
+ await self.parent.command(G90Commands.CONTROLDEVICE,
72
+ [self.index, 1, self.subindex])
73
+
74
+ @property
75
+ def supports_updates(self) -> bool:
76
+ """
77
+ Indicates if disabling/enabling the device (relay) is supported.
78
+
79
+ :return: Support for enabling/disabling the device
80
+ """
81
+ # No support for manipulating of disable/enabled for the device, since
82
+ # single protocol entity read from the G90 alarm panel results in
83
+ # multiple `G90Device` instances and changing the state would
84
+ # subsequently require a design change to allow multiple entities to
85
+ # reflect that. Multiple device entities are for multi-channel relays
86
+ # mostly.
87
+ return False
88
+
89
+ async def delete(self) -> None:
90
+ """
91
+ Deletes the device (relay) from the G90 alarm panel.
92
+ """
93
+ _LOGGER.debug("Deleting device: %s", self)
94
+ # Mark the device as unavailable
95
+ self.is_unavailable = True
96
+ # Delete the device from the alarm panel
97
+ await self.parent.command(G90Commands.DELDEVICE, [self.index])
@@ -0,0 +1,156 @@
1
+ # Copyright (c) 2025 Ilia Sotnikov
2
+ #
3
+ # Permission is hereby granted, free of charge, to any person obtaining a copy
4
+ # of this software and associated documentation files (the "Software"), to deal
5
+ # in the Software without restriction, including without limitation the rights
6
+ # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
7
+ # copies of the Software, and to permit persons to whom the Software is
8
+ # furnished to do so, subject to the following conditions:
9
+ #
10
+ # The above copyright notice and this permission notice shall be included in
11
+ # all copies or substantial portions of the Software.
12
+ #
13
+ # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
14
+ # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
15
+ # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
16
+ # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
17
+ # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
18
+ # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
19
+ # SOFTWARE.
20
+ """
21
+ Device list.
22
+ """
23
+ from typing import AsyncGenerator, Optional
24
+ import logging
25
+ import asyncio
26
+ from .device import G90Device
27
+ from .base_list import G90BaseList
28
+ from ..const import G90Commands
29
+ from ..definitions.devices import G90DeviceDefinitions
30
+ from ..entities.sensor import G90SensorUserFlags
31
+ from ..exceptions import G90EntityRegistrationError
32
+
33
+ _LOGGER = logging.getLogger(__name__)
34
+
35
+
36
+ class G90DeviceList(G90BaseList[G90Device]):
37
+ """
38
+ Device list class.
39
+ """
40
+ async def _fetch(self) -> AsyncGenerator[G90Device, None]:
41
+ """
42
+ Fetch the list of devices from the panel.
43
+
44
+ :yields: G90Device: Device entity.
45
+ """
46
+ devices = self._parent.paginated_result(
47
+ G90Commands.GETDEVICELIST
48
+ )
49
+
50
+ async for device in devices:
51
+ obj = G90Device(
52
+ *device.data, parent=self._parent, subindex=0,
53
+ proto_idx=device.proto_idx
54
+ )
55
+
56
+ yield obj
57
+
58
+ # Multi-node devices (first node has already been handled
59
+ # above)
60
+ for node in range(1, obj.node_count):
61
+ obj = G90Device(
62
+ *device.data, parent=self._parent,
63
+ subindex=node, proto_idx=device.proto_idx
64
+ )
65
+ yield obj
66
+
67
+ async def register(
68
+ self, definition_name: str,
69
+ room_id: int, timeout: float, name: Optional[str] = None,
70
+ ) -> G90Device:
71
+ """
72
+ Register the devices (switches) to the panel.
73
+
74
+ Contrary to registering the sensors, the registration of devices does
75
+ not have an associated notification from the panel, hence the list of
76
+ devices is polled to determine when new device is added.
77
+
78
+ :param definition_name: Name of the device definition to register.
79
+ :param room_id: ID of the room to assign the device to.
80
+ :param timeout: Timeout in seconds to wait for the device to be added.
81
+ :param name: Optional name for the device, if not provided, the
82
+ name from the definition will be used.
83
+ :raises G90EntityRegistrationError: If the device could not be
84
+ registered or found after the registration.
85
+ :return: G90Device: The registered device entity.
86
+ """
87
+ device_definition = G90DeviceDefinitions.get_by_name(definition_name)
88
+ dev_name = name or device_definition.name
89
+
90
+ # Register the device with the panel
91
+ await self._parent.command(
92
+ G90Commands.ADDDEVICE, [
93
+ dev_name,
94
+ # Registering device requires to provide a free index from
95
+ # panel point of view
96
+ await self.find_free_idx(),
97
+ room_id,
98
+ device_definition.type,
99
+ device_definition.subtype,
100
+ device_definition.timeout,
101
+ # Newly registered devices are enabled by default
102
+ G90SensorUserFlags.ENABLED,
103
+ device_definition.baudrate,
104
+ device_definition.protocol,
105
+ device_definition.reserved_data,
106
+ device_definition.node_count,
107
+ device_definition.rx,
108
+ device_definition.tx,
109
+ device_definition.private_data
110
+ ]
111
+ )
112
+
113
+ # Confirm the registration of the device to the panel
114
+ res = await self._parent.command(
115
+ G90Commands.SENDREGDEVICERESULT,
116
+ # 1 = register, 0 = cancel
117
+ [1]
118
+ )
119
+
120
+ # The command above returns the index of the added device in the
121
+ # device list from panel point of view
122
+ try:
123
+ added_at = next(iter(res))
124
+ _LOGGER.debug('Device added at index=%s', added_at)
125
+ except StopIteration:
126
+ msg = (
127
+ f"Failed to register device '{dev_name}' - response does not"
128
+ ' contain the index in the device list'
129
+ )
130
+ _LOGGER.debug(msg)
131
+ # pylint: disable=raise-missing-from
132
+ raise G90EntityRegistrationError(msg)
133
+
134
+ # Update the list of devices polling for the new entity
135
+ # to appear in the list - it takes some time for the panel
136
+ # to process the registration and add the device to the list
137
+ found = None
138
+ for _ in range(int(timeout)):
139
+ # Update the list of devices from the panel
140
+ await self.update()
141
+ # Try to find the device by the index it was added at
142
+ if found := await self.find_by_idx(
143
+ added_at, exclude_unavailable=False
144
+ ):
145
+ break
146
+ await asyncio.sleep(1)
147
+
148
+ if found:
149
+ return found
150
+
151
+ msg = (
152
+ f"Failed to find the added device '{dev_name}'"
153
+ f' at index {added_at}'
154
+ )
155
+ _LOGGER.debug(msg)
156
+ raise G90EntityRegistrationError(msg)