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 approaching weather alert in the MessageCenter inbox](docs/images/weather-alert.png) + +| 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: + +![Home Assistant appliance and doorbell notifications in the MessageCenter inbox](docs/images/home-assistant-notifications.png) + +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 + + + + +
+
+
+
+

Messages

+ 5 +
+ +
+ +
+
+

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 + ); +});