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:
+165
-2
@@ -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;
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user