tecnoctl 0.1.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.
- tecnoctl/__init__.py +5 -0
- tecnoctl/cli.py +278 -0
- tecnoctl/client.py +846 -0
- tecnoctl-0.1.0.dist-info/METADATA +79 -0
- tecnoctl-0.1.0.dist-info/RECORD +9 -0
- tecnoctl-0.1.0.dist-info/WHEEL +5 -0
- tecnoctl-0.1.0.dist-info/entry_points.txt +2 -0
- tecnoctl-0.1.0.dist-info/licenses/LICENSE +21 -0
- tecnoctl-0.1.0.dist-info/top_level.txt +1 -0
tecnoctl/__init__.py
ADDED
tecnoctl/cli.py
ADDED
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
"""Command-line interface for :class:`tecnoctl.AlarmClient`."""
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import getpass
|
|
5
|
+
import json
|
|
6
|
+
import logging
|
|
7
|
+
import math
|
|
8
|
+
import os
|
|
9
|
+
import sys
|
|
10
|
+
import time
|
|
11
|
+
|
|
12
|
+
from dotenv import load_dotenv
|
|
13
|
+
|
|
14
|
+
from .client import AlarmClient, ProtocolError
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _one_based(value):
|
|
18
|
+
try:
|
|
19
|
+
number = int(value)
|
|
20
|
+
except ValueError as exc:
|
|
21
|
+
raise argparse.ArgumentTypeError("must be an integer") from exc
|
|
22
|
+
if not 1 <= number <= 65536:
|
|
23
|
+
raise argparse.ArgumentTypeError("must be between 1 and 65536")
|
|
24
|
+
return number - 1
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _app_id(value):
|
|
28
|
+
try:
|
|
29
|
+
number = int(value, 0)
|
|
30
|
+
except ValueError as exc:
|
|
31
|
+
raise argparse.ArgumentTypeError(
|
|
32
|
+
"must be a decimal or 0x-prefixed integer"
|
|
33
|
+
) from exc
|
|
34
|
+
if not 0 <= number <= 0xFFFF:
|
|
35
|
+
raise argparse.ArgumentTypeError("must fit in 16 bits")
|
|
36
|
+
return number
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _positive(value):
|
|
40
|
+
try:
|
|
41
|
+
number = int(value)
|
|
42
|
+
except ValueError as exc:
|
|
43
|
+
raise argparse.ArgumentTypeError("must be an integer") from exc
|
|
44
|
+
if number < 1:
|
|
45
|
+
raise argparse.ArgumentTypeError("must be at least 1")
|
|
46
|
+
return number
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _nonnegative(value):
|
|
50
|
+
try:
|
|
51
|
+
number = int(value)
|
|
52
|
+
except ValueError as exc:
|
|
53
|
+
raise argparse.ArgumentTypeError("must be an integer") from exc
|
|
54
|
+
if number < 0:
|
|
55
|
+
raise argparse.ArgumentTypeError("cannot be negative")
|
|
56
|
+
return number
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _watch_interval(value):
|
|
60
|
+
try:
|
|
61
|
+
number = float(value)
|
|
62
|
+
except ValueError as exc:
|
|
63
|
+
raise argparse.ArgumentTypeError("must be a number") from exc
|
|
64
|
+
if not 5 <= number < float("inf"):
|
|
65
|
+
raise argparse.ArgumentTypeError("must be at least 5 seconds")
|
|
66
|
+
return number
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _clear_status_line():
|
|
70
|
+
if sys.stderr.isatty():
|
|
71
|
+
print("\r\033[2K", end="", file=sys.stderr, flush=True)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _countdown(seconds):
|
|
75
|
+
if not sys.stderr.isatty():
|
|
76
|
+
time.sleep(seconds)
|
|
77
|
+
return
|
|
78
|
+
deadline = time.monotonic() + seconds
|
|
79
|
+
while (remaining := deadline - time.monotonic()) > 0:
|
|
80
|
+
print(
|
|
81
|
+
f"\r\033[2Knext request in {math.ceil(remaining)} s",
|
|
82
|
+
end="",
|
|
83
|
+
file=sys.stderr,
|
|
84
|
+
flush=True,
|
|
85
|
+
)
|
|
86
|
+
time.sleep(min(1, remaining))
|
|
87
|
+
_clear_status_line()
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _parser():
|
|
91
|
+
parser = argparse.ArgumentParser(
|
|
92
|
+
description="Control a Tecnoalarm panel over direct TCP"
|
|
93
|
+
)
|
|
94
|
+
parser.add_argument("host", help="panel hostname or IP address")
|
|
95
|
+
parser.add_argument("--port", type=int, default=10001)
|
|
96
|
+
parser.add_argument("--timeout", type=float, default=10.0)
|
|
97
|
+
parser.add_argument(
|
|
98
|
+
"--debug", action="store_true", help="log connection and protocol diagnostics"
|
|
99
|
+
)
|
|
100
|
+
parser.add_argument(
|
|
101
|
+
"-v", "--verbose", action="store_true", help="log connection status"
|
|
102
|
+
)
|
|
103
|
+
parser.add_argument(
|
|
104
|
+
"--app-id",
|
|
105
|
+
type=_app_id,
|
|
106
|
+
help="16-bit client ID (default: stable value derived from this machine)",
|
|
107
|
+
)
|
|
108
|
+
parser.add_argument(
|
|
109
|
+
"--passphrase", help="network passphrase (prefer TECNOCTL_PASSPHRASE)"
|
|
110
|
+
)
|
|
111
|
+
parser.add_argument(
|
|
112
|
+
"--code", help="4-6 digit user code (prefer TECNOCTL_CODE)"
|
|
113
|
+
)
|
|
114
|
+
commands = parser.add_subparsers(dest="command", required=True)
|
|
115
|
+
commands.add_parser("info", help="panel model, limits, session, and raw information")
|
|
116
|
+
commands.add_parser("clock", help="raw panel clock record")
|
|
117
|
+
commands.add_parser("status", help="complete panel/program/remote status")
|
|
118
|
+
commands.add_parser("panel-status", help="decoded central-unit status and all flags")
|
|
119
|
+
commands.add_parser("group-status", help="program and remote status without names")
|
|
120
|
+
commands.add_parser("permissions", help="program and remote permissions for this code")
|
|
121
|
+
commands.add_parser("programs", help="program IDs, names, permissions, and states")
|
|
122
|
+
commands.add_parser("remotes", help="remote-control IDs, names, permissions, and states")
|
|
123
|
+
zones = commands.add_parser("zones", help="zone IDs, names, and states")
|
|
124
|
+
zones.add_argument("--start", type=_one_based, default=0, metavar="ZONE")
|
|
125
|
+
zones.add_argument("--count", type=_positive)
|
|
126
|
+
zones.add_argument("--filter", choices=("all", "open", "isolated"), default="all")
|
|
127
|
+
code_names = commands.add_parser("code-names", help="code labels only; never PIN digits")
|
|
128
|
+
code_names.add_argument("--start", type=_one_based, default=0, metavar="CODE")
|
|
129
|
+
code_names.add_argument("--count", type=_positive)
|
|
130
|
+
events = commands.add_parser("events", help="event log; use --limit 0 for all")
|
|
131
|
+
events.add_argument("--limit", type=_nonnegative, default=50)
|
|
132
|
+
watch = commands.add_parser(
|
|
133
|
+
"watch", help="print alarm and program-state events as JSON lines"
|
|
134
|
+
)
|
|
135
|
+
watch.add_argument("--interval", type=_watch_interval, default=30.0)
|
|
136
|
+
watch.add_argument(
|
|
137
|
+
"--debug",
|
|
138
|
+
action="store_true",
|
|
139
|
+
default=argparse.SUPPRESS,
|
|
140
|
+
help="log connection, protocol, and poll diagnostics",
|
|
141
|
+
)
|
|
142
|
+
watch.add_argument(
|
|
143
|
+
"-v",
|
|
144
|
+
"--verbose",
|
|
145
|
+
action="store_true",
|
|
146
|
+
default=argparse.SUPPRESS,
|
|
147
|
+
help="log connection status and meaningful poll activity",
|
|
148
|
+
)
|
|
149
|
+
commands.add_parser("sync", help="full configuration and status synchronization")
|
|
150
|
+
open_zones = commands.add_parser("open-zones", help="open zones blocking a program")
|
|
151
|
+
open_zones.add_argument("program", type=_one_based)
|
|
152
|
+
arm = commands.add_parser("arm", help="arm a program")
|
|
153
|
+
arm.add_argument("program", type=_one_based)
|
|
154
|
+
arm.add_argument("--exclude-open", action="store_true")
|
|
155
|
+
disarm = commands.add_parser("disarm", help="disarm a program")
|
|
156
|
+
disarm.add_argument("program", type=_one_based)
|
|
157
|
+
remote_on = commands.add_parser("remote-on", help="turn a remote control on")
|
|
158
|
+
remote_on.add_argument("remote", type=_one_based)
|
|
159
|
+
remote_off = commands.add_parser("remote-off", help="turn a remote control off")
|
|
160
|
+
remote_off.add_argument("remote", type=_one_based)
|
|
161
|
+
zone_status = commands.add_parser("zone-status", help="read one zone")
|
|
162
|
+
zone_status.add_argument("zone", type=_one_based)
|
|
163
|
+
isolate = commands.add_parser("isolate", help="isolate a zone (master code required)")
|
|
164
|
+
isolate.add_argument("zone", type=_one_based)
|
|
165
|
+
reintegrate = commands.add_parser(
|
|
166
|
+
"reintegrate", help="reintegrate a zone (master code required)"
|
|
167
|
+
)
|
|
168
|
+
reintegrate.add_argument("zone", type=_one_based)
|
|
169
|
+
return parser
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def main():
|
|
173
|
+
load_dotenv(".env")
|
|
174
|
+
parser = _parser()
|
|
175
|
+
args = parser.parse_args()
|
|
176
|
+
log_level = logging.DEBUG if args.debug else logging.INFO if args.verbose else None
|
|
177
|
+
if log_level is not None:
|
|
178
|
+
logging.basicConfig(
|
|
179
|
+
level=log_level,
|
|
180
|
+
format="%(asctime)s %(levelname)s %(message)s",
|
|
181
|
+
datefmt="%H:%M:%S",
|
|
182
|
+
)
|
|
183
|
+
passphrase = args.passphrase or os.getenv("TECNOCTL_PASSPHRASE")
|
|
184
|
+
code = args.code or os.getenv("TECNOCTL_CODE")
|
|
185
|
+
if passphrase is None:
|
|
186
|
+
passphrase = getpass.getpass("Network passphrase: ")
|
|
187
|
+
if code is None:
|
|
188
|
+
code = getpass.getpass("Access code: ")
|
|
189
|
+
|
|
190
|
+
try:
|
|
191
|
+
client = AlarmClient(
|
|
192
|
+
args.host,
|
|
193
|
+
passphrase,
|
|
194
|
+
code,
|
|
195
|
+
port=args.port,
|
|
196
|
+
app_id=args.app_id,
|
|
197
|
+
timeout=args.timeout,
|
|
198
|
+
)
|
|
199
|
+
if args.command == "watch":
|
|
200
|
+
print(
|
|
201
|
+
f"watching {args.host}:{args.port} every {args.interval:g} seconds; "
|
|
202
|
+
"Ctrl-C to stop",
|
|
203
|
+
file=sys.stderr,
|
|
204
|
+
)
|
|
205
|
+
try:
|
|
206
|
+
for event in client.watch(args.interval, wait=_countdown):
|
|
207
|
+
_clear_status_line()
|
|
208
|
+
print(json.dumps(event), flush=True)
|
|
209
|
+
except KeyboardInterrupt:
|
|
210
|
+
pass
|
|
211
|
+
finally:
|
|
212
|
+
_clear_status_line()
|
|
213
|
+
client.close()
|
|
214
|
+
return
|
|
215
|
+
|
|
216
|
+
with client as alarm:
|
|
217
|
+
if args.command == "info":
|
|
218
|
+
result = alarm.panel_info()
|
|
219
|
+
elif args.command == "clock":
|
|
220
|
+
result = {"raw": alarm.clock.hex()}
|
|
221
|
+
elif args.command == "status":
|
|
222
|
+
result = alarm.status()
|
|
223
|
+
elif args.command == "panel-status":
|
|
224
|
+
result = alarm.panel_status()
|
|
225
|
+
elif args.command == "group-status":
|
|
226
|
+
result = alarm.group_status()
|
|
227
|
+
elif args.command == "permissions":
|
|
228
|
+
result = alarm.permissions()
|
|
229
|
+
elif args.command == "programs":
|
|
230
|
+
result = alarm.programs()
|
|
231
|
+
elif args.command == "remotes":
|
|
232
|
+
result = alarm.remotes()
|
|
233
|
+
elif args.command == "zones":
|
|
234
|
+
result = alarm.zones(args.start, args.count)
|
|
235
|
+
if args.filter != "all":
|
|
236
|
+
result = [zone for zone in result if zone[args.filter]]
|
|
237
|
+
elif args.command == "code-names":
|
|
238
|
+
result = alarm.code_names(args.start, args.count)
|
|
239
|
+
elif args.command == "events":
|
|
240
|
+
result = alarm.events(args.limit)
|
|
241
|
+
elif args.command == "sync":
|
|
242
|
+
result = alarm.sync()
|
|
243
|
+
elif args.command == "open-zones":
|
|
244
|
+
result = {"zones": [zone + 1 for zone in alarm.open_zones(args.program)]}
|
|
245
|
+
else:
|
|
246
|
+
_run_action(alarm, args)
|
|
247
|
+
return
|
|
248
|
+
print(json.dumps(result, indent=2))
|
|
249
|
+
except (OSError, ProtocolError, ValueError) as exc:
|
|
250
|
+
parser.exit(1, f"error: {exc}\n")
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def _run_action(alarm, args):
|
|
254
|
+
if args.command == "arm":
|
|
255
|
+
excluded = alarm.arm(args.program, args.exclude_open)
|
|
256
|
+
suffix = (
|
|
257
|
+
f"; excluded zones {', '.join(str(zone + 1) for zone in excluded)}"
|
|
258
|
+
if excluded
|
|
259
|
+
else ""
|
|
260
|
+
)
|
|
261
|
+
print(f"program {args.program + 1} armed{suffix}")
|
|
262
|
+
elif args.command == "disarm":
|
|
263
|
+
alarm.disarm(args.program)
|
|
264
|
+
print(f"program {args.program + 1} disarmed")
|
|
265
|
+
elif args.command in ("remote-on", "remote-off"):
|
|
266
|
+
enabled = args.command == "remote-on"
|
|
267
|
+
alarm.remote(args.remote, enabled)
|
|
268
|
+
print(f"remote {args.remote + 1} {'on' if enabled else 'off'}")
|
|
269
|
+
elif args.command == "zone-status":
|
|
270
|
+
print(json.dumps(alarm.zone_status(args.zone), indent=2))
|
|
271
|
+
else:
|
|
272
|
+
isolated = args.command == "isolate"
|
|
273
|
+
alarm.set_zone_isolation(args.zone, isolated)
|
|
274
|
+
print(f"zone {args.zone + 1} {'isolated' if isolated else 'reintegrated'}")
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
if __name__ == "__main__":
|
|
278
|
+
main()
|
tecnoctl/client.py
ADDED
|
@@ -0,0 +1,846 @@
|
|
|
1
|
+
"""Reusable client for the myTecnoalarm direct encrypted-TCP protocol."""
|
|
2
|
+
|
|
3
|
+
from datetime import datetime, timedelta
|
|
4
|
+
import logging
|
|
5
|
+
import secrets
|
|
6
|
+
import socket
|
|
7
|
+
import struct
|
|
8
|
+
import time
|
|
9
|
+
import uuid
|
|
10
|
+
|
|
11
|
+
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms
|
|
12
|
+
from cryptography.hazmat.decrepit.ciphers.modes import CFB
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
logger = logging.getLogger(__name__)
|
|
16
|
+
|
|
17
|
+
DLE, STX, ACK, RDY, NAK, BUSY = 0x10, 0x02, 0x06, 0x0C, 0x15, 0x0F
|
|
18
|
+
BRIDGE_RECORD = 2304
|
|
19
|
+
EVOLUTION_MODELS = {49, 50, 57, 58}
|
|
20
|
+
LONG_TEXT_MODELS = {35, 36, 38, 39, 42, 43, 44, 45, 46, 47, 48, 49, 50, 57, 58}
|
|
21
|
+
MODEL_NAMES = {
|
|
22
|
+
33: "TP888", 34: "TP888E", 35: "TP440", 36: "TP440E",
|
|
23
|
+
38: "TP42", 39: "TP42E", 42: "EV424", 43: "EV424E",
|
|
24
|
+
45: "TP888P", 46: "TP888PE", 47: "TP312", 48: "TP312E",
|
|
25
|
+
49: "EV50", 50: "EV50E", 57: "EV150", 58: "EV150E",
|
|
26
|
+
}
|
|
27
|
+
MODEL_LIMITS = {
|
|
28
|
+
13: (32, 16, 256, 201, 2000),
|
|
29
|
+
24: (32, 32, 512, 301, 2000),
|
|
30
|
+
25: (8, 8, 96, 201, 2000),
|
|
31
|
+
29: (8, 8, 28, 121, 1500), 30: (8, 8, 28, 121, 1500),
|
|
32
|
+
31: (8, 8, 28, 121, 1500), 32: (8, 8, 28, 121, 1500),
|
|
33
|
+
33: (8, 8, 88, 201, 1500), 34: (8, 8, 88, 201, 1500),
|
|
34
|
+
35: (32, 32, 440, 301, 2000), 36: (32, 32, 440, 301, 2000),
|
|
35
|
+
38: (8, 8, 42, 121, 1500), 39: (8, 8, 42, 121, 1500),
|
|
36
|
+
40: (8, 8, 28, 121, 1500), 41: (8, 8, 28, 121, 1500),
|
|
37
|
+
42: (6, 6, 24, 49, 2000), 43: (6, 6, 24, 49, 2000),
|
|
38
|
+
44: (32, 32, 440, 301, 2000),
|
|
39
|
+
45: (16, 16, 88, 201, 1500), 46: (16, 16, 88, 201, 1500),
|
|
40
|
+
47: (32, 32, 312, 301, 2000), 48: (32, 32, 312, 301, 2000),
|
|
41
|
+
49: (8, 32, 50, 121, 2000), 50: (8, 32, 50, 121, 2000),
|
|
42
|
+
57: (16, 32, 150, 201, 2000), 58: (16, 32, 150, 201, 2000),
|
|
43
|
+
}
|
|
44
|
+
RECORD_NAMES = {
|
|
45
|
+
1: "clock", 2305: "authentication", 2306: "operation",
|
|
46
|
+
2307: "program description", 2308: "remote description",
|
|
47
|
+
2309: "code description", 2310: "zone description", 2311: "start event log",
|
|
48
|
+
2312: "event log", 2313: "panel information", 2314: "permissions",
|
|
49
|
+
2316: "zone status", 2317: "program/remote status", 2318: "panel status",
|
|
50
|
+
2319: "priority", 2320: "isolate zone", 2321: "reintegrate zone",
|
|
51
|
+
2339: "Evolution remote description", 2340: "Evolution program/remote status",
|
|
52
|
+
}
|
|
53
|
+
PROGRAM_STATE_NAMES = {
|
|
54
|
+
0: "disarmed", 1: "pre_exit", 2: "exit", 3: "armed",
|
|
55
|
+
4: "partial_exit", 5: "partial", 6: "partial_end",
|
|
56
|
+
}
|
|
57
|
+
GENERAL_STATUS_BITS = (
|
|
58
|
+
("standby", "fault", "battery_alarm", "power_alarm", "tamper_active", "anomaly_active", "robbery_active", "technical_active"),
|
|
59
|
+
("chime", "line_status", "prealarm", "program_alarm", "access_denied", "alarm", "system_ok", "cellular_status"),
|
|
60
|
+
("tamper_alarm", "anomaly_alarm", "false_code_alarm", "false_key_alarm", "alive_alarm", "mask_alarm", "robbery_alarm", "technical_alarm"),
|
|
61
|
+
("generic_memory", "exit_time", "maintenance", "call_active", "partial_warning", "automatic_warning", "zones_isolated", "mask_active"),
|
|
62
|
+
("tamper_memory", "anomaly_memory", "false_code_memory", "false_key_memory", "call_memory", "battery_memory", "power_memory", "line_memory"),
|
|
63
|
+
("cellular_memory", "voice_memory_expired", "answerer_on", "internal_siren", "external_siren", "output_1", "output_2", "local_expansion"),
|
|
64
|
+
("panic", "failure_alarm", "failure_active", "mask_key_active", "output_3", "output_4", "isolation_inhibited", "failure_memory"),
|
|
65
|
+
("tecno_output_internal_siren", "tecno_output_external_siren", "tecno_output_1", "tecno_output_2", "tecno_output_3", "tecno_output_4", "reserved_6", "reserved_7"),
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class ProtocolError(RuntimeError):
|
|
70
|
+
pass
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _crc16(data):
|
|
74
|
+
crc = 0xFFFF
|
|
75
|
+
for byte in data:
|
|
76
|
+
crc ^= byte
|
|
77
|
+
for _ in range(8):
|
|
78
|
+
crc = (crc >> 1) ^ (0xA001 if crc & 1 else 0)
|
|
79
|
+
return crc
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _bridge_frame(control, session, record, index=0, payload=b""):
|
|
83
|
+
body = struct.pack(
|
|
84
|
+
"<HHHHHH", BRIDGE_RECORD, session, len(payload) + 6, record, index, len(payload)
|
|
85
|
+
) + payload
|
|
86
|
+
return bytes((DLE, control)) + body + struct.pack("<H", _crc16(body))
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _stuff(frame):
|
|
90
|
+
return frame[:2] + frame[2:].replace(b"\x10", b"\x10\x10")
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _take_frame(buffer):
|
|
94
|
+
"""Return (unescaped frame, consumed bytes), or None for incomplete input."""
|
|
95
|
+
if len(buffer) < 2:
|
|
96
|
+
return None
|
|
97
|
+
if buffer[0] != DLE:
|
|
98
|
+
raise ProtocolError(f"invalid response prefix 0x{buffer[0]:02x}")
|
|
99
|
+
|
|
100
|
+
offset = 0
|
|
101
|
+
if buffer[1] == BUSY:
|
|
102
|
+
if len(buffer) < 4:
|
|
103
|
+
return None
|
|
104
|
+
if buffer[2] != DLE:
|
|
105
|
+
raise ProtocolError("invalid BUSY response")
|
|
106
|
+
offset = 2
|
|
107
|
+
|
|
108
|
+
control = buffer[offset + 1]
|
|
109
|
+
if control in (ACK, NAK):
|
|
110
|
+
return bytes(buffer[: offset + 2]), offset + 2
|
|
111
|
+
if control != RDY:
|
|
112
|
+
raise ProtocolError(f"unexpected control byte 0x{control:02x}")
|
|
113
|
+
|
|
114
|
+
decoded = bytearray(buffer[: offset + 2])
|
|
115
|
+
cursor = offset + 2
|
|
116
|
+
wanted = None
|
|
117
|
+
while cursor < len(buffer):
|
|
118
|
+
byte = buffer[cursor]
|
|
119
|
+
decoded.append(byte)
|
|
120
|
+
cursor += 1
|
|
121
|
+
if byte == DLE:
|
|
122
|
+
if cursor == len(buffer):
|
|
123
|
+
return None
|
|
124
|
+
if buffer[cursor] != DLE:
|
|
125
|
+
raise ProtocolError("invalid DLE escaping")
|
|
126
|
+
cursor += 1
|
|
127
|
+
|
|
128
|
+
if wanted is None and len(decoded) >= offset + 14:
|
|
129
|
+
payload_length = int.from_bytes(decoded[offset + 12 : offset + 14], "little")
|
|
130
|
+
if payload_length > 4096:
|
|
131
|
+
raise ProtocolError(f"implausible payload length {payload_length}")
|
|
132
|
+
wanted = offset + payload_length + 16
|
|
133
|
+
if wanted is not None and len(decoded) == wanted:
|
|
134
|
+
return bytes(decoded), cursor
|
|
135
|
+
if wanted is not None and len(decoded) > wanted:
|
|
136
|
+
raise ProtocolError("response exceeded declared length")
|
|
137
|
+
return None
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _parse_response(frame, session, record, index, expected_length):
|
|
141
|
+
offset = 2 if frame[1] == BUSY else 0
|
|
142
|
+
control = frame[offset + 1]
|
|
143
|
+
if control == NAK:
|
|
144
|
+
raise ProtocolError("alarm rejected the request (NAK)")
|
|
145
|
+
if control == ACK:
|
|
146
|
+
if expected_length:
|
|
147
|
+
raise ProtocolError("alarm returned ACK without the expected data")
|
|
148
|
+
return b""
|
|
149
|
+
if control != RDY:
|
|
150
|
+
raise ProtocolError(f"unexpected response control 0x{control:02x}")
|
|
151
|
+
|
|
152
|
+
outer, response_session, inner_length, response_record, response_index, payload_length = (
|
|
153
|
+
struct.unpack_from("<HHHHHH", frame, offset + 2)
|
|
154
|
+
)
|
|
155
|
+
if outer != BRIDGE_RECORD:
|
|
156
|
+
raise ProtocolError(f"unexpected bridge record {outer}")
|
|
157
|
+
if response_session != session:
|
|
158
|
+
raise ProtocolError(f"session mismatch: expected {session}, got {response_session}")
|
|
159
|
+
if inner_length != payload_length + 6:
|
|
160
|
+
raise ProtocolError("invalid inner length")
|
|
161
|
+
if response_record != record or response_index != index:
|
|
162
|
+
raise ProtocolError(
|
|
163
|
+
f"response mismatch: record/index {response_record}/{response_index}"
|
|
164
|
+
)
|
|
165
|
+
if payload_length != expected_length:
|
|
166
|
+
raise ProtocolError(
|
|
167
|
+
f"unexpected payload length: expected {expected_length}, got {payload_length}"
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
body_start = offset + 2
|
|
171
|
+
payload_start = offset + 14
|
|
172
|
+
payload_end = payload_start + payload_length
|
|
173
|
+
received_crc = int.from_bytes(frame[payload_end : payload_end + 2], "little")
|
|
174
|
+
if received_crc != _crc16(frame[body_start:payload_end]):
|
|
175
|
+
raise ProtocolError("response CRC mismatch")
|
|
176
|
+
return frame[payload_start:payload_end]
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
class AlarmClient:
|
|
180
|
+
"""A direct TCP session with a Tecnoalarm panel.
|
|
181
|
+
|
|
182
|
+
Program, remote, and zone arguments use zero-based indexes. Returned IDs
|
|
183
|
+
are one-based for display.
|
|
184
|
+
"""
|
|
185
|
+
|
|
186
|
+
def __init__(
|
|
187
|
+
self, host, passphrase, code, *, port=10001, app_id=None, timeout=10.0
|
|
188
|
+
):
|
|
189
|
+
if app_id is None:
|
|
190
|
+
app_id = uuid.getnode() & 0xFFFF or 1
|
|
191
|
+
if not host:
|
|
192
|
+
raise ValueError("host is required")
|
|
193
|
+
if not 1 <= port <= 65535:
|
|
194
|
+
raise ValueError("port must be between 1 and 65535")
|
|
195
|
+
if timeout <= 0:
|
|
196
|
+
raise ValueError("timeout must be positive")
|
|
197
|
+
if not code.isdigit() or not 4 <= len(code) <= 6:
|
|
198
|
+
raise ValueError("access code must contain 4 to 6 digits")
|
|
199
|
+
if not passphrase:
|
|
200
|
+
raise ValueError("passphrase is required")
|
|
201
|
+
if not 0 <= app_id <= 0xFFFF:
|
|
202
|
+
raise ValueError("app ID must fit in 16 bits")
|
|
203
|
+
|
|
204
|
+
self.host, self.port, self.timeout = host, port, timeout
|
|
205
|
+
self.key = bytes(ord(char) & 0xFF for char in passphrase[:16].ljust(16))
|
|
206
|
+
self.code, self.app_id = code, app_id
|
|
207
|
+
self.session = 0
|
|
208
|
+
self.clock = b""
|
|
209
|
+
self._info = None
|
|
210
|
+
self._permissions = None
|
|
211
|
+
self.sock = self.encryptor = self.decryptor = None
|
|
212
|
+
self.rx = bytearray()
|
|
213
|
+
|
|
214
|
+
def __enter__(self):
|
|
215
|
+
return self.connect()
|
|
216
|
+
|
|
217
|
+
def __exit__(self, *_):
|
|
218
|
+
self.close()
|
|
219
|
+
|
|
220
|
+
def connect(self):
|
|
221
|
+
logger.debug(
|
|
222
|
+
"connecting to %s:%d (timeout=%g seconds)",
|
|
223
|
+
self.host,
|
|
224
|
+
self.port,
|
|
225
|
+
self.timeout,
|
|
226
|
+
)
|
|
227
|
+
try:
|
|
228
|
+
self.session = 0
|
|
229
|
+
self.clock = b""
|
|
230
|
+
self._info = self._permissions = None
|
|
231
|
+
self.rx.clear()
|
|
232
|
+
self.sock = socket.create_connection((self.host, self.port), self.timeout)
|
|
233
|
+
self.sock.settimeout(self.timeout)
|
|
234
|
+
logger.debug("TCP connection established; starting encrypted handshake")
|
|
235
|
+
iv = secrets.token_bytes(16)
|
|
236
|
+
cipher = Cipher(algorithms.AES(self.key), CFB(iv))
|
|
237
|
+
self.encryptor, self.decryptor = cipher.encryptor(), cipher.decryptor()
|
|
238
|
+
time.sleep(0.5) # The official app gives the panel time to enter crypto mode.
|
|
239
|
+
self.sock.sendall(iv)
|
|
240
|
+
|
|
241
|
+
try:
|
|
242
|
+
self.clock = self._exchange(1, expected_length=10)
|
|
243
|
+
except (OSError, ProtocolError) as exc:
|
|
244
|
+
raise ProtocolError(
|
|
245
|
+
"clock handshake (record 1) failed: "
|
|
246
|
+
f"{exc}; check the direct-TCP port and network passphrase"
|
|
247
|
+
) from exc
|
|
248
|
+
logger.debug("clock handshake succeeded")
|
|
249
|
+
auth = bytearray(48)
|
|
250
|
+
auth[:2] = self.app_id.to_bytes(2, "little")
|
|
251
|
+
auth[2 : 2 + len(self.code)] = bytes(map(int, self.code))
|
|
252
|
+
auth[8] = 1
|
|
253
|
+
try:
|
|
254
|
+
response = self._exchange(2305, payload=auth, expected_length=3)
|
|
255
|
+
except (OSError, ProtocolError) as exc:
|
|
256
|
+
raise ProtocolError(
|
|
257
|
+
"authentication handshake (record 2305) failed: "
|
|
258
|
+
f"{exc}; check the access code, app ID, and user permissions"
|
|
259
|
+
) from exc
|
|
260
|
+
if response[0] != ACK:
|
|
261
|
+
raise ProtocolError(
|
|
262
|
+
"authentication handshake (record 2305) returned "
|
|
263
|
+
f"{response.hex()} instead of ACK; check the access code, app ID, "
|
|
264
|
+
"and user permissions"
|
|
265
|
+
)
|
|
266
|
+
self.session = int.from_bytes(response[1:3], "little")
|
|
267
|
+
logger.info(
|
|
268
|
+
"connected to %s:%d; authenticated session=%d",
|
|
269
|
+
self.host,
|
|
270
|
+
self.port,
|
|
271
|
+
self.session,
|
|
272
|
+
)
|
|
273
|
+
return self
|
|
274
|
+
except Exception as exc:
|
|
275
|
+
logger.info("connection failed: %s", exc)
|
|
276
|
+
self.close()
|
|
277
|
+
raise
|
|
278
|
+
|
|
279
|
+
def close(self):
|
|
280
|
+
if self.sock is not None:
|
|
281
|
+
self.sock.close()
|
|
282
|
+
self.sock = None
|
|
283
|
+
logger.debug("connection closed")
|
|
284
|
+
|
|
285
|
+
def _exchange(self, record, index=0, payload=b"", expected_length=0):
|
|
286
|
+
name = RECORD_NAMES.get(record, "unknown")
|
|
287
|
+
started = time.monotonic()
|
|
288
|
+
logger.debug(
|
|
289
|
+
"requesting %s (record=%d index=%d payload=%d bytes)",
|
|
290
|
+
name,
|
|
291
|
+
record,
|
|
292
|
+
index,
|
|
293
|
+
len(payload),
|
|
294
|
+
)
|
|
295
|
+
try:
|
|
296
|
+
frame = _bridge_frame(STX, self.session, record, index, bytes(payload))
|
|
297
|
+
self.sock.sendall(self.encryptor.update(_stuff(frame)))
|
|
298
|
+
while True:
|
|
299
|
+
complete = _take_frame(self.rx)
|
|
300
|
+
if complete is not None:
|
|
301
|
+
response, consumed = complete
|
|
302
|
+
del self.rx[:consumed]
|
|
303
|
+
result = _parse_response(
|
|
304
|
+
response, self.session, record, index, expected_length
|
|
305
|
+
)
|
|
306
|
+
logger.debug(
|
|
307
|
+
"received %s (record=%d index=%d response=%d bytes) in %.3fs",
|
|
308
|
+
name,
|
|
309
|
+
record,
|
|
310
|
+
index,
|
|
311
|
+
len(result),
|
|
312
|
+
time.monotonic() - started,
|
|
313
|
+
)
|
|
314
|
+
return result
|
|
315
|
+
chunk = self.sock.recv(4096)
|
|
316
|
+
if not chunk:
|
|
317
|
+
raise ProtocolError("alarm closed the connection")
|
|
318
|
+
self.rx.extend(self.decryptor.update(chunk))
|
|
319
|
+
except (OSError, ProtocolError) as exc:
|
|
320
|
+
logger.debug(
|
|
321
|
+
"%s request failed after %.3fs: %s",
|
|
322
|
+
name,
|
|
323
|
+
time.monotonic() - started,
|
|
324
|
+
exc,
|
|
325
|
+
)
|
|
326
|
+
raise ProtocolError(
|
|
327
|
+
f"{name} request (record {record}, index {index}) failed: {exc}"
|
|
328
|
+
) from exc
|
|
329
|
+
|
|
330
|
+
def _panel_info_raw(self):
|
|
331
|
+
if self._info is None:
|
|
332
|
+
self._info = self._exchange(2313, expected_length=16)
|
|
333
|
+
return self._info
|
|
334
|
+
|
|
335
|
+
def profile(self):
|
|
336
|
+
info = self._panel_info_raw()
|
|
337
|
+
programs, remotes, zones, codes, events = MODEL_LIMITS.get(
|
|
338
|
+
info[0], (8, 8, 28, 121, 1500)
|
|
339
|
+
)
|
|
340
|
+
return {
|
|
341
|
+
"model_id": info[0],
|
|
342
|
+
"model": MODEL_NAMES.get(info[0], f"model-{info[0]}"),
|
|
343
|
+
"firmware_nature": info[1],
|
|
344
|
+
"max_programs": programs,
|
|
345
|
+
"max_remotes": remotes,
|
|
346
|
+
"max_zones": zones,
|
|
347
|
+
"max_code_names": codes,
|
|
348
|
+
"event_capacity": events,
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
def panel_info(self):
|
|
352
|
+
info = self._panel_info_raw()
|
|
353
|
+
return {
|
|
354
|
+
**self.profile(),
|
|
355
|
+
"session": self.session,
|
|
356
|
+
"master_session": self.session == 1,
|
|
357
|
+
"app_id": self.app_id,
|
|
358
|
+
"raw": info.hex(),
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
def panel_status(self):
|
|
362
|
+
data = self._exchange(2318, expected_length=16)
|
|
363
|
+
general = data[8:16]
|
|
364
|
+
flags = {
|
|
365
|
+
name: bool(general[byte] & (1 << bit))
|
|
366
|
+
for byte, names in enumerate(GENERAL_STATUS_BITS)
|
|
367
|
+
for bit, name in enumerate(names)
|
|
368
|
+
}
|
|
369
|
+
flags.update(
|
|
370
|
+
battery=flags["battery_alarm"] or flags["battery_memory"],
|
|
371
|
+
powerless=flags["power_alarm"] or flags["power_memory"],
|
|
372
|
+
tamper=flags["tamper_active"] or flags["tamper_memory"],
|
|
373
|
+
anomaly=flags["anomaly_active"] or flags["anomaly_memory"],
|
|
374
|
+
false_code=flags["false_code_alarm"] or flags["false_code_memory"],
|
|
375
|
+
false_key=flags["false_key_alarm"] or flags["false_key_memory"],
|
|
376
|
+
)
|
|
377
|
+
return {
|
|
378
|
+
"firmware_nature": data[0],
|
|
379
|
+
"firmware_release": data[1],
|
|
380
|
+
"hardware_release": data[2],
|
|
381
|
+
"vocabulary_nature": data[3],
|
|
382
|
+
"vocabulary_release": data[4],
|
|
383
|
+
"supply_voltage_raw": data[5],
|
|
384
|
+
"battery_voltage_raw": data[6],
|
|
385
|
+
"phone_call_active": bool(data[7] & 0x01),
|
|
386
|
+
"answerer_active": bool(data[7] & 0x02),
|
|
387
|
+
"secure_connection": bool(data[7] & 0x04),
|
|
388
|
+
"general": flags,
|
|
389
|
+
"raw": data.hex(),
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
@staticmethod
|
|
393
|
+
def _group_layout(model):
|
|
394
|
+
if model == 13:
|
|
395
|
+
return 34, 2, 2, 32
|
|
396
|
+
if model in (24, 35, 36, 44, 47, 48):
|
|
397
|
+
return 36, 4, 4, 32
|
|
398
|
+
if model in (57, 58):
|
|
399
|
+
return 64, 4, 32, 16
|
|
400
|
+
if model in (45, 46):
|
|
401
|
+
return 20, 4, 4, 16
|
|
402
|
+
if model in (49, 50):
|
|
403
|
+
return 64, 4, 32, 8
|
|
404
|
+
if model in (42, 43):
|
|
405
|
+
return 12, 2, 4, 6
|
|
406
|
+
return 12, 4, 4, 8
|
|
407
|
+
|
|
408
|
+
def group_status(self):
|
|
409
|
+
profile = self.profile()
|
|
410
|
+
length, remote_bytes, program_offset, program_count = self._group_layout(
|
|
411
|
+
profile["model_id"]
|
|
412
|
+
)
|
|
413
|
+
record = 2340 if profile["model_id"] in EVOLUTION_MODELS else 2317
|
|
414
|
+
data = self._exchange(record, expected_length=length)
|
|
415
|
+
remote_bits = int.from_bytes(data[:remote_bytes], "little")
|
|
416
|
+
return {
|
|
417
|
+
"programs": [
|
|
418
|
+
{
|
|
419
|
+
"program": i + 1,
|
|
420
|
+
"state": value & 0x0F,
|
|
421
|
+
"state_name": PROGRAM_STATE_NAMES.get(value & 0x0F, "unknown"),
|
|
422
|
+
"state_group": (
|
|
423
|
+
"disarmed" if (value & 0x0F) == 0
|
|
424
|
+
else "partial" if (value & 0x0F) in (4, 5)
|
|
425
|
+
else "armed-or-transition"
|
|
426
|
+
),
|
|
427
|
+
"armed": (value & 0x0F) != 0,
|
|
428
|
+
"prealarm": bool(value & 0x10),
|
|
429
|
+
"alarm": bool(value & 0x20),
|
|
430
|
+
"alarm_memory": bool(value & 0x40),
|
|
431
|
+
"flags": value & 0xF0,
|
|
432
|
+
}
|
|
433
|
+
for i, value in enumerate(
|
|
434
|
+
data[
|
|
435
|
+
program_offset : program_offset
|
|
436
|
+
+ min(program_count, profile["max_programs"])
|
|
437
|
+
]
|
|
438
|
+
)
|
|
439
|
+
],
|
|
440
|
+
"remotes": [
|
|
441
|
+
{"remote": i + 1, "on": bool(remote_bits & (1 << i))}
|
|
442
|
+
for i in range(profile["max_remotes"])
|
|
443
|
+
],
|
|
444
|
+
"raw": data.hex(),
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
def permissions(self):
|
|
448
|
+
if self._permissions is not None:
|
|
449
|
+
return self._permissions
|
|
450
|
+
model = self.profile()["model_id"]
|
|
451
|
+
length = 12 if model in (35, 36, 47, 48) else 8
|
|
452
|
+
data = self._exchange(2314, index=self.session, expected_length=length)
|
|
453
|
+
program_mask = int.from_bytes(data[:4], "little")
|
|
454
|
+
remote_flag_offset = (4 if model in (35, 36, 47, 48, 57, 58) else 0) + 6
|
|
455
|
+
self._permissions = {
|
|
456
|
+
"program_mask": program_mask,
|
|
457
|
+
"programs": [
|
|
458
|
+
i + 1
|
|
459
|
+
for i in range(self.profile()["max_programs"])
|
|
460
|
+
if program_mask & (1 << i)
|
|
461
|
+
],
|
|
462
|
+
"remote_access": (
|
|
463
|
+
not bool(data[remote_flag_offset] & 0x02)
|
|
464
|
+
if remote_flag_offset < len(data)
|
|
465
|
+
else None
|
|
466
|
+
),
|
|
467
|
+
"raw": data.hex(),
|
|
468
|
+
}
|
|
469
|
+
return self._permissions
|
|
470
|
+
|
|
471
|
+
def _description(self, kind, index):
|
|
472
|
+
if kind not in ("program", "remote", "zone", "code"):
|
|
473
|
+
raise ValueError(f"unknown description type {kind!r}")
|
|
474
|
+
profile = self.profile()
|
|
475
|
+
model = profile["model_id"]
|
|
476
|
+
maximum = profile["max_code_names" if kind == "code" else f"max_{kind}s"]
|
|
477
|
+
if not 0 <= index < maximum:
|
|
478
|
+
raise ValueError(f"{kind} must be between 1 and {maximum}")
|
|
479
|
+
|
|
480
|
+
if kind == "program":
|
|
481
|
+
record, length, fallback = 2307, (32 if model in LONG_TEXT_MODELS else 24), f"P{index + 1}"
|
|
482
|
+
elif kind == "remote":
|
|
483
|
+
record = 2339 if model in EVOLUTION_MODELS else 2308
|
|
484
|
+
length = 38 if model in EVOLUTION_MODELS else (34 if model in LONG_TEXT_MODELS else 26)
|
|
485
|
+
fallback = f"T{index + 1}"
|
|
486
|
+
elif kind == "zone":
|
|
487
|
+
record, length, fallback = 2310, (32 if model in LONG_TEXT_MODELS else 24), f"Z{index + 1}"
|
|
488
|
+
else:
|
|
489
|
+
record, length, fallback = 2309, (24 if model in LONG_TEXT_MODELS else 16), f"Cod{index + 1}"
|
|
490
|
+
|
|
491
|
+
raw = self._exchange(record, index=index, expected_length=length)
|
|
492
|
+
encoded = raw[:16].split(b"\0", 1)[0]
|
|
493
|
+
encoding = "iso-8859-7" if profile["firmware_nature"] == 5 else "utf-8"
|
|
494
|
+
return encoded.decode(encoding, "replace").rstrip() or fallback
|
|
495
|
+
|
|
496
|
+
def descriptions(self, kind, start=0, count=None):
|
|
497
|
+
if kind not in ("program", "remote", "zone", "code"):
|
|
498
|
+
raise ValueError(f"unknown description type {kind!r}")
|
|
499
|
+
profile = self.profile()
|
|
500
|
+
maximum = profile["max_code_names" if kind == "code" else f"max_{kind}s"]
|
|
501
|
+
start, count = self._range(kind, start, count, maximum)
|
|
502
|
+
return [
|
|
503
|
+
{kind: i + 1, "name": self._description(kind, i)}
|
|
504
|
+
for i in range(start, start + count)
|
|
505
|
+
]
|
|
506
|
+
|
|
507
|
+
@staticmethod
|
|
508
|
+
def _range(kind, start, count, maximum):
|
|
509
|
+
if not 0 <= start < maximum:
|
|
510
|
+
raise ValueError(f"first {kind} must be between 1 and {maximum}")
|
|
511
|
+
if count is None:
|
|
512
|
+
count = maximum - start
|
|
513
|
+
if count < 1 or start + count > maximum:
|
|
514
|
+
raise ValueError(f"{kind} range exceeds 1..{maximum}")
|
|
515
|
+
return start, count
|
|
516
|
+
|
|
517
|
+
def programs(self):
|
|
518
|
+
states = self.group_status()["programs"]
|
|
519
|
+
enabled = set(self.permissions()["programs"])
|
|
520
|
+
for item in states:
|
|
521
|
+
item["name"] = self._description("program", item["program"] - 1)
|
|
522
|
+
item["enabled"] = item["program"] in enabled
|
|
523
|
+
return states
|
|
524
|
+
|
|
525
|
+
def remotes(self):
|
|
526
|
+
states = self.group_status()["remotes"]
|
|
527
|
+
access = self.permissions()["remote_access"]
|
|
528
|
+
for item in states:
|
|
529
|
+
item["name"] = self._description("remote", item["remote"] - 1)
|
|
530
|
+
item["enabled"] = access
|
|
531
|
+
return states
|
|
532
|
+
|
|
533
|
+
@staticmethod
|
|
534
|
+
def _decode_zone(zone, data):
|
|
535
|
+
return {
|
|
536
|
+
"zone": zone + 1,
|
|
537
|
+
"open": bool(data[0] & 0x02 or data[3] & 0x02),
|
|
538
|
+
"isolated": bool(data[0] & 0x01),
|
|
539
|
+
"raw": data.hex(),
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
def zone_statuses(self, start=0, count=None):
|
|
543
|
+
maximum = self.profile()["max_zones"]
|
|
544
|
+
start, count = self._range("zone", start, count, maximum)
|
|
545
|
+
result = []
|
|
546
|
+
while count:
|
|
547
|
+
batch = min(count, 25)
|
|
548
|
+
raw = self._exchange(
|
|
549
|
+
2316, index=start, payload=struct.pack("<H", batch),
|
|
550
|
+
expected_length=batch * 4,
|
|
551
|
+
)
|
|
552
|
+
result.extend(
|
|
553
|
+
self._decode_zone(start + i, raw[i * 4 : i * 4 + 4])
|
|
554
|
+
for i in range(batch)
|
|
555
|
+
)
|
|
556
|
+
start += batch
|
|
557
|
+
count -= batch
|
|
558
|
+
return result
|
|
559
|
+
|
|
560
|
+
def zone_status(self, zone):
|
|
561
|
+
return self.zone_statuses(zone, 1)[0]
|
|
562
|
+
|
|
563
|
+
def zones(self, start=0, count=None):
|
|
564
|
+
result = self.zone_statuses(start, count)
|
|
565
|
+
for item in result:
|
|
566
|
+
item["name"] = self._description("zone", item["zone"] - 1)
|
|
567
|
+
return result
|
|
568
|
+
|
|
569
|
+
def code_names(self, start=0, count=None):
|
|
570
|
+
return self.descriptions("code", start, count)
|
|
571
|
+
|
|
572
|
+
def sync(self):
|
|
573
|
+
return {
|
|
574
|
+
"panel": self.panel_info(),
|
|
575
|
+
"permissions": self.permissions(),
|
|
576
|
+
"programs": self.programs(),
|
|
577
|
+
"remotes": self.remotes(),
|
|
578
|
+
"zones": self.zones(),
|
|
579
|
+
"code_names": self.code_names(),
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
def events(self, limit=50):
|
|
583
|
+
if limit < 0:
|
|
584
|
+
raise ValueError("event limit cannot be negative")
|
|
585
|
+
capacity = self.profile()["event_capacity"]
|
|
586
|
+
wanted = capacity if limit == 0 else min(limit, capacity)
|
|
587
|
+
start_index = int.from_bytes(self._exchange(2311, expected_length=2), "little")
|
|
588
|
+
result = []
|
|
589
|
+
finished = False
|
|
590
|
+
while len(result) < wanted and not finished:
|
|
591
|
+
batch = min(12, wanted - len(result))
|
|
592
|
+
raw = self._exchange(
|
|
593
|
+
2312, index=len(result) + 1, payload=struct.pack("<H", batch),
|
|
594
|
+
expected_length=batch * 8,
|
|
595
|
+
)
|
|
596
|
+
for i in range(batch):
|
|
597
|
+
event = raw[i * 8 : i * 8 + 8]
|
|
598
|
+
if event[:4] == b"\xff\xff\xff\xff":
|
|
599
|
+
finished = True
|
|
600
|
+
break
|
|
601
|
+
result.append(self._decode_event(len(result) + 1, event))
|
|
602
|
+
return {"start_index": start_index, "events": result}
|
|
603
|
+
|
|
604
|
+
@staticmethod
|
|
605
|
+
def _decode_event(index, data):
|
|
606
|
+
seconds = int.from_bytes(data[4:8], "little")
|
|
607
|
+
timestamp = datetime(2000, 1, 1) + timedelta(seconds=seconds)
|
|
608
|
+
return {
|
|
609
|
+
"index": index,
|
|
610
|
+
"timestamp": timestamp.isoformat(sep=" "),
|
|
611
|
+
"event": data[0] | ((data[1] << 8) & 0x300),
|
|
612
|
+
"argument": data[2] | ((data[3] << 8) & 0x300),
|
|
613
|
+
"detail_1": (data[1] >> 2) & 0x3F,
|
|
614
|
+
"detail_2": (data[3] >> 2) & 0x3F,
|
|
615
|
+
"raw": data.hex(),
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
def status(self):
|
|
619
|
+
return {
|
|
620
|
+
"panel": self.panel_info(),
|
|
621
|
+
"clock_raw": self.clock.hex(),
|
|
622
|
+
"status": self.panel_status(),
|
|
623
|
+
**self.group_status(),
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
def watch(self, interval=30.0, reconnect_delay=30.0, wait=None):
|
|
627
|
+
"""Yield alarm and connection events, closing the panel between polls."""
|
|
628
|
+
if not 5 <= interval < float("inf"):
|
|
629
|
+
raise ValueError("watch interval must be at least 5 seconds")
|
|
630
|
+
if not 5 <= reconnect_delay < float("inf"):
|
|
631
|
+
raise ValueError("reconnect delay must be at least 5 seconds")
|
|
632
|
+
if self.sock is None:
|
|
633
|
+
self.connect()
|
|
634
|
+
wait = wait or time.sleep
|
|
635
|
+
|
|
636
|
+
previous = None
|
|
637
|
+
disconnected = False
|
|
638
|
+
while True:
|
|
639
|
+
pending = []
|
|
640
|
+
delay = interval
|
|
641
|
+
try:
|
|
642
|
+
if self.sock is None:
|
|
643
|
+
self.connect()
|
|
644
|
+
group = self.group_status()
|
|
645
|
+
programs = group["programs"]
|
|
646
|
+
started = []
|
|
647
|
+
state_changes = []
|
|
648
|
+
changes = []
|
|
649
|
+
if previous is not None:
|
|
650
|
+
for program in programs:
|
|
651
|
+
before = previous.get(program["program"], {})
|
|
652
|
+
detected_by = [
|
|
653
|
+
name
|
|
654
|
+
for name in ("alarm", "alarm_memory")
|
|
655
|
+
if program[name] and not before.get(name, False)
|
|
656
|
+
]
|
|
657
|
+
if detected_by:
|
|
658
|
+
started.append((program, detected_by))
|
|
659
|
+
state = program["state"]
|
|
660
|
+
before_state = before.get("state")
|
|
661
|
+
if state != before_state:
|
|
662
|
+
changes.append(
|
|
663
|
+
f"program {program['program']} "
|
|
664
|
+
f"{PROGRAM_STATE_NAMES.get(before_state, 'unknown')}"
|
|
665
|
+
f"({before_state}) -> {program['state_name']}({state}), "
|
|
666
|
+
f"flags=0x{program['flags']:02x}"
|
|
667
|
+
)
|
|
668
|
+
if before_state == 0 and state in (1, 2, 4):
|
|
669
|
+
state_changes.append(
|
|
670
|
+
("program_arming", program, before_state)
|
|
671
|
+
)
|
|
672
|
+
elif state in (3, 5, 6):
|
|
673
|
+
state_changes.append(
|
|
674
|
+
("program_armed", program, before_state)
|
|
675
|
+
)
|
|
676
|
+
elif state == 0:
|
|
677
|
+
state_changes.append(
|
|
678
|
+
("program_disarmed", program, before_state)
|
|
679
|
+
)
|
|
680
|
+
panel = self.panel_status()["general"] if started else None
|
|
681
|
+
log = self.events(1)["events"] if started else []
|
|
682
|
+
if previous is None:
|
|
683
|
+
active = [
|
|
684
|
+
f"program {program['program']} {program['state_name']}"
|
|
685
|
+
f"({program['state']}) flags=0x{program['flags']:02x}"
|
|
686
|
+
for program in programs
|
|
687
|
+
if program["state"] or program["flags"]
|
|
688
|
+
]
|
|
689
|
+
poll_result = "baseline: " + (
|
|
690
|
+
"; ".join(active) or "all programs disarmed"
|
|
691
|
+
)
|
|
692
|
+
else:
|
|
693
|
+
poll_result = "; ".join(changes) or "no program changes"
|
|
694
|
+
poll_logger = (
|
|
695
|
+
logger.info
|
|
696
|
+
if previous is None or changes or started
|
|
697
|
+
else logger.debug
|
|
698
|
+
)
|
|
699
|
+
poll_logger(
|
|
700
|
+
"watch poll succeeded: %s; next request in %g seconds",
|
|
701
|
+
poll_result,
|
|
702
|
+
interval,
|
|
703
|
+
)
|
|
704
|
+
except (OSError, ProtocolError) as exc:
|
|
705
|
+
if not disconnected:
|
|
706
|
+
pending.append(
|
|
707
|
+
{
|
|
708
|
+
"type": "connection_lost",
|
|
709
|
+
"timestamp": datetime.now()
|
|
710
|
+
.astimezone()
|
|
711
|
+
.isoformat(timespec="seconds"),
|
|
712
|
+
"error": str(exc),
|
|
713
|
+
}
|
|
714
|
+
)
|
|
715
|
+
disconnected = True
|
|
716
|
+
delay = reconnect_delay
|
|
717
|
+
logger.info(
|
|
718
|
+
"watch poll failed: %s; next request in %g seconds",
|
|
719
|
+
exc,
|
|
720
|
+
reconnect_delay,
|
|
721
|
+
)
|
|
722
|
+
else:
|
|
723
|
+
timestamp = datetime.now().astimezone().isoformat(timespec="seconds")
|
|
724
|
+
if disconnected:
|
|
725
|
+
pending.append(
|
|
726
|
+
{"type": "connection_restored", "timestamp": timestamp}
|
|
727
|
+
)
|
|
728
|
+
disconnected = False
|
|
729
|
+
for program, detected_by in started:
|
|
730
|
+
pending.append(
|
|
731
|
+
{
|
|
732
|
+
"type": "alarm",
|
|
733
|
+
"timestamp": timestamp,
|
|
734
|
+
"program": program["program"],
|
|
735
|
+
"detected_by": detected_by,
|
|
736
|
+
"program_status": program,
|
|
737
|
+
"panel_status": panel,
|
|
738
|
+
"latest_event": log[0] if log else None,
|
|
739
|
+
}
|
|
740
|
+
)
|
|
741
|
+
for event_type, program, before_state in state_changes:
|
|
742
|
+
pending.append(
|
|
743
|
+
{
|
|
744
|
+
"type": event_type,
|
|
745
|
+
"timestamp": timestamp,
|
|
746
|
+
"program": program["program"],
|
|
747
|
+
"previous_state": before_state,
|
|
748
|
+
"previous_state_name": PROGRAM_STATE_NAMES.get(
|
|
749
|
+
before_state, "unknown"
|
|
750
|
+
),
|
|
751
|
+
"mode": (
|
|
752
|
+
"partial"
|
|
753
|
+
if program["state"] in (4, 5)
|
|
754
|
+
else "full"
|
|
755
|
+
if program["state"] in (1, 2, 3, 6)
|
|
756
|
+
else None
|
|
757
|
+
),
|
|
758
|
+
"program_status": program,
|
|
759
|
+
}
|
|
760
|
+
)
|
|
761
|
+
previous = {program["program"]: program for program in programs}
|
|
762
|
+
finally:
|
|
763
|
+
self.close()
|
|
764
|
+
|
|
765
|
+
yield from pending
|
|
766
|
+
wait(delay)
|
|
767
|
+
|
|
768
|
+
@staticmethod
|
|
769
|
+
def _operation_payload(action, target, session, open_zones=()):
|
|
770
|
+
if not 0 <= target <= 0xFF:
|
|
771
|
+
raise ValueError("program/remote number is out of range")
|
|
772
|
+
if len(open_zones) > 25:
|
|
773
|
+
raise ProtocolError("the panel reported more than 25 open zones")
|
|
774
|
+
payload = bytearray(60)
|
|
775
|
+
payload[0], payload[1] = action, target
|
|
776
|
+
payload[4:6] = session.to_bytes(2, "little")
|
|
777
|
+
payload[6], payload[7] = 14, 32
|
|
778
|
+
for position, zone in enumerate(open_zones):
|
|
779
|
+
struct.pack_into("<H", payload, 10 + position * 2, zone)
|
|
780
|
+
return payload
|
|
781
|
+
|
|
782
|
+
def _operation(self, action, target, open_zones=()):
|
|
783
|
+
payload = self._operation_payload(action, target, self.session, open_zones)
|
|
784
|
+
response = self._exchange(2306, payload=payload, expected_length=1)
|
|
785
|
+
if response != bytes((ACK,)):
|
|
786
|
+
raise ProtocolError(f"operation failed: {response.hex()}")
|
|
787
|
+
|
|
788
|
+
def _priority(self):
|
|
789
|
+
self._exchange(2319, payload=b"\x01\x00\x00\x00")
|
|
790
|
+
|
|
791
|
+
def _check_target(self, kind, target):
|
|
792
|
+
maximum = self.profile()[f"max_{kind}s"]
|
|
793
|
+
if not 0 <= target < maximum:
|
|
794
|
+
raise ValueError(f"{kind} must be between 1 and {maximum}")
|
|
795
|
+
|
|
796
|
+
def open_zones(self, program):
|
|
797
|
+
self._check_target("program", program)
|
|
798
|
+
payload = self._operation_payload(24, program, self.session)
|
|
799
|
+
response = self._exchange(2306, payload=payload, expected_length=104)
|
|
800
|
+
if response[0] != ACK:
|
|
801
|
+
raise ProtocolError(f"open-zone query failed: {response.hex()}")
|
|
802
|
+
count = int.from_bytes(response[2:4], "little")
|
|
803
|
+
if count > 50:
|
|
804
|
+
raise ProtocolError(f"invalid open-zone count {count}")
|
|
805
|
+
return [int.from_bytes(response[4 + i * 2 : 6 + i * 2], "little") for i in range(count)]
|
|
806
|
+
|
|
807
|
+
def arm(self, program, exclude_open=False):
|
|
808
|
+
self._check_target("program", program)
|
|
809
|
+
if program + 1 not in self.permissions()["programs"]:
|
|
810
|
+
raise ProtocolError(f"access code cannot control program {program + 1}")
|
|
811
|
+
if self.panel_status()["general"]["maintenance"]:
|
|
812
|
+
raise ProtocolError("the panel is in maintenance mode")
|
|
813
|
+
zones = self.open_zones(program)
|
|
814
|
+
if zones and not exclude_open:
|
|
815
|
+
shown = ", ".join(str(zone + 1) for zone in zones)
|
|
816
|
+
raise ProtocolError(
|
|
817
|
+
f"program has open zones ({shown}); close them or rerun with --exclude-open"
|
|
818
|
+
)
|
|
819
|
+
if len(zones) > 25:
|
|
820
|
+
raise ProtocolError("cannot exclude more than 25 open zones")
|
|
821
|
+
self._priority()
|
|
822
|
+
self._operation(3, program, zones)
|
|
823
|
+
return zones
|
|
824
|
+
|
|
825
|
+
def disarm(self, program):
|
|
826
|
+
self._check_target("program", program)
|
|
827
|
+
if program + 1 not in self.permissions()["programs"]:
|
|
828
|
+
raise ProtocolError(f"access code cannot control program {program + 1}")
|
|
829
|
+
self._priority()
|
|
830
|
+
self._operation(4, program)
|
|
831
|
+
|
|
832
|
+
def remote(self, remote, turn_on):
|
|
833
|
+
self._check_target("remote", remote)
|
|
834
|
+
if self.permissions()["remote_access"] is False:
|
|
835
|
+
raise ProtocolError("access code cannot control remotes")
|
|
836
|
+
self._priority()
|
|
837
|
+
self._operation(11 if turn_on else 12, remote)
|
|
838
|
+
|
|
839
|
+
def set_zone_isolation(self, zone, isolated):
|
|
840
|
+
self._check_target("zone", zone)
|
|
841
|
+
if self.session != 1:
|
|
842
|
+
raise ProtocolError("zone isolation requires a master access code")
|
|
843
|
+
self._priority()
|
|
844
|
+
response = self._exchange(2320 if isolated else 2321, index=zone, expected_length=1)
|
|
845
|
+
if response != bytes((ACK,)):
|
|
846
|
+
raise ProtocolError(f"zone operation failed: {response.hex()}")
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: tecnoctl
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Unofficial client for the myTecnoalarm direct TCP protocol
|
|
5
|
+
License-Expression: MIT
|
|
6
|
+
Project-URL: Repository, https://github.com/EnricoDev1/tecnoctl
|
|
7
|
+
Project-URL: Issues, https://github.com/EnricoDev1/tecnoctl/issues
|
|
8
|
+
Requires-Python: >=3.10
|
|
9
|
+
Description-Content-Type: text/markdown
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Requires-Dist: cryptography>=47
|
|
12
|
+
Requires-Dist: python-dotenv>=1
|
|
13
|
+
Dynamic: license-file
|
|
14
|
+
|
|
15
|
+
# tecnoctl
|
|
16
|
+
|
|
17
|
+
Unofficial Python client and CLI for the direct TCP interface used by the
|
|
18
|
+
myTecnoalarm Android application. It does not use Tecnoalarm cloud, HTTP, or
|
|
19
|
+
video services.
|
|
20
|
+
|
|
21
|
+
> Experimental: This project was developed with AI assistance. The protocol was reverse-engineered and has not been tested on
|
|
22
|
+
> every hardware. Start with read-only commands. The protocol uses unauthenticated
|
|
23
|
+
> AES-CFB; never expose TCP port 10001 to the Internet. Use a trusted LAN or VPN.
|
|
24
|
+
|
|
25
|
+
## Install
|
|
26
|
+
|
|
27
|
+
```sh
|
|
28
|
+
python3 -m pip install .
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
## CLI
|
|
32
|
+
|
|
33
|
+
```sh
|
|
34
|
+
cp .env.example .env
|
|
35
|
+
# Edit .env with your credentials.
|
|
36
|
+
|
|
37
|
+
tecnoctl HOST status
|
|
38
|
+
tecnoctl HOST --verbose status
|
|
39
|
+
tecnoctl HOST --debug status
|
|
40
|
+
tecnoctl HOST zones
|
|
41
|
+
tecnoctl HOST watch
|
|
42
|
+
tecnoctl HOST arm 1
|
|
43
|
+
tecnoctl HOST disarm 1
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
The CLI loads `.env` automatically; exported variables take precedence. Run
|
|
47
|
+
`tecnoctl --help` for all commands. CLI IDs are one-based. `watch` connects,
|
|
48
|
+
checks, and disconnects every 30 seconds, then prints JSON when an alarm starts,
|
|
49
|
+
a program starts arming or becomes armed/disarmed, or connectivity changes. The
|
|
50
|
+
minimum `--interval` is 5 seconds. Add `--debug` before any command for
|
|
51
|
+
connection and protocol diagnostics, or `--verbose` for quieter connection
|
|
52
|
+
status.
|
|
53
|
+
|
|
54
|
+
> `watch` is experimental and is not a primary alarm notification system. Some
|
|
55
|
+
> panels accept only one direct TCP client, so each check may briefly delay the
|
|
56
|
+
> official app.
|
|
57
|
+
|
|
58
|
+
## Python API
|
|
59
|
+
|
|
60
|
+
```python
|
|
61
|
+
from tecnoctl import AlarmClient
|
|
62
|
+
|
|
63
|
+
with AlarmClient("192.168.1.20", "network passphrase", "123456") as alarm:
|
|
64
|
+
print(alarm.status())
|
|
65
|
+
alarm.arm(0) # Library indexes are zero-based.
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
The class also exposes panel, program, remote, zone, permission, and event-log
|
|
69
|
+
queries. `alarm.watch()` yields JSON-friendly alarm and connection events. See
|
|
70
|
+
`AlarmClient` in `tecnoctl/client.py`.
|
|
71
|
+
|
|
72
|
+
## Development
|
|
73
|
+
|
|
74
|
+
```sh
|
|
75
|
+
python3 -m unittest discover -s tests
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
MIT licensed. Tecnoalarm is a trademark of its owner; this project is not
|
|
79
|
+
affiliated with or endorsed by Tecnoalarm.
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
tecnoctl/__init__.py,sha256=uES9w0iuTW-LAHqdl65aA9be2suI0AlKc-sWM90iB48,123
|
|
2
|
+
tecnoctl/cli.py,sha256=C8X9QU_TYIOet_gbJ2kXvt8q3vj-XWzAigvHIDL0k68,10451
|
|
3
|
+
tecnoctl/client.py,sha256=l__wb-tr5GtyHNIW8QCxYviREygotHnxyxMOF2XQgj4,34645
|
|
4
|
+
tecnoctl-0.1.0.dist-info/licenses/LICENSE,sha256=ntSkcBE0pYiR6FqDRWMZG0e-3RyBm8otTxW3kJltpOs,1063
|
|
5
|
+
tecnoctl-0.1.0.dist-info/METADATA,sha256=VtsQQuao4vs3Pmjvac3R-GK0gllOUX8dm_9YTLtCyAM,2452
|
|
6
|
+
tecnoctl-0.1.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
|
|
7
|
+
tecnoctl-0.1.0.dist-info/entry_points.txt,sha256=wwlcylXrI4_LH-IPlHseurU2tHU4yOQAHZRiynviujQ,47
|
|
8
|
+
tecnoctl-0.1.0.dist-info/top_level.txt,sha256=87qt0VncNtLnhj3iXXFw5VWWerspseRKhU7eZAYwKWg,9
|
|
9
|
+
tecnoctl-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Enrico
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
tecnoctl
|