sigilOS's Lumen window manager now switches between a dark and light theme based on the time of day. The implementation — nightmode.sg in sigil-os — does this without network access, without NTP, and without geolocation. The user sets their UTC offset once. The OS applies it every time nm_apply() is called by the theme scheduler.
Why no network?
Most operating systems derive the local time from a combination of NTP (for UTC accuracy) and timezone database lookups (for the local offset). Both require either a network connection or a bundled timezone database. sigilOS's Pi 3 floor has neither on a fresh boot — no NTP daemon, no /usr/share/zoneinfo. The tz-coarse tier solves this by pushing the lookup to the user: you tell the OS your UTC offset once, and the scheduler uses it forever. Coarse, but correct. A network-aware tz tier (NTP + automatic timezone detection) is planned for later milestones when the network stack is fully integrated.
The config cell
The night-mode configuration lives at a fixed address — 0xA20000, a 64-byte cell in kernel-reserved memory:
| Offset | Field | Default | Notes |
|---|---|---|---|
| 0 | tz_offset | 0 | UTC hours, clamped to −12..+14 |
| 8 | sunrise | 7 | Hour (local time) to switch to THEME_LIGHT |
| 16 | sunset | 20 | Hour (local time) to switch to THEME_DARK |
The cell is readable and writable via the OS config API. nm_set_tz(cell, offset) clamps the supplied value to [−12, +14] and stores it. nm_set_times(cell, rise, set) stores the sunrise and sunset hours. Both calls validate their inputs — a tz_offset of 99 gets clamped to +14, not silently accepted.
The logic
nm_apply_hour(cell, h) is the core decision function. It takes a local hour (0–23) and returns THEME_DARK or THEME_LIGHT:
if h >= sunrise and h < sunset:
return THEME_LIGHT
else:
return THEME_DARK
The window is half-open — [sunrise, sunset). At exactly sunrise, light mode activates. At exactly sunset, dark mode activates. This matches the intuitive behavior: you want light mode to turn on at dawn, not one hour after it.
nm_apply(cell) wraps this with the RTC read:
utc_seconds = nm_rtc_seconds() # loopback=0 on QEMU; Kernel RTC at integration
local_hour = (utc_seconds / 3600 + tz_offset) mod 24
return nm_apply_hour(cell, local_hour)
On QEMU, nm_rtc_seconds() returns 0 — midnight UTC. That means the integration test always starts in dark mode (local hour = tz_offset mod 24; with tz_offset=0, local_hour=0, which is before sunrise=7). The QEMU PASS pixel for THEME_DARK is (16, 16, 32). The Kernel RTC integration will bind the real wall-clock value when the RTC driver lands.
Test coverage
The nightmodetest.sg covers nine logic assertions:
| # | What it checks |
|---|---|
| 1 | Default sunrise=7, sunset=20 |
| 2 | tz_offset clamps to −12 for −99 input |
| 3 | tz_offset clamps to +14 for +99 input |
| 4 | Hour 12 (noon) → THEME_LIGHT |
| 5 | Hour 23 (late night) → THEME_DARK |
| 6 | Hour sunrise−1 → THEME_DARK (boundary: one hour before) |
| 7 | Hour sunrise → THEME_LIGHT (boundary: exactly at dawn) |
| 8 | Hour sunset → THEME_DARK (boundary: exactly at dusk) |
| 9 | UTC hour 17 + tz_offset +5 → local hour 22 → THEME_DARK |
The test also renders a split-screen: left half painted in THEME_DARK (bg=(16,16,32)), right half in THEME_LIGHT (bg=(250,250,253)). Both halves are verified with pixel samples. QEMU raspi3b: PASS (74,200,160).
What's next
The RTC integration: when the Kernel RTC driver lands, nm_rtc_seconds() will return real wall-clock UTC seconds instead of the loopback zero. The night-mode scheduler will immediately reflect the real time with no other changes — the logic is already correct, only the time source is a stub.
The tz-precise tier (network time + automatic timezone) is planned for the 1.3 networking milestone, when the TCP stack is fully integrated with the OS session layer. For now, tz-coarse with a manual UTC offset is the right floor — it works on a Pi 3 with no network, which is exactly what the Four Pillars ask for.