tecnoctl 0.1.0__tar.gz

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-0.1.0/LICENSE ADDED
@@ -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,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,65 @@
1
+ # tecnoctl
2
+
3
+ Unofficial Python client and CLI for the direct TCP interface used by the
4
+ myTecnoalarm Android application. It does not use Tecnoalarm cloud, HTTP, or
5
+ video services.
6
+
7
+ > Experimental: This project was developed with AI assistance. The protocol was reverse-engineered and has not been tested on
8
+ > every hardware. Start with read-only commands. The protocol uses unauthenticated
9
+ > AES-CFB; never expose TCP port 10001 to the Internet. Use a trusted LAN or VPN.
10
+
11
+ ## Install
12
+
13
+ ```sh
14
+ python3 -m pip install .
15
+ ```
16
+
17
+ ## CLI
18
+
19
+ ```sh
20
+ cp .env.example .env
21
+ # Edit .env with your credentials.
22
+
23
+ tecnoctl HOST status
24
+ tecnoctl HOST --verbose status
25
+ tecnoctl HOST --debug status
26
+ tecnoctl HOST zones
27
+ tecnoctl HOST watch
28
+ tecnoctl HOST arm 1
29
+ tecnoctl HOST disarm 1
30
+ ```
31
+
32
+ The CLI loads `.env` automatically; exported variables take precedence. Run
33
+ `tecnoctl --help` for all commands. CLI IDs are one-based. `watch` connects,
34
+ checks, and disconnects every 30 seconds, then prints JSON when an alarm starts,
35
+ a program starts arming or becomes armed/disarmed, or connectivity changes. The
36
+ minimum `--interval` is 5 seconds. Add `--debug` before any command for
37
+ connection and protocol diagnostics, or `--verbose` for quieter connection
38
+ status.
39
+
40
+ > `watch` is experimental and is not a primary alarm notification system. Some
41
+ > panels accept only one direct TCP client, so each check may briefly delay the
42
+ > official app.
43
+
44
+ ## Python API
45
+
46
+ ```python
47
+ from tecnoctl import AlarmClient
48
+
49
+ with AlarmClient("192.168.1.20", "network passphrase", "123456") as alarm:
50
+ print(alarm.status())
51
+ alarm.arm(0) # Library indexes are zero-based.
52
+ ```
53
+
54
+ The class also exposes panel, program, remote, zone, permission, and event-log
55
+ queries. `alarm.watch()` yields JSON-friendly alarm and connection events. See
56
+ `AlarmClient` in `tecnoctl/client.py`.
57
+
58
+ ## Development
59
+
60
+ ```sh
61
+ python3 -m unittest discover -s tests
62
+ ```
63
+
64
+ MIT licensed. Tecnoalarm is a trademark of its owner; this project is not
65
+ affiliated with or endorsed by Tecnoalarm.
@@ -0,0 +1,19 @@
1
+ [build-system]
2
+ requires = ["setuptools>=77"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "tecnoctl"
7
+ version = "0.1.0"
8
+ description = "Unofficial client for the myTecnoalarm direct TCP protocol"
9
+ readme = "README.md"
10
+ requires-python = ">=3.10"
11
+ license = "MIT"
12
+ dependencies = ["cryptography>=47", "python-dotenv>=1"]
13
+
14
+ [project.scripts]
15
+ tecnoctl = "tecnoctl.cli:main"
16
+
17
+ [project.urls]
18
+ Repository = "https://github.com/EnricoDev1/tecnoctl"
19
+ Issues = "https://github.com/EnricoDev1/tecnoctl/issues"
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,5 @@
1
+ """Public API for tecnoctl."""
2
+
3
+ from .client import AlarmClient, ProtocolError
4
+
5
+ __all__ = ["AlarmClient", "ProtocolError"]
@@ -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()