Skip to content

Second civil timezone as a live clock on the time row (MODE_ZONE2 + zone2 key) - #15

Draft
peterlewis wants to merge 124 commits into
mitxela:masterfrom
peterlewis:zone2
Draft

peterlewis wants to merge 124 commits into
mitxela:masterfrom
peterlewis:zone2

Conversation

@peterlewis

@peterlewis peterlewis commented Jul 21, 2026 •

Copy link
Copy Markdown

Adds a second civil timezone — MODE_ZONE2 — shown as a live clock on the time row, the way MODE_LST and MODE_SOLAR show their timebases, and named by a new zone2 config key. A correct second zone: it walks the same DST rule engine as the primary timezone, so it tracks real transitions, not a frozen offset. The date row keeps the local civil date, and the colons switch to colon_alt_mode, so the row can never be mistaken for local time.

Stack: based on cuckoo (#14); the top of the #5 → #15 series. Merging the series in order reproduces the rollup byte-for-byte.
Try it now: fwt.bin · fwd.bin — the union of this PR stack, byte-identical to the merged result.


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 / proposal. A self-contained, opt-in second-timezone display for the Mk IV. Verified natively and in the WASM emulator (see Build & test); it stays a draft for your review.

What this adds

One new display mode, disabled by default:

Mode Time row Date row
MODE_ZONE2 the second zone's HH:MM:SS and sub-seconds, with the alternate colon animation the local civil date

The big digits tick the second zone's time, quantised to civil seconds exactly like LST and SOLAR, so the sub-second digits and the holdover dashing come from the civil clock unchanged. With no zone configured — or an IANA name that hasn't resolved yet — the time row shows dashes, never a fabricated time.

Configuring it — the zone2 key

The mode is enabled like any other (MODE_ZONE2 = enabled, or from the on-device menu once #13 lands). Which zone it shows is set by a new zone2 key in config.txt:

MODE_ZONE2 = enabled

# A fixed literal — resolved immediately, no SD card / FATFS needed, no DST:
zone2 = UTC
zone2 = +05:30
zone2 = -08:00

# …or an IANA zone name — resolved from TZRULES.BIN / TZMAP.BIN (the same tables
# the primary zone uses), so it follows real DST transitions:
zone2 = America/New_York
zone2 = Europe/Paris
  • Literal offsets (UTC, +HH:MM, -HH:MM) resolve at parse time with no filesystem access, so they work even without the timezone tables on the card.
  • IANA names are resolved from the same TZRULES.BIN / TZMAP.BIN the primary zone already loads, so the second zone gets correct DST — the offset is recomputed per second through the rule engine, not frozen. Until the name resolves (or if it isn't in the tables) the time row shows dashes.

Design

  • Shared rule engine. loadRules() gained a destination-pointer parameter so the DST rule set can be parsed into a second table (rules2[]) instead of only the primary one. zone2_offset_at() walks that table exactly as the primary zone does; a literal short-circuits the walk. This is what makes it a correct second zone rather than a fixed number.
  • Deferred load. IANA resolution touches FATFS, which isn't safe from every context, so the name is stashed (preloadZone2) at parse time and resolved in the same delayed-load path the primary rules use.
  • Alternate timebase. MODE_ZONE2 joins MODE_LST / MODE_SOLAR (is_alt_mode()): alt_update() stages the next second's reading in thread context — here plain integer maths, civil UTC plus zone2_offset_at() — and the SysTick_Alt_* handlers latch it at the .900 prep, so no table walk or FATFS ever runs in an interrupt.

Honest note on completeness

The zone2 enable toggle is reachable from the on-device two-button menu (it appears in the calendar-modes section), but the zone2 target — the actual city/offset — can only be set in config.txt, not from the clock's buttons. A city picker on the device would need a scrollable name list that the current menu doesn't have. So this ships as a config-set, menu-toggled feature; that limitation is deliberate and called out here rather than hidden.

Build & test

Build mk4-time in Release. The mode's behaviour is covered by a native/WASM harness (zone2_check) that checks: dashes on the time row while the zone is unset or still resolving; the fixed-offset literal paths (UTC / +HH:MM / -HH:MM) computed from disciplined UTC; an IANA name (Europe/Madrid) through the deferred loader and the real TZRULES.BIN — CEST in July, CET in January; the live tick against an independent oracle; the local date kept on the date row even when the second zone is on another day; the alternate colons; and the civil clock restored on leaving the mode. It stays a draft for your review, not for want of testing.

Files

  • mk4-time/Core/Inc/main.h — MODE_ZONE2 enum entry.
  • mk4-time/Core/Src/main.c — the struct tzrule tag and the rules2[] second rule table; loadRules() destination-pointer parameter, loadZone2Single(), zone2_offset_at(), zone2_parse_fixed(); the zone2 + MODE_ZONE2 config keys; MODE_ZONE2 in the alternate-timebase family (is_alt_mode(), alt_compute()); the menu enable-row.
  • qspi/config.txt — the zone2 key and the MODE_ZONE2 toggle, documented.
  • docs/firmware-additions.md — the second-timezone section.

Update 2026-09-28: ZONE 2 moved from the date row to the time row, as a live clock alongside LST and SOLAR: the date-row readout (a label page, then HH:MM:SS with a +1/-1 day marker) was hard to read at a glance. The zone2 key, the rule engine and the menu toggle are unchanged.

🤖 Generated with Claude Code

peterlewis and others added 8 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>
Wires up sun_times()'s civil/nautical tiers (previously passed NULL) and adds the -18 deg
astronomical tier + dawn-by-symmetry. New MODE_DARK pages the ladder (civil/nautical/astronomical
dusk + astronomical dawn) with a live countdown, MODE_SUN-style aligned columns. Honest edges:
no fix -> dashes, white night -> NO DARK, polar night -> DARK NOW. Config: MODE_DARK = on.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The twilight-ladder mode ships parseable from config.txt (main.c already
accepts the MODE_DARK key) but was undocumented, so the only way to find
it was to read the parser. Adds the block alongside the other astronomy
modes and lists it in the page_ms paged-mode set it belongs to.
…tro_dusk

sun_times() grew a tenth out-parameter (astro_dusk) when MODE_DARK landed;
main.c was updated but the three call sites in test/test_astro.c were not,
so the native suite has not compiled since. Restores the build and adds the
two cases the new parameter exists for: Greenwich on 21 June, where the sun
stops at -15.1 deg and darkness is correctly reported absent (nan), and
Fairbanks in December, where dusk lands at 27.301764 (03:18 UTC, sun at
-17.98 deg by an independent solar-position check).

  cc -std=c99 -O2 -I ../Core/Inc ../Core/Src/astro.c test_astro.c -lm
  47/47 passed, 0 failed
peterlewis and others added 17 commits July 26, 2026 17:02
The page_ms comment named SUN/LATLON only, omitting MODE_DARK from the
tier that adds it. States the membership rule and the expression each
paged mode uses, so a new paged mode never falsifies it.
- 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>
…-loop stall

$PMLOOP on real hardware pinned the once-per-second main-loop stall to tag 3
(the timezone lookup): a steady $PMLOOP,300,3 every second on a STATIONARY,
GPS-locked clock.

Root cause: the NMEA parser sets new_position on every 1 Hz fix, and the main
loop answers it by re-running the full ZoneDetect lookup — f_open TZMAP.BIN →
ZDOpenDatabase → polygon search, all off the QSPI FATFS, ~300 ms. With no
distance threshold, metre-scale GPS jitter re-ran the whole thing every second:
the clock re-decided it was in Europe/London 60×/minute. That 300 ms stall is
what starved the seg-balance mirror refill (the once-per-second BALANCE freeze —
the ISR-fresh-mirrors fix cured the symptom, this cures the cause) and is the
dominant contributor to the date-board OVRDIS byte-drop window.

Fix: gate the lookup on real movement — skip unless the fix moved >0.005°
(≈0.5 km) since the last lookup. ~100× the jitter of a still clock (so it looks
up exactly ONCE), far finer than any timezone boundary (so a moving clock still
re-detects within ~0.5 km of a crossing). The per-loop distance check is a
handful of FP ops. Device-main-loop only — inert in the emulator (which never
runs main()'s while(1)); emu + ARM both build clean, date board unchanged.

Co-Authored-By: Claude Fable 5 <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>
… too

The local_solar_time check added here calls sun_times() for its solar_noon
only, and was written against the nine-argument signature. Same 10-arg fix
as the astro tier; the suite builds and passes again from this tier up.
The shipped example and its prose read alt_colon_mode; the parser matches
only colon_alt_mode, so uncommenting the line silently did nothing.
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>
…efault off)

The display is voltage-driven (buffer chips + 10R per segment; the DAC sets the rail),
so a digit's lit segments share the drop across its common return path — FEWER segments
leaves more voltage per LED, and '-', '1', '7' glow visibly brighter than '8', worst at
low brightness. There is no per-digit analog knob, but the scan buffers are sized [80]
with the DMA circular: replay the 5-step scan as 16 cycles of 5 slots.

Slots 0..4 remain the live masters every existing writer already targets (206 write
sites, none changed). segbal_poll() — main loop, at most 1 kHz — mirrors them into
slots 5..79 with each digit lit in s of its 16 cycles, s = 16 - strength*(16-2N)/100:
at full strength s = 2N and every lit segment averages the same duty (1/N * 2N/16 = 1/8),
uniform across the display; dense digits are untouched, sparse ones dim to match. The
cycle mask (k*s)%16<s spreads the lit cycles evenly (worst refresh ~7.8 kHz) and always
lights cycle 0 — the master — so masters count toward the duty exactly.

GPIOB mirror slots keep the bCat column-select bits in every cycle; buffer_c[].high is
GPIOC's one-cold column select + enables (NOT segment data — an earlier draft duty-cycled
it and adversarial review caught the mis-addressing) and is carried verbatim except
cSegDP, the digit's decimal point, which is counted in the digit's N and rides its duty.

Config: seg_balance = on (=100) or 0..100 percent; default 0 = stock scan, stock timing.
display_scan_len shadows the DMA count so the poll steers 5<->80 across standby (guarded:
never restarts DMA in MODE_STANDBY) and the date-board chainloader's 40-slot takeover.

Verified: WASM build of this source — exact duty counts at strength 100/50, GPIOC
addressing byte verbatim in all 16 cycles, DP synced to its digit, live enable/disable/
re-enable clean (20/20 checks); ARM Release build 0 errors; independent line-by-line
review verdict SOUND.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…hout the diode knee

Hardware A/B at low brightness showed strength 100 (s = 2N, the linear map) still leaving
sparse digits visibly brighter. That matches the drive physics: near the LED forward-voltage
knee, segment current is exponential in per-segment voltage, so a dense digit's shared-path
droop collapses its current by MORE than the linear 1/N model — the true bloom ratio can
exceed the 8x a linear duty map can counter.

segbal_duty(): 0..100 keeps the exact linear blend (100 -> s = 2N, unchanged behaviour);
101..300 becomes s = 16*(N/8)^(strength/100) — continuous at 100 (gamma 1), floor 1 for lit
digits. At 200 a 1-segment '-' runs 1/16 duty against the 8-segment '8.''s 16/16: a 16x
range on a knee-shaped curve. seg_balance widens to uint16 (atomic on M4), parse clamps to
300. Tune live over serial against the real display.

Verified: WASM slot checks green at 100/50/200/300 incl. the 999->300 clamp (segbal_check);
ARM Release 0 errors.

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

Hardware A/B confirmed the bloom is rail-dependent: one fixed strength over-dims sparse
digits at full brightness while under-correcting at 5%. The firmware already knows the
rail (dac_target, 4095 = dimmest), so interpolate the effective strength between two
live-tunable anchors: seg_balance (dim end) and seg_balance_bright (full brightness).
Unset (default) keeps constant strength — prior behaviour unchanged. -1 unsets. Tune
each anchor at its own operating point over serial; the clock then compensates correctly
across the whole auto-brightness range.

Verified: WASM slot checks green at both rail ends (dac 4095 -> dim anchor's curve,
dac 0 -> bright anchor's curve) + unset fallback; ARM Release 0 errors.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
peterlewis and others added 10 commits September 27, 2026 21:28
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Refresh the QSPI release images from the tree: fwt.bin gains the ZoneDetect
distance gate; fwd.bin carries the ISR-fresh-mirrors date-board build (the
date board has no tz code, so it is unchanged by 347f0af itself).

(Split out of the segbal/tz rollup commits — artifact half; the code halves
live on stack/segment-balance and stack/firmware-hardening.)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The exact residue between the pure union of the nine stacked PR tiers and
the emulator-verified + hardware-run rollup tree @ 347f0af. After the
round-5 re-homes (MODE_TEMPCOMP cadence/enum -> tempcomp tier, segbal fade
mirror -> segment-balance tier, golden-config mode docs -> their feature
tiers, subsolar helper -> sidereal tier) what remains is comment wording,
comment placement and whitespace, plus one default-off config key line:

- evil-merge comment wording from the original merge commits (10c8bf6 /
  15567d1), not cherry-pickable as tier commits: tc_seed trigger comments
  (x3 sites), page_ms comment wording (x4 sites), postConfigCleanup /
  applyColonForMode 'sidereal colon' wording, delayedPostConfigCleanup and
  astro repaint notes, a comment-block move around write_rtc, and two
  whitespace-only line tweaks.
- 16ece45/8515aa7 astro comment refinements (gmst_hours shared-series
  comment, local_solar_time wrap note, LST/solar test-anchor comments +
  one blank line) that postdate the relocated subsolar commit's content.
- config.txt: the significance_fade ladder paragraph incl. the
  'significance_fade = off' key line (default-off; absence is
  behaviourally identical), the mode_tempcomp wording, one trailing-space
  line, one blank line.

No functional code differs: all firmware logic, data tables, binaries and
build inputs are byte-identical before this commit except the lines above.

After this commit the stack tip is BYTE-IDENTICAL to rollup @ 347f0af while
the linear ancestry contains exactly the nine tiers' assigned commits.

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

Hardware report: opening SYS > MATRIX and adjusting it corrupted the display
(digits flickering between garbage values) and then froze the clock hard —
only an unplug recovered it.

Cause: s_matrix live-previewed by writing the display SCAN TIMERS (TIM1->ARR
and TIM7->ARR) on every tap. TIM7 clocks the buffer_c -> GPIOC display DMA, so
hammering its reload mid-edit — racing the seg_balance scan reconfiguration
(setDisplayPWM abort/restart of the same DMA) — stalled the very display the
menu renders on. With the render dead the user can't see the menu to escape,
and the REBOOT chord can't be confirmed either. The emulator proved the value
logic is correct (clean accel, clamps at the 8 kHz floor, no oscillation), so
this is purely the hardware live-apply.

Fix: s_matrix now scrubs the shadow value only; the rate is applied ONCE via
setDisplayFreq in menu_commit_edit / menu_cancel_edit (which already called it
as the authoritative sync). The number still previews (the L3 render reads
menu_val), so nothing is lost — only the destabilising live hardware write is
gone. All menu suites green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The action erases the menu store AND the learned tempcomp model
(tc_forget + tc_reset) before re-reading config.txt — that is a factory
reset, and the old name undersold exactly the part that hurts: the
per-unit learned calibration. Serial-only key, never valid in
config.txt, so nothing stored can reference the old name.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
MODE_DARK gets a menu row (DISP>ASTRO). Adding it pushes MODE_STAR to ordinal 32, which overflows
the u32 mode-enable bitmask, so ovr.modes_mask/modes_val + cfg_modes_defined widen to uint64_t
(1ull<<m throughout). The two extra high words store in the record's reserved bytes 40/44, so old
settings round-trip untouched and EE_SCHEMA stays 1.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The docs told users to send menu_reset, which the parser no longer accepts
(it is factory_reset, no alias). config.txt called the reboot gesture the
RESET chord, but RESET labels the destructive menu row and REBOOT is the
chord that restarts. MODE_DARK was config-enabled but undocumented.
…tore

The settings store picked its medium from the die's flash size: two spare
internal-flash pages on 1 MB STM32L476RG parts, SETTINGS.BIN on the QSPI
volume on 256 KB RC parts. Drop the internal-flash backend so every clock
uses SETTINGS.BIN, whichever part it was built with.

Upstream's bootloader already treats every die as 256 KB (a764935: boot +
app always end at 0x08040000), and every board has the same QSPI chip, so
one backend covers both. The internal-flash path only ever ran on RG
silicon and the emulator models the QSPI path, so it was the least-tested
code in the store.

- ee_init_base always resolves /SETTINGS.BIN; no usable file means
  RAM-only, exactly as on RC before
- remove EE_BK_INTERNAL, the HAL flash unlock/erase/program branches, and
  the per-medium ee_slots/ee_pgsz (now the EE_SLOTS/EE_QSPI_PGSZ constants)
- menu_dump keeps its line format and still reports flash=...KB, now the
  way to tell an RC from an RG

RC behaviour is unchanged. An RG clock upgrading from an earlier beta
starts from config.txt, since its internal-flash records are no longer
read. With no SETTINGS.BIN, menu_dump now prints page_a=00000000 instead
of an internal-flash address. Docs updated to match; the qspi/output
release images are not rebuilt here.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Fw9XWENLiPxKA2HxxTnHh7
…st settings

The settings store lives in SETTINGS.BIN on every clock, but the file only
ever existed where flash.sh generated it while formatting a fresh volume.
A drive updated by copying files, the common route, never got one, and
there was nothing in the repo to copy: such a clock ran RAM-only, losing
every menu edit at power-off.

output/settings.bin is the blank store (16 KiB of 0xFF), alongside the
other drive files. flash.sh now copies it instead of generating it, still
first on the fresh volume so it stays contiguous, and refuses anything but
the blank store: a used one copied back off a clock would provision every
new drive with that clock's settings.

The release zip carries it in its own settings/ folder, never in flash/.
Copying flash/ onto the drive is how firmware gets updated, and a blank
store copied over a live one loses its settings at the reboot that applies
the update. qspi.md documents the copy route and that rule.

The one-liner in qspi.md now sets LC_ALL=C (BSD tr rejects \377 in a
UTF-8 locale) and uses head -c, which cannot short-read the way
dd count=4 can from a pipe.
A .gitattributes rule marks every .bin as binary. The blank store has
no NUL byte, so git's heuristic took it for text and diffed it as one
16 KB line, which is how GitHub rendered it in the pull request.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The brightness sentence enters the stack at mitxela#10, below the manual, so it is
documented here with the others: brightness_report beside seg_balance and
colon_balance, and the sentence's fields beside $PMTXTS.

The golden-config rewrite earlier in this tier drops the key's example from
config.txt, as it drops the colon anchors; this restores it in the same
place. From this tier the menu's BRIGHT sets the same manual override as
the brightness key, and the firmware's field comment now says so.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
brightness_report gains an on-device toggle beside PPS MSG, the other sentence switch. It is
recorded in the menu store like every simple key: KID_BRIT in simple_mask, the value in byte 48,
applied at boot by menu_apply_overrides, and a config.txt that sets brightness_report takes
precedence by the usual mtime rule.

KID_BRIT is 13 because 12 is KID_CUCKOO, next in the stack, so a store written by either build
reads the same. Byte 48 was zeroed padding in every prior record, so old records read BRT MSG as
never set and EE_SCHEMA stays 1. The row costs 200 B of flash; no new warnings.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
peterlewis and others added 13 commits September 27, 2026 23:46
…nger rolls the model back

tc_persist_after_seed restores the stored sample counts and residual after the boot seed, guarded by
tc_persist_seeded, which was set at boot and never cleared. readConfigFile ends with the same seed tail
on every reload, so each edit of config.txt rolled the live counts and residual back to the last stored
snapshot, re-capped the prior order, and pushed the next save back by up to 30 minutes. The
coefficients were never touched. The flag now clears on use.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ur piece

A cuckoo is a short, scheduled display flourish on the TIME row (the
date row is never addressed). Cadence is intrinsic, after the
Westminster pattern: a fixed small gesture — the NOD, a shallow
brightness trough sweeping the six big digits left to right in ~550 ms —
on each quarter-hour, and the configured piece on the hour. One config
key, enable-and-select fused:

  cuckoo = off        # off (default) | trust

trust: the time row dips to a dim x-ray, then a relight wave walks
HH -> MM -> SS -> ds -> cs -> ms, each digit relighting to the
confidence the tolerance ladder actually holds for it right now. Fully
locked earns one unified pulse; in holdover the wave dies at the honest
position. The final frame of every flourish is the plain live face, and
the clock never shows wrong digits.

Engine: pieces write per-digit INTENT levels (0..16, meaning perceived
lightness) which the seg_balance dither mirror composes into duty
through a perceptual gamma (16*(L/16)^2.2 — light is linear in lit
cycles, the eye is not; bench-verified). One interleave engine, two
clients; with cuckoo = off the display path is byte-identical to stock.
Every envelope is a terminating integer countdown. MODE_STANDBY and
text/countdown modes refuse and abort — a dark clock stays dark.

Preview: the on-device menu row (DISP > CUCKOO) PERFORMS each value as
it is selected — the menu's colon-preview idiom applied to the
flourishes — and `cuckoo_preview = trust|nod|on` over USB serial plays
once without touching the config. Selection persists in the on-device
settings store (new key id in previously-zeroed record padding; old
records round-trip; schema unchanged).

Additional pieces (carry, heartbeat, pendulum, rain) are specified in
CUCKOO_SPEC.md and parked pending visual rework; their names parse to
off. See the spec for the full design, including the stand-in law and
the hardware reasons the feature is time-board-only.

Verified: WASM conformance harness 23/23 (envelopes, termination,
quarter/hour scheduling through real boundaries, off byte-identity,
parked-name safety, preview semantics); settings-store and
factory-reset suites pass; ARM Release clean (174756 B text); running
on a bench Mk IV.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The tier ships the shared reference but never described its own feature.
MODE_ZONE2 pages a remote city's live HH:MM:SS, computed from GPS-disciplined UTC + the on-device
tzrules.bin, so it stays DST-correct through every transition — unlike a hardcoded offset. Named by
the `zone2` config key: a full IANA name (Europe/Tokyo), or a fixed literal (UTC / +05:30). A second
DST table (rules2[]) via a dest-pointer refactor of loadRules(); dashes when unset/unresolved. Set
from config.txt, consistent with the primary zone (auto-detected or zone_override) — no menu editor.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The fwt.bin carried through the reflow rebase was the pre-reflow cuckoo-era
binary (31 display modes) — it predates MODE_DARK and MODE_ZONE2 at their
reflowed enum positions, so flashing it would ship firmware missing both new
features. This is the freshly-built union image from the reflowed source:
33 modes including MODE_DARK (ordinal 27) and MODE_ZONE2 (ordinal 33).

Firmware source is byte-identical to the tree already committed here; only the
compiled release image changes. version.c is untouched (0.0.5 frozen; BUILD_ID
handles reflash uniqueness).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The MODE_ZONE2 / zone2 keys already parse, but weren't listed in the shipped
config.txt so they weren't discoverable there. Document both, matching the file's
style: the `zone2` target beside ZONE_OVERRIDE (its natural home — same IANA /
fixed-offset format), and the `MODE_ZONE2` enable toggle with the other date-row
mode keys. Comments only + two commented/disabled defaults; no behaviour change,
no effect on the firmware image.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Last of the three features missing from the stack shared reference.
The committed images had drifted from the tree: fwt.bin was several
source-changing commits behind and fwd.bin still matched upstream despite
the date-board rewrite. Rebuilt with the STM32 GNU 14.3 toolchain and
re-CRCed via qspi/release.sh. BUILD_ID and __DATE__/__TIME__ are re-stamped
on every build by design, so these images are never byte-reproducible; that
per-build stamp is what the bootloader CRC uses to tell reflashes apart.
Built at the rewritten rollup tip: CRLF date-board source, seg_balance
shipped commented, the full doc set. Stamps 0.0.5+1785083826 (time) and
0.0.2+1785083826 (date); the new BUILD_ID is what the bootloader CRC sees,
so reflashing registers as a new image.
The committed time-board image predated the settings change, so it still
carried the internal-flash backend: an RG clock flashed from it would keep
ignoring SETTINGS.BIN, contradicting qspi.md. Rebuilt from this tree with
the STM32 GNU 14.3 toolchain and CRC-wrapped via fw-crc.sh. Stamp
0.0.5+1790493307.

fwd.bin is deliberately left as it was: the date-board source is
unchanged, and a rebuilt image would carry a new build stamp, which the
bootloader reads as a new image and reflashes the date board for nothing.

The app image is 174,204 B of the 196,544 B region, down 4,632 B: the HAL
flash erase and program paths the store no longer uses are gone.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The time-board image now carries the brightness sentence added at mitxela#10
(brightness_report, off by default). Rebuilt from this tree with the STM32
GNU 14.3 toolchain, every object compiled fresh, and CRC-wrapped via
fw-crc.sh. Stamp 0.0.5+1790541120. The app image is 174,932 B of the
196,544 B region, 728 B more than before; no new warnings.

fwd.bin is unchanged: the date-board source is untouched, and a rebuilt
image would carry a new build stamp and reflash every date board for
nothing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The time-board image now carries the SYS > BRT MSG menu toggle for the brightness sentence,
added in the menu tier. Rebuilt from this tree with the STM32 GNU 14.3 toolchain, every object
compiled fresh, and CRC-wrapped via fw-crc.sh. Stamp 0.0.5+1790545649. The app image is
175,076 B of the 196,544 B region, 144 B more than before; no new warnings.

fwd.bin is unchanged: the date-board source is untouched, and a rebuilt image would carry a
new build stamp and reflash every date board for nothing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The time-board image now carries the menu tier's fix that stops a config.txt reload rolling the
learned tempcomp model back to its stored snapshot. Rebuilt from this tree with the STM32 GNU 14.3
toolchain, every object compiled fresh, and CRC-wrapped via fw-crc.sh. Stamp 0.0.5+1790549238. The app
image is 175,084 B of the 196,544 B region, 8 B more than before; no new warnings.

fwd.bin is unchanged: the date-board source is untouched, and a rebuilt image would carry a new
build stamp and reflash every date board for nothing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
peterlewis and others added 2 commits September 28, 2026 00:02
ZONE 2 joins the alternate-timebase modes: the time row ticks the second zone's time, the date row
keeps the local civil date, and the colons take colon_alt_mode so the row can't be mistaken for local
time. It replaces the date-row readout (a label page, then HH:MM:SS with a +1/-1 day marker), which
was hard to read at a glance.

The reading is integer: civil UTC plus the zone's offset, staged by alt_update like the sidereal and
solar readings, so the sub-second digits and holdover dashing come from the civil clock unchanged.
Until zone2 resolves (unset, or an IANA name still waiting for the deferred loader) the time row shows
dashes, as LST does without a position. is_alt_mode() names the family in one place. The app image
grows by 168 B; no new warnings.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The time-board image now shows ZONE 2 as a live clock on the time row, beside LST and SOLAR. Rebuilt
from this tree with the STM32 GNU 14.3 toolchain, every object compiled fresh, and CRC-wrapped via
fw-crc.sh. Stamp 0.0.5+1790550105. The app image is 175,252 B of the 196,544 B region, 168 B more than
before; no new warnings.

fwd.bin is unchanged: the date-board source is untouched, and a rebuilt image would carry a new
build stamp and reflash every date board for nothing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@peterlewis peterlewis changed the title Second civil timezone on the date row (MODE_ZONE2 + zone2 key) Second civil timezone as a live clock on the time row (MODE_ZONE2 + zone2 key) Sep 27, 2026
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.

2 participants