From 06b7ebb3577797cb23ff4b578e2d4d4601f9f9e7 Mon Sep 17 00:00:00 2001 From: Ina Krebes Date: Tue, 22 Sep 2026 16:31:58 +0200 Subject: [PATCH 1/9] Add Oslo-focused config and project documentation - config/config.js: first config with Oslo clock, Norwegian holidays, Oslo weather (openmeteo), and NRK Nyheter newsfeed replacing US defaults - CLAUDE.md: project readme for Claude with setup, startup, and module notes - .gitignore: un-ignore config/config.js so it can be shared via git Co-Authored-By: Claude Sonnet 4.6 --- .gitignore | 1 + CLAUDE.md | 101 +++++++++++++++++++++++++++++++++++++++++++++++ config/config.js | 92 ++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 194 insertions(+) create mode 100644 CLAUDE.md create mode 100644 config/config.js diff --git a/.gitignore b/.gitignore index e1365c2fe6..e5e1ac81e4 100644 --- a/.gitignore +++ b/.gitignore @@ -61,6 +61,7 @@ Temporary Items config !config/config.js.sample !config/custom.css.sample +!config/config.js # Vim ## swap diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000000..aa39f7c5e3 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,101 @@ +# MagicMirror — Oslo Edition + +Custom MagicMirror setup for a Raspberry Pi behind a physical mirror. Oslo-focused: local time, Ruter public transport departures, Norwegian holidays, and weather. + +Upstream: [MagicMirrorOrg/MagicMirror](https://github.com/MagicMirrorOrg/MagicMirror). Fork lives at `nrkno/MagicMirror`. + +## Project goals + +- Display Ruter departures for a specific stop (coordinates-based) +- Oslo clock, Norwegian holidays calendar, later: Yr weather +- Keep the UI minimal — no clutter, dark mirror aesthetic + +## Running the app + +### First time setup + +```sh +npm run install-mm +``` + +Use `install-mm` instead of plain `npm install` — it omits dev dependencies and avoids engine version conflicts with ESLint plugins that require a newer Node patch than what's installed. + +### On macOS (local dev) + +```sh +npm run server +``` + +Then open http://localhost:8080 in any browser. No Electron needed. + +If port 8080 is in use, find and kill the occupying process first: +```sh +lsof -ti :8080 | xargs kill +npm run server +``` + +To open a native Electron window instead of the browser: +```sh +./node_modules/.bin/electron js/electron.js +``` + +### On the Pi (with a real screen) + +```sh +npm start +``` + +This runs `start:wayland`. Requires a display attached to the Pi. + +### On the Pi (headless, for testing) + +`npm start` fails without a Wayland compositor. Use Xvfb: +```sh +xvfb-run -a env DISPLAY=:99 ELECTRON_OZONE_PLATFORM_HINT=x11 ./node_modules/.bin/electron . --no-sandbox +``` +Requires `apt install xvfb`. + +## Config + +Config lives at `config/config.js`. Create it from the sample if it doesn't exist: +``` +cp config/config.js.sample config/config.js +``` + +Key settings for this setup: +- Timezone: `Europe/Oslo` +- Language: `nb` (Norwegian Bokmål) or `en` +- Norwegian holiday calendar: + `https://calendar.google.com/calendar/ical/nb.norwegian%23holiday%40group.v.calendar.google.com/public/basic.ics` + +## Custom modules + +Custom modules go in `modules/`. Each module is a directory: +``` +modules/ + MMM-RuterDepartures/ + MMM-RuterDepartures.js # module definition (browser-side) + node_helper.js # server-side helper (optional, for API calls) + MMM-RuterDepartures.css # styles +``` + +### Ruter departures module (in progress) + +Target stop: coordinates `59.932267, 10.724083` (configurable in `config/config.js`). + +Ruter's public API endpoint for stop discovery and departures: +- Stop by coordinates: `https://api.ruter.no/v2/travel/getStopsByCoordinates/?X=&Y=&proposals=1` +- Departures: `https://api.ruter.no/v2/travel/getdepartures/` + +The module should fetch from `node_helper.js` (server side) and pass results to the frontend module for rendering — do not embed the Ruter monitor website (`mon.ruter.no`) in an iframe. + +## Node / npm + +Node 24 via nvm is required. The system Node on Linux was too old — install nvm and `nvm use 24` before running `npm install`. + +## Repo hygiene + +- `config/config.js` is safe to commit as long as it contains no secrets (API keys, tokens). The current Oslo config has none, so commit it freely. +- If you add a private API key later, use `config/config.js.template` instead — put `${MY_SECRET}` placeholders in it and MagicMirror will compile it to `config.js` at startup using environment variables. Commit the template, not the compiled file. +- `modules/` third-party modules should each be gitignored or tracked separately +- Keep `node_modules/` out of commits (already in `.gitignore`) diff --git a/config/config.js b/config/config.js new file mode 100644 index 0000000000..374eebff23 --- /dev/null +++ b/config/config.js @@ -0,0 +1,92 @@ +let config = { + address: "localhost", + port: 8080, + basePath: "/", + ipWhitelist: ["127.0.0.1", "::ffff:127.0.0.1", "::1"], + + useHttps: false, + httpsPrivateKey: "", + httpsCertificate: "", + + language: "nb", + locale: "nb-NO", + + logLevel: ["INFO", "LOG", "WARN", "ERROR"], + timeFormat: 24, + units: "metric", + + modules: [ + { + module: "alert", + }, + { + module: "updatenotification", + position: "top_bar" + }, + { + module: "clock", + position: "top_left", + config: { + timezone: "Europe/Oslo" + } + }, + { + module: "calendar", + header: "Norske helligdager", + position: "top_left", + config: { + calendars: [ + { + fetchInterval: 7 * 24 * 60 * 60 * 1000, + symbol: "calendar-check", + url: "https://calendar.google.com/calendar/ical/nb.norwegian%23holiday%40group.v.calendar.google.com/public/basic.ics" + } + ] + } + }, + { + module: "compliments", + position: "lower_third" + }, + { + module: "weather", + position: "top_right", + config: { + weatherProvider: "openmeteo", + type: "current", + lat: 59.9139, + lon: 10.7522 + } + }, + { + module: "weather", + position: "top_right", + header: "Værmelding", + config: { + weatherProvider: "openmeteo", + type: "forecast", + lat: 59.9139, + lon: 10.7522 + } + }, + { + module: "newsfeed", + position: "bottom_bar", + config: { + feeds: [ + { + title: "NRK Nyheter", + url: "https://www.nrk.no/toppsaker.rss" + } + ], + showSourceTitle: true, + showPublishDate: true, + broadcastNewsFeeds: true, + broadcastNewsUpdates: true + } + }, + ] +}; + +/*************** DO NOT EDIT THE LINE BELOW ***************/ +if (typeof module !== "undefined") { module.exports = config; } From 5d4c938e59d19ee4f75d48fefde5e831995bc714 Mon Sep 17 00:00:00 2001 From: Ina Krebes Date: Wed, 23 Sep 2026 11:44:19 +0200 Subject: [PATCH 2/9] Add MMM-RuterDepartures module for Marienlyst/Majorstuen Custom module fetching real-time departures from the two nearest stops to coordinates 59.932267, 10.724083 (Marienlyst) using the Entur Journey Planner v3 GraphQL API. Updates every 30 seconds. Features: - Separate sections per stop (Marienlyst and Majorstuen) - Walk time filtering per stop (5 min Marienlyst, 10 min Majorstuen) - Fetches a large pool of departures so filtering leaves enough to show - Excludes irrelevant nearby stops (Vestre Aker kirke) - Realtime departures highlighted in green Co-Authored-By: Claude Sonnet 4.6 --- .gitignore | 1 + config/config.js | 17 +++ .../MMM-RuterDepartures.css | 39 ++++++ .../MMM-RuterDepartures.js | 92 ++++++++++++++ modules/MMM-RuterDepartures/node_helper.js | 117 ++++++++++++++++++ 5 files changed, 266 insertions(+) create mode 100644 modules/MMM-RuterDepartures/MMM-RuterDepartures.css create mode 100644 modules/MMM-RuterDepartures/MMM-RuterDepartures.js create mode 100644 modules/MMM-RuterDepartures/node_helper.js diff --git a/.gitignore b/.gitignore index e5e1ac81e4..1401927984 100644 --- a/.gitignore +++ b/.gitignore @@ -56,6 +56,7 @@ Temporary Items # Ignore all modules /modules/* +!/modules/MMM-RuterDepartures # Ignore users config file but keep the samples. config diff --git a/config/config.js b/config/config.js index 374eebff23..c1fff8529c 100644 --- a/config/config.js +++ b/config/config.js @@ -69,6 +69,23 @@ let config = { lon: 10.7522 } }, + { + module: "MMM-RuterDepartures", + position: "top_right", + header: "Avganger Marienlyst / Majorstuen", + config: { + lat: 59.932267, + lon: 10.724083, + maxStops: 3, + maxDepartures: 8, + updateInterval: 30 * 1000, + excludeStops: ["Vestre Aker kirke"], + walkMinutes: { + "Marienlyst": 5, + "Majorstuen": 10, + }, + } + }, { module: "newsfeed", position: "bottom_bar", diff --git a/modules/MMM-RuterDepartures/MMM-RuterDepartures.css b/modules/MMM-RuterDepartures/MMM-RuterDepartures.css new file mode 100644 index 0000000000..bcfd6e569f --- /dev/null +++ b/modules/MMM-RuterDepartures/MMM-RuterDepartures.css @@ -0,0 +1,39 @@ +.ruter-stop { + margin-bottom: 12px; +} + +.ruter-stop-name { + margin-bottom: 4px; + text-transform: uppercase; + letter-spacing: 0.05em; +} + +.ruter-departures { + width: 100%; + border-collapse: collapse; +} + +.ruter-departures td { + padding: 3px 8px; +} + +.ruter-line { + font-weight: bold; + min-width: 36px; + color: #fff; +} + +.ruter-destination { + color: #ccc; + padding-right: 16px; +} + +.ruter-time { + text-align: right; + min-width: 52px; + color: #aaa; +} + +.ruter-time.realtime { + color: #7fc97f; +} diff --git a/modules/MMM-RuterDepartures/MMM-RuterDepartures.js b/modules/MMM-RuterDepartures/MMM-RuterDepartures.js new file mode 100644 index 0000000000..7ee268a80c --- /dev/null +++ b/modules/MMM-RuterDepartures/MMM-RuterDepartures.js @@ -0,0 +1,92 @@ +Module.register("MMM-RuterDepartures", { + defaults: { + lat: 59.932267, + lon: 10.724083, + maxStops: 3, + maxDepartures: 6, + updateInterval: 30 * 1000, + walkMinutes: {}, + defaultWalkMinutes: 0, + }, + + start() { + this.stops = []; + this.sendSocketNotification("RUTER_GET_DEPARTURES", { + ...this.config, + identifier: this.identifier, + }); + }, + + socketNotificationReceived(notification, payload) { + if (notification === "RUTER_DEPARTURES" && payload.identifier === this.identifier) { + this.stops = payload.stops; + this.updateDom(); + } + }, + + getDom() { + const wrapper = document.createElement("div"); + + if (!this.stops.length) { + wrapper.className = "dimmed small"; + wrapper.textContent = "Laster avganger…"; + return wrapper; + } + + for (const stop of this.stops) { + const walkMins = this.walkMinutesFor(stop.name); + const now = new Date(); + + const reachable = stop.departures.filter((dep) => { + const mins = Math.round((new Date(dep.expectedTime) - now) / 60000); + return mins >= walkMins; + }).slice(0, this.config.maxDepartures); + + const section = document.createElement("div"); + section.className = "ruter-stop"; + + const header = document.createElement("div"); + header.className = "ruter-stop-name dimmed xsmall"; + header.textContent = stop.name; + section.appendChild(header); + + if (!reachable.length) { + const empty = document.createElement("div"); + empty.className = "dimmed xsmall"; + empty.textContent = "Ingen avganger innen rekkevidde"; + section.appendChild(empty); + } else { + const table = document.createElement("table"); + table.className = "ruter-departures small"; + + for (const dep of reachable) { + const mins = Math.round((new Date(dep.expectedTime) - now) / 60000); + const timeStr = `${mins} min`; + const row = document.createElement("tr"); + row.innerHTML = ` + ${dep.line} + ${dep.destination} + ${timeStr} + `; + table.appendChild(row); + } + + section.appendChild(table); + } + + wrapper.appendChild(section); + } + + return wrapper; + }, + + walkMinutesFor(stopName) { + const map = this.config.walkMinutes; + for (const key of Object.keys(map)) { + if (stopName.toLowerCase().includes(key.toLowerCase())) { + return map[key]; + } + } + return this.config.defaultWalkMinutes; + }, +}); diff --git a/modules/MMM-RuterDepartures/node_helper.js b/modules/MMM-RuterDepartures/node_helper.js new file mode 100644 index 0000000000..0876428a72 --- /dev/null +++ b/modules/MMM-RuterDepartures/node_helper.js @@ -0,0 +1,117 @@ +const NodeHelper = require("node_helper"); + +const ENTUR_URL = "https://api.entur.io/journey-planner/v3/graphql"; +const CLIENT_NAME = "nrkno-magicmirror"; + +module.exports = NodeHelper.create({ + start() { + this.timers = {}; + }, + + socketNotificationReceived(notification, payload) { + if (notification === "RUTER_GET_DEPARTURES") { + clearTimeout(this.timers[payload.identifier]); + this.run(payload); + } + }, + + async run(config) { + const { lat, lon, maxStops, excludeStops = [], maxDepartures, updateInterval, identifier } = config; + + try { + const allStops = await this.fetchNearbyStops(lat, lon, maxStops); + const stops = allStops.filter( + (s) => !excludeStops.some((ex) => s.name.toLowerCase().includes(ex.toLowerCase())) + ); + const stopsWithDepartures = stops.length + ? await this.fetchDepartures(stops, maxDepartures) + : []; + this.sendSocketNotification("RUTER_DEPARTURES", { identifier, stops: stopsWithDepartures }); + } catch (err) { + console.error("[MMM-RuterDepartures]", err.message); + } + + this.timers[identifier] = setTimeout(() => this.run(config), updateInterval); + }, + + async fetchNearbyStops(lat, lon, maxStops) { + const query = `{ + nearest( + latitude: ${lat} + longitude: ${lon} + maximumDistance: 500 + maximumResults: ${maxStops} + filterByPlaceTypes: [stopPlace] + ) { + edges { + node { + place { + ... on StopPlace { + id + name + } + } + } + } + } + }`; + + const data = await this.graphql(query); + return data.data.nearest.edges.map((e) => e.node.place); + }, + + async fetchDepartures(stops, maxDepartures) { + const fetchCount = maxDepartures * 5; + const fields = stops + .map( + (stop, i) => ` + stop${i}: stopPlace(id: "${stop.id}") { + name + estimatedCalls(timeRange: 72100, numberOfDepartures: ${fetchCount}) { + realtime + expectedDepartureTime + cancellation + destinationDisplay { frontText } + serviceJourney { + journeyPattern { + line { + publicCode + transportMode + } + } + } + } + }` + ) + .join("\n"); + + const data = await this.graphql(`{ ${fields} }`); + + return stops.map((stop, i) => { + const s = data.data[`stop${i}`]; + const departures = (s ? s.estimatedCalls : []) + .filter((c) => !c.cancellation) + .map((c) => ({ + line: c.serviceJourney.journeyPattern.line.publicCode, + mode: c.serviceJourney.journeyPattern.line.transportMode, + destination: c.destinationDisplay.frontText, + expectedTime: c.expectedDepartureTime, + realtime: c.realtime, + })) + .sort((a, b) => new Date(a.expectedTime) - new Date(b.expectedTime)); + return { name: s ? s.name : stop.name, departures }; + }); + }, + + async graphql(query) { + const res = await fetch(ENTUR_URL, { + method: "POST", + headers: { + "Content-Type": "application/json", + "ET-Client-Name": CLIENT_NAME, + }, + body: JSON.stringify({ query }), + }); + return res.json(); + }, +}); From 62fd1e629d6abc3d280f016ac0b3a5b65b4fdf90 Mon Sep 17 00:00:00 2001 From: Ina Krebes Date: Wed, 23 Sep 2026 11:58:05 +0200 Subject: [PATCH 3/9] Limit departures to 4 per stop Co-Authored-By: Claude Sonnet 4.6 --- config/config.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/config/config.js b/config/config.js index c1fff8529c..115fdd1ab3 100644 --- a/config/config.js +++ b/config/config.js @@ -77,7 +77,7 @@ let config = { lat: 59.932267, lon: 10.724083, maxStops: 3, - maxDepartures: 8, + maxDepartures: 4, updateInterval: 30 * 1000, excludeStops: ["Vestre Aker kirke"], walkMinutes: { From 522d542b5708ce3fc117d9531a8f88737a22f684 Mon Sep 17 00:00:00 2001 From: Ina Krebes Date: Wed, 14 Oct 2026 14:36:46 +0200 Subject: [PATCH 4/9] Add Yr-Meteogram module with Oslo weather forecast Embeds Yr's SVG meteogram for Oslo (location 1-72837) in bottom_center, raised with transform to sit above the newsfeed. Co-Authored-By: Claude Opus 4.7 --- .gitignore | 1 + config/config.js | 17 ++++++--- modules/MMM-YrMeteogram/MMM-YrMeteogram.css | 7 ++++ modules/MMM-YrMeteogram/MMM-YrMeteogram.js | 38 +++++++++++++++++++++ 4 files changed, 59 insertions(+), 4 deletions(-) create mode 100644 modules/MMM-YrMeteogram/MMM-YrMeteogram.css create mode 100644 modules/MMM-YrMeteogram/MMM-YrMeteogram.js diff --git a/.gitignore b/.gitignore index 1401927984..b5abd4d2f5 100644 --- a/.gitignore +++ b/.gitignore @@ -57,6 +57,7 @@ Temporary Items # Ignore all modules /modules/* !/modules/MMM-RuterDepartures +!/modules/MMM-YrMeteogram # Ignore users config file but keep the samples. config diff --git a/config/config.js b/config/config.js index 115fdd1ab3..e316508b5a 100644 --- a/config/config.js +++ b/config/config.js @@ -44,10 +44,6 @@ let config = { ] } }, - { - module: "compliments", - position: "lower_third" - }, { module: "weather", position: "top_right", @@ -69,6 +65,19 @@ let config = { lon: 10.7522 } }, + { + module: "MMM-YrMeteogram", + position: "bottom_center", + config: { + locationId: "1-72837", + lang: "nb", + width: "700px", + } + }, + { + module: "compliments", + position: "lower_third", + }, { module: "MMM-RuterDepartures", position: "top_right", diff --git a/modules/MMM-YrMeteogram/MMM-YrMeteogram.css b/modules/MMM-YrMeteogram/MMM-YrMeteogram.css new file mode 100644 index 0000000000..ebe4e166c7 --- /dev/null +++ b/modules/MMM-YrMeteogram/MMM-YrMeteogram.css @@ -0,0 +1,7 @@ +.module.MMM-YrMeteogram { + transform: translateY(-300px); +} + +.MMM-YrMeteogram img { + display: block; +} diff --git a/modules/MMM-YrMeteogram/MMM-YrMeteogram.js b/modules/MMM-YrMeteogram/MMM-YrMeteogram.js new file mode 100644 index 0000000000..91f5776888 --- /dev/null +++ b/modules/MMM-YrMeteogram/MMM-YrMeteogram.js @@ -0,0 +1,38 @@ +Module.register("MMM-YrMeteogram", { + defaults: { + locationId: "1-72837", + lang: "nb", + updateInterval: 30 * 60 * 1000, // 30 minutes + width: "400px", + }, + + getStyles() { + return ["MMM-YrMeteogram.css"]; + }, + + start() { + this.scheduleUpdate(); + }, + + scheduleUpdate() { + this.updateDom(); + setInterval(() => this.updateDom(), this.config.updateInterval); + }, + + getDom() { + const wrapper = document.createElement("div"); + const { locationId, lang, width } = this.config; + + const langPath = lang === "nb" ? "nb/innhold" : `${lang}/content`; + const url = `https://www.yr.no/${langPath}/${locationId}/meteogram.svg?mode=dark&_=${Date.now()}`; + + const img = document.createElement("img"); + img.src = url; + img.style.width = width; + img.style.display = "block"; + img.alt = "Yr meteogram"; + + wrapper.appendChild(img); + return wrapper; + }, +}); From 8c72e34b0c4e7b4bf51c3556a9f51cccfba4ff5f Mon Sep 17 00:00:00 2001 From: Ina Krebes Date: Wed, 14 Oct 2026 14:53:09 +0200 Subject: [PATCH 5/9] Apply NRK design system styling MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Load NRK Sans from static.nrk.no as the primary typeface - Cooler text palette and uppercase/letter-spaced labels per NRK hierarchy - Weight contrast (400/500/700/800) in place of size jumps - Add getStyles() to Ruter module so its CSS actually loads Background stays pure #000 — required for two-way mirror glass. Co-Authored-By: Claude Opus 4.7 --- .gitignore | 1 + config/custom.css | 77 +++++++++++++++++++ .../MMM-RuterDepartures.css | 24 +++--- .../MMM-RuterDepartures.js | 4 + 4 files changed, 97 insertions(+), 9 deletions(-) create mode 100644 config/custom.css diff --git a/.gitignore b/.gitignore index b5abd4d2f5..1370e3a997 100644 --- a/.gitignore +++ b/.gitignore @@ -64,6 +64,7 @@ config !config/config.js.sample !config/custom.css.sample !config/config.js +!config/custom.css # Vim ## swap diff --git a/config/custom.css b/config/custom.css new file mode 100644 index 0000000000..0165e22085 --- /dev/null +++ b/config/custom.css @@ -0,0 +1,77 @@ +/* NRK-styled MagicMirror + * + * Applies NRK Sans typography and the periwinkle accent from the NRK design system. + * Background stays pure #000 — required for the two-way mirror glass. + */ + +@import url("https://static.nrk.no/nrk-sans/latest/nrk-sans.min.css"); + +:root { + /* NRK periwinkle accent for interactive/highlight text on dark surfaces. */ + --nrk-accent: #8bb5eb; + + --color-text: #a7b3c2; + --color-text-dimmed: #5a6676; + --color-text-bright: #ffffff; + --color-background: #000; + + --font-primary: "NRK Sans", "Inter", -apple-system, BlinkMacSystemFont, sans-serif; + --font-secondary: "NRK Sans", "Inter", sans-serif; + + --font-size: 20px; + --font-size-small: 0.875rem; + + --gap-body-top: 60px; + --gap-body-right: 60px; + --gap-body-bottom: 60px; + --gap-body-left: 60px; + + --gap-modules: 36px; +} + +/* NRK hierarchy: heavy weight for primary labels, restrained sizes. */ +header.module-header { + font-weight: 500; + font-size: 0.875rem; + text-transform: uppercase; + letter-spacing: 0.08em; + color: var(--color-text-dimmed); + border-bottom: none; + padding-bottom: 0; +} + +/* Clock — big and bold like NRK editorial headlines. */ +.clock .time { + font-weight: 800; + letter-spacing: -0.02em; +} + +.clock .date { + font-weight: 400; + color: var(--color-text); +} + +/* Weather — larger, cleaner current temperature. */ +.weather .large.light { + font-weight: 300; +} + +/* Keep radii subtle (4px NRK standard). */ +.module, +.module-content, +table { + border-radius: 0.25rem; +} + +/* News ticker — brighter headline, smaller source label. */ +.newsfeed-source { + font-size: 0.75rem; + text-transform: uppercase; + letter-spacing: 0.06em; + color: var(--color-text-dimmed); +} + +.newsfeed-title { + font-weight: 400; + color: var(--color-text-bright); +} diff --git a/modules/MMM-RuterDepartures/MMM-RuterDepartures.css b/modules/MMM-RuterDepartures/MMM-RuterDepartures.css index bcfd6e569f..ac1a8d4252 100644 --- a/modules/MMM-RuterDepartures/MMM-RuterDepartures.css +++ b/modules/MMM-RuterDepartures/MMM-RuterDepartures.css @@ -1,11 +1,14 @@ .ruter-stop { - margin-bottom: 12px; + margin-bottom: 16px; } .ruter-stop-name { - margin-bottom: 4px; + margin-bottom: 6px; + font-size: 0.75rem; + font-weight: 500; text-transform: uppercase; - letter-spacing: 0.05em; + letter-spacing: 0.08em; + color: var(--color-text-dimmed, #666); } .ruter-departures { @@ -14,26 +17,29 @@ } .ruter-departures td { - padding: 3px 8px; + padding: 4px 8px; } .ruter-line { - font-weight: bold; + font-weight: 700; min-width: 36px; - color: #fff; + color: var(--color-text-bright, #fff); + letter-spacing: 0.01em; } .ruter-destination { - color: #ccc; + color: var(--color-text, #a7b3c2); padding-right: 16px; } .ruter-time { text-align: right; min-width: 52px; - color: #aaa; + font-weight: 400; + color: var(--color-text, #a7b3c2); } +/* Periwinkle accent for live/realtime departures — NRK's on-dark accent color. */ .ruter-time.realtime { - color: #7fc97f; + color: var(--nrk-accent, #8bb5eb); } diff --git a/modules/MMM-RuterDepartures/MMM-RuterDepartures.js b/modules/MMM-RuterDepartures/MMM-RuterDepartures.js index 7ee268a80c..d08b6f83f8 100644 --- a/modules/MMM-RuterDepartures/MMM-RuterDepartures.js +++ b/modules/MMM-RuterDepartures/MMM-RuterDepartures.js @@ -9,6 +9,10 @@ Module.register("MMM-RuterDepartures", { defaultWalkMinutes: 0, }, + getStyles() { + return ["MMM-RuterDepartures.css"]; + }, + start() { this.stops = []; this.sendSocketNotification("RUTER_GET_DEPARTURES", { From 539fb5693a777eea35e38c1b38743a01ccca9d7d Mon Sep 17 00:00:00 2001 From: Ina Krebes Date: Wed, 14 Oct 2026 14:55:58 +0200 Subject: [PATCH 6/9] Remove compliments, lower meteogram position The compliments module was cluttering the mirror's lower half; the meteogram now has that space to itself and sits only 150px above the baseline for a more balanced vertical rhythm. Co-Authored-By: Claude Opus 4.7 --- config/config.js | 4 ---- modules/MMM-YrMeteogram/MMM-YrMeteogram.css | 2 +- 2 files changed, 1 insertion(+), 5 deletions(-) diff --git a/config/config.js b/config/config.js index e316508b5a..478495f53b 100644 --- a/config/config.js +++ b/config/config.js @@ -74,10 +74,6 @@ let config = { width: "700px", } }, - { - module: "compliments", - position: "lower_third", - }, { module: "MMM-RuterDepartures", position: "top_right", diff --git a/modules/MMM-YrMeteogram/MMM-YrMeteogram.css b/modules/MMM-YrMeteogram/MMM-YrMeteogram.css index ebe4e166c7..31b458e8c6 100644 --- a/modules/MMM-YrMeteogram/MMM-YrMeteogram.css +++ b/modules/MMM-YrMeteogram/MMM-YrMeteogram.css @@ -1,5 +1,5 @@ .module.MMM-YrMeteogram { - transform: translateY(-300px); + transform: translateY(-150px); } .MMM-YrMeteogram img { From b052b425ea927ea74dd834b62c454d67093b7c4b Mon Sep 17 00:00:00 2001 From: Ina Krebes Date: Wed, 14 Oct 2026 15:03:51 +0200 Subject: [PATCH 7/9] Fix Ruter alignment and empty Majorstuen results - Replace per-stop tables with a shared CSS grid so line/destination/time columns line up across all stops regardless of content width. - Raise the per-stop fetch pool to max(maxDepartures * 15, 50) so busy hubs like Majorstuen have departures left after the walk-time filter. Co-Authored-By: Claude Opus 4.7 --- .../MMM-RuterDepartures.css | 21 ++++++++++--------- .../MMM-RuterDepartures.js | 18 ++++++++-------- modules/MMM-RuterDepartures/node_helper.js | 5 ++++- 3 files changed, 24 insertions(+), 20 deletions(-) diff --git a/modules/MMM-RuterDepartures/MMM-RuterDepartures.css b/modules/MMM-RuterDepartures/MMM-RuterDepartures.css index ac1a8d4252..690bc7c407 100644 --- a/modules/MMM-RuterDepartures/MMM-RuterDepartures.css +++ b/modules/MMM-RuterDepartures/MMM-RuterDepartures.css @@ -11,30 +11,31 @@ color: var(--color-text-dimmed, #666); } -.ruter-departures { - width: 100%; - border-collapse: collapse; -} - -.ruter-departures td { - padding: 4px 8px; +.ruter-row { + display: grid; + grid-template-columns: 2.5em 1fr 4em; + gap: 10px; + align-items: baseline; + padding: 4px 0; } .ruter-line { + text-align: right; font-weight: 700; - min-width: 36px; color: var(--color-text-bright, #fff); letter-spacing: 0.01em; } .ruter-destination { color: var(--color-text, #a7b3c2); - padding-right: 16px; + text-align: left; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; } .ruter-time { text-align: right; - min-width: 52px; font-weight: 400; color: var(--color-text, #a7b3c2); } diff --git a/modules/MMM-RuterDepartures/MMM-RuterDepartures.js b/modules/MMM-RuterDepartures/MMM-RuterDepartures.js index d08b6f83f8..b77faba495 100644 --- a/modules/MMM-RuterDepartures/MMM-RuterDepartures.js +++ b/modules/MMM-RuterDepartures/MMM-RuterDepartures.js @@ -60,22 +60,22 @@ Module.register("MMM-RuterDepartures", { empty.textContent = "Ingen avganger innen rekkevidde"; section.appendChild(empty); } else { - const table = document.createElement("table"); - table.className = "ruter-departures small"; + const list = document.createElement("div"); + list.className = "ruter-departures small"; for (const dep of reachable) { const mins = Math.round((new Date(dep.expectedTime) - now) / 60000); - const timeStr = `${mins} min`; - const row = document.createElement("tr"); + const row = document.createElement("div"); + row.className = "ruter-row"; row.innerHTML = ` - ${dep.line} - ${dep.destination} - ${timeStr} + ${dep.line} + ${dep.destination} + ${mins} min `; - table.appendChild(row); + list.appendChild(row); } - section.appendChild(table); + section.appendChild(list); } wrapper.appendChild(section); diff --git a/modules/MMM-RuterDepartures/node_helper.js b/modules/MMM-RuterDepartures/node_helper.js index 0876428a72..6b20da09fe 100644 --- a/modules/MMM-RuterDepartures/node_helper.js +++ b/modules/MMM-RuterDepartures/node_helper.js @@ -61,7 +61,10 @@ module.exports = NodeHelper.create({ }, async fetchDepartures(stops, maxDepartures) { - const fetchCount = maxDepartures * 5; + // Busy hubs like Majorstuen have many lines departing within minutes, so a + // small fetch count combined with a walk-time filter drops everything. + // Fetch a generous pool so the window reaches well past typical walk times. + const fetchCount = Math.max(maxDepartures * 15, 50); const fields = stops .map( (stop, i) => ` From 9e5dbe0a0ad38846b232d8a845afef3bad42e879 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?P=C3=A5l=20Kristian=20Granholt?= Date: Tue, 27 Oct 2026 13:36:51 +0100 Subject: [PATCH 8/9] Add all-day portrait departures dashboard --- .gitignore | 1 + config/config.js | 83 +------ .../MMM-PortraitBoard/MMM-PortraitBoard.css | 68 +++++ .../MMM-PortraitBoard/MMM-PortraitBoard.js | 232 ++++++++++++++++++ modules/MMM-PortraitBoard/README.md | 21 ++ modules/MMM-PortraitBoard/node_helper.js | 43 ++++ .../MMM-RuterDepartures.js | 1 + modules/MMM-RuterDepartures/node_helper.js | 2 + prototypes/portrait-board/README.md | 9 + prototypes/portrait-board/index.html | 65 +++++ prototypes/portrait-board/preview.js | 20 ++ prototypes/portrait-board/style.css | 69 ++++++ 12 files changed, 542 insertions(+), 72 deletions(-) create mode 100644 modules/MMM-PortraitBoard/MMM-PortraitBoard.css create mode 100644 modules/MMM-PortraitBoard/MMM-PortraitBoard.js create mode 100644 modules/MMM-PortraitBoard/README.md create mode 100644 modules/MMM-PortraitBoard/node_helper.js create mode 100644 prototypes/portrait-board/README.md create mode 100644 prototypes/portrait-board/index.html create mode 100644 prototypes/portrait-board/preview.js create mode 100644 prototypes/portrait-board/style.css diff --git a/.gitignore b/.gitignore index 1370e3a997..71066f61dd 100644 --- a/.gitignore +++ b/.gitignore @@ -58,6 +58,7 @@ Temporary Items /modules/* !/modules/MMM-RuterDepartures !/modules/MMM-YrMeteogram +!/modules/MMM-PortraitBoard # Ignore users config file but keep the samples. config diff --git a/config/config.js b/config/config.js index 478495f53b..ecf6107ab3 100644 --- a/config/config.js +++ b/config/config.js @@ -17,94 +17,33 @@ let config = { modules: [ { - module: "alert", - }, - { - module: "updatenotification", - position: "top_bar" - }, - { - module: "clock", - position: "top_left", + module: "MMM-PortraitBoard", + position: "fullscreen_above", config: { - timezone: "Europe/Oslo" + timezone: "Europe/Oslo", + homeHour: 14, + homeMinute: 30, + walkMinutes: { Marienlyst: 5, Majorstuen: 10 } } }, { module: "calendar", - header: "Norske helligdager", - position: "top_left", - config: { - calendars: [ - { - fetchInterval: 7 * 24 * 60 * 60 * 1000, - symbol: "calendar-check", - url: "https://calendar.google.com/calendar/ical/nb.norwegian%23holiday%40group.v.calendar.google.com/public/basic.ics" - } - ] - } - }, - { - module: "weather", - position: "top_right", - config: { - weatherProvider: "openmeteo", - type: "current", - lat: 59.9139, - lon: 10.7522 - } - }, - { - module: "weather", - position: "top_right", - header: "Værmelding", - config: { - weatherProvider: "openmeteo", - type: "forecast", - lat: 59.9139, - lon: 10.7522 - } - }, - { - module: "MMM-YrMeteogram", - position: "bottom_center", + // Data source only. Add the shared Google Calendar's public/private iCal URL when available. config: { - locationId: "1-72837", - lang: "nb", - width: "700px", + calendars: [] } }, { module: "MMM-RuterDepartures", - position: "top_right", - header: "Avganger Marienlyst / Majorstuen", + // Data source only. The portrait module renders the departures. config: { lat: 59.932267, lon: 10.724083, maxStops: 3, - maxDepartures: 4, + maxDepartures: 16, updateInterval: 30 * 1000, excludeStops: ["Vestre Aker kirke"], - walkMinutes: { - "Marienlyst": 5, - "Majorstuen": 10, - }, - } - }, - { - module: "newsfeed", - position: "bottom_bar", - config: { - feeds: [ - { - title: "NRK Nyheter", - url: "https://www.nrk.no/toppsaker.rss" - } - ], - showSourceTitle: true, - showPublishDate: true, - broadcastNewsFeeds: true, - broadcastNewsUpdates: true + walkMinutes: { Marienlyst: 5, Majorstuen: 10 } } }, ] diff --git a/modules/MMM-PortraitBoard/MMM-PortraitBoard.css b/modules/MMM-PortraitBoard/MMM-PortraitBoard.css new file mode 100644 index 0000000000..3722ceed35 --- /dev/null +++ b/modules/MMM-PortraitBoard/MMM-PortraitBoard.css @@ -0,0 +1,68 @@ +/* Dedicated fullscreen layout: other MagicMirror positions are unused in portrait mode. */ +html:has(.portrait-board) { overflow: hidden; } +body:has(.portrait-board) { margin: 0; width: 100%; height: 100%; position: absolute; } +body:has(.portrait-board) .region.fullscreen.above { inset: 0; } +body:has(.portrait-board) .region.fullscreen.above > .container { height: 100%; display: flex; justify-content: center; } +body:has(.portrait-board) .region.fullscreen.above .module { margin: 0; width: min(100vw, 56.25vh); height: 100vh; } +body:has(.portrait-board) .region.fullscreen.above .module-content { height: 100%; } + +.portrait-board { --portrait-primary: #fff; --portrait-secondary: #b8bec8; --portrait-rule: #3b414a; box-sizing: border-box; container-type: inline-size; width: 100%; height: 100%; overflow: hidden; background: #05080e; color: var(--portrait-primary); font-family: "NRK Sans", Arial, sans-serif; display: grid; grid-template-rows: auto 1fr; text-align: left; line-height: 1.2; } +.portrait-board * { box-sizing: border-box; } +.portrait-masthead { display: grid; grid-template-columns: auto minmax(0, 1fr); column-gap: 2cqw; padding: 5cqw 5cqw 0; border: 0; margin: 0; line-height: 1.2; text-transform: none; } +.portrait-date { grid-column: 1; grid-row: 2; color: var(--portrait-secondary); font-size: 3.7cqw; white-space: nowrap; margin-top: 1cqw; } +.portrait-clock { grid-column: 1; grid-row: 1; color: var(--portrait-primary); font-size: 11cqw; font-weight: 750; line-height: .94; letter-spacing: -.045em; font-variant-numeric: tabular-nums; } +.portrait-header-greeting { grid-column: 2; grid-row: 1 / 3; align-self: center; justify-self: end; margin: 0; font-size: 7cqw; line-height: 1.05; letter-spacing: -.04em; font-weight: 750; color: var(--portrait-primary); text-align: right; } +.portrait-body { display: flex; flex-direction: column; min-height: 0; padding: 3cqw 5cqw; gap: 1cqw; } +.portrait-panel { border-top: 1px solid var(--portrait-rule); padding-top: 2cqw; } +.portrait-heading { display: flex; align-items: baseline; justify-content: space-between; gap: 2cqw; } +.portrait-heading h2 { margin: 0; font-size: 5cqw; line-height: 1.2; font-weight: 750; color: var(--portrait-secondary); } +.portrait-aside { color: var(--portrait-secondary); font-size: 2.5cqw; font-weight: 700; letter-spacing: .08em; white-space: nowrap; } +.portrait-weather-main { display: flex; align-items: center; gap: 4cqw; margin-top: 0; } +.portrait-weather-symbol { width: 13cqw; font-size: 11cqw; line-height: 1; color: var(--portrait-primary); text-align: center; } +.portrait-temperature { font-size: 15cqw; line-height: 1; font-weight: 650; letter-spacing: -.065em; } +.portrait-summary { font-size: 3.9cqw; line-height: 1.22; margin: 1cqw 0 0; color: var(--portrait-primary); } +.portrait-event { display: flex; gap: 5cqw; align-items: baseline; padding: 3cqw 0; border-bottom: 1px solid var(--portrait-rule); font-size: 4.4cqw; } +.portrait-event time { color: var(--portrait-primary); font-weight: 750; white-space: nowrap; font-variant-numeric: tabular-nums; } +.portrait-event div { display: flex; flex-direction: column; gap: .6cqw; min-width: 0; } +.portrait-event strong { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.portrait-event span { font-size: 3cqw; color: var(--portrait-secondary); } +.portrait-departures { margin-top: 1cqw; } +.portrait-home .portrait-departures { margin-top: 0; } +.portrait-departure-group { margin-top: 2cqw; } +.portrait-departure-group + .portrait-departure-group { margin-top: 2cqw; } +.portrait-group-header { display: flex; justify-content: space-between; align-items: baseline; gap: 2cqw; margin-bottom: 1cqw; } +.portrait-group-heading { margin: 0; font-size: 3.8cqw; line-height: 1.2; font-weight: 700; color: var(--portrait-secondary); } +.portrait-track-heading { font-size: 2.8cqw; font-weight: 700; color: var(--portrait-secondary); text-align: center; width: 7cqw; flex: none; } +.portrait-departure { display: grid; grid-template-columns: 13cqw minmax(0, 1fr) auto; align-items: center; gap: 2cqw; padding: 1cqw 0; border-bottom: 1px solid var(--portrait-rule); font-size: 5cqw; } +.portrait-departure-metro { grid-template-columns: 13cqw minmax(0, 1fr) auto 7cqw; } +.portrait-track { text-align: center; font-size: 4.4cqw; font-weight: 750; font-variant-numeric: tabular-nums; } +.portrait-badge { width: 11cqw; text-align: center; padding: 1cqw 0; font-size: 4.4cqw; line-height: 1; font-weight: 800; background: #2772b6; } +.portrait-bus { background: #d64846; } +.portrait-metro { background: #2772b6; } +/* Colours sampled from the T-bane network map; line numbers are white on every badge. */ +.portrait-metro-line-1 { background: #53abde; color: #fff; } +.portrait-metro-line-2 { background: #e27938; color: #fff; } +.portrait-metro-line-3 { background: #ae7cb0; color: #fff; } +.portrait-metro-line-4 { background: #235ca5; color: #fff; } +.portrait-metro-line-5 { background: #6cb358; color: #fff; } +.portrait-destination { min-width: 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.portrait-minutes { color: var(--portrait-primary); font-size: 5cqw; font-weight: 750; font-variant-numeric: tabular-nums; white-space: nowrap; } +.portrait-unavailable { font-size: 4cqw; color: var(--portrait-secondary); } + +/* Side-by-side greeting comparison, opt-in via ?preview=messages. */ +body:has(.portrait-comparison) .region.fullscreen.above .module { width: 100vw; } +.portrait-comparison { box-sizing: border-box; width: 100%; height: 100%; overflow-y: auto; padding: 24px; background: #05080e; color: #fff; font-family: "NRK Sans", Arial, sans-serif; } +.portrait-comparison-title { margin: 0 0 4px; font-size: 30px; } +.portrait-comparison-note { margin: 0 0 24px; color: #b8bec8; font-size: 16px; } +.portrait-comparison-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 24px; max-width: 1500px; margin: 0 auto; } +.portrait-comparison-card { min-width: 0; container-type: inline-size; } +.portrait-comparison-label { margin: 0 0 8px; color: #b8bec8; font-size: 16px; font-weight: 700; } +.portrait-comparison-card .portrait-board { height: auto; aspect-ratio: 9 / 16; border: 1px solid #3b414a; } +@media (max-width: 700px) { + .portrait-comparison-grid { grid-template-columns: 1fr; } + .portrait-comparison { padding: 16px; } +} + +@media (orientation: portrait) { + body:has(.portrait-board):not(:has(.portrait-comparison)) .region.fullscreen.above .module { width: 100vw; } +} diff --git a/modules/MMM-PortraitBoard/MMM-PortraitBoard.js b/modules/MMM-PortraitBoard/MMM-PortraitBoard.js new file mode 100644 index 0000000000..9cd956d644 --- /dev/null +++ b/modules/MMM-PortraitBoard/MMM-PortraitBoard.js @@ -0,0 +1,232 @@ +Module.register("MMM-PortraitBoard", { + defaults: { + timezone: "Europe/Oslo", + homeHour: 14, + homeMinute: 30, + lat: 59.9139, + lon: 10.7522, + weatherUpdateInterval: 30 * 60 * 1000, + weatherMaxAge: 90 * 60 * 1000, + departuresMaxAge: 2 * 60 * 1000, + walkMinutes: { Marienlyst: 5, Majorstuen: 10 }, + maxBusDepartures: 5, + maxMetroDepartures: 6 + }, + + getStyles() { + return ["MMM-PortraitBoard.css"]; + }, + + start() { + this.weather = null; + this.weatherReceivedAt = 0; + this.departures = []; + this.departuresReceivedAt = 0; + this.events = []; + this.sendSocketNotification("PORTRAIT_WEATHER_START", { + identifier: this.identifier, + lat: this.config.lat, + lon: this.config.lon, + updateInterval: this.config.weatherUpdateInterval + }); + this.timer = setInterval(() => this.updateDom(0), 30 * 1000); + }, + + socketNotificationReceived(notification, payload) { + if (notification === "PORTRAIT_WEATHER" && payload.identifier === this.identifier) { + this.weather = payload.weather; + this.weatherReceivedAt = Date.now(); + this.updateDom(0); + } + }, + + notificationReceived(notification, payload) { + if (notification === "PORTRAIT_DEPARTURES") { + this.departures = payload.stops || []; + this.departuresReceivedAt = payload.receivedAt; + this.updateDom(0); + } else if (notification === "CALENDAR_EVENTS") { + this.events = payload || []; + this.updateDom(0); + } + }, + + localParts(date) { + const parts = new Intl.DateTimeFormat("nb-NO", { + timeZone: this.config.timezone, weekday: "long", day: "numeric", month: "long", + hour: "2-digit", minute: "2-digit", hourCycle: "h23" + }).formatToParts(date); + return Object.fromEntries(parts.map(({ type, value }) => [type, value])); + }, + + greetingFor(minutes, weekday) { + if (minutes <= 7 * 60 + 30) return "Tidlig ute i dag? :)"; + if (minutes <= 9 * 60 + 30) return "God morgen"; + if (minutes < 12 * 60) return "God formiddag"; + if (minutes < this.config.homeHour * 60 + this.config.homeMinute) return "Ha en fin dag :)"; + return weekday.toLowerCase() === "fredag" ? "God helg" : "God tur hjem"; + }, + + masthead(parts) { + const minutes = Number(parts.hour) * 60 + Number(parts.minute); + const header = this.element("header", "portrait-masthead"); + header.appendChild(this.element("time", "portrait-clock", `${parts.hour}:${parts.minute}`)); + header.appendChild(this.element("span", "portrait-date", `${parts.weekday} ${parts.day}. ${parts.month}`)); + header.appendChild(this.element("p", "portrait-header-greeting", this.greetingFor(minutes, parts.weekday))); + return header; + }, + + element(tag, className, text) { + const el = document.createElement(tag); + if (className) el.className = className; + if (text !== undefined) el.textContent = text; + return el; + }, + + section(title, aside) { + const section = this.element("section", "portrait-panel"); + const heading = this.element("div", "portrait-heading"); + heading.appendChild(this.element("h2", "", title)); + if (aside) heading.appendChild(this.element("span", "portrait-aside", aside)); + section.appendChild(heading); + return section; + }, + + weatherSection(home, now) { + if (!this.weather || now - this.weatherReceivedAt > this.config.weatherMaxAge) return null; + const section = this.section("Været"); + section.classList.add("portrait-weather"); + const main = this.element("div", "portrait-weather-main"); + main.appendChild(this.element("span", "portrait-weather-symbol", this.weather.symbol)); + main.appendChild(this.element("strong", "portrait-temperature", `${Math.round(this.weather.temperature)}°`)); + section.appendChild(main); + const summary = home ? this.weather.homeSummary : this.weather.summary; + section.appendChild(this.element("p", "portrait-summary", summary)); + return section; + }, + + eventsSection(now) { + const today = new Intl.DateTimeFormat("en-CA", { timeZone: this.config.timezone, year: "numeric", month: "2-digit", day: "2-digit" }).format(new Date(now)); + const events = this.events.filter((event) => { + const start = Number(event.startDate); + const end = Number(event.endDate); + return Number.isFinite(start) && Number.isFinite(end) && end > now && + new Intl.DateTimeFormat("en-CA", { timeZone: this.config.timezone, year: "numeric", month: "2-digit", day: "2-digit" }).format(new Date(start)) === today; + }).slice(0, 2); + if (!events.length) return null; + const section = this.section("Fellesmøter i dag"); + section.classList.add("portrait-events"); + for (const event of events) { + const row = this.element("div", "portrait-event"); + row.appendChild(this.element("time", "", event.fullDayEvent ? "I dag" : new Intl.DateTimeFormat("nb-NO", { timeZone: this.config.timezone, hour: "2-digit", minute: "2-digit", hourCycle: "h23" }).format(new Date(Number(event.startDate))))); + const detail = this.element("div"); + detail.appendChild(this.element("strong", "", event.title)); + if (event.location) detail.appendChild(this.element("span", "", event.location)); + row.appendChild(detail); + section.appendChild(row); + } + return section; + }, + + departuresFor(stopName, mode, now, limit) { + const walk = this.config.walkMinutes[stopName] || 0; + return this.departures + .filter((stop) => stop.name.toLowerCase().includes(stopName.toLowerCase())) + .flatMap((stop) => stop.departures) + .filter((dep) => dep.mode === mode && (new Date(dep.expectedTime).getTime() - now) / 60000 >= walk) + .sort((a, b) => new Date(a.expectedTime) - new Date(b.expectedTime)) + .slice(0, limit); + }, + + departureGroup(title, stopName, mode, now) { + const group = this.element("div", "portrait-departure-group"); + const heading = this.element("div", "portrait-group-header"); + heading.appendChild(this.element("h3", "portrait-group-heading", `${title} · ${stopName}`)); + if (mode === "metro") heading.appendChild(this.element("span", "portrait-track-heading", "Spor")); + group.appendChild(heading); + const limit = mode === "metro" ? this.config.maxMetroDepartures : this.config.maxBusDepartures; + const reachable = this.departuresFor(stopName, mode, now, limit); + if (!reachable.length) group.appendChild(this.element("p", "portrait-unavailable", "Ingen avganger innen rekkevidde")); + for (const dep of reachable) { + const row = this.element("div", `portrait-departure${mode === "metro" ? " portrait-departure-metro" : ""}`); + const metroLine = mode === "metro" && ["1", "2", "3", "4", "5"].includes(String(dep.line)) ? ` portrait-metro-line-${dep.line}` : ""; + row.appendChild(this.element("span", `portrait-badge portrait-${mode}${metroLine}`, dep.line)); + row.appendChild(this.element("strong", "portrait-destination", dep.destination)); + row.appendChild(this.element("span", "portrait-minutes", `${Math.max(0, Math.ceil((new Date(dep.expectedTime) - now) / 60000))} min`)); + if (mode === "metro") row.appendChild(this.element("span", "portrait-track", dep.track || "–")); + group.appendChild(row); + } + return group; + }, + + departuresSection(now) { + const section = this.section("Avganger"); + section.classList.add("portrait-departures"); + if (now - this.departuresReceivedAt > this.config.departuresMaxAge) { + section.appendChild(this.element("p", "portrait-unavailable", "Avganger ikke tilgjengelige nå")); + return section; + } + section.appendChild(this.departureGroup("Buss", "Marienlyst", "bus", now)); + section.appendChild(this.departureGroup("T-bane", "Majorstuen", "metro", now)); + return section; + }, + + messagesPreview() { + const preview = this.element("main", "portrait-comparison"); + preview.appendChild(this.element("h1", "portrait-comparison-title", "Hilsener gjennom dagen")); + preview.appendChild(this.element("p", "portrait-comparison-note", "Utsnitt av dashbordet · eksempelvær · klokkeslett i Europe/Oslo")); + const grid = this.element("div", "portrait-comparison-grid"); + const cases = [ + ["00:00–07:30", "2026-10-21T07:30:00+02:00"], + ["07:31–09:30", "2026-10-21T09:00:00+02:00"], + ["09:31–11:59", "2026-10-21T10:30:00+02:00"], + ["12:00–14:29", "2026-10-21T13:00:00+02:00"], + ["14:30–23:59 · øvrige dager", "2026-10-21T15:00:00+02:00"], + ["14:30–23:59 · fredag", "2026-10-23T15:00:00+02:00"] + ]; + for (const [label, date] of cases) { + const card = this.element("div", "portrait-comparison-card"); + card.appendChild(this.element("h2", "portrait-comparison-label", label)); + const parts = this.localParts(new Date(date)); + const home = Number(parts.hour) * 60 + Number(parts.minute) >= this.config.homeHour * 60 + this.config.homeMinute; + const board = this.element("div", `portrait-board ${home ? "portrait-home" : "portrait-workday"}`); + board.appendChild(this.masthead(parts)); + const body = this.element("div", "portrait-body"); + const weather = this.section("Været"); + weather.classList.add("portrait-weather"); + const main = this.element("div", "portrait-weather-main"); + main.appendChild(this.element("span", "portrait-weather-symbol", "☁")); + main.appendChild(this.element("strong", "portrait-temperature", "12°")); + weather.appendChild(main); + weather.appendChild(this.element("p", "portrait-summary", home ? "Opphold på vei hjem" : "Opphold utover dagen")); + body.appendChild(weather); + board.appendChild(body); + card.appendChild(board); + grid.appendChild(card); + } + preview.appendChild(grid); + return preview; + }, + + getDom() { + if (new URLSearchParams(window.location.search).get("preview") === "messages") return this.messagesPreview(); + const now = Date.now(); + // Preview only changes the displayed clock/date and view; live data is still filtered against real time. + const preview = new URLSearchParams(window.location.search).get("preview"); + const parts = this.localParts(preview === "wednesday" ? new Date("2026-10-21T15:00:00+02:00") : new Date(now)); + if (preview === "morning") { + parts.hour = "09"; + parts.minute = "00"; + } + const minutes = Number(parts.hour) * 60 + Number(parts.minute); + const home = minutes >= this.config.homeHour * 60 + this.config.homeMinute; + const board = this.element("main", "portrait-board portrait-home"); + board.appendChild(this.masthead(parts)); + const body = this.element("div", "portrait-body"); + const weather = this.weatherSection(home, now); + if (weather) body.appendChild(weather); + body.appendChild(this.departuresSection(now)); + board.appendChild(body); + return board; + } +}); diff --git a/modules/MMM-PortraitBoard/README.md b/modules/MMM-PortraitBoard/README.md new file mode 100644 index 0000000000..8763aa8743 --- /dev/null +++ b/modules/MMM-PortraitBoard/README.md @@ -0,0 +1,21 @@ +# Portrait board in MagicMirror + +Configured in `config/config.js` as the only visible module. Open `http://localhost:8080` after `npm run server`. The page fills a portrait display (2160 × 3840 on a typical 4K portrait screen); on a landscape browser it is centered at 9:16 for preview. The operating system must rotate the physical monitor separately. + +For a temporary going-home layout preview with **Wednesday, 21 October, 15:00** and **God tur hjem**, open `http://localhost:8080/?preview=wednesday`. This only changes the displayed date/time and view in that browser tab. Weather and departures are still live and departure minutes refer to the real current time. Remove the query string to return to the normal schedule. + +To inspect the dashboard at a morning time, open `http://localhost:8080/?preview=morning`. It shows **09:00** on today's date with live weather and departures; departure minutes refer to the real current time. + +To compare all six greetings side by side using the dashboard's actual header and weather styling, open `http://localhost:8080/?preview=messages`. This comparison uses example weather and dates, and does not affect the normal display. + +The Wednesday preview uses the normal number of rows. Bus and T-bane departure times remain live. + +- All day: the former going-home layout with compact weather and separate **Buss · Marienlyst** (up to five) and **T-bane · Majorstuen** (up to six) groups, sorted by departure time within each group. Other modes at each stop are excluded. The weather summary changes at 14:30 **Europe/Oslo** to describe the trip home. The view and clock update every 30 seconds without reloading. Shared meetings are not shown in this departures-focused layout. +- Top-right greeting (Europe/Oslo): **Tidlig ute i dag? :)** from 00:00–07:30; **God morgen** from 07:31–09:30; **God formiddag** from 09:31–11:59; **Ha en fin dag :)** from 12:00–14:29; **God helg** from 14:30–23:59 on Fridays and **God tur hjem** at those times on other days. +- T-bane rows show the platform number (**Spor**) from Entur's `quay.publicCode` at the right; missing numbers show a dash. Bus rows do not show a platform column. +- T-bane badges for lines 1–5 use colours sampled from the [official network map](https://cdn.sanity.io/files/5a84xxkm/prod/e46cf566dd5b9cc4e323b70b168115d7bc3c911f.pdf): 1 light blue, 2 orange, 3 purple, 4 dark blue, 5 green. These are map-derived screen colours, not published brand specifications; the line number remains visible independently of colour. +- Missing or old weather disappears. If departure data is unavailable or old, the screen says so instead of showing outdated times. + +The existing `MMM-RuterDepartures` module fetches Entur data and broadcasts it to this module; it has no position, so its original display is hidden. The built-in `calendar` module remains configured as a data source for future use, but meetings are not currently displayed. If a shared Google Calendar iCal URL is added later, do not commit a private feed URL containing a secret token. + +Weather uses the Norwegian Meteorological Institute's locationforecast API (the data behind Yr), fetched by this module's node helper. This is intentionally a short weather summary, not the Yr meteogram. The sample design at `prototypes/portrait-board/index.html` remains independent and uses example data. This implementation does **not** change the physical display orientation or resolution. diff --git a/modules/MMM-PortraitBoard/node_helper.js b/modules/MMM-PortraitBoard/node_helper.js new file mode 100644 index 0000000000..a79822b408 --- /dev/null +++ b/modules/MMM-PortraitBoard/node_helper.js @@ -0,0 +1,43 @@ +const NodeHelper = require("node_helper"); + +module.exports = NodeHelper.create({ + start() { + this.timers = {}; + }, + + socketNotificationReceived(notification, payload) { + if (notification !== "PORTRAIT_WEATHER_START") return; + clearTimeout(this.timers[payload.identifier]); + this.fetchWeather(payload); + }, + + async fetchWeather(config) { + try { + const url = new URL("https://api.met.no/weatherapi/locationforecast/2.0/compact"); + url.searchParams.set("lat", config.lat); + url.searchParams.set("lon", config.lon); + const response = await fetch(url, { headers: { "User-Agent": "MagicMirror-Oslo-Edition/1.0 (https://github.com/nrkno/MagicMirror)" } }); + if (!response.ok) throw new Error(`Yr returned HTTP ${response.status}`); + const data = await response.json(); + const series = data.properties.timeseries; + const current = series[0]; + const symbol = current.data.next_1_hours?.summary?.symbol_code || current.data.next_6_hours?.summary?.symbol_code || ""; + const next = series.find((point) => new Date(point.time) > Date.now() + 3 * 60 * 60 * 1000); + const nextSymbol = next?.data.next_1_hours?.summary?.symbol_code || ""; + const rainy = /rain|sleet|snow|showers/.test(symbol); + const laterRain = /rain|sleet|snow|showers/.test(nextSymbol); + this.sendSocketNotification("PORTRAIT_WEATHER", { + identifier: config.identifier, + weather: { + temperature: current.data.instant.details.air_temperature, + symbol: rainy ? "☂" : /clearsky|fair/.test(symbol) ? "☀" : "☁", + summary: rainy ? "Nedbør nå" : laterRain ? "Nedbør senere i dag" : "Opphold utover dagen", + homeSummary: rainy ? "Nedbør på vei hjem" : "Opphold på vei hjem" + } + }); + } catch (error) { + console.error("[MMM-PortraitBoard] Yr:", error.message); + } + this.timers[config.identifier] = setTimeout(() => this.fetchWeather(config), Math.max(10 * 60 * 1000, config.updateInterval)); + } +}); diff --git a/modules/MMM-RuterDepartures/MMM-RuterDepartures.js b/modules/MMM-RuterDepartures/MMM-RuterDepartures.js index b77faba495..c80b9b8986 100644 --- a/modules/MMM-RuterDepartures/MMM-RuterDepartures.js +++ b/modules/MMM-RuterDepartures/MMM-RuterDepartures.js @@ -24,6 +24,7 @@ Module.register("MMM-RuterDepartures", { socketNotificationReceived(notification, payload) { if (notification === "RUTER_DEPARTURES" && payload.identifier === this.identifier) { this.stops = payload.stops; + this.sendNotification("PORTRAIT_DEPARTURES", { stops: this.stops, receivedAt: Date.now() }); this.updateDom(); } }, diff --git a/modules/MMM-RuterDepartures/node_helper.js b/modules/MMM-RuterDepartures/node_helper.js index 6b20da09fe..abb0d4270b 100644 --- a/modules/MMM-RuterDepartures/node_helper.js +++ b/modules/MMM-RuterDepartures/node_helper.js @@ -74,6 +74,7 @@ module.exports = NodeHelper.create({ realtime expectedDepartureTime cancellation + quay { publicCode } destinationDisplay { frontText } serviceJourney { journeyPattern { @@ -99,6 +100,7 @@ module.exports = NodeHelper.create({ mode: c.serviceJourney.journeyPattern.line.transportMode, destination: c.destinationDisplay.frontText, expectedTime: c.expectedDepartureTime, + track: c.quay?.publicCode || null, realtime: c.realtime, })) .sort((a, b) => new Date(a.expectedTime) - new Date(b.expectedTime)); diff --git a/prototypes/portrait-board/README.md b/prototypes/portrait-board/README.md new file mode 100644 index 0000000000..3a8f595369 --- /dev/null +++ b/prototypes/portrait-board/README.md @@ -0,0 +1,9 @@ +# Portrait information screen — design prototype + +Open `index.html` in a browser (double-click it in Finder). This is a **standalone mockup**; it does not change the existing MagicMirror page at `http://localhost:8080` and requires no server or npm command. + +Use **Før 14:00** and **Etter 14:00** to preview the two modes. Uncheck **Vis møter i dag** to see the workday layout without shared events. All times, dates, weather, meetings and departures are **example data**, not live information; there is no automatic switch at 14:00 yet. + +The canvas is 9:16 (2160 × 3840 on a 4K portrait screen) and scales to fit the available browser height on a landscape monitor. For a full-size preview, use browser device emulation at 2160 × 3840 or a portrait monitor. The controls sit outside the canvas so they would not appear on the actual display. + +Next steps after design review: choose content hierarchy and type sizes at real viewing distance; connect concise weather to Yr, shared events to Google Calendar and departures to Ruter; then add Oslo-time scheduling, empty states and stale-data handling. Keep live data separate from the preview until the design is approved. diff --git a/prototypes/portrait-board/index.html b/prototypes/portrait-board/index.html new file mode 100644 index 0000000000..f750a84e82 --- /dev/null +++ b/prototypes/portrait-board/index.html @@ -0,0 +1,65 @@ + + + + + + Inforskjerm — portrettskisse + + + + +
+ DESIGNSKISSE · EKSEMPELDATA +
+ + +
+ +
+ +
+
+
+

