conduyt-py 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.
- conduyt_py-0.1.0/PKG-INFO +143 -0
- conduyt_py-0.1.0/README.md +124 -0
- conduyt_py-0.1.0/pyproject.toml +38 -0
- conduyt_py-0.1.0/setup.cfg +4 -0
- conduyt_py-0.1.0/src/conduyt/__init__.py +18 -0
- conduyt_py-0.1.0/src/conduyt/core/__init__.py +0 -0
- conduyt_py-0.1.0/src/conduyt/core/cobs.py +60 -0
- conduyt_py-0.1.0/src/conduyt/core/constants.py +119 -0
- conduyt_py-0.1.0/src/conduyt/core/crc8.py +44 -0
- conduyt_py-0.1.0/src/conduyt/core/errors.py +30 -0
- conduyt_py-0.1.0/src/conduyt/core/wire.py +78 -0
- conduyt_py-0.1.0/src/conduyt/device.py +141 -0
- conduyt_py-0.1.0/src/conduyt/hello.py +125 -0
- conduyt_py-0.1.0/src/conduyt/modules/__init__.py +18 -0
- conduyt_py-0.1.0/src/conduyt/modules/dht.py +36 -0
- conduyt_py-0.1.0/src/conduyt/modules/encoder.py +32 -0
- conduyt_py-0.1.0/src/conduyt/modules/neopixel.py +48 -0
- conduyt_py-0.1.0/src/conduyt/modules/oled.py +41 -0
- conduyt_py-0.1.0/src/conduyt/modules/pid.py +41 -0
- conduyt_py-0.1.0/src/conduyt/modules/servo.py +35 -0
- conduyt_py-0.1.0/src/conduyt/modules/stepper.py +34 -0
- conduyt_py-0.1.0/src/conduyt/sync.py +54 -0
- conduyt_py-0.1.0/src/conduyt/transports/__init__.py +7 -0
- conduyt_py-0.1.0/src/conduyt/transports/mock.py +46 -0
- conduyt_py-0.1.0/src/conduyt/transports/mqtt.py +78 -0
- conduyt_py-0.1.0/src/conduyt/transports/serial.py +75 -0
- conduyt_py-0.1.0/src/conduyt_py.egg-info/PKG-INFO +143 -0
- conduyt_py-0.1.0/src/conduyt_py.egg-info/SOURCES.txt +36 -0
- conduyt_py-0.1.0/src/conduyt_py.egg-info/dependency_links.txt +1 -0
- conduyt_py-0.1.0/src/conduyt_py.egg-info/requires.txt +10 -0
- conduyt_py-0.1.0/src/conduyt_py.egg-info/top_level.txt +1 -0
- conduyt_py-0.1.0/tests/test_cobs.py +58 -0
- conduyt_py-0.1.0/tests/test_conformance.py +39 -0
- conduyt_py-0.1.0/tests/test_crc8.py +32 -0
- conduyt_py-0.1.0/tests/test_device.py +353 -0
- conduyt_py-0.1.0/tests/test_hello.py +264 -0
- conduyt_py-0.1.0/tests/test_modules.py +302 -0
- conduyt_py-0.1.0/tests/test_wire.py +72 -0
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: conduyt-py
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: CONDUYT protocol SDK for Python — host-side hardware control
|
|
5
|
+
License: MIT
|
|
6
|
+
Project-URL: Homepage, https://github.com/virgilvox/conduyt
|
|
7
|
+
Project-URL: Repository, https://github.com/virgilvox/conduyt
|
|
8
|
+
Project-URL: Issues, https://github.com/virgilvox/conduyt/issues
|
|
9
|
+
Keywords: conduyt,iot,hardware,serial,mqtt,microcontroller,binary-protocol
|
|
10
|
+
Requires-Python: >=3.10
|
|
11
|
+
Description-Content-Type: text/markdown
|
|
12
|
+
Provides-Extra: serial
|
|
13
|
+
Requires-Dist: pyserial-asyncio>=0.6; extra == "serial"
|
|
14
|
+
Provides-Extra: mqtt
|
|
15
|
+
Requires-Dist: aiomqtt>=2.0; extra == "mqtt"
|
|
16
|
+
Provides-Extra: all
|
|
17
|
+
Requires-Dist: pyserial-asyncio>=0.6; extra == "all"
|
|
18
|
+
Requires-Dist: aiomqtt>=2.0; extra == "all"
|
|
19
|
+
|
|
20
|
+
# conduyt-py
|
|
21
|
+
|
|
22
|
+
CONDUYT protocol SDK for Python. Async and sync APIs for host-side hardware control.
|
|
23
|
+
|
|
24
|
+
## Install
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
pip install conduyt-py
|
|
28
|
+
|
|
29
|
+
# With serial transport
|
|
30
|
+
pip install conduyt-py[serial]
|
|
31
|
+
|
|
32
|
+
# With MQTT transport
|
|
33
|
+
pip install conduyt-py[mqtt]
|
|
34
|
+
|
|
35
|
+
# All transports
|
|
36
|
+
pip install conduyt-py[all]
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
## Quick Start
|
|
40
|
+
|
|
41
|
+
### Async
|
|
42
|
+
|
|
43
|
+
```python
|
|
44
|
+
from conduyt import ConduytDevice
|
|
45
|
+
from conduyt.transports.serial import SerialTransport
|
|
46
|
+
|
|
47
|
+
device = ConduytDevice(SerialTransport("/dev/ttyUSB0"))
|
|
48
|
+
hello = await device.connect()
|
|
49
|
+
|
|
50
|
+
await device.pin(13).mode("output")
|
|
51
|
+
await device.pin(13).write(1)
|
|
52
|
+
value = await device.pin(0).read("analog")
|
|
53
|
+
|
|
54
|
+
await device.disconnect()
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
### Sync
|
|
58
|
+
|
|
59
|
+
```python
|
|
60
|
+
from conduyt import ConduytDeviceSync
|
|
61
|
+
from conduyt.transports.serial import SerialTransport
|
|
62
|
+
|
|
63
|
+
device = ConduytDeviceSync(SerialTransport("/dev/ttyUSB0"))
|
|
64
|
+
device.connect()
|
|
65
|
+
|
|
66
|
+
device.pin(13).mode("output")
|
|
67
|
+
device.pin(13).write(1)
|
|
68
|
+
value = device.pin(0).read("analog")
|
|
69
|
+
|
|
70
|
+
device.disconnect()
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
### Module Usage
|
|
74
|
+
|
|
75
|
+
```python
|
|
76
|
+
from conduyt.modules.servo import ConduytServo
|
|
77
|
+
|
|
78
|
+
servo = ConduytServo(device)
|
|
79
|
+
await servo.attach(9)
|
|
80
|
+
await servo.write(90)
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
## API Reference
|
|
84
|
+
|
|
85
|
+
### ConduytDevice (async)
|
|
86
|
+
|
|
87
|
+
| Method | Returns | Description |
|
|
88
|
+
|--------|---------|-------------|
|
|
89
|
+
| `connect()` | `HelloResp` | Connect and run HELLO handshake |
|
|
90
|
+
| `disconnect()` | `None` | Close connection |
|
|
91
|
+
| `ping()` | `None` | Ping/pong roundtrip |
|
|
92
|
+
| `reset()` | `None` | Reset device state |
|
|
93
|
+
| `pin(num)` | `PinProxy` | Pin control object |
|
|
94
|
+
|
|
95
|
+
### ConduytDeviceSync
|
|
96
|
+
|
|
97
|
+
Synchronous wrapper around `ConduytDevice`. Same methods, no `await`.
|
|
98
|
+
|
|
99
|
+
### PinProxy
|
|
100
|
+
|
|
101
|
+
| Method | Returns | Description |
|
|
102
|
+
|--------|---------|-------------|
|
|
103
|
+
| `mode(mode)` | `None` | Set pin mode: `"input"`, `"output"`, `"pwm"`, `"analog"`, `"input_pullup"` |
|
|
104
|
+
| `write(value)` | `None` | Digital or PWM write |
|
|
105
|
+
| `read(mode?)` | `int` | Read pin value |
|
|
106
|
+
|
|
107
|
+
### Errors
|
|
108
|
+
|
|
109
|
+
| Class | When |
|
|
110
|
+
|-------|------|
|
|
111
|
+
| `ConduytNAKError` | Device rejected a command |
|
|
112
|
+
| `ConduytTimeoutError` | No response within timeout |
|
|
113
|
+
| `ConduytDisconnectedError` | Transport disconnected |
|
|
114
|
+
|
|
115
|
+
## Transports
|
|
116
|
+
|
|
117
|
+
| Transport | Module | Dependency |
|
|
118
|
+
|-----------|--------|------------|
|
|
119
|
+
| `SerialTransport` | `conduyt.transports.serial` | `pyserial-asyncio` |
|
|
120
|
+
| `MQTTTransport` | `conduyt.transports.mqtt` | `aiomqtt` |
|
|
121
|
+
| `MockTransport` | `conduyt.transports.mock` | None |
|
|
122
|
+
|
|
123
|
+
## Modules
|
|
124
|
+
|
|
125
|
+
| Module | Import | Hardware |
|
|
126
|
+
|--------|--------|----------|
|
|
127
|
+
| `ConduytServo` | `conduyt.modules.servo` | Hobby servos |
|
|
128
|
+
| `ConduytNeoPixel` | `conduyt.modules.neopixel` | WS2812/SK6812 LEDs |
|
|
129
|
+
| `ConduytDHT` | `conduyt.modules.dht` | DHT11/DHT22 sensors |
|
|
130
|
+
| `ConduytOLED` | `conduyt.modules.oled` | SSD1306 OLED displays |
|
|
131
|
+
| `ConduytStepper` | `conduyt.modules.stepper` | Stepper motors |
|
|
132
|
+
| `ConduytEncoder` | `conduyt.modules.encoder` | Rotary encoders |
|
|
133
|
+
| `ConduytPID` | `conduyt.modules.pid` | PID controller |
|
|
134
|
+
|
|
135
|
+
## Requirements
|
|
136
|
+
|
|
137
|
+
- Python >= 3.10
|
|
138
|
+
- `pyserial-asyncio >= 0.6` (for serial transport)
|
|
139
|
+
- `aiomqtt >= 2.0` (for MQTT transport)
|
|
140
|
+
|
|
141
|
+
## License
|
|
142
|
+
|
|
143
|
+
MIT. Copyright (c) 2026 LumenCanvas.
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
# conduyt-py
|
|
2
|
+
|
|
3
|
+
CONDUYT protocol SDK for Python. Async and sync APIs for host-side hardware control.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
pip install conduyt-py
|
|
9
|
+
|
|
10
|
+
# With serial transport
|
|
11
|
+
pip install conduyt-py[serial]
|
|
12
|
+
|
|
13
|
+
# With MQTT transport
|
|
14
|
+
pip install conduyt-py[mqtt]
|
|
15
|
+
|
|
16
|
+
# All transports
|
|
17
|
+
pip install conduyt-py[all]
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
## Quick Start
|
|
21
|
+
|
|
22
|
+
### Async
|
|
23
|
+
|
|
24
|
+
```python
|
|
25
|
+
from conduyt import ConduytDevice
|
|
26
|
+
from conduyt.transports.serial import SerialTransport
|
|
27
|
+
|
|
28
|
+
device = ConduytDevice(SerialTransport("/dev/ttyUSB0"))
|
|
29
|
+
hello = await device.connect()
|
|
30
|
+
|
|
31
|
+
await device.pin(13).mode("output")
|
|
32
|
+
await device.pin(13).write(1)
|
|
33
|
+
value = await device.pin(0).read("analog")
|
|
34
|
+
|
|
35
|
+
await device.disconnect()
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
### Sync
|
|
39
|
+
|
|
40
|
+
```python
|
|
41
|
+
from conduyt import ConduytDeviceSync
|
|
42
|
+
from conduyt.transports.serial import SerialTransport
|
|
43
|
+
|
|
44
|
+
device = ConduytDeviceSync(SerialTransport("/dev/ttyUSB0"))
|
|
45
|
+
device.connect()
|
|
46
|
+
|
|
47
|
+
device.pin(13).mode("output")
|
|
48
|
+
device.pin(13).write(1)
|
|
49
|
+
value = device.pin(0).read("analog")
|
|
50
|
+
|
|
51
|
+
device.disconnect()
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
### Module Usage
|
|
55
|
+
|
|
56
|
+
```python
|
|
57
|
+
from conduyt.modules.servo import ConduytServo
|
|
58
|
+
|
|
59
|
+
servo = ConduytServo(device)
|
|
60
|
+
await servo.attach(9)
|
|
61
|
+
await servo.write(90)
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
## API Reference
|
|
65
|
+
|
|
66
|
+
### ConduytDevice (async)
|
|
67
|
+
|
|
68
|
+
| Method | Returns | Description |
|
|
69
|
+
|--------|---------|-------------|
|
|
70
|
+
| `connect()` | `HelloResp` | Connect and run HELLO handshake |
|
|
71
|
+
| `disconnect()` | `None` | Close connection |
|
|
72
|
+
| `ping()` | `None` | Ping/pong roundtrip |
|
|
73
|
+
| `reset()` | `None` | Reset device state |
|
|
74
|
+
| `pin(num)` | `PinProxy` | Pin control object |
|
|
75
|
+
|
|
76
|
+
### ConduytDeviceSync
|
|
77
|
+
|
|
78
|
+
Synchronous wrapper around `ConduytDevice`. Same methods, no `await`.
|
|
79
|
+
|
|
80
|
+
### PinProxy
|
|
81
|
+
|
|
82
|
+
| Method | Returns | Description |
|
|
83
|
+
|--------|---------|-------------|
|
|
84
|
+
| `mode(mode)` | `None` | Set pin mode: `"input"`, `"output"`, `"pwm"`, `"analog"`, `"input_pullup"` |
|
|
85
|
+
| `write(value)` | `None` | Digital or PWM write |
|
|
86
|
+
| `read(mode?)` | `int` | Read pin value |
|
|
87
|
+
|
|
88
|
+
### Errors
|
|
89
|
+
|
|
90
|
+
| Class | When |
|
|
91
|
+
|-------|------|
|
|
92
|
+
| `ConduytNAKError` | Device rejected a command |
|
|
93
|
+
| `ConduytTimeoutError` | No response within timeout |
|
|
94
|
+
| `ConduytDisconnectedError` | Transport disconnected |
|
|
95
|
+
|
|
96
|
+
## Transports
|
|
97
|
+
|
|
98
|
+
| Transport | Module | Dependency |
|
|
99
|
+
|-----------|--------|------------|
|
|
100
|
+
| `SerialTransport` | `conduyt.transports.serial` | `pyserial-asyncio` |
|
|
101
|
+
| `MQTTTransport` | `conduyt.transports.mqtt` | `aiomqtt` |
|
|
102
|
+
| `MockTransport` | `conduyt.transports.mock` | None |
|
|
103
|
+
|
|
104
|
+
## Modules
|
|
105
|
+
|
|
106
|
+
| Module | Import | Hardware |
|
|
107
|
+
|--------|--------|----------|
|
|
108
|
+
| `ConduytServo` | `conduyt.modules.servo` | Hobby servos |
|
|
109
|
+
| `ConduytNeoPixel` | `conduyt.modules.neopixel` | WS2812/SK6812 LEDs |
|
|
110
|
+
| `ConduytDHT` | `conduyt.modules.dht` | DHT11/DHT22 sensors |
|
|
111
|
+
| `ConduytOLED` | `conduyt.modules.oled` | SSD1306 OLED displays |
|
|
112
|
+
| `ConduytStepper` | `conduyt.modules.stepper` | Stepper motors |
|
|
113
|
+
| `ConduytEncoder` | `conduyt.modules.encoder` | Rotary encoders |
|
|
114
|
+
| `ConduytPID` | `conduyt.modules.pid` | PID controller |
|
|
115
|
+
|
|
116
|
+
## Requirements
|
|
117
|
+
|
|
118
|
+
- Python >= 3.10
|
|
119
|
+
- `pyserial-asyncio >= 0.6` (for serial transport)
|
|
120
|
+
- `aiomqtt >= 2.0` (for MQTT transport)
|
|
121
|
+
|
|
122
|
+
## License
|
|
123
|
+
|
|
124
|
+
MIT. Copyright (c) 2026 LumenCanvas.
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "conduyt-py"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "CONDUYT protocol SDK for Python — host-side hardware control"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
license = {text = "MIT"}
|
|
7
|
+
requires-python = ">=3.10"
|
|
8
|
+
dependencies = []
|
|
9
|
+
keywords = ["conduyt", "iot", "hardware", "serial", "mqtt", "microcontroller", "binary-protocol"]
|
|
10
|
+
|
|
11
|
+
[project.urls]
|
|
12
|
+
Homepage = "https://github.com/virgilvox/conduyt"
|
|
13
|
+
Repository = "https://github.com/virgilvox/conduyt"
|
|
14
|
+
Issues = "https://github.com/virgilvox/conduyt/issues"
|
|
15
|
+
|
|
16
|
+
[project.optional-dependencies]
|
|
17
|
+
serial = ["pyserial-asyncio>=0.6"]
|
|
18
|
+
mqtt = ["aiomqtt>=2.0"]
|
|
19
|
+
all = ["pyserial-asyncio>=0.6", "aiomqtt>=2.0"]
|
|
20
|
+
|
|
21
|
+
[build-system]
|
|
22
|
+
requires = ["setuptools>=68"]
|
|
23
|
+
build-backend = "setuptools.build_meta"
|
|
24
|
+
|
|
25
|
+
[tool.setuptools.packages.find]
|
|
26
|
+
where = ["src"]
|
|
27
|
+
|
|
28
|
+
[tool.mypy]
|
|
29
|
+
strict = true
|
|
30
|
+
python_version = "3.10"
|
|
31
|
+
|
|
32
|
+
[tool.ruff]
|
|
33
|
+
target-version = "py310"
|
|
34
|
+
line-length = 100
|
|
35
|
+
|
|
36
|
+
[tool.pytest.ini_options]
|
|
37
|
+
testpaths = ["tests"]
|
|
38
|
+
asyncio_mode = "auto"
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"""
|
|
2
|
+
conduyt-py — CONDUYT Protocol SDK for Python
|
|
3
|
+
|
|
4
|
+
Reference host-side SDK for the CONDUYT protocol.
|
|
5
|
+
Transport-agnostic, capability-first hardware control.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from conduyt.device import ConduytDevice
|
|
9
|
+
from conduyt.sync import ConduytDeviceSync
|
|
10
|
+
from conduyt.core.errors import ConduytNAKError, ConduytTimeoutError, ConduytDisconnectedError
|
|
11
|
+
|
|
12
|
+
__all__ = [
|
|
13
|
+
"ConduytDevice",
|
|
14
|
+
"ConduytDeviceSync",
|
|
15
|
+
"ConduytNAKError",
|
|
16
|
+
"ConduytTimeoutError",
|
|
17
|
+
"ConduytDisconnectedError",
|
|
18
|
+
]
|
|
File without changes
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"""COBS — Consistent Overhead Byte Stuffing"""
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def cobs_encode(src: bytes | bytearray) -> bytes:
|
|
5
|
+
"""Encode data using COBS. Output never contains 0x00."""
|
|
6
|
+
if len(src) == 0:
|
|
7
|
+
return bytes([0x01])
|
|
8
|
+
|
|
9
|
+
dst = bytearray()
|
|
10
|
+
code_idx = len(dst)
|
|
11
|
+
dst.append(0) # placeholder for first code byte
|
|
12
|
+
code = 1
|
|
13
|
+
|
|
14
|
+
for b in src:
|
|
15
|
+
if b == 0x00:
|
|
16
|
+
dst[code_idx] = code
|
|
17
|
+
code_idx = len(dst)
|
|
18
|
+
dst.append(0) # placeholder
|
|
19
|
+
code = 1
|
|
20
|
+
else:
|
|
21
|
+
dst.append(b)
|
|
22
|
+
code += 1
|
|
23
|
+
if code == 0xFF:
|
|
24
|
+
dst[code_idx] = code
|
|
25
|
+
code_idx = len(dst)
|
|
26
|
+
dst.append(0)
|
|
27
|
+
code = 1
|
|
28
|
+
|
|
29
|
+
dst[code_idx] = code
|
|
30
|
+
return bytes(dst)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def cobs_decode(src: bytes | bytearray) -> bytes | None:
|
|
34
|
+
"""Decode COBS-encoded data. Returns None on decode error."""
|
|
35
|
+
if len(src) == 0:
|
|
36
|
+
return None
|
|
37
|
+
|
|
38
|
+
dst = bytearray()
|
|
39
|
+
idx = 0
|
|
40
|
+
|
|
41
|
+
while idx < len(src):
|
|
42
|
+
code = src[idx]
|
|
43
|
+
idx += 1
|
|
44
|
+
|
|
45
|
+
if code == 0x00:
|
|
46
|
+
return None # unexpected zero
|
|
47
|
+
|
|
48
|
+
count = code - 1
|
|
49
|
+
|
|
50
|
+
if idx + count > len(src):
|
|
51
|
+
return None
|
|
52
|
+
|
|
53
|
+
for _ in range(count):
|
|
54
|
+
dst.append(src[idx])
|
|
55
|
+
idx += 1
|
|
56
|
+
|
|
57
|
+
if code < 0xFF and idx < len(src):
|
|
58
|
+
dst.append(0x00)
|
|
59
|
+
|
|
60
|
+
return bytes(dst)
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
"""
|
|
2
|
+
CONDUYT Protocol Constants
|
|
3
|
+
Generated from protocol/constants.json — DO NOT EDIT
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
PROTOCOL_VERSION = 0x01
|
|
7
|
+
MAGIC = bytes([0x43, 0x44]) # "CD"
|
|
8
|
+
HEADER_SIZE = 8 # MAGIC(2) + VER(1) + TYPE(1) + SEQ(1) + LEN(2) + CRC(1)
|
|
9
|
+
|
|
10
|
+
# Host -> Device Commands
|
|
11
|
+
class CMD:
|
|
12
|
+
PING = 0x01
|
|
13
|
+
HELLO = 0x02
|
|
14
|
+
PIN_MODE = 0x10
|
|
15
|
+
PIN_WRITE = 0x11
|
|
16
|
+
PIN_READ = 0x12
|
|
17
|
+
PIN_SUBSCRIBE = 0x13
|
|
18
|
+
PIN_UNSUBSCRIBE = 0x14
|
|
19
|
+
I2C_WRITE = 0x20
|
|
20
|
+
I2C_READ = 0x21
|
|
21
|
+
I2C_READ_REG = 0x22
|
|
22
|
+
SPI_XFER = 0x30
|
|
23
|
+
MOD_CMD = 0x40
|
|
24
|
+
STREAM_START = 0x50
|
|
25
|
+
STREAM_STOP = 0x51
|
|
26
|
+
DS_WRITE = 0x60
|
|
27
|
+
DS_READ = 0x61
|
|
28
|
+
DS_SUBSCRIBE = 0x62
|
|
29
|
+
OTA_BEGIN = 0x70
|
|
30
|
+
OTA_CHUNK = 0x71
|
|
31
|
+
OTA_FINALIZE = 0x72
|
|
32
|
+
RESET = 0xF0
|
|
33
|
+
|
|
34
|
+
# Device -> Host Events
|
|
35
|
+
class EVT:
|
|
36
|
+
PONG = 0x80
|
|
37
|
+
HELLO_RESP = 0x81
|
|
38
|
+
ACK = 0x82
|
|
39
|
+
NAK = 0x83
|
|
40
|
+
PIN_EVENT = 0x90
|
|
41
|
+
PIN_READ_RESP = 0x91
|
|
42
|
+
I2C_READ_RESP = 0xA0
|
|
43
|
+
SPI_XFER_RESP = 0xB0
|
|
44
|
+
MOD_EVENT = 0xC0
|
|
45
|
+
MOD_RESP = 0xC1
|
|
46
|
+
STREAM_DATA = 0xD0
|
|
47
|
+
DS_EVENT = 0xD1
|
|
48
|
+
DS_READ_RESP = 0xD2
|
|
49
|
+
LOG = 0xE0
|
|
50
|
+
FATAL = 0xFF
|
|
51
|
+
|
|
52
|
+
# NAK Error Codes
|
|
53
|
+
class ERR:
|
|
54
|
+
UNKNOWN_TYPE = 0x01
|
|
55
|
+
CRC_MISMATCH = 0x02
|
|
56
|
+
PAYLOAD_TOO_LARGE = 0x03
|
|
57
|
+
INVALID_PIN = 0x04
|
|
58
|
+
PIN_MODE_UNSUPPORTED = 0x05
|
|
59
|
+
I2C_NOT_AVAILABLE = 0x06
|
|
60
|
+
I2C_NACK = 0x07
|
|
61
|
+
MODULE_NOT_LOADED = 0x08
|
|
62
|
+
UNKNOWN_MODULE_CMD = 0x09
|
|
63
|
+
MODULE_BUSY = 0x0A
|
|
64
|
+
SUB_LIMIT_REACHED = 0x0B
|
|
65
|
+
OUT_OF_MEMORY = 0x0C
|
|
66
|
+
UNKNOWN_DATASTREAM = 0x0D
|
|
67
|
+
DATASTREAM_READONLY = 0x0E
|
|
68
|
+
OTA_INVALID = 0x0F
|
|
69
|
+
VERSION_MISMATCH = 0x10
|
|
70
|
+
|
|
71
|
+
ERR_NAMES: dict[int, str] = {
|
|
72
|
+
ERR.UNKNOWN_TYPE: "UNKNOWN_TYPE",
|
|
73
|
+
ERR.CRC_MISMATCH: "CRC_MISMATCH",
|
|
74
|
+
ERR.PAYLOAD_TOO_LARGE: "PAYLOAD_TOO_LARGE",
|
|
75
|
+
ERR.INVALID_PIN: "INVALID_PIN",
|
|
76
|
+
ERR.PIN_MODE_UNSUPPORTED: "PIN_MODE_UNSUPPORTED",
|
|
77
|
+
ERR.I2C_NOT_AVAILABLE: "I2C_NOT_AVAILABLE",
|
|
78
|
+
ERR.I2C_NACK: "I2C_NACK",
|
|
79
|
+
ERR.MODULE_NOT_LOADED: "MODULE_NOT_LOADED",
|
|
80
|
+
ERR.UNKNOWN_MODULE_CMD: "UNKNOWN_MODULE_CMD",
|
|
81
|
+
ERR.MODULE_BUSY: "MODULE_BUSY",
|
|
82
|
+
ERR.SUB_LIMIT_REACHED: "SUB_LIMIT_REACHED",
|
|
83
|
+
ERR.OUT_OF_MEMORY: "OUT_OF_MEMORY",
|
|
84
|
+
ERR.UNKNOWN_DATASTREAM: "UNKNOWN_DATASTREAM",
|
|
85
|
+
ERR.DATASTREAM_READONLY: "DATASTREAM_READONLY",
|
|
86
|
+
ERR.OTA_INVALID: "OTA_INVALID",
|
|
87
|
+
ERR.VERSION_MISMATCH: "VERSION_MISMATCH",
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
# Datastream Type Codes
|
|
91
|
+
class DS_TYPE:
|
|
92
|
+
BOOL = 0x01
|
|
93
|
+
INT8 = 0x02
|
|
94
|
+
UINT8 = 0x03
|
|
95
|
+
INT16 = 0x04
|
|
96
|
+
UINT16 = 0x05
|
|
97
|
+
INT32 = 0x06
|
|
98
|
+
FLOAT32 = 0x07
|
|
99
|
+
STRING = 0x08
|
|
100
|
+
BYTES = 0x09
|
|
101
|
+
|
|
102
|
+
# Pin Capability Bitmask
|
|
103
|
+
class PIN_CAP:
|
|
104
|
+
DIGITAL_IN = 1 << 0
|
|
105
|
+
DIGITAL_OUT = 1 << 1
|
|
106
|
+
PWM_OUT = 1 << 2
|
|
107
|
+
ANALOG_IN = 1 << 3
|
|
108
|
+
I2C_SDA = 1 << 4
|
|
109
|
+
I2C_SCL = 1 << 5
|
|
110
|
+
SPI = 1 << 6
|
|
111
|
+
INTERRUPT = 1 << 7
|
|
112
|
+
|
|
113
|
+
# Pin Modes
|
|
114
|
+
class PIN_MODE:
|
|
115
|
+
INPUT = 0x00
|
|
116
|
+
OUTPUT = 0x01
|
|
117
|
+
PWM = 0x02
|
|
118
|
+
ANALOG = 0x03
|
|
119
|
+
INPUT_PULLUP = 0x04
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"""CRC8 Dallas/Maxim (polynomial 0x31, init 0x00)"""
|
|
2
|
+
|
|
3
|
+
_TABLE = bytes([
|
|
4
|
+
0x00, 0x31, 0x62, 0x53, 0xC4, 0xF5, 0xA6, 0x97,
|
|
5
|
+
0xB9, 0x88, 0xDB, 0xEA, 0x7D, 0x4C, 0x1F, 0x2E,
|
|
6
|
+
0x43, 0x72, 0x21, 0x10, 0x87, 0xB6, 0xE5, 0xD4,
|
|
7
|
+
0xFA, 0xCB, 0x98, 0xA9, 0x3E, 0x0F, 0x5C, 0x6D,
|
|
8
|
+
0x86, 0xB7, 0xE4, 0xD5, 0x42, 0x73, 0x20, 0x11,
|
|
9
|
+
0x3F, 0x0E, 0x5D, 0x6C, 0xFB, 0xCA, 0x99, 0xA8,
|
|
10
|
+
0xC5, 0xF4, 0xA7, 0x96, 0x01, 0x30, 0x63, 0x52,
|
|
11
|
+
0x7C, 0x4D, 0x1E, 0x2F, 0xB8, 0x89, 0xDA, 0xEB,
|
|
12
|
+
0x3D, 0x0C, 0x5F, 0x6E, 0xF9, 0xC8, 0x9B, 0xAA,
|
|
13
|
+
0x84, 0xB5, 0xE6, 0xD7, 0x40, 0x71, 0x22, 0x13,
|
|
14
|
+
0x7E, 0x4F, 0x1C, 0x2D, 0xBA, 0x8B, 0xD8, 0xE9,
|
|
15
|
+
0xC7, 0xF6, 0xA5, 0x94, 0x03, 0x32, 0x61, 0x50,
|
|
16
|
+
0xBB, 0x8A, 0xD9, 0xE8, 0x7F, 0x4E, 0x1D, 0x2C,
|
|
17
|
+
0x02, 0x33, 0x60, 0x51, 0xC6, 0xF7, 0xA4, 0x95,
|
|
18
|
+
0xF8, 0xC9, 0x9A, 0xAB, 0x3C, 0x0D, 0x5E, 0x6F,
|
|
19
|
+
0x41, 0x70, 0x23, 0x12, 0x85, 0xB4, 0xE7, 0xD6,
|
|
20
|
+
0x7A, 0x4B, 0x18, 0x29, 0xBE, 0x8F, 0xDC, 0xED,
|
|
21
|
+
0xC3, 0xF2, 0xA1, 0x90, 0x07, 0x36, 0x65, 0x54,
|
|
22
|
+
0x39, 0x08, 0x5B, 0x6A, 0xFD, 0xCC, 0x9F, 0xAE,
|
|
23
|
+
0x80, 0xB1, 0xE2, 0xD3, 0x44, 0x75, 0x26, 0x17,
|
|
24
|
+
0xFC, 0xCD, 0x9E, 0xAF, 0x38, 0x09, 0x5A, 0x6B,
|
|
25
|
+
0x45, 0x74, 0x27, 0x16, 0x81, 0xB0, 0xE3, 0xD2,
|
|
26
|
+
0xBF, 0x8E, 0xDD, 0xEC, 0x7B, 0x4A, 0x19, 0x28,
|
|
27
|
+
0x06, 0x37, 0x64, 0x55, 0xC2, 0xF3, 0xA0, 0x91,
|
|
28
|
+
0x47, 0x76, 0x25, 0x14, 0x83, 0xB2, 0xE1, 0xD0,
|
|
29
|
+
0xFE, 0xCF, 0x9C, 0xAD, 0x3A, 0x0B, 0x58, 0x69,
|
|
30
|
+
0x04, 0x35, 0x66, 0x57, 0xC0, 0xF1, 0xA2, 0x93,
|
|
31
|
+
0xBD, 0x8C, 0xDF, 0xEE, 0x79, 0x48, 0x1B, 0x2A,
|
|
32
|
+
0xE1, 0xD0, 0x83, 0xB2, 0x25, 0x14, 0x47, 0x76,
|
|
33
|
+
0x58, 0x69, 0x3A, 0x0B, 0x9C, 0xAD, 0xFE, 0xCF,
|
|
34
|
+
0xA2, 0x93, 0xC0, 0xF1, 0x66, 0x57, 0x04, 0x35,
|
|
35
|
+
0x1B, 0x2A, 0x79, 0x48, 0xDF, 0xEE, 0xBD, 0x8C,
|
|
36
|
+
])
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def crc8(data: bytes | bytearray) -> int:
|
|
40
|
+
"""Compute CRC8 Dallas/Maxim over a byte sequence."""
|
|
41
|
+
crc = 0x00
|
|
42
|
+
for b in data:
|
|
43
|
+
crc = _TABLE[crc ^ b]
|
|
44
|
+
return crc
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"""CONDUYT Error Types"""
|
|
2
|
+
|
|
3
|
+
from .constants import ERR_NAMES
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class ConduytNAKError(Exception):
|
|
7
|
+
"""Raised when the device replies with a NAK packet."""
|
|
8
|
+
|
|
9
|
+
def __init__(self, code: int, seq: int) -> None:
|
|
10
|
+
name = ERR_NAMES.get(code, f"UNKNOWN_0x{code:02x}")
|
|
11
|
+
super().__init__(f"NAK: {name} (code=0x{code:02x}, seq={seq})")
|
|
12
|
+
self.code = code
|
|
13
|
+
self.error_name = name
|
|
14
|
+
self.seq = seq
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class ConduytTimeoutError(Exception):
|
|
18
|
+
"""Raised when a command does not receive a response in time."""
|
|
19
|
+
|
|
20
|
+
def __init__(self, seq: int, timeout_ms: int) -> None:
|
|
21
|
+
super().__init__(f"Timeout: no response for seq={seq} after {timeout_ms}ms")
|
|
22
|
+
self.seq = seq
|
|
23
|
+
self.timeout_ms = timeout_ms
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class ConduytDisconnectedError(Exception):
|
|
27
|
+
"""Raised when an operation is attempted on a disconnected device."""
|
|
28
|
+
|
|
29
|
+
def __init__(self, message: str = "Device is not connected") -> None:
|
|
30
|
+
super().__init__(message)
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
"""CONDUYT Wire Format — Packet encode/decode"""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import struct
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
|
|
8
|
+
from .constants import PROTOCOL_VERSION, MAGIC, HEADER_SIZE
|
|
9
|
+
from .crc8 import crc8
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass
|
|
13
|
+
class ConduytPacket:
|
|
14
|
+
version: int
|
|
15
|
+
type: int
|
|
16
|
+
seq: int
|
|
17
|
+
payload: bytes
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def wire_encode(packet: ConduytPacket) -> bytes:
|
|
21
|
+
"""Encode a ConduytPacket into raw wire bytes."""
|
|
22
|
+
payload_len = len(packet.payload)
|
|
23
|
+
buf = bytearray(HEADER_SIZE + payload_len)
|
|
24
|
+
|
|
25
|
+
# MAGIC
|
|
26
|
+
buf[0] = MAGIC[0]
|
|
27
|
+
buf[1] = MAGIC[1]
|
|
28
|
+
# VER
|
|
29
|
+
buf[2] = packet.version
|
|
30
|
+
# TYPE
|
|
31
|
+
buf[3] = packet.type
|
|
32
|
+
# SEQ
|
|
33
|
+
buf[4] = packet.seq
|
|
34
|
+
# LEN (little-endian uint16)
|
|
35
|
+
struct.pack_into("<H", buf, 5, payload_len)
|
|
36
|
+
# PAYLOAD
|
|
37
|
+
buf[7 : 7 + payload_len] = packet.payload
|
|
38
|
+
# CRC8 over [VER..end of PAYLOAD]
|
|
39
|
+
crc_region = buf[2 : 7 + payload_len]
|
|
40
|
+
buf[7 + payload_len] = crc8(crc_region)
|
|
41
|
+
|
|
42
|
+
return bytes(buf)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def wire_decode(data: bytes | bytearray) -> ConduytPacket:
|
|
46
|
+
"""Decode raw wire bytes into a ConduytPacket. Raises ValueError on error."""
|
|
47
|
+
if len(data) < HEADER_SIZE:
|
|
48
|
+
raise ValueError(f"Incomplete packet: need {HEADER_SIZE} bytes, got {len(data)}")
|
|
49
|
+
|
|
50
|
+
if data[0] != MAGIC[0] or data[1] != MAGIC[1]:
|
|
51
|
+
raise ValueError(f"Invalid magic: 0x{data[0]:02x}{data[1]:02x}")
|
|
52
|
+
|
|
53
|
+
version = data[2]
|
|
54
|
+
if version != PROTOCOL_VERSION:
|
|
55
|
+
raise ValueError(f"Version mismatch: expected {PROTOCOL_VERSION}, got {version}")
|
|
56
|
+
|
|
57
|
+
pkt_type = data[3]
|
|
58
|
+
seq = data[4]
|
|
59
|
+
payload_len = struct.unpack_from("<H", data, 5)[0]
|
|
60
|
+
|
|
61
|
+
total = HEADER_SIZE + payload_len
|
|
62
|
+
if len(data) < total:
|
|
63
|
+
raise ValueError(f"Incomplete packet: need {total} bytes, got {len(data)}")
|
|
64
|
+
|
|
65
|
+
crc_region = data[2 : 7 + payload_len]
|
|
66
|
+
expected_crc = crc8(crc_region)
|
|
67
|
+
actual_crc = data[7 + payload_len]
|
|
68
|
+
|
|
69
|
+
if expected_crc != actual_crc:
|
|
70
|
+
raise ValueError(f"CRC mismatch: expected 0x{expected_crc:02x}, got 0x{actual_crc:02x}")
|
|
71
|
+
|
|
72
|
+
payload = bytes(data[7 : 7 + payload_len])
|
|
73
|
+
return ConduytPacket(version=version, type=pkt_type, seq=seq, payload=payload)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def make_packet(pkt_type: int, seq: int, payload: bytes = b"") -> ConduytPacket:
|
|
77
|
+
"""Create a ConduytPacket ready for encoding."""
|
|
78
|
+
return ConduytPacket(version=PROTOCOL_VERSION, type=pkt_type, seq=seq, payload=payload)
|