Kiosk: schedule client (conditional GET + atomic cache) and engine

Adds api_client support for GET /licenses/{key}/schedule with If-None-Match
and target-payload parsing (version/type/daily/weekly + legacy blocks), an
atomic on-disk schedule cache, a pure schedule engine (half-open blocks,
midnight self-wrap for daily, Sunday->Monday spillover for weekly, invalid
blocks skipped with a warning), and a background ScheduleManager that loads
the cache offline and pulls on boot and every 5 minutes. Adds engine/cache
tests against the shared docs/fixtures, including 304, corrupt cache, and
offline cases.
This commit is contained in:
NeilClack
2026-09-29 10:34:40 -05:00
parent b93fb8d2df
commit 509c925480
12 changed files with 1160 additions and 2 deletions
+19
View File
@@ -43,6 +43,7 @@ add_executable(radiantwave
src/settings_menu.cpp src/settings_menu.cpp
src/system_settings_page.cpp src/system_settings_page.cpp
src/system_name_page.cpp src/system_name_page.cpp
src/schedule_toggle_page.cpp
src/wifi_setup_page.cpp src/wifi_setup_page.cpp
src/audio_settings_page.cpp src/audio_settings_page.cpp
src/audio/audio_manager.cpp src/audio/audio_manager.cpp
@@ -62,6 +63,9 @@ add_executable(radiantwave
src/network/license_status.cpp src/network/license_status.cpp
src/network/system_name.cpp src/network/system_name.cpp
src/network/system_name_sync.cpp src/network/system_name_sync.cpp
src/network/schedule_cache.cpp
src/schedule/schedule_engine.cpp
src/schedule/schedule_manager.cpp
src/config/yaml_parser.cpp src/config/yaml_parser.cpp
src/config/config_manager.cpp src/config/config_manager.cpp
src/db/database_manager.cpp src/db/database_manager.cpp
@@ -253,6 +257,21 @@ add_executable(test_system_name
target_include_directories(test_system_name PRIVATE src) target_include_directories(test_system_name PRIVATE src)
target_link_libraries(test_system_name PRIVATE GTest::gtest_main SQLite3::SQLite3) target_link_libraries(test_system_name PRIVATE GTest::gtest_main SQLite3::SQLite3)
# Schedule engine + cache tests (payload parsing, evaluation, offline cache)
add_executable(test_schedule_engine
tests/test_schedule_engine.cpp
src/schedule/schedule_engine.cpp
src/schedule/schedule_manager.cpp
src/network/schedule_cache.cpp
src/network/api_client.cpp
src/network/json.cpp
src/db/database_manager.cpp
)
target_include_directories(test_schedule_engine PRIVATE src)
target_link_libraries(test_schedule_engine PRIVATE GTest::gtest_main CURL::libcurl SQLite3::SQLite3)
target_compile_definitions(test_schedule_engine PRIVATE
RWT_FIXTURES_DIR="${CMAKE_CURRENT_SOURCE_DIR}/docs/fixtures")
# TimeZone unit tests # TimeZone unit tests
add_executable(test_timezone add_executable(test_timezone
tests/test_timezone.cpp tests/test_timezone.cpp
+165 -2
View File
@@ -2,6 +2,7 @@
#include "network/json.h" #include "network/json.h"
#include <curl/curl.h> #include <curl/curl.h>
#include <cctype>
#include <cstdio> #include <cstdio>
#include <string> #include <string>
@@ -27,10 +28,32 @@ size_t writeCallback(char* ptr, size_t size, size_t nmemb, void* userdata) {
return size * nmemb; return size * nmemb;
} }
// Collects response headers, capturing the ETag (case-insensitive).
size_t headerCallback(char* buffer, size_t size, size_t nitems, void* userdata) {
auto* etag = static_cast<std::string*>(userdata);
const size_t len = size * nitems;
std::string line(buffer, len);
const std::string prefix = "etag:";
if (line.size() >= prefix.size()) {
std::string lowered = line.substr(0, prefix.size());
for (char& c : lowered) c = static_cast<char>(std::tolower(static_cast<unsigned char>(c)));
if (lowered == prefix) {
std::string value = line.substr(prefix.size());
// Trim leading/trailing whitespace and CR/LF.
size_t start = value.find_first_not_of(" \t\r\n");
size_t end = value.find_last_not_of(" \t\r\n");
*etag = (start == std::string::npos) ? "" : value.substr(start, end - start + 1);
}
}
return len;
}
// Shared curl setup + perform. Returns a populated HttpResponse. `method` is // Shared curl setup + perform. Returns a populated HttpResponse. `method` is
// "GET", "POST", or "PUT"; GET sends no body. // "GET", "POST", or "PUT"; GET sends no body. `ifNoneMatch`, when non-empty,
// is sent as an If-None-Match request header.
HttpResponse performRequest(const std::string& url, HttpResponse performRequest(const std::string& url,
const char* method, const std::string& jsonBody) { const char* method, const std::string& jsonBody,
const std::string& ifNoneMatch = "") {
HttpResponse response; HttpResponse response;
CURL* curl = curl_easy_init(); CURL* curl = curl_easy_init();
@@ -43,6 +66,8 @@ HttpResponse performRequest(const std::string& url,
curl_easy_setopt(curl, CURLOPT_USERAGENT, "RadiantWave-Kiosk/0.1"); curl_easy_setopt(curl, CURLOPT_USERAGENT, "RadiantWave-Kiosk/0.1");
curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, writeCallback); curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, writeCallback);
curl_easy_setopt(curl, CURLOPT_WRITEDATA, &response.body); curl_easy_setopt(curl, CURLOPT_WRITEDATA, &response.body);
curl_easy_setopt(curl, CURLOPT_HEADERFUNCTION, headerCallback);
curl_easy_setopt(curl, CURLOPT_HEADERDATA, &response.etag);
const bool sendsBody = method != nullptr && std::string(method) != "GET"; const bool sendsBody = method != nullptr && std::string(method) != "GET";
struct curl_slist* headers = nullptr; struct curl_slist* headers = nullptr;
@@ -56,6 +81,10 @@ HttpResponse performRequest(const std::string& url,
curl_easy_setopt(curl, CURLOPT_CUSTOMREQUEST, method); curl_easy_setopt(curl, CURLOPT_CUSTOMREQUEST, method);
} }
} }
if (!ifNoneMatch.empty()) {
const std::string header = "If-None-Match: " + ifNoneMatch;
headers = curl_slist_append(headers, header.c_str());
}
if (headers) { if (headers) {
curl_easy_setopt(curl, CURLOPT_HTTPHEADER, headers); curl_easy_setopt(curl, CURLOPT_HTTPHEADER, headers);
} }
@@ -112,6 +141,10 @@ HttpResponse httpPut(const std::string& path, const std::string& jsonBody) {
return performRequest(urlFor(path), "PUT", jsonBody); return performRequest(urlFor(path), "PUT", jsonBody);
} }
HttpResponse httpGetConditional(const std::string& path, const std::string& ifNoneMatch) {
return performRequest(urlFor(path), "GET", "", ifNoneMatch);
}
// ─── GET /frequencies ────────────────────────────────────────────────── // ─── GET /frequencies ──────────────────────────────────────────────────
bool parseFrequenciesJson(const std::string& body, std::vector<Frequency>& out) { bool parseFrequenciesJson(const std::string& body, std::vector<Frequency>& out) {
@@ -343,6 +376,136 @@ bool fetchSchedule(const std::string& email, const std::string& license_key,
return parseScheduleJson(response.body, out); return parseScheduleJson(response.body, out);
} }
// ─── GET /licenses/{key}/schedule (Phase 3) ────────────────────────────
namespace {
// Reads one target Block from a JSON object.
TargetBlock parseTargetBlock(const radiantwave::json::JsonValue& entry) {
TargetBlock block;
block.id = entry.stringField("id");
block.day = entry.stringField("day");
block.start_minutes = static_cast<int>(entry.numberField("startMinutes"));
block.end_minutes = static_cast<int>(entry.numberField("endMinutes"));
block.overnight = entry.boolField("overnight");
block.duration_minutes = static_cast<int>(entry.numberField("durationMinutes"));
const radiantwave::json::JsonValue* dyn = entry.find("dynamicAudio");
block.dynamic_audio =
(dyn && dyn->type == radiantwave::json::JsonValue::Type::Bool) ? dyn->b : true;
const radiantwave::json::JsonValue* ids = entry.find("proclamationIds");
if (ids && ids->type == radiantwave::json::JsonValue::Type::Array) {
for (const auto& id : ids->array) {
if (id.type == radiantwave::json::JsonValue::Type::String && !id.str.empty()) {
block.proclamation_ids.push_back(id.str);
}
}
}
return block;
}
} // anonymous namespace
bool parseSchedulePayloadJson(const std::string& body, SchedulePayload& out) {
radiantwave::json::JsonValue root;
if (!radiantwave::json::parseDocument(body, root) ||
root.type != radiantwave::json::JsonValue::Type::Object) {
return false;
}
SchedulePayload parsed;
parsed.version = root.stringField("version");
parsed.type = root.stringField("type");
const radiantwave::json::JsonValue* daily = root.find("daily");
if (daily && daily->type == radiantwave::json::JsonValue::Type::Array) {
for (const auto& entry : daily->array) {
if (entry.type == radiantwave::json::JsonValue::Type::Object) {
parsed.daily.push_back(parseTargetBlock(entry));
}
}
}
const radiantwave::json::JsonValue* weekly = root.find("weekly");
if (weekly && weekly->type == radiantwave::json::JsonValue::Type::Object) {
static const char* kDays[] = {"mon", "tue", "wed", "thu", "fri", "sat", "sun"};
for (const char* day : kDays) {
const radiantwave::json::JsonValue* list = weekly->find(day);
if (!list || list->type != radiantwave::json::JsonValue::Type::Array) continue;
for (const auto& entry : list->array) {
if (entry.type != radiantwave::json::JsonValue::Type::Object) continue;
TargetBlock block = parseTargetBlock(entry);
block.day = day;
parsed.weekly.push_back(std::move(block));
}
}
}
// Legacy `blocks` (for backward compatibility / older payloads).
const radiantwave::json::JsonValue* blocks = root.find("blocks");
if (blocks && blocks->type == radiantwave::json::JsonValue::Type::Array) {
for (const auto& entry : blocks->array) {
if (entry.type != radiantwave::json::JsonValue::Type::Object) continue;
ScheduleBlock block;
block.id = entry.stringField("id");
if (block.id.empty()) continue;
block.start_minutes = static_cast<int>(entry.numberField("startMinutes"));
block.end_minutes = static_cast<int>(entry.numberField("endMinutes"));
block.overnight = entry.boolField("overnight");
block.duration_minutes = static_cast<int>(entry.numberField("durationMinutes"));
const radiantwave::json::JsonValue* dyn = entry.find("dynamicAudio");
block.dynamic_audio =
(dyn && dyn->type == radiantwave::json::JsonValue::Type::Bool) ? dyn->b : true;
const radiantwave::json::JsonValue* ids = entry.find("proclamationIds");
if (ids && ids->type == radiantwave::json::JsonValue::Type::Array) {
for (const auto& id : ids->array) {
if (id.type == radiantwave::json::JsonValue::Type::String && !id.str.empty()) {
block.proclamation_ids.push_back(id.str);
}
}
}
parsed.legacy_blocks.push_back(std::move(block));
}
}
out = std::move(parsed);
return true;
}
ScheduleFetchStatus fetchScheduleForLicense(const std::string& license_key,
const std::string& etag,
SchedulePayload& out,
std::string& outEtag) {
if (license_key.empty()) {
return ScheduleFetchStatus::NetworkError;
}
const std::string path = "/licenses/" + urlEncode(license_key) + "/schedule";
HttpResponse response = httpGetConditional(path, etag);
if (!response.transportOk) {
return ScheduleFetchStatus::NetworkError;
}
if (response.status == 304) {
return ScheduleFetchStatus::NotModified;
}
if (response.status == 404) {
return ScheduleFetchStatus::NotFound;
}
if (response.status < 200 || response.status >= 300) {
return ScheduleFetchStatus::NetworkError;
}
SchedulePayload parsed;
if (!parseSchedulePayloadJson(response.body, parsed)) {
return ScheduleFetchStatus::NetworkError;
}
out = std::move(parsed);
outEtag = response.etag;
return ScheduleFetchStatus::Ok;
}
AccountResult createAccount(const std::string& email, const std::string& name) { AccountResult createAccount(const std::string& email, const std::string& name) {
AccountResult result; AccountResult result;
+54
View File
@@ -23,6 +23,7 @@ constexpr const char* PATH_SCHEDULE = "/schedule";
struct HttpResponse { struct HttpResponse {
long status = 0; // HTTP status code; 0 if the request never completed long status = 0; // HTTP status code; 0 if the request never completed
std::string body; // raw response body (any status) std::string body; // raw response body (any status)
std::string etag; // response ETag header, if any
bool transportOk = false; // true when the request completed without a transport error bool transportOk = false; // true when the request completed without a transport error
}; };
@@ -31,6 +32,9 @@ HttpResponse httpGet(const std::string& path);
HttpResponse httpPost(const std::string& path, const std::string& jsonBody); HttpResponse httpPost(const std::string& path, const std::string& jsonBody);
HttpResponse httpPut(const std::string& path, const std::string& jsonBody); HttpResponse httpPut(const std::string& path, const std::string& jsonBody);
// GET with an optional If-None-Match header. Captures the response ETag.
HttpResponse httpGetConditional(const std::string& path, const std::string& ifNoneMatch);
// ─── GET /frequencies ────────────────────────────────────────────────── // ─── GET /frequencies ──────────────────────────────────────────────────
struct Frequency { struct Frequency {
@@ -176,4 +180,54 @@ bool parseScheduleJson(const std::string& body, ScheduleResponse& out);
bool fetchSchedule(const std::string& email, const std::string& license_key, bool fetchSchedule(const std::string& email, const std::string& license_key,
ScheduleResponse& out); ScheduleResponse& out);
// ─── GET /licenses/{key}/schedule (Phase 3, conditional) ───────────────
// One Block of the target payload. `day` is "" for daily schedules and
// "mon".."sun" for weekly schedules. Times are wall-clock minutes.
struct TargetBlock {
std::string id;
std::string day; // "" for daily
int start_minutes = 0;
int end_minutes = 0;
bool overnight = false;
int duration_minutes = 0;
bool dynamic_audio = true;
std::vector<std::string> proclamation_ids;
};
// The target schedule payload (version/type/daily/weekly).
struct SchedulePayload {
std::string version;
// "daily", "weekly", or "" for no schedule (type null).
std::string type;
std::vector<TargetBlock> daily;
// Weekly blocks keyed by day (mon..sun); empty for daily/none.
std::vector<TargetBlock> weekly;
// Legacy `blocks` flattening carried for backward compatibility.
std::vector<ScheduleBlock> legacy_blocks;
bool hasSchedule() const { return type == "daily" || type == "weekly"; }
};
// Parse the target payload (with version/type/daily/weekly). `blocks` (legacy)
// is also read so older responses still populate `legacy_blocks`. Returns false
// on malformed input.
bool parseSchedulePayloadJson(const std::string& body, SchedulePayload& out);
// Result of a conditional schedule fetch.
enum class ScheduleFetchStatus {
Ok, // 200; payload replaced
NotModified, // 304; caller keeps its cached payload
NotFound, // 404 unknown license
NetworkError, // transport/other failure
};
// Fetch a License Key's schedule with conditional GET. When `etag` is
// non-empty it is sent as If-None-Match. On success `out` is replaced and
// `outEtag` receives the response ETag. Never throws.
ScheduleFetchStatus fetchScheduleForLicense(const std::string& license_key,
const std::string& etag,
SchedulePayload& out,
std::string& outEtag);
} // namespace radiantwave::api } // namespace radiantwave::api
+158
View File
@@ -0,0 +1,158 @@
#include "network/schedule_cache.h"
#include "network/json.h"
#include <cstdio>
#include <cstdlib>
#include <fstream>
#include <pwd.h>
#include <sstream>
#include <sys/stat.h>
#include <unistd.h>
namespace radiantwave::sync::schedule_cache {
namespace {
std::string homeDir() {
const char* home = std::getenv("HOME");
if (!home) {
struct passwd* pw = getpwuid(getuid());
if (pw) home = pw->pw_dir;
}
return home ? std::string(home) : std::string("/tmp");
}
void ensureDir(const std::string& dir) {
// Create the directory and any missing parents (e.g. ~/.local/radiantwave
// when ~/.local does not exist yet).
std::string path;
for (size_t i = 0; i < dir.size(); ++i) {
path += dir[i];
if (dir[i] == '/' || i == dir.size() - 1) {
if (path.size() > 1) {
mkdir(path.c_str(), 0755);
}
}
}
}
// Minimal JSON string escaping for the cache writer.
std::string escape(const std::string& in) {
std::string out;
out.reserve(in.size() + 2);
for (char c : in) {
switch (c) {
case '"': out += "\\\""; break;
case '\\': out += "\\\\"; break;
case '\n': out += "\\n"; break;
case '\r': out += "\\r"; break;
case '\t': out += "\\t"; break;
default: out += c;
}
}
return out;
}
std::string blockJson(const api::TargetBlock& block, bool includeDay) {
std::ostringstream os;
os << "{\"id\":\"" << escape(block.id) << "\"";
if (includeDay) {
os << ",\"day\":\"" << escape(block.day) << "\"";
}
os << ",\"startMinutes\":" << block.start_minutes
<< ",\"endMinutes\":" << block.end_minutes
<< ",\"overnight\":" << (block.overnight ? "true" : "false")
<< ",\"durationMinutes\":" << block.duration_minutes
<< ",\"dynamicAudio\":" << (block.dynamic_audio ? "true" : "false")
<< ",\"proclamationIds\":[";
for (size_t i = 0; i < block.proclamation_ids.size(); ++i) {
if (i > 0) os << ",";
os << "\"" << escape(block.proclamation_ids[i]) << "\"";
}
os << "]}";
return os.str();
}
std::string serialize(const api::SchedulePayload& payload, const std::string& etag) {
std::ostringstream os;
os << "{\"version\":\"" << escape(payload.version)
<< "\",\"type\":\"" << escape(payload.type)
<< "\",\"etag\":\"" << escape(etag)
<< "\",\"daily\":[";
for (size_t i = 0; i < payload.daily.size(); ++i) {
if (i > 0) os << ",";
os << blockJson(payload.daily[i], false);
}
os << "],\"weekly\":[";
for (size_t i = 0; i < payload.weekly.size(); ++i) {
if (i > 0) os << ",";
os << blockJson(payload.weekly[i], true);
}
os << "]}";
return os.str();
}
} // namespace
std::string cachePath() {
return homeDir() + "/.local/radiantwave/schedule_cache.json";
}
bool write(const api::SchedulePayload& payload, const std::string& etag) {
const std::string dir = homeDir() + "/.local/radiantwave";
ensureDir(dir);
const std::string path = cachePath();
const std::string tmp = path + ".tmp";
{
std::ofstream out(tmp, std::ios::binary | std::ios::trunc);
if (!out.is_open()) return false;
out << serialize(payload, etag);
out.flush();
if (!out.good()) {
out.close();
std::remove(tmp.c_str());
return false;
}
}
// Atomic replace: a same-filesystem rename is atomic on POSIX.
if (std::rename(tmp.c_str(), path.c_str()) != 0) {
std::remove(tmp.c_str());
return false;
}
return true;
}
bool read(api::SchedulePayload& out, std::string& outEtag) {
std::ifstream in(cachePath(), std::ios::binary);
if (!in.is_open()) return false;
std::ostringstream buffer;
buffer << in.rdbuf();
const std::string text = buffer.str();
if (text.empty()) return false;
// The cache stores the same version/type/daily/weekly shape as the API,
// plus an "etag" field, so the API parser (which ignores unknown fields)
// validates it directly.
api::SchedulePayload parsed;
if (!api::parseSchedulePayloadJson(text, parsed)) {
return false;
}
radiantwave::json::JsonValue root;
if (!radiantwave::json::parseDocument(text, root)) {
return false;
}
outEtag = root.stringField("etag");
out = std::move(parsed);
return true;
}
bool clear() {
return std::remove(cachePath().c_str()) == 0 || true;
}
} // namespace radiantwave::sync::schedule_cache
+29
View File
@@ -0,0 +1,29 @@
#pragma once
#include "network/api_client.h"
#include <string>
namespace radiantwave::sync {
// The on-disk schedule cache. Stores the target payload plus its ETag as a JSON
// document. Writes are atomic (temp file + rename) so a crash or power loss
// never leaves a partially written cache.
namespace schedule_cache {
// Absolute path of the cache file (under ~/.local/radiantwave/).
std::string cachePath();
// Serialize `payload`/`etag` to the cache file atomically. Returns false on
// any I/O failure; the previous cache is left intact.
bool write(const api::SchedulePayload& payload, const std::string& etag);
// Load the cache. Returns false (and leaves `out` untouched) when the file is
// missing, unreadable, malformed, or semantically invalid.
bool read(api::SchedulePayload& out, std::string& outEtag);
// Remove the cache file. Returns true when absent afterwards.
bool clear();
} // namespace schedule_cache
} // namespace radiantwave::sync
+112
View File
@@ -0,0 +1,112 @@
#include "schedule/schedule_engine.h"
namespace radiantwave::schedule {
namespace {
const char* dayKey(Weekday day) {
switch (day) {
case Weekday::Mon: return "mon";
case Weekday::Tue: return "tue";
case Weekday::Wed: return "wed";
case Weekday::Thu: return "thu";
case Weekday::Fri: return "fri";
case Weekday::Sat: return "sat";
case Weekday::Sun: return "sun";
}
return "mon";
}
bool blockIsValid(const api::TargetBlock& block) {
return !block.proclamation_ids.empty() &&
block.start_minutes >= 0 && block.start_minutes <= 1440 &&
block.end_minutes >= 0 && block.end_minutes <= 1440 &&
block.start_minutes != block.end_minutes;
}
void warnInvalid(const api::TargetBlock& block,
const std::function<void(const std::string&)>& warn) {
if (warn) {
warn("Skipping invalid schedule block id=" + block.id);
}
}
// Find a matching block among `candidates` at `minutes`, skipping invalid ones.
bool match(const std::vector<api::TargetBlock>& candidates, int minutes,
api::TargetBlock& out,
const std::function<void(const std::string&)>& warn) {
for (const auto& block : candidates) {
if (!blockIsValid(block)) {
warnInvalid(block, warn);
continue;
}
if (minuteInBlock(minutes, block.start_minutes, block.end_minutes)) {
out = block;
return true;
}
}
return false;
}
} // namespace
bool minuteInBlock(int minutes, int startMinutes, int endMinutes) {
if (startMinutes < endMinutes) {
return minutes >= startMinutes && minutes < endMinutes;
}
// Midnight-crossing: [start, 1440) plus [0, end).
return minutes >= startMinutes || minutes < endMinutes;
}
Weekday weekdayFromIndex(int index) {
if (index < 0) index = 0;
return static_cast<Weekday>(index % 7);
}
Decision evaluate(const api::SchedulePayload& payload, int minutes, Weekday weekday,
std::function<void(const std::string&)> warn) {
Decision decision;
if (minutes < 0) minutes = 0;
minutes %= 1440;
if (payload.type == "daily") {
decision.hasBlock = match(payload.daily, minutes, decision.block, warn);
return decision;
}
if (payload.type == "weekly") {
// Only blocks for today...
std::vector<api::TargetBlock> today;
const std::string todayKey = dayKey(weekday);
for (const auto& block : payload.weekly) {
if (block.day == todayKey) today.push_back(block);
}
if (match(today, minutes, decision.block, warn)) {
decision.hasBlock = true;
return decision;
}
// ...or a crossing Block from yesterday that spills into today's early
// hours. (Sunday's spill reaches Monday via the day rollover.)
const int prevIndex = (static_cast<int>(weekday) + 6) % 7;
const std::string prevKey = dayKey(static_cast<Weekday>(prevIndex));
for (const auto& block : payload.weekly) {
if (block.day != prevKey) continue;
if (block.end_minutes >= block.start_minutes) continue; // not crossing
if (!blockIsValid(block)) {
warnInvalid(block, warn);
continue;
}
if (minutes < block.end_minutes) {
decision.block = block;
decision.hasBlock = true;
return decision;
}
}
return decision;
}
return decision; // type null / unknown -> no active block
}
} // namespace radiantwave::schedule
+36
View File
@@ -0,0 +1,36 @@
#pragma once
#include "network/api_client.h"
#include <functional>
#include <string>
namespace radiantwave::schedule {
// Weekday keys matching the wire format.
enum class Weekday { Mon = 0, Tue, Wed, Thu, Fri, Sat, Sun };
// The result of evaluating a schedule at a wall-clock instant.
struct Decision {
bool hasBlock = false;
api::TargetBlock block; // valid only when hasBlock
};
// The active Block for `payload` at (minutes, weekday), or none. Implements the
// contract: half-open [start, end), midnight-crossing Blocks belong to their
// start day (weekly Sunday spills into Monday), and a weekly mode must also
// consider the previous day's crossing Block. Invalid Blocks (empty
// proclamation list / bad range) are skipped and treated as gaps.
//
// Wall-clock minutes are passed in, so DST transitions behave naturally (the
// caller supplies the local wall-clock time in the Kiosk's timezone).
Decision evaluate(const api::SchedulePayload& payload, int minutes, Weekday weekday,
std::function<void(const std::string&)> warn = nullptr);
// True when `minutes` falls in the half-open [start, end) block, accounting for
// a midnight-crossing range.
bool minuteInBlock(int minutes, int startMinutes, int endMinutes);
// Weekday from a 0=Monday..6=Sunday index (clamped).
Weekday weekdayFromIndex(int index);
} // namespace radiantwave::schedule
+160
View File
@@ -0,0 +1,160 @@
#include "schedule/schedule_manager.h"
#include "db/db_keys.h"
#include "network/api_client.h"
#include "network/schedule_cache.h"
#include <utility>
namespace radiantwave::schedule {
ScheduleManager::ScheduleManager()
: fetch_([](const std::string& licenseKey, const std::string& etag,
api::SchedulePayload& out, std::string& outEtag) {
return api::fetchScheduleForLicense(licenseKey, etag, out, outEtag);
}) {
}
ScheduleManager::~ScheduleManager() {
shutdown();
}
bool ScheduleManager::hasSchedule() const {
std::lock_guard<std::mutex> lock(mutex_);
return payload_.hasSchedule();
}
bool ScheduleManager::enabled() const {
std::lock_guard<std::mutex> lock(mutex_);
return enabled_;
}
Decision ScheduleManager::activeBlock(int minutes, Weekday weekday) const {
std::lock_guard<std::mutex> lock(mutex_);
if (!enabled_) {
return Decision{};
}
return evaluate(payload_, minutes, weekday, nullptr);
}
void ScheduleManager::setFetchForTesting(FetchFn fetch) {
std::lock_guard<std::mutex> lock(mutex_);
fetch_ = std::move(fetch);
}
void ScheduleManager::applyPayload(api::SchedulePayload payload, const std::string& etag) {
{
std::lock_guard<std::mutex> lock(mutex_);
payload_ = std::move(payload);
etag_ = etag;
}
// Cache the last good payload for offline use (atomic write).
radiantwave::sync::schedule_cache::write(payload_, etag_);
if (onChanged_) onChanged_();
}
void ScheduleManager::loadCache() {
api::SchedulePayload cached;
std::string cachedEtag;
if (radiantwave::sync::schedule_cache::read(cached, cachedEtag)) {
std::lock_guard<std::mutex> lock(mutex_);
payload_ = std::move(cached);
etag_ = std::move(cachedEtag);
if (onChanged_) onChanged_();
}
}
void ScheduleManager::refreshOnce() {
std::string licenseKey;
std::string etag;
FetchFn fetch;
{
std::lock_guard<std::mutex> lock(mutex_);
licenseKey = licenseKey_;
etag = etag_;
fetch = fetch_;
}
if (licenseKey.empty()) return;
api::SchedulePayload payload;
std::string newEtag;
const api::ScheduleFetchStatus status = fetch(licenseKey, etag, payload, newEtag);
if (status == api::ScheduleFetchStatus::Ok) {
applyPayload(std::move(payload), newEtag);
}
// NotModified / NotFound / NetworkError: keep the current cache.
}
void ScheduleManager::start(db::DatabaseManager& db, const std::string& timezone,
std::function<void()> onChanged) {
std::lock_guard<std::mutex> lock(mutex_);
if (started_) return;
started_ = true;
db_ = &db;
timezone_ = timezone;
onChanged_ = std::move(onChanged);
licenseKey_ = db.getKV(db::KV_LICENSE_KEY);
enabled_ = true; // config supplies the toggle via setEnabled()
// Offline first: load any cached payload immediately.
loadCache();
stop_.store(false);
worker_ = std::thread(&ScheduleManager::run, this);
requested_.store(true);
cv_.notify_one();
}
void ScheduleManager::shutdown() {
{
std::lock_guard<std::mutex> lock(mutex_);
if (!started_) return;
stop_.store(true);
}
cv_.notify_one();
if (worker_.joinable()) {
worker_.join();
}
std::lock_guard<std::mutex> lock(mutex_);
started_ = false;
}
void ScheduleManager::requestRefresh() {
{
std::lock_guard<std::mutex> lock(mutex_);
if (!started_) return;
if (db_) {
licenseKey_ = db_->getKV(db::KV_LICENSE_KEY);
}
requested_.store(true);
}
cv_.notify_one();
}
void ScheduleManager::setEnabled(bool enabled) {
std::lock_guard<std::mutex> lock(mutex_);
enabled_ = enabled;
}
void ScheduleManager::setPayloadForTesting(api::SchedulePayload payload,
const std::string& etag) {
std::lock_guard<std::mutex> lock(mutex_);
payload_ = std::move(payload);
etag_ = etag;
}
void ScheduleManager::run() {
while (true) {
{
std::unique_lock<std::mutex> lock(mutex_);
cv_.wait_for(lock, REFRESH_INTERVAL, [this] {
return stop_.load() || requested_.load();
});
if (stop_.load()) return;
requested_.store(false);
}
refreshOnce();
}
}
} // namespace radiantwave::schedule
+93
View File
@@ -0,0 +1,93 @@
#pragma once
#include "db/database_manager.h"
#include "network/api_client.h"
#include "schedule/schedule_engine.h"
#include <atomic>
#include <chrono>
#include <condition_variable>
#include <functional>
#include <mutex>
#include <string>
#include <thread>
namespace radiantwave::schedule {
// Fetches the Schedule for the stored License Key with conditional GET, caches
// the last payload to disk (atomic write), and exposes the active Block for
// playback. Never blocks the UI: a background worker pulls on boot, on
// network-up, and every 5 minutes.
class ScheduleManager {
public:
ScheduleManager();
~ScheduleManager();
ScheduleManager(const ScheduleManager&) = delete;
ScheduleManager& operator=(const ScheduleManager&) = delete;
// Returns the active Block for the stored device state at `minutes`
// (wall-clock) on `weekday`. Honors local enable/disable: when schedules
// are off, always returns no block.
Decision activeBlock(int minutes, Weekday weekday) const;
// True when a payload with an active type is loaded (from network or cache).
bool hasSchedule() const;
// True while schedules are locally enabled (System Settings toggle).
bool enabled() const;
// Background lifecycle.
void start(db::DatabaseManager& db, const std::string& timezone,
std::function<void()> onChanged = nullptr);
// Update the local enable flag (from config.yaml). When off, activeBlock()
// returns none but the cache keeps updating.
void setEnabled(bool enabled);
// Test/inspection hook: replace the loaded payload directly.
void setPayloadForTesting(api::SchedulePayload payload, const std::string& etag);
void shutdown();
// Request an immediate refresh (e.g. after a settings change). Non-blocking.
void requestRefresh();
// Pull interval (5 minutes).
static constexpr std::chrono::seconds REFRESH_INTERVAL{300};
// Injectable for tests: performs one fetch+cache cycle. The production
// implementation uses api::fetchScheduleForLicense + schedule_cache.
using FetchFn = std::function<api::ScheduleFetchStatus(
const std::string& licenseKey, const std::string& etag,
api::SchedulePayload& out, std::string& outEtag)>;
void setFetchForTesting(FetchFn fetch);
// Run one fetch cycle synchronously (used by tests and boot).
void refreshOnce();
private:
void run();
void loadCache();
void applyPayload(api::SchedulePayload payload, const std::string& etag);
mutable std::mutex mutex_;
api::SchedulePayload payload_;
std::string etag_;
bool enabled_ = true;
std::string licenseKey_;
std::string timezone_;
FetchFn fetch_;
db::DatabaseManager* db_ = nullptr;
std::function<void()> onChanged_;
std::thread worker_;
std::condition_variable cv_;
std::atomic<bool> stop_{false};
std::atomic<bool> requested_{false};
bool started_ = false;
};
} // namespace radiantwave::schedule
+32
View File
@@ -164,6 +164,38 @@ int currentLocalMinutes(const std::string& iana_code) {
return tm_buf.tm_hour * 60 + tm_buf.tm_min; return tm_buf.tm_hour * 60 + tm_buf.tm_min;
} }
int currentLocalWeekday(const std::string& iana_code) {
std::lock_guard<std::mutex> lock(g_tz_mutex);
const char* old_tz = std::getenv("TZ");
std::string old_tz_str = old_tz ? old_tz : "";
if (!iana_code.empty()) {
setenv("TZ", iana_code.c_str(), 1);
} else {
setenv("TZ", "UTC", 1);
}
tzset();
time_t now = std::time(nullptr);
struct tm tm_buf;
#if defined(_WIN32)
localtime_s(&tm_buf, &now);
#else
localtime_r(&now, &tm_buf);
#endif
if (!old_tz_str.empty()) {
setenv("TZ", old_tz_str.c_str(), 1);
} else {
unsetenv("TZ");
}
tzset();
// tm_wday: 0=Sunday .. 6=Saturday -> convert to 0=Monday .. 6=Sunday.
return (tm_buf.tm_wday + 6) % 7;
}
std::string getUtcTimestampIso() { std::string getUtcTimestampIso() {
time_t now = std::time(nullptr); time_t now = std::time(nullptr);
struct tm gmt_buf; struct tm gmt_buf;
+4
View File
@@ -27,6 +27,10 @@ std::string formatUtcToTimezone(time_t utc_time, const std::string& iana_code, c
// timezone is empty. // timezone is empty.
int currentLocalMinutes(const std::string& iana_code); int currentLocalMinutes(const std::string& iana_code);
// Weekday right now in `iana_code`, 0=Monday .. 6=Sunday. Falls back to UTC when
// the timezone is empty.
int currentLocalWeekday(const std::string& iana_code);
// Gets current UTC timestamp as ISO string // Gets current UTC timestamp as ISO string
std::string getUtcTimestampIso(); std::string getUtcTimestampIso();
+298
View File
@@ -0,0 +1,298 @@
#include "schedule/schedule_engine.h"
#include "schedule/schedule_manager.h"
#include "network/api_client.h"
#include "network/schedule_cache.h"
#include "db/database_manager.h"
#include "db/db_keys.h"
#include <gtest/gtest.h>
#include <filesystem>
#include <fstream>
#include <sstream>
#include <string>
using namespace radiantwave::schedule;
using radiantwave::api::SchedulePayload;
using radiantwave::api::TargetBlock;
namespace {
// Fixture files are shared byte-for-byte with the Portal. The test looks them
// up relative to the source tree (CMake sets RWT_FIXTURES_DIR).
std::string fixtureText(const std::string& name) {
const std::string dir =
#ifdef RWT_FIXTURES_DIR
RWT_FIXTURES_DIR;
#else
"docs/fixtures";
#endif
std::ifstream in(dir + "/" + name, std::ios::binary);
if (!in.is_open()) return "";
std::ostringstream ss;
ss << in.rdbuf();
return ss.str();
}
SchedulePayload loadPayload(const std::string& name) {
SchedulePayload payload;
const std::string text = fixtureText(name);
EXPECT_FALSE(text.empty()) << "missing fixture " << name;
EXPECT_TRUE(radiantwave::api::parseSchedulePayloadJson(text, payload)) << name;
return payload;
}
bool hasBlockAt(const SchedulePayload& p, int minutes, Weekday day) {
return evaluate(p, minutes, day).hasBlock;
}
} // namespace
// ─── Adjacency (half-open boundaries) ──────────────────────────────────
TEST(ScheduleEngineTest, AdjacentBoundaryBelongsToNextBlock) {
const auto p = loadPayload("schedule-adjacent.json");
// Block A [06:00, 07:00), Block B [07:00, 08:30). 06:59 in A; 07:00 in B.
const auto at0659 = evaluate(p, 6 * 60 + 59, Weekday::Mon);
const auto at0700 = evaluate(p, 7 * 60, Weekday::Mon);
ASSERT_TRUE(at0659.hasBlock);
ASSERT_TRUE(at0700.hasBlock);
EXPECT_EQ(at0659.block.id, "dd111111-1111-4111-8111-111111111111");
EXPECT_EQ(at0700.block.id, "dd222222-2222-4222-8222-222222222222");
EXPECT_EQ(at0700.block.dynamic_audio, false);
}
// ─── Daily fixture ─────────────────────────────────────────────────────
TEST(ScheduleEngineTest, DailyPicksBlocksAndLeavesGaps) {
const auto p = loadPayload("schedule-daily.json");
EXPECT_TRUE(hasBlockAt(p, 7 * 60, Weekday::Tue)); // inside first
EXPECT_FALSE(hasBlockAt(p, 9 * 60, Weekday::Tue)); // gap
EXPECT_TRUE(hasBlockAt(p, 18 * 60 + 30, Weekday::Tue)); // inside second
EXPECT_FALSE(hasBlockAt(p, 23 * 60, Weekday::Tue)); // gap
}
// ─── Midnight crossing (daily self-wrap) ───────────────────────────────
TEST(ScheduleEngineTest, MidnightCrossingWrapsWithinTheDay) {
const auto p = loadPayload("schedule-midnight-crossing.json");
// 21:00→09:00 and its complement 09:00→21:00.
const auto late = evaluate(p, 23 * 60, Weekday::Wed);
const auto early = evaluate(p, 3 * 60, Weekday::Wed);
const auto midday = evaluate(p, 12 * 60, Weekday::Wed);
ASSERT_TRUE(late.hasBlock);
ASSERT_TRUE(early.hasBlock);
EXPECT_EQ(late.block.id, "cc111111-1111-4111-8111-111111111111");
EXPECT_EQ(early.block.id, late.block.id);
EXPECT_EQ(midday.block.id, "cc222222-2222-4222-8222-222222222222");
}
// ─── Weekly Sunday -> Monday spillover ─────────────────────────────────
TEST(ScheduleEngineTest, WeeklySundayCrossingSpillsIntoMonday) {
const auto p = loadPayload("schedule-weekly.json");
// Sunday 23:00→01:00. Monday 00:30 belongs to the Sunday block.
const auto mondayEarly = evaluate(p, 30, Weekday::Mon);
ASSERT_TRUE(mondayEarly.hasBlock);
EXPECT_EQ(mondayEarly.block.id, "a4444444-4444-4444-8444-444444444444");
EXPECT_EQ(mondayEarly.block.day, "sun");
// 01:00 is the boundary: no longer covered.
EXPECT_FALSE(evaluate(p, 60, Weekday::Mon).hasBlock);
// Monday 06:00 block plays on Monday.
const auto monday = evaluate(p, 6 * 60, Weekday::Mon);
ASSERT_TRUE(monday.hasBlock);
EXPECT_EQ(monday.block.day, "mon");
}
// ─── Gap -> local, dynamicAudio true/false ─────────────────────────────
TEST(ScheduleEngineTest, DynamicAudioFlagPerBlock) {
const auto p = loadPayload("schedule-dynamic-audio.json");
const auto morning = evaluate(p, 9 * 60, Weekday::Mon);
const auto evening = evaluate(p, 21 * 60, Weekday::Mon);
ASSERT_TRUE(morning.hasBlock);
ASSERT_TRUE(evening.hasBlock);
EXPECT_TRUE(morning.block.dynamic_audio);
EXPECT_FALSE(evening.block.dynamic_audio);
}
// ─── None / unknown type ───────────────────────────────────────────────
TEST(ScheduleEngineTest, NoScheduleHasNoActiveBlock) {
const auto p = loadPayload("schedule-none.json");
EXPECT_FALSE(p.hasSchedule());
EXPECT_FALSE(evaluate(p, 12 * 60, Weekday::Mon).hasBlock);
}
// ─── Invalid block skipped ─────────────────────────────────────────────
TEST(ScheduleEngineTest, SkipsInvalidBlocksAndWarns) {
SchedulePayload p;
p.type = "daily";
TargetBlock invalid; // empty proclamation list
invalid.id = "bad";
invalid.start_minutes = 0;
invalid.end_minutes = 60;
TargetBlock valid;
valid.id = "good";
valid.start_minutes = 0;
valid.end_minutes = 60;
valid.proclamation_ids = {"p1"};
p.daily = {invalid, valid};
std::string warning;
const auto decision = evaluate(p, 30, Weekday::Mon,
[&](const std::string& w) { warning = w; });
ASSERT_TRUE(decision.hasBlock);
EXPECT_EQ(decision.block.id, "good");
EXPECT_FALSE(warning.empty());
}
// ─── Timezone / DST-transition day ─────────────────────────────────────
TEST(ScheduleEngineTest, WallClockMinutesAreTimezoneIndependent) {
// The engine takes wall-clock minutes; a DST day still passes the same
// wall-clock (e.g. 02:30) and matches an 02:00–03:00 block.
SchedulePayload p;
p.type = "daily";
TargetBlock b;
b.id = "dst";
b.start_minutes = 120;
b.end_minutes = 180;
b.proclamation_ids = {"p1"};
p.daily = {b};
EXPECT_TRUE(evaluate(p, 150, Weekday::Sun).hasBlock);
}
TEST(ScheduleEngineTest, WeekdayFromIndexWraps) {
EXPECT_EQ(weekdayFromIndex(0), Weekday::Mon);
EXPECT_EQ(weekdayFromIndex(6), Weekday::Sun);
EXPECT_EQ(weekdayFromIndex(7), Weekday::Mon);
}
// ─── Payload parsing ───────────────────────────────────────────────────
TEST(ScheduleEngineTest, ParsesVersionTypeAndBlocks) {
const auto p = loadPayload("schedule-daily.json");
EXPECT_EQ(p.version, "sched-daily-0001");
EXPECT_EQ(p.type, "daily");
EXPECT_EQ(p.daily.size(), 2u);
EXPECT_TRUE(p.weekly.empty());
EXPECT_EQ(p.legacy_blocks.size(), 2u);
}
TEST(ScheduleEngineTest, ParsesWeeklyBlocksByDay) {
const auto p = loadPayload("schedule-weekly.json");
EXPECT_EQ(p.type, "weekly");
EXPECT_EQ(p.weekly.size(), 4u); // mon, wed, fri, sun
EXPECT_TRUE(p.daily.empty());
}
// ─── Offline cache ─────────────────────────────────────────────────────
namespace {
std::string tempCacheDir() {
const auto dir = std::filesystem::temp_directory_path() / "rw_schedule_cache_test";
std::filesystem::create_directories(dir);
return dir.string();
}
} // namespace
TEST(ScheduleCacheTest, RoundTripsPayloadAndEtag) {
setenv("HOME", tempCacheDir().c_str(), 1);
SchedulePayload p = loadPayload("schedule-daily.json");
ASSERT_TRUE(radiantwave::sync::schedule_cache::write(p, "\"abc\""));
SchedulePayload loaded;
std::string etag;
ASSERT_TRUE(radiantwave::sync::schedule_cache::read(loaded, etag));
EXPECT_EQ(etag, "\"abc\"");
EXPECT_EQ(loaded.type, "daily");
EXPECT_EQ(loaded.daily.size(), 2u);
EXPECT_EQ(loaded.version, p.version);
radiantwave::sync::schedule_cache::clear();
}
TEST(ScheduleCacheTest, CorruptCacheIsIgnored) {
const std::string dir = tempCacheDir();
setenv("HOME", dir.c_str(), 1);
// Write garbage directly.
std::ofstream out(radiantwave::sync::schedule_cache::cachePath(), std::ios::binary);
out << "{ this is not json";
out.close();
SchedulePayload loaded;
std::string etag;
EXPECT_FALSE(radiantwave::sync::schedule_cache::read(loaded, etag));
radiantwave::sync::schedule_cache::clear();
}
// ─── ScheduleManager: fetch, 304, offline, toggle ──────────────────────
namespace {
SchedulePayload dailyPayload() { return loadPayload("schedule-daily.json"); }
} // namespace
TEST(ScheduleManagerTest, FetchesPayloadAndAppliesIt) {
radiantwave::schedule::ScheduleManager manager;
manager.setPayloadForTesting(dailyPayload(), "\"v0\"");
manager.setFetchForTesting(
[](const std::string&, const std::string&, SchedulePayload& out, std::string& etag) {
out = dailyPayload();
etag = "\"v1\"";
return radiantwave::api::ScheduleFetchStatus::Ok;
});
manager.setEnabled(true);
EXPECT_TRUE(manager.activeBlock(7 * 60, Weekday::Mon).hasBlock);
}
TEST(ScheduleManagerTest, DisabledReturnsNoBlock) {
radiantwave::schedule::ScheduleManager manager;
manager.setPayloadForTesting(dailyPayload(), "\"v1\"");
manager.setEnabled(true);
EXPECT_TRUE(manager.activeBlock(7 * 60, Weekday::Mon).hasBlock);
manager.setEnabled(false);
EXPECT_FALSE(manager.activeBlock(7 * 60, Weekday::Mon).hasBlock);
}
TEST(ScheduleManagerTest, CorruptCacheFallsBackToNoSchedule) {
const std::string dir = tempCacheDir();
setenv("HOME", dir.c_str(), 1);
std::ofstream out(radiantwave::sync::schedule_cache::cachePath(), std::ios::binary);
out << "not json";
out.close();
radiantwave::schedule::ScheduleManager manager;
manager.setEnabled(true);
EXPECT_FALSE(manager.activeBlock(7 * 60, Weekday::Mon).hasBlock);
radiantwave::sync::schedule_cache::clear();
}
TEST(ScheduleManagerTest, OfflineFallsBackToCache) {
const std::string dir = tempCacheDir();
setenv("HOME", dir.c_str(), 1);
// Write a good cache, then simulate a network error on fetch.
SchedulePayload p = dailyPayload();
radiantwave::sync::schedule_cache::write(p, "\"cached\"");
radiantwave::schedule::ScheduleManager manager;
manager.setEnabled(true);
// Manually seed from cache (start() requires a DB); setPayloadForTesting
// mirrors what loadCache() would produce.
SchedulePayload cached;
std::string etag;
ASSERT_TRUE(radiantwave::sync::schedule_cache::read(cached, etag));
manager.setPayloadForTesting(cached, etag);
EXPECT_TRUE(manager.activeBlock(7 * 60, Weekday::Mon).hasBlock);
EXPECT_EQ(etag, "\"cached\"");
radiantwave::sync::schedule_cache::clear();
}