44 Commits
Author SHA1 Message Date
NeilClack 476202f8e2 Merge dev into main: Bug Fix - ScheduleManager startup self-deadlock
build / nix-build (push) Successful in 9m50s
Bug Fix: ScheduleManager::start() held mutex_ and then called loadCache(), which re-locks the same non-recursive mutex. Whenever a schedule cache file was present (the normal state for a provisioned kiosk), this self-deadlocked and blocked SDL_AppInit before the first frame was presented, so the kiosk booted to a black screen showing only the compositor cursor.

Narrow the lock scope so loadCache() acquires mutex_ itself, and add a regression test that starts with a cached payload present.
2026-10-07 08:58:46 -05:00
NeilClack 9b57e48969 Fix ScheduleManager start() self-deadlock on cached schedule
start() held mutex_ and then called loadCache(), which re-locks the same
non-recursive mutex. When a schedule cache file exists and parses (the
normal case for a provisioned kiosk), this self-deadlocks and blocks
SDL_AppInit before the first frame, leaving only a black screen and the
compositor cursor.

Narrow the lock to the member setup so loadCache() can take the lock
itself. Add a regression test that starts with a cached payload present.
2026-10-07 08:43:13 -05:00
NeilClack a5a6b5bef0 Add Raspberry Pi build steps
build / nix-build (push) Successful in 8m20s
2026-10-03 12:58:56 -05:00
NeilClack 2793dc4586 Remove gitconfig token 2026-10-01 17:42:59 -05:00
NeilClack 34c72b51dd Merge pull request 'Daily & Weekly Named Schedules' (#2) from dev into main
build / nix-build (push) Successful in 51s
Reviewed-on: #2
2026-09-30 21:31:07 +00:00
NeilClack f9515d4cad Define user for scp 2026-09-30 11:55:35 -05:00
NeilClack 44e796597c Update IP address for infrastructure server 2026-09-30 11:51:29 -05:00
NeilClack 8e569ed1f4 Change to new actions runner on Orion 2026-09-30 00:04:21 -05:00
NeilClack a088ca12d8 chore: prettier-format captured Portal fixtures 2026-09-29 13:29:56 -05:00
NeilClack a0939aa3cc docs: record Phase 5 verification results 2026-09-29 12:48:27 -05:00
NeilClack c65884f929 docs: Phase 5 finalize contract, add release notes 2026-09-29 12:47:33 -05:00
NeilClack 4535ba223b 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.
2026-09-29 12:46:05 -05:00
NeilClack ff5346fd0c docs: finalize Phase 4 schedule copy in the contract 2026-09-29 10:50:18 -05:00
NeilClack fc4ac4b107 docs: document Phase 3 schedule endpoint, model, and resolved open items 2026-09-29 10:35:44 -05:00
NeilClack da0b6ffd5e 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.
2026-09-29 10:34:45 -05:00
NeilClack 509c925480 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.
2026-09-29 10:34:40 -05:00
NeilClack b93fb8d2df docs: document System Name PUT endpoint and validation fields in OpenAPI 2026-09-29 09:59:17 -05:00
NeilClack 1bfe64580c 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.
2026-09-29 09:58:08 -05:00
NeilClack e7df02344b 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.
2026-09-29 09:57:52 -05:00
NeilClack 003a90d810 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.
2026-09-29 08:45:16 -05:00
NeilClack 59b499037b 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.
2026-09-29 08:43:59 -05:00
NeilClack 336f47c6ac 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.
2026-09-29 08:36:31 -05:00
NeilClack 6804bbb048 Ignore Emacs temporary files
build / nix-build (push) Canceled after 0s
2026-09-17 15:09:47 -05:00
NeilClack 21f13c6cd5 Consolidate Gitea CI into one event-routed workflow 2026-09-11 19:14:58 -05:00
NeilClack f37beaf07e Add schedule sync and schedule-aware playback 2026-09-11 19:14:55 -05:00
NeilClack a043ec84d8 Merge branch 'dev' 2026-09-11 15:08:22 -05:00
NeilClack 9fead922c4 Move Proclamations From Sub-Categories To Groups
build-dev / nix-build (push) Successful in 32s
The portal's v2 API replaced nested categories (proclamation_categories.parent_id)
with flat categories plus tag-like groups: a proclamation now belongs to one
category and optionally one group. This lets the Proclamation Options page drop
its special-cased two-level tree.

Database:
- Add the proclamation_groups table (id, name, sort_order) and a
  proclamations.group_id column.
- Drop parent_id from the proclamation_categories DDL so fresh databases get a
  flat table.
- runMigrations() creates the new table/column, then runs a one-time
  transactional v1->v2 migration keyed on PRAGMA user_version: every legacy
  sub-category becomes a group (its id is preserved), proclamations that
  pointed at a sub-category are remapped to (parent category, new group), and
  the now-stale sub-category rows are deleted. selected flags are never
  touched, so user choices survive. No network is needed, so offline kiosks
  migrate at startup; re-running is a no-op.
- Add replaceGroups() and getAllGroups(); stop reading/writing parent_id.

API sync:
- Add PATH_GROUPS ("/groups") and parseGroupsJson().
- parseProclamationsJson() now also reads groupId.
- syncProclamationsFromApi() fetches /proclamations, /categories and /groups,
  leaves the database untouched if any request or parse fails, then replaces
  categories, replaces groups and upserts proclamations.

Proclamation Options page:
- FlatItem is now Category / Group / Proclamation.
- Each category lists only the groups that have at least one member in that
  category, so impossible pairings (e.g. Physical/Prayers) never render.
- Proclamations with no group, or with a group the portal has removed, sit
  directly under the category. Groups are collapsible and use a distinct color.

Tests:
- test_db: groups round-trip plus a legacy->v2 migration test asserting the
  group is created, proclamations are remapped, selection is preserved and a
  second initialize() is a no-op.
- test_proclamation_sync / test_api_client: parse groupId, parse groups, and
  the live smoke test now also fetches /groups.

Docs: README database table and AGENTS notes updated for groups.
2026-09-10 19:49:47 -05:00
NeilClack 2b1040d3db Add OpenAPI v2 spec; remove draft docs
Track the portal v2 API as openapi.yaml and drop the
superseded portal_api_v2_schema.md and user_manual_draft.md.
2026-09-10 17:54:19 -05:00
NeilClack cf991c34af Merge branch 'dev'
# Conflicts:
#	.gitea/workflows/dev.yml
#	.gitea/workflows/main.yml
2026-09-01 17:50:57 -05:00
NeilClack b56601bb93 Remove Attic upload verification steps
build-dev / nix-build (push) Successful in 42s
2026-09-01 17:17:21 -05:00
NeilClack 72ba15891d Fix attic verification cleanup on read-only store paths
build-dev / nix-build (push) Failing after 41s
2026-09-01 17:08:26 -05:00
NeilClack 356629a5a1 Fix attic verify: fetch deps from cache.nixos.org
The verify step fetched the closure from Attic alone, but Attic's
upstream-cache filter (cache.nixos.org-1) intentionally skips nixpkgs deps
like sqlite, so `nix copy` failed on 'path is not valid'. Mirror the kiosk
updater and fetch from Attic + cache.nixos.org together, Attic first so a
truncated RadiantWave NAR still fails before the manifest is published.
2026-09-01 15:55:34 -05:00
NeilClack 6019be6359 Add attic verification
build-dev / nix-build (push) Canceled after 26s
2026-09-01 10:53:57 -05:00
NeilClack fee31c87b8 Add manifest upload to release workflow 2026-08-28 16:51:53 -05:00
NeilClack 5369532ab4 Merge pull request 'Fix audio device problems' (#1) from dev into main
Reviewed-on: #1
2026-08-28 21:30:12 +00:00
NeilClack 42b915bd25 fixed (bug) Audio device switch killing playback via stale SDL device id
build-dev / nix-build (push) Successful in 45s
recreateMixerOnDevice destroyed the active mixer before opening the
replacement. MIX_DestroyMixer quits SDL audio at refcount 0, tearing down
the device registry and renumbering device ids, so the cached id being
reopened went stale ('Invalid audio device instance ID') and the app went
permanently mute. Open the new mixer first while the old device is alive,
then tear the old one down; a failed switch now keeps the current output
playing instead of killing audio. Also log SDL_GetError() on open failure.
2026-08-28 16:18:36 -05:00
NeilClack d60a8bd7fb Add WiFi prompt
build-dev / nix-build (push) Successful in 46s
2026-08-28 15:35:57 -05:00
NeilClack ca7b8b7e6f Update audio pipeline with extensive logging and defualt with fallbacks
build-dev / nix-build (push) Successful in 45s
2026-08-28 15:04:42 -05:00
NeilClack bf2d4d1f89 Update build dependencies to properly link dynamically. 2026-08-28 15:03:58 -05:00
NeilClack 2917d33866 fixed (bug) Audio not looping after ending.
build-dev / nix-build (push) Successful in 46s
2026-08-28 13:55:20 -05:00
NeilClack 3f6201182e Publish live-update manifest to fileserver (dev)
build-dev / nix-build (push) Successful in 43s
After pushing the dev closure to Attic, write a small JSON manifest
(version + storePath) to the fileserver for kiosks to poll. storePath is
the readlink of the result-pkg out-link, so it matches exactly the
closure pushed to the dev Attic cache and can be fetched with
'nix copy --from' without building from source. Upload goes to
/mnt/infrastructure_data/files/radiantwave-dev/manifest.json via the
build-server's radiantwave-infra SSH alias.
2026-08-27 20:58:13 -05:00
NeilClack d84307bd0e fixed (bug) Audio output device not switching
build-dev / nix-build (push) Successful in 47s
2026-08-27 18:45:57 -05:00
NeilClack 43cd93bbd7 Switch from window-size to render-output-size 2026-08-25 11:30:20 -05:00
NeilClack 92ba9f39fb Switch from window-size to render-output-size 2026-08-25 11:28:02 -05:00
90 changed files with 8395 additions and 1316 deletions
+502
View File
@@ -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.
+109
View File
@@ -0,0 +1,109 @@
name: build
on:
workflow_dispatch:
push:
tags: ['*']
jobs:
nix-build:
runs-on: orion-lxc
steps:
- name: Checkout
run: |
rm -rf ./* ./.[!.]* 2>/dev/null || true
REF="${{ gitea.ref_name }}"
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
REF=dev
fi
git clone --branch "$REF" \
"${{ gitea.server_url }}/${{ gitea.repository }}.git" .
test -f flake.nix
git log --oneline -1
- name: Inspect workspace
run: |
pwd
ls -la
git rev-parse --show-toplevel 2>&1 || true
git remote -v 2>&1 || true
git describe --tags --always 2>&1 || true
test -f flake.nix && echo "flake.nix present" || echo "NO flake.nix"
- name: Compute version
run: |
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
echo "APP_VERSION=$(git rev-parse --short HEAD)" >> "$GITHUB_ENV"
else
echo "APP_VERSION=$(git describe --tags --always)" >> "$GITHUB_ENV"
fi
# ---- x86_64 (native) ----
- name: Build package (x86_64)
run: |
nix build -L ".#packages.$(nix eval --impure --raw --expr 'builtins.currentSystem').default" \
--impure --out-link result-pkg
- name: Build + offline test suite (x86_64)
run: |
nix build -L ".#checks.$(nix eval --impure --raw --expr 'builtins.currentSystem').default" \
--impure --out-link result-checks
- name: Live portal tests
env:
RWT_LIVE_ACCOUNT_EMAIL: ${{ secrets.RWT_LIVE_ACCOUNT_EMAIL }}
RWT_LIVE_LICENSE_EMAIL: ${{ secrets.RWT_LIVE_LICENSE_EMAIL }}
RWT_LIVE_LICENSE_KEY: ${{ secrets.RWT_LIVE_LICENSE_KEY }}
run: |
nix develop --command bash -c '
cmake -S . -B build -G Ninja -DCMAKE_BUILD_TYPE=Debug
cmake --build build --target test_api_client test_license_service
./build/test_api_client
./build/test_license_service
'
# ---- aarch64 (emulated via host binfmt_misc/qemu-user) ----
- name: Build package (aarch64)
run: |
nix build -L ".#packages.aarch64-linux.default" \
--impure --out-link result-pkg-aarch64
- name: Build + offline test suite (aarch64)
run: |
nix build -L ".#checks.aarch64-linux.default" \
--impure --out-link result-checks-aarch64
# ---- Publish (only reached if every build/test above passed) ----
- name: Push to Attic dev cache
if: github.event_name == 'workflow_dispatch'
env:
ATTIC_TOKEN: ${{ secrets.ATTIC_TOKEN_DEV }}
run: |
attic login radiantwave https://attic.radiantwavetech.com "$ATTIC_TOKEN"
attic push radiantwave:radiantwave-dev ./result-pkg ./result-pkg-aarch64
- name: Push to Attic release cache
if: github.event_name != 'workflow_dispatch'
env:
ATTIC_TOKEN: ${{ secrets.ATTIC_TOKEN_PROD }}
run: |
attic login radiantwave https://attic.radiantwavetech.com "$ATTIC_TOKEN"
attic push radiantwave:radiantwave ./result-pkg ./result-pkg-aarch64
- name: Publish manifests
run: |
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
DEST=radiantwave-dev
else
DEST=radiantwave
fi
X86_PATH=$(readlink -f ./result-pkg)
ARM_PATH=$(readlink -f ./result-pkg-aarch64)
printf '{"version":"%s","storePath":"%s"}\n' "${{ env.APP_VERSION }}" "$X86_PATH" > manifest.json
printf '{"version":"%s","storePath":"%s"}\n' "${{ env.APP_VERSION }}" "$ARM_PATH" > aarch64-manifest.json
cat manifest.json aarch64-manifest.json
scp manifest.json aarch64-manifest.json \
nclack@157.245.124.103:/mnt/infrastructure_data/files/$DEST/
-68
View File
@@ -1,68 +0,0 @@
name: build-dev
on:
# Every push to dev: build, run the offline test suite, publish to the
# dev Attic cache.
push:
branches: [dev]
jobs:
nix-build:
runs-on: build-server
steps:
# actions/checkout@v4 is unreliable on host-mode runners (its files are
# copied into the workspace instead of the repo), so clone explicitly.
- name: Checkout
run: |
rm -rf ./* ./.[!.]* 2>/dev/null || true
git config --global http.extraheader "Authorization: token ${{ github.token }}"
git clone --branch "${{ gitea.ref_name }}" \
"${{ gitea.server_url }}/${{ gitea.repository }}.git" .
test -f flake.nix
git log --oneline -1
- name: Inspect workspace
run: |
pwd
ls -la
git rev-parse --show-toplevel 2>&1 || true
git remote -v 2>&1 || true
git describe --tags --always 2>&1 || true
test -f flake.nix && echo "flake.nix present" || echo "NO flake.nix"
# Dev builds fingerprint as the short commit hash.
# Persisted via GITHUB_ENV so the impure nix eval below reads it via
# builtins.getEnv.
- name: Compute version
run: |
echo "APP_VERSION=$(git rev-parse --short HEAD)" >> "$GITHUB_ENV"
# checks is keyed per-system (checks.<system>.default); nix build's
# #attr shorthand can't resolve it, so resolve the current system inline.
# --impure lets the flake read APP_VERSION via builtins.getEnv
# (pure evals always get the package defaults).
- name: Build package
run: |
nix build -L ".#packages.$(nix eval --impure --raw --expr 'builtins.currentSystem').default" \
--impure --out-link result-pkg
# The offline test suite runs in the check derivation's checkPhase.
# This is a separate build/output from the package itself.
- name: Build + offline test suite
run: |
nix build -L ".#checks.$(nix eval --impure --raw --expr 'builtins.currentSystem').default" \
--impure --out-link result-checks
- name: Live portal tests
env:
RWT_LIVE_ACCOUNT_EMAIL: ${{ secrets.RWT_LIVE_ACCOUNT_EMAIL }}
RWT_LIVE_LICENSE_EMAIL: ${{ secrets.RWT_LIVE_LICENSE_EMAIL }}
RWT_LIVE_LICENSE_KEY: ${{ secrets.RWT_LIVE_LICENSE_KEY }}
run: |
nix develop --command bash -c '
cmake -S . -B build -G Ninja -DCMAKE_BUILD_TYPE=Debug
cmake --build build --target test_api_client test_license_service
./build/test_api_client
./build/test_license_service
'
# Dev closure (the actual package, not the check output) lands in the
# dev cache.
- name: Push to Attic dev cache
env:
ATTIC_TOKEN: ${{ secrets.ATTIC_TOKEN_DEV }}
run: |
attic login radiantwave https://attic.radiantwavetech.com "$ATTIC_TOKEN"
attic push radiantwave:radiantwave-dev ./result-pkg
-76
View File
@@ -1,76 +0,0 @@
name: build-main
on:
# Manual only. Dispatch against main: build, run the offline test suite,
# publish to the release Attic cache.
workflow_dispatch:
jobs:
nix-build:
runs-on: build-server
steps:
# actions/checkout@v4 is unreliable on host-mode runners (its files are
# copied into the workspace instead of the repo), so clone explicitly.
# A full clone (no --depth) is required so `git describe --tags` resolves
# the real nearest tag instead of falling back to a hash.
- name: Checkout
run: |
rm -rf ./* ./.[!.]* 2>/dev/null || true
git config --global http.extraheader "Authorization: token ${{ github.token }}"
git clone --branch "${{ gitea.ref_name }}" \
"${{ gitea.server_url }}/${{ gitea.repository }}.git" .
test -f flake.nix
git log --oneline -1
- name: Inspect workspace
run: |
pwd
ls -la
git rev-parse --show-toplevel 2>&1 || true
git remote -v 2>&1 || true
git describe --tags --always 2>&1 || true
test -f flake.nix && echo "flake.nix present" || echo "NO flake.nix"
# Release builds report the current git tag.
# Persisted via GITHUB_ENV so the impure nix eval below reads it via
# builtins.getEnv.
- name: Compute version
run: |
echo "APP_VERSION=$(git describe --tags --always)" >> "$GITHUB_ENV"
# checks is keyed per-system (checks.<system>.default); nix build's
# #attr shorthand can't resolve it, so resolve the current system inline.
# --impure lets the flake read APP_VERSION via builtins.getEnv
# (pure evals always get the package defaults).
- name: Build package
run: |
nix build -L ".#packages.$(nix eval --impure --raw --expr 'builtins.currentSystem').default" \
--impure --out-link result-pkg
# The offline test suite runs in the check derivation's checkPhase.
# This is a separate build/output from the package itself.
- name: Build + offline test suite
run: |
nix build -L ".#checks.$(nix eval --impure --raw --expr 'builtins.currentSystem').default" \
--impure --out-link result-checks
- name: Live portal tests
env:
RWT_LIVE_ACCOUNT_EMAIL: ${{ secrets.RWT_LIVE_ACCOUNT_EMAIL }}
RWT_LIVE_LICENSE_EMAIL: ${{ secrets.RWT_LIVE_LICENSE_EMAIL }}
RWT_LIVE_LICENSE_KEY: ${{ secrets.RWT_LIVE_LICENSE_KEY }}
run: |
nix develop --command bash -c '
cmake -S . -B build -G Ninja -DCMAKE_BUILD_TYPE=Debug
cmake --build build --target test_api_client test_license_service
./build/test_api_client
./build/test_license_service
'
# Release closure (the actual package, not the check output) lands in the
# release cache.
- name: Push to Attic release cache
env:
ATTIC_TOKEN: ${{ secrets.ATTIC_TOKEN_PROD }}
run: |
attic login radiantwave https://attic.radiantwavetech.com "$ATTIC_TOKEN"
attic push radiantwave:radiantwave ./result-pkg
+5
View File
@@ -33,3 +33,8 @@ Thumbs.db
# Compiled binary
bin/
# Emacs
*~
\#*\#
.#*
+15 -4
View File
@@ -102,6 +102,14 @@ suite and need the `RWT_LIVE_*` secrets to pass.
- `ConfigManager` hot-reloads `config.yaml` from a watcher thread and fires
`onChanged` callbacks; `AudioManager` volume/device/`dynamic_audio` are read
from it.
- Proclamations carry a flat `category_id` plus a tag-like `group_id`
(`proclamation_groups`). The Options page lists every category and, under it,
only the groups that have at least one member in that category; ungrouped
proclamations sit directly under the category. `runMigrations()` performs a
one-time v1→v2 migration (`PRAGMA user_version`) that converts legacy
`proclamation_categories.parent_id` sub-categories into groups and remaps
their proclamations, preserving selections. No network is needed for it, so
offline kiosks migrate on startup.
---
@@ -217,13 +225,16 @@ cached.
- Do not modify files in `archive/` — reference only.
- Build needs SDL3 + SDL3_ttf/image/mixer/net + GTest installed; CI uses
Gitea Actions on a self-hosted host runner (`build-server`, no docker).
The workflow lives in `.gitea/workflows/build.yml` and is **manual-only**
(`workflow_dispatch`); pick the branch when dispatching. It runs
The single workflow `.gitea/workflows/build.yml` has two triggers:
**`workflow_dispatch`** always builds the `dev` branch and publishes the dev
cache/manifest, and a **tag push** (`tags: ['*']`) builds the tag and
publishes the release cache/manifest. The event name routes the steps
(`github.event_name`). It runs
`nix build -L .#checks.<system>.default` (build + the offline test suite;
`checks` is keyed per-system, so the workflow resolves the current system
via `nix eval --impure --expr 'builtins.currentSystem'`). The build bakes
in a per-branch `APP_VERSION` — the current git tag on `main`, the current
commit hash on `dev`. The flake reads `APP_VERSION` from the environment via
in an `APP_VERSION` — the short commit hash on a manual dev run, the current
git tag on a tag push. The flake reads `APP_VERSION` from the environment via
`builtins.getEnv`, so the build must pass `--impure` (pure evals get the
package defaults). The live portal tests run from `nix develop` when the
`RWT_LIVE_*` secrets are set and need `RWT_LIVE_ACCOUNT_EMAIL`,
+54 -1
View File
@@ -40,6 +40,10 @@ add_executable(radiantwave
src/page_manager.cpp
src/home.cpp
src/settings.cpp
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
@@ -57,6 +61,11 @@ 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/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
@@ -112,7 +121,14 @@ add_executable(test_provisioning
tests/test_provisioning.cpp
)
target_include_directories(test_provisioning PRIVATE src)
target_link_libraries(test_provisioning PRIVATE SDL3::SDL3)
# provisioning.h pulls in app_state.h, so the test compiles against the SDL3,
# SDL3_mixer, and SQLite headers transitively (app_state.h -> audio_manager.h ->
# SDL3_mixer; app_state.h -> database_manager.h -> sqlite3.h).
target_link_libraries(test_provisioning PRIVATE
SDL3::SDL3
SDL3_mixer::SDL3_mixer
SQLite3::SQLite3
)
add_executable(test_assets
tests/test_assets.cpp
@@ -184,9 +200,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)
@@ -196,9 +214,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)
@@ -219,6 +239,39 @@ 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)
# 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)
# 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
+3 -2
View File
@@ -484,8 +484,9 @@ SQLite at `~/.local/share/radiantwave/database.sqlite` (WAL mode).
| ------- | --------- |
| app_config | Key-value store: license, email, audio device, volume |
| logs | Application logs |
| proclamations | Synced proclamation text |
| proclamation_categories | Category metadata |
| proclamations | Synced proclamation text (category_id + group_id) |
| proclamation_categories | Flat category metadata |
| proclamation_groups | Tag-like groups that organize proclamations within a category |
| solfeggio_frequencies | 9 frequency presets |
| audio_files | Downloaded audio files |
+59
View File
@@ -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"
]
}
]
}
+59
View File
@@ -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"]
}
]
}
+53
View File
@@ -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"]
}
]
}
+30
View File
@@ -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"]
}
]
}
@@ -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"]
}
]
}
+7
View File
@@ -0,0 +1,7 @@
{
"version": "sched-none-0001",
"type": null,
"daily": [],
"weekly": {},
"blocks": []
}
+53
View File
@@ -0,0 +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"]
}
]
}
+50
View File
@@ -0,0 +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": []
}
+63
View File
@@ -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": []
}
+619
View File
@@ -0,0 +1,619 @@
# 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=<email>&licenseKey=<key>`
(`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": "<text>" }`.
- **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": "<opaque string; also used as the ETag>",
"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": "<code>"}`).
- 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: "<version>"` 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=<email>&licenseKey=<key>` 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 (not Kiosk-facing)
These are session-authenticated Portal actions; they are **not** part of the
Kiosk API and are not served by `/api/v2/...`.
- 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.
---
## 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, 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.
2. **`blocks` for weekly.** Chosen as an empty array (server cannot know the
Kiosk timezone). Consequence: old Kiosks see weekly schedules as "no
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.** 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 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
(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.** 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.
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**.
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.
---
## 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.
- 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.
- 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.
- 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.
+55
View File
@@ -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`.
+164
View File
@@ -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.
+12
View File
@@ -112,6 +112,18 @@ in
export WLR_RENDERER=pixman
export WLR_NO_HARDWARE_CURSORS=1
# Prefer the PipeWire audio backend so SDL enumerates every sink
# (built-in analog, HDMI, and the loopback virtual speaker) instead of
# silently falling back to an ALSA-only device view that hides the
# PipeWire sinks and makes the device selector show a single entry.
export SDL_AUDIODRIVER=pipewire
if [ -n "$${XDG_RUNTIME_DIR:-}" ]; then
for i in {1..20}; do
[ -S "$XDG_RUNTIME_DIR/pipewire-0" ] && break
sleep 0.5
done
fi
cage -- ${radiantWaveApp}/bin/radiantwave > /tmp/kiosk.log 2>&1
fi
'';
+802
View File
@@ -0,0 +1,802 @@
openapi: 3.0.3
info:
title: Radiant Wave Portal API (v2)
version: 2.0.0
description: |
Read API used by the Radiant Wave kiosk plus the automation onboarding
webhook.
## Authentication
The v2 API is **not** API-key gated. The former bearer-token middleware in
`src/hooks.server.ts` is present but disabled, so v2 routes are served
without a global API key.
Two endpoints carry their own credential semantics:
- `GET /api/v2/proclamations` optionally accepts the paired
`x-license-key` / `x-email` headers to scope results to an entitled user.
Supplying neither returns the full catalogue; supplying only one, or a
pair that does not match, is rejected.
- `POST /api/v2/automations/create/account` is protected by an HMAC-SHA256
request signature (see the operation description).
servers:
- url: https://portal.radiantwavetech.com
description: Production
- url: http://localhost:5173
description: Local development
tags:
- name: Catalogue
description: Reference data for categories, groups, frequencies and palettes.
- name: Proclamations
description: Proclamation content, optionally scoped by license entitlement.
- name: License
description: License key validation.
- name: Schedule
description: A user's daily listening schedule, consumed by the kiosk.
- name: Automations
description: Signed machine-to-machine webhooks.
paths:
/api/v2/categories:
get:
tags: [Catalogue]
summary: List proclamation categories
operationId: listCategories
responses:
'200':
description: All proclamation categories.
content:
application/json:
schema:
type: array
items:
$ref: '#/components/schemas/Category'
/api/v2/proclamation-categories:
get:
tags: [Catalogue]
summary: List proclamation categories (dedicated alias)
description: Alias of `/api/v2/categories`; returns the identical payload.
operationId: listProclamationCategories
responses:
'200':
description: All proclamation categories.
content:
application/json:
schema:
type: array
items:
$ref: '#/components/schemas/Category'
/api/v2/groups:
get:
tags: [Catalogue]
summary: List proclamation groups
description: Groups are ordered by `sortOrder` ascending.
operationId: listGroups
responses:
'200':
description: All proclamation groups.
content:
application/json:
schema:
type: array
items:
$ref: '#/components/schemas/Group'
/api/v2/proclamation-groups:
get:
tags: [Catalogue]
summary: List proclamation groups (dedicated alias)
description: Alias of `/api/v2/groups`; returns the identical payload.
operationId: listProclamationGroups
responses:
'200':
description: All proclamation groups.
content:
application/json:
schema:
type: array
items:
$ref: '#/components/schemas/Group'
/api/v2/frequencies:
get:
tags: [Catalogue]
summary: List canonical solfeggio frequencies
description: |
Returns the nine canonical frequencies (ids 1-9) the kiosk expects.
These are static values, not the portal database autoincrement ids.
operationId: listFrequencies
responses:
'200':
description: The nine canonical frequencies.
content:
application/json:
schema:
type: array
minItems: 9
maxItems: 9
items:
$ref: '#/components/schemas/Frequency'
/api/v2/color-palettes:
get:
tags: [Catalogue]
summary: List color palettes per canonical frequency
description: |
Returns one 7-color palette for each canonical frequency id (1-9).
Palette rows stored in the database take precedence; ids without a row
fall back to the built-in canonical palette.
operationId: listColorPalettes
responses:
'200':
description: Nine palettes, one per canonical frequency id.
content:
application/json:
schema:
type: array
minItems: 9
maxItems: 9
items:
$ref: '#/components/schemas/ColorPalette'
/api/v2/proclamations:
get:
tags: [Proclamations]
summary: List proclamations
description: |
Without entitlement headers, returns the full proclamation catalogue.
With both `x-license-key` and `x-email`, returns only the proclamations
assigned to the matching user. Both headers must be present together and
the key must belong to the user.
Proclamations whose frequency has no canonical id (for example 936 Hz)
are omitted from the response.
operationId: listProclamations
parameters:
- in: header
name: x-license-key
schema:
type: string
required: false
description: License key. Must be supplied together with `x-email`.
- in: header
name: x-email
schema:
type: string
format: email
required: false
description: Account email. Must be supplied together with `x-license-key`.
responses:
'200':
description: Proclamation records with canonical frequency ids and embedded palettes.
content:
application/json:
schema:
type: array
items:
$ref: '#/components/schemas/Proclamation'
'400':
description: |
Missing entitlement or the supplied key/email pair is invalid.
Message is `not-authorized`, or
`this key doesn't appear to belong to you` when the key does not
belong to the specified user.
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorMessage'
/api/v2/license:
post:
tags: [License]
summary: Validate a license key
description: |
Validates that the license key exists, is active, and belongs to the
account matching `email_address`. A successful validation records
`lastValidatedAt` on the key.
operationId: validateLicense
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/LicenseRequest'
responses:
'200':
description: The validated key and its owner email.
content:
application/json:
schema:
$ref: '#/components/schemas/LicenseResponse'
'400':
description: |
Validation failure or malformed body. Possible messages:
`content-type must be application/json`, `invalid JSON body`,
`request body is required`, `license_key is required`,
`email_address is required`, `license key not found`,
`license key is not active`, `user does not exist`,
`key does not belong to specified user`.
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorMessage'
'413':
description: The request body exceeded the 64 KiB limit.
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/schedule:
get:
tags: [Schedule]
summary: Get a user's listening schedule (query-param auth)
description: |
Machine-to-app endpoint used by the RadiantWave kiosk. Authenticates via
the paired `email` + `licenseKey` **query parameters** and validates only
that the key belongs to the account for that email. License
active/entitlement is deliberately not checked here: an invalid license
would already lock the kiosk out, so this endpoint only checks the
pairing.
Returns every Block with its start/end times and the ordered list of
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
name: email
required: true
schema:
type: string
format: email
description: Account email.
- in: query
name: licenseKey
required: true
schema:
type: string
pattern: '^[A-Z0-9]{4}-[A-Z0-9]{4}-[A-Z0-9]{4}-[A-Z0-9]{4}$'
description: License key. The legacy alias `key` is also accepted.
responses:
'200':
description: The user's schedule (possibly empty).
content:
application/json:
schema:
$ref: '#/components/schemas/ScheduleResponse'
example:
email: jane@example.com
blocks:
- id: 3f1c2b7a-9d4e-4c8a-b2f6-1a2b3c4d5e6f
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
'400':
description: |
Missing/invalid parameters, unknown key or user, or the key does not
belong to the email's account. Possible messages: `not-authorized`,
`this key doesn't appear to belong to you`.
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}/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]
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]
summary: Create an account and license key (signed webhook)
description: |
Machine-to-machine onboarding webhook. The request must be signed with
HMAC-SHA256 using `API_V2_ONBOARDING_SECRET`.
The canonical string that is signed is the newline-joined concatenation
of:
```
POST
<path>
<x-timestamp>
<x-nonce>
<raw request body>
```
where `<path>` is `/api/v2/automations/create/account`. The signature is
the lowercase hex digest.
On success, creates the user if they do not already exist, generates a
license key, emails it, and returns the user id. Invalid or missing
signatures return `401 { "error": "invalid signature" }` (the same
response is used for every signature failure, including replay and
timestamp skew).
operationId: createAutomationAccount
parameters:
- in: header
name: x-timestamp
required: true
schema:
type: string
description: Unix epoch seconds. Must be within 300 seconds of the server clock.
- in: header
name: x-nonce
required: true
schema:
type: string
description: Unique value per request. Replays within the skew window are rejected.
- in: header
name: x-signature
required: true
schema:
type: string
pattern: '^[0-9a-f]{64}$'
description: Lowercase hex HMAC-SHA256 of the canonical request string.
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/OnboardingRequest'
responses:
'200':
description: Account and license key created.
content:
application/json:
schema:
$ref: '#/components/schemas/OnboardingResponse'
'400':
description: |
Malformed or invalid request. Possible messages:
`request body is required`, `invalid JSON body`, or
`email is required`.
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorMessage'
'401':
description: Signature missing, malformed, expired, replayed, or incorrect.
content:
application/json:
schema:
type: object
required: [error]
properties:
error:
type: string
example: invalid signature
'413':
description: The request body exceeded the 64 KiB limit.
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorMessage'
'429':
description: |
Rate limit exceeded. Limits are 5 requests per minute per IP and
3 requests per 24 hours per email address.
headers:
Retry-After:
schema:
type: integer
description: Seconds to wait before retrying.
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorMessage'
'500':
description: The account could not be created.
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorMessage'
components:
schemas:
Category:
type: object
required: [id, name, sortOrder]
properties:
id:
type: integer
name:
type: string
sortOrder:
type: integer
Group:
type: object
required: [id, name, sortOrder]
properties:
id:
type: integer
name:
type: string
sortOrder:
type: integer
Frequency:
type: object
required: [id, name, value]
properties:
id:
type: integer
minimum: 1
maximum: 9
description: Canonical id used by the kiosk.
name:
type: string
value:
type: integer
description: Frequency in Hz.
ColorPalette:
type: object
required: [solfeggioFrequencyId, colors]
properties:
solfeggioFrequencyId:
type: integer
minimum: 1
maximum: 9
colors:
type: array
description: Seven hex colors, ordered light to dark.
minItems: 1
items:
type: string
pattern: '^#[0-9A-Fa-f]{6}$'
Proclamation:
type: object
required: [id, name, content, categoryId, groupId, solfeggioFrequencyId]
properties:
id:
type: string
format: uuid
name:
type: string
content:
type: string
description: HTML content.
categoryId:
type: integer
groupId:
type: integer
solfeggioFrequencyId:
type: integer
minimum: 1
maximum: 9
colorPalette:
type: array
description: Present only when a palette exists for the canonical frequency id.
minItems: 1
items:
type: string
pattern: '^#[0-9A-Fa-f]{6}$'
LicenseRequest:
type: object
required: [license_key, email_address]
properties:
license_key:
type: string
pattern: '^[A-Z0-9]{4}-[A-Z0-9]{4}-[A-Z0-9]{4}-[A-Z0-9]{4}$'
example: ABCD-EFGH-IJKL-MNOP
email_address:
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, systemNameStatus]
properties:
key:
type: string
example: ABCD-EFGH-IJKL-MNOP
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:
[
id,
startTime,
startMinutes,
endTime,
endMinutes,
overnight,
durationMinutes,
dynamicAudio,
proclamationIds
]
properties:
id:
type: string
format: uuid
startTime:
type: string
example: '06:00'
description: Wall-clock start time as `HH:MM`.
startMinutes:
type: integer
minimum: 0
maximum: 1440
description: Minutes from midnight.
endTime:
type: string
example: '08:00'
description: Wall-clock end time as `HH:MM`.
endMinutes:
type: integer
minimum: 0
maximum: 1440
description: Minutes from midnight.
overnight:
type: boolean
description: True when the end clock time is at or before the start, i.e. the block crosses midnight.
durationMinutes:
type: integer
minimum: 1
description: Block length in minutes. An overnight block counts the wrap past midnight as the next day.
dynamicAudio:
type: boolean
description: |
When true, dynamic audio is enabled for the block's duration, overriding any
audio selections made on the device while the block is active.
proclamationIds:
type: array
description: IDs of the selected Proclamations, in play order.
items:
type: string
format: uuid
ScheduleResponse:
type: object
required: [email, blocks]
properties:
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
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:
type: object
required: [email]
properties:
email:
type: string
format: email
maxLength: 254
name:
type: string
maxLength: 120
description: Optional display name. Defaults to the email local-part.
OnboardingResponse:
type: object
required: [success, message, user_id]
properties:
success:
type: boolean
message:
type: string
example: Account and license key created
user_id:
type: string
ErrorMessage:
type: object
required: [message]
properties:
message:
type: string
-191
View File
@@ -1,191 +0,0 @@
# Portal API v2 — current schema
The kiosk talks to `https://portal.radiantwavetech.com/api/v2`. This document
describes the current contract after the move to an **active/inactive license
model**. There are no subscription levels: a license is either **valid**
(subscribed) or not, and the kiosk treats a successful `POST /license`
validation as the device's "subscribed" flag.
Base URL: `https://portal.radiantwavetech.com/api/v2`
Authentication: `Authorization: Bearer sk_<hex>` on every request (the API key
is baked into the kiosk binary at build time).
---
## `POST /license`
Validates a license key / email pair.
Request:
```
POST /api/v2/license
Content-Type: application/json
Authorization: Bearer sk_<hex>
```
```json
{
"license_key": "XXXX-XXXX-XXXX-XXXX",
"email_address": "user@example.com"
}
```
Response (200):
```json
{
"key": "XXXX-XXXX-XXXX-XXXX",
"email_address": "user@example.com"
}
```
Contract (kiosk `parseLicenseJson`):
- `key`: string, **required non-empty** (empty key ⇒ parse failure).
- `email_address`: string.
- Any other fields (e.g. the legacy `subscription_level`) are ignored. A 2xx
response means the license is valid and the subscription is **active**; the
kiosk stores no subscription state other than the `license_last_verified`
stamp that drives its 7-day offline grace window.
Errors:
| Code | Body | Meaning |
|------|------|---------|
| 200 | success object | License valid and owned by the email |
| 400 | `{"message": "license_key is required"}` | Missing `license_key` |
| 400 | `{"message": "email_address is required"}` | Missing `email_address` |
| 400 | `{"message": "license key not found"}` | No key with that value |
| 400 | `{"message": "license key is not active"}` | Key exists but inactive |
| 400 | `{"message": "user does not exist"}` | No user with that email |
| 400 | `{"message": "key does not belong to specified user"}` | Key owned by a different user |
| 401 | `{"error": "..."}` | Missing/invalid API key |
---
## `GET /proclamations`
Lists every proclamation. There is **no `subscriptionLevelId`** — any validated
license may access any proclamation.
Response (200):
```json
[
{
"id": "b40fdcb9-703c-420e-af32-a081964c6f08",
"name": "Peace & Relaxation",
"content": "<p>The text content, HTML formatted</p>",
"categoryId": 5,
"solfeggioFrequencyId": 5,
"colorPalette": ["#32CD32", "#3CB371", "#90EE90", "#98FB98", "#ADFFA9", "#C8FFD2", "#F0FFF0"]
}
]
```
Contract (kiosk `parseProclamationsJson` + `parseProclamationPalettesJson`):
- `id`: string UUID, **required non-empty** (empty ids are skipped).
- `name`: string, used as the display title. Kiosk strips HTML from it.
- `content`: string, **HTML** (the kiosk strips tags and unescapes entities
before rendering).
- `categoryId`: int, maps to a `/categories` id.
- `solfeggioFrequencyId`: int, **must be a canonical id (1–9)**.
- `colorPalette` (optional): array of exactly 7 `"#RRGGBB"` strings. When
absent, the kiosk falls back to `/color-palettes`, then to its own 528 Hz
seed.
- Legacy `subscriptionLevelId` fields in the payload are ignored.
Errors:
| Code | Body | Meaning |
|------|------|---------|
| 200 | array of proclamation records | Success |
| 400 | `{"message": "not-authorized"}` | Entitlement headers supplied but key or email does not exist |
| 401 | `{"error": "..."}` | Missing/invalid API key |
---
## `GET /categories`
Lists the proclamation categories.
Response (200):
```json
[
{
"id": 4,
"name": "Spiritual",
"parentId": 0,
"sortOrder": 0
}
]
```
Contract (kiosk `parseCategoriesJson`):
- `id`: int, **required non-zero** (zero ids are skipped).
- `name`: string.
- `parentId`: int, `null` maps to 0 (root).
- `sortOrder`: int.
---
## `POST /automations/create/account`
Creates (or reuses) a user account and issues a license key. **No
`subscription_level` is sent** — every created license is a standard active
license.
Request:
```
POST /api/v2/automations/create/account
Content-Type: application/json
Authorization: Bearer sk_<hex>
X-Timestamp: <unix seconds>
X-Nonce: <random uuid>
X-Signature: <hex HMAC-SHA256 of canonical request with the onboarding secret>
```
```json
{
"email": "user@example.com",
"name": "Optional Display Name"
}
```
- `email` (required, string).
- `name` (optional, string; defaults to the local part of `email`).
Response (200):
```json
{
"success": true,
"message": "Account and license key created",
"user_id": "uuid"
}
```
Errors:
| Code | Body | Meaning |
|------|------|---------|
| 200 | success object | Account (+ license key) created |
| 400 | `{"message": "email is required"}` | Missing/empty `email` |
| 401 | `{"error": "invalid signature"}` | Bad/expired/replayed HMAC signature |
| 429 | `{"message": "rate limit exceeded"}` | Too many requests from this IP/email |
| 500 | `{"message": "Failed to create account"}` | User creation failed |
---
## Readiness routes (not yet wired into the app)
- `GET /color-palettes` → `[{solfeggioFrequencyId, colors[7]}]`
- `GET /frequencies` → canonical id 1–9 + `value` in Hz
These exist for future sync steps; the kiosk does not call them yet.
+4
View File
@@ -7,6 +7,8 @@
#include "db/database_manager.h"
#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"
@@ -19,6 +21,8 @@ struct AppState {
radiantwave::db::DatabaseManager db;
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;
+388 -31
View File
@@ -32,6 +32,7 @@ namespace radiantwave::audio
if (lower.find("analog") != std::string::npos ||
lower.find("built-in") != std::string::npos ||
lower.find("builtin") != std::string::npos ||
lower.find("virtual") != std::string::npos ||
lower.find("realtek") != std::string::npos ||
lower.find("hda") != std::string::npos ||
lower.find("speaker") != std::string::npos ||
@@ -76,7 +77,6 @@ namespace radiantwave::audio
enabled_ = cfg.audio.enabled;
currentDevice_ = cfg.audio.output_device;
currentTrack_ = cfg.audio.current_track;
if (!MIX_Init())
{
std::string err = SDL_GetError();
@@ -86,17 +86,68 @@ namespace radiantwave::audio
return false;
}
// Create mixer device with default audio device
mixer_ = MIX_CreateMixerDevice(SDL_AUDIO_DEVICE_DEFAULT_PLAYBACK, nullptr);
// Startup diagnostics: which audio backend SDL picked (PipeWire, ALSA,
// Pulse, dummy, ...) and what playback devices it actually sees. This is
// the first place a "only the built-in speakers show up" report points;
// a single device here means SDL itself never enumerated the HDMI sink.
const char *driver = SDL_GetCurrentAudioDriver();
std::string driverName = driver ? driver : "(none)";
int deviceCount = 0;
SDL_AudioDeviceID *devices = SDL_GetAudioPlaybackDevices(&deviceCount);
std::string deviceNames;
if (devices)
{
for (int i = 0; i < deviceCount; ++i)
{
if (i)
{
deviceNames += ", ";
}
const char *n = SDL_GetAudioDeviceName(devices[i]);
deviceNames += n ? n : "(unnamed)";
}
SDL_free(devices);
}
logger::Logger::instance().info(
"Audio",
"SDL audio driver: " + driverName + "; " +
std::to_string(deviceCount) + " playback device(s) [" +
(deviceNames.empty() ? "(none)" : deviceNames) + "]");
// Try to open the mixer on the device previously persisted in config;
// fall back to the system default when the configured device is gone.
SDL_AudioDeviceID targetId = SDL_AUDIO_DEVICE_DEFAULT_PLAYBACK;
if (!currentDevice_.empty())
{
int count = 0;
SDL_AudioDeviceID *devices = SDL_GetAudioPlaybackDevices(&count);
if (devices)
{
for (int i = 0; i < count; ++i)
{
const char *name = SDL_GetAudioDeviceName(devices[i]);
if (name && currentDevice_ == name)
{
targetId = devices[i];
break;
}
}
SDL_free(devices);
}
}
mixer_ = MIX_CreateMixerDevice(targetId, nullptr);
if (!mixer_)
{
std::string err = SDL_GetError();
logger::Logger::instance().warning("Audio",
"Could not open default audio playback device. Check speaker connections (MIX_CreateMixerDevice error: " + err + ")");
"Could not open audio playback device. Check speaker connections (MIX_CreateMixerDevice error: " + err + ")");
initialized_ = false;
return false;
}
currentDeviceId_ = targetId;
slots_[0].track = MIX_CreateTrack(mixer_);
slots_[1].track = MIX_CreateTrack(mixer_);
initialized_ = true;
@@ -135,6 +186,40 @@ namespace radiantwave::audio
return enabled_ ? (volume_ / 100.0f) : 0.0f;
}
SDL_PropertiesID AudioManager::createInfiniteLoopOptions()
{
// MIX_PlayTrack()'s options default to a single pass; requesting
// infinite loops is what keeps a track going until it is explicitly
// faded out or the app shuts down.
SDL_PropertiesID props = SDL_CreateProperties();
if (props)
{
SDL_SetNumberProperty(props, MIX_PROP_PLAY_LOOPS_NUMBER, -1);
}
return props;
}
bool AudioManager::playTrackLooping(int slotIndex)
{
if (!initialized_ || !mixer_ || slotIndex < 0 || slotIndex >= 2 ||
!slots_[slotIndex].track)
{
return false;
}
SDL_PropertiesID props = createInfiniteLoopOptions();
if (!props)
{
logger::Logger::instance().error(
"Audio", "Failed to create playback options for " +
displayNameFor(currentTrack_));
return false;
}
bool ok = MIX_PlayTrack(slots_[slotIndex].track, props);
SDL_DestroyProperties(props);
return ok;
}
void AudioManager::shutdown()
{
std::lock_guard<std::mutex> lock(mutex_);
@@ -181,18 +266,62 @@ namespace radiantwave::audio
{
const char *name = SDL_GetAudioDeviceName(devices[i]);
std::string raw = name ? name : "";
list.push_back({raw, mapToFriendlyDeviceName(raw)});
AudioDeviceItem item;
item.raw_name = raw;
item.friendly_name = mapToFriendlyDeviceName(raw);
item.id = devices[i];
list.push_back(item);
}
SDL_free(devices);
}
else
{
list.push_back({"", "Built-In Speakers"});
if (devices)
{
SDL_free(devices);
}
const char *driver = SDL_GetCurrentAudioDriver();
// Zero enumerated devices is a symptom, not a real output. Surface
// the truth (a single "System Default" entry that still routes to
// whatever the OS default is) instead of pretending a fabricated
// device, and say which backend was active so kiosk logs show why.
logger::Logger::instance().debug(
"Audio",
"No playback devices enumerated (audio driver: " +
std::string(driver ? driver : "none") +
"); falling back to the system default");
list.push_back({"", "System Default", 0});
}
return list;
}
std::string AudioManager::resolvePlaybackDeviceName(SDL_AudioDeviceID id)
{
if (id == 0 || id == SDL_AUDIO_DEVICE_DEFAULT_PLAYBACK)
{
return "";
}
int count = 0;
SDL_AudioDeviceID *devices = SDL_GetAudioPlaybackDevices(&count);
std::string found;
if (devices)
{
for (int i = 0; i < count; ++i)
{
if (devices[i] == id)
{
const char *name = SDL_GetAudioDeviceName(devices[i]);
found = name ? name : "";
break;
}
}
SDL_free(devices);
}
return found;
}
void AudioManager::setVolume(int volume_0_to_100)
{
std::lock_guard<std::mutex> lock(mutex_);
@@ -253,14 +382,76 @@ namespace radiantwave::audio
bool AudioManager::switchDevice(const std::string &raw_device_name)
{
std::lock_guard<std::mutex> lock(mutex_);
currentDevice_ = raw_device_name;
logger::Logger::instance().debug("Audio", "Switched audio output device to: " + mapToFriendlyDeviceName(raw_device_name));
// Resolve the config's raw name to an SDL device id so the mixer is
// actually reopened on the requested device (""/unresolvable = default).
SDL_AudioDeviceID targetId = SDL_AUDIO_DEVICE_DEFAULT_PLAYBACK;
if (!raw_device_name.empty())
{
int count = 0;
SDL_AudioDeviceID *devices = SDL_GetAudioPlaybackDevices(&count);
if (devices)
{
for (int i = 0; i < count; ++i)
{
const char *name = SDL_GetAudioDeviceName(devices[i]);
if (name && raw_device_name == name)
{
targetId = devices[i];
break;
}
}
SDL_free(devices);
}
}
return switchDeviceById(targetId);
}
bool AudioManager::switchDeviceById(SDL_AudioDeviceID id)
{
std::lock_guard<std::mutex> lock(mutex_);
// 0 is the synthetic "default / built-in" entry from getPlaybackDevices().
if (id == 0)
{
id = SDL_AUDIO_DEVICE_DEFAULT_PLAYBACK;
}
// No mixer yet: just record the choice for initialize().
if (!mixer_ || !initialized_)
{
currentDeviceId_ = id;
currentDevice_ = resolvePlaybackDeviceName(id);
if (configManager_)
{
configManager_->update([name = currentDevice_](config::AppConfig &cfg)
{ cfg.audio.output_device = name; });
}
return true;
}
if (!recreateMixerOnDevice(id))
{
logger::Logger::instance().error("Audio",
"Failed to switch audio output device to: " + mapToFriendlyDeviceName(resolvePlaybackDeviceName(id)));
return false;
}
currentDeviceId_ = id;
// After recreation SDL may have normalized the id (e.g. default).
if (SDL_GetAudioDeviceName(id) == nullptr && id != SDL_AUDIO_DEVICE_DEFAULT_PLAYBACK)
{
currentDeviceId_ = SDL_AUDIO_DEVICE_DEFAULT_PLAYBACK;
}
currentDevice_ = resolvePlaybackDeviceName(currentDeviceId_);
logger::Logger::instance().info("Audio", "Switched audio output device to: " + mapToFriendlyDeviceName(currentDevice_));
if (configManager_)
{
configManager_->update([raw_device_name](config::AppConfig &cfg)
{ cfg.audio.output_device = raw_device_name; });
configManager_->update([name = currentDevice_](config::AppConfig &cfg)
{ cfg.audio.output_device = name; });
}
return true;
@@ -272,6 +463,150 @@ namespace radiantwave::audio
return currentDevice_;
}
SDL_AudioDeviceID AudioManager::getCurrentDeviceId() const
{
std::lock_guard<std::mutex> lock(mutex_);
return currentDeviceId_;
}
bool AudioManager::handleDeviceEvent(const SDL_Event &event)
{
if (event.type != SDL_EVENT_AUDIO_DEVICE_ADDED &&
event.type != SDL_EVENT_AUDIO_DEVICE_REMOVED)
{
return false;
}
std::lock_guard<std::mutex> lock(mutex_);
if (!mixer_ || !initialized_)
{
return false;
}
// Only playback device events are relevant to the active output.
if (event.adevice.recording)
{
return false;
}
const SDL_AudioDeviceID changedId = event.adevice.which;
// If a specific device (not the default) was removed, fail over to the
// system default so playback keeps going on whatever remains.
if (event.type == SDL_EVENT_AUDIO_DEVICE_REMOVED &&
currentDeviceId_ != SDL_AUDIO_DEVICE_DEFAULT_PLAYBACK &&
changedId == currentDeviceId_)
{
logger::Logger::instance().warning("Audio",
"Active audio output device removed; failing over to the system default");
if (recreateMixerOnDevice(SDL_AUDIO_DEVICE_DEFAULT_PLAYBACK))
{
currentDeviceId_ = SDL_AUDIO_DEVICE_DEFAULT_PLAYBACK;
currentDevice_ = "";
}
return true;
}
// A device arrived or a non-active device left: nothing to remap, but
// the audio settings page refreshes its list on its own timer.
return false;
}
// Must be called with mutex_ held. Creates a fresh mixer on the given
// device, recreating the two track slots and resuming the active track if
// one was playing.
//
// The new mixer is created BEFORE the current one is destroyed. Reversing
// the order (destroy-then-reopen) quits the whole SDL audio subsystem the
// moment the last device closes: MIX_DestroyMixer() follows up with
// SDL_QuitSubSystem(SDL_INIT_AUDIO), which tears down SDL's device registry
// and renumbers every enumerated device id on the way back up. The id this
// switch was asked to open is then stale and the reopen fails with
// "Invalid audio device instance ID". Keeping the current device open while
// the replacement opens also means a failed switch leaves the current
// output playing instead of going permanently mute.
bool AudioManager::recreateMixerOnDevice(SDL_AudioDeviceID targetId)
{
// Capture the playback state we want to preserve across the switch.
std::string resumeTrack = currentTrack_;
float fadeSeconds = slots_[activeSlot_].fadeInDuration > 0.0f
? slots_[activeSlot_].fadeInDuration
: 0.0f;
// Open the replacement mixer on the target device first, while the
// current device (and SDL's device registry) is still alive.
MIX_Mixer *newMixer = MIX_CreateMixerDevice(targetId, nullptr);
if (!newMixer)
{
std::string err = SDL_GetError();
logger::Logger::instance().error(
"Audio",
"Could not open audio output device "
+ mapToFriendlyDeviceName(resolvePlaybackDeviceName(targetId))
+ "; keeping the current device (MIX_CreateMixerDevice error: "
+ err + ")");
return false;
}
MIX_Track *newTracks[2];
for (int i = 0; i < 2; ++i)
{
newTracks[i] = MIX_CreateTrack(newMixer);
}
if (!newTracks[0] || !newTracks[1])
{
MIX_DestroyMixer(newMixer);
logger::Logger::instance().error(
"Audio", "Could not create mixer tracks; keeping the current device (SDL error: "
+ std::string(SDL_GetError()) + ")");
return false;
}
// Tear down the existing mixer (its tracks die with it) and adopt the
// new one.
for (int i = 0; i < 2; ++i)
{
slots_[i].track = nullptr;
if (slots_[i].audio)
{
MIX_DestroyAudio(slots_[i].audio);
slots_[i].audio = nullptr;
}
}
if (mixer_)
{
MIX_DestroyMixer(mixer_);
mixer_ = nullptr;
}
mixer_ = newMixer;
slots_[0].track = newTracks[0];
slots_[1].track = newTracks[1];
float gain = enabled_ ? (volume_ / 100.0f) : 0.0f;
MIX_SetMixerGain(mixer_, gain);
// Resume the previously active track at full gain (or restart its
// fade-in if it was still ramping).
if (!resumeTrack.empty())
{
if (loadTrackInto(activeSlot_, resumeTrack))
{
if (fadeSeconds > 0.0f)
{
startFadeIn(activeSlot_, fadeSeconds);
}
else
{
MIX_SetTrackGain(slots_[activeSlot_].track, 1.0f);
playTrackLooping(activeSlot_); // Loop playback
}
}
}
return true;
}
bool AudioManager::loadTrackInto(int slotIndex, const std::string &wav_filename)
{
if (!initialized_ || !mixer_ || !slots_[slotIndex].track)
@@ -322,7 +657,7 @@ namespace radiantwave::audio
slot.fadeInDuration = seconds;
slot.fadeInElapsed = 0.0f;
MIX_SetTrackGain(slot.track, 0.0f);
MIX_PlayTrack(slot.track, 0); // Loop playback
playTrackLooping(slotIndex); // Loop playback
logger::Logger::instance().info("Audio", "Started playback of " + displayNameFor(currentTrack_) + " (fade-in)");
}
@@ -336,7 +671,7 @@ namespace radiantwave::audio
return false;
MIX_SetTrackGain(slots_[activeSlot_].track, 1.0f);
MIX_PlayTrack(slots_[activeSlot_].track, 0); // Loop playback
playTrackLooping(activeSlot_); // Loop playback
// Explicit user selection: persist the manual track so it survives a
// restart and is used whenever dynamic audio is turned off. Dynamic
@@ -373,10 +708,11 @@ namespace radiantwave::audio
slot.fadeInElapsed = 0.0f;
slot.fadeOutRemaining = 0.0f;
MIX_SetTrackGain(slot.track, 1.0f);
if (!MIX_TrackPlaying(slot.track))
{
MIX_PlayTrack(slot.track, 0); // Loop playback
}
// Always restart: MIX_PlayTrack() re-starts a playing track too, so
// this cancels any in-flight stop-fade inherited from a quick
// pop->push within the fade window. Gating on MIX_TrackPlaying()
// left a fading track to finish its fade and go silently stopped.
playTrackLooping(activeSlot_); // Loop playback
return true;
}
@@ -411,10 +747,14 @@ namespace radiantwave::audio
if (!loadTrackInto(otherSlot, wav_filename))
return false;
// Fade out whatever is currently playing on the active slot.
// Fade out whatever is currently playing on the active slot. Clear any
// in-flight fade-in bookkeeping first so update() stops ramping this
// slot's gain up while the mixer fades it down.
TrackSlot &active = slots_[activeSlot_];
if (active.audio)
{
active.fadeInDuration = 0.0f;
active.fadeInElapsed = 0.0f;
MIX_StopTrack(active.track, MIX_TrackMSToFrames(active.track, static_cast<Sint64>(fade * 1000.0)));
active.fadeOutRemaining = fade;
}
@@ -442,6 +782,10 @@ namespace radiantwave::audio
{
continue;
}
// The mixer owns the ramp now; drop any pending fade-in so the
// per-frame update() cannot fight the fade-out.
slot.fadeInDuration = 0.0f;
slot.fadeInElapsed = 0.0f;
MIX_StopTrack(slot.track, MIX_TrackMSToFrames(slot.track, static_cast<Sint64>(fade * 1000.0)));
slot.fadeOutRemaining = fade;
faded = true;
@@ -476,7 +820,29 @@ namespace radiantwave::audio
{
TrackSlot &slot = slots_[i];
if (!slot.track)
{
continue;
}
// A slot mid stop-fade belongs to the mixer: skip both the fade-in
// ramp and the active-gain pin so nothing drives its gain up while
// the mixer ramps it down (this also keeps a quit/pop fade clean).
if (slot.fadeOutRemaining > 0.0f)
{
slot.fadeOutRemaining -= dt;
if (slot.fadeOutRemaining <= 0.0f)
{
slot.fadeOutRemaining = 0.0f;
// Free the audio once the fade-out has finished; the track
// object itself is reused.
if (slot.audio)
{
MIX_DestroyAudio(slot.audio);
slot.audio = nullptr;
}
}
continue;
}
if (slot.fadeInDuration > 0.0f)
{
@@ -493,21 +859,12 @@ namespace radiantwave::audio
// Keep the active track at full per-track gain; the mixer gain
// carries the user's volume / mute state.
MIX_SetTrackGain(slot.track, 1.0f);
}
if (slot.fadeOutRemaining > 0.0f)
{
slot.fadeOutRemaining -= dt;
if (slot.fadeOutRemaining <= 0.0f)
// Self-heal: playback must continue indefinitely, so restart a
// track that stopped unexpectedly (there is no pause state and
// nothing else should legitimately stop the active track).
if (slot.audio && enabled_ && !MIX_TrackPlaying(slot.track))
{
slot.fadeOutRemaining = 0.0f;
// Free the audio once the fade-out has finished; the track
// object itself is reused.
if (slot.audio)
{
MIX_DestroyAudio(slot.audio);
slot.audio = nullptr;
}
playTrackLooping(i);
}
}
}
+35
View File
@@ -1,6 +1,7 @@
#pragma once
#include "config/config_manager.h"
#include <SDL3/SDL.h>
#include <SDL3_mixer/SDL_mixer.h>
#include <mutex>
#include <string>
@@ -15,6 +16,7 @@ namespace radiantwave::audio {
struct AudioDeviceItem {
std::string raw_name; // e.g. alsa_output.pci-0000_00_1f.3.analog-stereo
std::string friendly_name; // e.g. "Built-In Speakers"
SDL_AudioDeviceID id = 0; // SDL playback device id (0 = synthetic fallback)
};
// Maps raw technical audio device strings to user-friendly labels
@@ -51,7 +53,24 @@ public:
// Audio device selection
bool switchDevice(const std::string& raw_device_name);
bool switchDeviceById(SDL_AudioDeviceID id);
std::string getCurrentDevice() const;
SDL_AudioDeviceID getCurrentDeviceId() const;
// Handle an SDL audio device event (SDL_EVENT_AUDIO_DEVICE_ADDED / REMOVED).
// Returns true if the active device was affected and the mixer was recreated.
bool handleDeviceEvent(const SDL_Event& event);
// Pure helper (no SDL audio open): find the playback device with the given
// SDL id, returning its raw name ("" when not found / id invalid). Exposed
// for tests; the real lookup is through getPlaybackDevices().
static std::string resolvePlaybackDeviceName(SDL_AudioDeviceID id);
// Builds the SDL_PropertiesID passed to MIX_PlayTrack() for tracks that
// loop forever (MIX_PROP_PLAY_LOOPS_NUMBER = -1). Exposed for tests; no
// mixer, track, or audio device is required. The caller owns the property
// set and must release it with SDL_DestroyProperties().
static SDL_PropertiesID createInfiniteLoopOptions();
// Playback control
bool playTrack(const std::string& wav_filename);
@@ -84,6 +103,21 @@ private:
void startFadeIn(int slotIndex, float seconds);
std::string displayNameFor(const std::string& wav_filename) const;
// Starts the track on the given slot with infinite looping. The track must
// already be loaded on the slot. Calling this on a playing/stopped/fading
// track restarts it (cancelling any in-flight stop-fade).
bool playTrackLooping(int slotIndex);
// Recreates the mixer on the given playback device id (0 = system default).
// Called with mutex_ held. Preserves playback state: whichever track was
// playing on the active slot is reloaded and resumed at full gain, an
// in-progress fade is restarted. The replacement mixer is opened before the
// current one is destroyed so SDL's device ids stay valid (destroy-first
// quits the whole audio subsystem and orphans the id being opened).
// Returns false on failure without touching the current mixer, so the
// existing output keeps playing.
bool recreateMixerOnDevice(SDL_AudioDeviceID targetId);
config::ConfigManager* configManager_ = nullptr;
db::DatabaseManager* db_ = nullptr;
@@ -91,6 +125,7 @@ private:
bool enabled_ = true;
int volume_ = 80; // 0 - 100
std::string currentDevice_ = "";
SDL_AudioDeviceID currentDeviceId_ = SDL_AUDIO_DEVICE_DEFAULT_PLAYBACK;
std::string currentTrack_ = "";
MIX_Mixer* mixer_ = nullptr;
+17 -4
View File
@@ -77,7 +77,7 @@ void AudioSettingsPage::onPush(AppState& state) {
} else if (modalState_ == ModalState::DeviceSelector) {
if (!devices_.empty()) {
currentDeviceIndex_ = modalSelectedIndex_;
state_->audio.switchDevice(devices_[currentDeviceIndex_].raw_name);
applyDeviceSelection();
}
modalState_ = ModalState::None;
} else if (modalState_ == ModalState::TrackSelector) {
@@ -156,7 +156,7 @@ void AudioSettingsPage::onResume(AppState& state) {
} else if (modalState_ == ModalState::DeviceSelector) {
if (!devices_.empty()) {
currentDeviceIndex_ = modalSelectedIndex_;
state_->audio.switchDevice(devices_[currentDeviceIndex_].raw_name);
applyDeviceSelection();
}
modalState_ = ModalState::None;
} else if (modalState_ == ModalState::TrackSelector) {
@@ -280,6 +280,19 @@ void AudioSettingsPage::refreshDeviceAndTrackLists() {
}
}
void AudioSettingsPage::applyDeviceSelection() {
if (!state_) return;
if (currentDeviceIndex_ < 0 || currentDeviceIndex_ >= static_cast<int>(devices_.size())) return;
const bool ok = state_->audio.switchDeviceById(devices_[currentDeviceIndex_].id);
if (ok) {
statusMessage_ = "Switched to " + devices_[currentDeviceIndex_].friendly_name;
} else {
statusMessage_ = "Device unavailable - kept the current output";
}
refreshDeviceAndTrackLists();
}
void AudioSettingsPage::enableDynamicAudio() {
if (!state_) return;
state_->config.update([](radiantwave::config::AppConfig& cfg) {
@@ -324,7 +337,7 @@ void AudioSettingsPage::handleEvent(const SDL_Event& event) {
if (modalState_ == ModalState::DeviceSelector) {
if (!devices_.empty()) {
currentDeviceIndex_ = modalSelectedIndex_;
state_->audio.switchDevice(devices_[currentDeviceIndex_].raw_name);
applyDeviceSelection();
}
} else if (modalState_ == ModalState::TrackSelector) {
if (modalSelectedIndex_ == 0) {
@@ -518,7 +531,7 @@ void AudioSettingsPage::render(SDL_Renderer* renderer) {
}
std::string devName = (currentDeviceIndex_ >= 0 && currentDeviceIndex_ < static_cast<int>(devices_.size()))
? devices_[currentDeviceIndex_].friendly_name : "Built-In Speakers";
? devices_[currentDeviceIndex_].friendly_name : "System Default";
deviceText_.ensure(renderer, fontHeader_, "Device: " + devName, white);
if (deviceText_.texture) {
SDL_FRect dst{500.0f, currentY, deviceText_.width, deviceText_.height};
+4
View File
@@ -36,6 +36,10 @@ private:
void enableDynamicAudio();
void selectTrack(int trackIdx);
// Applies the currently selected device (devices_[currentDeviceIndex_]) via
// the audio manager and records user-facing success/failure feedback.
void applyDeviceSelection();
AppState* state_ = nullptr;
int selectedItem_ = 0; // 0 = Volume, 1 = Device, 2 = Track, 3 = Status, 4 = Back
+2
View File
@@ -16,6 +16,8 @@ struct ProclamationConfig {
float scroll_speed = 1.0f; // scroll speed multiplier
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 {
+6
View File
@@ -250,6 +250,12 @@ void ConfigManager::loadAndMergeDefaults() {
if (proc_node.hasChild("current_id")) {
merged.proclamation.current_id = proc_node.getChild("current_id").asString(defaults.proclamation.current_id);
}
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);
}
}
{
+8
View File
@@ -231,6 +231,12 @@ AppConfig parseConfigFromYaml(const std::string& yamlContent) {
if (proc_node.hasChild("current_id")) {
config.proclamation.current_id = proc_node.getChild("current_id").asString(config.proclamation.current_id);
}
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;
@@ -256,6 +262,8 @@ std::string serializeConfigToYaml(const AppConfig& config) {
ss << " scroll_speed: " << config.proclamation.scroll_speed << "\n";
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();
}
+33
View File
@@ -1,5 +1,7 @@
#include "date_format.h"
#include <cctype>
#include <cstdio>
#include <ctime>
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<unsigned char>(hm24[0])) ||
!std::isdigit(static_cast<unsigned char>(hm24[1])) ||
!std::isdigit(static_cast<unsigned char>(hm24[3])) ||
!std::isdigit(static_cast<unsigned char>(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
+9
View File
@@ -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
+281 -18
View File
@@ -131,6 +131,7 @@ bool DatabaseManager::runMigrations() {
is_active INTEGER DEFAULT 1,
selected INTEGER DEFAULT 0,
category_id INTEGER DEFAULT 0,
group_id INTEGER DEFAULT 0,
solfeggio_frequency_id INTEGER DEFAULT 0,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
@@ -138,7 +139,12 @@ bool DatabaseManager::runMigrations() {
CREATE TABLE IF NOT EXISTS proclamation_categories (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL,
parent_id INTEGER DEFAULT 0,
sort_order INTEGER DEFAULT 0
);
CREATE TABLE IF NOT EXISTS proclamation_groups (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL,
sort_order INTEGER DEFAULT 0
);
@@ -175,6 +181,23 @@ bool DatabaseManager::runMigrations() {
hex TEXT NOT NULL,
PRIMARY KEY (solfeggio_frequency_id, color_index)
);
CREATE TABLE IF NOT EXISTS schedule_blocks (
id TEXT PRIMARY KEY,
start_minutes INTEGER NOT NULL DEFAULT 0,
end_minutes INTEGER NOT NULL DEFAULT 0,
overnight INTEGER NOT NULL DEFAULT 0,
duration_minutes INTEGER NOT NULL DEFAULT 0,
dynamic_audio INTEGER NOT NULL DEFAULT 1,
sort_order INTEGER NOT NULL DEFAULT 0
);
CREATE TABLE IF NOT EXISTS schedule_block_proclamations (
block_id TEXT NOT NULL,
position INTEGER NOT NULL,
proclamation_id TEXT NOT NULL,
PRIMARY KEY (block_id, position)
);
)";
bool ok = executeSql(schema);
@@ -182,6 +205,7 @@ bool DatabaseManager::runMigrations() {
// Back-fill columns for databases created before these fields existed.
ensureColumn("proclamations", "selected", "selected INTEGER DEFAULT 0");
ensureColumn("proclamations", "category_id", "category_id INTEGER DEFAULT 0");
ensureColumn("proclamations", "group_id", "group_id INTEGER DEFAULT 0");
ensureColumn("proclamations", "solfeggio_frequency_id", "solfeggio_frequency_id INTEGER DEFAULT 0");
ensureColumn("audio_files", "solfeggio_frequency_id", "solfeggio_frequency_id INTEGER DEFAULT 0");
// Clean up the pre-active/inactive-license subscription level keys.
@@ -189,12 +213,72 @@ bool DatabaseManager::runMigrations() {
executeSql("DELETE FROM system_kv WHERE key IN "
"('subscription_level_id','subscription_level_name','subscription_level_description');");
}
if (ok) {
ok = migrateSubCategoriesToGroups();
}
if (ok) {
seedColorPalettes();
}
return ok;
}
// Schema migration v2: sub-categories -> tag-like groups.
//
// Pre-v2 databases modelled a two-level category tree via
// `proclamation_categories.parent_id`, and proclamations pointed at either a
// root or a sub-category. v2 flattens categories and turns each former
// sub-category into a group, remapping the proclamations that referenced it to
// its parent category + the new group. Selection flags are never touched, so
// the user's picks survive the upgrade. `PRAGMA user_version` records the
// migration so it runs exactly once; the whole thing is one transaction so a
// failure leaves the database untouched. No network is required, so this
// always runs at startup, including on offline kiosks.
bool DatabaseManager::migrateSubCategoriesToGroups() {
if (!db_) return false;
int userVersion = 0;
{
sqlite3_stmt* stmt = nullptr;
if (sqlite3_prepare_v2(db_, "PRAGMA user_version;", -1, &stmt, nullptr) == SQLITE_OK) {
if (sqlite3_step(stmt) == SQLITE_ROW) {
userVersion = sqlite3_column_int(stmt, 0);
}
sqlite3_finalize(stmt);
}
}
if (userVersion >= 2) {
return true; // already migrated
}
// Fresh databases (created by the v2 schema) have no legacy parent_id
// column, so there is nothing to convert: just stamp the version.
if (!columnExists("proclamation_categories", "parent_id")) {
return executeSql("PRAGMA user_version = 2;");
}
bool migrated = executeSql(
"BEGIN;"
"INSERT OR IGNORE INTO proclamation_groups (id, name, sort_order) "
" SELECT id, name, sort_order FROM proclamation_categories WHERE parent_id != 0;"
"UPDATE proclamations "
" SET group_id = category_id, "
" category_id = COALESCE("
" (SELECT parent_id FROM proclamation_categories "
" WHERE id = proclamations.category_id), "
" category_id) "
" WHERE category_id IN "
" (SELECT id FROM proclamation_categories WHERE parent_id != 0);"
"DELETE FROM proclamation_categories WHERE parent_id != 0;"
"PRAGMA user_version = 2;"
"COMMIT;");
if (!migrated) {
executeSql("ROLLBACK;");
}
return migrated;
}
void DatabaseManager::seedColorPalettes() {
static const char* kSeedSql =
"INSERT OR IGNORE INTO color_palettes (solfeggio_frequency_id, color_index, hex) VALUES "
@@ -337,7 +421,8 @@ void DatabaseManager::bindProclamationRecord(sqlite3_stmt* stmt, const Proclamat
sqlite3_bind_int(stmt, 6, record.is_active ? 1 : 0);
sqlite3_bind_int(stmt, 7, record.selected ? 1 : 0);
sqlite3_bind_int(stmt, 8, record.category_id);
sqlite3_bind_int(stmt, 9, record.solfeggio_frequency_id);
sqlite3_bind_int(stmt, 9, record.group_id);
sqlite3_bind_int(stmt, 10, record.solfeggio_frequency_id);
}
bool DatabaseManager::saveProclamation(const ProclamationRecord& record) {
@@ -345,12 +430,13 @@ bool DatabaseManager::saveProclamation(const ProclamationRecord& record) {
if (!db_) return false;
const char* sql = "INSERT INTO proclamations (id, title, content, solfeggio_freq_hz, category, "
"is_active, selected, category_id, solfeggio_frequency_id) "
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) "
"is_active, selected, category_id, group_id, solfeggio_frequency_id) "
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) "
"ON CONFLICT(id) DO UPDATE SET title=excluded.title, content=excluded.content, "
"solfeggio_freq_hz=excluded.solfeggio_freq_hz, category=excluded.category, "
"is_active=excluded.is_active, selected=excluded.selected, "
"category_id=excluded.category_id, solfeggio_frequency_id=excluded.solfeggio_frequency_id;";
"category_id=excluded.category_id, group_id=excluded.group_id, "
"solfeggio_frequency_id=excluded.solfeggio_frequency_id;";
sqlite3_stmt* stmt = nullptr;
if (sqlite3_prepare_v2(db_, sql, -1, &stmt, nullptr) != SQLITE_OK) {
@@ -374,7 +460,8 @@ static ProclamationRecord readProclamationRecord(sqlite3_stmt* stmt, int firstCo
rec.is_active = (sqlite3_column_int(stmt, firstCol + 5) != 0);
rec.selected = (sqlite3_column_int(stmt, firstCol + 6) != 0);
rec.category_id = sqlite3_column_int(stmt, firstCol + 7);
rec.solfeggio_frequency_id = sqlite3_column_int(stmt, firstCol + 8);
rec.group_id = sqlite3_column_int(stmt, firstCol + 8);
rec.solfeggio_frequency_id = sqlite3_column_int(stmt, firstCol + 9);
return rec;
}
@@ -384,7 +471,7 @@ std::vector<ProclamationRecord> DatabaseManager::getActiveProclamations() {
if (!db_) return result;
const char* sql = "SELECT id, title, content, solfeggio_freq_hz, category, is_active, "
"selected, category_id, solfeggio_frequency_id "
"selected, category_id, group_id, solfeggio_frequency_id "
"FROM proclamations WHERE is_active = 1;";
sqlite3_stmt* stmt = nullptr;
if (sqlite3_prepare_v2(db_, sql, -1, &stmt, nullptr) != SQLITE_OK) {
@@ -414,8 +501,8 @@ bool DatabaseManager::replaceCategories(const std::vector<ProclamationCategoryRe
return false;
}
const char* insSql = "INSERT INTO proclamation_categories (id, name, parent_id, sort_order) "
"VALUES (?, ?, ?, ?);";
const char* insSql = "INSERT INTO proclamation_categories (id, name, sort_order) "
"VALUES (?, ?, ?);";
sqlite3_stmt* stmt = nullptr;
if (sqlite3_prepare_v2(db_, insSql, -1, &stmt, nullptr) != SQLITE_OK) {
sqlite3_exec(db_, "ROLLBACK;", nullptr, nullptr, nullptr);
@@ -425,8 +512,50 @@ bool DatabaseManager::replaceCategories(const std::vector<ProclamationCategoryRe
for (const auto& cat : records) {
sqlite3_bind_int(stmt, 1, cat.id);
sqlite3_bind_text(stmt, 2, cat.name.c_str(), -1, SQLITE_TRANSIENT);
sqlite3_bind_int(stmt, 3, cat.parent_id);
sqlite3_bind_int(stmt, 4, cat.sort_order);
sqlite3_bind_int(stmt, 3, cat.sort_order);
if (sqlite3_step(stmt) != SQLITE_DONE) {
ok = false;
break;
}
sqlite3_reset(stmt);
sqlite3_clear_bindings(stmt);
}
sqlite3_finalize(stmt);
if (ok) {
sqlite3_exec(db_, "COMMIT;", nullptr, nullptr, nullptr);
} else {
sqlite3_exec(db_, "ROLLBACK;", nullptr, nullptr, nullptr);
}
return ok;
}
bool DatabaseManager::replaceGroups(const std::vector<ProclamationGroupRecord>& records) {
std::lock_guard<std::mutex> lock(mutex_);
if (!db_) return false;
if (sqlite3_exec(db_, "BEGIN;", nullptr, nullptr, nullptr) != SQLITE_OK) {
return false;
}
bool ok = true;
if (sqlite3_exec(db_, "DELETE FROM proclamation_groups;", nullptr, nullptr, nullptr) != SQLITE_OK) {
sqlite3_exec(db_, "ROLLBACK;", nullptr, nullptr, nullptr);
return false;
}
const char* insSql = "INSERT INTO proclamation_groups (id, name, sort_order) "
"VALUES (?, ?, ?);";
sqlite3_stmt* stmt = nullptr;
if (sqlite3_prepare_v2(db_, insSql, -1, &stmt, nullptr) != SQLITE_OK) {
sqlite3_exec(db_, "ROLLBACK;", nullptr, nullptr, nullptr);
return false;
}
for (const auto& group : records) {
sqlite3_bind_int(stmt, 1, group.id);
sqlite3_bind_text(stmt, 2, group.name.c_str(), -1, SQLITE_TRANSIENT);
sqlite3_bind_int(stmt, 3, group.sort_order);
if (sqlite3_step(stmt) != SQLITE_DONE) {
ok = false;
break;
@@ -499,12 +628,12 @@ bool DatabaseManager::syncProclamations(const std::vector<ProclamationRecord>& r
// Upsert each proclamation, preserving the stashed selection state.
if (ok) {
const char* sql = "INSERT INTO proclamations (id, title, content, solfeggio_freq_hz, category, "
"is_active, selected, category_id, solfeggio_frequency_id) "
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) "
"is_active, selected, category_id, group_id, solfeggio_frequency_id) "
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) "
"ON CONFLICT(id) DO UPDATE SET title=excluded.title, content=excluded.content, "
"solfeggio_freq_hz=excluded.solfeggio_freq_hz, category=excluded.category, "
"is_active=excluded.is_active, category_id=excluded.category_id, "
"solfeggio_frequency_id=excluded.solfeggio_frequency_id;";
"group_id=excluded.group_id, solfeggio_frequency_id=excluded.solfeggio_frequency_id;";
sqlite3_stmt* stmt = nullptr;
if (sqlite3_prepare_v2(db_, sql, -1, &stmt, nullptr) != SQLITE_OK) {
@@ -555,7 +684,7 @@ std::vector<ProclamationRecord> DatabaseManager::getAllProclamations() {
if (!db_) return result;
const char* sql = "SELECT id, title, content, solfeggio_freq_hz, category, is_active, "
"selected, category_id, solfeggio_frequency_id FROM proclamations;";
"selected, category_id, group_id, solfeggio_frequency_id FROM proclamations;";
sqlite3_stmt* stmt = nullptr;
if (sqlite3_prepare_v2(db_, sql, -1, &stmt, nullptr) != SQLITE_OK) {
return result;
@@ -574,7 +703,7 @@ std::vector<ProclamationCategoryRecord> DatabaseManager::getAllCategories() {
std::vector<ProclamationCategoryRecord> result;
if (!db_) return result;
const char* sql = "SELECT id, name, parent_id, sort_order FROM proclamation_categories "
const char* sql = "SELECT id, name, sort_order FROM proclamation_categories "
"ORDER BY sort_order ASC, id ASC;";
sqlite3_stmt* stmt = nullptr;
if (sqlite3_prepare_v2(db_, sql, -1, &stmt, nullptr) != SQLITE_OK) {
@@ -585,8 +714,31 @@ std::vector<ProclamationCategoryRecord> DatabaseManager::getAllCategories() {
ProclamationCategoryRecord rec;
rec.id = sqlite3_column_int(stmt, 0);
rec.name = reinterpret_cast<const char*>(sqlite3_column_text(stmt, 1));
rec.parent_id = sqlite3_column_int(stmt, 2);
rec.sort_order = sqlite3_column_int(stmt, 3);
rec.sort_order = sqlite3_column_int(stmt, 2);
result.push_back(rec);
}
sqlite3_finalize(stmt);
return result;
}
std::vector<ProclamationGroupRecord> DatabaseManager::getAllGroups() {
std::lock_guard<std::mutex> lock(mutex_);
std::vector<ProclamationGroupRecord> result;
if (!db_) return result;
const char* sql = "SELECT id, name, sort_order FROM proclamation_groups "
"ORDER BY sort_order ASC, id ASC;";
sqlite3_stmt* stmt = nullptr;
if (sqlite3_prepare_v2(db_, sql, -1, &stmt, nullptr) != SQLITE_OK) {
return result;
}
while (sqlite3_step(stmt) == SQLITE_ROW) {
ProclamationGroupRecord rec;
rec.id = sqlite3_column_int(stmt, 0);
rec.name = reinterpret_cast<const char*>(sqlite3_column_text(stmt, 1));
rec.sort_order = sqlite3_column_int(stmt, 2);
result.push_back(rec);
}
@@ -948,4 +1100,115 @@ std::vector<AudioFileRecord> DatabaseManager::getAudioFiles() {
return result;
}
// ─── Schedule Blocks Table ─────────────────────────────────────────────
bool DatabaseManager::replaceScheduleBlocks(const std::vector<ScheduleBlockRecord>& records) {
std::lock_guard<std::mutex> lock(mutex_);
if (!db_) return false;
if (sqlite3_exec(db_, "BEGIN;", nullptr, nullptr, nullptr) != SQLITE_OK) {
return false;
}
bool ok = true;
if (sqlite3_exec(db_, "DELETE FROM schedule_block_proclamations;", nullptr, nullptr, nullptr) != SQLITE_OK ||
sqlite3_exec(db_, "DELETE FROM schedule_blocks;", nullptr, nullptr, nullptr) != SQLITE_OK) {
sqlite3_exec(db_, "ROLLBACK;", nullptr, nullptr, nullptr);
return false;
}
const char* blockSql = "INSERT INTO schedule_blocks "
"(id, start_minutes, end_minutes, overnight, duration_minutes, "
" dynamic_audio, sort_order) "
"VALUES (?, ?, ?, ?, ?, ?, ?);";
const char* procSql = "INSERT INTO schedule_block_proclamations "
"(block_id, position, proclamation_id) VALUES (?, ?, ?);";
for (size_t i = 0; i < records.size() && ok; ++i) {
const ScheduleBlockRecord& rec = records[i];
sqlite3_stmt* blockStmt = nullptr;
if (sqlite3_prepare_v2(db_, blockSql, -1, &blockStmt, nullptr) != SQLITE_OK) {
ok = false;
break;
}
sqlite3_bind_text(blockStmt, 1, rec.id.c_str(), -1, SQLITE_TRANSIENT);
sqlite3_bind_int(blockStmt, 2, rec.start_minutes);
sqlite3_bind_int(blockStmt, 3, rec.end_minutes);
sqlite3_bind_int(blockStmt, 4, rec.overnight ? 1 : 0);
sqlite3_bind_int(blockStmt, 5, rec.duration_minutes);
sqlite3_bind_int(blockStmt, 6, rec.dynamic_audio ? 1 : 0);
sqlite3_bind_int(blockStmt, 7, static_cast<int>(i));
if (sqlite3_step(blockStmt) != SQLITE_DONE) ok = false;
sqlite3_finalize(blockStmt);
if (!ok) break;
for (size_t p = 0; p < rec.proclamation_ids.size(); ++p) {
sqlite3_stmt* procStmt = nullptr;
if (sqlite3_prepare_v2(db_, procSql, -1, &procStmt, nullptr) != SQLITE_OK) {
ok = false;
break;
}
sqlite3_bind_text(procStmt, 1, rec.id.c_str(), -1, SQLITE_TRANSIENT);
sqlite3_bind_int(procStmt, 2, static_cast<int>(p));
sqlite3_bind_text(procStmt, 3, rec.proclamation_ids[p].c_str(), -1, SQLITE_TRANSIENT);
if (sqlite3_step(procStmt) != SQLITE_DONE) ok = false;
sqlite3_finalize(procStmt);
if (!ok) break;
}
}
if (ok) {
sqlite3_exec(db_, "COMMIT;", nullptr, nullptr, nullptr);
} else {
sqlite3_exec(db_, "ROLLBACK;", nullptr, nullptr, nullptr);
}
return ok;
}
std::vector<ScheduleBlockRecord> DatabaseManager::getScheduleBlocks() {
std::lock_guard<std::mutex> lock(mutex_);
std::vector<ScheduleBlockRecord> result;
if (!db_) return result;
const char* sql = "SELECT id, start_minutes, end_minutes, overnight, duration_minutes, "
"dynamic_audio FROM schedule_blocks ORDER BY sort_order ASC;";
sqlite3_stmt* stmt = nullptr;
if (sqlite3_prepare_v2(db_, sql, -1, &stmt, nullptr) != SQLITE_OK) {
return result;
}
while (sqlite3_step(stmt) == SQLITE_ROW) {
ScheduleBlockRecord rec;
rec.id = reinterpret_cast<const char*>(sqlite3_column_text(stmt, 0));
rec.start_minutes = sqlite3_column_int(stmt, 1);
rec.end_minutes = sqlite3_column_int(stmt, 2);
rec.overnight = (sqlite3_column_int(stmt, 3) != 0);
rec.duration_minutes = sqlite3_column_int(stmt, 4);
rec.dynamic_audio = (sqlite3_column_int(stmt, 5) != 0);
result.push_back(std::move(rec));
}
sqlite3_finalize(stmt);
// Attach each block's proclamation ids in play order.
const char* procSql = "SELECT proclamation_id FROM schedule_block_proclamations "
"WHERE block_id = ? ORDER BY position ASC;";
for (auto& block : result) {
sqlite3_stmt* procStmt = nullptr;
if (sqlite3_prepare_v2(db_, procSql, -1, &procStmt, nullptr) != SQLITE_OK) {
break;
}
sqlite3_bind_text(procStmt, 1, block.id.c_str(), -1, SQLITE_TRANSIENT);
while (sqlite3_step(procStmt) == SQLITE_ROW) {
const unsigned char* text = sqlite3_column_text(procStmt, 0);
if (text) {
block.proclamation_ids.push_back(reinterpret_cast<const char*>(text));
}
}
sqlite3_finalize(procStmt);
}
return result;
}
} // namespace radiantwave::db
+40 -1
View File
@@ -16,13 +16,23 @@ struct ProclamationRecord {
bool is_active = true;
bool selected = false;
int category_id = 0; // maps to ProclamationCategoryRecord::id
int group_id = 0; // maps to ProclamationGroupRecord::id (0 = ungrouped)
int solfeggio_frequency_id = 0; // maps to the solfeggio frequency preset
};
struct ProclamationCategoryRecord {
int id = 0;
std::string name;
int parent_id = 0; // 0 = root category
int sort_order = 0;
};
// A group is a tag-like organizer: it is not owned by a category. A
// proclamation belongs to one category and may additionally belong to one
// group. Within the UI, a group appears under a category only when at least
// one proclamation pairs that (category_id, group_id).
struct ProclamationGroupRecord {
int id = 0;
std::string name;
int sort_order = 0;
};
@@ -44,6 +54,18 @@ struct AudioFileRecord {
int solfeggio_frequency_id = 0; // 0 = not associated with a solfeggio frequency
};
// One scheduled listening block, mirrored from GET /schedule. `proclamation_ids`
// is the ordered play list for the block.
struct ScheduleBlockRecord {
std::string id;
int start_minutes = 0;
int end_minutes = 0;
bool overnight = false;
int duration_minutes = 0;
bool dynamic_audio = true; // override the device audio for this block
std::vector<std::string> proclamation_ids;
};
// A single color in a solfeggio frequency's color palette (RGBA).
struct PaletteColor {
uint8_t r = 0;
@@ -90,6 +112,9 @@ public:
// Full sync: replaces all categories with the given set.
bool replaceCategories(const std::vector<ProclamationCategoryRecord>& records);
// Full sync: replaces all groups with the given set.
bool replaceGroups(const std::vector<ProclamationGroupRecord>& records);
// Full sync: upserts proclamations, deletes any not in `records`,
// preserves the existing `selected` state, and forces the proclamation
// identified by `defaultProclamationId` to remain selected.
@@ -102,6 +127,9 @@ public:
// Fetch all categories ordered by sort_order then id.
std::vector<ProclamationCategoryRecord> getAllCategories();
// Fetch all groups ordered by sort_order then id.
std::vector<ProclamationGroupRecord> getAllGroups();
// Toggle a single proclamation's selected flag.
bool setProclamationSelected(const std::string& id, bool selected);
@@ -138,12 +166,23 @@ public:
// palettes can be pushed from the portal once the API route exists.
bool replaceColorPalettes(const std::vector<PaletteRecord>& records);
// ── Schedule Blocks Table ─────────────────────────────────────────
// Full sync: replaces the stored schedule with `records` (the portal is the
// source of truth). Pass an empty vector to clear the schedule. Block order
// and per-block proclamation order are preserved exactly as given.
bool replaceScheduleBlocks(const std::vector<ScheduleBlockRecord>& records);
// Fetch all schedule blocks, ordered as stored, with their proclamation ids
// in play order.
std::vector<ScheduleBlockRecord> getScheduleBlocks();
// ── Event Logging Table ───────────────────────────────────────────
bool logEvent(const std::string& level, const std::string& component, const std::string& message);
private:
std::string resolvePath(const std::string& raw_path) const;
bool runMigrations();
bool migrateSubCategoriesToGroups();
bool executeSql(const std::string& sql);
bool columnExists(const std::string& table, const std::string& column);
bool ensureColumn(const std::string& table, const std::string& column,
+16
View File
@@ -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
+25 -1
View File
@@ -180,6 +180,16 @@ void Home::render(SDL_Renderer *renderer)
SDL_RenderFillRect(renderer, &line);
}
// Large bold reminder to connect WiFi, drawn below the menu while no
// network is joined (refreshed every 5s in update()).
if (wifiNote_.texture && ssid_.empty())
{
float noteX = (fw - wifiNote_.width) / 2.0f;
float noteY = settingsY_ + settings_.height + 40.0f;
SDL_FRect noteDst{noteX, noteY, wifiNote_.width, wifiNote_.height};
SDL_RenderTexture(renderer, wifiNote_.texture, nullptr, &noteDst);
}
// Bottom-right status footer (20pt, aligned to a virtual rectangle:
// names left-aligned at the rect's left edge, values right-aligned at
// the rect's right edge; the longest name's colon sits 4 spaces from its
@@ -239,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<SDL_Color> valueColors(values.size(), footerColor);
std::vector<bool> valueBold(values.size(), false);
@@ -352,6 +362,15 @@ void Home::createTextures(AppState &state)
TTF_CloseFont(font);
font = TTF_OpenFont("assets/fonts/Roboto-Regular.ttf", 48);
if (font)
{
TTF_SetFontStyle(font, TTF_STYLE_BOLD);
wifiNote_ = renderText(state.renderer, font, "Please connect to WiFi", white);
TTF_SetFontStyle(font, TTF_STYLE_NORMAL);
TTF_CloseFont(font);
}
SDL_Surface *bgSurface = IMG_Load("assets/images/RWT-Circular-Color-ForOnDark-NoText.png");
if (bgSurface)
{
@@ -410,6 +429,11 @@ void Home::destroyTextures()
SDL_DestroyTexture(settings_.texture);
settings_.texture = nullptr;
}
if (wifiNote_.texture)
{
SDL_DestroyTexture(wifiNote_.texture);
wifiNote_.texture = nullptr;
}
for (auto &t : footerNames_)
t.destroy();
for (auto &t : footerValues_)
+1
View File
@@ -34,6 +34,7 @@ private:
TextLine play_;
TextLine settings_;
TextLine bg_;
TextLine wifiNote_;
AppState* state_ = nullptr;
float playY_ = 0;
+6 -1
View File
@@ -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 <SDL3/SDL.h>
@@ -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;
+18
View File
@@ -162,6 +162,15 @@ 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);
// 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",
@@ -196,6 +205,13 @@ SDL_AppResult SDL_AppEvent(void *appstate, SDL_Event *event) {
state.quitRequested = true;
}
if (event->type == SDL_EVENT_AUDIO_DEVICE_ADDED ||
event->type == SDL_EVENT_AUDIO_DEVICE_REMOVED) {
// Route device hotplug to the audio manager so an active device that
// disappears is failed over to the system default automatically.
state.audio.handleDeviceEvent(*event);
}
if (event->type == SDL_EVENT_WINDOW_RESIZED) {
radiantwave::logger::Logger::instance().info(
"Graphics", "Window resized to " +
@@ -264,6 +280,8 @@ void SDL_AppQuit(void *appstate, SDL_AppResult result) {
auto *state = static_cast<AppState *>(appstate);
if (state) {
state->audioDownloader.shutdown();
state->scheduleManager.shutdown();
state->systemNameSync.shutdown();
state->licenseTracker.shutdown();
state->audio.shutdown();
state->network.shutdown();
+308 -9
View File
@@ -2,6 +2,8 @@
#include "network/json.h"
#include <curl/curl.h>
#include <cctype>
#include <cstdio>
#include <string>
namespace radiantwave::api {
@@ -26,9 +28,32 @@ size_t writeCallback(char* ptr, size_t size, size_t nmemb, void* userdata) {
return size * nmemb;
}
// Shared curl setup + perform. Returns a populated HttpResponse.
// 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<std::string*>(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<char>(std::tolower(static_cast<unsigned char>(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. `ifNoneMatch`, when non-empty,
// is sent as an If-None-Match request header.
HttpResponse performRequest(const std::string& url,
bool post, const std::string& jsonBody) {
const char* method, const std::string& jsonBody,
const std::string& ifNoneMatch = "") {
HttpResponse response;
CURL* curl = curl_easy_init();
@@ -41,13 +66,24 @@ 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;
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<long>(jsonBody.size()));
if (std::string(method) == "POST") {
curl_easy_setopt(curl, CURLOPT_POST, 1L);
} else {
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);
@@ -65,16 +101,48 @@ 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<char>(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);
}
HttpResponse httpGetConditional(const std::string& path, const std::string& ifNoneMatch) {
return performRequest(urlFor(path), "GET", "", ifNoneMatch);
}
// ─── GET /frequencies ──────────────────────────────────────────────────
@@ -177,6 +245,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 +254,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;
@@ -221,6 +296,216 @@ bool parseCreateAccountJson(const std::string& body, AccountBody& out) {
return true;
}
// ─── GET /schedule ─────────────────────────────────────────────────────
bool parseScheduleJson(const std::string& body, ScheduleResponse& out) {
radiantwave::json::JsonValue root;
if (!radiantwave::json::parseDocument(body, root) ||
root.type != radiantwave::json::JsonValue::Type::Object) {
return false;
}
ScheduleResponse parsed;
parsed.email = root.stringField("email");
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<int>(entry.numberField("startMinutes"));
block.end_minutes = static_cast<int>(entry.numberField("endMinutes"));
block.overnight = entry.boolField("overnight");
block.duration_minutes = static_cast<int>(entry.numberField("durationMinutes"));
// `dynamicAudio` is optional on the wire and defaults to true.
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.blocks.push_back(std::move(block));
}
}
out = std::move(parsed);
return true;
}
namespace {
// Percent-encodes a query-parameter value (RFC 3986 unreserved set).
std::string urlEncode(const std::string& value) {
CURL* curl = curl_easy_init();
if (!curl) return value;
char* escaped = curl_easy_escape(curl, value.c_str(), static_cast<int>(value.size()));
std::string result = escaped ? escaped : value;
if (escaped) curl_free(escaped);
curl_easy_cleanup(curl);
return result;
}
} // anonymous namespace
bool fetchSchedule(const std::string& email, const std::string& license_key,
ScheduleResponse& out) {
if (email.empty() || license_key.empty()) {
return false;
}
const std::string path = std::string(PATH_SCHEDULE) +
"?email=" + urlEncode(email) +
"&licenseKey=" + urlEncode(license_key);
HttpResponse response = httpGet(path);
if (!response.transportOk || response.status < 200 || response.status >= 300) {
return false;
}
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<int>(entry.numberField("startMinutes"));
block.end_minutes = static_cast<int>(entry.numberField("endMinutes"));
block.overnight = entry.boolField("overnight");
block.duration_minutes = static_cast<int>(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<int>(entry.numberField("startMinutes"));
block.end_minutes = static_cast<int>(entry.numberField("endMinutes"));
block.overnight = entry.boolField("overnight");
block.duration_minutes = static_cast<int>(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;
@@ -247,4 +532,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<int>(response.status);
result.transport_ok = response.transportOk;
return result;
}
} // namespace radiantwave::api
+114 -2
View File
@@ -12,21 +12,28 @@ constexpr const char* API_BASE_URL = "https://portal.radiantwavetech.com/api/v2"
// Paths relative to API_BASE_URL.
constexpr const char* PATH_PROCLAMATIONS = "/proclamations";
constexpr const char* PATH_CATEGORIES = "/categories";
constexpr const char* PATH_GROUPS = "/groups";
constexpr const char* PATH_FREQUENCIES = "/frequencies";
constexpr const char* PATH_LICENSE = "/license";
constexpr const char* PATH_CREATE_ACCOUNT = "/automations/create/account";
constexpr const char* PATH_COLOR_PALETTES = "/color-palettes";
constexpr const char* PATH_SCHEDULE = "/schedule";
// Result of a single HTTP request.
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
};
// 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 with an optional If-None-Match header. Captures the response ETag.
HttpResponse httpGetConditional(const std::string& path, const std::string& ifNoneMatch);
// ─── GET /frequencies ──────────────────────────────────────────────────
@@ -77,6 +84,10 @@ bool fetchColorPalettes(std::vector<ColorPalette>& 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.
@@ -93,7 +104,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 ──────────────────────────────────
@@ -118,4 +147,87 @@ struct AccountResult {
// to the user and is NOT part of the response. `name` may be empty.
AccountResult createAccount(const std::string& email, const std::string& name = "");
// ─── GET /schedule ─────────────────────────────────────────────────────
// One scheduled listening block. Wall-clock times are carried as minutes from
// midnight; overnight blocks wrap past midnight (end clock time <= start).
struct ScheduleBlock {
std::string id;
int start_minutes = 0;
int end_minutes = 0;
bool overnight = false;
int duration_minutes = 0;
// When true the kiosk enables dynamic audio for the block's duration,
// overriding the device's audio selection. Defaults to true: schedules are
// dynamic unless the portal explicitly says otherwise.
bool dynamic_audio = true;
// Selected proclamation ids, in play order.
std::vector<std::string> proclamation_ids;
};
struct ScheduleResponse {
std::string email;
std::vector<ScheduleBlock> blocks;
};
// Parse the JSON payload returned by the schedule endpoint. `dynamicAudio` is
// optional and defaults to true when absent. Returns false on malformed input.
bool parseScheduleJson(const std::string& body, ScheduleResponse& out);
// Fetch a user's schedule. Authenticates via the paired `email` + `license_key`
// query parameters. Returns true only on HTTP 2xx + successful parse; failures
// (including a missing/blank pair) leave `out` untouched.
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<std::string> 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<TargetBlock> daily;
// Weekly blocks keyed by day (mon..sun); empty for daily/none.
std::vector<TargetBlock> weekly;
// Legacy `blocks` flattening carried for backward compatibility.
std::vector<ScheduleBlock> 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
+23 -2
View File
@@ -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 <chrono>
#include <cstdlib>
@@ -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;
}
+12
View File
@@ -1,6 +1,8 @@
#pragma once
#include "db/database_manager.h"
#include "network/api_client.h"
#include <functional>
#include <string>
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<api::LicenseResult(
const std::string& key, const std::string& email, const std::string& systemName)>;
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.
+69 -2
View File
@@ -1,4 +1,5 @@
#include "network/proclamation_sync.h"
#include "db/db_keys.h"
#include "network/api_client.h"
#include "network/json.h"
@@ -66,6 +67,7 @@ bool parseProclamationsJson(const std::string& body,
rec.title = stripHtml(entry.stringField("name"));
rec.content = stripHtml(entry.stringField("content"));
rec.category_id = static_cast<int>(entry.numberField("categoryId"));
rec.group_id = static_cast<int>(entry.numberField("groupId"));
rec.solfeggio_frequency_id = static_cast<int>(entry.numberField("solfeggioFrequencyId"));
out.push_back(std::move(rec));
}
@@ -88,7 +90,28 @@ bool parseCategoriesJson(const std::string& body,
if (rec.id == 0) continue;
rec.name = entry.stringField("name");
rec.parent_id = static_cast<int>(entry.numberField("parentId"));
rec.sort_order = static_cast<int>(entry.numberField("sortOrder"));
out.push_back(std::move(rec));
}
return true;
}
bool parseGroupsJson(const std::string& body,
std::vector<radiantwave::db::ProclamationGroupRecord>& out) {
json::JsonValue root;
if (!json::parseDocument(body, root) || root.type != json::JsonValue::Type::Array) {
return false;
}
out.clear();
for (const auto& entry : root.array) {
if (entry.type != json::JsonValue::Type::Object) continue;
radiantwave::db::ProclamationGroupRecord rec;
rec.id = static_cast<int>(entry.numberField("id"));
if (rec.id == 0) continue;
rec.name = entry.stringField("name");
rec.sort_order = static_cast<int>(entry.numberField("sortOrder"));
out.push_back(std::move(rec));
}
@@ -114,22 +137,66 @@ bool syncProclamationsFromApi(radiantwave::db::DatabaseManager& db,
return false;
}
api::HttpResponse groupsResponse =
api::httpGet(api::PATH_GROUPS);
if (!groupsResponse.transportOk ||
groupsResponse.status < 200 || groupsResponse.status >= 300) {
return false;
}
std::vector<radiantwave::db::ProclamationRecord> proclamations;
std::vector<radiantwave::db::ProclamationCategoryRecord> categories;
std::vector<radiantwave::db::ProclamationGroupRecord> groups;
if (!parseProclamationsJson(proclamationsResponse.body, proclamations) ||
!parseCategoriesJson(categoriesResponse.body, categories)) {
!parseCategoriesJson(categoriesResponse.body, categories) ||
!parseGroupsJson(groupsResponse.body, groups)) {
return false;
}
if (!db.replaceCategories(categories)) {
return false;
}
if (!db.replaceGroups(groups)) {
return false;
}
if (!db.syncProclamations(proclamations, defaultProclamationId)) {
return false;
}
// Best-effort: a schedule fetch/store failure must never fail the
// proclamation sync or roll back the data just written.
syncScheduleFromApi(db);
return true;
}
bool syncScheduleFromApi(radiantwave::db::DatabaseManager& db) {
const std::string email = db.getKV(radiantwave::db::KV_EMAIL_ADDRESS);
const std::string license_key = db.getKV(radiantwave::db::KV_LICENSE_KEY);
if (email.empty() || license_key.empty()) {
return false;
}
api::ScheduleResponse schedule;
if (!api::fetchSchedule(email, license_key, schedule)) {
return false;
}
std::vector<radiantwave::db::ScheduleBlockRecord> records;
records.reserve(schedule.blocks.size());
for (const auto& block : schedule.blocks) {
radiantwave::db::ScheduleBlockRecord rec;
rec.id = block.id;
rec.start_minutes = block.start_minutes;
rec.end_minutes = block.end_minutes;
rec.overnight = block.overnight;
rec.duration_minutes = block.duration_minutes;
rec.dynamic_audio = block.dynamic_audio;
rec.proclamation_ids = block.proclamation_ids;
records.push_back(std::move(rec));
}
return db.replaceScheduleBlocks(records);
}
// ─── Color palettes (readiness code; not wired into the app flow) ─────
bool parseProclamationPalettesJson(const std::string& body,
+16 -3
View File
@@ -15,13 +15,26 @@ bool parseProclamationsJson(const std::string& body,
bool parseCategoriesJson(const std::string& body,
std::vector<radiantwave::db::ProclamationCategoryRecord>& out);
// Fetches proclamations + categories from the API and stores them in the DB,
// preserving any existing selection and forcing `defaultProclamationId` to
// stay selected. Returns true only if the API sync succeeded; on failure the
// Parse the JSON payload returned by the groups endpoint.
bool parseGroupsJson(const std::string& body,
std::vector<radiantwave::db::ProclamationGroupRecord>& out);
// Fetches proclamations + categories + groups from the API and stores them in
// the DB, preserving any existing selection and forcing `defaultProclamationId`
// to stay selected. Returns true only if the API sync succeeded; on failure the
// database is left untouched so the app can fall back to cached data.
//
// Also requests the user's schedule and replaces the stored schedule when the
// device has an email + license key. Schedule sync is best-effort: a schedule
// failure never fails (or rolls back) the proclamation sync.
bool syncProclamationsFromApi(radiantwave::db::DatabaseManager& db,
const std::string& defaultProclamationId);
// Fetch GET /schedule for the device's paired email + license key and replace
// the stored schedule blocks. Skips (returns false) when either credential is
// missing. Returns true only on HTTP 2xx + successful parse + successful store.
bool syncScheduleFromApi(radiantwave::db::DatabaseManager& db);
// Parse color palettes embedded in the proclamations JSON payload. Each
// proclamation object may carry a "colorPalette" array of "#RRGGBB" strings;
// the palette is keyed by the proclamation's solfeggioFrequencyId. When
+158
View File
@@ -0,0 +1,158 @@
#include "network/schedule_cache.h"
#include "network/json.h"
#include <cstdio>
#include <cstdlib>
#include <fstream>
#include <pwd.h>
#include <sstream>
#include <sys/stat.h>
#include <unistd.h>
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
+29
View File
@@ -0,0 +1,29 @@
#pragma once
#include "network/api_client.h"
#include <string>
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
+123
View File
@@ -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
+62
View File
@@ -0,0 +1,62 @@
#pragma once
#include "db/database_manager.h"
#include "network/api_client.h"
#include <functional>
#include <string>
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<api::PutNameResult(const std::string& key,
const std::string& name)>;
// 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
+70
View File
@@ -0,0 +1,70 @@
#include "network/system_name_sync.h"
#include "network/api_client.h"
#include <utility>
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<std::mutex> 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<std::mutex> lock(mutex_);
stop_.store(true);
}
cv_.notify_one();
if (worker_.joinable()) {
worker_.join();
}
}
void SystemNameSync::requestSync() {
{
std::lock_guard<std::mutex> lock(mutex_);
if (!worker_.joinable()) return;
requested_.store(true);
}
cv_.notify_one();
}
void SystemNameSync::run() {
while (true) {
db::DatabaseManager* db = nullptr;
{
std::unique_lock<std::mutex> 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
+47
View File
@@ -0,0 +1,47 @@
#pragma once
#include "db/database_manager.h"
#include "network/system_name.h"
#include <atomic>
#include <chrono>
#include <condition_variable>
#include <mutex>
#include <thread>
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<bool> stop_{false};
std::atomic<bool> requested_{false};
};
} // namespace radiantwave::systemname
+106 -55
View File
@@ -148,6 +148,7 @@ void ProclamationOptionsPage::loadFromDb()
{
proclamations_ = state_->db.getAllProclamations();
categories_ = state_->db.getAllCategories();
groups_ = state_->db.getAllGroups();
state_->db.ensureDefaultProclamationSelected(DEFAULT_PROPLEAMATION_ID);
for (auto& p : proclamations_) {
@@ -173,6 +174,16 @@ void ProclamationOptionsPage::rebuildItems()
{
items_.clear();
// Schedule master toggle: enables/disables following the portal schedule.
// It is a plain on/off row (selected stores the on state) and sits above
// the always-on proclamation so it is always the first list entry.
items_.push_back({
FlatItem::Type::ScheduleToggle,
"schedule",
"Schedule Active",
0, false, state_->config.get().proclamation.schedule_active, false,
});
// Always-on top-level proclamation, never inside a category.
items_.push_back({
FlatItem::Type::Proclamation,
@@ -181,42 +192,55 @@ void ProclamationOptionsPage::rebuildItems()
0, false, true, true,
});
for (const auto& root : categories_) {
if (root.parent_id != 0) continue;
// Groups that actually exist, so a proclamation pointing at a group the
// portal has since removed falls back to the ungrouped bucket.
auto groupExists = [this](int groupId) {
return std::any_of(groups_.begin(), groups_.end(),
[groupId](const auto& g) { return g.id == groupId; });
};
for (const auto& category : categories_) {
items_.push_back({
FlatItem::Type::RootCategory,
std::to_string(root.id),
root.name,
FlatItem::Type::Category,
std::to_string(category.id),
category.name,
0, false, false, false,
});
for (const auto& sub : categories_) {
if (sub.parent_id != root.id) continue;
items_.push_back({
FlatItem::Type::SubCategory,
std::to_string(sub.id),
sub.name,
1, true, false, false,
});
// Groups are tag-like and global. A group is shown under a category
// only when it has at least one proclamation in that category, so
// impossible pairings (e.g. "Physical / Prayers") never render.
for (const auto& group : groups_) {
std::vector<const radiantwave::db::ProclamationRecord*> members;
for (const auto& p : proclamations_) {
if (p.id == DEFAULT_PROPLEAMATION_ID) continue;
if (p.category_id != sub.id) continue;
if (p.category_id != category.id || p.group_id != group.id) continue;
members.push_back(&p);
}
if (members.empty()) continue;
items_.push_back({
FlatItem::Type::Group,
std::to_string(group.id),
group.name,
1, true, false, false,
});
for (const auto* p : members) {
items_.push_back({
FlatItem::Type::Proclamation,
p.id,
p.title,
2, false, p.selected, false,
p->id,
p->title,
2, false, p->selected, false,
});
}
}
// Proclamations assigned directly to the root category.
// Proclamations with no group (or a stale group id) sit directly under
// the category.
for (const auto& p : proclamations_) {
if (p.id == DEFAULT_PROPLEAMATION_ID) continue;
if (p.category_id != root.id) continue;
if (p.category_id != category.id) continue;
if (p.group_id != 0 && groupExists(p.group_id)) continue;
items_.push_back({
FlatItem::Type::Proclamation,
p.id,
@@ -235,29 +259,34 @@ void ProclamationOptionsPage::rebuildVisible()
return;
}
// Tree walk: track the currently-open root and sub category (items_ is
// already ordered root -> sub -> proclamation, so each category dominates
// the items until the next category of equal or shallower depth).
int currentRoot = -1; // items_ index of the current root category
int currentSub = -1; // items_ index of the current sub-category
// Tree walk: track the currently-open category and group (items_ is
// already ordered category -> group -> proclamation, so each category
// dominates the items until the next category).
int currentCategory = -1; // items_ index of the current category
int currentGroup = -1; // items_ index of the current group
for (size_t i = 0; i < items_.size(); ++i) {
const FlatItem& item = items_[i];
if (item.type == FlatItem::Type::RootCategory) {
currentRoot = static_cast<int>(i);
currentSub = -1;
if (item.type == FlatItem::Type::ScheduleToggle) {
visible_.push_back(i);
continue;
}
if (item.type == FlatItem::Type::SubCategory) {
bool rootOpen = currentRoot >= 0 && items_[currentRoot].expanded;
if (rootOpen) {
currentSub = static_cast<int>(i);
if (item.type == FlatItem::Type::Category) {
currentCategory = static_cast<int>(i);
currentGroup = -1;
visible_.push_back(i);
continue;
}
if (item.type == FlatItem::Type::Group) {
bool categoryOpen = currentCategory >= 0 && items_[currentCategory].expanded;
if (categoryOpen) {
currentGroup = static_cast<int>(i);
visible_.push_back(i);
} else {
currentSub = -1;
currentGroup = -1;
}
continue;
}
@@ -266,9 +295,10 @@ void ProclamationOptionsPage::rebuildVisible()
bool parentVisible = false;
if (item.depth == 0) {
parentVisible = true; // the always-on top-level proclamation
} else if (currentRoot >= 0 && items_[currentRoot].expanded) {
} else if (currentCategory >= 0 && items_[currentCategory].expanded) {
parentVisible = (item.depth == 1) ||
(item.depth == 2 && currentSub >= 0);
(item.depth == 2 && currentGroup >= 0 &&
items_[currentGroup].expanded);
}
if (parentVisible) visible_.push_back(i);
}
@@ -283,12 +313,24 @@ void ProclamationOptionsPage::toggleItem()
if (visible_.empty()) return;
FlatItem& item = items_[visible_[highlightIndex_]];
if (item.type == FlatItem::Type::RootCategory) {
if (item.type == FlatItem::Type::Category || item.type == FlatItem::Type::Group) {
item.expanded = !item.expanded;
rebuildVisible();
return;
}
if (item.type == FlatItem::Type::ScheduleToggle) {
item.selected = !item.selected;
const bool enabled = item.selected;
state_->config.update([enabled](radiantwave::config::AppConfig& cfg) {
cfg.proclamation.schedule_active = enabled;
});
statusMessage_ = enabled
? "Schedule active - playback follows the portal schedule"
: "Schedule inactive - playback uses your selections";
return;
}
if (item.isDefault) {
statusMessage_ = "The top-level proclamation is always on";
return;
@@ -388,7 +430,7 @@ void ProclamationOptionsPage::collapseCurrent()
{
if (visible_.empty()) return;
FlatItem& item = items_[visible_[highlightIndex_]];
if (item.type == FlatItem::Type::RootCategory) {
if (item.type == FlatItem::Type::Category || item.type == FlatItem::Type::Group) {
if (item.expanded) {
item.expanded = false;
rebuildVisible();
@@ -400,7 +442,7 @@ void ProclamationOptionsPage::expandCurrent()
{
if (visible_.empty()) return;
FlatItem& item = items_[visible_[highlightIndex_]];
if (item.type == FlatItem::Type::RootCategory) {
if (item.type == FlatItem::Type::Category || item.type == FlatItem::Type::Group) {
if (!item.expanded) {
item.expanded = true;
rebuildVisible();
@@ -417,8 +459,8 @@ void ProclamationOptionsPage::moveToParent()
int start = static_cast<int>(visible_[highlightIndex_]);
for (int i = start - 1; i >= 0; --i) {
const FlatItem& candidate = items_[i];
if ((candidate.type == FlatItem::Type::RootCategory ||
candidate.type == FlatItem::Type::SubCategory) &&
if ((candidate.type == FlatItem::Type::Category ||
candidate.type == FlatItem::Type::Group) &&
candidate.depth == item.depth - 1) {
for (size_t vi = 0; vi < visible_.size(); ++vi) {
if (visible_[vi] == static_cast<size_t>(i)) {
@@ -526,7 +568,8 @@ void ProclamationOptionsPage::handleEvent(const SDL_Event& event)
if (searchActive_) return;
if (!visible_.empty()) {
const FlatItem& item = items_[visible_[highlightIndex_]];
if (item.type == FlatItem::Type::RootCategory && item.expanded) {
if ((item.type == FlatItem::Type::Category ||
item.type == FlatItem::Type::Group) && item.expanded) {
collapseCurrent();
} else {
moveToParent();
@@ -567,6 +610,7 @@ void ProclamationOptionsPage::render(SDL_Renderer* renderer)
SDL_Color white = {255, 255, 255, 255};
SDL_Color gray = {170, 170, 170, 255};
SDL_Color categoryBlue = {100, 180, 255, 255};
SDL_Color groupBlue = {160, 210, 255, 255};
SDL_Color selectedOrange = {255, 170, 60, 255};
float titleY = 50.0f;
@@ -698,14 +742,25 @@ void ProclamationOptionsPage::render(SDL_Renderer* renderer)
}
} else {
// --- Browse tree ---
// The schedule toggle renders its on/off state inline; category
// and group rows get an expand/collapse marker.
auto labelFor = [](const FlatItem& item) -> std::string {
if (item.type == FlatItem::Type::ScheduleToggle) {
return "Schedule Active: " +
std::string(item.selected ? "[ON]" : "[OFF]");
}
if (item.type == FlatItem::Type::Category ||
item.type == FlatItem::Type::Group) {
return (item.expanded ? "[-] " : "[+] ") + item.label;
}
return item.label;
};
// Stable width: compute from every item so the list doesn't
// shift left/right when groups expand or collapse.
float maxTotalWidth = 0.0f;
for (const auto& item : items_) {
std::string label = item.label;
if (item.type == FlatItem::Type::RootCategory) {
label = (item.expanded ? "[-] " : "[+] ") + label;
}
std::string label = labelFor(item);
int w = 0, h = 0;
TTF_GetStringSize(fontItem_, label.c_str(), 0, &w, &h);
float total = item.depth * indent + static_cast<float>(w);
@@ -715,10 +770,7 @@ void ProclamationOptionsPage::render(SDL_Renderer* renderer)
std::vector<float> textWidths(visible_.size());
for (int i = scrollOffset_; i < scrollOffset_ + visibleCount_ && i < static_cast<int>(visible_.size()); ++i) {
const FlatItem& item = items_[visible_[i]];
std::string label = item.label;
if (item.type == FlatItem::Type::RootCategory) {
label = (item.expanded ? "[-] " : "[+] ") + label;
}
std::string label = labelFor(item);
int w = 0, h = 0;
TTF_GetStringSize(fontItem_, label.c_str(), 0, &w, &h);
textWidths[i] = static_cast<float>(w);
@@ -733,14 +785,13 @@ void ProclamationOptionsPage::render(SDL_Renderer* renderer)
const FlatItem& item = items_[visible_[i]];
float y = listTop + rowIndex * rowPitch;
std::string label = item.label;
if (item.type == FlatItem::Type::RootCategory) {
label = (item.expanded ? "[-] " : "[+] ") + label;
}
std::string label = labelFor(item);
SDL_Color color = white;
if (item.type == FlatItem::Type::RootCategory || item.type == FlatItem::Type::SubCategory) {
if (item.type == FlatItem::Type::Category) {
color = categoryBlue;
} else if (item.type == FlatItem::Type::Group) {
color = groupBlue;
} else if (item.selected || item.isDefault) {
color = selectedOrange;
}
+6 -5
View File
@@ -28,14 +28,14 @@ public:
private:
struct FlatItem {
enum class Type { RootCategory, SubCategory, Proclamation };
enum class Type { ScheduleToggle, Category, Group, Proclamation };
Type type = Type::Proclamation;
std::string id; // category id or proclamation id
std::string id; // category id, group id, proclamation id, or "schedule"
std::string label; // display text
int depth = 0; // 0 = root category / default, 1 = sub-category, 2 = proclamation
bool expanded = false; // categories only
bool selected = false; // proclamations only
int depth = 0; // 0 = category / default, 1 = group, 2 = proclamation
bool expanded = false; // categories and groups only
bool selected = false; // proclamations and the schedule toggle
bool isDefault = false; // the always-on top-level proclamation
};
@@ -67,6 +67,7 @@ private:
AppState* state_ = nullptr;
std::vector<radiantwave::db::ProclamationRecord> proclamations_;
std::vector<radiantwave::db::ProclamationCategoryRecord> categories_;
std::vector<radiantwave::db::ProclamationGroupRecord> groups_;
std::vector<FlatItem> items_;
std::vector<size_t> visible_; // indices into items_, in display order
std::vector<size_t> searchResults_; // indices into proclamations_, search mode
+112
View File
@@ -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<void(const std::string&)>& 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<api::TargetBlock>& candidates, int minutes,
api::TargetBlock& out,
const std::function<void(const std::string&)>& 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<Weekday>(index % 7);
}
Decision evaluate(const api::SchedulePayload& payload, int minutes, Weekday weekday,
std::function<void(const std::string&)> 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<api::TargetBlock> 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<int>(weekday) + 6) % 7;
const std::string prevKey = dayKey(static_cast<Weekday>(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
+36
View File
@@ -0,0 +1,36 @@
#pragma once
#include "network/api_client.h"
#include <functional>
#include <string>
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<void(const std::string&)> 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
+164
View File
@@ -0,0 +1,164 @@
#include "schedule/schedule_manager.h"
#include "db/db_keys.h"
#include "network/api_client.h"
#include "network/schedule_cache.h"
#include <utility>
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<std::mutex> lock(mutex_);
return payload_.hasSchedule();
}
bool ScheduleManager::enabled() const {
std::lock_guard<std::mutex> lock(mutex_);
return enabled_;
}
Decision ScheduleManager::activeBlock(int minutes, Weekday weekday) const {
std::lock_guard<std::mutex> lock(mutex_);
if (!enabled_) {
return Decision{};
}
return evaluate(payload_, minutes, weekday, nullptr);
}
void ScheduleManager::setFetchForTesting(FetchFn fetch) {
std::lock_guard<std::mutex> lock(mutex_);
fetch_ = std::move(fetch);
}
void ScheduleManager::applyPayload(api::SchedulePayload payload, const std::string& etag) {
{
std::lock_guard<std::mutex> 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<std::mutex> 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<std::mutex> 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<void()> onChanged) {
{
std::lock_guard<std::mutex> 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. Called outside the
// lock because loadCache() acquires mutex_ itself; holding it here would
// self-deadlock on the non-recursive mutex when a cache file exists.
loadCache();
stop_.store(false);
worker_ = std::thread(&ScheduleManager::run, this);
requested_.store(true);
cv_.notify_one();
}
void ScheduleManager::shutdown() {
{
std::lock_guard<std::mutex> lock(mutex_);
if (!started_) return;
stop_.store(true);
}
cv_.notify_one();
if (worker_.joinable()) {
worker_.join();
}
std::lock_guard<std::mutex> lock(mutex_);
started_ = false;
}
void ScheduleManager::requestRefresh() {
{
std::lock_guard<std::mutex> 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<std::mutex> lock(mutex_);
enabled_ = enabled;
}
void ScheduleManager::setPayloadForTesting(api::SchedulePayload payload,
const std::string& etag) {
std::lock_guard<std::mutex> lock(mutex_);
payload_ = std::move(payload);
etag_ = etag;
}
void ScheduleManager::run() {
while (true) {
{
std::unique_lock<std::mutex> 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
+93
View File
@@ -0,0 +1,93 @@
#pragma once
#include "db/database_manager.h"
#include "network/api_client.h"
#include "schedule/schedule_engine.h"
#include <atomic>
#include <chrono>
#include <condition_variable>
#include <functional>
#include <mutex>
#include <string>
#include <thread>
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<void()> 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<api::ScheduleFetchStatus(
const std::string& licenseKey, const std::string& etag,
api::SchedulePayload& out, std::string& outEtag)>;
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<void()> onChanged_;
std::thread worker_;
std::condition_variable cv_;
std::atomic<bool> stop_{false};
std::atomic<bool> requested_{false};
bool started_ = false;
};
} // namespace radiantwave::schedule
+152
View File
@@ -0,0 +1,152 @@
#include "schedule_toggle_page.h"
#include "app_state.h"
#include "config/app_config.h"
#include "logical_size.h"
#include <SDL3/SDL.h>
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<float>(winW);
float fh = static_cast<float>(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);
}
}
+40
View File
@@ -0,0 +1,40 @@
#pragma once
#include "page.h"
#include "text_cache.h"
#include <SDL3_ttf/SDL_ttf.h>
#include <string>
// 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_;
};
+185
View File
@@ -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 <cstdint>
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
+712 -717
View File
File diff suppressed because it is too large Load Diff
+23
View File
@@ -1,5 +1,6 @@
#pragma once
#include "db/database_manager.h"
#include "fibonacci_line_pattern.h"
#include "page.h"
#include "scroller_streams.h"
@@ -82,6 +83,15 @@ private:
bool addLineAt(float baseY);
void advanceStream();
void switchAudioForCurrentStream();
// 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();
// True when the current stream should use dynamic (frequency-following)
// audio. A schedule block with dynamicAudio=true forces it on; otherwise the
// configured audio.dynamic_audio setting applies.
bool effectiveDynamicAudio() const;
void updateAutoVelocity(float dt);
void ensureWordTexture(const std::string& word);
void ensureCharTexture(const std::string& glyph);
@@ -97,6 +107,19 @@ private:
int streamIndex_ = 0;
bool prefilling_ = false;
// 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<std::string> activeBlockProclamationIds_;
bool activeBlockDynamicAudio_ = false;
bool scheduleActive_ = false;
int activeBlockIndex_ = -1;
bool scheduleChangePending_ = false;
float schedulePollTimer_ = 0.0f;
TTF_Font* font_ = nullptr;
int fontPxSize_ = 32;
float lineHeight_ = 0;
+33 -9
View File
@@ -53,21 +53,45 @@ std::vector<ProclamationStream> buildStreams(
const std::unordered_map<int, std::vector<StreamColor>>& palettesByFreqId,
const std::vector<StreamColor>& fallbackPalette,
const std::unordered_map<int, db::AudioFileRecord>& audioByFreqId,
const std::string& defaultProclamationId)
const std::string& defaultProclamationId,
const std::vector<std::string>* scheduledIds)
{
const db::ProclamationRecord* defaultRecord = nullptr;
std::vector<const db::ProclamationRecord*> selected;
selected.reserve(proclamations.size());
for (const auto& rec : proclamations) {
if (rec.id == defaultProclamationId) {
defaultRecord = &rec;
continue;
if (scheduledIds) {
// A schedule supplies an explicit, ordered play list. Look each id up
// in the proclamation pool, skipping unknown ids and the default (which
// is interleaved as usual).
std::unordered_map<std::string, const db::ProclamationRecord*> byId;
byId.reserve(proclamations.size());
for (const auto& rec : proclamations) {
byId[rec.id] = &rec;
}
if (!rec.selected) {
continue;
defaultRecord = byId.count(defaultProclamationId) ? byId[defaultProclamationId] : nullptr;
selected.reserve(scheduledIds->size());
for (const auto& id : *scheduledIds) {
if (id == defaultProclamationId) {
continue;
}
auto it = byId.find(id);
if (it != byId.end()) {
selected.push_back(it->second);
}
}
} else {
selected.reserve(proclamations.size());
for (const auto& rec : proclamations) {
if (rec.id == defaultProclamationId) {
defaultRecord = &rec;
continue;
}
if (!rec.selected) {
continue;
}
selected.push_back(&rec);
}
selected.push_back(&rec);
}
auto makeStream = [&](const db::ProclamationRecord& rec, int repeat) {
+18 -1
View File
@@ -25,6 +25,16 @@ struct StreamColor {
inline constexpr int kSelectedRepeatCount = 20;
inline constexpr int kDefaultRepeatCount = 10;
// True when `minutes` (minutes since midnight) falls inside a schedule block.
// Non-overnight blocks are [start, end); overnight blocks wrap past midnight
// and match [start, 1440) or [0, end).
inline bool minuteInScheduleBlock(int minutes, int start_minutes, int end_minutes,
bool overnight)
{
return overnight ? (minutes >= start_minutes || minutes < end_minutes)
: (minutes >= start_minutes && minutes < end_minutes);
}
struct ProclamationStream {
std::string name;
std::vector<std::string> words;
@@ -45,11 +55,18 @@ struct ProclamationStream {
// the default plays. `palettesByFreqId` and `audioByFreqId` are
// database-derived lookups; `fallbackPalette` is used for the default stream
// and unknown frequencies.
//
// When `scheduledIds` is non-null it replaces the `selected` flag as the source
// of the play order: the listed proclamation ids are played in the given order
// (unknown ids and the default id are skipped) and interleaved with the default
// exactly like a normal selection. An empty list falls back to the default
// only. Passing nullptr keeps the normal database-selection behavior.
std::vector<ProclamationStream> buildStreams(
const std::vector<db::ProclamationRecord>& proclamations,
const std::unordered_map<int, std::vector<StreamColor>>& palettesByFreqId,
const std::vector<StreamColor>& fallbackPalette,
const std::unordered_map<int, db::AudioFileRecord>& audioByFreqId,
const std::string& defaultProclamationId);
const std::string& defaultProclamationId,
const std::vector<std::string>* scheduledIds = nullptr);
} // namespace radiantwave::scroller
+7 -28
View File
@@ -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 <SDL3/SDL.h>
#include <SDL3_image/SDL_image.h>
@@ -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<AudioSettingsPage>());
break;
case 2:
state_->pages.push(*state_, std::make_unique<EmailAddressPage>());
state_->pages.push(*state_, std::make_unique<SystemSettingsPage>());
break;
case 3:
state_->pages.push(*state_, std::make_unique<LicenseKeyPage>());
break;
case 4:
state_->pages.push(*state_, std::make_unique<WiFiSetupPage>());
break;
case 5:
state_->pages.push(*state_, std::make_unique<TimeZonePage>());
break;
case 6:
state_->pages.push(*state_, std::make_unique<DateFormatPage>());
break;
case 7:
state_->pages.push(*state_, std::make_unique<LogViewerPage>());
break;
case 8:
case 4:
state_->pages.replace(*state_, std::make_unique<Home>());
break;
default:
+17
View File
@@ -0,0 +1,17 @@
#include "settings_menu.h"
namespace radiantwave::settings {
std::vector<SystemSettingEntry> systemSettingEntries() {
return {
{"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},
};
}
} // namespace radiantwave::settings
+31
View File
@@ -0,0 +1,31 @@
#pragma once
#include <string>
#include <vector>
namespace radiantwave::settings {
// Identifies a page reachable from the System Settings menu.
enum class SystemSettingId {
EmailAddress,
LicenseKey,
SystemName,
Schedule,
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<SystemSettingEntry> systemSettingEntries();
} // namespace radiantwave::settings
+226
View File
@@ -0,0 +1,226 @@
#include "system_name_page.h"
#include "app_state.h"
#include "network/system_name.h"
#include "logical_size.h"
#include <SDL3/SDL.h>
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<float>(winW);
float fh = static_cast<float>(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);
}
}
+44
View File
@@ -0,0 +1,44 @@
#pragma once
#include "page.h"
#include "text_cache.h"
#include <SDL3_ttf/SDL_ttf.h>
#include <string>
// 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_;
};
+280
View File
@@ -0,0 +1,280 @@
#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 "system_name_page.h"
#include "schedule_toggle_page.h"
#include <SDL3/SDL.h>
#include <SDL3_image/SDL_image.h>
#include <algorithm>
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<int>(items_.size());
if (count > 0)
selectedItem_ = (selectedItem_ - 1 + count) % count;
});
state.keybinds.bind(SDLK_DOWN, "system_settings_down", [this]()
{
int count = static_cast<int>(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<int>(entries_.size()))
{
state.pages.pop(state);
return;
}
switch (entries_[selectedItem_].id)
{
case radiantwave::settings::SystemSettingId::EmailAddress:
state.pages.push(state, std::make_unique<EmailAddressPage>());
break;
case radiantwave::settings::SystemSettingId::LicenseKey:
state.pages.push(state, std::make_unique<LicenseKeyPage>());
break;
case radiantwave::settings::SystemSettingId::SystemName:
state.pages.push(state, std::make_unique<SystemNamePage>());
break;
case radiantwave::settings::SystemSettingId::Schedule:
state.pages.push(state, std::make_unique<ScheduleTogglePage>());
break;
case radiantwave::settings::SystemSettingId::WiFiSetup:
state.pages.push(state, std::make_unique<WiFiSetupPage>());
break;
case radiantwave::settings::SystemSettingId::Timezone:
state.pages.push(state, std::make_unique<TimeZonePage>());
break;
case radiantwave::settings::SystemSettingId::DateFormat:
state.pages.push(state, std::make_unique<DateFormatPage>());
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<float>(winW);
float fh = static_cast<float>(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<int>(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<int>(entries_.size()) + 1)
{
selectedItem_ = 0;
}
std::vector<std::string> 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<float>(bgSurface->w);
bgHeight_ = static_cast<float>(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<float>(surface->w);
titleHeight_ = static_cast<float>(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<float>(surface->w);
item.height = static_cast<float>(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();
}
+50
View File
@@ -0,0 +1,50 @@
#pragma once
#include "page.h"
#include "settings_menu.h"
#include <SDL3_ttf/SDL_ttf.h>
#include <string>
#include <vector>
// 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<radiantwave::settings::SystemSettingEntry> entries_;
// Setting rows followed by a trailing "Back to Settings" row.
std::vector<MenuItem> items_;
SDL_Texture* bgTexture_ = nullptr;
float bgWidth_ = 0;
float bgHeight_ = 0;
SDL_Texture* titleTexture_ = nullptr;
float titleWidth_ = 0;
float titleHeight_ = 0;
};
+63
View File
@@ -133,6 +133,69 @@ std::string formatUtcToTimezone(time_t utc_time, const std::string& iana_code, c
return std::string(buf);
}
int currentLocalMinutes(const std::string& iana_code) {
std::lock_guard<std::mutex> 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();
return tm_buf.tm_hour * 60 + tm_buf.tm_min;
}
int currentLocalWeekday(const std::string& iana_code) {
std::lock_guard<std::mutex> 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;
+9
View File
@@ -22,6 +22,15 @@ const std::vector<CountryTimeZones>& getStandardTimeZones();
// Converts a UTC timestamp to the user's configured IANA timezone
std::string formatUtcToTimezone(time_t utc_time, const std::string& iana_code, const std::string& format = "%Y-%m-%d %H:%M:%S %Z");
// Wall-clock minutes since midnight (0-1439) right now in `iana_code`. Used by
// the schedule to pick the active listening block. Falls back to UTC when the
// 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();
+8 -1
View File
@@ -1,5 +1,6 @@
#include "timezone_page.h"
#include "app_state.h"
#include "date_format.h"
#include "logical_size.h"
#include <SDL3/SDL.h>
#include <algorithm>
@@ -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};
+17
View File
@@ -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<unsigned char>(input[start]))) {
++start;
}
while (end > start && std::isspace(static_cast<unsigned char>(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
+10
View File
@@ -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
+79 -10
View File
@@ -113,6 +113,58 @@ TEST(ApiClientTest, ParseCreateAccountJsonRejectsMalformed) {
EXPECT_FALSE(parseCreateAccountJson("[]", account));
}
TEST(ApiClientTest, ParseScheduleJson) {
const std::string body =
R"({"email":"jane@example.com","blocks":[
{"id":"b1","startTime":"06:00","startMinutes":360,"endTime":"08:00",
"endMinutes":480,"overnight":false,"durationMinutes":120,
"dynamicAudio":false,"proclamationIds":["p1","p2"]},
{"id":"b2","startTime":"21:00","startMinutes":1260,"endTime":"09:00",
"endMinutes":540,"overnight":true,"durationMinutes":720,
"dynamicAudio":true,"proclamationIds":[]}]})";
ScheduleResponse out;
EXPECT_TRUE(parseScheduleJson(body, out));
EXPECT_EQ(out.email, "jane@example.com");
ASSERT_EQ(out.blocks.size(), 2u);
EXPECT_EQ(out.blocks[0].id, "b1");
EXPECT_EQ(out.blocks[0].start_minutes, 360);
EXPECT_EQ(out.blocks[0].end_minutes, 480);
EXPECT_FALSE(out.blocks[0].overnight);
EXPECT_EQ(out.blocks[0].duration_minutes, 120);
EXPECT_FALSE(out.blocks[0].dynamic_audio);
ASSERT_EQ(out.blocks[0].proclamation_ids.size(), 2u);
EXPECT_EQ(out.blocks[0].proclamation_ids[0], "p1");
EXPECT_EQ(out.blocks[0].proclamation_ids[1], "p2");
EXPECT_EQ(out.blocks[1].id, "b2");
EXPECT_TRUE(out.blocks[1].overnight);
EXPECT_TRUE(out.blocks[1].dynamic_audio);
EXPECT_TRUE(out.blocks[1].proclamation_ids.empty());
}
TEST(ApiClientTest, ParseScheduleDefaultsDynamicAudioTrue) {
const std::string body =
R"({"email":"a@b.com","blocks":[
{"id":"b1","startMinutes":0,"endMinutes":60,"overnight":false,
"durationMinutes":60,"proclamationIds":["p1"]}]})";
ScheduleResponse out;
EXPECT_TRUE(parseScheduleJson(body, out));
ASSERT_EQ(out.blocks.size(), 1u);
EXPECT_TRUE(out.blocks[0].dynamic_audio);
}
TEST(ApiClientTest, ParseScheduleRejectsMalformed) {
ScheduleResponse out;
EXPECT_FALSE(parseScheduleJson("not json", out));
EXPECT_FALSE(parseScheduleJson("[1,2,3]", out)); // array, not object
// Missing id blocks are skipped, not fatal.
EXPECT_TRUE(parseScheduleJson(R"({"blocks":[{"startMinutes":0}]})", out));
EXPECT_TRUE(out.blocks.empty());
}
// ─── Offline: error message extraction ────────────────────────────────
TEST(ApiClientTest, ExtractErrorMessage) {
@@ -142,16 +194,25 @@ TEST(ApiClientTest, ParseProclamationsToleratesLegacyLevelField) {
EXPECT_EQ(out[1].category_id, 32);
}
TEST(ApiClientTest, ParseCategoriesHandlesNullParentId) {
const std::string body =
R"([{"id":4,"name":"Spiritual","sortOrder":0,"parentId":null},
{"id":5,"name":"General","sortOrder":0,"parentId":4}])";
TEST(ApiClientTest, ParseFlatCategoriesAndGroups) {
const std::string categoriesBody =
R"([{"id":4,"name":"Spiritual","sortOrder":0},
{"id":5,"name":"General","sortOrder":1}])";
std::vector<radiantwave::db::ProclamationCategoryRecord> out;
EXPECT_TRUE(radiantwave::sync::parseCategoriesJson(body, out));
ASSERT_EQ(out.size(), 2u);
EXPECT_EQ(out[0].parent_id, 0); // null parentId maps to 0 (root)
EXPECT_EQ(out[1].parent_id, 4);
std::vector<radiantwave::db::ProclamationCategoryRecord> categories;
EXPECT_TRUE(radiantwave::sync::parseCategoriesJson(categoriesBody, categories));
ASSERT_EQ(categories.size(), 2u);
EXPECT_EQ(categories[0].id, 4);
EXPECT_EQ(categories[1].id, 5);
const std::string groupsBody =
R"([{"id":9,"name":"Prayers","sortOrder":0}])";
std::vector<radiantwave::db::ProclamationGroupRecord> groups;
EXPECT_TRUE(radiantwave::sync::parseGroupsJson(groupsBody, groups));
ASSERT_EQ(groups.size(), 1u);
EXPECT_EQ(groups[0].id, 9);
EXPECT_EQ(groups[0].name, "Prayers");
}
// ─── Live: GET /frequencies (harmless, always runs) ───────────────────
@@ -194,7 +255,15 @@ TEST(ApiClientLiveTest, ProclamationsAndCategoriesLiveSmoke) {
std::vector<radiantwave::db::ProclamationCategoryRecord> categories;
EXPECT_TRUE(radiantwave::sync::parseCategoriesJson(catsResponse.body, categories));
EXPECT_GE(categories.size(), 20u);
EXPECT_FALSE(categories.empty());
HttpResponse groupsResponse = httpGet(PATH_GROUPS);
ASSERT_TRUE(groupsResponse.transportOk);
ASSERT_EQ(groupsResponse.status, 200);
std::vector<radiantwave::db::ProclamationGroupRecord> groups;
EXPECT_TRUE(radiantwave::sync::parseGroupsJson(groupsResponse.body, groups));
EXPECT_FALSE(groups.empty());
}
// ─── Live: POST /license error paths (no credentials required) ─────────
+36
View File
@@ -8,9 +8,34 @@ TEST(AudioTest, FriendlyDeviceNameMapping) {
EXPECT_EQ(mapToFriendlyDeviceName("AMD Radeon HD Audio Controller HDMI"), "TV / HDMI Monitor");
EXPECT_EQ(mapToFriendlyDeviceName("USB Audio Headset Dongle"), "USB Audio Device");
EXPECT_EQ(mapToFriendlyDeviceName("bluez_sink.00_11_22_33_44_55"), "Bluetooth Audio");
EXPECT_EQ(mapToFriendlyDeviceName("Virtual Speaker"), "Built-In Speakers");
EXPECT_EQ(mapToFriendlyDeviceName(""), "Default Audio Output");
}
TEST(AudioTest, ResolvePlaybackDeviceNameHandlesSentinels) {
// The sentinel and 0 mean "system default" and never correspond to a real
// enumerated playback device, so they resolve to the empty (default) name.
EXPECT_EQ(AudioManager::resolvePlaybackDeviceName(0), "");
EXPECT_EQ(AudioManager::resolvePlaybackDeviceName(SDL_AUDIO_DEVICE_DEFAULT_PLAYBACK), "");
}
TEST(AudioTest, GetPlaybackDevicesFallsBackWithIdZeroWhenNone) {
AudioManager am;
auto devices = am.getPlaybackDevices();
// Headless hosts (CI) expose no playback devices, so expect the synthetic
// fallback entry that carries id 0 (the "system default" marker).
if (devices.empty()) {
// No devices and no fallback would be a contract violation.
FAIL() << "getPlaybackDevices() returned an empty list without fallback";
}
for (const auto& d : devices) {
if (d.raw_name.empty()) {
EXPECT_EQ(d.id, 0u);
EXPECT_EQ(d.friendly_name, "System Default");
}
}
}
TEST(AudioTest, AudioManagerVolumeClamping) {
radiantwave::config::ConfigManager config;
std::string temp_path = (std::filesystem::temp_directory_path() / "test_rw_audio_cfg.yaml").string();
@@ -36,3 +61,14 @@ TEST(AudioTest, AudioManagerVolumeClamping) {
am.shutdown();
std::filesystem::remove(temp_path);
}
TEST(AudioTest, PlayOptionsLoopInfinite) {
// Regression: MIX_PlayTrack() must not default to a single pass. Tracks
// have to loop forever so playback never runs out mid-scroll; a track that
// stops (rather than attenuates) also can't be revived by raising app
// volume. Assert the options request infinite looping (-1).
SDL_PropertiesID props = AudioManager::createInfiniteLoopOptions();
ASSERT_NE(props, 0u);
EXPECT_EQ(SDL_GetNumberProperty(props, MIX_PROP_PLAY_LOOPS_NUMBER, 0), -1);
SDL_DestroyProperties(props);
}
+3
View File
@@ -44,6 +44,7 @@ TEST(YamlParserTest, ConfigRoundTrip) {
original.audio.volume = 60;
original.audio.dynamic_audio = false;
original.proclamation.scroll_speed = 1.8f;
original.proclamation.schedule_active = true;
std::string serialized = serializeConfigToYaml(original);
AppConfig parsed = parseConfigFromYaml(serialized);
@@ -53,6 +54,7 @@ TEST(YamlParserTest, ConfigRoundTrip) {
EXPECT_EQ(parsed.audio.volume, original.audio.volume);
EXPECT_EQ(parsed.audio.dynamic_audio, original.audio.dynamic_audio);
EXPECT_FLOAT_EQ(parsed.proclamation.scroll_speed, original.proclamation.scroll_speed);
EXPECT_TRUE(parsed.proclamation.schedule_active);
}
TEST(ConfigManagerTest, DefaultGeneration) {
@@ -68,6 +70,7 @@ TEST(ConfigManagerTest, DefaultGeneration) {
EXPECT_EQ(cfg.audio.volume, 80);
EXPECT_EQ(cfg.audio.dynamic_audio, true);
EXPECT_EQ(cfg.proclamation.font_size, 32);
EXPECT_FALSE(cfg.proclamation.schedule_active);
EXPECT_TRUE(std::filesystem::exists(temp_path));
std::filesystem::remove(temp_path);
+56
View File
@@ -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<time_t>(-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<time_t>(-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<time_t>(-1));
EXPECT_EQ(formatTime12h(tEvening), "7:05 PM");
}
+148 -4
View File
@@ -185,23 +185,119 @@ TEST(DatabaseManagerTest, ReplaceAndReadCategories) {
DbFixture fx("test_rw_db_cats.db");
std::vector<ProclamationCategoryRecord> cats;
cats.push_back({3, "Root", 0, 1});
cats.push_back({7, "Child", 3, 2});
cats.push_back({3, "Spiritual", 1});
cats.push_back({7, "Physical", 2});
ASSERT_TRUE(fx.db.replaceCategories(cats));
auto all = fx.db.getAllCategories();
ASSERT_EQ(all.size(), 2u);
EXPECT_EQ(all[0].id, 3);
EXPECT_EQ(all[0].parent_id, 0);
EXPECT_EQ(all[0].name, "Spiritual");
EXPECT_EQ(all[0].sort_order, 1);
EXPECT_EQ(all[1].id, 7);
EXPECT_EQ(all[1].parent_id, 3);
EXPECT_EQ(all[1].name, "Physical");
EXPECT_EQ(all[1].sort_order, 2);
// Replacing wipes previous categories.
ASSERT_TRUE(fx.db.replaceCategories({}));
EXPECT_EQ(fx.db.getAllCategories().size(), 0u);
}
TEST(DatabaseManagerTest, ReplaceAndReadGroups) {
DbFixture fx("test_rw_db_groups.db");
std::vector<ProclamationGroupRecord> groups;
groups.push_back({10, "Prayers", 2});
groups.push_back({4, "Affirmations", 1});
ASSERT_TRUE(fx.db.replaceGroups(groups));
auto all = fx.db.getAllGroups();
ASSERT_EQ(all.size(), 2u);
// Ordered by sort_order, not by id.
EXPECT_EQ(all[0].id, 4);
EXPECT_EQ(all[0].name, "Affirmations");
EXPECT_EQ(all[1].id, 10);
EXPECT_EQ(all[1].name, "Prayers");
ASSERT_TRUE(fx.db.replaceGroups({}));
EXPECT_EQ(fx.db.getAllGroups().size(), 0u);
}
TEST(DatabaseManagerTest, MigratesLegacySubCategoriesToGroups) {
const std::string path =
(std::filesystem::temp_directory_path() / "test_rw_db_migrate_v1.db").string();
if (std::filesystem::exists(path)) {
std::filesystem::remove(path);
}
// Build a pre-v2 database by hand: a two-level category tree and a
// proclamation selected inside a sub-category.
{
sqlite3* raw = nullptr;
ASSERT_EQ(sqlite3_open(path.c_str(), &raw), SQLITE_OK);
const char* legacy =
"CREATE TABLE proclamations ("
" id TEXT PRIMARY KEY, title TEXT NOT NULL, content TEXT NOT NULL,"
" solfeggio_freq_hz REAL DEFAULT 528.0, category TEXT DEFAULT 'general',"
" is_active INTEGER DEFAULT 1, selected INTEGER DEFAULT 0,"
" category_id INTEGER DEFAULT 0, solfeggio_frequency_id INTEGER DEFAULT 0,"
" created_at DATETIME DEFAULT CURRENT_TIMESTAMP);"
"CREATE TABLE proclamation_categories ("
" id INTEGER PRIMARY KEY, name TEXT NOT NULL,"
" parent_id INTEGER DEFAULT 0, sort_order INTEGER DEFAULT 0);"
"INSERT INTO proclamation_categories (id, name, parent_id, sort_order)"
" VALUES (3,'Spiritual',0,1),(7,'Inner',3,2);"
"INSERT INTO proclamations (id,title,content,category_id,solfeggio_frequency_id,selected)"
" VALUES ('p1','Peace','x',7,2,1),('p2','Root-level','y',3,5,0);";
char* err = nullptr;
int rc = sqlite3_exec(raw, legacy, nullptr, nullptr, &err);
if (err) sqlite3_free(err);
sqlite3_close(raw);
ASSERT_EQ(rc, SQLITE_OK);
}
DatabaseManager db;
ASSERT_TRUE(db.initialize(path));
// Each former sub-category becomes a group; only the root remains a category.
auto groups = db.getAllGroups();
ASSERT_EQ(groups.size(), 1u);
EXPECT_EQ(groups[0].id, 7);
EXPECT_EQ(groups[0].name, "Inner");
auto cats = db.getAllCategories();
ASSERT_EQ(cats.size(), 1u);
EXPECT_EQ(cats[0].id, 3);
EXPECT_EQ(cats[0].name, "Spiritual");
// Proclamations pointing at the old sub-category are remapped to the root
// category + the new group; selection is preserved.
auto procs = db.getAllProclamations();
ASSERT_EQ(procs.size(), 2u);
for (const auto& p : procs) {
EXPECT_EQ(p.category_id, 3) << "proclamation " << p.id;
if (p.id == "p1") {
EXPECT_EQ(p.group_id, 7);
EXPECT_TRUE(p.selected);
} else if (p.id == "p2") {
EXPECT_EQ(p.group_id, 0);
EXPECT_FALSE(p.selected);
}
}
// Re-running the migration is a no-op (user_version gate + idempotent SQL).
db.close();
ASSERT_TRUE(db.initialize(path));
EXPECT_EQ(db.getAllGroups().size(), 1u);
EXPECT_EQ(db.getAllCategories().size(), 1u);
EXPECT_EQ(db.getAllProclamations().size(), 2u);
db.close();
std::filesystem::remove(path);
}
TEST(DatabaseManagerTest, SyncAudioFilesUpsertsAndDeletesMissing) {
DbFixture fx("test_rw_db_audio.db");
@@ -251,6 +347,54 @@ TEST(DatabaseManagerTest, SyncAudioFilesClearsEverythingWhenEmpty) {
EXPECT_EQ(fx.db.getAudioFiles().size(), 0u);
}
TEST(DatabaseManagerTest, ReplaceAndReadScheduleBlocks) {
DbFixture fx("test_rw_db_schedule.db");
ScheduleBlockRecord morning;
morning.id = "b1";
morning.start_minutes = 360;
morning.end_minutes = 480;
morning.overnight = false;
morning.duration_minutes = 120;
morning.dynamic_audio = false;
morning.proclamation_ids = {"p2", "p1"};
ScheduleBlockRecord night;
night.id = "b2";
night.start_minutes = 1260;
night.end_minutes = 540;
night.overnight = true;
night.duration_minutes = 720;
night.dynamic_audio = true;
night.proclamation_ids = {"p3"};
ASSERT_TRUE(fx.db.replaceScheduleBlocks({morning, night}));
auto rows = fx.db.getScheduleBlocks();
ASSERT_EQ(rows.size(), 2u);
EXPECT_EQ(rows[0].id, "b1");
EXPECT_EQ(rows[0].start_minutes, 360);
EXPECT_EQ(rows[0].end_minutes, 480);
EXPECT_FALSE(rows[0].overnight);
EXPECT_EQ(rows[0].duration_minutes, 120);
EXPECT_FALSE(rows[0].dynamic_audio);
// Play order is preserved, not sorted.
ASSERT_EQ(rows[0].proclamation_ids.size(), 2u);
EXPECT_EQ(rows[0].proclamation_ids[0], "p2");
EXPECT_EQ(rows[0].proclamation_ids[1], "p1");
EXPECT_EQ(rows[1].id, "b2");
EXPECT_TRUE(rows[1].overnight);
EXPECT_TRUE(rows[1].dynamic_audio);
ASSERT_EQ(rows[1].proclamation_ids.size(), 1u);
EXPECT_EQ(rows[1].proclamation_ids[0], "p3");
// A re-sync replaces the whole schedule.
ASSERT_TRUE(fx.db.replaceScheduleBlocks({}));
EXPECT_EQ(fx.db.getScheduleBlocks().size(), 0u);
}
TEST(DatabaseManagerTest, PalettesSeededAndRead) {
DbFixture fx("test_rw_db_palette.db");
+75
View File
@@ -1,5 +1,6 @@
#include "network/license_service.h"
#include "db/db_keys.h"
#include "network/system_name.h"
#include <gtest/gtest.h>
#include <cstdlib>
@@ -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 = "<none>";
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) {
+37 -8
View File
@@ -6,8 +6,8 @@ using namespace radiantwave::sync;
TEST(ProclamationSyncTest, ParseProclamationsJson) {
const std::string body =
R"([{"id":"p1","name":"Peace","content":"<p>I am <b>peace</b>.</p>","categoryId":3,"solfeggioFrequencyId":2},
{"id":"p2","name":"Joy &amp; Calm","content":"text","categoryId":0,"solfeggioFrequencyId":0},
R"([{"id":"p1","name":"Peace","content":"<p>I am <b>peace</b>.</p>","categoryId":3,"groupId":7,"solfeggioFrequencyId":2},
{"id":"p2","name":"Joy &amp; Calm","content":"text","categoryId":0,"groupId":0,"solfeggioFrequencyId":0},
{"notAnObject":true}])";
std::vector<ProclamationRecord> out;
@@ -18,12 +18,14 @@ TEST(ProclamationSyncTest, ParseProclamationsJson) {
EXPECT_EQ(out[0].title, "Peace");
EXPECT_EQ(out[0].content, "I am peace.");
EXPECT_EQ(out[0].category_id, 3);
EXPECT_EQ(out[0].group_id, 7);
EXPECT_EQ(out[0].solfeggio_frequency_id, 2);
EXPECT_EQ(out[1].id, "p2");
EXPECT_EQ(out[1].title, "Joy & Calm");
EXPECT_EQ(out[1].content, "text");
EXPECT_EQ(out[1].category_id, 0);
EXPECT_EQ(out[1].group_id, 0);
EXPECT_EQ(out[1].solfeggio_frequency_id, 0);
}
@@ -38,9 +40,10 @@ TEST(ProclamationSyncTest, ParseIgnoresLegacySubscriptionLevelField) {
}
TEST(ProclamationSyncTest, ParseCategoriesJson) {
// Categories are flat now; a legacy parentId field is simply ignored.
const std::string body =
R"([{"id":3,"name":"Spiritual","parentId":0,"sortOrder":1},
{"id":7,"name":"Inner","parentId":3,"sortOrder":2}])";
R"([{"id":3,"name":"Spiritual","sortOrder":1},
{"id":7,"name":"Physical","parentId":3,"sortOrder":2}])";
std::vector<ProclamationCategoryRecord> out;
EXPECT_TRUE(parseCategoriesJson(body, out));
@@ -48,18 +51,35 @@ TEST(ProclamationSyncTest, ParseCategoriesJson) {
EXPECT_EQ(out[0].id, 3);
EXPECT_EQ(out[0].name, "Spiritual");
EXPECT_EQ(out[0].parent_id, 0);
EXPECT_EQ(out[0].sort_order, 1);
EXPECT_EQ(out[1].id, 7);
EXPECT_EQ(out[1].name, "Inner");
EXPECT_EQ(out[1].parent_id, 3);
EXPECT_EQ(out[1].name, "Physical");
EXPECT_EQ(out[1].sort_order, 2);
}
TEST(ProclamationSyncTest, ParseGroupsJson) {
const std::string body =
R"([{"id":5,"name":"Prayers","sortOrder":0},
{"id":8,"name":"Affirmations","sortOrder":3}])";
std::vector<ProclamationGroupRecord> out;
EXPECT_TRUE(parseGroupsJson(body, out));
ASSERT_EQ(out.size(), 2u);
EXPECT_EQ(out[0].id, 5);
EXPECT_EQ(out[0].name, "Prayers");
EXPECT_EQ(out[0].sort_order, 0);
EXPECT_EQ(out[1].id, 8);
EXPECT_EQ(out[1].name, "Affirmations");
EXPECT_EQ(out[1].sort_order, 3);
}
TEST(ProclamationSyncTest, RejectMalformedJson) {
std::vector<ProclamationRecord> procs;
std::vector<ProclamationCategoryRecord> cats;
std::vector<ProclamationGroupRecord> groups;
EXPECT_FALSE(parseProclamationsJson("not json", procs));
EXPECT_FALSE(parseProclamationsJson(R"({"id":"p1"})", procs)); // object, not array
@@ -69,6 +89,10 @@ TEST(ProclamationSyncTest, RejectMalformedJson) {
// Non-numeric ids are tolerated (row skipped), not fatal.
EXPECT_TRUE(parseCategoriesJson(R"([{"id":"x"}])", cats));
EXPECT_EQ(cats.size(), 0u);
EXPECT_FALSE(parseGroupsJson("garbage", groups));
EXPECT_TRUE(parseGroupsJson(R"([{"id":"x"}])", groups));
EXPECT_EQ(groups.size(), 0u);
}
TEST(ProclamationSyncTest, ParseSkipsMissingIdAndZeroCategory) {
@@ -78,9 +102,14 @@ TEST(ProclamationSyncTest, ParseSkipsMissingIdAndZeroCategory) {
EXPECT_EQ(procs[0].id, "ok");
std::vector<ProclamationCategoryRecord> cats;
EXPECT_TRUE(parseCategoriesJson(R"([{"id":0,"name":"Bad"},{"id":9,"name":"Good","parentId":0}])", cats));
EXPECT_TRUE(parseCategoriesJson(R"([{"id":0,"name":"Bad"},{"id":9,"name":"Good","sortOrder":1}])", cats));
ASSERT_EQ(cats.size(), 1u);
EXPECT_EQ(cats[0].id, 9);
std::vector<ProclamationGroupRecord> groups;
EXPECT_TRUE(parseGroupsJson(R"([{"id":0,"name":"Bad"},{"id":4,"name":"Good","sortOrder":1}])", groups));
ASSERT_EQ(groups.size(), 1u);
EXPECT_EQ(groups[0].id, 4);
}
TEST(ProclamationSyncTest, StripHtmlEntities) {
+376
View File
@@ -0,0 +1,376 @@
#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 <gtest/gtest.h>
#include <filesystem>
#include <fstream>
#include <sstream>
#include <string>
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();
}
// Regression: start() must not deadlock when a cache file is present. It used
// to hold mutex_ and then call loadCache(), which re-locked the same
// non-recursive mutex (self-deadlock), blocking SDL_AppInit before the first
// frame was ever presented — the kiosk sat on a black screen.
TEST(ScheduleManagerTest, StartWithCachedPayloadDoesNotDeadlock) {
const std::string dir = tempCacheDir();
setenv("HOME", dir.c_str(), 1);
SchedulePayload p = dailyPayload();
ASSERT_TRUE(radiantwave::sync::schedule_cache::write(p, "\"cached\""));
const std::string dbPath =
(std::filesystem::temp_directory_path() / "rw_schedule_start_test.db")
.string();
std::filesystem::remove(dbPath);
radiantwave::db::DatabaseManager db;
ASSERT_TRUE(db.initialize(dbPath));
radiantwave::schedule::ScheduleManager manager;
manager.start(db, "UTC");
EXPECT_TRUE(manager.hasSchedule());
manager.shutdown();
db.close();
std::filesystem::remove(dbPath);
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");
}
+128
View File
@@ -17,6 +17,7 @@ using radiantwave::scroller::StreamColor;
using radiantwave::scroller::buildStreams;
using radiantwave::scroller::kDefaultRepeatCount;
using radiantwave::scroller::kSelectedRepeatCount;
using radiantwave::scroller::minuteInScheduleBlock;
const std::string kDefaultId = "default";
@@ -94,6 +95,62 @@ TEST(ScrollerStreamsTest, InterleavesSelectedWithDefault)
EXPECT_EQ(streams[3].audioFilename, "OriginalAudio.wav");
}
TEST(ScrollerStreamsTest, InterleavedListCyclesBackToFirstSelected)
{
// Three selected proclamations: the built list must be
// [S1, Default, S2, Default, S3, Default]. ScrollerPage advances through
// this list with a modulo, so once the trailing default finishes the cycle
// restarts at the FIRST selected proclamation — the "always on" default
// must never accumulate or take over the queue.
ProclamationRecord b;
b.id = "B";
b.title = "B";
b.content = "bbbb";
b.selected = true;
b.solfeggio_frequency_id = 2;
ProclamationRecord c;
c.id = "C";
c.title = "C";
c.content = "cccc";
c.selected = true;
c.solfeggio_frequency_id = 3;
ProclamationRecord d;
d.id = "D";
d.title = "D";
d.content = "dddd";
d.selected = true;
d.solfeggio_frequency_id = 4;
ProclamationRecord def;
def.id = kDefaultId;
def.title = "Default";
def.content = "dddddddddd";
def.selected = true;
def.solfeggio_frequency_id = 5;
auto streams = buildStreams({b, def, c, d}, {}, palette(), {}, kDefaultId);
ASSERT_EQ(streams.size(), 6u);
EXPECT_EQ(streams[0].name, "B");
EXPECT_EQ(streams[1].name, "Default");
EXPECT_EQ(streams[2].name, "C");
EXPECT_EQ(streams[3].name, "Default");
EXPECT_EQ(streams[4].name, "D");
EXPECT_EQ(streams[5].name, "Default");
// Every default copy is independent playback state (fresh counters), so a
// copy finishing its passes advances to the next stream rather than
// restarting "the default" forever.
for (size_t i = 1; i < streams.size(); i += 2) {
EXPECT_EQ(streams[i].name, "Default");
EXPECT_EQ(streams[i].completed, 0) << "default copy " << i;
EXPECT_EQ(streams[i].wordsPlaced, 0) << "default copy " << i;
EXPECT_EQ(streams[i].repeat, kDefaultRepeatCount) << "default copy " << i;
}
}
TEST(ScrollerStreamsTest, DefaultOnlyWhenNothingSelected)
{
ProclamationRecord p1;
@@ -129,6 +186,77 @@ TEST(ScrollerStreamsTest, MissingDefaultStillStreamsSelected)
EXPECT_EQ(streams[0].repeat, kSelectedRepeatCount);
}
TEST(ScrollerStreamsTest, MinuteInScheduleBlockDaytime)
{
// 06:00 (360) to 08:00 (480), half-open.
EXPECT_FALSE(minuteInScheduleBlock(359, 360, 480, false));
EXPECT_TRUE(minuteInScheduleBlock(360, 360, 480, false));
EXPECT_TRUE(minuteInScheduleBlock(479, 360, 480, false));
EXPECT_FALSE(minuteInScheduleBlock(480, 360, 480, false));
}
TEST(ScrollerStreamsTest, MinuteInScheduleBlockOvernight)
{
// 21:00 (1260) to 09:00 (540) wraps past midnight.
EXPECT_TRUE(minuteInScheduleBlock(1260, 1260, 540, true));
EXPECT_TRUE(minuteInScheduleBlock(1439, 1260, 540, true));
EXPECT_TRUE(minuteInScheduleBlock(0, 1260, 540, true));
EXPECT_TRUE(minuteInScheduleBlock(539, 1260, 540, true));
EXPECT_FALSE(minuteInScheduleBlock(540, 1260, 540, true));
EXPECT_FALSE(minuteInScheduleBlock(1259, 1260, 540, true));
}
TEST(ScrollerStreamsTest, ScheduledIdsDriveOrderRegardlessOfSelection)
{
ProclamationRecord p1;
p1.id = "one";
p1.title = "One";
p1.content = "x";
p1.selected = false;
ProclamationRecord p2;
p2.id = "two";
p2.title = "Two";
p2.content = "y";
p2.selected = false;
ProclamationRecord def;
def.id = kDefaultId;
def.title = "Default";
def.content = "z";
// Explicit order two -> one, interleaved with the default. Unknown ids are
// skipped; the `selected` flag is ignored.
std::vector<std::string> scheduled = {"two", "missing", "one"};
auto streams = buildStreams({p1, p2, def}, {}, palette(), {}, kDefaultId, &scheduled);
ASSERT_EQ(streams.size(), 4u);
EXPECT_EQ(streams[0].name, "Two");
EXPECT_EQ(streams[1].name, "Default");
EXPECT_EQ(streams[2].name, "One");
EXPECT_EQ(streams[3].name, "Default");
}
TEST(ScrollerStreamsTest, EmptyScheduleFallsBackToDefault)
{
ProclamationRecord p1;
p1.id = "one";
p1.title = "One";
p1.content = "x";
p1.selected = true;
ProclamationRecord def;
def.id = kDefaultId;
def.title = "Default";
def.content = "y";
std::vector<std::string> scheduled; // empty block
auto streams = buildStreams({p1, def}, {}, palette(), {}, kDefaultId, &scheduled);
ASSERT_EQ(streams.size(), 1u);
EXPECT_EQ(streams[0].name, "Default");
}
TEST(ScrollerStreamsTest, SplitsGluedSentencePunctuation)
{
ProclamationRecord p1;
+56
View File
@@ -0,0 +1,56 @@
#include "settings_menu.h"
#include <gtest/gtest.h>
#include <set>
#include <string>
namespace settings = radiantwave::settings;
TEST(SystemSettingsMenuTest, ListsTheSystemSettingsInOrder) {
const auto entries = settings::systemSettingEntries();
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, "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) {
const auto entries = settings::systemSettingEntries();
std::set<std::string> labels;
std::set<int> ids;
for (const auto& entry : entries) {
EXPECT_FALSE(entry.label.empty());
labels.insert(entry.label);
ids.insert(static_cast<int>(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::SystemName:
case settings::SystemSettingId::Schedule:
case settings::SystemSettingId::WiFiSetup:
case settings::SystemSettingId::Timezone:
case settings::SystemSettingId::DateFormat:
SUCCEED();
break;
default:
FAIL() << "Unhandled SystemSettingId for label " << entry.label;
}
}
}
+244
View File
@@ -0,0 +1,244 @@
#include "network/system_name.h"
#include "db/db_keys.h"
#include "validation.h"
#include <gtest/gtest.h>
#include <filesystem>
#include <string>
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), "");
}
+18
View File
@@ -26,6 +26,24 @@ TEST(TimeZoneTest, UtcTimestampFormat) {
EXPECT_NE(iso.find("UTC"), std::string::npos);
}
TEST(TimeZoneTest, CurrentLocalMinutesInRangeAndMatchesUtc) {
int minutes = currentLocalMinutes("UTC");
EXPECT_GE(minutes, 0);
EXPECT_LT(minutes, 1440);
// Cross-check against the UTC wall clock. The two calls happen within the
// same second, so a minute-boundary rollover is vanishingly unlikely.
time_t now = std::time(nullptr);
struct tm gmt_buf;
gmtime_r(&now, &gmt_buf);
EXPECT_EQ(minutes, gmt_buf.tm_hour * 60 + gmt_buf.tm_min);
// A timezone that is known to differ from UTC must still be in range.
int ny = currentLocalMinutes("America/New_York");
EXPECT_GE(ny, 0);
EXPECT_LT(ny, 1440);
}
TEST(TimeZoneTest, FormatUtcToTimezone) {
// 2026-07-24 12:00:00 UTC
// Fixed epoch timestamp: 1784808000
-62
View File
@@ -1,62 +0,0 @@
Radiant Wave
User Manual
Version 1.0
Intro
Some thank you message and introduction to Radiant Waves.
Table of Contents
Setup
Connect to WiFi
Connection to the internet is required for Radiant Wave to operate.
Wifi screen appears first
UP/DOWN to navigate through detected WiFi networks
Enter to select one
Password prompt if applicable
I still need to test entering an incorrect WiFi password on a kiosk machine
Check for crashes or correct error messaging
Email Address
The next screen you’ll be presented with is the email address prompt.
When you purchased your RadiantWave system, you should have received an email at the address you provided during your purchase containing your license key.
Enter the email address where you received your license key here.
Note that you will not be able to enter an invalid email address, but you will be able to enter the incorrect email address. You will see how to change your email address later.
License Key
Grab your license key from your email, now is the time to use it!
A few notes on the License Key prompt:
You don't have to hold SHIFT or enable CAPS LOCK, because that's already done for you
You won't have to enter the dashes "-", as those are entered for you
You CAN enter an incorrect license key
You CAN fix it later
Same steps as the email address, just type out your license key and press enter.
The Main Menu
Now you're in! This is the main menu, and it doesn't do a whole lot. It's only purpose is to display some system information for you to quickly reference when needed, such as the email address and license key you currently have saved or the current system version you have running. Other than that, the Main Menu's only purpose is to provide navigation between the system settings, and the main attraction itself.
TODO
wifi
email
license
main menu
settings
time zone
date
logs
audio options
proclamations
play
updates