diff --git a/.github/dependabot.yml b/.github/dependabot.yml
new file mode 100644
index 0000000..dd53094
--- /dev/null
+++ b/.github/dependabot.yml
@@ -0,0 +1,7 @@
+version: 2
+updates:
+ - package-ecosystem: npm
+ directory: /
+ schedule:
+ interval: weekly
+ open-pull-requests-limit: 5
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
new file mode 100644
index 0000000..15271bf
--- /dev/null
+++ b/.github/workflows/ci.yml
@@ -0,0 +1,45 @@
+name: CI
+
+on:
+ push:
+ branches: [main]
+ pull_request:
+
+permissions:
+ contents: read
+
+concurrency:
+ group: ci-${{ github.workflow }}-${{ github.ref }}
+ cancel-in-progress: true
+
+jobs:
+ test:
+ name: Node ${{ matrix.node }}
+ runs-on: ubuntu-latest
+ strategy:
+ fail-fast: false
+ matrix:
+ node: [18, 20, 22, 24]
+
+ steps:
+ - name: Check out repository
+ uses: actions/checkout@v7
+
+ - name: Set up Node.js
+ uses: actions/setup-node@v7
+ with:
+ node-version: ${{ matrix.node }}
+ cache: npm
+
+ - name: Install dependencies
+ run: npm ci
+
+ - name: Run tests
+ run: npm test
+
+ - name: Verify package contents
+ run: npm pack --dry-run
+
+ - name: Audit production dependencies
+ if: matrix.node == 22
+ run: npm audit --omit=dev --audit-level=high
diff --git a/CHANGELOG.md b/CHANGELOG.md
new file mode 100644
index 0000000..0c2241b
--- /dev/null
+++ b/CHANGELOG.md
@@ -0,0 +1,36 @@
+# Changelog
+
+All notable changes to MMM-MessageCenter are documented in this file.
+
+## 0.1.0 - 2026-08-01
+
+### Added
+
+- Normalized message contract with independent urgency and retention.
+- Newest-first in-memory history, expiration, deduplication, and acknowledgement.
+- Individual message acknowledgement and dismissal controls and notifications.
+- Webhook ingestion with optional bearer authentication.
+- Optional MQTT and Unix-domain-socket transports using the same normalized schema.
+- Optional ingestion-time image snapshots with full-page and compact presentation.
+- MagicMirror internal notification ingestion for weather and MMM-Remote-Control.
+- Toasts, semantic attention state, and optional MMM-pages channel routing.
+- Integration-neutral attention-event configuration with compatibility for the
+ former Seymour-named option.
+- Inbox UI with urgency styling, read-state transitions, history controls, and
+ MagicMirror locale and clock-format support.
+- Compact region-friendly presentation for standard MagicMirror layouts.
+- Unit tests, product roadmap, static UI preview, and live-test checklist.
+
+### Security
+
+- Webhook JSON size limits and optional bearer-token enforcement.
+- Explicit allowlisting for MMM-Remote-Control ingestion to avoid operational
+ traffic and recursive MessageCenter events.
+- Image download limits, timeouts, signature checks, bounded redirects, and
+ private-host/unencrypted-HTTP blocking by default.
+- Localhost-only webhook binding by default; LAN access now requires explicit
+ configuration.
+- Rolling image-cache limits preserve the newest 12 snapshots within a 12 MiB
+ decoded-byte budget while retaining older text history.
+- Non-touch presentation can hide all buttons and limit rendered messages
+ without deleting bounded queue history.
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..0f53322
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2026 Brian Wente
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/MMM-MessageCenter.css b/MMM-MessageCenter.css
index 4bc3275..061eda0 100644
--- a/MMM-MessageCenter.css
+++ b/MMM-MessageCenter.css
@@ -1,63 +1,332 @@
-.message-center {
+.messages-wrapper {
+ box-sizing: border-box;
width: min(720px, calc(100vw - 48px));
+ margin-inline: auto;
max-height: calc(100vh - 96px);
+ padding: 0 0.25rem 1rem;
overflow-y: auto;
color: #fff;
+ font-size: 1.1rem;
+ text-align: left;
+ scrollbar-width: thin;
+ scrollbar-color: rgba(255, 255, 255, 0.28) transparent;
}
-.message-center__heading {
- margin: 0 0 20px;
- font-size: 30px;
- font-weight: 400;
- text-align: left;
+.messages-header {
+ position: sticky;
+ top: 0;
+ z-index: 1;
+ display: flex;
+ gap: 1rem;
+ align-items: center;
+ justify-content: space-between;
+ padding: 0.75rem 0 1rem;
+ background: linear-gradient(
+ var(--message-center-header-background, transparent) 72%,
+ transparent
+ );
+}
+
+.messages-heading {
+ display: flex;
+ gap: 0.65rem;
+ align-items: center;
}
-.message-center__empty {
+.messages-title {
margin: 0;
- color: rgba(255, 255, 255, 0.65);
- text-align: left;
+ font-size: 1.65rem;
+ font-weight: 500;
+}
+
+.messages-count {
+ min-width: 1.6rem;
+ padding: 0.18rem 0.55rem;
+ border-radius: 999px;
+ background: rgba(255, 255, 255, 0.14);
+ font-size: 0.78rem;
+ line-height: 1.3;
+ text-align: center;
+}
+
+.messages-count.has-unread {
+ background: rgba(255, 190, 72, 0.18);
+ color: rgb(255, 205, 112);
+}
+
+.messages-controls {
+ display: flex;
+ gap: 0.5rem;
+ align-items: center;
+}
+
+.messages-acknowledge,
+.messages-clear-read {
+ min-height: 44px;
+ padding: 0.55rem 0.85rem;
+ border: 1px solid rgba(255, 255, 255, 0.2);
+ border-radius: 999px;
+ background: rgba(255, 255, 255, 0.08);
+ color: #fff;
+ font: inherit;
+ font-size: 0.86rem;
+ touch-action: manipulation;
+}
+
+.messages-clear-read {
+ color: rgba(255, 255, 255, 0.75);
+}
+
+.message-item {
+ position: relative;
+ margin-bottom: 0.65rem;
+ padding: 0.9rem 1rem;
+ border: 1px solid rgba(255, 255, 255, 0.1);
+ border-radius: 14px;
+ background: rgba(255, 255, 255, 0.045);
+ transition: border-color 220ms ease, background-color 220ms ease;
+}
+
+.message-item.unread {
+ background: rgba(255, 190, 72, 0.08);
+}
+
+.message-item.urgency-attention {
+ border-left: 4px solid rgba(255, 190, 72, 0.42);
+}
+
+.message-item.urgency-attention.unread {
+ border-left-color: rgba(255, 190, 72, 0.95);
}
-.message-center__list {
- display: grid;
- gap: 12px;
+.message-item.urgency-critical {
+ border-left: 4px solid rgba(255, 92, 92, 0.48);
+}
+
+.message-item.urgency-critical.unread {
+ border-left-color: rgba(255, 92, 92, 0.98);
+ background: rgba(255, 92, 92, 0.09);
+}
+
+.message-item.unread .message-title {
+ font-weight: 600;
+}
+
+.message-title {
margin: 0;
+ font-size: 1.2rem;
+}
+
+.message-unread-indicator {
+ padding: 0.15rem 0.45rem;
+ border-radius: 999px;
+ background: rgba(255, 190, 72, 0.18);
+ color: rgb(255, 205, 112);
+ font-size: 0.68rem;
+ font-weight: 600;
+ letter-spacing: 0.05em;
+ text-transform: uppercase;
+}
+
+.message-body {
+ margin: 0.35rem 0 0;
+ opacity: 0.85;
+}
+
+.message-image {
+ display: block;
+ width: 100%;
+ max-height: 280px;
+ margin-top: 0.65rem;
+ border-radius: 10px;
+ object-fit: contain;
+ object-position: center;
+}
+
+.message-meta {
+ display: flex;
+ gap: 0.75rem;
+ align-items: center;
+ justify-content: space-between;
+ margin: 0.35rem 0 0;
+ font-size: 0.8rem;
+ opacity: 0.62;
+}
+
+.message-source {
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.message-time {
+ flex: none;
+}
+
+.message-controls {
+ display: flex;
+ flex: none;
+ gap: 0.4rem;
+}
+
+.message-acknowledge,
+.message-dismiss {
+ min-height: 36px;
+ padding: 0.35rem 0.6rem;
+ border: 1px solid rgba(255, 255, 255, 0.18);
+ border-radius: 999px;
+ background: rgba(255, 255, 255, 0.07);
+ color: rgba(255, 255, 255, 0.82);
+ font: inherit;
+ font-size: 0.72rem;
+ touch-action: manipulation;
+}
+
+.message-dismiss {
+ color: rgba(255, 255, 255, 0.62);
+}
+
+.messages-empty {
+ padding: 4rem 1rem;
+ text-align: center;
+}
+
+.messages-empty-title {
+ margin: 0;
+ font-size: 1.35rem;
+ opacity: 0.82;
+}
+
+.messages-empty-detail {
+ margin: 0.45rem 0 0;
+ font-size: 0.9rem;
+ opacity: 0.5;
+}
+
+.messages-acknowledge:focus-visible,
+.messages-clear-read:focus-visible,
+.message-acknowledge:focus-visible,
+.message-dismiss:focus-visible {
+ outline: 3px solid rgba(255, 255, 255, 0.95);
+ outline-offset: 3px;
+}
+
+.messages-wrapper.messages-compact {
+ width: min(360px, 100%);
+ margin-inline: 0;
+ max-height: none;
padding: 0;
- list-style: none;
+ overflow: visible;
+ font-size: 0.9rem;
}
-.message-center__item {
- padding: 16px 18px;
- border-left: 4px solid rgba(255, 255, 255, 0.25);
- border-radius: 8px;
- background: rgba(0, 0, 0, 0.55);
- text-align: left;
+.messages-compact .messages-header {
+ position: static;
+ gap: 0.5rem;
+ padding: 0 0 0.45rem;
+ background: none;
}
-.message-center__item.is-unread {
- border-left-color: #ffb347;
- background: rgba(48, 31, 10, 0.72);
+.messages-compact .messages-heading {
+ gap: 0.45rem;
}
-.message-center__title,
-.message-center__body,
-.message-center__meta {
+.messages-compact .messages-title {
+ font-size: 1rem;
+ letter-spacing: 0.04em;
+ text-transform: uppercase;
+}
+
+.messages-compact .messages-count {
+ padding: 0.1rem 0.4rem;
+ font-size: 0.65rem;
+}
+
+.messages-compact .messages-controls {
+ gap: 0.3rem;
+}
+
+.messages-compact .messages-acknowledge,
+.messages-compact .messages-clear-read {
+ min-height: 32px;
+ padding: 0.3rem 0.55rem;
+ font-size: 0.7rem;
+}
+
+.messages-compact .message-item {
+ margin-bottom: 0.4rem;
+ padding: 0.55rem 0.65rem;
+ border-radius: 10px;
+}
+
+.messages-compact .message-title {
+ font-size: 1rem;
+}
+
+.messages-compact .message-unread-indicator {
+ padding: 0.08rem 0.3rem;
+ font-size: 0.58rem;
+}
+
+.messages-compact .message-body {
+ display: -webkit-box;
+ margin-top: 0.2rem;
+ overflow: hidden;
+ -webkit-box-orient: vertical;
+ -webkit-line-clamp: 2;
+}
+
+.messages-compact .message-image {
+ position: absolute;
+ top: 0.55rem;
+ right: 0.65rem;
+ width: 96px;
+ height: 64px;
margin: 0;
+ border-radius: 7px;
+ object-fit: cover;
}
-.message-center__title {
- font-size: 22px;
- font-weight: 500;
+.messages-compact .message-item.has-image .message-heading,
+.messages-compact .message-item.has-image .message-body,
+.messages-compact .message-item.has-image .message-meta {
+ padding-right: 104px;
+}
+
+.messages-compact .message-meta {
+ margin-top: 0.2rem;
+ font-size: 0.7rem;
+}
+
+.messages-compact .message-acknowledge,
+.messages-compact .message-dismiss {
+ min-height: 30px;
+ padding: 0.2rem 0.4rem;
+ font-size: 0.62rem;
}
-.message-center__body {
- margin-top: 6px;
- font-size: 18px;
- line-height: 1.35;
+.messages-compact .messages-empty {
+ padding: 0.75rem 0;
}
-.message-center__meta {
- margin-top: 10px;
- color: rgba(255, 255, 255, 0.55);
- font-size: 13px;
+.messages-compact .messages-empty-title {
+ font-size: 0.9rem;
+}
+
+@media (max-height: 640px) {
+ .messages-wrapper {
+ max-height: calc(100vh - 48px);
+ }
+
+ .messages-header {
+ padding-top: 0.4rem;
+ }
+
+ .messages-count {
+ font-size: 0.7rem;
+ }
+
+ .message-item {
+ padding-block: 0.7rem;
+ }
}
diff --git a/MMM-MessageCenter.js b/MMM-MessageCenter.js
index 702b172..71188ab 100644
--- a/MMM-MessageCenter.js
+++ b/MMM-MessageCenter.js
@@ -1,15 +1,86 @@
-/* global Module, Log */
+/* global Module, Log, config */
Module.register("MMM-MessageCenter", {
defaults: {
+ ui: "messages",
+ displayMode: "page",
+ maxVisibleMessages: null,
+ showControls: true,
+ compactMaxMessages: 3,
+ compactShowControls: false,
+ pages: true,
+ legacyAttentionEvents: true,
messagesPage: 4,
+ channelRoutes: {},
maxMessages: 50,
+ expirationSweepInterval: 60000,
+ publishAttentionState: true,
+ showHeader: true,
showToasts: true,
clearAttentionWhenViewed: true,
+ internalNotifications: {
+ enabled: true,
+ remoteControl: {
+ enabled: true,
+ mappings: {
+ MC_MESSAGE: {
+ mode: "message"
+ },
+ SHOW_ALERT: {
+ mode: "alert",
+ type: "remote.alert",
+ source: "magicmirror.remote-control",
+ urgency: "passive",
+ retention: "archive"
+ }
+ }
+ },
+ weather: {
+ enabled: false,
+ rain: {
+ enabled: true,
+ messageId: "rain-next-hour",
+ source: "magicmirror.weather",
+ entityId: "local-weather",
+ leadTimeMinutes: 60,
+ windowMinutes: 45,
+ probabilityThreshold: 50,
+ amountThreshold: 0.1,
+ urgency: "attention",
+ retention: "untilViewed",
+ channel: "weather",
+ timeout: 10000,
+ expiresAfterMinutes: 90
+ }
+ }
+ },
webhook: {
host: "127.0.0.1",
port: 8787,
token: ""
+ },
+ transports: {
+ mqtt: {
+ enabled: false,
+ url: "mqtt://127.0.0.1:1883",
+ topic: "messagecenter/messages",
+ username: "",
+ password: ""
+ },
+ unixSocket: {
+ enabled: false,
+ path: "/tmp/mmm-messagecenter.sock",
+ mode: 0o600
+ }
+ },
+ images: {
+ enabled: false,
+ maxBytes: 1024 * 1024,
+ maxCachedImages: 12,
+ maxTotalBytes: 12 * 1024 * 1024,
+ timeout: 5000,
+ allowPrivateHosts: false,
+ allowHttp: false
}
},
@@ -19,85 +90,363 @@ Module.register("MMM-MessageCenter", {
start() {
this.currentPage = null;
+ this.maxPages = null;
this.messages = [];
this.unreadAttentionCount = 0;
this.returnTimer = null;
+ this.autoNavigation = null;
+ this.expirationTimer = null;
+ this.pendingView = null;
- this.sendSocketNotification("MC_START", this.config.webhook);
+ this.sendSocketNotification("MC_START", {
+ webhook: this.config.webhook,
+ transports: this.config.transports,
+ images: this.config.images
+ });
this.sendNotification("QUERY_PAGE_NUMBER");
+ this.startExpirationTimer();
Log.info("[MMM-MessageCenter] Started");
},
stop() {
- this.clearReturnTimer();
+ this.cancelAutoNavigation();
+ this.cancelPendingView();
+ this.stopExpirationTimer();
this.sendSocketNotification("MC_STOP");
},
getDom() {
+ if (this.config.ui !== "messages") {
+ const hidden = document.createElement("div");
+ hidden.style.display = "none";
+ return hidden;
+ }
+
const wrapper = document.createElement("section");
- wrapper.className = "message-center";
+ wrapper.className = `messages-wrapper messages-${this.getDisplayMode()}`;
wrapper.setAttribute("aria-label", "Message center");
+ wrapper.setAttribute("aria-live", "polite");
- const heading = document.createElement("h2");
- heading.className = "message-center__heading";
- heading.textContent = "Messages";
- wrapper.appendChild(heading);
+ if (this.config.showHeader) {
+ wrapper.appendChild(this.getHeaderDom());
+ }
if (!this.messages.length) {
- const empty = document.createElement("p");
- empty.className = "message-center__empty";
- empty.textContent = "No messages";
+ const empty = document.createElement("div");
+ empty.className = "messages-empty";
+
+ const emptyTitle = document.createElement("p");
+ emptyTitle.className = "messages-empty-title";
+ emptyTitle.textContent = "You’re all caught up";
+ empty.appendChild(emptyTitle);
+
+ if (this.getDisplayMode() !== "compact") {
+ const emptyDetail = document.createElement("p");
+ emptyDetail.className = "messages-empty-detail";
+ emptyDetail.textContent = "New household messages will appear here.";
+ empty.appendChild(emptyDetail);
+ }
+
wrapper.appendChild(empty);
return wrapper;
}
- const list = document.createElement("ol");
- list.className = "message-center__list";
+ this.getDisplayedMessages().forEach((message) => {
+ const item = document.createElement("article");
+ item.className =
+ `message-item urgency-${message.urgency}` +
+ `${message.unread ? " unread" : ""}${message.image ? " has-image" : ""}`;
- this.messages.forEach((message) => {
- const item = document.createElement("li");
- item.className = `message-center__item${message.unread ? " is-unread" : ""}`;
+ const heading = document.createElement("div");
+ heading.className = "message-heading";
const title = document.createElement("h3");
- title.className = "message-center__title";
+ title.className = "message-title";
title.textContent = message.title;
- item.appendChild(title);
+ heading.appendChild(title);
+
+ if (message.unread) {
+ const unread = document.createElement("span");
+ unread.className = "message-unread-indicator";
+ unread.textContent = "New";
+ heading.appendChild(unread);
+ }
+
+ item.appendChild(heading);
if (message.body) {
const body = document.createElement("p");
- body.className = "message-center__body";
+ body.className = "message-body";
body.textContent = message.body;
item.appendChild(body);
}
- const meta = document.createElement("p");
- meta.className = "message-center__meta";
- meta.textContent = `${message.source} · ${new Date(message.timestamp).toLocaleString()}`;
+ if (message.image) {
+ const image = document.createElement("img");
+ image.className = "message-image";
+ image.src = message.image.dataUrl;
+ image.alt = message.image.alt;
+ image.loading = "eager";
+ image.decoding = "async";
+ item.appendChild(image);
+ }
+
+ const meta = document.createElement("div");
+ meta.className = "message-meta";
+
+ const source = document.createElement("span");
+ source.className = "message-source";
+ source.textContent = this.getMessageSourceLabel(message.source);
+ meta.appendChild(source);
+
+ const timestamp = document.createElement("time");
+ const date = new Date(message.timestamp);
+ timestamp.className = "message-time";
+ timestamp.dateTime = date.toISOString();
+ timestamp.textContent = this.formatDisplayedTimestamp(date);
+ meta.appendChild(timestamp);
+
+ if (this.shouldShowMessageControls()) {
+ const controls = document.createElement("div");
+ controls.className = "message-controls";
+
+ if (message.unread) {
+ const acknowledge = document.createElement("button");
+ acknowledge.className = "message-acknowledge";
+ acknowledge.type = "button";
+ acknowledge.textContent = "Mark read";
+ acknowledge.setAttribute("aria-label", `Mark ${message.title} read`);
+ acknowledge.addEventListener("click", () => {
+ this.acknowledgeMessage(message.source, message.id);
+ });
+ controls.appendChild(acknowledge);
+ }
+
+ const dismiss = document.createElement("button");
+ dismiss.className = "message-dismiss";
+ dismiss.type = "button";
+ dismiss.textContent = "Dismiss";
+ dismiss.setAttribute("aria-label", `Dismiss ${message.title}`);
+ dismiss.addEventListener("click", () => {
+ this.dismissMessage(message.source, message.id);
+ });
+ controls.appendChild(dismiss);
+ meta.appendChild(controls);
+ }
+
item.appendChild(meta);
- list.appendChild(item);
+ wrapper.appendChild(item);
});
- wrapper.appendChild(list);
return wrapper;
},
- notificationReceived(notification, payload) {
+ getHeaderDom() {
+ const header = document.createElement("header");
+ header.className = "messages-header";
+
+ const heading = document.createElement("div");
+ heading.className = "messages-heading";
+
+ const title = document.createElement("h2");
+ title.className = "messages-title";
+ title.textContent = "Messages";
+ heading.appendChild(title);
+
+ const count = document.createElement("span");
+ count.className = "messages-count";
+ const counts = this.getMessageCounts();
+ count.textContent = counts.unread
+ ? `${counts.unread} new · ${counts.total} total`
+ : `${counts.total} ${counts.total === 1 ? "message" : "messages"}`;
+ count.setAttribute(
+ "aria-label",
+ counts.unread
+ ? `${counts.unread} unread, ${counts.total} total messages`
+ : `${counts.total} total messages, none unread`
+ );
+ if (counts.unread) count.classList.add("has-unread");
+ heading.appendChild(count);
+ header.appendChild(heading);
+
+ const showControls = this.shouldShowMessageControls();
+ const controls = document.createElement("div");
+ controls.className = "messages-controls";
+
+ if (showControls && counts.unread > 0) {
+ const acknowledge = document.createElement("button");
+ acknowledge.className = "messages-acknowledge";
+ acknowledge.type = "button";
+ acknowledge.textContent = "Mark all read";
+ acknowledge.addEventListener("click", () => this.clearAttention());
+ controls.appendChild(acknowledge);
+ }
+
+ if (showControls && counts.read > 0) {
+ const clearRead = document.createElement("button");
+ clearRead.className = "messages-clear-read";
+ clearRead.type = "button";
+ clearRead.textContent = "Clear read";
+ clearRead.addEventListener("click", () => this.clearRead());
+ controls.appendChild(clearRead);
+ }
+
+ if (controls.childNodes.length) header.appendChild(controls);
+
+ return header;
+ },
+
+ getMessageCounts() {
+ const unread = this.messages.filter((message) => message.unread).length;
+ return {
+ total: this.messages.length,
+ unread,
+ read: this.messages.length - unread
+ };
+ },
+
+ getDisplayMode() {
+ return this.config.displayMode === "compact" ? "compact" : "page";
+ },
+
+ getDisplayedMessages() {
+ const configuredLimit = Number.isInteger(this.config.maxVisibleMessages) &&
+ this.config.maxVisibleMessages > 0
+ ? this.config.maxVisibleMessages
+ : null;
+ if (configuredLimit === null && this.getDisplayMode() !== "compact") {
+ return this.messages;
+ }
+ const limit = configuredLimit || (
+ Number.isInteger(this.config.compactMaxMessages) &&
+ this.config.compactMaxMessages > 0
+ ? this.config.compactMaxMessages
+ : this.defaults.compactMaxMessages
+ );
+ return this.messages.slice(0, limit);
+ },
+
+ shouldShowMessageControls() {
+ if (this.config.showControls === false) return false;
+ return this.getDisplayMode() !== "compact" || this.config.compactShowControls === true;
+ },
+
+ getMessageSourceLabel(source) {
+ const labels = {
+ "magicmirror.weather": "Weather",
+ "magicmirror.remote-control": "Remote Control",
+ "home-assistant": "Home Assistant",
+ "home-assistant.smartthings": "SmartThings via Home Assistant",
+ smartthings: "SmartThings"
+ };
+ return labels[source] || source;
+ },
+
+ getGlobalDateTimePreferences() {
+ const globalConfig =
+ typeof config !== "undefined" &&
+ config &&
+ typeof config === "object" &&
+ !Array.isArray(config)
+ ? config
+ : {};
+ const timeFormat =
+ globalConfig.timeFormat === 12 || globalConfig.timeFormat === 24
+ ? globalConfig.timeFormat
+ : null;
+ const localeCandidates = [globalConfig.locale, globalConfig.language];
+ let locale;
+
+ for (const candidate of localeCandidates) {
+ if (typeof candidate !== "string" || !candidate.trim()) continue;
+ try {
+ new Intl.DateTimeFormat(candidate);
+ locale = candidate;
+ break;
+ } catch (_error) {
+ // Try the next configured preference, then the browser default.
+ }
+ }
+
+ return { locale, timeFormat };
+ },
+
+ formatMessageTimestamp(value) {
+ return this.formatDateTimeValue(value, true);
+ },
+
+ formatClockTime(value) {
+ return this.formatDateTimeValue(value, false);
+ },
+
+ formatDisplayedTimestamp(value) {
+ return this.getDisplayMode() === "compact"
+ ? this.formatClockTime(value)
+ : this.formatMessageTimestamp(value);
+ },
+
+ formatDateTimeValue(value, includeDate) {
+ const date = value instanceof Date ? value : new Date(value);
+ if (Number.isNaN(date.getTime())) return "";
+
+ const { locale, timeFormat } = this.getGlobalDateTimePreferences();
+ const options = {
+ ...(includeDate
+ ? {
+ year: "numeric",
+ month: "numeric",
+ day: "numeric"
+ }
+ : {}),
+ hour: "numeric",
+ minute: "2-digit",
+ ...(includeDate ? { second: "2-digit" } : {}),
+ ...(timeFormat === 12
+ ? { hourCycle: "h12" }
+ : timeFormat === 24
+ ? { hourCycle: "h23" }
+ : {})
+ };
+
+ try {
+ return new Intl.DateTimeFormat(locale, options).format(date);
+ } catch (_error) {
+ return includeDate ? date.toLocaleString() : date.toLocaleTimeString();
+ }
+ },
+
+ notificationReceived(notification, payload, sender) {
+ if (notification === "MAX_PAGES_CHANGED") {
+ if (Number.isInteger(payload) && payload >= 0) this.maxPages = payload;
+ return;
+ }
+
if (notification === "NEW_PAGE" || notification === "PAGE_NUMBER_IS") {
if (!Number.isInteger(payload) || payload < 0) return;
this.currentPage = payload;
+ if (this.autoNavigation && payload !== this.autoNavigation.targetPage) {
+ this.cancelAutoNavigation();
+ }
+ if (payload !== this.resolvePageTarget("messages")) {
+ this.cancelPendingView();
+ }
if (
- payload === this.config.messagesPage &&
+ payload === this.resolvePageTarget("messages") &&
this.config.clearAttentionWhenViewed &&
this.unreadAttentionCount > 0
) {
- this.clearAttention();
+ this.scheduleMarkViewed();
}
return;
}
if (notification === "MC_ACK_ALL") this.clearAttention();
+ if (notification === "MC_ACK_MESSAGE") this.handleMessageCommand(payload, "acknowledge");
+ if (notification === "MC_DISMISS_MESSAGE") this.handleMessageCommand(payload, "dismiss");
+ if (notification === "MC_CLEAR_READ") this.clearRead();
if (notification === "MC_CLEAR_ALL") this.clearMessages();
+ this.handleInternalNotification(notification, payload, sender);
},
socketNotificationReceived(notification, payload) {
@@ -107,71 +456,511 @@ Module.register("MMM-MessageCenter", {
}
if (notification !== "MC_MESSAGE") return;
+ this.receiveMessage(payload);
+ },
+ receiveMessage(payload, options = {}) {
const message = this.normalizeMessage(payload);
if (!message) {
Log.warn("[MMM-MessageCenter] Ignored invalid or expired message");
- return;
+ return false;
}
- this.messages.unshift(message);
- this.messages = this.messages.slice(0, this.getMaxMessages());
+ const previousAttentionState = this.getAttentionState();
+ let inboxChanged = false;
+ const duplicateIndex = message.hasExplicitId
+ ? this.messages.findIndex(
+ (stored) =>
+ stored.hasExplicitId &&
+ stored.source === message.source &&
+ stored.id === message.id
+ )
+ : -1;
+ if (duplicateIndex !== -1) {
+ const duplicate = this.messages[duplicateIndex];
+ if (duplicate.timestamp > message.timestamp) {
+ Log.info(`[MMM-MessageCenter] Ignored stale update ${message.source}/${message.id}`);
+ return false;
+ }
+ if (this.isEquivalentMessage(duplicate, message)) {
+ Log.info(`[MMM-MessageCenter] Ignored duplicate ${message.source}/${message.id}`);
+ return false;
+ }
+ this.messages.splice(duplicateIndex, 1);
+ inboxChanged = true;
+ }
- if (message.priority === "attention") {
- this.unreadAttentionCount += 1;
- this.sendNotification("ATTENTION_ON", this.unreadAttentionCount);
+ if (message.retention !== "ephemeral") {
+ this.messages.unshift(message);
+ this.messages.sort((left, right) => right.timestamp - left.timestamp);
+ this.messages = this.messages.slice(0, this.getMaxMessages());
+ this.pruneCachedImages();
+ inboxChanged = true;
}
+ if (inboxChanged) this.publishAttention(previousAttentionState);
- if (this.config.showToasts) {
+ if (this.config.showToasts && options.showToast !== false) {
this.sendNotification("SHOW_ALERT", {
type: "notification",
title: message.title,
message: message.body,
- timer: message.priority === "attention" ? 6000 : 4000
+ timer: message.urgency === "critical"
+ ? 8000
+ : message.urgency === "attention"
+ ? 6000
+ : 4000
});
}
- this.handlePageAction(message.actions);
+ if (this.config.pages) this.handlePageAction(message.actions);
this.updateDom(200);
+ return true;
},
- handlePageAction(actions) {
- if (!actions || !Number.isInteger(actions.switchChannel) || actions.switchChannel < 0) {
- return;
+ handleInternalNotification(notification, payload, sender) {
+ const internalConfig = this.config.internalNotifications;
+ if (!internalConfig || internalConfig.enabled === false) return false;
+
+ if (this.isRemoteControlSender(sender)) {
+ return this.handleRemoteControlNotification(notification, payload);
}
- const returnPage = this.currentPage;
- this.sendNotification("PAGE_CHANGED", actions.switchChannel);
+ if (notification === "WEATHER_UPDATED") {
+ return this.handleWeatherUpdated(payload);
+ }
+ return false;
+ },
- if (!Number.isFinite(actions.timeout) || actions.timeout <= 0 || returnPage === null) {
- return;
+ isRemoteControlSender(sender) {
+ if (!sender || typeof sender !== "object") return false;
+ return (
+ sender.name === "MMM-Remote-Control" ||
+ sender.data?.module === "MMM-Remote-Control"
+ );
+ },
+
+ getRemoteControlNotificationConfig() {
+ const defaults = this.defaults.internalNotifications.remoteControl;
+ const configured = this.config.internalNotifications?.remoteControl || {};
+ return {
+ ...defaults,
+ ...configured,
+ mappings: configured.mappings === undefined
+ ? defaults.mappings
+ : configured.mappings
+ };
+ },
+
+ handleRemoteControlNotification(notification, payload) {
+ const config = this.getRemoteControlNotificationConfig();
+ if (
+ !config.enabled ||
+ !config.mappings ||
+ typeof config.mappings !== "object" ||
+ Array.isArray(config.mappings) ||
+ !Object.prototype.hasOwnProperty.call(config.mappings, notification)
+ ) {
+ return false;
}
+ const mapping = config.mappings[notification];
+ if (!mapping || typeof mapping !== "object" || Array.isArray(mapping)) return false;
+ if (!payload || typeof payload !== "object" || Array.isArray(payload)) return false;
+
+ if (mapping.mode === "message") {
+ return this.receiveMessage({
+ ...payload,
+ source: payload.source || "magicmirror.remote-control"
+ });
+ }
+
+ if (mapping.mode !== "alert") return false;
+ const title = payload.title;
+ const body = payload.message ?? payload.body;
+ if (
+ (typeof title !== "string" || !title.trim()) &&
+ (typeof body !== "string" || !body.trim())
+ ) {
+ return false;
+ }
+
+ this.receiveMessage(
+ {
+ id: payload.id,
+ type: mapping.type || "remote.alert",
+ source: mapping.source || "magicmirror.remote-control",
+ entityId: payload.entityId,
+ title: typeof title === "string" && title.trim() ? title : "Remote alert",
+ body: typeof body === "string" ? body : "",
+ urgency: mapping.urgency,
+ retention: mapping.retention,
+ expires: payload.expires,
+ actions: mapping.actions
+ },
+ { showToast: false }
+ );
+ return true;
+ },
+
+ handleWeatherUpdated(payload, now = Date.now()) {
+ const weatherConfig = this.getWeatherNotificationConfig();
+ if (!weatherConfig.enabled || !weatherConfig.rain.enabled) return false;
+ if (!payload || !Array.isArray(payload.hourlyArray) || !payload.hourlyArray.length) {
+ return false;
+ }
+
+ const rain = weatherConfig.rain;
+ const forecast = this.findRainForecast(payload.hourlyArray, now, rain);
+ if (!forecast) {
+ return this.resolveMessage(rain.source, rain.messageId);
+ }
+
+ const alreadyTracked = this.messages.some(
+ (message) => message.source === rain.source && message.id === rain.messageId
+ );
+ if (alreadyTracked) return true;
+
+ const location = payload.locationName ? ` near ${String(payload.locationName)}` : "";
+ const forecastTime = this.formatClockTime(forecast.timestamp);
+ this.receiveMessage({
+ id: rain.messageId,
+ type: "weather.precipitation",
+ source: rain.source,
+ entityId: rain.entityId,
+ title: "Rain approaching",
+ body: `Rain is expected${location} around ${forecastTime}.`,
+ urgency: rain.urgency,
+ retention: rain.retention,
+ timestamp: now,
+ expires: forecast.timestamp + rain.expiresAfterMinutes * 60000,
+ actions: {
+ switchChannel: rain.channel,
+ timeout: rain.timeout
+ }
+ });
+ return true;
+ },
+
+ getWeatherNotificationConfig() {
+ const defaults = this.defaults.internalNotifications.weather;
+ const configured = this.config.internalNotifications?.weather || {};
+ return {
+ ...defaults,
+ ...configured,
+ rain: {
+ ...defaults.rain,
+ ...(configured.rain || {})
+ }
+ };
+ },
+
+ findRainForecast(hourlyArray, now, config) {
+ const target = now + config.leadTimeMinutes * 60000;
+ const tolerance = config.windowMinutes * 60000;
+ return hourlyArray
+ .map((entry) => ({
+ entry,
+ timestamp: this.getWeatherTimestamp(entry?.date)
+ }))
+ .filter(
+ ({ entry, timestamp }) =>
+ timestamp !== null &&
+ timestamp > now &&
+ Math.abs(timestamp - target) <= tolerance &&
+ this.isRainForecast(entry, config)
+ )
+ .sort((left, right) =>
+ Math.abs(left.timestamp - target) - Math.abs(right.timestamp - target)
+ )[0] || null;
+ },
+
+ getWeatherTimestamp(value) {
+ if (Number.isFinite(value)) return value;
+ const parsed = new Date(value).getTime();
+ return Number.isNaN(parsed) ? null : parsed;
+ },
+
+ isRainForecast(entry, config) {
+ if (!entry || typeof entry !== "object") return false;
+ const number = (value) => {
+ const parsed = Number(value);
+ return Number.isFinite(parsed) ? parsed : null;
+ };
+ const probability = number(entry.precipitationProbability);
+ const rain = number(entry.rain);
+ const snow = number(entry.snow);
+ const amount = number(entry.precipitationAmount);
+ const weatherType = String(entry.weatherType || "").toLowerCase();
+ const rainType = /(rain|shower|drizzle|thunderstorm)/.test(weatherType);
+ const snowOnly = /(snow|sleet|ice)/.test(weatherType) && !rainType;
+ const hasRainAmount =
+ (rain !== null && rain >= config.amountThreshold) ||
+ (!snowOnly &&
+ (snow === null || snow <= 0) &&
+ amount !== null &&
+ amount >= config.amountThreshold);
+ const likelyEnough = probability === null || probability >= config.probabilityThreshold;
+ return hasRainAmount || (rainType && likelyEnough);
+ },
+
+ handlePageAction(actions) {
+ if (!actions) return;
+ const targetPage = this.resolvePageTarget(actions.switchChannel);
+ if (!this.isValidPage(targetPage)) return;
+
+ const hasTimedReturn = Number.isFinite(actions.timeout) && actions.timeout > 0;
+ const returnPage = this.autoNavigation
+ ? this.autoNavigation.returnPage
+ : this.currentPage;
+
this.clearReturnTimer();
+ this.autoNavigation = hasTimedReturn
+ ? { targetPage, returnPage }
+ : null;
+ this.sendNotification("PAGE_CHANGED", targetPage);
+
+ if (!hasTimedReturn || returnPage === null || returnPage === targetPage) {
+ return;
+ }
+
this.returnTimer = setTimeout(() => {
this.returnTimer = null;
- this.sendNotification("PAGE_CHANGED", returnPage);
+ const navigation = this.autoNavigation;
+ this.autoNavigation = null;
+ if (
+ navigation &&
+ this.currentPage === navigation.targetPage &&
+ this.isValidPage(navigation.returnPage)
+ ) {
+ this.sendNotification("PAGE_CHANGED", navigation.returnPage);
+ }
}, actions.timeout);
},
+ cancelAutoNavigation() {
+ this.clearReturnTimer();
+ this.autoNavigation = null;
+ },
+
+ isValidPage(page) {
+ return (
+ this.maxPages !== null &&
+ Number.isInteger(page) &&
+ page >= 0 &&
+ page < this.maxPages
+ );
+ },
+
+ resolvePageTarget(target) {
+ if (Number.isInteger(target)) return target;
+ if (typeof target !== "string" || !target.trim()) return null;
+
+ const name = target.trim();
+ if (name === "messages") return this.config.messagesPage;
+
+ const routes = this.config.channelRoutes;
+ if (!routes || typeof routes !== "object" || Array.isArray(routes)) return null;
+ return Object.prototype.hasOwnProperty.call(routes, name) && Number.isInteger(routes[name])
+ ? routes[name]
+ : null;
+ },
+
clearReturnTimer() {
if (!this.returnTimer) return;
clearTimeout(this.returnTimer);
this.returnTimer = null;
},
+ startExpirationTimer() {
+ this.stopExpirationTimer();
+ const configuredInterval = Number(this.config.expirationSweepInterval);
+ if (!Number.isFinite(configuredInterval) || configuredInterval <= 0) return;
+
+ this.expirationTimer = setInterval(
+ () => this.pruneExpiredMessages(),
+ Math.max(1000, configuredInterval)
+ );
+ },
+
+ stopExpirationTimer() {
+ if (!this.expirationTimer) return;
+ clearInterval(this.expirationTimer);
+ this.expirationTimer = null;
+ },
+
+ pruneExpiredMessages(now = Date.now()) {
+ const previousAttentionState = this.getAttentionState();
+ const retained = this.messages.filter(
+ (message) => message.expires === null || message.expires > now
+ );
+ if (retained.length === this.messages.length) return false;
+
+ this.messages = retained;
+ this.publishAttention(previousAttentionState);
+ this.updateDom(200);
+ return true;
+ },
+
+ getAttentionState() {
+ const unreadMessages = this.messages.filter((message) => message.unread);
+ const sources = [...new Set(unreadMessages.map((message) => message.source))];
+ const highestUrgency = unreadMessages.some(
+ (message) => message.urgency === "critical"
+ )
+ ? "critical"
+ : unreadMessages.length
+ ? "attention"
+ : "passive";
+
+ return {
+ active: unreadMessages.length > 0,
+ unreadCount: unreadMessages.length,
+ highestPriority: highestUrgency,
+ highestUrgency,
+ sources
+ };
+ },
+
+ publishAttention(previousState = null) {
+ const state = this.getAttentionState();
+ this.unreadAttentionCount = state.unreadCount;
+
+ const changed =
+ !previousState ||
+ previousState.active !== state.active ||
+ previousState.unreadCount !== state.unreadCount ||
+ previousState.highestPriority !== state.highestPriority ||
+ previousState.sources.join("\u0000") !== state.sources.join("\u0000");
+ if (!changed) return;
+
+ if (this.config.publishAttentionState !== false) {
+ this.sendNotification("MESSAGE_CENTER_ATTENTION_CHANGED", state);
+ }
+
+ if (this.shouldPublishLegacyAttentionEvents()) {
+ if (state.active) this.sendNotification("ATTENTION_ON", state.unreadCount);
+ else if (previousState && previousState.active) this.sendNotification("ATTENTION_OFF");
+ }
+ },
+
+ shouldPublishLegacyAttentionEvents() {
+ if (typeof this.config.attention === "string") {
+ return this.config.attention === "seymour";
+ }
+ return this.config.legacyAttentionEvents !== false;
+ },
+
clearAttention() {
- this.unreadAttentionCount = 0;
+ const previousAttentionState = this.getAttentionState();
this.messages.forEach((message) => {
message.unread = false;
});
- this.sendNotification("ATTENTION_OFF");
+ this.publishAttention(previousAttentionState);
+ this.updateDom(200);
+ },
+
+ handleMessageCommand(payload, action) {
+ if (!payload || typeof payload !== "object" || Array.isArray(payload)) return false;
+ if (payload.source === undefined || payload.id === undefined) return false;
+ return action === "acknowledge"
+ ? this.acknowledgeMessage(String(payload.source), String(payload.id))
+ : this.dismissMessage(String(payload.source), String(payload.id));
+ },
+
+ acknowledgeMessage(source, id) {
+ const message = this.messages.find(
+ (candidate) => candidate.source === source && candidate.id === id
+ );
+ if (!message || !message.unread) return false;
+
+ const previousAttentionState = this.getAttentionState();
+ message.unread = false;
+ this.publishAttention(previousAttentionState);
+ this.updateDom(200);
+ return true;
+ },
+
+ dismissMessage(source, id) {
+ return this.resolveMessage(source, id);
+ },
+
+ scheduleMarkViewed() {
+ this.cancelPendingView();
+ this.updateDom(0);
+
+ const finish = () => {
+ this.pendingView = null;
+ if (this.currentPage === this.resolvePageTarget("messages")) {
+ this.markViewed();
+ }
+ };
+
+ if (
+ typeof requestAnimationFrame === "function" &&
+ typeof cancelAnimationFrame === "function"
+ ) {
+ const pending = { type: "frame", ids: [] };
+ this.pendingView = pending;
+ pending.ids.push(
+ requestAnimationFrame(() => {
+ pending.ids.push(requestAnimationFrame(finish));
+ })
+ );
+ return;
+ }
+
+ this.pendingView = {
+ type: "timer",
+ id: setTimeout(finish, 0)
+ };
+ },
+
+ cancelPendingView() {
+ if (!this.pendingView) return;
+ if (this.pendingView.type === "frame") {
+ this.pendingView.ids.forEach((id) => cancelAnimationFrame(id));
+ } else {
+ clearTimeout(this.pendingView.id);
+ }
+ this.pendingView = null;
+ },
+
+ clearRead() {
+ const retained = this.messages.filter((message) => message.unread);
+ if (retained.length === this.messages.length) return false;
+ this.messages = retained;
+ this.updateDom(200);
+ return true;
+ },
+
+ markViewed() {
+ const previousAttentionState = this.getAttentionState();
+ this.messages.forEach((message) => {
+ if (message.retention !== "untilAcknowledged") message.unread = false;
+ });
+ this.publishAttention(previousAttentionState);
this.updateDom(200);
},
+ resolveMessage(source, id) {
+ const previousAttentionState = this.getAttentionState();
+ const retained = this.messages.filter(
+ (message) => message.source !== source || message.id !== id
+ );
+ if (retained.length === this.messages.length) return false;
+
+ this.messages = retained;
+ this.publishAttention(previousAttentionState);
+ this.updateDom(200);
+ return true;
+ },
+
clearMessages() {
+ this.cancelAutoNavigation();
+ const previousAttentionState = this.getAttentionState();
this.messages = [];
- this.clearAttention();
+ this.publishAttention(previousAttentionState);
+ this.updateDom(200);
},
getMaxMessages() {
@@ -180,28 +969,136 @@ Module.register("MMM-MessageCenter", {
: this.defaults.maxMessages;
},
+ getImageCacheLimits() {
+ const configured = this.config.images && typeof this.config.images === "object"
+ ? this.config.images
+ : {};
+ return {
+ maxCachedImages: Number.isInteger(configured.maxCachedImages) &&
+ configured.maxCachedImages >= 0
+ ? configured.maxCachedImages
+ : this.defaults.images.maxCachedImages,
+ maxTotalBytes: Number.isInteger(configured.maxTotalBytes) &&
+ configured.maxTotalBytes >= 0
+ ? configured.maxTotalBytes
+ : this.defaults.images.maxTotalBytes
+ };
+ },
+
+ getCachedImageBytes(image) {
+ if (!image || typeof image.dataUrl !== "string") return 0;
+ const separator = image.dataUrl.indexOf(",");
+ if (separator === -1) return 0;
+ const encoded = image.dataUrl.slice(separator + 1);
+ const padding = encoded.endsWith("==") ? 2 : encoded.endsWith("=") ? 1 : 0;
+ return Math.max(0, Math.floor(encoded.length * 3 / 4) - padding);
+ },
+
+ pruneCachedImages() {
+ const { maxCachedImages, maxTotalBytes } = this.getImageCacheLimits();
+ let retainedCount = 0;
+ let retainedBytes = 0;
+ let removed = 0;
+
+ this.messages.forEach((message) => {
+ if (!message.image) return;
+ const imageBytes = this.getCachedImageBytes(message.image);
+ if (
+ retainedCount >= maxCachedImages ||
+ retainedBytes + imageBytes > maxTotalBytes
+ ) {
+ message.image = null;
+ removed += 1;
+ return;
+ }
+ retainedCount += 1;
+ retainedBytes += imageBytes;
+ });
+ return removed;
+ },
+
+ isEquivalentMessage(left, right) {
+ return (
+ left.title === right.title &&
+ left.body === right.body &&
+ left.type === right.type &&
+ left.entityId === right.entityId &&
+ left.priority === right.priority &&
+ left.urgency === right.urgency &&
+ left.retention === right.retention &&
+ left.expires === right.expires &&
+ JSON.stringify(left.image) === JSON.stringify(right.image) &&
+ JSON.stringify(left.actions) === JSON.stringify(right.actions)
+ );
+ },
+
normalizeMessage(raw) {
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return null;
const now = Date.now();
- const timestamp = Number.isFinite(raw.timestamp) ? raw.timestamp : now;
+ const candidateTimestamp = Number.isFinite(raw.timestamp) ? raw.timestamp : now;
+ const timestamp = Number.isNaN(new Date(candidateTimestamp).getTime())
+ ? now
+ : candidateTimestamp;
const expires = Number.isFinite(raw.expires) ? raw.expires : null;
if (expires !== null && expires <= now) return null;
- const priority = raw.priority === "attention" ? "attention" : "ephemeral";
+ const legacyPriority = raw.priority === "attention" ? "attention" : "ephemeral";
+ const urgencyValues = ["passive", "attention", "critical"];
+ const urgency = urgencyValues.includes(raw.urgency)
+ ? raw.urgency
+ : legacyPriority === "attention"
+ ? "attention"
+ : "passive";
+ const retentionValues = ["ephemeral", "untilViewed", "untilAcknowledged", "archive"];
+ const retention = retentionValues.includes(raw.retention)
+ ? raw.retention
+ : urgency === "critical"
+ ? "untilAcknowledged"
+ : urgency === "attention"
+ ? "untilViewed"
+ : "archive";
+ const priority = urgency === "passive" ? "ephemeral" : "attention";
const actions = raw.actions && typeof raw.actions === "object" ? raw.actions : {};
+ const image = this.normalizeImage(raw.image);
+
+ const hasExplicitId = raw.id !== undefined && raw.id !== null && raw.id !== "";
return {
- id: String(raw.id || `${now}`),
+ id: hasExplicitId ? String(raw.id) : `${now}`,
+ hasExplicitId,
type: String(raw.type || "generic"),
source: String(raw.source || "unknown"),
+ entityId: raw.entityId === undefined || raw.entityId === null || raw.entityId === ""
+ ? null
+ : String(raw.entityId),
title: String(raw.title || "Message"),
body: String(raw.body || ""),
priority,
+ urgency,
+ retention,
timestamp,
- unread: priority === "attention",
+ unread: urgency !== "passive" && retention !== "ephemeral",
expires,
- actions
+ actions,
+ image
+ };
+ },
+
+ normalizeImage(raw) {
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) return null;
+ if (
+ typeof raw.dataUrl !== "string" ||
+ !/^data:image\/(?:jpeg|png|webp);base64,[A-Za-z0-9+/=]+$/.test(raw.dataUrl)
+ ) {
+ return null;
+ }
+ return {
+ dataUrl: raw.dataUrl,
+ alt: typeof raw.alt === "string" && raw.alt.trim()
+ ? raw.alt.trim().slice(0, 240)
+ : "Message snapshot",
+ capturedAt: Number.isFinite(raw.capturedAt) ? raw.capturedAt : null
};
}
});
diff --git a/README.md b/README.md
index 0c96ed8..d05effc 100644
--- a/README.md
+++ b/README.md
@@ -1,10 +1,32 @@
# MMM-MessageCenter
-MMM-MessageCenter is a webhook-driven message inbox for
-[MagicMirror²](https://magicmirror.builders/) installations using
-[MMM-pages](https://github.com/edward-shen/MMM-pages). It stores recent messages,
-shows optional toast alerts, raises an attention notification, and can temporarily
-switch pages for important events.
+MMM-MessageCenter is a centralized notification hub for
+[MagicMirror²](https://magicmirror.builders/). It receives, normalizes,
+prioritizes, and displays messages from Home Assistant, MagicMirror modules, and
+external systems so an installation can provide one calm, consistent
+notification experience.
+
+The project is intended to make MagicMirror behave less like a collection of
+dashboard widgets and more like an ambient information appliance. The current
+release provides a webhook, an in-memory message queue, toast alerts, semantic
+attention notifications, an optional inbox, and optional page routing through
+[MMM-pages](https://github.com/edward-shen/MMM-pages).
+
+MessageCenter does not control LEDs, speakers, GPIO, or other hardware. It
+publishes message and attention intent; integrations such as MMM-Seymour decide
+how that intent should be presented.
+
+## Design principles
+
+- One normalized entry point for household notifications.
+- Hardware-independent and usable by vanilla MagicMirror installations.
+- An optional UI rather than a required presentation layer.
+- Semantic events instead of device-specific commands.
+- Calm interactions that do not fight manual navigation.
+- A stable core message schema that can gain providers and presentation adapters.
+
+See the [living product roadmap](docs/ROADMAP.md) for the boundary between
+current behavior, near-term work, and exploratory ideas.
## Installation
@@ -18,20 +40,88 @@ npm install --omit=dev
## Configuration
+### Standard MagicMirror region
+
+Compact mode is designed for an ordinary MagicMirror region and does not
+require MMM-pages, Seymour, or any hardware integration:
+
+```js
+{
+ module: "MMM-MessageCenter",
+ position: "top_right",
+ config: {
+ displayMode: "compact",
+ compactMaxMessages: 3,
+ pages: false
+ }
+}
+```
+
+It shows the newest messages in a narrow region, uses time-only metadata, and
+keeps the complete bounded queue available internally. History buttons are
+hidden by default in compact mode; set `compactShowControls: true` for touch or
+interactive browser installations. Other modules can always use `MC_ACK_ALL`,
+`MC_CLEAR_READ`, and `MC_CLEAR_ALL`.
+
+### Full-page inbox
+
```js
{
module: "MMM-MessageCenter",
position: "middle_center",
classes: "message-center-page",
config: {
+ ui: "messages",
+ displayMode: "page",
+ maxVisibleMessages: null,
+ showControls: true,
+ pages: true,
+ legacyAttentionEvents: true,
messagesPage: 4,
+ channelRoutes: {
+ weather: 1
+ },
maxMessages: 50,
+ expirationSweepInterval: 60000,
+ publishAttentionState: true,
+ showHeader: true,
showToasts: true,
clearAttentionWhenViewed: true,
+ internalNotifications: {
+ remoteControl: {
+ enabled: true
+ },
+ weather: {
+ enabled: true
+ }
+ },
webhook: {
host: "127.0.0.1",
port: 8787,
token: ""
+ },
+ transports: {
+ mqtt: {
+ enabled: false,
+ url: "mqtt://127.0.0.1:1883",
+ topic: "messagecenter/messages",
+ username: "",
+ password: ""
+ },
+ unixSocket: {
+ enabled: false,
+ path: "/tmp/mmm-messagecenter.sock",
+ mode: 0o600
+ }
+ },
+ images: {
+ enabled: false,
+ maxBytes: 1024 * 1024,
+ maxCachedImages: 12,
+ maxTotalBytes: 12 * 1024 * 1024,
+ timeout: 5000,
+ allowPrivateHosts: false,
+ allowHttp: false
}
}
}
@@ -56,15 +146,188 @@ Place the module class on the corresponding MMM-pages page:
| Option | Type | Default | Description |
| --- | --- | --- | --- |
+| `ui` | string | `"messages"` | Render the inbox when set to `messages`; other values keep it hidden. |
+| `displayMode` | string | `"page"` | Use `page` for the full inbox or `compact` for a normal MagicMirror region. |
+| `maxVisibleMessages` | integer or `null` | `null` | Render only the newest configured number without deleting retained history. A positive value overrides `compactMaxMessages` in either display mode. |
+| `showControls` | boolean | `true` | Show history and per-message buttons where the display mode allows them. Set to `false` for non-touch displays. |
+| `compactMaxMessages` | integer | `3` | Maximum newest messages rendered in compact mode; the underlying queue is unchanged. |
+| `compactShowControls` | boolean | `false` | Show condensed history buttons in compact mode. |
+| `pages` | boolean | `true` | Allow validated message actions to switch MMM-pages pages. |
+| `legacyAttentionEvents` | boolean | `true` | Emit compatibility `ATTENTION_ON` and `ATTENTION_OFF` notifications. Structured attention state remains the preferred contract. |
| `messagesPage` | integer | `4` | Zero-based MMM-pages index containing the inbox. |
-| `maxMessages` | integer | `50` | Maximum messages retained in browser memory. |
+| `channelRoutes` | object | `{}` | Maps semantic channel names such as `weather` to MMM-pages indexes. The built-in `messages` route always uses `messagesPage`. |
+| `maxMessages` | integer | `50` | Hard limit for all retained messages. Oldest history is displaced when the queue reaches this size. |
+| `expirationSweepInterval` | number | `60000` | Milliseconds between active expiration checks; use `0` to disable. |
+| `publishAttentionState` | boolean | `true` | Publish structured `MESSAGE_CENTER_ATTENTION_CHANGED` snapshots. |
+| `showHeader` | boolean | `true` | Show the inbox title, explicit unread/total counts, and touch-friendly history controls. |
| `showToasts` | boolean | `true` | Send `SHOW_ALERT` for incoming messages. |
| `clearAttentionWhenViewed` | boolean | `true` | Mark messages read when their page opens. |
-| `webhook.host` | string | `"127.0.0.1"` | Address on which the webhook listens. |
+| `internalNotifications.enabled` | boolean | `true` | Allow configured providers to consume MagicMirror module notifications. |
+| `internalNotifications.remoteControl.enabled` | boolean | `true` | Capture the calm default allowlist of user-facing MMM-Remote-Control notifications. |
+| `internalNotifications.remoteControl.mappings` | object | See below | Explicit allowlist and normalization policy for notifications emitted by MMM-Remote-Control. |
+| `internalNotifications.weather.enabled` | boolean | `false` | Convert eligible default-weather forecasts into MessageCenter alerts. |
+| `webhook.host` | string | `"127.0.0.1"` | Address on which the webhook listens. The secure default accepts only software running on the mirror. |
| `webhook.port` | integer | `8787` | Webhook TCP port. |
-| `webhook.token` | string | `""` | Bearer token required for non-localhost listening. |
+| `webhook.token` | string | `""` | Optional bearer token. When configured, every webhook request must provide it. |
+| `transports.mqtt.enabled` | boolean | `false` | Subscribe to MQTT messages using the existing MessageCenter schema. |
+| `transports.mqtt.url` | string | `"mqtt://127.0.0.1:1883"` | MQTT broker URL. Keep credentials in the separate username and password settings. |
+| `transports.mqtt.topic` | string | `"messagecenter/messages"` | Exact MQTT topic to subscribe to. Use `topics` with an array for several exact topics. |
+| `transports.mqtt.username` | string | `""` | Optional MQTT username stored only in private MagicMirror configuration. |
+| `transports.mqtt.password` | string | `""` | Optional MQTT password stored only in private MagicMirror configuration. |
+| `transports.unixSocket.enabled` | boolean | `false` | Accept newline-delimited JSON from local processes through a Unix-domain socket. |
+| `transports.unixSocket.path` | string | `"/tmp/mmm-messagecenter.sock"` | Absolute local socket path. |
+| `transports.unixSocket.mode` | integer | `0o600` | Filesystem permissions applied to the socket. |
+| `images.enabled` | boolean | `false` | Fetch and preserve one remote image when a message enters through REST, MQTT, or the Unix socket. |
+| `images.maxBytes` | integer | `1048576` | Maximum downloaded snapshot size; accepted range is 1 KiB through 5 MiB. |
+| `images.maxCachedImages` | integer | `12` | Maximum newest snapshots retained. Older messages remain but release their image data. |
+| `images.maxTotalBytes` | integer | `12582912` | Maximum decoded bytes retained across all snapshots. The count and byte limits both apply. |
+| `images.timeout` | integer | `5000` | Image download timeout in milliseconds. |
+| `images.allowPrivateHosts` | boolean | `false` | Permit image hosts resolving to private or local addresses. Enable only for trusted camera networks. |
+| `images.allowHttp` | boolean | `false` | Permit unencrypted HTTP image URLs. HTTPS remains required by default. |
Messages are stored only in memory and reset when MagicMirror restarts.
+Inbox timestamps and newly generated weather-alert times follow MagicMirror's
+global `timeFormat` (`12` or `24`) and `locale`/`language` preferences. Changing
+those preferences reformats rendered metadata; it does not rewrite historical
+message body text that was generated earlier.
+
+The inbox is background-agnostic and leaves its sticky header transparent by
+default. Themes that need an opaque header while scrolling can set the
+`--message-center-header-background` CSS custom property to the page background
+color in `custom.css`.
+
+### Non-touch presentation
+
+Buttons can be omitted while retaining automatic viewed-state behavior and the
+notification API used by other modules. `maxVisibleMessages` limits only the
+rendered newest entries; `maxMessages` remains the hard queue limit.
+
+```js
+{
+ module: "MMM-MessageCenter",
+ position: "middle_center",
+ config: {
+ displayMode: "page",
+ showControls: false,
+ maxVisibleMessages: 6,
+ clearAttentionWhenViewed: true
+ }
+}
+```
+
+## MagicMirror internal notifications
+
+MessageCenter can consume MagicMirror's internal module broadcasts directly.
+This keeps the notification experience useful without Home Assistant and lets
+existing modules remain the authoritative data providers.
+
+### MMM-Remote-Control
+
+MMM-Remote-Control rebroadcasts remote `SHOW_ALERT` requests and can intentionally
+forward any MagicMirror notification through its `NOTIFICATION` action.
+MessageCenter uses an explicit allowlist rather than treating Remote Control's
+operational traffic as household messages.
+
+The defaults are:
+
+```js
+internalNotifications: {
+ remoteControl: {
+ enabled: true,
+ mappings: {
+ MC_MESSAGE: { mode: "message" },
+ SHOW_ALERT: {
+ mode: "alert",
+ type: "remote.alert",
+ source: "magicmirror.remote-control",
+ urgency: "passive",
+ retention: "archive"
+ }
+ }
+ }
+}
+```
+
+`SHOW_ALERT` is retained as passive history but does not create another toast,
+because MagicMirror's alert module already receives the original alert.
+An intentionally forwarded `MC_MESSAGE` payload enters the normal MessageCenter
+schema and may request retention, attention, and routing:
+
+```json
+{
+ "action": "NOTIFICATION",
+ "notification": "MC_MESSAGE",
+ "payload": {
+ "id": "entry-reminder",
+ "source": "remote-control",
+ "type": "household.reminder",
+ "title": "Front door",
+ "body": "Please check the front door.",
+ "urgency": "attention",
+ "retention": "untilViewed"
+ }
+}
+```
+
+`REMOTE_ACTION`, `REGISTER_API`, presence, brightness, temperature, refresh,
+module visibility, page navigation, and MessageCenter's own emitted events are
+not captured. Replace `mappings` with a deliberately chosen mapping object to
+change the allowlist; use `{}` to capture nothing while leaving the provider
+available.
+
+### Rain approaching
+
+The first internal provider listens for `WEATHER_UPDATED` from MagicMirror's
+default `weather` module. Enable it with:
+
+```js
+internalNotifications: {
+ weather: {
+ enabled: true,
+ rain: {
+ leadTimeMinutes: 60,
+ windowMinutes: 45,
+ probabilityThreshold: 50,
+ amountThreshold: 0.1,
+ channel: "weather",
+ timeout: 10000
+ }
+ }
+}
+```
+
+At least one default `weather` module instance must use `type: "hourly"`. That
+instance broadcasts the provider-neutral `hourlyArray` used by the rule. Other
+current or daily weather instances can coexist; their broadcasts do not contain
+hourly data and will not incorrectly clear an active alert.
+
+The default rule looks approximately one hour ahead, allowing a 45-minute
+tolerance for provider forecast intervals. It alerts for a rain weather type
+meeting the probability threshold or a forecast rain/precipitation amount
+meeting the amount threshold. Snow-only forecasts are ignored.
+
+Only one `rain-next-hour` event remains active at a time. Repeated weather
+refreshes do not repeat its toast or attention signal. A later hourly update
+without qualifying rain resolves the message and its attention state. As a
+safety net, the message expires 90 minutes after the matched forecast time.
+
+
+
+| Rain option | Default | Description |
+| --- | --- | --- |
+| `enabled` | `true` | Enable the rain rule when the weather provider is enabled. |
+| `leadTimeMinutes` | `60` | Forecast lead time to examine. |
+| `windowMinutes` | `45` | Allowed distance on either side of the target time. |
+| `probabilityThreshold` | `50` | Minimum rain probability percentage when using weather type. |
+| `amountThreshold` | `0.1` | Minimum numeric rain or precipitation amount. |
+| `urgency` | `"attention"` | Message urgency. |
+| `retention` | `"untilViewed"` | Message retention and acknowledgement policy. |
+| `channel` | `"weather"` | Semantic destination resolved through `channelRoutes`. |
+| `timeout` | `10000` | Milliseconds before returning to the prior channel. |
+| `expiresAfterMinutes` | `90` | Safety expiration measured from the forecast time. |
+
+Home Assistant may still send the same semantic message through the webhook.
+It is an optional provider rather than a runtime requirement.
## Sending messages
@@ -75,15 +338,36 @@ curl http://127.0.0.1:8787/message \
-H "Content-Type: application/json" \
-d '{
"source": "home-assistant",
+ "entityId": "garage-door",
"title": "Garage door open",
"body": "The garage door has been open for 10 minutes.",
- "priority": "attention",
- "actions": { "switchChannel": 4, "timeout": 10000 }
+ "urgency": "attention",
+ "retention": "untilViewed",
+ "actions": { "switchChannel": "messages", "timeout": 10000 }
}'
```
-To receive requests from another device, bind to a LAN address such as
-`"0.0.0.0"` and configure a strong token. Then include it with every request:
+Home Assistant messages share the same inbox and attention model, whether they
+contain a simple household update or a cached camera snapshot:
+
+
+
+The default configuration accepts requests only from software running on the
+mirror. To receive webhooks from Home Assistant or another LAN system, opt in
+to network access and preferably configure a strong token:
+
+```js
+webhook: {
+ host: "0.0.0.0",
+ port: 8787,
+ token: "GENERATE_A_STRONG_RANDOM_TOKEN"
+}
+```
+
+Do not expose the webhook port directly to the public internet.
+
+For permanent or less-trusted network installations, configure a strong token
+and include it with every request:
```sh
curl http://MIRROR_IP:8787/message \
@@ -92,27 +376,170 @@ curl http://MIRROR_IP:8787/message \
-d '{"title":"Test message"}'
```
-Do not expose this webhook directly to the public internet.
+When a non-localhost webhook has no token, MessageCenter logs a startup warning.
+Existing installations that intentionally accept LAN requests should keep an
+explicit non-localhost `webhook.host` during upgrades.
+
+### MQTT
+
+MQTT is optional and disabled by default. Enable it when the installation
+already has a broker; REST remains the easiest transport for new users. MQTT
+payloads use exactly the same message schema as the webhook. The adapter
+reconnects automatically, subscribes at QoS 0, accepts up to 32 KiB, and does
+not log credentials.
+
+Home Assistant can publish a message without changing its semantic fields:
+
+```yaml
+action: mqtt.publish
+data:
+ topic: messagecenter/messages
+ payload: |-
+ {
+ "id": "storage-warning",
+ "type": "system.storage",
+ "source": "home-assistant",
+ "title": "Storage running low",
+ "body": "The mirror has less than 10% free space.",
+ "urgency": "attention",
+ "retention": "untilAcknowledged"
+ }
+```
+
+Use stable source/ID pairs for conditions that may be reported repeatedly;
+MessageCenter's normal update and deduplication behavior applies regardless of
+transport. MQTT wildcard subscriptions are not currently supported.
+
+### Unix socket
+
+The Unix-domain socket is intended for trusted monitoring scripts running on
+the mirror. It does not open a network port. Each newline-delimited JSON object
+is normalized through the same path as REST and MQTT. The default `0o600` mode
+allows only the MagicMirror process owner to connect; widen it deliberately
+only when another local service account must publish.
+
+For example, with `socat` installed:
+
+```sh
+printf '%s\n' '{"id":"system-network","type":"system.network","source":"system-monitor","title":"Network unavailable","body":"Connectivity has been unavailable for five minutes.","urgency":"attention","retention":"untilAcknowledged"}' \
+ | socat - UNIX-CONNECT:/tmp/mmm-messagecenter.sock
+```
+
+MessageCenter supplies the transport, schema, and presentation. Disk, network,
+temperature, and service checks should remain separate monitoring scripts or
+services so the module stays hardware-independent.
+
+### Image snapshots
+
+When `images.enabled` is true, messages arriving through REST, MQTT, or the Unix
+socket may include one image URL:
+
+```json
+{
+ "id": "front-door-2026-08-02T12:00:00Z",
+ "type": "security.doorbell",
+ "source": "home-assistant",
+ "title": "Someone is at the door",
+ "body": "Doorbell motion was detected.",
+ "urgency": "attention",
+ "retention": "untilAcknowledged",
+ "image": {
+ "url": "https://images.example.net/events/doorbell.png",
+ "alt": "Doorbell camera snapshot"
+ }
+}
+```
+
+MessageCenter downloads the snapshot during ingestion and embeds the captured
+bytes in the in-memory message. The image therefore does not change if the URL
+later points to a newer camera frame. Full-page cards show a larger contained
+image; compact cards show a 96-by-64-pixel recognition thumbnail. Text remains
+the authoritative alert and is still delivered if the image cannot be cached.
+
+Only JPEG, PNG, and WebP content is accepted. MessageCenter validates both the
+response content type and file signature, follows at most three validated
+redirects, and does not pass the original URL to the browser. HTTPS and public
+hosts are required by default. Private hosts and HTTP each require a separate,
+explicit opt-in. Images remain in memory and disappear with message history or
+when MagicMirror restarts. By default, the newest 12 images are retained within
+a 12 MiB decoded-byte budget. Reaching either limit releases image data from
+the oldest affected messages without deleting their text or history state.
## Message schema
+The refined contract separates urgency from retention. Existing senders using
+`priority: "attention"` or `priority: "ephemeral"` remain supported.
+
| Field | Type | Description |
| --- | --- | --- |
| `id` | string | Optional sender-provided identifier. |
| `source` | string | Origin such as `home-assistant`. |
+| `entityId` | string | Optional stable subject such as `dishwasher` or `front-door`. |
+| `type` | string | Semantic event type such as `appliance.complete`. |
| `title` | string | Message heading. |
| `body` | string | Message details. |
-| `priority` | string | `ephemeral` or `attention`. |
+| `urgency` | string | `passive`, `attention`, or `critical`. Controls awareness and toast duration. |
+| `retention` | string | `ephemeral`, `untilViewed`, `untilAcknowledged`, or `archive`. Explicit `ephemeral` messages do not enter inbox history. |
+| `priority` | string | Legacy compatibility field: `ephemeral` or `attention`. |
| `timestamp` | number | Epoch timestamp in milliseconds. |
| `expires` | number | Optional expiration time in milliseconds. |
-| `actions.switchChannel` | integer | Optional MMM-pages target index. |
+| `image.url` | string | Optional snapshot URL for enabled external image ingestion. HTTPS is required by default. |
+| `image.alt` | string | Short accessible description of the snapshot. |
+| `actions.switchChannel` | string or integer | Semantic destination or legacy MMM-pages index. `messages` is built in. |
| `actions.timeout` | number | Optional milliseconds before returning. |
+Legacy `priority: "attention"` maps to `urgency: "attention"` and
+`retention: "untilViewed"`. Legacy `priority: "ephemeral"` maps to passive,
+bounded inbox history to preserve the original behavior. New senders should use
+the explicit fields.
+
+After the inbox first renders, it clears `untilViewed` attention and transitions
+from the unread styling to the read urgency edge. Messages marked
+`untilAcknowledged` continue requesting attention until the user explicitly
+marks them read. Messages remain in bounded in-memory history until they expire,
+are cleared, or are displaced by `maxMessages`.
+
+The header distinguishes unread attention from retained history. **Mark all
+read** acknowledges unread messages; **Clear read** removes only acknowledged
+history and preserves anything still unread. Each full-page message also has
+**Mark read** and **Dismiss** controls. Compact mode keeps per-message controls
+hidden unless `compactShowControls: true` is configured. Known internal sources
+are shown with friendly labels, such as **Weather** instead of
+`magicmirror.weather`.
+
+A timed page action returns only while MessageCenter still owns the automatic
+navigation. Turning the encoder, touching another channel, or otherwise changing
+pages cancels the pending return so an alert cannot fight the user. Consecutive
+timed alerts preserve the page that was visible before the first alert.
+
## Notifications
MMM-MessageCenter emits `ATTENTION_ON` with the unread count and
`ATTENTION_OFF` when attention is cleared. Other modules may send `MC_ACK_ALL`
-to mark messages read or `MC_CLEAR_ALL` to empty the inbox.
+to mark messages read, `MC_CLEAR_READ` to remove acknowledged history while
+preserving unread messages, or `MC_CLEAR_ALL` to empty the inbox. To act on one
+message, send `MC_ACK_MESSAGE` or `MC_DISMISS_MESSAGE` with
+`{ source: "message-source", id: "message-id" }`. Both fields are required so
+identical IDs from different providers remain independent.
+
+It also emits `MESSAGE_CENTER_ATTENTION_CHANGED` with `active`, `unreadCount`,
+`highestUrgency`, `highestPriority` (compatibility alias), and `sources`. The
+structured event is the preferred contract for new integrations; the legacy
+events remain available for compatibility.
+
+These are ordinary MagicMirror notifications; MessageCenter does not control
+WLED or depend on a particular lighting implementation. MMM-Seymour may consume
+them as one attention source alongside Home Assistant, calendar, or other
+modules.
+
+The former `attention: "seymour"` setting remains supported for existing
+installations. New configurations should use the integration-neutral
+`legacyAttentionEvents` option.
+
+Potential senders include Home Assistant, calendars, cameras, doorbells,
+weather services, household appliances, custom webhooks, and other MagicMirror
+modules. All senders should normalize into the same message contract rather than
+creating independent alert experiences.
## Development
@@ -120,7 +547,10 @@ to mark messages read or `MC_CLEAR_ALL` to empty the inbox.
npm test
```
+Open `dev/message-center-preview.html` in a browser to review a representative
+five-message inbox inside a fixed 1024x600 stage without sending live household
+events.
+
## License
-No license has been selected yet. Until one is added, the source is not granted
-for reuse or redistribution.
+MMM-MessageCenter is available under the [MIT License](LICENSE).
diff --git a/dev/message-center-preview.html b/dev/message-center-preview.html
new file mode 100644
index 0000000..123eba3
--- /dev/null
+++ b/dev/message-center-preview.html
@@ -0,0 +1,97 @@
+
+
+
+
+
+ MMM-MessageCenter 1024x600 preview
+
+
+
+
+
+
+
+
+
+
+
Front Door
+ New
+
+ Motion was detected at the front door.
+
+ home-assistant
+
+
+
+
+
+
+
Rain starting soon
+ New
+
+ Light rain is expected in approximately 20 minutes.
+
+ weather
+
+
+
+
+
+
+
Dishwasher
+
+ The dishes are done.
+
+ SmartThings via Home Assistant
+
+
+
+
+
+
+
Calendar
+
+ Dentist appointment tomorrow at 10:30 AM.
+
+ calendar
+
+
+
+
+
+
+
Laundry
+
+ The dryer cycle finished.
+
+ home-assistant
+
+
+
+
+
+
+
diff --git a/docs/LIVE_TEST.md b/docs/LIVE_TEST.md
new file mode 100644
index 0000000..80c2996
--- /dev/null
+++ b/docs/LIVE_TEST.md
@@ -0,0 +1,34 @@
+# Live test checklist
+
+Use this checklist after deploying a reviewed repository build to a mirror.
+Do not place tokens, addresses, or private household payloads in this document.
+
+## MMM-Remote-Control
+
+- Send one `SHOW_ALERT` with a harmless test title and body.
+- Confirm the alert appears once, not twice.
+- Open Messages and confirm it appears once as read, passive history with the
+ source label **Remote Control**.
+- Forward an `MC_MESSAGE` with a stable test `id`, `urgency: "attention"`, and
+ `retention: "untilViewed"`.
+- Confirm it produces one toast, one unread history entry, and semantic attention.
+- Forward the identical payload again and confirm it does not repeat the toast or
+ create another history entry.
+- Open Messages and confirm `untilViewed` attention clears.
+- Forward an `untilAcknowledged` test and confirm opening Messages does not clear it;
+ use **Mark all read** to acknowledge it.
+
+## Noise and navigation
+
+- Change brightness and confirm no message is created.
+- Show or hide a module and confirm no message is created.
+- Change pages and confirm no message is created.
+- Trigger presence or refresh only if safe, and confirm no message is created.
+- Send a message with a timed page action and confirm it returns to the original page.
+- Repeat, then navigate manually before timeout and confirm automatic return is canceled.
+
+## Recovery
+
+- Review MagicMirror logs for MessageCenter errors.
+- If normal mirror behavior regresses, restore the timestamped module backup and
+ restart MagicMirror before investigating further.
diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md
new file mode 100644
index 0000000..c5829c6
--- /dev/null
+++ b/docs/ROADMAP.md
@@ -0,0 +1,234 @@
+# MMM-MessageCenter product roadmap
+
+Status: living direction, not a release commitment
+
+MMM-MessageCenter is the notification and attention layer for MagicMirror. Its
+job is to accept messages from many systems, apply consistent lifecycle and
+routing policy, and publish semantic state that any UI or hardware adapter may
+present.
+
+Seymour is the first appliance integration, not a requirement. A vanilla
+MagicMirror installation should be able to use the inbox and toast behavior with
+all hardware integrations disabled.
+
+## Product boundary
+
+MessageCenter owns:
+
+- input validation and message normalization;
+- queue ordering, retention, expiration, and acknowledgement;
+- deduplication and message identity;
+- toast and optional inbox presentation;
+- interruption and return policy;
+- semantic attention state;
+- a stable interface for input providers and presentation adapters.
+
+MessageCenter does not own:
+
+- WLED presets or effects;
+- GPIO pins, LEDs, buttons, or rotary encoders;
+- speaker or audio-device control;
+- camera transport and authentication;
+- Home Assistant entity implementation;
+- the physical form or interaction model of a particular appliance.
+
+Those responsibilities belong to adapters such as MMM-Seymour, Home Assistant
+automations, camera providers, or future sound and desktop-notification modules.
+
+## Current message contract
+
+The implemented message shape is:
+
+```json
+{
+ "id": "dishwasher-cycle-1042",
+ "type": "appliance.complete",
+ "source": "home-assistant",
+ "entityId": "dishwasher",
+ "title": "Dishwasher",
+ "body": "The dishes are done",
+ "urgency": "attention",
+ "retention": "untilViewed",
+ "timestamp": 1784608200000,
+ "unread": true,
+ "expires": null,
+ "actions": {
+ "switchChannel": "messages",
+ "timeout": 10000
+ }
+}
+```
+
+Messages are ordered newest first. Urgency and retention are independent, while
+legacy `priority` senders remain compatible. Messages are lost when MagicMirror
+restarts.
+
+## Lifecycle model
+
+Urgency and retention are separate concerns and should evolve independently.
+
+### Urgency
+
+- `passive`: useful information without an attention request;
+- `attention`: requires timely awareness;
+- `critical`: highest urgency with an extended toast and explicit acknowledgement support.
+
+### Retention
+
+- `ephemeral`: toast or transient display, not retained in the inbox;
+- `untilViewed`: requests attention until the message page is viewed;
+- `untilAcknowledged`: retained until explicit user acknowledgement;
+- `archive`: retained according to configured history policy.
+
+This separation allows a low-urgency dishwasher completion to remain until
+viewed while a time-sensitive weather warning can demand attention and still
+expire automatically.
+
+The transition must preserve compatibility with existing `ephemeral` and
+`attention` senders.
+
+## Semantic attention
+
+The current compatibility events are:
+
+- `ATTENTION_ON`
+- `ATTENTION_OFF`
+
+The contract also publishes a structured snapshot:
+
+```js
+MESSAGE_CENTER_ATTENTION_CHANGED
+{
+ active: true,
+ unreadCount: 3,
+ highestUrgency: "attention",
+ highestPriority: "attention", // compatibility alias
+ sources: ["home-assistant", "weather"]
+}
+```
+
+Consumers decide whether that means a WLED animation, a GPIO indicator, a sound,
+a screen effect, a desktop notification, or no additional presentation. Legacy
+events remain during a compatibility period.
+
+## Page and channel routing
+
+The action still accepts a zero-based MMM-pages index for compatibility:
+
+```json
+{
+ "switchChannel": 2,
+ "timeout": 10000
+}
+```
+
+Automatic navigation returns only while MessageCenter still owns the temporary
+page change. Manual encoder, keyboard, or touch navigation cancels the return.
+Consecutive timed alerts preserve the page visible before the first alert.
+
+Numeric indexes are fragile when users reorder pages. Semantic destinations are
+resolved through `channelRoutes`; `messages` is a built-in destination:
+
+```json
+{
+ "switchChannel": "weather",
+ "timeout": 10000
+}
+```
+
+The module resolves semantic channels to the installed page indexes.
+
+## Input providers
+
+The HTTP webhook, MQTT transport, Unix-domain socket, and MagicMirror's internal
+notification bus feed the same normalization path. MQTT and the Unix socket are
+optional and disabled by default. The default weather integration consumes
+provider-neutral `WEATHER_UPDATED` data and applies the first configurable
+forecast rule.
+
+Future provider work may include:
+
+- additional rules for calendar, news, media, camera, and system notifications;
+- broader MQTT topic policies and Home Assistant entities;
+- calendars and scheduled reminders;
+- cameras and doorbells;
+- weather alert feeds;
+- plugin-defined local or remote sources.
+
+All providers normalize into the same core message object. Provider-specific
+credentials and transport details must not leak into the core schema.
+
+## Presentation adapters
+
+The built-in inbox and MagicMirror toast notification are the first presentation
+surfaces. Future adapters may include:
+
+- Seymour WLED status and attention patterns;
+- audio cues and spoken alerts;
+- screen-edge or full-screen critical effects;
+- desktop and mobile notifications;
+- synchronized displays on multiple mirrors.
+
+Presentation adapters consume semantic state and must not become message
+producers merely to control hardware.
+
+## Delivery phases
+
+### Phase 1 — working baseline
+
+- HTTP webhook with optional bearer-token support (implemented);
+- normalized message object (implemented);
+- newest-first in-memory queue (implemented);
+- toast notifications (implemented);
+- attention lifecycle compatibility events (implemented);
+- optional MMM-pages routing and timed return (implemented);
+- user navigation cancels automatic return (implemented);
+- built-in inbox with unread treatment and bulk acknowledgement (implemented);
+- compact region-friendly inbox presentation (implemented);
+- confirmed Home Assistant delivery on Seymour (implemented).
+
+### Phase 2 — appliance UX and lifecycle
+
+- message icons and broader source identity;
+- categories and filtering;
+- deduplication by source and message ID (implemented);
+- active expiration (implemented) and message aging;
+- separate urgency and retention fields (implemented);
+- individual and bulk acknowledgement and dismissal (implemented);
+- structured attention-state event (implemented);
+- semantic channel destinations (implemented);
+- internal MagicMirror notification-provider foundation (implemented);
+- one-hour rain alert from the default weather module (implemented);
+- explicit MMM-Remote-Control alert and message ingestion (implemented);
+- continued physical-device layout and interaction testing (ongoing).
+
+### Phase 3 — reliable and rich notifications
+
+- persistent storage and restart recovery;
+- ingestion-time cached JPEG, PNG, and WebP snapshots (implemented); broader media attachments remain future work;
+- configurable sounds and speech;
+- safe notification actions;
+- critical-alert and manual-acknowledgement policy;
+- transport adapters for webhook, MQTT, and Unix-domain-socket inputs (implemented);
+- additional external transport adapters as justified by real integrations.
+
+### Phase 4 — notification platform
+
+- documented provider and presentation plugin APIs;
+- notification rules and user-defined policies;
+- scheduled reminders;
+- cross-mirror synchronization;
+- mobile or companion-client support.
+
+## Decision rules
+
+When evaluating roadmap work:
+
+1. Preserve a calm appliance experience; interruption must be proportional.
+2. Never let an automatic action fight explicit user navigation.
+3. Keep the message contract independent of hardware and page implementations.
+4. Prefer semantic state over device commands.
+5. Preserve backward compatibility or document an intentional migration.
+6. Validate changes on the physical Seymour display as well as in unit tests.
+7. Treat this roadmap as revisable evidence of direction, not a promise that
+ every exploratory feature will ship.
diff --git a/docs/images/home-assistant-notifications.png b/docs/images/home-assistant-notifications.png
new file mode 100644
index 0000000..75d89c3
Binary files /dev/null and b/docs/images/home-assistant-notifications.png differ
diff --git a/docs/images/weather-alert.png b/docs/images/weather-alert.png
new file mode 100644
index 0000000..bce73bd
Binary files /dev/null and b/docs/images/weather-alert.png differ
diff --git a/node_helper.js b/node_helper.js
index e861bca..6408d61 100644
--- a/node_helper.js
+++ b/node_helper.js
@@ -1,22 +1,45 @@
const NodeHelper = require("node_helper");
const express = require("express");
+const dns = require("node:dns");
+const fs = require("node:fs");
+const net = require("node:net");
+const mqtt = require("mqtt");
+
+const MAX_PAYLOAD_BYTES = 32 * 1024;
+const IMAGE_TYPES = new Set(["image/jpeg", "image/png", "image/webp"]);
module.exports = NodeHelper.create({
start() {
this.server = null;
+ this.mqttClient = null;
+ this.unixServer = null;
+ this.imageConfig = this.getImageConfig();
},
socketNotificationReceived(notification, payload) {
- if (notification === "MC_START") this.startWebhook(payload);
- if (notification === "MC_STOP") this.stopWebhook();
+ if (notification === "MC_START") this.startTransports(payload);
+ if (notification === "MC_STOP") this.stopTransports();
+ },
+
+ startTransports(rawConfig = {}) {
+ const config = rawConfig && typeof rawConfig === "object" && !Array.isArray(rawConfig)
+ ? rawConfig
+ : {};
+ const hasTransportBundle = Object.prototype.hasOwnProperty.call(config, "webhook");
+ this.imageConfig = this.getImageConfig(hasTransportBundle ? config.images : {});
+ this.startWebhook(hasTransportBundle ? config.webhook : config);
+
+ const transports = hasTransportBundle && config.transports &&
+ typeof config.transports === "object" && !Array.isArray(config.transports)
+ ? config.transports
+ : {};
+ this.startMqtt(transports.mqtt);
+ this.startUnixSocket(transports.unixSocket);
},
startWebhook(rawConfig = {}) {
if (this.server) return;
-
- if (!rawConfig || typeof rawConfig !== "object" || Array.isArray(rawConfig)) {
- rawConfig = {};
- }
+ if (!rawConfig || typeof rawConfig !== "object" || Array.isArray(rawConfig)) rawConfig = {};
const host = typeof rawConfig.host === "string" ? rawConfig.host : "127.0.0.1";
const port = Number.isInteger(rawConfig.port) ? rawConfig.port : 8787;
@@ -28,16 +51,15 @@ module.exports = NodeHelper.create({
}
if (host !== "127.0.0.1" && host !== "localhost" && !token) {
- this.sendSocketNotification(
- "MC_ERROR",
- "A webhook token is required when listening beyond localhost"
+ console.warn(
+ "[MMM-MessageCenter] Webhook is available on the local network without authentication. " +
+ "Configure webhook.token to require bearer authentication; never expose this port to the internet."
);
- return;
}
const app = express();
app.disable("x-powered-by");
- app.use(express.json({ limit: "32kb" }));
+ app.use(express.json({ limit: `${MAX_PAYLOAD_BYTES}b` }));
app.post("/message", (request, response) => {
if (token && !this.isAuthorized(request, token)) {
@@ -48,7 +70,7 @@ module.exports = NodeHelper.create({
return response.status(400).json({ error: "A JSON object payload is required" });
}
- this.sendSocketNotification("MC_MESSAGE", request.body);
+ this.ingestPayload(request.body);
return response.status(202).json({ status: "accepted" });
});
@@ -72,13 +94,367 @@ module.exports = NodeHelper.create({
return authorization === `Bearer ${token}`;
},
+ ingestPayload(payload) {
+ if (!payload || typeof payload !== "object" || Array.isArray(payload)) return false;
+ const imageRequest = this.getImageRequest(payload.image);
+ if (this.imageConfig.enabled && imageRequest) {
+ const queuedPayload = Number.isFinite(payload.timestamp)
+ ? payload
+ : { ...payload, timestamp: Date.now() };
+ this.cacheImage(imageRequest)
+ .then((image) => this.sendSocketNotification("MC_MESSAGE", { ...queuedPayload, image }))
+ .catch(() => {
+ this.sendSocketNotification("MC_ERROR", "Message image could not be cached");
+ const fallback = { ...queuedPayload };
+ delete fallback.image;
+ this.sendSocketNotification("MC_MESSAGE", fallback);
+ });
+ return true;
+ }
+ this.sendSocketNotification("MC_MESSAGE", payload);
+ return true;
+ },
+
+ getImageConfig(rawConfig = {}) {
+ const config = rawConfig && typeof rawConfig === "object" && !Array.isArray(rawConfig)
+ ? rawConfig
+ : {};
+ return {
+ enabled: config.enabled === true,
+ maxBytes: Number.isInteger(config.maxBytes) &&
+ config.maxBytes >= 1024 && config.maxBytes <= 5 * 1024 * 1024
+ ? config.maxBytes
+ : 1024 * 1024,
+ timeout: Number.isInteger(config.timeout) && config.timeout >= 250 && config.timeout <= 30000
+ ? config.timeout
+ : 5000,
+ allowPrivateHosts: config.allowPrivateHosts === true,
+ allowHttp: config.allowHttp === true
+ };
+ },
+
+ getImageRequest(rawImage) {
+ if (typeof rawImage === "string") return { url: rawImage, alt: "Message snapshot" };
+ if (!rawImage || typeof rawImage !== "object" || Array.isArray(rawImage)) return null;
+ if (typeof rawImage.url !== "string") return null;
+ return {
+ url: rawImage.url,
+ alt: typeof rawImage.alt === "string" && rawImage.alt.trim()
+ ? rawImage.alt.trim().slice(0, 240)
+ : "Message snapshot"
+ };
+ },
+
+ async cacheImage(request) {
+ let url = new URL(request.url);
+ const controller = new AbortController();
+ const timeout = setTimeout(() => controller.abort(), this.imageConfig.timeout);
+
+ try {
+ for (let redirects = 0; redirects <= 3; redirects += 1) {
+ await this.validateImageUrl(url);
+ const response = await this.fetchImage(url, { signal: controller.signal, redirect: "manual" });
+ if (response.status >= 300 && response.status < 400) {
+ const location = response.headers.get("location");
+ if (!location || redirects === 3) throw new Error("Invalid image redirect");
+ url = new URL(location, url);
+ continue;
+ }
+ if (!response.ok) throw new Error("Image request failed");
+
+ const type = String(response.headers.get("content-type") || "")
+ .split(";", 1)[0]
+ .trim()
+ .toLowerCase();
+ if (!IMAGE_TYPES.has(type)) throw new Error("Unsupported image type");
+ const declaredLength = Number(response.headers.get("content-length"));
+ if (Number.isFinite(declaredLength) && declaredLength > this.imageConfig.maxBytes) {
+ throw new Error("Image is too large");
+ }
+
+ const buffer = await this.readImageBody(response, this.imageConfig.maxBytes);
+ if (!this.matchesImageSignature(buffer, type)) throw new Error("Invalid image content");
+ return {
+ dataUrl: `data:${type};base64,${buffer.toString("base64")}`,
+ alt: request.alt,
+ capturedAt: Date.now()
+ };
+ }
+ throw new Error("Too many image redirects");
+ } finally {
+ clearTimeout(timeout);
+ }
+ },
+
+ async validateImageUrl(url) {
+ if (url.protocol !== "https:" && !(url.protocol === "http:" && this.imageConfig.allowHttp)) {
+ throw new Error("Images must use HTTPS");
+ }
+ if (url.username || url.password) throw new Error("Image URL credentials are not allowed");
+ if (this.imageConfig.allowPrivateHosts) return;
+ if (url.hostname.toLowerCase() === "localhost") throw new Error("Private image host");
+
+ const addresses = await dns.promises.lookup(url.hostname, { all: true, verbatim: true });
+ if (!addresses.length || addresses.some(({ address }) => this.isPrivateAddress(address))) {
+ throw new Error("Private image host");
+ }
+ },
+
+ isPrivateAddress(address) {
+ if (net.isIPv4(address)) {
+ const [a, b] = address.split(".").map(Number);
+ return a === 0 || a === 10 || a === 127 ||
+ (a === 100 && b >= 64 && b <= 127) ||
+ (a === 169 && b === 254) ||
+ (a === 172 && b >= 16 && b <= 31) ||
+ (a === 192 && b === 168) ||
+ (a === 198 && (b === 18 || b === 19)) ||
+ a >= 224;
+ }
+ if (net.isIPv6(address)) {
+ const normalized = address.toLowerCase();
+ return normalized === "::" || normalized === "::1" ||
+ normalized.startsWith("fc") || normalized.startsWith("fd") ||
+ /^fe[89ab]/.test(normalized) || normalized.startsWith("::ffff:") &&
+ this.isPrivateAddress(normalized.slice(7));
+ }
+ return true;
+ },
+
+ fetchImage(url, options) {
+ return fetch(url, options);
+ },
+
+ async readImageBody(response, maxBytes) {
+ if (!response.body || typeof response.body.getReader !== "function") {
+ const buffer = Buffer.from(await response.arrayBuffer());
+ if (buffer.length > maxBytes) throw new Error("Image is too large");
+ return buffer;
+ }
+
+ const reader = response.body.getReader();
+ const chunks = [];
+ let length = 0;
+ while (true) {
+ const { done, value } = await reader.read();
+ if (done) break;
+ length += value.length;
+ if (length > maxBytes) {
+ await reader.cancel();
+ throw new Error("Image is too large");
+ }
+ chunks.push(Buffer.from(value));
+ }
+ return Buffer.concat(chunks, length);
+ },
+
+ matchesImageSignature(buffer, type) {
+ if (type === "image/jpeg") return buffer.length >= 3 &&
+ buffer[0] === 0xff && buffer[1] === 0xd8 && buffer[2] === 0xff;
+ if (type === "image/png") return buffer.length >= 8 &&
+ buffer.subarray(0, 8).equals(Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]));
+ if (type === "image/webp") return buffer.length >= 12 &&
+ buffer.subarray(0, 4).toString() === "RIFF" &&
+ buffer.subarray(8, 12).toString() === "WEBP";
+ return false;
+ },
+
+ parsePayload(value, transport) {
+ const size = Buffer.isBuffer(value) ? value.length : Buffer.byteLength(String(value));
+ if (size > MAX_PAYLOAD_BYTES) {
+ this.sendSocketNotification(
+ "MC_ERROR",
+ `${transport} payload exceeded ${MAX_PAYLOAD_BYTES} bytes`
+ );
+ return false;
+ }
+
+ let payload;
+ try {
+ payload = JSON.parse(Buffer.isBuffer(value) ? value.toString("utf8") : String(value));
+ } catch (_error) {
+ this.sendSocketNotification("MC_ERROR", `Invalid JSON payload from ${transport}`);
+ return false;
+ }
+
+ if (!this.ingestPayload(payload)) {
+ this.sendSocketNotification("MC_ERROR", `${transport} requires a JSON object payload`);
+ return false;
+ }
+ return true;
+ },
+
+ startMqtt(rawConfig = {}) {
+ if (this.mqttClient) return;
+ const config = rawConfig && typeof rawConfig === "object" && !Array.isArray(rawConfig)
+ ? rawConfig
+ : {};
+ if (config.enabled !== true) return;
+
+ const url = typeof config.url === "string" && config.url.trim()
+ ? config.url.trim()
+ : "mqtt://127.0.0.1:1883";
+ const topics = (Array.isArray(config.topics) ? config.topics : [config.topic])
+ .filter((topic) => typeof topic === "string" && topic.trim())
+ .map((topic) => topic.trim());
+ if (!topics.length) {
+ this.sendSocketNotification("MC_ERROR", "MQTT requires at least one topic");
+ return;
+ }
+
+ const options = {
+ clean: true,
+ reconnectPeriod: 5000,
+ connectTimeout: 10000
+ };
+ for (const key of ["username", "password", "clientId"]) {
+ if (typeof config[key] === "string" && config[key]) options[key] = config[key];
+ }
+
+ let client;
+ try {
+ client = this.connectMqtt(url, options);
+ } catch (error) {
+ this.sendSocketNotification("MC_ERROR", `MQTT startup failed: ${error.message}`);
+ return;
+ }
+ this.mqttClient = client;
+ client.on("connect", () => {
+ client.subscribe(topics, { qos: 0 }, (error) => {
+ if (error) {
+ this.sendSocketNotification("MC_ERROR", `MQTT subscription failed: ${error.message}`);
+ return;
+ }
+ console.log(`[MMM-MessageCenter] MQTT subscribed to ${topics.join(", ")}`);
+ });
+ });
+ client.on("message", (topic, payload) => {
+ if (topics.includes(topic)) this.parsePayload(payload, `MQTT topic ${topic}`);
+ });
+ client.on("reconnect", () => {
+ console.log("[MMM-MessageCenter] MQTT reconnecting");
+ });
+ client.on("error", (error) => {
+ console.warn(`[MMM-MessageCenter] MQTT error: ${error.message}`);
+ });
+ },
+
+ connectMqtt(url, options) {
+ return mqtt.connect(url, options);
+ },
+
+ startUnixSocket(rawConfig = {}) {
+ if (this.unixServer) return;
+ const config = rawConfig && typeof rawConfig === "object" && !Array.isArray(rawConfig)
+ ? rawConfig
+ : {};
+ if (config.enabled !== true) return;
+
+ const socketPath = typeof config.path === "string" && config.path.trim()
+ ? config.path.trim()
+ : "/tmp/mmm-messagecenter.sock";
+ if (!socketPath.startsWith("/")) {
+ this.sendSocketNotification("MC_ERROR", "Unix socket path must be absolute");
+ return;
+ }
+ const mode = Number.isInteger(config.mode) && config.mode >= 0 && config.mode <= 0o777
+ ? config.mode
+ : 0o600;
+
+ let server;
+ try {
+ server = net.createServer((connection) => this.handleUnixConnection(connection));
+ } catch (error) {
+ this.sendSocketNotification("MC_ERROR", `Unix socket startup failed: ${error.message}`);
+ return;
+ }
+ this.unixServer = server;
+ this.unixSocketPath = socketPath;
+ server.on("error", (error) => {
+ this.sendSocketNotification("MC_ERROR", `Unix socket failed: ${error.message}`);
+ this.unixServer = null;
+ });
+ try {
+ server.listen(socketPath, () => {
+ fs.chmod(socketPath, mode, (error) => {
+ if (error) {
+ this.sendSocketNotification(
+ "MC_ERROR",
+ `Unix socket permissions failed: ${error.message}`
+ );
+ }
+ });
+ console.log(`[MMM-MessageCenter] Unix socket listening at ${socketPath}`);
+ });
+ } catch (error) {
+ this.unixServer = null;
+ this.unixSocketPath = null;
+ this.sendSocketNotification("MC_ERROR", `Unix socket startup failed: ${error.message}`);
+ }
+ },
+
+ handleUnixConnection(connection) {
+ let buffered = "";
+ let bytes = 0;
+ const respond = (accepted) => {
+ if (connection.writable) {
+ connection.write(`${JSON.stringify({ status: accepted ? "accepted" : "rejected" })}\n`);
+ }
+ };
+ const consume = (line) => {
+ if (!line.trim()) return;
+ respond(this.parsePayload(line, "Unix socket"));
+ };
+
+ connection.setEncoding("utf8");
+ connection.on("data", (chunk) => {
+ bytes += Buffer.byteLength(chunk);
+ if (bytes > MAX_PAYLOAD_BYTES) {
+ this.sendSocketNotification(
+ "MC_ERROR",
+ `Unix socket payload exceeded ${MAX_PAYLOAD_BYTES} bytes`
+ );
+ buffered = "";
+ respond(false);
+ connection.end();
+ return;
+ }
+ buffered += chunk;
+ const lines = buffered.split("\n");
+ buffered = lines.pop();
+ lines.forEach(consume);
+ });
+ connection.on("end", () => {
+ consume(buffered);
+ });
+ },
+
stopWebhook() {
if (!this.server) return;
this.server.close();
this.server = null;
},
- stop() {
+ stopMqtt() {
+ if (!this.mqttClient) return;
+ this.mqttClient.end(true);
+ this.mqttClient = null;
+ },
+
+ stopUnixSocket() {
+ if (!this.unixServer) return;
+ this.unixServer.close();
+ this.unixServer = null;
+ this.unixSocketPath = null;
+ },
+
+ stopTransports() {
this.stopWebhook();
+ this.stopMqtt();
+ this.stopUnixSocket();
+ },
+
+ stop() {
+ this.stopTransports();
}
});
diff --git a/package-lock.json b/package-lock.json
index 901e11b..186c66e 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -7,13 +7,63 @@
"": {
"name": "mmm-messagecenter",
"version": "0.1.0",
+ "license": "MIT",
"dependencies": {
- "express": "^5.1.0"
+ "express": "^5.1.0",
+ "mqtt": "^5.15.2"
},
"engines": {
"node": ">=18"
}
},
+ "node_modules/@babel/runtime": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz",
+ "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@types/node": {
+ "version": "26.1.2",
+ "resolved": "https://registry.npmjs.org/@types/node/-/node-26.1.2.tgz",
+ "integrity": "sha512-Vu4a5UFA9rIIFJ7rB/Vaafh9lrCQszopTCx6KjFboXTGQbPNasehVR5TEiithSDGyd1DEiUByggTZsg8jukeIg==",
+ "license": "MIT",
+ "dependencies": {
+ "undici-types": "~8.3.0"
+ }
+ },
+ "node_modules/@types/readable-stream": {
+ "version": "4.0.24",
+ "resolved": "https://registry.npmjs.org/@types/readable-stream/-/readable-stream-4.0.24.tgz",
+ "integrity": "sha512-NRvUNC/JFGPJvqdAfEve8oginbM6V08u5NzLWpG8MwA2kTPOLnqk+wpwuPT+mp3aUsxyuT6m2gnrPuHYCruzEg==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/node": "*"
+ }
+ },
+ "node_modules/@types/ws": {
+ "version": "8.18.1",
+ "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz",
+ "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/node": "*"
+ }
+ },
+ "node_modules/abort-controller": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz",
+ "integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==",
+ "license": "MIT",
+ "dependencies": {
+ "event-target-shim": "^5.0.0"
+ },
+ "engines": {
+ "node": ">=6.5"
+ }
+ },
"node_modules/accepts": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz",
@@ -27,6 +77,38 @@
"node": ">= 0.6"
}
},
+ "node_modules/base64-js": {
+ "version": "1.5.1",
+ "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
+ "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/feross"
+ },
+ {
+ "type": "patreon",
+ "url": "https://www.patreon.com/feross"
+ },
+ {
+ "type": "consulting",
+ "url": "https://feross.org/support"
+ }
+ ],
+ "license": "MIT"
+ },
+ "node_modules/bl": {
+ "version": "6.1.6",
+ "resolved": "https://registry.npmjs.org/bl/-/bl-6.1.6.tgz",
+ "integrity": "sha512-jLsPgN/YSvPUg9UX0Kd73CXpm2Psg9FxMeCSXnk3WBO3CMT10JMwijubhGfHCnFu6TPn1ei3b975dxv7K2pWVg==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/readable-stream": "^4.0.0",
+ "buffer": "^6.0.3",
+ "inherits": "^2.0.4",
+ "readable-stream": "^4.2.0"
+ }
+ },
"node_modules/body-parser": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz",
@@ -64,6 +146,48 @@
"url": "https://opencollective.com/express"
}
},
+ "node_modules/broker-factory": {
+ "version": "3.1.15",
+ "resolved": "https://registry.npmjs.org/broker-factory/-/broker-factory-3.1.15.tgz",
+ "integrity": "sha512-ko+aWvgNuP49meGrdjUu7rC+Y+Wai3cCPxP3xWwHsHfehFjOh5ZQM2yC4gEB2UddeZ/YXhm0K1eG/L6fxym2Og==",
+ "license": "MIT",
+ "dependencies": {
+ "@babel/runtime": "^7.29.7",
+ "fast-unique-numbers": "^9.0.27",
+ "tslib": "^2.8.1",
+ "worker-factory": "^7.0.50"
+ }
+ },
+ "node_modules/buffer": {
+ "version": "6.0.3",
+ "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz",
+ "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/feross"
+ },
+ {
+ "type": "patreon",
+ "url": "https://www.patreon.com/feross"
+ },
+ {
+ "type": "consulting",
+ "url": "https://feross.org/support"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "base64-js": "^1.3.1",
+ "ieee754": "^1.2.1"
+ }
+ },
+ "node_modules/buffer-from": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz",
+ "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==",
+ "license": "MIT"
+ },
"node_modules/bytes": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
@@ -102,6 +226,41 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/commist": {
+ "version": "3.2.0",
+ "resolved": "https://registry.npmjs.org/commist/-/commist-3.2.0.tgz",
+ "integrity": "sha512-4PIMoPniho+LqXmpS5d3NuGYncG6XWlkBSVGiWycL22dd42OYdUGil2CWuzklaJoNxyxUSpO4MKIBU94viWNAw==",
+ "license": "MIT"
+ },
+ "node_modules/concat-stream": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-2.0.0.tgz",
+ "integrity": "sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==",
+ "engines": [
+ "node >= 6.0"
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "buffer-from": "^1.0.0",
+ "inherits": "^2.0.3",
+ "readable-stream": "^3.0.2",
+ "typedarray": "^0.0.6"
+ }
+ },
+ "node_modules/concat-stream/node_modules/readable-stream": {
+ "version": "3.6.2",
+ "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz",
+ "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==",
+ "license": "MIT",
+ "dependencies": {
+ "inherits": "^2.0.3",
+ "string_decoder": "^1.1.1",
+ "util-deprecate": "^1.0.1"
+ },
+ "engines": {
+ "node": ">= 6"
+ }
+ },
"node_modules/content-disposition": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz",
@@ -242,6 +401,24 @@
"node": ">= 0.6"
}
},
+ "node_modules/event-target-shim": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz",
+ "integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/events": {
+ "version": "3.3.0",
+ "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz",
+ "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.8.x"
+ }
+ },
"node_modules/express": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz",
@@ -285,6 +462,19 @@
"url": "https://opencollective.com/express"
}
},
+ "node_modules/fast-unique-numbers": {
+ "version": "9.0.27",
+ "resolved": "https://registry.npmjs.org/fast-unique-numbers/-/fast-unique-numbers-9.0.27.tgz",
+ "integrity": "sha512-nDA9ADeINN8SA2u2wCtU+siWFTTDqQR37XvgPIDDmboWQeExz7X0mImxuaN+kJddliIqy2FpVRmnvRZ+j8i1/A==",
+ "license": "MIT",
+ "dependencies": {
+ "@babel/runtime": "^7.29.2",
+ "tslib": "^2.8.1"
+ },
+ "engines": {
+ "node": ">=18.2.0"
+ }
+ },
"node_modules/finalhandler": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz",
@@ -406,6 +596,12 @@
"node": ">= 0.4"
}
},
+ "node_modules/help-me": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/help-me/-/help-me-5.0.0.tgz",
+ "integrity": "sha512-7xgomUX6ADmcYzFik0HzAxh/73YlKR9bmFzf51CZwR+b6YtzU2m0u49hQCqV6SvlqIqsaxovfwdvbnsw3b/zpg==",
+ "license": "MIT"
+ },
"node_modules/http-errors": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
@@ -442,12 +638,41 @@
"url": "https://opencollective.com/express"
}
},
+ "node_modules/ieee754": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
+ "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/feross"
+ },
+ {
+ "type": "patreon",
+ "url": "https://www.patreon.com/feross"
+ },
+ {
+ "type": "consulting",
+ "url": "https://feross.org/support"
+ }
+ ],
+ "license": "BSD-3-Clause"
+ },
"node_modules/inherits": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
"license": "ISC"
},
+ "node_modules/ip-address": {
+ "version": "10.4.0",
+ "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.4.0.tgz",
+ "integrity": "sha512-oSK96Grm3aP6OrS263xVxbNDGVL7rzBtYdpGqlDG8iQdoenDoTs/nkki+DflYbAEE8Xl6o5YxhxlrKvI3nqKXQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 12"
+ }
+ },
"node_modules/ipaddr.js": {
"version": "1.9.1",
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
@@ -463,6 +688,22 @@
"integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==",
"license": "MIT"
},
+ "node_modules/js-sdsl": {
+ "version": "4.3.0",
+ "resolved": "https://registry.npmjs.org/js-sdsl/-/js-sdsl-4.3.0.tgz",
+ "integrity": "sha512-mifzlm2+5nZ+lEcLJMoBK0/IH/bDg8XnJfd/Wq6IP+xoCjLZsTOnV2QpxlVbX9bMnkl5PdEjNtBJ9Cj1NjifhQ==",
+ "license": "MIT",
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/js-sdsl"
+ }
+ },
+ "node_modules/lru-cache": {
+ "version": "10.4.3",
+ "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz",
+ "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==",
+ "license": "ISC"
+ },
"node_modules/math-intrinsics": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
@@ -518,6 +759,58 @@
"url": "https://opencollective.com/express"
}
},
+ "node_modules/minimist": {
+ "version": "1.2.8",
+ "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
+ "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==",
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/mqtt": {
+ "version": "5.15.2",
+ "resolved": "https://registry.npmjs.org/mqtt/-/mqtt-5.15.2.tgz",
+ "integrity": "sha512-VWZU2CSUY3U3oN0PSBRDE5SNsFi4zqqNeQ/uv3pZWqY3CrBXD/dhd0ZLjlsk5YnebGlrapi4lRVNJPUNQ5aZ3w==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/readable-stream": "^4.0.21",
+ "@types/ws": "^8.18.1",
+ "commist": "^3.2.0",
+ "concat-stream": "^2.0.0",
+ "debug": "^4.4.1",
+ "help-me": "^5.0.0",
+ "lru-cache": "^10.4.3",
+ "minimist": "^1.2.8",
+ "mqtt-packet": "^9.0.2",
+ "number-allocator": "^1.0.14",
+ "readable-stream": "^4.7.0",
+ "rfdc": "^1.4.1",
+ "socks": "^2.8.6",
+ "split2": "^4.2.0",
+ "worker-timers": "^8.0.23",
+ "ws": "^8.18.3"
+ },
+ "bin": {
+ "mqtt": "build/bin/mqtt.js",
+ "mqtt_pub": "build/bin/pub.js",
+ "mqtt_sub": "build/bin/sub.js"
+ },
+ "engines": {
+ "node": ">=16.0.0"
+ }
+ },
+ "node_modules/mqtt-packet": {
+ "version": "9.0.2",
+ "resolved": "https://registry.npmjs.org/mqtt-packet/-/mqtt-packet-9.0.2.tgz",
+ "integrity": "sha512-MvIY0B8/qjq7bKxdN1eD+nrljoeaai+qjLJgfRn3TiMuz0pamsIWY2bFODPZMSNmabsLANXsLl4EMoWvlaTZWA==",
+ "license": "MIT",
+ "dependencies": {
+ "bl": "^6.0.8",
+ "debug": "^4.3.4",
+ "process-nextick-args": "^2.0.1"
+ }
+ },
"node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
@@ -533,6 +826,16 @@
"node": ">= 0.6"
}
},
+ "node_modules/number-allocator": {
+ "version": "1.0.14",
+ "resolved": "https://registry.npmjs.org/number-allocator/-/number-allocator-1.0.14.tgz",
+ "integrity": "sha512-OrL44UTVAvkKdOdRQZIJpLkAdjXGTRda052sN4sO77bKEzYYqWKMBjQvrJFzqygI99gL6Z4u2xctPW1tB8ErvA==",
+ "license": "MIT",
+ "dependencies": {
+ "debug": "^4.3.1",
+ "js-sdsl": "4.3.0"
+ }
+ },
"node_modules/object-inspect": {
"version": "1.13.4",
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
@@ -585,6 +888,21 @@
"url": "https://opencollective.com/express"
}
},
+ "node_modules/process": {
+ "version": "0.11.10",
+ "resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz",
+ "integrity": "sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6.0"
+ }
+ },
+ "node_modules/process-nextick-args": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz",
+ "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==",
+ "license": "MIT"
+ },
"node_modules/proxy-addr": {
"version": "2.0.7",
"resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
@@ -642,6 +960,28 @@
"node": ">= 0.10"
}
},
+ "node_modules/readable-stream": {
+ "version": "4.7.0",
+ "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-4.7.0.tgz",
+ "integrity": "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==",
+ "license": "MIT",
+ "dependencies": {
+ "abort-controller": "^3.0.0",
+ "buffer": "^6.0.3",
+ "events": "^3.3.0",
+ "process": "^0.11.10",
+ "string_decoder": "^1.3.0"
+ },
+ "engines": {
+ "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
+ }
+ },
+ "node_modules/rfdc": {
+ "version": "1.4.1",
+ "resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz",
+ "integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==",
+ "license": "MIT"
+ },
"node_modules/router": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz",
@@ -658,6 +998,26 @@
"node": ">= 18"
}
},
+ "node_modules/safe-buffer": {
+ "version": "5.2.1",
+ "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
+ "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/feross"
+ },
+ {
+ "type": "patreon",
+ "url": "https://www.patreon.com/feross"
+ },
+ {
+ "type": "consulting",
+ "url": "https://feross.org/support"
+ }
+ ],
+ "license": "MIT"
+ },
"node_modules/safer-buffer": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
@@ -787,6 +1147,39 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/smart-buffer": {
+ "version": "4.2.0",
+ "resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz",
+ "integrity": "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 6.0.0",
+ "npm": ">= 3.0.0"
+ }
+ },
+ "node_modules/socks": {
+ "version": "2.8.9",
+ "resolved": "https://registry.npmjs.org/socks/-/socks-2.8.9.tgz",
+ "integrity": "sha512-LJhUYUvItdQ0LkJTmPeaEObWXAqFyfmP85x0tch/ez9cahmhlBBLbIqDFnvBnUJGagb0JbIQrkBs1wJ+yRYpEw==",
+ "license": "MIT",
+ "dependencies": {
+ "ip-address": "^10.1.1",
+ "smart-buffer": "^4.2.0"
+ },
+ "engines": {
+ "node": ">= 10.0.0",
+ "npm": ">= 3.0.0"
+ }
+ },
+ "node_modules/split2": {
+ "version": "4.2.0",
+ "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz",
+ "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==",
+ "license": "ISC",
+ "engines": {
+ "node": ">= 10.x"
+ }
+ },
"node_modules/statuses": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
@@ -796,6 +1189,15 @@
"node": ">= 0.8"
}
},
+ "node_modules/string_decoder": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz",
+ "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==",
+ "license": "MIT",
+ "dependencies": {
+ "safe-buffer": "~5.2.0"
+ }
+ },
"node_modules/toidentifier": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
@@ -805,6 +1207,12 @@
"node": ">=0.6"
}
},
+ "node_modules/tslib": {
+ "version": "2.8.1",
+ "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
+ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
+ "license": "0BSD"
+ },
"node_modules/type-is": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz",
@@ -836,6 +1244,18 @@
"url": "https://opencollective.com/express"
}
},
+ "node_modules/typedarray": {
+ "version": "0.0.6",
+ "resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz",
+ "integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==",
+ "license": "MIT"
+ },
+ "node_modules/undici-types": {
+ "version": "8.3.0",
+ "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz",
+ "integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==",
+ "license": "MIT"
+ },
"node_modules/unpipe": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
@@ -845,6 +1265,12 @@
"node": ">= 0.8"
}
},
+ "node_modules/util-deprecate": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
+ "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
+ "license": "MIT"
+ },
"node_modules/vary": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",
@@ -854,11 +1280,79 @@
"node": ">= 0.8"
}
},
+ "node_modules/worker-factory": {
+ "version": "7.0.50",
+ "resolved": "https://registry.npmjs.org/worker-factory/-/worker-factory-7.0.50.tgz",
+ "integrity": "sha512-hhwc0G+sFwM4qBuhJIUBn2p1Jf8v/FwmLUANBf/Q+Lt2uI8mfIZQhXaZQACodQD4R7Zp6cn/6702bIvNn2puJQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@babel/runtime": "^7.29.7",
+ "fast-unique-numbers": "^9.0.27",
+ "tslib": "^2.8.1"
+ }
+ },
+ "node_modules/worker-timers": {
+ "version": "8.0.34",
+ "resolved": "https://registry.npmjs.org/worker-timers/-/worker-timers-8.0.34.tgz",
+ "integrity": "sha512-WXL+Dqsm0G6dnC66rQsvM3tPT2adhbqSirWUCZGglkALOr8ocS0KlpU0iuKbjflJOlu3ydZikMEVrYnHOM9suw==",
+ "license": "MIT",
+ "dependencies": {
+ "@babel/runtime": "^7.29.7",
+ "tslib": "^2.8.1",
+ "worker-timers-broker": "^8.0.18",
+ "worker-timers-worker": "^9.0.15"
+ }
+ },
+ "node_modules/worker-timers-broker": {
+ "version": "8.0.18",
+ "resolved": "https://registry.npmjs.org/worker-timers-broker/-/worker-timers-broker-8.0.18.tgz",
+ "integrity": "sha512-FrjzDVX1wKfZN0gRbCFqv8VHuTncG4sbI/WGEg4tSSQeIsnwqg4YBYWMAHYLJtUDEYYmiK65UKFrVMVk2irDSg==",
+ "license": "MIT",
+ "dependencies": {
+ "@babel/runtime": "^7.29.7",
+ "broker-factory": "^3.1.15",
+ "fast-unique-numbers": "^9.0.27",
+ "tslib": "^2.8.1",
+ "worker-timers-worker": "^9.0.15"
+ }
+ },
+ "node_modules/worker-timers-worker": {
+ "version": "9.0.15",
+ "resolved": "https://registry.npmjs.org/worker-timers-worker/-/worker-timers-worker-9.0.15.tgz",
+ "integrity": "sha512-KKUe7lZ/Aignr51H6hOUik8LwTnIgojH/1lwhli8A8qIEIyewogZTpNpMW5B6BF7nmwBOkUoTYgf1H3QShcjSA==",
+ "license": "MIT",
+ "dependencies": {
+ "@babel/runtime": "^7.29.7",
+ "tslib": "^2.8.1",
+ "worker-factory": "^7.0.50"
+ }
+ },
"node_modules/wrappy": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
"license": "ISC"
+ },
+ "node_modules/ws": {
+ "version": "8.21.1",
+ "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.1.tgz",
+ "integrity": "sha512-+0NTnW77fFN/DjQi6k/Sq/Yvk4Sgajw7urW8V+asjXnRgDs9gyGkdb7EzgfhA4goXsRIZKE28fzIXBHEzhuiWw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=10.0.0"
+ },
+ "peerDependencies": {
+ "bufferutil": "^4.0.1",
+ "utf-8-validate": ">=5.0.2"
+ },
+ "peerDependenciesMeta": {
+ "bufferutil": {
+ "optional": true
+ },
+ "utf-8-validate": {
+ "optional": true
+ }
+ }
}
}
}
diff --git a/package.json b/package.json
index 67560da..a86205f 100644
--- a/package.json
+++ b/package.json
@@ -1,8 +1,23 @@
{
"name": "mmm-messagecenter",
"version": "0.1.0",
- "description": "Webhook-driven message center for MagicMirror² and MMM-pages",
+ "description": "Hardware-independent notification and attention hub for MagicMirror²",
"private": true,
+ "author": "Brian Wente",
+ "license": "MIT",
+ "repository": {
+ "type": "git",
+ "url": "git+https://github.com/bwente/MMM-MessageCenter.git"
+ },
+ "homepage": "https://github.com/bwente/MMM-MessageCenter#readme",
+ "bugs": {
+ "url": "https://github.com/bwente/MMM-MessageCenter/issues"
+ },
+ "keywords": [
+ "magicmirror",
+ "notifications",
+ "home-assistant"
+ ],
"scripts": {
"test": "node --test"
},
@@ -10,6 +25,7 @@
"node": ">=18"
},
"dependencies": {
- "express": "^5.1.0"
+ "express": "^5.1.0",
+ "mqtt": "^5.15.2"
}
}
diff --git a/test/MMM-MessageCenter.test.js b/test/MMM-MessageCenter.test.js
index b74288c..a2b1a4e 100644
--- a/test/MMM-MessageCenter.test.js
+++ b/test/MMM-MessageCenter.test.js
@@ -22,9 +22,13 @@ function instance(config = {}) {
webhook: { ...definition.defaults.webhook, ...config.webhook }
},
currentPage: null,
+ maxPages: null,
messages: [],
unreadAttentionCount: 0,
returnTimer: null,
+ autoNavigation: null,
+ expirationTimer: null,
+ pendingView: null,
notifications,
updateDom() {},
sendNotification(name, payload) {
@@ -34,10 +38,73 @@ function instance(config = {}) {
};
}
+function withGlobalConfig(value, callback) {
+ const hadConfig = Object.prototype.hasOwnProperty.call(global, "config");
+ const previous = global.config;
+ global.config = value;
+ try {
+ return callback();
+ } finally {
+ if (hadConfig) global.config = previous;
+ else delete global.config;
+ }
+}
+
test("defaults the message center to page four", () => {
assert.equal(definition.defaults.messagesPage, 4);
});
+test("defaults to the full page display mode", () => {
+ const module = instance();
+ module.messages = [{ id: "one" }, { id: "two" }, { id: "three" }, { id: "four" }];
+
+ assert.equal(module.getDisplayMode(), "page");
+ assert.equal(module.getDisplayedMessages().length, 4);
+});
+
+test("compact mode limits visible history without changing the queue", () => {
+ const module = instance({ displayMode: "compact", compactMaxMessages: 2 });
+ module.messages = [{ id: "one" }, { id: "two" }, { id: "three" }];
+
+ assert.equal(module.getDisplayMode(), "compact");
+ assert.deepEqual(module.getDisplayedMessages().map(({ id }) => id), ["one", "two"]);
+ assert.equal(module.messages.length, 3);
+});
+
+test("compact mode falls back safely for an invalid visible-message limit", () => {
+ const module = instance({ displayMode: "compact", compactMaxMessages: 0 });
+ module.messages = [{ id: "one" }, { id: "two" }, { id: "three" }, { id: "four" }];
+
+ assert.equal(module.getDisplayedMessages().length, definition.defaults.compactMaxMessages);
+});
+
+test("limits full-page rendering without changing retained history", () => {
+ const module = instance({ maxVisibleMessages: 2 });
+ module.messages = [{ id: "one" }, { id: "two" }, { id: "three" }];
+
+ assert.deepEqual(module.getDisplayedMessages().map(({ id }) => id), ["one", "two"]);
+ assert.equal(module.messages.length, 3);
+});
+
+test("generic visible-message limit takes precedence in compact mode", () => {
+ const module = instance({
+ displayMode: "compact",
+ maxVisibleMessages: 1,
+ compactMaxMessages: 3
+ });
+ module.messages = [{ id: "one" }, { id: "two" }, { id: "three" }];
+
+ assert.deepEqual(module.getDisplayedMessages().map(({ id }) => id), ["one"]);
+});
+
+test("supports non-touch presentation without removing the header", () => {
+ const page = instance({ showControls: false });
+ const compact = instance({ displayMode: "compact", compactShowControls: true });
+
+ assert.equal(page.shouldShowMessageControls(), false);
+ assert.equal(compact.shouldShowMessageControls(), true);
+});
+
test("normalizes an attention message", () => {
const module = instance();
const message = module.normalizeMessage({
@@ -50,9 +117,45 @@ test("normalizes an attention message", () => {
assert.equal(message.id, "7");
assert.equal(message.source, "home-assistant");
assert.equal(message.priority, "attention");
+ assert.equal(message.urgency, "attention");
+ assert.equal(message.retention, "untilViewed");
assert.equal(message.unread, true);
});
+test("normalizes the refined contract independently of legacy priority", () => {
+ const module = instance();
+ const message = module.normalizeMessage({
+ entityId: "dishwasher",
+ urgency: "critical",
+ retention: "untilAcknowledged"
+ });
+
+ assert.equal(message.entityId, "dishwasher");
+ assert.equal(message.urgency, "critical");
+ assert.equal(message.retention, "untilAcknowledged");
+ assert.equal(message.priority, "attention");
+ assert.equal(message.unread, true);
+
+ const criticalDefault = module.normalizeMessage({ urgency: "critical" });
+ assert.equal(criticalDefault.retention, "untilAcknowledged");
+});
+
+test("normalizes only cached image snapshots", () => {
+ const module = instance();
+ const dataUrl = `data:image/png;base64,${Buffer.from("snapshot").toString("base64")}`;
+ const message = module.normalizeMessage({
+ image: { dataUrl, alt: "Person at the front door", capturedAt: 1234 }
+ });
+
+ assert.deepEqual(message.image, {
+ dataUrl,
+ alt: "Person at the front door",
+ capturedAt: 1234
+ });
+ assert.equal(module.normalizeMessage({ image: { url: "https://example.com/live.jpg" } }).image, null);
+ assert.equal(module.normalizeMessage({ image: { dataUrl: "data:text/html;base64,AAAA" } }).image, null);
+});
+
test("rejects invalid and expired messages", () => {
const module = instance();
@@ -61,6 +164,15 @@ test("rejects invalid and expired messages", () => {
assert.equal(module.normalizeMessage({ expires: Date.now() - 1 }), null);
});
+test("replaces an out-of-range timestamp with the current time", () => {
+ const module = instance();
+ const before = Date.now();
+ const message = module.normalizeMessage({ timestamp: Number.MAX_VALUE });
+
+ assert.ok(message.timestamp >= before);
+ assert.ok(message.timestamp <= Date.now());
+});
+
test("stores messages, raises attention, and shows a toast", () => {
const module = instance();
@@ -73,7 +185,7 @@ test("stores messages, raises attention, and shows a toast", () => {
assert.equal(module.unreadAttentionCount, 1);
assert.deepEqual(
module.notifications.map(({ name }) => name),
- ["ATTENTION_ON", "SHOW_ALERT"]
+ ["MESSAGE_CENTER_ATTENTION_CHANGED", "ATTENTION_ON", "SHOW_ALERT"]
);
});
@@ -90,19 +202,816 @@ test("caps stored messages at maxMessages", () => {
);
});
-test("clears attention when the message page is viewed", () => {
+test("keeps only the newest configured image count without removing history", () => {
+ const module = instance({
+ showToasts: false,
+ images: {
+ ...definition.defaults.images,
+ maxCachedImages: 2,
+ maxTotalBytes: 100
+ }
+ });
+ const image = (value) => ({
+ dataUrl: `data:image/png;base64,${Buffer.from(value).toString("base64")}`,
+ alt: "Snapshot"
+ });
+
+ module.receiveMessage({ id: "one", timestamp: 1, image: image("1111") });
+ module.receiveMessage({ id: "two", timestamp: 2, image: image("2222") });
+ module.receiveMessage({ id: "three", timestamp: 3, image: image("3333") });
+
+ assert.equal(module.messages.length, 3);
+ assert.deepEqual(module.messages.map(({ id }) => id), ["three", "two", "one"]);
+ assert.equal(module.messages[0].image !== null, true);
+ assert.equal(module.messages[1].image !== null, true);
+ assert.equal(module.messages[2].image, null);
+});
+
+test("releases oldest image data when the total-byte budget is reached", () => {
+ const module = instance({
+ showToasts: false,
+ images: {
+ ...definition.defaults.images,
+ maxCachedImages: 12,
+ maxTotalBytes: 7
+ }
+ });
+ const dataUrl = (value) => `data:image/png;base64,${Buffer.from(value).toString("base64")}`;
+
+ module.messages = [
+ { id: "new", image: { dataUrl: dataUrl("1111") } },
+ { id: "old", image: { dataUrl: dataUrl("2222") } }
+ ];
+
+ assert.equal(module.pruneCachedImages(), 1);
+ assert.equal(module.messages[0].image !== null, true);
+ assert.equal(module.messages[1].image, null);
+});
+
+test("falls back safely for invalid image-cache limits", () => {
+ const module = instance({ images: { maxCachedImages: -1, maxTotalBytes: -1 } });
+
+ assert.deepEqual(module.getImageCacheLimits(), {
+ maxCachedImages: 12,
+ maxTotalBytes: 12 * 1024 * 1024
+ });
+});
+
+test("explicit ephemeral messages toast without entering inbox history", () => {
const module = instance();
- module.messages = [{ unread: true }];
+
+ module.socketNotificationReceived("MC_MESSAGE", {
+ title: "Temperature updated",
+ urgency: "passive",
+ retention: "ephemeral"
+ });
+
+ assert.equal(module.messages.length, 0);
+ assert.deepEqual(module.notifications.map(({ name }) => name), ["SHOW_ALERT"]);
+});
+
+test("an ephemeral update removes matching retained attention cleanly", () => {
+ const module = instance({ showToasts: false });
+
+ module.socketNotificationReceived("MC_MESSAGE", {
+ id: "door",
+ source: "entry",
+ urgency: "attention",
+ retention: "untilViewed"
+ });
+ module.socketNotificationReceived("MC_MESSAGE", {
+ id: "door",
+ source: "entry",
+ urgency: "passive",
+ retention: "ephemeral"
+ });
+
+ assert.equal(module.messages.length, 0);
+ assert.equal(module.unreadAttentionCount, 0);
+ assert.equal(module.notifications.at(-1).name, "ATTENTION_OFF");
+});
+
+test("attention count follows unread messages retained by the inbox", () => {
+ const module = instance({ maxMessages: 2, showToasts: false });
+
+ module.socketNotificationReceived("MC_MESSAGE", {
+ title: "Attention",
+ priority: "attention"
+ });
+ module.socketNotificationReceived("MC_MESSAGE", { title: "Update one" });
+ module.socketNotificationReceived("MC_MESSAGE", { title: "Update two" });
+
+ assert.equal(module.unreadAttentionCount, 0);
+ assert.equal(module.notifications.at(-1).name, "ATTENTION_OFF");
+});
+
+test("replaces duplicate source and id messages with the newest copy", () => {
+ const module = instance({ showToasts: false });
+
+ module.socketNotificationReceived("MC_MESSAGE", {
+ id: "cycle-42",
+ source: "dishwasher",
+ title: "Running"
+ });
+ module.socketNotificationReceived("MC_MESSAGE", {
+ id: "cycle-42",
+ source: "dishwasher",
+ title: "Complete"
+ });
+
+ assert.equal(module.messages.length, 1);
+ assert.equal(module.messages[0].title, "Complete");
+});
+
+test("ignores stale asynchronous updates and orders history by timestamp", () => {
+ const module = instance({ showToasts: false });
+ module.receiveMessage({ id: "door", source: "camera", title: "New", timestamp: 200 });
+ module.receiveMessage({ id: "weather", source: "weather", title: "Earlier", timestamp: 150 });
+
+ assert.equal(
+ module.receiveMessage({ id: "door", source: "camera", title: "Old", timestamp: 100 }),
+ false
+ );
+ assert.deepEqual(module.messages.map(({ title }) => title), ["New", "Earlier"]);
+});
+
+test("reports separate total, unread, and read history counts", () => {
+ const module = instance({ showToasts: false });
+ module.messages = [
+ { unread: true },
+ { unread: false },
+ { unread: false }
+ ];
+
+ assert.deepEqual(module.getMessageCounts(), { total: 3, unread: 1, read: 2 });
+});
+
+test("clear read keeps unread messages and removes read history", () => {
+ const module = instance({ showToasts: false });
+ module.messages = [
+ { id: "new", unread: true },
+ { id: "old", unread: false }
+ ];
+
+ assert.equal(module.clearRead(), true);
+ assert.deepEqual(module.messages.map(({ id }) => id), ["new"]);
+ assert.equal(module.clearRead(), false);
+});
+
+test("acknowledges one message without changing its retained history", () => {
+ const module = instance({ showToasts: false });
+ module.messages = [
+ { id: "door", source: "entry", urgency: "critical", unread: true },
+ { id: "rain", source: "weather", urgency: "attention", unread: true }
+ ];
+ module.unreadAttentionCount = 2;
+
+ assert.equal(module.acknowledgeMessage("entry", "door"), true);
+ assert.deepEqual(module.messages.map(({ id, unread }) => ({ id, unread })), [
+ { id: "door", unread: false },
+ { id: "rain", unread: true }
+ ]);
+ assert.equal(module.unreadAttentionCount, 1);
+ assert.equal(module.acknowledgeMessage("entry", "door"), false);
+});
+
+test("dismisses one message and clears its attention", () => {
+ const module = instance({ showToasts: false });
+ module.messages = [
+ { id: "door", source: "entry", urgency: "critical", unread: true },
+ { id: "door", source: "garage", urgency: "attention", unread: true }
+ ];
+ module.unreadAttentionCount = 2;
+
+ assert.equal(module.dismissMessage("entry", "door"), true);
+ assert.deepEqual(module.messages.map(({ source }) => source), ["garage"]);
+ assert.equal(module.unreadAttentionCount, 1);
+});
+
+test("accepts targeted acknowledgement and dismissal notifications", () => {
+ const module = instance({ showToasts: false });
+ module.messages = [
+ { id: "one", source: "chores", urgency: "attention", unread: true },
+ { id: "two", source: "chores", urgency: "passive", unread: false }
+ ];
+ module.unreadAttentionCount = 1;
+
+ module.notificationReceived("MC_ACK_MESSAGE", { source: "chores", id: "one" });
+ module.notificationReceived("MC_DISMISS_MESSAGE", { source: "chores", id: "two" });
+
+ assert.deepEqual(module.messages.map(({ id, unread }) => ({ id, unread })), [
+ { id: "one", unread: false }
+ ]);
+ assert.equal(module.unreadAttentionCount, 0);
+});
+
+test("ignores malformed or unknown targeted message commands", () => {
+ const module = instance({ showToasts: false });
+ module.messages = [{ id: "one", source: "chores", unread: true }];
+
+ assert.equal(module.handleMessageCommand(null, "acknowledge"), false);
+ assert.equal(module.handleMessageCommand({ source: "chores" }, "dismiss"), false);
+ assert.equal(
+ module.handleMessageCommand({ source: "chores", id: "missing" }, "acknowledge"),
+ false
+ );
+ assert.equal(module.messages[0].unread, true);
+});
+
+test("presents friendly labels for known internal sources", () => {
+ const module = instance();
+
+ assert.equal(module.getMessageSourceLabel("magicmirror.weather"), "Weather");
+ assert.equal(module.getMessageSourceLabel("magicmirror.remote-control"), "Remote Control");
+ assert.equal(module.getMessageSourceLabel("home-assistant"), "Home Assistant");
+ assert.equal(
+ module.getMessageSourceLabel("home-assistant.smartthings"),
+ "SmartThings via Home Assistant"
+ );
+ assert.equal(module.getMessageSourceLabel("custom-source"), "custom-source");
+});
+
+test("formats timestamps using MagicMirror 12-hour preferences", () => {
+ const module = instance();
+ const date = new Date(2026, 6, 23, 14, 5, 6);
+
+ withGlobalConfig({ timeFormat: 12, locale: "en-US" }, () => {
+ assert.match(module.formatMessageTimestamp(date), /2:05:06\s*PM/i);
+ assert.match(module.formatClockTime(date), /2:05\s*PM/i);
+ });
+});
+
+test("formats timestamps using MagicMirror 24-hour preferences", () => {
+ const module = instance();
+ const date = new Date(2026, 6, 23, 14, 5, 6);
+
+ withGlobalConfig({ timeFormat: 24, locale: "en-US" }, () => {
+ assert.match(module.formatMessageTimestamp(date), /14:05:06/);
+ assert.match(module.formatClockTime(date), /14:05/);
+ assert.doesNotMatch(module.formatClockTime(date), /AM|PM/i);
+ });
+});
+
+test("compact mode uses a time-only metadata timestamp", () => {
+ const module = instance({ displayMode: "compact" });
+ const date = new Date(2026, 6, 23, 14, 5, 6);
+
+ withGlobalConfig({ timeFormat: 12, locale: "en-US" }, () => {
+ assert.match(module.formatDisplayedTimestamp(date), /^2:05\s*PM$/i);
+ });
+});
+
+test("prefers MagicMirror locale and falls back to language", () => {
+ const module = instance();
+ const date = new Date(2026, 6, 23, 14, 5, 6);
+
+ withGlobalConfig({ timeFormat: 24, locale: "en-GB", language: "en-US" }, () => {
+ assert.match(module.formatMessageTimestamp(date), /^23\/07\/2026/);
+ });
+ withGlobalConfig({ timeFormat: 24, locale: "", language: "en-US" }, () => {
+ assert.match(module.formatMessageTimestamp(date), /^7\/23\/2026/);
+ });
+});
+
+test("falls back safely when global date and time preferences are invalid", () => {
+ const module = instance();
+ const date = new Date(2026, 6, 23, 14, 5, 6);
+
+ withGlobalConfig({ timeFormat: "twelve", locale: "not_a_locale" }, () => {
+ assert.ok(module.formatMessageTimestamp(date).length > 0);
+ assert.ok(module.formatClockTime(date).length > 0);
+ });
+ withGlobalConfig(undefined, () => {
+ assert.ok(module.formatMessageTimestamp(date).length > 0);
+ });
+ assert.equal(module.formatMessageTimestamp("not-a-date"), "");
+});
+
+test("reformats metadata without rewriting a stored message body", () => {
+ const module = instance();
+ const message = {
+ body: "Rain is expected around 15:00.",
+ timestamp: new Date(2026, 6, 23, 14, 5, 6).getTime()
+ };
+
+ const twelveHourMetadata = withGlobalConfig(
+ { timeFormat: 12, locale: "en-US" },
+ () => module.formatMessageTimestamp(message.timestamp)
+ );
+ const twentyFourHourMetadata = withGlobalConfig(
+ { timeFormat: 24, locale: "en-US" },
+ () => module.formatMessageTimestamp(message.timestamp)
+ );
+
+ assert.match(twelveHourMetadata, /2:05:06\s*PM/i);
+ assert.match(twentyFourHourMetadata, /14:05:06/);
+ assert.equal(message.body, "Rain is expected around 15:00.");
+});
+
+test("ignores an equivalent webhook retry without repeating its toast", () => {
+ const module = instance();
+ const payload = {
+ id: "cycle-42",
+ source: "dishwasher",
+ title: "Complete",
+ body: "The dishes are done"
+ };
+
+ module.socketNotificationReceived("MC_MESSAGE", payload);
+ module.socketNotificationReceived("MC_MESSAGE", payload);
+
+ assert.equal(module.messages.length, 1);
+ assert.equal(
+ module.notifications.filter(({ name }) => name === "SHOW_ALERT").length,
+ 1
+ );
+});
+
+test("keeps identical ids from different sources", () => {
+ const module = instance({ showToasts: false });
+
+ module.socketNotificationReceived("MC_MESSAGE", { id: "open", source: "front-door" });
+ module.socketNotificationReceived("MC_MESSAGE", { id: "open", source: "garage" });
+
+ assert.equal(module.messages.length, 2);
+});
+
+function weatherInstance(rain = {}) {
+ const module = instance({
+ channelRoutes: { weather: 2 },
+ internalNotifications: {
+ weather: {
+ enabled: true,
+ rain: { timeout: 0, ...rain }
+ }
+ }
+ });
+ module.maxPages = 6;
+ module.currentPage = 0;
+ return module;
+}
+
+test("turns a provider-neutral hourly rain forecast into a message", () => {
+ const module = weatherInstance();
+ const now = Date.now();
+
+ assert.equal(
+ module.handleWeatherUpdated(
+ {
+ locationName: "Baltimore",
+ providerName: "openmeteo",
+ hourlyArray: [
+ {
+ date: now + 60 * 60000,
+ weatherType: "rain",
+ precipitationProbability: 70,
+ precipitationAmount: 0.8
+ }
+ ]
+ },
+ now
+ ),
+ true
+ );
+
+ assert.equal(module.messages.length, 1);
+ assert.equal(module.messages[0].id, "rain-next-hour");
+ assert.equal(module.messages[0].type, "weather.precipitation");
+ assert.equal(module.messages[0].source, "magicmirror.weather");
+ assert.equal(module.messages[0].entityId, "local-weather");
+ assert.equal(module.messages[0].urgency, "attention");
+ assert.equal(module.messages[0].retention, "untilViewed");
+ assert.match(module.messages[0].body, /Baltimore/);
+ assert.equal(
+ module.notifications.some(
+ ({ name, payload }) => name === "PAGE_CHANGED" && payload === 2
+ ),
+ true
+ );
+});
+
+test("uses MagicMirror clock preferences in newly generated weather text", () => {
+ const future = new Date();
+ future.setDate(future.getDate() + 1);
+ future.setHours(14, 0, 0, 0);
+ const now = future.getTime();
+ const hourlyArray = [
+ {
+ date: now + 60 * 60000,
+ weatherType: "rain",
+ precipitationProbability: 70
+ }
+ ];
+ const twelveHourModule = weatherInstance();
+ const twentyFourHourModule = weatherInstance();
+
+ withGlobalConfig({ timeFormat: 12, locale: "en-US" }, () => {
+ twelveHourModule.handleWeatherUpdated({ hourlyArray }, now);
+ });
+ withGlobalConfig({ timeFormat: 24, locale: "en-US" }, () => {
+ twentyFourHourModule.handleWeatherUpdated({ hourlyArray }, now);
+ });
+
+ assert.match(twelveHourModule.messages[0].body, /around 3:00\s*PM\./i);
+ assert.match(twentyFourHourModule.messages[0].body, /around 15:00\./);
+ assert.doesNotMatch(twentyFourHourModule.messages[0].body, /AM|PM/i);
+});
+
+test("receives weather through the normal MagicMirror notification path", () => {
+ const module = weatherInstance();
+ const now = Date.now();
+
+ module.notificationReceived("WEATHER_UPDATED", {
+ hourlyArray: [
+ {
+ date: now + 60 * 60000,
+ rain: 0.5,
+ precipitationProbability: 65
+ }
+ ]
+ });
+
+ assert.equal(module.messages.length, 1);
+ assert.equal(module.messages[0].type, "weather.precipitation");
+});
+
+test("leaves weather broadcasts disabled by default", () => {
+ const module = instance();
+ const now = Date.now();
+
+ module.notificationReceived("WEATHER_UPDATED", {
+ hourlyArray: [
+ {
+ date: now + 60 * 60000,
+ weatherType: "rain",
+ precipitationProbability: 90
+ }
+ ]
+ });
+
+ assert.equal(module.messages.length, 0);
+});
+
+test("does not repeat a rain alert while the same event remains active", () => {
+ const module = weatherInstance();
+ const now = Date.now();
+ const payload = {
+ hourlyArray: [
+ {
+ date: now + 60 * 60000,
+ weatherType: "showers",
+ precipitationProbability: 80
+ }
+ ]
+ };
+
+ module.handleWeatherUpdated(payload, now);
+ module.handleWeatherUpdated(payload, now + 10 * 60000);
+
+ assert.equal(module.messages.length, 1);
+ assert.equal(
+ module.notifications.filter(({ name }) => name === "SHOW_ALERT").length,
+ 1
+ );
+});
+
+test("resolves the rain message when a later hourly update is dry", () => {
+ const module = weatherInstance();
+ const now = Date.now();
+
+ module.handleWeatherUpdated(
+ {
+ hourlyArray: [
+ {
+ date: now + 60 * 60000,
+ weatherType: "rain",
+ precipitationProbability: 75
+ }
+ ]
+ },
+ now
+ );
+ module.handleWeatherUpdated(
+ {
+ hourlyArray: [
+ {
+ date: now + 70 * 60000,
+ weatherType: "cloudy",
+ precipitationProbability: 10,
+ precipitationAmount: 0
+ }
+ ]
+ },
+ now + 10 * 60000
+ );
+
+ assert.equal(module.messages.length, 0);
+ assert.equal(module.unreadAttentionCount, 0);
+ assert.equal(module.notifications.at(-1).name, "ATTENTION_OFF");
+});
+
+test("ignores non-hourly weather broadcasts without clearing an alert", () => {
+ const module = weatherInstance();
+ module.messages = [
+ {
+ id: "rain-next-hour",
+ source: "magicmirror.weather",
+ urgency: "attention",
+ unread: true
+ }
+ ];
+ module.unreadAttentionCount = 1;
+
+ assert.equal(
+ module.handleWeatherUpdated({ currentWeather: {}, hourlyArray: [] }),
+ false
+ );
+ assert.equal(module.messages.length, 1);
+});
+
+test("does not treat snow or low-probability precipitation as rain", () => {
+ const module = weatherInstance();
+ const now = Date.now();
+
+ for (const forecast of [
+ {
+ date: now + 60 * 60000,
+ weatherType: "snow",
+ precipitationProbability: 90,
+ precipitationAmount: 2,
+ snow: 2
+ },
+ {
+ date: now + 60 * 60000,
+ weatherType: "rain",
+ precipitationProbability: 20,
+ precipitationAmount: 0
+ }
+ ]) {
+ assert.equal(module.findRainForecast([forecast], now, module.getWeatherNotificationConfig().rain), null);
+ }
+});
+
+test("can disable all internal MagicMirror notification providers", () => {
+ const module = instance({ internalNotifications: { enabled: false } });
+
+ assert.equal(
+ module.handleInternalNotification("WEATHER_UPDATED", { hourlyArray: [{}] }),
+ false
+ );
+ assert.equal(module.messages.length, 0);
+});
+
+test("captures a Remote Control alert without repeating its toast", () => {
+ const module = instance();
+
+ module.notificationReceived(
+ "SHOW_ALERT",
+ { title: "Delivery", message: "A package is at the door." },
+ { name: "MMM-Remote-Control" }
+ );
+
+ assert.equal(module.messages.length, 1);
+ assert.equal(module.messages[0].type, "remote.alert");
+ assert.equal(module.messages[0].source, "magicmirror.remote-control");
+ assert.equal(module.messages[0].title, "Delivery");
+ assert.equal(module.messages[0].body, "A package is at the door.");
+ assert.equal(module.notifications.some(({ name }) => name === "SHOW_ALERT"), false);
+});
+
+test("accepts an intentionally forwarded Remote Control message", () => {
+ const module = instance();
+
+ module.notificationReceived(
+ "MC_MESSAGE",
+ {
+ id: "door-open",
+ source: "entry-system",
+ title: "Front door",
+ body: "The front door is open.",
+ urgency: "attention",
+ retention: "untilViewed"
+ },
+ { name: "MMM-Remote-Control" }
+ );
+
+ assert.equal(module.messages.length, 1);
+ assert.equal(module.messages[0].id, "door-open");
+ assert.equal(module.messages[0].source, "entry-system");
+ assert.equal(module.messages[0].unread, true);
+ assert.equal(module.notifications.filter(({ name }) => name === "SHOW_ALERT").length, 1);
+});
+
+test("recognizes Remote Control through sender module data", () => {
+ const module = instance();
+
+ module.notificationReceived(
+ "SHOW_ALERT",
+ { title: "Reminder", message: "Check the mailbox." },
+ { data: { module: "MMM-Remote-Control" } }
+ );
+
+ assert.equal(module.messages.length, 1);
+ assert.equal(module.messages[0].source, "magicmirror.remote-control");
+});
+
+test("rejects an expired message forwarded by Remote Control", () => {
+ const module = instance();
+
+ module.notificationReceived(
+ "MC_MESSAGE",
+ {
+ title: "Old reminder",
+ expires: Date.now() - 1
+ },
+ { name: "MMM-Remote-Control" }
+ );
+
+ assert.equal(module.messages.length, 0);
+ assert.equal(module.notifications.length, 0);
+});
+
+test("ignores Remote Control operational notifications", () => {
+ const module = instance();
+ const sender = { name: "MMM-Remote-Control" };
+
+ for (const [notification, payload] of [
+ ["REMOTE_ACTION", { action: "BRIGHTNESS", value: 80 }],
+ ["REGISTER_API", { action: "pages" }],
+ ["USER_PRESENCE", true],
+ ["PAGE_CHANGED", 2],
+ ["REFRESH", undefined],
+ ["SHOW", { module: "clock" }]
+ ]) {
+ module.notificationReceived(notification, payload, sender);
+ }
+
+ assert.equal(module.messages.length, 0);
+ assert.equal(module.notifications.length, 0);
+});
+
+test("ignores malformed Remote Control message payloads", () => {
+ const module = instance();
+ const sender = { name: "MMM-Remote-Control" };
+
+ module.notificationReceived("MC_MESSAGE", "not-an-object", sender);
+ module.notificationReceived("SHOW_ALERT", {}, sender);
+ module.notificationReceived("SHOW_ALERT", [], sender);
+
+ assert.equal(module.messages.length, 0);
+ assert.equal(module.notifications.length, 0);
+});
+
+test("does not ingest MessageCenter notifications as Remote Control messages", () => {
+ const module = instance();
+
+ module.notificationReceived(
+ "SHOW_ALERT",
+ { title: "MessageCenter toast", message: "Do not capture this." },
+ { name: "MMM-MessageCenter" }
+ );
+ module.notificationReceived(
+ "MESSAGE_CENTER_ATTENTION_CHANGED",
+ { active: true },
+ { name: "MMM-Remote-Control" }
+ );
+
+ assert.equal(module.messages.length, 0);
+});
+
+test("allows Remote Control notification mappings to be disabled", () => {
+ const module = instance({
+ internalNotifications: {
+ remoteControl: {
+ enabled: false
+ }
+ }
+ });
+
+ module.notificationReceived(
+ "SHOW_ALERT",
+ { title: "Ignored", message: "Disabled provider" },
+ { name: "MMM-Remote-Control" }
+ );
+
+ assert.equal(module.messages.length, 0);
+});
+
+test("prunes expired messages and publishes cleared attention state", () => {
+ const module = instance({ showToasts: false });
+ const now = Date.now();
+ module.messages = [
+ {
+ id: "leak",
+ source: "utility-room",
+ title: "Water detected",
+ priority: "attention",
+ unread: true,
+ expires: now - 1
+ },
+ {
+ id: "weather",
+ source: "weather",
+ title: "Cloudy",
+ priority: "ephemeral",
+ unread: false,
+ expires: now + 10000
+ }
+ ];
+ module.unreadAttentionCount = 1;
+
+ assert.equal(module.pruneExpiredMessages(now), true);
+ assert.deepEqual(module.messages.map(({ id }) => id), ["weather"]);
+ assert.equal(module.unreadAttentionCount, 0);
+ assert.deepEqual(module.notifications.map(({ name }) => name), [
+ "MESSAGE_CENTER_ATTENTION_CHANGED",
+ "ATTENTION_OFF"
+ ]);
+});
+
+test("publishes a structured attention snapshot", () => {
+ const module = instance({ showToasts: false });
+
+ module.socketNotificationReceived("MC_MESSAGE", {
+ id: "rain",
+ source: "weather",
+ title: "Rain starting",
+ priority: "attention"
+ });
+
+ assert.deepEqual(module.notifications[0], {
+ name: "MESSAGE_CENTER_ATTENTION_CHANGED",
+ payload: {
+ active: true,
+ unreadCount: 1,
+ highestPriority: "attention",
+ highestUrgency: "attention",
+ sources: ["weather"]
+ }
+ });
+});
+
+test("viewing messages preserves explicit acknowledgement requirements", () => {
+ const module = instance();
+ module.messages = [
+ { source: "weather", urgency: "critical", retention: "untilAcknowledged", unread: true },
+ { source: "chores", urgency: "attention", retention: "untilViewed", unread: true }
+ ];
+ module.unreadAttentionCount = 2;
+
+ module.markViewed();
+
+ assert.equal(module.messages[0].unread, true);
+ assert.equal(module.messages[1].unread, false);
+ assert.equal(module.unreadAttentionCount, 1);
+
+ module.clearAttention();
+ assert.equal(module.messages[0].unread, false);
+ assert.equal(module.unreadAttentionCount, 0);
+});
+
+test("clears attention after the message page first renders", async () => {
+ const module = instance();
+ let renders = 0;
+ module.updateDom = () => {
+ renders += 1;
+ };
+ module.messages = [
+ { source: "chores", urgency: "attention", retention: "untilViewed", unread: true }
+ ];
module.unreadAttentionCount = 1;
module.notificationReceived("NEW_PAGE", 4);
assert.equal(module.currentPage, 4);
+ assert.equal(module.unreadAttentionCount, 1);
+ assert.equal(module.messages[0].unread, true);
+ assert.equal(renders, 1);
+
+ await new Promise((resolve) => setTimeout(resolve, 5));
+
assert.equal(module.unreadAttentionCount, 0);
assert.equal(module.messages[0].unread, false);
+ assert.equal(renders, 2);
assert.equal(module.notifications.at(-1).name, "ATTENTION_OFF");
});
+test("leaving the message page cancels pending viewed state", async () => {
+ const module = instance();
+ module.messages = [
+ { source: "chores", urgency: "attention", retention: "untilViewed", unread: true }
+ ];
+ module.unreadAttentionCount = 1;
+
+ module.notificationReceived("NEW_PAGE", 4);
+ module.notificationReceived("NEW_PAGE", 2);
+ await new Promise((resolve) => setTimeout(resolve, 5));
+
+ assert.equal(module.currentPage, 2);
+ assert.equal(module.messages[0].unread, true);
+ assert.equal(module.unreadAttentionCount, 1);
+});
+
test("ignores invalid page actions", () => {
const module = instance();
@@ -113,14 +1022,91 @@ test("ignores invalid page actions", () => {
assert.equal(module.notifications.length, 0);
});
+test("rejects pages outside the MMM-pages range", () => {
+ const module = instance();
+ module.notificationReceived("MAX_PAGES_CHANGED", 7);
+
+ module.handlePageAction({ switchChannel: 7 });
+ module.handlePageAction({ switchChannel: 99 });
+
+ assert.equal(module.notifications.length, 0);
+});
+
test("switches to a valid page", () => {
const module = instance();
+ module.maxPages = 7;
module.handlePageAction({ switchChannel: 4 });
assert.deepEqual(module.notifications, [{ name: "PAGE_CHANGED", payload: 4 }]);
});
+test("resolves the built-in messages destination", () => {
+ const module = instance({ messagesPage: 0 });
+ module.maxPages = 7;
+
+ module.handlePageAction({ switchChannel: "messages" });
+
+ assert.deepEqual(module.notifications, [{ name: "PAGE_CHANGED", payload: 0 }]);
+});
+
+test("resolves configured semantic channel destinations", () => {
+ const module = instance({ channelRoutes: { weather: 2, cameras: 5 } });
+ module.maxPages = 7;
+
+ module.handlePageAction({ switchChannel: "weather" });
+ module.handlePageAction({ switchChannel: "missing" });
+
+ assert.deepEqual(module.notifications, [{ name: "PAGE_CHANGED", payload: 2 }]);
+});
+
+test("waits for the MMM-pages page count before switching", () => {
+ const module = instance();
+
+ module.handlePageAction({ switchChannel: 4 });
+
+ assert.equal(module.notifications.length, 0);
+});
+
+test("can disable automatic page actions", () => {
+ const module = instance({ pages: false, showToasts: false });
+ module.maxPages = 7;
+
+ module.socketNotificationReceived("MC_MESSAGE", {
+ title: "No page switch",
+ actions: { switchChannel: 4 }
+ });
+
+ assert.equal(module.notifications.some(({ name }) => name === "PAGE_CHANGED"), false);
+});
+
+test("can disable Seymour attention notifications", () => {
+ const module = instance({ attention: "none", showToasts: false });
+
+ module.socketNotificationReceived("MC_MESSAGE", {
+ title: "Stored only",
+ priority: "attention"
+ });
+
+ assert.equal(module.unreadAttentionCount, 1);
+ assert.equal(module.notifications.some(({ name }) => name === "ATTENTION_ON"), false);
+});
+
+test("uses an integration-neutral switch for compatibility attention events", () => {
+ const module = instance({ legacyAttentionEvents: false, showToasts: false });
+
+ module.socketNotificationReceived("MC_MESSAGE", {
+ title: "Generic attention",
+ urgency: "attention",
+ retention: "untilViewed"
+ });
+
+ assert.deepEqual(
+ module.notifications.map(({ name }) => name),
+ ["MESSAGE_CENTER_ATTENTION_CHANGED"]
+ );
+});
+
test("manual clear removes all messages and attention", () => {
const module = instance();
module.messages = [{ unread: true }];
@@ -131,3 +1117,50 @@ test("manual clear removes all messages and attention", () => {
assert.deepEqual(module.messages, []);
assert.equal(module.unreadAttentionCount, 0);
});
+
+test("timed page action returns only while it still owns navigation", async () => {
+ const module = instance();
+ module.maxPages = 6;
+ module.currentPage = 1;
+
+ module.handlePageAction({ switchChannel: 4, timeout: 5 });
+ module.notificationReceived("NEW_PAGE", 4);
+ await new Promise((resolve) => setTimeout(resolve, 20));
+
+ assert.deepEqual(module.notifications, [
+ { name: "PAGE_CHANGED", payload: 4 },
+ { name: "PAGE_CHANGED", payload: 1 }
+ ]);
+});
+
+test("manual navigation cancels an automatic page return", async () => {
+ const module = instance();
+ module.maxPages = 6;
+ module.currentPage = 1;
+
+ module.handlePageAction({ switchChannel: 4, timeout: 5 });
+ module.notificationReceived("NEW_PAGE", 4);
+ module.notificationReceived("NEW_PAGE", 2);
+ await new Promise((resolve) => setTimeout(resolve, 20));
+
+ assert.deepEqual(module.notifications, [{ name: "PAGE_CHANGED", payload: 4 }]);
+ assert.equal(module.autoNavigation, null);
+});
+
+test("consecutive alerts preserve the original return page", async () => {
+ const module = instance();
+ module.maxPages = 6;
+ module.currentPage = 1;
+
+ module.handlePageAction({ switchChannel: 4, timeout: 20 });
+ module.notificationReceived("NEW_PAGE", 4);
+ module.handlePageAction({ switchChannel: 3, timeout: 5 });
+ module.notificationReceived("NEW_PAGE", 3);
+ await new Promise((resolve) => setTimeout(resolve, 20));
+
+ assert.deepEqual(module.notifications, [
+ { name: "PAGE_CHANGED", payload: 4 },
+ { name: "PAGE_CHANGED", payload: 3 },
+ { name: "PAGE_CHANGED", payload: 1 }
+ ]);
+});
diff --git a/test/node_helper.test.js b/test/node_helper.test.js
index 6168ec6..815df83 100644
--- a/test/node_helper.test.js
+++ b/test/node_helper.test.js
@@ -1,4 +1,5 @@
const assert = require("node:assert/strict");
+const EventEmitter = require("node:events");
const ModuleLoader = require("node:module");
const test = require("node:test");
@@ -22,6 +23,10 @@ function helper() {
return {
...helperDefinition,
server: null,
+ mqttClient: null,
+ unixServer: null,
+ unixSocketPath: null,
+ imageConfig: helperDefinition.getImageConfig(),
socketNotifications,
sendSocketNotification(name, payload) {
socketNotifications.push({ name, payload });
@@ -51,13 +56,31 @@ test("rejects invalid webhook ports", () => {
assert.match(module.socketNotifications[0].payload, /Invalid webhook port/);
});
-test("requires a token for non-localhost binding", () => {
+test("allows unauthenticated LAN binding with a warning", () => {
const module = helper();
+ const originalListen = require("express").application.listen;
+ const originalWarn = console.warn;
+ let listened;
+ let warning;
- module.startWebhook({ host: "0.0.0.0", port: 8787, token: "" });
+ require("express").application.listen = function listen(port, host, callback) {
+ listened = { port, host };
+ if (callback) callback();
+ return { on() {}, close() {} };
+ };
+ console.warn = (message) => {
+ warning = message;
+ };
- assert.equal(module.server, null);
- assert.match(module.socketNotifications[0].payload, /token is required/);
+ try {
+ module.startWebhook({ host: "0.0.0.0", port: 8787, token: "" });
+ } finally {
+ require("express").application.listen = originalListen;
+ console.warn = originalWarn;
+ }
+
+ assert.deepEqual(listened, { port: 8787, host: "0.0.0.0" });
+ assert.match(warning, /without authentication/);
});
test("invalid startup configuration falls back safely", () => {
@@ -79,3 +102,259 @@ test("invalid startup configuration falls back safely", () => {
assert.deepEqual(listened, { port: 8787, host: "127.0.0.1" });
});
+
+test("starts the configured transport bundle", () => {
+ const module = helper();
+ const started = [];
+ module.startWebhook = (config) => started.push({ name: "webhook", config });
+ module.startMqtt = (config) => started.push({ name: "mqtt", config });
+ module.startUnixSocket = (config) => started.push({ name: "unix", config });
+
+ module.startTransports({
+ webhook: { port: 9000 },
+ images: { enabled: true, maxBytes: 4096 },
+ transports: {
+ mqtt: { enabled: true },
+ unixSocket: { enabled: true }
+ }
+ });
+
+ assert.deepEqual(started, [
+ { name: "webhook", config: { port: 9000 } },
+ { name: "mqtt", config: { enabled: true } },
+ { name: "unix", config: { enabled: true } }
+ ]);
+ assert.equal(module.imageConfig.enabled, true);
+ assert.equal(module.imageConfig.maxBytes, 4096);
+});
+
+test("parses transport payloads through the shared message path", () => {
+ const module = helper();
+
+ assert.equal(module.parsePayload('{"title":"Disk space low"}', "test"), true);
+ assert.deepEqual(module.socketNotifications[0], {
+ name: "MC_MESSAGE",
+ payload: { title: "Disk space low" }
+ });
+
+ assert.equal(module.parsePayload("not-json", "test"), false);
+ assert.match(module.socketNotifications.at(-1).payload, /Invalid JSON/);
+ assert.equal(module.parsePayload("[]", "test"), false);
+ assert.match(module.socketNotifications.at(-1).payload, /JSON object/);
+ assert.equal(module.parsePayload("x".repeat(33 * 1024), "test"), false);
+ assert.match(module.socketNotifications.at(-1).payload, /exceeded/);
+});
+
+test("subscribes to configured MQTT topics and ingests matching messages", () => {
+ const module = helper();
+ const client = new EventEmitter();
+ let connection;
+ let subscription;
+ let ended = false;
+ client.subscribe = (topics, options, callback) => {
+ subscription = { topics, options };
+ callback();
+ };
+ client.end = (force) => {
+ ended = force;
+ };
+ module.connectMqtt = (url, options) => {
+ connection = { url, options };
+ return client;
+ };
+
+ module.startMqtt({
+ enabled: true,
+ url: "mqtt://broker.local",
+ topics: ["messagecenter/messages", "mirror/system"],
+ username: "mirror",
+ password: "private"
+ });
+ client.emit("connect");
+ client.emit("message", "unrelated/topic", Buffer.from('{"title":"Ignored"}'));
+ client.emit("message", "mirror/system", Buffer.from('{"title":"Network offline"}'));
+ module.stopMqtt();
+
+ assert.equal(connection.url, "mqtt://broker.local");
+ assert.equal(connection.options.username, "mirror");
+ assert.equal(connection.options.password, "private");
+ assert.deepEqual(subscription, {
+ topics: ["messagecenter/messages", "mirror/system"],
+ options: { qos: 0 }
+ });
+ assert.deepEqual(
+ module.socketNotifications.filter(({ name }) => name === "MC_MESSAGE"),
+ [{ name: "MC_MESSAGE", payload: { title: "Network offline" } }]
+ );
+ assert.equal(ended, true);
+ assert.equal(module.mqttClient, null);
+});
+
+test("rejects enabled MQTT without a usable topic", () => {
+ const module = helper();
+
+ module.startMqtt({ enabled: true, topic: "" });
+
+ assert.equal(module.mqttClient, null);
+ assert.match(module.socketNotifications[0].payload, /at least one topic/);
+});
+
+test("reports MQTT startup failures without exposing configuration", () => {
+ const module = helper();
+ module.connectMqtt = () => {
+ throw new Error("invalid broker URL");
+ };
+
+ module.startMqtt({
+ enabled: true,
+ topic: "messagecenter/messages",
+ password: "do-not-log"
+ });
+
+ assert.equal(module.mqttClient, null);
+ assert.match(module.socketNotifications[0].payload, /startup failed/);
+ assert.doesNotMatch(module.socketNotifications[0].payload, /do-not-log/);
+});
+
+test("accepts newline-delimited messages from a Unix connection", () => {
+ const module = helper();
+ const connection = new EventEmitter();
+ const responses = [];
+ connection.writable = true;
+ connection.setEncoding = (encoding) => assert.equal(encoding, "utf8");
+ connection.write = (value) => responses.push(value);
+ connection.end = () => {};
+
+ module.handleUnixConnection(connection);
+ connection.emit("data", '{"title":"Storage warning"}\nnot-json\n');
+ connection.emit("end");
+
+ assert.deepEqual(
+ module.socketNotifications.filter(({ name }) => name === "MC_MESSAGE"),
+ [{ name: "MC_MESSAGE", payload: { title: "Storage warning" } }]
+ );
+ assert.deepEqual(responses, [
+ '{"status":"accepted"}\n',
+ '{"status":"rejected"}\n'
+ ]);
+});
+
+test("requires an absolute Unix socket path", () => {
+ const module = helper();
+
+ module.startUnixSocket({ enabled: true, path: "relative.sock" });
+
+ assert.equal(module.unixServer, null);
+ assert.match(module.socketNotifications[0].payload, /must be absolute/);
+});
+
+test("uses conservative image-cache defaults", () => {
+ const module = helper();
+
+ assert.deepEqual(module.getImageConfig({ enabled: true }), {
+ enabled: true,
+ maxBytes: 1024 * 1024,
+ timeout: 5000,
+ allowPrivateHosts: false,
+ allowHttp: false
+ });
+ assert.equal(module.isPrivateAddress("127.0.0.1"), true);
+ assert.equal(module.isPrivateAddress("192.168.1.20"), true);
+ assert.equal(module.isPrivateAddress("8.8.8.8"), false);
+ assert.equal(module.isPrivateAddress("::1"), true);
+});
+
+test("blocks insecure and private image URLs unless explicitly allowed", async () => {
+ const module = helper();
+
+ await assert.rejects(
+ module.validateImageUrl(new URL("http://images.example.test/snapshot.jpg")),
+ /HTTPS/
+ );
+ await assert.rejects(
+ module.validateImageUrl(new URL("https://127.0.0.1/snapshot.jpg")),
+ /Private image host/
+ );
+
+ module.imageConfig = module.getImageConfig({
+ enabled: true,
+ allowPrivateHosts: true,
+ allowHttp: true
+ });
+ await assert.doesNotReject(
+ module.validateImageUrl(new URL("http://127.0.0.1/snapshot.jpg"))
+ );
+});
+
+test("caches a validated image at message ingestion", async () => {
+ const module = helper();
+ const png = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10, 1, 2, 3]);
+ module.imageConfig = module.getImageConfig({ enabled: true });
+ module.validateImageUrl = async (url) => {
+ assert.equal(url.href, "https://images.example.test/doorbell.png");
+ };
+ module.fetchImage = async () => ({
+ ok: true,
+ status: 200,
+ headers: {
+ get(name) {
+ if (name === "content-type") return "image/png";
+ if (name === "content-length") return String(png.length);
+ return null;
+ }
+ },
+ body: null,
+ async arrayBuffer() {
+ return png;
+ }
+ });
+
+ assert.equal(module.ingestPayload({
+ title: "Doorbell",
+ image: {
+ url: "https://images.example.test/doorbell.png",
+ alt: "Visitor at the door"
+ }
+ }), true);
+ await new Promise((resolve) => setImmediate(resolve));
+
+ assert.equal(module.socketNotifications.length, 1);
+ assert.equal(module.socketNotifications[0].name, "MC_MESSAGE");
+ assert.equal(module.socketNotifications[0].payload.image.alt, "Visitor at the door");
+ assert.match(module.socketNotifications[0].payload.image.dataUrl, /^data:image\/png;base64,/);
+});
+
+test("delivers message text when image caching fails", async () => {
+ const module = helper();
+ module.imageConfig = module.getImageConfig({ enabled: true });
+ module.cacheImage = async () => {
+ throw new Error("download failed");
+ };
+
+ module.ingestPayload({
+ title: "Doorbell",
+ image: "https://images.example.test/doorbell.png"
+ });
+ await new Promise((resolve) => setImmediate(resolve));
+
+ assert.deepEqual(module.socketNotifications[0], {
+ name: "MC_ERROR",
+ payload: "Message image could not be cached"
+ });
+ assert.equal(module.socketNotifications[1].name, "MC_MESSAGE");
+ assert.equal(module.socketNotifications[1].payload.title, "Doorbell");
+ assert.equal(Number.isFinite(module.socketNotifications[1].payload.timestamp), true);
+ assert.equal(Object.hasOwn(module.socketNotifications[1].payload, "image"), false);
+});
+
+test("validates image signatures independently of response headers", () => {
+ const module = helper();
+ const png = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]);
+
+ assert.equal(module.matchesImageSignature(png, "image/png"), true);
+ assert.equal(module.matchesImageSignature(Buffer.from("not an image"), "image/png"), false);
+ assert.equal(
+ module.matchesImageSignature(Buffer.from([0xff, 0xd8, 0xff, 0x00]), "image/jpeg"),
+ true
+ );
+});