Release v0.1.0
This commit is contained in:
8 files changed
+301
No files matched your search
+10
@@ -0,0 +1,10 @@
|
|||||||
|
# Python-generated files
|
||||||
|
__pycache__/
|
||||||
|
*.py[oc]
|
||||||
|
build/
|
||||||
|
dist/
|
||||||
|
wheels/
|
||||||
|
*.egg-info
|
||||||
|
|
||||||
|
# Virtual environments
|
||||||
|
.venv
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
3.13
|
||||||
@@ -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"
|
||||||
@@ -0,0 +1,172 @@
|
|||||||
|
from dataclasses import dataclass, field
|
||||||
|
import struct
|
||||||
|
import time
|
||||||
|
from serial import Serial
|
||||||
|
|
||||||
|
SYNC_PATTERN = 0xBEEF
|
||||||
|
SYNC_BYTES = struct.pack("<H", SYNC_PATTERN)
|
||||||
|
GOOD_SAMPLE_FRACTION = 0.25
|
||||||
|
SYNC_INTERVAL_S = 0.05
|
||||||
|
|
||||||
|
|
||||||
|
class SerialSync:
|
||||||
|
def __init__(self, serial: Serial) -> 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 = "<HBII"
|
||||||
|
SIZE = struct.calcsize(FORMAT)
|
||||||
|
|
||||||
|
def pack(self) -> 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
|
||||||
Whitespace-only changes.
@@ -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()
|
||||||
@@ -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" }]
|
||||||
Reference in new issue
Block a user