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 ADDED
@@ -0,0 +1,5 @@
1
+ """Public API for tecnoctl."""
2
+
3
+ from .client import AlarmClient, ProtocolError
4
+
5
+ __all__ = ["AlarmClient", "ProtocolError"]
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,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ tecnoctl = tecnoctl.cli:main
@@ -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