Add schedule sync and schedule-aware playback

This commit is contained in:
NeilClack
2026-09-11 19:14:55 -05:00
parent a043ec84d8
commit f37beaf07e
23 changed files with 893 additions and 32 deletions
+80
View File
@@ -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;
+34
View File
@@ -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
+33
View File
@@ -1,4 +1,5 @@
#include "network/proclamation_sync.h"
#include "db/db_keys.h"
#include "network/api_client.h"
#include "network/json.h"
@@ -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,
+9
View File
@@ -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