Add schedule sync and schedule-aware playback

This commit is contained in:
NeilClack
2026-09-11 19:14:55 -05:00
parent a043ec84d8
commit f37beaf07e
23 changed files with 893 additions and 32 deletions
+145
View File
@@ -31,6 +31,8 @@ tags:
description: Proclamation content, optionally scoped by license entitlement. description: Proclamation content, optionally scoped by license entitlement.
- name: License - name: License
description: License key validation. description: License key validation.
- name: Schedule
description: A user's daily listening schedule, consumed by the kiosk.
- name: Automations - name: Automations
description: Signed machine-to-machine webhooks. description: Signed machine-to-machine webhooks.
paths: paths:
@@ -230,6 +232,83 @@ paths:
application/json: application/json:
schema: schema:
$ref: '#/components/schemas/ErrorMessage' $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. 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`.
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/automations/create/account: /api/v2/automations/create/account:
post: post:
tags: [Automations] tags: [Automations]
@@ -436,6 +515,72 @@ components:
email_address: email_address:
type: string type: string
format: email format: email
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
blocks:
type: array
maxItems: 4
items:
$ref: '#/components/schemas/ScheduleBlock'
OnboardingRequest: OnboardingRequest:
type: object type: object
required: [email] required: [email]
+1
View File
@@ -16,6 +16,7 @@ struct ProclamationConfig {
float scroll_speed = 1.0f; // scroll speed multiplier float scroll_speed = 1.0f; // scroll speed multiplier
int font_size = 32; // font size in pt int font_size = 32; // font size in pt
std::string current_id = ""; // currently active proclamation ID std::string current_id = ""; // currently active proclamation ID
bool schedule_active = false; // follow the portal's listening schedule
}; };
struct AppConfig { struct AppConfig {
+3
View File
@@ -250,6 +250,9 @@ void ConfigManager::loadAndMergeDefaults() {
if (proc_node.hasChild("current_id")) { if (proc_node.hasChild("current_id")) {
merged.proclamation.current_id = proc_node.getChild("current_id").asString(defaults.proclamation.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);
}
} }
{ {
+4
View File
@@ -231,6 +231,9 @@ AppConfig parseConfigFromYaml(const std::string& yamlContent) {
if (proc_node.hasChild("current_id")) { if (proc_node.hasChild("current_id")) {
config.proclamation.current_id = proc_node.getChild("current_id").asString(config.proclamation.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);
}
} }
return config; return config;
@@ -256,6 +259,7 @@ std::string serializeConfigToYaml(const AppConfig& config) {
ss << " scroll_speed: " << config.proclamation.scroll_speed << "\n"; ss << " scroll_speed: " << config.proclamation.scroll_speed << "\n";
ss << " font_size: " << config.proclamation.font_size << "\n"; ss << " font_size: " << config.proclamation.font_size << "\n";
ss << " current_id: \"" << config.proclamation.current_id << "\"\n"; ss << " current_id: \"" << config.proclamation.current_id << "\"\n";
ss << " schedule_active: " << (config.proclamation.schedule_active ? "true" : "false") << "\n";
return ss.str(); return ss.str();
} }
+128
View File
@@ -181,6 +181,23 @@ bool DatabaseManager::runMigrations() {
hex TEXT NOT NULL, hex TEXT NOT NULL,
PRIMARY KEY (solfeggio_frequency_id, color_index) 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); bool ok = executeSql(schema);
@@ -1083,4 +1100,115 @@ std::vector<AudioFileRecord> DatabaseManager::getAudioFiles() {
return result; 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 } // namespace radiantwave::db
+22
View File
@@ -54,6 +54,18 @@ struct AudioFileRecord {
int solfeggio_frequency_id = 0; // 0 = not associated with a solfeggio frequency 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). // A single color in a solfeggio frequency's color palette (RGBA).
struct PaletteColor { struct PaletteColor {
uint8_t r = 0; uint8_t r = 0;
@@ -154,6 +166,16 @@ public:
// palettes can be pushed from the portal once the API route exists. // palettes can be pushed from the portal once the API route exists.
bool replaceColorPalettes(const std::vector<PaletteRecord>& records); 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 ─────────────────────────────────────────── // ── Event Logging Table ───────────────────────────────────────────
bool logEvent(const std::string& level, const std::string& component, const std::string& message); bool logEvent(const std::string& level, const std::string& component, const std::string& message);
+80
View File
@@ -221,6 +221,86 @@ bool parseCreateAccountJson(const std::string& body, AccountBody& out) {
return true; 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);
}
AccountResult createAccount(const std::string& email, const std::string& name) { AccountResult createAccount(const std::string& email, const std::string& name) {
AccountResult result; AccountResult result;
+34
View File
@@ -17,6 +17,7 @@ constexpr const char* PATH_FREQUENCIES = "/frequencies";
constexpr const char* PATH_LICENSE = "/license"; constexpr const char* PATH_LICENSE = "/license";
constexpr const char* PATH_CREATE_ACCOUNT = "/automations/create/account"; constexpr const char* PATH_CREATE_ACCOUNT = "/automations/create/account";
constexpr const char* PATH_COLOR_PALETTES = "/color-palettes"; constexpr const char* PATH_COLOR_PALETTES = "/color-palettes";
constexpr const char* PATH_SCHEDULE = "/schedule";
// Result of a single HTTP request. // Result of a single HTTP request.
struct HttpResponse { struct HttpResponse {
@@ -119,4 +120,37 @@ struct AccountResult {
// to the user and is NOT part of the response. `name` may be empty. // to the user and is NOT part of the response. `name` may be empty.
AccountResult createAccount(const std::string& email, const std::string& name = ""); 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);
} // namespace radiantwave::api } // namespace radiantwave::api
+33
View File
@@ -1,4 +1,5 @@
#include "network/proclamation_sync.h" #include "network/proclamation_sync.h"
#include "db/db_keys.h"
#include "network/api_client.h" #include "network/api_client.h"
#include "network/json.h" #include "network/json.h"
@@ -161,9 +162,41 @@ bool syncProclamationsFromApi(radiantwave::db::DatabaseManager& db,
if (!db.syncProclamations(proclamations, defaultProclamationId)) { if (!db.syncProclamations(proclamations, defaultProclamationId)) {
return false; 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; 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) ───── // ─── Color palettes (readiness code; not wired into the app flow) ─────
bool parseProclamationPalettesJson(const std::string& body, bool parseProclamationPalettesJson(const std::string& body,
+9
View File
@@ -23,9 +23,18 @@ bool parseGroupsJson(const std::string& body,
// the DB, preserving any existing selection and forcing `defaultProclamationId` // the DB, preserving any existing selection and forcing `defaultProclamationId`
// to stay selected. Returns true only if the API sync succeeded; on failure the // 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. // 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, bool syncProclamationsFromApi(radiantwave::db::DatabaseManager& db,
const std::string& defaultProclamationId); 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 // Parse color palettes embedded in the proclamations JSON payload. Each
// proclamation object may carry a "colorPalette" array of "#RRGGBB" strings; // proclamation object may carry a "colorPalette" array of "#RRGGBB" strings;
// the palette is keyed by the proclamation's solfeggioFrequencyId. When // the palette is keyed by the proclamation's solfeggioFrequencyId. When
+44 -12
View File
@@ -174,6 +174,16 @@ void ProclamationOptionsPage::rebuildItems()
{ {
items_.clear(); 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. // Always-on top-level proclamation, never inside a category.
items_.push_back({ items_.push_back({
FlatItem::Type::Proclamation, FlatItem::Type::Proclamation,
@@ -258,6 +268,11 @@ void ProclamationOptionsPage::rebuildVisible()
for (size_t i = 0; i < items_.size(); ++i) { for (size_t i = 0; i < items_.size(); ++i) {
const FlatItem& item = items_[i]; const FlatItem& item = items_[i];
if (item.type == FlatItem::Type::ScheduleToggle) {
visible_.push_back(i);
continue;
}
if (item.type == FlatItem::Type::Category) { if (item.type == FlatItem::Type::Category) {
currentCategory = static_cast<int>(i); currentCategory = static_cast<int>(i);
currentGroup = -1; currentGroup = -1;
@@ -304,6 +319,18 @@ void ProclamationOptionsPage::toggleItem()
return; 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) { if (item.isDefault) {
statusMessage_ = "The top-level proclamation is always on"; statusMessage_ = "The top-level proclamation is always on";
return; return;
@@ -715,14 +742,25 @@ void ProclamationOptionsPage::render(SDL_Renderer* renderer)
} }
} else { } else {
// --- Browse tree --- // --- 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 // Stable width: compute from every item so the list doesn't
// shift left/right when groups expand or collapse. // shift left/right when groups expand or collapse.
float maxTotalWidth = 0.0f; float maxTotalWidth = 0.0f;
for (const auto& item : items_) { for (const auto& item : items_) {
std::string label = item.label; std::string label = labelFor(item);
if (item.type == FlatItem::Type::Category || item.type == FlatItem::Type::Group) {
label = (item.expanded ? "[-] " : "[+] ") + label;
}
int w = 0, h = 0; int w = 0, h = 0;
TTF_GetStringSize(fontItem_, label.c_str(), 0, &w, &h); TTF_GetStringSize(fontItem_, label.c_str(), 0, &w, &h);
float total = item.depth * indent + static_cast<float>(w); float total = item.depth * indent + static_cast<float>(w);
@@ -732,10 +770,7 @@ void ProclamationOptionsPage::render(SDL_Renderer* renderer)
std::vector<float> textWidths(visible_.size()); std::vector<float> textWidths(visible_.size());
for (int i = scrollOffset_; i < scrollOffset_ + visibleCount_ && i < static_cast<int>(visible_.size()); ++i) { for (int i = scrollOffset_; i < scrollOffset_ + visibleCount_ && i < static_cast<int>(visible_.size()); ++i) {
const FlatItem& item = items_[visible_[i]]; const FlatItem& item = items_[visible_[i]];
std::string label = item.label; std::string label = labelFor(item);
if (item.type == FlatItem::Type::Category || item.type == FlatItem::Type::Group) {
label = (item.expanded ? "[-] " : "[+] ") + label;
}
int w = 0, h = 0; int w = 0, h = 0;
TTF_GetStringSize(fontItem_, label.c_str(), 0, &w, &h); TTF_GetStringSize(fontItem_, label.c_str(), 0, &w, &h);
textWidths[i] = static_cast<float>(w); textWidths[i] = static_cast<float>(w);
@@ -750,10 +785,7 @@ void ProclamationOptionsPage::render(SDL_Renderer* renderer)
const FlatItem& item = items_[visible_[i]]; const FlatItem& item = items_[visible_[i]];
float y = listTop + rowIndex * rowPitch; float y = listTop + rowIndex * rowPitch;
std::string label = item.label; std::string label = labelFor(item);
if (item.type == FlatItem::Type::Category || item.type == FlatItem::Type::Group) {
label = (item.expanded ? "[-] " : "[+] ") + label;
}
SDL_Color color = white; SDL_Color color = white;
if (item.type == FlatItem::Type::Category) { if (item.type == FlatItem::Type::Category) {
+3 -3
View File
@@ -28,14 +28,14 @@ public:
private: private:
struct FlatItem { struct FlatItem {
enum class Type { Category, Group, Proclamation }; enum class Type { ScheduleToggle, Category, Group, Proclamation };
Type type = Type::Proclamation; Type type = Type::Proclamation;
std::string id; // category id, group id, or proclamation id std::string id; // category id, group id, proclamation id, or "schedule"
std::string label; // display text std::string label; // display text
int depth = 0; // 0 = category / default, 1 = group, 2 = proclamation int depth = 0; // 0 = category / default, 1 = group, 2 = proclamation
bool expanded = false; // categories and groups only bool expanded = false; // categories and groups only
bool selected = false; // proclamations only bool selected = false; // proclamations and the schedule toggle
bool isDefault = false; // the always-on top-level proclamation bool isDefault = false; // the always-on top-level proclamation
}; };
+85 -7
View File
@@ -5,6 +5,7 @@
#include "logical_size.h" #include "logical_size.h"
#include "proclamation_options_page.h" #include "proclamation_options_page.h"
#include "scramble/fibonacci_scrambler.h" #include "scramble/fibonacci_scrambler.h"
#include "timezone_helper.h"
#include "utf8_utils.h" #include "utf8_utils.h"
#include <SDL3/SDL.h> #include <SDL3/SDL.h>
@@ -72,6 +73,7 @@ void ScrollerPage::onPush(AppState &state) {
createFont(state); createFont(state);
createPatterns(); createPatterns();
lines_.clear();
prefilling_ = true; prefilling_ = true;
prefillLines(); prefillLines();
prefilling_ = false; prefilling_ = false;
@@ -79,9 +81,10 @@ void ScrollerPage::onPush(AppState &state) {
// Start audio. In dynamic mode the current stream's solfeggio-frequency // Start audio. In dynamic mode the current stream's solfeggio-frequency
// track wins (when its file exists); otherwise the configured manual track // track wins (when its file exists); otherwise the configured manual track
// plays. A frequency file that is not on disk yet falls back to the manual // plays. A frequency file that is not on disk yet falls back to the manual
// track rather than playing nothing. // track rather than playing nothing. A schedule block with dynamicAudio=true
// forces dynamic audio for its duration; false leaves the device setting be.
std::string track = state.config.get().audio.current_track; std::string track = state.config.get().audio.current_track;
if (state.config.get().audio.dynamic_audio && !streams_.empty()) { if (effectiveDynamicAudio() && !streams_.empty()) {
const ProclamationStream &stream = streams_[streamIndex_]; const ProclamationStream &stream = streams_[streamIndex_];
if (stream.solfeggio_frequency_id != 0 && if (stream.solfeggio_frequency_id != 0 &&
!stream.audioFilename.empty() && !stream.audioFilename.empty() &&
@@ -137,6 +140,14 @@ void ScrollerPage::update(float dt) {
state_->audio.update(dt); state_->audio.update(dt);
} }
// Block boundaries are minute-granular, so a one-second poll is plenty and
// avoids mutating the process timezone every frame.
schedulePollTimer_ -= dt;
if (schedulePollTimer_ <= 0.0f) {
schedulePollTimer_ = 1.0f;
pollScheduleChange();
}
updateAutoVelocity(dt); updateAutoVelocity(dt);
scrollOffset_ += velocity_ * dt; scrollOffset_ += velocity_ * dt;
@@ -200,8 +211,18 @@ void ScrollerPage::render(SDL_Renderer *renderer) {
void ScrollerPage::buildStreams(AppState &state) { void ScrollerPage::buildStreams(AppState &state) {
streams_.clear(); streams_.clear();
fallbackPalette_.clear(); fallbackPalette_.clear();
lines_.clear();
streamIndex_ = 0; streamIndex_ = 0;
scheduleChangePending_ = false;
// Schedule state: only an active schedule with a currently-matching block
// changes the play list. The block boundary is handled in advanceStream(),
// so rebuilding mid-playback never truncates the current proclamation.
const radiantwave::config::AppConfig cfg = state.config.get();
scheduleActive_ = cfg.proclamation.schedule_active;
timezone_ = cfg.timezone;
scheduleBlocks_ = scheduleActive_ ? state.db.getScheduleBlocks()
: std::vector<radiantwave::db::ScheduleBlockRecord>{};
activeBlockIndex_ = currentScheduleBlockIndex();
auto proclamations = state.db.getActiveProclamations(); auto proclamations = state.db.getActiveProclamations();
if (proclamations.empty()) { if (proclamations.empty()) {
@@ -241,9 +262,53 @@ void ScrollerPage::buildStreams(AppState &state) {
} }
} }
const std::vector<std::string>* scheduledIds = nullptr;
std::vector<std::string> scheduledOrder;
if (activeBlockIndex_ >= 0 &&
activeBlockIndex_ < static_cast<int>(scheduleBlocks_.size())) {
scheduledOrder = scheduleBlocks_[activeBlockIndex_].proclamation_ids;
scheduledIds = &scheduledOrder;
}
streams_ = radiantwave::scroller::buildStreams( streams_ = radiantwave::scroller::buildStreams(
proclamations, palettesByFreqId, fallbackPalette_, audioByFreqId, proclamations, palettesByFreqId, fallbackPalette_, audioByFreqId,
DEFAULT_PROPLEAMATION_ID); DEFAULT_PROPLEAMATION_ID, scheduledIds);
}
int ScrollerPage::currentScheduleBlockIndex() const {
if (!scheduleActive_ || scheduleBlocks_.empty()) {
return -1;
}
const int now = radiantwave::timezone::currentLocalMinutes(timezone_);
for (size_t i = 0; i < scheduleBlocks_.size(); ++i) {
const radiantwave::db::ScheduleBlockRecord &block = scheduleBlocks_[i];
if (radiantwave::scroller::minuteInScheduleBlock(
now, block.start_minutes, block.end_minutes, block.overnight)) {
return static_cast<int>(i);
}
}
return -1;
}
void ScrollerPage::pollScheduleChange() {
if (!scheduleActive_) {
return;
}
if (currentScheduleBlockIndex() != activeBlockIndex_) {
scheduleChangePending_ = true;
}
}
bool ScrollerPage::effectiveDynamicAudio() const {
// dynamicAudio=true on the active block forces dynamic audio, overriding
// whatever the device selected. false means "don't override": the device's
// own selection (dynamic or a fixed track) applies as usual.
if (scheduleActive_ && activeBlockIndex_ >= 0 &&
activeBlockIndex_ < static_cast<int>(scheduleBlocks_.size()) &&
scheduleBlocks_[activeBlockIndex_].dynamic_audio) {
return true;
}
return state_ ? state_->config.get().audio.dynamic_audio : false;
} }
void ScrollerPage::createFont(AppState &state) { void ScrollerPage::createFont(AppState &state) {
@@ -451,7 +516,19 @@ void ScrollerPage::advanceStream() {
if (streams_.empty()) { if (streams_.empty()) {
return; return;
} }
streamIndex_ = (streamIndex_ + 1) % static_cast<int>(streams_.size()); if (scheduleChangePending_ && state_) {
// A schedule block changed while the previous proclamation was playing.
// Apply it only now, at a stream boundary, so the current proclamation
// finishes instead of being cut off.
buildStreams(*state_);
createPatterns();
streamIndex_ = 0;
} else {
streamIndex_ = (streamIndex_ + 1) % static_cast<int>(streams_.size());
}
if (streams_.empty()) {
return;
}
// Diagnostic: report the palette the incoming stream will render so the // Diagnostic: report the palette the incoming stream will render so the
// console shows whether colors should change when this stream starts. // console shows whether colors should change when this stream starts.
const ProclamationStream &stream = streams_[streamIndex_]; const ProclamationStream &stream = streams_[streamIndex_];
@@ -473,8 +550,9 @@ void ScrollerPage::switchAudioForCurrentStream() {
} }
// Dynamic audio only: with a fixed manual track selected the audio must // Dynamic audio only: with a fixed manual track selected the audio must
// never switch as proclamations change. // never switch as proclamations change. An active schedule block's
if (!state_->config.get().audio.dynamic_audio) { // dynamicAudio flag overrides the device-wide setting.
if (!effectiveDynamicAudio()) {
return; return;
} }
+22
View File
@@ -1,5 +1,6 @@
#pragma once #pragma once
#include "db/database_manager.h"
#include "fibonacci_line_pattern.h" #include "fibonacci_line_pattern.h"
#include "page.h" #include "page.h"
#include "scroller_streams.h" #include "scroller_streams.h"
@@ -82,6 +83,16 @@ private:
bool addLineAt(float baseY); bool addLineAt(float baseY);
void advanceStream(); void advanceStream();
void switchAudioForCurrentStream(); void switchAudioForCurrentStream();
// Re-reads the schedule from the DB/config and records which block (if any)
// matches the clock right now. Returns the matching index, or -1.
int currentScheduleBlockIndex() const;
// 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 updateAutoVelocity(float dt);
void ensureWordTexture(const std::string& word); void ensureWordTexture(const std::string& word);
void ensureCharTexture(const std::string& glyph); void ensureCharTexture(const std::string& glyph);
@@ -97,6 +108,17 @@ private:
int streamIndex_ = 0; int streamIndex_ = 0;
bool prefilling_ = false; bool prefilling_ = false;
// Schedule playback. `scheduleActive_` mirrors config; `scheduleBlocks_` is
// the cached schedule; `activeBlockIndex_` is the block currently playing
// (-1 = normal mode / no matching block). A change is latched in
// `scheduleChangePending_` and applied when the current proclamation ends.
std::string timezone_;
std::vector<radiantwave::db::ScheduleBlockRecord> scheduleBlocks_;
bool scheduleActive_ = false;
int activeBlockIndex_ = -1;
bool scheduleChangePending_ = false;
float schedulePollTimer_ = 0.0f;
TTF_Font* font_ = nullptr; TTF_Font* font_ = nullptr;
int fontPxSize_ = 32; int fontPxSize_ = 32;
float lineHeight_ = 0; 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::unordered_map<int, std::vector<StreamColor>>& palettesByFreqId,
const std::vector<StreamColor>& fallbackPalette, const std::vector<StreamColor>& fallbackPalette,
const std::unordered_map<int, db::AudioFileRecord>& audioByFreqId, 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; const db::ProclamationRecord* defaultRecord = nullptr;
std::vector<const db::ProclamationRecord*> selected; std::vector<const db::ProclamationRecord*> selected;
selected.reserve(proclamations.size());
for (const auto& rec : proclamations) { if (scheduledIds) {
if (rec.id == defaultProclamationId) { // A schedule supplies an explicit, ordered play list. Look each id up
defaultRecord = &rec; // in the proclamation pool, skipping unknown ids and the default (which
continue; // 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) { defaultRecord = byId.count(defaultProclamationId) ? byId[defaultProclamationId] : nullptr;
continue;
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) { 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 kSelectedRepeatCount = 20;
inline constexpr int kDefaultRepeatCount = 10; 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 { struct ProclamationStream {
std::string name; std::string name;
std::vector<std::string> words; std::vector<std::string> words;
@@ -45,11 +55,18 @@ struct ProclamationStream {
// the default plays. `palettesByFreqId` and `audioByFreqId` are // the default plays. `palettesByFreqId` and `audioByFreqId` are
// database-derived lookups; `fallbackPalette` is used for the default stream // database-derived lookups; `fallbackPalette` is used for the default stream
// and unknown frequencies. // 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( std::vector<ProclamationStream> buildStreams(
const std::vector<db::ProclamationRecord>& proclamations, const std::vector<db::ProclamationRecord>& proclamations,
const std::unordered_map<int, std::vector<StreamColor>>& palettesByFreqId, const std::unordered_map<int, std::vector<StreamColor>>& palettesByFreqId,
const std::vector<StreamColor>& fallbackPalette, const std::vector<StreamColor>& fallbackPalette,
const std::unordered_map<int, db::AudioFileRecord>& audioByFreqId, 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 } // namespace radiantwave::scroller
+31
View File
@@ -133,6 +133,37 @@ std::string formatUtcToTimezone(time_t utc_time, const std::string& iana_code, c
return std::string(buf); 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;
}
std::string getUtcTimestampIso() { std::string getUtcTimestampIso() {
time_t now = std::time(nullptr); time_t now = std::time(nullptr);
struct tm gmt_buf; struct tm gmt_buf;
+5
View File
@@ -22,6 +22,11 @@ const std::vector<CountryTimeZones>& getStandardTimeZones();
// Converts a UTC timestamp to the user's configured IANA timezone // 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"); 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);
// Gets current UTC timestamp as ISO string // Gets current UTC timestamp as ISO string
std::string getUtcTimestampIso(); std::string getUtcTimestampIso();
+52
View File
@@ -113,6 +113,58 @@ TEST(ApiClientTest, ParseCreateAccountJsonRejectsMalformed) {
EXPECT_FALSE(parseCreateAccountJson("[]", account)); 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 ──────────────────────────────── // ─── Offline: error message extraction ────────────────────────────────
TEST(ApiClientTest, ExtractErrorMessage) { TEST(ApiClientTest, ExtractErrorMessage) {
+3
View File
@@ -44,6 +44,7 @@ TEST(YamlParserTest, ConfigRoundTrip) {
original.audio.volume = 60; original.audio.volume = 60;
original.audio.dynamic_audio = false; original.audio.dynamic_audio = false;
original.proclamation.scroll_speed = 1.8f; original.proclamation.scroll_speed = 1.8f;
original.proclamation.schedule_active = true;
std::string serialized = serializeConfigToYaml(original); std::string serialized = serializeConfigToYaml(original);
AppConfig parsed = parseConfigFromYaml(serialized); AppConfig parsed = parseConfigFromYaml(serialized);
@@ -53,6 +54,7 @@ TEST(YamlParserTest, ConfigRoundTrip) {
EXPECT_EQ(parsed.audio.volume, original.audio.volume); EXPECT_EQ(parsed.audio.volume, original.audio.volume);
EXPECT_EQ(parsed.audio.dynamic_audio, original.audio.dynamic_audio); EXPECT_EQ(parsed.audio.dynamic_audio, original.audio.dynamic_audio);
EXPECT_FLOAT_EQ(parsed.proclamation.scroll_speed, original.proclamation.scroll_speed); EXPECT_FLOAT_EQ(parsed.proclamation.scroll_speed, original.proclamation.scroll_speed);
EXPECT_TRUE(parsed.proclamation.schedule_active);
} }
TEST(ConfigManagerTest, DefaultGeneration) { TEST(ConfigManagerTest, DefaultGeneration) {
@@ -68,6 +70,7 @@ TEST(ConfigManagerTest, DefaultGeneration) {
EXPECT_EQ(cfg.audio.volume, 80); EXPECT_EQ(cfg.audio.volume, 80);
EXPECT_EQ(cfg.audio.dynamic_audio, true); EXPECT_EQ(cfg.audio.dynamic_audio, true);
EXPECT_EQ(cfg.proclamation.font_size, 32); EXPECT_EQ(cfg.proclamation.font_size, 32);
EXPECT_FALSE(cfg.proclamation.schedule_active);
EXPECT_TRUE(std::filesystem::exists(temp_path)); EXPECT_TRUE(std::filesystem::exists(temp_path));
std::filesystem::remove(temp_path); std::filesystem::remove(temp_path);
+48
View File
@@ -347,6 +347,54 @@ TEST(DatabaseManagerTest, SyncAudioFilesClearsEverythingWhenEmpty) {
EXPECT_EQ(fx.db.getAudioFiles().size(), 0u); 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) { TEST(DatabaseManagerTest, PalettesSeededAndRead) {
DbFixture fx("test_rw_db_palette.db"); DbFixture fx("test_rw_db_palette.db");
+72
View File
@@ -17,6 +17,7 @@ using radiantwave::scroller::StreamColor;
using radiantwave::scroller::buildStreams; using radiantwave::scroller::buildStreams;
using radiantwave::scroller::kDefaultRepeatCount; using radiantwave::scroller::kDefaultRepeatCount;
using radiantwave::scroller::kSelectedRepeatCount; using radiantwave::scroller::kSelectedRepeatCount;
using radiantwave::scroller::minuteInScheduleBlock;
const std::string kDefaultId = "default"; const std::string kDefaultId = "default";
@@ -185,6 +186,77 @@ TEST(ScrollerStreamsTest, MissingDefaultStillStreamsSelected)
EXPECT_EQ(streams[0].repeat, kSelectedRepeatCount); 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) TEST(ScrollerStreamsTest, SplitsGluedSentencePunctuation)
{ {
ProclamationRecord p1; ProclamationRecord p1;
+18
View File
@@ -26,6 +26,24 @@ TEST(TimeZoneTest, UtcTimestampFormat) {
EXPECT_NE(iso.find("UTC"), std::string::npos); 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) { TEST(TimeZoneTest, FormatUtcToTimezone) {
// 2026-07-24 12:00:00 UTC // 2026-07-24 12:00:00 UTC
// Fixed epoch timestamp: 1784808000 // Fixed epoch timestamp: 1784808000