commit 64e8da17e77bebac50fb0432d1d91b61a39bc635 Author: Hector van der Aa Date: Wed Sep 16 14:52:53 2026 +0200 Proof of concept diff --git a/clock-sync-firmware/.gitignore b/clock-sync-firmware/.gitignore new file mode 100644 index 0000000..89cc49c --- /dev/null +++ b/clock-sync-firmware/.gitignore @@ -0,0 +1,5 @@ +.pio +.vscode/.browse.c_cpp.db* +.vscode/c_cpp_properties.json +.vscode/launch.json +.vscode/ipch diff --git a/clock-sync-firmware/include/README b/clock-sync-firmware/include/README new file mode 100644 index 0000000..49819c0 --- /dev/null +++ b/clock-sync-firmware/include/README @@ -0,0 +1,37 @@ + +This directory is intended for project header files. + +A header file is a file containing C declarations and macro definitions +to be shared between several project source files. You request the use of a +header file in your project source file (C, C++, etc) located in `src` folder +by including it, with the C preprocessing directive `#include'. + +```src/main.c + +#include "header.h" + +int main (void) +{ + ... +} +``` + +Including a header file produces the same results as copying the header file +into each source file that needs it. Such copying would be time-consuming +and error-prone. With a header file, the related declarations appear +in only one place. If they need to be changed, they can be changed in one +place, and programs that include the header file will automatically use the +new version when next recompiled. The header file eliminates the labor of +finding and changing all the copies as well as the risk that a failure to +find one copy will result in inconsistencies within a program. + +In C, the convention is to give header files names that end with `.h'. + +Read more about using header files in official GCC documentation: + +* Include Syntax +* Include Operation +* Once-Only Headers +* Computed Includes + +https://gcc.gnu.org/onlinedocs/cpp/Header-Files.html diff --git a/clock-sync-firmware/lib/README b/clock-sync-firmware/lib/README new file mode 100644 index 0000000..9379397 --- /dev/null +++ b/clock-sync-firmware/lib/README @@ -0,0 +1,46 @@ + +This directory is intended for project specific (private) libraries. +PlatformIO will compile them to static libraries and link into the executable file. + +The source code of each library should be placed in a separate directory +("lib/your_library_name/[Code]"). + +For example, see the structure of the following example libraries `Foo` and `Bar`: + +|--lib +| | +| |--Bar +| | |--docs +| | |--examples +| | |--src +| | |- Bar.c +| | |- Bar.h +| | |- library.json (optional. for custom build options, etc) https://docs.platformio.org/page/librarymanager/config.html +| | +| |--Foo +| | |- Foo.c +| | |- Foo.h +| | +| |- README --> THIS FILE +| +|- platformio.ini +|--src + |- main.c + +Example contents of `src/main.c` using Foo and Bar: +``` +#include +#include + +int main (void) +{ + ... +} + +``` + +The PlatformIO Library Dependency Finder will find automatically dependent +libraries by scanning project source files. + +More information about PlatformIO Library Dependency Finder +- https://docs.platformio.org/page/librarymanager/ldf.html diff --git a/clock-sync-firmware/platformio.ini b/clock-sync-firmware/platformio.ini new file mode 100644 index 0000000..4dcf2a8 --- /dev/null +++ b/clock-sync-firmware/platformio.ini @@ -0,0 +1,15 @@ +; PlatformIO Project Configuration File +; +; Build options: build flags, source filter +; Upload options: custom upload port, speed and extra flags +; Library options: dependencies, extra library storages +; Advanced options: extra scripting +; +; Please visit documentation for the other options and examples +; https://docs.platformio.org/page/projectconf.html + +[env:megaatmega2560] +platform = atmelavr +board = megaatmega2560 +framework = arduino +monitor_speed = 115200 \ No newline at end of file diff --git a/clock-sync-firmware/src/main.cpp b/clock-sync-firmware/src/main.cpp new file mode 100644 index 0000000..f1936f2 --- /dev/null +++ b/clock-sync-firmware/src/main.cpp @@ -0,0 +1,36 @@ +#include + +#pragma pack(push, 1) +typedef struct { + uint16_t sync; + uint8_t type; + uint32_t r_t; + uint32_t s_t; +} sync_packet; +#pragma pack(pop) + +void setup() { + Serial.begin(115200); +} + +void loop() { + uint8_t previous = 0; + sync_packet packet{0xBEEF,3,0,0}; + while (true) { + if (Serial.available()) { + uint8_t byte; + Serial.readBytes(&byte, 1); + if (previous == 0xEF && byte == 0xBE) { + break; + } + previous = byte; + } + } + uint32_t r_t = micros(); + Serial.readBytes(&packet.type, 9); + if (packet.type == 3) { + packet.r_t = r_t; + packet.s_t = micros(); + Serial.write((uint8_t*)&packet, sizeof(packet)); + } +} diff --git a/clock-sync-firmware/test/README b/clock-sync-firmware/test/README new file mode 100644 index 0000000..9b1e87b --- /dev/null +++ b/clock-sync-firmware/test/README @@ -0,0 +1,11 @@ + +This directory is intended for PlatformIO Test Runner and project tests. + +Unit Testing is a software testing method by which individual units of +source code, sets of one or more MCU program modules together with associated +control data, usage procedures, and operating procedures, are tested to +determine whether they are fit for use. Unit testing finds problems early +in the development cycle. + +More information about PlatformIO Unit Testing: +- https://docs.platformio.org/en/latest/advanced/unit-testing/index.html diff --git a/clock-sync-software/.python-version b/clock-sync-software/.python-version new file mode 100644 index 0000000..24ee5b1 --- /dev/null +++ b/clock-sync-software/.python-version @@ -0,0 +1 @@ +3.13 diff --git a/clock-sync-software/README.md b/clock-sync-software/README.md new file mode 100644 index 0000000..e69de29 diff --git a/clock-sync-software/pyproject.toml b/clock-sync-software/pyproject.toml new file mode 100644 index 0000000..511f687 --- /dev/null +++ b/clock-sync-software/pyproject.toml @@ -0,0 +1,19 @@ +[project] +name = "clock-sync-software" +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", +] + +[project.scripts] +clock-sync-software = "clock_sync_software:main" + +[build-system] +requires = ["uv_build>=0.12.13,<0.13.0"] +build-backend = "uv_build" diff --git a/clock-sync-software/src/clock_sync_software/__init__.py b/clock-sync-software/src/clock_sync_software/__init__.py new file mode 100644 index 0000000..64345da --- /dev/null +++ b/clock-sync-software/src/clock_sync_software/__init__.py @@ -0,0 +1,497 @@ +import struct +import time +from dataclasses import dataclass + +from serial import Serial +from serial.tools import list_ports + + +SYNC_PATTERN = 0xBEEF +SYNC_BYTES = struct.pack(" 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)}" + ) + + sync, type_, r_t, s_t = struct.unpack( + cls.FORMAT, + data, + ) + + return cls( + sync=sync, + 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 + + +def fit_clock( + samples: list[SyncSample], +) -> tuple[float, float]: + """ + Fit: + + pc_ns = scale * arduino_ns + offset_ns + + Since both sides are in nanoseconds, the ideal scale is 1.0. + """ + + if len(samples) < 2: + raise ValueError("At least two samples are required") + + xs = [sample.arduino_mid_ns for sample in samples] + + ys = [sample.pc_mid_ns for sample in 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") + + scale = numerator / denominator + offset_ns = y_mean - scale * x_mean + + return scale, offset_ns + + +def arduino_ns_to_monotonic_ns( + arduino_ns: float, + scale: float, + offset_ns: float, +) -> int: + return round(scale * arduino_ns + offset_ns) + + +def arduino_us_to_monotonic_ns( + arduino_us: int, + scale: float, + offset_ns: float, +) -> int: + return arduino_ns_to_monotonic_ns( + arduino_us * 1_000, + scale, + offset_ns, + ) + + +def start_sync(ser: Serial) -> None: + previous: bytes | None = None + + while True: + byte = ser.read(1) + + if not byte: + raise TimeoutError("Timed out waiting for sync pattern") + + if previous == b"\xef" and byte == b"\xbe": + return + + previous = byte + + +def read_exactly( + ser: Serial, + size: int, +) -> bytes: + data = bytearray() + + while len(data) < size: + chunk = ser.read(size - len(data)) + + if not chunk: + raise TimeoutError( + f"Timed out reading packet: received {len(data)}/{size} bytes" + ) + + data.extend(chunk) + + return bytes(data) + + +def choose_serial_port() -> str: + ports = list(list_ports.comports()) + + if not ports: + raise RuntimeError("No serial ports found") + + for index, port in enumerate(ports): + print(f"{index}: {port.device} {port.description}") + + while True: + try: + choice = int(input("Choose a port to connect: ")) + + if 0 <= choice < len(ports): + selected_port = ports[choice].device + + print(f"Selected port: {selected_port}") + + return selected_port + + except ValueError: + pass + + print("Unknown port. Try again.") + + +def get_sync_sample( + ser: Serial, + request_packet: bytes, +) -> SyncSample: + # + # Bracket the serial write. + # + tbw = time.monotonic_ns() + + ser.write(request_packet) + ser.flush() + + taw = time.monotonic_ns() + + # + # Wait for response packet. + # + start_sync(ser) + + payload = read_exactly( + ser, + SyncPacket.SIZE - len(SYNC_BYTES), + ) + + tar = time.monotonic_ns() + + reply = SyncPacket.unpack(SYNC_BYTES + payload) + + # + # PC timestamps. + # + # Estimate request-transmit time as midpoint + # around write()/flush(). + # + t1 = (tbw + taw) // 2 + + # + # Arduino receive/transmit timestamps. + # Convert micros() -> ns. + # + t2 = reply.r_t * 1_000 + t3 = reply.s_t * 1_000 + + # + # PC receive timestamp. + # + t4 = tar + + # + # Midpoints give us an approximate + # Arduino<->PC correspondence. + # + arduino_mid_ns = (t2 + t3) / 2 + + pc_mid_ns = (t1 + t4) / 2 + + # + # Corrected transport RTT: + # + # total PC round trip + # minus Arduino processing time. + # + arduino_processing_ns = t3 - t2 + + rtt_ns = (t4 - t1) - arduino_processing_ns + + return SyncSample( + arduino_mid_ns=arduino_mid_ns, + pc_mid_ns=pc_mid_ns, + rtt_ns=rtt_ns, + ) + + +def calibrate( + ser: Serial, + request_packet: bytes, +) -> tuple[ + float, + float, + list[SyncSample], +]: + print() + print(f"Calibrating clocks for {CALIBRATION_DURATION_S:.0f} seconds...") + + samples: list[SyncSample] = [] + + start_ns = time.monotonic_ns() + duration_ns = int(CALIBRATION_DURATION_S * 1_000_000_000) + + while time.monotonic_ns() - start_ns < duration_ns: + try: + sample = get_sync_sample( + ser, + request_packet, + ) + + samples.append(sample) + + elapsed_s = (time.monotonic_ns() - start_ns) / 1_000_000_000 + + offset_ns = sample.pc_mid_ns - sample.arduino_mid_ns + + print( + f"\r" + f"{elapsed_s:5.1f}/" + f"{CALIBRATION_DURATION_S:.0f} s" + f" | offset: " + f"{offset_ns / 1_000_000:+.3f} ms" + f" | RTT: " + f"{sample.rtt_ns / 1_000_000:.3f} ms", + end="", + flush=True, + ) + + except TimeoutError as exc: + print(f"\nSync timeout: {exc}") + + time.sleep(SYNC_INTERVAL_S) + + print() + + if len(samples) < 2: + raise RuntimeError("Not enough valid synchronization samples") + + # + # Select lowest-latency 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] + + scale, offset_ns = fit_clock(good_samples) + + return ( + scale, + offset_ns, + good_samples, + ) + + +def validate( + ser: Serial, + request_packet: bytes, + scale: float, + offset_ns: float, +) -> None: + print() + print(f"Running {VALIDATION_DURATION_S:.0f}-second validation test...") + + errors_ns: list[float] = [] + rtts_ns: list[int] = [] + + start_ns = time.monotonic_ns() + duration_ns = int(VALIDATION_DURATION_S * 1_000_000_000) + + while time.monotonic_ns() - start_ns < duration_ns: + try: + sample = get_sync_sample( + ser, + request_packet, + ) + + predicted_pc_ns = scale * sample.arduino_mid_ns + offset_ns + + error_ns = sample.pc_mid_ns - predicted_pc_ns + + errors_ns.append(error_ns) + + rtts_ns.append(sample.rtt_ns) + + elapsed_s = (time.monotonic_ns() - start_ns) / 1_000_000_000 + + print( + f"\r" + f"{elapsed_s:5.1f}/" + f"{VALIDATION_DURATION_S:.0f} s" + f" | error: " + f"{error_ns / 1_000_000:+.3f} ms" + f" | RTT: " + f"{sample.rtt_ns / 1_000_000:.3f} ms", + end="", + flush=True, + ) + + except TimeoutError as exc: + print(f"\nSync timeout: {exc}") + + time.sleep(SYNC_INTERVAL_S) + + print() + + if not errors_ns: + raise RuntimeError("No valid validation samples") + + absolute_errors_ns = [abs(error) for error in errors_ns] + + max_abs_error_ns = max(absolute_errors_ns) + + mean_abs_error_ns = sum(absolute_errors_ns) / len(absolute_errors_ns) + + mean_signed_error_ns = sum(errors_ns) / len(errors_ns) + + rms_error_ns = (sum(error * error for error in errors_ns) / len(errors_ns)) ** 0.5 + + min_error_ns = min(errors_ns) + max_error_ns = max(errors_ns) + + print() + print("Validation results") + print("------------------") + print(f"Samples: {len(errors_ns)}") + print() + print(f"Maximum abs error: {max_abs_error_ns / 1_000_000:.6f} ms") + print(f"Mean abs error: {mean_abs_error_ns / 1_000_000:.6f} ms") + print(f"RMS error: {rms_error_ns / 1_000_000:.6f} ms") + print(f"Mean signed error: {mean_signed_error_ns / 1_000_000:+.6f} ms") + print(f"Minimum error: {min_error_ns / 1_000_000:+.6f} ms") + print(f"Maximum error: {max_error_ns / 1_000_000:+.6f} ms") + + print() + print(f"Best validation RTT: {min(rtts_ns) / 1_000_000:.6f} ms") + + +def main() -> None: + try: + selected_port = choose_serial_port() + except KeyboardInterrupt: + print() + return + + request_packet = SyncPacket( + sync=SYNC_PATTERN, + type=3, + r_t=0, + s_t=0, + ).pack() + + with Serial( + selected_port, + baudrate=BAUDRATE, + timeout=SERIAL_TIMEOUT, + ) as ser: + print("Waiting 5 seconds before synchronization...") + + time.sleep(5) + + # + # Remove startup noise / stale bytes. + # + ser.reset_input_buffer() + + scale, offset_ns, good_samples = calibrate( + ser, + request_packet, + ) + + drift_ppm = (scale - 1.0) * 1_000_000 + + best_rtt_ns = min(sample.rtt_ns for sample in good_samples) + + worst_good_rtt_ns = max(sample.rtt_ns for sample in good_samples) + + print() + print("Clock synchronization complete") + print("--------------------------------") + print(f"Calibration samples used: {len(good_samples)}") + + print(f"Best RTT: {best_rtt_ns / 1_000_000:.6f} ms") + + print(f"Calibration RTT cutoff: {worst_good_rtt_ns / 1_000_000:.6f} ms") + + print() + print(f"Scale: {scale:.12f}") + + print(f"Drift: {drift_ppm:+.3f} ppm") + + print(f"Offset: {offset_ns:.0f} ns") + + print(f"Offset: {offset_ns / 1_000_000:.6f} ms") + + print() + print("Clock equation:") + print(f"PC_ns = {scale:.12f} * Arduino_ns {offset_ns:+.0f}") + + print() + print("For an Arduino micros() timestamp:") + + print(f"PC_ns = {scale:.12f} * (Arduino_us * 1000) {offset_ns:+.0f}") + + # + # Now test the model without changing it. + # + validate( + ser, + request_packet, + scale, + offset_ns, + ) + + +if __name__ == "__main__": + main() diff --git a/clock-sync-software/uv.lock b/clock-sync-software/uv.lock new file mode 100644 index 0000000..2f48bce --- /dev/null +++ b/clock-sync-software/uv.lock @@ -0,0 +1,23 @@ +version = 1 +revision = 3 +requires-python = ">=3.13" + +[[package]] +name = "clock-sync-software" +version = "0.1.0" +source = { editable = "." } +dependencies = [ + { name = "pyserial" }, +] + +[package.metadata] +requires-dist = [{ name = "pyserial", specifier = ">=3.5" }] + +[[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" }, +]