Skip to content

Self-learning holdover temperature compensation#9

Draft
peterlewis wants to merge 16 commits into
mitxela:masterfrom
peterlewis:tempcomp
Draft

Self-learning holdover temperature compensation#9
peterlewis wants to merge 16 commits into
mitxela:masterfrom
peterlewis:tempcomp

Conversation

@peterlewis

@peterlewis peterlewis commented Jul 5, 2026

Copy link
Copy Markdown

Adds opt-in temperature compensation: the clock learns ppm-vs-die-temperature for both oscillators while GPS-locked (tc_learn), steers the timebase in holdover (tc_apply), trims the battery RTC (tc_rtc), round-trips the model via tc_dump/$PMTXTC/tc_seed, optionally retains it in flash (tc_persist), and drives the significance_fade uncertainty display and the MODE_TEMPCOMP read-out.
Stack: based on sidereal-modes (#8).
Try it now: fwt.bin · fwd.bin — the union of this PR stack, byte-identical to the merged result.
Docs: docs/firmware-additions.md, shipped in the final (menu) PR.


Note

You can try this PR without hardware: the PCC web app runs the real clock4 firmware compiled to WebAssembly, and its clock is built from a rollup of the whole PR series together, so this PR's behaviour is live there alongside the others. The app's DEVICE → UPDATES panel shows the exact commit it was built from. Fair warning: the app around the firmware is beta and still has bugs — the firmware behaviour is the real thing, the app chrome less so.

Draft — self-learning holdover temperature compensation, built on the $PMTXTS telemetry from #5.

Stacking note (please read first)

The series is a linear stack; this PR is based on #8 (sidereal-modes), so the GitHub diff vs master shows the lower PRs' commits too — it consumes the per-PPS pps_cap snapshot and die temperature that #5 adds. The temp-comp changes are the branch's own commits — the compensator itself, then significance-fade + the evolving warm-start model, plus their fixes (the branch's diff against #8's head). Note #5's own text says "no compensation shipped" — this is the deliberate follow-up. (Flash retention of the learned model — tc_persist — arrives with the settings store in the final menu PR.)

What it does

While GPS-locked the clock learns each oscillator's frequency error vs die temperature (binned, weighted least-squares). During a GPS-loss holdover it then steers the SysTick display timebase from the HSE model to cancel temperature-driven drift (tc_apply), and optionally trims RTC->CALR from the LSE model so the battery RTC hands over better time across a power loss (tc_rtc). All enables default off — stock behaviour unchanged.

Design notes

  • No maths in the ISR. All fitting/evaluation is main-loop (the calibrateRTC float precedent); the tick ISR does only an integer Bresenham SysTick->LOAD stretch — a fractional-ppm rate steer, no phase steps.
  • Origin-free HSE model. Each PPS re-zeroes the ms cascade, so a capture is a self-contained one-second accumulation with an arbitrary standing offset. The model is learned in ticks with that origin; steering applies only model(T_now) − model(T_at_loss), so the origin cancels and the per-edge phase snap makes re-lock instantly neutral. (It corrects temperature-driven drift; the absolute TCXO offset at loss is its spec — re-lock anchoring is a possible follow-up.)
  • Ticks-per-ppm derived at runtime from the captured SysTick period (no core-clock constant baked in).
  • tc_dump = on over serial prints the learned coefficients as paste-ready config.txt lines (+ a $PMTXTC sentence); pasting them back freezes the model. Serial-only, guarded against config-file reload.
  • MODE_TEMPCOMP diagnostic display pages die temp / model offset / sample count.
  • CALR trims happen only in holdover (never contends with calibrateRTC); the WPR sequence runs IRQ-off against PendSV's write_rtc.

The follow-on commit (both opt-in, off = stock)

digit_bright[] carries the per-digit significance levels; with seg_balance active the sub-second digits dim gradually through the duty mirror rather than snapping to dashes.

  • tc_seed — pasted tc_dump coefficients currently freeze the model. With tc_seed = on they load as a starting point instead: the clock is temperature-compensated immediately after power-up and keeps learning; once its own data supports a fit of equal or higher order, the learned fit replaces the seed. tc_dump now also prints tc_seed_lo/tc_seed_hi (the temperature range the model covers) so its output round-trips. Over serial, send the coefficients first and tc_seed = on last — that line triggers the load, which runs from the main loop (tc_housekeeping), so the USB ISR never writes the model.

Status

Hardware-validated on an Mk IV — the LSE model learned 18.68 ppm vs 18.9 ppm measured directly (~1%). Builds clean; the base commit passed an adversarial review (ISR-safety, integer math, sign conventions, re-lock neutrality, off-means-identical), and the follow-on commit was reviewed twice more and behaviour-tested in a WASM build of this exact branch (seeding, seed-to-learned handover, dash thresholds). Draft pending broader holdover-steering validation.

🤖 Generated with Claude Code

peterlewis added a commit to peterlewis/clock4 that referenced this pull request Jul 6, 2026
…ignificance-fade + tc_seed)

