// NetMoCloud – Netatmo Large Widget für die iOS/iPadOS Scriptable-App // Zeigt Wohnzimmer (Hauptmodul), Küche, Kinderzimmer, Schlafzimmer und Balkon als Kacheln. // Auth: OAuth2 Refresh Token (wird im Betriebssystem-Keychain gespeichert und bei jeder Verlängerung erneuert). // Nur Verbindung zu api.netatmo.com (HTTPS), sonst keine externen Server. // Version: 2.4 (20261030) // Author: A. Kleinod (anb030.de) // ---------- Einstellungen ---------- const ORDER = ["küche", "kinder", "schlaf"]; // Reihenfolge der Innenmodule (Suchbegriffe im Modulnamen) const CO2_ORANGE_FROM = 1000; // bis 999 ppm grün, ab 1000 orange const CO2_RED_FROM = 1201; // ab 1201 ppm rot const SHORT_NAMES = [ // Kurznamen für die Kacheln [/wetterstation|wohn/i, "Wohn"], [/kinder/i, "Kind"], [/schlaf/i, "Schlaf"], [/küche|kueche/i, "Küche"], [/outdoor|außen|aussen|balkon/i, "Balkon"], ]; const STALE_MIN = 40; // Minuten, ab denen Werte als veraltet gelten const REFRESH_MIN = 10; // Netatmo meldet ca. alle 10 Minuten neue Werte // ---------- Keychain ---------- // Hinweis: Die Schlüsselnamen "netmolocal_…" (und die Datei netmolocal_cache.json) stammen vom früheren Arbeitstitel. // Sie bleiben absichtlich so, sonst wären die gespeicherten Zugangsdaten nach einer Umbenennung weg. const K = { cid: "netmolocal_client_id", sec: "netmolocal_client_secret", rt: "netmolocal_refresh_token", at: "netmolocal_access_token", exp: "netmolocal_expires_at", }; function hasCredentials() { return [K.cid, K.sec, K.rt].every((k) => Keychain.contains(k) && hasText(Keychain.get(k))); } async function setup() { const a = new Alert(); a.title = "NetMoCloud einrichten"; a.message = "Werte findest du auf dev.netatmo.com unter My Apps → deine App.\nDer Refresh Token kommt aus „Token generation“ (Scope: read_station)."; a.addTextField("Client ID"); a.addSecureTextField("Client Secret"); a.addSecureTextField("Refresh Token"); a.addAction("Speichern"); a.addCancelAction("Abbrechen"); const i = await a.presentAlert(); if (i === -1) return false; const cid = a.textFieldValue(0).trim(); const sec = a.textFieldValue(1).trim(); const rt = a.textFieldValue(2).trim(); if (!cid || !sec || !rt) { const e = new Alert(); e.title = "Unvollständig"; e.message = "Bitte alle drei Felder ausfüllen."; e.addAction("OK"); await e.presentAlert(); return false; } Keychain.set(K.cid, cid); Keychain.set(K.sec, sec); Keychain.set(K.rt, rt); if (Keychain.contains(K.at)) Keychain.remove(K.at); if (Keychain.contains(K.exp)) Keychain.remove(K.exp); return true; } // ---------- API ---------- // Fehler mit Ursache: "net" = keine Verbindung, "api" = Netatmo-Störung, "auth" = Token/Zugangsdaten ungültig function fail(kind, message) { const e = new Error(message); e.kind = kind; return e; } function hasText(v) { return typeof v === "string" && v.trim().length > 0; } // Text aus Server-Antworten für Meldungen entschärfen (nur kurze, unkritische Zeichen) function safe(s) { return String(s == null ? "" : s).replace(/[^\w .,:;()\-äöüÄÖÜß]/g, "").slice(0, 60); } // Holt JSON von Netatmo. // Keine Verbindung -> "net"; keine gültige JSON-Antwort (z. B. HTML-Fehlerseite) -> "api". async function fetchJSON(req) { let text; try { text = await req.loadString(); } catch (e) { throw fail("net", "Keine Verbindung zu Netatmo."); } const status = req.response && req.response.statusCode ? req.response.statusCode : 0; let json = null; try { json = JSON.parse(text); } catch (e) {} if (json === null || typeof json !== "object") { throw fail("api", "Ungültige Antwort von Netatmo" + (status ? " (HTTP " + status + ")" : "") + "."); } return { json: json, status: status }; } function requestTokens(refreshToken) { const body = [ "grant_type=refresh_token", "refresh_token=" + encodeURIComponent(refreshToken), "client_id=" + encodeURIComponent(Keychain.get(K.cid)), "client_secret=" + encodeURIComponent(Keychain.get(K.sec)), ].join("&"); const req = new Request("https://api.netatmo.com/oauth2/token"); req.method = "POST"; req.headers = { "Content-Type": "application/x-www-form-urlencoded;charset=UTF-8" }; req.body = body; return fetchJSON(req); } function cachedAccessToken() { if (Keychain.contains(K.at) && Keychain.contains(K.exp)) { const at = Keychain.get(K.at); if (hasText(at) && Date.now() < Number(Keychain.get(K.exp)) - 60000) return at; } return null; } function clearAccessToken() { if (Keychain.contains(K.at)) Keychain.remove(K.at); if (Keychain.contains(K.exp)) Keychain.remove(K.exp); } async function getAccessToken(forceRefresh) { if (!forceRefresh) { const cached = cachedAccessToken(); if (cached) return cached; } let used = Keychain.get(K.rt); // der Refresh Token, mit dem diese Anfrage gesendet wird let { json: res, status } = await requestTokens(used); // Läuft gleichzeitig ein zweiter Lauf (Widget + App), kann dieser den Refresh Token schon erneuert haben. // Dann ist der eben benutzte Token ungültig, im Keychain steht aber bereits der neue -> noch einmal versuchen. if (!hasText(res.access_token) && res.error === "invalid_grant") { const current = Keychain.contains(K.rt) ? Keychain.get(K.rt) : ""; if (hasText(current) && current !== used) { const other = cachedAccessToken(); if (other) return other; used = current; ({ json: res, status } = await requestTokens(used)); } } if (!hasText(res.access_token)) { const KNOWN = ["invalid_grant", "invalid_client", "invalid_request", "invalid_token", "unauthorized_client"]; const err = typeof res.error === "string" ? res.error : ""; // Nur eindeutige Ablehnungen (HTTP 4xx außer 429) gelten als ungültige Zugangsdaten. // Serverfehler (5xx), 429 und unbekannte Antworten sind vorübergehend -> "api", Zugangsdaten bleiben unberührt. const rejected = KNOWN.includes(err) && status >= 400 && status < 500 && status !== 429; throw fail( rejected ? "auth" : "api", "Token-Refresh fehlgeschlagen: " + (KNOWN.includes(err) ? err : "unbekannt") + (status ? " (HTTP " + status + ")" : "") + (rejected ? "\nScript in der App starten und Zugangsdaten neu eingeben." : "") ); } // Netatmo erneuert bei jeder Verlängerung beide Token und macht die alten ungültig -> neuen Refresh Token sofort speichern. // Aber nur, wenn er gültig ist UND im Keychain noch der Token steht, mit dem wir angefragt haben. // Hat in der Zwischenzeit ein anderer Lauf (Widget/App) oder die Eingabe in setup() einen neueren Token // gespeichert, wird dieser nicht überschrieben; unser Access Token gilt dann nur für diesen Lauf. const stored = Keychain.contains(K.rt) ? Keychain.get(K.rt) : ""; const unchanged = stored === used; if (!hasText(res.refresh_token)) { // Laut Netatmo-Doku liefert die Erneuerung immer beide Token. Fehlt der neue, ist der alte möglicherweise // schon ungültig. Alten Token behalten (nichts überschreiben), den Access Token aber nicht zwischenspeichern: // so schlägt die nächste Erneuerung sofort sichtbar fehl (-> "Token ungültig"), statt später unbemerkt. console.warn("Netatmo hat keinen neuen Refresh Token geliefert."); if (unchanged) clearAccessToken(); return res.access_token; } if (!unchanged) return res.access_token; Keychain.set(K.rt, res.refresh_token); const seconds = Number(res.expires_in); if (isFinite(seconds) && seconds > 0) { Keychain.set(K.at, res.access_token); Keychain.set(K.exp, String(Date.now() + seconds * 1000)); } else { clearAccessToken(); // Ablaufzeit unbekannt: Token nur für diesen Lauf verwenden } return res.access_token; } // Plausibilitätsprüfung für Stationsdaten (aus Netatmo oder aus dem Zwischenspeicher) function isValidStation(s) { if (!s || typeof s !== "object") return false; const hasData = s.dashboard_data && typeof s.dashboard_data === "object"; return Boolean(hasData || Array.isArray(s.modules)); } async function loadStation() { let token = await getAccessToken(false); for (let attempt = 0; attempt < 2; attempt++) { const req = new Request("https://api.netatmo.com/api/getstationsdata?get_favorites=false"); req.headers = { Authorization: "Bearer " + token }; const { json: res } = await fetchJSON(req); const dev = res.body && Array.isArray(res.body.devices) ? res.body.devices[0] : null; if (isValidStation(dev)) return dev; // Fehlercode 2/3: Access Token ungültig/abgelaufen -> einmal neu holen und wiederholen const code = res.error && typeof res.error === "object" ? res.error.code : null; if ((code === 2 || code === 3) && attempt === 0) { clearAccessToken(); token = await getAccessToken(true); continue; } const msg = res.error && typeof res.error === "object" ? safe(res.error.message) : safe(res.error); throw fail("api", "Keine Stationsdaten erhalten" + (msg ? ": " + msg : "")); } } // ---------- Zwischenspeicher (Offline-Stand) ---------- let CACHED = false; // true, wenn die Daten aus dem Zwischenspeicher stammen let CACHED_REASON = "net"; // Ursache: "net", "api", "auth" oder "nodata" (Station ohne Messwerte) const CACHE_FILE = "netmolocal_cache.json"; function cachePath() { const fm = FileManager.local(); return fm.joinPath(fm.documentsDirectory(), CACHE_FILE); } // Positivliste: nur die Felder, die das Widget wirklich anzeigt. // Keine Zugangsdaten, keine Geräte-IDs (_id), keine Standortdaten, keine übrigen Messwerte oder Metadaten. const CACHE_DASH_FIELDS = ["Temperature", "CO2", "Humidity", "Pressure", "min_temp", "max_temp", "time_utc"]; const CACHE_MODULE_TYPES = ["NAModule4", "NAModule1"]; // Innen- und Außenmodul; andere Module zeigt das Widget nicht function slimDash(d) { if (!d || typeof d !== "object") return undefined; const out = {}; for (const k of CACHE_DASH_FIELDS) { if (typeof d[k] === "number" && isFinite(d[k])) out[k] = d[k]; } if (["up", "down", "stable"].includes(d.pressure_trend)) out.pressure_trend = d.pressure_trend; return out; } function slimModule(m, withType) { const out = {}; if (typeof m.module_name === "string") out.module_name = m.module_name.slice(0, 60); if (withType) out.type = m.type; if (typeof m.reachable === "boolean") out.reachable = m.reachable; if (typeof m.battery_percent === "number" && isFinite(m.battery_percent)) out.battery_percent = m.battery_percent; const d = slimDash(m.dashboard_data); if (d) out.dashboard_data = d; return out; } function slimStation(s) { const out = slimModule(s, false); out.modules = (Array.isArray(s.modules) ? s.modules : []) .filter((m) => m && typeof m === "object" && CACHE_MODULE_TYPES.includes(m.type)) .map((m) => slimModule(m, true)); return out; } function saveCache(station) { try { // Nur Stände mit echten Messwerten speichern: eine Station ohne Daten (Netatmo meldet sie als nicht erreichbar) // würde sonst den letzten guten Stand überschreiben. if (!isValidStation(station) || !station.dashboard_data) return; FileManager.local().writeString(cachePath(), JSON.stringify(slimStation(station))); } catch (e) {} } function loadCache() { try { const fm = FileManager.local(); const p = cachePath(); if (!fm.fileExists(p)) return null; const station = JSON.parse(fm.readString(p)); return isValidStation(station) ? slimStation(station) : null; // beschädigt/unvollständig -> ignorieren; alte Dateien werden bereinigt } catch (e) { return null; } } // ---------- Hilfsfunktionen ---------- function fmtTemp(t) { return typeof t !== "number" || !isFinite(t) ? "–" : t.toFixed(1).replace(".", ","); } function shortName(name) { for (const [re, short] of SHORT_NAMES) { if (re.test(name || "")) return short; } return name || "Modul"; } // Hell-/Dunkelmodus: Im Widget-Feld "Parameter" (Widget bearbeiten) kann "light" oder "dark" stehen // und legt die Darstellung fest. Leer (oder etwas anderes) = automatisch nach iOS. function readMode() { try { const p = String(args.widgetParameter || "").trim().toLowerCase(); if (p === "light" || p === "hell") return "light"; if (p === "dark" || p === "dunkel") return "dark"; } catch (e) {} return null; } const MODE = readMode(); function themed(light, dark) { if (MODE === "light") return new Color(light); if (MODE === "dark") return new Color(dark); return Color.dynamic(new Color(light), new Color(dark)); } // Farben (automatisch nach System, oder fest nach Parameter) const P = { bg: themed("#f2f2f7", "#14181f"), tile: themed("#ffffff", "#232a35"), text: themed("#111418", "#ffffff"), sub: themed("#5f6b7a", "#9aa4b2"), title: themed("#5f6b7a", "#ffffff"), // Kachel-Titel (Raumname): im Dunkelmodus weiß, im Hellmodus grau faint: themed("#8a919c", "#6b7280"), hum: themed("#0a6cc4", "#8ecbff"), }; // Halbtransparente Hintergründe für das Status-Badge in der Kopfzeile function themedSoft(light, dark, a) { if (MODE === "light") return new Color(light, a); if (MODE === "dark") return new Color(dark, a); return Color.dynamic(new Color(light, a), new Color(dark, a)); } const ORANGE_SOFT = themedSoft("#d97706", "#ff9f0a", 0.18); const RED_SOFT = themedSoft("#d92d20", "#ff453a", 0.18); const GREEN = themed("#1f9d45", "#34c759"); const ORANGE = themed("#d97706", "#ff9f0a"); const RED = themed("#d92d20", "#ff453a"); function co2Color(ppm) { if (ppm < CO2_ORANGE_FROM) return GREEN; if (ppm < CO2_RED_FROM) return ORANGE; return RED; } // Luftfeuchtigkeit: unter 40 % rot, 40–60 % grün, über 60 % rot function humColor(h) { return h >= 40 && h <= 60 ? GREEN : RED; } function fmtTime(sec) { if (typeof sec !== "number" || !isFinite(sec)) return "–"; const d = new Date(sec * 1000); return d.toLocaleTimeString("de-DE", { hour: "2-digit", minute: "2-digit" }); } function orderIndex(name) { const n = (name || "").toLowerCase(); const i = ORDER.findIndex((k) => n.includes(k)); return i === -1 ? 99 : i; } // ---------- Status-Symbole (Batterie / Netzteil / N/A), mit DrawContext gezeichnet ---------- const BATTERY_LOW_MAX = 20; // bis einschließlich 20 % "leer", ab 21 % "voll" const ICON_GREEN = new Color("#2da649"); const ICON_RED = new Color("#ee3b23"); // Farbe von Ring, Stecker und "N/A". Automatik: mittleres Grau, denn auf dem Home-Bildschirm lässt sich der // Hell-/Dunkelmodus eines Widgets im Script nicht zuverlässig erkennen, Grau ist auf beiden Kacheln lesbar. // Mit festem Parameter ("light" / "dark") sind die Symbole dunkel bzw. weiß. const ICON_INK = MODE === "light" ? new Color("#111418") : MODE === "dark" ? new Color("#ffffff") : new Color("#7c8695"); // kind: "full" | "low" | "plug" | "na" function statusIcon(kind) { const ink = ICON_INK; const ctx = new DrawContext(); ctx.size = new Size(100, 100); ctx.opaque = false; ctx.respectScreenScale = true; ctx.setStrokeColor(ink); ctx.setFillColor(ink); ctx.setLineWidth(6); ctx.strokeEllipse(new Rect(4, 4, 92, 92)); // Kreisring if (kind === "full" || kind === "low") { const body = new Path(); body.addRoundedRect(new Rect(33, 23, 34, 60), 5, 5); ctx.addPath(body); ctx.setLineWidth(5); ctx.strokePath(); ctx.fillRect(new Rect(42, 17, 16, 6)); // Pol oben const n = kind === "full" ? 4 : 2; ctx.setFillColor(kind === "full" ? ICON_GREEN : ICON_RED); for (let i = 0; i < n; i++) { ctx.fillRect(new Rect(38, 78 - (i + 1) * 10.5 - i * 2.7, 24, 10.5)); // Balken von unten nach oben } } else if (kind === "plug") { const p = new Path(); p.addRoundedRect(new Rect(37, 16, 6, 24), 3, 3); // Stifte p.addRoundedRect(new Rect(57, 16, 6, 24), 3, 3); p.addRoundedRect(new Rect(29, 36, 42, 22), 5, 5); // Steckerkörper p.addRect(new Rect(44, 56, 12, 10)); // Hals ctx.addPath(p); ctx.fillPath(); const cable = new Path(); cable.move(new Point(50, 64)); cable.addQuadCurve(new Point(38, 86), new Point(50, 84)); // Kabel ctx.addPath(cable); ctx.setLineWidth(6); ctx.strokePath(); } else { ctx.setFont(Font.heavySystemFont(36)); ctx.setTextColor(ink); ctx.setTextAlignedCenter(); ctx.drawTextInRect("N/A", new Rect(8, 28, 84, 44)); } return ctx.getImage(); } // Welches Symbol? Hauptmodul: Netzteil. Sonst nach battery_percent; ohne Zahl (egal warum): N/A. function iconKind(mod, opts) { if (opts.main) return "plug"; const b = mod.battery_percent; if (typeof b !== "number" || !isFinite(b) || b < 0 || b > 100) return "na"; return b > BATTERY_LOW_MAX ? "full" : "low"; } // ---------- Widget ---------- function addTile(parent, mod, opts) { const name = mod.module_name || "Modul"; const d = mod.dashboard_data; const t = parent.addStack(); t.layoutVertically(); t.backgroundColor = P.tile; t.cornerRadius = 16; t.setPadding(10, 12, 10, 12); const head = t.addStack(); head.centerAlignContent(); if (!opts.outdoor && d && mod.reachable !== false && d.CO2 != null) { const dot = head.addText("● "); dot.font = Font.systemFont(13); dot.textColor = co2Color(d.CO2); } const title = head.addText(shortName(name)); title.font = Font.semiboldSystemFont(13); title.textColor = P.title; title.lineLimit = 1; head.addSpacer(); if (opts.outdoor && d && mod.reachable !== false && d.Humidity != null) { // Balkon: Luftfeuchtigkeit mittig zwischen Raumname und Symbol const drop = head.addText("💧 "); drop.font = Font.systemFont(13); const hum = head.addText(d.Humidity + " %"); hum.font = Font.boldSystemFont(11); // gleiche Größe wie in den Innenräumen (dort schrumpft die Schrift etwas) hum.textColor = P.hum; hum.lineLimit = 1; hum.minimumScaleFactor = 0.75; head.addSpacer(); } // Status-Symbol immer oben rechts const ic = head.addImage(statusIcon(iconKind(mod, opts))); ic.imageSize = new Size(18, 18); t.addSpacer(2); if (!d || mod.reachable === false) { const off = t.addText("offline"); off.font = Font.mediumSystemFont(18); off.textColor = RED; return; } const stale = CACHED || (Date.now() / 1000 - d.time_utc) / 60 > STALE_MIN; const tempRow = t.addStack(); tempRow.bottomAlignContent(); const num = tempRow.addText(fmtTemp(d.Temperature)); num.font = Font.boldSystemFont(opts.big ? 38 : 32); num.textColor = stale ? P.faint : P.text; const unit = tempRow.addText(" °C"); unit.font = Font.mediumSystemFont(opts.big ? 18 : 15); unit.textColor = P.sub; tempRow.addSpacer(); t.addSpacer(2); const row = t.addStack(); row.centerAlignContent(); if (!opts.outdoor && d.CO2 != null) { const co2 = row.addText(d.CO2 + " ppm"); co2.font = Font.boldSystemFont(13); co2.textColor = co2Color(d.CO2); co2.lineLimit = 1; co2.minimumScaleFactor = 0.8; row.addSpacer(); // schiebt die Luftfeuchtigkeit nach rechts } if (!opts.outdoor && d.Humidity != null) { const drop = row.addText("💧 "); drop.font = Font.systemFont(13); const hum = row.addText(d.Humidity + " %"); hum.font = Font.boldSystemFont(13); hum.textColor = P.hum; hum.lineLimit = 1; hum.minimumScaleFactor = 0.8; const hdot = row.addText(" ●"); hdot.font = Font.systemFont(13); hdot.textColor = humColor(d.Humidity); } if (opts.outdoor) { // Balkon: Tiefsttemperatur links, Höchsttemperatur rechts if (d.min_temp != null) { const lo = row.addText("↓ " + fmtTemp(d.min_temp) + " °C"); lo.font = Font.boldSystemFont(13); lo.textColor = P.hum; lo.lineLimit = 1; lo.minimumScaleFactor = 0.8; } row.addSpacer(); if (d.max_temp != null) { const hi = row.addText("↑ " + fmtTemp(d.max_temp) + " °C"); hi.font = Font.boldSystemFont(13); hi.textColor = ORANGE; hi.lineLimit = 1; hi.minimumScaleFactor = 0.8; } } } // Luftdruck-Kachel mit Trend (Pfeil + Farbe) const PRESSURE_TREND = { up: { arrow: "↗", label: "steigend", color: () => GREEN }, down: { arrow: "↘", label: "fallend", color: () => ORANGE }, stable: { arrow: "→", label: "stabil", color: () => P.sub }, }; function addPressureTile(parent, station) { const d = station.dashboard_data; const ok = d && station.reachable !== false && d.Pressure != null; const tr = ok ? PRESSURE_TREND[d.pressure_trend] : null; const t = parent.addStack(); t.layoutVertically(); t.backgroundColor = P.tile; t.cornerRadius = 16; t.setPadding(10, 12, 10, 12); const head = t.addStack(); head.centerAlignContent(); if (tr) { const dot = head.addText("● "); dot.font = Font.systemFont(13); dot.textColor = tr.color(); } const title = head.addText("Luftdruck"); title.font = Font.semiboldSystemFont(13); title.textColor = P.title; title.lineLimit = 1; head.addSpacer(); t.addSpacer(2); const valRow = t.addStack(); valRow.bottomAlignContent(); const num = valRow.addText(ok ? String(Math.round(d.Pressure)) : "–"); num.font = Font.boldSystemFont(32); num.textColor = CACHED ? P.faint : P.text; num.lineLimit = 1; num.minimumScaleFactor = 0.8; const unit = valRow.addText(" hPa"); unit.font = Font.mediumSystemFont(15); unit.textColor = P.sub; valRow.addSpacer(); t.addSpacer(2); const row = t.addStack(); row.centerAlignContent(); if (tr) { const trend = row.addText(tr.arrow + " " + tr.label); trend.font = Font.boldSystemFont(13); trend.textColor = tr.color(); trend.lineLimit = 1; trend.minimumScaleFactor = 0.8; } row.addSpacer(); } function buildWidget(station) { const w = new ListWidget(); w.backgroundColor = P.bg; w.setPadding(14, 14, 14, 14); w.refreshAfterDate = new Date(Date.now() + REFRESH_MIN * 60 * 1000); const mods = station.modules || []; const indoor = mods .filter((m) => m.type === "NAModule4") .sort((a, b) => orderIndex(a.module_name) - orderIndex(b.module_name)); const outdoor = mods.find((m) => m.type === "NAModule1"); // Kopfzeile const header = w.addStack(); header.centerAlignContent(); const h = header.addText("NetMoCloud"); h.font = Font.boldSystemFont(18); h.textColor = P.text; header.addSpacer(); // Status oben rechts: normal grau "Stand HH:MM"; bei Problemen ein farbiges Badge mit Ursache und Uhrzeit. // Veraltet = ältester Messwert aller Kacheln ist älter als STALE_MIN Minuten. const REASON_LABEL = { net: "Offline", api: "Netatmo-Störung", auth: "Token ungültig", nodata: "Keine Daten" }; const times = [station.dashboard_data] .concat(mods.map((m) => m.dashboard_data)) .map((x) => (x ? x.time_utc : null)) .filter((x) => typeof x === "number" && isFinite(x)); const oldest = times.length ? Math.min(...times) : null; const isStale = oldest !== null && (Date.now() / 1000 - oldest) / 60 > STALE_MIN; const noData = oldest === null; // weder Messwerte noch Zwischenspeicher if (CACHED || isStale || noData) { const bad = CACHED && CACHED_REASON === "auth"; const text = CACHED ? REASON_LABEL[CACHED_REASON] || "Offline" : noData ? "Keine Daten" : "Veraltet"; const badge = header.addStack(); badge.backgroundColor = bad ? RED_SOFT : ORANGE_SOFT; badge.cornerRadius = 8; badge.setPadding(3, 8, 3, 8); const bt = badge.addText(oldest !== null ? text + " · " + fmtTime(oldest) : text); bt.font = Font.boldSystemFont(11); bt.textColor = bad ? RED : ORANGE; bt.lineLimit = 1; bt.minimumScaleFactor = 0.8; } else { const upd = station.dashboard_data ? fmtTime(station.dashboard_data.time_utc) : "–"; const u = header.addText("Stand " + upd); u.font = Font.systemFont(11); u.textColor = P.faint; } w.addSpacer(8); // Reihe 1: Wohnzimmer + 1. Innenmodul const row1 = w.addStack(); row1.spacing = 8; const main = { module_name: station.module_name || "Wohnzimmer", dashboard_data: station.dashboard_data, reachable: station.reachable, }; addTile(row1, main, { main: true }); if (indoor[0]) addTile(row1, indoor[0], {}); w.addSpacer(8); // Reihe 2: weitere Innenmodule const row2 = w.addStack(); row2.spacing = 8; if (indoor[1]) addTile(row2, indoor[1], {}); if (indoor[2]) addTile(row2, indoor[2], {}); w.addSpacer(8); // Reihe 3: Balkon + Luftdruck const row3 = w.addStack(); row3.spacing = 8; if (outdoor) { addTile(row3, outdoor, { outdoor: true }); } addPressureTile(row3, station); w.addSpacer(); return w; } function errorWidget(msg) { const w = new ListWidget(); w.backgroundColor = P.bg; const t = w.addText("NetMoCloud"); t.font = Font.boldSystemFont(16); t.textColor = P.text; w.addSpacer(6); const m = w.addText(msg); m.font = Font.systemFont(12); m.textColor = RED; w.refreshAfterDate = new Date(Date.now() + 15 * 60 * 1000); return w; } async function createWidget() { if (!hasCredentials()) { throw new Error("Noch nicht eingerichtet. Script einmal in der Scriptable-App öffnen und starten."); } let station; try { station = await loadStation(); CACHED = false; if (station.dashboard_data) { saveCache(station); } else { // Netatmo antwortet, meldet die Station aber ohne Messwerte (nicht erreichbar): // letzten guten Stand mit Hinweis zeigen. Gibt es keinen, bleibt die "offline"-Anzeige. const old = loadCache(); if (old && old.dashboard_data) { station = old; CACHED = true; CACHED_REASON = "nodata"; } } } catch (e) { // Keine Verbindung oder Netatmo-Störung: letzten bekannten Stand anzeigen station = loadCache(); if (!station) throw e; CACHED = true; CACHED_REASON = e.kind || "api"; } return buildWidget(station); } // ---------- Start ---------- if (config.runsInWidget) { let widget; try { widget = await createWidget(); } catch (e) { widget = errorWidget(String(e.message || e)); } Script.setWidget(widget); } else { // In der App: Menü if (!hasCredentials()) { await setup(); } const menu = new Alert(); menu.title = "NetMoCloud"; menu.addAction("Large-Vorschau"); menu.addAction("Zugangsdaten neu eingeben"); menu.addCancelAction("Schließen"); const choice = await menu.presentSheet(); if (choice === 1) { await setup(); } if (choice === 0 || choice === 1) { try { const widget = await createWidget(); await widget.presentLarge(); } catch (e) { const a = new Alert(); a.title = "Fehler"; a.message = String(e.message || e); a.addAction("OK"); await a.presentAlert(); } } } Script.complete();