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
+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