commit e2ce3d8acdb37a5bdb68183815ddf09d190e2c44 Author: Hector van der Aa Date: Wed Sep 16 14:48:39 2026 +0200 Release v0.1.0 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..505a3b1 --- /dev/null +++ b/.gitignore @@ -0,0 +1,10 @@ +# Python-generated files +__pycache__/ +*.py[oc] +build/ +dist/ +wheels/ +*.egg-info + +# Virtual environments +.venv diff --git a/.python-version b/.python-version new file mode 100644 index 0000000..24ee5b1 --- /dev/null +++ b/.python-version @@ -0,0 +1 @@ +3.13 diff --git a/README.md b/README.md new file mode 100644 index 0000000..e69de29 diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..a1ece7b --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,16 @@ +[project] +name = "serial-sync" +version = "0.1.0" +description = "Add your description here" +readme = "README.md" +authors = [ + { name = "Hector van der Aa", email = "hector@h3cx.dev" } +] +requires-python = ">=3.13" +dependencies = [ + "pyserial>=3.5", +] + +[build-system] +requires = ["uv_build>=0.12.13,<0.13.0"] +build-backend = "uv_build" diff --git a/src/serial_sync/__init__.py b/src/serial_sync/__init__.py new file mode 100644 index 0000000..1b98578 --- /dev/null +++ b/src/serial_sync/__init__.py @@ -0,0 +1,172 @@ +from dataclasses import dataclass, field +import struct +import time +from serial import Serial + +SYNC_PATTERN = 0xBEEF +SYNC_BYTES = struct.pack(" None: + self._serial = serial + self._scale: float | None = None + self._offset: float | None = None + + def calibrate_sync(self, period: float) -> None: + request_packet = SyncPacket( + type=3, + r_t=0, + s_t=0, + ).pack() + + print("Waiting 5 seconds before synchronization...") + + time.sleep(5) + + self._serial.reset_input_buffer() + + samples: list[SyncSample] = [] + + start_ns: int = time.monotonic_ns() + duration_ns: int = int(period * 1_000_000_000) + + while time.monotonic_ns() < start_ns + duration_ns: + tbw = time.monotonic_ns() + self._serial.write(request_packet) + self._serial.flush() + taw = time.monotonic_ns() + + previous: bytes | None = None + + while True: + byte = self._serial.read(1) + + if not byte: + raise TimeoutError("Timed out waiting for sync pattern") + + if previous == b"\xef" and byte == b"\xbe": + break + + previous = byte + + data = bytearray() + size = SyncPacket.SIZE - len(SYNC_BYTES) + while len(data) < size: + chunk = self._serial.read(size - len(data)) + + if not chunk: + raise TimeoutError( + f"Timed out reading packet: received {len(data)}/{size} bytes" + ) + + data.extend(chunk) + + payload = bytes(data) + + tar = time.monotonic_ns() + reply = SyncPacket.unpack(SYNC_BYTES + payload) + + t1 = (tbw + taw) // 2 + t2 = reply.r_t * 1_000 + t3 = reply.s_t * 1_000 + t4 = tar + + arduino_mid_ns = (t2 + t3) / 2 + pc_mid_ns = (t1 + t4) / 2 + + arduino_processing_ns = t3 - t2 + + rtt_ns = (t4 - t1) - arduino_processing_ns + + samples.append(SyncSample(arduino_mid_ns, pc_mid_ns, rtt_ns)) + time.sleep(SYNC_INTERVAL_S) + + if len(samples) < 2: + raise RuntimeError("Not enough valid synchronization samples") + + sorted_samples = sorted( + samples, + key=lambda sample: sample.rtt_ns, + ) + + keep_count = max( + 2, + int(len(sorted_samples) * GOOD_SAMPLE_FRACTION), + ) + + good_samples = sorted_samples[:keep_count] + + xs = [sample.arduino_mid_ns for sample in good_samples] + + ys = [sample.pc_mid_ns for sample in good_samples] + + x_mean = sum(xs) / len(xs) + y_mean = sum(ys) / len(ys) + + numerator = sum((x - x_mean) * (y - y_mean) for x, y in zip(xs, ys)) + + denominator = sum((x - x_mean) ** 2 for x in xs) + + if denominator == 0: + raise ValueError("Cannot fit clock: all Arduino timestamps are equal") + + self._scale = numerator / denominator + self._offset = y_mean - self._scale * x_mean + + def convert_timestamp(self, arduino_ns: int) -> int: + if self._scale is None or self._offset is None: + raise RuntimeError("SerialSync has not been calibrated") + + return round(self._scale * arduino_ns + self._offset) + + +@dataclass +class SyncPacket: + sync: int = field(default=0xBEEF, init=False) + type: int + r_t: int + s_t: int + + FORMAT = " bytes: + return struct.pack( + self.FORMAT, + self.sync, + self.type, + self.r_t, + self.s_t, + ) + + @classmethod + def unpack(cls, data: bytes) -> "SyncPacket": + if len(data) != cls.SIZE: + raise ValueError( + f"Invalid SyncPacket size: expected {cls.SIZE}, got {len(data)}" + ) + + _, type_, r_t, s_t = struct.unpack( + cls.FORMAT, + data, + ) + + return cls( + type=type_, + r_t=r_t, + s_t=s_t, + ) + + +@dataclass +class SyncSample: + arduino_mid_ns: float + pc_mid_ns: float + rtt_ns: int + + @property + def observed_offset_ns(self) -> float: + return self.pc_mid_ns - self.arduino_mid_ns diff --git a/src/serial_sync/py.typed b/src/serial_sync/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/test/main.py b/test/main.py new file mode 100644 index 0000000..8a2dedf --- /dev/null +++ b/test/main.py @@ -0,0 +1,79 @@ +import time + +from serial import Serial +from serial.tools import list_ports + +from serial_sync import SerialSync + + +BAUDRATE = 115200 +SERIAL_TIMEOUT = 1.0 +CALIBRATION_PERIOD_S = 30.0 + + +def choose_serial_port() -> str: + ports = list(list_ports.comports()) + + if not ports: + raise RuntimeError("No serial ports found") + + print("Available serial ports:") + + for index, port in enumerate(ports): + print(f"{index}: {port.device} ({port.description})") + + while True: + try: + choice = int(input("Choose a port: ")) + + if 0 <= choice < len(ports): + return ports[choice].device + + except ValueError: + pass + + print("Invalid choice, try again.") + + +def main() -> None: + try: + port = choose_serial_port() + + print() + print(f"Opening {port} at {BAUDRATE} baud...") + + with Serial( + port=port, + baudrate=BAUDRATE, + timeout=SERIAL_TIMEOUT, + ) as serial: + # + # Opening the serial port may reset an Arduino, + # so allow it to boot before calibration. + # + print("Waiting for Arduino to start...") + time.sleep(5) + + sync = SerialSync(serial) + + print() + print(f"Calibrating for {CALIBRATION_PERIOD_S:.1f} seconds...") + + sync.calibrate_sync(CALIBRATION_PERIOD_S) + + print() + print("Synchronization complete") + print("------------------------") + print(f"Scale: {sync._scale:.12f}") + print(f"Offset: {sync._offset:.0f} ns") + print(f"Offset: {sync._offset / 1_000_000:.6f} ms") + + except KeyboardInterrupt: + print("\nInterrupted.") + + except Exception as exc: + print(f"\nError: {exc}") + + +if __name__ == "__main__": + main() diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..9daff8f --- /dev/null +++ b/uv.lock @@ -0,0 +1,23 @@ +version = 1 +revision = 3 +requires-python = ">=3.13" + +[[package]] +name = "pyserial" +version = "3.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1e/7d/ae3f0a63f41e4d2f6cb66a5b57197850f919f59e558159a4dd3a818f5082/pyserial-3.5.tar.gz", hash = "sha256:3c77e014170dfffbd816e6ffc205e9842efb10be9f58ec16d3e8675b4925cddb", size = 159125, upload-time = "2020-11-23T03:59:15.045Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/07/bc/587a445451b253b285629263eb51c2d8e9bcea4fc97826266d186f96f558/pyserial-3.5-py2.py3-none-any.whl", hash = "sha256:c4451db6ba391ca6ca299fb3ec7bae67a5c55dde170964c7a14ceefec02f2cf0", size = 90585, upload-time = "2020-11-23T03:59:13.41Z" }, +] + +[[package]] +name = "serial-sync" +version = "0.1.0" +source = { editable = "." } +dependencies = [ + { name = "pyserial" }, +] + +[package.metadata] +requires-dist = [{ name = "pyserial", specifier = ">=3.5" }]