parseAccessPoint() is extracted from an anonymous namespace so the pure property->AccessPointInfo mapping is testable. getAccessPoints() now reads the Flags/WpaFlags/RsnFlags properties (they were never read, so every network was reported Open and connections lacked a security section) and fixes the double container-enter on the SSID 'ay' read that silently emptied the scan list.
9.9 KiB
RadiantWave — Agent Instructions
What This Is
A kiosk app for RadiantWave wellness technology. Displays scrolling proclamation
text (Fibonacci line-length pattern, per-frequency color palettes), plays
solfeggio-frequency audio, and runs a first-run provisioning walkthrough on a
headless Linux kiosk. Talks to https://portal.radiantwavetech.com/api/v2.
Language: C++17 (no extensions). Build: CMake + Ninja.
Graphics: SDL3 + SDL3_ttf/SDL3_image. Audio: SDL3_mixer.
Also links: SDL3_net, SQLite3, libcurl, OpenSSL, GTest.
Linux-only: libsystemd (sd-bus) via pkg-config, compiled only when
UNIX AND NOT APPLE — guarded by #ifdef __linux__ in the network manager.
Build & Run
cmake -S . -B build -G Ninja -DCMAKE_BUILD_TYPE=Debug
cmake --build build # builds the app AND every test binary
./build/radiantwave
The Home footer's top "Version" row is baked in at build time. Default it to the project version, or pass your own at configure time:
cmake -S . -B build -G Ninja -DCMAKE_BUILD_TYPE=Debug -DAPP_VERSION="1.2.3"
The Nix package passes its flake version automatically via cmakeFlags.
The portal API key is also a build-time argument: -DAPI_KEY="..." (CMake) or
apiKey on callPackage (Nix). It is compiled into the binary as the
Authorization: Bearer token and defaults to empty (no auth header). Keep the
real value in CI secrets, never in source — rotate by rebuilding with a new
key.
- Development happens on macOS; WiFi/D-Bus code is Linux-only, but
NetworkManager::hasInternet()(a raw TCP probe) works everywhere, so the provisioning walkthrough does not trap a Mac on the WiFi page. - The app creates a 1920x1080 fullscreen window with VSync and a hidden
cursor.
~/.local/radiantwave/config.yaml(YAML) and~/.local/radiantwave/radiantwave.db(SQLite, WAL) are created at first run. - No root
flake.nixexists; the Nix flake inarchive/is reference only.
Tests
There is no ctest integration and no single "run all" target. Each test
file is its own add_executable in CMakeLists.txt; build them all with
cmake --build build, then run each ./build/test_* binary.
Two frameworks coexist:
tests,test_assets,test_provisioning— the minimal built-in harness (tests/test_harness.h,TEST(name)+RUN_ALL_TESTS()).- Everything else — GoogleTest (
GTest::gtest_main). GTest is a hardfind_packagerequirement; the build fails without it.
Test targets: tests, test_assets, test_provisioning, test_network_unit,
test_nm_device, test_config, test_db, test_proclamation_sync,
test_api_client, test_license_service, test_license_status,
test_validation, test_date_format, test_timezone, test_logger,
test_audio, test_audio_downloader, test_fibonacci_pattern,
test_scroller_streams, test_fibonacci_scrambler, test_color_palettes.
test_nm_device covers the pure parseAccessPoint() mapping; the sd-bus reads
in WifiDevice::getAccessPoints() are Linux-only and verified live. Do not
wrap the per-AP ay SSID property read in sd_bus_message_enter_container()
before sd_bus_message_read_array() — read_array enters/exits the array
itself, and the double-enter silently empties the scan list (see
src/network/nm_device.cpp).
New test files must be registered as a new add_executable target in
CMakeLists.txt (there is no glob). test_assets must pass before committing —
it checks the font and image files exist.
test_api_client and test_license_service include "Live" tests that hit the
portal unconditionally — they are excluded from the Nix checkPhase offline
suite and need the RWT_LIVE_* secrets to pass.
Runtime data & keys
AppState(inapp_state.h) holds renderer,PageManager,KeybindManager,config,db,network,audio,audioDownloader,licenseTracker, and provisioning flags. No globals — passAppState&around.- Device identity/state lives in the SQLite
system_kvtable. Canonical keys are insrc/db/db_keys.h:email_address,license_key,subscription_level_id/name/description,license_last_verified(7-day offline grace window for a known-good license). ConfigManagerhot-reloadsconfig.yamlfrom a watcher thread and firesonChangedcallbacks;AudioManagervolume/device/dynamic_audioare read from it.
First-run provisioning walkthrough
Fixed prerequisite order (src/provisioning.h), evaluated on every boot; the
first missing step's page is pushed:
- Internet reachable? →
WiFiSetupPage - Email address set? →
EmailAddressPage - License key set? →
LicenseKeyPage - Everything satisfied →
Home
advance() pops the current step and re-evaluates; internet is only re-probed
when it was still missing (cached in state.walkthroughInternetUp). A
brand-new device (nothing configured) only sets the firstRunWalkthrough
placeholder flag — the welcome walkthrough is not implemented yet.
Architecture
Entry point (src/main.cpp)
SDL3 callback API (SDL_MAIN_USE_CALLBACKS): SDL_AppInit inits subsystems
and calls provisioning::begin, SDL_AppEvent routes through keybinds then
pages, SDL_AppIterate updates/renders, SDL_AppQuit tears down.
Pages & navigation
Page lifecycle (src/page.h): onPush/onResume create textures,
onPause destroys them, onPop final-cleans. PageManager is a stack
(push/pop/popToRoot/replace). Home and the provisioning steps use
replace()-style flows; only settings sub-pages stack deeply. TAB pops the
stack and pushes a fresh Home.
Existing pages: Home, Settings, WiFiSetupPage, EmailAddressPage, LicenseKeyPage, LicenseBlockedPage, AudioSettingsPage, ProclamationOptionsPage, ScrollerPage, LogViewerPage, TimezonePage, DateFormatPage.
Keybinds (src/keybind_manager.cpp)
Binds are checked before the active page sees events. Left/right modifier pairs are normalized to combined masks, so a bind's modifiers must be a subset of the held modifiers (plain binds fire regardless of modifiers).
Global binds in main.cpp: TAB returns Home, UP/DOWN move the Home
menu selection. There is no quit keybind — the app is a kiosk. App quit (SDL
window/system close) fades audio out over ~5s before teardown. Note: Home's
UP/DOWN handlers both increment currentMenuItem (x+1)%2 (see
src/home.cpp) — there is no separate "down" path.
ScrollerPage
Full-screen vertical scroller. Proclamation streams come from the DB
(scroller_streams.cpp), colors from per-frequency palettes with a 528 Hz
fallback, lines built from a Fibonacci length pattern
(fibonacci_line_pattern.cpp). Audio is crossfaded per stream via
AudioManager fade APIs. Word textures are cached and page text rendering is
cached.
Portal API & color palettes
- Request/response changes against the portal are tracked in
ApiUpdates.md(sections #4 and #5). - Color palettes are meant to come from the portal, not code releases. The
client-side pieces are implemented but deliberately not wired into any sync
or startup flow until the portal routes exist:
api::fetchColorPalettes()/api::parseColorPalettesJson()— theGET /color-palettesroute.sync::syncColorPalettesFromApi()/sync::syncProclamationPalettesFromApi()/sync::parseProclamationPalettesJson()— replace the localcolor_palettestable viadb::replaceColorPalettes().db::getPaletteForFrequencyId()falls back to the 528 Hz palette for frequency ids with no rows; the startup seed only backfills missing ids, so portal values win after a sync.
- Do not call the above sync functions from the app until the portal routes
ship. Tests live in
tests/test_color_palettes.cpp(test_color_palettestarget). - Intended wiring points (marked with
TODO(portal #4/#5)comments):syncProclamationsFromApi()insrc/network/proclamation_sync.cpp, plus the call sitesHome::syncProclamationsIfEmpty()(src/home.cpp) andProclamationOptionsPage::onPush()(src/proclamation_options_page.cpp).
Style & UI Layout Rules
PascalCasefor types,camelCasefor functions/vars,snake_casefor files,UPPER_SNAKEfor constants/macros.- Use
std::unique_ptr; no rawnew/deleteoutside the SDL callbacks. - Use
overrideon all virtual implementations.#pragma onceheaders. - One class per header/source pair. SDL3 headers first, then stdlib, then project headers.
UI Menu & List Layout Conventions
- Left Alignment & Centered Bounding Box: In vertical menus/lists, all text
options are left-aligned with each other (
sharedX). The bounding box enclosing all options (width = widest item, height = sum of item heights + margins) MUST BE centered on the screen. - Vertical Spacing: All margins between consecutive list items are 12 pixels.
- Selection Underline: The 6-pixel underline occupies half the 12px margin (2-3px gap below the item text, 3-4px gap above the next item).
Common Pitfalls
- Do not commit audio assets (
.wavfiles) — downloaded at runtime. - Do not modify files in
archive/— reference only. - Build needs SDL3 + SDL3_ttf/image/mixer/net + GTest installed; CI uses
Gitea Actions on a self-hosted host runner (
build-server, no docker). The workflow lives in.gitea/workflows/build.ymland runs on bothdevandmain. It runsnix build -L .#checks.<system>.default(build + the offline test suite;checksis keyed per-system, so the workflow resolves the current system vianix eval --impure --expr 'builtins.currentSystem'); when theRW_LIVE_API_KEYsecret is set it also runstest_api_client/test_license_serviceagainst the portal vianix develop(outside the sandbox, like a dev machine). test_assetsmust pass before committing — it catches missing fonts/images that break the app at runtime.cmake --build buildbuilds everything; pass--target <name>to build one test binary.