urst-mpy 1.0.2__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,73 @@
1
+ # Sustainable Use License
2
+
3
+ Version 1.0
4
+
5
+ ## Acceptance
6
+
7
+ By using the software, you agree to all of the terms and conditions below.
8
+
9
+ ## Copyright License
10
+
11
+ The licensor grants you a non-exclusive, royalty-free, worldwide, non-sublicensable, non-transferable license
12
+ to use, copy, distribute, make available, and prepare derivative works of the software, in each case subject
13
+ to the limitations below.
14
+
15
+ ## Limitations
16
+
17
+ You may use or modify the software only for your own internal business purposes or for non-commercial or
18
+ personal use. You may distribute the software or provide it to others only if you do so free of charge for
19
+ non-commercial purposes. You may not alter, remove, or obscure any licensing, copyright, or other notices of
20
+ the licensor in the software. Any use of the licensor’s trademarks is subject to applicable law.
21
+
22
+ ## Patents
23
+
24
+ The licensor grants you a license, under any patent claims the licensor can license, or becomes able to
25
+ license, to make, have made, use, sell, offer for sale, import and have imported the software, in each case
26
+ subject to the limitations and conditions in this license. This license does not cover any patent claims that
27
+ you cause to be infringed by modifications or additions to the software. If you or your company make any
28
+ written claim that the software infringes or contributes to infringement of any patent, your patent license
29
+ for the software granted under these terms ends immediately. If your company makes such a claim, your patent
30
+ license ends immediately for work on behalf of your company.
31
+
32
+ ## Notices
33
+
34
+ You must ensure that anyone who gets a copy of any part of the software from you also gets a copy of these
35
+ terms. If you modify the software, you must include in any modified copies of the software a prominent notice
36
+ stating that you have modified the software.
37
+
38
+ ## No Other Rights
39
+
40
+ These terms do not imply any licenses other than those expressly granted in these terms.
41
+
42
+ ## Termination
43
+
44
+ If you use the software in violation of these terms, such use is not licensed, and your license will
45
+ automatically terminate. If the licensor provides you with a notice of your violation, and you cease all
46
+ violation of this license no later than 30 days after you receive that notice, your license will be reinstated
47
+ retroactively. However, if you violate these terms after such reinstatement, any additional violation of these
48
+ terms will cause your license to terminate automatically and permanently.
49
+
50
+ ## No Liability
51
+
52
+ As far as the law allows, the software comes as is, without any warranty or condition, and the licensor will
53
+ not be liable to you for any damages arising out of these terms or the use or nature of the software, under
54
+ any kind of legal claim.
55
+
56
+ ## Definitions
57
+
58
+ The “licensor” is the entity offering these terms.
59
+
60
+ The “software” is the software the licensor makes available under these terms, including any portion of it.
61
+
62
+ “You” refers to the individual or entity agreeing to these terms.
63
+
64
+ “Your company” is any legal entity, sole proprietorship, or other kind of organization that you work for, plus
65
+ all organizations that have control over, are under the control of, or are under common control with that
66
+ organization. Control means ownership of substantially all the assets of an entity, or the power to direct its
67
+ management and policies by vote, contract, or otherwise. Control can be direct or indirect.
68
+
69
+ “Your license” is the license granted to you for the software under these terms.
70
+
71
+ “Use” means anything you do with the software requiring your license.
72
+
73
+ “Trademark” means trademarks, service marks, and similar rights.
@@ -0,0 +1,172 @@
1
+ Metadata-Version: 2.4
2
+ Name: urst-mpy
3
+ Version: 1.0.2
4
+ Summary: MicroPython implementation of the Universal Reliable Serial Transport protocol [URST](https://github.com/simonl65/URST-Specification/blob/main/URST-Specification.md)
5
+ Author: Simon R. Lincoln
6
+ Author-email: Simon R. Lincoln <oss@codeability.co.uk>
7
+ License-Expression: SUL-1.0
8
+ License-File: LICENSE.md
9
+ Classifier: Programming Language :: Python :: 3
10
+ Classifier: Programming Language :: Python :: Implementation :: MicroPython
11
+ Classifier: Topic :: System :: Networking
12
+ Classifier: Topic :: Communications
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: Operating System :: OS Independent
15
+ Requires-Dist: pyserial>=3.5
16
+ Requires-Python: >=3.12
17
+ Project-URL: Repository, https://github.com/simonl65/URST-mpy
18
+ Project-URL: Bug Tracker, https://github.com/simonl65/URST-mpy/issues
19
+ Description-Content-Type: text/markdown
20
+
21
+ # URST (Universal Reliable Serial Transport) for MicroPython
22
+
23
+ [![License](https://img.shields.io/badge/license-SUL--1.0-green.svg)](LICENSE.md)
24
+
25
+ **URST for MicroPython** is a professional-grade implementation of the [Universal Reliable Serial Transport (URST) protocol](URST-Specification.md). It provides reliable, error-checked, and fragmented data transmission over unreliable serial (UART/XBee) connections, specifically optimized for MicroPython devices like the Raspberry Pi Pico and ESP32.
26
+
27
+ ## Key Features
28
+
29
+ - **Reliable Delivery**: Strict stop-and-wait ARQ (Automatic Repeat Request) with configurable timeouts and retries.
30
+ - **MicroPython Optimized**: Native support for `machine.UART` and `utime.ticks_ms()` for precise timing on hardware.
31
+ - **Hardware Agnostic**: Works on Desktop Python (via `pyserial`) and MicroPython seamlessly.
32
+ - **Error Detection**: Robust CRC-16/CCITT_FALSE validation for every frame.
33
+ - **Robust Framing**: Uses COBS (Consistent Overhead Byte Stuffing) for zero-byte-free encoding, ensuring unambiguous frame delimiting via `0x00`.
34
+ - **Message Fragmentation**: Automatically handles messages larger than the physical MTU (up to 8KB+ reassembly).
35
+ - **Connection Handshake**: Built-in capability negotiation and sequence synchronization.
36
+ - **Simple API**: Clean `send()` and `read()` interface that abstracts away the complexity of serial framing and retransmission.
37
+
38
+ ## Installation
39
+
40
+ ### For MicroPython Devices
41
+
42
+ **Option A — Source install via mip (simplest):**
43
+
44
+ ```bash
45
+ mpremote mip install github:simonl65/URST-mpy
46
+ ```
47
+
48
+ **Option B — Pre-compiled .mpy (smallest flash footprint, fastest startup):**
49
+
50
+ Pre-compiling with `mpy-cross` reduces the package from ~19.7 KB to ~6.4 KB on flash
51
+ and eliminates the parse-and-compile step at boot time.
52
+
53
+ ```bash
54
+ # 1. Install mpy-cross (once)
55
+ pip install mpy-cross
56
+
57
+ # 2. Clone the repo and build
58
+ git clone https://github.com/simonl65/urst-mpy.git
59
+ cd urst-mpy
60
+ make mpy # produces dist/urst/*.mpy
61
+
62
+ # 3. Deploy the compiled files to your device
63
+ mpremote cp -r dist/urst :
64
+ ```
65
+
66
+ **Option C — Copy source directly:**
67
+
68
+ Copy the `urst/` directory from this repository to the root of your MicroPython device's filesystem.
69
+
70
+ ### For Desktop Development
71
+
72
+ If you want to use it on your PC (e.g., for testing or gateway applications), install `pyserial` first:
73
+
74
+ ```bash
75
+ pip install pyserial
76
+ ```
77
+
78
+ ## Quick Start (MicroPython)
79
+
80
+ ```python
81
+ import urst
82
+ import machine
83
+ import time
84
+
85
+ # 1. Initialize UART on your device (e.g., Raspberry Pi Pico)
86
+ uart = machine.UART(0, baudrate=57600, tx=machine.Pin(0), rx=machine.Pin(1))
87
+
88
+ # 2. Initialize URST with the UART object
89
+ transport = urst.Urst(uart)
90
+
91
+ # 3. Send a message (automatically handles framing, CRC, and ACK waiting)
92
+ # It will fragment large data into ~194 byte chunks automatically.
93
+ transport.send(b"Hello from Pico!")
94
+
95
+ # 4. Read a complete message (handles reassembly of fragments)
96
+ while True:
97
+ message = transport.read()
98
+ if message:
99
+ print(f"Received: {message.decode()}")
100
+ time.sleep(0.1)
101
+ ```
102
+
103
+ ## Quick Start (Desktop Python)
104
+
105
+ ```python
106
+ from urst import Urst
107
+
108
+ # Initialize URST on your serial port (requires pyserial)
109
+ transport = Urst(port="/dev/ttyUSB0", baud=57600)
110
+
111
+ transport.send(b"Hello from Desktop!")
112
+ message = transport.read()
113
+ ```
114
+
115
+ ## Protocol Architecture
116
+
117
+ URST follows a strictly layered architecture to ensure separation of concerns:
118
+
119
+ ```text
120
+ ┌───────────────────────────────────┐
121
+ │ Handler Layer (Application) │ User API: send(), read()
122
+ ├───────────────────────────────────┤
123
+ │ Protocol Layer (Reliable) │ CONNECT/ACK/NAK, Retransmission
124
+ ├───────────────────────────────────┤
125
+ │ Transport Layer (Framing) │ Frame Type, Sequence Numbers
126
+ ├───────────────────────────────────┤
127
+ │ Codec Layer (Encoding/IO) │ COBS, CRC, UART (machine/pyserial)
128
+ └───────────────────────────────────┘
129
+ ```
130
+
131
+ For full technical details, please refer to the [URST Specification](URST-Specification.md).
132
+
133
+ ## Development
134
+
135
+ ### Setup
136
+
137
+ This project uses [uv](https://docs.astral.sh/uv/) for local development and testing.
138
+
139
+ ```bash
140
+ git clone https://github.com/simonl65/urst-mpy.git
141
+ cd urst-mpy
142
+ uv sync
143
+ ```
144
+
145
+ ### Running Tests
146
+
147
+ If using UV:
148
+
149
+ ```bash
150
+ uv run pytest
151
+ ```
152
+
153
+ If you aren't using UV you should set `PYTHONPATH` to ensure the tests find the package correctly:
154
+
155
+ ```bash
156
+ PYTHONPATH=. pytest
157
+ ```
158
+
159
+ ### Linting & Formatting
160
+
161
+ ```bash
162
+ uv run ruff check .
163
+ uv run ruff format .
164
+ ```
165
+
166
+ ## License
167
+
168
+ This project is licensed under the **Sustainable Use License (SUL-1.0)**. See the [LICENSE.md](LICENSE.md) file for details.
169
+
170
+ ---
171
+
172
+ **Author:** Simon R. Lincoln ([oss@codeability.co.uk](mailto:oss@codeability.co.uk))
@@ -0,0 +1,152 @@
1
+ # URST (Universal Reliable Serial Transport) for MicroPython
2
+
3
+ [![License](https://img.shields.io/badge/license-SUL--1.0-green.svg)](LICENSE.md)
4
+
5
+ **URST for MicroPython** is a professional-grade implementation of the [Universal Reliable Serial Transport (URST) protocol](URST-Specification.md). It provides reliable, error-checked, and fragmented data transmission over unreliable serial (UART/XBee) connections, specifically optimized for MicroPython devices like the Raspberry Pi Pico and ESP32.
6
+
7
+ ## Key Features
8
+
9
+ - **Reliable Delivery**: Strict stop-and-wait ARQ (Automatic Repeat Request) with configurable timeouts and retries.
10
+ - **MicroPython Optimized**: Native support for `machine.UART` and `utime.ticks_ms()` for precise timing on hardware.
11
+ - **Hardware Agnostic**: Works on Desktop Python (via `pyserial`) and MicroPython seamlessly.
12
+ - **Error Detection**: Robust CRC-16/CCITT_FALSE validation for every frame.
13
+ - **Robust Framing**: Uses COBS (Consistent Overhead Byte Stuffing) for zero-byte-free encoding, ensuring unambiguous frame delimiting via `0x00`.
14
+ - **Message Fragmentation**: Automatically handles messages larger than the physical MTU (up to 8KB+ reassembly).
15
+ - **Connection Handshake**: Built-in capability negotiation and sequence synchronization.
16
+ - **Simple API**: Clean `send()` and `read()` interface that abstracts away the complexity of serial framing and retransmission.
17
+
18
+ ## Installation
19
+
20
+ ### For MicroPython Devices
21
+
22
+ **Option A — Source install via mip (simplest):**
23
+
24
+ ```bash
25
+ mpremote mip install github:simonl65/URST-mpy
26
+ ```
27
+
28
+ **Option B — Pre-compiled .mpy (smallest flash footprint, fastest startup):**
29
+
30
+ Pre-compiling with `mpy-cross` reduces the package from ~19.7 KB to ~6.4 KB on flash
31
+ and eliminates the parse-and-compile step at boot time.
32
+
33
+ ```bash
34
+ # 1. Install mpy-cross (once)
35
+ pip install mpy-cross
36
+
37
+ # 2. Clone the repo and build
38
+ git clone https://github.com/simonl65/urst-mpy.git
39
+ cd urst-mpy
40
+ make mpy # produces dist/urst/*.mpy
41
+
42
+ # 3. Deploy the compiled files to your device
43
+ mpremote cp -r dist/urst :
44
+ ```
45
+
46
+ **Option C — Copy source directly:**
47
+
48
+ Copy the `urst/` directory from this repository to the root of your MicroPython device's filesystem.
49
+
50
+ ### For Desktop Development
51
+
52
+ If you want to use it on your PC (e.g., for testing or gateway applications), install `pyserial` first:
53
+
54
+ ```bash
55
+ pip install pyserial
56
+ ```
57
+
58
+ ## Quick Start (MicroPython)
59
+
60
+ ```python
61
+ import urst
62
+ import machine
63
+ import time
64
+
65
+ # 1. Initialize UART on your device (e.g., Raspberry Pi Pico)
66
+ uart = machine.UART(0, baudrate=57600, tx=machine.Pin(0), rx=machine.Pin(1))
67
+
68
+ # 2. Initialize URST with the UART object
69
+ transport = urst.Urst(uart)
70
+
71
+ # 3. Send a message (automatically handles framing, CRC, and ACK waiting)
72
+ # It will fragment large data into ~194 byte chunks automatically.
73
+ transport.send(b"Hello from Pico!")
74
+
75
+ # 4. Read a complete message (handles reassembly of fragments)
76
+ while True:
77
+ message = transport.read()
78
+ if message:
79
+ print(f"Received: {message.decode()}")
80
+ time.sleep(0.1)
81
+ ```
82
+
83
+ ## Quick Start (Desktop Python)
84
+
85
+ ```python
86
+ from urst import Urst
87
+
88
+ # Initialize URST on your serial port (requires pyserial)
89
+ transport = Urst(port="/dev/ttyUSB0", baud=57600)
90
+
91
+ transport.send(b"Hello from Desktop!")
92
+ message = transport.read()
93
+ ```
94
+
95
+ ## Protocol Architecture
96
+
97
+ URST follows a strictly layered architecture to ensure separation of concerns:
98
+
99
+ ```text
100
+ ┌───────────────────────────────────┐
101
+ │ Handler Layer (Application) │ User API: send(), read()
102
+ ├───────────────────────────────────┤
103
+ │ Protocol Layer (Reliable) │ CONNECT/ACK/NAK, Retransmission
104
+ ├───────────────────────────────────┤
105
+ │ Transport Layer (Framing) │ Frame Type, Sequence Numbers
106
+ ├───────────────────────────────────┤
107
+ │ Codec Layer (Encoding/IO) │ COBS, CRC, UART (machine/pyserial)
108
+ └───────────────────────────────────┘
109
+ ```
110
+
111
+ For full technical details, please refer to the [URST Specification](URST-Specification.md).
112
+
113
+ ## Development
114
+
115
+ ### Setup
116
+
117
+ This project uses [uv](https://docs.astral.sh/uv/) for local development and testing.
118
+
119
+ ```bash
120
+ git clone https://github.com/simonl65/urst-mpy.git
121
+ cd urst-mpy
122
+ uv sync
123
+ ```
124
+
125
+ ### Running Tests
126
+
127
+ If using UV:
128
+
129
+ ```bash
130
+ uv run pytest
131
+ ```
132
+
133
+ If you aren't using UV you should set `PYTHONPATH` to ensure the tests find the package correctly:
134
+
135
+ ```bash
136
+ PYTHONPATH=. pytest
137
+ ```
138
+
139
+ ### Linting & Formatting
140
+
141
+ ```bash
142
+ uv run ruff check .
143
+ uv run ruff format .
144
+ ```
145
+
146
+ ## License
147
+
148
+ This project is licensed under the **Sustainable Use License (SUL-1.0)**. See the [LICENSE.md](LICENSE.md) file for details.
149
+
150
+ ---
151
+
152
+ **Author:** Simon R. Lincoln ([oss@codeability.co.uk](mailto:oss@codeability.co.uk))
@@ -0,0 +1,69 @@
1
+ [project]
2
+ name = "urst-mpy"
3
+ version = "1.0.2"
4
+ description = "MicroPython implementation of the Universal Reliable Serial Transport protocol [URST](https://github.com/simonl65/URST-Specification/blob/main/URST-Specification.md)"
5
+ readme = "README.md"
6
+ license = "SUL-1.0"
7
+ license-files = ["LICENSE.md"]
8
+ authors = [{ name = "Simon R. Lincoln", email = "oss@codeability.co.uk" }]
9
+ requires-python = ">=3.12"
10
+ dependencies = ["pyserial>=3.5"]
11
+ classifiers = [
12
+ "Programming Language :: Python :: 3",
13
+ "Programming Language :: Python :: Implementation :: MicroPython",
14
+ "Topic :: System :: Networking",
15
+ "Topic :: Communications",
16
+ "Intended Audience :: Developers",
17
+ "Operating System :: OS Independent",
18
+ ]
19
+
20
+ [project.urls]
21
+ "Repository" = "https://github.com/simonl65/URST-mpy"
22
+ "Bug Tracker" = "https://github.com/simonl65/URST-mpy/issues"
23
+
24
+ [build-system]
25
+ requires = ["uv_build>=0.11.19"]
26
+ build-backend = "uv_build"
27
+
28
+ [tool.uv_build]
29
+ package-dir = ""
30
+ [tool.uv.build-backend]
31
+ module-root = ""
32
+ module-name = "urst"
33
+
34
+ [dependency-groups]
35
+ dev = ["pytest>=8.0", "pytest-cov>=5.0", "ruff>=0.4", "uv-build>=0.11.19"]
36
+
37
+
38
+ # --- Ruff ---
39
+ [tool.ruff]
40
+ src = ["urst"]
41
+ line-length = 80
42
+ target-version = "py312"
43
+ exclude = ["build", "dist", ".venv", "tests"]
44
+
45
+ [tool.ruff.lint]
46
+ select = [
47
+ "E", # pycodestyle errors
48
+ "W", # pycodestyle warnings
49
+ "F", # pyflakes errors
50
+ "I", # isort
51
+ "UP", # pyupgrade
52
+ "B", # flake8-bugbear bug-hunting (e.g., bare except)
53
+ "SIM", # flake8-simplify
54
+ "S", # security
55
+ "I", # isort imports
56
+ ]
57
+ ignore = ["E501", "SIM105"]
58
+
59
+ [tool.ruff.format]
60
+ quote-style = "double"
61
+ indent-style = "space"
62
+
63
+
64
+ # --- Pytest ---
65
+ [tool.pytest.ini_options]
66
+ minversion = "6.0"
67
+ pythonpath = ["."]
68
+ testpaths = ["tests"]
69
+ python_files = ["test_*.py", "*_test.py", "*_tests.py"]
@@ -0,0 +1,18 @@
1
+ try:
2
+ import logging
3
+ except ImportError:
4
+ # Minimal MicroPython fallback: swallow all log calls via __getattr__.
5
+ class _NoLog:
6
+ def __getattr__(self, _):
7
+ return lambda *a, **k: None
8
+
9
+ class _NoLogging:
10
+ def getLogger(self, _):
11
+ return _NoLog()
12
+
13
+ logging = _NoLogging()
14
+
15
+ from .core_handler import Urst
16
+
17
+ __version__ = "1.0.2"
18
+ __all__ = ["Urst"]
@@ -0,0 +1,175 @@
1
+ try:
2
+ import logging
3
+ except ImportError:
4
+ from . import logging
5
+
6
+ # MicroPython compatibility for typing
7
+ try:
8
+ from typing import Any
9
+ except ImportError:
10
+ # Minimal fallback for MicroPython
11
+ pass
12
+
13
+ # MicroPython compatibility for time
14
+ import time
15
+
16
+ try:
17
+ _ = time.ticks_ms
18
+ except AttributeError:
19
+ # Desktop Python shim
20
+ def ticks_ms():
21
+ return int(time.time() * 1000)
22
+
23
+ def ticks_diff(later, earlier):
24
+ return later - earlier
25
+
26
+ time.ticks_ms = ticks_ms
27
+ time.ticks_diff = ticks_diff
28
+
29
+ logger = logging.getLogger(__name__)
30
+
31
+
32
+ def calculate_crc16(data: bytes | bytearray) -> int:
33
+ """
34
+ Calculate the CRC16/CCITT-FALSE for the given data.
35
+
36
+ Poly: 0x1021, Init: 0xFFFF, No final XOR, MSB-first.
37
+ """
38
+ crc = 0xFFFF
39
+ for byte in data:
40
+ crc ^= byte << 8
41
+ for _ in range(8):
42
+ crc = (crc << 1 ^ 4129) & 65535 if crc & 32768 else crc << 1 & 65535
43
+ return crc
44
+
45
+
46
+ def serialize_crc(crc):
47
+ """Serialize CRC into 2 bytes, little-endian (no struct dependency)."""
48
+ return bytes([crc & 0xFF, (crc >> 8) & 0xFF])
49
+
50
+
51
+ def cobs_encode(data: bytes | bytearray) -> bytes:
52
+ """
53
+ Encode data using Consistent Overhead Byte Stuffing (COBS).
54
+ """
55
+ output = bytearray()
56
+ code_index = 0
57
+ code = 1
58
+ output.append(0)
59
+
60
+ for byte in data:
61
+ if byte == 0:
62
+ output[code_index] = code
63
+ code_index = len(output)
64
+ output.append(0)
65
+ code = 1
66
+ continue
67
+
68
+ output.append(byte)
69
+ code += 1
70
+
71
+ if code == 0xFF:
72
+ output[code_index] = code
73
+ code_index = len(output)
74
+ output.append(0)
75
+ code = 1
76
+
77
+ output[code_index] = code
78
+ return bytes(output)
79
+
80
+
81
+ def cobs_decode(data: bytes | bytearray) -> bytes | None:
82
+ """
83
+ Decode COBS-encoded data.
84
+ """
85
+ if len(data) == 0:
86
+ return None
87
+ if 0x00 in data:
88
+ return None
89
+
90
+ output = bytearray()
91
+ index = 0
92
+ size = len(data)
93
+
94
+ while index < size:
95
+ code = data[index]
96
+ if code == 0:
97
+ return None
98
+
99
+ index += 1
100
+ end = index + code - 1
101
+ if end > size:
102
+ return None
103
+
104
+ output.extend(data[index:end])
105
+ index = end
106
+
107
+ if code < 0xFF and index < size:
108
+ output.append(0x00)
109
+
110
+ return bytes(output)
111
+
112
+
113
+ class CodecLayer:
114
+ """
115
+ Handles encoding and decoding of URST packets at the byte level.
116
+ """
117
+
118
+ def __init__(self, ser: Any):
119
+ self.ser = ser
120
+ self._rx_buffer = bytearray()
121
+ logger.debug("Initializing Codec Layer")
122
+
123
+ def write_frame(self, frame: bytes) -> int:
124
+ """
125
+ Write a complete physical frame to the serial port.
126
+ """
127
+ sent = self.ser.write(frame)
128
+ if hasattr(self.ser, "flush"):
129
+ self.ser.flush()
130
+ return sent
131
+
132
+ def read_frame(self, timeout_ms: int = 1000) -> bytes | None:
133
+ """
134
+ Read from serial until a complete frame is found (between two 0x00 delimiters).
135
+ """
136
+ start_time = time.ticks_ms()
137
+
138
+ while time.ticks_diff(time.ticks_ms(), start_time) < timeout_ms:
139
+ # Check if we already have a frame in the buffer
140
+ if b"\x00" in self._rx_buffer:
141
+ # Find the first 0x00
142
+ first_zero = self._rx_buffer.find(b"\x00")
143
+
144
+ # If there's another 0x00 after it, we might have a frame
145
+ second_zero = self._rx_buffer.find(b"\x00", first_zero + 1)
146
+ if second_zero != -1:
147
+ # Extract the frame
148
+ frame = bytes(self._rx_buffer[first_zero : second_zero + 1])
149
+ # Remove from buffer by reassigning to remaining slice
150
+ self._rx_buffer = self._rx_buffer[second_zero + 1 :]
151
+
152
+ # If it's just two 0x00s with nothing between, it's not a valid frame
153
+ if len(frame) <= 2:
154
+ continue
155
+ return frame
156
+
157
+ # If no second zero, but we have a lot of junk before first zero, clear it
158
+ if first_zero > 0:
159
+ self._rx_buffer = self._rx_buffer[first_zero:]
160
+
161
+ # Read more data
162
+ if hasattr(self.ser, "in_waiting"):
163
+ bytes_to_read = max(1, self.ser.in_waiting)
164
+ elif hasattr(self.ser, "any"):
165
+ bytes_to_read = max(1, self.ser.any())
166
+ else:
167
+ bytes_to_read = 1
168
+
169
+ data = self.ser.read(bytes_to_read)
170
+ if data:
171
+ self._rx_buffer.extend(data)
172
+ else:
173
+ time.sleep(0.01) # Avoid busy waiting
174
+
175
+ return None
@@ -0,0 +1,28 @@
1
+ # fmt: off
2
+ try:
3
+ from micropython import const
4
+ except ImportError:
5
+ def const(x): return x # noqa: E731
6
+
7
+ # Protocol Constants
8
+ MAX_RETRIES = const(3) # Maximum transmission attempts
9
+ ACK_TIMEOUT_MS = const(1000) # ACK timeout in milliseconds
10
+ MAX_PAYLOAD_SIZE = const(200) # Maximum payload bytes
11
+ RX_BUFFER_SIZE = const(512) # Receive buffer size in bytes
12
+ MAX_MSG_BYTES = const(8192) # Maximum message bytes advertised
13
+ MAX_FRAGMENTS = const(32) # Maximum fragments per message
14
+ CONSECUTIVE_COBS_FAILS = const(5) # Consecutive COBS fails threshold
15
+
16
+ # Frame Types
17
+ FRAME_DATA = const(0x01) # Application data frame
18
+ FRAME_ACK = const(0x02) # Acknowledgment (success)
19
+ FRAME_NAK = const(0x03) # Negative acknowledgment
20
+ FRAME_FRAG = const(0x04) # Fragmented message chunk
21
+ FRAME_CONNECT = const(0x05) # Connection establishment + caps
22
+ FRAME_CONNECT_ACK = const(0x06) # Connection acknowledgment + caps
23
+ FRAME_ERROR = const(0x07) # Receiver error / capability info
24
+ FRAME_ABORT = const(0x08) # Abort transmission of message
25
+ FRAME_BUSY = const(0x09) # Receiver busy (pause sending)
26
+ FRAME_READY = const(0x0A) # Receiver ready (resume sending)
27
+
28
+ FRAME_DELIMITER = const(0x00)
@@ -0,0 +1,136 @@
1
+ try:
2
+ import logging
3
+ except ImportError:
4
+ from . import logging
5
+ import sys
6
+
7
+ # MicroPython compatibility for typing
8
+ try:
9
+ from typing import TYPE_CHECKING, Any
10
+ except ImportError:
11
+ TYPE_CHECKING = False
12
+ # Minimal fallback
13
+ pass
14
+
15
+ from . import constants
16
+ from .codec_layer import CodecLayer
17
+ from .protocol_layer import ProtocolLayer
18
+
19
+ if TYPE_CHECKING:
20
+ pass # type: ignore
21
+
22
+ logger = logging.getLogger(__name__)
23
+
24
+
25
+ class Urst:
26
+ """
27
+ Main interface for the Universal Reliable Serial Transport (URST) protocol.
28
+ """
29
+
30
+ def __init__(self, port: Any, baud: int = 57600, *, timeout: float = 1.0):
31
+ logger.debug("Initializing Urst")
32
+ self.port = port
33
+ self.baud = baud
34
+ self.timeout = timeout
35
+
36
+ if sys.implementation.name == "micropython":
37
+ import machine
38
+
39
+ if isinstance(port, machine.UART):
40
+ self.ser = port
41
+ else:
42
+ # port could be id (int)
43
+ self.ser = machine.UART(port, baudrate=baud) # type: ignore
44
+ else:
45
+ # Desktop implementation
46
+ if hasattr(port, "write") and hasattr(port, "read"):
47
+ # Already a serial-like object (e.g. mock or already opened serial)
48
+ self.ser = port
49
+ else:
50
+ try:
51
+ from serial import Serial as SerialImpl # type: ignore
52
+
53
+ self.ser = SerialImpl(
54
+ port=port, baudrate=baud, timeout=timeout
55
+ )
56
+ except ImportError as exc:
57
+ raise RuntimeError(
58
+ "pyserial is required to use Urst on desktop Python"
59
+ ) from exc
60
+
61
+ self.codec = CodecLayer(self.ser)
62
+ self.protocol = ProtocolLayer(self.codec)
63
+ self._msg_id = 0
64
+ self._reassembly: dict[int, Any] = {}
65
+
66
+ def send(self, data: bytes) -> int:
67
+ """
68
+ Send data over the URST transport with automatic fragmentation and reliability.
69
+ """
70
+ max_frag_data = constants.MAX_PAYLOAD_SIZE - 6 # 194 bytes
71
+
72
+ if len(data) <= max_frag_data:
73
+ if self.protocol.send_reliable(constants.FRAME_DATA, data):
74
+ return len(data)
75
+ return 0
76
+
77
+ total_frags = (len(data) + max_frag_data - 1) // max_frag_data
78
+ msg_id = self._msg_id
79
+ self._msg_id = (self._msg_id + 1) & 0xFF
80
+
81
+ for i in range(total_frags):
82
+ chunk = data[i * max_frag_data : (i + 1) * max_frag_data]
83
+ # Fragment payload structure (§6.2)
84
+ header = bytes([msg_id, i, total_frags, len(chunk)])
85
+ if not self.protocol.send_reliable(
86
+ constants.FRAME_FRAG, header + chunk
87
+ ):
88
+ return i * max_frag_data
89
+
90
+ return len(data)
91
+
92
+ def read(self, bytes_to_read: int = -1) -> bytes:
93
+ """
94
+ Read a complete URST message (reassembled if necessary).
95
+ """
96
+ while True:
97
+ frame = self.protocol.receive_frame()
98
+ if not frame:
99
+ return b"" # Timeout or duplicate frame (already ACKed)
100
+
101
+ frame_type = frame["type"]
102
+ payload = frame["payload"]
103
+
104
+ if frame_type == constants.FRAME_DATA:
105
+ return payload
106
+
107
+ if frame_type == constants.FRAME_FRAG:
108
+ if len(payload) < 4:
109
+ continue
110
+
111
+ msg_id = payload[0]
112
+ frag_num = payload[1]
113
+ total = payload[2]
114
+ data_len = payload[3]
115
+ data = payload[4 : 4 + data_len]
116
+
117
+ if msg_id not in self._reassembly:
118
+ self._reassembly[msg_id] = {"total": total, "fragments": {}}
119
+
120
+ self._reassembly[msg_id]["fragments"][frag_num] = data
121
+
122
+ if len(self._reassembly[msg_id]["fragments"]) == total:
123
+ # Reassemble message
124
+ msg = b"".join(
125
+ self._reassembly[msg_id]["fragments"][j]
126
+ for j in range(total)
127
+ )
128
+ del self._reassembly[msg_id]
129
+ return msg
130
+
131
+ # Handle other frame types or continue waiting
132
+ if (
133
+ frame_type == constants.FRAME_CONNECT
134
+ or frame_type == constants.FRAME_CONNECT_ACK
135
+ ):
136
+ continue
@@ -0,0 +1,296 @@
1
+ try:
2
+ import logging
3
+ except ImportError:
4
+ from . import logging
5
+
6
+ import struct
7
+
8
+ # Pre-computed CONNECT capability payload (fixed protocol constants).
9
+ # Hoisted to avoid re-running struct.pack on every handshake call.
10
+ _CONNECT_PAYLOAD = struct.pack("<BHBBHBB", 4, 8192, 32, 1, 1000, 3, 0)
11
+
12
+ # MicroPython compatibility for typing
13
+ try: # noqa: SIM105
14
+ from typing import Any
15
+ except ImportError:
16
+ # Minimal fallback for MicroPython
17
+ pass
18
+
19
+ # MicroPython compatibility for time
20
+ import time # noqa: E402
21
+
22
+ try:
23
+ _ = time.ticks_ms # type: ignore
24
+ except AttributeError:
25
+ # Desktop Python shim
26
+ def ticks_ms():
27
+ return int(time.time() * 1000)
28
+
29
+ def ticks_diff(later, earlier):
30
+ return later - earlier
31
+
32
+ time.ticks_ms = ticks_ms # type: ignore
33
+ time.ticks_diff = ticks_diff # type: ignore
34
+
35
+ from collections import deque # noqa: E402
36
+
37
+ from . import constants # noqa: E402
38
+ from .codec_layer import ( # noqa: E402
39
+ calculate_crc16,
40
+ cobs_decode,
41
+ cobs_encode,
42
+ serialize_crc,
43
+ )
44
+
45
+ logger = logging.getLogger(__name__)
46
+
47
+ # Module-level tuples avoid re-allocating set objects on each membership test.
48
+ # Using tuple for MicroPython portability (frozenset availability varies by port).
49
+ _VALID_FRAME_TYPES = (
50
+ constants.FRAME_DATA,
51
+ constants.FRAME_ACK,
52
+ constants.FRAME_NAK,
53
+ constants.FRAME_FRAG,
54
+ constants.FRAME_CONNECT,
55
+ constants.FRAME_CONNECT_ACK,
56
+ constants.FRAME_ERROR,
57
+ constants.FRAME_ABORT,
58
+ constants.FRAME_BUSY,
59
+ constants.FRAME_READY,
60
+ )
61
+ _EMPTY_PAYLOAD_TYPES = (
62
+ constants.FRAME_ACK,
63
+ constants.FRAME_NAK,
64
+ constants.FRAME_BUSY,
65
+ constants.FRAME_READY,
66
+ )
67
+ _DATA_FRAG_TYPES = (constants.FRAME_DATA, constants.FRAME_FRAG)
68
+ _PAYLOAD_FRAME_TYPES = (
69
+ constants.FRAME_DATA,
70
+ constants.FRAME_FRAG,
71
+ constants.FRAME_CONNECT,
72
+ )
73
+
74
+
75
+ def _is_empty_payload_only_type(frame_type):
76
+ return frame_type in _EMPTY_PAYLOAD_TYPES
77
+
78
+
79
+ def build_frame(frame_type: int, seq: int, payload: bytes = b"") -> bytes:
80
+ """
81
+ Build a complete physical frame (delimiter + COBS + delimiter).
82
+
83
+ Order: Header (type, seq) -> Payload -> CRC -> COBS -> Delimiters.
84
+ """
85
+ if frame_type not in _VALID_FRAME_TYPES:
86
+ raise ValueError(f"Unknown frame type: {frame_type}")
87
+ if not 0 <= seq <= 0xFF:
88
+ raise ValueError("Sequence number must be in range 0..255")
89
+
90
+ logical = bytes([frame_type, seq]) + payload
91
+ crc = calculate_crc16(logical)
92
+ with_crc = logical + serialize_crc(crc)
93
+ encoded = cobs_encode(with_crc)
94
+ delimiter = bytes([constants.FRAME_DELIMITER])
95
+ return delimiter + encoded + delimiter
96
+
97
+
98
+ def parse_frame(raw: bytes) -> dict | None:
99
+ """
100
+ Strips delimiters, COBS-decodes, validates CRC, and parses the header.
101
+
102
+ Returns:
103
+ {'type': int, 'seq': int, 'payload': bytes} or None on failure.
104
+ """
105
+ if len(raw) < 3:
106
+ return None
107
+
108
+ delimiter = constants.FRAME_DELIMITER
109
+ if raw[0] != delimiter or raw[-1] != delimiter:
110
+ return None
111
+
112
+ encoded = raw[1:-1]
113
+ decoded = cobs_decode(encoded)
114
+ if decoded is None or len(decoded) < 4:
115
+ return None
116
+
117
+ payload_with_header = decoded[:-2]
118
+ received_crc = int.from_bytes(decoded[-2:], "little")
119
+ expected_crc = calculate_crc16(payload_with_header)
120
+ if received_crc != expected_crc:
121
+ return None
122
+
123
+ frame_type = payload_with_header[0]
124
+ seq = payload_with_header[1]
125
+ payload = payload_with_header[2:]
126
+
127
+ if frame_type not in _VALID_FRAME_TYPES:
128
+ return None
129
+ if (
130
+ len(payload) > constants.MAX_PAYLOAD_SIZE
131
+ and len(payload) != 252
132
+ and frame_type == constants.FRAME_DATA
133
+ ):
134
+ return None
135
+ if _is_empty_payload_only_type(frame_type) and payload:
136
+ return None
137
+
138
+ return {"type": frame_type, "seq": seq, "payload": payload}
139
+
140
+
141
+ class ProtocolLayer:
142
+ """
143
+ Handles the URST protocol logic, including sequence management and reliable delivery.
144
+ """
145
+
146
+ def __init__(self, codec: Any):
147
+ self.codec = codec
148
+ self.next_send_seq = 0
149
+ self.expected_recv_seq = 0
150
+ self.last_received_seq = -1
151
+ self.is_connected = False
152
+ self._recv_queue = deque(
153
+ (), constants.MAX_FRAGMENTS
154
+ ) # O(1) popleft on MicroPython
155
+ logger.debug("Initializing Protocol Layer")
156
+
157
+ def connect(self) -> bool:
158
+ """Perform the CONNECT handshake with retries (§5.6)."""
159
+ payload = _CONNECT_PAYLOAD
160
+ for attempt in range(constants.MAX_RETRIES + 1):
161
+ logger.debug(f"Handshake attempt {attempt + 1}")
162
+ self.codec.write_frame(
163
+ build_frame(constants.FRAME_CONNECT, 0, payload)
164
+ )
165
+ # Handshake must not use the queue as it needs fresh response
166
+ resp = self.codec.read_frame(constants.ACK_TIMEOUT_MS)
167
+ if resp:
168
+ p = parse_frame(resp)
169
+ if p and p["type"] == constants.FRAME_CONNECT_ACK:
170
+ (
171
+ self.next_send_seq,
172
+ self.expected_recv_seq,
173
+ self.last_received_seq,
174
+ ) = 0, 0, -1
175
+ self.is_connected = True
176
+ logger.debug("URST Connected (received CONNECT_ACK)")
177
+ return True
178
+ if p and p["type"] == constants.FRAME_CONNECT:
179
+ self.codec.write_frame(
180
+ build_frame(
181
+ constants.FRAME_CONNECT_ACK, p["seq"], payload
182
+ )
183
+ )
184
+ (
185
+ self.next_send_seq,
186
+ self.expected_recv_seq,
187
+ self.last_received_seq,
188
+ ) = 0, 0, -1
189
+ self.is_connected = True
190
+ logger.debug(
191
+ "URST Connected (simultaneous CONNECT resolved)"
192
+ )
193
+ return True
194
+ if p:
195
+ logger.warning(
196
+ f"Unexpected frame during handshake: {p['type']}"
197
+ )
198
+ else:
199
+ logger.warning("Handshake timeout")
200
+ return False
201
+
202
+ def send_reliable(self, frame_type: int, payload: bytes) -> bool:
203
+ """Send a frame reliably using stop-and-wait (§5.1.1)."""
204
+ if (
205
+ not self.is_connected
206
+ and frame_type != constants.FRAME_CONNECT
207
+ and not self.connect()
208
+ ):
209
+ logger.error("Failed to establish connection before sending")
210
+ return False
211
+
212
+ seq = self.next_send_seq
213
+ frame = build_frame(frame_type, seq, payload)
214
+
215
+ for attempt in range(constants.MAX_RETRIES + 1):
216
+ logger.debug(
217
+ f"Sending frame type {frame_type:#x}, seq {seq}, attempt {attempt + 1}"
218
+ )
219
+ self.codec.write_frame(frame)
220
+
221
+ start_wait = time.ticks_ms() # type: ignore
222
+ while (
223
+ time.ticks_diff(time.ticks_ms(), start_wait)
224
+ < constants.ACK_TIMEOUT_MS
225
+ ): # type: ignore
226
+ # Read fresh frames only, bypassing the queue
227
+ p = self.receive_frame(timeout_ms=100, use_queue=False)
228
+ if p:
229
+ if p["type"] == constants.FRAME_ACK and p["seq"] == seq:
230
+ self.next_send_seq = (self.next_send_seq + 1) & 0xFF
231
+ logger.debug(f"Received ACK for seq {seq}")
232
+ return True
233
+ if p["type"] == constants.FRAME_NAK and p["seq"] == seq:
234
+ logger.warning(
235
+ f"Received NAK for seq {seq}, retrying..."
236
+ )
237
+ break
238
+
239
+ # If it's a payload frame, it's already been ACKed by receive_frame.
240
+ # We must queue it so Urst.read() can find it later.
241
+ if p["type"] in _DATA_FRAG_TYPES:
242
+ logger.debug(
243
+ f"Queuing payload frame type {p['type']} received during wait"
244
+ )
245
+ self._recv_queue.append(p)
246
+ else:
247
+ logger.warning(f"Timeout waiting for ACK for seq {seq}")
248
+
249
+ return False
250
+
251
+ def receive_frame(
252
+ self, timeout_ms: int | None = None, use_queue: bool = True
253
+ ) -> dict | None:
254
+ """Receive a frame and handle ACKs/seq checks (§5.1.2, §5.6.2)."""
255
+ if use_queue and self._recv_queue:
256
+ return self._recv_queue.popleft()
257
+
258
+ if timeout_ms is None:
259
+ timeout_ms = constants.ACK_TIMEOUT_MS
260
+
261
+ raw = self.codec.read_frame(timeout_ms)
262
+ if not raw:
263
+ return None
264
+ p = parse_frame(raw)
265
+ if not p:
266
+ return None
267
+ ft, seq = p["type"], p["seq"]
268
+
269
+ if ft == constants.FRAME_ACK or ft == constants.FRAME_NAK:
270
+ return p
271
+
272
+ if ft in _PAYLOAD_FRAME_TYPES:
273
+ if ft == constants.FRAME_CONNECT or seq == self.expected_recv_seq:
274
+ if ft == constants.FRAME_CONNECT:
275
+ payload = _CONNECT_PAYLOAD
276
+ self.codec.write_frame(
277
+ build_frame(constants.FRAME_CONNECT_ACK, seq, payload)
278
+ )
279
+ (
280
+ self.next_send_seq,
281
+ self.expected_recv_seq,
282
+ self.last_received_seq,
283
+ ) = 0, 0, -1
284
+ self.is_connected = True
285
+ return p
286
+ self.codec.write_frame(build_frame(constants.FRAME_ACK, seq))
287
+ self.last_received_seq, self.expected_recv_seq = (
288
+ seq,
289
+ (self.expected_recv_seq + 1) & 0xFF,
290
+ )
291
+ return p
292
+ if seq == self.last_received_seq:
293
+ self.codec.write_frame(build_frame(constants.FRAME_ACK, seq))
294
+ return None
295
+ self.codec.write_frame(build_frame(constants.FRAME_NAK, seq))
296
+ return p