From 02671b2d4a0ead71124e83bd29f029b0b093db42 Mon Sep 17 00:00:00 2001 From: Peter Lewis Date: Sat, 4 Jul 2026 09:57:27 +0100 Subject: [PATCH 1/2] Add $PMTXTS per-PPS host timestamping (opt-in) With `pps = on` in config.txt, emit one proprietary NMEA sentence per PPS edge over the existing CDC stream, carrying the sub-second phase captured at the edge plus calibration / holdover / die-temperature telemetry. Time-critical fields are snapshotted in the PPS ISR; formatting and CDC submission happen in the main loop, serialised against the NMEA passthrough. With no enumerated host the record is dropped before any formatting. mk4-time/Core/Src/main.c: capturePPS(), emitPPSTimestamp(), measure_temp(), a `pps` config key and one main-loop hook. qspi/config.txt documents the key. Co-Authored-By: Claude Opus 4.8 --- mk4-time/Core/Src/main.c | 137 +++++++++++++++++++++++++++++++++++++++ qspi/config.txt | 4 ++ 2 files changed, 141 insertions(+) diff --git a/mk4-time/Core/Src/main.c b/mk4-time/Core/Src/main.c index 2a35026..e6dd655 100644 --- a/mk4-time/Core/Src/main.c +++ b/mk4-time/Core/Src/main.c @@ -177,6 +177,25 @@ uint8_t requestMode = 255; uint8_t nmea_cdc_level=0; int debug_rtc_val = 0; +// --- PPS host timestamping ---------------------------------------------------------------- +// Optional: emit one proprietary NMEA sentence ($PMTXTS) per PPS edge over the CDC port so a +// host can measure the clock's timing stability (phase jitter, oscillator drift, holdover) — +// things the plain NMEA stream cannot convey. Enabled by config "pps = on". Capture happens in +// the PPS ISR (cheap, just snapshots); the sentence is formatted + sent from the main loop. +volatile uint8_t pps_ts_enabled = 0; +volatile _Bool pps_record_pending = 0; +int16_t die_temp_c = 0; // latest STM32 die temperature (°C), a proxy for the crystal temperature +volatile struct { + uint32_t seq; // increments every PPS edge (32-bit: no practical wrap; host detects gaps) + uint32_t systick; // SysTick->VAL at the edge, captured BEFORE the reload (down-counter) + uint16_t subms; // 0..999 modelled ms-of-second at the edge, BEFORE the counters reset + uint32_t epoch; // currentTime at the edge (Unix seconds, UTC) + int32_t calerr; // debug_rtc_val: signed LSE cycle error over CAL_PERIOD s (=> ppm on host) + uint32_t sincecal; // seconds since last successful RTC calibration (holdover age) + int16_t temp; // die temperature (°C) — for host-side ppm-vs-temperature characterisation + uint8_t flags; // bit0 data_valid, bit1 had_pps, bit2 rtc_good +} pps_cap; + #define CHECK_CONFIG_MTIME struct { @@ -1031,6 +1050,10 @@ void parseConfigString(char *key, char *value) { nmea_cdc_level = NMEA_RMC; } else nmea_cdc_level = NMEA_ALL; + } else if (strcasecmp(key, "pps") == 0) { + + pps_ts_enabled = truthy(value); // emit a $PMTXTS timing sentence on each PPS edge + } else if (key[0]=='B' && key[1]=='S' && key[3]==0) { //BS1, BS2, etc if (!key[2] || key[2]<'1' || key[2]>'0'+sizeof(brightnessCurve)/sizeof(brightnessCurve[0])) return; @@ -1247,9 +1270,25 @@ void calibrateRTC(void){ void EXTI9_5_IRQHandler(void){__HAL_GPIO_EXTI_CLEAR_IT(GPIO_PIN_7);} +// Snapshot the timing state at the instant of the PPS edge. MUST run before SysTick->VAL is +// reloaded and before millisec/centisec/decisec are zeroed, so it captures the phase error +// between the firmware's modelled second and the true GPS edge. +#define capturePPS() do { \ + pps_cap.systick = SysTick->VAL; \ + pps_cap.subms = (uint16_t)decisec*100 + (uint16_t)centisec*10 + millisec; \ + pps_cap.epoch = (uint32_t)currentTime; \ + pps_cap.calerr = debug_rtc_val; \ + pps_cap.sincecal = (uint32_t)currentTime - (uint32_t)rtc_last_calibration; \ + pps_cap.temp = die_temp_c; \ + pps_cap.flags = (data_valid?1:0) | (had_pps?2:0) | (rtc_good?4:0); \ + pps_cap.seq++; \ + pps_record_pending = 1; \ + } while(0) + // PPS rising edge void PPS(void) { + capturePPS(); SysTick->VAL = SysTick->LOAD; buffer_c[3].low=cLut[0]; @@ -1278,6 +1317,7 @@ void PPS(void) void PPS_NoUpdate(void) { + capturePPS(); SysTick->VAL = SysTick->LOAD; triggerPendSV(); @@ -1297,6 +1337,7 @@ void PPS_NoUpdate(void) void PPS_Countdown(void) { + capturePPS(); SysTick->VAL = SysTick->LOAD; buffer_c[3].low=cLut[9]; @@ -1333,6 +1374,65 @@ void PPS_Init(void){ SetPPS( &PPS ); } +// usbd_cdc_if.h isn't pulled into main.c; forward-declare the one symbol we need. +extern uint8_t CDC_Copy_Transmit(uint8_t* buf, uint16_t Len); +extern USBD_HandleTypeDef hUsbDeviceFS; + +// Format + send one $PMTXTS sentence from the values captured at the last PPS edge. +// Runs in the main loop (snprintf is fine here, never in the ISR). Clears pps_record_pending +// on a successful send and for any undeliverable record (no host, formatting failure) — a +// fresh record arrives on the next edge, so only USBD_BUSY is worth retrying. +// Sentence: $PMTXTS,,,,,,,,,*CC +// subms+(load-systick)/(load+1) = modelled sub-second position at the edge (phase error); +// ppm = calerr * 1e6 / (32768 * CAL_PERIOD) [CAL_PERIOD=63]; temp = die °C; +// flags: b0 valid, b1 pps, b2 rtc. +static uint8_t emitPPSTimestamp(void){ + // With no enumerated host (e.g. charger-only power) CDC can never accept the sentence; + // drop the record before doing any formatting work, otherwise the pending flag would + // re-run the whole format-and-fail cycle every main-loop pass until a host appears. + if (hUsbDeviceFS.dev_state != USBD_STATE_CONFIGURED) { + pps_record_pending = 0; + return USBD_FAIL; + } + + __disable_irq(); // atomic snapshot of the ISR-written capture + uint32_t snap_seq = pps_cap.seq; + uint32_t st = pps_cap.systick; + uint16_t subms = pps_cap.subms; + uint32_t epoch = pps_cap.epoch; + int32_t calerr = pps_cap.calerr; + uint32_t sincecal = pps_cap.sincecal; + int16_t temp = pps_cap.temp; + uint8_t flags = pps_cap.flags; + __enable_irq(); + + uint32_t load = SysTick->LOAD; // constant; sent so the host needn't assume core clock + + char body[96]; // everything between '$' and '*' + int n = snprintf(body, sizeof body, "PMTXTS,%lu,%lu,%u,%lu,%lu,%ld,%lu,%d,%X", + (unsigned long)snap_seq, (unsigned long)epoch, (unsigned)subms, + (unsigned long)st, (unsigned long)load, (long)calerr, + (unsigned long)sincecal, (int)temp, (unsigned)flags); + if (n < 0 || n >= (int)sizeof body) { pps_record_pending = 0; return USBD_FAIL; } + + uint8_t cks = 0; // standard NMEA XOR checksum + for (int i = 0; i < n; i++) cks ^= (uint8_t)body[i]; + + char line[NMEA_BUF_SIZE]; // must fit the CDC txbuf[NMEA_BUF_SIZE] downstream + int m = snprintf(line, sizeof line, "$%s*%02X\r\n", body, (unsigned)cks); + if (m < 0 || m >= (int)sizeof line) { pps_record_pending = 0; return USBD_FAIL; } + + // The CDC IN endpoint is shared with the ISR NMEA passthrough; serialise the (tiny) submit, + // and clear the pending flag only if no fresh PPS edge arrived since the snapshot (so a + // record captured mid-send isn't silently dropped). FAIL also clears: the record is + // undeliverable (USB de-inited under us), unlike BUSY where the host may drain the FIFO. + __disable_irq(); + uint8_t r = CDC_Copy_Transmit((uint8_t*)line, (uint16_t)m); + if (r != USBD_BUSY && pps_cap.seq == snap_seq) pps_record_pending = 0; + __enable_irq(); + return r; +} + #define timetick() \ millisec++; \ if (millisec>=10) { \ @@ -1545,6 +1645,34 @@ void measure_vbat(void){ vbat = (float)adc *0.0024102564102564104;//3*3.29/4095.0; } +// Read the STM32 internal die-temperature sensor on hadc3 (shared with VBAT) into die_temp_c. +// The die sits slightly above ambient on this low-power board, but it tracks the crystal well +// enough to characterise the oscillator's temperature dependence. +void measure_temp(void){ + ADC_ChannelConfTypeDef s = {0}; + s.Rank = ADC_REGULAR_RANK_1; + s.SamplingTime = ADC_SAMPLETIME_640CYCLES_5; // temp sensor needs a long sampling time + s.SingleDiff = ADC_SINGLE_ENDED; + s.OffsetNumber = ADC_OFFSET_NONE; + s.Offset = 0; + + s.Channel = ADC_CHANNEL_TEMPSENSOR; + HAL_ADC_ConfigChannel(&hadc3, &s); + ADC123_COMMON->CCR |= ADC_CCR_TSEN; + HAL_Delay(1); // tSTART for the temperature sensor (~120 us) + HAL_ADC_Start(&hadc3); + HAL_ADC_PollForConversion(&hadc3, 10); + uint16_t raw = HAL_ADC_GetValue(&hadc3); + ADC123_COMMON->CCR &= ~ADC_CCR_TSEN; + + // Factory-calibrated conversion (TS_CAL1/TS_CAL2 in flash). VREF taken as 3300 mV; absolute + // accuracy isn't critical — the curve is fitted against GPS-measured ppm, not trusted raw. + die_temp_c = (int16_t)__HAL_ADC_CALC_TEMPERATURE(3300, raw, ADC_RESOLUTION_12B); + + s.Channel = ADC_CHANNEL_VBAT; // restore so measure_vbat() keeps working + HAL_ADC_ConfigChannel(&hadc3, &s); +} + uint8_t f_getzcmp(FIL* fp, char * str){ unsigned int rc; char * a = str; @@ -2124,6 +2252,15 @@ int main(void) monitor_vbus(); + if (pps_ts_enabled) { + static uint32_t last_temp_read = 0; + if ((uint32_t)currentTime - last_temp_read >= 4) { // refresh die temp every ~4 s + last_temp_read = (uint32_t)currentTime; + measure_temp(); + } + if (pps_record_pending) emitPPSTimestamp(); // emit clears pending itself on success + } + if (displayMode == MODE_VBAT) measure_vbat(); diff --git a/qspi/config.txt b/qspi/config.txt index 3606e81..28eb75d 100644 --- a/qspi/config.txt +++ b/qspi/config.txt @@ -100,3 +100,7 @@ mode_satview = 0 # show coin cell voltage mode_vbat = 0 + +# output a $PMTXTS timing sentence over USB serial on each GPS PPS pulse, carrying the +# sub-millisecond phase measurement made at the edge, for host-side jitter/drift analysis. +pps = off From 199a215c796e4faa232eb1ba1840192ae298d896 Mon Sep 17 00:00:00 2001 From: Peter Lewis Date: Tue, 7 Jul 2026 23:23:37 +0100 Subject: [PATCH 2/2] $PMTXTS: USB SOF correlation for sub-millisecond host timestamping USB is host-polled, so a $PMTXTS packet's *arrival* carries several ms of jitter that swamps the clock's ~180us precision. This anchors each PPS edge to a USB Start-Of-Frame instead: enable DWT->CYCCNT (free-running 12.5ns) as a shared timebase, latch (frame, DWT) in the SOF interrupt, and append the edge's DWT + that anchor to $PMTXTS. A host that can read each USB frame's own arrival time in hardware then places the edge as hostTime(sof_frame) + (dwt_pps-dwt_sof)/f_dwt, immune to delivery lateness. dwt_pps deltas self-calibrate f_dwt (no core-clock assumption). Measured on hardware: ~6-110ms raw arrival jitter -> ~100-175us, at the device's own PPS floor. - The tail is optional and valid-gated: emitted only once a real SOF has latched, so the first edge after enumeration / with pps just toggled on / a build without USB SOF emits the plain 9-field sentence, never a stale (0,0) anchor. - The SOF latch runs only when pps=on (the 1kHz IRQ is cheap and below the priority-0 display DMA); valid resets when off so a re-enable can't reuse a stale anchor. NMEA_BUF_SIZE 90->128 for the longer sentence. Backward-compatible: a 9-field parser reads the first nine fields and ignores the tail. Co-Authored-By: Claude Opus 4.8 --- mk4-time/Core/Inc/main.h | 5 ++-- mk4-time/Core/Src/main.c | 41 ++++++++++++++++++++++++-- mk4-time/USB_DEVICE/Target/usbd_conf.c | 23 ++++++++++++++- qspi/config.txt | 4 +++ 4 files changed, 68 insertions(+), 5 deletions(-) diff --git a/mk4-time/Core/Inc/main.h b/mk4-time/Core/Inc/main.h index 539aba0..ad580a5 100644 --- a/mk4-time/Core/Inc/main.h +++ b/mk4-time/Core/Inc/main.h @@ -125,8 +125,9 @@ extern _Bool resendDate; #define DAC_BUFFER_SIZE 20 #define ADC_BUFFER_SIZE 50 -// NMEA 0183 messages have a max length of 82 characters -#define NMEA_BUF_SIZE 90 +// NMEA 0183 messages have a max length of 82 characters; the extended $PMTXTS (with the SOF- +// correlation tail: dwt_pps, sof_frame, dwt_sof) runs ~110, so this sizes the tx/rx buffers for it. +#define NMEA_BUF_SIZE 128 #define CMD_LOAD_TEXT 0x90 #define CMD_SET_FREQUENCY 0x91 diff --git a/mk4-time/Core/Src/main.c b/mk4-time/Core/Src/main.c index e6dd655..dc0abe4 100644 --- a/mk4-time/Core/Src/main.c +++ b/mk4-time/Core/Src/main.c @@ -194,8 +194,21 @@ volatile struct { uint32_t sincecal; // seconds since last successful RTC calibration (holdover age) int16_t temp; // die temperature (°C) — for host-side ppm-vs-temperature characterisation uint8_t flags; // bit0 data_valid, bit1 had_pps, bit2 rtc_good + uint32_t dwt_pps; // DWT->CYCCNT (free-running 12.5ns) latched at the edge — SOF-correlation timebase } pps_cap; +// --- SOF correlation (optional: sub-ms USB timestamping without a hardware PPS wire) ---------------- +// Latched by the USB SOF interrupt (PCD_SOFCallback, usbd_conf.c) every 1ms WHEN pps_ts_enabled: the +// 11-bit USB frame number and the DWT count at that Start-Of-Frame. Appended to $PMTXTS so a host — +// which can read each USB frame's own arrival time in hardware — can place the PPS edge on its clock +// via the frame, immune to the ~6ms host-driven read jitter. Written in the SOF ISR, read at emit +// under __disable_irq. pps_sof_valid gates the tail: 0 until the first SOF has latched a real anchor +// (so the first PPS after enumeration, or with pps just toggled on, or a build with no SOF, emits the +// plain 9-field sentence rather than a stale (0,0) anchor). +volatile uint32_t pps_sof_dwt = 0; // DWT->CYCCNT at the most recent SOF +volatile uint16_t pps_sof_frame = 0; // USB 11-bit frame number at that SOF (matches host frame mod 2048) +volatile uint8_t pps_sof_valid = 0; // 1 once a real SOF anchor has been latched; 0 = emit 9-field only + #define CHECK_CONFIG_MTIME struct { @@ -1274,6 +1287,7 @@ void EXTI9_5_IRQHandler(void){__HAL_GPIO_EXTI_CLEAR_IT(GPIO_PIN_7);} // reloaded and before millisec/centisec/decisec are zeroed, so it captures the phase error // between the firmware's modelled second and the true GPS edge. #define capturePPS() do { \ + pps_cap.dwt_pps = DWT->CYCCNT; \ pps_cap.systick = SysTick->VAL; \ pps_cap.subms = (uint16_t)decisec*100 + (uint16_t)centisec*10 + millisec; \ pps_cap.epoch = (uint32_t)currentTime; \ @@ -1382,10 +1396,15 @@ extern USBD_HandleTypeDef hUsbDeviceFS; // Runs in the main loop (snprintf is fine here, never in the ISR). Clears pps_record_pending // on a successful send and for any undeliverable record (no host, formatting failure) — a // fresh record arrives on the next edge, so only USBD_BUSY is worth retrying. -// Sentence: $PMTXTS,,,,,,,,,*CC +// Sentence: $PMTXTS,,,,,,,,,[,,,]*CC // subms+(load-systick)/(load+1) = modelled sub-second position at the edge (phase error); // ppm = calerr * 1e6 / (32768 * CAL_PERIOD) [CAL_PERIOD=63]; temp = die °C; // flags: b0 valid, b1 pps, b2 rtc. +// Optional SOF-correlation tail (present only with a live SOF anchor): dwt_pps = DWT cycle count at +// the PPS edge; sof_frame = USB 11-bit frame number of the most recent SOF; dwt_sof = DWT at that SOF. +// A host that knows each USB frame's own arrival time places the edge as +// hostTime(sof_frame) + (dwt_pps-dwt_sof)/f_dwt, immune to USB read jitter; dwt_pps deltas (~80e6/s) +// self-calibrate f_dwt, so no core-clock assumption. A 9-field parser can ignore the tail. static uint8_t emitPPSTimestamp(void){ // With no enumerated host (e.g. charger-only power) CDC can never accept the sentence; // drop the record before doing any formatting work, otherwise the pending flag would @@ -1404,16 +1423,28 @@ static uint8_t emitPPSTimestamp(void){ uint32_t sincecal = pps_cap.sincecal; int16_t temp = pps_cap.temp; uint8_t flags = pps_cap.flags; + uint32_t dwt_pps = pps_cap.dwt_pps; // DWT at the PPS edge (SOF-correlation timebase) + uint32_t sof_dwt = pps_sof_dwt; // DWT at the most recent SOF ... + uint16_t sof_fr = pps_sof_frame; // ... and that SOF's 11-bit USB frame number ... + uint8_t sof_ok = pps_sof_valid; // ... valid only once a real SOF has latched an anchor __enable_irq(); uint32_t load = SysTick->LOAD; // constant; sent so the host needn't assume core clock - char body[96]; // everything between '$' and '*' + char body[128]; // everything between '$' and '*' int n = snprintf(body, sizeof body, "PMTXTS,%lu,%lu,%u,%lu,%lu,%ld,%lu,%d,%X", (unsigned long)snap_seq, (unsigned long)epoch, (unsigned)subms, (unsigned long)st, (unsigned long)load, (long)calerr, (unsigned long)sincecal, (int)temp, (unsigned)flags); if (n < 0 || n >= (int)sizeof body) { pps_record_pending = 0; return USBD_FAIL; } + // Append the SOF-correlation tail only when a real anchor exists — never a stale (0,0). Absent tail = + // the plain sentence a 9-field parser expects (also the output when SOF isn't running). + if (sof_ok) { + int t = snprintf(body + n, sizeof body - n, ",%lu,%u,%lu", + (unsigned long)dwt_pps, (unsigned)sof_fr, (unsigned long)sof_dwt); + if (t < 0 || t >= (int)(sizeof body - n)) { pps_record_pending = 0; return USBD_FAIL; } + n += t; + } uint8_t cks = 0; // standard NMEA XOR checksum for (int i = 0; i < n; i++) cks ^= (uint8_t)body[i]; @@ -2017,6 +2048,12 @@ int main(void) /* USER CODE BEGIN SysInit */ + // Enable the DWT cycle counter (free-running at the 80 MHz core clock, 12.5 ns/tick, wraps ~53.7 s): + // the monotonic timebase the PPS edge and each USB SOF are both latched against for host correlation. + CoreDebug->DEMCR |= CoreDebug_DEMCR_TRCENA_Msk; + DWT->CYCCNT = 0; + DWT->CTRL |= DWT_CTRL_CYCCNTENA_Msk; + buffer_c[0].high=0b11001110; buffer_c[1].high=0b11001101; buffer_c[2].high=0b11001011; diff --git a/mk4-time/USB_DEVICE/Target/usbd_conf.c b/mk4-time/USB_DEVICE/Target/usbd_conf.c index ab89fbb..3077194 100644 --- a/mk4-time/USB_DEVICE/Target/usbd_conf.c +++ b/mk4-time/USB_DEVICE/Target/usbd_conf.c @@ -204,6 +204,27 @@ static void PCD_SOFCallback(PCD_HandleTypeDef *hpcd) void HAL_PCD_SOFCallback(PCD_HandleTypeDef *hpcd) #endif /* USE_HAL_PCD_REGISTER_CALLBACKS */ { + /* USER CODE BEGIN SOF_latch */ + /* SOF-correlation ($PMTXTS): latch the DWT cycle count and the USB 11-bit frame number at the instant + of this Start-Of-Frame, as early as possible for minimal latency. main.c emits them so the host can + anchor the PPS edge to a USB frame (whose host-side arrival time it can read in hardware), sidestep- + ping the ~6 ms host-driven read jitter. Only latch when the timestamp feature is on (the SOF IRQ + still fires — cheap, below the priority-0 display DMA — but does no work otherwise); pps_sof_valid + tracks whether the anchor is real so a stale (0,0) is never emitted (and resets to 0 whenever the + feature is off, so a later re-enable can't reuse it). */ + extern volatile uint8_t pps_ts_enabled, pps_sof_valid; + extern volatile uint32_t pps_sof_dwt; + extern volatile uint16_t pps_sof_frame; + if (pps_ts_enabled) { + pps_sof_dwt = DWT->CYCCNT; + /* OTG device register block = global base + USB_OTG_DEVICE_BASE; frame number = DSTS[13:8]. */ + USB_OTG_DeviceTypeDef *dev = (USB_OTG_DeviceTypeDef *)((uint32_t)hpcd->Instance + USB_OTG_DEVICE_BASE); + pps_sof_frame = (uint16_t)((dev->DSTS >> 8) & 0x7FFU); + pps_sof_valid = 1; + } else { + pps_sof_valid = 0; + } + /* USER CODE END SOF_latch */ USBD_LL_SOF((USBD_HandleTypeDef*)hpcd->pData); } @@ -361,7 +382,7 @@ USBD_StatusTypeDef USBD_LL_Init(USBD_HandleTypeDef *pdev) hpcd_USB_OTG_FS.Init.dev_endpoints = 6; hpcd_USB_OTG_FS.Init.speed = PCD_SPEED_FULL; hpcd_USB_OTG_FS.Init.phy_itface = PCD_PHY_EMBEDDED; - hpcd_USB_OTG_FS.Init.Sof_enable = DISABLE; + hpcd_USB_OTG_FS.Init.Sof_enable = ENABLE; /* $PMTXTS SOF-correlation: 1 kHz SOF IRQ latches (frame, DWT) when pps=on */ hpcd_USB_OTG_FS.Init.low_power_enable = DISABLE; hpcd_USB_OTG_FS.Init.lpm_enable = DISABLE; hpcd_USB_OTG_FS.Init.battery_charging_enable = DISABLE; diff --git a/qspi/config.txt b/qspi/config.txt index 28eb75d..6083b8a 100644 --- a/qspi/config.txt +++ b/qspi/config.txt @@ -103,4 +103,8 @@ mode_vbat = 0 # output a $PMTXTS timing sentence over USB serial on each GPS PPS pulse, carrying the # sub-millisecond phase measurement made at the edge, for host-side jitter/drift analysis. +# When on, the sentence also carries a USB SOF-correlation tail (dwt_pps, sof_frame, dwt_sof) +# that lets a host place the edge on its clock to ~microseconds despite USB delivery jitter; +# a host that only needs the phase can ignore the extra fields. Enabling this turns on the USB +# Start-Of-Frame interrupt (a cheap 1 kHz latch, below the display in priority). pps = off