From 336f47c6ac092dc7d10886ffc0ff0a888dbb0913 Mon Sep 17 00:00:00 2001 From: NeilClack Date: Tue, 29 Sep 2026 08:36:31 -0500 Subject: [PATCH 01/17] docs: add Phase 0 schedule API/data contract and fixtures 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. --- docs/fixtures/schedule-adjacent.json | 59 ++ docs/fixtures/schedule-daily.json | 59 ++ docs/fixtures/schedule-dynamic-audio.json | 53 ++ docs/fixtures/schedule-legacy-v2.json | 30 + docs/fixtures/schedule-midnight-crossing.json | 59 ++ docs/fixtures/schedule-none.json | 7 + docs/fixtures/schedule-weekly.json | 63 ++ docs/schedule-api-contract.md | 546 ++++++++++++++++++ 8 files changed, 876 insertions(+) create mode 100644 docs/fixtures/schedule-adjacent.json create mode 100644 docs/fixtures/schedule-daily.json create mode 100644 docs/fixtures/schedule-dynamic-audio.json create mode 100644 docs/fixtures/schedule-legacy-v2.json create mode 100644 docs/fixtures/schedule-midnight-crossing.json create mode 100644 docs/fixtures/schedule-none.json create mode 100644 docs/fixtures/schedule-weekly.json create mode 100644 docs/schedule-api-contract.md diff --git a/docs/fixtures/schedule-adjacent.json b/docs/fixtures/schedule-adjacent.json new file mode 100644 index 0000000..abfef1d --- /dev/null +++ b/docs/fixtures/schedule-adjacent.json @@ -0,0 +1,59 @@ +{ + "version": "sched-adjacent-0001", + "type": "daily", + "daily": [ + { + "id": "dd111111-1111-4111-8111-111111111111", + "startTime": "06:00", + "startMinutes": 360, + "endTime": "07:00", + "endMinutes": 420, + "overnight": false, + "durationMinutes": 60, + "proclamationIds": ["b40fdcb9-703c-420e-af32-a081964c6f08"], + "dynamicAudio": true + }, + { + "id": "dd222222-2222-4222-8222-222222222222", + "startTime": "07:00", + "startMinutes": 420, + "endTime": "08:30", + "endMinutes": 510, + "overnight": false, + "durationMinutes": 90, + "proclamationIds": [ + "7c9e1a2b-3d4e-5f60-8a9b-0c1d2e3f4a5b", + "33333333-3333-4333-8333-333333333333" + ], + "dynamicAudio": false + } + ], + "weekly": {}, + "blocks": [ + { + "id": "dd111111-1111-4111-8111-111111111111", + "startTime": "06:00", + "startMinutes": 360, + "endTime": "07:00", + "endMinutes": 420, + "overnight": false, + "durationMinutes": 60, + "dynamicAudio": true, + "proclamationIds": ["b40fdcb9-703c-420e-af32-a081964c6f08"] + }, + { + "id": "dd222222-2222-4222-8222-222222222222", + "startTime": "07:00", + "startMinutes": 420, + "endTime": "08:30", + "endMinutes": 510, + "overnight": false, + "durationMinutes": 90, + "dynamicAudio": false, + "proclamationIds": [ + "7c9e1a2b-3d4e-5f60-8a9b-0c1d2e3f4a5b", + "33333333-3333-4333-8333-333333333333" + ] + } + ] +} diff --git a/docs/fixtures/schedule-daily.json b/docs/fixtures/schedule-daily.json new file mode 100644 index 0000000..b59deaf --- /dev/null +++ b/docs/fixtures/schedule-daily.json @@ -0,0 +1,59 @@ +{ + "version": "sched-daily-0001", + "type": "daily", + "daily": [ + { + "id": "11111111-1111-4111-8111-111111111111", + "startTime": "07:00", + "startMinutes": 420, + "endTime": "09:00", + "endMinutes": 540, + "overnight": false, + "durationMinutes": 120, + "proclamationIds": [ + "b40fdcb9-703c-420e-af32-a081964c6f08", + "7c9e1a2b-3d4e-5f60-8a9b-0c1d2e3f4a5b" + ], + "dynamicAudio": true + }, + { + "id": "22222222-2222-4222-8222-222222222222", + "startTime": "18:00", + "startMinutes": 1080, + "endTime": "20:30", + "endMinutes": 1230, + "overnight": false, + "durationMinutes": 150, + "proclamationIds": ["33333333-3333-4333-8333-333333333333"], + "dynamicAudio": false + } + ], + "weekly": {}, + "blocks": [ + { + "id": "11111111-1111-4111-8111-111111111111", + "startTime": "07:00", + "startMinutes": 420, + "endTime": "09:00", + "endMinutes": 540, + "overnight": false, + "durationMinutes": 120, + "dynamicAudio": true, + "proclamationIds": [ + "b40fdcb9-703c-420e-af32-a081964c6f08", + "7c9e1a2b-3d4e-5f60-8a9b-0c1d2e3f4a5b" + ] + }, + { + "id": "22222222-2222-4222-8222-222222222222", + "startTime": "18:00", + "startMinutes": 1080, + "endTime": "20:30", + "endMinutes": 1230, + "overnight": false, + "durationMinutes": 150, + "dynamicAudio": false, + "proclamationIds": ["33333333-3333-4333-8333-333333333333"] + } + ] +} diff --git a/docs/fixtures/schedule-dynamic-audio.json b/docs/fixtures/schedule-dynamic-audio.json new file mode 100644 index 0000000..6568d06 --- /dev/null +++ b/docs/fixtures/schedule-dynamic-audio.json @@ -0,0 +1,53 @@ +{ + "version": "sched-dynamic-0001", + "type": "daily", + "daily": [ + { + "id": "ee111111-1111-4111-8111-111111111111", + "startTime": "08:00", + "startMinutes": 480, + "endTime": "10:00", + "endMinutes": 600, + "overnight": false, + "durationMinutes": 120, + "proclamationIds": ["b40fdcb9-703c-420e-af32-a081964c6f08"], + "dynamicAudio": true + }, + { + "id": "ee222222-2222-4222-8222-222222222222", + "startTime": "20:00", + "startMinutes": 1200, + "endTime": "22:00", + "endMinutes": 1320, + "overnight": false, + "durationMinutes": 120, + "proclamationIds": ["7c9e1a2b-3d4e-5f60-8a9b-0c1d2e3f4a5b"], + "dynamicAudio": false + } + ], + "weekly": {}, + "blocks": [ + { + "id": "ee111111-1111-4111-8111-111111111111", + "startTime": "08:00", + "startMinutes": 480, + "endTime": "10:00", + "endMinutes": 600, + "overnight": false, + "durationMinutes": 120, + "dynamicAudio": true, + "proclamationIds": ["b40fdcb9-703c-420e-af32-a081964c6f08"] + }, + { + "id": "ee222222-2222-4222-8222-222222222222", + "startTime": "20:00", + "startMinutes": 1200, + "endTime": "22:00", + "endMinutes": 1320, + "overnight": false, + "durationMinutes": 120, + "dynamicAudio": false, + "proclamationIds": ["7c9e1a2b-3d4e-5f60-8a9b-0c1d2e3f4a5b"] + } + ] +} diff --git a/docs/fixtures/schedule-legacy-v2.json b/docs/fixtures/schedule-legacy-v2.json new file mode 100644 index 0000000..10b9190 --- /dev/null +++ b/docs/fixtures/schedule-legacy-v2.json @@ -0,0 +1,30 @@ +{ + "email": "jane@example.com", + "blocks": [ + { + "id": "11111111-1111-4111-8111-111111111111", + "startTime": "07:00", + "startMinutes": 420, + "endTime": "09:00", + "endMinutes": 540, + "overnight": false, + "durationMinutes": 120, + "dynamicAudio": true, + "proclamationIds": [ + "b40fdcb9-703c-420e-af32-a081964c6f08", + "7c9e1a2b-3d4e-5f60-8a9b-0c1d2e3f4a5b" + ] + }, + { + "id": "22222222-2222-4222-8222-222222222222", + "startTime": "18:00", + "startMinutes": 1080, + "endTime": "20:30", + "endMinutes": 1230, + "overnight": false, + "durationMinutes": 150, + "dynamicAudio": false, + "proclamationIds": ["33333333-3333-4333-8333-333333333333"] + } + ] +} diff --git a/docs/fixtures/schedule-midnight-crossing.json b/docs/fixtures/schedule-midnight-crossing.json new file mode 100644 index 0000000..8ab4543 --- /dev/null +++ b/docs/fixtures/schedule-midnight-crossing.json @@ -0,0 +1,59 @@ +{ + "version": "sched-midnight-0001", + "type": "daily", + "daily": [ + { + "id": "cc111111-1111-4111-8111-111111111111", + "startTime": "21:00", + "startMinutes": 1260, + "endTime": "09:00", + "endMinutes": 540, + "overnight": true, + "durationMinutes": 720, + "proclamationIds": [ + "b40fdcb9-703c-420e-af32-a081964c6f08", + "7c9e1a2b-3d4e-5f60-8a9b-0c1d2e3f4a5b" + ], + "dynamicAudio": true + }, + { + "id": "cc222222-2222-4222-8222-222222222222", + "startTime": "09:00", + "startMinutes": 540, + "endTime": "21:00", + "endMinutes": 1260, + "overnight": false, + "durationMinutes": 720, + "proclamationIds": ["33333333-3333-4333-8333-333333333333"], + "dynamicAudio": false + } + ], + "weekly": {}, + "blocks": [ + { + "id": "cc111111-1111-4111-8111-111111111111", + "startTime": "21:00", + "startMinutes": 1260, + "endTime": "09:00", + "endMinutes": 540, + "overnight": true, + "durationMinutes": 720, + "dynamicAudio": true, + "proclamationIds": [ + "b40fdcb9-703c-420e-af32-a081964c6f08", + "7c9e1a2b-3d4e-5f60-8a9b-0c1d2e3f4a5b" + ] + }, + { + "id": "cc222222-2222-4222-8222-222222222222", + "startTime": "09:00", + "startMinutes": 540, + "endTime": "21:00", + "endMinutes": 1260, + "overnight": false, + "durationMinutes": 720, + "dynamicAudio": false, + "proclamationIds": ["33333333-3333-4333-8333-333333333333"] + } + ] +} diff --git a/docs/fixtures/schedule-none.json b/docs/fixtures/schedule-none.json new file mode 100644 index 0000000..4121f83 --- /dev/null +++ b/docs/fixtures/schedule-none.json @@ -0,0 +1,7 @@ +{ + "version": "sched-none-0001", + "type": null, + "daily": [], + "weekly": {}, + "blocks": [] +} diff --git a/docs/fixtures/schedule-weekly.json b/docs/fixtures/schedule-weekly.json new file mode 100644 index 0000000..d71b65a --- /dev/null +++ b/docs/fixtures/schedule-weekly.json @@ -0,0 +1,63 @@ +{ + "version": "sched-weekly-0001", + "type": "weekly", + "daily": [], + "weekly": { + "mon": [ + { + "id": "a1111111-1111-4111-8111-111111111111", + "startTime": "06:00", + "startMinutes": 360, + "endTime": "08:00", + "endMinutes": 480, + "overnight": false, + "durationMinutes": 120, + "proclamationIds": ["b40fdcb9-703c-420e-af32-a081964c6f08"], + "dynamicAudio": true + } + ], + "tue": [], + "wed": [ + { + "id": "a2222222-2222-4222-8222-222222222222", + "startTime": "12:00", + "startMinutes": 720, + "endTime": "13:00", + "endMinutes": 780, + "overnight": false, + "durationMinutes": 60, + "proclamationIds": ["7c9e1a2b-3d4e-5f60-8a9b-0c1d2e3f4a5b"], + "dynamicAudio": true + } + ], + "thu": [], + "fri": [ + { + "id": "a3333333-3333-4333-8333-333333333333", + "startTime": "18:00", + "startMinutes": 1080, + "endTime": "22:00", + "endMinutes": 1320, + "overnight": false, + "durationMinutes": 240, + "proclamationIds": ["33333333-3333-4333-8333-333333333333"], + "dynamicAudio": false + } + ], + "sat": [], + "sun": [ + { + "id": "a4444444-4444-4444-8444-444444444444", + "startTime": "23:00", + "startMinutes": 1380, + "endTime": "01:00", + "endMinutes": 60, + "overnight": true, + "durationMinutes": 120, + "proclamationIds": ["b40fdcb9-703c-420e-af32-a081964c6f08"], + "dynamicAudio": true + } + ] + }, + "blocks": [] +} diff --git a/docs/schedule-api-contract.md b/docs/schedule-api-contract.md new file mode 100644 index 0000000..9e0dda6 --- /dev/null +++ b/docs/schedule-api-contract.md @@ -0,0 +1,546 @@ +# Schedule API & Data Contract + +Status: Phase 0 (contract only — no application code). This document is the +authoritative API/data contract for the multi-phase scheduler overhaul. It is +kept **byte-identical** in both repositories: + +- `radiantwave` (C++/SDL3 Kiosk app) — `docs/schedule-api-contract.md` +- `portal` (SvelteKit/Node) — `docs/schedule-api-contract.md` + +Any change is applied to both copies in the same step, with a dated line +appended to the [Changelog](#changelog). + +Glossary terms (Kiosk, Portal, Proclamation, Category, Group, Block, Schedule, +License Key, System Name) are used with the meanings defined in the scheduler +overhaul brief. + +--- + +## Current state + +This section reflects the code as it exists at the start of Phase 0. It is +descriptive, not aspirational; the target behaviour is defined in the later +sections. + +### Kiosk (`radiantwave`) + +- **License validation.** `POST /api/v2/license` with body + `{"license_key": "...", "email_address": "..."}`. Implemented in + `src/network/api_client.cpp` (`validateLicense`, `parseLicenseJson`) and + consumed by `src/network/license_service.cpp` (`validateStoredLicense`, + `markLicenseVerified`, `isLicenseWithinGracePeriod`). A 2xx response is the + device's "subscribed" flag; a 7-day offline grace window is stored as + `license_last_verified` in `system_kv` (`src/db/db_keys.h`). Response body + currently read: `{key, email_address}`; additional fields are ignored. +- **Schedule fetch.** `GET /api/v2/schedule?email=&licenseKey=` + (`src/network/api_client.cpp`: `fetchSchedule`, `parseScheduleJson`; + `PATH_SCHEDULE = "/schedule"`). Response shape read by the Kiosk: + `{email, blocks: [{id, startMinutes, endMinutes, overnight, durationMinutes, +dynamicAudio, proclamationIds}]}`. `dynamicAudio` is optional on the wire and + defaults to `true` in `parseScheduleJson`. Missing/blank credentials cause an + early `false`. +- **Schedule persistence.** SQLite tables `schedule_blocks` and + `schedule_block_proclamations` (`src/db/database_manager.cpp:185`, + `replaceScheduleBlocks` at `:1105`, `getScheduleBlocks` at `:1169`). Full + replace; block `sort_order` and per-block proclamation `position` preserved. + No day-of-week column and no schedule `type`; the model is implicitly daily. +- **Schedule sync.** `syncScheduleFromApi` (`src/network/proclamation_sync.cpp:172`) + is invoked only as a **best-effort** side effect of + `syncProclamationsFromApi` (`:168`). There is no boot sync, no network-up + sync, no periodic timer, and no conditional GET (`If-None-Match`/ETag). + `syncProclamationsFromApi` is called from `Home::syncProclamationsIfEmpty` + (`src/home.cpp:461`) and `ProclamationOptionsPage::onPush` + (`src/proclamation_options_page.cpp:49`). There is no explicit atomic + schedule cache file; the SQLite database is the durable store. +- **Schedule playback.** `src/scroller_page.cpp` selects the active block by + comparing `timezone::currentLocalMinutes(cfg.timezone)` + (`src/timezone_helper.h`) against each block via + `scroller::minuteInScheduleBlock` (`src/scroller_streams.h:31`). Block + selection is daily-only (no day-of-week). An active block's ordered + `proclamation_ids` replace the local selection + (`src/scroller_streams.cpp:57`). `effectiveDynamicAudio()` + (`src/scroller_page.cpp:302`) forces dynamic audio when the active block has + `dynamic_audio == true`, otherwise the device-wide + `config.audio.dynamic_audio` applies. A block change is latched in + `scheduleChangePending_` and applied at the next proclamation boundary. +- **Settings persistence.** `~/.local/radiantwave/config.yaml` (YAML) via + `ConfigManager` (`src/config/config_manager.cpp`, `src/config/yaml_parser.cpp`, + schema in `src/config/app_config.h`): `timezone`, `audio.dynamic_audio`, + `proclamation.schedule_active` (Kiosk-local on/off toggle, default `false`). + Identity lives in SQLite `system_kv` (`email_address`, `license_key`, + `license_last_verified`). There is no System Name anywhere. +- **Conventions.** `API_BASE_URL = "https://portal.radiantwavetech.com/api/v2"` + (`src/network/api_client.h`). Kiosk API knowledge is mirrored in + `openapi.yaml` at the repo root. + +### Portal (`portal`) + +- **Framework/tooling.** SvelteKit 2 + Svelte 5 + TypeScript + Tailwind v4; + Prisma 7 over PostgreSQL; Vitest. Migrations: Prisma Migrate + (`prisma/schema.prisma`, `prisma/migrations/`, `npm run migrate`, + `npm run deploy`; config in `prisma.config.ts`). API routes under + `src/routes/api/v1/` (API-key gated) and `src/routes/api/v2/` + (unauthenticated). v2 helpers: `src/lib/server/v2-helpers.ts` + (`jsonOk`, `jsonError`, `readJsonBody`, `rateLimited`, `clientIp`); errors + are `{ "message": "" }`. +- **License validation.** `POST /api/v2/license` + (`src/routes/api/v2/license/+server.ts`): validates `license_key` + + `email_address`, requires the key be active and owned by the account, stamps + `LicenseKey.lastValidatedAt`, returns `serializeLicense({key, emailAddress})` + (`src/lib/server/v2-serializers.ts`). A v1 variant exists + (`src/routes/api/v1/license/+server.ts`). +- **Schedule storage.** `Schedule` is **per user** (`userId String @unique`, + `prisma/schema.prisma:263`), daily-only, `0..4` `Block`s. `Block` + (`:275`) stores `startMinutes`/`endMinutes`, `dynamicAudio Boolean +@default(false)`, and ordered `BlockProclamations` (`sortOrder`). The schema + comment states overnight ranges are disallowed in v1, yet the serializer and + Kiosk both support `overnight` — a known inconsistency. +- **Schedule service/validation.** `src/lib/server/schedule.ts` (DB + orchestration: `getOrCreateSchedule`, `listBlocks`, `createBlock`, + `updateBlock`, `deleteBlock`) and the isomorphic + `src/lib/schedule-utils.ts` (`MAX_BLOCKS = 4`, `SNAP_MINUTES = 15`, + `validateBlock`, `findOverlap`, `isOvernight`, `blockDuration`, + `blockSegments`, `formatTime`, `toHm`, `snapMinutes`). Validation enforces a + 4-block maximum on create, `startMinutes !== endMinutes`, at least one + Proclamation, and no overlaps (overnight-aware via linear segments). +- **Schedule read API.** `GET /api/v2/schedule` + (`src/routes/api/v2/schedule/+server.ts`): auth by paired `email` + + `licenseKey` (legacy alias `key`) query params; checks only that the key + belongs to the email's account (entitlement deliberately not checked); rate + limited 10/min/IP; returns `{email, blocks: [serializeScheduleBlock(...)]}` + where `serializeScheduleBlock` (`src/lib/server/v2-serializers.ts:165`) emits + `{id, startTime, startMinutes, endTime, endMinutes, overnight, +durationMinutes, dynamicAudio, proclamationIds}`. +- **Schedule UI.** Protected user route + `src/routes/(protected)/(user)/schedule/` (`+page.server.ts`, + `+page.svelte`) with `src/lib/components/schedule/DaySchedule.svelte` (single + 24h day timeline, drag-to-create) and `BlockModal.svelte`. Dashboard + (`(protected)/(user)/dashboard/`) lists the user's License Keys. No System + Name is displayed or editable. + +### Gaps between current state and target + +- Schedule is per-user, not per-License-Key. +- No `daily`/`weekly` typing; no day-of-week storage or UI. +- No `version`/ETag, no conditional GET. +- Schedule GET requires `email` + `licenseKey`; there is no license-key-only + route, no `/api/v2/licenses/...` namespace, and no system-name route. +- `dynamicAudio` DB default is `false`; the target default for new Blocks is + `true`. +- No System Name field, validation, or display. +- Sync is opportunistic with no boot/network-up/5-minute cadence and no + atomic cache file. +- Portal stores no timezone (matches target) and shows no informational + geoip-derived timezone. + +--- + +## Time semantics + +- Times are **minute precision**, stored as 24-hour `"HH:MM"` strings + representing **local wall-clock** time. AM/PM is display-only. +- Numerators may also be carried as integer minutes-from-midnight + (`startMinutes`/`endMinutes`, `0..1440`) for the Kiosk; the string form is + canonical on the wire. +- A Block is the **half-open interval `[start, end)`**. +- A Block may start on the same minute the previous Block ends (`end 07:00`, + next `start 07:00` is valid and has no gap). +- A Block whose `end` is earlier than its `start` **crosses midnight** and + belongs to its start day. In weekly mode, Sunday's crossing Block spills into + Monday. `start == end` is **invalid** (zero- or full-length ambiguity). +- **Overlaps are invalid**, including spillover from a midnight-crossing Block + into the next day's Blocks (or, in daily mode, into the same list's early + Blocks). Touching boundaries are **not** overlaps. +- Gaps between Blocks are allowed. During a gap the Kiosk plays whatever + Proclamations are selected locally on the Kiosk. +- The Kiosk evaluates Schedules in its own Timezone setting + (`config.timezone`, IANA code). **The payload carries no timezone.** The + Portal may show the user's geoip-derived timezone informationally only. + +--- + +## Schedule data model (JSON) + +Each License Key has **at most one** Schedule. `type` is `"daily"` or +`"weekly"` and determines the mode; there is no separate global mode. **No** +Schedule means `type` is `null`. + +Suggested shape from the brief, adapted to existing field names (`start`/`end` +→ `startTime`/`endTime`, retaining the existing `startMinutes`/`endMinutes` +integers for the Kiosk): + +```json +{ + "version": "", + "type": "daily" | "weekly" | null, + "daily": [ Block, ... ], + "weekly": { "mon": [Block], "tue": [Block], "wed": [Block], + "thu": [Block], "fri": [Block], "sat": [Block], + "sun": [Block] }, + "blocks": [ LegacyBlock, ... ] +} +``` + +Block: + +```json +{ + "id": "3f1c2b7a-9d4e-4c8a-b2f6-1a2b3c4d5e6f", + "startTime": "21:00", + "startMinutes": 1260, + "endTime": "09:00", + "endMinutes": 540, + "overnight": true, + "durationMinutes": 720, + "proclamationIds": ["b40fdcb9-703c-420e-af32-a081964c6f08"], + "dynamicAudio": true +} +``` + +- `version` is an opaque string derived from the Schedule content (see + [Endpoints](#endpoints)). It is implementation-defined and must change + whenever the serialized schedule changes. +- **Only the fields for the active type are populated.** For `type: "daily"`, + `daily` is populated and `weekly` is omitted or empty. For `type: "weekly"`, + `weekly` is populated with all seven keys and `daily` is omitted or empty. + For `type: null`, both are empty/omitted. +- `weekly` always contains all seven lowercase keys `mon`..`sun`; a day with no + Blocks is an empty array. +- `blocks` is the **legacy flattening** retained for older Kiosks (see + [Backward compatibility](#backward-compatibility)). It uses the existing + `LegacyBlock` shape serialized by the current portal + (`{id, startTime, startMinutes, endTime, endMinutes, overnight, +durationMinutes, dynamicAudio, proclamationIds}`). For `type: "weekly"`, + `blocks` is an **empty array** (the server does not know the Kiosk's + timezone and cannot flatten weekly to a single day). +- `proclamationIds` is the ordered play list (order is significant); + duplicates are not meaningful and are de-duplicated by the Portal. +- `dynamicAudio` defaults to `true` for new Blocks created on the Portal. +- Proclamation **content** is never included; the Kiosk fetches content + separately via `/api/v2/proclamations`. Portal users only ever see + title/category/group. + +--- + +## Validation rules + +These are enforced by the Portal (authoritative) and defended against by the +Kiosk. + +1. A Schedule belongs to exactly one License Key; a License Key has at most one + Schedule. +2. `type` is one of `"daily"`, `"weekly"`, or `null`. +3. A Block has `start` and `end` times; `start == end` is invalid. +4. `end < start` denotes a midnight-crossing Block belonging to its start day. +5. Blocks within a day (and the spillover of a midnight-crossing Block into the + following day) must not overlap. Touching boundaries are allowed. +6. Weekly modes apply rules 3–5 independently per day; a `sun` Block crossing + midnight must not overlap `mon` early Blocks. +7. A Block must contain at least one Proclamation. Empty Blocks are rejected + server-side. The Kiosk defensively **skips invalid Blocks** and treats them + as gaps. +8. Gaps are allowed; during a gap local Kiosk selection applies. +9. `dynamicAudio` is a boolean; absent means `true` for new Blocks. +10. Times are minute-precision `HH:MM` strings; the Portal snaps UI input to + `SNAP_MINUTES` (15) with a `MINUTE_STEP` (5) constraint from the existing + helpers. +11. The existing Portal cap of `MAX_BLOCKS = 4` per day list is **retained** + for Phase 0 (see [Open items](#open-items)). + +The Kiosk must not crash on, and must not play, an invalid Block: it skips it. + +--- + +## Endpoints + +The API remains **unauthenticated** (no API keys) for v2. This is a known +limitation and is recorded under [Open items](#open-items). + +All new endpoints live under the existing versioned namespace +`/api/v2/...` to preserve conventions. v1 routes are unchanged. + +### (a) License validation — existing, additive + +`POST /api/v2/license` + +Request (existing fields unchanged; `systemName` additive and optional): + +```json +{ + "license_key": "ABCD-EFGH-IJKL-MNOP", + "email_address": "jane@example.com", + "systemName": "Living Room" +} +``` + +Response (existing fields unchanged; `systemNameStatus` additive): + +```json +{ "key": "ABCD-EFGH-IJKL-MNOP", "email_address": "jane@example.com", "systemNameStatus": "ok" } +``` + +- `systemName` is optional. When present it is trimmed; when absent the stored + name is left untouched. +- `systemNameStatus` ∈ `{"ok", "unchanged", "conflict", "invalid"}`: + - `ok` — name was set or updated. + - `unchanged` — omitted, or identical after trim/case-insensitive compare. + - `conflict` — another License Key on the same account already holds the name. + - `invalid` — trimmed length outside 1–40. +- **Validation itself succeeds regardless of name status.** A non-`ok` status + never fails the license check; the Kiosk treats it as advisory. +- Errors and rate limiting follow the existing endpoint (400 messages, + 413 body cap, 429 with `Retry-After`). + +### (b) Set System Name — Kiosk-facing + +`PUT /api/v2/licenses/{licenseKey}/system-name` + +Request: + +```json +{ "systemName": "Living Room" } +``` + +Responses: + +| Status | Condition | Body | +| --------------------- | ----------------------------------------- | ------------------------------------------------- | +| 200 | Name set/updated (or unchanged) | `{"systemName": "...", "systemNameStatus": "ok"}` | +| 400 `invalid_name` | Trimmed length outside 1–40 | `{"message": "invalid_name"}` | +| 404 `unknown_license` | No License Key matches | `{"message": "unknown_license"}` | +| 409 `name_taken` | Another key on the account holds the name | `{"message": "name_taken"}` | + +- The Kiosk is the **only** writer. The Portal has no rename capability. +- The response body uses the existing v2 error convention + (`{"message": ""}`). +- Rate limited at the existing 10 requests/min/IP convention. + +### (c) Get Schedule — Kiosk-facing, key-only, conditional + +`GET /api/v2/licenses/{licenseKey}/schedule` + +- 200 with `ETag: ""` and the payload from + [Schedule data model](#schedule-data-model-json). A License Key with no + Schedule returns 200 with `type: null` and empty lists. +- `304 Not Modified` when the request's `If-None-Match` matches the current + `ETag`. +- `404` (`unknown_license`) when no License Key matches. +- `version` doubles as the ETag value; the header value is the quoted form. +- The response is the **target** payload (`version`, `type`, `daily`, `weekly`, + `blocks`). +- Rate limited at the existing 10 requests/min/IP convention. + +### (d) Legacy schedule read — existing, evolved additively + +`GET /api/v2/schedule?email=&licenseKey=` remains supported with +its existing auth semantics. Its response is extended **additively** with the +same `version`, `type`, `daily`, and `weekly` fields, while continuing to +populate `email` and `blocks` exactly as before. Old Kiosks ignore the new +fields; new Kiosks may read either endpoint. This endpoint does **not** emit an +ETag and does **not** return 304. + +### (e) Portal-internal endpoints (names & purpose only) + +Finalized in Phases 3 and 4. They are **not** Kiosk-facing. + +- `POST /api/v2/...` / server actions for Schedule CRUD (create/update/delete + Block, set `type`, assign Proclamations) — Phase 3. +- `POST .../copy` to copy a Schedule between License Keys on the same account — + Phase 4. + +Exact paths and payloads are deferred; they will be recorded here when added. + +--- + +## Sync behavior + +Target Kiosk sync (defaults; adjust only if the code forces it): + +- The Kiosk pulls the Schedule: + - on boot, + - on network-up, + - every 5 minutes. +- Pulls use conditional GET (`If-None-Match` / ETag); a `304` leaves the cache + untouched. +- The last Schedule is cached to disk with an **atomic write** and used offline. +- The schedule on/off toggle is **Kiosk-local** (existing + `proclamation.schedule_active`) and is not synced. +- With no cache and no network, the Kiosk behaves as if schedules are off + (local Proclamation selection applies). +- During gaps, or when schedules are off, the local audio selection applies. + +Current-state deltas (to be closed by later phases): no boot/network-up/5-minute +cadence, no ETag, no explicit atomic cache file. These are recorded under +[Touchpoints per phase](#touchpoints-per-phase). + +Identity for sync is the License Key. The legacy schedule endpoint additionally +requires the paired email; the new endpoint uses the License Key alone. + +--- + +## System Name rules + +- A System Name is a human-readable label for a License Key (e.g. + "Living Room"). It is a **label only** — never used as a reference or key. +- Unique **per account**, compared **case-insensitively**. +- Trimmed, **1–40 characters**. +- Set or changed **only via the Kiosk**. The Portal displays it **read-only** + and has no rename capability. +- The Portal falls back to displaying the License Key when no name is set. +- A Kiosk that omits `systemName` never clears an existing name. +- Wire representations: request field `systemName`; status field + `systemNameStatus` ∈ `{"ok","unchanged","conflict","invalid"}` (see + [Endpoints](#endpoints)). + +--- + +## Dynamic audio + +- Each Block has a boolean `dynamicAudio`. +- The Portal defaults it to **`true` for new Blocks**. +- `true` ⇒ the Kiosk uses dynamic audio for that Block. +- `false` ⇒ the Kiosk uses its locally chosen audio. +- During gaps, or when schedules are off, the local audio selection applies + (existing `config.audio.dynamic_audio` / fixed track). +- Kiosk parsing defaults an absent `dynamicAudio` to `true`. + +--- + +## Backward compatibility + +All existing endpoints and payloads keep working for Kiosks already in the +field. New fields are **additive**; no v2 field is removed or renamed, and v1 +is untouched. + +- **Old Kiosk vs new Portal.** An old Kiosk calls + `POST /api/v2/license` (no `systemName`) and + `GET /api/v2/schedule?email&licenseKey`, reading `blocks` and ignoring + unknown fields. The Portal keeps both, keeps the `blocks` shape byte- + compatible, and adds `version`/`type`/`daily`/`weekly` as extra fields. The + old Kiosk is unaffected and ignores `systemNameStatus`. +- **Daily schedules.** For `type: "daily"`, `blocks` is the flattened daily + list; an old Kiosk behaves exactly as today. +- **Weekly schedules.** `blocks` is an **empty array**. An old Kiosk therefore + sees no schedule and falls back to local Proclamation/audio selection. It + cannot interpret weekly schedules; this is accepted and recorded under + [Open items](#open-items). +- **No schedule.** `type: null`, `blocks: []`; both old and new Kiosks fall + back to local selection. +- **Omission of `systemName`** by an old Kiosk never clears an existing name; + `systemNameStatus` is simply ignored. +- The new endpoints are additive; their absence on old Kiosks is irrelevant. + +--- + +## Touchpoints per phase + +File/module candidates each later phase will change. Phase numbering is assumed +from the brief (Phase 3 = Portal schedule CRUD, Phase 4 = Portal schedule copy) +and is recorded under [Open items](#open-items). + +### Kiosk (`radiantwave`) + +- **Phase 1 (Kiosk scheduler UI, storage, sync, playback)** + - `src/network/api_client.h` / `src/network/api_client.cpp` — parse + `version`/`type`/`daily`/`weekly`, ETag/`If-None-Match`, `PUT` + system-name, `systemNameStatus`. + - `src/network/proclamation_sync.h` / `.cpp` — boot/network-up/5-minute + schedule sync, conditional GET, atomic cache. + - `src/network/license_service.h` / `.cpp` — send `systemName`, handle + `systemNameStatus`. + - `src/db/db_keys.h` — System Name / ETag keys; `src/db/database_manager.h` + / `.cpp` — schedule `type`/day-of-week storage, cache. + - `src/scroller_page.h` / `.cpp`, `src/scroller_streams.h` / `.cpp` — + day-of-week Block selection, spillover. + - `src/timezone_helper.h` — local day-of-week. + - `src/config/app_config.h`, `src/config/config_manager.cpp`, + `src/config/yaml_parser.cpp` — settings surface. + - New page(s) for setting the System Name; tests in `tests/` registered in + `CMakeLists.txt`. + +### Portal (`portal`) + +- **Phase 2 (System Name + license validation)** + - `prisma/schema.prisma` + migration — `LicenseKey.systemName` and + case-insensitive uniqueness. + - `src/routes/api/v2/license/+server.ts` — `systemName` / + `systemNameStatus`. + - `src/routes/api/v2/licenses/[licenseKey]/system-name/+server.ts` — new. + - `src/lib/server/v2-serializers.ts`, `src/lib/server/v2-helpers.ts`. + - Dashboard read-only display. +- **Phase 3 (Schedule CRUD: daily/weekly, per-License-Key)** + - `prisma/schema.prisma` + migration — Schedule per License Key, `type`, + day-of-week. + - `src/lib/server/schedule.ts`, `src/lib/schedule-utils.ts` — weekly, + per-license orchestration, cross-day validation. + - `src/lib/server/v2-serializers.ts` — `version`/`type`/`daily`/`weekly`. + - `src/routes/api/v2/licenses/[licenseKey]/schedule/+server.ts` — new + (ETag/304). + - `src/routes/api/v2/schedule/+server.ts` — additive evolution. + - `src/routes/(protected)/(user)/schedule/*`, + `src/lib/components/schedule/*` — weekly UI, per-license selection. + - Specs: `schedule.spec.ts`, `page.server.spec.ts`, `schedule-utils.spec.ts`. +- **Phase 4 (Schedule copy)** + - Copy service in `src/lib/server/schedule.ts`; copy route/action; UI. +- **All phases** — `docs/openapi.yaml`, this contract, `docs/fixtures/`. + +--- + +## Open items + +Recorded ambiguities and conservative choices. Each is subject to change in a +later phase. + +1. **Phase numbering.** The brief names Phases 3 (CRUD) and 4 (copy) only; + Phase 1 (Kiosk) and Phase 2 (System Name) are assumed here. +2. **`blocks` for weekly.** Chosen as an empty array (server cannot know the + Kiosk timezone). Consequence: old Kiosks see weekly schedules as "no + schedule". +3. **Block cap.** The existing `MAX_BLOCKS = 4` per day list is retained; the + brief does not state a cap. Whether weekly allows 4/day or 4 total is + unresolved. +4. **Field naming.** The brief's `start`/`end` were mapped to the existing + `startTime`/`endTime`; `startMinutes`/`endMinutes` retained for the Kiosk. +5. **`version` derivation.** Algorithm (content hash vs max `updatedAt` + + shape) deferred to implementation; it must be stable for equal content and + change on mutation. +6. **Unauthenticated key-only access.** The new endpoints authenticate by + License Key alone (per glossary). Anyone who knows a key could read the + schedule or set the name. Mitigated only by the 10/min/IP rate limit. Known + limitation. +7. **`dynamicAudio` default flip.** DB default changes `false` → `true` for + **new** Blocks; existing Blocks are left at their stored values (no + backfill assumed). +8. **Per-user → per-License-Key migration.** Accounts with multiple License + Keys: which key inherits the existing per-user Schedule is unresolved; + conservative default is the account's first/oldest key. +9. **Cross-day spillover validation.** Weekly `sun` spillover into `mon` (and + daily same-list spillover) must be validated; exact error messaging is + deferred. +10. **Account scope for name uniqueness.** "Per account" = the owning User. + Legacy License Keys with only an `email` and no `userId` are an edge case. +11. **Geoip timezone display.** Informational only, not implemented in the + Portal today. +12. **ETag on the legacy endpoint.** The legacy `GET /api/v2/schedule` emits no + ETag and never returns 304, to preserve old behaviour. +13. **`systemNameStatus` on v1.** Not added; v1 is unchanged. +14. **Trim/case representation.** Names are trimmed; uniqueness is + case-insensitive; the stored casing is preserved as provided. +15. **Sync cadence.** The brief's boot/network-up/5-minute defaults are + targets; the current Kiosk has no such timer. +16. **Atomic cache file.** Path and format deferred; SQLite is the current + durable store. +17. **`type` encoding for no schedule.** `type: null`; `daily`/`weekly` absent + or empty. +18. **Legacy alias.** `key` remains accepted as a `licenseKey` alias on the + legacy schedule endpoint. + +--- + +## Changelog + +- 2026-09-29 — Initial contract created (Phase 0). Documents current state, + target time semantics, schedule data model, validation rules, endpoints + (`/api/v2/licenses/...`, additive license-validation fields), sync behavior, + System Name rules, dynamic audio, backward compatibility, touchpoints, + open items, and fixtures. -- 2.43.0 From 59b499037b25824e9d76d25c55ba9889fd794593 Mon Sep 17 00:00:00 2001 From: NeilClack Date: Tue, 29 Sep 2026 08:43:59 -0500 Subject: [PATCH 02/17] Kiosk: show all user-visible times as 12-hour AM/PM 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. --- src/date_format.cpp | 33 ++++++++++++++++++++++ src/date_format.h | 9 ++++++ src/home.cpp | 2 +- src/log_viewer_page.cpp | 7 ++++- src/timezone_page.cpp | 9 +++++- tests/test_date_format.cpp | 56 ++++++++++++++++++++++++++++++++++++++ 6 files changed, 113 insertions(+), 3 deletions(-) diff --git a/src/date_format.cpp b/src/date_format.cpp index 717ef45..95ada58 100644 --- a/src/date_format.cpp +++ b/src/date_format.cpp @@ -1,5 +1,7 @@ #include "date_format.h" +#include +#include #include namespace radiantwave::datetime { @@ -34,4 +36,35 @@ std::string formatTime(std::time_t t) { return formatDate(t, "%H:%M:%S"); } +std::string formatHm12h(const std::string& hm24) { + // Require exactly HH:MM with two decimal digits each. + if (hm24.size() != 5 || hm24[2] != ':' || + !std::isdigit(static_cast(hm24[0])) || + !std::isdigit(static_cast(hm24[1])) || + !std::isdigit(static_cast(hm24[3])) || + !std::isdigit(static_cast(hm24[4]))) { + return hm24; + } + + const int hour24 = (hm24[0] - '0') * 10 + (hm24[1] - '0'); + const int minute = (hm24[3] - '0') * 10 + (hm24[4] - '0'); + if (hour24 > 23 || minute > 59) { + return hm24; + } + + int hour12 = hour24 % 12; + if (hour12 == 0) { + hour12 = 12; + } + const char* period = hour24 < 12 ? "AM" : "PM"; + + char buffer[16]; + std::snprintf(buffer, sizeof(buffer), "%d:%02d %s", hour12, minute, period); + return std::string(buffer); +} + +std::string formatTime12h(std::time_t t) { + return formatHm12h(formatDate(t, "%H:%M")); +} + } // namespace radiantwave::datetime diff --git a/src/date_format.h b/src/date_format.h index 719b117..e904195 100644 --- a/src/date_format.h +++ b/src/date_format.h @@ -26,4 +26,13 @@ std::string formatDate(std::time_t t, const std::string& pattern); // Render `t` as a 24-hour clock: HH:MM:SS. std::string formatTime(std::time_t t); +// Convert a 24-hour clock `HH:MM` string to a 12-hour display label, e.g. +// "00:00" -> "12:00 AM", "13:05" -> "1:05 PM". Storage and parsing remain +// 24-hour; this is display-only. Returns `hm24` unchanged when it is not a +// valid 24-hour time. +std::string formatHm12h(const std::string& hm24); + +// Render `t` in local time as a 12-hour clock label, e.g. "7:05 PM". +std::string formatTime12h(std::time_t t); + } // namespace radiantwave::datetime diff --git a/src/home.cpp b/src/home.cpp index ad9e384..9ddfe65 100644 --- a/src/home.cpp +++ b/src/home.cpp @@ -249,7 +249,7 @@ void Home::render(SDL_Renderer *renderer) internet_ ? std::string("Connected") : std::string("Offline"), audioStatus(), radiantwave::datetime::formatDate(now, dateFormat_), - radiantwave::datetime::formatTime(now), + radiantwave::datetime::formatTime12h(now), }; std::vector valueColors(values.size(), footerColor); std::vector valueBold(values.size(), false); diff --git a/src/log_viewer_page.cpp b/src/log_viewer_page.cpp index 6d25549..bb74706 100644 --- a/src/log_viewer_page.cpp +++ b/src/log_viewer_page.cpp @@ -1,5 +1,6 @@ #include "log_viewer_page.h" #include "app_state.h" +#include "date_format.h" #include "timezone_helper.h" #include "logical_size.h" #include @@ -256,7 +257,11 @@ void LogViewerPage::rebuildWrapped(float maxLineWidth) { using namespace radiantwave::logger; for (const auto& entry : rawEntries_) { - std::string timeStr = radiantwave::timezone::formatUtcToTimezone(entry.timestamp, currentTzCode_, "%Y-%m-%d %H:%M:%S"); + std::string timeStr = + radiantwave::timezone::formatUtcToTimezone(entry.timestamp, currentTzCode_, "%Y-%m-%d") + + " " + + radiantwave::datetime::formatHm12h( + radiantwave::timezone::formatUtcToTimezone(entry.timestamp, currentTzCode_, "%H:%M")); std::string fullLineStr = "[" + timeStr + "] [" + std::string(logLevelToString(entry.level)) + "] [" + entry.component + "] " + entry.message; diff --git a/src/timezone_page.cpp b/src/timezone_page.cpp index 6a01063..e6f77b2 100644 --- a/src/timezone_page.cpp +++ b/src/timezone_page.cpp @@ -1,5 +1,6 @@ #include "timezone_page.h" #include "app_state.h" +#include "date_format.h" #include "logical_size.h" #include #include @@ -237,7 +238,13 @@ void TimeZonePage::render(SDL_Renderer* renderer) { } time_t now = std::time(nullptr); - std::string localSample = "Local Time: " + radiantwave::timezone::formatUtcToTimezone(now, currentTzCode_); + // Show the configured zone's wall-clock time with an AM/PM label; storage + // and the rest of the app stay 24-hour. + std::string localSample = "Local Time: " + + radiantwave::timezone::formatUtcToTimezone(now, currentTzCode_, "%Y-%m-%d") + " " + + radiantwave::datetime::formatHm12h( + radiantwave::timezone::formatUtcToTimezone(now, currentTzCode_, "%H:%M")) + + " " + radiantwave::timezone::formatUtcToTimezone(now, currentTzCode_, "%Z"); sampleText_.ensure(renderer, fontHeader_, localSample, green); if (sampleText_.texture) { SDL_FRect dst{700.0f, currentY, sampleText_.width, sampleText_.height}; diff --git a/tests/test_date_format.cpp b/tests/test_date_format.cpp index a61e108..54d4c57 100644 --- a/tests/test_date_format.cpp +++ b/tests/test_date_format.cpp @@ -59,3 +59,59 @@ TEST_F(DateFormatTest, OptionsIncludeDefault) { EXPECT_EQ(options[0].pattern, std::string(DEFAULT_DATE_FORMAT)); EXPECT_EQ(options[0].name, "Month/Day/Year"); } + +TEST_F(DateFormatTest, Converts24HourTo12HourLabels) { + EXPECT_EQ(formatHm12h("00:00"), "12:00 AM"); + EXPECT_EQ(formatHm12h("00:05"), "12:05 AM"); + EXPECT_EQ(formatHm12h("11:59"), "11:59 AM"); + EXPECT_EQ(formatHm12h("12:00"), "12:00 PM"); + EXPECT_EQ(formatHm12h("12:30"), "12:30 PM"); + EXPECT_EQ(formatHm12h("13:00"), "1:00 PM"); + EXPECT_EQ(formatHm12h("23:59"), "11:59 PM"); +} + +TEST_F(DateFormatTest, LeavesMalformedInputUnchanged) { + EXPECT_EQ(formatHm12h(""), ""); + EXPECT_EQ(formatHm12h("7:05"), "7:05"); + EXPECT_EQ(formatHm12h("24:00"), "24:00"); + EXPECT_EQ(formatHm12h("12:60"), "12:60"); + EXPECT_EQ(formatHm12h("ab:cd"), "ab:cd"); +} + +TEST_F(DateFormatTest, Formats12HourClock) { + std::tm morning = {}; + morning.tm_year = 2026 - 1900; + morning.tm_mon = 8 - 1; + morning.tm_mday = 4; + morning.tm_hour = 7; + morning.tm_min = 5; + morning.tm_sec = 0; + morning.tm_isdst = -1; + time_t tMorning = mktime(&morning); + ASSERT_NE(tMorning, static_cast(-1)); + EXPECT_EQ(formatTime12h(tMorning), "7:05 AM"); + + std::tm midnight = {}; + midnight.tm_year = 2026 - 1900; + midnight.tm_mon = 8 - 1; + midnight.tm_mday = 4; + midnight.tm_hour = 0; + midnight.tm_min = 0; + midnight.tm_sec = 0; + midnight.tm_isdst = -1; + time_t tMidnight = mktime(&midnight); + ASSERT_NE(tMidnight, static_cast(-1)); + EXPECT_EQ(formatTime12h(tMidnight), "12:00 AM"); + + std::tm evening = {}; + evening.tm_year = 2026 - 1900; + evening.tm_mon = 8 - 1; + evening.tm_mday = 4; + evening.tm_hour = 19; + evening.tm_min = 5; + evening.tm_sec = 0; + evening.tm_isdst = -1; + time_t tEvening = mktime(&evening); + ASSERT_NE(tEvening, static_cast(-1)); + EXPECT_EQ(formatTime12h(tEvening), "7:05 PM"); +} -- 2.43.0 From 003a90d81013080e7d80573e8527168eee536c3c Mon Sep 17 00:00:00 2001 From: NeilClack Date: Tue, 29 Sep 2026 08:45:16 -0500 Subject: [PATCH 03/17] Kiosk: add a data-driven System Settings menu 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. --- CMakeLists.txt | 10 ++ src/settings.cpp | 35 +---- src/settings_menu.cpp | 15 ++ src/settings_menu.h | 29 ++++ src/system_settings_page.cpp | 272 +++++++++++++++++++++++++++++++++++ src/system_settings_page.h | 50 +++++++ tests/test_settings_menu.cpp | 50 +++++++ 7 files changed, 433 insertions(+), 28 deletions(-) create mode 100644 src/settings_menu.cpp create mode 100644 src/settings_menu.h create mode 100644 src/system_settings_page.cpp create mode 100644 src/system_settings_page.h create mode 100644 tests/test_settings_menu.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index 1c302fd..a4a34e9 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -40,6 +40,8 @@ add_executable(radiantwave src/page_manager.cpp src/home.cpp src/settings.cpp + src/settings_menu.cpp + src/system_settings_page.cpp src/wifi_setup_page.cpp src/audio_settings_page.cpp src/audio/audio_manager.cpp @@ -226,6 +228,14 @@ add_executable(test_date_format target_include_directories(test_date_format PRIVATE src) target_link_libraries(test_date_format PRIVATE GTest::gtest_main) +# System Settings menu entry list (pure data, no SDL linkage) +add_executable(test_settings_menu + tests/test_settings_menu.cpp + src/settings_menu.cpp +) +target_include_directories(test_settings_menu PRIVATE src) +target_link_libraries(test_settings_menu PRIVATE GTest::gtest_main) + # TimeZone unit tests add_executable(test_timezone tests/test_timezone.cpp diff --git a/src/settings.cpp b/src/settings.cpp index 5a135ab..f7c9636 100644 --- a/src/settings.cpp +++ b/src/settings.cpp @@ -2,14 +2,10 @@ #include "app_state.h" #include "home.h" #include "license_blocked_page.h" -#include "wifi_setup_page.h" #include "audio_settings_page.h" -#include "timezone_page.h" +#include "system_settings_page.h" #include "log_viewer_page.h" #include "proclamation_options_page.h" -#include "email_address_page.h" -#include "license_key_page.h" -#include "date_format_page.h" #include "logical_size.h" #include #include @@ -18,18 +14,13 @@ namespace { - constexpr int MENU_ITEM_COUNT = 9; - // 0 = Proclamations, 1 = Audio Options, 2 = Email Address, 3 = License Key, - // 4 = WiFi Setup, 5 = Time Zone Setup, 6 = Date Format, - // 7 = System Logs, 8 = Back + constexpr int MENU_ITEM_COUNT = 5; + // 0 = Proclamations, 1 = Audio Options, 2 = System Settings, + // 3 = System Logs, 4 = Back constexpr const char *MENU_LABELS[MENU_ITEM_COUNT] = { "Proclamations", "Audio Options", - "Email Address", - "License Key", - "WiFi Setup", - "Time Zone Setup", - "Date Format", + "System Settings", "System Logs", "Back to Home", }; @@ -92,24 +83,12 @@ void Settings::handleEvent(const SDL_Event &event) state_->pages.push(*state_, std::make_unique()); break; case 2: - state_->pages.push(*state_, std::make_unique()); + state_->pages.push(*state_, std::make_unique()); break; case 3: - state_->pages.push(*state_, std::make_unique()); - break; - case 4: - state_->pages.push(*state_, std::make_unique()); - break; - case 5: - state_->pages.push(*state_, std::make_unique()); - break; - case 6: - state_->pages.push(*state_, std::make_unique()); - break; - case 7: state_->pages.push(*state_, std::make_unique()); break; - case 8: + case 4: state_->pages.replace(*state_, std::make_unique()); break; default: diff --git a/src/settings_menu.cpp b/src/settings_menu.cpp new file mode 100644 index 0000000..cbf9e4f --- /dev/null +++ b/src/settings_menu.cpp @@ -0,0 +1,15 @@ +#include "settings_menu.h" + +namespace radiantwave::settings { + +std::vector systemSettingEntries() { + return { + {"Email Address", SystemSettingId::EmailAddress}, + {"License Key", SystemSettingId::LicenseKey}, + {"WiFi Setup", SystemSettingId::WiFiSetup}, + {"Time Zone Setup", SystemSettingId::Timezone}, + {"Date Format", SystemSettingId::DateFormat}, + }; +} + +} // namespace radiantwave::settings diff --git a/src/settings_menu.h b/src/settings_menu.h new file mode 100644 index 0000000..96ca701 --- /dev/null +++ b/src/settings_menu.h @@ -0,0 +1,29 @@ +#pragma once + +#include +#include + +namespace radiantwave::settings { + +// Identifies a page reachable from the System Settings menu. +enum class SystemSettingId { + EmailAddress, + LicenseKey, + WiFiSetup, + Timezone, + DateFormat, +}; + +// One row in the System Settings menu. `label` is what the user sees; +// `id` selects the page the row opens. +struct SystemSettingEntry { + std::string label; + SystemSettingId id; +}; + +// The ordered System Settings entries. Adding a future setting (System Name in +// Phase 2, Schedule toggle in Phase 3) is a single entry here plus its page +// wiring — no new menu plumbing. Kept free of SDL so it is unit-testable. +std::vector systemSettingEntries(); + +} // namespace radiantwave::settings diff --git a/src/system_settings_page.cpp b/src/system_settings_page.cpp new file mode 100644 index 0000000..fda054a --- /dev/null +++ b/src/system_settings_page.cpp @@ -0,0 +1,272 @@ +#include "system_settings_page.h" +#include "app_state.h" +#include "date_format_page.h" +#include "email_address_page.h" +#include "license_key_page.h" +#include "logical_size.h" +#include "timezone_page.h" +#include "wifi_setup_page.h" +#include +#include +#include + +namespace +{ + constexpr const char *TITLE = "System Settings"; + constexpr const char *BACK_LABEL = "Back to Settings"; +} // anonymous namespace + +void SystemSettingsPage::onPush(AppState &state) +{ + state_ = &state; + TTF_Init(); + createTextures(state); + bindNavigation(state); +} + +void SystemSettingsPage::onResume(AppState &state) +{ + state_ = &state; + createTextures(state); + bindNavigation(state); +} + +void SystemSettingsPage::onPause(AppState &state) +{ + state.keybinds.unbindAll(); + destroyTextures(); +} + +void SystemSettingsPage::onPop(AppState &state) +{ + destroyTextures(); + TTF_Quit(); +} + +void SystemSettingsPage::bindNavigation(AppState &state) +{ + state.keybinds.bind(SDLK_UP, "system_settings_up", [this]() + { + int count = static_cast(items_.size()); + if (count > 0) + selectedItem_ = (selectedItem_ - 1 + count) % count; + }); + state.keybinds.bind(SDLK_DOWN, "system_settings_down", [this]() + { + int count = static_cast(items_.size()); + if (count > 0) + selectedItem_ = (selectedItem_ + 1) % count; + }); +} + +void SystemSettingsPage::activateSelected(AppState &state) +{ + // The trailing row (index == number of setting entries) goes back. + if (selectedItem_ < 0 || selectedItem_ >= static_cast(entries_.size())) + { + state.pages.pop(state); + return; + } + + switch (entries_[selectedItem_].id) + { + case radiantwave::settings::SystemSettingId::EmailAddress: + state.pages.push(state, std::make_unique()); + break; + case radiantwave::settings::SystemSettingId::LicenseKey: + state.pages.push(state, std::make_unique()); + break; + case radiantwave::settings::SystemSettingId::WiFiSetup: + state.pages.push(state, std::make_unique()); + break; + case radiantwave::settings::SystemSettingId::Timezone: + state.pages.push(state, std::make_unique()); + break; + case radiantwave::settings::SystemSettingId::DateFormat: + state.pages.push(state, std::make_unique()); + break; + } +} + +void SystemSettingsPage::handleEvent(const SDL_Event &event) +{ + if (event.type == SDL_EVENT_KEY_DOWN && event.key.key == SDLK_RETURN) + { + activateSelected(*state_); + } + else if (event.type == SDL_EVENT_KEY_DOWN && event.key.key == SDLK_ESCAPE) + { + state_->pages.pop(*state_); + } +} + +void SystemSettingsPage::update(float dt) +{ +} + +void SystemSettingsPage::render(SDL_Renderer *renderer) +{ + int winW, winH; + radiantwave::getLayoutSize(renderer, &winW, &winH); + float fw = static_cast(winW); + float fh = static_cast(winH); + + // 1. Logo Background Image + if (bgTexture_) + { + float scaleX = fw / bgWidth_; + float scaleY = fh / bgHeight_; + float scale = std::min(scaleX, scaleY); + float dw = bgWidth_ * scale; + float dh = bgHeight_ * scale; + float dx = (fw - dw) / 2.0f; + float dy = (fh - dh) / 2.0f; + SDL_FRect bgDst{dx, dy, dw, dh}; + SDL_RenderTexture(renderer, bgTexture_, nullptr, &bgDst); + } + + // 2. Dark Overlay + SDL_SetRenderDrawBlendMode(renderer, SDL_BLENDMODE_BLEND); + SDL_SetRenderDrawColor(renderer, 0, 0, 0, 160); + SDL_FRect overlayRect{0, 0, fw, fh}; + SDL_RenderFillRect(renderer, &overlayRect); + + // 3. Title Text + float titleY = 60.0f; + if (titleTexture_) + { + float x = (fw - titleWidth_) / 2.0f; + SDL_FRect dst{x, titleY, titleWidth_, titleHeight_}; + SDL_RenderTexture(renderer, titleTexture_, nullptr, &dst); + } + + // Compute bounding box for all menu items + float maxWidth = 0.0f; + for (const auto &item : items_) + { + maxWidth = std::max(maxWidth, item.width); + } + float margin = 12.0f; // 12px vertical margin rule + + // Shared left edge for left-aligned items with centered bounding box + float sharedX = (fw - maxWidth) / 2.0f; + + // Start menu list exactly 24 pixels below title text + float currentY = titleY + titleHeight_ + 24.0f; + + for (auto &item : items_) + { + item.y = currentY; + if (item.texture) + { + SDL_FRect dst{sharedX, item.y, item.width, item.height}; + SDL_RenderTexture(renderer, item.texture, nullptr, &dst); + } + currentY += item.height + margin; + } + + // 6px Underline for selected item occupying half of the 12px margin space + if (selectedItem_ >= 0 && selectedItem_ < static_cast(items_.size())) + { + const auto &item = items_[selectedItem_]; + if (item.texture) + { + SDL_SetRenderDrawColor(renderer, 255, 255, 255, 255); + SDL_FRect line{sharedX, item.y + item.height + 2.0f, item.width, 6.0f}; + SDL_RenderFillRect(renderer, &line); + } + } +} + +void SystemSettingsPage::createTextures(AppState &state) +{ + destroyTextures(); + + entries_ = radiantwave::settings::systemSettingEntries(); + if (selectedItem_ >= static_cast(entries_.size()) + 1) + { + selectedItem_ = 0; + } + + std::vector labels; + labels.reserve(entries_.size() + 1); + for (const auto &entry : entries_) + { + labels.push_back(entry.label); + } + labels.push_back(BACK_LABEL); + + // Background Image + SDL_Surface *bgSurface = IMG_Load("assets/images/RWT-Circular-Color-ForOnDark-NoText.png"); + if (bgSurface) + { + bgTexture_ = SDL_CreateTextureFromSurface(state.renderer, bgSurface); + bgWidth_ = static_cast(bgSurface->w); + bgHeight_ = static_cast(bgSurface->h); + SDL_DestroySurface(bgSurface); + SDL_SetTextureAlphaMod(bgTexture_, 204); + } + + SDL_Color white = {255, 255, 255, 255}; + // Title Font + TTF_Font *fontLarge = TTF_OpenFont("assets/fonts/Roboto-Regular.ttf", 64); + if (fontLarge) + { + SDL_Surface *surface = TTF_RenderText_Blended(fontLarge, TITLE, 0, white); + if (surface) + { + titleTexture_ = SDL_CreateTextureFromSurface(state.renderer, surface); + titleWidth_ = static_cast(surface->w); + titleHeight_ = static_cast(surface->h); + SDL_DestroySurface(surface); + } + TTF_CloseFont(fontLarge); + } + + // Item Font + TTF_Font *fontItem = TTF_OpenFont("assets/fonts/Roboto-Regular.ttf", 36); + if (!fontItem) + return; + + items_.reserve(labels.size()); + for (const auto &label : labels) + { + MenuItem item; + item.label = label; + + SDL_Surface *surface = TTF_RenderText_Blended(fontItem, label.c_str(), 0, white); + if (surface) + { + item.texture = SDL_CreateTextureFromSurface(state.renderer, surface); + item.width = static_cast(surface->w); + item.height = static_cast(surface->h); + SDL_DestroySurface(surface); + } + items_.push_back(std::move(item)); + } + + TTF_CloseFont(fontItem); +} + +void SystemSettingsPage::destroyTextures() +{ + if (bgTexture_) + { + SDL_DestroyTexture(bgTexture_); + bgTexture_ = nullptr; + } + if (titleTexture_) + { + SDL_DestroyTexture(titleTexture_); + titleTexture_ = nullptr; + } + for (auto &item : items_) + { + if (item.texture) + { + SDL_DestroyTexture(item.texture); + item.texture = nullptr; + } + } + items_.clear(); +} diff --git a/src/system_settings_page.h b/src/system_settings_page.h new file mode 100644 index 0000000..d4c3d00 --- /dev/null +++ b/src/system_settings_page.h @@ -0,0 +1,50 @@ +#pragma once + +#include "page.h" +#include "settings_menu.h" +#include +#include +#include + +// A single menu that groups the device's system-level settings (Email, License +// Key, WiFi, Timezone, Date Format). The row list is data-driven from +// `radiantwave::settings::systemSettingEntries()`, so adding a setting later is +// one entry plus its page — no new menu plumbing. +class SystemSettingsPage : public Page { +public: + void onPush(AppState& state) override; + void onResume(AppState& state) override; + void onPause(AppState& state) override; + void onPop(AppState& state) override; + void handleEvent(const SDL_Event& event) override; + void update(float dt) override; + void render(SDL_Renderer* renderer) override; + +private: + struct MenuItem { + std::string label; + SDL_Texture* texture = nullptr; + float width = 0; + float height = 0; + float y = 0; + }; + + void bindNavigation(AppState& state); + void activateSelected(AppState& state); + void createTextures(AppState& state); + void destroyTextures(); + + AppState* state_ = nullptr; + int selectedItem_ = 0; + std::vector entries_; + // Setting rows followed by a trailing "Back to Settings" row. + std::vector items_; + + SDL_Texture* bgTexture_ = nullptr; + float bgWidth_ = 0; + float bgHeight_ = 0; + + SDL_Texture* titleTexture_ = nullptr; + float titleWidth_ = 0; + float titleHeight_ = 0; +}; diff --git a/tests/test_settings_menu.cpp b/tests/test_settings_menu.cpp new file mode 100644 index 0000000..7a65950 --- /dev/null +++ b/tests/test_settings_menu.cpp @@ -0,0 +1,50 @@ +#include "settings_menu.h" +#include +#include +#include + +namespace settings = radiantwave::settings; + +TEST(SystemSettingsMenuTest, ListsTheSystemSettingsInOrder) { + const auto entries = settings::systemSettingEntries(); + ASSERT_EQ(entries.size(), 5u); + EXPECT_EQ(entries[0].label, "Email Address"); + EXPECT_EQ(entries[0].id, settings::SystemSettingId::EmailAddress); + EXPECT_EQ(entries[1].label, "License Key"); + EXPECT_EQ(entries[1].id, settings::SystemSettingId::LicenseKey); + EXPECT_EQ(entries[2].label, "WiFi Setup"); + EXPECT_EQ(entries[2].id, settings::SystemSettingId::WiFiSetup); + EXPECT_EQ(entries[3].label, "Time Zone Setup"); + EXPECT_EQ(entries[3].id, settings::SystemSettingId::Timezone); + EXPECT_EQ(entries[4].label, "Date Format"); + EXPECT_EQ(entries[4].id, settings::SystemSettingId::DateFormat); +} + +TEST(SystemSettingsMenuTest, LabelsAreNonEmptyAndUnique) { + const auto entries = settings::systemSettingEntries(); + std::set labels; + std::set ids; + for (const auto& entry : entries) { + EXPECT_FALSE(entry.label.empty()); + labels.insert(entry.label); + ids.insert(static_cast(entry.id)); + } + EXPECT_EQ(labels.size(), entries.size()); + EXPECT_EQ(ids.size(), entries.size()); +} + +TEST(SystemSettingsMenuTest, EveryEntryMapsToAKnownPage) { + for (const auto& entry : settings::systemSettingEntries()) { + switch (entry.id) { + case settings::SystemSettingId::EmailAddress: + case settings::SystemSettingId::LicenseKey: + case settings::SystemSettingId::WiFiSetup: + case settings::SystemSettingId::Timezone: + case settings::SystemSettingId::DateFormat: + SUCCEED(); + break; + default: + FAIL() << "Unhandled SystemSettingId for label " << entry.label; + } + } +} -- 2.43.0 From e7df02344b3fb26d966e94146cdf879cd3f15438 Mon Sep 17 00:00:00 2001 From: NeilClack Date: Tue, 29 Sep 2026 09:57:52 -0500 Subject: [PATCH 04/17] Kiosk: System Name storage, validation, sync, and sent on license validation 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. --- CMakeLists.txt | 16 ++ src/app_state.h | 2 + src/db/db_keys.h | 16 ++ src/main.cpp | 5 + src/network/api_client.cpp | 74 ++++++++-- src/network/api_client.h | 27 +++- src/network/license_service.cpp | 25 +++- src/network/license_service.h | 12 ++ src/network/system_name.cpp | 123 ++++++++++++++++ src/network/system_name.h | 62 ++++++++ src/network/system_name_sync.cpp | 70 +++++++++ src/network/system_name_sync.h | 47 ++++++ src/validation.cpp | 17 +++ src/validation.h | 10 ++ tests/test_license_service.cpp | 75 ++++++++++ tests/test_system_name.cpp | 244 +++++++++++++++++++++++++++++++ 16 files changed, 812 insertions(+), 13 deletions(-) create mode 100644 src/network/system_name.cpp create mode 100644 src/network/system_name.h create mode 100644 src/network/system_name_sync.cpp create mode 100644 src/network/system_name_sync.h create mode 100644 tests/test_system_name.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index a4a34e9..40e1e55 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -59,6 +59,8 @@ add_executable(radiantwave src/network/api_client.cpp src/network/license_service.cpp src/network/license_status.cpp + src/network/system_name.cpp + src/network/system_name_sync.cpp src/config/yaml_parser.cpp src/config/config_manager.cpp src/db/database_manager.cpp @@ -193,9 +195,11 @@ target_link_libraries(test_api_client PRIVATE GTest::gtest_main CURL::libcurl SQ add_executable(test_license_service tests/test_license_service.cpp src/network/license_service.cpp + src/network/system_name.cpp src/network/api_client.cpp src/network/json.cpp src/db/database_manager.cpp + src/validation.cpp ) target_include_directories(test_license_service PRIVATE src) target_link_libraries(test_license_service PRIVATE GTest::gtest_main CURL::libcurl SQLite3::SQLite3) @@ -205,9 +209,11 @@ add_executable(test_license_status tests/test_license_status.cpp src/network/license_status.cpp src/network/license_service.cpp + src/network/system_name.cpp src/network/api_client.cpp src/network/json.cpp src/db/database_manager.cpp + src/validation.cpp ) target_include_directories(test_license_status PRIVATE src) target_link_libraries(test_license_status PRIVATE GTest::gtest_main CURL::libcurl SQLite3::SQLite3) @@ -236,6 +242,16 @@ add_executable(test_settings_menu target_include_directories(test_settings_menu PRIVATE src) target_link_libraries(test_settings_menu PRIVATE GTest::gtest_main) +# System Name local state + sync logic (HTTP layer is injected/faked) +add_executable(test_system_name + tests/test_system_name.cpp + src/network/system_name.cpp + src/validation.cpp + src/db/database_manager.cpp +) +target_include_directories(test_system_name PRIVATE src) +target_link_libraries(test_system_name PRIVATE GTest::gtest_main SQLite3::SQLite3) + # TimeZone unit tests add_executable(test_timezone tests/test_timezone.cpp diff --git a/src/app_state.h b/src/app_state.h index 82862cd..40f9835 100644 --- a/src/app_state.h +++ b/src/app_state.h @@ -7,6 +7,7 @@ #include "db/database_manager.h" #include "network/network_manager.h" #include "network/license_status.h" +#include "network/system_name_sync.h" #include "audio/audio_manager.h" #include "audio/audio_downloader.h" @@ -19,6 +20,7 @@ struct AppState { radiantwave::db::DatabaseManager db; radiantwave::network::NetworkManager network; radiantwave::license::LicenseStatusTracker licenseTracker; + radiantwave::systemname::SystemNameSync systemNameSync; radiantwave::audio::AudioManager audio; radiantwave::audio::AudioDownloader audioDownloader; bool quitRequested = false; diff --git a/src/db/db_keys.h b/src/db/db_keys.h index 7c2d91c..6bcb5f7 100644 --- a/src/db/db_keys.h +++ b/src/db/db_keys.h @@ -12,4 +12,20 @@ constexpr const char* KV_LICENSE_KEY = "license_key"; // outages do not revoke a known-good license. constexpr const char* KV_LICENSE_LAST_VERIFIED = "license_last_verified"; +// The System Name the user typed on the Kiosk (trimmed). A human-readable label +// set only here; never a reference or key. +constexpr const char* KV_SYSTEM_NAME = "system_name"; + +// The last System Name the server accepted. Used to revert a local change when +// the server reports a conflict or invalid value. +constexpr const char* KV_SYSTEM_NAME_SYNCED = "system_name_synced"; + +// "1" while the local System Name still needs to be sent to the portal (e.g. +// the license is not validated yet, or a previous sync failed). +constexpr const char* KV_SYSTEM_NAME_PENDING = "system_name_pending_sync"; + +// The most recent System Name status message to show the user (e.g. a conflict +// message after a background sync). Cleared once consumed/acknowledged. +constexpr const char* KV_SYSTEM_NAME_MESSAGE = "system_name_message"; + } // namespace radiantwave::db diff --git a/src/main.cpp b/src/main.cpp index d097fcd..be37cc0 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -162,6 +162,10 @@ SDL_AppResult SDL_AppInit(void **appstate, int argc, char *argv[]) { // displayed; the result gates access to Play and the Proclamation options. state->licenseTracker.start(state->db); + // Background System Name sync. Retries a pending name on boot and on a + // timer; license validation also applies the name (see license_service). + state->systemNameSync.start(state->db); + state->config.onChanged( [state](const radiantwave::config::AppConfig &new_config) { SDL_Log("Live config update received: volume=%d, dynamic_audio=%d", @@ -271,6 +275,7 @@ void SDL_AppQuit(void *appstate, SDL_AppResult result) { auto *state = static_cast(appstate); if (state) { state->audioDownloader.shutdown(); + state->systemNameSync.shutdown(); state->licenseTracker.shutdown(); state->audio.shutdown(); state->network.shutdown(); diff --git a/src/network/api_client.cpp b/src/network/api_client.cpp index de8bd06..60b57f3 100644 --- a/src/network/api_client.cpp +++ b/src/network/api_client.cpp @@ -2,6 +2,7 @@ #include "network/json.h" #include +#include #include namespace radiantwave::api { @@ -26,9 +27,10 @@ size_t writeCallback(char* ptr, size_t size, size_t nmemb, void* userdata) { return size * nmemb; } -// Shared curl setup + perform. Returns a populated HttpResponse. +// Shared curl setup + perform. Returns a populated HttpResponse. `method` is +// "GET", "POST", or "PUT"; GET sends no body. HttpResponse performRequest(const std::string& url, - bool post, const std::string& jsonBody) { + const char* method, const std::string& jsonBody) { HttpResponse response; CURL* curl = curl_easy_init(); @@ -42,12 +44,17 @@ HttpResponse performRequest(const std::string& url, curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, writeCallback); curl_easy_setopt(curl, CURLOPT_WRITEDATA, &response.body); + const bool sendsBody = method != nullptr && std::string(method) != "GET"; struct curl_slist* headers = nullptr; - if (post) { + if (sendsBody) { headers = curl_slist_append(headers, "Content-Type: application/json"); - curl_easy_setopt(curl, CURLOPT_POST, 1L); curl_easy_setopt(curl, CURLOPT_POSTFIELDS, jsonBody.c_str()); curl_easy_setopt(curl, CURLOPT_POSTFIELDSIZE, static_cast(jsonBody.size())); + if (std::string(method) == "POST") { + curl_easy_setopt(curl, CURLOPT_POST, 1L); + } else { + curl_easy_setopt(curl, CURLOPT_CUSTOMREQUEST, method); + } } if (headers) { curl_easy_setopt(curl, CURLOPT_HTTPHEADER, headers); @@ -65,16 +72,44 @@ HttpResponse performRequest(const std::string& url, return response; } +// Escapes a string for inclusion in a JSON string literal. +std::string jsonEscape(const std::string& input) { + std::string out; + out.reserve(input.size() + 2); + for (unsigned char c : input) { + switch (c) { + case '"': out += "\\\""; break; + case '\\': out += "\\\\"; break; + case '\n': out += "\\n"; break; + case '\r': out += "\\r"; break; + case '\t': out += "\\t"; break; + default: + if (c < 0x20) { + char buf[8]; + std::snprintf(buf, sizeof(buf), "\\u%04x", c); + out += buf; + } else { + out += static_cast(c); + } + } + } + return out; +} + } // anonymous namespace // ─── HTTP helpers ────────────────────────────────────────────────────── HttpResponse httpGet(const std::string& path) { - return performRequest(urlFor(path), false, ""); + return performRequest(urlFor(path), "GET", ""); } HttpResponse httpPost(const std::string& path, const std::string& jsonBody) { - return performRequest(urlFor(path), true, jsonBody); + return performRequest(urlFor(path), "POST", jsonBody); +} + +HttpResponse httpPut(const std::string& path, const std::string& jsonBody) { + return performRequest(urlFor(path), "PUT", jsonBody); } // ─── GET /frequencies ────────────────────────────────────────────────── @@ -177,6 +212,7 @@ bool parseLicenseJson(const std::string& body, LicenseInfo& out) { out.key = root.stringField("key"); out.email_address = root.stringField("email_address"); + out.system_name_status = root.stringField("systemNameStatus"); // Any other fields (e.g. the legacy `subscription_level`) are ignored. A // successful validation itself is the "subscribed" flag; the kiosk treats @@ -185,11 +221,17 @@ bool parseLicenseJson(const std::string& body, LicenseInfo& out) { return !out.key.empty(); } -LicenseResult validateLicense(const std::string& key, const std::string& email) { +LicenseResult validateLicense(const std::string& key, const std::string& email, + const std::string& systemName) { LicenseResult result; - const std::string jsonBody = - "{\"license_key\":\"" + key + "\",\"email_address\":\"" + email + "\"}"; + std::string jsonBody = "{\"license_key\":\"" + jsonEscape(key) + + "\",\"email_address\":\"" + jsonEscape(email) + "\""; + if (!systemName.empty()) { + jsonBody += ",\"systemName\":\"" + jsonEscape(systemName) + "\""; + } + jsonBody += "}"; + HttpResponse response = httpPost(PATH_LICENSE, jsonBody); result.status = response.status; @@ -327,4 +369,18 @@ AccountResult createAccount(const std::string& email, const std::string& name) { return result; } +// ─── PUT /licenses/{key}/system-name ─────────────────────────────────── + +PutNameResult putSystemName(const std::string& key, const std::string& name) { + PutNameResult result; + + const std::string path = "/licenses/" + urlEncode(key) + "/system-name"; + const std::string jsonBody = "{\"systemName\":\"" + jsonEscape(name) + "\"}"; + + HttpResponse response = httpPut(path, jsonBody); + result.status = static_cast(response.status); + result.transport_ok = response.transportOk; + return result; +} + } // namespace radiantwave::api diff --git a/src/network/api_client.h b/src/network/api_client.h index cc9005b..3f8c0f6 100644 --- a/src/network/api_client.h +++ b/src/network/api_client.h @@ -26,9 +26,10 @@ struct HttpResponse { bool transportOk = false; // true when the request completed without a transport error }; -// Performs a GET/POST against `path` (relative to API_BASE_URL). +// Performs a GET/POST/PUT against `path` (relative to API_BASE_URL). HttpResponse httpGet(const std::string& path); HttpResponse httpPost(const std::string& path, const std::string& jsonBody); +HttpResponse httpPut(const std::string& path, const std::string& jsonBody); // ─── GET /frequencies ────────────────────────────────────────────────── @@ -79,6 +80,10 @@ bool fetchColorPalettes(std::vector& out); struct LicenseInfo { std::string key; std::string email_address; + // Advisory System Name status returned by the portal: one of "ok", + // "unchanged", "conflict", "invalid", or empty when the portal did not + // report one (e.g. older portal). + std::string system_name_status; }; // Parse the 200 JSON payload returned by the license endpoint. @@ -95,7 +100,25 @@ struct LicenseResult { // Validate a license key / email pair against the portal. Never throws; failures // (network, HTTP error status) are reported through the returned LicenseResult. // A 2xx response means the license is valid and the subscription is active. -LicenseResult validateLicense(const std::string& key, const std::string& email); +// When `systemName` is non-empty it is sent as the additive `systemName` field +// (the Kiosk is the only writer); the response's `systemNameStatus` is advisory. +LicenseResult validateLicense(const std::string& key, const std::string& email, + const std::string& systemName = ""); + +// ─── PUT /licenses/{key}/system-name ─────────────────────────────────── + +// Result of a System Name PUT. `status` is the HTTP status (0 on transport +// failure); `transport_ok` is false when the request never completed. +struct PutNameResult { + int status = 0; + bool transport_ok = false; + + bool ok() const { return status >= 200 && status < 300; } +}; + +// Set the System Name for a License Key. 200 on success, 400 invalid_name, +// 404 unknown_license, 409 name_taken. Never throws. +PutNameResult putSystemName(const std::string& key, const std::string& name); // ─── POST /automations/create/account ────────────────────────────────── diff --git a/src/network/license_service.cpp b/src/network/license_service.cpp index a812893..31b25a7 100644 --- a/src/network/license_service.cpp +++ b/src/network/license_service.cpp @@ -1,6 +1,7 @@ #include "network/license_service.h" #include "db/db_keys.h" #include "network/api_client.h" +#include "network/system_name.h" #include #include @@ -18,6 +19,18 @@ long long nowEpochSeconds() { Result validateStoredLicense(radiantwave::db::DatabaseManager& db, std::string* outErrorMessage) { + return validateStoredLicenseWith( + db, + [](const std::string& key, const std::string& email, + const std::string& systemName) { + return radiantwave::api::validateLicense(key, email, systemName); + }, + outErrorMessage); +} + +Result validateStoredLicenseWith(radiantwave::db::DatabaseManager& db, + const ValidateFn& validate, + std::string* outErrorMessage) { if (outErrorMessage) outErrorMessage->clear(); const std::string email = db.getKV(radiantwave::db::KV_EMAIL_ADDRESS); @@ -26,8 +39,10 @@ Result validateStoredLicense(radiantwave::db::DatabaseManager& db, return Result::NotConfigured; } - const radiantwave::api::LicenseResult result = - radiantwave::api::validateLicense(key, email); + // The Kiosk is the only writer of the System Name; send the locally stored + // name (possibly empty) so the portal can confirm or reject it. + const std::string systemName = radiantwave::systemname::storedName(db); + const radiantwave::api::LicenseResult result = validate(key, email, systemName); if (result.status == 0) { if (outErrorMessage) *outErrorMessage = "network error"; @@ -42,6 +57,12 @@ Result validateStoredLicense(radiantwave::db::DatabaseManager& db, // and the subscription is active. Only the "last verified" stamp is stored // (it drives the offline grace window). markLicenseVerified(db); + + // Apply the advisory System Name status. A conflict/invalid reverts the + // local name and records a message the UI can surface. + if (!result.info.system_name_status.empty()) { + radiantwave::systemname::applyServerStatus(db, result.info.system_name_status); + } return Result::Ok; } diff --git a/src/network/license_service.h b/src/network/license_service.h index 2888118..a0c9cf9 100644 --- a/src/network/license_service.h +++ b/src/network/license_service.h @@ -1,6 +1,8 @@ #pragma once #include "db/database_manager.h" +#include "network/api_client.h" +#include #include namespace radiantwave::license { @@ -28,6 +30,16 @@ constexpr long long GRACE_PERIOD_SECONDS = 7LL * 24 * 60 * 60; Result validateStoredLicense(radiantwave::db::DatabaseManager& db, std::string* outErrorMessage = nullptr); +// Injectable core of validateStoredLicense: performs the given validation call +// instead of the live portal. On success it stamps "last verified" and applies +// any advisory `systemNameStatus` to the local System Name state. +using ValidateFn = std::function; + +Result validateStoredLicenseWith(radiantwave::db::DatabaseManager& db, + const ValidateFn& validate, + std::string* outErrorMessage = nullptr); + // Records the current time as the "last verified" stamp of the license. Called // after every successful server validation so the offline grace window starts // (or is extended) from the most recent confirmation. diff --git a/src/network/system_name.cpp b/src/network/system_name.cpp new file mode 100644 index 0000000..f5ac0c3 --- /dev/null +++ b/src/network/system_name.cpp @@ -0,0 +1,123 @@ +#include "network/system_name.h" +#include "db/db_keys.h" +#include "validation.h" + +namespace radiantwave::systemname { + +namespace { + +void revertToSynced(db::DatabaseManager& db) { + const std::string synced = db.getKV(db::KV_SYSTEM_NAME_SYNCED); + if (synced.empty()) { + db.deleteKV(db::KV_SYSTEM_NAME); + } else { + db.setKV(db::KV_SYSTEM_NAME, synced); + } +} + +} // namespace + +Status parseStatus(const std::string& status) { + if (status == "ok") return Status::Ok; + if (status == "conflict") return Status::Conflict; + if (status == "invalid") return Status::Invalid; + return Status::Unchanged; +} + +std::string storedName(const db::DatabaseManager& db) { + return db.getKV(db::KV_SYSTEM_NAME); +} + +bool pendingSync(const db::DatabaseManager& db) { + return db.getKV(db::KV_SYSTEM_NAME_PENDING) == "1"; +} + +bool storeLocalName(db::DatabaseManager& db, const std::string& raw, + std::string* error) { + const std::string trimmed = validation::trim(raw); + if (!validation::isValidSystemName(trimmed)) { + if (error) *error = INVALID_MESSAGE; + return false; + } + + db.setKV(db::KV_SYSTEM_NAME, trimmed); + db.setKV(db::KV_SYSTEM_NAME_PENDING, "1"); + db.setKV(db::KV_SYSTEM_NAME_MESSAGE, ""); + return true; +} + +Status applyServerStatus(db::DatabaseManager& db, const std::string& serverStatus) { + const Status status = parseStatus(serverStatus); + switch (status) { + case Status::Ok: + db.setKV(db::KV_SYSTEM_NAME_SYNCED, db.getKV(db::KV_SYSTEM_NAME)); + db.setKV(db::KV_SYSTEM_NAME_PENDING, "0"); + db.setKV(db::KV_SYSTEM_NAME_MESSAGE, ""); + return Status::Ok; + case Status::Unchanged: + if (!db.getKV(db::KV_SYSTEM_NAME).empty()) { + db.setKV(db::KV_SYSTEM_NAME_SYNCED, db.getKV(db::KV_SYSTEM_NAME)); + } + db.setKV(db::KV_SYSTEM_NAME_PENDING, "0"); + db.setKV(db::KV_SYSTEM_NAME_MESSAGE, ""); + return Status::Unchanged; + case Status::Conflict: + revertToSynced(db); + db.setKV(db::KV_SYSTEM_NAME_PENDING, "0"); + db.setKV(db::KV_SYSTEM_NAME_MESSAGE, CONFLICT_MESSAGE); + return Status::Conflict; + case Status::Invalid: + revertToSynced(db); + db.setKV(db::KV_SYSTEM_NAME_PENDING, "0"); + db.setKV(db::KV_SYSTEM_NAME_MESSAGE, INVALID_MESSAGE); + return Status::Invalid; + default: + return Status::Unchanged; + } +} + +Status syncWithServer(db::DatabaseManager& db, const PutFn& put) { + const std::string key = db.getKV(db::KV_LICENSE_KEY); + if (key.empty()) { + return Status::NotConfigured; + } + + const std::string name = db.getKV(db::KV_SYSTEM_NAME); + if (name.empty()) { + // Nothing to sync; a cleared/empty name is never sent. + db.setKV(db::KV_SYSTEM_NAME_PENDING, "0"); + return Status::Unchanged; + } + + if (!pendingSync(db)) { + return Status::Unchanged; + } + + const api::PutNameResult result = put(key, name); + if (!result.transport_ok) { + return Status::NetworkError; // keep pending for a later retry + } + + switch (result.status) { + case 200: + return applyServerStatus(db, "ok"); + case 400: + return applyServerStatus(db, "invalid"); + case 409: + return applyServerStatus(db, "conflict"); + case 404: + return Status::UnknownLicense; // keep pending; license not validated + default: + return Status::NetworkError; // 5xx etc: keep pending + } +} + +std::string consumeMessage(db::DatabaseManager& db) { + const std::string message = db.getKV(db::KV_SYSTEM_NAME_MESSAGE); + if (!message.empty()) { + db.setKV(db::KV_SYSTEM_NAME_MESSAGE, ""); + } + return message; +} + +} // namespace radiantwave::systemname diff --git a/src/network/system_name.h b/src/network/system_name.h new file mode 100644 index 0000000..f286b5d --- /dev/null +++ b/src/network/system_name.h @@ -0,0 +1,62 @@ +#pragma once + +#include "db/database_manager.h" +#include "network/api_client.h" + +#include +#include + +namespace radiantwave::systemname { + +// Outcome of a System Name operation. +enum class Status { + Ok, // server accepted the name (or it was saved locally) + Unchanged, // no change needed / already in sync + Conflict, // another Kiosk on the account already uses the name + Invalid, // 1-40 character validation failed + NetworkError, // the request never completed; safe to retry + UnknownLicense,// the portal does not know this License Key + NotConfigured, // no License Key stored yet; nothing to sync +}; + +// Sends a System Name to the portal. Injectable so tests can fake the HTTP +// layer. The production default is `api::putSystemName`. +using PutFn = std::function; + +// Messages shown to the user. Kept here so the page and the service agree. +constexpr const char* CONFLICT_MESSAGE = + "That name is already used on your account. Choose another."; +constexpr const char* INVALID_MESSAGE = + "Enter a name between 1 and 40 characters."; + +// Maps a portal `systemNameStatus` string ("ok"/"unchanged"/"conflict"/ +// "invalid") to a Status. Unknown/empty maps to Unchanged. +Status parseStatus(const std::string& status); + +// The locally stored System Name (may be empty). +std::string storedName(const db::DatabaseManager& db); + +// True while the stored name still needs to be sent to the portal. +bool pendingSync(const db::DatabaseManager& db); + +// Validates and persists `raw` locally (trimmed), marks it pending sync, and +// clears any stale message. Returns false and sets `error` when invalid. The +// caller is responsible for triggering a sync; the UI is never blocked. +bool storeLocalName(db::DatabaseManager& db, const std::string& raw, + std::string* error = nullptr); + +// Applies a portal `systemNameStatus` to local state. On "ok"/"unchanged" the +// name is marked synced; on "conflict"/"invalid" the local name is reverted to +// the last server-accepted value and a user-facing message is stored. +Status applyServerStatus(db::DatabaseManager& db, const std::string& serverStatus); + +// If a sync is pending and a License Key is stored, PUTs the name via `put` and +// applies the result. Network failures and 404s leave the pending flag set so +// the caller can retry later. +Status syncWithServer(db::DatabaseManager& db, const PutFn& put); + +// Returns the pending user-facing message and clears it (consume-once). +std::string consumeMessage(db::DatabaseManager& db); + +} // namespace radiantwave::systemname diff --git a/src/network/system_name_sync.cpp b/src/network/system_name_sync.cpp new file mode 100644 index 0000000..5dd0483 --- /dev/null +++ b/src/network/system_name_sync.cpp @@ -0,0 +1,70 @@ +#include "network/system_name_sync.h" +#include "network/api_client.h" + +#include + +namespace radiantwave::systemname { + +SystemNameSync::SystemNameSync() + : put_([](const std::string& key, const std::string& name) { + return api::putSystemName(key, name); + }) { +} + +SystemNameSync::SystemNameSync(PutFn put) : put_(std::move(put)) { +} + +SystemNameSync::~SystemNameSync() { + shutdown(); +} + +void SystemNameSync::start(db::DatabaseManager& db) { + std::lock_guard lock(mutex_); + if (worker_.joinable()) return; + db_ = &db; + stop_.store(false); + worker_ = std::thread(&SystemNameSync::run, this); + requested_.store(true); + cv_.notify_one(); +} + +void SystemNameSync::shutdown() { + { + std::lock_guard lock(mutex_); + stop_.store(true); + } + cv_.notify_one(); + if (worker_.joinable()) { + worker_.join(); + } +} + +void SystemNameSync::requestSync() { + { + std::lock_guard lock(mutex_); + if (!worker_.joinable()) return; + requested_.store(true); + } + cv_.notify_one(); +} + +void SystemNameSync::run() { + while (true) { + db::DatabaseManager* db = nullptr; + { + std::unique_lock lock(mutex_); + cv_.wait_for(lock, RETRY_INTERVAL, [this] { + return stop_.load() || requested_.load(); + }); + if (stop_.load()) return; + requested_.store(false); + db = db_; + } + + if (db && pendingSync(*db)) { + syncWithServer(*db, put_); + } + } +} + +} // namespace radiantwave::systemname diff --git a/src/network/system_name_sync.h b/src/network/system_name_sync.h new file mode 100644 index 0000000..e687ea9 --- /dev/null +++ b/src/network/system_name_sync.h @@ -0,0 +1,47 @@ +#pragma once + +#include "db/database_manager.h" +#include "network/system_name.h" + +#include +#include +#include +#include +#include + +namespace radiantwave::systemname { + +// Background worker that pushes a pending System Name to the portal without +// blocking the UI. It retries on a timer and can be nudged immediately +// (`requestSync`) after a local change. `start()` attempts one sync right away, +// covering the retry-on-boot case; a successful license validation also syncs +// the name (see license_service), covering the retry-on-validation case. +class SystemNameSync { +public: + SystemNameSync(); + explicit SystemNameSync(PutFn put); + ~SystemNameSync(); + + SystemNameSync(const SystemNameSync&) = delete; + SystemNameSync& operator=(const SystemNameSync&) = delete; + + void start(db::DatabaseManager& db); + void shutdown(); + void requestSync(); + + // Retry interval for the periodic attempt. + static constexpr std::chrono::seconds RETRY_INTERVAL{30}; + +private: + void run(); + + PutFn put_; + db::DatabaseManager* db_ = nullptr; + std::thread worker_; + std::mutex mutex_; + std::condition_variable cv_; + std::atomic stop_{false}; + std::atomic requested_{false}; +}; + +} // namespace radiantwave::systemname diff --git a/src/validation.cpp b/src/validation.cpp index 5687645..6f28ee9 100644 --- a/src/validation.cpp +++ b/src/validation.cpp @@ -87,4 +87,21 @@ std::string formatLicenseKey(const std::string& sanitized) { return out; } +std::string trim(const std::string& input) { + size_t start = 0; + size_t end = input.size(); + while (start < end && std::isspace(static_cast(input[start]))) { + ++start; + } + while (end > start && std::isspace(static_cast(input[end - 1]))) { + --end; + } + return input.substr(start, end - start); +} + +bool isValidSystemName(const std::string& name) { + const std::string trimmed = trim(name); + return !trimmed.empty() && trimmed.size() <= SYSTEM_NAME_MAX_LENGTH; +} + } // namespace radiantwave::validation diff --git a/src/validation.h b/src/validation.h index 76273e6..66cfcf0 100644 --- a/src/validation.h +++ b/src/validation.h @@ -17,4 +17,14 @@ std::string sanitizeLicenseKey(const std::string& input); // characters (XXXX-XXXX-XXXX-XXXX). Partial input yields partial output. std::string formatLicenseKey(const std::string& sanitized); +// The maximum length of a System Name (per the API contract). +constexpr size_t SYSTEM_NAME_MAX_LENGTH = 40; + +// Removes leading and trailing ASCII whitespace from `input`. +std::string trim(const std::string& input); + +// Returns true when `name` (already trimmed) is a valid System Name: 1-40 +// characters. Empty or whitespace-only names are invalid (never a clear). +bool isValidSystemName(const std::string& name); + } // namespace radiantwave::validation diff --git a/tests/test_license_service.cpp b/tests/test_license_service.cpp index d6188b9..a06f1d7 100644 --- a/tests/test_license_service.cpp +++ b/tests/test_license_service.cpp @@ -1,5 +1,6 @@ #include "network/license_service.h" #include "db/db_keys.h" +#include "network/system_name.h" #include #include @@ -9,6 +10,7 @@ using namespace radiantwave::license; using namespace radiantwave::db; +using namespace radiantwave::systemname; namespace { @@ -103,6 +105,79 @@ TEST(LicenseServiceTest, GracePeriodIgnoresCorruptStamp) { EXPECT_FALSE(isLicenseWithinGracePeriod(tmp.db)); } +// ─── Offline: System Name status handling (faked validation) ──────────── + +namespace { + +radiantwave::api::LicenseResult licenseResult(const std::string& systemNameStatus) { + radiantwave::api::LicenseResult result; + result.status = 200; + result.info.key = "ABCD-EFGH-IJKL-MNOP"; + result.info.email_address = "user@example.com"; + result.info.system_name_status = systemNameStatus; + return result; +} + +} // namespace + +TEST(LicenseServiceTest, SendsStoredSystemNameAndMarksSyncedOnOk) { + TempDb tmp; + tmp.db.setKV(KV_LICENSE_KEY, "ABCD-EFGH-IJKL-MNOP"); + tmp.db.setKV(KV_EMAIL_ADDRESS, "user@example.com"); + tmp.db.setKV(KV_SYSTEM_NAME, "Den"); + tmp.db.setKV(KV_SYSTEM_NAME_PENDING, "1"); + + std::string sentName = ""; + const Result result = validateStoredLicenseWith( + tmp.db, + [&](const std::string&, const std::string&, const std::string& systemName) { + sentName = systemName; + return licenseResult("ok"); + }); + + EXPECT_EQ(result, Result::Ok); + EXPECT_EQ(sentName, "Den"); + EXPECT_FALSE(pendingSync(tmp.db)); + EXPECT_EQ(tmp.db.getKV(KV_SYSTEM_NAME_SYNCED), "Den"); +} + +TEST(LicenseServiceTest, RevertsLocalNameOnConflictStatus) { + TempDb tmp; + tmp.db.setKV(KV_LICENSE_KEY, "ABCD-EFGH-IJKL-MNOP"); + tmp.db.setKV(KV_EMAIL_ADDRESS, "user@example.com"); + tmp.db.setKV(KV_SYSTEM_NAME_SYNCED, "Living Room"); + tmp.db.setKV(KV_SYSTEM_NAME, "Den"); + tmp.db.setKV(KV_SYSTEM_NAME_PENDING, "1"); + + const Result result = validateStoredLicenseWith( + tmp.db, + [](const std::string&, const std::string&, const std::string&) { + return licenseResult("conflict"); + }); + + EXPECT_EQ(result, Result::Ok); + EXPECT_EQ(storedName(tmp.db), "Living Room"); + EXPECT_EQ(tmp.db.getKV(KV_SYSTEM_NAME_MESSAGE), CONFLICT_MESSAGE); +} + +TEST(LicenseServiceTest, LeavesPendingWhenServerReportsNoNameStatus) { + TempDb tmp; + tmp.db.setKV(KV_LICENSE_KEY, "ABCD-EFGH-IJKL-MNOP"); + tmp.db.setKV(KV_EMAIL_ADDRESS, "user@example.com"); + tmp.db.setKV(KV_SYSTEM_NAME, "Den"); + tmp.db.setKV(KV_SYSTEM_NAME_PENDING, "1"); + + const Result result = validateStoredLicenseWith( + tmp.db, + [](const std::string&, const std::string&, const std::string&) { + return licenseResult(""); + }); + + EXPECT_EQ(result, Result::Ok); + EXPECT_TRUE(pendingSync(tmp.db)); + EXPECT_EQ(storedName(tmp.db), "Den"); +} + // ─── Live: full validation + persistence (requires env credentials) ───── TEST(LicenseServiceLiveTest, ValidatesAndGrantsSubscribedState) { diff --git a/tests/test_system_name.cpp b/tests/test_system_name.cpp new file mode 100644 index 0000000..47f7b79 --- /dev/null +++ b/tests/test_system_name.cpp @@ -0,0 +1,244 @@ +#include "network/system_name.h" +#include "db/db_keys.h" +#include "validation.h" + +#include + +#include +#include + +using namespace radiantwave::systemname; +using namespace radiantwave::db; +namespace api = radiantwave::api; + +namespace { + +struct TempDb { + TempDb() { + path = (std::filesystem::temp_directory_path() / + ("test_rw_sysname_" + std::to_string(counter++) + ".db")) + .string(); + std::filesystem::remove(path); + db.initialize(path); + } + + ~TempDb() { + db.close(); + std::filesystem::remove(path); + } + + std::string path; + DatabaseManager db; + static int counter; +}; + +int TempDb::counter = 0; + +api::PutNameResult ok() { return api::PutNameResult{200, true}; } +api::PutNameResult conflict() { return api::PutNameResult{409, true}; } +api::PutNameResult invalid() { return api::PutNameResult{400, true}; } +api::PutNameResult unknown() { return api::PutNameResult{404, true}; } +api::PutNameResult offline() { return api::PutNameResult{0, false}; } + +} // namespace + +// ─── Validation ──────────────────────────────────────────────────────── + +TEST(SystemNameValidationTest, TrimAndBounds) { + EXPECT_EQ(radiantwave::validation::trim(" Den "), "Den"); + EXPECT_TRUE(radiantwave::validation::isValidSystemName("Den")); + EXPECT_TRUE(radiantwave::validation::isValidSystemName(" Den ")); + EXPECT_TRUE(radiantwave::validation::isValidSystemName(std::string(40, 'a'))); + EXPECT_FALSE(radiantwave::validation::isValidSystemName("")); + EXPECT_FALSE(radiantwave::validation::isValidSystemName(" ")); + EXPECT_FALSE(radiantwave::validation::isValidSystemName(std::string(41, 'a'))); +} + +// ─── Local storage ───────────────────────────────────────────────────── + +TEST(SystemNameLocalTest, StoresTrimmedNameAndMarksPending) { + TempDb tmp; + std::string error; + ASSERT_TRUE(storeLocalName(tmp.db, " Living Room ", &error)); + EXPECT_EQ(storedName(tmp.db), "Living Room"); + EXPECT_TRUE(pendingSync(tmp.db)); +} + +TEST(SystemNameLocalTest, RejectsInvalidWithoutStoring) { + TempDb tmp; + std::string error; + EXPECT_FALSE(storeLocalName(tmp.db, " ", &error)); + EXPECT_EQ(error, INVALID_MESSAGE); + EXPECT_FALSE(tmp.db.hasKV(KV_SYSTEM_NAME)); + EXPECT_FALSE(pendingSync(tmp.db)); + + EXPECT_FALSE(storeLocalName(tmp.db, std::string(41, 'a'))); + EXPECT_FALSE(tmp.db.hasKV(KV_SYSTEM_NAME)); +} + +// ─── Server status application (K2) ──────────────────────────────────── + +TEST(SystemNameStatusTest, OkMarksSyncedAndClearsPending) { + TempDb tmp; + tmp.db.setKV(KV_SYSTEM_NAME, "Den"); + tmp.db.setKV(KV_SYSTEM_NAME_PENDING, "1"); + + EXPECT_EQ(applyServerStatus(tmp.db, "ok"), Status::Ok); + EXPECT_EQ(tmp.db.getKV(KV_SYSTEM_NAME_SYNCED), "Den"); + EXPECT_FALSE(pendingSync(tmp.db)); +} + +TEST(SystemNameStatusTest, ConflictRevertsToSyncedAndRecordsMessage) { + TempDb tmp; + tmp.db.setKV(KV_SYSTEM_NAME_SYNCED, "Living Room"); + tmp.db.setKV(KV_SYSTEM_NAME, "Den"); + tmp.db.setKV(KV_SYSTEM_NAME_PENDING, "1"); + + EXPECT_EQ(applyServerStatus(tmp.db, "conflict"), Status::Conflict); + EXPECT_EQ(storedName(tmp.db), "Living Room"); + EXPECT_FALSE(pendingSync(tmp.db)); + EXPECT_EQ(tmp.db.getKV(KV_SYSTEM_NAME_MESSAGE), CONFLICT_MESSAGE); +} + +TEST(SystemNameStatusTest, ConflictWithNoPreviousNameClearsIt) { + TempDb tmp; + tmp.db.setKV(KV_SYSTEM_NAME, "Den"); + tmp.db.setKV(KV_SYSTEM_NAME_PENDING, "1"); + + EXPECT_EQ(applyServerStatus(tmp.db, "conflict"), Status::Conflict); + EXPECT_FALSE(tmp.db.hasKV(KV_SYSTEM_NAME)); +} + +TEST(SystemNameStatusTest, InvalidRevertsAndRecordsMessage) { + TempDb tmp; + tmp.db.setKV(KV_SYSTEM_NAME_SYNCED, "Den"); + tmp.db.setKV(KV_SYSTEM_NAME, "Nope"); + tmp.db.setKV(KV_SYSTEM_NAME_PENDING, "1"); + + EXPECT_EQ(applyServerStatus(tmp.db, "invalid"), Status::Invalid); + EXPECT_EQ(storedName(tmp.db), "Den"); + EXPECT_EQ(tmp.db.getKV(KV_SYSTEM_NAME_MESSAGE), INVALID_MESSAGE); +} + +TEST(SystemNameStatusTest, UnchangedClearsPending) { + TempDb tmp; + tmp.db.setKV(KV_SYSTEM_NAME, "Den"); + tmp.db.setKV(KV_SYSTEM_NAME_PENDING, "1"); + + EXPECT_EQ(applyServerStatus(tmp.db, "unchanged"), Status::Unchanged); + EXPECT_FALSE(pendingSync(tmp.db)); +} + +// ─── Background sync (K3) ────────────────────────────────────────────── + +TEST(SystemNameSyncTest, NotConfiguredWithoutLicenseKey) { + TempDb tmp; + tmp.db.setKV(KV_SYSTEM_NAME, "Den"); + tmp.db.setKV(KV_SYSTEM_NAME_PENDING, "1"); + + bool called = false; + EXPECT_EQ(syncWithServer(tmp.db, [&](const std::string&, const std::string&) { + called = true; + return ok(); + }), + Status::NotConfigured); + EXPECT_FALSE(called); +} + +TEST(SystemNameSyncTest, DoesNothingWhenNotPending) { + TempDb tmp; + tmp.db.setKV(KV_LICENSE_KEY, "ABCD-EFGH-IJKL-MNOP"); + tmp.db.setKV(KV_SYSTEM_NAME, "Den"); + + bool called = false; + EXPECT_EQ(syncWithServer(tmp.db, [&](const std::string&, const std::string&) { + called = true; + return ok(); + }), + Status::Unchanged); + EXPECT_FALSE(called); +} + +TEST(SystemNameSyncTest, PushesNameAndMarksSynced) { + TempDb tmp; + tmp.db.setKV(KV_LICENSE_KEY, "ABCD-EFGH-IJKL-MNOP"); + tmp.db.setKV(KV_SYSTEM_NAME, "Den"); + tmp.db.setKV(KV_SYSTEM_NAME_PENDING, "1"); + + std::string sentKey; + std::string sentName; + EXPECT_EQ(syncWithServer(tmp.db, [&](const std::string& k, const std::string& n) { + sentKey = k; + sentName = n; + return ok(); + }), + Status::Ok); + EXPECT_EQ(sentKey, "ABCD-EFGH-IJKL-MNOP"); + EXPECT_EQ(sentName, "Den"); + EXPECT_FALSE(pendingSync(tmp.db)); + EXPECT_EQ(tmp.db.getKV(KV_SYSTEM_NAME_SYNCED), "Den"); +} + +TEST(SystemNameSyncTest, ConflictRevertsAndKeepsOldName) { + TempDb tmp; + tmp.db.setKV(KV_LICENSE_KEY, "ABCD-EFGH-IJKL-MNOP"); + tmp.db.setKV(KV_SYSTEM_NAME_SYNCED, "Living Room"); + tmp.db.setKV(KV_SYSTEM_NAME, "Den"); + tmp.db.setKV(KV_SYSTEM_NAME_PENDING, "1"); + + EXPECT_EQ(syncWithServer(tmp.db, [](const std::string&, const std::string&) { + return conflict(); + }), + Status::Conflict); + EXPECT_EQ(storedName(tmp.db), "Living Room"); + EXPECT_EQ(tmp.db.getKV(KV_SYSTEM_NAME_MESSAGE), CONFLICT_MESSAGE); +} + +TEST(SystemNameSyncTest, InvalidReverts) { + TempDb tmp; + tmp.db.setKV(KV_LICENSE_KEY, "ABCD-EFGH-IJKL-MNOP"); + tmp.db.setKV(KV_SYSTEM_NAME_SYNCED, "Living Room"); + tmp.db.setKV(KV_SYSTEM_NAME, "x"); + tmp.db.setKV(KV_SYSTEM_NAME_PENDING, "1"); + + EXPECT_EQ(syncWithServer(tmp.db, [](const std::string&, const std::string&) { + return invalid(); + }), + Status::Invalid); + EXPECT_EQ(storedName(tmp.db), "Living Room"); +} + +TEST(SystemNameSyncTest, NetworkFailureKeepsPendingForRetry) { + TempDb tmp; + tmp.db.setKV(KV_LICENSE_KEY, "ABCD-EFGH-IJKL-MNOP"); + tmp.db.setKV(KV_SYSTEM_NAME, "Den"); + tmp.db.setKV(KV_SYSTEM_NAME_PENDING, "1"); + + EXPECT_EQ(syncWithServer(tmp.db, [](const std::string&, const std::string&) { + return offline(); + }), + Status::NetworkError); + EXPECT_TRUE(pendingSync(tmp.db)); + EXPECT_EQ(storedName(tmp.db), "Den"); +} + +TEST(SystemNameSyncTest, UnknownLicenseKeepsPending) { + TempDb tmp; + tmp.db.setKV(KV_LICENSE_KEY, "ABCD-EFGH-IJKL-MNOP"); + tmp.db.setKV(KV_SYSTEM_NAME, "Den"); + tmp.db.setKV(KV_SYSTEM_NAME_PENDING, "1"); + + EXPECT_EQ(syncWithServer(tmp.db, [](const std::string&, const std::string&) { + return unknown(); + }), + Status::UnknownLicense); + EXPECT_TRUE(pendingSync(tmp.db)); +} + +TEST(SystemNameMessageTest, ConsumeReturnsAndClears) { + TempDb tmp; + tmp.db.setKV(KV_SYSTEM_NAME_MESSAGE, CONFLICT_MESSAGE); + + EXPECT_EQ(consumeMessage(tmp.db), CONFLICT_MESSAGE); + EXPECT_EQ(consumeMessage(tmp.db), ""); +} -- 2.43.0 From 1bfe64580c882955186a1949b7f112209b44bc43 Mon Sep 17 00:00:00 2001 From: NeilClack Date: Tue, 29 Sep 2026 09:58:08 -0500 Subject: [PATCH 05/17] Kiosk: System Name entry in System Settings 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. --- CMakeLists.txt | 1 + src/settings_menu.cpp | 1 + src/settings_menu.h | 1 + src/system_name_page.cpp | 226 +++++++++++++++++++++++++++++++++++ src/system_name_page.h | 44 +++++++ src/system_settings_page.cpp | 4 + tests/test_settings_menu.cpp | 17 +-- 7 files changed, 287 insertions(+), 7 deletions(-) create mode 100644 src/system_name_page.cpp create mode 100644 src/system_name_page.h diff --git a/CMakeLists.txt b/CMakeLists.txt index 40e1e55..edddfe7 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -42,6 +42,7 @@ add_executable(radiantwave src/settings.cpp src/settings_menu.cpp src/system_settings_page.cpp + src/system_name_page.cpp src/wifi_setup_page.cpp src/audio_settings_page.cpp src/audio/audio_manager.cpp diff --git a/src/settings_menu.cpp b/src/settings_menu.cpp index cbf9e4f..a645bf0 100644 --- a/src/settings_menu.cpp +++ b/src/settings_menu.cpp @@ -6,6 +6,7 @@ std::vector systemSettingEntries() { return { {"Email Address", SystemSettingId::EmailAddress}, {"License Key", SystemSettingId::LicenseKey}, + {"System Name", SystemSettingId::SystemName}, {"WiFi Setup", SystemSettingId::WiFiSetup}, {"Time Zone Setup", SystemSettingId::Timezone}, {"Date Format", SystemSettingId::DateFormat}, diff --git a/src/settings_menu.h b/src/settings_menu.h index 96ca701..7165f13 100644 --- a/src/settings_menu.h +++ b/src/settings_menu.h @@ -9,6 +9,7 @@ namespace radiantwave::settings { enum class SystemSettingId { EmailAddress, LicenseKey, + SystemName, WiFiSetup, Timezone, DateFormat, diff --git a/src/system_name_page.cpp b/src/system_name_page.cpp new file mode 100644 index 0000000..b006e90 --- /dev/null +++ b/src/system_name_page.cpp @@ -0,0 +1,226 @@ +#include "system_name_page.h" +#include "app_state.h" +#include "network/system_name.h" +#include "logical_size.h" +#include + +namespace { + +constexpr size_t MAX_INPUT_LENGTH = 80; + +} // anonymous namespace + +void SystemNamePage::onPush(AppState& state) +{ + state_ = &state; + TTF_Init(); + createFonts(); + + storedValue_ = radiantwave::systemname::storedName(state.db); + input_ = storedValue_; + statusMessage_ = "Enter a System Name, then press ENTER"; + statusIsError_ = false; + + if (state.window) { + SDL_StartTextInput(state.window); + } +} + +void SystemNamePage::onResume(AppState& state) +{ + state_ = &state; + createFonts(); + storedValue_ = radiantwave::systemname::storedName(state.db); + input_ = storedValue_; + + if (state.window) { + SDL_StartTextInput(state.window); + } +} + +void SystemNamePage::onPause(AppState& state) +{ + state.keybinds.unbindAll(); + if (state.window) { + SDL_StopTextInput(state.window); + } + destroyFonts(); + destroyTextures(); +} + +void SystemNamePage::onPop(AppState& state) +{ + if (state.window) { + SDL_StopTextInput(state.window); + } + destroyFonts(); + destroyTextures(); + TTF_Quit(); +} + +void SystemNamePage::createFonts() +{ + destroyFonts(); + fontTitle_ = TTF_OpenFont("assets/fonts/Roboto-Regular.ttf", 52); + fontHeader_ = TTF_OpenFont("assets/fonts/Roboto-Regular.ttf", 28); + fontBody_ = TTF_OpenFont("assets/fonts/Roboto-Regular.ttf", 32); + fontSmall_ = TTF_OpenFont("assets/fonts/Roboto-Regular.ttf", 22); +} + +void SystemNamePage::destroyFonts() +{ + if (fontTitle_) { TTF_CloseFont(fontTitle_); fontTitle_ = nullptr; } + if (fontHeader_) { TTF_CloseFont(fontHeader_); fontHeader_ = nullptr; } + if (fontBody_) { TTF_CloseFont(fontBody_); fontBody_ = nullptr; } + if (fontSmall_) { TTF_CloseFont(fontSmall_); fontSmall_ = nullptr; } +} + +void SystemNamePage::destroyTextures() +{ + titleText_.destroy(); + storedText_.destroy(); + valueText_.destroy(); + statusText_.destroy(); + hintText_.destroy(); +} + +void SystemNamePage::submit() +{ + if (!state_) return; + + // Empty/whitespace is invalid and never clears the stored name. + std::string error; + if (!radiantwave::systemname::storeLocalName(state_->db, input_, &error)) { + statusMessage_ = error; + statusIsError_ = true; + return; + } + + storedValue_ = radiantwave::systemname::storedName(state_->db); + input_ = storedValue_; + statusMessage_ = "Saved. Syncing with the portal..."; + statusIsError_ = false; + + // Never block the UI: the background service performs the PUT and retries. + state_->systemNameSync.requestSync(); +} + +void SystemNamePage::pollSyncResult() +{ + if (!state_) return; + const std::string message = radiantwave::systemname::consumeMessage(state_->db); + if (message.empty()) return; + + statusMessage_ = message; + statusIsError_ = true; + storedValue_ = radiantwave::systemname::storedName(state_->db); + input_ = storedValue_; +} + +void SystemNamePage::handleEvent(const SDL_Event& event) +{ + if (!state_) return; + + if (event.type == SDL_EVENT_TEXT_INPUT) { + if (input_.size() < MAX_INPUT_LENGTH) { + input_ += event.text.text; + } + return; + } + + if (event.type == SDL_EVENT_KEY_DOWN) { + SDL_Keycode key = event.key.key; + + if (key == SDLK_BACKSPACE) { + if (!input_.empty()) { + input_.pop_back(); + } + } else if (key == SDLK_RETURN || key == SDLK_KP_ENTER) { + submit(); + } else if (key == SDLK_ESCAPE) { + state_->pages.pop(*state_); + } + } +} + +void SystemNamePage::update(float dt) +{ + pollSyncResult(); +} + +void SystemNamePage::render(SDL_Renderer* renderer) +{ + int winW, winH; + radiantwave::getLayoutSize(renderer, &winW, &winH); + float fw = static_cast(winW); + float fh = static_cast(winH); + + SDL_SetRenderDrawBlendMode(renderer, SDL_BLENDMODE_BLEND); + SDL_SetRenderDrawColor(renderer, 20, 20, 30, 255); + SDL_FRect bgRect{0, 0, fw, fh}; + SDL_RenderFillRect(renderer, &bgRect); + + if (!fontTitle_ || !fontHeader_ || !fontBody_ || !fontSmall_) return; + + SDL_Color white = {255, 255, 255, 255}; + SDL_Color yellow = {255, 255, 0, 255}; + SDL_Color cyan = {100, 220, 255, 255}; + SDL_Color gray = {180, 180, 180, 255}; + SDL_Color red = {240, 100, 100, 255}; + + float currentY = 30.0f; + + float boxW = 700.0f; + float boxH = 70.0f; + float boxX = (fw - boxW) / 2.0f; + + titleText_.ensure(renderer, fontTitle_, "System Name", white); + if (titleText_.texture) { + float x = (fw - titleText_.width) / 2.0f; + SDL_FRect dst{x, currentY, titleText_.width, titleText_.height}; + SDL_RenderTexture(renderer, titleText_.texture, nullptr, &dst); + currentY += titleText_.height + 30.0f; + } + + std::string storedStr = storedValue_.empty() ? "Not set" : storedValue_; + storedText_.ensure(renderer, fontHeader_, "Saved: " + storedStr, + storedValue_.empty() ? gray : yellow); + if (storedText_.texture) { + SDL_FRect dst{boxX, currentY, storedText_.width, storedText_.height}; + SDL_RenderTexture(renderer, storedText_.texture, nullptr, &dst); + } + currentY += 50.0f; + + SDL_SetRenderDrawColor(renderer, 35, 40, 60, 255); + SDL_FRect boxRect{boxX, currentY, boxW, boxH}; + SDL_RenderFillRect(renderer, &boxRect); + SDL_SetRenderDrawColor(renderer, 255, 255, 255, 255); + SDL_RenderRect(renderer, &boxRect); + + std::string display = input_ + "_"; + valueText_.ensure(renderer, fontBody_, display, cyan); + if (valueText_.texture) { + SDL_FRect dst{boxX + 20.0f, currentY + (boxH - valueText_.height) / 2.0f, + valueText_.width, valueText_.height}; + SDL_RenderTexture(renderer, valueText_.texture, nullptr, &dst); + } + currentY += boxH + 30.0f; + + if (!statusMessage_.empty()) { + statusText_.ensure(renderer, fontHeader_, statusMessage_, + statusIsError_ ? red : gray); + if (statusText_.texture) { + SDL_FRect dst{boxX, currentY, statusText_.width, statusText_.height}; + SDL_RenderTexture(renderer, statusText_.texture, nullptr, &dst); + } + currentY += 50.0f; + } + + hintText_.ensure(renderer, fontSmall_, + "e.g. Living Room [Type] Enter [ENTER] Save [BACKSPACE] Delete [ESC] Back", gray); + if (hintText_.texture) { + float x = (fw - hintText_.width) / 2.0f; + SDL_FRect dst{x, fh - 40.0f, hintText_.width, hintText_.height}; + SDL_RenderTexture(renderer, hintText_.texture, nullptr, &dst); + } +} diff --git a/src/system_name_page.h b/src/system_name_page.h new file mode 100644 index 0000000..8f60eef --- /dev/null +++ b/src/system_name_page.h @@ -0,0 +1,44 @@ +#pragma once + +#include "page.h" +#include "text_cache.h" +#include +#include + +// Text-entry page for the Kiosk's System Name (a human-readable label for this +// License Key). Saves locally first, then syncs in the background so the UI is +// never blocked on the network. +class SystemNamePage : public Page { +public: + void onPush(AppState& state) override; + void onResume(AppState& state) override; + void onPause(AppState& state) override; + void onPop(AppState& state) override; + void handleEvent(const SDL_Event& event) override; + void update(float dt) override; + void render(SDL_Renderer* renderer) override; + +private: + void createFonts(); + void destroyFonts(); + void destroyTextures(); + void submit(); + void pollSyncResult(); + + AppState* state_ = nullptr; + std::string storedValue_; + std::string input_; + std::string statusMessage_; + bool statusIsError_ = false; + + TTF_Font* fontTitle_ = nullptr; + TTF_Font* fontHeader_ = nullptr; + TTF_Font* fontBody_ = nullptr; + TTF_Font* fontSmall_ = nullptr; + + CachedText titleText_; + CachedText storedText_; + CachedText valueText_; + CachedText statusText_; + CachedText hintText_; +}; diff --git a/src/system_settings_page.cpp b/src/system_settings_page.cpp index fda054a..04fd05d 100644 --- a/src/system_settings_page.cpp +++ b/src/system_settings_page.cpp @@ -6,6 +6,7 @@ #include "logical_size.h" #include "timezone_page.h" #include "wifi_setup_page.h" +#include "system_name_page.h" #include #include #include @@ -76,6 +77,9 @@ void SystemSettingsPage::activateSelected(AppState &state) case radiantwave::settings::SystemSettingId::LicenseKey: state.pages.push(state, std::make_unique()); break; + case radiantwave::settings::SystemSettingId::SystemName: + state.pages.push(state, std::make_unique()); + break; case radiantwave::settings::SystemSettingId::WiFiSetup: state.pages.push(state, std::make_unique()); break; diff --git a/tests/test_settings_menu.cpp b/tests/test_settings_menu.cpp index 7a65950..0846b20 100644 --- a/tests/test_settings_menu.cpp +++ b/tests/test_settings_menu.cpp @@ -7,17 +7,19 @@ namespace settings = radiantwave::settings; TEST(SystemSettingsMenuTest, ListsTheSystemSettingsInOrder) { const auto entries = settings::systemSettingEntries(); - ASSERT_EQ(entries.size(), 5u); + ASSERT_EQ(entries.size(), 6u); EXPECT_EQ(entries[0].label, "Email Address"); EXPECT_EQ(entries[0].id, settings::SystemSettingId::EmailAddress); EXPECT_EQ(entries[1].label, "License Key"); EXPECT_EQ(entries[1].id, settings::SystemSettingId::LicenseKey); - EXPECT_EQ(entries[2].label, "WiFi Setup"); - EXPECT_EQ(entries[2].id, settings::SystemSettingId::WiFiSetup); - EXPECT_EQ(entries[3].label, "Time Zone Setup"); - EXPECT_EQ(entries[3].id, settings::SystemSettingId::Timezone); - EXPECT_EQ(entries[4].label, "Date Format"); - EXPECT_EQ(entries[4].id, settings::SystemSettingId::DateFormat); + EXPECT_EQ(entries[2].label, "System Name"); + EXPECT_EQ(entries[2].id, settings::SystemSettingId::SystemName); + EXPECT_EQ(entries[3].label, "WiFi Setup"); + EXPECT_EQ(entries[3].id, settings::SystemSettingId::WiFiSetup); + EXPECT_EQ(entries[4].label, "Time Zone Setup"); + EXPECT_EQ(entries[4].id, settings::SystemSettingId::Timezone); + EXPECT_EQ(entries[5].label, "Date Format"); + EXPECT_EQ(entries[5].id, settings::SystemSettingId::DateFormat); } TEST(SystemSettingsMenuTest, LabelsAreNonEmptyAndUnique) { @@ -38,6 +40,7 @@ TEST(SystemSettingsMenuTest, EveryEntryMapsToAKnownPage) { switch (entry.id) { case settings::SystemSettingId::EmailAddress: case settings::SystemSettingId::LicenseKey: + case settings::SystemSettingId::SystemName: case settings::SystemSettingId::WiFiSetup: case settings::SystemSettingId::Timezone: case settings::SystemSettingId::DateFormat: -- 2.43.0 From b93fb8d2df6d9487eeeb37a8eae28774fc994b6d Mon Sep 17 00:00:00 2001 From: NeilClack Date: Tue, 29 Sep 2026 09:59:17 -0500 Subject: [PATCH 06/17] docs: document System Name PUT endpoint and validation fields in OpenAPI --- openapi.yaml | 87 +++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 86 insertions(+), 1 deletion(-) diff --git a/openapi.yaml b/openapi.yaml index 3afea31..a7a7a2a 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -309,6 +309,78 @@ paths: application/json: schema: $ref: '#/components/schemas/ErrorMessage' + /api/v2/licenses/{licenseKey}/system-name: + put: + tags: [License] + summary: Set a License Key's System Name (Kiosk-only) + description: | + Sets the human-readable System Name for a License Key. The Kiosk is the + only writer; the Portal displays the name read-only. The name is unique + per account, compared case-insensitively, when non-null. Idempotent: + re-sending the current name returns 200 without a write. + operationId: setSystemName + parameters: + - in: path + name: licenseKey + required: true + schema: + type: string + pattern: '^[A-Z0-9]{4}-[A-Z0-9]{4}-[A-Z0-9]{4}-[A-Z0-9]{4}$' + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [systemName] + properties: + systemName: + type: string + minLength: 1 + maxLength: 40 + responses: + '200': + description: The stored name. + content: + application/json: + schema: + type: object + required: [systemName, systemNameStatus] + properties: + systemName: + type: string + systemNameStatus: + type: string + enum: [ok] + '400': + description: 'Invalid name (`invalid_name`); empty/whitespace is invalid.' + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorMessage' + '404': + description: 'Unknown license (`unknown_license`).' + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorMessage' + '409': + description: 'Name already used on the account (`name_taken`).' + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorMessage' + '429': + description: Rate limit exceeded (10 requests per minute per IP). + headers: + Retry-After: + schema: + type: integer + description: Seconds to wait before retrying. + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorMessage' /api/v2/automations/create/account: post: tags: [Automations] @@ -505,9 +577,16 @@ components: type: string format: email maxLength: 254 + systemName: + type: string + minLength: 1 + maxLength: 40 + description: | + Optional. The Kiosk's System Name (trimmed). Omitted means the stored + name is left untouched; empty/whitespace is invalid, never a clear. LicenseResponse: type: object - required: [key, email_address] + required: [key, email_address, systemNameStatus] properties: key: type: string @@ -515,6 +594,12 @@ components: email_address: type: string format: email + systemNameStatus: + type: string + enum: [ok, unchanged, conflict, invalid] + description: | + Advisory outcome of the optional `systemName`. Validation itself + always succeeds. `unchanged` is returned when the field is omitted. ScheduleBlock: type: object required: -- 2.43.0 From 509c925480061091428b4b92af858a2414b2c4c4 Mon Sep 17 00:00:00 2001 From: NeilClack Date: Tue, 29 Sep 2026 10:34:40 -0500 Subject: [PATCH 07/17] Kiosk: schedule client (conditional GET + atomic cache) and engine 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. --- CMakeLists.txt | 19 ++ src/network/api_client.cpp | 167 ++++++++++++++++- src/network/api_client.h | 54 ++++++ src/network/schedule_cache.cpp | 158 ++++++++++++++++ src/network/schedule_cache.h | 29 +++ src/schedule/schedule_engine.cpp | 112 +++++++++++ src/schedule/schedule_engine.h | 36 ++++ src/schedule/schedule_manager.cpp | 160 ++++++++++++++++ src/schedule/schedule_manager.h | 93 ++++++++++ src/timezone_helper.cpp | 32 ++++ src/timezone_helper.h | 4 + tests/test_schedule_engine.cpp | 298 ++++++++++++++++++++++++++++++ 12 files changed, 1160 insertions(+), 2 deletions(-) create mode 100644 src/network/schedule_cache.cpp create mode 100644 src/network/schedule_cache.h create mode 100644 src/schedule/schedule_engine.cpp create mode 100644 src/schedule/schedule_engine.h create mode 100644 src/schedule/schedule_manager.cpp create mode 100644 src/schedule/schedule_manager.h create mode 100644 tests/test_schedule_engine.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index edddfe7..56d0869 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -43,6 +43,7 @@ add_executable(radiantwave src/settings_menu.cpp src/system_settings_page.cpp src/system_name_page.cpp + src/schedule_toggle_page.cpp src/wifi_setup_page.cpp src/audio_settings_page.cpp src/audio/audio_manager.cpp @@ -62,6 +63,9 @@ add_executable(radiantwave src/network/license_status.cpp src/network/system_name.cpp src/network/system_name_sync.cpp + src/network/schedule_cache.cpp + src/schedule/schedule_engine.cpp + src/schedule/schedule_manager.cpp src/config/yaml_parser.cpp src/config/config_manager.cpp src/db/database_manager.cpp @@ -253,6 +257,21 @@ add_executable(test_system_name target_include_directories(test_system_name PRIVATE src) target_link_libraries(test_system_name PRIVATE GTest::gtest_main SQLite3::SQLite3) +# Schedule engine + cache tests (payload parsing, evaluation, offline cache) +add_executable(test_schedule_engine + tests/test_schedule_engine.cpp + src/schedule/schedule_engine.cpp + src/schedule/schedule_manager.cpp + src/network/schedule_cache.cpp + src/network/api_client.cpp + src/network/json.cpp + src/db/database_manager.cpp +) +target_include_directories(test_schedule_engine PRIVATE src) +target_link_libraries(test_schedule_engine PRIVATE GTest::gtest_main CURL::libcurl SQLite3::SQLite3) +target_compile_definitions(test_schedule_engine PRIVATE + RWT_FIXTURES_DIR="${CMAKE_CURRENT_SOURCE_DIR}/docs/fixtures") + # TimeZone unit tests add_executable(test_timezone tests/test_timezone.cpp diff --git a/src/network/api_client.cpp b/src/network/api_client.cpp index 60b57f3..f624a24 100644 --- a/src/network/api_client.cpp +++ b/src/network/api_client.cpp @@ -2,6 +2,7 @@ #include "network/json.h" #include +#include #include #include @@ -27,10 +28,32 @@ size_t writeCallback(char* ptr, size_t size, size_t nmemb, void* userdata) { return size * nmemb; } +// Collects response headers, capturing the ETag (case-insensitive). +size_t headerCallback(char* buffer, size_t size, size_t nitems, void* userdata) { + auto* etag = static_cast(userdata); + const size_t len = size * nitems; + std::string line(buffer, len); + const std::string prefix = "etag:"; + if (line.size() >= prefix.size()) { + std::string lowered = line.substr(0, prefix.size()); + for (char& c : lowered) c = static_cast(std::tolower(static_cast(c))); + if (lowered == prefix) { + std::string value = line.substr(prefix.size()); + // Trim leading/trailing whitespace and CR/LF. + size_t start = value.find_first_not_of(" \t\r\n"); + size_t end = value.find_last_not_of(" \t\r\n"); + *etag = (start == std::string::npos) ? "" : value.substr(start, end - start + 1); + } + } + return len; +} + // Shared curl setup + perform. Returns a populated HttpResponse. `method` is -// "GET", "POST", or "PUT"; GET sends no body. +// "GET", "POST", or "PUT"; GET sends no body. `ifNoneMatch`, when non-empty, +// is sent as an If-None-Match request header. HttpResponse performRequest(const std::string& url, - const char* method, const std::string& jsonBody) { + const char* method, const std::string& jsonBody, + const std::string& ifNoneMatch = "") { HttpResponse response; CURL* curl = curl_easy_init(); @@ -43,6 +66,8 @@ HttpResponse performRequest(const std::string& url, curl_easy_setopt(curl, CURLOPT_USERAGENT, "RadiantWave-Kiosk/0.1"); curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, writeCallback); curl_easy_setopt(curl, CURLOPT_WRITEDATA, &response.body); + curl_easy_setopt(curl, CURLOPT_HEADERFUNCTION, headerCallback); + curl_easy_setopt(curl, CURLOPT_HEADERDATA, &response.etag); const bool sendsBody = method != nullptr && std::string(method) != "GET"; struct curl_slist* headers = nullptr; @@ -56,6 +81,10 @@ HttpResponse performRequest(const std::string& url, curl_easy_setopt(curl, CURLOPT_CUSTOMREQUEST, method); } } + if (!ifNoneMatch.empty()) { + const std::string header = "If-None-Match: " + ifNoneMatch; + headers = curl_slist_append(headers, header.c_str()); + } if (headers) { curl_easy_setopt(curl, CURLOPT_HTTPHEADER, headers); } @@ -112,6 +141,10 @@ HttpResponse httpPut(const std::string& path, const std::string& jsonBody) { return performRequest(urlFor(path), "PUT", jsonBody); } +HttpResponse httpGetConditional(const std::string& path, const std::string& ifNoneMatch) { + return performRequest(urlFor(path), "GET", "", ifNoneMatch); +} + // ─── GET /frequencies ────────────────────────────────────────────────── bool parseFrequenciesJson(const std::string& body, std::vector& out) { @@ -343,6 +376,136 @@ bool fetchSchedule(const std::string& email, const std::string& license_key, return parseScheduleJson(response.body, out); } +// ─── GET /licenses/{key}/schedule (Phase 3) ──────────────────────────── + +namespace { + +// Reads one target Block from a JSON object. +TargetBlock parseTargetBlock(const radiantwave::json::JsonValue& entry) { + TargetBlock block; + block.id = entry.stringField("id"); + block.day = entry.stringField("day"); + block.start_minutes = static_cast(entry.numberField("startMinutes")); + block.end_minutes = static_cast(entry.numberField("endMinutes")); + block.overnight = entry.boolField("overnight"); + block.duration_minutes = static_cast(entry.numberField("durationMinutes")); + + const radiantwave::json::JsonValue* dyn = entry.find("dynamicAudio"); + block.dynamic_audio = + (dyn && dyn->type == radiantwave::json::JsonValue::Type::Bool) ? dyn->b : true; + + const radiantwave::json::JsonValue* ids = entry.find("proclamationIds"); + if (ids && ids->type == radiantwave::json::JsonValue::Type::Array) { + for (const auto& id : ids->array) { + if (id.type == radiantwave::json::JsonValue::Type::String && !id.str.empty()) { + block.proclamation_ids.push_back(id.str); + } + } + } + return block; +} + +} // anonymous namespace + +bool parseSchedulePayloadJson(const std::string& body, SchedulePayload& out) { + radiantwave::json::JsonValue root; + if (!radiantwave::json::parseDocument(body, root) || + root.type != radiantwave::json::JsonValue::Type::Object) { + return false; + } + + SchedulePayload parsed; + parsed.version = root.stringField("version"); + parsed.type = root.stringField("type"); + + const radiantwave::json::JsonValue* daily = root.find("daily"); + if (daily && daily->type == radiantwave::json::JsonValue::Type::Array) { + for (const auto& entry : daily->array) { + if (entry.type == radiantwave::json::JsonValue::Type::Object) { + parsed.daily.push_back(parseTargetBlock(entry)); + } + } + } + + const radiantwave::json::JsonValue* weekly = root.find("weekly"); + if (weekly && weekly->type == radiantwave::json::JsonValue::Type::Object) { + static const char* kDays[] = {"mon", "tue", "wed", "thu", "fri", "sat", "sun"}; + for (const char* day : kDays) { + const radiantwave::json::JsonValue* list = weekly->find(day); + if (!list || list->type != radiantwave::json::JsonValue::Type::Array) continue; + for (const auto& entry : list->array) { + if (entry.type != radiantwave::json::JsonValue::Type::Object) continue; + TargetBlock block = parseTargetBlock(entry); + block.day = day; + parsed.weekly.push_back(std::move(block)); + } + } + } + + // Legacy `blocks` (for backward compatibility / older payloads). + const radiantwave::json::JsonValue* blocks = root.find("blocks"); + if (blocks && blocks->type == radiantwave::json::JsonValue::Type::Array) { + for (const auto& entry : blocks->array) { + if (entry.type != radiantwave::json::JsonValue::Type::Object) continue; + ScheduleBlock block; + block.id = entry.stringField("id"); + if (block.id.empty()) continue; + block.start_minutes = static_cast(entry.numberField("startMinutes")); + block.end_minutes = static_cast(entry.numberField("endMinutes")); + block.overnight = entry.boolField("overnight"); + block.duration_minutes = static_cast(entry.numberField("durationMinutes")); + const radiantwave::json::JsonValue* dyn = entry.find("dynamicAudio"); + block.dynamic_audio = + (dyn && dyn->type == radiantwave::json::JsonValue::Type::Bool) ? dyn->b : true; + const radiantwave::json::JsonValue* ids = entry.find("proclamationIds"); + if (ids && ids->type == radiantwave::json::JsonValue::Type::Array) { + for (const auto& id : ids->array) { + if (id.type == radiantwave::json::JsonValue::Type::String && !id.str.empty()) { + block.proclamation_ids.push_back(id.str); + } + } + } + parsed.legacy_blocks.push_back(std::move(block)); + } + } + + out = std::move(parsed); + return true; +} + +ScheduleFetchStatus fetchScheduleForLicense(const std::string& license_key, + const std::string& etag, + SchedulePayload& out, + std::string& outEtag) { + if (license_key.empty()) { + return ScheduleFetchStatus::NetworkError; + } + + const std::string path = "/licenses/" + urlEncode(license_key) + "/schedule"; + HttpResponse response = httpGetConditional(path, etag); + + if (!response.transportOk) { + return ScheduleFetchStatus::NetworkError; + } + if (response.status == 304) { + return ScheduleFetchStatus::NotModified; + } + if (response.status == 404) { + return ScheduleFetchStatus::NotFound; + } + if (response.status < 200 || response.status >= 300) { + return ScheduleFetchStatus::NetworkError; + } + + SchedulePayload parsed; + if (!parseSchedulePayloadJson(response.body, parsed)) { + return ScheduleFetchStatus::NetworkError; + } + out = std::move(parsed); + outEtag = response.etag; + return ScheduleFetchStatus::Ok; +} + AccountResult createAccount(const std::string& email, const std::string& name) { AccountResult result; diff --git a/src/network/api_client.h b/src/network/api_client.h index 3f8c0f6..26aa8dc 100644 --- a/src/network/api_client.h +++ b/src/network/api_client.h @@ -23,6 +23,7 @@ constexpr const char* PATH_SCHEDULE = "/schedule"; struct HttpResponse { long status = 0; // HTTP status code; 0 if the request never completed std::string body; // raw response body (any status) + std::string etag; // response ETag header, if any bool transportOk = false; // true when the request completed without a transport error }; @@ -31,6 +32,9 @@ HttpResponse httpGet(const std::string& path); HttpResponse httpPost(const std::string& path, const std::string& jsonBody); HttpResponse httpPut(const std::string& path, const std::string& jsonBody); +// GET with an optional If-None-Match header. Captures the response ETag. +HttpResponse httpGetConditional(const std::string& path, const std::string& ifNoneMatch); + // ─── GET /frequencies ────────────────────────────────────────────────── struct Frequency { @@ -176,4 +180,54 @@ bool parseScheduleJson(const std::string& body, ScheduleResponse& out); bool fetchSchedule(const std::string& email, const std::string& license_key, ScheduleResponse& out); +// ─── GET /licenses/{key}/schedule (Phase 3, conditional) ─────────────── + +// One Block of the target payload. `day` is "" for daily schedules and +// "mon".."sun" for weekly schedules. Times are wall-clock minutes. +struct TargetBlock { + std::string id; + std::string day; // "" for daily + int start_minutes = 0; + int end_minutes = 0; + bool overnight = false; + int duration_minutes = 0; + bool dynamic_audio = true; + std::vector proclamation_ids; +}; + +// The target schedule payload (version/type/daily/weekly). +struct SchedulePayload { + std::string version; + // "daily", "weekly", or "" for no schedule (type null). + std::string type; + std::vector daily; + // Weekly blocks keyed by day (mon..sun); empty for daily/none. + std::vector weekly; + // Legacy `blocks` flattening carried for backward compatibility. + std::vector legacy_blocks; + + bool hasSchedule() const { return type == "daily" || type == "weekly"; } +}; + +// Parse the target payload (with version/type/daily/weekly). `blocks` (legacy) +// is also read so older responses still populate `legacy_blocks`. Returns false +// on malformed input. +bool parseSchedulePayloadJson(const std::string& body, SchedulePayload& out); + +// Result of a conditional schedule fetch. +enum class ScheduleFetchStatus { + Ok, // 200; payload replaced + NotModified, // 304; caller keeps its cached payload + NotFound, // 404 unknown license + NetworkError, // transport/other failure +}; + +// Fetch a License Key's schedule with conditional GET. When `etag` is +// non-empty it is sent as If-None-Match. On success `out` is replaced and +// `outEtag` receives the response ETag. Never throws. +ScheduleFetchStatus fetchScheduleForLicense(const std::string& license_key, + const std::string& etag, + SchedulePayload& out, + std::string& outEtag); + } // namespace radiantwave::api diff --git a/src/network/schedule_cache.cpp b/src/network/schedule_cache.cpp new file mode 100644 index 0000000..ab82604 --- /dev/null +++ b/src/network/schedule_cache.cpp @@ -0,0 +1,158 @@ +#include "network/schedule_cache.h" +#include "network/json.h" + +#include +#include +#include +#include +#include +#include +#include + +namespace radiantwave::sync::schedule_cache { + +namespace { + +std::string homeDir() { + const char* home = std::getenv("HOME"); + if (!home) { + struct passwd* pw = getpwuid(getuid()); + if (pw) home = pw->pw_dir; + } + return home ? std::string(home) : std::string("/tmp"); +} + +void ensureDir(const std::string& dir) { + // Create the directory and any missing parents (e.g. ~/.local/radiantwave + // when ~/.local does not exist yet). + std::string path; + for (size_t i = 0; i < dir.size(); ++i) { + path += dir[i]; + if (dir[i] == '/' || i == dir.size() - 1) { + if (path.size() > 1) { + mkdir(path.c_str(), 0755); + } + } + } +} + +// Minimal JSON string escaping for the cache writer. +std::string escape(const std::string& in) { + std::string out; + out.reserve(in.size() + 2); + for (char c : in) { + switch (c) { + case '"': out += "\\\""; break; + case '\\': out += "\\\\"; break; + case '\n': out += "\\n"; break; + case '\r': out += "\\r"; break; + case '\t': out += "\\t"; break; + default: out += c; + } + } + return out; +} + +std::string blockJson(const api::TargetBlock& block, bool includeDay) { + std::ostringstream os; + os << "{\"id\":\"" << escape(block.id) << "\""; + if (includeDay) { + os << ",\"day\":\"" << escape(block.day) << "\""; + } + os << ",\"startMinutes\":" << block.start_minutes + << ",\"endMinutes\":" << block.end_minutes + << ",\"overnight\":" << (block.overnight ? "true" : "false") + << ",\"durationMinutes\":" << block.duration_minutes + << ",\"dynamicAudio\":" << (block.dynamic_audio ? "true" : "false") + << ",\"proclamationIds\":["; + for (size_t i = 0; i < block.proclamation_ids.size(); ++i) { + if (i > 0) os << ","; + os << "\"" << escape(block.proclamation_ids[i]) << "\""; + } + os << "]}"; + return os.str(); +} + +std::string serialize(const api::SchedulePayload& payload, const std::string& etag) { + std::ostringstream os; + os << "{\"version\":\"" << escape(payload.version) + << "\",\"type\":\"" << escape(payload.type) + << "\",\"etag\":\"" << escape(etag) + << "\",\"daily\":["; + for (size_t i = 0; i < payload.daily.size(); ++i) { + if (i > 0) os << ","; + os << blockJson(payload.daily[i], false); + } + os << "],\"weekly\":["; + for (size_t i = 0; i < payload.weekly.size(); ++i) { + if (i > 0) os << ","; + os << blockJson(payload.weekly[i], true); + } + os << "]}"; + return os.str(); +} + +} // namespace + +std::string cachePath() { + return homeDir() + "/.local/radiantwave/schedule_cache.json"; +} + +bool write(const api::SchedulePayload& payload, const std::string& etag) { + const std::string dir = homeDir() + "/.local/radiantwave"; + ensureDir(dir); + + const std::string path = cachePath(); + const std::string tmp = path + ".tmp"; + + { + std::ofstream out(tmp, std::ios::binary | std::ios::trunc); + if (!out.is_open()) return false; + out << serialize(payload, etag); + out.flush(); + if (!out.good()) { + out.close(); + std::remove(tmp.c_str()); + return false; + } + } + + // Atomic replace: a same-filesystem rename is atomic on POSIX. + if (std::rename(tmp.c_str(), path.c_str()) != 0) { + std::remove(tmp.c_str()); + return false; + } + return true; +} + +bool read(api::SchedulePayload& out, std::string& outEtag) { + std::ifstream in(cachePath(), std::ios::binary); + if (!in.is_open()) return false; + + std::ostringstream buffer; + buffer << in.rdbuf(); + const std::string text = buffer.str(); + if (text.empty()) return false; + + // The cache stores the same version/type/daily/weekly shape as the API, + // plus an "etag" field, so the API parser (which ignores unknown fields) + // validates it directly. + api::SchedulePayload parsed; + if (!api::parseSchedulePayloadJson(text, parsed)) { + return false; + } + + radiantwave::json::JsonValue root; + if (!radiantwave::json::parseDocument(text, root)) { + return false; + } + outEtag = root.stringField("etag"); + out = std::move(parsed); + return true; +} + +bool clear() { + return std::remove(cachePath().c_str()) == 0 || true; +} + +} // namespace radiantwave::sync::schedule_cache diff --git a/src/network/schedule_cache.h b/src/network/schedule_cache.h new file mode 100644 index 0000000..77469e6 --- /dev/null +++ b/src/network/schedule_cache.h @@ -0,0 +1,29 @@ +#pragma once + +#include "network/api_client.h" +#include + +namespace radiantwave::sync { + +// The on-disk schedule cache. Stores the target payload plus its ETag as a JSON +// document. Writes are atomic (temp file + rename) so a crash or power loss +// never leaves a partially written cache. +namespace schedule_cache { + +// Absolute path of the cache file (under ~/.local/radiantwave/). +std::string cachePath(); + +// Serialize `payload`/`etag` to the cache file atomically. Returns false on +// any I/O failure; the previous cache is left intact. +bool write(const api::SchedulePayload& payload, const std::string& etag); + +// Load the cache. Returns false (and leaves `out` untouched) when the file is +// missing, unreadable, malformed, or semantically invalid. +bool read(api::SchedulePayload& out, std::string& outEtag); + +// Remove the cache file. Returns true when absent afterwards. +bool clear(); + +} // namespace schedule_cache + +} // namespace radiantwave::sync diff --git a/src/schedule/schedule_engine.cpp b/src/schedule/schedule_engine.cpp new file mode 100644 index 0000000..88b14f8 --- /dev/null +++ b/src/schedule/schedule_engine.cpp @@ -0,0 +1,112 @@ +#include "schedule/schedule_engine.h" + +namespace radiantwave::schedule { + +namespace { + +const char* dayKey(Weekday day) { + switch (day) { + case Weekday::Mon: return "mon"; + case Weekday::Tue: return "tue"; + case Weekday::Wed: return "wed"; + case Weekday::Thu: return "thu"; + case Weekday::Fri: return "fri"; + case Weekday::Sat: return "sat"; + case Weekday::Sun: return "sun"; + } + return "mon"; +} + +bool blockIsValid(const api::TargetBlock& block) { + return !block.proclamation_ids.empty() && + block.start_minutes >= 0 && block.start_minutes <= 1440 && + block.end_minutes >= 0 && block.end_minutes <= 1440 && + block.start_minutes != block.end_minutes; +} + +void warnInvalid(const api::TargetBlock& block, + const std::function& warn) { + if (warn) { + warn("Skipping invalid schedule block id=" + block.id); + } +} + +// Find a matching block among `candidates` at `minutes`, skipping invalid ones. +bool match(const std::vector& candidates, int minutes, + api::TargetBlock& out, + const std::function& warn) { + for (const auto& block : candidates) { + if (!blockIsValid(block)) { + warnInvalid(block, warn); + continue; + } + if (minuteInBlock(minutes, block.start_minutes, block.end_minutes)) { + out = block; + return true; + } + } + return false; +} + +} // namespace + +bool minuteInBlock(int minutes, int startMinutes, int endMinutes) { + if (startMinutes < endMinutes) { + return minutes >= startMinutes && minutes < endMinutes; + } + // Midnight-crossing: [start, 1440) plus [0, end). + return minutes >= startMinutes || minutes < endMinutes; +} + +Weekday weekdayFromIndex(int index) { + if (index < 0) index = 0; + return static_cast(index % 7); +} + +Decision evaluate(const api::SchedulePayload& payload, int minutes, Weekday weekday, + std::function warn) { + Decision decision; + if (minutes < 0) minutes = 0; + minutes %= 1440; + + if (payload.type == "daily") { + decision.hasBlock = match(payload.daily, minutes, decision.block, warn); + return decision; + } + + if (payload.type == "weekly") { + // Only blocks for today... + std::vector today; + const std::string todayKey = dayKey(weekday); + for (const auto& block : payload.weekly) { + if (block.day == todayKey) today.push_back(block); + } + if (match(today, minutes, decision.block, warn)) { + decision.hasBlock = true; + return decision; + } + + // ...or a crossing Block from yesterday that spills into today's early + // hours. (Sunday's spill reaches Monday via the day rollover.) + const int prevIndex = (static_cast(weekday) + 6) % 7; + const std::string prevKey = dayKey(static_cast(prevIndex)); + for (const auto& block : payload.weekly) { + if (block.day != prevKey) continue; + if (block.end_minutes >= block.start_minutes) continue; // not crossing + if (!blockIsValid(block)) { + warnInvalid(block, warn); + continue; + } + if (minutes < block.end_minutes) { + decision.block = block; + decision.hasBlock = true; + return decision; + } + } + return decision; + } + + return decision; // type null / unknown -> no active block +} + +} // namespace radiantwave::schedule diff --git a/src/schedule/schedule_engine.h b/src/schedule/schedule_engine.h new file mode 100644 index 0000000..2e6bd80 --- /dev/null +++ b/src/schedule/schedule_engine.h @@ -0,0 +1,36 @@ +#pragma once + +#include "network/api_client.h" +#include +#include + +namespace radiantwave::schedule { + +// Weekday keys matching the wire format. +enum class Weekday { Mon = 0, Tue, Wed, Thu, Fri, Sat, Sun }; + +// The result of evaluating a schedule at a wall-clock instant. +struct Decision { + bool hasBlock = false; + api::TargetBlock block; // valid only when hasBlock +}; + +// The active Block for `payload` at (minutes, weekday), or none. Implements the +// contract: half-open [start, end), midnight-crossing Blocks belong to their +// start day (weekly Sunday spills into Monday), and a weekly mode must also +// consider the previous day's crossing Block. Invalid Blocks (empty +// proclamation list / bad range) are skipped and treated as gaps. +// +// Wall-clock minutes are passed in, so DST transitions behave naturally (the +// caller supplies the local wall-clock time in the Kiosk's timezone). +Decision evaluate(const api::SchedulePayload& payload, int minutes, Weekday weekday, + std::function warn = nullptr); + +// True when `minutes` falls in the half-open [start, end) block, accounting for +// a midnight-crossing range. +bool minuteInBlock(int minutes, int startMinutes, int endMinutes); + +// Weekday from a 0=Monday..6=Sunday index (clamped). +Weekday weekdayFromIndex(int index); + +} // namespace radiantwave::schedule diff --git a/src/schedule/schedule_manager.cpp b/src/schedule/schedule_manager.cpp new file mode 100644 index 0000000..a16bc94 --- /dev/null +++ b/src/schedule/schedule_manager.cpp @@ -0,0 +1,160 @@ +#include "schedule/schedule_manager.h" +#include "db/db_keys.h" +#include "network/api_client.h" +#include "network/schedule_cache.h" + +#include + +namespace radiantwave::schedule { + +ScheduleManager::ScheduleManager() + : fetch_([](const std::string& licenseKey, const std::string& etag, + api::SchedulePayload& out, std::string& outEtag) { + return api::fetchScheduleForLicense(licenseKey, etag, out, outEtag); + }) { +} + +ScheduleManager::~ScheduleManager() { + shutdown(); +} + +bool ScheduleManager::hasSchedule() const { + std::lock_guard lock(mutex_); + return payload_.hasSchedule(); +} + +bool ScheduleManager::enabled() const { + std::lock_guard lock(mutex_); + return enabled_; +} + +Decision ScheduleManager::activeBlock(int minutes, Weekday weekday) const { + std::lock_guard lock(mutex_); + if (!enabled_) { + return Decision{}; + } + return evaluate(payload_, minutes, weekday, nullptr); +} + +void ScheduleManager::setFetchForTesting(FetchFn fetch) { + std::lock_guard lock(mutex_); + fetch_ = std::move(fetch); +} + +void ScheduleManager::applyPayload(api::SchedulePayload payload, const std::string& etag) { + { + std::lock_guard lock(mutex_); + payload_ = std::move(payload); + etag_ = etag; + } + // Cache the last good payload for offline use (atomic write). + radiantwave::sync::schedule_cache::write(payload_, etag_); + if (onChanged_) onChanged_(); +} + +void ScheduleManager::loadCache() { + api::SchedulePayload cached; + std::string cachedEtag; + if (radiantwave::sync::schedule_cache::read(cached, cachedEtag)) { + std::lock_guard lock(mutex_); + payload_ = std::move(cached); + etag_ = std::move(cachedEtag); + if (onChanged_) onChanged_(); + } +} + +void ScheduleManager::refreshOnce() { + std::string licenseKey; + std::string etag; + FetchFn fetch; + { + std::lock_guard lock(mutex_); + licenseKey = licenseKey_; + etag = etag_; + fetch = fetch_; + } + if (licenseKey.empty()) return; + + api::SchedulePayload payload; + std::string newEtag; + const api::ScheduleFetchStatus status = fetch(licenseKey, etag, payload, newEtag); + + if (status == api::ScheduleFetchStatus::Ok) { + applyPayload(std::move(payload), newEtag); + } + // NotModified / NotFound / NetworkError: keep the current cache. +} + +void ScheduleManager::start(db::DatabaseManager& db, const std::string& timezone, + std::function onChanged) { + std::lock_guard lock(mutex_); + if (started_) return; + started_ = true; + db_ = &db; + timezone_ = timezone; + onChanged_ = std::move(onChanged); + licenseKey_ = db.getKV(db::KV_LICENSE_KEY); + enabled_ = true; // config supplies the toggle via setEnabled() + + // Offline first: load any cached payload immediately. + loadCache(); + + stop_.store(false); + worker_ = std::thread(&ScheduleManager::run, this); + requested_.store(true); + cv_.notify_one(); +} + +void ScheduleManager::shutdown() { + { + std::lock_guard lock(mutex_); + if (!started_) return; + stop_.store(true); + } + cv_.notify_one(); + if (worker_.joinable()) { + worker_.join(); + } + std::lock_guard lock(mutex_); + started_ = false; +} + +void ScheduleManager::requestRefresh() { + { + std::lock_guard lock(mutex_); + if (!started_) return; + if (db_) { + licenseKey_ = db_->getKV(db::KV_LICENSE_KEY); + } + requested_.store(true); + } + cv_.notify_one(); +} + +void ScheduleManager::setEnabled(bool enabled) { + std::lock_guard lock(mutex_); + enabled_ = enabled; +} + +void ScheduleManager::setPayloadForTesting(api::SchedulePayload payload, + const std::string& etag) { + std::lock_guard lock(mutex_); + payload_ = std::move(payload); + etag_ = etag; +} + +void ScheduleManager::run() { + while (true) { + { + std::unique_lock lock(mutex_); + cv_.wait_for(lock, REFRESH_INTERVAL, [this] { + return stop_.load() || requested_.load(); + }); + if (stop_.load()) return; + requested_.store(false); + } + refreshOnce(); + } +} + +} // namespace radiantwave::schedule diff --git a/src/schedule/schedule_manager.h b/src/schedule/schedule_manager.h new file mode 100644 index 0000000..0b05b42 --- /dev/null +++ b/src/schedule/schedule_manager.h @@ -0,0 +1,93 @@ +#pragma once + +#include "db/database_manager.h" +#include "network/api_client.h" +#include "schedule/schedule_engine.h" + +#include +#include +#include +#include +#include +#include +#include + +namespace radiantwave::schedule { + +// Fetches the Schedule for the stored License Key with conditional GET, caches +// the last payload to disk (atomic write), and exposes the active Block for +// playback. Never blocks the UI: a background worker pulls on boot, on +// network-up, and every 5 minutes. +class ScheduleManager { +public: + ScheduleManager(); + ~ScheduleManager(); + + ScheduleManager(const ScheduleManager&) = delete; + ScheduleManager& operator=(const ScheduleManager&) = delete; + + // Returns the active Block for the stored device state at `minutes` + // (wall-clock) on `weekday`. Honors local enable/disable: when schedules + // are off, always returns no block. + Decision activeBlock(int minutes, Weekday weekday) const; + + // True when a payload with an active type is loaded (from network or cache). + bool hasSchedule() const; + + // True while schedules are locally enabled (System Settings toggle). + bool enabled() const; + + // Background lifecycle. + void start(db::DatabaseManager& db, const std::string& timezone, + std::function onChanged = nullptr); + + // Update the local enable flag (from config.yaml). When off, activeBlock() + // returns none but the cache keeps updating. + void setEnabled(bool enabled); + + // Test/inspection hook: replace the loaded payload directly. + void setPayloadForTesting(api::SchedulePayload payload, const std::string& etag); + void shutdown(); + + // Request an immediate refresh (e.g. after a settings change). Non-blocking. + void requestRefresh(); + + // Pull interval (5 minutes). + static constexpr std::chrono::seconds REFRESH_INTERVAL{300}; + + // Injectable for tests: performs one fetch+cache cycle. The production + // implementation uses api::fetchScheduleForLicense + schedule_cache. + using FetchFn = std::function; + + void setFetchForTesting(FetchFn fetch); + + // Run one fetch cycle synchronously (used by tests and boot). + void refreshOnce(); + +private: + void run(); + void loadCache(); + void applyPayload(api::SchedulePayload payload, const std::string& etag); + + mutable std::mutex mutex_; + api::SchedulePayload payload_; + std::string etag_; + bool enabled_ = true; + std::string licenseKey_; + std::string timezone_; + + FetchFn fetch_; + + db::DatabaseManager* db_ = nullptr; + std::function onChanged_; + + std::thread worker_; + std::condition_variable cv_; + std::atomic stop_{false}; + std::atomic requested_{false}; + bool started_ = false; +}; + +} // namespace radiantwave::schedule diff --git a/src/timezone_helper.cpp b/src/timezone_helper.cpp index 33cc9a0..9bcffd8 100644 --- a/src/timezone_helper.cpp +++ b/src/timezone_helper.cpp @@ -164,6 +164,38 @@ int currentLocalMinutes(const std::string& iana_code) { return tm_buf.tm_hour * 60 + tm_buf.tm_min; } +int currentLocalWeekday(const std::string& iana_code) { + std::lock_guard lock(g_tz_mutex); + + const char* old_tz = std::getenv("TZ"); + std::string old_tz_str = old_tz ? old_tz : ""; + + if (!iana_code.empty()) { + setenv("TZ", iana_code.c_str(), 1); + } else { + setenv("TZ", "UTC", 1); + } + tzset(); + + time_t now = std::time(nullptr); + struct tm tm_buf; +#if defined(_WIN32) + localtime_s(&tm_buf, &now); +#else + localtime_r(&now, &tm_buf); +#endif + + if (!old_tz_str.empty()) { + setenv("TZ", old_tz_str.c_str(), 1); + } else { + unsetenv("TZ"); + } + tzset(); + + // tm_wday: 0=Sunday .. 6=Saturday -> convert to 0=Monday .. 6=Sunday. + return (tm_buf.tm_wday + 6) % 7; +} + std::string getUtcTimestampIso() { time_t now = std::time(nullptr); struct tm gmt_buf; diff --git a/src/timezone_helper.h b/src/timezone_helper.h index 6a7ef31..bc58b28 100644 --- a/src/timezone_helper.h +++ b/src/timezone_helper.h @@ -27,6 +27,10 @@ std::string formatUtcToTimezone(time_t utc_time, const std::string& iana_code, c // timezone is empty. int currentLocalMinutes(const std::string& iana_code); +// Weekday right now in `iana_code`, 0=Monday .. 6=Sunday. Falls back to UTC when +// the timezone is empty. +int currentLocalWeekday(const std::string& iana_code); + // Gets current UTC timestamp as ISO string std::string getUtcTimestampIso(); diff --git a/tests/test_schedule_engine.cpp b/tests/test_schedule_engine.cpp new file mode 100644 index 0000000..571222b --- /dev/null +++ b/tests/test_schedule_engine.cpp @@ -0,0 +1,298 @@ +#include "schedule/schedule_engine.h" +#include "schedule/schedule_manager.h" +#include "network/api_client.h" +#include "network/schedule_cache.h" +#include "db/database_manager.h" +#include "db/db_keys.h" + +#include + +#include +#include +#include +#include + +using namespace radiantwave::schedule; +using radiantwave::api::SchedulePayload; +using radiantwave::api::TargetBlock; + +namespace { + +// Fixture files are shared byte-for-byte with the Portal. The test looks them +// up relative to the source tree (CMake sets RWT_FIXTURES_DIR). +std::string fixtureText(const std::string& name) { + const std::string dir = +#ifdef RWT_FIXTURES_DIR + RWT_FIXTURES_DIR; +#else + "docs/fixtures"; +#endif + std::ifstream in(dir + "/" + name, std::ios::binary); + if (!in.is_open()) return ""; + std::ostringstream ss; + ss << in.rdbuf(); + return ss.str(); +} + +SchedulePayload loadPayload(const std::string& name) { + SchedulePayload payload; + const std::string text = fixtureText(name); + EXPECT_FALSE(text.empty()) << "missing fixture " << name; + EXPECT_TRUE(radiantwave::api::parseSchedulePayloadJson(text, payload)) << name; + return payload; +} + +bool hasBlockAt(const SchedulePayload& p, int minutes, Weekday day) { + return evaluate(p, minutes, day).hasBlock; +} + +} // namespace + +// ─── Adjacency (half-open boundaries) ────────────────────────────────── + +TEST(ScheduleEngineTest, AdjacentBoundaryBelongsToNextBlock) { + const auto p = loadPayload("schedule-adjacent.json"); + // Block A [06:00, 07:00), Block B [07:00, 08:30). 06:59 in A; 07:00 in B. + const auto at0659 = evaluate(p, 6 * 60 + 59, Weekday::Mon); + const auto at0700 = evaluate(p, 7 * 60, Weekday::Mon); + ASSERT_TRUE(at0659.hasBlock); + ASSERT_TRUE(at0700.hasBlock); + EXPECT_EQ(at0659.block.id, "dd111111-1111-4111-8111-111111111111"); + EXPECT_EQ(at0700.block.id, "dd222222-2222-4222-8222-222222222222"); + EXPECT_EQ(at0700.block.dynamic_audio, false); +} + +// ─── Daily fixture ───────────────────────────────────────────────────── + +TEST(ScheduleEngineTest, DailyPicksBlocksAndLeavesGaps) { + const auto p = loadPayload("schedule-daily.json"); + EXPECT_TRUE(hasBlockAt(p, 7 * 60, Weekday::Tue)); // inside first + EXPECT_FALSE(hasBlockAt(p, 9 * 60, Weekday::Tue)); // gap + EXPECT_TRUE(hasBlockAt(p, 18 * 60 + 30, Weekday::Tue)); // inside second + EXPECT_FALSE(hasBlockAt(p, 23 * 60, Weekday::Tue)); // gap +} + +// ─── Midnight crossing (daily self-wrap) ─────────────────────────────── + +TEST(ScheduleEngineTest, MidnightCrossingWrapsWithinTheDay) { + const auto p = loadPayload("schedule-midnight-crossing.json"); + // 21:00→09:00 and its complement 09:00→21:00. + const auto late = evaluate(p, 23 * 60, Weekday::Wed); + const auto early = evaluate(p, 3 * 60, Weekday::Wed); + const auto midday = evaluate(p, 12 * 60, Weekday::Wed); + ASSERT_TRUE(late.hasBlock); + ASSERT_TRUE(early.hasBlock); + EXPECT_EQ(late.block.id, "cc111111-1111-4111-8111-111111111111"); + EXPECT_EQ(early.block.id, late.block.id); + EXPECT_EQ(midday.block.id, "cc222222-2222-4222-8222-222222222222"); +} + +// ─── Weekly Sunday -> Monday spillover ───────────────────────────────── + +TEST(ScheduleEngineTest, WeeklySundayCrossingSpillsIntoMonday) { + const auto p = loadPayload("schedule-weekly.json"); + // Sunday 23:00→01:00. Monday 00:30 belongs to the Sunday block. + const auto mondayEarly = evaluate(p, 30, Weekday::Mon); + ASSERT_TRUE(mondayEarly.hasBlock); + EXPECT_EQ(mondayEarly.block.id, "a4444444-4444-4444-8444-444444444444"); + EXPECT_EQ(mondayEarly.block.day, "sun"); + + // 01:00 is the boundary: no longer covered. + EXPECT_FALSE(evaluate(p, 60, Weekday::Mon).hasBlock); + + // Monday 06:00 block plays on Monday. + const auto monday = evaluate(p, 6 * 60, Weekday::Mon); + ASSERT_TRUE(monday.hasBlock); + EXPECT_EQ(monday.block.day, "mon"); +} + +// ─── Gap -> local, dynamicAudio true/false ───────────────────────────── + +TEST(ScheduleEngineTest, DynamicAudioFlagPerBlock) { + const auto p = loadPayload("schedule-dynamic-audio.json"); + const auto morning = evaluate(p, 9 * 60, Weekday::Mon); + const auto evening = evaluate(p, 21 * 60, Weekday::Mon); + ASSERT_TRUE(morning.hasBlock); + ASSERT_TRUE(evening.hasBlock); + EXPECT_TRUE(morning.block.dynamic_audio); + EXPECT_FALSE(evening.block.dynamic_audio); +} + +// ─── None / unknown type ─────────────────────────────────────────────── + +TEST(ScheduleEngineTest, NoScheduleHasNoActiveBlock) { + const auto p = loadPayload("schedule-none.json"); + EXPECT_FALSE(p.hasSchedule()); + EXPECT_FALSE(evaluate(p, 12 * 60, Weekday::Mon).hasBlock); +} + +// ─── Invalid block skipped ───────────────────────────────────────────── + +TEST(ScheduleEngineTest, SkipsInvalidBlocksAndWarns) { + SchedulePayload p; + p.type = "daily"; + TargetBlock invalid; // empty proclamation list + invalid.id = "bad"; + invalid.start_minutes = 0; + invalid.end_minutes = 60; + TargetBlock valid; + valid.id = "good"; + valid.start_minutes = 0; + valid.end_minutes = 60; + valid.proclamation_ids = {"p1"}; + p.daily = {invalid, valid}; + + std::string warning; + const auto decision = evaluate(p, 30, Weekday::Mon, + [&](const std::string& w) { warning = w; }); + ASSERT_TRUE(decision.hasBlock); + EXPECT_EQ(decision.block.id, "good"); + EXPECT_FALSE(warning.empty()); +} + +// ─── Timezone / DST-transition day ───────────────────────────────────── + +TEST(ScheduleEngineTest, WallClockMinutesAreTimezoneIndependent) { + // The engine takes wall-clock minutes; a DST day still passes the same + // wall-clock (e.g. 02:30) and matches an 02:00–03:00 block. + SchedulePayload p; + p.type = "daily"; + TargetBlock b; + b.id = "dst"; + b.start_minutes = 120; + b.end_minutes = 180; + b.proclamation_ids = {"p1"}; + p.daily = {b}; + EXPECT_TRUE(evaluate(p, 150, Weekday::Sun).hasBlock); +} + +TEST(ScheduleEngineTest, WeekdayFromIndexWraps) { + EXPECT_EQ(weekdayFromIndex(0), Weekday::Mon); + EXPECT_EQ(weekdayFromIndex(6), Weekday::Sun); + EXPECT_EQ(weekdayFromIndex(7), Weekday::Mon); +} + +// ─── Payload parsing ─────────────────────────────────────────────────── + +TEST(ScheduleEngineTest, ParsesVersionTypeAndBlocks) { + const auto p = loadPayload("schedule-daily.json"); + EXPECT_EQ(p.version, "sched-daily-0001"); + EXPECT_EQ(p.type, "daily"); + EXPECT_EQ(p.daily.size(), 2u); + EXPECT_TRUE(p.weekly.empty()); + EXPECT_EQ(p.legacy_blocks.size(), 2u); +} + +TEST(ScheduleEngineTest, ParsesWeeklyBlocksByDay) { + const auto p = loadPayload("schedule-weekly.json"); + EXPECT_EQ(p.type, "weekly"); + EXPECT_EQ(p.weekly.size(), 4u); // mon, wed, fri, sun + EXPECT_TRUE(p.daily.empty()); +} + +// ─── Offline cache ───────────────────────────────────────────────────── + +namespace { + +std::string tempCacheDir() { + const auto dir = std::filesystem::temp_directory_path() / "rw_schedule_cache_test"; + std::filesystem::create_directories(dir); + return dir.string(); +} + +} // namespace + +TEST(ScheduleCacheTest, RoundTripsPayloadAndEtag) { + setenv("HOME", tempCacheDir().c_str(), 1); + SchedulePayload p = loadPayload("schedule-daily.json"); + ASSERT_TRUE(radiantwave::sync::schedule_cache::write(p, "\"abc\"")); + + SchedulePayload loaded; + std::string etag; + ASSERT_TRUE(radiantwave::sync::schedule_cache::read(loaded, etag)); + EXPECT_EQ(etag, "\"abc\""); + EXPECT_EQ(loaded.type, "daily"); + EXPECT_EQ(loaded.daily.size(), 2u); + EXPECT_EQ(loaded.version, p.version); + radiantwave::sync::schedule_cache::clear(); +} + +TEST(ScheduleCacheTest, CorruptCacheIsIgnored) { + const std::string dir = tempCacheDir(); + setenv("HOME", dir.c_str(), 1); + // Write garbage directly. + std::ofstream out(radiantwave::sync::schedule_cache::cachePath(), std::ios::binary); + out << "{ this is not json"; + out.close(); + + SchedulePayload loaded; + std::string etag; + EXPECT_FALSE(radiantwave::sync::schedule_cache::read(loaded, etag)); + radiantwave::sync::schedule_cache::clear(); +} + +// ─── ScheduleManager: fetch, 304, offline, toggle ────────────────────── + +namespace { + +SchedulePayload dailyPayload() { return loadPayload("schedule-daily.json"); } + +} // namespace + +TEST(ScheduleManagerTest, FetchesPayloadAndAppliesIt) { + radiantwave::schedule::ScheduleManager manager; + manager.setPayloadForTesting(dailyPayload(), "\"v0\""); + manager.setFetchForTesting( + [](const std::string&, const std::string&, SchedulePayload& out, std::string& etag) { + out = dailyPayload(); + etag = "\"v1\""; + return radiantwave::api::ScheduleFetchStatus::Ok; + }); + manager.setEnabled(true); + EXPECT_TRUE(manager.activeBlock(7 * 60, Weekday::Mon).hasBlock); +} + +TEST(ScheduleManagerTest, DisabledReturnsNoBlock) { + radiantwave::schedule::ScheduleManager manager; + manager.setPayloadForTesting(dailyPayload(), "\"v1\""); + manager.setEnabled(true); + EXPECT_TRUE(manager.activeBlock(7 * 60, Weekday::Mon).hasBlock); + + manager.setEnabled(false); + EXPECT_FALSE(manager.activeBlock(7 * 60, Weekday::Mon).hasBlock); +} + +TEST(ScheduleManagerTest, CorruptCacheFallsBackToNoSchedule) { + const std::string dir = tempCacheDir(); + setenv("HOME", dir.c_str(), 1); + std::ofstream out(radiantwave::sync::schedule_cache::cachePath(), std::ios::binary); + out << "not json"; + out.close(); + + radiantwave::schedule::ScheduleManager manager; + manager.setEnabled(true); + EXPECT_FALSE(manager.activeBlock(7 * 60, Weekday::Mon).hasBlock); + radiantwave::sync::schedule_cache::clear(); +} + +TEST(ScheduleManagerTest, OfflineFallsBackToCache) { + const std::string dir = tempCacheDir(); + setenv("HOME", dir.c_str(), 1); + + // Write a good cache, then simulate a network error on fetch. + SchedulePayload p = dailyPayload(); + radiantwave::sync::schedule_cache::write(p, "\"cached\""); + + radiantwave::schedule::ScheduleManager manager; + manager.setEnabled(true); + + // Manually seed from cache (start() requires a DB); setPayloadForTesting + // mirrors what loadCache() would produce. + SchedulePayload cached; + std::string etag; + ASSERT_TRUE(radiantwave::sync::schedule_cache::read(cached, etag)); + manager.setPayloadForTesting(cached, etag); + EXPECT_TRUE(manager.activeBlock(7 * 60, Weekday::Mon).hasBlock); + EXPECT_EQ(etag, "\"cached\""); + radiantwave::sync::schedule_cache::clear(); +} -- 2.43.0 From da0b6ffd5e579fb8483e79d61e559252066ba6a1 Mon Sep 17 00:00:00 2001 From: NeilClack Date: Tue, 29 Sep 2026 10:34:45 -0500 Subject: [PATCH 08/17] Kiosk: apply schedule engine in playback and add System Settings toggle 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. --- #algorithm.md# | 502 +++++++++++++++++++++++++++ src/app_state.h | 2 + src/config/app_config.h | 1 + src/config/config_manager.cpp | 3 + src/config/yaml_parser.cpp | 4 + src/main.cpp | 6 + src/schedule_toggle_page.cpp | 152 ++++++++ src/schedule_toggle_page.h | 40 +++ src/scramble/#fibonacci_scrambler.h# | 185 ++++++++++ src/scroller_page.cpp | 65 ++-- src/scroller_page.h | 17 +- src/settings_menu.cpp | 1 + src/settings_menu.h | 1 + src/system_settings_page.cpp | 4 + tests/test_settings_menu.cpp | 17 +- 15 files changed, 959 insertions(+), 41 deletions(-) create mode 100644 #algorithm.md# create mode 100644 src/schedule_toggle_page.cpp create mode 100644 src/schedule_toggle_page.h create mode 100644 src/scramble/#fibonacci_scrambler.h# diff --git a/#algorithm.md# b/#algorithm.md# new file mode 100644 index 0000000..fe5ba5a --- /dev/null +++ b/#algorithm.md# @@ -0,0 +1,502 @@ +**Ben Williams** February 9, 2007 MATH491 Summer Research Project 2006–2007 Department of Mathematics and Statistics, University of Canterbury + +## Abstract + +An analysis of some recently proposed methods of digital image scrambling that use Fibonacci numbers. This report describes the mathematics required to understand why these methods work. + +## Contents + +1. Introduction +2. Properties of the Fibonacci numbers +3. Affine transformations +4. Applications to digital image scrambling +5. Conclusion Appendix: Methods to calculate $f_n$ in $\mathbb{Z}_m$ + +--- + +## 1. Introduction + +Digital image scrambling is a method to hide the content of an image. One application is in satellite television broadcasting systems to prevent unauthorized viewing. The scrambling algorithm needs to be fast so that each frame can be de-scrambled in real-time. A method used in the past has been to permute the rows of the image. A key is then required to describe the inverse permutation. + +Other methods of scrambling include scrambling pixel positions or scrambling the color values of each pixel. Without knowledge of the key it should be difficult to recover the scrambled image given time constraints. + +Digital image scrambling methods have specific requirements that general data encryption algorithms may not provide. The algorithms need to be simple and fast so that they can be used by low cost equipment. The scrambled image should not adversely affect data compression algorithms, and information about the image such as dimension needs to be available without decrypting the image. + +In order to understand the new scrambling methods proposed in [2], [3], [4], some properties of the Fibonacci numbers are described. Affine transformations are then presented because these will be used to scramble pixels to new positions within an image. Some affine transformations that use Fibonacci numbers for scrambling digital images are described and analysed. + +--- + +## 2. Properties of the Fibonacci numbers + +**Definition 2.1.** The _Fibonacci numbers_ are the numbers in the sequence $0, 1, 1, 2, 3, 5, 8, 13, 21, 34, 55, 89, \dots$, where the $n$th Fibonacci number $f_n$ is defined by the linear recurrence relation + +$$f_n = f_{n-1} + f_{n-2} \quad \text{for } n \ge 2 \text{ and } f_0 = 0,\ f_1 = 1.$$ + +**Theorem 2.1.** $f_n$ is even $\iff$ 3 divides $n$. + +_Proof._ Consider the Fibonacci sequence where each number is reduced modulo 2. + +$$s_n = f_n \bmod 2$$ $$s_n = (f_{n-1} + f_{n-2}) \bmod 2$$ $$s_n = (s_{n-1} + s_{n-2}) \bmod 2$$ + +The sequence generated is + +$$0, 1, 1, 0, 1, 1, 0, 1, 1, 0, \dots$$ + +The period of the sequence is 3 because $s_i = s_{i+3}$ for all $i \in \mathbb{N}$. It follows that + +$$s_{3k} = 0, \qquad s_{3k+1} = 1, \qquad s_{3k+2} = 1 \quad \text{for all } k \in \mathbb{N}.$$ + +Therefore $f_n \equiv 0 \pmod 2 \iff n = 3k$. $\blacksquare$ + +**Definition 2.2.** The _Fibonacci matrix_ is the $2 \times 2$ matrix + +$$F = \begin{bmatrix} 0 & 1 \ 1 & 1 \end{bmatrix}$$ + +It is used to represent the Fibonacci sequence as a matrix equation. Let $\mathbf{u}_n = \begin{bmatrix} f_{n-1} \ f_n \end{bmatrix}$. Then for $n \ge 1$, $\mathbf{u}_{n+1} = F\mathbf{u}_n$. + +**Theorem 2.2.** + +$$f_n = \frac{(1+\sqrt5)^n - (1-\sqrt5)^n}{2^n \sqrt5}$$ + +**Table 1: The Fibonacci numbers** + +|$n$|$f_n$|$f_{n+1}$|$f_n^2 \bmod f_{n+1}$| +|---|---|---|---| +|0 |0 |1 |0 | +|1|1|1|0| +|2|1|2|-1| +|3|2|3|1| +|4|3|5|-1| +|5|5|8|1| +|6|8|13|-1| +|7|13|21|1| +|8|21|34|-1| +|9|34|55|1| +|10|55|89|-1| +|11|89|144|1| +|12|144|233|-1| +|13|233|377|1| +|14|377|610|-1| +|15|610|987|1| +|16|987|1597|-1| + +_Proof of Theorem 2.2._ [5] p. 355. + +$$\mathbf{u}_{n+1} = F\mathbf{u}_n = F(F\mathbf{u}_{n-1}) = F^2 \mathbf{u}_{n-1} = \cdots = F^n \mathbf{u}_{n-(n-1)} = F^n \mathbf{u}_1$$ + +$\lambda$ is an eigenvalue of $F$ if $\det(F - \lambda I) = 0$. Solving the characteristic equation for $F$ gives eigenvalues + +$$\lambda_1 = \frac{1+\sqrt5}{2}, \qquad \lambda_2 = \frac{1-\sqrt5}{2}$$ + +Let $\mathbf{v}_1$ and $\mathbf{v}_2$ be corresponding eigenvectors. These eigenvectors are linearly independent because $\lambda_1 \ne \lambda_2$, therefore they span $\mathbb{R}^2$. + +Let $\mathbf{u}_1 = c_1 \mathbf{v}_1 + c_2 \mathbf{v}_2$. + +$$\mathbf{u}_{n+1} = F^n \mathbf{u}_1 = F^n(c_1 \mathbf{v}_1 + c_2 \mathbf{v}_2) = c_1 F^n \mathbf{v}_1 + c_2 F^n \mathbf{v}_2$$ + +$$\begin{bmatrix} f_n \ f_{n+1} \end{bmatrix} = c_1 \lambda_1^n \mathbf{v}_1 + c_2 \lambda_2^n \mathbf{v}_2$$ + +Therefore $f_n = d_1 \lambda_1^n + d_2 \lambda_2^n$ for coefficients yet to be determined. Solving the system of equations + +$$f_0 = d_1 \lambda_1^0 + d_2 \lambda_2^0 = d_1 + d_2 = 0$$ + +$$f_1 = d_1 \lambda_1^1 + d_2 \lambda_2^1 = d_1\left(\frac{1+\sqrt5}{2}\right) + d_2\left(\frac{1-\sqrt5}{2}\right) = 1$$ + +gives $d_1 = \dfrac{1}{\sqrt5}$, $d_2 = \dfrac{-1}{\sqrt5}$. + +$$f_n = \frac{1}{\sqrt5}\left(\left(\frac{1+\sqrt5}{2}\right)^n - \left(\frac{1-\sqrt5}{2}\right)^n\right) = \frac{(1+\sqrt5)^n - (1-\sqrt5)^n}{2^n\sqrt5} \qquad \blacksquare$$ + +**Lemma 2.3.** For $n \ge 1$ + +$$F^n = \begin{bmatrix} f_{n-1} & f_n \ f_n & f_{n+1} \end{bmatrix}$$ + +_Proof._ By induction. Let $P(n)$ be the proposition that $F^n = \begin{bmatrix} f_{n-1} & f_n \ f_n & f_{n+1} \end{bmatrix}$. + +$P(1)$ is the proposition that $F = \begin{bmatrix} 0 & 1 \ 1 & 1 \end{bmatrix}$, which is true. + +If $P(k)$ is true for some positive integer $k$ then + +$$F^k F = \begin{bmatrix} f_{k-1} & f_k \ f_k & f_{k+1} \end{bmatrix}\begin{bmatrix} 0 & 1 \ 1 & 1 \end{bmatrix} = \begin{bmatrix} f_k & f_{k-1}+f_k \ f_{k+1} & f_k+f_{k+1} \end{bmatrix} = \begin{bmatrix} f_k & f_{k+1} \ f_{k+1} & f_{k+2} \end{bmatrix} = \begin{bmatrix} f_{(k+1)-1} & f_{k+1} \ f_{k+1} & f_{(k+1)+1} \end{bmatrix} = F^{k+1}$$ + +Therefore $P(k) \Rightarrow P(k+1)$ and by induction, $P(n)$ is true for all $n \ge 1$. $\blacksquare$ + +**Theorem 2.4.** Let $f_n$ denote the $n$th Fibonacci number. Then for $n \ge 1$ + +$$f_{n-1} \cdot f_{n+1} - f_n^2 = (-1)^n$$ + +_Proof._ [7] Taking determinants on both sides of the equation in Lemma 2.3 gives + +$$\begin{vmatrix} f_{n-1} & f_n \ f_n & f_{n+1} \end{vmatrix} = \det(F^n) = \det(F)^n = (-1)^n \qquad \blacksquare$$ + +**Theorem 2.5.** Any two consecutive Fibonacci numbers $f_n$ and $f_{n+1}$ are relatively prime. + +_Proof._ By induction. Let $P(n)$ be the proposition that $\gcd(f_n, f_{n+1}) = 1$. + +$P(0)$ is the proposition that $\gcd(0,1)=1$, which is true. + +If $P(k)$ is true for some positive integer $k$ then + +$$\gcd(f_k, f_{k+1}) = 1 = \gcd(f_k+f_{k+1}, f_{k+1}) = \gcd(f_{k+1}, f_{k+2})$$ + +because $f_k + f_{k+1} = f_{k+2}$ by Definition 2.1. + +Therefore $P(k) \Rightarrow P(k+1)$ and by induction, $P(n)$ is true for all $n \ge 0$. $\blacksquare$ + +--- + +## 3. Affine transformations + +Let $\mathbb{Z}_m = {0,1,2,\dots,m-1}$. Denote $\mathbb{Z}_m^n$ as the set of vectors of dimension $n$ with each component an element in $\mathbb{Z}_m$. Denote $M_n(\mathbb{Z}_m)$ as the set of all $n \times n$ matrices with each entry an element in $\mathbb{Z}_m$. Matrix operations addition and multiplication over $\mathbb{Z}_m$ are defined the same as addition and multiplication over $\mathbb{Z}$ with each matrix entry reduced modulo $m$. + +For example: Let $A = (a_{i,j})$, $B = (b_{i,j}) \in M_n(\mathbb{Z}_m)$ and $m \in \mathbb{N}$. We write + +$$A \equiv B \pmod m$$ + +if $a_{i,j} \equiv b_{i,j} \pmod m$ for all $i,j \in {0,1,\dots,n}$. + +**Definition 3.1.** If there exists $A' \in M_n(\mathbb{Z}_m)$ such that $AA' \equiv I_n \pmod m$ then $A$ is _invertible_mod $m$. + +**Theorem 3.1.** Let $A \in M_n(\mathbb{Z}_m)$ and $m \in \mathbb{N}$. $A$ is invertible $\iff \det(A)$ and $m$ are relatively prime. + +_Proof._ [4] p. 94. Suppose $A$ is invertible. Then $AB \equiv I \pmod m$ for some $B \in M_n(\mathbb{Z}_m)$. + +$$\det(AB) \equiv 1 \pmod m$$ $$\det(A) \cdot \det(B) \equiv 1 \pmod m$$ + +Let $a = \det(A)$ and $b = \det(B)$. + +$$ab + km = 1 \text{ for some } k \in \mathbb{Z}$$ + +Suppose $c \mid a$ and $c \mid m$. Then $c \mid (ab+km)$, i.e. $c \mid 1$. Therefore $\gcd(a,m) = 1$. + +Conversely suppose $\gcd(a,m) = 1$. Then + +$$ab + km = 1 \text{ for some } b,k \in \mathbb{Z}$$ $$ab \equiv 1 \pmod m$$ + +A well known equation for square matrices is + +$$A , \mathrm{adj}(A) = \det(A), I$$ + +where $\mathrm{adj}(A)$ is the adjoint matrix of $A$. This equation gives a formula to calculate $A^{-1}$. + +$$A,\mathrm{adj}(A) \equiv \det(A), I \pmod m$$ $$bA,\mathrm{adj}(A) \equiv ba, I \pmod m$$ $$A, b,\mathrm{adj}(A) \equiv I \pmod m$$ $$A^{-1} \equiv b,\mathrm{adj}(A) \pmod m \qquad \blacksquare$$ + +**Remark 1.** If $\det(A) = 0$ then $A$ is not invertible, since $\gcd(0,m) = m$. An example of a non-invertible matrix with a non-zero determinant is $\begin{bmatrix} 2 & 0 \ 0 & 1 \end{bmatrix}$ over $M_2(\mathbb{Z}_4)$. + +**Definition 3.2.** A transformation $T: \mathbb{Z}_m^n \to \mathbb{Z}_m^n$ defined by $T(\mathbf{x}) = A\mathbf{x} + \mathbf{b} \pmod m$ for some $\mathbf{b} \in \mathbb{Z}_m^n$, $A \in M_n(\mathbb{Z}_m)$ is called _affine_. If $\mathbf{b} = 0$, then $T$ is a _linear_ transformation. + +**Corollary 3.2.** The affine linear map from Definition 3.2 is bijective if and only if $\det(A)$ and $m$ are relatively prime. + +**Definition 3.3.** Let $A \in M_n(\mathbb{Z}_m)$ and $m \in \mathbb{N}$. $A$ is _periodic_ if there exists $t \in \mathbb{N}$ such that $t > 0$ and $A^t = I_n$. The _period_ of $A$ is the smallest positive integer $t$ such that $A^t = I_n$. + +An affine transformation $T: \mathbb{Z}_m^n \to \mathbb{Z}_m^n$ is _periodic_ if $T^t(\mathbf{x}) = \mathbf{x}$ for some positive integer $t$ and all $\mathbf{x} \in \mathbb{Z}_m^n$. + +**Theorem 3.3.** Let $A \in M_n(\mathbb{Z}_m)$ and $m \in \mathbb{N}$. $A$ is periodic $\iff$ $A$ is invertible. + +_Proof._ Suppose $A$ is periodic. Then for some positive integer $t$ + +$$A^t \equiv I \pmod m$$ $$A, A^{t-1} \equiv I \pmod m$$ + +Therefore $A$ is invertible. + +Conversely, suppose $A$ is invertible. There are exactly $m^{n^2}$ distinct matrices in $M_n(\mathbb{Z}_m)$. Define the multiset $S = {A^i \bmod m : 0 \le i \le m^{n^2}}$. There exists $A^k, A^l \in S$ where $k \le l$ and $A^k = A^l$. + +$$A^k \equiv A^l \pmod m$$ $$A^k(A^{-1})^k \equiv A^l(A^{-1})^k \pmod m$$ $$I \equiv A^{l-k} \pmod m$$ + +Therefore $A$ is periodic. $\blacksquare$ + +**Theorem 3.4.** The affine transformation $T: \mathbf{x} \to A\mathbf{x} + c\mathbf{b}$ defined in 3.2 is periodic if and only if $A$ is invertible. + +_Proof._ Define affine transformation $S: \mathbb{Z}_m^{n+1} \to \mathbb{Z}_m^{n+1}$ by + +$$S(\mathbf{v}) = \begin{bmatrix} A & \mathbf{b} \ \mathbf{0}^T & 1 \end{bmatrix} = A'\mathbf{v}$$ + +Let $\mathbf{y} = \begin{bmatrix} \mathbf{x} \ c \end{bmatrix}$ for some $c \in \mathbb{Z}_m$, $\mathbf{x} \in \mathbb{Z}_m^n$. Then + +$$S(\mathbf{y}) = \begin{bmatrix} A & \mathbf{b} \ \mathbf{0}^T & 1 \end{bmatrix}\begin{bmatrix} \mathbf{x} \ c \end{bmatrix} = \begin{bmatrix} A\mathbf{x} + c\mathbf{b} \ c \end{bmatrix} = \begin{bmatrix} T(\mathbf{x}) \ c \end{bmatrix}$$ + +Let $P(n)$ be the proposition that + +$$S^n\left(\begin{bmatrix} \mathbf{x} \ c \end{bmatrix}\right) = \begin{bmatrix} T^n(\mathbf{x}) \ c \end{bmatrix}$$ + +$P(1)$ has been proved. If $P(k)$ is true, then + +$$S^{k+1}(\mathbf{y}) = S(S^k(\mathbf{y})) = \begin{bmatrix} A & \mathbf{b} \ \mathbf{0}^T & 1 \end{bmatrix}\begin{bmatrix} T^k(\mathbf{x}) \ c \end{bmatrix} = \begin{bmatrix} A,T^k(\mathbf{x}) + c\mathbf{b} \ c \end{bmatrix} = \begin{bmatrix} T^{k+1}(\mathbf{x}) \ c \end{bmatrix}$$ + +Therefore $P(k) \Rightarrow P(k+1)$ and by induction, $P(n)$ is true for all $n \ge 1$. + +If there exists positive integer $t$ such that + +$$S^t\left(\begin{bmatrix} \mathbf{x} \ c \end{bmatrix}\right) = \begin{bmatrix} T^t(\mathbf{x}) \ c \end{bmatrix} = \begin{bmatrix} \mathbf{x} \ c \end{bmatrix}$$ + +then $S$ and $T$ are both periodic, otherwise $S$ and $T$ are both non-periodic. + +$$T \text{ is periodic} \iff S \text{ is periodic}$$ $$T \text{ is periodic} \iff A' \text{ is invertible (Theorem 3.3)}$$ $$T \text{ is periodic} \iff \text{there exists } A'^{-1} = \begin{bmatrix} A^{-1} & A^{-1}(-\mathbf{b}) \ \mathbf{0}^T & 1 \end{bmatrix}$$ $$T \text{ is periodic} \iff A \text{ is invertible} \qquad \blacksquare$$ + +**Remark 2.** Any scalar multiple of $\mathbf{b}$ can be used in $T$ and the period will remain the same. + +--- + +## 4. Applications to digital image scrambling + +**Definition 4.1.** The _Fibonacci affine transformation_ $T: \mathbb{Z}_m^2 \to \mathbb{Z}_m^2$ is defined by + +$$T(\mathbf{x}) = F\mathbf{x} + \mathbf{b} \pmod m$$ + +for some $\mathbf{b} \in \mathbb{Z}_m^2$, $m \in \mathbb{N}$ and $F$ from Definition 2.2. + +**Proposition 4.1.** The Fibonacci affine transformation is periodic. + +_Proof._ $\gcd(\det(F), m) = 1$ for all $m$, so by Theorems 3.1 and 3.4, the transformation is periodic. $\blacksquare$ + +The Fibonacci matrix transformation is described in [3] where the authors use it to scramble images of dimension $m \times m$ pixels. Each pixel has a coordinate in $\mathbb{Z}_m^2$ that can be scrambled to a new coordinate by $T$. The period of the transformation varies for different $m$. The suggested scrambling method is to apply $T$ on every pixel of the image. Then to decrypt, repeatedly apply $T$ to the scrambled image until the original image is recovered. It is unclear why the inverse transformation of $T$ is not used to make the decryption quicker. The vector $\mathbf{b}$ can be used as a key. Different values of $\mathbf{b}$ will affect the period of $T$. + +**Definition 4.2.** The _Arnold transformation_ $T: \mathbb{Z}_m^2 \to \mathbb{Z}_m^2$ is defined by $T(\mathbf{x}) = A\mathbf{x} \pmod m$ for $m \in \mathbb{N}$ and + +$$A = \begin{bmatrix} 1 & 1 \ 1 & 2 \end{bmatrix}$$ + +The Arnold transformation works the same way as the Fibonacci matrix transformation. The Arnold transformation is also periodic for all $m$. + +**Remark 3.** $F^2 = A$. + +Another transformation described in [4] works as follows. + +**Definition 4.3.** $T: \mathbb{Z}_m^2 \to \mathbb{Z}_m^2$ defined by $T(\mathbf{x}) = B\mathbf{x} + \mathbf{b} \pmod{f_{n+1}}$ for some $\mathbf{b} \in \mathbb{Z}_m^2$, $m \in \mathbb{N}$ with + +$$B = \begin{bmatrix} f_n & 0 \ 0 & f_n \end{bmatrix}$$ + +**Proposition 4.2.** $B$ is invertible, hence the transformation is periodic. + +_Proof._ $\det(B) = f_n^2 = f_{n-1}f_{n+1} + (-1)^{n+1}$ by Theorem 2.4. + +If $c \mid \det(B)$ and $c \mid f_{n+1}$ then $c \mid 1$, therefore $\det(B)$ and $f_{n+1}$ are coprime. $\blacksquare$ + +**Proposition 4.3.** When $n$ is odd the period of $B$ is 2. When $n$ is even the period of $B$ is 4. + +_Proof._ From Theorem 2.4 we have + +$$f_n^2 \equiv (-1)^{n+1} \pmod{f_{n+1}}$$ + +so $f_n^4 \equiv 1 \pmod{f_{n+1}}$ and $B^4 \equiv I \pmod{f_{n+1}}$. + +If $B$ has period $t$ then $t \mid 4$. + +When $n$ is even, $B \ne I$ and $B^2 \equiv -I \pmod{f_{n+1}}$. Therefore when $n$ is even the period of $B$ is 4. + +When $n$ is odd, $f_n^2 \equiv 1 \pmod{f_{n+1}}$, so $B^2 \equiv I \pmod{f_{n+1}}$. Since $B \ne I \pmod{f_{n+1}}$, when $n$ is odd the period of $B$ is 2. $\blacksquare$ + +**Remark 4.** Although $B$ has period 2 or 4, the period of the transformation may not be 2 or 4 when $\mathbf{b}$ is non-zero. + +The transformation permutes the columns and rows of the image by the permutation $R: k \to kf_n + b \pmod{f_{n+1}}$ where $k$ is a column or row number in ${1,2,\dots,f_{n+1}-1}$. The authors in [4] also include zero in this set but zero is a fixed point when $b = 0$. The authors state that having a period of 2 or 4 is efficient because encryption and decryption are the same functions and decryption can be achieved by reapplying the transformation. + +The case where $b = 0$ will be examined. Let $S: k \to kf_n \pmod{f_{n+1}}$ for $k$ in ${1,2,\dots,f_{n+1}-1}$. It follows from Proposition 4.3 that $S$ has period 4 if $n$ is even and period 2 if $n$ is odd. + +**Definition 4.4.** A _uniform permutation_ is a permutation in which all disjoint cycles are of the same length. This means there are no fixed points unless the permutation is the identity. + +**Remark 5.** If a permutation has period $t$, then the length of each cycle divides $t$, since $t$ is the lowest common multiple of all cycle lengths. + +Suppose the columns or rows of an image are scrambled using a uniform permutation. Then every row or column is moved, so the image content may be hidden more effectively than a permutation that leaves some rows or columns unchanged. + +In general the permutation $S$ is not uniform. Fixed points can be found by solving $x$ in the equation + +$$xf_n \equiv x \pmod{f_{n+1}} \tag{1}$$ + +**Proposition 4.4.** For $n > 3$, if $n$ is odd then $S$ is not uniform. + +_Proof._ $x = k(f_n+1)$, $k \in \mathbb{N}$, gives solutions to equation (1). + +$$k(f_n+1) \cdot f_n = k(f_n^2+f_n) \equiv k(1+f_n) \pmod{f_{n+1}}$$ + +**Remark 6.** When $n=3$, $S$ is uniform. $\blacksquare$ + +**Proposition 4.5.** If $n$ is even, then $S$ has no transpositions. + +_Proof._ A transposition exists if + +$$x \equiv f_n^2 x \pmod{f_{n+1}} \tag{2}$$ + +and + +$$x \not\equiv f_n x \pmod{f_{n+1}} \tag{3}$$ + +for some $x \in {1,2,\dots,f_{n+1}-1}$. This proof will show that if equation (2) has a solution, then $x \equiv f_n x \pmod{f_{n+1}}$ has the same solution. + +Solutions are found by solving for $x$: + +$$x \equiv f_n^2 x \pmod{f_{n+1}} \tag{4}$$ $$x \equiv -x \pmod{f_{n+1}} \tag{5}$$ $$2x \equiv 0 \pmod{f_{n+1}} \tag{6}$$ + +A solution exists $\iff$ 2 divides $f_{n+1}$, then $x = \dfrac{f_{n+1}}{2}$. + +$f_{n+1}$ is even, so by Theorem 2.1, $f_n$ is odd. + +$$f_n x = (2m+1)x \equiv (2m)\frac{f_{n+1}}{2} + \frac{f_{n+1}}{2} \equiv m f_{n+1} + x \pmod{f_{n+1}}$$ $$f_n x \equiv x \pmod{f_{n+1}}$$ + +This is a contradiction to equation (3). Therefore, no transpositions exist. $\blacksquare$ + +**Proposition 4.6.** For even $n$, $S$ is uniform $\iff f_{n+1}$ is odd. + +_Proof._ Suppose $S$ is uniform, then no fixed points exist. If $x \equiv f_n x \pmod{f_{n+1}}$ has no solutions then by Proposition 4.5 + +$$x \equiv f_n^2 x \pmod{f_{n+1}} \tag{7}$$ $$2x \equiv 0 \pmod{f_{n+1}} \tag{8}$$ + +has no solutions so $f_{n+1}$ is odd. + +Conversely, suppose $f_{n+1}$ is odd and a fixed point $x$ exists. Then equation (8) has no solution, thus + +$$x \equiv f_n^2 x \pmod{f_{n+1}}$$ + +has no solution. But a fixed point exists so + +$$x \equiv f_n x \equiv f_n^2 x \pmod{f_{n+1}}$$ + +This is a contradiction. $\blacksquare$ + +**Proposition 4.7.** For even $n$, $f_{n+1}$ is odd $\iff n \equiv 0$ or $4 \pmod 6$. + +_Proof._ Let $f_{n+1}$ be odd, then by Theorem 2.1 either + +$$n+1 = 3k+1 \implies n = 3k$$ + +or + +$$n+1 = 3l+2 \implies n = 3l+1$$ + +Equivalently, $f_{n+1}$ is odd $\iff n = 3k$ or $n = 3l+1$. Now restrict $n$ to even values, so let $k = 2m$ and $l = 2m+1$. Then + +$$n = 6m$$ + +or + +$$n = 3(2m+1)+1 = 6m+4$$ + +Therefore, for even $n$, $f_{n+1}$ is odd $\iff n = 6m$ or $n = 6m+4$. $\blacksquare$ + +--- + +## 5. Conclusion + +The Fibonacci transformation and the Arnold transformation can be used on square images of any size. The period of the transformation will vary for different size images. The period of $F \bmod m$ can be as large as $6m$ so the decryption process described could be very inefficient. + +The transformation in Definition 4.3 transforms pixels of an $f_{n+1} \times f_{n+1}$ image. In most practical cases, images are unlikely to be square and of this dimension. In this case the image can be cropped to $f_{n+1} \times f_{n+1}$ so that the transformation can be applied. Alternatively, a large Fibonacci number can be chosen that exceeds the dimensions of the image. But this would generate an encrypted image larger than the unencrypted image. Due to these limitations, the transformations do not appear to be very practical. + +--- + +## Appendix: Methods to calculate $f_n$ in $\mathbb{Z}_m$ + +To calculate the $n$th Fibonacci number mod $m$, the equation in Theorem 2.2 can be used and then $f_n$ can be reduced modulo $m$. For very large $n$, this is infeasible. If $F$ can be diagonalized over $\mathbb{Z}_m$ then calculating $F^n$ is fast. + +Eigenvalues of $F$ exist if for non-zero vector $\mathbf{x}$, + +$$F\mathbf{x} \equiv \lambda \mathbf{x} \pmod m$$ $$F\mathbf{x} - \lambda \mathbf{x} \equiv \mathbf{0} \pmod m$$ $$(F - \lambda I)\mathbf{x} \equiv \mathbf{0} \pmod m$$ + +Therefore $\lambda$ is an eigenvalue of $F$ if + +$$\gcd(\det(F-\lambda I), m) > 1$$ + +The characteristic polynomial of $F$ is $p(\lambda) = \det(F - \lambda I) = \lambda^2 - \lambda - 1$. + +For example: $\lambda_1 = 3$ and $\lambda_2 = 8$ are eigenvalues of $F$ in $\mathbb{Z}_{10}$ because $p(3) = 5$ and $p(8) = 5$ and $\gcd(5,10) \ne 1$. + +$$E_3 = \mathrm{null}\begin{bmatrix} -3 & 1 \ 1 & -2 \end{bmatrix} = \mathrm{null}\begin{bmatrix} 7 & 1 \ 1 & 8 \end{bmatrix} = \mathrm{null}\begin{bmatrix} 1 & 8 \ 7 & 1 \end{bmatrix} = \mathrm{null}\begin{bmatrix} 1 & 8 \ 0 & 5 \end{bmatrix} = \mathrm{span}\begin{bmatrix} 6 \ -2 \end{bmatrix}$$ + +$$E_8 = \mathrm{null}\begin{bmatrix} -8 & 1 \ 1 & -7 \end{bmatrix} = \mathrm{null}\begin{bmatrix} 2 & 1 \ 1 & 3 \end{bmatrix} = \mathrm{null}\begin{bmatrix} 1 & 3 \ 2 & 1 \end{bmatrix} = \mathrm{null}\begin{bmatrix} 1 & 3 \ 0 & 5 \end{bmatrix} = \mathrm{span}\begin{bmatrix} 6 \ -2 \end{bmatrix}$$ + +Although $\lambda_1$ and $\lambda_2$ are distinct, they have the same eigenspace so we do not have two linearly independent eigenvectors to diagonalize $F$. + +**Proposition .1.** If $m \mid p(\lambda_1)$ and $m \mid p(\lambda_2)$ for distinct eigenvalues $\lambda_1$ and $\lambda_2$, and $\gcd(\lambda_1 - \lambda_2, m) = 1$, then + +$$f_n \equiv (\lambda_1 - \lambda_2)^{-1}(\lambda_1^n - \lambda_2^n) \pmod m$$ + +_Proof._ If $m \mid p(\lambda)$ then $\lambda(\lambda - 1) \equiv 1 \pmod m$ and $\lambda(1-\lambda) \equiv -1 \pmod m$. + +$$E_\lambda = \mathrm{null}\begin{bmatrix} -\lambda & 1 \ 1 & 1-\lambda \end{bmatrix} = \mathrm{null}\begin{bmatrix} 1 & 1-\lambda \ -\lambda & 1 \end{bmatrix} = \mathrm{null}\begin{bmatrix} 1 & 1-\lambda \ 0 & \lambda(1-\lambda)+1 \end{bmatrix} = \mathrm{null}\begin{bmatrix} 1 & 1-\lambda \ 0 & 0 \end{bmatrix} = \mathrm{span}\begin{bmatrix} \lambda-1 \ 1 \end{bmatrix}$$ + +Let $\mathbf{v}_1$ and $\mathbf{v}_2$ be eigenvectors corresponding to $\lambda_1$ and $\lambda_2$ and let $P = [\mathbf{v}_1\ \mathbf{v}_2]$. + +$$F = PDP^{-1}$$ $$F^n = PD^nP^{-1} = \begin{bmatrix} \lambda_1-1 & \lambda_2-1 \ 1 & 1 \end{bmatrix}\begin{bmatrix} \lambda_1^n & 0 \ 0 & \lambda_2^n \end{bmatrix}\frac{1}{\det(P)}\begin{bmatrix} 1 & 1-\lambda_2 \ -1 & \lambda_1-1 \end{bmatrix}$$ + +$$= \frac{1}{\det(P)}\begin{bmatrix} \lambda_1-1 & \lambda_2-1 \ 1 & 1 \end{bmatrix}\begin{bmatrix} \lambda_1^n & (1-\lambda_2)\lambda_1^n \ -\lambda_2^n & (\lambda_1-1)\lambda_2^n \end{bmatrix}$$ + +$$= \frac{1}{\det(P)}\begin{bmatrix} (\lambda_1-1)\lambda_1^n+(1-\lambda_2)\lambda_2^n & (\lambda_1-1)(1-\lambda_2)\lambda_1^n+(\lambda_1-1)(\lambda_2-1)\lambda_2^n \ \lambda_1^n-\lambda_2^n & (1-\lambda_2)\lambda_1^n+(\lambda_1-1)\lambda_2^n \end{bmatrix}$$ + +$$F^n = \begin{bmatrix} f_{n-1} & f_n \ f_n & f_{n+1} \end{bmatrix}$$ + +$$f_n = \frac{1}{\det(P)}\lambda_1^n - \lambda_2^n \pmod m$$ + +$$f_n = (\lambda_1-\lambda_2)^{-1}(\lambda_1^n - \lambda_2^n) \pmod m \qquad \blacksquare$$ + +**Remark 7.** If $m \mid p(\lambda_1)$ and $m \mid p(\lambda_2)$, this does not imply $\gcd(\lambda_1-\lambda_2, m) = 1$. E.g. in $\mathbb{Z}_{55}$, $p(48) \equiv 0 \pmod{55}$ and $p(8) \equiv 0 \pmod{55}$ but $\gcd(48-8, 55) = 5$. + +The eigenvalues in these examples have been found by trying each element in $\mathbb{Z}_m$. Eigenvalues can be found directly if the equation from Theorem 2.2 is used. There are some advantages to using prime $m$. When $m$ is prime, 2 is invertible and a method to calculate $\sqrt5$ is possible for some primes. Also $\gcd(\lambda_1-\lambda_2, m) = 1$ is always true, so the equation from Proposition .1 can be used once the eigenvalues have been found. + +**Definition .1.** Given integers $a$ and $p$, if $x^2 \equiv a \pmod p$ for some $x \in {1,2,\dots,p-1}$ then $a$ is a _quadratic residue_ mod $p$. + +**Definition .2.** Given odd prime $p$ and integer $a$, the _Legendre symbol_ is + +$$(a/p) = a^{\frac{p-1}{2}} \pmod p$$ + +**Theorem .2.** For $p$ an odd prime and $a \in \mathbb{Z}_p^*$ + +$$a \text{ is a quadratic residue} \pmod p \iff (a/p) \equiv 1 \pmod p$$ + +_Proof._ [9] Suppose $x^2 \equiv a \pmod p$ for some $x \in \mathbb{Z}_p^*$. Then by using Fermat's Theorem + +$$a^{\frac{p-1}{2}} = (x^2)^{\frac{p-1}{2}} = x^{p-1} \equiv 1 \pmod p$$ + +Suppose $(a/p) = 1$ and let $a = g^k$ for primitive $g \in \mathbb{Z}_p^*$ and $k \in {1,2,\dots,p-1}$. Note: this is always possible for prime $p$. + +$$a^{\frac{p-1}{2}} = (g^k)^{\frac{p-1}{2}} = g^{k(\frac{p-1}{2})} \equiv 1 \pmod p \quad \text{(by assumption)}$$ $$g^{k(\frac{p-1}{2})} = g^{p-1} \pmod p \quad \text{(because primitive)}$$ + +Therefore $(p-1) \mid k(\tfrac{p-1}{2})$, so $k$ is even. Then $a = g^k = g^{2m} = (g^m)^2$ for some $m \in \mathbb{N}$. Thus $a$ is a quadratic residue. $\blacksquare$ + +**Theorem .3 (Law of Quadratic Reciprocity).** For distinct odd primes $p$ and $q$, + +1. If both $p$ and $q \equiv 3 \pmod 4$, then either $(q/p) = 1$ or $(p/q) = 1$ but not both. +2. If $p$ or $q \equiv 1 \pmod 4$, then $(q/p) = 1 \iff (p/q) = 1$. + +**Proposition .4.** For odd prime $p$, 5 is a quadratic residue $\iff p \equiv 1$ or $-1 \pmod{10}$. + +_Proof._ By Quadratic reciprocity, + +$$(5/p) = 1 \iff (p/5) = 1$$ $$(5/p) = 1 \iff p^{\frac{5-1}{2}} \equiv 1 \pmod 5$$ $$(5/p) = 1 \iff p^2 \equiv 1 \pmod 5$$ $$(5/p) = 1 \iff p \equiv 1 \text{ or } p \equiv -1 \pmod 5$$ $$(5/p) = 1 \iff p = 5k \pm 1$$ $$(5/p) = 1 \iff p = 5(2m) \pm 1 \quad \text{(odd } k \text{ gives even } p\text{)}$$ $$(5/p) = 1 \iff p \equiv 1 \text{ or } p \equiv -1 \pmod{10} \qquad \blacksquare$$ + +Now that we can identify which primes have 5 as a quadratic residue, there still remains the problem of calculating $\sqrt5$. In general, calculating square roots of quadratic residues has the same level of complexity as integer factorization [12]. + +**Proposition .5.** For given integer $a$, if $p \equiv 3 \pmod 4$ then $\sqrt{a} \equiv a^{\frac{p+1}{4}} \pmod p$. + +_Proof._ Assume $a$ is a quadratic residue. Then + +$$a^{\frac{p-1}{2}} \equiv 1 \pmod p$$ $$a \cdot a^{\frac{p-1}{2}} \equiv a \pmod p$$ $$a^{\frac{p-1}{2}+1} \equiv a \pmod p$$ $$a^{\frac{p+1}{2}} \equiv a \pmod p$$ $$\sqrt{a^{\frac{p+1}{2}}} \equiv \sqrt{a} \pmod p$$ $$a^{\frac{p+1}{4}} \equiv \sqrt{a} \pmod p$$ + +There is always a solution because $4 \mid (p+1)$. $\blacksquare$ + +**Remark 8.** In the first 1000 primes the proportion that are of the form $10k \pm 1$ is 0.491. The proportion that are of the form $4k+3$ is 0.504. The proportion that satisfy both forms is 0.251. + +--- + +## References + +1. Buchmann, J.A. _Introduction to Cryptography_, Springer-Verlag New York Inc, 2001. +2. J. Zou, R. K. Ward, D. Qi. _A new image scrambling method based on Fibonacci numbers_, Proceedings of the 2004 International Symposium on Circuits and Systems ISCAS 2004, May 23–26, 2004, Vancouver, Canada, Vol 3. pg 965. +3. J. Zou, X. Tie, R. K. Ward, D. Qi. _Some Novel Image Scrambling Methods Based on Affine Modular Matrix Transformation_, Journal of Information & Computational Science 2:1 (2005) 223–227. +4. J. Zou, R. K. Ward, D. Qi. _The Generalized Fibonacci Transformations and Application to Image Scrambling_, Acoustics, Speech, and Signal Processing, 2004. Proceedings. (ICASSP '04). IEEE International Conference on, Vol 3. pg 385. +5. Poole, D. _Linear Algebra: A Modern Introduction_, 2nd Ed, Australia: Belmont, CA: Thomson Brooks/Cole, 2006. +6. W. Zeng, H. Yu, and C. Lin. _Multimedia Security Technologies for Digital Rights Management_, Amsterdam; Boston: Academic Press, 2006. +7. http://en.wikipedia.org/wiki/Fibonacci_number +8. http://mcraeclan.com/MathHelp/BasicNumberSquareQuadraticResidues.htm (Read 30/11/2006) +9. http://rooster.stanford.edu/~ben/maths/numbertheory/qr.html (Read 30/11/2006) +10. http://mathworld.wolfram.com/QuadraticResidue.html (Read 30/11/2006) +11. http://en.wikipedia.org/wiki/Quadratic_reciprocity (Read 5/12/2006) +12. http://en.wikipedia.org/wiki/Quadratic_residue (Read 5/12/2006) +--- +I asked if this algorithm could be modified to handle images of any size instead of pinned Fibonacci numbered sizes. This was the response I received, but I did not receive any examples, code or math: + +Good news: the paper's own math already gives us what we need — the restriction to specific sizes (like fn+1×fn+1fn+1​×fn+1​) only applied to Zou's _scaled_ transformation (Definition 4.3). The plain **Fibonacci affine transform**(Definition 4.1, T(x)=Fx+b  mT(x)=Fx+bmodm) is periodic for _every_ modulus mm, because det⁡(F)=−1det(F)=−1 is coprime to every integer (Proposition 4.1). The only real constraint is that FF acts on a **square** Zm2Zm2​grid. + +So to handle _any_ image size, we just need two small extensions beyond the paper: + +1. **Pad to a square.** Any W×HW×H image can be embedded in a square canvas of side N=max⁡(W,H)N=max(W,H) (no need for NN to be a Fibonacci number, or a power of two, or anything special — any NNworks because of the fact above). +2. **Use the exact inverse instead of relying on the period.** The paper decrypts by re-applying TT until the period comes back around, which the authors themselves call inefficient (up to 6N6N applications). Since F−1≡[−1110](modN)F−1≡[−11​10​](modN) always exists, we can decrypt in exactly as many steps as we encrypted — no searching for the period. + +That turns the "encryption key" into: a **round count** tt (how many times FF is applied — more rounds = more scrambled) and a **sequence of offset vectors** b1,…,btb1​,…,bt​ (derived from a passphrase), applied in order to encrypt and in reverse with F−1F−1 to decrypt. + +I built this as a working tool below: type text, it's rendered to an image, padded to a square, and scrambled with this generalized Fibonacci map — fully reversible with the same key. \ No newline at end of file diff --git a/src/app_state.h b/src/app_state.h index 40f9835..a97d964 100644 --- a/src/app_state.h +++ b/src/app_state.h @@ -8,6 +8,7 @@ #include "network/network_manager.h" #include "network/license_status.h" #include "network/system_name_sync.h" +#include "schedule/schedule_manager.h" #include "audio/audio_manager.h" #include "audio/audio_downloader.h" @@ -21,6 +22,7 @@ struct AppState { radiantwave::network::NetworkManager network; radiantwave::license::LicenseStatusTracker licenseTracker; radiantwave::systemname::SystemNameSync systemNameSync; + radiantwave::schedule::ScheduleManager scheduleManager; radiantwave::audio::AudioManager audio; radiantwave::audio::AudioDownloader audioDownloader; bool quitRequested = false; diff --git a/src/config/app_config.h b/src/config/app_config.h index b8d04d5..f263ec8 100644 --- a/src/config/app_config.h +++ b/src/config/app_config.h @@ -17,6 +17,7 @@ struct ProclamationConfig { int font_size = 32; // font size in pt std::string current_id = ""; // currently active proclamation ID bool schedule_active = false; // follow the portal's listening schedule + bool schedule_enabled = true; // local Schedule on/off toggle (default on) }; struct AppConfig { diff --git a/src/config/config_manager.cpp b/src/config/config_manager.cpp index 77cbcbf..488acc7 100644 --- a/src/config/config_manager.cpp +++ b/src/config/config_manager.cpp @@ -253,6 +253,9 @@ void ConfigManager::loadAndMergeDefaults() { if (proc_node.hasChild("schedule_active")) { merged.proclamation.schedule_active = proc_node.getChild("schedule_active").asBool(defaults.proclamation.schedule_active); } + if (proc_node.hasChild("schedule_enabled")) { + merged.proclamation.schedule_enabled = proc_node.getChild("schedule_enabled").asBool(defaults.proclamation.schedule_enabled); + } } { diff --git a/src/config/yaml_parser.cpp b/src/config/yaml_parser.cpp index 2ec71fc..cb1b225 100644 --- a/src/config/yaml_parser.cpp +++ b/src/config/yaml_parser.cpp @@ -234,6 +234,9 @@ AppConfig parseConfigFromYaml(const std::string& yamlContent) { if (proc_node.hasChild("schedule_active")) { config.proclamation.schedule_active = proc_node.getChild("schedule_active").asBool(config.proclamation.schedule_active); } + if (proc_node.hasChild("schedule_enabled")) { + config.proclamation.schedule_enabled = proc_node.getChild("schedule_enabled").asBool(config.proclamation.schedule_enabled); + } } return config; @@ -260,6 +263,7 @@ std::string serializeConfigToYaml(const AppConfig& config) { ss << " font_size: " << config.proclamation.font_size << "\n"; ss << " current_id: \"" << config.proclamation.current_id << "\"\n"; ss << " schedule_active: " << (config.proclamation.schedule_active ? "true" : "false") << "\n"; + ss << " schedule_enabled: " << (config.proclamation.schedule_enabled ? "true" : "false") << "\n"; return ss.str(); } diff --git a/src/main.cpp b/src/main.cpp index be37cc0..374cfd1 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -166,6 +166,11 @@ SDL_AppResult SDL_AppInit(void **appstate, int argc, char *argv[]) { // timer; license validation also applies the name (see license_service). state->systemNameSync.start(state->db); + // Background Schedule sync. Loads the on-disk cache immediately, then pulls + // on boot, on a 5-minute timer, and on demand. Playback reads it locally. + state->scheduleManager.setEnabled(state->config.get().proclamation.schedule_enabled); + state->scheduleManager.start(state->db, state->config.get().timezone); + state->config.onChanged( [state](const radiantwave::config::AppConfig &new_config) { SDL_Log("Live config update received: volume=%d, dynamic_audio=%d", @@ -275,6 +280,7 @@ void SDL_AppQuit(void *appstate, SDL_AppResult result) { auto *state = static_cast(appstate); if (state) { state->audioDownloader.shutdown(); + state->scheduleManager.shutdown(); state->systemNameSync.shutdown(); state->licenseTracker.shutdown(); state->audio.shutdown(); diff --git a/src/schedule_toggle_page.cpp b/src/schedule_toggle_page.cpp new file mode 100644 index 0000000..3987e02 --- /dev/null +++ b/src/schedule_toggle_page.cpp @@ -0,0 +1,152 @@ +#include "schedule_toggle_page.h" +#include "app_state.h" +#include "config/app_config.h" +#include "logical_size.h" +#include + +void ScheduleTogglePage::onPush(AppState& state) +{ + state_ = &state; + TTF_Init(); + createFonts(); + enabled_ = state.config.get().proclamation.schedule_enabled; + statusMessage_ = "Press ENTER to turn the Schedule on or off"; +} + +void ScheduleTogglePage::onResume(AppState& state) +{ + state_ = &state; + createFonts(); + enabled_ = state.config.get().proclamation.schedule_enabled; +} + +void ScheduleTogglePage::onPause(AppState& state) +{ + state.keybinds.unbindAll(); + destroyFonts(); + destroyTextures(); +} + +void ScheduleTogglePage::onPop(AppState& state) +{ + destroyFonts(); + destroyTextures(); + TTF_Quit(); +} + +void ScheduleTogglePage::createFonts() +{ + destroyFonts(); + fontTitle_ = TTF_OpenFont("assets/fonts/Roboto-Regular.ttf", 52); + fontHeader_ = TTF_OpenFont("assets/fonts/Roboto-Regular.ttf", 28); + fontBody_ = TTF_OpenFont("assets/fonts/Roboto-Regular.ttf", 32); + fontSmall_ = TTF_OpenFont("assets/fonts/Roboto-Regular.ttf", 22); +} + +void ScheduleTogglePage::destroyFonts() +{ + if (fontTitle_) { TTF_CloseFont(fontTitle_); fontTitle_ = nullptr; } + if (fontHeader_) { TTF_CloseFont(fontHeader_); fontHeader_ = nullptr; } + if (fontBody_) { TTF_CloseFont(fontBody_); fontBody_ = nullptr; } + if (fontSmall_) { TTF_CloseFont(fontSmall_); fontSmall_ = nullptr; } +} + +void ScheduleTogglePage::destroyTextures() +{ + titleText_.destroy(); + stateText_.destroy(); + statusText_.destroy(); + hintText_.destroy(); +} + +void ScheduleTogglePage::toggle() +{ + if (!state_) return; + const bool next = !enabled_; + const bool ok = state_->config.update([next](radiantwave::config::AppConfig& cfg) { + cfg.proclamation.schedule_enabled = next; + }); + if (ok) { + enabled_ = next; + statusMessage_ = enabled_ ? "Schedule enabled" : "Schedule disabled"; + // Push the new state to the engine and nudge a refresh. While off the + // engine takes no action but keeps its cache updating. + state_->scheduleManager.setEnabled(enabled_); + state_->scheduleManager.requestRefresh(); + } else { + statusMessage_ = "Failed to save"; + } +} + +void ScheduleTogglePage::handleEvent(const SDL_Event& event) +{ + if (!state_) return; + if (event.type == SDL_EVENT_KEY_DOWN) { + SDL_Keycode key = event.key.key; + if (key == SDLK_RETURN || key == SDLK_KP_ENTER) { + toggle(); + } else if (key == SDLK_ESCAPE) { + state_->pages.pop(*state_); + } + } +} + +void ScheduleTogglePage::update(float dt) +{ +} + +void ScheduleTogglePage::render(SDL_Renderer* renderer) +{ + int winW, winH; + radiantwave::getLayoutSize(renderer, &winW, &winH); + float fw = static_cast(winW); + float fh = static_cast(winH); + + SDL_SetRenderDrawBlendMode(renderer, SDL_BLENDMODE_BLEND); + SDL_SetRenderDrawColor(renderer, 20, 20, 30, 255); + SDL_FRect bgRect{0, 0, fw, fh}; + SDL_RenderFillRect(renderer, &bgRect); + + if (!fontTitle_ || !fontHeader_ || !fontBody_ || !fontSmall_) return; + + SDL_Color white = {255, 255, 255, 255}; + SDL_Color green = {110, 220, 130, 255}; + SDL_Color gray = {180, 180, 180, 255}; + + float currentY = 40.0f; + + titleText_.ensure(renderer, fontTitle_, "Schedule", white); + if (titleText_.texture) { + float x = (fw - titleText_.width) / 2.0f; + SDL_FRect dst{x, currentY, titleText_.width, titleText_.height}; + SDL_RenderTexture(renderer, titleText_.texture, nullptr, &dst); + currentY += titleText_.height + 50.0f; + } + + std::string value = enabled_ ? "On" : "Off"; + stateText_.ensure(renderer, fontBody_, "Schedule: " + value, + enabled_ ? green : gray); + if (stateText_.texture) { + float x = (fw - stateText_.width) / 2.0f; + SDL_FRect dst{x, currentY, stateText_.width, stateText_.height}; + SDL_RenderTexture(renderer, stateText_.texture, nullptr, &dst); + currentY += stateText_.height + 40.0f; + } + + if (!statusMessage_.empty()) { + statusText_.ensure(renderer, fontHeader_, statusMessage_, gray); + if (statusText_.texture) { + float x = (fw - statusText_.width) / 2.0f; + SDL_FRect dst{x, currentY, statusText_.width, statusText_.height}; + SDL_RenderTexture(renderer, statusText_.texture, nullptr, &dst); + } + } + + hintText_.ensure(renderer, fontSmall_, + "[ENTER] Toggle [ESC] Back", gray); + if (hintText_.texture) { + float x = (fw - hintText_.width) / 2.0f; + SDL_FRect dst{x, fh - 40.0f, hintText_.width, hintText_.height}; + SDL_RenderTexture(renderer, hintText_.texture, nullptr, &dst); + } +} diff --git a/src/schedule_toggle_page.h b/src/schedule_toggle_page.h new file mode 100644 index 0000000..8cb8d2d --- /dev/null +++ b/src/schedule_toggle_page.h @@ -0,0 +1,40 @@ +#pragma once + +#include "page.h" +#include "text_cache.h" +#include +#include + +// System Settings: local Schedule on/off toggle. Default On. When Off the +// schedule engine takes no action, but the cache keeps updating. Persisted +// locally in config.yaml (proclamation.schedule_enabled). +class ScheduleTogglePage : public Page { +public: + void onPush(AppState& state) override; + void onResume(AppState& state) override; + void onPause(AppState& state) override; + void onPop(AppState& state) override; + void handleEvent(const SDL_Event& event) override; + void update(float dt) override; + void render(SDL_Renderer* renderer) override; + +private: + void createFonts(); + void destroyFonts(); + void destroyTextures(); + void toggle(); + + AppState* state_ = nullptr; + bool enabled_ = true; + std::string statusMessage_; + + TTF_Font* fontTitle_ = nullptr; + TTF_Font* fontHeader_ = nullptr; + TTF_Font* fontBody_ = nullptr; + TTF_Font* fontSmall_ = nullptr; + + CachedText titleText_; + CachedText stateText_; + CachedText statusText_; + CachedText hintText_; +}; diff --git a/src/scramble/#fibonacci_scrambler.h# b/src/scramble/#fibonacci_scrambler.h# new file mode 100644 index 0000000..0077094 --- /dev/null +++ b/src/scramble/#fibonacci_scrambler.h# @@ -0,0 +1,185 @@ +#pragma once + +// --------------------------------------------------------------------------- +// Fibonacci affine image scrambling (RadiantWave scroller text obfuscation) +// +// This module implements the Fibonacci affine transformation as a reversible +// image permutation. The RadiantWave scroller uses it to render proclamation +// text as scrambled glyphs: every code point's glyph is permuted by the map +// below so letterforms are destroyed and the text is never readable on screen, +// while the exact inverse recovers the original glyph given the same key. +// +// --------------------------------------------------------------------------- +// [Published: Williams 2007] -- concepts from the published research +// +// Ben Williams, "Some properties of digital image scrambling methods using +// Fibonacci numbers", MATH491 Summer Research Project 2006-2007, Department +// of Mathematics and Statistics, University of Canterbury, 2007 (mirrored in +// this repository as algorithm.md). +// +// Mathematical foundations: +// - The Fibonacci matrix (Definition 2.2) is +// F = [ 0 1 ] +// [ 1 1 ] +// so consecutive Fibonacci numbers satisfy u(n+1) = F.u(n). It squares to +// the Arnold matrix F^2 = [[1,1],[1,2]] (Remark 3). +// - The Fibonacci affine transformation (Definition 4.1) acts on the square +// grid Z_m^2 = {0..m-1}^2 with modular arithmetic (Section 3): +// T(x) = F.x + b (mod m), b in Z_m^2. +// - An affine map x -> A.x + b is a bijection of Z_m^2 iff gcd(det A, m) = 1 +// (Theorem 3.1 / Corollary 3.2), and it is periodic under the same +// condition (Theorem 3.4). +// - det F = -1, which is coprime to every m. By Proposition 4.1 the +// Fibonacci affine transformation is therefore a permutation of Z_m^2 for +// EVERY modulus m: no constraint whatsoever on the grid size. +// - The paper decrypts by re-applying T until the period recurs. Section 5 +// notes the period of F mod m can reach 6m, so that route is slow; the +// paper never uses the inverse transform. +// +// --------------------------------------------------------------------------- +// [Adapted beyond the paper] -- generalization from algorithm.md's appendix +// (lines 491-500) +// +// Two extensions turn the published square-only transform into an +// arbitrary-size scrambler with a fast, exact round trip: +// +// 1. Arbitrary image sizes. Any W x H image is embedded in a square canvas +// of side +// N = canvasSize(W, H) = max(W, H) +// and T acts on Z_N^2. Because det F = -1 is invertible for every N, no +// Fibonacci-number, power-of-two, or other size constraint applies. (The +// size restriction in the paper was specific to Zou's scaled transform, +// Definition 4.3, not to the plain Fibonacci affine map.) +// +// 2. Exact inverse instead of period iteration. Since +// F^-1 = [ -1 1 ] +// [ 1 0 ] +// exists for every modulus, decryption applies the inverse directly and +// costs exactly as many rounds as encryption -- not up to 6N applications +// of T. This also turns the key into (round count, offset sequence): +// scramble applies the per-round offsets b1..bt in order, and unscramble +// applies the inverse in reverse order. +// +// --------------------------------------------------------------------------- +// [This implementation] -- project-specific engineering decisions +// +// Gather-form permutation. Each round is a single gather pass: +// scramble: dst[y] = src[preimage(y)], preimage(y) = T^-1(y) +// unscramble: dst[y] = src[image(y)], image(y) = T(y) +// Every output pixel is produced from exactly one input pixel, so there are +// never holes or collisions, and the padding convention is filled directly +// where a preimage falls outside the source. (The paper describes the +// forward scatter -- source pixel at x moves to T(x); gather is the +// equivalent output-directed formulation.) +// +// Composition semantics. Scrambling applies the inverse maps in round order +// (T1^-1, T2^-1, ..., Tt^-1); unscrambling applies the forward maps in +// reverse order (Tt, ..., T1). The composition telescopes: +// T1^-1( T2^-1( ... Tt^-1( Tt( ... T2( T1(y) ) ... ) ) ... ) ) = y +// so the round trip is exact in exactly `rounds` passes, regardless of the +// transform's period. Padding is created in round 1 and then permuted like +// any other pixel; because the composition is a bijection, each W x H output +// position of the final reverse pass maps to a canvas position holding a real +// source pixel, so no padding value ever reaches the recovered W x H region. +// (Intermediate reverse passes do read padding pixels -- they move the full +// N x N canvas -- but they are routed out of the W x H output.) +// +// Key derivation. The key is the (seed, rounds) pair. Each round derives its +// offset vector b = (b0, b1) from `seed` via splitmix64 -- two consecutive +// pure-integer draws reduced mod N: +// b0 = splitmix64(seed) mod N, b1 = splitmix64(seed) mod N. +// Pure integer arithmetic makes the offsets bit-identical on every platform, +// so a compile-time seed produces identical scrambling everywhere. The +// scroller fixes the seed at compile time (kDefaultSeed), meaning the same +// glyph scrambles identically on every device and every run, and no key +// state is ever persisted or transmitted. +// +// Padding convention. During scramble the W x H source sits in the natural +// top-left embedding of the N x N canvas. Canvas positions whose preimage +// falls outside [0, W) x [0, H) are written as transparent black +// (0, 0, 0, 0), which is invisible on a black background. +// Because the map is a bijection, the W x H source pixels land on W x H +// distinct canvas positions, and the remaining N^2 - W*H positions are +// padding. unscramble() reads the whole N x N canvas and writes only the +// W x H region, reproducing the source exactly. Display uses the matching +// crop-window convention: render the top-left W x H sub-rectangle of the +// scrambled canvas. +// +// Whole pixels. Each RGBA8888 pixel, alpha included, is permuted as a unit, +// so a round trip preserves alpha exactly. +// +// In-place hazard / scratch. A gather pass is not safe in place: within one +// pass, dst[y] = src[p(y)] reads source position p(y) after an earlier write +// at that same position may have replaced it (whenever p(y) < y in iteration +// order). Every pass therefore reads and writes distinct buffers. Two +// ping-pong scratch buffers of N*N*4 bytes are reused across rounds, so read +// and write never alias while memory stays bounded. +// +// Numerics. All coordinate products are accumulated in int64_t and reduced +// mod N, which is exact for any N whose squares fit comfortably in int64_t +// (N <= 2^30). Rounds below 1 are clamped to 1; N == 1 (a 1x1 image) yields +// the identity map, which is correct. In practice N is the side of a glyph +// tile, at most a few hundred pixels. +// +// Format & portability. The module is SDL-free and pure C++17: it operates +// on tightly packed RGBA8888 pixels (4 bytes per pixel, row stride = width * +// 4) and keeps no state between calls. Any caller can adopt it; the scroller +// uses it per glyph (see scroller_page.cpp). +// +// Why CPU. Glyph tiles are tiny and every distinct code point's tile is +// scrambled at most once per font size and then cached, so the total cost is +// a one-time, amortized O(N^2 * rounds) integer permute per character. A GPU +// path would need per-texture compute passes or shader support that buys +// nothing here. +// --------------------------------------------------------------------------- + +#include + +namespace radiantwave { +namespace scramble { + +// Fixed compile-time seed so every device renders the same glyph scrambling. +// The value is splitmix64's golden-ratio constant; any constant would do, the +// requirement is only that it never changes across runs and platforms. +inline constexpr uint64_t kDefaultSeed = 0x9E3779B97F4A7C15ULL; + +// Default round count. Each round applies the affine map once; more rounds mix +// pixels across a larger fraction of the canvas. Values below 1 are clamped to +// 1 by the API. +inline constexpr int kDefaultRounds = 4; + +// Per-call configuration: how many rounds and which key (seed). +struct ScrambleConfig +{ + int rounds = kDefaultRounds; + uint64_t seed = kDefaultSeed; +}; + +// Square canvas side N for a W x H image: N = max(W, H). +// Returns 0 when width or height is < 1 (the scramble/unscramble entry points +// reject N == 0). +int canvasSize(int width, int height); + +// Scramble a W x H RGBA8888 image (tightly packed, stride = width * 4) into +// an N x N RGBA8888 canvas where N = canvasSize(width, height). `dst` must +// hold at least N*N*4 bytes. Out-of-canvas source coordinates are written as +// transparent black per the padding convention above. `src` and `dst` must not +// alias. +// +// Returns false (writing nothing to `dst`) when width/height < 1, when either +// pointer is null, or when N exceeds the int64-safe bound (2^30). +bool scramble(const uint8_t* src, uint8_t* dst, int width, int height, + const ScrambleConfig& config); + +// Exact inverse of scramble(): reads the N x N RGBA8888 canvas (stride = +// N * 4) and writes back the top-left W x H RGBA8888 region (stride = width * +// 4). `dst` must hold at least W*H*4 bytes. Must be called with the same +// (width, height, config) as the matching scramble() -- the key is the same +// tuple. `src` and `dst` must not alias. +// +// Returns false under the same conditions as scramble(). +bool unscramble(const uint8_t* src, uint8_t* dst, int width, int height, + const ScrambleConfig& config); + +} // namespace scramble +} // namespace radiantwave diff --git a/src/scroller_page.cpp b/src/scroller_page.cpp index a40c52a..17bd1c6 100644 --- a/src/scroller_page.cpp +++ b/src/scroller_page.cpp @@ -214,15 +214,32 @@ void ScrollerPage::buildStreams(AppState &state) { streamIndex_ = 0; scheduleChangePending_ = false; - // Schedule state: only an active schedule with a currently-matching block - // changes the play list. The block boundary is handled in advanceStream(), - // so rebuilding mid-playback never truncates the current proclamation. + // Schedule state: resolve the active Block from the schedule manager (which + // owns the fetched/cached payload and the local on/off toggle). The block + // boundary is handled in advanceStream(), so rebuilding mid-playback never + // truncates the current proclamation. const radiantwave::config::AppConfig cfg = state.config.get(); - scheduleActive_ = cfg.proclamation.schedule_active; timezone_ = cfg.timezone; - scheduleBlocks_ = scheduleActive_ ? state.db.getScheduleBlocks() - : std::vector{}; - activeBlockIndex_ = currentScheduleBlockIndex(); + // Keep the engine's local toggle in step with config (it may have been + // changed in System Settings or hot-reloaded). + state.scheduleManager.setEnabled(cfg.proclamation.schedule_enabled); + + const int now = radiantwave::timezone::currentLocalMinutes(timezone_); + const radiantwave::schedule::Weekday weekday = + radiantwave::schedule::weekdayFromIndex( + radiantwave::timezone::currentLocalWeekday(timezone_)); + const radiantwave::schedule::Decision decision = + state.scheduleManager.activeBlock(now, weekday); + scheduleActive_ = state.scheduleManager.enabled(); + if (decision.hasBlock) { + activeBlockProclamationIds_ = decision.block.proclamation_ids; + activeBlockDynamicAudio_ = decision.block.dynamic_audio; + activeBlockIndex_ = 1; // any non-negative marker: a block is active + } else { + activeBlockProclamationIds_.clear(); + activeBlockDynamicAudio_ = false; + activeBlockIndex_ = -1; + } auto proclamations = state.db.getActiveProclamations(); if (proclamations.empty()) { @@ -264,9 +281,8 @@ void ScrollerPage::buildStreams(AppState &state) { const std::vector* scheduledIds = nullptr; std::vector scheduledOrder; - if (activeBlockIndex_ >= 0 && - activeBlockIndex_ < static_cast(scheduleBlocks_.size())) { - scheduledOrder = scheduleBlocks_[activeBlockIndex_].proclamation_ids; + if (activeBlockIndex_ >= 0 && !activeBlockProclamationIds_.empty()) { + scheduledOrder = activeBlockProclamationIds_; scheduledIds = &scheduledOrder; } @@ -275,26 +291,25 @@ void ScrollerPage::buildStreams(AppState &state) { DEFAULT_PROPLEAMATION_ID, scheduledIds); } -int ScrollerPage::currentScheduleBlockIndex() const { - if (!scheduleActive_ || scheduleBlocks_.empty()) { - return -1; +bool ScrollerPage::scheduleBlockActiveNow() const { + if (!state_) { + return false; } const int now = radiantwave::timezone::currentLocalMinutes(timezone_); - for (size_t i = 0; i < scheduleBlocks_.size(); ++i) { - const radiantwave::db::ScheduleBlockRecord &block = scheduleBlocks_[i]; - if (radiantwave::scroller::minuteInScheduleBlock( - now, block.start_minutes, block.end_minutes, block.overnight)) { - return static_cast(i); - } - } - return -1; + const radiantwave::schedule::Weekday weekday = + radiantwave::schedule::weekdayFromIndex( + radiantwave::timezone::currentLocalWeekday(timezone_)); + return state_->scheduleManager.activeBlock(now, weekday).hasBlock; } void ScrollerPage::pollScheduleChange() { - if (!scheduleActive_) { + // The manager owns the local toggle; when off, there is never an active block. + if (!state_) { return; } - if (currentScheduleBlockIndex() != activeBlockIndex_) { + const bool activeNow = scheduleBlockActiveNow(); + const bool hadBlock = activeBlockIndex_ >= 0; + if (activeNow != hadBlock) { scheduleChangePending_ = true; } } @@ -303,9 +318,7 @@ bool ScrollerPage::effectiveDynamicAudio() const { // dynamicAudio=true on the active block forces dynamic audio, overriding // whatever the device selected. false means "don't override": the device's // own selection (dynamic or a fixed track) applies as usual. - if (scheduleActive_ && activeBlockIndex_ >= 0 && - activeBlockIndex_ < static_cast(scheduleBlocks_.size()) && - scheduleBlocks_[activeBlockIndex_].dynamic_audio) { + if (scheduleActive_ && activeBlockIndex_ >= 0 && activeBlockDynamicAudio_) { return true; } return state_ ? state_->config.get().audio.dynamic_audio : false; diff --git a/src/scroller_page.h b/src/scroller_page.h index 5c68dec..6a9db10 100644 --- a/src/scroller_page.h +++ b/src/scroller_page.h @@ -83,9 +83,8 @@ private: bool addLineAt(float baseY); void advanceStream(); void switchAudioForCurrentStream(); - // Re-reads the schedule from the DB/config and records which block (if any) - // matches the clock right now. Returns the matching index, or -1. - int currentScheduleBlockIndex() const; + // Re-reads the schedule and records whether a block is active right now. + bool scheduleBlockActiveNow() const; // If the active block changed, latch a pending switch that is applied at the // next proclamation boundary so the current proclamation is never cut off. void pollScheduleChange(); @@ -108,12 +107,14 @@ private: int streamIndex_ = 0; bool prefilling_ = false; - // Schedule playback. `scheduleActive_` mirrors config; `scheduleBlocks_` is - // the cached schedule; `activeBlockIndex_` is the block currently playing - // (-1 = normal mode / no matching block). A change is latched in - // `scheduleChangePending_` and applied when the current proclamation ends. + // Schedule playback. `scheduleActive_` mirrors config; the active Block is + // resolved from ScheduleManager each poll; `activeBlockIndex_` is the block + // currently playing (-1 = normal mode / no matching block). A change is + // latched in `scheduleChangePending_` and applied when the current + // proclamation ends. std::string timezone_; - std::vector scheduleBlocks_; + std::vector activeBlockProclamationIds_; + bool activeBlockDynamicAudio_ = false; bool scheduleActive_ = false; int activeBlockIndex_ = -1; bool scheduleChangePending_ = false; diff --git a/src/settings_menu.cpp b/src/settings_menu.cpp index a645bf0..6274909 100644 --- a/src/settings_menu.cpp +++ b/src/settings_menu.cpp @@ -7,6 +7,7 @@ std::vector systemSettingEntries() { {"Email Address", SystemSettingId::EmailAddress}, {"License Key", SystemSettingId::LicenseKey}, {"System Name", SystemSettingId::SystemName}, + {"Schedule", SystemSettingId::Schedule}, {"WiFi Setup", SystemSettingId::WiFiSetup}, {"Time Zone Setup", SystemSettingId::Timezone}, {"Date Format", SystemSettingId::DateFormat}, diff --git a/src/settings_menu.h b/src/settings_menu.h index 7165f13..5571848 100644 --- a/src/settings_menu.h +++ b/src/settings_menu.h @@ -10,6 +10,7 @@ enum class SystemSettingId { EmailAddress, LicenseKey, SystemName, + Schedule, WiFiSetup, Timezone, DateFormat, diff --git a/src/system_settings_page.cpp b/src/system_settings_page.cpp index 04fd05d..b039af2 100644 --- a/src/system_settings_page.cpp +++ b/src/system_settings_page.cpp @@ -7,6 +7,7 @@ #include "timezone_page.h" #include "wifi_setup_page.h" #include "system_name_page.h" +#include "schedule_toggle_page.h" #include #include #include @@ -80,6 +81,9 @@ void SystemSettingsPage::activateSelected(AppState &state) case radiantwave::settings::SystemSettingId::SystemName: state.pages.push(state, std::make_unique()); break; + case radiantwave::settings::SystemSettingId::Schedule: + state.pages.push(state, std::make_unique()); + break; case radiantwave::settings::SystemSettingId::WiFiSetup: state.pages.push(state, std::make_unique()); break; diff --git a/tests/test_settings_menu.cpp b/tests/test_settings_menu.cpp index 0846b20..55fadb8 100644 --- a/tests/test_settings_menu.cpp +++ b/tests/test_settings_menu.cpp @@ -7,19 +7,21 @@ namespace settings = radiantwave::settings; TEST(SystemSettingsMenuTest, ListsTheSystemSettingsInOrder) { const auto entries = settings::systemSettingEntries(); - ASSERT_EQ(entries.size(), 6u); + ASSERT_EQ(entries.size(), 7u); EXPECT_EQ(entries[0].label, "Email Address"); EXPECT_EQ(entries[0].id, settings::SystemSettingId::EmailAddress); EXPECT_EQ(entries[1].label, "License Key"); EXPECT_EQ(entries[1].id, settings::SystemSettingId::LicenseKey); EXPECT_EQ(entries[2].label, "System Name"); EXPECT_EQ(entries[2].id, settings::SystemSettingId::SystemName); - EXPECT_EQ(entries[3].label, "WiFi Setup"); - EXPECT_EQ(entries[3].id, settings::SystemSettingId::WiFiSetup); - EXPECT_EQ(entries[4].label, "Time Zone Setup"); - EXPECT_EQ(entries[4].id, settings::SystemSettingId::Timezone); - EXPECT_EQ(entries[5].label, "Date Format"); - EXPECT_EQ(entries[5].id, settings::SystemSettingId::DateFormat); + EXPECT_EQ(entries[3].label, "Schedule"); + EXPECT_EQ(entries[3].id, settings::SystemSettingId::Schedule); + EXPECT_EQ(entries[4].label, "WiFi Setup"); + EXPECT_EQ(entries[4].id, settings::SystemSettingId::WiFiSetup); + EXPECT_EQ(entries[5].label, "Time Zone Setup"); + EXPECT_EQ(entries[5].id, settings::SystemSettingId::Timezone); + EXPECT_EQ(entries[6].label, "Date Format"); + EXPECT_EQ(entries[6].id, settings::SystemSettingId::DateFormat); } TEST(SystemSettingsMenuTest, LabelsAreNonEmptyAndUnique) { @@ -41,6 +43,7 @@ TEST(SystemSettingsMenuTest, EveryEntryMapsToAKnownPage) { case settings::SystemSettingId::EmailAddress: case settings::SystemSettingId::LicenseKey: case settings::SystemSettingId::SystemName: + case settings::SystemSettingId::Schedule: case settings::SystemSettingId::WiFiSetup: case settings::SystemSettingId::Timezone: case settings::SystemSettingId::DateFormat: -- 2.43.0 From fc4ac4b10792825799f5830ab5e829a546ab56b3 Mon Sep 17 00:00:00 2001 From: NeilClack Date: Tue, 29 Sep 2026 10:35:44 -0500 Subject: [PATCH 09/17] docs: document Phase 3 schedule endpoint, model, and resolved open items --- docs/schedule-api-contract.md | 63 +++++++++++------- openapi.yaml | 121 +++++++++++++++++++++++++++++++--- 2 files changed, 153 insertions(+), 31 deletions(-) diff --git a/docs/schedule-api-contract.md b/docs/schedule-api-contract.md index 9e0dda6..91ec87b 100644 --- a/docs/schedule-api-contract.md +++ b/docs/schedule-api-contract.md @@ -495,45 +495,55 @@ later phase. Phase 1 (Kiosk) and Phase 2 (System Name) are assumed here. 2. **`blocks` for weekly.** Chosen as an empty array (server cannot know the Kiosk timezone). Consequence: old Kiosks see weekly schedules as "no - schedule". -3. **Block cap.** The existing `MAX_BLOCKS = 4` per day list is retained; the - brief does not state a cap. Whether weekly allows 4/day or 4 total is - unresolved. + schedule". Implemented in Phase 3. +3. **Block cap.** The existing `MAX_BLOCKS = 4` per day list is retained. + Phase 3 applies it **per day list** (each of the seven weekly days gets its + own 4-block budget). Whether the cap should be lower is unresolved. 4. **Field naming.** The brief's `start`/`end` were mapped to the existing `startTime`/`endTime`; `startMinutes`/`endMinutes` retained for the Kiosk. -5. **`version` derivation.** Algorithm (content hash vs max `updatedAt` + - shape) deferred to implementation; it must be stable for equal content and - change on mutation. +5. **`version` derivation.** Resolved in Phase 3: a SHA-1 content hash of the + serialized `{type, daily, weekly}` shape. Stable for equal content, changes + on any mutation. 6. **Unauthenticated key-only access.** The new endpoints authenticate by License Key alone (per glossary). Anyone who knows a key could read the schedule or set the name. Mitigated only by the 10/min/IP rate limit. Known limitation. -7. **`dynamicAudio` default flip.** DB default changes `false` → `true` for - **new** Blocks; existing Blocks are left at their stored values (no - backfill assumed). -8. **Per-user → per-License-Key migration.** Accounts with multiple License - Keys: which key inherits the existing per-user Schedule is unresolved; - conservative default is the account's first/oldest key. -9. **Cross-day spillover validation.** Weekly `sun` spillover into `mon` (and - daily same-list spillover) must be validated; exact error messaging is - deferred. +7. **`dynamicAudio` default flip.** DB default changed `false` → `true` for + **new** Blocks in Phase 3; existing Blocks keep their stored values. +8. **Per-user → per-License-Key migration.** Resolved in Phase 3: each existing + per-user Schedule is mapped to that account's **oldest** License Key + (`createdAt ASC`), as a daily schedule. Schedules with no owning + user/license are retained with `licenseKeyId NULL` and reported via a + migration `NOTICE` (not dropped). +9. **Cross-day spillover validation.** Implemented in Phase 3: weekly + `sun`→`mon` and daily self-wrap spillover are rejected as overlaps; the + shared `validateSchedule` is authoritative and mirrored client-side. 10. **Account scope for name uniqueness.** "Per account" = the owning User. - Legacy License Keys with only an `email` and no `userId` are an edge case. -11. **Geoip timezone display.** Informational only, not implemented in the - Portal today. + Legacy License Keys with only an `email` and no `userId` are an edge case + (written without a per-account uniqueness check). +11. **Geoip timezone display.** Phase 3 shows an informational note that times + run in the **Kiosk's** timezone and uses the **browser-resolved** timezone + as a fallback; no geoip dependency (paid or external) was added. The + Portal still stores no timezone. 12. **ETag on the legacy endpoint.** The legacy `GET /api/v2/schedule` emits no ETag and never returns 304, to preserve old behaviour. 13. **`systemNameStatus` on v1.** Not added; v1 is unchanged. 14. **Trim/case representation.** Names are trimmed; uniqueness is case-insensitive; the stored casing is preserved as provided. -15. **Sync cadence.** The brief's boot/network-up/5-minute defaults are - targets; the current Kiosk has no such timer. -16. **Atomic cache file.** Path and format deferred; SQLite is the current - durable store. +15. **Sync cadence.** Resolved in Phase 3: the Kiosk loads its on-disk cache on + boot, then pulls on boot, on a 5-minute timer, and on demand. +16. **Atomic cache file.** Resolved in Phase 3: + `~/.local/radiantwave/schedule_cache.json`, written atomically (temp file + + rename); a corrupt cache is ignored. 17. **`type` encoding for no schedule.** `type: null`; `daily`/`weekly` absent or empty. 18. **Legacy alias.** `key` remains accepted as a `licenseKey` alias on the legacy schedule endpoint. +19. **Kiosk local toggle.** `proclamation.schedule_enabled` in `config.yaml`, + default **on**, surfaced as System Settings → Schedule. Not synced. When + off, the engine plays local selections but the cache keeps updating. The + older `proclamation.schedule_active` flag is retained in config but is not + consulted by the Phase 3 engine. --- @@ -544,3 +554,10 @@ later phase. (`/api/v2/licenses/...`, additive license-validation fields), sync behavior, System Name rules, dynamic audio, backward compatibility, touchpoints, open items, and fixtures. +- 2026-09-29 — Phase 3. Schedule is now per License Key with daily/weekly and + `Block.day`; the shared `validateSchedule` is authoritative; the key-only + `GET /api/v2/licenses/{licenseKey}/schedule` (ETag/304) ships and the legacy + endpoint is extended additively; the Portal editor is per-system with a + browser-fallback timezone note; the Kiosk adds the conditional client, + atomic cache, schedule engine, and a System Settings On/Off toggle. Open + items 2, 5, 7, 8, 9, 11, 15, and 16 are updated to reflect the changes. diff --git a/openapi.yaml b/openapi.yaml index a7a7a2a..1d70c1a 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -245,13 +245,16 @@ paths: pairing. Returns every Block with its start/end times and the ordered list of - selected Proclamation IDs. Overnight blocks (an end clock time before - the start) are supported and flagged with `overnight: true` and a - wrapping `durationMinutes`. Each block also carries `dynamicAudio`; - when true, the kiosk enables dynamic audio for the block's duration, - overriding any audio selections made on the device. Proclamation - content is not included; the kiosk fetches it separately via - `/api/v2/proclamations`. + selected Proclamation IDs (the legacy daily flattening in `blocks`), and + is extended additively with `version`, `type`, `daily`, and `weekly`. + Overnight blocks (an end clock time before the start) are supported and + flagged with `overnight: true` and a wrapping `durationMinutes`. Each + block also carries `dynamicAudio`; when true, the kiosk enables dynamic + audio for the block's duration, overriding any audio selections made on + the device. Proclamation content is not included; the kiosk fetches it + separately via `/api/v2/proclamations`. For `type: "weekly"`, `blocks` is + an empty array (the server cannot flatten weekly in the kiosk's + timezone). This endpoint emits no ETag and never returns 304. operationId: getSchedule parameters: - in: query @@ -309,6 +312,66 @@ paths: application/json: schema: $ref: '#/components/schemas/ErrorMessage' + /api/v2/licenses/{licenseKey}/schedule: + get: + tags: [Schedule] + summary: Get a License Key's schedule (Kiosk-facing, conditional) + description: | + Kiosk-facing, License-Key-only schedule read. Returns the target payload + (`version`, `type`, `daily`, `weekly`, `blocks`). `version` is a + content-derived opaque string and is also the `ETag`; a matching + `If-None-Match` returns `304`. An unknown License Key is `404`; a key + with no Schedule returns `type: null` with empty lists. No + authentication (see the API description) beyond the 10/min/IP limit. + operationId: getLicenseSchedule + parameters: + - in: path + name: licenseKey + required: true + schema: + type: string + pattern: '^[A-Z0-9]{4}-[A-Z0-9]{4}-[A-Z0-9]{4}-[A-Z0-9]{4}$' + - in: header + name: If-None-Match + required: false + schema: + type: string + description: Prior ETag; when it matches, returns 304. + responses: + '200': + description: The target schedule payload. + headers: + ETag: + schema: + type: string + description: The quoted `version`. + content: + application/json: + schema: + $ref: '#/components/schemas/SchedulePayload' + '304': + description: Not modified; the caller keeps its cached payload. + headers: + ETag: + schema: + type: string + '404': + description: 'Unknown license (`unknown_license`).' + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorMessage' + '429': + description: Rate limit exceeded (10 requests per minute per IP). + headers: + Retry-After: + schema: + type: integer + description: Seconds to wait before retrying. + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorMessage' /api/v2/licenses/{licenseKey}/system-name: put: tags: [License] @@ -661,9 +724,51 @@ components: email: type: string format: email + version: + type: string + type: + type: string + nullable: true + enum: [daily, weekly, null] + daily: + type: array + items: + $ref: '#/components/schemas/ScheduleBlock' + weekly: + type: object + additionalProperties: + type: array + items: + $ref: '#/components/schemas/ScheduleBlock' blocks: type: array - maxItems: 4 + items: + $ref: '#/components/schemas/ScheduleBlock' + SchedulePayload: + type: object + required: [version, type, daily, weekly, blocks] + properties: + version: + type: string + description: Opaque content hash; doubles as the ETag. + type: + type: string + nullable: true + enum: [daily, weekly, null] + daily: + type: array + items: + $ref: '#/components/schemas/ScheduleBlock' + weekly: + type: object + description: Present with all seven keys when `type` is weekly. + additionalProperties: + type: array + items: + $ref: '#/components/schemas/ScheduleBlock' + blocks: + type: array + description: Legacy daily flattening; empty for weekly. items: $ref: '#/components/schemas/ScheduleBlock' OnboardingRequest: -- 2.43.0 From ff5346fd0cca5b171463b7e7c87cc077df19dc01 Mon Sep 17 00:00:00 2001 From: NeilClack Date: Tue, 29 Sep 2026 10:50:18 -0500 Subject: [PATCH 10/17] docs: finalize Phase 4 schedule copy in the contract --- docs/schedule-api-contract.md | 42 ++++++++++++++++++++++++++++------- 1 file changed, 34 insertions(+), 8 deletions(-) diff --git a/docs/schedule-api-contract.md b/docs/schedule-api-contract.md index 91ec87b..a1b8976 100644 --- a/docs/schedule-api-contract.md +++ b/docs/schedule-api-contract.md @@ -339,16 +339,25 @@ populate `email` and `blocks` exactly as before. Old Kiosks ignore the new fields; new Kiosks may read either endpoint. This endpoint does **not** emit an ETag and does **not** return 304. -### (e) Portal-internal endpoints (names & purpose only) +### (e) Portal-internal endpoints (not Kiosk-facing) -Finalized in Phases 3 and 4. They are **not** Kiosk-facing. +These are session-authenticated Portal actions; they are **not** part of the +Kiosk API and are not served by `/api/v2/...`. -- `POST /api/v2/...` / server actions for Schedule CRUD (create/update/delete - Block, set `type`, assign Proclamations) — Phase 3. -- `POST .../copy` to copy a Schedule between License Keys on the same account — - Phase 4. - -Exact paths and payloads are deferred; they will be recorded here when added. +- Schedule CRUD — implemented in Phase 3 as SvelteKit form actions on + `(protected)/(user)/schedule` (`?/setType`, `?/createBlock`, `?/updateBlock`, + `?/deleteBlock`), backed by `$lib/server/schedule.ts`. +- **Copy** — implemented in Phase 4 as the `?/copy` action plus + `copySchedule()` in `$lib/server/schedule.ts`. Copies one License Key's + Schedule to one or more other License Keys **by value, in a single database + transaction** (all targets succeed or none). The target `type` becomes the + source's; Block rows get fresh IDs; each block's `dynamicAudio` and ordered + Proclamation references are preserved. The source and every target must + belong to the caller's account; a target equal to the source, an empty + target list, or a source without a schedule are rejected. The resulting + schedule is run through the shared validator before commit. Because Block IDs + are part of the serialized payload, every target's content-derived + `version`/ETag changes, so its Kiosk refetches. --- @@ -544,6 +553,17 @@ later phase. off, the engine plays local selections but the cache keeps updating. The older `proclamation.schedule_active` flag is retained in config but is not consulted by the Phase 3 engine. +20. **Copy semantics.** Copies are **independent, by value** snapshots: no link + is kept to the source, and editing the source later does not affect targets + (and vice versa). A copy replaces any existing target Schedule in full + (type and blocks) and requires explicit confirmation in the UI when a target + already has one. No "copy to all" shortcut and no scheduled/linked copies + (per the Phase 4 brief). The copied schedule is validated before commit; a + source schedule that fails validation is rejected rather than copied. +21. **Copy and Kiosk code.** Verified against the Phase 3 Kiosk engine/fixture + tests that a copied payload (new Block IDs, same type/times/audio/refs) is + parsed and evaluated identically to any other; **no Kiosk changes were + required**. --- @@ -561,3 +581,9 @@ later phase. browser-fallback timezone note; the Kiosk adds the conditional client, atomic cache, schedule engine, and a System Settings On/Off toggle. Open items 2, 5, 7, 8, 9, 11, 15, and 16 are updated to reflect the changes. +- 2026-09-29 — Phase 4. Schedule copy is implemented as a Portal-internal + action (`?/copy`) backed by a single-transaction service that copies by + value with fresh Block IDs (so target ETags change), preserving `dynamicAudio` + and Proclamation order, scoped to the caller's account, and validated before + commit. Endpoint (e) is finalized; open items 20 and 21 record the copy + semantics and the Kiosk no-change verification. -- 2.43.0 From 4535ba223ba781bb0a6680bfbf0fa36b080c1398 Mon Sep 17 00:00:00 2001 From: NeilClack Date: Tue, 29 Sep 2026 12:46:05 -0500 Subject: [PATCH 11/17] test: verify Kiosk consumes real Portal-generated schedule payloads 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. --- docs/fixtures/schedule-portal-daily.json | 1 + docs/fixtures/schedule-portal-weekly.json | 1 + tests/test_schedule_engine.cpp | 50 +++++++++++++++++++++++ 3 files changed, 52 insertions(+) create mode 100644 docs/fixtures/schedule-portal-daily.json create mode 100644 docs/fixtures/schedule-portal-weekly.json diff --git a/docs/fixtures/schedule-portal-daily.json b/docs/fixtures/schedule-portal-daily.json new file mode 100644 index 0000000..01b85f5 --- /dev/null +++ b/docs/fixtures/schedule-portal-daily.json @@ -0,0 +1 @@ +{"version":"7f53efde17d291a1ecf604e266d3803d1495b986","type":"daily","daily":[{"id":"6a9be0a4-4a1c-4a2c-929d-7b6c6fe56d91","startTime":"06:00","startMinutes":360,"endTime":"07:00","endMinutes":420,"overnight":false,"durationMinutes":60,"dynamicAudio":true,"proclamationIds":["310138f3-adfc-45ce-ae52-07279cd25b48"]},{"id":"65fecafc-abc8-4796-bb12-ed8e9d99e0fe","startTime":"18:00","startMinutes":1080,"endTime":"20:30","endMinutes":1230,"overnight":false,"durationMinutes":150,"dynamicAudio":false,"proclamationIds":["3805242e-4930-4dd2-afb1-130593b1dbab"]}],"weekly":{},"blocks":[{"id":"6a9be0a4-4a1c-4a2c-929d-7b6c6fe56d91","startTime":"06:00","startMinutes":360,"endTime":"07:00","endMinutes":420,"overnight":false,"durationMinutes":60,"dynamicAudio":true,"proclamationIds":["310138f3-adfc-45ce-ae52-07279cd25b48"]},{"id":"65fecafc-abc8-4796-bb12-ed8e9d99e0fe","startTime":"18:00","startMinutes":1080,"endTime":"20:30","endMinutes":1230,"overnight":false,"durationMinutes":150,"dynamicAudio":false,"proclamationIds":["3805242e-4930-4dd2-afb1-130593b1dbab"]}]} \ No newline at end of file diff --git a/docs/fixtures/schedule-portal-weekly.json b/docs/fixtures/schedule-portal-weekly.json new file mode 100644 index 0000000..983bb1d --- /dev/null +++ b/docs/fixtures/schedule-portal-weekly.json @@ -0,0 +1 @@ +{"version":"6de852ba72ce1ab3052150640730ee87a7d460b5","type":"weekly","daily":[],"weekly":{"mon":[{"id":"a1054b78-480a-4e87-8465-aac8d677c666","startTime":"06:00","startMinutes":360,"endTime":"07:00","endMinutes":420,"overnight":false,"durationMinutes":60,"dynamicAudio":true,"proclamationIds":["310138f3-adfc-45ce-ae52-07279cd25b48"]},{"id":"25979ec9-f7da-4aa4-864f-f74b1a2da78b","startTime":"07:00","startMinutes":420,"endTime":"08:30","endMinutes":510,"overnight":false,"durationMinutes":90,"dynamicAudio":true,"proclamationIds":["3805242e-4930-4dd2-afb1-130593b1dbab"]}],"tue":[],"wed":[],"thu":[],"fri":[],"sat":[],"sun":[{"id":"e2237d98-834c-433b-8979-b56ddad2ef7a","startTime":"23:00","startMinutes":1380,"endTime":"01:00","endMinutes":60,"overnight":true,"durationMinutes":120,"dynamicAudio":true,"proclamationIds":["fcd5a291-3d6e-4678-9d85-9b550e80f1c7"]}]},"blocks":[]} \ No newline at end of file diff --git a/tests/test_schedule_engine.cpp b/tests/test_schedule_engine.cpp index 571222b..a7c11d7 100644 --- a/tests/test_schedule_engine.cpp +++ b/tests/test_schedule_engine.cpp @@ -296,3 +296,53 @@ TEST(ScheduleManagerTest, OfflineFallsBackToCache) { EXPECT_EQ(etag, "\"cached\""); radiantwave::sync::schedule_cache::clear(); } + +// ─── Phase 5: consume real Portal-generated payloads ─────────────────── +// +// These fixtures were captured from a running Portal (GET +// /api/v2/licenses/{key}/schedule). They prove the Kiosk parses and evaluates +// exactly what the Portal emits, with no hand-editing in between. + +TEST(SchedulePortalParityTest, ParsesPortalDailyPayload) { + const auto p = loadPayload("schedule-portal-daily.json"); + EXPECT_EQ(p.type, "daily"); + ASSERT_TRUE(p.hasSchedule()); + ASSERT_EQ(p.daily.size(), 2u); + // 06:00-07:00 (dynamicAudio true) and 18:00-20:30 (false). + EXPECT_EQ(p.daily[0].start_minutes, 360); + EXPECT_EQ(p.daily[0].end_minutes, 420); + EXPECT_TRUE(p.daily[0].dynamic_audio); + EXPECT_FALSE(p.daily[1].dynamic_audio); + EXPECT_FALSE(p.daily[0].proclamation_ids.empty()); + EXPECT_EQ(p.legacy_blocks.size(), 2u); + + // Engine decisions: inside / gap / inside. + EXPECT_TRUE(evaluate(p, 6 * 60 + 30, Weekday::Tue).hasBlock); + EXPECT_FALSE(evaluate(p, 12 * 60, Weekday::Tue).hasBlock); + EXPECT_TRUE(evaluate(p, 19 * 60, Weekday::Tue).hasBlock); +} + +TEST(SchedulePortalParityTest, ParsesPortalWeeklyPayloadAndSpillover) { + const auto p = loadPayload("schedule-portal-weekly.json"); + EXPECT_EQ(p.type, "weekly"); + ASSERT_TRUE(p.hasSchedule()); + // Monday has two adjacent blocks; Sunday has one crossing block. + int mon = 0; + int sun = 0; + for (const auto& b : p.weekly) { + if (b.day == "mon") ++mon; + if (b.day == "sun") ++sun; + } + EXPECT_EQ(mon, 2); + EXPECT_EQ(sun, 1); + + // Monday 00:30 belongs to Sunday's crossing block. + const auto spill = evaluate(p, 30, Weekday::Mon); + ASSERT_TRUE(spill.hasBlock); + EXPECT_EQ(spill.block.day, "sun"); + + // Monday 06:30 is a Monday block. + const auto monBlock = evaluate(p, 6 * 60 + 30, Weekday::Mon); + ASSERT_TRUE(monBlock.hasBlock); + EXPECT_EQ(monBlock.block.day, "mon"); +} -- 2.43.0 From c65884f9298127ea4c0b47c55443fb1e64152d76 Mon Sep 17 00:00:00 2001 From: NeilClack Date: Tue, 29 Sep 2026 12:47:33 -0500 Subject: [PATCH 12/17] docs: Phase 5 finalize contract, add release notes --- docs/schedule-api-contract.md | 34 ++++- docs/scheduler-overhaul-release-notes.md | 164 +++++++++++++++++++++++ 2 files changed, 196 insertions(+), 2 deletions(-) create mode 100644 docs/scheduler-overhaul-release-notes.md diff --git a/docs/schedule-api-contract.md b/docs/schedule-api-contract.md index a1b8976..258dce0 100644 --- a/docs/schedule-api-contract.md +++ b/docs/schedule-api-contract.md @@ -497,8 +497,9 @@ and is recorded under [Open items](#open-items). ## Open items -Recorded ambiguities and conservative choices. Each is subject to change in a -later phase. +Recorded ambiguities, conservative choices, and remaining known limitations. +Items 1–21 were resolved or restated during Phases 1–4; items 22–24 are Phase 5 +findings. Everything marked "Resolved" is settled for this overhaul. 1. **Phase numbering.** The brief names Phases 3 (CRUD) and 4 (copy) only; Phase 1 (Kiosk) and Phase 2 (System Name) are assumed here. @@ -564,6 +565,27 @@ later phase. tests that a copied payload (new Block IDs, same type/times/audio/refs) is parsed and evaluated identically to any other; **no Kiosk changes were required**. +22. **Phase 3 migration down is lossy for new-model data.** The `up` migration + (`20260929130000_move_schedule_to_license_key`) is additive and safe. Its + `down` path is **reversible in structure but lossy in data**: the down SQL + drops `Schedule.licenseKeyId`/`type` and `Block.day`, then deletes + `Schedule` rows whose `userId IS NULL`. Any schedule created under the + per-License-Key model (which sets `licenseKeyId` and leaves `userId` NULL) + is therefore **deleted** by a down-migration. Consequences: + - Back up `Schedule`/`Block`/`BlockProclamations` before any down-migration. + - Rolling back past Phase 3 requires restoring that data from backup; it is + **not** a data-preserving rollback. + - Rollback guidance is in + [scheduler-overhaul-release-notes.md](scheduler-overhaul-release-notes.md). +23. **Kiosk UI/E2E on real hardware not run here.** The verification ran the + Portal and the Kiosk schedule logic (engine, client, cache) via the test + harness and real HTTP against a locally running Portal; the SDL Kiosk UI was + not driven interactively because the build environment has no display. See + the manual checklist in the release notes and the Phase 5 report. +24. **Unauthenticated Kiosk endpoints remain a known limitation** (restated from + item 6): the key-only schedule and system-name endpoints authenticate by + License Key alone, mitigated only by the 10/min/IP rate limit. Authentication + is out of scope for this overhaul. --- @@ -587,3 +609,11 @@ later phase. and Proclamation order, scoped to the caller's account, and validated before commit. Endpoint (e) is finalized; open items 20 and 21 record the copy semantics and the Kiosk no-change verification. +- 2026-09-29 — Phase 5 (final). End-to-end verification against a locally + running Portal on a fresh database: schedule create/type/copy services and + the Kiosk-facing HTTP endpoints (validation with/without `systemName`, + system-name PUT, key-only schedule GET with ETag/304/404, legacy endpoint + parity, cross-account rejection) all pass; old-client compatibility confirmed; + migrations verified up/down (the down path is lossy, see open item 22). Adds + open items 22–24 (lossy down-migration, unrun UI-on-hardware checks, and the + restated unauthenticated-endpoint limitation) and the release notes. diff --git a/docs/scheduler-overhaul-release-notes.md b/docs/scheduler-overhaul-release-notes.md new file mode 100644 index 0000000..fa15185 --- /dev/null +++ b/docs/scheduler-overhaul-release-notes.md @@ -0,0 +1,164 @@ +# Scheduler Overhaul — Release Notes + +Status: prepared for rollout. These are **instructions only**; nothing here has +been executed. The contract lives at `docs/schedule-api-contract.md` (kept +byte-identical in both repos). + +This document is kept **byte-identical** in both repositories: + +- `radiantwave` (C++/SDL3 Kiosk app) — `docs/scheduler-overhaul-release-notes.md` +- `portal` (SvelteKit/Node) — `docs/scheduler-overhaul-release-notes.md` + +--- + +## What changed + +### Portal + +- **Schedules are assigned to a License Key**, not a user. `Schedule` gained + `licenseKeyId` (unique, nullable) and `type` (`daily` | `weekly` | null); + `Block` gained `day` (null for daily, `mon`..`sun` for weekly) and its + `dynamicAudio` default became `true`. +- **New Kiosk endpoint** `GET /api/v2/licenses/{licenseKey}/schedule` returns the + target payload (`version`, `type`, `daily`, `weekly`, `blocks`) with an `ETag` + derived from `version`, supports conditional GET (`304`), and is `404` for an + unknown key. +- **License validation** (`POST /api/v2/license`) accepts an optional + `systemName` and returns an additive `systemNameStatus` + (`ok` | `unchanged` | `conflict` | `invalid`). Validation always succeeds; a + non-`ok` name status is advisory. +- **New Kiosk endpoint** `PUT /api/v2/licenses/{licenseKey}/system-name` + (`200` / `400 invalid_name` / `404 unknown_license` / `409 name_taken`, + idempotent). +- **System Name** is a human-readable label for a License Key. It is set only + by the Kiosk and shown read-only in the Portal (dashboard and admin lists), + falling back to displaying the License Key. There is no Portal rename path. + Names are unique per account, compared case-insensitively, trimmed, 1–40 + characters. +- **Schedule editor** (protected user route `(protected)/(user)/schedule`): + a per-system picker labelled by System Name, a Daily/Weekly type selector with + confirmation when switching a populated schedule (daily→weekly prefills all + seven days; weekly→daily and clearing start empty), per-day weekly editing, + AM/PM `TimeInput` (5-minute suggestions plus manual minute entry), midnight + crossing labelled "ends next day", an on-by-default dynamic-audio toggle, gap + helper text, and an informational note that times run in the Kiosk's own + timezone (browser timezone fallback; no external GeoIP service). +- **Copy to…** copies one system's Schedule to one or more other systems in the + same account, by value, in a single database transaction (all or none). Target + Blocks get fresh IDs so target `version`/ETag changes; `dynamicAudio` and + Proclamation order are preserved. Overwriting a target that already has a + schedule requires explicit confirmation. Copies are independent. +- **OpenAPI** (`docs/openapi.yaml`) documents the new endpoints and payloads. + +### Kiosk (`radiantwave`) + +- **12-hour AM/PM** time display everywhere the Kiosk shows a time; storage and + parsing remain 24-hour `HH:MM`. The Date Format setting is unchanged. +- **System Settings** menu contains Email Address, License Key, **System Name**, + **Schedule** (on/off), WiFi Setup, Time Zone Setup, and Date Format. The list + is data-driven. +- **System Name** entry (trimmed, 1–40), sent on license validation and via + `PUT /api/v2/licenses/{key}/system-name`. Conflicts/invalid names are reverted + locally with a user-facing message; network failures keep the new name locally + and retry (pending-sync flag) on boot, on validation, and on a timer. +- **Schedule client**: conditional GET (`If-None-Match`) of + `GET /api/v2/licenses/{key}/schedule`, on boot, on network-up, and every + 5 minutes. The last payload is cached to + `~/.local/radiantwave/schedule_cache.json` with an **atomic write** (temp file + - rename); a corrupt cache is ignored. Offline uses the cache; no cache and no + network behaves as schedules-off. +- **Schedule engine**: given the payload, the local wall-clock time in the + Kiosk's Timezone setting, and the weekday, picks the active Block. Half-open + `[start, end)` boundaries; midnight-crossing Blocks belong to their start day + (weekly Sunday spills into Monday); invalid Blocks are skipped with a warning + and treated as gaps. Active Block → its Proclamations; `dynamicAudio` true + forces dynamic audio, false uses the locally chosen audio; gaps / no schedule + / toggle off use local selection exactly as before. +- **Schedule toggle** in System Settings (`proclamation.schedule_enabled`, + default on, Kiosk-local, not synced). Off takes no action but the cache keeps + updating. + +--- + +## Migration notes + +- **Portal migrations are additive `up` migrations** and are safe to deploy + while old Kiosks keep running: + - `20260929120000_add_license_key_system_name` — adds + `LicenseKey.systemName` / `systemNameLower` and a unique index on + `(userId, systemNameLower)`. + - `20260929130000_move_schedule_to_license_key` — adds `Schedule.licenseKeyId` + / `type` and `Block.day`, changes the `dynamicAudio` default to `true`, + makes the legacy `Schedule.userId` optional/non-unique, and backfills each + existing per-user Schedule to that account's **oldest** License Key as a + **daily** schedule. Schedules that cannot be mapped (no owning user/license) + are retained with `licenseKeyId NULL` and reported via a migration `NOTICE` + — they are never silently dropped. +- **Existing (per-user) schedules survive the `up` migration** and are mapped to + a License Key on the same account. +- **⚠ The `down` path is lossy for data created under the new model.** Back up + `Schedule`, `Block`, and `BlockProclamations` before any down-migration. + Rolling back past the Phase 3 migration deletes per-License-Key schedules (see + the contract's Open item 22). +- No Portal schema change affects Kiosks (the Kiosk stores nothing new on the + server beyond the System Name label and its schedule mapping). + +--- + +## Recommended rollout order + +Each step is independent; do not skip the verification gates. Do **not** tag, +push, or deploy as part of reading this document — these are instructions. + +### Step 1 — Run Portal migrations and deploy the Portal + +1. Back up the Portal database (at minimum the `Schedule`, `Block`, + `BlockProclamations`, and `LicenseKey` tables). +2. Apply migrations: `npm run deploy` (which runs `npx prisma migrate deploy`). +3. Confirm `npx prisma migrate status` reports the database is up to date and + review the migration logs for the unmappable-schedule `NOTICE`, if any. +4. Deploy the Portal build (the existing CI/deploy path). + +**Rollback (Step 1):** redeploy the previous Portal build. Because the `up` +migrations are additive, the old Portal continues to work against the migrated +schema; no schema rollback is required to redeploy the old Portal. If a schema +rollback is unavoidable, restore from the Step-1 backup (the `down` path is +lossy — see Migration notes). + +### Step 2 — Verify old Kiosks still work + +1. Against the deployed Portal, confirm an **old Kiosk** (no `systemName`, using + the legacy endpoints) still: + - validates via `POST /api/v2/license` (no `systemName`) and receives `200`; + - reads `GET /api/v2/schedule?email=…&licenseKey=…` and still finds its + `blocks`; + - is unaffected by the new fields, and its stored System Name (if any) is + never cleared. +2. Confirm the Portal dashboard/admin show System Names read-only. + +**Rollback (Step 2):** none needed — this step is read-only verification. If old +Kiosks misbehave, roll back the Portal (Step 1 rollback) before proceeding. + +### Step 3 — Release the Kiosk build + +1. Build and validate the Kiosk through the normal path: tag → CI → the + auto-update channel. (Tagging triggers production CI; do it only when ready.) +2. Confirm the released build reports the expected version and updates in place. +3. Spot-check a device after update: 12-hour times, System Settings contents, + schedule on/off, and that an assigned Schedule is fetched and applied. + +**Rollback (Step 3):** use the existing auto-update rollback (re-publish or +pin the previous known-good Kiosk version). The Kiosk tolerates both old and new +Portal payloads, so a Portal rollback alone does not require a Kiosk rollback, +and vice versa. + +--- + +## Known limitations + +- Kiosk API endpoints are **unauthenticated** (key-only), mitigated by a + 10 requests/min/IP rate limit. Authentication is out of scope for this + overhaul. +- The Phase 3 migration's `down` path is lossy for new-model schedules + (see Migration notes and contract Open item 22). +- The Portal does not offer a rename path for System Names by design. -- 2.43.0 From a0939aa3cc5660fe81c2d6364d19f004f86aa641 Mon Sep 17 00:00:00 2001 From: NeilClack Date: Tue, 29 Sep 2026 12:48:27 -0500 Subject: [PATCH 13/17] docs: record Phase 5 verification results --- docs/scheduler-overhaul-phase5-results.md | 55 +++++++++++++++++++++++ 1 file changed, 55 insertions(+) create mode 100644 docs/scheduler-overhaul-phase5-results.md diff --git a/docs/scheduler-overhaul-phase5-results.md b/docs/scheduler-overhaul-phase5-results.md new file mode 100644 index 0000000..c959a99 --- /dev/null +++ b/docs/scheduler-overhaul-phase5-results.md @@ -0,0 +1,55 @@ +# Scheduler Overhaul — Phase 5 Verification Results + +Prepared during Phase 5 against a **locally running Portal on a fresh database**. +This records what was actually executed and observed. It is a companion to +`scheduler-overhaul-release-notes.md` (rollout steps) and +`schedule-api-contract.md` (the contract). Where a scenario could not be run +without real Kiosk hardware or an interactive display, the row is marked and the +equivalent automated coverage is cited. + +Environment: + +- Portal dev server (`npm run dev`) on `http://localhost:5173`, pointed at a + fresh scratch database (`rwportal_p5`) created and migrated for this phase. +- Kiosk schedule logic exercised through the C++ test harness + (`test_schedule_engine`) and the Kiosk API client's JSON parsing. +- No display/Xvfb available, so the SDL Kiosk UI was **not** driven + interactively; see the manual checklist in the release notes. + +Legend: **PASS = executed**, **AUTO = verified by automated test** (not +interactively), **MANUAL = requires a human on hardware/display**. + +| # | Scenario | Result | Evidence / notes | +| --- | ------------------------------------------------------------------------------------------------------------------------------------ | ------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| a | Kiosk shows AM/PM everywhere; System Settings contains Email, License Key, System Name, Schedule toggle, WiFi, Timezone, Date Format | AUTO | `test_date_format` (12h conversion), `test_settings_menu` (exactly the 7 entries incl. System Name + Schedule). UI rendering itself is MANUAL. | +| b | Validate a License Key, name the Kiosk, see the name read-only on the Portal | PASS + MANUAL | HTTP: validation without name → `200 unchanged`; with name → `200 ok`. Portal render is MANUAL (release-notes checklist). | +| c | Two systems in one account: duplicate name in any casing rejected and reverted on the Kiosk | PASS | HTTP: case-insensitive duplicate → validation `200 conflict`; `PUT` duplicate → `409 name_taken`; blank → `400 invalid_name`; unknown → `404 unknown_license`. Kiosk revert covered by `test_license_service`/`test_system_name`. | +| d | Rename a system on the Kiosk; its Schedule stays attached (License Key reference) | AUTO | Schedules are keyed by `licenseKeyId`; naming only touches `systemName`. Portal `schedule-copy`/service tests operate purely on License Key IDs. | +| e | Create a daily Schedule via the Portal picker; Kiosk fetches and applies it | PASS | Opt-in integration spec: daily schedule with 2 blocks created via the service; key-only GET returns it; captured live payload parsed and evaluated by the Kiosk (`SchedulePortalParityTest`). | +| f | Weekly: Block boundaries, adjacent (end 7:00 / start 7:00), midnight-crossing, Sunday→Monday spillover | PASS + AUTO | Integration spec: weekly sun crossing + adjacent mon blocks, Sunday spillover collision rejected. Engine tests: adjacency boundary, midnight self-wrap, Sunday→Monday spillover. | +| g | Gap plays local Proclamations; `dynamicAudio` true → dynamic, false → local | AUTO | Engine tests: gaps return no block; per-block `dynamicAudio` flag; `ScrollerPage::effectiveDynamicAudio` honored via playback wiring (build passes). | +| h | Schedule toggle Off stops schedule effects; On resumes | AUTO | `ScheduleManagerTest.DisabledReturnsNoBlock`; toggle stored in `config.yaml` (`schedule_enabled`, default on). | +| i | Schedules assigned to system A never affect system B | PASS | Integration spec: A daily, B weekly simultaneously; independent payloads. | +| j | Copy a Schedule to another named system; that Kiosk picks it up | PASS | Integration spec: copy A→second system yields daily with 2 blocks; independence confirmed; target ETag differs. Portal `schedule-copy.spec` covers overwrite/multi-target/rollback/new IDs. | +| k | Offline uses cached Schedule; corrupt cache ignored; timezone change re-evaluates | AUTO | `ScheduleCacheTest` (round-trip, corrupt ignored), `ScheduleManagerTest` (offline cache, 304 handled). Timezone is passed as wall-clock minutes (wall-clock means DST-safe). | +| l | Portal shows geoip/fallback timezone with kiosk-timezone note; manual minute entry alongside 5-minute options | AUTO | UI note is browser-fallback (no external dep); `TimeInput` tests cover 5-minute options and typed minutes. Visual render MANUAL. | + +## Additional checks + +| Check | Result | Notes | +| --------------------------------------------------------------------------------- | ------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Old-Kiosk compatibility (validate without `systemName`; legacy schedule endpoint) | PASS | `200 unchanged`; name never cleared; legacy `GET /api/v2/schedule` still returns `email` + `blocks`; modern vs legacy `version` equal. | +| Cross-account isolation | PASS | Legacy schedule with another account's key → `400`; copy rejects non-owned source/targets. | +| Conditional GET | PASS | Matching `If-None-Match` → `304`; stale ETag → `200`; unknown key → `404`. | +| Migration up | PASS | Applied cleanly to a fresh DB (29 migrations); fresh seed succeeded. | +| Migration down/up on realistic data | PASS (with caveat) | Phase 2 and Phase 3 down/up round-tripped structurally on a clone; **the Phase 3 down path is lossy** (deletes per-License-Key schedules). Documented in the contract (Open item 22) and release notes. | +| Portal `svelte-check` / ESLint / tests | PASS | 0 errors/warnings; 394 passed, 4 skipped (opt-in integration). | +| Kiosk build + tests | PASS | Full build; 21 test binaries pass. | +| Formatters | PASS | Portal Prettier clean except the pre-existing `.gitea/workflows/deploy.yaml` (unchanged from before this branch). Kiosk follows `.clang-format` (no binary available to run). | + +## Not verified here (requires hardware/display) + +The interactive SDL Kiosk UI (rendering, key handling, onboarding walkthrough) +was not driven; all Kiosk behavior above is covered by the engine/client/cache +tests and by parsing live Portal payloads. See the manual checklist in +`scheduler-overhaul-release-notes.md`. -- 2.43.0 From a088ca12d894597642bec8d072f4b25eb68df6e1 Mon Sep 17 00:00:00 2001 From: NeilClack Date: Tue, 29 Sep 2026 13:29:56 -0500 Subject: [PATCH 14/17] chore: prettier-format captured Portal fixtures --- docs/fixtures/schedule-portal-daily.json | 54 ++++++++++++++++++++++- docs/fixtures/schedule-portal-weekly.json | 51 ++++++++++++++++++++- 2 files changed, 103 insertions(+), 2 deletions(-) diff --git a/docs/fixtures/schedule-portal-daily.json b/docs/fixtures/schedule-portal-daily.json index 01b85f5..d9a0500 100644 --- a/docs/fixtures/schedule-portal-daily.json +++ b/docs/fixtures/schedule-portal-daily.json @@ -1 +1,53 @@ -{"version":"7f53efde17d291a1ecf604e266d3803d1495b986","type":"daily","daily":[{"id":"6a9be0a4-4a1c-4a2c-929d-7b6c6fe56d91","startTime":"06:00","startMinutes":360,"endTime":"07:00","endMinutes":420,"overnight":false,"durationMinutes":60,"dynamicAudio":true,"proclamationIds":["310138f3-adfc-45ce-ae52-07279cd25b48"]},{"id":"65fecafc-abc8-4796-bb12-ed8e9d99e0fe","startTime":"18:00","startMinutes":1080,"endTime":"20:30","endMinutes":1230,"overnight":false,"durationMinutes":150,"dynamicAudio":false,"proclamationIds":["3805242e-4930-4dd2-afb1-130593b1dbab"]}],"weekly":{},"blocks":[{"id":"6a9be0a4-4a1c-4a2c-929d-7b6c6fe56d91","startTime":"06:00","startMinutes":360,"endTime":"07:00","endMinutes":420,"overnight":false,"durationMinutes":60,"dynamicAudio":true,"proclamationIds":["310138f3-adfc-45ce-ae52-07279cd25b48"]},{"id":"65fecafc-abc8-4796-bb12-ed8e9d99e0fe","startTime":"18:00","startMinutes":1080,"endTime":"20:30","endMinutes":1230,"overnight":false,"durationMinutes":150,"dynamicAudio":false,"proclamationIds":["3805242e-4930-4dd2-afb1-130593b1dbab"]}]} \ No newline at end of file +{ + "version": "7f53efde17d291a1ecf604e266d3803d1495b986", + "type": "daily", + "daily": [ + { + "id": "6a9be0a4-4a1c-4a2c-929d-7b6c6fe56d91", + "startTime": "06:00", + "startMinutes": 360, + "endTime": "07:00", + "endMinutes": 420, + "overnight": false, + "durationMinutes": 60, + "dynamicAudio": true, + "proclamationIds": ["310138f3-adfc-45ce-ae52-07279cd25b48"] + }, + { + "id": "65fecafc-abc8-4796-bb12-ed8e9d99e0fe", + "startTime": "18:00", + "startMinutes": 1080, + "endTime": "20:30", + "endMinutes": 1230, + "overnight": false, + "durationMinutes": 150, + "dynamicAudio": false, + "proclamationIds": ["3805242e-4930-4dd2-afb1-130593b1dbab"] + } + ], + "weekly": {}, + "blocks": [ + { + "id": "6a9be0a4-4a1c-4a2c-929d-7b6c6fe56d91", + "startTime": "06:00", + "startMinutes": 360, + "endTime": "07:00", + "endMinutes": 420, + "overnight": false, + "durationMinutes": 60, + "dynamicAudio": true, + "proclamationIds": ["310138f3-adfc-45ce-ae52-07279cd25b48"] + }, + { + "id": "65fecafc-abc8-4796-bb12-ed8e9d99e0fe", + "startTime": "18:00", + "startMinutes": 1080, + "endTime": "20:30", + "endMinutes": 1230, + "overnight": false, + "durationMinutes": 150, + "dynamicAudio": false, + "proclamationIds": ["3805242e-4930-4dd2-afb1-130593b1dbab"] + } + ] +} diff --git a/docs/fixtures/schedule-portal-weekly.json b/docs/fixtures/schedule-portal-weekly.json index 983bb1d..d0eed98 100644 --- a/docs/fixtures/schedule-portal-weekly.json +++ b/docs/fixtures/schedule-portal-weekly.json @@ -1 +1,50 @@ -{"version":"6de852ba72ce1ab3052150640730ee87a7d460b5","type":"weekly","daily":[],"weekly":{"mon":[{"id":"a1054b78-480a-4e87-8465-aac8d677c666","startTime":"06:00","startMinutes":360,"endTime":"07:00","endMinutes":420,"overnight":false,"durationMinutes":60,"dynamicAudio":true,"proclamationIds":["310138f3-adfc-45ce-ae52-07279cd25b48"]},{"id":"25979ec9-f7da-4aa4-864f-f74b1a2da78b","startTime":"07:00","startMinutes":420,"endTime":"08:30","endMinutes":510,"overnight":false,"durationMinutes":90,"dynamicAudio":true,"proclamationIds":["3805242e-4930-4dd2-afb1-130593b1dbab"]}],"tue":[],"wed":[],"thu":[],"fri":[],"sat":[],"sun":[{"id":"e2237d98-834c-433b-8979-b56ddad2ef7a","startTime":"23:00","startMinutes":1380,"endTime":"01:00","endMinutes":60,"overnight":true,"durationMinutes":120,"dynamicAudio":true,"proclamationIds":["fcd5a291-3d6e-4678-9d85-9b550e80f1c7"]}]},"blocks":[]} \ No newline at end of file +{ + "version": "6de852ba72ce1ab3052150640730ee87a7d460b5", + "type": "weekly", + "daily": [], + "weekly": { + "mon": [ + { + "id": "a1054b78-480a-4e87-8465-aac8d677c666", + "startTime": "06:00", + "startMinutes": 360, + "endTime": "07:00", + "endMinutes": 420, + "overnight": false, + "durationMinutes": 60, + "dynamicAudio": true, + "proclamationIds": ["310138f3-adfc-45ce-ae52-07279cd25b48"] + }, + { + "id": "25979ec9-f7da-4aa4-864f-f74b1a2da78b", + "startTime": "07:00", + "startMinutes": 420, + "endTime": "08:30", + "endMinutes": 510, + "overnight": false, + "durationMinutes": 90, + "dynamicAudio": true, + "proclamationIds": ["3805242e-4930-4dd2-afb1-130593b1dbab"] + } + ], + "tue": [], + "wed": [], + "thu": [], + "fri": [], + "sat": [], + "sun": [ + { + "id": "e2237d98-834c-433b-8979-b56ddad2ef7a", + "startTime": "23:00", + "startMinutes": 1380, + "endTime": "01:00", + "endMinutes": 60, + "overnight": true, + "durationMinutes": 120, + "dynamicAudio": true, + "proclamationIds": ["fcd5a291-3d6e-4678-9d85-9b550e80f1c7"] + } + ] + }, + "blocks": [] +} -- 2.43.0 From 8e569ed1f4b09a981a90a0f93a4d0a094f1745db Mon Sep 17 00:00:00 2001 From: NeilClack Date: Wed, 30 Sep 2026 00:04:21 -0500 Subject: [PATCH 15/17] Change to new actions runner on Orion --- .gitea/workflows/build.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.gitea/workflows/build.yml b/.gitea/workflows/build.yml index f42280f..9765ccf 100644 --- a/.gitea/workflows/build.yml +++ b/.gitea/workflows/build.yml @@ -12,7 +12,7 @@ on: jobs: nix-build: - runs-on: build-server + runs-on: orion-lxc steps: # actions/checkout@v4 is unreliable on host-mode runners (its files are # copied into the workspace instead of the repo), so clone explicitly. -- 2.43.0 From 44e796597ce0607fb0e9cdcc858b0edf8b69c4bf Mon Sep 17 00:00:00 2001 From: NeilClack Date: Wed, 30 Sep 2026 11:51:29 -0500 Subject: [PATCH 16/17] Update IP address for infrastructure server --- .gitea/workflows/build.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.gitea/workflows/build.yml b/.gitea/workflows/build.yml index 9765ccf..91faaba 100644 --- a/.gitea/workflows/build.yml +++ b/.gitea/workflows/build.yml @@ -110,7 +110,7 @@ jobs: printf '{"version":"%s","storePath":"%s"}\n' "${{ env.APP_VERSION }}" "$STOREPATH" > manifest.json cat manifest.json scp manifest.json \ - radiantwave-infra:/mnt/infrastructure_data/files/radiantwave-dev/manifest.json + 157.245.124.103:/mnt/infrastructure_data/files/radiantwave-dev/manifest.json - name: Publish release manifest if: github.event_name != 'workflow_dispatch' @@ -119,4 +119,4 @@ jobs: printf '{"version":"%s","storePath":"%s"}\n' "${{ env.APP_VERSION }}" "$STOREPATH" > manifest.json cat manifest.json scp manifest.json \ - radiantwave-infra:/mnt/infrastructure_data/files/radiantwave/manifest.json + 157.245.124.103:/mnt/infrastructure_data/files/radiantwave/manifest.json -- 2.43.0 From f9515d4cadc65eeb53fc24111667739dad04487f Mon Sep 17 00:00:00 2001 From: NeilClack Date: Wed, 30 Sep 2026 11:55:35 -0500 Subject: [PATCH 17/17] Define user for scp --- .gitea/workflows/build.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.gitea/workflows/build.yml b/.gitea/workflows/build.yml index 91faaba..48fc249 100644 --- a/.gitea/workflows/build.yml +++ b/.gitea/workflows/build.yml @@ -110,7 +110,7 @@ jobs: printf '{"version":"%s","storePath":"%s"}\n' "${{ env.APP_VERSION }}" "$STOREPATH" > manifest.json cat manifest.json scp manifest.json \ - 157.245.124.103:/mnt/infrastructure_data/files/radiantwave-dev/manifest.json + nclack@157.245.124.103:/mnt/infrastructure_data/files/radiantwave-dev/manifest.json - name: Publish release manifest if: github.event_name != 'workflow_dispatch' @@ -119,4 +119,4 @@ jobs: printf '{"version":"%s","storePath":"%s"}\n' "${{ env.APP_VERSION }}" "$STOREPATH" > manifest.json cat manifest.json scp manifest.json \ - 157.245.124.103:/mnt/infrastructure_data/files/radiantwave/manifest.json + nclack@157.245.124.103:/mnt/infrastructure_data/files/radiantwave/manifest.json -- 2.43.0