Warning screen
- New reusable WarningPage (word-wrapped message, ENTER acknowledges and
continues to the screen you were about to enter).
- First use: pressing Play while the selected audio is still downloading.
Dynamic Audio requires every file; a fixed track requires its own file.
License re-validation
- No longer re-verifies on every Home render. Checks now run only when the
email/license key is entered or changed, on the manual option, at startup
when already due, and hourly while valid.
- Invalid + unchanged credentials are never re-checked (persisted pair).
- Valid re-checks at most once per hour, anchored on a DB timestamp so an
overdue check runs immediately after a power cycle.
- New system_kv keys track the checked pair, outcome, and last-check time.
- Added a System Settings row "Manually Validate License Key OK|Invalid"
(OK green, Invalid red, label white) that runs a check immediately.
Portal / local integration
- License validation request now sends the app version as `appVersion`.
- The entered license key is mirrored to ~/.local/radiantwave/license.txt.
Misc
- "Please connect to WiFi" only shows when no WiFi is joined AND the internet
is unreachable (fixes the NetworkManager-missing and Ethernet cases).
- Added unit tests for the audio-download warning, license scheduling policy,
license request shape, and the new settings-menu entry.
Bug Fix: ScheduleManager::start() held mutex_ and then called loadCache(), which re-locks the same non-recursive mutex. Whenever a schedule cache file was present (the normal state for a provisioned kiosk), this self-deadlocked and blocked SDL_AppInit before the first frame was presented, so the kiosk booted to a black screen showing only the compositor cursor.
Narrow the lock scope so loadCache() acquires mutex_ itself, and add a regression test that starts with a cached payload present.
start() held mutex_ and then called loadCache(), which re-locks the same
non-recursive mutex. When a schedule cache file exists and parses (the
normal case for a provisioned kiosk), this self-deadlocks and blocks
SDL_AppInit before the first frame, leaving only a black screen and the
compositor cursor.
Narrow the lock to the member setup so loadCache() can take the lock
itself. Add a regression test that starts with a cached payload present.
Adds two fixtures captured from a running Portal (daily and weekly) and
tests that parse them with the Kiosk API client and evaluate them with the
schedule engine, including adjacency and Sunday->Monday spillover. Proves
the Kiosk consumes actual Portal output with no hand-editing.
ScrollerPage now resolves the active Block from ScheduleManager (reusing
the existing stream-switch path), honoring dynamicAudio true/false and
falling back to local selection for gaps, no schedule, or toggle off.
Adds a 'Schedule' On/Off entry (default On) in System Settings persisted
in config.yaml, and config plumbing (proclamation.schedule_enabled). All
on-device schedule times remain 12h AM/PM.
Adds api_client support for GET /licenses/{key}/schedule with If-None-Match
and target-payload parsing (version/type/daily/weekly + legacy blocks), an
atomic on-disk schedule cache, a pure schedule engine (half-open blocks,
midnight self-wrap for daily, Sunday->Monday spillover for weekly, invalid
blocks skipped with a warning), and a background ScheduleManager that loads
the cache offline and pulls on boot and every 5 minutes. Adds engine/cache
tests against the shared docs/fixtures, including 304, corrupt cache, and
offline cases.
Adds a System Name text-entry page (hint 'e.g. Living Room', 1-40 char
validation) and a data-driven System Settings row for it. Saving persists
locally and nudges the background sync; conflict/invalid results surface
the message and restore the previous name. Extends the settings-menu
tests.
Adds local System Name persistence (trimmed, 1-40) in system_kv, an
injectable sync module with conflict/invalid revert and a pending-sync
flag, a background SystemNameSync worker (retries on boot and on a timer),
and api_client support for PUT /licenses/{key}/system-name plus the
additive systemName field/systemNameStatus on license validation. The
license service sends the stored name and applies the status. Covers
validation, pending retry, and conflict revert with a faked HTTP layer.
Settings now shows Proclamations, Audio Options, System Settings, System
Logs, and Back. The new SystemSettingsPage groups Email Address, License
Key, WiFi Setup, Time Zone Setup, and Date Format, driven by the pure
settings::systemSettingEntries() list so future entries need one row, not
new menu plumbing. Adds unit tests for the entry list.
Add datetime::formatHm12h/formatTime12h and use them for the Home footer
clock, the Timezone page's local-time sample, and the log viewer
timestamps. Storage and parsing remain 24-hour HH:MM. Extends the
date-format tests with the midnight/noon edge cases.
Add docs/schedule-api-contract.md (current state, time semantics, schedule
data model, validation rules, endpoints, sync behavior, System Name rules,
dynamic audio, backward compatibility, per-phase touchpoints, open items,
changelog) and docs/fixtures/*.json (daily, weekly, none, midnight-crossing,
adjacent, dynamic-audio, and legacy v2 payloads). Kept byte-identical with
the portal repo.
The portal's v2 API replaced nested categories (proclamation_categories.parent_id)
with flat categories plus tag-like groups: a proclamation now belongs to one
category and optionally one group. This lets the Proclamation Options page drop
its special-cased two-level tree.
Database:
- Add the proclamation_groups table (id, name, sort_order) and a
proclamations.group_id column.
- Drop parent_id from the proclamation_categories DDL so fresh databases get a
flat table.
- runMigrations() creates the new table/column, then runs a one-time
transactional v1->v2 migration keyed on PRAGMA user_version: every legacy
sub-category becomes a group (its id is preserved), proclamations that
pointed at a sub-category are remapped to (parent category, new group), and
the now-stale sub-category rows are deleted. selected flags are never
touched, so user choices survive. No network is needed, so offline kiosks
migrate at startup; re-running is a no-op.
- Add replaceGroups() and getAllGroups(); stop reading/writing parent_id.
API sync:
- Add PATH_GROUPS ("/groups") and parseGroupsJson().
- parseProclamationsJson() now also reads groupId.
- syncProclamationsFromApi() fetches /proclamations, /categories and /groups,
leaves the database untouched if any request or parse fails, then replaces
categories, replaces groups and upserts proclamations.
Proclamation Options page:
- FlatItem is now Category / Group / Proclamation.
- Each category lists only the groups that have at least one member in that
category, so impossible pairings (e.g. Physical/Prayers) never render.
- Proclamations with no group, or with a group the portal has removed, sit
directly under the category. Groups are collapsible and use a distinct color.
Tests:
- test_db: groups round-trip plus a legacy->v2 migration test asserting the
group is created, proclamations are remapped, selection is preserved and a
second initialize() is a no-op.
- test_proclamation_sync / test_api_client: parse groupId, parse groups, and
the live smoke test now also fetches /groups.
Docs: README database table and AGENTS notes updated for groups.
The verify step fetched the closure from Attic alone, but Attic's
upstream-cache filter (cache.nixos.org-1) intentionally skips nixpkgs deps
like sqlite, so `nix copy` failed on 'path is not valid'. Mirror the kiosk
updater and fetch from Attic + cache.nixos.org together, Attic first so a
truncated RadiantWave NAR still fails before the manifest is published.
recreateMixerOnDevice destroyed the active mixer before opening the
replacement. MIX_DestroyMixer quits SDL audio at refcount 0, tearing down
the device registry and renumbering device ids, so the cached id being
reopened went stale ('Invalid audio device instance ID') and the app went
permanently mute. Open the new mixer first while the old device is alive,
then tear the old one down; a failed switch now keeps the current output
playing instead of killing audio. Also log SDL_GetError() on open failure.
After pushing the dev closure to Attic, write a small JSON manifest
(version + storePath) to the fileserver for kiosks to poll. storePath is
the readlink of the result-pkg out-link, so it matches exactly the
closure pushed to the dev Attic cache and can be fetched with
'nix copy --from' without building from source. Upload goes to
/mnt/infrastructure_data/files/radiantwave-dev/manifest.json via the
build-server's radiantwave-infra SSH alias.
Override the LETTERBOX logical presentation while the scroller is
active so it renders into the full physical window. On a 1920x6480
rotated display the scroller now scrolls through all 6480 pixels of
vertical space instead of being confined to the 1920x1080 letterbox
region. Text scale is unchanged (same font size, same pixel
rasterization); more lines are simply visible at once.
- onPush: save current logical presentation, switch to DISABLED with
real window dimensions
- onPop: restore the saved LETTERBOX presentation
- resize handler in main.cpp: skip re-apply while scroller is active
(it owns its own presentation and restores on pop)