Skip to content

plan 0010: local timezone offset + LED day/night brightness - #147

Open
TheAngryRaven wants to merge 1 commit into
BETAfrom
claude/datalogger-timezone-support-jn5noh
Open

plan 0010: local timezone offset + LED day/night brightness#147
TheAngryRaven wants to merge 1 commit into
BETAfrom
claude/datalogger-timezone-support-jn5noh

Conversation

@TheAngryRaven

Copy link
Copy Markdown
Owner

Summary

Gives the device a local wall clock so the NeoPixel strip can dim after dark on the driver's schedule — "7am" has to mean their 7am, not Greenwich's.

Nothing in the firmware derived local time before this; gpsData.hour is raw UTC out of the PVT callback. The failure mode without an offset isn't subtle: a US Central driver at 07:30 local is at 12:30 UTC, which a naive UTC gate calls the middle of the night and dims the strip in broad daylight.

New pure unit local_time.{h,cpp} — a fixed signed minute offset applied to a 4-digit-year DateTime, with correct date rollover both directions across month, year and leap-day boundaries. Plus isNight(), which tests the window [nightStart, dayStart) modulo the day, so the ordinary wrapped case (19:00 → 07:00) needs no special casing at the call site. Equal bounds = empty window = swap disabled, no separate enable flag to keep in sync.

Three details worth flagging:

  • Minutes, not hours. India is +5:30, Newfoundland −3:30, Chatham +12:45.
  • Four-digit year. GpsData.year stays 2-digit for the existing filename/header format strings; the unit takes 4 digits and callers add 2000. The leap rule (gps_time::isLeapYear, reused rather than re-derived) needs the century.
  • An out-of-band offset is ignored, not clamped. A corrupt setting must not be able to walk the calendar; it falls back to 0, which is exactly the pre-0010 behaviour.

The consumer is npxEffectiveBrightness() feeding npxPushFrame(). That was already the single global-brightness choke point (led_frame::applyCap), so the whole feature is a change of argument at one call site and the "no channel ever exceeds the cap" invariant is untouched.

Two traps handled explicitly:

  • No time lock means DAY. gpsData.timeValid needs fullyResolved, up to ~12.5 min from a cold start. Rendering night brightness in that window would mean a strip that comes up dark and reads as broken hardware.
  • A night cap of 0 blanks the frame; it does NOT cut the 5 V rail. Only led_brightness 0 does that. Power-cycling the boost converter at 19:00 mid-session is not a brightness change.

Deliberately out of scope

No DST. A fixed offset walks the boundary an hour twice a year, which is beneath the resolution of a dim-after-dark gate. Rule tables are a standing correctness liability (legislatures keep moving the dates) and tzdata is ~100 KB shipped to a sealed device. local_time is where rules would go if that changes.

Local time never touches saved data. DOVEX row timestamps are Unix epoch ms, and the header datetime, log filenames and generated course names all stay UTC. A log is routinely viewed somewhere other than where it was recorded, so the conversion belongs on the presentation side where the reader's preference is known. Nothing in the logging pipeline may call into local_time — the header comment says so.

Also fixes: the settings file was one key away from a boot loop

Adding four keys turned up a latent cliff. settings.ino had a 512-byte file buffer and a StaticJsonDocument<512>, every read path caps at sizeof(settingsFileBuffer) - 1 (511), and the existing 18-key file already serialized to 436 bytes. The four keys here put it at 543.

Past the cap the failure is silent and self-inflicting:

  1. read() truncates at 511 bytes, mid-token.
  2. deserializeJson returns IncompleteInput, so every getSetting() fails — not just the new keys.
  3. SETTINGS_SETUP() reads that as corruption, quarantines to SETTINGS.json.bad, regenerates fresh defaults — new random BLE name, new PIN, new device name; the user's pairing is gone.
  4. ensureDefaultSettings() adds the keys back one read-modify-write at a time until the file crosses 511 again.
  5. Loop, every boot.

The document was a second independent wall: a <512> doc returns NoMemory at 22 string pairs (measured against the pinned ArduinoJson 6.21.5, not estimated).

Both raised to 1024 — they must stay equal, the invariant being that the buffer can always hold what the document serializes — and setSettingInner() now refuses any write whose document overflowed() or whose measureJson() exceeds the buffer. That turns a silent permanent brick into one loudly refused write with the previous file intact. Costs ~1 KB RAM.

Adding a settings key is no longer free — check the serialized size.

Type of change

  • New feature / behavior
  • Bug fix (no user-visible behavior change beyond the fix) — the settings buffer ceiling above
  • Refactor (no behavior change)
  • Tests only
  • CI / tooling / docs
  • Breaking change (track files, log format, BLE protocol, or a removed mode)

Not breaking: the log format, filenames and BLE protocol are byte-for-byte unchanged, and the four new settings keys auto-populate with defaults that reproduce the previous behaviour (utc_offset_min 0 = UTC).