Fredag 16. oktober

+
+ +
+ +
+
+

God morgen

+
+ +
+
+

Været

+
+
+ +
11°
+
+

Lettskyet nå.
Opphold utover dagen.

+
Ta med en lett jakke
+
+ +
+

Fellesmøter i dag

+
AllmøteAuditoriet
+
Ledelsen informererStorsalen
+
+ + +
+
+ + diff --git a/prototypes/portrait-board/preview.js b/prototypes/portrait-board/preview.js new file mode 100644 index 0000000000..cdfab83d05 --- /dev/null +++ b/prototypes/portrait-board/preview.js @@ -0,0 +1,20 @@ +// Static design review only: no live departures, weather, calendar or automatic scheduling. +const board = document.querySelector(".board"); +const buttons = document.querySelectorAll("button[data-mode]"); +const eventsToggle = document.querySelector("#show-events"); + +function render(mode) { + const goingHome = mode === "home"; + board.dataset.mode = mode; + board.dataset.events = eventsToggle.checked ? "some" : "none"; + for (const button of buttons) button.setAttribute("aria-pressed", String(button.dataset.mode === mode)); + document.querySelector("#clock").textContent = goingHome ? "16:10" : "09:36"; + document.querySelector("#hero-title").textContent = goingHome ? "God tur hjem" : "God morgen"; + document.querySelector("#weather-summary").innerHTML = goingHome ? "Opphold på vei hjem." : "Lettskyet nå.
Opphold utover dagen."; + document.querySelector("#events-panel").hidden = goingHome || !eventsToggle.checked; + document.querySelector("#departures-panel").hidden = !goingHome; +} + +for (const button of buttons) button.addEventListener("click", () => render(button.dataset.mode)); +eventsToggle.addEventListener("change", () => render(board.dataset.mode)); +render("workday"); diff --git a/prototypes/portrait-board/style.css b/prototypes/portrait-board/style.css new file mode 100644 index 0000000000..353f095187 --- /dev/null +++ b/prototypes/portrait-board/style.css @@ -0,0 +1,69 @@ +@import url("https://static.nrk.no/nrk-sans/latest/nrk-sans.min.css"); + +:root { font-family: "NRK Sans", Arial, sans-serif; color: #fff; background: #202936; } +* { box-sizing: border-box; } +body { margin: 0; min-height: 100vh; display: flex; align-items: center; justify-content: center; } +button, input { font: inherit; } + +.preview-controls { position: fixed; z-index: 2; top: 20px; left: 20px; padding: 16px; background: #121b2a; border: 1px solid #55657a; border-radius: 8px; display: grid; gap: 12px; font-size: 14px; } +.preview-label { font-size: 11px; letter-spacing: .08em; color: #b6c3d8; } +.control-row { display: flex; gap: 6px; } +.control-row button { padding: 8px 12px; border: 1px solid #7795b7; background: transparent; color: #fff; border-radius: 4px; cursor: pointer; } +.control-row button[aria-pressed="true"] { background: #fff; color: #103774; } +.control-row button:focus-visible, .calendar-control:focus-within { outline: 3px solid #ffd45f; outline-offset: 3px; } +.calendar-control { display: flex; gap: 8px; align-items: center; cursor: pointer; } + +/* 9:16 portrait canvas: 2160 × 3840 at native 4K; scales as one unit for laptop preview. */ +.board { width: min(100vw, 56.25vh); aspect-ratio: 9 / 16; flex: none; container-type: inline-size; background: #05080e; display: grid; grid-template-rows: 11% 1fr; overflow: hidden; box-shadow: 0 14px 60px #0008; } +.masthead { background: #103774; display: flex; justify-content: space-between; align-items: center; gap: 3cqw; padding: 3cqw 5cqw; } +.heading-aside { letter-spacing: .08em; } +.masthead-date { font-size: 3.7cqw; margin: 0; white-space: nowrap; } +.clock { font-size: 11cqw; font-weight: 750; line-height: .94; letter-spacing: -.045em; font-variant-numeric: tabular-nums; } +.board-body { display: flex; flex-direction: column; min-height: 0; padding: 4cqw 5cqw 3cqw; gap: 5cqw; } +.hero { flex: none; } +h1 { font-size: 11cqw; line-height: 1.05; letter-spacing: -.04em; margin: 0; } +.weather-panel, .events-panel, .departures-panel { border-top: 1px solid #60728b; padding-top: 3.4cqw; } +.section-heading { display: flex; align-items: baseline; justify-content: space-between; gap: 2cqw; } +h2 { margin: 0; font-size: 5cqw; font-weight: 750; } +.heading-aside { color: #a5b9d3; font-size: 2.5cqw; font-weight: 700; white-space: nowrap; } +.weather-main { display: flex; align-items: center; gap: 4cqw; margin-top: 1cqw; } +.weather-icon { width: 20cqw; height: 20cqw; color: #b6d4f8; } +.temperature { font-size: 23cqw; line-height: 1; font-weight: 650; letter-spacing: -.065em; } +.temperature span { color: #8bb5eb; } +.weather-summary { font-size: 5cqw; line-height: 1.22; margin: 2cqw 0 0; } +.weather-summary strong { font-weight: 750; } +.weather-detail { margin-top: 5cqw; } +.weather-detail strong { font-size: 3.6cqw; } +.events-panel { padding-bottom: 3cqw; } +.event { display: flex; gap: 5cqw; align-items: baseline; padding: 3cqw 0; border-bottom: 1px solid #263345; font-size: 4.4cqw; } +.event time { color: #8bb5eb; font-weight: 750; font-variant-numeric: tabular-nums; } +.event div { display: flex; flex-direction: column; gap: .6cqw; } +.event span { font-size: 3cqw; color: #a5b9d3; } + +.board[data-mode="home"] .board-body { gap: 3cqw; } +.board[data-mode="home"] .weather-main { margin-top: 0; } +.board[data-mode="home"] .weather-icon { width: 13cqw; height: 13cqw; } +.board[data-mode="home"] .temperature { font-size: 15cqw; } +.board[data-mode="home"] .weather-summary { font-size: 3.9cqw; } +.board[data-mode="home"] .weather-detail { display: none; } +.board[data-mode="home"] .departure { padding: 2cqw 0; } +.board[data-mode="workday"][data-events="none"] .weather-panel { margin-top: 8cqw; } +.board[data-mode="workday"][data-events="none"] .weather-icon { width: 24cqw; height: 24cqw; } +.board[data-mode="workday"][data-events="none"] .temperature { font-size: 27cqw; } +.board[data-mode="workday"][data-events="none"] .weather-summary { font-size: 6cqw; } +.board[data-mode="workday"][data-events="none"] .weather-detail { margin-top: 9cqw; } +.departures-panel { margin-top: 1cqw; } +.departure { display: grid; grid-template-columns: 13cqw minmax(0, 1fr) auto; align-items: center; gap: 2cqw; padding: 3.4cqw 0; border-bottom: 1px solid #415167; font-size: 5cqw; } +.departure strong { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.line-badge { width: 11cqw; text-align: center; padding: 1cqw 0; font-size: 4.4cqw; line-height: 1; font-weight: 800; color: #fff; } +.line-badge.tram { background: #2772b6; } +.line-badge.bus { background: #d64846; } +.minutes { color: #ffd45f; font-size: 7cqw; font-weight: 750; font-variant-numeric: tabular-nums; white-space: nowrap; } +.minutes small { font-size: 3cqw; color: #d5dbe5; font-weight: 500; } +[hidden] { display: none !important; } + +@media (max-width: 800px) and (orientation: portrait) { + body { display: block; } + .preview-controls { position: relative; inset: auto; border-radius: 0; } + .board { width: 100vw; box-shadow: none; } +} From 797aa83340579ddffa562180399595077341db10 Mon Sep 17 00:00:00 2001 From: Ina Krebes Date: Tue, 27 Oct 2026 14:10:19 +0100 Subject: [PATCH 9/9] Show only clock on portrait board when offline The node helper checks internet reachability every 30 s. After two failed checks in a row the board hides weather and departures and shows a large clock and date, so no stale data is displayed. It returns to the full board on the first successful check. Preview the offline layout with ?preview=offline. Co-Authored-By: Claude Opus 5.5 --- .../MMM-PortraitBoard/MMM-PortraitBoard.css | 6 ++++ .../MMM-PortraitBoard/MMM-PortraitBoard.js | 24 ++++++++++++- modules/MMM-PortraitBoard/node_helper.js | 36 +++++++++++++++++++ 3 files changed, 65 insertions(+), 1 deletion(-) diff --git a/modules/MMM-PortraitBoard/MMM-PortraitBoard.css b/modules/MMM-PortraitBoard/MMM-PortraitBoard.css index 3722ceed35..d8b41e5d28 100644 --- a/modules/MMM-PortraitBoard/MMM-PortraitBoard.css +++ b/modules/MMM-PortraitBoard/MMM-PortraitBoard.css @@ -49,6 +49,12 @@ body:has(.portrait-board) .region.fullscreen.above .module-content { height: 100 .portrait-minutes { color: var(--portrait-primary); font-size: 5cqw; font-weight: 750; font-variant-numeric: tabular-nums; white-space: nowrap; } .portrait-unavailable { font-size: 4cqw; color: var(--portrait-secondary); } +/* Offline: only a large centred clock and date, so no stale data is shown. */ +.portrait-offline { grid-template-rows: none; align-content: center; justify-items: center; text-align: center; } +.portrait-offline .portrait-clock { font-size: 26cqw; } +.portrait-offline .portrait-date { font-size: 6cqw; margin-top: 3cqw; } +.portrait-offline-note { grid-row: 3; margin-top: 8cqw; color: var(--portrait-rule); font-size: 3cqw; font-weight: 700; letter-spacing: .08em; text-transform: uppercase; } + /* Side-by-side greeting comparison, opt-in via ?preview=messages. */ body:has(.portrait-comparison) .region.fullscreen.above .module { width: 100vw; } .portrait-comparison { box-sizing: border-box; width: 100%; height: 100%; overflow-y: auto; padding: 24px; background: #05080e; color: #fff; font-family: "NRK Sans", Arial, sans-serif; } diff --git a/modules/MMM-PortraitBoard/MMM-PortraitBoard.js b/modules/MMM-PortraitBoard/MMM-PortraitBoard.js index 9cd956d644..cf2e861a5e 100644 --- a/modules/MMM-PortraitBoard/MMM-PortraitBoard.js +++ b/modules/MMM-PortraitBoard/MMM-PortraitBoard.js @@ -10,7 +10,11 @@ Module.register("MMM-PortraitBoard", { departuresMaxAge: 2 * 60 * 1000, walkMinutes: { Marienlyst: 5, Majorstuen: 10 }, maxBusDepartures: 5, - maxMetroDepartures: 6 + maxMetroDepartures: 6, + // When the internet is unreachable, the board shows only the clock and date. + connectivityUrl: "https://api.entur.io/", + connectivityInterval: 30 * 1000, + failuresBeforeOffline: 2 }, getStyles() { @@ -23,6 +27,12 @@ Module.register("MMM-PortraitBoard", { this.departures = []; this.departuresReceivedAt = 0; this.events = []; + this.online = true; + this.sendSocketNotification("PORTRAIT_CONNECTIVITY_START", { + url: this.config.connectivityUrl, + interval: this.config.connectivityInterval, + failuresBeforeOffline: this.config.failuresBeforeOffline + }); this.sendSocketNotification("PORTRAIT_WEATHER_START", { identifier: this.identifier, lat: this.config.lat, @@ -37,6 +47,9 @@ Module.register("MMM-PortraitBoard", { this.weather = payload.weather; this.weatherReceivedAt = Date.now(); this.updateDom(0); + } else if (notification === "PORTRAIT_CONNECTIVITY") { + this.online = payload.online; + this.updateDom(0); } }, @@ -208,11 +221,20 @@ Module.register("MMM-PortraitBoard", { return preview; }, + offlineBoard(parts) { + const board = this.element("main", "portrait-board portrait-offline"); + board.appendChild(this.element("time", "portrait-clock", `${parts.hour}:${parts.minute}`)); + board.appendChild(this.element("span", "portrait-date", `${parts.weekday} ${parts.day}. ${parts.month}`)); + board.appendChild(this.element("span", "portrait-offline-note", "Frakoblet")); + return board; + }, + getDom() { if (new URLSearchParams(window.location.search).get("preview") === "messages") return this.messagesPreview(); const now = Date.now(); // Preview only changes the displayed clock/date and view; live data is still filtered against real time. const preview = new URLSearchParams(window.location.search).get("preview"); + if (!this.online || preview === "offline") return this.offlineBoard(this.localParts(new Date(now))); const parts = this.localParts(preview === "wednesday" ? new Date("2026-10-21T15:00:00+02:00") : new Date(now)); if (preview === "morning") { parts.hour = "09"; diff --git a/modules/MMM-PortraitBoard/node_helper.js b/modules/MMM-PortraitBoard/node_helper.js index a79822b408..ec77e87584 100644 --- a/modules/MMM-PortraitBoard/node_helper.js +++ b/modules/MMM-PortraitBoard/node_helper.js @@ -6,11 +6,47 @@ module.exports = NodeHelper.create({ }, socketNotificationReceived(notification, payload) { + if (notification === "PORTRAIT_CONNECTIVITY_START") { + this.startConnectivity(payload); + return; + } if (notification !== "PORTRAIT_WEATHER_START") return; clearTimeout(this.timers[payload.identifier]); this.fetchWeather(payload); }, + // One shared check for all connected browsers; a new browser just gets the current state. + startConnectivity(config) { + if (this.connectivity) { + this.sendSocketNotification("PORTRAIT_CONNECTIVITY", { online: this.connectivity.online }); + return; + } + this.connectivity = { online: true, failures: 0 }; + this.checkConnectivity(config); + }, + + async checkConnectivity(config) { + let reachable = false; + try { + // Any HTTP response, even an error status, proves the internet is reachable. + await fetch(config.url, { method: "HEAD", signal: AbortSignal.timeout(10 * 1000) }); + reachable = true; + } catch { + reachable = false; + } + + const state = this.connectivity; + state.failures = reachable ? 0 : state.failures + 1; + // Go offline only after several failures in a row, but come back on the first success. + const online = reachable || (state.online && state.failures < config.failuresBeforeOffline); + if (online !== state.online) { + state.online = online; + console.log(`[MMM-PortraitBoard] Connection ${online ? "restored" : "lost"}`); + this.sendSocketNotification("PORTRAIT_CONNECTIVITY", { online }); + } + setTimeout(() => this.checkConnectivity(config), config.interval); + }, + async fetchWeather(config) { try { const url = new URL("https://api.met.no/weatherapi/locationforecast/2.0/compact");