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,183 @@
|
|
|
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
|
+
Sensor list.
|
|
22
|
+
"""
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
from typing import AsyncGenerator, Optional, TYPE_CHECKING
|
|
25
|
+
import logging
|
|
26
|
+
import asyncio
|
|
27
|
+
|
|
28
|
+
from .sensor import G90Sensor
|
|
29
|
+
from .base_list import G90BaseList
|
|
30
|
+
from ..const import G90Commands
|
|
31
|
+
from ..exceptions import G90EntityRegistrationError, G90Error
|
|
32
|
+
from ..definitions.sensors import G90SensorDefinitions
|
|
33
|
+
from ..entities.sensor import G90SensorUserFlags
|
|
34
|
+
|
|
35
|
+
if TYPE_CHECKING:
|
|
36
|
+
from ..alarm import G90Alarm
|
|
37
|
+
|
|
38
|
+
_LOGGER = logging.getLogger(__name__)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class G90SensorList(G90BaseList[G90Sensor]):
|
|
42
|
+
"""
|
|
43
|
+
Sensor list class.
|
|
44
|
+
"""
|
|
45
|
+
def __init__(self, parent: G90Alarm) -> None:
|
|
46
|
+
super().__init__(parent)
|
|
47
|
+
self._sensor_change_future: Optional[asyncio.Future[G90Sensor]] = None
|
|
48
|
+
|
|
49
|
+
async def _fetch(self) -> AsyncGenerator[G90Sensor, None]:
|
|
50
|
+
"""
|
|
51
|
+
Fetch the list of sensors from the panel.
|
|
52
|
+
|
|
53
|
+
:yields: G90Sensor: Sensor entity.
|
|
54
|
+
"""
|
|
55
|
+
entities = self._parent.paginated_result(
|
|
56
|
+
G90Commands.GETSENSORLIST
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
async for entity in entities:
|
|
60
|
+
yield G90Sensor(
|
|
61
|
+
*entity.data, parent=self._parent, subindex=0,
|
|
62
|
+
proto_idx=entity.proto_idx
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
async def sensor_change_callback(
|
|
66
|
+
self, idx: int, name: str, added: bool
|
|
67
|
+
) -> None:
|
|
68
|
+
"""
|
|
69
|
+
Sensor change callback.
|
|
70
|
+
|
|
71
|
+
Should be invoked from corresponding panel's notification handler to
|
|
72
|
+
finish the registration process.
|
|
73
|
+
|
|
74
|
+
:param idx: Sensor index.
|
|
75
|
+
:param name: Sensor name.
|
|
76
|
+
:param added: True if the sensor was added, False if removed.
|
|
77
|
+
"""
|
|
78
|
+
_LOGGER.debug(
|
|
79
|
+
"Sensor change callback: name='%s', index=%d, added=%s",
|
|
80
|
+
name, idx, added
|
|
81
|
+
)
|
|
82
|
+
# The method depends on the future to be created before it is called
|
|
83
|
+
if not self._sensor_change_future:
|
|
84
|
+
raise G90EntityRegistrationError(
|
|
85
|
+
"Sensor change callback called without a future"
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
# Update the sensor list to get the latest data, unfortunately there is
|
|
89
|
+
# no panel command to get just a single sensor
|
|
90
|
+
try:
|
|
91
|
+
await self.update()
|
|
92
|
+
except G90Error as err:
|
|
93
|
+
_LOGGER.debug(
|
|
94
|
+
"Failed to update the sensor list: %s", err
|
|
95
|
+
)
|
|
96
|
+
# Indicate the error to the caller
|
|
97
|
+
self._sensor_change_future.set_exception(err)
|
|
98
|
+
return
|
|
99
|
+
|
|
100
|
+
# Attempt to find the sensor just changed by its index
|
|
101
|
+
if not (found := await self.find_by_idx(
|
|
102
|
+
idx, exclude_unavailable=False
|
|
103
|
+
)):
|
|
104
|
+
msg = (
|
|
105
|
+
f"Failed to find the added sensor '{name}'"
|
|
106
|
+
f' at index {idx}'
|
|
107
|
+
)
|
|
108
|
+
_LOGGER.debug(msg)
|
|
109
|
+
# Indicate the error to the caller
|
|
110
|
+
self._sensor_change_future.set_exception(
|
|
111
|
+
G90EntityRegistrationError(msg)
|
|
112
|
+
)
|
|
113
|
+
return
|
|
114
|
+
|
|
115
|
+
# Provide the found sensor as the future result so that the caller
|
|
116
|
+
# can use it
|
|
117
|
+
self._sensor_change_future.set_result(found)
|
|
118
|
+
|
|
119
|
+
async def register(
|
|
120
|
+
self, definition_name: str,
|
|
121
|
+
room_id: int, timeout: float, name: Optional[str] = None,
|
|
122
|
+
) -> G90Sensor:
|
|
123
|
+
"""
|
|
124
|
+
Registers sensor to the panel.
|
|
125
|
+
|
|
126
|
+
:param definition_name: Sensor definition name.
|
|
127
|
+
:param room_id: Room ID to assign the sensor to.
|
|
128
|
+
:param timeout: Timeout for the registration process.
|
|
129
|
+
:param name: Optional name for the sensor, if not provided, the
|
|
130
|
+
definition name will be used.
|
|
131
|
+
:raises G90EntityRegistrationError: If the registration fails.
|
|
132
|
+
:return: G90Sensor: The registered sensor entity.
|
|
133
|
+
"""
|
|
134
|
+
sensor_definition = G90SensorDefinitions.get_by_name(definition_name)
|
|
135
|
+
dev_name = name or sensor_definition.name
|
|
136
|
+
|
|
137
|
+
# Future is needed to coordinate the registration process with panel
|
|
138
|
+
# notifications (see `sensor_change_callback()` method)
|
|
139
|
+
self._sensor_change_future = asyncio.get_running_loop().create_future()
|
|
140
|
+
|
|
141
|
+
# Register the sensor with the panel
|
|
142
|
+
await self._parent.command(
|
|
143
|
+
G90Commands.ADDSENSOR, [
|
|
144
|
+
dev_name,
|
|
145
|
+
# Registering sensor requires to provide a free index from
|
|
146
|
+
# panel point of view
|
|
147
|
+
await self.find_free_idx(),
|
|
148
|
+
room_id,
|
|
149
|
+
sensor_definition.type,
|
|
150
|
+
sensor_definition.subtype,
|
|
151
|
+
sensor_definition.timeout,
|
|
152
|
+
# Newly registered sensors are enabled by default and set to
|
|
153
|
+
# alarm in away and home modes
|
|
154
|
+
G90SensorUserFlags.ENABLED
|
|
155
|
+
| G90SensorUserFlags.ALERT_WHEN_AWAY_AND_HOME,
|
|
156
|
+
sensor_definition.baudrate,
|
|
157
|
+
sensor_definition.protocol,
|
|
158
|
+
sensor_definition.reserved_data,
|
|
159
|
+
sensor_definition.node_count,
|
|
160
|
+
sensor_definition.rx,
|
|
161
|
+
sensor_definition.tx,
|
|
162
|
+
sensor_definition.private_data
|
|
163
|
+
]
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
# Waiting for the registration to finish, where panel will send
|
|
167
|
+
# corresponding notification processed by `sensor_change_callback()`
|
|
168
|
+
# method, which manipulates the future created above.
|
|
169
|
+
done, _ = await asyncio.wait(
|
|
170
|
+
[self._sensor_change_future], timeout=timeout
|
|
171
|
+
)
|
|
172
|
+
|
|
173
|
+
if self._sensor_change_future not in done:
|
|
174
|
+
msg = f"Failed to learn the device '{dev_name}', timed out"
|
|
175
|
+
_LOGGER.debug(msg)
|
|
176
|
+
raise G90EntityRegistrationError(msg)
|
|
177
|
+
|
|
178
|
+
# Propagate any exception that might have occurred in
|
|
179
|
+
# `sensor_change_callback()` method during the registration process
|
|
180
|
+
self._sensor_change_future.exception()
|
|
181
|
+
|
|
182
|
+
# Return the registered sensor entity
|
|
183
|
+
return self._sensor_change_future.result()
|
pyg90alarm/exceptions.py
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
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
|
+
"""
|
|
23
|
+
Exceptions specific to G90-based alarm systems.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
import asyncio
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class G90Error(Exception):
|
|
30
|
+
"""
|
|
31
|
+
Represents a generic exception raised by many package classes.
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class G90TimeoutError(asyncio.TimeoutError): # pylint:disable=R0903
|
|
36
|
+
"""
|
|
37
|
+
Raised when particular package class to report an operation (typically
|
|
38
|
+
device command) has timed out.
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class G90CommandFailure(G90Error):
|
|
43
|
+
"""
|
|
44
|
+
Raised when a command to the alarm panel reports failure.
|
|
45
|
+
"""
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class G90CommandError(G90Error):
|
|
49
|
+
"""
|
|
50
|
+
Raised when a command to the alarm panel reports an error.
|
|
51
|
+
"""
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class G90EntityRegistrationError(G90Error):
|
|
55
|
+
"""
|
|
56
|
+
Raised when registering an entity to the alarm panel fails.
|
|
57
|
+
"""
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class G90PeripheralDefinitionNotFound(G90Error):
|
|
61
|
+
"""
|
|
62
|
+
Raised when a peripheral definition is not found.
|
|
63
|
+
"""
|
|
File without changes
|
|
@@ -0,0 +1,293 @@
|
|
|
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 support for basic commands of G90 alarm panel.
|
|
23
|
+
"""
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
import logging
|
|
26
|
+
import json
|
|
27
|
+
import asyncio
|
|
28
|
+
from asyncio import Future
|
|
29
|
+
from asyncio.protocols import DatagramProtocol
|
|
30
|
+
from asyncio.transports import DatagramTransport, BaseTransport
|
|
31
|
+
from typing import Optional, Tuple, List, Any
|
|
32
|
+
from dataclasses import dataclass
|
|
33
|
+
from ..exceptions import (
|
|
34
|
+
G90Error, G90TimeoutError, G90CommandFailure, G90CommandError
|
|
35
|
+
)
|
|
36
|
+
from ..const import G90Commands
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
_LOGGER = logging.getLogger(__name__)
|
|
40
|
+
G90BaseCommandData = List[Any]
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@dataclass
|
|
44
|
+
class G90Header:
|
|
45
|
+
"""
|
|
46
|
+
Represents JSON structure of the header used in alarm panel commands.
|
|
47
|
+
|
|
48
|
+
:meta private:
|
|
49
|
+
"""
|
|
50
|
+
code: Optional[int] = None
|
|
51
|
+
data: Optional[G90BaseCommandData] = None
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class G90BaseCommand(DatagramProtocol):
|
|
55
|
+
"""
|
|
56
|
+
Implements basic command handling for alarm panel protocol.
|
|
57
|
+
"""
|
|
58
|
+
# pylint: disable=too-many-instance-attributes
|
|
59
|
+
# Lock need to be shared across all of the class instances
|
|
60
|
+
_sk_lock = asyncio.Lock()
|
|
61
|
+
|
|
62
|
+
# pylint: disable=too-many-positional-arguments,too-many-arguments
|
|
63
|
+
def __init__(self, host: str, port: int, code: G90Commands,
|
|
64
|
+
data: Optional[G90BaseCommandData] = None,
|
|
65
|
+
local_port: Optional[int] = None,
|
|
66
|
+
timeout: float = 3.0, retries: int = 3) -> None:
|
|
67
|
+
self._remote_host = host
|
|
68
|
+
self._remote_port = port
|
|
69
|
+
self._local_port = local_port
|
|
70
|
+
self._code = code
|
|
71
|
+
self._timeout = timeout
|
|
72
|
+
self._retries = retries
|
|
73
|
+
self._data = '""'
|
|
74
|
+
self._result: G90BaseCommandData = []
|
|
75
|
+
self._connection_result: Optional[
|
|
76
|
+
Future[Tuple[str, int, bytes]]
|
|
77
|
+
] = None
|
|
78
|
+
if data:
|
|
79
|
+
self._data = json.dumps([code, data],
|
|
80
|
+
# No newlines to be inserted
|
|
81
|
+
indent=None,
|
|
82
|
+
# No whitespace around entities
|
|
83
|
+
separators=(',', ':'))
|
|
84
|
+
self._resp = G90Header()
|
|
85
|
+
|
|
86
|
+
# Implementation of datagram protocol,
|
|
87
|
+
# https://docs.python.org/3/library/asyncio-protocol.html#datagram-protocols
|
|
88
|
+
def connection_made(self, transport: BaseTransport) -> None:
|
|
89
|
+
"""
|
|
90
|
+
Invoked when connection is established.
|
|
91
|
+
"""
|
|
92
|
+
|
|
93
|
+
def connection_lost(self, exc: Optional[Exception]) -> None:
|
|
94
|
+
"""
|
|
95
|
+
Invoked when connection is lost.
|
|
96
|
+
"""
|
|
97
|
+
|
|
98
|
+
def datagram_received(self, data: bytes, addr: Tuple[str, int]) -> None:
|
|
99
|
+
"""
|
|
100
|
+
Invoked when datagram is received.
|
|
101
|
+
"""
|
|
102
|
+
if asyncio.isfuture(self._connection_result):
|
|
103
|
+
if self._connection_result.done():
|
|
104
|
+
_LOGGER.warning('Excessive packet received'
|
|
105
|
+
' from %s:%s: %s',
|
|
106
|
+
addr[0], addr[1], data)
|
|
107
|
+
return
|
|
108
|
+
self._connection_result.set_result((*addr, data))
|
|
109
|
+
|
|
110
|
+
def error_received(self, exc: Exception) -> None:
|
|
111
|
+
"""
|
|
112
|
+
Invoked when error is received.
|
|
113
|
+
"""
|
|
114
|
+
if (
|
|
115
|
+
asyncio.isfuture(self._connection_result) and not
|
|
116
|
+
self._connection_result.done()
|
|
117
|
+
):
|
|
118
|
+
self._connection_result.set_exception(exc)
|
|
119
|
+
|
|
120
|
+
async def _create_connection(self) -> (
|
|
121
|
+
Tuple[DatagramTransport, DatagramProtocol]
|
|
122
|
+
):
|
|
123
|
+
"""
|
|
124
|
+
Creates UDP connection to the alarm panel.
|
|
125
|
+
"""
|
|
126
|
+
loop = asyncio.get_running_loop()
|
|
127
|
+
|
|
128
|
+
_LOGGER.debug('Creating UDP endpoint for %s:%s',
|
|
129
|
+
self.host, self.port)
|
|
130
|
+
local_addr = None
|
|
131
|
+
if self._local_port:
|
|
132
|
+
local_addr = ('0.0.0.0', self._local_port)
|
|
133
|
+
|
|
134
|
+
transport, protocol = await loop.create_datagram_endpoint(
|
|
135
|
+
lambda: self,
|
|
136
|
+
remote_addr=(self.host, self.port),
|
|
137
|
+
allow_broadcast=True,
|
|
138
|
+
local_addr=local_addr)
|
|
139
|
+
|
|
140
|
+
return (transport, protocol)
|
|
141
|
+
|
|
142
|
+
def to_wire(self) -> bytes:
|
|
143
|
+
"""
|
|
144
|
+
Returns the command in wire format.
|
|
145
|
+
"""
|
|
146
|
+
wire = bytes(f'ISTART[{self._code},{self._code},{self._data}]IEND\0',
|
|
147
|
+
'utf-8')
|
|
148
|
+
_LOGGER.debug('Encoded to wire format %s', wire)
|
|
149
|
+
return wire
|
|
150
|
+
|
|
151
|
+
def from_wire(self, data: bytes) -> G90BaseCommandData:
|
|
152
|
+
"""
|
|
153
|
+
Parses the response from the alarm panel.
|
|
154
|
+
"""
|
|
155
|
+
_LOGGER.debug('To be decoded from wire format %s', data)
|
|
156
|
+
try:
|
|
157
|
+
self._parse(data.decode('utf-8'))
|
|
158
|
+
except UnicodeDecodeError as exc:
|
|
159
|
+
raise G90Error(
|
|
160
|
+
'Unable to decode response from UTF-8'
|
|
161
|
+
) from exc
|
|
162
|
+
return self._resp.data or []
|
|
163
|
+
|
|
164
|
+
def _parse(self, data: str) -> None:
|
|
165
|
+
"""
|
|
166
|
+
Processes the response from the alarm panel.
|
|
167
|
+
"""
|
|
168
|
+
if not data.startswith('ISTART'):
|
|
169
|
+
raise G90Error('Missing start marker in data')
|
|
170
|
+
if not data.endswith('IEND\0'):
|
|
171
|
+
raise G90Error('Missing end marker in data')
|
|
172
|
+
payload = data[6:-5]
|
|
173
|
+
_LOGGER.debug("Decoded from wire: string '%s'", payload)
|
|
174
|
+
|
|
175
|
+
if not payload:
|
|
176
|
+
return
|
|
177
|
+
|
|
178
|
+
# Panel may report the last command has failed
|
|
179
|
+
if payload == 'fail':
|
|
180
|
+
raise G90CommandFailure(
|
|
181
|
+
f"Command {self._code.name}"
|
|
182
|
+
f" (code={self._code.value}) failed"
|
|
183
|
+
)
|
|
184
|
+
|
|
185
|
+
# Also, panel may report an error supplying specific reason, e.g.
|
|
186
|
+
# command and its arguments that have failed
|
|
187
|
+
if payload.startswith('error'):
|
|
188
|
+
error = payload[5:]
|
|
189
|
+
raise G90CommandError(
|
|
190
|
+
f"Command {self._code.name}"
|
|
191
|
+
f" (code={self._code.value}) failed"
|
|
192
|
+
f" with error: '{error}'")
|
|
193
|
+
|
|
194
|
+
resp = None
|
|
195
|
+
try:
|
|
196
|
+
resp = json.loads(payload, strict=False)
|
|
197
|
+
except json.JSONDecodeError as exc:
|
|
198
|
+
raise G90Error(
|
|
199
|
+
f"Unable to parse response as JSON: '{payload}'"
|
|
200
|
+
) from exc
|
|
201
|
+
|
|
202
|
+
if not isinstance(resp, list):
|
|
203
|
+
raise G90Error(
|
|
204
|
+
f"Malformed response, 'list' expected: '{payload}'"
|
|
205
|
+
)
|
|
206
|
+
|
|
207
|
+
if resp is not None:
|
|
208
|
+
self._resp = G90Header(*resp)
|
|
209
|
+
_LOGGER.debug('Parsed from wire: %s', self._resp)
|
|
210
|
+
|
|
211
|
+
if not self._resp.code:
|
|
212
|
+
raise G90Error(f"Missing code in response: '{payload}'")
|
|
213
|
+
# Check there is data if the response is non-empty
|
|
214
|
+
if not self._resp.data:
|
|
215
|
+
raise G90Error(f"Missing data in response: '{payload}'")
|
|
216
|
+
|
|
217
|
+
if self._resp.code != self._code:
|
|
218
|
+
raise G90Error(
|
|
219
|
+
'Wrong response - received code '
|
|
220
|
+
f"{self._resp.code}, expected code {self._code}")
|
|
221
|
+
|
|
222
|
+
@property
|
|
223
|
+
def result(self) -> G90BaseCommandData:
|
|
224
|
+
"""
|
|
225
|
+
The result of the command.
|
|
226
|
+
"""
|
|
227
|
+
return self._result
|
|
228
|
+
|
|
229
|
+
@property
|
|
230
|
+
def host(self) -> str:
|
|
231
|
+
"""
|
|
232
|
+
The hostname/IP address of the alarm panel.
|
|
233
|
+
"""
|
|
234
|
+
return self._remote_host
|
|
235
|
+
|
|
236
|
+
@property
|
|
237
|
+
def port(self) -> int:
|
|
238
|
+
"""
|
|
239
|
+
The port of the alarm panel.
|
|
240
|
+
"""
|
|
241
|
+
return self._remote_port
|
|
242
|
+
|
|
243
|
+
async def process(self) -> G90BaseCommand:
|
|
244
|
+
"""
|
|
245
|
+
Processes the command.
|
|
246
|
+
"""
|
|
247
|
+
# Disallow using `NONE` command, which is intended to use by inheriting
|
|
248
|
+
# classes overriding `process()` method
|
|
249
|
+
if self._code == G90Commands.NONE:
|
|
250
|
+
raise G90Error("'NONE' command code is disallowed")
|
|
251
|
+
|
|
252
|
+
transport, _ = await self._create_connection()
|
|
253
|
+
attempts = self._retries
|
|
254
|
+
while True:
|
|
255
|
+
attempts = attempts - 1
|
|
256
|
+
loop = asyncio.get_running_loop()
|
|
257
|
+
self._connection_result = loop.create_future()
|
|
258
|
+
async with self._sk_lock:
|
|
259
|
+
_LOGGER.debug('(code %s) Sending request to %s:%s',
|
|
260
|
+
self._code, self.host, self.port)
|
|
261
|
+
transport.sendto(self.to_wire())
|
|
262
|
+
done, _ = await asyncio.wait([self._connection_result],
|
|
263
|
+
timeout=self._timeout)
|
|
264
|
+
if self._connection_result in done:
|
|
265
|
+
break
|
|
266
|
+
# Cancel the future to signal protocol handler it is no longer
|
|
267
|
+
# valid, the future will be re-created on next retry
|
|
268
|
+
self._connection_result.cancel()
|
|
269
|
+
if not attempts:
|
|
270
|
+
transport.close()
|
|
271
|
+
raise G90TimeoutError()
|
|
272
|
+
_LOGGER.debug('Timed out, retrying')
|
|
273
|
+
transport.close()
|
|
274
|
+
(host, port, data) = self._connection_result.result()
|
|
275
|
+
_LOGGER.debug('Received response from %s:%s', host, port)
|
|
276
|
+
if self.host != '255.255.255.255':
|
|
277
|
+
if self.host != host or host == '255.255.255.255':
|
|
278
|
+
raise G90Error(f'Received response from wrong host {host},'
|
|
279
|
+
f' expected from {self.host}')
|
|
280
|
+
if self.port != port:
|
|
281
|
+
raise G90Error(f'Received response from wrong port {port},'
|
|
282
|
+
f' expected from {self.port}')
|
|
283
|
+
|
|
284
|
+
ret = self.from_wire(data)
|
|
285
|
+
self._result = ret
|
|
286
|
+
return self
|
|
287
|
+
|
|
288
|
+
def __repr__(self) -> str:
|
|
289
|
+
"""
|
|
290
|
+
Returns string representation of the command.
|
|
291
|
+
"""
|
|
292
|
+
return f'Command: {self._code}, request: {self._data},' \
|
|
293
|
+
f' response: {self._resp.data}'
|
|
@@ -0,0 +1,157 @@
|
|
|
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
|
+
Represents various configuration aspects of the alarm panel.
|
|
23
|
+
"""
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
from typing import TYPE_CHECKING
|
|
26
|
+
import logging
|
|
27
|
+
from dataclasses import dataclass
|
|
28
|
+
from enum import IntFlag
|
|
29
|
+
from ..const import G90Commands
|
|
30
|
+
if TYPE_CHECKING:
|
|
31
|
+
from ..alarm import G90Alarm
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class G90AlertConfigFlags(IntFlag):
|
|
35
|
+
"""
|
|
36
|
+
Alert configuration flags, used bitwise
|
|
37
|
+
"""
|
|
38
|
+
AC_POWER_FAILURE = 1
|
|
39
|
+
AC_POWER_RECOVER = 2
|
|
40
|
+
ARM_DISARM = 4
|
|
41
|
+
HOST_LOW_VOLTAGE = 8
|
|
42
|
+
SENSOR_LOW_VOLTAGE = 16
|
|
43
|
+
WIFI_AVAILABLE = 32
|
|
44
|
+
WIFI_UNAVAILABLE = 64
|
|
45
|
+
DOOR_OPEN = 128
|
|
46
|
+
DOOR_CLOSE = 256
|
|
47
|
+
SMS_PUSH = 512
|
|
48
|
+
UNKNOWN1 = 2048
|
|
49
|
+
UNKNOWN2 = 8192
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
_LOGGER = logging.getLogger(__name__)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@dataclass
|
|
56
|
+
class G90AlertConfigData:
|
|
57
|
+
"""
|
|
58
|
+
Represents alert configuration data as received from the alarm panel.
|
|
59
|
+
"""
|
|
60
|
+
flags_data: int
|
|
61
|
+
|
|
62
|
+
@property
|
|
63
|
+
def flags(self) -> G90AlertConfigFlags:
|
|
64
|
+
"""
|
|
65
|
+
:return: The alert configuration flags
|
|
66
|
+
"""
|
|
67
|
+
return G90AlertConfigFlags(self.flags_data)
|
|
68
|
+
|
|
69
|
+
@flags.setter
|
|
70
|
+
def flags(self, value: G90AlertConfigFlags) -> None:
|
|
71
|
+
"""
|
|
72
|
+
:param value: The alert configuration flags
|
|
73
|
+
"""
|
|
74
|
+
self.flags_data = value.value
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class G90AlertConfig:
|
|
78
|
+
"""
|
|
79
|
+
Represents alert configuration as received from the alarm panel.
|
|
80
|
+
"""
|
|
81
|
+
def __init__(self, parent: G90Alarm) -> None:
|
|
82
|
+
self.parent = parent
|
|
83
|
+
|
|
84
|
+
async def _get(self) -> G90AlertConfigData:
|
|
85
|
+
"""
|
|
86
|
+
Retrieves the alert configuration flags directly from the device.
|
|
87
|
+
|
|
88
|
+
:return: The alerts configured
|
|
89
|
+
"""
|
|
90
|
+
_LOGGER.debug('Retrieving alert configuration from the device')
|
|
91
|
+
res = await self.parent.command(G90Commands.GETNOTICEFLAG)
|
|
92
|
+
data = G90AlertConfigData(*res)
|
|
93
|
+
_LOGGER.debug(
|
|
94
|
+
'Alert configuration: %s, flags: %s', data,
|
|
95
|
+
repr(data.flags)
|
|
96
|
+
)
|
|
97
|
+
return data
|
|
98
|
+
|
|
99
|
+
async def set(self, flags: G90AlertConfigFlags) -> None:
|
|
100
|
+
"""
|
|
101
|
+
.. deprecated:: 2.3.0
|
|
102
|
+
|
|
103
|
+
This method is deprecated and will always raise a RuntimeError.
|
|
104
|
+
Please use :meth:`set_flag` to set individual flags.
|
|
105
|
+
"""
|
|
106
|
+
raise RuntimeError(
|
|
107
|
+
'The set() method is deprecated. Please use set_flag() to set'
|
|
108
|
+
' individual flags instead.'
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
async def _set(self, flags: G90AlertConfigFlags) -> None:
|
|
112
|
+
"""
|
|
113
|
+
Sets the alert configuration flags on the device.
|
|
114
|
+
"""
|
|
115
|
+
_LOGGER.debug('Setting alert configuration to %s', repr(flags))
|
|
116
|
+
await self.parent.command(G90Commands.SETNOTICEFLAG, [flags.value])
|
|
117
|
+
|
|
118
|
+
async def get_flag(self, flag: G90AlertConfigFlags) -> bool:
|
|
119
|
+
"""
|
|
120
|
+
:param flag: The flag to check
|
|
121
|
+
"""
|
|
122
|
+
return flag in await self.flags
|
|
123
|
+
|
|
124
|
+
async def set_flag(self, flag: G90AlertConfigFlags, value: bool) -> None:
|
|
125
|
+
"""
|
|
126
|
+
Sets the given flag to the desired value.
|
|
127
|
+
|
|
128
|
+
Uses read-modify-write approach.
|
|
129
|
+
|
|
130
|
+
:param flag: The flag to set
|
|
131
|
+
:param value: The value to set
|
|
132
|
+
"""
|
|
133
|
+
# Retrieve current flags
|
|
134
|
+
current_flags = await self.flags
|
|
135
|
+
# Skip updating the flag if it has the desired value
|
|
136
|
+
if (flag in current_flags) == value:
|
|
137
|
+
_LOGGER.debug(
|
|
138
|
+
'Flag %s already set to %s, skipping update',
|
|
139
|
+
repr(flag), value
|
|
140
|
+
)
|
|
141
|
+
return
|
|
142
|
+
|
|
143
|
+
# Set or reset corresponding user flag depending on desired value
|
|
144
|
+
if value:
|
|
145
|
+
current_flags |= flag
|
|
146
|
+
else:
|
|
147
|
+
current_flags &= ~flag
|
|
148
|
+
|
|
149
|
+
# Set the updated flags
|
|
150
|
+
await self._set(current_flags)
|
|
151
|
+
|
|
152
|
+
@property
|
|
153
|
+
async def flags(self) -> G90AlertConfigFlags:
|
|
154
|
+
"""
|
|
155
|
+
:return: Symbolic names for corresponding flag bits
|
|
156
|
+
"""
|
|
157
|
+
return (await self._get()).flags
|