Files
radiantwave/src/network/schedule_cache.cpp
T
NeilClack 509c925480 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.
2026-09-29 10:34:40 -05:00

159 lines
4.5 KiB
C++

#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