How it was verified

  • Host unit tests pass — 507 cases / 325,581 assertions, -Wall -Wextra -Wpedantic -Werror clean. 30 of those are new local_time cases: real-world zones (+5:30, −3:30, +12:45), rollover in both directions across month/year/leap-day boundaries, an exhaustive all-1440-minutes sweep proving the night window is half-open so no minute is both day and night, out-of-band offsets ignored, and no-2000s-assumption cases (1999→2000, 2100 non-leap).
  • clang-tidy clean — not run locally (no toolchain in this environment); CI covers it.
  • Compiles for the XIAO nRF52840 Sense — not verified locally, no arduino-cli available. CI's compile-sketch is the first real check.
  • Native sim builds the real firmware TU and all 6 sim tests pass (boot soak, determinism, golden fixtures, both lap oracles, two-session carryover). Golden frame hashes unchanged, as expected — the LED strip isn't in the framebuffer. Sim warning count is 10 both with this change and on stashed BETA, so no new warnings.
  • ArduinoJson sizing measured empirically against the pinned 6.21.5 rather than estimated — that's how the NoMemory wall was found.
  • Tested on real hardware — not done. Needs a bench check that the strip actually swaps caps at the configured local hour and that a device with a populated SETTINGS.json still reads its settings after the buffer bump.

Checklist

  • CHANGELOG.md updated under [Unreleased] (Added: the feature; Changed: the settings buffer fix)
  • ARCHITECTURE.md / CLAUDE.md updated — new subsystem 17, the local_time pure unit in both file maps, settings table + key constants, and two new ARCHITECTURE design-decision sections (Local time is presentation-only, The settings file has a hard size ceiling)
  • New testable logic has a matching test in tests/local_time_test.cpp
  • Branch is focused — one plan (0010), one commit. The settings buffer change is not a drive-by: this branch is what pushes the file over the cap, so shipping the keys without it would ship the boot loop.
  • docs/plans/0010-local-timezone-led-day-night.md records the rationale, including the cliff

Notes for review

  • Night mode is a brightness cap, not a different look. Chosen for v1 because the cap is one argument at an existing choke point; a day/night palette is listed as a follow-up in the plan.
  • settingUtcOffsetMin is extern'd in neopixel.h even though it's conceptually device-wide, because the LED strip is its only consumer today. Flagged in a comment to move it if a second one appears.
  • The four new settings keys are written on every channel (uniform SETTINGS.json shape, following the SensorEgg DOVEX-column precedent) but only read by a BIRDSEYE_ENABLE_NEOPIXEL build.

Generated by Claude Code

Give the device a local wall clock so the NeoPixel strip can dim after
dark on the driver's schedule: "7am" has to mean their 7am, not
Greenwich's. A US Central driver at 07:30 local is at 12:30 UTC, which a
naive UTC gate calls the middle of the night.

New pure unit local_time.{h,cpp}: a fixed signed minute offset applied to
a 4-digit-year DateTime with correct rollover both ways across month,
year and leap-day boundaries, plus isNight(), which tests the window
[nightStart, dayStart) modulo the day so the ordinary wrapped case
(19:00 -> 07:00) needs no special casing at the call site. Equal bounds
mean an empty window, which is how the swap is disabled without a
separate flag. Minutes rather than hours because India is +5:30 and
Newfoundland -3:30. An out-of-band offset is ignored, not clamped: a
corrupt setting must not be able to walk the calendar. 30 host tests.

The consumer is npxEffectiveBrightness() feeding npxPushFrame(), which
was already the single global-brightness choke point, so the invariant
that no channel exceeds the cap is untouched. Two rules there: no time
lock means DAY (timeValid can be ~12.5 min out from a cold start, and a
strip that comes up dark reads as dead hardware), and a night cap of 0
blanks the frame without cutting the 5 V rail -- only led_brightness 0
does that.

No DST, deliberately. A fixed offset walks the boundary an hour twice a
year, beneath the resolution of a dim-after-dark gate; rule tables are a
standing correctness liability and tzdata is ~100 KB on a sealed device.

Logged data is unchanged and still UTC. DOVEX row timestamps are Unix
epoch ms, and the header datetime, log filenames and generated course
names all stay UTC. A log is routinely viewed somewhere other than where
it was recorded, so timezone presentation belongs to the viewing app.
Nothing in the logging pipeline may call into local_time.

Also fixes a latent cliff the four new keys would have gone over: the
settings file and JSON document were both 512 bytes, every read path
caps at sizeof(settingsFileBuffer) - 1, and the 18-key file was already
436 B. At 543 B the file parses as IncompleteInput, EVERY key read
fails, SETTINGS_SETUP reads that as corruption and regenerates the file
(losing the BLE name, PIN and pairing), ensureDefaultSettings grows it
back over the cap, and it loops every boot. The document was a second
wall -- a <512> doc returns NoMemory at 22 string pairs, measured
against the pinned ArduinoJson 6.21.5. Both raised to 1024, and
setSettingInner() now refuses any write whose document overflowed() or
whose measureJson() exceeds the buffer, turning a silent permanent brick
into one loud refused write with the old file left intact.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F5A5R2ec4hBDiYZX6YjPqa
@github-actions

