Add schedule sync and schedule-aware playback
This commit is contained in:
+145
@@ -31,6 +31,8 @@ tags:
|
||||
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:
|
||||
@@ -230,6 +232,83 @@ paths:
|
||||
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. 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:
|
||||
post:
|
||||
tags: [Automations]
|
||||
@@ -436,6 +515,72 @@ components:
|
||||
email_address:
|
||||
type: string
|
||||
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:
|
||||
type: object
|
||||
required: [email]
|
||||
|
||||
@@ -16,6 +16,7 @@ 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
|
||||
};
|
||||
|
||||
struct AppConfig {
|
||||
|
||||
@@ -250,6 +250,9 @@ 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);
|
||||
}
|
||||
}
|
||||
|
||||
{
|
||||
|
||||
@@ -231,6 +231,9 @@ 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);
|
||||
}
|
||||
}
|
||||
|
||||
return config;
|
||||
@@ -256,6 +259,7 @@ 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";
|
||||
|
||||
return ss.str();
|
||||
}
|
||||
|
||||
@@ -181,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);
|
||||
@@ -1083,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
|
||||
|
||||
@@ -54,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;
|
||||
@@ -154,6 +166,16 @@ 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);
|
||||
|
||||
|
||||
@@ -221,6 +221,86 @@ 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);
|
||||
}
|
||||
|
||||
AccountResult createAccount(const std::string& email, const std::string& name) {
|
||||
AccountResult result;
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ 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 {
|
||||
@@ -119,4 +120,37 @@ 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);
|
||||
|
||||
} // namespace radiantwave::api
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
#include "network/proclamation_sync.h"
|
||||
#include "db/db_keys.h"
|
||||
#include "network/api_client.h"
|
||||
#include "network/json.h"
|
||||
|
||||
@@ -161,9 +162,41 @@ bool syncProclamationsFromApi(radiantwave::db::DatabaseManager& db,
|
||||
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,
|
||||
|
||||
@@ -23,9 +23,18 @@ bool parseGroupsJson(const std::string& body,
|
||||
// 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
|
||||
|
||||
@@ -174,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,
|
||||
@@ -258,6 +268,11 @@ void ProclamationOptionsPage::rebuildVisible()
|
||||
for (size_t i = 0; i < items_.size(); ++i) {
|
||||
const FlatItem& item = items_[i];
|
||||
|
||||
if (item.type == FlatItem::Type::ScheduleToggle) {
|
||||
visible_.push_back(i);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (item.type == FlatItem::Type::Category) {
|
||||
currentCategory = static_cast<int>(i);
|
||||
currentGroup = -1;
|
||||
@@ -304,6 +319,18 @@ void ProclamationOptionsPage::toggleItem()
|
||||
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;
|
||||
@@ -715,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::Category || item.type == FlatItem::Type::Group) {
|
||||
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);
|
||||
@@ -732,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::Category || item.type == FlatItem::Type::Group) {
|
||||
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);
|
||||
@@ -750,10 +785,7 @@ 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::Category || item.type == FlatItem::Type::Group) {
|
||||
label = (item.expanded ? "[-] " : "[+] ") + label;
|
||||
}
|
||||
std::string label = labelFor(item);
|
||||
|
||||
SDL_Color color = white;
|
||||
if (item.type == FlatItem::Type::Category) {
|
||||
|
||||
@@ -28,14 +28,14 @@ public:
|
||||
|
||||
private:
|
||||
struct FlatItem {
|
||||
enum class Type { Category, Group, Proclamation };
|
||||
enum class Type { ScheduleToggle, Category, Group, 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
|
||||
int depth = 0; // 0 = category / default, 1 = group, 2 = proclamation
|
||||
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
|
||||
};
|
||||
|
||||
|
||||
+84
-6
@@ -5,6 +5,7 @@
|
||||
#include "logical_size.h"
|
||||
#include "proclamation_options_page.h"
|
||||
#include "scramble/fibonacci_scrambler.h"
|
||||
#include "timezone_helper.h"
|
||||
#include "utf8_utils.h"
|
||||
|
||||
#include <SDL3/SDL.h>
|
||||
@@ -72,6 +73,7 @@ void ScrollerPage::onPush(AppState &state) {
|
||||
createFont(state);
|
||||
createPatterns();
|
||||
|
||||
lines_.clear();
|
||||
prefilling_ = true;
|
||||
prefillLines();
|
||||
prefilling_ = false;
|
||||
@@ -79,9 +81,10 @@ void ScrollerPage::onPush(AppState &state) {
|
||||
// Start audio. In dynamic mode the current stream's solfeggio-frequency
|
||||
// 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
|
||||
// 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;
|
||||
if (state.config.get().audio.dynamic_audio && !streams_.empty()) {
|
||||
if (effectiveDynamicAudio() && !streams_.empty()) {
|
||||
const ProclamationStream &stream = streams_[streamIndex_];
|
||||
if (stream.solfeggio_frequency_id != 0 &&
|
||||
!stream.audioFilename.empty() &&
|
||||
@@ -137,6 +140,14 @@ void ScrollerPage::update(float 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);
|
||||
|
||||
scrollOffset_ += velocity_ * dt;
|
||||
@@ -200,8 +211,18 @@ void ScrollerPage::render(SDL_Renderer *renderer) {
|
||||
void ScrollerPage::buildStreams(AppState &state) {
|
||||
streams_.clear();
|
||||
fallbackPalette_.clear();
|
||||
lines_.clear();
|
||||
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();
|
||||
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(
|
||||
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) {
|
||||
@@ -451,7 +516,19 @@ void ScrollerPage::advanceStream() {
|
||||
if (streams_.empty()) {
|
||||
return;
|
||||
}
|
||||
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
|
||||
// console shows whether colors should change when this stream starts.
|
||||
const ProclamationStream &stream = streams_[streamIndex_];
|
||||
@@ -473,8 +550,9 @@ void ScrollerPage::switchAudioForCurrentStream() {
|
||||
}
|
||||
|
||||
// Dynamic audio only: with a fixed manual track selected the audio must
|
||||
// never switch as proclamations change.
|
||||
if (!state_->config.get().audio.dynamic_audio) {
|
||||
// never switch as proclamations change. An active schedule block's
|
||||
// dynamicAudio flag overrides the device-wide setting.
|
||||
if (!effectiveDynamicAudio()) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -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,16 @@ private:
|
||||
bool addLineAt(float baseY);
|
||||
void advanceStream();
|
||||
void switchAudioForCurrentStream();
|
||||
// Re-reads the schedule from the DB/config and records which block (if any)
|
||||
// matches the clock right now. Returns the matching index, or -1.
|
||||
int currentScheduleBlockIndex() const;
|
||||
// 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 +108,17 @@ private:
|
||||
int streamIndex_ = 0;
|
||||
bool prefilling_ = false;
|
||||
|
||||
// Schedule playback. `scheduleActive_` mirrors config; `scheduleBlocks_` is
|
||||
// the cached schedule; `activeBlockIndex_` is the block currently playing
|
||||
// (-1 = normal mode / no matching block). A change is latched in
|
||||
// `scheduleChangePending_` and applied when the current proclamation ends.
|
||||
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;
|
||||
int fontPxSize_ = 32;
|
||||
float lineHeight_ = 0;
|
||||
|
||||
@@ -53,12 +53,35 @@ 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());
|
||||
|
||||
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;
|
||||
}
|
||||
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;
|
||||
@@ -69,6 +92,7 @@ std::vector<ProclamationStream> buildStreams(
|
||||
}
|
||||
selected.push_back(&rec);
|
||||
}
|
||||
}
|
||||
|
||||
auto makeStream = [&](const db::ProclamationRecord& rec, int repeat) {
|
||||
ProclamationStream stream;
|
||||
|
||||
+18
-1
@@ -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
|
||||
|
||||
@@ -133,6 +133,37 @@ 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;
|
||||
}
|
||||
|
||||
std::string getUtcTimestampIso() {
|
||||
time_t now = std::time(nullptr);
|
||||
struct tm gmt_buf;
|
||||
|
||||
@@ -22,6 +22,11 @@ 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);
|
||||
|
||||
// Gets current UTC timestamp as ISO string
|
||||
std::string getUtcTimestampIso();
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -347,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");
|
||||
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -185,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;
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user