pyg90alarm 2.1.1__py3-none-any.whl → 2.2.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.
@@ -20,6 +20,7 @@
20
20
  """
21
21
  Base entity.
22
22
  """
23
+ from __future__ import annotations
23
24
  from abc import ABC, abstractmethod
24
25
  # `Self` has been introduced in Python 3.11, need to use `typing_extensions`
25
26
  # for earlier versions
@@ -81,3 +82,12 @@ class G90BaseEntity(ABC):
81
82
 
82
83
  :return: Index of the entity.
83
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
+ """
@@ -24,11 +24,21 @@ Base entity list.
24
24
  from abc import ABC, abstractmethod
25
25
  from typing import (
26
26
  List, AsyncGenerator, Optional, TypeVar, Generic, cast, TYPE_CHECKING,
27
+ Callable, Coroutine, Union
27
28
  )
28
29
  import asyncio
29
30
  import logging
30
31
 
32
+ from ..exceptions import G90Error
31
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
+
32
42
  if TYPE_CHECKING:
33
43
  from ..alarm import G90Alarm
34
44
  else:
@@ -36,7 +46,6 @@ else:
36
46
  # (`G90Alarm` -> `G90SensorList` -> `G90BaseList` -> `G90Alarm`)
37
47
  G90Alarm = object
38
48
 
39
- T = TypeVar('T', bound=G90BaseEntity)
40
49
  _LOGGER = logging.getLogger(__name__)
41
50
 
42
51
 
@@ -50,6 +59,7 @@ class G90BaseList(Generic[T], ABC):
50
59
  self._entities: List[T] = []
51
60
  self._lock = asyncio.Lock()
52
61
  self._parent = parent
62
+ self._list_change_cb: Optional[ListChangeCallback[T]] = None
53
63
 
54
64
  @abstractmethod
55
65
  async def _fetch(self) -> AsyncGenerator[T, None]:
@@ -91,26 +101,41 @@ class G90BaseList(Generic[T], ABC):
91
101
  entities = self._fetch()
92
102
 
93
103
  non_existing_entities = self._entities.copy()
94
- async for entity in entities:
95
- try:
96
- existing_entity_idx = self._entities.index(entity)
97
- except ValueError:
98
- existing_entity_idx = None
99
-
100
- if existing_entity_idx is not None:
101
- existing_entity = self._entities[existing_entity_idx]
102
- # Update the existing entity with the new data
103
- _LOGGER.debug(
104
- "Updating existing entity '%s' from protocol"
105
- " data '%s'", existing_entity, entity
106
- )
107
-
108
- self._entities[existing_entity_idx].update(entity)
109
- non_existing_entities.remove(entity)
110
- else:
111
- # Add the new entity to the list
112
- _LOGGER.debug('Adding new entity: %s', entity)
113
- self._entities.append(entity)
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
114
139
 
115
140
  # Mark the entities that are no longer in the list
116
141
  for unavailable_entity in non_existing_entities:
@@ -126,30 +151,30 @@ class G90BaseList(Generic[T], ABC):
126
151
 
127
152
  return self._entities
128
153
 
129
- async def find(
130
- self, idx: int, name: str, exclude_unavailable: bool
154
+ async def find_by_idx(
155
+ self, idx: int, exclude_unavailable: bool, subindex: int = 0
131
156
  ) -> Optional[T]:
132
157
  """
133
- Finds entity by index and name.
158
+ Finds entity by index.
134
159
 
135
160
  :param idx: Entity index
136
- :param name: Entity name
137
161
  :param exclude_unavailable: Exclude unavailable entities
138
- :return: Entity instance
162
+ :param subindex: Entity subindex
163
+ :return: Entity instance or None if not found
139
164
  """
140
165
  entities = await self.entities
141
166
 
142
167
  found = None
143
- # Fast lookup by direct index
144
- if idx < len(entities) and entities[idx].name == name:
168
+ if idx < len(entities):
145
169
  entity = entities[idx]
146
- _LOGGER.debug('Found entity via fast lookup: %s', entity)
147
- found = entity
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
148
174
 
149
- # Fast lookup failed, perform slow one over the whole entities list
150
175
  if not found:
151
176
  for entity in entities:
152
- if entity.index == idx and entity.name == name:
177
+ if entity.index == idx and entity.subindex == subindex:
153
178
  _LOGGER.debug('Found entity: %s', entity)
154
179
  found = entity
155
180
 
@@ -161,5 +186,83 @@ class G90BaseList(Generic[T], ABC):
161
186
  'Entity is found but unavailable, will result in none returned'
162
187
  )
163
188
 
164
- _LOGGER.error('Entity not found: idx=%s, name=%s', idx, name)
189
+ _LOGGER.error(
190
+ 'Entity not found by index=%s and subindex=%s', idx, subindex
191
+ )
165
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
@@ -22,10 +22,13 @@
22
22
  Provides interface to devices (switches) of G90 alarm panel.
23
23
  """
24
24
  from __future__ import annotations
25
+ from typing import Optional
25
26
  import logging
26
27
  from .sensor import G90Sensor
27
28
  from ..const import G90Commands
28
-
29
+ from ..definitions.base import G90PeripheralDefinition
30
+ from ..definitions.devices import G90DeviceDefinitions
31
+ from ..exceptions import G90PeripheralDefinitionNotFound
29
32
 
30
33
  _LOGGER = logging.getLogger(__name__)
31
34
 
@@ -34,6 +37,25 @@ class G90Device(G90Sensor):
34
37
  """
35
38
  Interacts with device (relay) on G90 alarm panel.
36
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
37
59
 
38
60
  async def turn_on(self) -> None:
39
61
  """
@@ -20,10 +20,17 @@
20
20
  """
21
21
  Device list.
22
22
  """
23
- from typing import AsyncGenerator
23
+ from typing import AsyncGenerator, Optional
24
+ import logging
25
+ import asyncio
24
26
  from .device import G90Device
25
27
  from .base_list import G90BaseList
26
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__)
27
34
 
28
35
 
29
36
  class G90DeviceList(G90BaseList[G90Device]):
@@ -56,3 +63,94 @@ class G90DeviceList(G90BaseList[G90Device]):
56
63
  subindex=node, proto_idx=device.proto_idx
57
64
  )
58
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)