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.
- pyg90alarm/__init__.py +84 -0
- pyg90alarm/alarm.py +1274 -0
- pyg90alarm/callback.py +146 -0
- pyg90alarm/cloud/__init__.py +31 -0
- pyg90alarm/cloud/const.py +56 -0
- pyg90alarm/cloud/messages.py +593 -0
- pyg90alarm/cloud/notifications.py +410 -0
- pyg90alarm/cloud/protocol.py +518 -0
- pyg90alarm/const.py +273 -0
- pyg90alarm/definitions/__init__.py +3 -0
- pyg90alarm/definitions/base.py +247 -0
- pyg90alarm/definitions/devices.py +366 -0
- pyg90alarm/definitions/sensors.py +843 -0
- pyg90alarm/entities/__init__.py +3 -0
- pyg90alarm/entities/base_entity.py +93 -0
- pyg90alarm/entities/base_list.py +268 -0
- pyg90alarm/entities/device.py +97 -0
- pyg90alarm/entities/device_list.py +156 -0
- pyg90alarm/entities/sensor.py +891 -0
- pyg90alarm/entities/sensor_list.py +183 -0
- pyg90alarm/exceptions.py +63 -0
- pyg90alarm/local/__init__.py +0 -0
- pyg90alarm/local/base_cmd.py +293 -0
- pyg90alarm/local/config.py +157 -0
- pyg90alarm/local/discovery.py +103 -0
- pyg90alarm/local/history.py +272 -0
- pyg90alarm/local/host_info.py +89 -0
- pyg90alarm/local/host_status.py +52 -0
- pyg90alarm/local/notifications.py +117 -0
- pyg90alarm/local/paginated_cmd.py +132 -0
- pyg90alarm/local/paginated_result.py +135 -0
- pyg90alarm/local/targeted_discovery.py +162 -0
- pyg90alarm/local/user_data_crc.py +46 -0
- pyg90alarm/notifications/__init__.py +0 -0
- pyg90alarm/notifications/base.py +481 -0
- pyg90alarm/notifications/protocol.py +127 -0
- pyg90alarm/py.typed +0 -0
- pyg90alarm-2.3.0.dist-info/METADATA +277 -0
- pyg90alarm-2.3.0.dist-info/RECORD +42 -0
- pyg90alarm-2.3.0.dist-info/WHEEL +5 -0
- pyg90alarm-2.3.0.dist-info/licenses/LICENSE +21 -0
- pyg90alarm-2.3.0.dist-info/top_level.txt +1 -0
|
@@ -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)
|