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.
- pyaltitool-0.1.0/LICENSE +21 -0
- pyaltitool-0.1.0/PKG-INFO +232 -0
- pyaltitool-0.1.0/README.md +202 -0
- pyaltitool-0.1.0/pyaltitool/__init__.py +42 -0
- pyaltitool-0.1.0/pyaltitool/crypto.py +165 -0
- pyaltitool-0.1.0/pyaltitool/device.py +810 -0
- pyaltitool-0.1.0/pyaltitool/protocol.py +855 -0
- pyaltitool-0.1.0/pyaltitool.egg-info/PKG-INFO +232 -0
- pyaltitool-0.1.0/pyaltitool.egg-info/SOURCES.txt +12 -0
- pyaltitool-0.1.0/pyaltitool.egg-info/dependency_links.txt +1 -0
- pyaltitool-0.1.0/pyaltitool.egg-info/requires.txt +1 -0
- pyaltitool-0.1.0/pyaltitool.egg-info/top_level.txt +1 -0
- pyaltitool-0.1.0/pyproject.toml +42 -0
- pyaltitool-0.1.0/setup.cfg +4 -0
pyaltitool-0.1.0/LICENSE
ADDED
|
@@ -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
|
+
[](https://pypi.org/project/pyaltitool/)
|
|
34
|
+
[](https://pypi.org/project/pyaltitool/)
|
|
35
|
+
[](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
|
+
[](https://pypi.org/project/pyaltitool/)
|
|
4
|
+
[](https://pypi.org/project/pyaltitool/)
|
|
5
|
+
[](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.")
|