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
+165 -2
View File
@@ -2,6 +2,7 @@
#include "network/json.h"
#include <curl/curl.h>
#include <cctype>
#include <cstdio>
#include <string>
@@ -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<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
// "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<Frequency>& 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<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 result;
+54
View File
@@ -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<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
+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