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.
|
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]
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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");
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user