From 177d25abef9670c1d38f51c029eafe55de63f51f Mon Sep 17 00:00:00 2001 From: Joseph <162703152+josephnef@users.noreply.github.com> Date: Fri, 10 Jul 2026 15:24:44 +0300 Subject: [PATCH 1/2] Hardware time distribution: beacon TX-egress TSF primitive + TsfSync, and the measured PTP reality MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The 802.11 MAC inserts its live TSF into every beacon at the instant of transmission — a genuine sub-µs hardware TX-egress timestamp (bench-measured 0.289 µs RMS against an independent receiver, vs 117-473 µs for any host-side "read the clock after send" shortcut). Paired with the per-frame hardware RX timestamp (RxAtrib.tsfl) it is the primitive for one-way hardware time distribution — the only sub-µs option on Wi-Fi silicon that exposes no host-reported TX-egress timestamp. Library: - Packet::TxEgressTsf(): parse the sender's hardware egress TSF from a received beacon / probe response (works against any AP), returned alongside tsfl. - TsfSync (src/TsfSync.h): running least-squares over {remote egress, local arrival} pairs -> skew (ppm), offset, and clock translation; feed it a Packet and beacons are picked automatically. Header-only, chip-neutral. - AdapterCaps hw_rx_timestamp / hw_beacon_txtsf flags (surfaced in adapter.caps), hardware-verified per generation (J2/J3 stamp beacons; J1 has no StartBeacon). - rxdemo emits tx_tsf on beacons; docs/logging.md documents it. - Headless selftest tsf_sync_math (ctest): skew/offset recovery, clock translation, the 32-bit TSF wrap, and the beacon pick. Docs: - docs/timing-accuracy.md: the measured NTP-vs-TSF comparison, why hardware PTP can't run on Realtek Wi-Fi (no exposed HW timestamps; TX egress firmware- blocked), the ath9k exception, the real-PTP-hardware (Intel I226) cross-check, and the TX-egress-shortcut benchmark, with source links. - tests/txegress_{witness.cpp,analyze.py,bench.sh}: the single-witness bench behind the 0.289 us / 117 us / 473 us figures. ctest 16/16 green; OFF-config builds unaffected. Co-Authored-By: Claude Opus 4.8 --- CMakeLists.txt | 10 +++ docs/logging.md | 2 +- docs/timing-accuracy.md | 138 ++++++++++++++++++++++++++++--- examples/common/caps_event.h | 2 + examples/rx/main.cpp | 13 ++- src/AdapterCaps.h | 10 +++ src/RxPacket.h | 30 +++++++ src/TsfSync.h | 120 +++++++++++++++++++++++++++ src/jaguar1/RtlJaguarDevice.cpp | 2 + src/jaguar2/RtlJaguar2Device.cpp | 2 + src/jaguar3/RtlJaguar3Device.cpp | 2 + tests/tsf_sync_selftest.cpp | 95 +++++++++++++++++++++ tests/txegress_analyze.py | 71 ++++++++++++++++ tests/txegress_bench.sh | 47 +++++++++++ tests/txegress_witness.cpp | 101 ++++++++++++++++++++++ 15 files changed, 627 insertions(+), 18 deletions(-) create mode 100644 src/TsfSync.h create mode 100644 tests/tsf_sync_selftest.cpp create mode 100644 tests/txegress_analyze.py create mode 100755 tests/txegress_bench.sh create mode 100644 tests/txegress_witness.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index 069ff24b..e902ca9b 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -564,6 +564,16 @@ target_link_libraries(AdapterCapsSelftest PRIVATE devourer) add_test(NAME adapter_caps_derive COMMAND AdapterCapsSelftest) +# Headless guard for the TsfSync one-way time-distribution fit (src/TsfSync.h): +# skew/offset recovery from beacon {egress, arrival} TSF pairs, clock +# translation, the local 32-bit TSF wrap, and the Packet::TxEgressTsf pick. +add_executable(TsfSyncSelftest + tests/tsf_sync_selftest.cpp +) +target_link_libraries(TsfSyncSelftest PRIVATE devourer) + +add_test(NAME tsf_sync_math COMMAND TsfSyncSelftest) + # Headless guard for the Jaguar2 per-packet TX-power quantizer # (jaguar2::txpkt_pwr_step_for_db) — dB delta -> the descriptor TXPWR_OFSET LUT # step. Gated on Jaguar2 being built (the header is behind DEVOURER_HAVE_JAGUAR2). diff --git a/docs/logging.md b/docs/logging.md index 22137e58..002a98f6 100644 --- a/docs/logging.md +++ b/docs/logging.md @@ -86,7 +86,7 @@ Emitters: L = library, RX/TX/... = demo. Optional fields in [brackets]; | ev | emitter | fields | |---|---|---| | `rx.pkt` | RX | n, len (first 10 + every 100th frame) | -| `rx.frame` | RX (`DEVOURER_STREAM_OUT`), duplex | rate, len, crc, icv, rssi[2], evm[2], snr[2], seq, tsfl, bw, stbc, ldpc, sgi, body hex | +| `rx.frame` | RX (`DEVOURER_STREAM_OUT`), duplex | rate, len, crc, icv, rssi[2], evm[2], snr[2], seq, tsfl, bw, stbc, ldpc, sgi, body hex; `tx_tsf` (sender's hardware egress TSF) on beacons/probe-responses only | | `rx.body` | RX (`DEVOURER_DUMP_BODY`) | rate, rssi[2], evm[2], snr[2], crc, len, body hex | | `rx.corrupt` | RX (`DEVOURER_RX_DUMP_ALL`) | len, crc, icv, rate, bw, stbc, ldpc, sgi, rssi[2], evm[2], snr[2] | | `rx.txhit` | RX, TX | hits, total_rx, len — canonical-SA (57:42:75:05:d6:00) matcher | diff --git a/docs/timing-accuracy.md b/docs/timing-accuracy.md index 6e5c6093..a295a9a3 100644 --- a/docs/timing-accuracy.md +++ b/docs/timing-accuracy.md @@ -15,14 +15,16 @@ methodology, and the honest caveats. Reproduce the transport half with the `chronyd -Q` on the client measures the offset to a `local`-clock chrony server on the other node over the Wi-Fi path, without disciplining the clock. Software timestamps (see PTP below). -- **PTP**: attempted with `ptp4l -S` on the same link. +- **PTP**: `ptp4l` on the same link. The hardware-timestamped mode PTP needs + for its precision is unsupported (`ethtool -T` → `PTP Hardware Clock: none`), + so it cannot start; software mode (`-S`) is no better than the NTP row. ## Results | Method | Precision over Wi-Fi (measured) | Timestamp source | |---|---|---| | **NTP** (`chrony`, software TS) | **~0.76 ms** RMS (quiet link); tens of ms under power-save/contention | app/kernel software | -| **PTP** (`ptp4l`) | **does not run** — see below | (would need HW/driver TS) | +| **PTP** (`ptp4l`) on the Wi-Fi radio | **no hardware mode** — stock driver exposes no HW timestamps; TX egress unavailable even patched (see below) | 802.11 MAC (RX only) | | **devourer TSF downlink** | **~0.25 µs** RMS residual | 802.11 MAC hardware (TSF) | So the TSF downlink is roughly **~3000×** tighter than NTP over the same Wi-Fi, @@ -42,18 +44,121 @@ NTP/PTP. devourer reads that per-frame RX TSF (`RxPacket.tsfl`) and the 64-bit timer directly — which is exactly what a PTP hardware-timestamping NIC would provide. -### Why PTP can't run here at all +### Why stock PTP can't run on the Wi-Fi radio + +Out of the box, `ptp4l` reports *"interface does not support requested +timestamping mode"* and `ethtool -T` shows `PTP Hardware Clock: none`: neither the +vendor drivers under `reference/` nor the in-tree `rtw88` expose the hardware +timestamp through the kernel timestamping API, even though both read the TSF +internally (the RX-descriptor `TSFL`). The hardware timestamp exists; the +OS-facing drivers just don't surface it — which is what devourer does +(`RxPacket.tsfl`). + +But surfacing the RX timestamp is only half of PTP. Separating the two halves is +what makes the picture honest: + +- **RX** is a genuine per-frame MAC timestamp, latched below the contention + layer, and it *can* be surfaced through the standard kernel API — attaching it + to each received frame lets an ordinary `SO_TIMESTAMPING` socket read it, and + it advances in step with real airtime. This holds on both transports (the USB + full-MAC path and the PCIe/`rtw88` `mac80211` path). +- **TX egress** — the instant the frame actually leaves the antenna — is the + wall. The Realtek firmware reports no on-chip TX-egress timestamp for data + frames, so a two-way PTP endpoint could only guess the send instant from a + host- or bus-completion time, reintroducing exactly the jitter PTP exists to + remove. This is a firmware limitation, not a driver one. + +So PTP fails on the Wi-Fi radio for the TX half, not the clock. For contrast, a +dedicated PTP NIC on the *same board* — the Intel I226 Ethernet on the Radxa X4 — +runs `ptp4l` as a hardware grandmaster with **both** TX and RX hardware +timestamps and its own PHC. It has the TX-egress timestamp the Wi-Fi silicon +does not. + +### The TX-egress wall — can it be cracked? + +The one-way broadcast below is the ceiling because no Realtek part reports *when +a frame actually leaves the antenna*. This was checked across the vendor and +in-tree drivers, the firmware TX feedback, the 802.11 ranging path, and the +literature; the wall is in the silicon, not the driver: + +- **The only TX feedback Realtek firmware emits is an ACK-status report, not a + timestamp.** The rtw88/rtw89 C2H "TX report" carries transmit status, a retry + count and a sequence number — no TSF or egress-time field — and it feeds the + standard mac80211 ACK-status path, not a timestamp path. +- **The mac80211/cfg80211 hardware-timestamping framework exists** (kernel + ~6.5+), but it is scoped to Timing-Measurement / Fine-Timing-Measurement + action frames, and only Intel and NXP drivers implement it; the Realtek + drivers register none of the required operations. +- **Fine Timing Measurement is not a backdoor.** Where Realtek exposes ranging + at all it returns only a firmware-computed distance — the underlying TX/RX + timestamps are never surfaced. +- **The one commodity Wi-Fi chip that _can_ do two-way hardware PTP is the + Atheros AR9388 (ath9k)**, which latches the TSF on both TX and RX and reports + the TX-status TSF to the host — the exact capability Realtek withholds. That + is the lone published exception, not a general result. +- **Beacons are the one exception — and they prove the rule.** The Realtek MAC + *does* hardware-stamp a single class of frame at egress: it inserts the live + TSF into a beacon's timestamp field at the instant of transmission. Measured + against an independent receiver's hardware RX timestamp, that egress stamp + lands at **0.289 µs** RMS. But it is embedded in the outgoing frame for + receivers to read, not reported back to the host — so it powers a one-way + broadcast, not a two-way endpoint. + +So two-way `ptp4l` over Wi-Fi needs ath9k-class silicon. On Realtek the honest +ceiling is a receive-only reference-broadcast scheme — timestamp a periodic AP +broadcast at reception via the TSF, no transmit timestamp required — which is +what devourer's TSF downlink already is, and what the published state of the art +for commodity Wi-Fi (OpenWiFiSync / RBIS) independently settled on, at +one-to-two-digit-µs accuracy. + +The tempting host-side shortcut — reading the TSF register right after handing a +frame to the radio, as a stand-in for egress — does not close the gap. Measured +against a witness receiver's hardware RX timestamp, that read lands **~117 µs** +RMS from true air on a quiet 5 GHz channel and **~473 µs** on a busy 2.4 GHz +one — 400–1600× the 0.289 µs the beacon's hardware egress stamp achieves, +because it inherits exactly the USB-queue and CSMA-backoff jitter it was meant to +avoid. So the broadcast path (hardware-stamped beacon, read at reception) is not +just convenient — it is the only sub-µs option on this silicon. + +If a future firmware or driver ever surfaces a TX-egress timestamp, this wall +falls and the RX timestamping already in place becomes a full two-way endpoint. + +Evidence behind the current state (so it can be re-checked if the situation +changes): + +- rtw88/rtw89 C2H TX-report contents — transmit status / retry count only, no + timestamp: + , + +- rtw89 registers no `set_hw_timestamp` / TSF timestamping ops: + +- mac80211 hardware-timestamping API, scoped to TM/FTM frames (Intel/NXP only): + , + , + +- Realtek ranging HAL returns only a firmware-computed distance (no t1–t4/TSF): + +- Atheros ath9k hardware-PTP-over-Wi-Fi precedent (USENIX ATC '21): + +- OpenWiFiSync / RBIS one-way broadcast sync (IEEE ETFA 2024): + + +### Wi-Fi TSF vs real PTP hardware — measured on one board + +Because that I226 is a true IEEE-1588 clock, it doubles as a local reference: +cross-comparing the Wi-Fi MAC TSF against it — both exposed as PTP hardware +clocks and disciplined slave-to-master on the same machine, no network in +between — puts a number on how good the TSF really is. + +| Quantity | Measured | Reading | +|---|---|---| +| TSF vs I226 offset | **~290 ns** RMS (mean ≈ 0) | sub-µs tracking of genuine PTP hardware | +| TSF crystal offset | **~+42 ppm** | the Wi-Fi reference oscillator's raw frequency error | -`ptp4l` reports *"interface does not support requested timestamping mode"*, and -`ethtool -T` shows `PTP Hardware Clock: none` with no timestamp modes. This is not -specific to the in-tree `rtw88`: grepping the **vendor** drivers under -`reference/` for `SOF_TIMESTAMPING` / `SIOCSHWTSTAMP` / `skb_hwtstamps` / -`get_ts_info` / `ptp_clock` finds **nothing** — neither the vendor nor the -mainline driver exposes the hardware timestamp through the kernel timestamping -API, even though both read the TSF internally (`CONFIG_HW_P0_TSF_SYNC`, the -RX-descriptor `TSFL`). The hardware timestamp exists; the OS-facing drivers just -don't surface it. devourer does — which is what makes TSF-based sub-µs sync -possible where PTP returns an error. +The ~290 ns residual is essentially the TSF's own 1 µs-resolution quantization +floor (a uniform ±0.5 µs rounds to ~289 ns RMS): once the +42 ppm crystal offset +is taken out, the Wi-Fi MAC clock is as steady as the reference — the servo has +nothing left to chase but the last microsecond of resolution. ## Transport latency — USB vs PCIe (the actuator floor) @@ -72,6 +177,13 @@ actuator collapses from ~0.5–1.2 ms of jitter over USB toward ~µs over PCIe the transport, not the method, is the closed-loop-TA floor, and a PCIe path would tighten the ~1.3 ms uplink residual toward tens of µs. +The same floor is what makes a *clock read* practical over PCIe. A PTP hardware +clock backed by the 8821CE TSF, read over that MMIO window, measures a gettime +read window of **~3.7 µs** (p99 ~5.6 µs) — cheap and steady enough to discipline +against, where the equivalent read over USB would cost tens of µs of jittery +vendor-control transfer. That is why a genuine PHC is tractable on the PCIe part +and not on the USB ones. + ## Honest caveats - devourer's ~0.25 µs is the **precision/stability** of a *one-way* broadcast fit diff --git a/examples/common/caps_event.h b/examples/common/caps_event.h index 6e080c1f..cc2518d4 100644 --- a/examples/common/caps_event.h +++ b/examples/common/caps_event.h @@ -67,6 +67,8 @@ inline void emit_adapter_caps(EventSink &sink, IRtlDevice *dev) { .f("narrowband", c.narrowband_ok ? 1 : 0) .f("fastretune", c.fastretune_ok ? 1 : 0) .f("per_chain_rssi", c.per_chain_rssi ? 1 : 0) + .f("hw_rx_tsf", c.hw_rx_timestamp ? 1 : 0) + .f("hw_beacon_txtsf", c.hw_beacon_txtsf ? 1 : 0) .f("xtal_cap_max", c.xtal_cap_max) .f("xtal_cap_default", c.xtal_cap_default); } diff --git a/examples/rx/main.cpp b/examples/rx/main.cpp index 8a5d0424..47feece3 100644 --- a/examples/rx/main.cpp +++ b/examples/rx/main.cpp @@ -484,8 +484,8 @@ static void packetProcessor(const Packet &packet) { const int snr[2] = {packet.RxAtrib.snr[0], packet.RxAtrib.snr[1]}; const size_t body_len = packet.Data.size() > 24 ? packet.Data.size() - 24 : 0; - devourer::Ev(*g_ev, "rx.frame") - .f("rate", packet.RxAtrib.data_rate) + auto ev = devourer::Ev(*g_ev, "rx.frame"); + ev.f("rate", packet.RxAtrib.data_rate) .f("len", packet.Data.size()) .f("crc", packet.RxAtrib.crc_err ? 1 : 0) .f("icv", packet.RxAtrib.icv_err ? 1 : 0) @@ -497,8 +497,13 @@ static void packetProcessor(const Packet &packet) { .f("bw", packet.RxAtrib.bw) .f("stbc", packet.RxAtrib.stbc) .f("ldpc", packet.RxAtrib.ldpc) - .f("sgi", packet.RxAtrib.sgi) - .hex("body", packet.Data.data() + 24, body_len); + .f("sgi", packet.RxAtrib.sgi); + /* tx_tsf: the sender's hardware TX-egress TSF (beacons / probe responses + * only). Pair with tsfl — the local hardware RX timestamp above — for + * one-way hardware time sync with no host-clock jitter on either end. */ + if (auto tx = packet.TxEgressTsf()) + ev.f("tx_tsf", (unsigned long long)*tx); + ev.hex("body", packet.Data.data() + 24, body_len); } if (dump_body && hits <= 5) { /* Tier-2 health diagnostics alongside the byte mirror: rate (0x04 = diff --git a/src/AdapterCaps.h b/src/AdapterCaps.h index 0d3bd2b2..3dbe655a 100644 --- a/src/AdapterCaps.h +++ b/src/AdapterCaps.h @@ -119,6 +119,16 @@ struct AdapterCaps { uint8_t xtal_cap_default = 0; /* efuse/default crystal-cap code */ bool fastretune_ok = false; /* lean FastRetune override exists */ bool per_chain_rssi = false; /* frame parser fills per-chain rssi (>=2ch) */ + /* Hardware timing. hw_rx_timestamp: every received frame is stamped with the + * MAC's microsecond TSF at receive (RxPacket.RxAtrib.tsfl) — true on all + * generations. hw_beacon_txtsf: this adapter, as a transmitter, inserts its + * live hardware TSF into the beacons it airs at the instant of transmission + * (a genuine sub-µs TX-egress timestamp a receiver reads via + * Packet::TxEgressTsf) — requires the hardware beacon function (StartBeacon), + * so Jaguar2/Jaguar3 only. Together they are the primitives for one-way + * hardware time distribution (see TsfSync). */ + bool hw_rx_timestamp = false; + bool hw_beacon_txtsf = false; }; inline void set_standard_freq_ranges(AdapterCaps &c) { diff --git a/src/RxPacket.h b/src/RxPacket.h index 7c044339..fa954d7d 100644 --- a/src/RxPacket.h +++ b/src/RxPacket.h @@ -2,6 +2,7 @@ #define DEVOURER_RX_PACKET_H #include +#include #include /* Parsed RX packet types, shared across chip generations. The per-generation @@ -71,6 +72,35 @@ struct Packet { rx_pkt_attrib RxAtrib; std::span Data; + + /* The transmitter's hardware TX-egress TSF, when the frame carries one. + * + * For the frames the 802.11 MAC regenerates itself — beacons and probe + * responses — the sender's MAC overwrites the 8-byte timestamp field (MPDU + * bytes 24-31) with its live TSF at the instant the frame is clocked onto + * the air. That is a genuine hardware TX-egress timestamp, latched below the + * CSMA/queueing layer (bench-measured sub-µs against an independent + * receiver, vs ~100+ µs for any host-side "read the clock after send" + * approximation). Paired with RxAtrib.tsfl (this receiver's hardware RX + * timestamp), each such frame yields the {remote egress, local arrival} + * pair that one-way hardware time distribution is built on — with no + * host-clock jitter on either end, and against any beaconing AP, not just a + * devourer transmitter. + * + * Returns nullopt for frames the MAC does not stamp (data frames and mgmt + * frames other than beacon / probe-response). */ + std::optional TxEgressTsf() const + { + if (Data.size() < 32) + return std::nullopt; + const uint8_t fc0 = Data[0]; /* proto=0, type=mgmt, subtype in [7:4] */ + if (fc0 != 0x80 /* beacon */ && fc0 != 0x50 /* probe response */) + return std::nullopt; + uint64_t tsf = 0; + for (int i = 0; i < 8; ++i) + tsf |= static_cast(Data[24 + i]) << (8 * i); + return tsf; + } }; #endif /* DEVOURER_RX_PACKET_H */ diff --git a/src/TsfSync.h b/src/TsfSync.h new file mode 100644 index 00000000..8fa85a9f --- /dev/null +++ b/src/TsfSync.h @@ -0,0 +1,120 @@ +#ifndef DEVOURER_TSF_SYNC_H +#define DEVOURER_TSF_SYNC_H + +#include +#include + +#include "RxPacket.h" + +/* One-way hardware time distribution from beacon TSFs. + * + * A transmitter — any AP, or a devourer StartBeacon master — inserts its live + * hardware TSF into every beacon at the instant of transmission: a genuine + * sub-µs TX-egress timestamp (Packet::TxEgressTsf). A receiver latches its own + * hardware TSF when the beacon arrives (RxPacket::RxAtrib::tsfl). Feeding both + * into TsfSync fits the receiver's clock to the transmitter's: + * + * local_tsf ≈ skew · remote_tsf + offset + * + * Both endpoints are hardware-latched below the CSMA/queueing layer, so the fit + * converges to the two crystals' relationship, not the medium's jitter — the + * basis of the reference-broadcast (RBIS) one-way sync that is the only sub-µs + * option on Wi-Fi silicon lacking a host-reported TX-egress timestamp. Once + * Ready(), LocalForRemote()/RemoteForLocal() translate a timestamp between the + * two clocks (e.g. to schedule a local action at a transmitter-defined instant). + * + * Least-squares in offset coordinates for numerical conditioning; the local + * 32-bit tsfl is reconstructed to 64 bits across its ~71.6 min wrap. Header-only, + * no chip dependency. Not internally synchronised — feed it from one thread + * (typically the RX callback). */ +namespace devourer { + +class TsfSync { + public: + /* Feed one beacon: the transmitter's 64-bit hardware egress TSF (µs, from + * Packet::TxEgressTsf) and this receiver's 32-bit RX TSF low word (µs, + * RxAtrib.tsfl). */ + void Add(uint64_t remote_tsf, uint32_t local_tsfl) { + const int64_t local = ReconLocal(local_tsfl); + const int64_t remote = static_cast(remote_tsf); + if (!_init) { + _x0 = static_cast(remote); + _y0 = static_cast(local); + _init = true; + } + const double xi = static_cast(remote) - _x0; + const double yi = static_cast(local) - _y0; + ++_n; + _sx += xi; + _sy += yi; + _sxx += xi * xi; + _sxy += xi * yi; + _last_remote = remote; + _last_local = local; + } + + /* Convenience: feed a received Packet. Returns true iff it carried an egress + * TSF (a beacon / probe response) and was ingested — so a caller can simply + * `sync.Add(packet)` on every frame and let non-beacons fall through. */ + bool Add(const Packet &p) { + const auto tx = p.TxEgressTsf(); + if (!tx) return false; + Add(*tx, p.RxAtrib.tsfl); + return true; + } + + /* Enough samples for a stable line. */ + bool Ready() const { return _n >= 16; } + long long Count() const { return _n; } + + /* Local clock rate relative to the transmitter, in ppm (positive = the local + * crystal runs fast). ~0 until a few samples in. */ + double SkewPpm() const { return (Slope() - 1.0) * 1e6; } + + /* Fitted slope of local vs remote (≈ 1 + skew). */ + double Slope() const { + const double den = static_cast(_n) * _sxx - _sx * _sx; + if (den == 0.0) return 1.0; + return (static_cast(_n) * _sxy - _sx * _sy) / den; + } + + /* Translate a transmitter (remote) TSF to the equivalent local TSF (µs). */ + int64_t LocalForRemote(uint64_t remote_tsf) const { + const double a = Slope(); + const double b = Intercept(a); + return llround(_y0 + a * (static_cast(static_cast(remote_tsf)) - _x0) + b); + } + + /* Translate a local TSF to the equivalent transmitter (remote) TSF (µs). */ + int64_t RemoteForLocal(uint64_t local_tsf) const { + const double a = Slope(); + if (a == 0.0) return static_cast(local_tsf); + const double b = Intercept(a); + return llround(_x0 + ((static_cast(static_cast(local_tsf)) - _y0) - b) / a); + } + + /* Instantaneous (local − remote) offset at the most recent beacon, µs — the + * raw crystal offset before the fit, useful as a coarse health readout. */ + int64_t OffsetUs() const { return _last_local - _last_remote; } + + private: + double Intercept(double a) const { + return (_sy - a * _sx) / (_n ? static_cast(_n) : 1.0); + } + int64_t ReconLocal(uint32_t lo) { + if (_linit && lo < _plo) _hi += (1LL << 32); + _plo = lo; + _linit = true; + return _hi + lo; + } + + bool _init = false, _linit = false; + double _x0 = 0, _y0 = 0, _sx = 0, _sy = 0, _sxx = 0, _sxy = 0; + long long _n = 0; + int64_t _hi = 0, _last_remote = 0, _last_local = 0; + uint32_t _plo = 0; +}; + +} // namespace devourer + +#endif /* DEVOURER_TSF_SYNC_H */ diff --git a/src/jaguar1/RtlJaguarDevice.cpp b/src/jaguar1/RtlJaguarDevice.cpp index 6958c9d9..6fc6b66b 100644 --- a/src/jaguar1/RtlJaguarDevice.cpp +++ b/src/jaguar1/RtlJaguarDevice.cpp @@ -1000,6 +1000,8 @@ devourer::AdapterCaps RtlJaguarDevice::GetAdapterCaps() { c.narrowband_ok = true; } c.fastretune_ok = true; /* phy_SwChnl8812_fast (8812/8821) + full-path fallback */ + c.hw_rx_timestamp = true; /* FrameParser fills RxAtrib.tsfl on every frame */ + c.hw_beacon_txtsf = false; /* no reserved-page beacon download on Jaguar1 */ c.xtal_cap_max = 0x3f; /* 6-bit AFE crystal-cap trim (0x2C) */ c.xtal_cap_default = _eepromManager->crystal_cap & 0x3f; devourer::set_standard_freq_ranges(c); diff --git a/src/jaguar2/RtlJaguar2Device.cpp b/src/jaguar2/RtlJaguar2Device.cpp index bcc07d69..74eacaeb 100644 --- a/src/jaguar2/RtlJaguar2Device.cpp +++ b/src/jaguar2/RtlJaguar2Device.cpp @@ -887,6 +887,8 @@ devourer::AdapterCaps RtlJaguar2Device::GetAdapterCaps() { * RF18 re-latch edge after the re-clock (see the set_channel_bw narrowband * branch). */ c.narrowband_ok = true; + c.hw_rx_timestamp = true; /* FrameParserJaguar2 fills RxAtrib.tsfl */ + c.hw_beacon_txtsf = true; /* StartBeacon: MAC inserts the egress TSF into beacons */ c.xtal_cap_max = 0x3f; /* 6-bit AFE crystal-cap trim (0x24/0x28) */ c.xtal_cap_default = _hal.efuse_logical_byte(0xB9) == 0xFF ? 0x20 diff --git a/src/jaguar3/RtlJaguar3Device.cpp b/src/jaguar3/RtlJaguar3Device.cpp index ce72ba61..d29f6a91 100644 --- a/src/jaguar3/RtlJaguar3Device.cpp +++ b/src/jaguar3/RtlJaguar3Device.cpp @@ -996,6 +996,8 @@ devourer::AdapterCaps RtlJaguar3Device::GetAdapterCaps() { c.bw_mask = devourer::bw_mask_for_generation(c.generation); c.fastretune_ok = true; c.narrowband_ok = true; /* 5/10 MHz baseband re-clock — Jaguar3 only */ + c.hw_rx_timestamp = true; /* FrameParserJaguar3 fills RxAtrib.tsfl */ + c.hw_beacon_txtsf = true; /* StartBeacon: MAC inserts the egress TSF into beacons */ c.xtal_cap_max = 0x7f; /* 7-bit AFE crystal-cap trim (0x1040) */ c.xtal_cap_default = 0x20; devourer::set_standard_freq_ranges(c); diff --git a/tests/tsf_sync_selftest.cpp b/tests/tsf_sync_selftest.cpp new file mode 100644 index 00000000..666f9ade --- /dev/null +++ b/tests/tsf_sync_selftest.cpp @@ -0,0 +1,95 @@ +// tsf_sync_selftest.cpp — headless unit tests for the TsfSync one-way time +// distribution fit (src/TsfSync.h): does it recover a known crystal skew and +// offset from {remote egress TSF, local RX TSF} beacon pairs, translate a +// timestamp between the two clocks, survive the local 32-bit TSF wrap, and pick +// beacons out of a frame stream via the Packet convenience? No hardware / no +// libusb — runs in ctest, unlike the txegress_witness harness (real adapters). +#include +#include +#include +#include + +#include "RxPacket.h" +#include "TsfSync.h" + +static int g_fail = 0; +#define CHECK(cond, msg) \ + do { \ + if (!(cond)) { std::printf("FAIL: %s\n", (msg)); ++g_fail; } \ + } while (0) + +int main() { + // --- 1. recover a known skew + offset, and translate between the clocks ---- + // Beacon pairs: local = (1 + skew)·remote + offset, at a 20 ms cadence, with + // the inherent 1 µs TSF quantization (llround). This mirrors the bench setup + // (a devourer beacon master's egress TSF vs a receiver's rx tsfl). + { + devourer::TsfSync s; + const double skew_ppm = 30.0; + const double a = 1.0 + skew_ppm / 1e6; + const int64_t offset = 14799307; // ~ the measured crystal offset + const int64_t remote0 = 19410073; + const int N = 1000; + for (int i = 0; i < N; ++i) { + const int64_t remote = remote0 + (int64_t)i * 20000; // 20 ms beacons + const int64_t local = (int64_t)llround(a * (double)remote) + offset; + s.Add((uint64_t)remote, (uint32_t)(local & 0xffffffffu)); // local < 2^32 + } + CHECK(s.Ready(), "ready after N beacons"); + CHECK(std::fabs(s.SkewPpm() - skew_ppm) < 1.0, + "skew recovered within 1 ppm of the true 30 ppm"); + + const int64_t r = remote0 + 500 * 20000; + const int64_t expect_local = (int64_t)llround(a * (double)r) + offset; + CHECK(std::llabs(s.LocalForRemote((uint64_t)r) - expect_local) <= 2, + "LocalForRemote matches the true local within quantization"); + // Round-trip: remote -> local -> remote. + CHECK(std::llabs(s.RemoteForLocal((uint64_t)expect_local) - r) <= 2, + "RemoteForLocal inverts LocalForRemote"); + } + + // --- 2. local 32-bit TSF wrap is reconstructed -------------------------- + // Feed pairs whose local low word crosses 2^32 (a=1, skew 0). If the wrap were + // mishandled the fitted slope would collapse (the low word drops ~4.29e9 at + // the wrap); a recovered ~0 ppm proves the reconstruction. + { + devourer::TsfSync s; + const int64_t base = (int64_t)0xFFFFF000; // start just below the wrap + const int64_t step = 20000; // 20 ms + for (int i = 0; i < 200; ++i) { + const int64_t remote = 1000 + (int64_t)i * step; + const int64_t local = base + (int64_t)i * step; // crosses 2^32 + s.Add((uint64_t)remote, (uint32_t)(local & 0xffffffffu)); + } + CHECK(s.Ready(), "ready across the wrap"); + CHECK(std::fabs(s.SkewPpm()) < 1.0, + "skew ~0 ppm across the 32-bit wrap (reconstruction correct)"); + } + + // --- 3. Packet convenience: ingest beacons, skip everything else -------- + { + devourer::TsfSync s; + uint8_t beacon[40] = {0}; + beacon[0] = 0x80; // beacon frame control + const uint64_t egress = 0x0000000123456789ULL; + for (int i = 0; i < 8; ++i) beacon[24 + i] = (uint8_t)(egress >> (8 * i)); + Packet bp; + bp.Data = std::span(beacon, sizeof(beacon)); + bp.RxAtrib.tsfl = 0x11223344u; + CHECK(s.Add(bp), "beacon accepted by Packet convenience"); + CHECK(s.Count() == 1, "beacon counted"); + + uint8_t data[40] = {0}; + data[0] = 0x08; // data frame — no egress TSF + Packet dp; + dp.Data = std::span(data, sizeof(data)); + dp.RxAtrib.tsfl = 0x55667788u; + CHECK(!s.Add(dp), "data frame rejected by Packet convenience"); + CHECK(s.Count() == 1, "data frame not counted"); + } + + std::printf(g_fail ? "tsf_sync_selftest: %d FAILURE(S)\n" + : "tsf_sync_selftest: all passed\n", + g_fail); + return g_fail ? 1 : 0; +} diff --git a/tests/txegress_analyze.py b/tests/txegress_analyze.py new file mode 100644 index 00000000..75cbbfce --- /dev/null +++ b/tests/txegress_analyze.py @@ -0,0 +1,71 @@ +#!/usr/bin/env python3 +# txegress_analyze.py — quantify the "read TSF after send to approximate egress" +# micro-option from txegress_witness JSONL. +# +# Each record has a TX stamp (tx_sw = the transmitter's software ReadTsf, OR +# tx_hw = the MAC-inserted beacon egress TSF) and rx_tsfl (this receiver's +# hardware RX timestamp = a low-jitter proxy for true on-air time). Both TX +# stamps are in the transmitter's clock; rx_tsfl is in the receiver's clock, so +# a straight line rx_tsfl = a*tx + b absorbs the constant offset, the two +# crystals' rate difference, and the fixed propagation delay. What's LEFT — the +# residual RMS — is the per-frame jitter of that TX stamp versus true air. +# +# software-stamp residual = jitter the read-TSF-after-send micro-option injects +# hardware-stamp residual = the MAC egress-timestamp floor (what a real TX HW +# timestamp would look like) +# +# Usage: python3 txegress_analyze.py +import json, sys, math + +def unwrap32(xs): + out, hi, prev = [], 0, None + for x in xs: + if prev is not None and x < prev - (1 << 31): + hi += 1 << 32 + out.append(x + hi); prev = x + return out + +def fit_resid(tx, rx): + # least-squares rx = a*tx + b; return residual RMS (ns) and slope (ppm dev) + n = len(tx) + mx = sum(tx) / n; my = sum(rx) / n + sxx = sum((t - mx) ** 2 for t in tx) + sxy = sum((t - mx) * (r - my) for t, r in zip(tx, rx)) + a = sxy / sxx; b = my - a * mx + res = [r - (a * t + b) for t, r in zip(tx, rx)] # residual in rx-clock µs + rms_us = math.sqrt(sum(r * r for r in res) / n) + # rx_tsfl and tx are both µs units; residual is µs -> ns + p2p = max(res) - min(res) + return rms_us * 1000.0, p2p * 1000.0, a + +def main(): + recs = [] + for line in open(sys.argv[1]): + line = line.strip() + if not line.startswith("{"): continue + try: recs.append(json.loads(line)) + except Exception: continue + recs = [r for r in recs if r.get("ev") == "txeg"] + # drop the first few (warm-up) and any with a zero stamp + recs = recs[3:] + if len(recs) < 20: + print("too few frames (%d)" % len(recs)); return + + rx = unwrap32([r["rx_tsfl"] for r in recs]) + for label, key in (("software (ReadTsf near send)", "tx_sw"), + ("hardware (MAC beacon egress)", "tx_hw")): + sub = [(r[key], x) for r, x in zip(recs, rx) if r.get(key, 0) > 0] + if len(sub) < 20: + print("%-32s : n=%d (skipped)" % (label, len(sub))); continue + tx = [s[0] for s in sub]; rxs = [s[1] for s in sub] + rms, p2p, slope = fit_resid(tx, rxs) + ppm = (slope - 1.0) * 1e6 + print("%-32s : n=%4d residual RMS = %8.3f µs (p2p %8.3f µs) xtal %+7.1f ppm" + % (label, len(sub), rms / 1000.0, p2p / 1000.0, ppm)) + + print("\nThe software residual is the jitter 'read TSF after send' injects vs true") + print("air; the hardware residual is the MAC egress-timestamp floor. The gap is") + print("why the shortcut is not a substitute for a real TX-egress timestamp.") + +if __name__ == "__main__": + main() diff --git a/tests/txegress_bench.sh b/tests/txegress_bench.sh new file mode 100755 index 00000000..63e519d3 --- /dev/null +++ b/tests/txegress_bench.sh @@ -0,0 +1,47 @@ +#!/usr/bin/env bash +# txegress_bench.sh — benchmark the "read TSF after send to approximate egress" +# micro-option (research angle 6) that the literature never measured. +# +# A transmitter (8812AU) airs frames carrying its own TX timestamp; a witness +# receiver (8822BU) reads that stamp AND its hardware rx_tsfl from the same +# packet, so one box measures the TX stamp's jitter versus true on-air time. +# Two transmit modes: +# SOFTWARE : timesync master embeds ReadTsf() near the send call (the micro- +# option) into each frame's TD tag. +# HARDWARE : timesync master airs a hardware beacon; the MAC inserts the true +# egress TSF into the beacon body — the real TX-egress reference. +# +# sudo tests/txegress_bench.sh +set -uo pipefail +cd "$(dirname "$0")/.." +TX_VID=${TX_VID:-0x0bda}; TX_PID=${TX_PID:-0x8812} # 8812AU transmitter +RX_VID=${RX_VID:-0x2357}; RX_PID=${RX_PID:-0x012d} # 8822BU witness (TP-Link) +CH=${CH:-6}; N=${N:-400} +SW=/tmp/txeg_sw.jsonl; HW=/tmp/txeg_hw.jsonl + +cleanup(){ sudo pkill -9 -x timesync txegress_witness 2>/dev/null; } +trap cleanup EXIT +cleanup; sleep 1 + +run_mode() { + local label="$1" out="$2"; shift 2 + echo "== $label ==" + sudo env DEVOURER_VID=$TX_VID DEVOURER_PID=$TX_PID DEVOURER_CHANNEL=$CH \ + DEVOURER_TSYNC_ROLE=master DEVOURER_TSYNC_INTERVAL_MS=10 DEVOURER_TSYNC_SECS=45 \ + "$@" ./build/timesync >/tmp/txeg_tx.log 2>&1 & + sleep 4 + sudo env DEVOURER_VID=$RX_VID DEVOURER_PID=$RX_PID DEVOURER_CHANNEL=$CH \ + ./build/txegress_witness "$N" 2>/dev/null > "$out" + sudo pkill -9 -x timesync 2>/dev/null; sleep 2 + echo " frames: $(grep -c '"ev":"txeg"' "$out")" +} + +run_mode "SOFTWARE (ReadTsf near send)" "$SW" +run_mode "HARDWARE (MAC beacon egress)" "$HW" DEVOURER_TSYNC_HWBEACON=1 + +echo +echo "==== SOFTWARE stamp vs true air ====" +python3 tests/txegress_analyze.py "$SW" +echo +echo "==== HARDWARE stamp vs true air ====" +python3 tests/txegress_analyze.py "$HW" diff --git a/tests/txegress_witness.cpp b/tests/txegress_witness.cpp new file mode 100644 index 00000000..bd715b55 --- /dev/null +++ b/tests/txegress_witness.cpp @@ -0,0 +1,101 @@ +// txegress_witness.cpp — benchmark the "read TSF after send to approximate the +// TX-egress time" micro-option against a real hardware egress reference, using a +// single witness receiver. +// +// Every frame carries its own TX timestamp, so one receiver captures both the TX +// stamp and a true-air proxy from the SAME packet — no cross-node clock join: +// * software stamp — the TD tag's tx_tsf (the transmitter's ReadTsf() near +// the send call), the micro-option under test. +// * hardware stamp — a beacon's bytes 24-31 (the MAC-inserted TSF, latched +// when the frame is actually clocked out — a genuine hw egress timestamp). +// * rx_tsfl — this receiver's hardware RX timestamp (RxAtrib.tsfl), +// latched in the MAC at receive: a low-jitter proxy for true on-air time. +// +// For each decoded frame from the transmitter we emit {seq, tx_sw, tx_hw, +// rx_tsfl, host_ns}. txegress_analyze.py then least-squares fits rx_tsfl against +// each TX stamp; the residual RMS is that stamp's jitter versus true air. The +// software stamp's residual minus the hardware stamp's residual (in quadrature) +// is exactly the error the micro-option injects — the number the literature +// lacks. +// +// Build: g++ -std=c++20 -O2 -Isrc -Iexamples/common -Iexamples/tdma \ +// tests/txegress_witness.cpp examples/common/env_config.cpp build/libdevourer.a \ +// $(pkg-config --cflags --libs libusb-1.0) -lpthread -o build/txegress_witness +// Run: sudo DEVOURER_PID=0x012d DEVOURER_CHANNEL=6 build/txegress_witness [nframes] +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +#include "RxPacket.h" +#include "SelectedChannel.h" +#include "UsbOpen.h" +#include "WiFiDriver.h" +#include "env_config.h" +#include "logger.h" +#include "tdma.h" + +static std::atomic g_n{0}; +static int g_want = 400; + +static int64_t host_ns() { + return std::chrono::duration_cast( + std::chrono::steady_clock::now().time_since_epoch()) + .count(); +} + +int main(int argc, char** argv) { + if (argc > 1) g_want = atoi(argv[1]); + auto logger = std::make_shared(); + libusb_context* ctx = nullptr; + libusb_init(&ctx); + libusb_set_option(ctx, LIBUSB_OPTION_LOG_LEVEL, LIBUSB_LOG_LEVEL_WARNING); + + uint16_t vid = 0x0bda, pid = 0x012d; + if (const char* p = std::getenv("DEVOURER_PID")) pid = (uint16_t)strtoul(p, 0, 0); + if (const char* v = std::getenv("DEVOURER_VID")) vid = (uint16_t)strtoul(v, 0, 0); + uint8_t ch = 6; + if (const char* c = std::getenv("DEVOURER_CHANNEL")) ch = atoi(c); + + auto* h = libusb_open_device_with_vid_pid(ctx, vid, pid); + if (!h) { fprintf(stderr, "open fail %04x:%04x\n", vid, pid); return 1; } + std::shared_ptr lk; + if (devourer::claim_interface_then_reset(h, 0, logger, true, lk) != 0) return 1; + WiFiDriver wifi(logger); + auto dev = wifi.CreateRtlDevice(h, ctx, lk, devourer_config_from_env()); + if (!dev) return 1; + + auto cb = [](const Packet& p) { + if (p.RxAtrib.crc_err || p.Data.size() < 32) return; + // Parse the TD tag (software tx_tsf); accept only frames that carry it. + tdma::Parsed td = tdma::parse_frame(p.Data.data(), p.Data.size()); + uint64_t tx_sw = 0, tx_hw = 0; + static const uint8_t kSa[6] = {0x57, 0x42, 0x75, 0x05, 0xd6, 0x00}; + if (td.ok) { + tx_sw = td.tx_tsf; // software-mode: the transmitter's ReadTsf() + } else if (p.Data[0] == 0x80 && std::memcmp(p.Data.data() + 10, kSa, 6) == 0) { + // hwbeacon-mode standard beacon (our SA): bytes 24-31 = MAC-inserted egress TSF + for (int i = 0; i < 8; ++i) tx_hw |= (uint64_t)p.Data[24 + i] << (8 * i); + } else { + return; // not one of ours + } + ++g_n; + printf("{\"ev\":\"txeg\",\"seq\":%u,\"tx_sw\":%llu,\"tx_hw\":%llu," + "\"rx_tsfl\":%u,\"host_ns\":%lld}\n", + td.seq, (unsigned long long)tx_sw, (unsigned long long)tx_hw, + p.RxAtrib.tsfl, (long long)host_ns()); + fflush(stdout); + }; + + std::thread rx([&] { dev->Init(cb, SelectedChannel{ch, 0, CHANNEL_WIDTH_20}); }); + while (g_n.load() < g_want) std::this_thread::sleep_for(std::chrono::milliseconds(50)); + fprintf(stderr, "txegress_witness: %d frames\n", g_n.load()); + _exit(0); +} From a51fcbaf79074485c784682f3ab46879d1d9bc90 Mon Sep 17 00:00:00 2001 From: Joseph <162703152+josephnef@users.noreply.github.com> Date: Fri, 10 Jul 2026 15:30:32 +0300 Subject: [PATCH 2/2] CI: build TsfSyncSelftest in the mingw job The mingw job builds an explicit target list because ctest runs every registered test and a missing binary is a "Not Run" failure. Add the new TsfSyncSelftest. Co-Authored-By: Claude Opus 4.8 --- .github/workflows/cmake-multi-platform.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/cmake-multi-platform.yml b/.github/workflows/cmake-multi-platform.yml index 0852f4dc..6000ba5d 100644 --- a/.github/workflows/cmake-multi-platform.yml +++ b/.github/workflows/cmake-multi-platform.yml @@ -149,7 +149,7 @@ jobs: ToneMaskSelftest BfReportDecodeSelftest SweepSpecSelftest TxPowerQuantSelftest LinkHealthSelftest TxCapsSelftest TxPktPwrSelftest RxQualitySelftest AdapterHealthSelftest LogEventSelftest - AdapterCapsSelftest TdmaTsfSelftest TimesyncSelftest + AdapterCapsSelftest TdmaTsfSelftest TimesyncSelftest TsfSyncSelftest - name: Test working-directory: build