wirelink 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- wirelink/__init__.py +4 -0
- wirelink/cobs.py +47 -0
- wirelink/codec.py +125 -0
- wirelink/crc.py +12 -0
- wirelink/link.py +293 -0
- wirelink-0.1.0.dist-info/METADATA +42 -0
- wirelink-0.1.0.dist-info/RECORD +10 -0
- wirelink-0.1.0.dist-info/WHEEL +5 -0
- wirelink-0.1.0.dist-info/licenses/LICENSE +21 -0
- wirelink-0.1.0.dist-info/top_level.txt +1 -0
wirelink/__init__.py
ADDED
wirelink/cobs.py
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"""Consistent Overhead Byte Stuffing.
|
|
2
|
+
|
|
3
|
+
Removes 0x00 from the payload so a single 0x00 byte can mark end-of-frame.
|
|
4
|
+
Worst case overhead is 1 byte per 254 bytes of payload.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def encode(data: bytes) -> bytes:
|
|
9
|
+
out = bytearray()
|
|
10
|
+
code_idx = 0
|
|
11
|
+
out.append(0) # placeholder for the first code byte
|
|
12
|
+
code = 1
|
|
13
|
+
for byte in data:
|
|
14
|
+
if byte == 0:
|
|
15
|
+
out[code_idx] = code
|
|
16
|
+
code_idx = len(out)
|
|
17
|
+
out.append(0)
|
|
18
|
+
code = 1
|
|
19
|
+
else:
|
|
20
|
+
out.append(byte)
|
|
21
|
+
code += 1
|
|
22
|
+
if code == 0xFF:
|
|
23
|
+
out[code_idx] = code
|
|
24
|
+
code_idx = len(out)
|
|
25
|
+
out.append(0)
|
|
26
|
+
code = 1
|
|
27
|
+
out[code_idx] = code
|
|
28
|
+
return bytes(out)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def decode(data: bytes) -> bytes:
|
|
32
|
+
out = bytearray()
|
|
33
|
+
i = 0
|
|
34
|
+
n = len(data)
|
|
35
|
+
while i < n:
|
|
36
|
+
code = data[i]
|
|
37
|
+
if code == 0:
|
|
38
|
+
raise ValueError("zero code byte inside frame")
|
|
39
|
+
i += 1
|
|
40
|
+
end = i + code - 1
|
|
41
|
+
if end > n:
|
|
42
|
+
raise ValueError("truncated frame")
|
|
43
|
+
out.extend(data[i:end])
|
|
44
|
+
i = end
|
|
45
|
+
if code != 0xFF and i < n:
|
|
46
|
+
out.append(0)
|
|
47
|
+
return bytes(out)
|
wirelink/codec.py
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
"""Message framing and the type-tagged argument codec.
|
|
2
|
+
|
|
3
|
+
Frame on the wire: COBS(body) 0x00
|
|
4
|
+
body: type(1) seq(1) nlen(1) name(nlen) argc(1) args... crc16(2, LE)
|
|
5
|
+
arg: tag(1) value(...)
|
|
6
|
+
|
|
7
|
+
Both sides implement exactly this. Keep it in sync with WireLink.cpp.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import struct
|
|
11
|
+
|
|
12
|
+
from .cobs import decode as cobs_decode
|
|
13
|
+
from .cobs import encode as cobs_encode
|
|
14
|
+
from .crc import crc16
|
|
15
|
+
|
|
16
|
+
# message types
|
|
17
|
+
CALL = 0x01 # host -> device, no reply expected
|
|
18
|
+
REQ = 0x02 # host -> device, reply expected
|
|
19
|
+
REPLY = 0x03 # device -> host, answers a REQ with the same seq
|
|
20
|
+
EVENT = 0x04 # device -> host, unsolicited
|
|
21
|
+
ERR = 0x05 # either direction, payload is a single str
|
|
22
|
+
HELLO = 0x10 # host -> device, discovery probe
|
|
23
|
+
HELLO_ACK = 0x11 # device -> host, [str device_name, str lib_version]
|
|
24
|
+
|
|
25
|
+
# argument tags
|
|
26
|
+
T_BOOL = 0x01
|
|
27
|
+
T_I32 = 0x02
|
|
28
|
+
T_U32 = 0x03
|
|
29
|
+
T_F32 = 0x04
|
|
30
|
+
T_STR = 0x05
|
|
31
|
+
T_BYTES = 0x06
|
|
32
|
+
|
|
33
|
+
MAX_FRAME = 192 # must not exceed WIRELINK_MAX_FRAME on the device
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class ProtocolError(Exception):
|
|
37
|
+
pass
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def pack_args(values) -> bytes:
|
|
41
|
+
out = bytearray([len(values)])
|
|
42
|
+
for v in values:
|
|
43
|
+
if isinstance(v, bool):
|
|
44
|
+
out += bytes([T_BOOL, 1 if v else 0])
|
|
45
|
+
elif isinstance(v, int):
|
|
46
|
+
if -2147483648 <= v <= 2147483647:
|
|
47
|
+
out += bytes([T_I32]) + struct.pack("<i", v)
|
|
48
|
+
elif 0 <= v <= 4294967295:
|
|
49
|
+
out += bytes([T_U32]) + struct.pack("<I", v)
|
|
50
|
+
else:
|
|
51
|
+
raise ValueError(f"integer out of range for the wire format: {v}")
|
|
52
|
+
elif isinstance(v, float):
|
|
53
|
+
out += bytes([T_F32]) + struct.pack("<f", v)
|
|
54
|
+
elif isinstance(v, str):
|
|
55
|
+
raw = v.encode("utf-8")
|
|
56
|
+
if len(raw) > 255:
|
|
57
|
+
raise ValueError("string argument longer than 255 bytes")
|
|
58
|
+
out += bytes([T_STR, len(raw)]) + raw
|
|
59
|
+
elif isinstance(v, (bytes, bytearray)):
|
|
60
|
+
if len(v) > 255:
|
|
61
|
+
raise ValueError("bytes argument longer than 255 bytes")
|
|
62
|
+
out += bytes([T_BYTES, len(v)]) + bytes(v)
|
|
63
|
+
else:
|
|
64
|
+
raise TypeError(f"unsupported argument type: {type(v).__name__}")
|
|
65
|
+
return bytes(out)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def unpack_args(buf: bytes):
|
|
69
|
+
if not buf:
|
|
70
|
+
raise ProtocolError("missing argc byte")
|
|
71
|
+
argc = buf[0]
|
|
72
|
+
i = 1
|
|
73
|
+
values = []
|
|
74
|
+
for _ in range(argc):
|
|
75
|
+
if i >= len(buf):
|
|
76
|
+
raise ProtocolError("truncated argument list")
|
|
77
|
+
tag = buf[i]
|
|
78
|
+
i += 1
|
|
79
|
+
if tag == T_BOOL:
|
|
80
|
+
values.append(bool(buf[i]))
|
|
81
|
+
i += 1
|
|
82
|
+
elif tag == T_I32:
|
|
83
|
+
values.append(struct.unpack_from("<i", buf, i)[0])
|
|
84
|
+
i += 4
|
|
85
|
+
elif tag == T_U32:
|
|
86
|
+
values.append(struct.unpack_from("<I", buf, i)[0])
|
|
87
|
+
i += 4
|
|
88
|
+
elif tag == T_F32:
|
|
89
|
+
values.append(struct.unpack_from("<f", buf, i)[0])
|
|
90
|
+
i += 4
|
|
91
|
+
elif tag in (T_STR, T_BYTES):
|
|
92
|
+
n = buf[i]
|
|
93
|
+
i += 1
|
|
94
|
+
chunk = buf[i:i + n]
|
|
95
|
+
i += n
|
|
96
|
+
values.append(chunk.decode("utf-8", "replace") if tag == T_STR else chunk)
|
|
97
|
+
else:
|
|
98
|
+
raise ProtocolError(f"unknown argument tag 0x{tag:02x}")
|
|
99
|
+
return values
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def build(msg_type: int, seq: int, name: str, values=()) -> bytes:
|
|
103
|
+
raw_name = name.encode("ascii")
|
|
104
|
+
if len(raw_name) > 255:
|
|
105
|
+
raise ValueError("message name longer than 255 bytes")
|
|
106
|
+
body = bytes([msg_type, seq & 0xFF, len(raw_name)]) + raw_name + pack_args(values)
|
|
107
|
+
body += struct.pack("<H", crc16(body))
|
|
108
|
+
frame = cobs_encode(body) + b"\x00"
|
|
109
|
+
if len(frame) > MAX_FRAME:
|
|
110
|
+
raise ValueError(f"frame of {len(frame)} bytes exceeds MAX_FRAME ({MAX_FRAME})")
|
|
111
|
+
return frame
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def parse(frame: bytes):
|
|
115
|
+
"""Return (msg_type, seq, name, values) for one de-stuffed frame."""
|
|
116
|
+
body = cobs_decode(frame)
|
|
117
|
+
if len(body) < 6:
|
|
118
|
+
raise ProtocolError("frame too short")
|
|
119
|
+
payload, got = body[:-2], struct.unpack("<H", body[-2:])[0]
|
|
120
|
+
want = crc16(payload)
|
|
121
|
+
if got != want:
|
|
122
|
+
raise ProtocolError(f"crc mismatch (got 0x{got:04x}, want 0x{want:04x})")
|
|
123
|
+
msg_type, seq, nlen = payload[0], payload[1], payload[2]
|
|
124
|
+
name = payload[3:3 + nlen].decode("ascii", "replace")
|
|
125
|
+
return msg_type, seq, name, unpack_args(payload[3 + nlen:])
|
wirelink/crc.py
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
"""CRC-16/CCITT-FALSE: poly 0x1021, init 0xFFFF, no reflection, no final xor."""
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def crc16(data: bytes, crc: int = 0xFFFF) -> int:
|
|
5
|
+
for byte in data:
|
|
6
|
+
crc ^= byte << 8
|
|
7
|
+
for _ in range(8):
|
|
8
|
+
if crc & 0x8000:
|
|
9
|
+
crc = ((crc << 1) ^ 0x1021) & 0xFFFF
|
|
10
|
+
else:
|
|
11
|
+
crc = (crc << 1) & 0xFFFF
|
|
12
|
+
return crc
|
wirelink/link.py
ADDED
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
"""Host side of WireLink.
|
|
2
|
+
|
|
3
|
+
The connection is a state machine owned by one background thread:
|
|
4
|
+
|
|
5
|
+
DISCONNECTED --open+handshake--> CONNECTED --IO error/timeout--> DISCONNECTED
|
|
6
|
+
|
|
7
|
+
Boards reset when the port opens, get unplugged, and come back under a
|
|
8
|
+
different port name. So the thread owns opening as well as reading, and a
|
|
9
|
+
Link object stays valid across all of that.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
import queue
|
|
13
|
+
import threading
|
|
14
|
+
import time
|
|
15
|
+
|
|
16
|
+
import serial
|
|
17
|
+
from serial.tools import list_ports
|
|
18
|
+
|
|
19
|
+
from . import codec
|
|
20
|
+
from .codec import ProtocolError
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class LinkError(Exception):
|
|
24
|
+
pass
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class NotConnected(LinkError):
|
|
28
|
+
pass
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class RemoteError(LinkError):
|
|
32
|
+
"""The device answered a request with an ERR message."""
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class Link:
|
|
36
|
+
def __init__(self, port=None, baud=115200, timeout=1.0, name=None,
|
|
37
|
+
auto_reconnect=True, reconnect_interval=1.0, boot_delay=0.3,
|
|
38
|
+
handshake_timeout=1.0, connect_timeout=None,
|
|
39
|
+
on_connect=None, on_disconnect=None):
|
|
40
|
+
"""
|
|
41
|
+
port fixed port, or None to search every serial port
|
|
42
|
+
name accept only a device reporting this name in its HELLO_ACK
|
|
43
|
+
boot_delay seconds to wait after opening, for boards that reset on DTR
|
|
44
|
+
connect_timeout if set, block here until connected or raise LinkError
|
|
45
|
+
"""
|
|
46
|
+
self.port = None
|
|
47
|
+
self.requested_port = port
|
|
48
|
+
self.baud = baud
|
|
49
|
+
self.timeout = timeout
|
|
50
|
+
self.match_name = name
|
|
51
|
+
self.device_name = None
|
|
52
|
+
self.device_version = None
|
|
53
|
+
self.auto_reconnect = auto_reconnect
|
|
54
|
+
self.reconnect_interval = reconnect_interval
|
|
55
|
+
self.boot_delay = boot_delay
|
|
56
|
+
self.handshake_timeout = handshake_timeout
|
|
57
|
+
self.on_connect = on_connect
|
|
58
|
+
self.on_disconnect = on_disconnect
|
|
59
|
+
|
|
60
|
+
self._ser = None
|
|
61
|
+
self._buf = bytearray()
|
|
62
|
+
self._seq = 0
|
|
63
|
+
self._handlers = {}
|
|
64
|
+
self._pending = {}
|
|
65
|
+
self._connected = threading.Event()
|
|
66
|
+
self._stop = threading.Event()
|
|
67
|
+
self._tx_lock = threading.Lock()
|
|
68
|
+
self._thread = threading.Thread(target=self._run, daemon=True, name="wirelink-rx")
|
|
69
|
+
self._thread.start()
|
|
70
|
+
|
|
71
|
+
if connect_timeout is not None and not self.wait_connected(connect_timeout):
|
|
72
|
+
self.close()
|
|
73
|
+
raise LinkError(f"no WireLink device found within {connect_timeout}s")
|
|
74
|
+
|
|
75
|
+
# ---------------------------------------------------------------- lifecycle
|
|
76
|
+
|
|
77
|
+
@classmethod
|
|
78
|
+
def autodetect(cls, baud=115200, name=None, connect_timeout=5.0, **kw):
|
|
79
|
+
"""Open the first port whose board answers a HELLO probe."""
|
|
80
|
+
return cls(port=None, baud=baud, name=name, connect_timeout=connect_timeout, **kw)
|
|
81
|
+
|
|
82
|
+
@property
|
|
83
|
+
def connected(self):
|
|
84
|
+
return self._connected.is_set()
|
|
85
|
+
|
|
86
|
+
def wait_connected(self, timeout=None):
|
|
87
|
+
return self._connected.wait(timeout)
|
|
88
|
+
|
|
89
|
+
def close(self):
|
|
90
|
+
self._stop.set()
|
|
91
|
+
self._thread.join(timeout=2.0)
|
|
92
|
+
self._close_port()
|
|
93
|
+
|
|
94
|
+
def __enter__(self):
|
|
95
|
+
return self
|
|
96
|
+
|
|
97
|
+
def __exit__(self, *exc):
|
|
98
|
+
self.close()
|
|
99
|
+
|
|
100
|
+
# ------------------------------------------------------------------ sending
|
|
101
|
+
|
|
102
|
+
def _next_seq(self):
|
|
103
|
+
with self._tx_lock:
|
|
104
|
+
self._seq = (self._seq + 1) & 0xFF
|
|
105
|
+
return self._seq
|
|
106
|
+
|
|
107
|
+
def _write(self, frame):
|
|
108
|
+
ser = self._ser
|
|
109
|
+
if ser is None:
|
|
110
|
+
raise NotConnected("not connected to a device")
|
|
111
|
+
try:
|
|
112
|
+
with self._tx_lock:
|
|
113
|
+
ser.write(frame)
|
|
114
|
+
except (OSError, serial.SerialException) as exc:
|
|
115
|
+
raise NotConnected(f"write failed: {exc}") from exc
|
|
116
|
+
|
|
117
|
+
def call(self, name, *args):
|
|
118
|
+
"""Fire and forget."""
|
|
119
|
+
self._write(codec.build(codec.CALL, self._next_seq(), name, args))
|
|
120
|
+
|
|
121
|
+
def request(self, name, *args, timeout=None):
|
|
122
|
+
"""Send and block until the device replies. Returns the list of values."""
|
|
123
|
+
seq = self._next_seq()
|
|
124
|
+
box = queue.Queue(maxsize=1)
|
|
125
|
+
self._pending[seq] = box
|
|
126
|
+
try:
|
|
127
|
+
self._write(codec.build(codec.REQ, seq, name, args))
|
|
128
|
+
try:
|
|
129
|
+
msg_type, values = box.get(timeout=timeout or self.timeout)
|
|
130
|
+
except queue.Empty:
|
|
131
|
+
raise LinkError(f"timed out waiting for a reply to {name!r}")
|
|
132
|
+
if msg_type is None:
|
|
133
|
+
raise NotConnected(f"link dropped while waiting for {name!r}")
|
|
134
|
+
if msg_type == codec.ERR:
|
|
135
|
+
raise RemoteError(values[0] if values else "unspecified device error")
|
|
136
|
+
return values
|
|
137
|
+
finally:
|
|
138
|
+
self._pending.pop(seq, None)
|
|
139
|
+
|
|
140
|
+
# ---------------------------------------------------------------- receiving
|
|
141
|
+
|
|
142
|
+
def on(self, name):
|
|
143
|
+
"""Decorator registering a handler for an event the device emits."""
|
|
144
|
+
def wrap(fn):
|
|
145
|
+
self._handlers.setdefault(name, []).append(fn)
|
|
146
|
+
return fn
|
|
147
|
+
return wrap
|
|
148
|
+
|
|
149
|
+
# ------------------------------------------------------------- the rx thread
|
|
150
|
+
|
|
151
|
+
def _run(self):
|
|
152
|
+
first = True
|
|
153
|
+
while not self._stop.is_set():
|
|
154
|
+
if self._ser is None:
|
|
155
|
+
if not first and not self.auto_reconnect:
|
|
156
|
+
return
|
|
157
|
+
if not self._try_connect():
|
|
158
|
+
self._stop.wait(self.reconnect_interval)
|
|
159
|
+
continue
|
|
160
|
+
first = False
|
|
161
|
+
if not self._pump(0.05):
|
|
162
|
+
self._drop()
|
|
163
|
+
|
|
164
|
+
def _candidates(self):
|
|
165
|
+
if self.requested_port:
|
|
166
|
+
return [self.requested_port]
|
|
167
|
+
return [p.device for p in list_ports.comports()]
|
|
168
|
+
|
|
169
|
+
def _try_connect(self):
|
|
170
|
+
for port in self._candidates():
|
|
171
|
+
try:
|
|
172
|
+
ser = serial.Serial(port, self.baud, timeout=0.05)
|
|
173
|
+
except (OSError, serial.SerialException):
|
|
174
|
+
continue
|
|
175
|
+
self._ser = ser
|
|
176
|
+
self._buf.clear()
|
|
177
|
+
time.sleep(self.boot_delay) # the board may be rebooting
|
|
178
|
+
try:
|
|
179
|
+
ser.reset_input_buffer()
|
|
180
|
+
except Exception:
|
|
181
|
+
pass
|
|
182
|
+
name, version = self._handshake()
|
|
183
|
+
if name is None or (self.match_name and name != self.match_name):
|
|
184
|
+
self._close_port()
|
|
185
|
+
continue
|
|
186
|
+
self.port = port
|
|
187
|
+
self.device_name = name
|
|
188
|
+
self.device_version = version
|
|
189
|
+
self._connected.set()
|
|
190
|
+
self._notify(self.on_connect)
|
|
191
|
+
return True
|
|
192
|
+
return False
|
|
193
|
+
|
|
194
|
+
def _handshake(self):
|
|
195
|
+
"""Send HELLO and pump frames until the ACK comes back."""
|
|
196
|
+
seq = self._next_seq()
|
|
197
|
+
box = queue.Queue(maxsize=1)
|
|
198
|
+
self._pending[seq] = box
|
|
199
|
+
try:
|
|
200
|
+
self._write(codec.build(codec.HELLO, seq, ""))
|
|
201
|
+
except LinkError:
|
|
202
|
+
self._pending.pop(seq, None)
|
|
203
|
+
return None, None
|
|
204
|
+
deadline = time.time() + self.handshake_timeout
|
|
205
|
+
try:
|
|
206
|
+
while time.time() < deadline:
|
|
207
|
+
if not self._pump(0.05):
|
|
208
|
+
return None, None
|
|
209
|
+
try:
|
|
210
|
+
msg_type, values = box.get_nowait()
|
|
211
|
+
except queue.Empty:
|
|
212
|
+
continue
|
|
213
|
+
if msg_type == codec.HELLO_ACK:
|
|
214
|
+
values += ["", ""]
|
|
215
|
+
return values[0], values[1]
|
|
216
|
+
return None, None
|
|
217
|
+
return None, None
|
|
218
|
+
finally:
|
|
219
|
+
self._pending.pop(seq, None)
|
|
220
|
+
|
|
221
|
+
def _pump(self, _unused=None):
|
|
222
|
+
"""Read whatever is available and dispatch complete frames.
|
|
223
|
+
|
|
224
|
+
Returns False if the port died, which is the signal to reconnect.
|
|
225
|
+
"""
|
|
226
|
+
ser = self._ser
|
|
227
|
+
if ser is None:
|
|
228
|
+
return False
|
|
229
|
+
try:
|
|
230
|
+
chunk = ser.read(256)
|
|
231
|
+
except (OSError, serial.SerialException, TypeError):
|
|
232
|
+
return False
|
|
233
|
+
if chunk:
|
|
234
|
+
self._buf.extend(chunk)
|
|
235
|
+
while True:
|
|
236
|
+
idx = self._buf.find(b"\x00")
|
|
237
|
+
if idx < 0:
|
|
238
|
+
break
|
|
239
|
+
frame = bytes(self._buf[:idx])
|
|
240
|
+
del self._buf[:idx + 1]
|
|
241
|
+
if frame:
|
|
242
|
+
self._dispatch(frame)
|
|
243
|
+
return True
|
|
244
|
+
|
|
245
|
+
def _dispatch(self, frame):
|
|
246
|
+
try:
|
|
247
|
+
msg_type, seq, name, values = codec.parse(frame)
|
|
248
|
+
except (ProtocolError, ValueError):
|
|
249
|
+
return # a corrupt frame is dropped, not fatal
|
|
250
|
+
if msg_type in (codec.REPLY, codec.ERR, codec.HELLO_ACK):
|
|
251
|
+
box = self._pending.get(seq)
|
|
252
|
+
if box is not None:
|
|
253
|
+
try:
|
|
254
|
+
box.put_nowait((msg_type, values))
|
|
255
|
+
except queue.Full:
|
|
256
|
+
pass
|
|
257
|
+
if msg_type != codec.ERR or box is not None:
|
|
258
|
+
return
|
|
259
|
+
for fn in self._handlers.get(name, ()):
|
|
260
|
+
try:
|
|
261
|
+
fn(*values)
|
|
262
|
+
except Exception as exc: # a bad handler must not kill the reader
|
|
263
|
+
print(f"[wirelink] handler for {name!r} raised: {exc!r}")
|
|
264
|
+
|
|
265
|
+
def _drop(self):
|
|
266
|
+
was = self._connected.is_set()
|
|
267
|
+
self._connected.clear()
|
|
268
|
+
self._close_port()
|
|
269
|
+
# Unblock anyone waiting on a reply instead of letting them time out.
|
|
270
|
+
for box in list(self._pending.values()):
|
|
271
|
+
try:
|
|
272
|
+
box.put_nowait((None, None))
|
|
273
|
+
except queue.Full:
|
|
274
|
+
pass
|
|
275
|
+
if was:
|
|
276
|
+
self._notify(self.on_disconnect)
|
|
277
|
+
|
|
278
|
+
def _close_port(self):
|
|
279
|
+
ser, self._ser = self._ser, None
|
|
280
|
+
self._buf.clear()
|
|
281
|
+
if ser is not None:
|
|
282
|
+
try:
|
|
283
|
+
ser.close()
|
|
284
|
+
except Exception:
|
|
285
|
+
pass
|
|
286
|
+
|
|
287
|
+
def _notify(self, cb):
|
|
288
|
+
if cb is None:
|
|
289
|
+
return
|
|
290
|
+
try:
|
|
291
|
+
cb(self)
|
|
292
|
+
except Exception as exc:
|
|
293
|
+
print(f"[wirelink] connection callback raised: {exc!r}")
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: wirelink
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Typed, framed messaging between Python and an Arduino-compatible board
|
|
5
|
+
Author: Azad Khan
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/yourname/wirelink
|
|
8
|
+
Project-URL: Issues, https://github.com/yourname/wirelink/issues
|
|
9
|
+
Keywords: arduino,esp32,serial,microcontroller,cobs,protocol
|
|
10
|
+
Classifier: Development Status :: 3 - Alpha
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Topic :: System :: Hardware :: Hardware Drivers
|
|
15
|
+
Requires-Python: >=3.8
|
|
16
|
+
Description-Content-Type: text/markdown
|
|
17
|
+
License-File: LICENSE
|
|
18
|
+
Requires-Dist: pyserial>=3.5
|
|
19
|
+
Provides-Extra: test
|
|
20
|
+
Requires-Dist: pytest>=7; extra == "test"
|
|
21
|
+
Dynamic: license-file
|
|
22
|
+
|
|
23
|
+
# wirelink (Python host)
|
|
24
|
+
|
|
25
|
+
Host side of [WireLink](https://github.com/yourname/wirelink): typed, framed, CRC-checked
|
|
26
|
+
messaging with an Arduino-compatible board. The matching board library is `WireLink` in the
|
|
27
|
+
Arduino Library Manager.
|
|
28
|
+
|
|
29
|
+
```python
|
|
30
|
+
from wirelink import Link
|
|
31
|
+
|
|
32
|
+
link = Link.autodetect(name="demo-board") # found by handshake, not by port number
|
|
33
|
+
link.call("setLed", 255, 40, 0)
|
|
34
|
+
print(link.request("uptime")[0])
|
|
35
|
+
|
|
36
|
+
@link.on("temp")
|
|
37
|
+
def show(celsius):
|
|
38
|
+
print(celsius)
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
The link reconnects on its own when a board resets or is unplugged, including when it comes
|
|
42
|
+
back under a different port name. See the project README for the wire format.
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
wirelink/__init__.py,sha256=1nNRii967Ohz9cJF5C2AiTdq-BS2EWEEUjBBsxl47as,147
|
|
2
|
+
wirelink/cobs.py,sha256=jW-8XFLZGN4vr0_xV5LcZ5Z_bhrSlninoU6r9hETQLA,1193
|
|
3
|
+
wirelink/codec.py,sha256=Lln4mD6mk1eppkaZwEzs4AbVjxts4-5o-p37QCibrMQ,4288
|
|
4
|
+
wirelink/crc.py,sha256=JHbOZ1sZY8ZUJwalEuMPWDgeqxOt9tkzz5Zn2KkxE3A,364
|
|
5
|
+
wirelink/link.py,sha256=TQmpzkf8v2ZGTegVUaZ3keCxJ8kbshy99LXbJUeMUDA,9817
|
|
6
|
+
wirelink-0.1.0.dist-info/licenses/LICENSE,sha256=97Jp1syvtZ9RqzaKXJsquTEuPZ068tyb2xvPGUX3hSo,1066
|
|
7
|
+
wirelink-0.1.0.dist-info/METADATA,sha256=CCkG6OxZrqtAfSEjI2f2kjhp0vfvuN3lkErDyAVCl3M,1448
|
|
8
|
+
wirelink-0.1.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
|
|
9
|
+
wirelink-0.1.0.dist-info/top_level.txt,sha256=HgMZoSd-75pcm8qdAva9U1zaaohkL1cpnsuy9vSPE7c,9
|
|
10
|
+
wirelink-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Azad Khan
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
wirelink
|