Files
Announcements/server/src/mqtt/index.js
T
MediaandClaude Opus 5 ae70bf1dd8 Implement Phase 4 integrations and display telemetry
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]>
2026-08-16 22:52:59 -05:00

315 lines
9.3 KiB
JavaScript

const PLAYBACK_STATE_VERSION = 1;
// Commands that change durable state are delivered at least once. next and
// previous are not idempotent and carry no command ID, so a redelivered copy
// would silently skip a slide; they stay at QoS 0 deliberately.
const COMMANDS = Object.freeze([
{ name: "mode", suffix: "mode", qos: 1 },
{ name: "pause", suffix: "pause", qos: 1 },
{ name: "resume", suffix: "resume", qos: 1 },
{ name: "reload", suffix: "reload", qos: 1 },
{ name: "static", suffix: "static", qos: 1 },
{ name: "next", suffix: "next", qos: 0 },
{ name: "previous", suffix: "previous", qos: 0 },
]);
const UUID_PATTERN =
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
function transportOf(snapshot) {
if (snapshot.staticTarget) return "static";
if (snapshot.transport?.rotationPaused) return "paused";
return snapshot.normalTarget ? "rotating" : "idle";
}
function compactState(snapshot) {
return {
version: PLAYBACK_STATE_VERSION,
mode: snapshot.mode,
transport: transportOf(snapshot),
activePlaylistId: snapshot.activePlaylistId ?? null,
currentEntryId: snapshot.currentEntryId ?? null,
currentAssetId: snapshot.normalTarget?.assetId ?? null,
staticAssetId: snapshot.staticTarget?.assetId ?? null,
revision: snapshot.playbackRevision,
};
}
function createMqttService({
config,
playbackService,
connect,
logger = console,
} = {}) {
if (!playbackService) throw new TypeError("playbackService is required");
const settings = config?.mqtt || {};
const prefix = settings.topicPrefix || "announcements";
const topic = (suffix) => `${prefix}/${suffix}`;
const availabilityTopic = topic("availability");
const modeStateTopic = topic("mode/state");
const playbackStateTopic = topic("playback/state");
const configured = Boolean(settings.url);
let client = null;
let connected = false;
let connectionCount = 0;
let lastConnectedAt = null;
let lastErrorAt = null;
let lastErrorMessage = null;
let commandCount = 0;
let stopping = false;
function warn(error, message, details) {
try {
logger.warn?.(`${message}${details ? ` ${JSON.stringify(details)}` : ""}`, error);
} catch {
// Logging failures must never propagate into the broker callbacks.
}
}
function publish(topicName, payload, options) {
if (!client || !connected) return false;
try {
client.publish(topicName, payload, options, (error) => {
if (error) warn(error, "MQTT publish failed", { topic: topicName });
});
return true;
} catch (error) {
warn(error, "MQTT publish threw", { topic: topicName });
return false;
}
}
// Retained authoritative state is only ever published on dedicated state
// topics so a subscriber can never mistake an echo for a new command.
function publishState(snapshot) {
if (!snapshot) return;
publish(modeStateTopic, snapshot.mode, { qos: 1, retain: true });
publish(playbackStateTopic, JSON.stringify(compactState(snapshot)), {
qos: 1,
retain: true,
});
}
function publishCurrentState() {
try {
publishState(playbackService.getSnapshot());
} catch (error) {
warn(error, "MQTT could not read the authoritative playback snapshot");
}
}
function handleMode(payload) {
const value = payload.trim().toLowerCase();
if (value !== "on" && value !== "off") {
warn(null, "Ignoring MQTT mode command with an unsupported payload", {
payload: value.slice(0, 40),
});
return false;
}
playbackService.setMode(value);
return true;
}
function handleStatic(payload) {
const assetId = payload.trim();
if (!UUID_PATTERN.test(assetId)) {
warn(null, "Ignoring MQTT static command that is not a LibraryAsset UUID", {
payload: assetId.slice(0, 60),
});
return false;
}
playbackService.enterStatic(assetId);
return true;
}
const handlers = Object.freeze({
mode: handleMode,
static: handleStatic,
next: () => (playbackService.next(), true),
previous: () => (playbackService.previous(), true),
pause: () => (playbackService.pause(), true),
resume: () => (playbackService.resume(), true),
reload: () => (playbackService.reload(), true),
});
const byTopic = new Map(
COMMANDS.map((command) => [topic(command.suffix), command]),
);
function handleMessage(topicName, buffer) {
const command = byTopic.get(topicName);
if (!command) {
warn(null, "Ignoring MQTT message on an unknown topic", { topic: topicName });
return;
}
let payload = "";
try {
payload = buffer ? buffer.toString("utf8") : "";
} catch (error) {
warn(error, "Ignoring MQTT message with an undecodable payload", {
topic: topicName,
});
return;
}
try {
// Every command runs through the one authoritative playback service.
// A rejected command leaves playback exactly as it was.
if (handlers[command.name](payload)) commandCount += 1;
} catch (error) {
warn(error, "MQTT command was rejected by the playback service", {
topic: topicName,
code: error?.code,
});
}
}
function subscribeAll() {
if (!client) return;
for (const command of COMMANDS) {
client.subscribe(topic(command.suffix), { qos: command.qos }, (error) => {
if (error) warn(error, "MQTT subscribe failed", { topic: topic(command.suffix) });
});
}
}
function handleConnect() {
connected = true;
connectionCount += 1;
lastConnectedAt = new Date().toISOString();
logger.info?.(`MQTT connected to the configured broker as ${prefix}`);
// Reconnect must restore both the subscriptions and the retained state a
// broker restart would otherwise have dropped.
publish(availabilityTopic, "online", { qos: 1, retain: true });
subscribeAll();
publishCurrentState();
}
function start() {
if (!configured) {
logger.info?.("MQTT is not configured; the integration stays disabled");
return null;
}
if (client) return client;
const factory = connect || require("mqtt").connect;
client = factory(settings.url, {
username: settings.username || undefined,
password: settings.password || undefined,
reconnectPeriod: settings.reconnectPeriodMs ?? 5_000,
connectTimeout: settings.connectTimeoutMs ?? 10_000,
clean: true,
// The broker publishes this for us if the API disappears without a
// clean disconnect, which is exactly the case a client cannot report.
will: {
topic: availabilityTopic,
payload: "offline",
qos: 1,
retain: true,
},
});
client.on("connect", handleConnect);
client.on("message", handleMessage);
client.on("reconnect", () => {
connected = false;
});
client.on("close", () => {
connected = false;
});
client.on("offline", () => {
connected = false;
});
client.on("error", (error) => {
connected = false;
lastErrorAt = new Date().toISOString();
lastErrorMessage = error?.message || "MQTT error";
// A broker outage degrades the integration only. Playback, HTTP, and
// the display protocol continue untouched and the client reconnects.
warn(error, "MQTT connection error; the integration is degraded");
});
return client;
}
async function stop() {
stopping = true;
if (!client) return;
const active = client;
client = null;
try {
if (connected) {
await new Promise((resolve) => {
let settled = false;
const finish = () => {
if (settled) return;
settled = true;
resolve();
};
const timer = setTimeout(finish, 1_000);
timer.unref?.();
// A clean shutdown retracts availability itself; the will is only
// for the unexpected case.
active.publish(availabilityTopic, "offline", { qos: 1, retain: true }, () => {
clearTimeout(timer);
finish();
});
});
for (const command of COMMANDS) {
try {
active.unsubscribe(topic(command.suffix));
} catch {
// Shutdown continues even if the broker already went away.
}
}
}
} catch (error) {
warn(error, "MQTT shutdown publication failed");
}
connected = false;
try {
await new Promise((resolve) => {
const timer = setTimeout(resolve, 1_000);
timer.unref?.();
active.end(false, {}, () => {
clearTimeout(timer);
resolve();
});
});
} catch (error) {
warn(error, "MQTT client did not close cleanly");
}
}
function status() {
return {
configured,
status: !configured ? "disabled" : connected ? "connected" : "disconnected",
connected,
topicPrefix: prefix,
connectionCount,
commandCount,
lastConnectedAt,
lastErrorAt,
lastErrorMessage,
stopping,
};
}
return {
availabilityTopic,
commandTopics: COMMANDS.map((command) => topic(command.suffix)),
compactState,
modeStateTopic,
playbackStateTopic,
publishState,
start,
status,
stop,
};
}
module.exports = {
COMMANDS,
PLAYBACK_STATE_VERSION,
createMqttService,
};