wirelink 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.
wirelink-0.1.0/LICENSE ADDED
@@ -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,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,20 @@
1
+ # wirelink (Python host)
2
+
3
+ Host side of [WireLink](https://github.com/yourname/wirelink): typed, framed, CRC-checked
4
+ messaging with an Arduino-compatible board. The matching board library is `WireLink` in the
5
+ Arduino Library Manager.
6
+
7
+ ```python
8
+ from wirelink import Link
9
+
10
+ link = Link.autodetect(name="demo-board") # found by handshake, not by port number
11
+ link.call("setLed", 255, 40, 0)
12
+ print(link.request("uptime")[0])
13
+
14
+ @link.on("temp")
15
+ def show(celsius):
16
+ print(celsius)
17
+ ```
18
+
19
+ The link reconnects on its own when a board resets or is unplugged, including when it comes
20
+ back under a different port name. See the project README for the wire format.
@@ -0,0 +1,34 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "wirelink"
7
+ version = "0.1.0"
8
+ description = "Typed, framed messaging between Python and an Arduino-compatible board"
9
+ readme = "README.md"
10
+ requires-python = ">=3.8"
11
+ license = { text = "MIT" }
12
+ authors = [{ name = "Azad Khan" }]
13
+ keywords = ["arduino", "esp32", "serial", "microcontroller", "cobs", "protocol"]
14
+ classifiers = [
15
+ "Development Status :: 3 - Alpha",
16
+ "Intended Audience :: Developers",
17
+ "License :: OSI Approved :: MIT License",
18
+ "Programming Language :: Python :: 3",
19
+ "Topic :: System :: Hardware :: Hardware Drivers",
20
+ ]
21
+ dependencies = ["pyserial>=3.5"]
22
+
23
+ [project.optional-dependencies]
24
+ test = ["pytest>=7"]
25
+
26
+ [project.urls]
27
+ Homepage = "https://github.com/yourname/wirelink"
28
+ Issues = "https://github.com/yourname/wirelink/issues"
29
+
30
+ [tool.setuptools.packages.find]
31
+ include = ["wirelink*"]
32
+
33
+ [tool.pytest.ini_options]
34
+ testpaths = ["tests"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,41 @@
1
+ import pytest
2
+
3
+ from wirelink import codec
4
+ from wirelink.cobs import decode, encode
5
+
6
+
7
+ @pytest.mark.parametrize("payload", [
8
+ b"", b"\x00", b"\x00" * 300, b"\xff" * 300, bytes(range(256)),
9
+ b"hello\x00world",
10
+ ])
11
+ def test_cobs_round_trip(payload):
12
+ assert decode(encode(payload)) == payload
13
+
14
+
15
+ def test_cobs_output_has_no_zero_bytes():
16
+ assert 0 not in encode(b"\x00" * 100 + b"\x01\x02")
17
+
18
+
19
+ @pytest.mark.parametrize("values", [
20
+ [], [True, False], [0, -1, 2147483647], [3.5], ["hi"], [b"\x00\x01"],
21
+ [1, 2.5, "x", True],
22
+ ])
23
+ def test_frame_round_trip(values):
24
+ frame = codec.build(codec.REQ, 42, "doThing", values)
25
+ assert frame.endswith(b"\x00")
26
+ msg_type, seq, name, out = codec.parse(frame[:-1])
27
+ assert (msg_type, seq, name) == (codec.REQ, 42, "doThing")
28
+ for got, want in zip(out, values):
29
+ assert got == pytest.approx(want) if isinstance(want, float) else got == want
30
+
31
+
32
+ def test_corrupt_frame_is_rejected():
33
+ frame = bytearray(codec.build(codec.EVENT, 1, "temp", [24.5])[:-1])
34
+ frame[-1] ^= 0xFF
35
+ with pytest.raises(codec.ProtocolError):
36
+ codec.parse(bytes(frame))
37
+
38
+
39
+ def test_oversized_frame_refused():
40
+ with pytest.raises(ValueError):
41
+ codec.build(codec.EVENT, 1, "x", ["y" * 255, "z" * 255])
@@ -0,0 +1,148 @@
1
+ """Link state-machine tests against a fake board, no hardware needed."""
2
+
3
+ import threading
4
+ import time
5
+
6
+ import pytest
7
+
8
+ from wirelink import Link, NotConnected, codec, link as link_mod
9
+
10
+
11
+ class FakeBoard:
12
+ """Minimal WireLink device: answers HELLO and 'ping', can be yanked."""
13
+
14
+ def __init__(self, name="fake-board", answer_hello=True):
15
+ self.name = name
16
+ self.answer_hello = answer_hello
17
+ self.out = bytearray()
18
+ self.rx = bytearray()
19
+ self.alive = True
20
+ self.is_open = True
21
+ self.lock = threading.Lock()
22
+
23
+ # -- pyserial surface ------------------------------------------------
24
+ def read(self, n=1):
25
+ if not self.alive:
26
+ raise OSError("device disappeared")
27
+ with self.lock:
28
+ chunk, self.out = bytes(self.out[:n]), self.out[n:]
29
+ if not chunk:
30
+ time.sleep(0.005)
31
+ return chunk
32
+
33
+ def write(self, data):
34
+ if not self.alive:
35
+ raise OSError("device disappeared")
36
+ self.rx.extend(data)
37
+ while True:
38
+ idx = self.rx.find(b"\x00")
39
+ if idx < 0:
40
+ break
41
+ frame, self.rx = bytes(self.rx[:idx]), self.rx[idx + 1:]
42
+ if frame:
43
+ self._handle(frame)
44
+ return len(data)
45
+
46
+ def reset_input_buffer(self):
47
+ with self.lock:
48
+ self.out.clear()
49
+
50
+ def close(self):
51
+ self.is_open = False
52
+
53
+ # -- device behaviour ------------------------------------------------
54
+ def _handle(self, frame):
55
+ msg_type, seq, name, values = codec.parse(frame)
56
+ if msg_type == codec.HELLO and self.answer_hello:
57
+ self._send(codec.HELLO_ACK, seq, "", [self.name, "0.1.0"])
58
+ elif msg_type == codec.REQ and name == "ping":
59
+ self._send(codec.REPLY, seq, name, ["pong"])
60
+ elif msg_type == codec.REQ:
61
+ self._send(codec.ERR, seq, name, ["no handler for this message"])
62
+
63
+ def _send(self, msg_type, seq, name, values):
64
+ with self.lock:
65
+ self.out.extend(codec.build(msg_type, seq, name, values))
66
+
67
+ def emit(self, name, *values):
68
+ self._send(codec.EVENT, 0, name, list(values))
69
+
70
+ def yank(self):
71
+ """Simulate the cable being pulled."""
72
+ self.alive = False
73
+
74
+
75
+ @pytest.fixture
76
+ def bench(monkeypatch):
77
+ """Patch pyserial so Link talks to whatever board is on a given port."""
78
+ boards = {}
79
+
80
+ def fake_serial(port, baud, timeout=None):
81
+ board = boards.get(port)
82
+ if board is None or not board.alive:
83
+ raise OSError(f"cannot open {port}")
84
+ return board
85
+
86
+ class Port:
87
+ def __init__(self, device):
88
+ self.device = device
89
+
90
+ monkeypatch.setattr(link_mod.serial, "Serial", fake_serial)
91
+ monkeypatch.setattr(link_mod.list_ports, "comports",
92
+ lambda: [Port(p) for p in boards])
93
+ return boards
94
+
95
+
96
+ def test_autodetect_skips_silent_ports(bench):
97
+ bench["COM1"] = FakeBoard("mute", answer_hello=False)
98
+ bench["COM2"] = FakeBoard("real-board")
99
+ with Link.autodetect(name="real-board", connect_timeout=3.0,
100
+ boot_delay=0, handshake_timeout=0.5) as link:
101
+ assert link.port == "COM2"
102
+ assert link.device_name == "real-board"
103
+
104
+
105
+ def test_request_and_error(bench):
106
+ bench["COM3"] = FakeBoard()
107
+ with Link.autodetect(connect_timeout=3.0, boot_delay=0) as link:
108
+ assert link.request("ping") == ["pong"]
109
+ with pytest.raises(link_mod.RemoteError):
110
+ link.request("nope")
111
+
112
+
113
+ def test_events_reach_handlers(bench):
114
+ board = bench.setdefault("COM4", FakeBoard())
115
+ got = []
116
+ with Link.autodetect(connect_timeout=3.0, boot_delay=0) as link:
117
+ link.on("temp")(lambda v: got.append(v))
118
+ board.emit("temp", 24.5)
119
+ deadline = time.time() + 2
120
+ while not got and time.time() < deadline:
121
+ time.sleep(0.01)
122
+ assert got == [pytest.approx(24.5)]
123
+
124
+
125
+ def test_reconnects_after_the_cable_is_pulled(bench):
126
+ bench["COM5"] = FakeBoard("demo")
127
+ events = []
128
+ with Link.autodetect(connect_timeout=3.0, boot_delay=0,
129
+ reconnect_interval=0.05,
130
+ on_connect=lambda l: events.append("up"),
131
+ on_disconnect=lambda l: events.append("down")) as link:
132
+ assert link.request("ping") == ["pong"]
133
+
134
+ bench["COM5"].yank()
135
+ deadline = time.time() + 2
136
+ while link.connected and time.time() < deadline:
137
+ time.sleep(0.01)
138
+ assert not link.connected
139
+ with pytest.raises(NotConnected):
140
+ link.request("ping")
141
+
142
+ # board comes back, possibly on a different port name
143
+ del bench["COM5"]
144
+ bench["COM9"] = FakeBoard("demo")
145
+ assert link.wait_connected(3.0)
146
+ assert link.port == "COM9"
147
+ assert link.request("ping") == ["pong"]
148
+ assert events[:3] == ["up", "down", "up"]
@@ -0,0 +1,4 @@
1
+ from .link import Link, LinkError, NotConnected, RemoteError
2
+
3
+ __all__ = ["Link", "LinkError", "NotConnected", "RemoteError"]
4
+ __version__ = "0.1.0"
@@ -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)
@@ -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:])
@@ -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
@@ -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,15 @@
1
+ LICENSE
2
+ README.md
3
+ pyproject.toml
4
+ tests/test_codec.py
5
+ tests/test_link.py
6
+ wirelink/__init__.py
7
+ wirelink/cobs.py
8
+ wirelink/codec.py
9
+ wirelink/crc.py
10
+ wirelink/link.py
11
+ wirelink.egg-info/PKG-INFO
12
+ wirelink.egg-info/SOURCES.txt
13
+ wirelink.egg-info/dependency_links.txt
14
+ wirelink.egg-info/requires.txt
15
+ wirelink.egg-info/top_level.txt
@@ -0,0 +1,4 @@
1
+ pyserial>=3.5
2
+
3
+ [test]
4
+ pytest>=7
@@ -0,0 +1 @@
1
+ wirelink