diff --git a/CMakeLists.txt b/CMakeLists.txt index edddfe7..56d0869 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -43,6 +43,7 @@ add_executable(radiantwave src/settings_menu.cpp src/system_settings_page.cpp src/system_name_page.cpp + src/schedule_toggle_page.cpp src/wifi_setup_page.cpp src/audio_settings_page.cpp src/audio/audio_manager.cpp @@ -62,6 +63,9 @@ add_executable(radiantwave src/network/license_status.cpp src/network/system_name.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/config_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_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 add_executable(test_timezone tests/test_timezone.cpp diff --git a/src/network/api_client.cpp b/src/network/api_client.cpp index 60b57f3..f624a24 100644 --- a/src/network/api_client.cpp +++ b/src/network/api_client.cpp @@ -2,6 +2,7 @@ #include "network/json.h" #include +#include #include #include @@ -27,10 +28,32 @@ size_t writeCallback(char* ptr, size_t size, size_t nmemb, void* userdata) { 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(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(std::tolower(static_cast(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 -// "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, - const char* method, const std::string& jsonBody) { + const char* method, const std::string& jsonBody, + const std::string& ifNoneMatch = "") { HttpResponse response; 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_WRITEFUNCTION, writeCallback); 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"; struct curl_slist* headers = nullptr; @@ -56,6 +81,10 @@ HttpResponse performRequest(const std::string& url, 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) { 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); } +HttpResponse httpGetConditional(const std::string& path, const std::string& ifNoneMatch) { + return performRequest(urlFor(path), "GET", "", ifNoneMatch); +} + // ─── GET /frequencies ────────────────────────────────────────────────── bool parseFrequenciesJson(const std::string& body, std::vector& out) { @@ -343,6 +376,136 @@ bool fetchSchedule(const std::string& email, const std::string& license_key, 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(entry.numberField("startMinutes")); + block.end_minutes = static_cast(entry.numberField("endMinutes")); + block.overnight = entry.boolField("overnight"); + block.duration_minutes = static_cast(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(entry.numberField("startMinutes")); + block.end_minutes = static_cast(entry.numberField("endMinutes")); + block.overnight = entry.boolField("overnight"); + block.duration_minutes = static_cast(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 result; diff --git a/src/network/api_client.h b/src/network/api_client.h index 3f8c0f6..26aa8dc 100644 --- a/src/network/api_client.h +++ b/src/network/api_client.h @@ -23,6 +23,7 @@ constexpr const char* PATH_SCHEDULE = "/schedule"; struct HttpResponse { long status = 0; // HTTP status code; 0 if the request never completed 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 }; @@ -31,6 +32,9 @@ HttpResponse httpGet(const std::string& path); HttpResponse httpPost(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 ────────────────────────────────────────────────── 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, 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 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 daily; + // Weekly blocks keyed by day (mon..sun); empty for daily/none. + std::vector weekly; + // Legacy `blocks` flattening carried for backward compatibility. + std::vector 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 diff --git a/src/network/schedule_cache.cpp b/src/network/schedule_cache.cpp new file mode 100644 index 0000000..ab82604 --- /dev/null +++ b/src/network/schedule_cache.cpp @@ -0,0 +1,158 @@ +#include "network/schedule_cache.h" +#include "network/json.h" + +#include +#include +#include +#include +#include +#include +#include + +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 diff --git a/src/network/schedule_cache.h b/src/network/schedule_cache.h new file mode 100644 index 0000000..77469e6 --- /dev/null +++ b/src/network/schedule_cache.h @@ -0,0 +1,29 @@ +#pragma once + +#include "network/api_client.h" +#include + +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 diff --git a/src/schedule/schedule_engine.cpp b/src/schedule/schedule_engine.cpp new file mode 100644 index 0000000..88b14f8 --- /dev/null +++ b/src/schedule/schedule_engine.cpp @@ -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& 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& candidates, int minutes, + api::TargetBlock& out, + const std::function& 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(index % 7); +} + +Decision evaluate(const api::SchedulePayload& payload, int minutes, Weekday weekday, + std::function 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 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(weekday) + 6) % 7; + const std::string prevKey = dayKey(static_cast(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 diff --git a/src/schedule/schedule_engine.h b/src/schedule/schedule_engine.h new file mode 100644 index 0000000..2e6bd80 --- /dev/null +++ b/src/schedule/schedule_engine.h @@ -0,0 +1,36 @@ +#pragma once + +#include "network/api_client.h" +#include +#include + +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 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 diff --git a/src/schedule/schedule_manager.cpp b/src/schedule/schedule_manager.cpp new file mode 100644 index 0000000..a16bc94 --- /dev/null +++ b/src/schedule/schedule_manager.cpp @@ -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 + +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 lock(mutex_); + return payload_.hasSchedule(); +} + +bool ScheduleManager::enabled() const { + std::lock_guard lock(mutex_); + return enabled_; +} + +Decision ScheduleManager::activeBlock(int minutes, Weekday weekday) const { + std::lock_guard lock(mutex_); + if (!enabled_) { + return Decision{}; + } + return evaluate(payload_, minutes, weekday, nullptr); +} + +void ScheduleManager::setFetchForTesting(FetchFn fetch) { + std::lock_guard lock(mutex_); + fetch_ = std::move(fetch); +} + +void ScheduleManager::applyPayload(api::SchedulePayload payload, const std::string& etag) { + { + std::lock_guard 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 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 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 onChanged) { + std::lock_guard 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 lock(mutex_); + if (!started_) return; + stop_.store(true); + } + cv_.notify_one(); + if (worker_.joinable()) { + worker_.join(); + } + std::lock_guard lock(mutex_); + started_ = false; +} + +void ScheduleManager::requestRefresh() { + { + std::lock_guard 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 lock(mutex_); + enabled_ = enabled; +} + +void ScheduleManager::setPayloadForTesting(api::SchedulePayload payload, + const std::string& etag) { + std::lock_guard lock(mutex_); + payload_ = std::move(payload); + etag_ = etag; +} + +void ScheduleManager::run() { + while (true) { + { + std::unique_lock 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 diff --git a/src/schedule/schedule_manager.h b/src/schedule/schedule_manager.h new file mode 100644 index 0000000..0b05b42 --- /dev/null +++ b/src/schedule/schedule_manager.h @@ -0,0 +1,93 @@ +#pragma once + +#include "db/database_manager.h" +#include "network/api_client.h" +#include "schedule/schedule_engine.h" + +#include +#include +#include +#include +#include +#include +#include + +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 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; + + 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 onChanged_; + + std::thread worker_; + std::condition_variable cv_; + std::atomic stop_{false}; + std::atomic requested_{false}; + bool started_ = false; +}; + +} // namespace radiantwave::schedule diff --git a/src/timezone_helper.cpp b/src/timezone_helper.cpp index 33cc9a0..9bcffd8 100644 --- a/src/timezone_helper.cpp +++ b/src/timezone_helper.cpp @@ -164,6 +164,38 @@ int currentLocalMinutes(const std::string& iana_code) { return tm_buf.tm_hour * 60 + tm_buf.tm_min; } +int currentLocalWeekday(const std::string& iana_code) { + std::lock_guard 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() { time_t now = std::time(nullptr); struct tm gmt_buf; diff --git a/src/timezone_helper.h b/src/timezone_helper.h index 6a7ef31..bc58b28 100644 --- a/src/timezone_helper.h +++ b/src/timezone_helper.h @@ -27,6 +27,10 @@ std::string formatUtcToTimezone(time_t utc_time, const std::string& iana_code, c // timezone is empty. 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 std::string getUtcTimestampIso(); diff --git a/tests/test_schedule_engine.cpp b/tests/test_schedule_engine.cpp new file mode 100644 index 0000000..571222b --- /dev/null +++ b/tests/test_schedule_engine.cpp @@ -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 + +#include +#include +#include +#include + +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(); +}