pyaltitool 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.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 turbo42
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,232 @@
1
+ Metadata-Version: 2.4
2
+ Name: pyaltitool
3
+ Version: 0.1.0
4
+ Summary: Unofficial Python library and CLI for communicating with Alti-2 skydiving altimeters
5
+ Author: turbo42
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/5BytesHook/pyaltitool
8
+ Project-URL: Repository, https://github.com/5BytesHook/pyaltitool
9
+ Project-URL: Issues, https://github.com/5BytesHook/pyaltitool/issues
10
+ Keywords: alti-2,altimeter,skydiving,serial,atlas
11
+ Classifier: Development Status :: 4 - Beta
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: Intended Audience :: Other Audience
14
+ Classifier: License :: OSI Approved :: MIT License
15
+ Classifier: Operating System :: MacOS
16
+ Classifier: Operating System :: POSIX :: Linux
17
+ Classifier: Operating System :: Microsoft :: Windows
18
+ Classifier: Programming Language :: Python :: 3
19
+ Classifier: Programming Language :: Python :: 3.10
20
+ Classifier: Programming Language :: Python :: 3.11
21
+ Classifier: Programming Language :: Python :: 3.12
22
+ Classifier: Programming Language :: Python :: 3.13
23
+ Classifier: Topic :: Scientific/Engineering
24
+ Classifier: Topic :: System :: Hardware
25
+ Requires-Python: >=3.10
26
+ Description-Content-Type: text/markdown
27
+ License-File: LICENSE
28
+ Requires-Dist: pyserial>=3.5
29
+ Dynamic: license-file
30
+
31
+ # pyaltitool
32
+
33
+ [![PyPI](https://img.shields.io/pypi/v/pyaltitool)](https://pypi.org/project/pyaltitool/)
34
+ [![Python](https://img.shields.io/pypi/pyversions/pyaltitool)](https://pypi.org/project/pyaltitool/)
35
+ [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
36
+
37
+ Unofficial Python library and CLI for communicating with Alti-2 skydiving altimeters over USB serial.
38
+
39
+ | Device | Status |
40
+ |-------------------|-----------|
41
+ | Atlas | Expected to work |
42
+ | Atlas 2 | Tested, works |
43
+ | Atlas 2 Student | Expected to work |
44
+ | Juno | Expected to work |
45
+ | MA-12 | Expected to work |
46
+ | MA-15A | Expected to work |
47
+
48
+ ## Features
49
+
50
+ - Read device information (serial number, firmware version, jump count, etc.)
51
+ - Read jump logbook records with full field parsing
52
+ - Export logbook to CSV
53
+ - Read device date/time
54
+ - Read custom name tables (aircraft, drop zones, alarms)
55
+ - Read/write raw FRAM memory (**Warning: write operations are untested and may corrupt device data. Use at your own risk!**)
56
+ - Read/write device settings (**Warning: writing settings is untested. May cause configuration errors or data loss. Proceed with caution!**)
57
+ - Auto port detection, keepalive, and auto-reconnect
58
+ - Pure Python — only depends on [pyserial](https://github.com/pyserial/pyserial)
59
+ - Automatic logbook date parsing for firmware < 1.0.10 (handles overflow bug).
60
+
61
+ ## Usage
62
+
63
+ ### Getting Started
64
+
65
+ ```bash
66
+ git clone https://github.com/5BytesHook/pyaltitool.git
67
+ cd pyaltitool
68
+ pip install pyserial
69
+ ```
70
+
71
+ Connect your Alti-2 device via USB and run:
72
+
73
+ ```bash
74
+ python altitool_cli.py
75
+ ```
76
+
77
+ The tool auto-detects the serial port. To specify a port manually:
78
+
79
+ ```bash
80
+ python altitool_cli.py -p /dev/cu.usbserial-XXXX # macOS
81
+ python altitool_cli.py -p /dev/ttyUSB0 # Linux
82
+ python altitool_cli.py -p COM3 # Windows
83
+ ```
84
+
85
+ ### Common Commands
86
+
87
+ Once connected, you'll enter the interactive prompt. Type any command for its usage:
88
+
89
+ ```
90
+ altitool> logbook all # Show all jumps
91
+ altitool> logbook last 10 # Show last 10 jumps
92
+ altitool> logbook csv all # Export all jumps to CSV
93
+ altitool> logbook csv last 20 jumps.csv # Export last 20 to a file
94
+ altitool> datetime # Read device clock
95
+ altitool> names aircraft # Read aircraft names
96
+ altitool> names dz # Read drop zone names
97
+ altitool> info # Show device info
98
+ altitool> help # Full command list
99
+ ```
100
+
101
+ Quick CSV export without entering interactive mode:
102
+
103
+ ```bash
104
+ python altitool_cli.py --csv # Export all jumps then exit
105
+ python altitool_cli.py --csv 50 # Export last 50 jumps then exit
106
+ ```
107
+
108
+ ---
109
+
110
+ ## Development
111
+
112
+ ### Install for Development
113
+
114
+ ```bash
115
+ git clone https://github.com/5BytesHook/pyaltitool.git
116
+ cd pyaltitool
117
+ pip install -e .
118
+ ```
119
+
120
+ ### Using as a Library
121
+
122
+ ```python
123
+ from pyaltitool import AltitoolDevice, auto_detect_port
124
+ from pyaltitool import parse_logbook_record, LOGBOOK_RECORD_SIZE
125
+
126
+ port = auto_detect_port() # works on macOS, Linux, Windows
127
+ with AltitoolDevice(port) as dev:
128
+ info = dev.connect()
129
+ print(f"{info['product_name']} S/N {info['serial_number']}, {info['total_jumps']} jumps")
130
+
131
+ dt = dev.read_datetime()
132
+ print(f"Device time: {dt:%Y-%m-%d %H:%M:%S}")
133
+
134
+ addr = info["summary_start"] + (info["total_jumps"] - 5) * LOGBOOK_RECORD_SIZE
135
+ data = dev.read_memory(addr, 5 * LOGBOOK_RECORD_SIZE)
136
+
137
+ for i in range(5):
138
+ rec = parse_logbook_record(data[i * 22 : (i + 1) * 22])
139
+ print(f" Jump #{rec['jump_number']}: {rec['exit_alt_ft']}ft exit, {rec['freefall_time']}s freefall")
140
+ ```
141
+
142
+ You can also set the `PYALTITOOL_PORT` environment variable to override auto-detection, or pass the port path directly:
143
+
144
+ ```python
145
+ # macOS: AltitoolDevice("/dev/cu.usbserial-XXXX")
146
+ # Linux: AltitoolDevice("/dev/ttyUSB0")
147
+ # Windows: AltitoolDevice("COM3")
148
+ ```
149
+
150
+ ### API Reference
151
+
152
+ ### `AltitoolDevice`
153
+
154
+ The main class for communicating with an Alti-2 device.
155
+
156
+ ```python
157
+ from pyaltitool import AltitoolDevice, auto_detect_port
158
+ ```
159
+
160
+ | Function / Method | Description |
161
+ |-------------------|-------------|
162
+ | `auto_detect_port() -> str \| None` | Detect the serial port for an Alti-2 device (macOS / Linux / Windows). |
163
+
164
+ | Method | Description |
165
+ |--------|-------------|
166
+ | `connect() -> dict` | Open port, wake device, perform handshake. Returns parsed Type 0 record. |
167
+ | `disconnect()` | End communication and close port. |
168
+ | `reconnect() -> dict` | Re-establish communication with full DTR wake. |
169
+ | `force_reconnect()` | Thread-safe reconnection (acquires lock internally). |
170
+ | `read_memory(address, length) -> bytes` | Read FRAM memory. Auto-reconnects on failure. |
171
+ | `write_memory(address, data)` | Write to FRAM. Splits large writes automatically. **Untested.** |
172
+ | `read_datetime() -> datetime` | Read device clock (A2 command). |
173
+ | `read_aircraft_names() -> dict[int, str]` | Read aircraft name table from FRAM. |
174
+ | `read_dz_names() -> dict[int, str]` | Read drop zone name table from FRAM. |
175
+ | `read_alarm_names() -> dict[int, str]` | Read alarm name table from FRAM. |
176
+ | `write_aircraft_name(index, name)` | Write an aircraft name. **Untested.** |
177
+ | `write_dz_name(index, name)` | Write a drop zone name. **Untested.** |
178
+ | `write_alarm_name(index, name)` | Write an alarm name. **Untested.** |
179
+ | `read_settings() -> dict` | Read device settings from FRAM. **Untested.** |
180
+ | `write_settings(settings, base=None)` | Write settings (read-modify-write). **Untested.** |
181
+
182
+ Properties: `connected`, `type_zero`, `session_key`.
183
+
184
+ ### Protocol helpers
185
+
186
+ ```python
187
+ from pyaltitool import (
188
+ parse_logbook_record, # Parse 22-byte jump record → dict
189
+ format_logbook_record, # Format parsed record as human-readable string
190
+ logbook_record_to_csv_row,# Format parsed record as CSV row
191
+ CSV_HEADER, # CSV header string
192
+ LOGBOOK_RECORD_SIZE, # 22
193
+ parse_type_zero, # Parse 32-byte Type 0 record → dict
194
+ parse_datetime_response, # Parse A2 response → datetime
195
+ parse_settings, # Parse 13-byte settings → dict
196
+ encode_settings, # Encode settings dict → 13 bytes
197
+ parse_fram_name, # Parse 10-byte FRAM name → str
198
+ encode_fram_name, # Encode str → 10-byte FRAM name
199
+ PRODUCT_NAMES, # {product_id: name} mapping
200
+ )
201
+ ```
202
+
203
+ ### Exceptions
204
+
205
+ | Exception | Description |
206
+ |-----------|-------------|
207
+ | `AltitoolError` | Base exception for all communication errors. |
208
+ | `AltitoolAckError(AltitoolError)` | Device returned an unexpected acknowledgement code. |
209
+
210
+ ## How It Works
211
+
212
+ The Alti-2 devices communicate over USB serial (57600 baud, 8N1, RTS/CTS hardware flow control) using a custom encrypted protocol:
213
+
214
+ 1. **DTR wake** — toggle DTR to wake the device from sleep
215
+ 2. **ASCII handshake** — send `"018080"` to receive the 32-byte Type 0 identification record
216
+ 3. **Session key** — derive a 16-byte XTEA key from the Type 0 record + product-specific seed
217
+ 4. **Encrypted commands** — all subsequent commands are 32-byte XTEA-encrypted packets, sent byte-by-byte with CTS flow control polling
218
+ 5. **Exit** — send `\x01EXIT` to end the session
219
+
220
+ The device has a ~10-15 second idle timeout. pyaltitool runs a background keepalive thread that sends periodic datetime pings to prevent disconnection.
221
+
222
+ For bulk logbook reads, the library automatically reconnects between batches of 100 records, as the device's serial state becomes unreliable during sustained transfers.
223
+
224
+ See [ALTI2_ATLAS_COMMUNICATION_PROTOCOL.md](ALTI2_ATLAS_COMMUNICATION_PROTOCOL.md) for the full protocol specification.
225
+
226
+ ## License
227
+
228
+ [MIT](LICENSE)
229
+
230
+ ## Disclaimer
231
+
232
+ This is an independent open-source project. It is not affiliated with, endorsed by, or supported by Alti-2 Technologies. Use at your own risk. The authors are not responsible for any damage to your altimeter. Always verify your equipment through official channels before skydiving.
@@ -0,0 +1,202 @@
1
+ # pyaltitool
2
+
3
+ [![PyPI](https://img.shields.io/pypi/v/pyaltitool)](https://pypi.org/project/pyaltitool/)
4
+ [![Python](https://img.shields.io/pypi/pyversions/pyaltitool)](https://pypi.org/project/pyaltitool/)
5
+ [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
6
+
7
+ Unofficial Python library and CLI for communicating with Alti-2 skydiving altimeters over USB serial.
8
+
9
+ | Device | Status |
10
+ |-------------------|-----------|
11
+ | Atlas | Expected to work |
12
+ | Atlas 2 | Tested, works |
13
+ | Atlas 2 Student | Expected to work |
14
+ | Juno | Expected to work |
15
+ | MA-12 | Expected to work |
16
+ | MA-15A | Expected to work |
17
+
18
+ ## Features
19
+
20
+ - Read device information (serial number, firmware version, jump count, etc.)
21
+ - Read jump logbook records with full field parsing
22
+ - Export logbook to CSV
23
+ - Read device date/time
24
+ - Read custom name tables (aircraft, drop zones, alarms)
25
+ - Read/write raw FRAM memory (**Warning: write operations are untested and may corrupt device data. Use at your own risk!**)
26
+ - Read/write device settings (**Warning: writing settings is untested. May cause configuration errors or data loss. Proceed with caution!**)
27
+ - Auto port detection, keepalive, and auto-reconnect
28
+ - Pure Python — only depends on [pyserial](https://github.com/pyserial/pyserial)
29
+ - Automatic logbook date parsing for firmware < 1.0.10 (handles overflow bug).
30
+
31
+ ## Usage
32
+
33
+ ### Getting Started
34
+
35
+ ```bash
36
+ git clone https://github.com/5BytesHook/pyaltitool.git
37
+ cd pyaltitool
38
+ pip install pyserial
39
+ ```
40
+
41
+ Connect your Alti-2 device via USB and run:
42
+
43
+ ```bash
44
+ python altitool_cli.py
45
+ ```
46
+
47
+ The tool auto-detects the serial port. To specify a port manually:
48
+
49
+ ```bash
50
+ python altitool_cli.py -p /dev/cu.usbserial-XXXX # macOS
51
+ python altitool_cli.py -p /dev/ttyUSB0 # Linux
52
+ python altitool_cli.py -p COM3 # Windows
53
+ ```
54
+
55
+ ### Common Commands
56
+
57
+ Once connected, you'll enter the interactive prompt. Type any command for its usage:
58
+
59
+ ```
60
+ altitool> logbook all # Show all jumps
61
+ altitool> logbook last 10 # Show last 10 jumps
62
+ altitool> logbook csv all # Export all jumps to CSV
63
+ altitool> logbook csv last 20 jumps.csv # Export last 20 to a file
64
+ altitool> datetime # Read device clock
65
+ altitool> names aircraft # Read aircraft names
66
+ altitool> names dz # Read drop zone names
67
+ altitool> info # Show device info
68
+ altitool> help # Full command list
69
+ ```
70
+
71
+ Quick CSV export without entering interactive mode:
72
+
73
+ ```bash
74
+ python altitool_cli.py --csv # Export all jumps then exit
75
+ python altitool_cli.py --csv 50 # Export last 50 jumps then exit
76
+ ```
77
+
78
+ ---
79
+
80
+ ## Development
81
+
82
+ ### Install for Development
83
+
84
+ ```bash
85
+ git clone https://github.com/5BytesHook/pyaltitool.git
86
+ cd pyaltitool
87
+ pip install -e .
88
+ ```
89
+
90
+ ### Using as a Library
91
+
92
+ ```python
93
+ from pyaltitool import AltitoolDevice, auto_detect_port
94
+ from pyaltitool import parse_logbook_record, LOGBOOK_RECORD_SIZE
95
+
96
+ port = auto_detect_port() # works on macOS, Linux, Windows
97
+ with AltitoolDevice(port) as dev:
98
+ info = dev.connect()
99
+ print(f"{info['product_name']} S/N {info['serial_number']}, {info['total_jumps']} jumps")
100
+
101
+ dt = dev.read_datetime()
102
+ print(f"Device time: {dt:%Y-%m-%d %H:%M:%S}")
103
+
104
+ addr = info["summary_start"] + (info["total_jumps"] - 5) * LOGBOOK_RECORD_SIZE
105
+ data = dev.read_memory(addr, 5 * LOGBOOK_RECORD_SIZE)
106
+
107
+ for i in range(5):
108
+ rec = parse_logbook_record(data[i * 22 : (i + 1) * 22])
109
+ print(f" Jump #{rec['jump_number']}: {rec['exit_alt_ft']}ft exit, {rec['freefall_time']}s freefall")
110
+ ```
111
+
112
+ You can also set the `PYALTITOOL_PORT` environment variable to override auto-detection, or pass the port path directly:
113
+
114
+ ```python
115
+ # macOS: AltitoolDevice("/dev/cu.usbserial-XXXX")
116
+ # Linux: AltitoolDevice("/dev/ttyUSB0")
117
+ # Windows: AltitoolDevice("COM3")
118
+ ```
119
+
120
+ ### API Reference
121
+
122
+ ### `AltitoolDevice`
123
+
124
+ The main class for communicating with an Alti-2 device.
125
+
126
+ ```python
127
+ from pyaltitool import AltitoolDevice, auto_detect_port
128
+ ```
129
+
130
+ | Function / Method | Description |
131
+ |-------------------|-------------|
132
+ | `auto_detect_port() -> str \| None` | Detect the serial port for an Alti-2 device (macOS / Linux / Windows). |
133
+
134
+ | Method | Description |
135
+ |--------|-------------|
136
+ | `connect() -> dict` | Open port, wake device, perform handshake. Returns parsed Type 0 record. |
137
+ | `disconnect()` | End communication and close port. |
138
+ | `reconnect() -> dict` | Re-establish communication with full DTR wake. |
139
+ | `force_reconnect()` | Thread-safe reconnection (acquires lock internally). |
140
+ | `read_memory(address, length) -> bytes` | Read FRAM memory. Auto-reconnects on failure. |
141
+ | `write_memory(address, data)` | Write to FRAM. Splits large writes automatically. **Untested.** |
142
+ | `read_datetime() -> datetime` | Read device clock (A2 command). |
143
+ | `read_aircraft_names() -> dict[int, str]` | Read aircraft name table from FRAM. |
144
+ | `read_dz_names() -> dict[int, str]` | Read drop zone name table from FRAM. |
145
+ | `read_alarm_names() -> dict[int, str]` | Read alarm name table from FRAM. |
146
+ | `write_aircraft_name(index, name)` | Write an aircraft name. **Untested.** |
147
+ | `write_dz_name(index, name)` | Write a drop zone name. **Untested.** |
148
+ | `write_alarm_name(index, name)` | Write an alarm name. **Untested.** |
149
+ | `read_settings() -> dict` | Read device settings from FRAM. **Untested.** |
150
+ | `write_settings(settings, base=None)` | Write settings (read-modify-write). **Untested.** |
151
+
152
+ Properties: `connected`, `type_zero`, `session_key`.
153
+
154
+ ### Protocol helpers
155
+
156
+ ```python
157
+ from pyaltitool import (
158
+ parse_logbook_record, # Parse 22-byte jump record → dict
159
+ format_logbook_record, # Format parsed record as human-readable string
160
+ logbook_record_to_csv_row,# Format parsed record as CSV row
161
+ CSV_HEADER, # CSV header string
162
+ LOGBOOK_RECORD_SIZE, # 22
163
+ parse_type_zero, # Parse 32-byte Type 0 record → dict
164
+ parse_datetime_response, # Parse A2 response → datetime
165
+ parse_settings, # Parse 13-byte settings → dict
166
+ encode_settings, # Encode settings dict → 13 bytes
167
+ parse_fram_name, # Parse 10-byte FRAM name → str
168
+ encode_fram_name, # Encode str → 10-byte FRAM name
169
+ PRODUCT_NAMES, # {product_id: name} mapping
170
+ )
171
+ ```
172
+
173
+ ### Exceptions
174
+
175
+ | Exception | Description |
176
+ |-----------|-------------|
177
+ | `AltitoolError` | Base exception for all communication errors. |
178
+ | `AltitoolAckError(AltitoolError)` | Device returned an unexpected acknowledgement code. |
179
+
180
+ ## How It Works
181
+
182
+ The Alti-2 devices communicate over USB serial (57600 baud, 8N1, RTS/CTS hardware flow control) using a custom encrypted protocol:
183
+
184
+ 1. **DTR wake** — toggle DTR to wake the device from sleep
185
+ 2. **ASCII handshake** — send `"018080"` to receive the 32-byte Type 0 identification record
186
+ 3. **Session key** — derive a 16-byte XTEA key from the Type 0 record + product-specific seed
187
+ 4. **Encrypted commands** — all subsequent commands are 32-byte XTEA-encrypted packets, sent byte-by-byte with CTS flow control polling
188
+ 5. **Exit** — send `\x01EXIT` to end the session
189
+
190
+ The device has a ~10-15 second idle timeout. pyaltitool runs a background keepalive thread that sends periodic datetime pings to prevent disconnection.
191
+
192
+ For bulk logbook reads, the library automatically reconnects between batches of 100 records, as the device's serial state becomes unreliable during sustained transfers.
193
+
194
+ See [ALTI2_ATLAS_COMMUNICATION_PROTOCOL.md](ALTI2_ATLAS_COMMUNICATION_PROTOCOL.md) for the full protocol specification.
195
+
196
+ ## License
197
+
198
+ [MIT](LICENSE)
199
+
200
+ ## Disclaimer
201
+
202
+ This is an independent open-source project. It is not affiliated with, endorsed by, or supported by Alti-2 Technologies. Use at your own risk. The authors are not responsible for any damage to your altimeter. Always verify your equipment through official channels before skydiving.
@@ -0,0 +1,42 @@
1
+ """pyaltitool — Unofficial Python library for communicating with Alti-2 skydiving altimeters.
2
+
3
+ Not affiliated with, endorsed by, or associated with Alti-2 Technologies.
4
+ https://github.com/5BytesHook/pyaltitool
5
+ """
6
+
7
+ __version__ = "0.1.0"
8
+
9
+ from .device import AltitoolDevice, AltitoolError, AltitoolAckError, auto_detect_port
10
+ from .protocol import (
11
+ PRODUCT_NAMES,
12
+ LOGBOOK_RECORD_SIZE,
13
+ parse_logbook_record,
14
+ format_logbook_record,
15
+ logbook_record_to_csv_row,
16
+ CSV_HEADER,
17
+ parse_type_zero,
18
+ parse_datetime_response,
19
+ parse_settings,
20
+ encode_settings,
21
+ parse_fram_name,
22
+ encode_fram_name,
23
+ )
24
+
25
+ __all__ = [
26
+ "AltitoolDevice",
27
+ "AltitoolError",
28
+ "AltitoolAckError",
29
+ "auto_detect_port",
30
+ "PRODUCT_NAMES",
31
+ "LOGBOOK_RECORD_SIZE",
32
+ "parse_logbook_record",
33
+ "format_logbook_record",
34
+ "logbook_record_to_csv_row",
35
+ "CSV_HEADER",
36
+ "parse_type_zero",
37
+ "parse_datetime_response",
38
+ "parse_settings",
39
+ "encode_settings",
40
+ "parse_fram_name",
41
+ "encode_fram_name",
42
+ ]
@@ -0,0 +1,165 @@
1
+ """
2
+ XTEA encryption/decryption and session key generation for pyaltitool.
3
+
4
+ The Alti-2 protocol uses XTEA (eXtended Tiny Encryption Algorithm) with:
5
+ - 16 rounds per block
6
+ - 4 blocks of 8 bytes = 32 bytes per encrypt/decrypt operation
7
+ - 16-byte key derived from the Type 0 record and product ID
8
+
9
+ Key generation uses product-specific seed bytes combined with bytes
10
+ from the Type 0 record returned by the device during initial handshake.
11
+ """
12
+
13
+ import struct
14
+
15
+
16
+ # -- XTEA Constants --
17
+ _DELTA = 0x9E3779B9
18
+ _MASK = 0xFFFFFFFF
19
+ _ROUNDS = 16
20
+ _BLOCK_SIZE = 8 # bytes per XTEA block
21
+ _NUM_BLOCKS = 4 # blocks per 32-byte packet
22
+ _PACKET_SIZE = _BLOCK_SIZE * _NUM_BLOCKS # 32 bytes
23
+
24
+
25
+ def xtea_encrypt(data: bytes, key: bytes) -> bytes:
26
+ """Encrypt 32 bytes using XTEA with 16-byte key (16 rounds, 4 blocks).
27
+
28
+ Args:
29
+ data: 32 bytes of plaintext.
30
+ key: 16 bytes encryption key.
31
+
32
+ Returns:
33
+ 32 bytes of ciphertext.
34
+ """
35
+ if len(data) != _PACKET_SIZE:
36
+ raise ValueError(f"Data must be {_PACKET_SIZE} bytes, got {len(data)}")
37
+ if len(key) != 16:
38
+ raise ValueError(f"Key must be 16 bytes, got {len(key)}")
39
+
40
+ kw = struct.unpack('<4I', key)
41
+ result = bytearray(_PACKET_SIZE)
42
+
43
+ for blk in range(_NUM_BLOCKS):
44
+ v0, v1 = struct.unpack_from('<2I', data, blk * _BLOCK_SIZE)
45
+ s = 0
46
+ for _ in range(_ROUNDS):
47
+ # v0 += (((v1<<4) ^ (v1>>5)) + v1) ^ (sum + key[sum & 3])
48
+ t = (((v1 << 4) & _MASK) ^ (v1 >> 5))
49
+ t = (t + v1) & _MASK
50
+ v0 = (v0 + (t ^ ((s + kw[s & 3]) & _MASK))) & _MASK
51
+ s = (s + _DELTA) & _MASK
52
+ # v1 += (((v0<<4) ^ (v0>>5)) + v0) ^ (sum + key[(sum>>11) & 3])
53
+ t = (((v0 << 4) & _MASK) ^ (v0 >> 5))
54
+ t = (t + v0) & _MASK
55
+ v1 = (v1 + (t ^ ((s + kw[(s >> 11) & 3]) & _MASK))) & _MASK
56
+ struct.pack_into('<2I', result, blk * _BLOCK_SIZE, v0, v1)
57
+
58
+ return bytes(result)
59
+
60
+
61
+ def xtea_decrypt(data: bytes, key: bytes) -> bytes:
62
+ """Decrypt 32 bytes using XTEA with 16-byte key (16 rounds, 4 blocks).
63
+
64
+ Args:
65
+ data: 32 bytes of ciphertext.
66
+ key: 16 bytes encryption key.
67
+
68
+ Returns:
69
+ 32 bytes of plaintext.
70
+ """
71
+ if len(data) != _PACKET_SIZE:
72
+ raise ValueError(f"Data must be {_PACKET_SIZE} bytes, got {len(data)}")
73
+ if len(key) != 16:
74
+ raise ValueError(f"Key must be 16 bytes, got {len(key)}")
75
+
76
+ kw = struct.unpack('<4I', key)
77
+ result = bytearray(_PACKET_SIZE)
78
+
79
+ for blk in range(_NUM_BLOCKS):
80
+ v0, v1 = struct.unpack_from('<2I', data, blk * _BLOCK_SIZE)
81
+ s = (_DELTA * _ROUNDS) & _MASK # 0xE3779B90 for 16 rounds
82
+ for _ in range(_ROUNDS):
83
+ # v1 -= (((v0<<4) ^ (v0>>5)) + v0) ^ (sum + key[(sum>>11) & 3])
84
+ t = (((v0 << 4) & _MASK) ^ (v0 >> 5))
85
+ t = (t + v0) & _MASK
86
+ v1 = (v1 - (t ^ ((s + kw[(s >> 11) & 3]) & _MASK))) & _MASK
87
+ s = (s - _DELTA) & _MASK
88
+ # v0 -= (((v1<<4) ^ (v1>>5)) + v1) ^ (sum + key[sum & 3])
89
+ t = (((v1 << 4) & _MASK) ^ (v1 >> 5))
90
+ t = (t + v1) & _MASK
91
+ v0 = (v0 - (t ^ ((s + kw[s & 3]) & _MASK))) & _MASK
92
+ struct.pack_into('<2I', result, blk * _BLOCK_SIZE, v0, v1)
93
+
94
+ return bytes(result)
95
+
96
+
97
+ # -- Product-specific key seeds --
98
+ _KEY_ATLAS = bytes([170, 105, 68])
99
+ _KEY_MA12 = bytes([56, 153, 207])
100
+ _KEY_JUNO = bytes([141, 175, 17])
101
+
102
+ _PRODUCT_KEY_MAP = {
103
+ 7: _KEY_ATLAS, # Atlas
104
+ 8: _KEY_MA12, # MA12
105
+ 9: _KEY_MA12, # MA12 mBar
106
+ 12: _KEY_ATLAS, # Atlas2
107
+ 14: _KEY_JUNO, # Atlas2 Juno
108
+ 15: _KEY_MA12, # MA15A
109
+ }
110
+
111
+
112
+ def build_session_key(type_zero_raw: bytes, product_id: int) -> bytes:
113
+ """Generate the 16-byte session key from Type 0 record and product ID.
114
+
115
+ The key is assembled from 3 product-specific seed bytes interleaved
116
+ with 13 bytes picked from specific positions in the Type 0 record.
117
+
118
+ Args:
119
+ type_zero_raw: The raw 32-byte Type 0 record from the device.
120
+ product_id: Product ID byte (type_zero_raw[15]).
121
+
122
+ Returns:
123
+ 16-byte session key for XTEA encryption/decryption.
124
+ """
125
+ if len(type_zero_raw) < 27:
126
+ raise ValueError(f"Type 0 record too short: {len(type_zero_raw)} bytes")
127
+
128
+ seed = _PRODUCT_KEY_MAP.get(product_id, _KEY_MA12)
129
+ r = type_zero_raw # shorthand
130
+
131
+ key = bytearray(16)
132
+ key[0] = seed[0]
133
+ key[1] = r[23]
134
+ key[2] = r[6]
135
+ key[3] = r[13]
136
+ key[4] = r[24]
137
+ key[5] = r[22]
138
+ key[6] = r[12]
139
+ key[7] = seed[1]
140
+ key[8] = r[7]
141
+ key[9] = r[8]
142
+ key[10] = r[10]
143
+ key[11] = seed[2]
144
+ key[12] = r[9]
145
+ key[13] = r[11]
146
+ key[14] = r[26]
147
+ key[15] = r[25]
148
+ return bytes(key)
149
+
150
+
151
+ def self_test() -> bool:
152
+ """Verify encrypt/decrypt roundtrip works correctly."""
153
+ import os
154
+ key = os.urandom(16)
155
+ plaintext = os.urandom(32)
156
+ ciphertext = xtea_encrypt(plaintext, key)
157
+ decrypted = xtea_decrypt(ciphertext, key)
158
+ assert decrypted == plaintext, "XTEA self-test FAILED: roundtrip mismatch"
159
+ assert ciphertext != plaintext, "XTEA self-test FAILED: ciphertext equals plaintext"
160
+ return True
161
+
162
+
163
+ if __name__ == '__main__':
164
+ self_test()
165
+ print("XTEA self-test passed.")