Conflicted files (EOL-divergent whole-file conflicts) resolved to the validated
rollup integration; see the following integration-delta commit for the exact
residue of the union against the PR branches.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
peterlewis added a commit to peterlewis/clock4 that referenced this pull request Jul 6, 2026
The exact residue between the pure union of the five draft PRs (mitxela#5 $PMTXTS,
mitxela#6 astro pack, mitxela#7 hardening, mitxela#8 sidereal/solar, mitxela#9 tempcomp + significance-fade
+ tc_seed — mitxela#5 and mitxela#6 arrive as ancestors of mitxela#9 and mitxela#8) and the rollup tree that
has been emulator-verified and hardware-run:

- astro.c/astro.h/test_astro.c: rollup astro refinements not yet folded back into
  the refreshed PR mitxela#6/mitxela#8 heads (candidates for a future PR update)
- version.c: rollup version string
- qspi/output/*.bin: rollup's built firmware images
- .settings IDE noise

After this commit the megapack tree is BYTE-IDENTICAL to the previously validated
rollup (feaf970) while the merge ancestry proves containment of all five PR heads.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
peterlewis added a commit to peterlewis/clock4 that referenced this pull request Jul 7, 2026
…xela#9)

The source said 0.0.5 (misidentifying a rollup build as stock) while the
committed image embedded 0.0.6 from an earlier build lineage. Source and
freshly built, CRC-gated image now both say 0.0.6. No cuckoo — that work is
parked on its own branch and versions later.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@peterlewis

Copy link
Copy Markdown
Author

Rebased onto the updated #5 (pcc-firmware-proposals), which now adds USB SOF correlation to $PMTXTS. The rebase re-parented both tempcomp commits onto the new #5 tip; the only conflict was the expected adjacency (the SOF globals and the tc_* block both sit just after pps_cap), resolved keep-both. Builds clean; no functional change to the temp-comp feature itself.

peterlewis added a commit to peterlewis/clock4 that referenced this pull request Jul 10, 2026
…ts dim out, not dash

PR mitxela#9 computes each sub-second digit's live significance (digit_bright, 0..FADE_MAX,
from the 3-sigma holdover uncertainty) but the display driver could only show-or-dash.
PR mitxela#10 built the missing per-digit dimmer. This connects them:

- segbal_poll scales the ds/cs/ms columns' mirror duty by their digit_bright, and the
  seconds column's decimal point by digit_bright[3] (it dies with the 0.1 s digit).
  While any digit is mid-fade the mirror runs even with seg_balance off (identity duty),
  so the fade works standalone; with balance on the two multiply.
- setPrecision's significance_fade branch now retires digits to BLANK instead of a dash
  glyph — the digit fades to black, as the feature's own comment always intended.
- Stock behaviour (significance_fade off) unchanged: the Tolerance dash ladder remains.

Verified in the WASM build of this source: partial fade tracked exactly (holdover age
11 s -> ms digit at 14/16 significance -> lit in exactly 14 of 16 cycles, DP tracking),
zero-significance digits blank in the masters, relock restores full digits with auto
balance resumed. ARM Release 0 errors.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
peterlewis and others added 6 commits July 12, 2026 11:45
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 <noreply@anthropic.com>
…ver USB

Experimental attack on the ~6ms host-driven USB read jitter that swamps the
clock's true ~180us precision — without a hardware PPS wire. The OTG_FS core
rules out mitxela's last-microsecond FIFO injection, so instead we let the host
anchor each PPS to a USB Start-Of-Frame, whose own arrival time the host can
read in hardware (macOS IOKit GetBusFrameNumberWithTime).

- Enable DWT->CYCCNT (free-running 12.5ns core-cycle counter) as the timebase
  both the PPS edge and each SOF are latched against. Unaffected by tempcomp
  SysTick steering (counts raw core clock), so it's a stable monotonic ruler.
- Enable the USB SOF interrupt; PCD_SOFCallback latches (DWT, 11-bit frame from
  DSTS[13:8]) every 1ms, as its first act for minimal latency.
- capturePPS() latches DWT at the edge; emitPPSTimestamp() appends the tail
  ,dwt_pps,sof_frame,dwt_sof to $PMTXTS (read under __disable_irq, no torn read).
- Bump NMEA_BUF_SIZE 90->128 for the longer sentence.

Host places the edge at hostTime(sof_frame) + (dwt_pps-dwt_sof)/f_dwt, immune to
delivery lateness; dwt_pps deltas self-calibrate f_dwt (no core-clock assumption).
Backward-compatible: lenient $PMTXTS parsers read the first 9 fields. Bench-only;
+1kHz SOF ISR (~0.05% CPU) is the cost to watch. Builds clean (0 errors).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Adversarial audit of the SOF-correlation change surfaced two real issues:
- Stale first anchor: pps_sof_dwt/frame init to 0, so the first PPS emitted
  after enumeration (or with pps just toggled on, or on the emulator which has
  no USB SOF) carried a (0,0) anchor → a ~53s/−729ms host error on that record.
  Now a pps_sof_valid flag gates the tail: emit the plain 9-field sentence until
  a real SOF has latched. Backward-compatible; also makes the emulator correct.
- SOF work ran unconditionally. Gate the latch on pps_ts_enabled (the SOF IRQ
  still fires — cheap, below the priority-0 display DMA — but does nothing when
  the feature is off); clear valid when off so a re-enable can't reuse a stale
  anchor. config.txt documents that pps=on enables the SOF tail.
Builds clean (0 errors). Refuted findings (torn read, 16-bit atomicity, buffer
truncation, display preemption, scope) left as-is.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Five GPS-derived astronomy read-outs, each opt-in via a MODE_* config key and
shown on the 10-char date row while the live clock keeps running on the time
row (SATVIEW-style, COUNT_NORMAL):

  MODE_SUN       sunrise / sunset / solar noon (local), auto-paged
  MODE_SUN_AZEL  sun azimuth & elevation, now
  MODE_MOON      moon phase index (0-7) + illuminated %
  MODE_GRID      Maidenhead grid locator
  MODE_LATLON    latitude / longitude, auto-paged

The astronomy maths is a self-contained Core/Src/astro.c (+ astro.h): low-
precision NOAA/Meeus sun alt-az and event times, moon phase, equation of time,
and Maidenhead. It has no firmware dependencies, so it unit-tests natively
(test/test_astro.c, 45 reference vectors) and was validated to ~1e-5 before
touching the firmware.

The L4 has only a single-precision FPU, so the double maths would be slow soft-
float. It is therefore computed in the main loop (astro_update(), gated on the
active mode exactly as measure_vbat() is for MODE_VBAT) and swapped into a cache
under a brief __disable_irq() mask; the sendDate() cases that run inside the
SysTick ISR only format the cached scalars -- no trig in the interrupt.

Modes are disabled by default; with no GPS fix they use fake_latitude/longitude
if set, else show "----". config.txt documents the keys and the moon-phase
index legend.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
"LAT 51.48" vs "LAT-51.48" let the minus swallow the separator. Add a sign
slot after the label space — "LAT  51.48" / "LAT -51.48" — so the digits
start in the same column either way, matching the RISE/SET layout. A 3-digit
longitude can't fit both the separator and the slot in 10 characters, so the
separator alone is dropped there ("LON-179.99").

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The paged modes (SUN, LATLON) previously flipped sub-screen every 2 s hard-coded.
Make the dwell a config key (page_ms, ms; unset -> 5500, floored at 250 so a tiny
value can't flood the date-board UART), driven off uwTick, and repaint the moment a
page flips rather than waiting for the 1 Hz date-row refresh (guarded by decisec!=9
to avoid racing the SysTick sendDate).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
peterlewis added a commit to peterlewis/clock4 that referenced this pull request Jul 12, 2026
…xela#9)

The source said 0.0.5 (misidentifying a rollup build as stock) while the
committed image embedded 0.0.6 from an earlier build lineage. Source and
freshly built, CRC-gated image now both say 0.0.6. No cuckoo — that work is
parked on its own branch and versions later.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
peterlewis and others added 9 commits July 12, 2026 14:14
- loadRules: reject untrusted rowLength/numEntries from TZRULES.BIN (RAM overflow)
- MODE_TEXT: clamp snprintf untruncated-return index (uart2_tx_buffer OOB)
- mk4-date latchDisplay: bound dp_pos per orientation (buffer_a/b OOB)
- readConfigFile: don't treat a zero FAT timestamp as a cache hit (first-boot hang)
- config-over-USB: defer postConfigCleanup out of the USB ISR (sendDate/nextMode races)
- firmwareCheckOnEject: fatfs_busy guard for FATFS reentrancy on USB eject

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Two further pre-existing bugs from the same read-through.

- mk4-time NMEA-over-CDC: CDC_Copy_Transmit() / CDC_Transmit_FS() dereference
  hUsbDeviceFS.pClassDataCDC unconditionally, but it is NULL until a USB host
  enumerates. The default nmea_cdc_level = NMEA_ALL forwards every GPS sentence
  to CDC, so on charger-only power the first forwarded sentence faults in ISR
  context. NULL guard on both, plus a length clamp before the memcpy.

- mk4-date CMD_LOAD_TEXT: 'if (text_idx > MAX_TEXT_LEN) return;' passes at
  text_idx == MAX_TEXT_LEN and writes text[32], one past the 32-byte array.
  '>' -> '>='.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
MODE_LST and MODE_SOLAR turn the big HH:MM:SS digits into a live ticking
Local Sidereal Time or apparent-solar clock. Both reuse the MODE_COUNTDOWN
takeover pattern: the heavy GMST/EoT double maths runs once per second in the
main loop, staging pre-rendered digits that the SysTick handlers latch at the
true GPS PPS boundary -- so accuracy and the P3..P0 sub-second digit-hiding are
inherited from the disciplined civil second, and civil timekeeping (currentTime,
PPS, the date row) is untouched.

The display is quantised to civil second boundaries and reseeded every second,
so sidereal's 1.00273791x rate makes the seconds double-step once every ~6 min
-- the honest signature of a GPS-disciplined sidereal clock. A dedicated colon
animation (alt_colon_mode, default alt_sawtooth, kept distinct from the civil
colon) marks the mode so it can never be mistaken for civil time; apparent solar
especially can sit within minutes of it. With no position the row shows dashes
-- never GMST-as-LST.

astro.c regains local_sidereal_time()/local_solar_time() with the GMST series
factored into one helper (gmst_hours, +T^2 term); native suite 53/53 incl. the
IAU J2000 anchor and Meeus example 12.b. All default-off: stock behaviour
unchanged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
sun_subsolar(t, &lat, &lon) returns the point where the sun is at the zenith:
latitude = solar declination, longitude where the local hour angle is zero
(lon = alpha - gmst*15). It reuses the same gmst_hours series as sun_az_el and
local_sidereal_time, so the three can never drift apart. Library + tests only —
no display mode consumes it yet (a companion app plots it).

Tests: the sun must sit at elevation 90 deg over its own subsolar point at every
existing test instant (self-consistent but not circular — az/el and subsolar
take different paths to the sky), plus declination anchors at the solstices and
the absolute GMST anchors (IAU J2000, Meeus ex. 12.b) recorded in comments.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Builds on the $PMTXTS telemetry: while GPS-locked the clock learns each
oscillator's frequency error vs die temperature (binned, per-oscillator), then
during a GPS-loss holdover it steers the SysTick timebase from the HSE model to
cancel temperature-driven drift, and optionally trims RTC->CALR from the LSE
model so the battery RTC hands over better time across a power loss.

- tc_learn / tc_apply / tc_rtc config keys (all default off = stock behaviour)
- steering is a Bresenham SysTick->LOAD stretch inside the tick ISR (integer
  only; all model maths is main-loop); origin-free HSE model so only the
  temperature DIFFERENCE since GPS loss is applied, and the per-edge PPS phase
  snap makes re-lock instantly neutral
- 'tc_dump = on' over serial prints the learned coefficients as paste-ready
  config lines (+ a $PMTXTC sentence); pasting them back freezes the model
- MODE_TEMPCOMP diagnostic display: die temp / model offset / sample count
- ticks-per-ppm derived from the runtime SysTick period (no core-clock assumption)

Hardware-validated on an Mk IV: the LSE model learned 18.68 ppm vs 18.9 ppm
measured directly (~1%). Survived an adversarial review pass (ISR-safety,
integer math, sign conventions, re-lock neutrality, off-means-identical).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Two coupled additions on top of the self-learning tempcomp, both opt-in and
byte-identical to stock behaviour when off.

significance_fade: during GPS-loss holdover, retire each sub-second digit by its
remaining SIGNIFICANCE rather than by the fixed dash timeouts. A live 3-sigma
time-interval-error bound U(tau) = k*sigma*tau (sigma = RSS of the calibration, the
MEASURED temp-model residual, and aging terms) drives setPrecision's P3/P2/P1/P0
ladder: a digit dashes the moment U passes its place value — the same dash glyph as
the Tolerance_time_* ladder, but driven by measured significance instead of fixed
timeouts. digit_bright[] carries the per-digit intensity for a future smooth-dimming
display change (and for emulators). tc_fit now also reports the weighted-RMS fit
residual, mean-centred for HSE whose model origin is arbitrary.

tc_seed (warm start): reload a previously-learned model -- the last tc_dump, persisted
in config.txt by the host -- as an EVOLVING prior rather than a freeze. The clock is
temperature-compensated from the first second and keeps refining: tc_fit_one returns
the achieved model order and tc_fit preserves the seed until real data supports a fit
at least as rich, then hands over. New keys tc_seed / tc_seed_lo / tc_seed_hi
(documented in qspi/config.txt); tc_dump emits the coverage so a paste round-trips.
Over serial the "tc_seed = on" line is the trigger -- it arms tc_seed_pending (a
single-word ISR write) and tc_housekeeping applies the seed from the MAIN LOOP,
serialized with tc_fit/tc_governor, after the whole paste has parsed. The learned
state keeps its main-loop-only contract; the USB ISR never touches the model.

Survived two adversarial review passes (the feature, then this port), with fixes
re-verified in the emulator against this exact tree: boot-time tc_nom_load capture
(ticks-per-ppm is never 0), origin-invariant HSE residual, main-loop-serialized
serial seeding with a whole-paste trigger, tc_reset clears the held prior,
significance_fade drives the dash ladder (no regression vs stock when enabled), and
measure_temp's cadence gate includes the fade so die_temp_c is always real.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…, not FADE_MAX

On a warm boot with significance_fade on, digit_bright[] defaulted to
{FADE_MAX,...} ("certainly significant"), so setPrecision() at boot (and each
PendSV second) showed the ms digit ticking — a ~1 s flash of numbers — until the
first per-second computeHoldoverFade() saw the huge holdover age (last_pps_time=0,
no PPS yet) and dashed them. Default to 0 (not significant): the sub-second digits
start dashed and only light once computeHoldoverFade proves significance after
lock (the same PendSV setPrecision path applies it, symmetric). Pre-existing PR mitxela#9
issue, not the SOF change. Builds clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ing arrives with seg_balance)

PR mitxela#9 computes each sub-second digit's live significance (digit_bright, 0..FADE_MAX,
from the 3-sigma holdover uncertainty) but the display driver could only show-or-dash.
PR mitxela#10 built the missing per-digit dimmer. This connects them:

- segbal_poll scales the ds/cs/ms columns' mirror duty by their digit_bright, and the
  seconds column's decimal point by digit_bright[3] (it dies with the 0.1 s digit).
  While any digit is mid-fade the mirror runs even with seg_balance off (identity duty),
  so the fade works standalone; with balance on the two multiply.
- setPrecision's significance_fade branch now retires digits to BLANK instead of a dash
  glyph — the digit fades to black, as the feature's own comment always intended.
- Stock behaviour (significance_fade off) unchanged: the Tolerance dash ladder remains.

Verified in the WASM build of this source: partial fade tracked exactly (holdover age
11 s -> ms digit at 14/16 significance -> lit in exactly 14 of 16 cycles, DP tracking),
zero-significance digits blank in the masters, relock restores full digits with auto
balance resumed. ARM Release 0 errors.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The MODE_TEMPCOMP date-row pages honour the configurable page_ms dwell
instead of a hardcoded 5.5 s, and the mode's enum entry moves to its final
position (after the astro read-outs, before the alternate-timebase modes)
so later tiers append ordinals without reshuffling.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
peterlewis added a commit to peterlewis/clock4 that referenced this pull request Jul 12, 2026
…xela#9)

The source said 0.0.5 (misidentifying a rollup build as stock) while the
committed image embedded 0.0.6 from an earlier build lineage. Source and
freshly built, CRC-gated image now both say 0.0.6. No cuckoo — that work is
parked on its own branch and versions later.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
peterlewis added a commit to peterlewis/clock4 that referenced this pull request Jul 12, 2026
Opt-in `tc_persist = on`: while GPS-locked the clock learns its oscillator
tempco, and a well-supported model is saved to a dedicated 2-page flash store
so the first holdover after a power-up is already compensated instead of
re-learning from cold. On boot it loads back as an EVOLVING seed through the
existing tc_seed path; config.txt still wins per-key.

Store (ee2) is parallel to the menu store, reusing its address helpers and
append-only ping-pong framing (magic/gen/schema, CRC16 in the last doubleword
so a torn write fails CRC and the prior generation wins). Runtime-derived base
lands in bank 2 on the RG (read-while-write; ~20 ms erase never stalls PPS or
SysTick) and disables itself below 0x8040000 (RC -> RAM-only, no brick).

Pre-mortem fixes folded in:
- persist gate: only a well-supported fit reaches flash (>=300 HSE samples,
  >=4 C coverage, believable residual) -> a shaky fit can never auto-seed a
  bad steer next boot.
- load sanity: a non-finite / physically-implausible stored model is rejected
  before it can seed the timebase.
- break the permanent-hold trap: cap the seeded prior order at 2 so the first
  genuine linear fit supersedes the seed.
- wear: commit at most once per 30 min on a MONOTONIC clock (uwTick), gated on
  L0 + UART-idle + !pps_record_pending, and only when the model moved
  meaningfully (ppm-at-{lo,mid,hi} change detector). A few writes/day -> the
  10k-cycle flash lasts for decades.
- config precedence: tc_seed/coefficient/tc_t0 lines in config.txt override the
  stored model (cfg_tc_defined bitmask).

Serial: `tc_forget = on` erases the stored copy (live model untouched);
`tc_reset = on` clears both.

Also bake the measured VTT9812FH (R11=470K) brightness curve as the compiled-in
brightnessCurve[] default, so the clock needs no config.txt BS lines (a BS line
still overrides its stop). config.txt documents tc_persist and marks the BS
lines optional.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

Gradual significance fade: digit_bright drives the duty mirror — digits dim out, not dash

PR mitxela#9 computes each sub-second digit's live significance (digit_bright, 0..FADE_MAX,
from the 3-sigma holdover uncertainty) but the display driver could only show-or-dash.
PR mitxela#10 built the missing per-digit dimmer. This connects them:

- segbal_poll scales the ds/cs/ms columns' mirror duty by their digit_bright, and the
  seconds column's decimal point by digit_bright[3] (it dies with the 0.1 s digit).
  While any digit is mid-fade the mirror runs even with seg_balance off (identity duty),
  so the fade works standalone; with balance on the two multiply.
- setPrecision's significance_fade branch now retires digits to BLANK instead of a dash
  glyph — the digit fades to black, as the feature's own comment always intended.
- Stock behaviour (significance_fade off) unchanged: the Tolerance dash ladder remains.

Verified in the WASM build of this source: partial fade tracked exactly (holdover age
11 s -> ms digit at 14/16 significance -> lit in exactly 14 of 16 cycles, DP tracking),
zero-significance digits blank in the masters, relock restores full digits with auto
balance resumed. ARM Release 0 errors.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

Fix significance_fade boot flash: digit_bright defaults to 0 (dashed), not FADE_MAX

On a warm boot with significance_fade on, digit_bright[] defaulted to
{FADE_MAX,...} ("certainly significant"), so setPrecision() at boot (and each
PendSV second) showed the ms digit ticking — a ~1 s flash of numbers — until the
first per-second computeHoldoverFade() saw the huge holdover age (last_pps_time=0,
no PPS yet) and dashed them. Default to 0 (not significant): the sub-second digits
start dashed and only light once computeHoldoverFade proves significance after
lock (the same PendSV setPrecision path applies it, symmetric). Pre-existing PR mitxela#9
issue, not the SOF change. Builds clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

Holdover significance-fade + persistent, evolving warm-start temp model

Two coupled additions on top of the self-learning tempcomp, both opt-in and
byte-identical to stock behaviour when off.

significance_fade: during GPS-loss holdover, retire each sub-second digit by its
remaining SIGNIFICANCE rather than by the fixed dash timeouts. A live 3-sigma
time-interval-error bound U(tau) = k*sigma*tau (sigma = RSS of the calibration, the
MEASURED temp-model residual, and aging terms) drives setPrecision's P3/P2/P1/P0
ladder: a digit dashes the moment U passes its place value — the same dash glyph as
the Tolerance_time_* ladder, but driven by measured significance instead of fixed
timeouts. digit_bright[] carries the per-digit intensity for a future smooth-dimming
display change (and for emulators). tc_fit now also reports the weighted-RMS fit
residual, mean-centred for HSE whose model origin is arbitrary.

tc_seed (warm start): reload a previously-learned model -- the last tc_dump, persisted
in config.txt by the host -- as an EVOLVING prior rather than a freeze. The clock is
temperature-compensated from the first second and keeps refining: tc_fit_one returns
the achieved model order and tc_fit preserves the seed until real data supports a fit
at least as rich, then hands over. New keys tc_seed / tc_seed_lo / tc_seed_hi
(documented in qspi/config.txt); tc_dump emits the coverage so a paste round-trips.
Over serial the "tc_seed = on" line is the trigger -- it arms tc_seed_pending (a
single-word ISR write) and tc_housekeeping applies the seed from the MAIN LOOP,
serialized with tc_fit/tc_governor, after the whole paste has parsed. The learned
state keeps its main-loop-only contract; the USB ISR never touches the model.

Survived two adversarial review passes (the feature, then this port), with fixes
re-verified in the emulator against this exact tree: boot-time tc_nom_load capture
(ticks-per-ppm is never 0), origin-invariant HSE residual, main-loop-serialized
serial seeding with a whole-paste trigger, tc_reset clears the held prior,
significance_fade drives the dash ladder (no regression vs stock when enabled), and
measure_temp's cadence gate includes the fade so die_temp_c is always real.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

Add self-learning holdover temperature compensation

Builds on the $PMTXTS telemetry: while GPS-locked the clock learns each
oscillator's frequency error vs die temperature (binned, per-oscillator), then
during a GPS-loss holdover it steers the SysTick timebase from the HSE model to
cancel temperature-driven drift, and optionally trims RTC->CALR from the LSE
model so the battery RTC hands over better time across a power loss.

- tc_learn / tc_apply / tc_rtc config keys (all default off = stock behaviour)
- steering is a Bresenham SysTick->LOAD stretch inside the tick ISR (integer
  only; all model maths is main-loop); origin-free HSE model so only the
  temperature DIFFERENCE since GPS loss is applied, and the per-edge PPS phase
  snap makes re-lock instantly neutral
- 'tc_dump = on' over serial prints the learned coefficients as paste-ready
  config lines (+ a $PMTXTC sentence); pasting them back freezes the model
- MODE_TEMPCOMP diagnostic display: die temp / model offset / sample count
- ticks-per-ppm derived from the runtime SysTick period (no core-clock assumption)

Hardware-validated on an Mk IV: the LSE model learned 18.68 ppm vs 18.9 ppm
measured directly (~1%). Survived an adversarial review pass (ISR-safety,
integer math, sign conventions, re-lock neutrality, off-means-identical).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

sidereal: rename config key alt_colon_mode -> colon_alt_mode

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

Add sidereal (LST) and apparent-solar time-row modes

MODE_LST and MODE_SOLAR turn the big HH:MM:SS digits into a live ticking
Local Sidereal Time or apparent-solar clock. Both reuse the MODE_COUNTDOWN
takeover pattern: the heavy GMST/EoT double maths runs once per second in the
main loop, staging pre-rendered digits that the SysTick handlers latch at the
true GPS PPS boundary -- so accuracy and the P3..P0 sub-second digit-hiding are
inherited from the disciplined civil second, and civil timekeeping (currentTime,
PPS, the date row) is untouched.

The display is quantised to civil second boundaries and reseeded every second,
so sidereal's 1.00273791x rate makes the seconds double-step once every ~6 min
-- the honest signature of a GPS-disciplined sidereal clock. A dedicated colon
animation (alt_colon_mode, default alt_sawtooth, kept distinct from the civil
colon) marks the mode so it can never be mistaken for civil time; apparent solar
especially can sit within minutes of it. With no position the row shows dashes
-- never GMST-as-LST.

astro.c regains local_sidereal_time()/local_solar_time() with the GMST series
factored into one helper (gmst_hours, +T^2 term); native suite 53/53 incl. the
IAU J2000 anchor and Meeus example 12.b. All default-off: stock behaviour
unchanged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

Fix charger-only-power CDC hard-fault and date-board CMD_LOAD_TEXT OOB

Two further pre-existing bugs from the same read-through.

- mk4-time NMEA-over-CDC: CDC_Copy_Transmit() / CDC_Transmit_FS() dereference
  hUsbDeviceFS.pClassDataCDC unconditionally, but it is NULL until a USB host
  enumerates. The default nmea_cdc_level = NMEA_ALL forwards every GPS sentence
  to CDC, so on charger-only power the first forwarded sentence faults in ISR
  context. NULL guard on both, plus a length clamp before the memcpy.

- mk4-date CMD_LOAD_TEXT: 'if (text_idx > MAX_TEXT_LEN) return;' passes at
  text_idx == MAX_TEXT_LEN and writes text[32], one past the 32-byte array.
  '>' -> '>='.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

Harden firmware: bounds + ISR-safety fixes from broad audit

- loadRules: reject untrusted rowLength/numEntries from TZRULES.BIN (RAM overflow)
- MODE_TEXT: clamp snprintf untruncated-return index (uart2_tx_buffer OOB)
- mk4-date latchDisplay: bound dp_pos per orientation (buffer_a/b OOB)
- readConfigFile: don't treat a zero FAT timestamp as a cache hit (first-boot hang)
- config-over-USB: defer postConfigCleanup out of the USB ISR (sendDate/nextMode races)
- firmwareCheckOnEject: fatfs_busy guard for FATFS reentrancy on USB eject

astro: subsolar-point helper (sun_subsolar) + shared-series comments

sun_subsolar(t, &lat, &lon) returns the point where the sun is at the zenith:
latitude = solar declination, longitude where the local hour angle is zero
(lon = alpha - gmst*15). It reuses the same gmst_hours series as sun_az_el and
local_sidereal_time, so the three can never drift apart. Library + tests only —
no display mode consumes it yet (a companion app plots it).

Tests: the sun must sit at elevation 90 deg over its own subsolar point at every
existing test instant (self-consistent but not circular — az/el and subsolar
take different paths to the sky), plus declination anchors at the solstices and
the absolute GMST anchors (IAU J2000, Meeus ex. 12.b) recorded in comments.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

astro: configurable per-screen page dwell (page_ms, default 5500 ms)

The paged modes (SUN, LATLON) previously flipped sub-screen every 2 s hard-coded.
Make the dwell a config key (page_ms, ms; unset -> 5500, floored at 250 so a tiny
value can't flood the date-board UART), driven off uwTick, and repaint the moment a
page flips rather than waiting for the 1 Hz date-row refresh (guarded by decisec!=9
to avoid racing the SysTick sendDate).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

astro: give LAT/LON a sign slot so negatives keep the label gap

"LAT 51.48" vs "LAT-51.48" let the minus swallow the separator. Add a sign
slot after the label space — "LAT  51.48" / "LAT -51.48" — so the digits
start in the same column either way, matching the RISE/SET layout. A 3-digit
longitude can't fit both the separator and the slot in 10 characters, so the
separator alone is dropped there ("LON-179.99").

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

Add astro pack: sun / moon / Maidenhead / lat-lon display modes

Five GPS-derived astronomy read-outs, each opt-in via a MODE_* config key and
shown on the 10-char date row while the live clock keeps running on the time
row (SATVIEW-style, COUNT_NORMAL):

  MODE_SUN       sunrise / sunset / solar noon (local), auto-paged
  MODE_SUN_AZEL  sun azimuth & elevation, now
  MODE_MOON      moon phase index (0-7) + illuminated %
  MODE_GRID      Maidenhead grid locator
  MODE_LATLON    latitude / longitude, auto-paged

The astronomy maths is a self-contained Core/Src/astro.c (+ astro.h): low-
precision NOAA/Meeus sun alt-az and event times, moon phase, equation of time,
and Maidenhead. It has no firmware dependencies, so it unit-tests natively
(test/test_astro.c, 45 reference vectors) and was validated to ~1e-5 before
touching the firmware.

The L4 has only a single-precision FPU, so the double maths would be slow soft-
float. It is therefore computed in the main loop (astro_update(), gated on the
active mode exactly as measure_vbat() is for MODE_VBAT) and swapped into a cache
under a brief __disable_irq() mask; the sendDate() cases that run inside the
SysTick ISR only format the cached scalars -- no trig in the interrupt.

Modes are disabled by default; with no GPS fix they use fake_latitude/longitude
if set, else show "----". config.txt documents the keys and the moon-phase
index legend.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

SOF timestamping: audit fixes — valid-gated tail, latch only when pps=on

Adversarial audit of the SOF-correlation change surfaced two real issues:
- Stale first anchor: pps_sof_dwt/frame init to 0, so the first PPS emitted
  after enumeration (or with pps just toggled on, or on the emulator which has
  no USB SOF) carried a (0,0) anchor → a ~53s/−729ms host error on that record.
  Now a pps_sof_valid flag gates the tail: emit the plain 9-field sentence until
  a real SOF has latched. Backward-compatible; also makes the emulator correct.
- SOF work ran unconditionally. Gate the latch on pps_ts_enabled (the SOF IRQ
  still fires — cheap, below the priority-0 display DMA — but does nothing when
  the feature is off); clear valid when off so a re-enable can't reuse a stale
  anchor. config.txt documents that pps=on enables the SOF tail.
Builds clean (0 errors). Refuted findings (torn read, 16-bit atomicity, buffer
truncation, display preemption, scope) left as-is.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

SOF-correlation timestamping: latch (USB frame, DWT) for sub-ms PPS over USB

Experimental attack on the ~6ms host-driven USB read jitter that swamps the
clock's true ~180us precision — without a hardware PPS wire. The OTG_FS core
rules out mitxela's last-microsecond FIFO injection, so instead we let the host
anchor each PPS to a USB Start-Of-Frame, whose own arrival time the host can
read in hardware (macOS IOKit GetBusFrameNumberWithTime).

- Enable DWT->CYCCNT (free-running 12.5ns core-cycle counter) as the timebase
  both the PPS edge and each SOF are latched against. Unaffected by tempcomp
  SysTick steering (counts raw core clock), so it's a stable monotonic ruler.
- Enable the USB SOF interrupt; PCD_SOFCallback latches (DWT, 11-bit frame from
  DSTS[13:8]) every 1ms, as its first act for minimal latency.
- capturePPS() latches DWT at the edge; emitPPSTimestamp() appends the tail
  ,dwt_pps,sof_frame,dwt_sof to $PMTXTS (read under __disable_irq, no torn read).
- Bump NMEA_BUF_SIZE 90->128 for the longer sentence.

Host places the edge at hostTime(sof_frame) + (dwt_pps-dwt_sof)/f_dwt, immune to
delivery lateness; dwt_pps deltas self-calibrate f_dwt (no core-clock assumption).
Backward-compatible: lenient $PMTXTS parsers read the first 9 fields. Bench-only;
+1kHz SOF ISR (~0.05% CPU) is the cost to watch. Builds clean (0 errors).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

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 <noreply@anthropic.com>

adjust hole sizes on acrylic support parts

tap drills to 2.6mm, switch holes to 4.0mm, lightsensor to 6.02mm

inc fwt version

add MODE_WDY_MM_DD, truncated weekday with MM-DD

add MODE_WEEKDA_DD

the missing Y is a reminder that some of the weekday names are truncated

Fix errant extra mode

This typo was simple enough to spot once we went looking for it, but the curious thing is that we hadn't seen any symptoms until now.

When cycling through modes with the buttons, going in one direction works fine but the other requires an extra button press to loop. By a quirk of the memory layout it only affects some clocks, and only when they've been powered on long enough to have a full fix for a while.

When cycling downwards, the loop erroneously attempted to set the mode to NUM_DISPLAY_MODES, which is one beyond the end of the array. It continues cycling until it finds an enabled mode. Looking at the memory map, the next value in memory happened to be debug_rtc_val:

 *fill*         0x200005a5        0x3
 .bss.config    0x200005a8       0x40 ./Core/Src/main.o
                0x200005a8                config
 .bss.debug_rtc_val
                0x200005e8        0x4 ./Core/Src/main.o
                0x200005e8                debug_rtc_val
 .bss.nmea_cdc_level
                0x200005ec        0x1 ./Core/Src/main.o
                0x200005ec                nmea_cdc_level
 .bss.colonMode
                0x200005ed        0x1 ./Core/Src/main.o
                0x200005ed                colonMode

This integer value is zero at powerup and later contains the remainder after calibrating the RTC crystal over a 63 second period. So it's only ever non-zero if the clock has had PPS for more than 63 seconds, and then depending on the unique crystal characteristics of that hardware, the value may be positive, meaning the high byte is zero. Only if the remainder is negative would the errant extra mode appear. The first batch of clocks in 2026 are apparently the first ones to meet the criteria.

The fallback in sendDate() for the unknown mode is just ISO8601 standard, so this doesn't really matter but it's interesting it flew under the radar for so long.

add wider versions of shelfstand for acrylic

split wallhanger4 into separate files, move keyhole to top

reduce wallhanger4 spring pressure to 3 degrees

New wallhanger that clips only onto PCB

cleaner way of grabbing the versions

update github release script

Merge branch 'ttff'

Release script for zip file with disk image and bootloader

if enabled, always start with mode_ttff

mode to display time till first fix

inc firmware versions

improve hinge-tile-gen.py

Hold both buttons to reset

smoked acrylic svg with 50 micron kerf baked in

simplify acrylic case svg

bootloader: skip erase if not needed

Erasing a page is much, much slower than writing a page.

Chips arrive erased from the factory, so this significantly speeds up the initial flashing process.

additional support for acrylic case threaded holes

lasercut pieces tile generator

hinge.svg threaded holes dia

hinge.svg: start and stop paths on corners, not at the arc

add acrylic case cad files

appease demands for noncompliant date formats

...but hide them behind an #ifdef

fwd protect stack when jumping to system loader

can't be too careful eh

fwt version 0.0.2

Add display test mode

Countdown: set decimal point when epoch has past

Once the moment has gone, always display 0.000, regardless of GPS/RTC precision

Resend date in pendSV during count_hidden

Enables text such as "utc offset" to still flip over when clock is folded/unfolded

Double-sided tape wallhanger by Kevin Thomasson

slim wallhanger (mitxela#3)

* slim wallhanger

* rename

* added midpoint locker
typo in hinge.svg

Black 6mm rectangle should have a height of 22.56, with the lower hole 6mm from the bottom, and the top hole 5.28 from the top. It was off by 0.28mm so hardly noticable with the kerf of the laser, but we may as well make it exact.

Add redrawn STEP files

These don't exactly match the STL files although both will work. STL files were generated from the OpenSCAD file. STEP files were redrawn with some fillets and other tweaks, moving forwards the step files may be the main reference

Move main STL files into folders for rev C, rev D

Set brightness curve in config file for phototransistor

The default curve in the firmware, with nothing specified in the config file (or this commented out) matches the LDR.

add STL for phototransistor (VTT9812FH)

bootloader user request on PA2/hinge TX

update config.txt template

inc fwt version

wipe vbat measurement on leaving mode_vbat

Reduce power-on precision if coin cell below 2.7V

oversampling vbat is substantially more stable

mode_vbat

initial vbat reading using adc3

monitor beidou, plot gps and the highest non-gps satview

further complication of GSV parsing

signal ID is not always present, number of fields is variable, what was meant to be a quick way of showing number of satellites is becoming a mess

correctly interpret multiple GSV messages

M10 gps module sends GPGSV multiple times in separate groups, even though it's only listening to a single band.

latch satview

ublox 10 series output GSV only every 5 seconds

update qspi.md

tzmap 2025b (full)

tzrules to 2025b

update timezone-names to 2025b

Bootloader updates to explicitly protect the stack

Using both noreturn and __builting_unreachable() is superfluous but hopefully makes it twice as unlikely to optimise the jump into a tail call. Not sure if this is better than -O0 but hopefully makes some people less unhappy.

add physical dimensions to hinge svg

generate either version in openscad

cad for vtt9812fh

a dubiously helpful overlay for brightness curve

method to convert a bunch of brightness values

basic support for touchscreen devices

allow pasting in brightness curve values

generate binary files

compatibility with toolchain 12.3.rel1

Ignore env hashes using clean/smudge filter

Add following to .git/config:

[filter "eclipse_env_hash"]
	clean = sed 's/env-hash=\"[-,0-9]*\"/env-hash=\"0000000000000000000\"/g'
	smudge = cat

Revert "Don't track these IDE files that keep changing"

This reverts commit 8e4d62c.

add shelfstand cad files

Add handcrafted SVG version of moshidraw hinge

add readme

CAD files for antenna case

Specifically for the patch antenna with SMA connector crimped as close as possible. Top half (caset.stl) needs to be printed upside down with support for the cable shroud. Last modified 2023-04-19

Add cad files for wall hangers

lasercut files update for easier tiling

Add moshidraw file for hinge (convert to DXF or SVG later)

make colon cover a teeny bit taller

add side cutouts to colons for better pliability

flat-top LEDs have 3.15 dia

commit STL files

cad updates from 2023-06-15

start adding cad files to this repo

Don't track these IDE files that keep changing

Add known firmware images to repo (2024-05-29)

Config file defaults, most modes off

IDE settings (unsure what changed)

slightly smaller hammer (re: b91a100)

sledgehammer to prevent -Os trashing launch_app()

suppress maybe-uninitialised warning

Pad bootloader to exactly 64K

Bootloader version string

env hash stuff - should probably ignore these files

partial changes to bootloader - unfinished

f_mount doesn't actually do anything until first read (unless forced) so it's not really possible to error out at the mount stage, but we don't want any possible way to hang without enabling usb

Clear colons in bootloader

They are off when tristated too but need to be explicitly high to remain off on the xl

and volatile again in the bootloader

This was working without it, so the fix is hypothetical. Might be because bootloader is -Os and time is -O3

another volatile optimisation victim

set volume label to CLOCK

blank date when updating on eject

delay setDisplayFrequency if needed

Don't write RTC during USB read/writes, pendSV could be delayed

fix countdown mode entering/exiting

Replace delayedButtonPress with resendDate

Not sure if this is better than the alternative, that is to delay CDC commands and reloaded config too.
Todo: countdown mode fixes

Measure lookup time macro

Takes 3x longer if launched via swd instead of bootloader. QSPI quad read seems to make it slower. Definitely something odd going on

possible compiler optimisation problems

enable config mtime checking

ftime field has a resolution of 2 seconds, so fast changes can be missed.
But this has the benefit of not wiping out CDC config when the OS does random other writes we might not have supressed (system volume information, DS_Store, etc)

remount filesystem, skip config mtime check

The remount does fix some occasional fs problems, which seems to just be stale data in fatfs window. This can be invalidated by doing USERFatFS.winsect = -1 but safest to remount properly.

The config ftime still sometimes misses changes if they're in quick succession. This might even be the OS not updating the value, or the low resolution (to the second). But realistically the config file is normally the only thing that gets changed so I don't think always reloading it is a problem.

manually manage Etc/ zones again

zonedetect backoff on usb read too

Only zonedetect once per nmea

Priority rewrite, avoid f_read in usb context

more typos

Typo

dos2unix generate-tzrules.py

Fix tzrules for zone names out of order

single script for flashing and creating disk image

mount as loop device is auto determined

create dummy 'system volume information'

Set qspi_write_time even if write fails

Only reload config if modified

update config template

Accept fake long/lat from config

day-of-week mode for no reason

if rtc valid but no tzrules file, copy out rtc rules string too

enum the loadrules error codes

reload map file each time

only copy rules string if rules valid

configure debug/release builds for date

same treatment for bootloader

fix linux eject sequence

util-linux eject sends a scsi start immediately before scsi stop

QSPI_STATUS consistency in bootloader

hacky delayed-check-on-eject

This is pretty much impossible to test anyway

Revert "Add fatfs read qspi timeout"

This reverts commit 70553aa.

Add fatfs read qspi timeout

Protection against qspi collisions

Not convinced the lock stuff is bullet proof. The zonedetect backoff is enough to fix it but there's still a remote chance something could get corrupted.

Also the chainloader ejection check runs in the usb interrupt context, if qspi was locked at ejection this could hang

fix typo (DP on error)

Don't connect USB during date update, unless an error occurs

force update version string on every build

reboot on eject if new firmware present

corrupted filesystem could cause f_crc to hang

Flashing script

rollover fix (hypothetical, from earlier)

we want at least the option of separate version strings

linker alignment fix and give bootloader same treatment

share version string method

ISO8601 build string, worth it

propagate changes to linker script

There's only one line difference between the scripts, we should probably try passing a parameter from the ide build options

Add build datetime string to firmware image

One disadvantage to this linker method is the error message is nonsensical if you overflow the pad region

update create-image.sh

fixup! Commit release script

Show firmware crc (date)

Firmware CRC display mode (for time)

Commit release script

Add firmware CRC script

Add notes about QSPI flash contents

Add example config.txt

Create disk image using loop device

Add example tzmap.bin (2023b)

Add example tzrules output

Add example timezone-names file

Specifically needs to match the shapefile version we're using

curl -L https://github.com/evansiroky/timezone-boundary-builder/releases/download/2023b/timezone-names.json | jq '.' > timezone-names.json

Newer tzrules generator, uses zdump and subprocess

Add original tzrules generator, depends on external csv

Add reboot CDC command

manual vbus detection for self powered version

Enable PA8 input and update IOC

Fix inverted date trailing dot error

In the inverted position, the decimal point is shown on the following digit. If the DP is the last character of the message, it would not set the cathodes and multiple dots would light up.

Pullup on usart2 rx

Without it and without date connected, frame error interrupt is called continuously

update default brightness curve for LDR

Enabling a mode via CDC should switch to it

Rip out io retarget stuff

USB Eject bootloader should retry update

Debug RTC mode + theoretical improvements to RTC cal

Critical fix to RTC calibration

When launched from ST-LINK or without bootloader, the calibration routine works fine. When launched by the bootloader, even if we jump out before anything is initialised, the calibration was way off. The LPTIM1 interrupt was running twice for each counter reset, because the interrupt ended before the flag had cleared. Clearing the flag at the start of the interrupt instead of the end fixes it.

Can't figure out why the launch method changes this, it must be something to do with the pipeline acceleration. Both bootloader and app have the same startup file, and it still happens if we skip out all of HAL.

display fault type (could make this more helpful at some point)

can have GPS satellites but no GLGSV message

suppress snprintf warning

"fix" hardfault on usb scsi command

When launched after firmware update, first boot causes hardfault when scsi command happens. The GetCapacity function pointer in USBD_Storage_Interface_fops_FS is corrupted.

Launching the older non-CDC firmware is fine. Disabling usb in bootloader has no effect. Zeroing ram or adding delays has no effect, but breakpointing with the debugger stops it happening.

Calling reset after a firmware update seems hacky but appears to fix everything.

bootloader: only init USB on error

prevents double-connect when booting normally

Alternate show tz name

Fix show offset

brightness curve in config file

prepare to rewrite brightness curve

pointless attention to detail

1.0 or +1.0 should be max brightness, 1 should be 1/4095

brightness override float optional

send full text buffer

dots are valid characters that don't take up a digit, so a valid message could be 20 bytes long. Might as well send it all

Brightness override

fix likely glitch with uart handler

was checking against UART_IT_RXNE instead of USART_ISR_RXNE

also explicitly hand errors over to the HAL routine

button delayed auto shift

date: don't disable swclk if button held at power on

iso week mode

Merge branch 'cdc-msc'

expand falsey

satview be explicit if there's no data

nmea output level, cleanup cdc transmit

add generic text display mode

rx config value can be empty

cope with config file not present

config cleanup on cdc commands

parse config from cdc input

fix MSC eject

fix descriptors for bus powered, 500mA

update build config with alt linker script

rip out unused descriptors

USB CDC MSC

CDC MSC Almost there

initial steps on usb msc+cdc composite

Add basic satview mode

countdown tolerance; handle on-the-fly changes

allow countdowns to the year 9999

Countdown enter/exit

Starting on countdown mode

Update eclipse env-hash nonsense

At some point we should clean/smudge this out of the repo

DP debug signals

update manufacturer/product/scsi description

Add brightness debug mode

Delay enabling uart2 interrupt until after doDateUpdate

swap buttons when inverted

proobably shouldn't be tracking these files

code generation using workspace 1.8.0

update ioc for code gen

update file names

code generated workspace 1.8.0

update .ioc for working code generation

update file name

Code generation using workspace 1.8.0

Update .ioc for correct code generation

update env hash

update project name

remove .launch files

Merge repos

Merge repos

Move everything into subdir

disable power on test message

Move everything into subfolder

Move everything into subfolder

Configure PC12 correctly to hide DP at power-on

Update to work with chips bigger than 256K

adjust animation speed to compensate for slower erase of the bank we're running our code from

don't light decimal points during bootloader

button 2 scrolls modes in reverse

swap colons, don't show DP during date update

env hash update

Add second button

Add second button

Colon flashing modes

Colons independently fading

Separate PWM for each colon pair

Fix .ioc, culprit was ADC1.DMAContinuousRequests=ENABLE

Interim checkin before .ioc corruption

Forget using DMA for ADC reading

Show offset mode and hook into USB write

Support for modes that don't display time

Use hyphens for reduced precision

Standby mode

Load tolerances from config file

Load modes from config file

sendDate cleanup

Reduce precision to align with accuracy

Rewire colons

Ordinal date

Julian date

Rudimentary support for decimal places

Debouncing

Button press changes date display

Initial button press mechanism

Move sTimeDPR to PC12

Calibrate RTC

Use LPTIM clocked from LSE

Better latch mechanism

Better latch mechanism

Improve rtc subsecond stuff

Load frequency from config

set frequency fixes

cmd set frequency

Cleanup

RTC subseconds somewhat accurate

Fixes to enable code generation

Fiddling with rtc timings

Smooth progress again

alternate progress bar

Smooth progress

Look ma no magic numbers

Progress bar progress

chainloadin' is a happenin'

Use 8E1 always to avoid having to switch

Recovery logic in place

Chainloader progress

Chainloader progress

Move display matrix to TIM7 to free up UART2 TX DMA channel

More RTC stuff

RTC basics

Ticking

Further fiddling with pointless parts

Buffer wheel thing

Mostly lame progress bar

We has a bootloader

Main structure in place

Checks CRC of firmware image on fatfs

Possibly better way of filling the flash, still lets us see usage

One method of filling the flash

Initial commit with working display, QSPI FATFS and USB

Safely shift code section to make room for bootloader

Power up at lowest brightness

Interim checkin

Enable RTC, fiddle with brightness stuff

Use 10MHz HSE, MSI for USB

Initial commit

Use timer and DMA to smooth DAC output, add brightness curve

DAC controlled brightness circuit, needs linearity correction

In-phase ADC apparently functioning

PWM generation on PA0 from timer 2 ch1

Use timer4 for display to free up timer2

Use coords from nmea

Use int32 for offset

Almost there

Apply rules to time

Change to little-endian, 6 byte format

Use TCXO, read binary rules file

PPS ticking

BCD routines

output unix time

DMA read line and check checksum

Trigger uart interrupt on character match

config stuff

Start on parsing config

USB Preemption priority

error handler

Move vectors to ram and swap systick

basic ticking

Matrix stuff

Zonedetect working

Add zonedetect files

Add locking to potentially avoid concurrency issues

Update .ioc, remove QSPI DMA

FatFS

USB MSC working

cleanup

more junk

junk

More work on qspi driver

Starting on QSPI driver

Enable printf

Initial commit
peterlewis added a commit to peterlewis/clock4 that referenced this pull request Jul 12, 2026
…k (PPS-latch)

The menu-settings commit gate (and the tempcomp-persist gate) deferred the ~20 ms
page erase around a pending $PMTXTS emit by gating on `!pps_record_pending`. But
pps_record_pending is set on EVERY PPS edge (capturePPS also feeds ADEV/RTC/
tempcomp) and is CLEARED only by emitPPSTimestamp, which runs only when
pps_ts_enabled. So on a GPS-locked clock with PPS-out off (the default) the flag
latches high after the first pulse and never clears — the gate never opens,
menu_dirty stays 1, and NO menu edit (or learned tempcomp model) is ever written
to flash. It works on a bench with no antenna (flag stays 0) and silently fails
in the field the moment there's a fix — exactly the reported "modes cycle fine
but don't survive a reboot."

Fix: gate on a REAL pending emit via a shared pps_emit_pending() helper
(pps_ts_enabled && pps_record_pending), used by both the menu commit gate
(menu_poll) and tc_persist_step. When PPS-out is off there is no emit to protect,
so the gate opens normally; when it's on, behaviour is unchanged.

This class of bug was invisible to the emulator: it has no PPS EXTI hardware
(pps_record_pending stays 0) and the persistence tests called ee_commit directly,
bypassing the gate entirely. Found by an adversarial silicon-path audit.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

emu: auto-persist learned tempcomp model to flash retained memory

Opt-in `tc_persist = on`: while GPS-locked the clock learns its oscillator
tempco, and a well-supported model is saved to a dedicated 2-page flash store
so the first holdover after a power-up is already compensated instead of
re-learning from cold. On boot it loads back as an EVOLVING seed through the
existing tc_seed path; config.txt still wins per-key.

Store (ee2) is parallel to the menu store, reusing its address helpers and
append-only ping-pong framing (magic/gen/schema, CRC16 in the last doubleword
so a torn write fails CRC and the prior generation wins). Runtime-derived base
lands in bank 2 on the RG (read-while-write; ~20 ms erase never stalls PPS or
SysTick) and disables itself below 0x8040000 (RC -> RAM-only, no brick).

Pre-mortem fixes folded in:
- persist gate: only a well-supported fit reaches flash (>=300 HSE samples,
  >=4 C coverage, believable residual) -> a shaky fit can never auto-seed a
  bad steer next boot.
- load sanity: a non-finite / physically-implausible stored model is rejected
  before it can seed the timebase.
- break the permanent-hold trap: cap the seeded prior order at 2 so the first
  genuine linear fit supersedes the seed.
- wear: commit at most once per 30 min on a MONOTONIC clock (uwTick), gated on
  L0 + UART-idle + !pps_record_pending, and only when the model moved
  meaningfully (ppm-at-{lo,mid,hi} change detector). A few writes/day -> the
  10k-cycle flash lasts for decades.
- config precedence: tc_seed/coefficient/tc_t0 lines in config.txt override the
  stored model (cfg_tc_defined bitmask).

Serial: `tc_forget = on` erases the stored copy (live model untouched);
`tc_reset = on` clears both.

Also bake the measured VTT9812FH (R11=470K) brightness curve as the compiled-in
brightnessCurve[] default, so the clock needs no config.txt BS lines (a BS line
still overrides its stop). config.txt documents tc_persist and marks the BS
lines optional.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

Gradual significance fade: digit_bright drives the duty mirror — digits dim out, not dash

PR mitxela#9 computes each sub-second digit's live significance (digit_bright, 0..FADE_MAX,
from the 3-sigma holdover uncertainty) but the display driver could only show-or-dash.
PR mitxela#10 built the missing per-digit dimmer. This connects them:

- segbal_poll scales the ds/cs/ms columns' mirror duty by their digit_bright, and the
  seconds column's decimal point by digit_bright[3] (it dies with the 0.1 s digit).
  While any digit is mid-fade the mirror runs even with seg_balance off (identity duty),
  so the fade works standalone; with balance on the two multiply.
- setPrecision's significance_fade branch now retires digits to BLANK instead of a dash
  glyph — the digit fades to black, as the feature's own comment always intended.
- Stock behaviour (significance_fade off) unchanged: the Tolerance dash ladder remains.

Verified in the WASM build of this source: partial fade tracked exactly (holdover age
11 s -> ms digit at 14/16 significance -> lit in exactly 14 of 16 cycles, DP tracking),
zero-significance digits blank in the masters, relock restores full digits with auto
balance resumed. ARM Release 0 errors.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

Fix significance_fade boot flash: digit_bright defaults to 0 (dashed), not FADE_MAX

On a warm boot with significance_fade on, digit_bright[] defaulted to
{FADE_MAX,...} ("certainly significant"), so setPrecision() at boot (and each
PendSV second) showed the ms digit ticking — a ~1 s flash of numbers — until the
first per-second computeHoldoverFade() saw the huge holdover age (last_pps_time=0,
no PPS yet) and dashed them. Default to 0 (not significant): the sub-second digits
start dashed and only light once computeHoldoverFade proves significance after
lock (the same PendSV setPrecision path applies it, symmetric). Pre-existing PR mitxela#9
issue, not the SOF change. Builds clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

Holdover significance-fade + persistent, evolving warm-start temp model

Two coupled additions on top of the self-learning tempcomp, both opt-in and
byte-identical to stock behaviour when off.

significance_fade: during GPS-loss holdover, retire each sub-second digit by its
remaining SIGNIFICANCE rather than by the fixed dash timeouts. A live 3-sigma
time-interval-error bound U(tau) = k*sigma*tau (sigma = RSS of the calibration, the
MEASURED temp-model residual, and aging terms) drives setPrecision's P3/P2/P1/P0
ladder: a digit dashes the moment U passes its place value — the same dash glyph as
the Tolerance_time_* ladder, but driven by measured significance instead of fixed
timeouts. digit_bright[] carries the per-digit intensity for a future smooth-dimming
display change (and for emulators). tc_fit now also reports the weighted-RMS fit
residual, mean-centred for HSE whose model origin is arbitrary.

tc_seed (warm start): reload a previously-learned model -- the last tc_dump, persisted
in config.txt by the host -- as an EVOLVING prior rather than a freeze. The clock is
temperature-compensated from the first second and keeps refining: tc_fit_one returns
the achieved model order and tc_fit preserves the seed until real data supports a fit
at least as rich, then hands over. New keys tc_seed / tc_seed_lo / tc_seed_hi
(documented in qspi/config.txt); tc_dump emits the coverage so a paste round-trips.
Over serial the "tc_seed = on" line is the trigger -- it arms tc_seed_pending (a
single-word ISR write) and tc_housekeeping applies the seed from the MAIN LOOP,
serialized with tc_fit/tc_governor, after the whole paste has parsed. The learned
state keeps its main-loop-only contract; the USB ISR never touches the model.

Survived two adversarial review passes (the feature, then this port), with fixes
re-verified in the emulator against this exact tree: boot-time tc_nom_load capture
(ticks-per-ppm is never 0), origin-invariant HSE residual, main-loop-serialized
serial seeding with a whole-paste trigger, tc_reset clears the held prior,
significance_fade drives the dash ladder (no regression vs stock when enabled), and
measure_temp's cadence gate includes the fade so die_temp_c is always real.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

Add self-learning holdover temperature compensation

Builds on the $PMTXTS telemetry: while GPS-locked the clock learns each
oscillator's frequency error vs die temperature (binned, per-oscillator), then
during a GPS-loss holdover it steers the SysTick timebase from the HSE model to
cancel temperature-driven drift, and optionally trims RTC->CALR from the LSE
model so the battery RTC hands over better time across a power loss.

- tc_learn / tc_apply / tc_rtc config keys (all default off = stock behaviour)
- steering is a Bresenham SysTick->LOAD stretch inside the tick ISR (integer
  only; all model maths is main-loop); origin-free HSE model so only the
  temperature DIFFERENCE since GPS loss is applied, and the per-edge PPS phase
  snap makes re-lock instantly neutral
- 'tc_dump = on' over serial prints the learned coefficients as paste-ready
  config lines (+ a $PMTXTC sentence); pasting them back freezes the model
- MODE_TEMPCOMP diagnostic display: die temp / model offset / sample count
- ticks-per-ppm derived from the runtime SysTick period (no core-clock assumption)

Hardware-validated on an Mk IV: the LSE model learned 18.68 ppm vs 18.9 ppm
measured directly (~1%). Survived an adversarial review pass (ISR-safety,
integer math, sign conventions, re-lock neutrality, off-means-identical).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

sidereal: rename config key alt_colon_mode -> colon_alt_mode

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

Add sidereal (LST) and apparent-solar time-row modes

MODE_LST and MODE_SOLAR turn the big HH:MM:SS digits into a live ticking
Local Sidereal Time or apparent-solar clock. Both reuse the MODE_COUNTDOWN
takeover pattern: the heavy GMST/EoT double maths runs once per second in the
main loop, staging pre-rendered digits that the SysTick handlers latch at the
true GPS PPS boundary -- so accuracy and the P3..P0 sub-second digit-hiding are
inherited from the disciplined civil second, and civil timekeeping (currentTime,
PPS, the date row) is untouched.

The display is quantised to civil second boundaries and reseeded every second,
so sidereal's 1.00273791x rate makes the seconds double-step once every ~6 min
-- the honest signature of a GPS-disciplined sidereal clock. A dedicated colon
animation (alt_colon_mode, default alt_sawtooth, kept distinct from the civil
colon) marks the mode so it can never be mistaken for civil time; apparent solar
especially can sit within minutes of it. With no position the row shows dashes
-- never GMST-as-LST.

astro.c regains local_sidereal_time()/local_solar_time() with the GMST series
factored into one helper (gmst_hours, +T^2 term); native suite 53/53 incl. the
IAU J2000 anchor and Meeus example 12.b. All default-off: stock behaviour
unchanged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

Fix charger-only-power CDC hard-fault and date-board CMD_LOAD_TEXT OOB

Two further pre-existing bugs from the same read-through.

- mk4-time NMEA-over-CDC: CDC_Copy_Transmit() / CDC_Transmit_FS() dereference
  hUsbDeviceFS.pClassDataCDC unconditionally, but it is NULL until a USB host
  enumerates. The default nmea_cdc_level = NMEA_ALL forwards every GPS sentence
  to CDC, so on charger-only power the first forwarded sentence faults in ISR
  context. NULL guard on both, plus a length clamp before the memcpy.

- mk4-date CMD_LOAD_TEXT: 'if (text_idx > MAX_TEXT_LEN) return;' passes at
  text_idx == MAX_TEXT_LEN and writes text[32], one past the 32-byte array.
  '>' -> '>='.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

Harden firmware: bounds + ISR-safety fixes from broad audit

- loadRules: reject untrusted rowLength/numEntries from TZRULES.BIN (RAM overflow)
- MODE_TEXT: clamp snprintf untruncated-return index (uart2_tx_buffer OOB)
- mk4-date latchDisplay: bound dp_pos per orientation (buffer_a/b OOB)
- readConfigFile: don't treat a zero FAT timestamp as a cache hit (first-boot hang)
- config-over-USB: defer postConfigCleanup out of the USB ISR (sendDate/nextMode races)
- firmwareCheckOnEject: fatfs_busy guard for FATFS reentrancy on USB eject

astro: subsolar-point helper (sun_subsolar) + shared-series comments

sun_subsolar(t, &lat, &lon) returns the point where the sun is at the zenith:
latitude = solar declination, longitude where the local hour angle is zero
(lon = alpha - gmst*15). It reuses the same gmst_hours series as sun_az_el and
local_sidereal_time, so the three can never drift apart. Library + tests only —
no display mode consumes it yet (a companion app plots it).

Tests: the sun must sit at elevation 90 deg over its own subsolar point at every
existing test instant (self-consistent but not circular — az/el and subsolar
take different paths to the sky), plus declination anchors at the solstices and
the absolute GMST anchors (IAU J2000, Meeus ex. 12.b) recorded in comments.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

astro: configurable per-screen page dwell (page_ms, default 5500 ms)

The paged modes (SUN, LATLON) previously flipped sub-screen every 2 s hard-coded.
Make the dwell a config key (page_ms, ms; unset -> 5500, floored at 250 so a tiny
value can't flood the date-board UART), driven off uwTick, and repaint the moment a
page flips rather than waiting for the 1 Hz date-row refresh (guarded by decisec!=9
to avoid racing the SysTick sendDate).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

astro: give LAT/LON a sign slot so negatives keep the label gap

"LAT 51.48" vs "LAT-51.48" let the minus swallow the separator. Add a sign
slot after the label space — "LAT  51.48" / "LAT -51.48" — so the digits
start in the same column either way, matching the RISE/SET layout. A 3-digit
longitude can't fit both the separator and the slot in 10 characters, so the
separator alone is dropped there ("LON-179.99").

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

Add astro pack: sun / moon / Maidenhead / lat-lon display modes

Five GPS-derived astronomy read-outs, each opt-in via a MODE_* config key and
shown on the 10-char date row while the live clock keeps running on the time
row (SATVIEW-style, COUNT_NORMAL):

  MODE_SUN       sunrise / sunset / solar noon (local), auto-paged
  MODE_SUN_AZEL  sun azimuth & elevation, now
  MODE_MOON      moon phase index (0-7) + illuminated %
  MODE_GRID      Maidenhead grid locator
  MODE_LATLON    latitude / longitude, auto-paged

The astronomy maths is a self-contained Core/Src/astro.c (+ astro.h): low-
precision NOAA/Meeus sun alt-az and event times, moon phase, equation of time,
and Maidenhead. It has no firmware dependencies, so it unit-tests natively
(test/test_astro.c, 45 reference vectors) and was validated to ~1e-5 before
touching the firmware.

The L4 has only a single-precision FPU, so the double maths would be slow soft-
float. It is therefore computed in the main loop (astro_update(), gated on the
active mode exactly as measure_vbat() is for MODE_VBAT) and swapped into a cache
under a brief __disable_irq() mask; the sendDate() cases that run inside the
SysTick ISR only format the cached scalars -- no trig in the interrupt.

Modes are disabled by default; with no GPS fix they use fake_latitude/longitude
if set, else show "----". config.txt documents the keys and the moon-phase
index legend.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

SOF timestamping: audit fixes — valid-gated tail, latch only when pps=on

Adversarial audit of the SOF-correlation change surfaced two real issues:
- Stale first anchor: pps_sof_dwt/frame init to 0, so the first PPS emitted
  after enumeration (or with pps just toggled on, or on the emulator which has
  no USB SOF) carried a (0,0) anchor → a ~53s/−729ms host error on that record.
  Now a pps_sof_valid flag gates the tail: emit the plain 9-field sentence until
  a real SOF has latched. Backward-compatible; also makes the emulator correct.
- SOF work ran unconditionally. Gate the latch on pps_ts_enabled (the SOF IRQ
  still fires — cheap, below the priority-0 display DMA — but does nothing when
  the feature is off); clear valid when off so a re-enable can't reuse a stale
  anchor. config.txt documents that pps=on enables the SOF tail.
Builds clean (0 errors). Refuted findings (torn read, 16-bit atomicity, buffer
truncation, display preemption, scope) left as-is.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

SOF-correlation timestamping: latch (USB frame, DWT) for sub-ms PPS over USB

Experimental attack on the ~6ms host-driven USB read jitter that swamps the
clock's true ~180us precision — without a hardware PPS wire. The OTG_FS core
rules out mitxela's last-microsecond FIFO injection, so instead we let the host
anchor each PPS to a USB Start-Of-Frame, whose own arrival time the host can
read in hardware (macOS IOKit GetBusFrameNumberWithTime).

- Enable DWT->CYCCNT (free-running 12.5ns core-cycle counter) as the timebase
  both the PPS edge and each SOF are latched against. Unaffected by tempcomp
  SysTick steering (counts raw core clock), so it's a stable monotonic ruler.
- Enable the USB SOF interrupt; PCD_SOFCallback latches (DWT, 11-bit frame from
  DSTS[13:8]) every 1ms, as its first act for minimal latency.
- capturePPS() latches DWT at the edge; emitPPSTimestamp() appends the tail
  ,dwt_pps,sof_frame,dwt_sof to $PMTXTS (read under __disable_irq, no torn read).
- Bump NMEA_BUF_SIZE 90->128 for the longer sentence.

Host places the edge at hostTime(sof_frame) + (dwt_pps-dwt_sof)/f_dwt, immune to
delivery lateness; dwt_pps deltas self-calibrate f_dwt (no core-clock assumption).
Backward-compatible: lenient $PMTXTS parsers read the first 9 fields. Bench-only;
+1kHz SOF ISR (~0.05% CPU) is the cost to watch. Builds clean (0 errors).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

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 <noreply@anthropic.com>

adjust hole sizes on acrylic support parts

tap drills to 2.6mm, switch holes to 4.0mm, lightsensor to 6.02mm

inc fwt version

add MODE_WDY_MM_DD, truncated weekday with MM-DD

add MODE_WEEKDA_DD

the missing Y is a reminder that some of the weekday names are truncated

Fix errant extra mode

This typo was simple enough to spot once we went looking for it, but the curious thing is that we hadn't seen any symptoms until now.

When cycling through modes with the buttons, going in one direction works fine but the other requires an extra button press to loop. By a quirk of the memory layout it only affects some clocks, and only when they've been powered on long enough to have a full fix for a while.

When cycling downwards, the loop erroneously attempted to set the mode to NUM_DISPLAY_MODES, which is one beyond the end of the array. It continues cycling until it finds an enabled mode. Looking at the memory map, the next value in memory happened to be debug_rtc_val:

 *fill*         0x200005a5        0x3
 .bss.config    0x200005a8       0x40 ./Core/Src/main.o
                0x200005a8                config
 .bss.debug_rtc_val
                0x200005e8        0x4 ./Core/Src/main.o
                0x200005e8                debug_rtc_val
 .bss.nmea_cdc_level
                0x200005ec        0x1 ./Core/Src/main.o
                0x200005ec                nmea_cdc_level
 .bss.colonMode
                0x200005ed        0x1 ./Core/Src/main.o
                0x200005ed                colonMode

This integer value is zero at powerup and later contains the remainder after calibrating the RTC crystal over a 63 second period. So it's only ever non-zero if the clock has had PPS for more than 63 seconds, and then depending on the unique crystal characteristics of that hardware, the value may be positive, meaning the high byte is zero. Only if the remainder is negative would the errant extra mode appear. The first batch of clocks in 2026 are apparently the first ones to meet the criteria.

The fallback in sendDate() for the unknown mode is just ISO8601 standard, so this doesn't really matter but it's interesting it flew under the radar for so long.

add wider versions of shelfstand for acrylic

split wallhanger4 into separate files, move keyhole to top

reduce wallhanger4 spring pressure to 3 degrees

New wallhanger that clips only onto PCB

cleaner way of grabbing the versions

update github release script

Merge branch 'ttff'

Release script for zip file with disk image and bootloader

if enabled, always start with mode_ttff

mode to display time till first fix

inc firmware versions

improve hinge-tile-gen.py

Hold both buttons to reset

smoked acrylic svg with 50 micron kerf baked in

simplify acrylic case svg

bootloader: skip erase if not needed

Erasing a page is much, much slower than writing a page.

Chips arrive erased from the factory, so this significantly speeds up the initial flashing process.

additional support for acrylic case threaded holes

lasercut pieces tile generator

hinge.svg threaded holes dia

hinge.svg: start and stop paths on corners, not at the arc

add acrylic case cad files

appease demands for noncompliant date formats

...but hide them behind an #ifdef

fwd protect stack when jumping to system loader

can't be too careful eh

fwt version 0.0.2

Add display test mode

Countdown: set decimal point when epoch has past

Once the moment has gone, always display 0.000, regardless of GPS/RTC precision

Resend date in pendSV during count_hidden

Enables text such as "utc offset" to still flip over when clock is folded/unfolded

Double-sided tape wallhanger by Kevin Thomasson

slim wallhanger (mitxela#3)

* slim wallhanger

* rename

* added midpoint locker
typo in hinge.svg

Black 6mm rectangle should have a height of 22.56, with the lower hole 6mm from the bottom, and the top hole 5.28 from the top. It was off by 0.28mm so hardly noticable with the kerf of the laser, but we may as well make it exact.

Add redrawn STEP files

These don't exactly match the STL files although both will work. STL files were generated from the OpenSCAD file. STEP files were redrawn with some fillets and other tweaks, moving forwards the step files may be the main reference

Move main STL files into folders for rev C, rev D

Set brightness curve in config file for phototransistor

The default curve in the firmware, with nothing specified in the config file (or this commented out) matches the LDR.

add STL for phototransistor (VTT9812FH)

bootloader user request on PA2/hinge TX

update config.txt template

inc fwt version

wipe vbat measurement on leaving mode_vbat

Reduce power-on precision if coin cell below 2.7V

oversampling vbat is substantially more stable

mode_vbat

initial vbat reading using adc3

monitor beidou, plot gps and the highest non-gps satview

further complication of GSV parsing

signal ID is not always present, number of fields is variable, what was meant to be a quick way of showing number of satellites is becoming a mess

correctly interpret multiple GSV messages

M10 gps module sends GPGSV multiple times in separate groups, even though it's only listening to a single band.

latch satview

ublox 10 series output GSV only every 5 seconds

update qspi.md

tzmap 2025b (full)

tzrules to 2025b

update timezone-names to 2025b

Bootloader updates to explicitly protect the stack

Using both noreturn and __builting_unreachable() is superfluous but hopefully makes it twice as unlikely to optimise the jump into a tail call. Not sure if this is better than -O0 but hopefully makes some people less unhappy.

add physical dimensions to hinge svg

generate either version in openscad

cad for vtt9812fh

a dubiously helpful overlay for brightness curve

method to convert a bunch of brightness values

basic support for touchscreen devices

allow pasting in brightness curve values

generate binary files

compatibility with toolchain 12.3.rel1

Ignore env hashes using clean/smudge filter

Add following to .git/config:

[filter "eclipse_env_hash"]
	clean = sed 's/env-hash=\"[-,0-9]*\"/env-hash=\"0000000000000000000\"/g'
	smudge = cat

Revert "Don't track these IDE files that keep changing"

This reverts commit 8e4d62c.

add shelfstand cad files

Add handcrafted SVG version of moshidraw hinge

add readme

CAD files for antenna case

Specifically for the patch antenna with SMA connector crimped as close as possible. Top half (caset.stl) needs to be printed upside down with support for the cable shroud. Last modified 2023-04-19

Add cad files for wall hangers

lasercut files update for easier tiling

Add moshidraw file for hinge (convert to DXF or SVG later)

make colon cover a teeny bit taller

add side cutouts to colons for better pliability

flat-top LEDs have 3.15 dia

commit STL files

cad updates from 2023-06-15

start adding cad files to this repo

Don't track these IDE files that keep changing

Add known firmware images to repo (2024-05-29)

Config file defaults, most modes off

IDE settings (unsure what changed)

slightly smaller hammer (re: b91a100)

sledgehammer to prevent -Os trashing launch_app()

suppress maybe-uninitialised warning

Pad bootloader to exactly 64K

Bootloader version string

env hash stuff - should probably ignore these files

partial changes to bootloader - unfinished

f_mount doesn't actually do anything until first read (unless forced) so it's not really possible to error out at the mount stage, but we don't want any possible way to hang without enabling usb

Clear colons in bootloader

They are off when tristated too but need to be explicitly high to remain off on the xl

and volatile again in the bootloader

This was working without it, so the fix is hypothetical. Might be because bootloader is -Os and time is -O3

another volatile optimisation victim

set volume label to CLOCK

blank date when updating on eject

delay setDisplayFrequency if needed

Don't write RTC during USB read/writes, pendSV could be delayed

fix countdown mode entering/exiting

Replace delayedButtonPress with resendDate

Not sure if this is better than the alternative, that is to delay CDC commands and reloaded config too.
Todo: countdown mode fixes

Measure lookup time macro

Takes 3x longer if launched via swd instead of bootloader. QSPI quad read seems to make it slower. Definitely something odd going on

possible compiler optimisation problems

enable config mtime checking

ftime field has a resolution of 2 seconds, so fast changes can be missed.
But this has the benefit of not wiping out CDC config when the OS does random other writes we might not have supressed (system volume information, DS_Store, etc)

remount filesystem, skip config mtime check

The remount does fix some occasional fs problems, which seems to just be stale data in fatfs window. This can be invalidated by doing USERFatFS.winsect = -1 but safest to remount properly.

The config ftime still sometimes misses changes if they're in quick succession. This might even be the OS not updating the value, or the low resolution (to the second). But realistically the config file is normally the only thing that gets changed so I don't think always reloading it is a problem.

manually manage Etc/ zones again

zonedetect backoff on usb read too

Only zonedetect once per nmea

Priority rewrite, avoid f_read in usb context

more typos

Typo

dos2unix generate-tzrules.py

Fix tzrules for zone names out of order

single script for flashing and creating disk image

mount as loop device is auto determined

create dummy 'system volume information'

Set qspi_write_time even if write fails

Only reload config if modified

update config template

Accept fake long/lat from config

day-of-week mode for no reason

if rtc valid but no tzrules file, copy out rtc rules string too

enum the loadrules error codes

reload map file each time

only copy rules string if rules valid

configure debug/release builds for date

same treatment for bootloader

fix linux eject sequence

util-linux eject sends a scsi start immediately before scsi stop

QSPI_STATUS consistency in bootloader

hacky delayed-check-on-eject

This is pretty much impossible to test anyway

Revert "Add fatfs read qspi timeout"

This reverts commit 70553aa.

Add fatfs read qspi timeout

Protection against qspi collisions

Not convinced the lock stuff is bullet proof. The zonedetect backoff is enough to fix it but there's still a remote chance something could get corrupted.

Also the chainloader ejection check runs in the usb interrupt context, if qspi was locked at ejection this could hang

fix typo (DP on error)

Don't connect USB during date update, unless an error occurs

force update version string on every build

reboot on eject if new firmware present

corrupted filesystem could cause f_crc to hang

Flashing script

rollover fix (hypothetical, from earlier)

we want at least the option of separate version strings

linker alignment fix and give bootloader same treatment

share version string method

ISO8601 build string, worth it

propagate changes to linker script

There's only one line difference between the scripts, we should probably try passing a parameter from the ide build options

Add build datetime string to firmware image

One disadvantage to this linker method is the error message is nonsensical if you overflow the pad region

update create-image.sh

fixup! Commit release script

Show firmware crc (date)

Firmware CRC display mode (for time)

Commit release script

Add firmware CRC script

Add notes about QSPI flash contents

Add example config.txt

Create disk image using loop device

Add example tzmap.bin (2023b)

Add example tzrules output

Add example timezone-names file

Specifically needs to match the shapefile version we're using

curl -L https://github.com/evansiroky/timezone-boundary-builder/releases/download/2023b/timezone-names.json | jq '.' > timezone-names.json

Newer tzrules generator, uses zdump and subprocess

Add original tzrules generator, depends on external csv

Add reboot CDC command

manual vbus detection for self powered version

Enable PA8 input and update IOC

Fix inverted date trailing dot error

In the inverted position, the decimal point is shown on the following digit. If the DP is the last character of the message, it would not set the cathodes and multiple dots would light up.

Pullup on usart2 rx

Without it and without date connected, frame error interrupt is called continuously

update default brightness curve for LDR

Enabling a mode via CDC should switch to it

Rip out io retarget stuff

USB Eject bootloader should retry update

Debug RTC mode + theoretical improvements to RTC cal

Critical fix to RTC calibration

When launched from ST-LINK or without bootloader, the calibration routine works fine. When launched by the bootloader, even if we jump out before anything is initialised, the calibration was way off. The LPTIM1 interrupt was running twice for each counter reset, because the interrupt ended before the flag had cleared. Clearing the flag at the start of the interrupt instead of the end fixes it.

Can't figure out why the launch method changes this, it must be something to do with the pipeline acceleration. Both bootloader and app have the same startup file, and it still happens if we skip out all of HAL.

display fault type (could make this more helpful at some point)

can have GPS satellites but no GLGSV message

suppress snprintf warning

"fix" hardfault on usb scsi command

When launched after firmware update, first boot causes hardfault when scsi command happens. The GetCapacity function pointer in USBD_Storage_Interface_fops_FS is corrupted.

Launching the older non-CDC firmware is fine. Disabling usb in bootloader has no effect. Zeroing ram or adding delays has no effect, but breakpointing with the debugger stops it happening.

Calling reset after a firmware update seems hacky but appears to fix everything.

bootloader: only init USB on error

prevents double-connect when booting normally

Alternate show tz name

Fix show offset

brightness curve in config file

prepare to rewrite brightness curve

pointless attention to detail

1.0 or +1.0 should be max brightness, 1 should be 1/4095

brightness override float optional

send full text buffer

dots are valid characters that don't take up a digit, so a valid message could be 20 bytes long. Might as well send it all

Brightness override

fix likely glitch with uart handler

was checking against UART_IT_RXNE instead of USART_ISR_RXNE

also explicitly hand errors over to the HAL routine

button delayed auto shift

date: don't disable swclk if button held at power on

iso week mode

Merge branch 'cdc-msc'

expand falsey

satview be explicit if there's no data

nmea output level, cleanup cdc transmit

add generic text display mode

rx config value can be empty

cope with config file not present

config cleanup on cdc commands

parse config from cdc input

fix MSC eject

fix descriptors for bus powered, 500mA

update build config with alt linker script

rip out unused descriptors

USB CDC MSC

CDC MSC Almost there

initial steps on usb msc+cdc composite

Add basic satview mode

countdown tolerance; handle on-the-fly changes

allow countdowns to the year 9999

Countdown enter/exit

Starting on countdown mode

Update eclipse env-hash nonsense

At some point we should clean/smudge this out of the repo

DP debug signals

update manufacturer/product/scsi description

Add brightness debug mode

Delay enabling uart2 interrupt until after doDateUpdate

swap buttons when inverted

proobably shouldn't be tracking these files

code generation using workspace 1.8.0

update ioc for code gen

update file names

code generated workspace 1.8.0

update .ioc for working code generation

update file name

Code generation using workspace 1.8.0

Update .ioc for correct code generation

update env hash

update project name

remove .launch files

Merge repos

Merge repos

Move everything into subdir

disable power on test message

Move everything into subfolder

Move everything into subfolder

Configure PC12 correctly to hide DP at power-on

Update to work with chips bigger than 256K

adjust animation speed to compensate for slower erase of the bank we're running our code from

don't light decimal points during bootloader

button 2 scrolls modes in reverse

swap colons, don't show DP during date update

env hash update

Add second button

Add second button

Colon flashing modes

Colons independently fading

Separate PWM for each colon pair

Fix .ioc, culprit was ADC1.DMAContinuousRequests=ENABLE

Interim checkin before .ioc corruption

Forget using DMA for ADC reading

Show offset mode and hook into USB write

Support for modes that don't display time

Use hyphens for reduced precision

Standby mode

Load tolerances from config file

Load modes from config file

sendDate cleanup

Reduce precision to align with accuracy

Rewire colons

Ordinal date

Julian date

Rudimentary support for decimal places

Debouncing

Button press changes date display

Initial button press mechanism

Move sTimeDPR to PC12

Calibrate RTC

Use LPTIM clocked from LSE

Better latch mechanism

Better latch mechanism

Improve rtc subsecond stuff

Load frequency from config

set frequency fixes

cmd set frequency

Cleanup

RTC subseconds somewhat accurate

Fixes to enable code generation

Fiddling with rtc timings

Smooth progress again

alternate progress bar

Smooth progress

Look ma no magic numbers

Progress bar progress

chainloadin' is a happenin'

Use 8E1 always to avoid having to switch

Recovery logic in place

Chainloader progress

Chainloader progress

Move display matrix to TIM7 to free up UART2 TX DMA channel

More RTC stuff

RTC basics

Ticking

Further fiddling with pointless parts

Buffer wheel thing

Mostly lame progress bar

We has a bootloader

Main structure in place

Checks CRC of firmware image on fatfs

Possibly better way of filling the flash, still lets us see usage

One method of filling the flash

Initial commit with working display, QSPI FATFS and USB

Safely shift code section to make room for bootloader

Power up at lowest brightness

Interim checkin

Enable RTC, fiddle with brightness stuff

Use 10MHz HSE, MSI for USB

Initial commit

Use timer and DMA to smooth DAC output, add brightness curve

DAC controlled brightness circuit, needs linearity correction

In-phase ADC apparently functioning

PWM generation on PA0 from timer 2 ch1

Use timer4 for display to free up timer2

Use coords from nmea

Use int32 for offset

Almost there

Apply rules to time

Change to little-endian, 6 byte format

Use TCXO, read binary rules file

PPS ticking

BCD routines

output unix time

DMA read line and check checksum

Trigger uart interrupt on character match

config stuff

Start on parsing config

USB Preemption priority

error handler

Move vectors to ram and swap systick

basic ticking

Matrix stuff

Zonedetect working

Add zonedetect files

Add locking to potentially avoid concurrency issues

Update .ioc, remove QSPI DMA

FatFS

USB MSC working

cleanup

more junk

junk

More work on qspi driver

Starting on QSPI driver

Enable printf

Initial commit
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant