pureboot.py: --scan walks a silent loader's rate; --info decodes the clock
The RC-oscillator answer's host half. --scan probes ±10 % around the built rate in 2 % steps, nearest first, one activation window (one reset) per probe: a fixed-baud loader whose oscillator drifted answers at the ratio, and the report gives the session workaround (--baud), the offset, the OSCCAL direction at ~1 %/step, and the autobaud way out. The walk and the advice are logic-tested (test_scan.py, red-proven on the trim direction) — a pty carries bytes at any rate, so the wire cannot arbitrate them. On an autobaud session --info now reads the measured bit period from ram_start — the geometry table gains that column — and undoes the unit's encoding ((cycles − 8) / 4, floored: libavr's spin granule and per-bit overhead), so the printed clock is the true one within a granule; --clock turns it into a stated drift. The autobaud end-to-end asserts the figure inside exactly that envelope at both clock points. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -167,6 +167,9 @@ if(PROJECT_IS_TOP_LEVEL)
|
|||||||
add_test(NAME pureboot.planner
|
add_test(NAME pureboot.planner
|
||||||
COMMAND ${Python3_EXECUTABLE} ${CMAKE_CURRENT_SOURCE_DIR}/test/test_planner.py
|
COMMAND ${Python3_EXECUTABLE} ${CMAKE_CURRENT_SOURCE_DIR}/test/test_planner.py
|
||||||
${CMAKE_CURRENT_SOURCE_DIR}/pureboot/pureboot.py)
|
${CMAKE_CURRENT_SOURCE_DIR}/pureboot/pureboot.py)
|
||||||
|
add_test(NAME pureboot.scan
|
||||||
|
COMMAND ${Python3_EXECUTABLE} ${CMAKE_CURRENT_SOURCE_DIR}/test/test_scan.py
|
||||||
|
${CMAKE_CURRENT_SOURCE_DIR}/pureboot/pureboot.py)
|
||||||
add_test(NAME pureboot.handshake
|
add_test(NAME pureboot.handshake
|
||||||
COMMAND ${Python3_EXECUTABLE} ${CMAKE_CURRENT_SOURCE_DIR}/test/test_handshake.py)
|
COMMAND ${Python3_EXECUTABLE} ${CMAKE_CURRENT_SOURCE_DIR}/test/test_handshake.py)
|
||||||
add_test(NAME pureboot.updatelink
|
add_test(NAME pureboot.updatelink
|
||||||
|
|||||||
@@ -43,6 +43,13 @@ RETRIES = 3 # rewrites of a page that reads back wrong, before the run stops
|
|||||||
UNIFIED_LOADER = 5
|
UNIFIED_LOADER = 5
|
||||||
SP_FLASH, SP_EEPROM, SP_RAM, SP_FUSE, SP_SPM = 0, 1, 2, 3, 4
|
SP_FLASH, SP_EEPROM, SP_RAM, SP_FUSE, SP_SPM = 0, 1, 2, 3, 4
|
||||||
|
|
||||||
|
# A v5+ autobaud loader keeps its measured bit period at ram_start, encoded
|
||||||
|
# as delay-loop counts: (bit cycles − UNIT_DISCOUNT) / UNIT_LOOP_CYCLES,
|
||||||
|
# floored — the spin granule and per-bit overhead of libavr's software UART.
|
||||||
|
# --info undoes the encoding to report the true clock, which therefore sits
|
||||||
|
# within one granule below it.
|
||||||
|
UNIT_LOOP_CYCLES, UNIT_DISCOUNT = 4, 8
|
||||||
|
|
||||||
# A selector's high nibble is the flash bank — the address bits above the 16-bit
|
# A selector's high nibble is the flash bank — the address bits above the 16-bit
|
||||||
# wire address — so a transfer names a byte address within one 64 KiB bank and
|
# wire address — so a transfer names a byte address within one 64 KiB bank and
|
||||||
# no command has to speak word addresses. No single transfer may cross a bank
|
# no command has to speak word addresses. No single transfer may cross a bank
|
||||||
@@ -68,31 +75,36 @@ CALIBRATE = 0xC0
|
|||||||
# from its chip database at build time). Die revisions that share a signature
|
# from its chip database at build time). Die revisions that share a signature
|
||||||
# share this row, as they share the silicon.
|
# share this row, as they share the silicon.
|
||||||
CHIP_GEOMETRY = {
|
CHIP_GEOMETRY = {
|
||||||
# signature : (flash, page, eeprom, patch_vector)
|
# signature : (flash, page, eeprom, patch_vector, ram_start)
|
||||||
(0x1E, 0x90, 0x07): (1024, 32, 64, True), # ATtiny13/13A
|
# ram_start is where SRAM begins in data space: the classic megas and the
|
||||||
(0x1E, 0x91, 0x08): (2048, 32, 128, True), # ATtiny25
|
# tinies keep it right after the plain I/O registers (0x60), the x8/x4
|
||||||
(0x1E, 0x92, 0x06): (4096, 64, 256, True), # ATtiny45
|
# generations past their extended I/O file (0x100). An autobaud loader's
|
||||||
(0x1E, 0x93, 0x0B): (8192, 64, 512, True), # ATtiny85
|
# measured bit period lives at exactly ram_start (its only RAM object;
|
||||||
(0x1E, 0x92, 0x05): (4096, 64, 256, True), # ATmega48/48A
|
# the loader's own build pins the layout), which is what --info reads.
|
||||||
(0x1E, 0x92, 0x0A): (4096, 64, 256, True), # ATmega48P/48PA
|
(0x1E, 0x90, 0x07): (1024, 32, 64, True, 0x60), # ATtiny13/13A
|
||||||
(0x1E, 0x93, 0x07): (8192, 64, 512, False), # ATmega8/8A
|
(0x1E, 0x91, 0x08): (2048, 32, 128, True, 0x60), # ATtiny25
|
||||||
(0x1E, 0x93, 0x0A): (8192, 64, 512, False), # ATmega88/88A
|
(0x1E, 0x92, 0x06): (4096, 64, 256, True, 0x60), # ATtiny45
|
||||||
(0x1E, 0x93, 0x0F): (8192, 64, 512, False), # ATmega88P/88PA
|
(0x1E, 0x93, 0x0B): (8192, 64, 512, True, 0x60), # ATtiny85
|
||||||
(0x1E, 0x94, 0x03): (16384, 128, 512, False), # ATmega16/16A
|
(0x1E, 0x92, 0x05): (4096, 64, 256, True, 0x100), # ATmega48/48A
|
||||||
(0x1E, 0x94, 0x06): (16384, 128, 512, False), # ATmega168/168A
|
(0x1E, 0x92, 0x0A): (4096, 64, 256, True, 0x100), # ATmega48P/48PA
|
||||||
(0x1E, 0x94, 0x0B): (16384, 128, 512, False), # ATmega168P/168PA
|
(0x1E, 0x93, 0x07): (8192, 64, 512, False, 0x60), # ATmega8/8A
|
||||||
(0x1E, 0x94, 0x0A): (16384, 128, 512, False), # ATmega164P/164PA
|
(0x1E, 0x93, 0x0A): (8192, 64, 512, False, 0x100), # ATmega88/88A
|
||||||
(0x1E, 0x94, 0x0F): (16384, 128, 512, False), # ATmega164A
|
(0x1E, 0x93, 0x0F): (8192, 64, 512, False, 0x100), # ATmega88P/88PA
|
||||||
(0x1E, 0x95, 0x02): (32768, 128, 1024, False), # ATmega32/32A
|
(0x1E, 0x94, 0x03): (16384, 128, 512, False, 0x60), # ATmega16/16A
|
||||||
(0x1E, 0x95, 0x0F): (32768, 128, 1024, False), # ATmega328P
|
(0x1E, 0x94, 0x06): (16384, 128, 512, False, 0x100), # ATmega168/168A
|
||||||
(0x1E, 0x95, 0x14): (32768, 128, 1024, False), # ATmega328
|
(0x1E, 0x94, 0x0B): (16384, 128, 512, False, 0x100), # ATmega168P/168PA
|
||||||
(0x1E, 0x95, 0x08): (32768, 128, 1024, False), # ATmega324P
|
(0x1E, 0x94, 0x0A): (16384, 128, 512, False, 0x100), # ATmega164P/164PA
|
||||||
(0x1E, 0x95, 0x11): (32768, 128, 1024, False), # ATmega324PA
|
(0x1E, 0x94, 0x0F): (16384, 128, 512, False, 0x100), # ATmega164A
|
||||||
(0x1E, 0x95, 0x15): (32768, 128, 1024, False), # ATmega324A
|
(0x1E, 0x95, 0x02): (32768, 128, 1024, False, 0x60), # ATmega32/32A
|
||||||
(0x1E, 0x96, 0x09): (65536, 256, 2048, False), # ATmega644/644A
|
(0x1E, 0x95, 0x0F): (32768, 128, 1024, False, 0x100), # ATmega328P
|
||||||
(0x1E, 0x96, 0x0A): (65536, 256, 2048, False), # ATmega644P/644PA
|
(0x1E, 0x95, 0x14): (32768, 128, 1024, False, 0x100), # ATmega328
|
||||||
(0x1E, 0x97, 0x05): (131072, 256, 4096, False),# ATmega1284P
|
(0x1E, 0x95, 0x08): (32768, 128, 1024, False, 0x100), # ATmega324P
|
||||||
(0x1E, 0x97, 0x06): (131072, 256, 4096, False),# ATmega1284
|
(0x1E, 0x95, 0x11): (32768, 128, 1024, False, 0x100), # ATmega324PA
|
||||||
|
(0x1E, 0x95, 0x15): (32768, 128, 1024, False, 0x100), # ATmega324A
|
||||||
|
(0x1E, 0x96, 0x09): (65536, 256, 2048, False, 0x100), # ATmega644/644A
|
||||||
|
(0x1E, 0x96, 0x0A): (65536, 256, 2048, False, 0x100), # ATmega644P/644PA
|
||||||
|
(0x1E, 0x97, 0x05): (131072, 256, 4096, False, 0x100),# ATmega1284P
|
||||||
|
(0x1E, 0x97, 0x06): (131072, 256, 4096, False, 0x100),# ATmega1284
|
||||||
}
|
}
|
||||||
|
|
||||||
VERBOSE = False
|
VERBOSE = False
|
||||||
@@ -412,7 +424,7 @@ class Info:
|
|||||||
if geometry is None:
|
if geometry is None:
|
||||||
sig = " ".join(f"{b:02x}" for b in signature)
|
sig = " ".join(f"{b:02x}" for b in signature)
|
||||||
raise Error(f"unknown signature {sig} — this tool has no geometry for it")
|
raise Error(f"unknown signature {sig} — this tool has no geometry for it")
|
||||||
flash, page, eeprom, patch = geometry
|
flash, page, eeprom, patch, _ = geometry
|
||||||
base = flash - SLOT
|
base = flash - SLOT
|
||||||
word_flash = flash > 0x10000
|
word_flash = flash > 0x10000
|
||||||
wire_base = base // 2 if word_flash else base
|
wire_base = base // 2 if word_flash else base
|
||||||
@@ -448,6 +460,11 @@ class Info:
|
|||||||
# The hand-over target as 'J' takes it: the trampoline below the
|
# The hand-over target as 'J' takes it: the trampoline below the
|
||||||
# loader, or word 0 where BOOTRST re-vectors reset in hardware.
|
# loader, or word 0 where BOOTRST re-vectors reset in hardware.
|
||||||
self.app_entry_word = (self.base - 2) // 2 if self.patch_vector else 0
|
self.app_entry_word = (self.base - 2) // 2 if self.patch_vector else 0
|
||||||
|
# Where SRAM begins, from the signature — None only for a chip this
|
||||||
|
# tool has no geometry row for, which the wire-block path (v1–4)
|
||||||
|
# permits where from_identity refuses.
|
||||||
|
geometry = CHIP_GEOMETRY.get(tuple(self.signature))
|
||||||
|
self.ram = geometry[4] if geometry else None
|
||||||
|
|
||||||
def describe(self):
|
def describe(self):
|
||||||
sig = " ".join(f"{b:02x}" for b in self.signature)
|
sig = " ".join(f"{b:02x}" for b in self.signature)
|
||||||
@@ -1353,6 +1370,58 @@ def op_fuses(loader):
|
|||||||
return fuse_bytes
|
return fuse_bytes
|
||||||
|
|
||||||
|
|
||||||
|
def scan_ratios():
|
||||||
|
"""The probe walk, in percent of the built rate: the built rate itself
|
||||||
|
first, then ±10 % in 2 % steps nearest-first — a drifted oscillator near
|
||||||
|
its trim is the common case, and each probe costs a reset."""
|
||||||
|
return [0] + [sign * step for step in (2, 4, 6, 8, 10) for sign in (-1, 1)]
|
||||||
|
|
||||||
|
|
||||||
|
def scan_rate(baud, pct):
|
||||||
|
return round(baud * (100 + pct) / 100)
|
||||||
|
|
||||||
|
|
||||||
|
def scan_report(baud, pct, version, clock=None):
|
||||||
|
"""The findings, one per line: the found rate is the session workaround,
|
||||||
|
its ratio to the built rate is the oscillator's offset, and the fixes are
|
||||||
|
the OSCCAL bake (≈1 %/step, opposing the drift) or the autobaud build."""
|
||||||
|
rate = scan_rate(baud, pct)
|
||||||
|
lines = [f"scan: answered at {rate} Bd ({pct:+d} % of the built rate) — pureboot {version}",
|
||||||
|
f" session --baud {rate}"]
|
||||||
|
if clock:
|
||||||
|
lines.append(f" clock ~{clock * (100 + pct) // 100} Hz (built for {clock})")
|
||||||
|
if pct:
|
||||||
|
direction = "lower" if pct > 0 else "higher"
|
||||||
|
lines.append(f" fix rebuild with OSCCAL ~{abs(pct)} steps {direction} (~1 %/step), "
|
||||||
|
"or the autobaud build")
|
||||||
|
else:
|
||||||
|
lines.append(" fix none — the built rate answers; check the earlier wiring instead")
|
||||||
|
return lines
|
||||||
|
|
||||||
|
|
||||||
|
def op_scan(port_path, baud, wait, clock=None):
|
||||||
|
"""A fixed-baud loader whose oscillator drifted still answers — at the
|
||||||
|
drifted ratio, since its rate scales with its clock. One probe per
|
||||||
|
activation window, and with an application resident the window opens
|
||||||
|
exactly once per reset, so each probe announces itself and expects a
|
||||||
|
fresh reset before knocking."""
|
||||||
|
for pct in scan_ratios():
|
||||||
|
rate = scan_rate(baud, pct)
|
||||||
|
print(f"scan: {rate} Bd ({pct:+d} %) — reset the target", flush=True)
|
||||||
|
port = Port(port_path, rate)
|
||||||
|
try:
|
||||||
|
info = Loader(port).connect(wait)
|
||||||
|
except Error:
|
||||||
|
continue
|
||||||
|
finally:
|
||||||
|
port.close()
|
||||||
|
for line in scan_report(baud, pct, info.version, clock):
|
||||||
|
print(line)
|
||||||
|
return
|
||||||
|
raise Error("no answer within ±10 % of the built rate — check the wiring, or deploy the "
|
||||||
|
"autobaud build, which has no rate to miss (README.md)")
|
||||||
|
|
||||||
|
|
||||||
# -------------------------------------------------------------------- cli ---
|
# -------------------------------------------------------------------- cli ---
|
||||||
|
|
||||||
|
|
||||||
@@ -1368,6 +1437,12 @@ def main():
|
|||||||
parser.add_argument("--autobaud", action="store_true",
|
parser.add_argument("--autobaud", action="store_true",
|
||||||
help="drive an autobaud loader: send the 0xC0 calibration pulse and a single "
|
help="drive an autobaud loader: send the 0xC0 calibration pulse and a single "
|
||||||
"knock, and take geometry from the signature (no clock/baud baked in)")
|
"knock, and take geometry from the signature (no clock/baud baked in)")
|
||||||
|
parser.add_argument("--scan", action="store_true",
|
||||||
|
help="walk ±10%% around --baud for a fixed-baud loader gone silent — one "
|
||||||
|
"reset per probe, standalone (README.md: deployment)")
|
||||||
|
parser.add_argument("--clock", type=int, metavar="HZ",
|
||||||
|
help="the clock the loader was built for — lets --scan and an autobaud "
|
||||||
|
"--info state drift in absolute terms")
|
||||||
parser.add_argument("--info", action="store_true", help="print the device info block")
|
parser.add_argument("--info", action="store_true", help="print the device info block")
|
||||||
parser.add_argument("--fuses", action="store_true", help="read the fuse and lock bytes")
|
parser.add_argument("--fuses", action="store_true", help="read the fuse and lock bytes")
|
||||||
parser.add_argument("--update-loader", metavar="FILE", help="replace the loader with this pureboot binary")
|
parser.add_argument("--update-loader", metavar="FILE", help="replace the loader with this pureboot binary")
|
||||||
@@ -1414,6 +1489,12 @@ def main():
|
|||||||
except (ValueError, AssertionError):
|
except (ValueError, AssertionError):
|
||||||
parser.error("--assume-fuses takes 8 hex digits: low,lock,extended,high")
|
parser.error("--assume-fuses takes 8 hex digits: low,lock,extended,high")
|
||||||
|
|
||||||
|
if args.scan:
|
||||||
|
if args.autobaud:
|
||||||
|
parser.error("--scan probes fixed rates; an autobaud loader has none to miss")
|
||||||
|
op_scan(args.port, args.baud, args.wait, args.clock)
|
||||||
|
return
|
||||||
|
|
||||||
port = Port(args.port, args.baud)
|
port = Port(args.port, args.baud)
|
||||||
verbose(f"{args.port}: {args.baud} Bd 8N1, DTR/RTS asserted")
|
verbose(f"{args.port}: {args.baud} Bd 8N1, DTR/RTS asserted")
|
||||||
try:
|
try:
|
||||||
@@ -1423,6 +1504,16 @@ def main():
|
|||||||
print("device:")
|
print("device:")
|
||||||
for line in info.lines():
|
for line in info.lines():
|
||||||
print(f" {line}")
|
print(f" {line}")
|
||||||
|
if args.autobaud:
|
||||||
|
# The whole of the loader's RAM is the measured bit period at
|
||||||
|
# ram_start; decoded and times the rate this session drives,
|
||||||
|
# that is the true clock — the number to hold an OSCCAL bake
|
||||||
|
# or a fixed-baud build against (README.md: deployment).
|
||||||
|
unit = int.from_bytes(loader.read_ram(info.ram, 2), "little")
|
||||||
|
cycles = unit * UNIT_LOOP_CYCLES + UNIT_DISCOUNT
|
||||||
|
clock = cycles * args.baud
|
||||||
|
offset = f", {(clock / args.clock - 1) * 100:+.1f} % of {args.clock}" if args.clock else ""
|
||||||
|
print(f" measured {clock} Hz ({cycles} cycles/bit × {args.baud} Bd{offset})")
|
||||||
fuse_bytes = fuse_override
|
fuse_bytes = fuse_override
|
||||||
if args.fuses or (args.update_loader and not info.patch_vector and fuse_bytes is None):
|
if args.fuses or (args.update_loader and not info.patch_vector and fuse_bytes is None):
|
||||||
read = op_fuses(loader)
|
read = op_fuses(loader)
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ baked in.
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import os
|
import os
|
||||||
|
import re
|
||||||
import sys
|
import sys
|
||||||
import time
|
import time
|
||||||
|
|
||||||
@@ -58,11 +59,23 @@ def main():
|
|||||||
try:
|
try:
|
||||||
# The host tool, in autobaud mode, sends the 0xC0 calibration pulse
|
# The host tool, in autobaud mode, sends the 0xC0 calibration pulse
|
||||||
# and a single knock at `baud`; the loader locks to it.
|
# and a single knock at `baud`; the loader locks to it.
|
||||||
out = pbsim.run_tool(tool, device.pty, baud, "--autobaud", "--info", "--fuses",
|
out = pbsim.run_tool(tool, device.pty, baud, "--autobaud", "--info", "--clock", str(hz),
|
||||||
"--flash", app_bin, "--eeprom", ee_path, "--stay")
|
"--fuses", "--flash", app_bin, "--eeprom", ee_path, "--stay")
|
||||||
for needed in ("version", "signature", "fuses", "verify:", "stays"):
|
for needed in ("version", "signature", "fuses", "verify:", "stays"):
|
||||||
if needed not in out:
|
if needed not in out:
|
||||||
fail(f"{label}: session output lacks {needed!r}\n{out}")
|
fail(f"{label}: session output lacks {needed!r}\n{out}")
|
||||||
|
# The measured clock, decoded from the unit at ram_start. The
|
||||||
|
# runner's clock is exact, so the figure must land inside the
|
||||||
|
# encoding's own envelope: the loader floors the bit period to
|
||||||
|
# 4-cycle spin granules after an 8-cycle discount, and the edge
|
||||||
|
# poll can shave a few cycles more — one granule of slack below
|
||||||
|
# the true clock, none above (in cycles per bit, times the rate).
|
||||||
|
measured = re.search(r"measured\s+(\d+) Hz", out)
|
||||||
|
if not measured:
|
||||||
|
fail(f"{label}: --info lacks the measured clock\n{out}")
|
||||||
|
measured = int(measured.group(1))
|
||||||
|
if not hz - 19 * baud <= measured <= hz + 4 * baud:
|
||||||
|
fail(f"{label}: measured clock {measured} Hz is {measured - hz:+d} off the true {hz}")
|
||||||
# Read both memories back over the locked link and check them.
|
# Read both memories back over the locked link and check them.
|
||||||
read_flash = os.path.join(workdir, f"rf_{label}.bin")
|
read_flash = os.path.join(workdir, f"rf_{label}.bin")
|
||||||
read_eeprom = os.path.join(workdir, f"re_{label}.bin")
|
read_eeprom = os.path.join(workdir, f"re_{label}.bin")
|
||||||
|
|||||||
53
test/test_scan.py
Normal file
53
test/test_scan.py
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""--scan's walk and report logic, no simulator: the probe order, the rate
|
||||||
|
arithmetic, and the advice's direction. The rate physics itself is not
|
||||||
|
sim-testable — a pty carries bytes at any termios rate — so what the wire
|
||||||
|
would arbitrate is pinned here as logic instead.
|
||||||
|
|
||||||
|
Usage: test_scan.py <tool_py>
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
|
||||||
|
def fail(message):
|
||||||
|
print(f"FAIL: {message}")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
sys.path.insert(0, os.path.dirname(os.path.abspath(sys.argv[1])))
|
||||||
|
import pureboot as pb
|
||||||
|
|
||||||
|
walk = pb.scan_ratios()
|
||||||
|
if walk != [0, -2, 2, -4, 4, -6, 6, -8, 8, -10, 10]:
|
||||||
|
fail(f"probe walk is not built-rate-first, nearest-out: {walk}")
|
||||||
|
|
||||||
|
if pb.scan_rate(9600, 4) != 9984 or pb.scan_rate(9600, -4) != 9216:
|
||||||
|
fail("probe rate arithmetic")
|
||||||
|
if pb.scan_rate(115200, 0) != 115200:
|
||||||
|
fail("the built rate must probe unchanged")
|
||||||
|
|
||||||
|
# A loader answering fast means a fast oscillator: the trim goes down.
|
||||||
|
report = "\n".join(pb.scan_report(9600, 4, 6))
|
||||||
|
for needle in ("9984", "+4 %", "--baud 9984", "4 steps lower", "pureboot 6"):
|
||||||
|
if needle not in report:
|
||||||
|
fail(f"+4 % report lacks {needle!r}:\n{report}")
|
||||||
|
report = "\n".join(pb.scan_report(9600, -6, 6))
|
||||||
|
if "6 steps higher" not in report:
|
||||||
|
fail(f"-6 % report advises the wrong direction:\n{report}")
|
||||||
|
|
||||||
|
report = "\n".join(pb.scan_report(9600, 0, 6))
|
||||||
|
if "none" not in report or "steps" in report:
|
||||||
|
fail(f"an on-rate answer must advise no trim:\n{report}")
|
||||||
|
|
||||||
|
report = "\n".join(pb.scan_report(9600, 4, 6, clock=9600000))
|
||||||
|
if "9984000" not in report:
|
||||||
|
fail(f"the absolute clock must scale with the found ratio:\n{report}")
|
||||||
|
|
||||||
|
print("OK")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
Reference in New Issue
Block a user