Add MQTT playback control, durable Home Assistant incident notifications, and display heartbeat telemetry on top of the verified Phase 3B playback authority. No playback logic is duplicated: every MQTT command and every observed condition goes through the existing playback service, and all integration work happens after the authoritative SQLite commit. MQTT (server/src/mqtt) subscribes the exact required command topics under a configurable prefix that defaults to announcements. Durable commands use QoS 1; next and previous stay at QoS 0 because they are not idempotent and carry no command ID. Retained authoritative state is published only on the separate availability, mode/state, and playback/state topics, never onto a command topic, and only after a mutation commits. A registered last will reports an ungraceful disappearance, a clean shutdown retracts it, and a broker outage degrades status while playback, HTTP, and the display protocol continue untouched. Notifications (server/src/notifications) use the Phase 1 incident and outbox tables. A failing condition opens one incident and queues exactly one notification; continued failure only updates its observation time; recovery queues exactly one recovery; a later failure is a new episode. Delivery retries on a documented capped schedule with a stable notification ID, survives restart, and never blocks playback. With no webhook URL configured, incidents are still recorded but no delivery work is queued. Conditions come from storage health transitions, committed playback state, display heartbeat staleness, and newly failed uploads. Displays (server/src/displays) ingest validated, bounded display:heartbeat telemetry keyed by persistent client ID rather than socket ID. Telemetry is read-only and never touches playback or playlist revisions. Offline is decided by heartbeat staleness, not by a closed socket, and a startup grace period keeps an API restart from manufacturing offline alerts. Adds GET /api/displays and meaningful-change display:status events. Health and status now report redacted storage, MQTT, notification, playback, and display state. External integrations degrade status and never affect readiness, so no integration outage can cause a restart loop. No MQTT password, S3 credential, webhook URL, or webhook secret is exposed. Uses the existing Phase 1 schema unchanged; no migration was required. Co-Authored-By: Claude Opus 5 <[email protected]>
251 lines
8.7 KiB
JavaScript
251 lines
8.7 KiB
JavaScript
const test = require("node:test");
|
|
const assert = require("node:assert/strict");
|
|
const mqtt = require("mqtt");
|
|
const { loadConfig } = require("../src/config");
|
|
const { createRuntime } = require("../src/runtime");
|
|
|
|
// Protocol behaviour that a hand-written fake cannot prove — retained
|
|
// messages, subscription delivery, and a broker-published last will — is
|
|
// verified against a real broker. The isolated verification stack sets
|
|
// MQTT_TEST_URL; without it these checks are skipped rather than faked.
|
|
const BROKER_URL = process.env.MQTT_TEST_URL;
|
|
const silentLogger = { info() {}, warn() {}, error() {} };
|
|
|
|
function testConfig(overrides = {}) {
|
|
return loadConfig({
|
|
NODE_ENV: "test",
|
|
HOST: "127.0.0.1",
|
|
PORT: "0",
|
|
DATABASE_PATH: ":memory:",
|
|
APP_TIMEZONE: "America/Chicago",
|
|
SHUTDOWN_TIMEOUT_MS: "1000",
|
|
S3_ENDPOINT: "http://seaweedfs:8333",
|
|
S3_BUCKET: "announcements-test",
|
|
MQTT_URL: BROKER_URL,
|
|
...overrides,
|
|
});
|
|
}
|
|
|
|
function testStorage() {
|
|
return {
|
|
async start() {},
|
|
stop() {},
|
|
health: () => ({ status: "healthy", checkedAt: null, lastSuccessAt: null }),
|
|
};
|
|
}
|
|
|
|
function connectObserver() {
|
|
return new Promise((resolve, reject) => {
|
|
const client = mqtt.connect(BROKER_URL, { reconnectPeriod: 0 });
|
|
client.once("connect", () => resolve(client));
|
|
client.once("error", reject);
|
|
});
|
|
}
|
|
|
|
function collect(client, topic) {
|
|
const received = [];
|
|
client.on("message", (messageTopic, payload) => {
|
|
if (messageTopic === topic) received.push(payload.toString("utf8"));
|
|
});
|
|
return received;
|
|
}
|
|
|
|
function waitFor(predicate, { timeoutMs = 5_000, label = "condition" } = {}) {
|
|
return new Promise((resolve, reject) => {
|
|
const deadline = Date.now() + timeoutMs;
|
|
const tick = () => {
|
|
let value;
|
|
try {
|
|
value = predicate();
|
|
} catch (error) {
|
|
reject(error);
|
|
return;
|
|
}
|
|
if (value) {
|
|
resolve(value);
|
|
return;
|
|
}
|
|
if (Date.now() > deadline) {
|
|
reject(new Error(`Timed out waiting for ${label}`));
|
|
return;
|
|
}
|
|
setTimeout(tick, 25);
|
|
};
|
|
tick();
|
|
});
|
|
}
|
|
|
|
async function subscribe(client, topic, options = {}) {
|
|
await new Promise((resolve, reject) => {
|
|
client.subscribe(topic, { qos: 1, ...options }, (error) =>
|
|
error ? reject(error) : resolve(),
|
|
);
|
|
});
|
|
}
|
|
|
|
async function publish(client, topic, payload, options = {}) {
|
|
await new Promise((resolve, reject) => {
|
|
client.publish(topic, payload, { qos: 1, ...options }, (error) =>
|
|
error ? reject(error) : resolve(),
|
|
);
|
|
});
|
|
}
|
|
|
|
async function startRuntime(t) {
|
|
const runtime = createRuntime({
|
|
config: testConfig(),
|
|
logger: silentLogger,
|
|
storage: testStorage(),
|
|
});
|
|
await runtime.start();
|
|
t.after(async () => {
|
|
try {
|
|
await runtime.stop("test cleanup");
|
|
} catch {
|
|
// A test that already stopped the runtime is fine.
|
|
}
|
|
});
|
|
await waitFor(() => runtime.mqttService.status().connected, {
|
|
label: "the API to connect to the broker",
|
|
});
|
|
return { runtime, baseUrl: `http://127.0.0.1:${runtime.address.port}` };
|
|
}
|
|
|
|
async function seedPlayback(runtime, baseUrl) {
|
|
const timestamp = Date.now();
|
|
for (const [id, hash] of [
|
|
["asset-one", "a"],
|
|
["asset-two", "c"],
|
|
]) {
|
|
runtime.database
|
|
.prepare(
|
|
`INSERT INTO assets (
|
|
id, source_sha256, display_name, original_filename, status,
|
|
display_object_key, display_sha256, display_content_type,
|
|
display_byte_size, display_width, display_height, created_at, updated_at
|
|
) VALUES (?, ?, ?, ?, 'ready', 'private/display/x.webp', ?, 'image/webp', 10, 1920, 1080, ?, ?)`,
|
|
)
|
|
.run(id, hash.repeat(64), id, `${id}.png`, hash.repeat(64), timestamp, timestamp);
|
|
}
|
|
const created = await fetch(`${baseUrl}/api/playlists`, {
|
|
method: "POST",
|
|
headers: { "content-type": "application/json" },
|
|
body: JSON.stringify({ name: "Broker" }),
|
|
});
|
|
const playlist = await created.json();
|
|
for (const assetId of ["asset-one", "asset-two"]) {
|
|
await fetch(`${baseUrl}/api/playlists/${playlist.id}/entries`, {
|
|
method: "POST",
|
|
headers: { "content-type": "application/json" },
|
|
body: JSON.stringify({ assetId, durationOverrideMs: 60_000 }),
|
|
});
|
|
}
|
|
await fetch(`${baseUrl}/api/playlists/${playlist.id}/activate`, { method: "POST" });
|
|
return playlist;
|
|
}
|
|
|
|
const options = BROKER_URL ? {} : { skip: "MQTT_TEST_URL is not configured" };
|
|
|
|
test("a real broker delivers commands and retains authoritative state", options, async (t) => {
|
|
const { runtime, baseUrl } = await startRuntime(t);
|
|
await seedPlayback(runtime, baseUrl);
|
|
|
|
const observer = await connectObserver();
|
|
t.after(() => observer.endAsync?.() ?? observer.end(true));
|
|
const availability = collect(observer, "announcements/availability");
|
|
const modeState = collect(observer, "announcements/mode/state");
|
|
await subscribe(observer, "announcements/availability");
|
|
await subscribe(observer, "announcements/mode/state");
|
|
|
|
// A late subscriber receives retained state immediately, which is what a
|
|
// Home Assistant restart depends on.
|
|
await waitFor(() => availability.includes("online"), {
|
|
label: "retained availability",
|
|
});
|
|
await waitFor(() => modeState.length > 0, { label: "retained mode state" });
|
|
assert.equal(modeState.at(-1), "on");
|
|
|
|
const commander = await connectObserver();
|
|
t.after(() => commander.endAsync?.() ?? commander.end(true));
|
|
await publish(commander, "announcements/mode", "off");
|
|
await waitFor(
|
|
() =>
|
|
runtime.database
|
|
.prepare("SELECT mode FROM playback_state WHERE output_id = 'global'")
|
|
.get().mode === "off",
|
|
{ label: "the mode command to commit" },
|
|
);
|
|
await waitFor(() => modeState.at(-1) === "off", { label: "retained mode state to follow" });
|
|
|
|
const before = runtime.playbackService.getSnapshot();
|
|
await publish(commander, "announcements/next", "", { qos: 0 });
|
|
await waitFor(
|
|
() => runtime.playbackService.getSnapshot().currentEntryId !== before.currentEntryId,
|
|
{ label: "the next command to advance playback" },
|
|
);
|
|
|
|
await publish(commander, "announcements/pause", "");
|
|
await waitFor(() => runtime.playbackService.getSnapshot().transportState === "paused", {
|
|
label: "pause",
|
|
});
|
|
await publish(commander, "announcements/resume", "");
|
|
await waitFor(() => runtime.playbackService.getSnapshot().transportState === "playing", {
|
|
label: "resume",
|
|
});
|
|
|
|
// An invalid payload is ignored over the real broker exactly as it is
|
|
// locally: the last committed mode survives untouched.
|
|
await publish(commander, "announcements/mode", " ON ");
|
|
await waitFor(() => runtime.playbackService.getSnapshot().mode === "on", {
|
|
label: "a whitespace-padded mode command to commit",
|
|
});
|
|
await publish(commander, "announcements/mode", "dim");
|
|
await new Promise((resolve) => setTimeout(resolve, 250));
|
|
assert.equal(runtime.playbackService.getSnapshot().mode, "on");
|
|
assert.equal(modeState.at(-1), "on", "no fake state was published for a rejected command");
|
|
});
|
|
|
|
test("a clean API shutdown retracts its retained availability", options, async (t) => {
|
|
const { runtime } = await startRuntime(t);
|
|
|
|
const observer = await connectObserver();
|
|
t.after(() => observer.endAsync?.() ?? observer.end(true));
|
|
const availability = collect(observer, "announcements/availability");
|
|
await subscribe(observer, "announcements/availability");
|
|
await waitFor(() => availability.includes("online"), { label: "online availability" });
|
|
|
|
await runtime.stop("clean shutdown test");
|
|
|
|
await waitFor(() => availability.at(-1) === "offline", {
|
|
label: "availability to be retracted on a planned shutdown",
|
|
});
|
|
});
|
|
|
|
test("an ungraceful disappearance makes the broker publish the last will", options, async (t) => {
|
|
const observer = await connectObserver();
|
|
t.after(() => observer.endAsync?.() ?? observer.end(true));
|
|
const availability = collect(observer, "test/availability");
|
|
await subscribe(observer, "test/availability");
|
|
|
|
// A crashed or power-cut API never sends DISCONNECT. Only a will can report
|
|
// that, so the will configuration is exercised against a real broker here;
|
|
// the unit suite proves the service registers exactly these options.
|
|
const willed = mqtt.connect(BROKER_URL, {
|
|
reconnectPeriod: 0,
|
|
will: { topic: "test/availability", payload: "offline", qos: 1, retain: true },
|
|
});
|
|
await new Promise((resolve, reject) => {
|
|
willed.once("connect", resolve);
|
|
willed.once("error", reject);
|
|
});
|
|
await publish(willed, "test/availability", "online", { retain: true });
|
|
await waitFor(() => availability.includes("online"), { label: "online availability" });
|
|
|
|
willed.stream.destroy();
|
|
|
|
await waitFor(() => availability.at(-1) === "offline", {
|
|
label: "the broker to publish the last will",
|
|
timeoutMs: 10_000,
|
|
});
|
|
});
|