Copy link
Copy Markdown

Coverage — host-testable units

📂 Overall coverage

Metric Coverage
Lines 🟢 1612/1635 (98.6%)
Functions 🟢 172/172 (100.0%)
Branches 🟡 1213/1349 (89.9%)

📄 File coverage

File Lines Functions Branches
BirdsEye/ble_stream.cpp 🟢 34/34 (100.0%) 🟢 8/8 (100.0%) 🟡 17/20 (85.0%)
BirdsEye/camera_fsm.cpp 🟢 238/246 (96.7%) 🟢 20/20 (100.0%) 🟡 142/160 (88.8%)
BirdsEye/course_creator.cpp 🟢 213/221 (96.4%) 🟢 21/21 (100.0%) 🟡 119/136 (87.5%)
BirdsEye/course_prune.cpp 🟢 37/37 (100.0%) 🟢 5/5 (100.0%) 🟢 47/50 (94.0%)
BirdsEye/crc32.cpp 🟢 30/30 (100.0%) 🟢 4/4 (100.0%) 🟢 24/24 (100.0%)
BirdsEye/crossing_pattern.cpp 🟢 15/15 (100.0%) 🟢 1/1 (100.0%) 🟢 12/12 (100.0%)
BirdsEye/dovex_header.cpp 🟢 106/107 (99.1%) 🟢 7/7 (100.0%) 🔴 62/88 (70.5%)
BirdsEye/filename_validator.cpp 🟢 14/14 (100.0%) 🟢 1/1 (100.0%) 🟢 30/30 (100.0%)
BirdsEye/gps_stats.cpp 🟢 25/25 (100.0%) 🟢 3/3 (100.0%) 🟢 8/8 (100.0%)
BirdsEye/gps_status_page.cpp 🟢 29/29 (100.0%) 🟢 4/4 (100.0%) 🟢 28/28 (100.0%)
BirdsEye/gps_time.cpp 🟢 45/45 (100.0%) 🟢 6/6 (100.0%) 🟢 30/32 (93.8%)
BirdsEye/gps_validation.cpp 🟢 24/24 (100.0%) 🟢 2/2 (100.0%) 🟢 66/66 (100.0%)
BirdsEye/haversine.cpp 🟢 8/8 (100.0%) 🟢 1/1 (100.0%) ⚫ 0/0 (0.0%)
BirdsEye/idle_policy.cpp 🟢 17/17 (100.0%) 🟢 2/2 (100.0%) 🟢 14/14 (100.0%)
BirdsEye/insta360_protocol.cpp 🟢 140/140 (100.0%) 🟢 16/16 (100.0%) 🟡 86/98 (87.8%)
BirdsEye/lap_format.cpp 🟢 18/18 (100.0%) 🟢 1/1 (100.0%) 🟢 9/9 (100.0%)
BirdsEye/led_animations.cpp 🟢 76/76 (100.0%) 🟢 5/5 (100.0%) 🟢 43/46 (93.5%)
BirdsEye/led_frame.cpp 🟢 21/21 (100.0%) 🟢 7/7 (100.0%) 🟢 6/6 (100.0%)
BirdsEye/led_modes.cpp 🟢 64/65 (98.5%) 🟢 5/5 (100.0%) 🟢 48/50 (96.0%)
BirdsEye/local_time.cpp 🟢 48/48 (100.0%) 🟢 6/6 (100.0%) 🟢 46/50 (92.0%)
BirdsEye/sat_bars.cpp 🟢 33/33 (100.0%) 🟢 2/2 (100.0%) 🟢 51/54 (94.4%)
BirdsEye/sd_access_policy.cpp 🟢 9/9 (100.0%) 🟢 3/3 (100.0%) 🟢 18/18 (100.0%)
BirdsEye/sd_format_page.cpp 🟢 25/25 (100.0%) 🟢 3/3 (100.0%) 🟢 25/26 (96.2%)
BirdsEye/sector_purple.cpp 🟢 51/51 (100.0%) 🟢 2/2 (100.0%) 🟡 42/50 (84.0%)
BirdsEye/sensoregg_protocol.cpp 🟢 44/45 (97.8%) 🟢 7/7 (100.0%) 🟢 33/34 (97.1%)
BirdsEye/sprint_select.cpp 🟢 25/25 (100.0%) 🟢 4/4 (100.0%) 🟢 46/48 (95.8%)
BirdsEye/tach_filter.cpp 🟢 93/93 (100.0%) 🟢 12/12 (100.0%) 🟡 74/84 (88.1%)
BirdsEye/track_json.cpp 🟢 116/120 (96.7%) 🟢 12/12 (100.0%) 🟡 67/88 (76.1%)
BirdsEye/wake_cause.cpp 🟢 14/14 (100.0%) 🟢 2/2 (100.0%) 🟢 20/20 (100.0%)

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