From 236b8db0d54faa2baf30e470f4cdc1d0b3dc40f2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thomas=20M=C3=BCller?= Date: Mon, 28 Sep 2026 14:09:31 +0200 Subject: [PATCH] Zeitplanung und nautische Einzeltermine erweitern --- README.md | 1 - deploy-test.ps1 | 1 - .../dist/sternwarte-card-retro.js | 1580 ----------------- .../dist/sternwarte-card.js | 10 +- .../custom_components/sternwarte/__init__.py | 22 + .../custom_components/sternwarte/const.py | 2 + .../sternwarte/coordinator.py | 27 + .../sternwarte/services.yaml | 8 + 8 files changed, 67 insertions(+), 1584 deletions(-) delete mode 100644 sternwarte-homeassistant-card/dist/sternwarte-card-retro.js diff --git a/README.md b/README.md index ad0166f..27c2658 100644 --- a/README.md +++ b/README.md @@ -37,7 +37,6 @@ Für Tests gibt es eine komplette Test-Home-Assistant-Umgebung mit SMB-Zugriff u In dieser Freigabe liegen die Card-Dateien, die Home Assistant direkt als Dashboard-Ressourcen lädt: - `\\192.168.1.71\config\www\sternwarte-card.js` -- `\\192.168.1.71\config\www\sternwarte-card-retro.js` - `\\192.168.1.71\config\www\astronomy.browser.min.js` - `\\192.168.1.71\config\www\moon_small.png` diff --git a/deploy-test.ps1 b/deploy-test.ps1 index 94edf48..75e4930 100644 --- a/deploy-test.ps1 +++ b/deploy-test.ps1 @@ -5,7 +5,6 @@ $sourceDir = Join-Path $repoRoot "sternwarte-homeassistant-card\dist" $targetDir = "\\192.168.1.71\config\www" $files = @( "sternwarte-card.js", - "sternwarte-card-retro.js", "astronomy.browser.min.js", "moon_small.png" ) diff --git a/sternwarte-homeassistant-card/dist/sternwarte-card-retro.js b/sternwarte-homeassistant-card/dist/sternwarte-card-retro.js deleted file mode 100644 index 99becb8..0000000 --- a/sternwarte-homeassistant-card/dist/sternwarte-card-retro.js +++ /dev/null @@ -1,1580 +0,0 @@ -/* Sternwarte Card · Retro Edition - * Vintage Maschinen-Bedienpaneel mit echten 7-Segment-Anzeigen, - * Kippschaltern, Jewel-Lampen, illuminierten Druckknoepfen und - * analoger Zeigerinstrument-Anzeige. Funktional identisch mit der - * Standard-Card, aber komplett unabhaengig. - */ - -const DEFAULT_ENTITIES = { - roof: "cover.sternwarte_dach", - power: "switch.sternwarte_dachsteuerung", - roofClosed: "binary_sensor.sternwarte_dach_zu", - roofOpen: "binary_sensor.sternwarte_dach_offen", - roofMoving: "binary_sensor.sternwarte_dach_bewegt", - roofStatus: "sensor.sternwarte_dachstatus", - roofPosition: "sensor.sternwarte_dachposition", - rain: "binary_sensor.sternwarte_regen", - wind: "sensor.sternwarte_windgeschwindigkeit", - scheduleOpen: "datetime.sternwarte_offnen_geplant", - scheduleClose: "datetime.sternwarte_schliessen_geplant", - astroLat: 50.2984230, - astroLon: 11.0481766, -}; - -const ASTRONOMY_LIB_URL = (() => { - try { return new URL("./astronomy.browser.min.js", import.meta.url).toString(); } - catch { return null; } -})(); - -const MOON_IMAGE_URL = (() => { - try { return new URL("./moon_small.png", import.meta.url).toString(); } - catch { return null; } -})(); - -/* ---------- 7-Segment-SVG ---------- */ - -const SEG_POLYS = { - a: "6,0 24,0 27,3 24,6 6,6 3,3", - b: "27,5 30,8 30,20 27,23 24,20 24,8", - c: "27,29 30,32 30,44 27,47 24,44 24,32", - d: "6,44 24,44 27,47 24,50 6,50 3,47", - e: "3,29 6,32 6,44 3,47 0,44 0,32", - f: "3,5 6,8 6,20 3,23 0,20 0,8", - g: "6,22 24,22 27,25 24,28 6,28 3,25", -}; -const SEG_MAP = { - "0": "abcdef", "1": "bc", "2": "abdeg", "3": "abcdg", "4": "bcfg", - "5": "acdfg", "6": "acdefg", "7": "abc", "8": "abcdefg", "9": "abcdfg", - "-": "g", " ": "", "_": "d", "?": "abeg", -}; - -function seg7(text, opts = {}) { - const { color = "red", size = 1, pad = 0 } = opts; - let str = String(text ?? ""); - if (pad > 0 && str.length < pad) str = str.padStart(pad, " "); - const out = []; - for (const ch of str) { - if (ch === ":") { - out.push(``); - continue; - } - if (ch === ".") { - out.push(``); - continue; - } - const lit = SEG_MAP[ch.toUpperCase()] ?? ""; - const polys = Object.entries(SEG_POLYS).map(([name, points]) => - `` - ).join(""); - out.push(``); - } - return `${out.join("")}`; -} - -/* ---------- Card ---------- */ - -class SternwarteCardRetro extends HTMLElement { - constructor() { - super(); - this._scheduleInputState = { - open: { draft: "", editing: false, pending: false }, - close: { draft: "", editing: false, pending: false }, - }; - this._pendingRender = false; - this._astronomyLoadPromise = null; - this._moonTexture = null; - this._moonTexturePromise = null; - } - - connectedCallback() { - this._ensureAstronomy(); - this._ensureMoonTexture(); - this._ensureClockTimer(); - } - - disconnectedCallback() { - if (this._clockTimer) { window.clearInterval(this._clockTimer); this._clockTimer = null; } - } - - setConfig(config) { - this.config = { ...DEFAULT_ENTITIES, ...config }; - if (!this.shadowRoot) this.attachShadow({ mode: "open" }); - } - - set hass(hass) { this._hass = hass; this._ensureClockTimer(); this._requestRender(); } - - getCardSize() { return 9; } - getGridOptions() { return { rows: 9, columns: 18, min_rows: 8 }; } - - _state(id) { return this._hass?.states?.[id]; } - - _ensureClockTimer() { - if (this._clockTimer) return; - this._clockTimer = window.setInterval(() => { - if (this._hass) this._requestRender(); - }, 1000); - } - - _astronomy() { return window.Astronomy || null; } - - _ensureAstronomy() { - const a = this._astronomy(); - if (a) return Promise.resolve(a); - if (this._astronomyLoadPromise) return this._astronomyLoadPromise; - if (!ASTRONOMY_LIB_URL) return Promise.resolve(null); - - this._astronomyLoadPromise = new Promise((resolve) => { - const finish = () => { - const loaded = this._astronomy(); - if (loaded && this._hass) this._requestRender(); - this._astronomyLoadPromise = null; - resolve(loaded || null); - }; - const existing = Array.from(document.querySelectorAll("script")) - .find((s) => s.src === ASTRONOMY_LIB_URL); - if (existing) { - existing.addEventListener("load", finish, { once: true }); - existing.addEventListener("error", finish, { once: true }); - return; - } - const s = document.createElement("script"); - s.src = ASTRONOMY_LIB_URL; s.async = true; - s.addEventListener("load", finish, { once: true }); - s.addEventListener("error", finish, { once: true }); - document.head.appendChild(s); - }); - return this._astronomyLoadPromise; - } - - _ensureMoonTexture() { - if (this._moonTexture) return Promise.resolve(this._moonTexture); - if (this._moonTexturePromise) return this._moonTexturePromise; - if (!MOON_IMAGE_URL) return Promise.resolve(null); - this._moonTexturePromise = new Promise((resolve) => { - const img = new Image(); - img.addEventListener("load", () => { - this._moonTexture = img; - this._moonTexturePromise = null; - if (this._hass) this._requestRender(); - resolve(img); - }, { once: true }); - img.addEventListener("error", () => { this._moonTexturePromise = null; resolve(null); }, { once: true }); - img.src = MOON_IMAGE_URL; - }); - return this._moonTexturePromise; - } - - _requestRender() { - if (this._isScheduleEditing()) { this._pendingRender = true; return; } - this._pendingRender = false; - this.render(); - } - - _isScheduleEditing() { return Object.values(this._scheduleInputState).some((f) => f.editing); } - _scheduleFieldState(k) { return this._scheduleInputState[k]; } - _scheduleInputValue(k, fb) { const f = this._scheduleFieldState(k); return f.draft || this._isoForInput(fb); } - - _resetScheduleDrafts() { - for (const f of Object.values(this._scheduleInputState)) { - f.draft = ""; f.editing = false; f.pending = false; - } - } - - _clearScheduleField(key, domain, service) { - const f = this._scheduleFieldState(key); - f.draft = ""; f.editing = false; f.pending = false; - this._call(domain, service); - this._requestRender(); - } - - _beginScheduleEdit(k, fb) { - const f = this._scheduleFieldState(k); - f.editing = true; f.pending = false; - if (!f.draft) f.draft = this._isoForInput(fb); - } - _updateScheduleDraft(k, v) { const f = this._scheduleFieldState(k); f.draft = v; f.pending = false; } - _endScheduleEdit(k) { - const f = this._scheduleFieldState(k); f.editing = false; - if (!this._isScheduleEditing() && this._pendingRender) this._requestRender(); - } - _bindScheduleInput(k, id, fb) { - const i = this.shadowRoot.getElementById(id); if (!i) return; - i.addEventListener("focus", () => this._beginScheduleEdit(k, fb)); - i.addEventListener("input", (e) => this._updateScheduleDraft(k, e.target.value)); - i.addEventListener("change", (e) => this._updateScheduleDraft(k, e.target.value)); - i.addEventListener("blur", () => this._endScheduleEdit(k)); - } - - _firstExistingEntityId(ids) { - for (const id of ids) if (id && this._state(id)) return id; - return ids.find(Boolean) || null; - } - - _scheduleOpenEntityId() { - return this._firstExistingEntityId([ - this.config.scheduleOpen, - "datetime.sternwarte_offnen_geplant", - "datetime.sternwarte_oeffnen_geplant", - ]); - } - _scheduleCloseEntityId() { - return this._firstExistingEntityId([this.config.scheduleClose, "datetime.sternwarte_schliessen_geplant"]); - } - _powerEntityId() { - const c = this.config?.power; - if (c && this._state(c)) return c; - const states = this._hass?.states || {}; - const fb = Object.keys(states).find((id) => { - if (!id.startsWith("switch.")) return false; - const n = id.toLowerCase(); - const fn = String(states[id]?.attributes?.friendly_name || "").toLowerCase(); - return n.includes("dachsteuer") || (n.includes("sternwarte") && n.includes("power")) || fn.includes("dachsteuerung"); - }); - return fb || c || "switch.sternwarte_dachsteuerung"; - } - - _call(d, s) { try { Promise.resolve(this._hass.callService(d, s, {})).catch(() => {}); } catch {} } - _callEntity(d, s, id) { - if (!id) return; - try { Promise.resolve(this._hass.callService(d, s, { entity_id: id })).catch(() => {}); } catch {} - } - - _bindClick(id, fn) { - this.shadowRoot.getElementById(id)?.addEventListener("click", (e) => { - e.preventDefault(); e.stopPropagation(); fn(); - }); - } - - _showMoreInfo(id) { - if (!id) return; - const ev = new Event("hass-more-info", { bubbles: true, composed: true }); - ev.detail = { entityId: id }; - this.dispatchEvent(ev); - } - _bindMoreInfo(id, entityId) { - const el = this.shadowRoot.getElementById(id); - if (!el || !entityId) return; - const open = (e) => { e.preventDefault(); e.stopPropagation(); this._showMoreInfo(entityId); }; - el.addEventListener("click", open); - el.addEventListener("keydown", (e) => { if (e.key === "Enter" || e.key === " ") open(e); }); - } - - _setDateTime(eid, inputId, key) { - const i = this.shadowRoot.getElementById(inputId); - const f = this._scheduleFieldState(key); - const v = f?.draft || i?.value || ""; - if (!v || !eid) return; - f.pending = true; f.editing = false; f.draft = v; - this._hass.callService("datetime", "set_value", { entity_id: eid, datetime: v }); - if (this._pendingRender) this._requestRender(); - } - - _isoForInput(state) { - if (!state || !state.state || state.state === "unknown" || state.state === "unavailable") return ""; - const v = new Date(state.state); if (Number.isNaN(v.getTime())) return ""; - const p = (n) => String(n).padStart(2, "0"); - return `${v.getFullYear()}-${p(v.getMonth() + 1)}-${p(v.getDate())}T${p(v.getHours())}:${p(v.getMinutes())}`; - } - - _seg7DateTime(state) { - if (!state || !state.state || state.state === "unknown" || state.state === "unavailable") return "--:--"; - const v = new Date(state.state); if (Number.isNaN(v.getTime())) return "--:--"; - const p = (n) => String(n).padStart(2, "0"); - return `${p(v.getDate())}.${p(v.getMonth() + 1)} ${p(v.getHours())}:${p(v.getMinutes())}`; - } - - _roofPositionValue() { - const v = Number(this._state(this.config.roofPosition)?.state); - if (Number.isNaN(v)) return null; - return Math.max(0, Math.min(100, Math.round(v))); - } - - _windValue() { - const w = this._state(this.config.wind); - const v = Number(w?.state); - if (Number.isNaN(v)) return { num: null, unit: "km/h", level: 0 }; - let level = 0; - if (v > 15) level = 2; else if (v > 10) level = 1; - return { num: Math.round(v), unit: w?.attributes?.unit_of_measurement ?? "km/h", level }; - } - - /* --- astronomy / moon (unchanged from original) --- */ - - _isMESZ(d) { - const y = d.getUTCFullYear(); - const lastSunday = (m, h) => { - const e = new Date(Date.UTC(y, m + 1, 0, h)); - while (e.getUTCDay() !== 0) e.setUTCDate(e.getUTCDate() - 1); - return e; - }; - return d >= lastSunday(2, 1) && d < lastSunday(9, 1); - } - _astroObserver() { - const a = this._astronomy(); if (!a) return null; - const lat = Number(this.config.astroLat), lon = Number(this.config.astroLon); - if (!Number.isFinite(lat) || !Number.isFinite(lon)) return null; - return new a.Observer(lat, lon, 0); - } - _astroTimeToUtcHour(t) { - if (!t) return null; - const d = t.date instanceof Date ? t.date : null; - if (!d || Number.isNaN(d.getTime())) return null; - return d.getUTCHours() + d.getUTCMinutes() / 60 + d.getUTCSeconds() / 3600; - } - _calcTwilight(date) { - const a = this._astronomy(), o = this._astroObserver(); - const empty = { sun: { dawn: null, dusk: null }, civil: { dawn: null, dusk: null }, naut: { dawn: null, dusk: null }, astro: { dawn: null, dusk: null } }; - if (!a || !o) return empty; - try { - const body = a.Body?.Sun ?? "Sun"; - const start = new Date(date); start.setHours(0, 0, 0, 0); - const limit = 2; - const ev = (alt) => ({ - dawn: this._astroTimeToUtcHour(a.SearchAltitude(body, o, +1, start, limit, alt)), - dusk: this._astroTimeToUtcHour(a.SearchAltitude(body, o, -1, start, limit, alt)), - }); - return { - sun: { dawn: this._astroTimeToUtcHour(a.SearchRiseSet(body, o, +1, start, limit, 0)), dusk: this._astroTimeToUtcHour(a.SearchRiseSet(body, o, -1, start, limit, 0)) }, - civil: ev(-6), naut: ev(-12), astro: ev(-18), - }; - } catch { return empty; } - } - _moonStateAt(d) { - const a = this._astronomy(), o = this._astroObserver(); - if (!a || !o) return { altitude: -Infinity, illumination: 0, phaseAngle: 0 }; - try { - const body = a.Body?.Moon ?? "Moon"; - const eq = a.Equator(body, d, o, true, true); - const hz = a.Horizon(d, o, eq.ra, eq.dec, "normal"); - const ill = a.Illumination(body, d); - const ph = Number(a.MoonPhase(d)) || 0; - return { - altitude: (Number(hz?.altitude) || 0) * Math.PI / 180, - illumination: Math.max(0, Math.min(1, Number(ill?.phase_fraction) || 0)), - phaseAngle: ((ph % 360) + 360) % 360, - }; - } catch { return { altitude: -Infinity, illumination: 0, phaseAngle: 0 }; } - } - _moonVisualState() { - const m = this._moonStateAt(new Date()); - const ph = m.phaseAngle ?? 0; - const waxing = ph <= 180; - const r = ph * Math.PI / 180; - return { - illumination: Math.max(0, Math.min(1, m.illumination || 0)), - illuminationPercent: Math.round((m.illumination || 0) * 100), - waxing, phaseAngle: ph, cosPhase: Math.cos(r), - }; - } - _renderMoonPhaseDataUrl(mv, size = 64) { - const tx = this._moonTexture; if (!tx) return ""; - const c = document.createElement("canvas"); c.width = size; c.height = size; - const ctx = c.getContext("2d"); if (!ctx) return ""; - const r = size / 2 - 1, x = size / 2, y = size / 2; - const cp = Number(mv.cosPhase) || 0; - ctx.save(); ctx.beginPath(); ctx.arc(x, y, r, 0, Math.PI * 2); ctx.clip(); - ctx.fillStyle = "rgba(8,10,16,0.96)"; ctx.beginPath(); ctx.arc(x, y, r, 0, Math.PI * 2); ctx.fill(); - ctx.globalAlpha = 0.98; ctx.drawImage(tx, x - r, y - r, r * 2, r * 2); ctx.globalAlpha = 1; - ctx.fillStyle = "rgba(8,10,16,0.96)"; ctx.beginPath(); ctx.arc(x, y, r, 0, Math.PI * 2); ctx.fill(); - ctx.save(); ctx.beginPath(); ctx.arc(x, y, r, 0, Math.PI * 2); ctx.clip(); - if (mv.illumination >= 0.999) { - ctx.drawImage(tx, x - r, y - r, r * 2, r * 2); - } else if (mv.illumination > 0.001) { - ctx.beginPath(); - for (let i = 0; i <= 72; i++) { - const t = -r + (2 * r * i) / 72; - const xr = Math.sqrt(Math.max(0, r * r - t * t)); - const xt = (mv.waxing ? cp : -cp) * xr; - if (i === 0) ctx.moveTo(x + xt, y + t); else ctx.lineTo(x + xt, y + t); - } - for (let i = 72; i >= 0; i--) { - const t = -r + (2 * r * i) / 72; - const xr = Math.sqrt(Math.max(0, r * r - t * t)); - ctx.lineTo(x + (mv.waxing ? xr : -xr), y + t); - } - ctx.closePath(); ctx.clip(); - ctx.drawImage(tx, x - r, y - r, r * 2, r * 2); - } - ctx.restore(); ctx.restore(); - ctx.strokeStyle = "rgba(255,200,80,0.35)"; ctx.lineWidth = 1; - ctx.beginPath(); ctx.arc(x, y, r, 0, Math.PI * 2); ctx.stroke(); - return c.toDataURL("image/png"); - } - - _astroState() { - const now = new Date(); - const tw = this._calcTwilight(now); - const off = this._isMESZ(now) ? 2 : 1; - const shift = (h) => ((h - 12 + 24) % 24); - const localizeHour = (h) => (h === null || h === undefined ? null : (h + off + 24) % 24); - const localize = (e) => e ? { dawn: localizeHour(e.dawn), dusk: localizeHour(e.dusk) } : null; - const lt = { sun: localize(tw.sun), civil: localize(tw.civil), naut: localize(tw.naut), astro: localize(tw.astro) }; - const nowL = shift(now.getHours() + now.getMinutes() / 60 + now.getSeconds() / 3600); - const segs = []; - const colors = { night: "#0a1320", astro: "#23426d", naut: "#3d6aaf", civil: "#b87a3a", day: "#e8b94c" }; - const add = (s, e, k) => { - if (s === null || e === null) return; - const ss = shift(s), ee = shift(e); - if (ee < ss) { segs.push({ start: ss, end: 24, color: colors[k] }); segs.push({ start: 0, end: ee, color: colors[k] }); return; } - if (ee <= ss) return; - segs.push({ start: ss, end: ee, color: colors[k] }); - }; - const hasAstroNight = lt.astro?.dawn !== null && lt.astro?.dawn !== undefined - && lt.astro?.dusk !== null && lt.astro?.dusk !== undefined; - const hasNautNight = lt.naut?.dawn !== null && lt.naut?.dawn !== undefined - && lt.naut?.dusk !== null && lt.naut?.dusk !== undefined; - const hasCivilNight = lt.civil?.dawn !== null && lt.civil?.dawn !== undefined - && lt.civil?.dusk !== null && lt.civil?.dusk !== undefined; - const hasSunCycle = lt.sun?.dawn !== null && lt.sun?.dawn !== undefined - && lt.sun?.dusk !== null && lt.sun?.dusk !== undefined; - if (hasAstroNight) { - add(0, lt.astro.dawn, "night"); - add(lt.astro.dawn, lt.naut?.dawn, "astro"); - add(lt.naut?.dawn, lt.civil?.dawn, "naut"); - add(lt.civil?.dawn, lt.sun?.dawn, "civil"); - add(lt.sun?.dawn, lt.sun?.dusk, "day"); - add(lt.sun?.dusk, lt.civil?.dusk, "civil"); - add(lt.civil?.dusk, lt.naut?.dusk, "naut"); - add(lt.naut?.dusk, lt.astro.dusk, "astro"); - add(lt.astro.dusk, 24, "night"); - } else if (hasNautNight) { - add(0, lt.naut.dawn, "astro"); - add(lt.naut.dawn, lt.civil?.dawn, "naut"); - add(lt.civil?.dawn, lt.sun?.dawn, "civil"); - add(lt.sun?.dawn, lt.sun?.dusk, "day"); - add(lt.sun?.dusk, lt.civil?.dusk, "civil"); - add(lt.civil?.dusk, lt.naut.dusk, "naut"); - add(lt.naut.dusk, 24, "astro"); - } else if (hasCivilNight) { - add(0, lt.civil.dawn, "naut"); - add(lt.civil.dawn, lt.sun?.dawn, "civil"); - add(lt.sun?.dawn, lt.sun?.dusk, "day"); - add(lt.sun?.dusk, lt.civil.dusk, "civil"); - add(lt.civil.dusk, 24, "naut"); - } else if (hasSunCycle) { - add(0, lt.sun.dawn, "civil"); - add(lt.sun.dawn, lt.sun.dusk, "day"); - add(lt.sun.dusk, 24, "civil"); - } - if (!segs.length) add(0, 24, "night"); - segs.sort((a, b) => a.start - b.start); - const stops = segs.flatMap((s) => { - const a = `${(s.start / 24) * 100}%`, b = `${(s.end / 24) * 100}%`; - return [`${s.color} ${a}`, `${s.color} ${b}`]; - }); - const grad = stops.length ? `linear-gradient(90deg, ${stops.join(", ")})` : colors.night; - const toLocal = (st) => { - if (!st?.state || st.state === "unknown" || st.state === "unavailable") return null; - const v = new Date(st.state); if (Number.isNaN(v.getTime())) return null; - return shift(v.getHours() + v.getMinutes() / 60); - }; - return { - gradient: grad, - phaseSegments: segs.map((s) => ({ - left: `${(s.start / 24) * 100}%`, - width: `${Math.max(((s.end - s.start) / 24) * 100, 0.35)}%`, - color: s.color, - })), - markerLeft: `${(nowL / 24) * 100}%`, - openMarkerLeft: toLocal(this._state(this._scheduleOpenEntityId())), - closeMarkerLeft: toLocal(this._state(this._scheduleCloseEntityId())), - }; - } - - /* ---------- render ---------- */ - - render() { - if (!this.shadowRoot || !this._hass) return; - - const powerEid = this._powerEntityId(); - const powerState = this._state(powerEid)?.state; - const powerOn = powerState === "on"; - const powerOff = powerState === "off"; - - const rainOn = this._state(this.config.rain)?.state === "on"; - const movingOn = this._state(this.config.roofMoving)?.state === "on"; - const openOn = this._state(this.config.roofOpen)?.state === "on"; - const closedOn = this._state(this.config.roofClosed)?.state === "on"; - - const openSchedEid = this._scheduleOpenEntityId(); - const closeSchedEid = this._scheduleCloseEntityId(); - const openSched = this._state(openSchedEid); - const closeSched = this._state(closeSchedEid); - const openInputV = this._scheduleInputValue("open", openSched); - const closeInputV = this._scheduleInputValue("close", closeSched); - if (openInputV === this._isoForInput(openSched)) { - this._scheduleFieldState("open").draft = ""; - this._scheduleFieldState("open").pending = false; - } - if (closeInputV === this._isoForInput(closeSched)) { - this._scheduleFieldState("close").draft = ""; - this._scheduleFieldState("close").pending = false; - } - - const pos = this._roofPositionValue(); - const wind = this._windValue(); - const moonV = this._moonVisualState(); - const moonImg = this._renderMoonPhaseDataUrl(moonV, 96); - const astro = this._astroState(); - - let openDis, closeDis, stopDis; - if (!powerOn) { openDis = closeDis = stopDis = true; } - else if (movingOn) { openDis = closeDis = true; stopDis = false; } - else if (openOn && !closedOn) { openDis = true; closeDis = false; stopDis = true; } - else if (closedOn && !openOn) { openDis = false; closeDis = true; stopDis = true; } - else if (closedOn && openOn) { openDis = closeDis = true; stopDis = false; } - else { openDis = closeDis = stopDis = false; } - - const now = new Date(); - const pad = (n) => String(n).padStart(2, "0"); - const timeStr = `${pad(now.getHours())}:${pad(now.getMinutes())}:${pad(now.getSeconds())}`; - const dateStr = `${pad(now.getDate())}.${pad(now.getMonth() + 1)}.${String(now.getFullYear()).slice(-2)}`; - - /* analog gauge for roof position */ - const gaugeAngle = pos === null ? -135 : -135 + (pos / 100) * 270; - const positionStr = pos === null ? " --" : String(pos).padStart(3, " "); - const windStr = wind.num === null ? " --" : String(wind.num).padStart(3, " "); - const moonStr = String(moonV.illuminationPercent).padStart(3, " "); - - /* status string for main display */ - let statusStr = "READY"; - if (!powerOn && powerOff) statusStr = " STBY"; - else if (movingOn) statusStr = " RUN "; - else if (openOn && !closedOn) statusStr = "OPEN "; - else if (closedOn && !openOn) statusStr = "SHUT "; - else if (rainOn) statusStr = "RAIN "; - - const css = this._renderStyles(); - - this.shadowRoot.innerHTML = ` - - -
-
-
- -
-
SONNENWEG OBSERVATORY · DOME CONTROL UNIT MK−II
-
-
SER. № 1966−A
-
-
-
- - ${rainOn ? ` -
- - ⚠ REGEN ERKANNT · DACH GESPERRT ⚠ - -
- ` : ""} - -
- -
-
PRIMARY READOUT
-
-
- UHR - ${seg7(timeStr, { color: "red", size: 0.75 })} -
-
- DAT - ${seg7(dateStr, { color: "amber", size: 0.55 })} -
-
- POS - ${seg7(positionStr, { color: "red", size: 0.75 })} - % -
-
- WND - ${seg7(windStr, { color: wind.level === 2 ? "red" : wind.level === 1 ? "amber" : "green", size: 0.75 })} - ${wind.unit} -
-
- MND - ${seg7(moonStr, { color: "amber", size: 0.55 })} - % -
-
- STA - ${seg7(statusStr, { color: powerOn ? "green" : "amber", size: 0.55 })} -
-
-
- - -
-
-
DOME STATUS
-
- ${this._jewel("DACH ZU", "green", closedOn)} - ${this._jewel("OFFEN", "green", openOn)} - ${this._jewel("BEWEGT", "amber", movingOn, true)} - ${this._jewel("REGEN", "red", rainOn, true)} - ${this._jewel("POWER", "white", powerOn)} - ${this._jewel("FEHLER", "red", powerState === undefined || powerState === "unavailable")} -
-
- -
-
POSITION
- -
-
- - -
-
MOON SCOPE
-
-
- ${moonImg ? `Mond` : `
`} -
-
-
-
- ${seg7(moonStr, { color: "amber", size: 0.5 })} - % ILL -
-
-
- - -
-
DIURNAL CHART · 12 → 12
-
-
-
- ${astro.openMarkerLeft === null ? "" : `
`} - ${astro.closeMarkerLeft === null ? "" : `
`} -
-
- 12 - 18 - 00 - 06 - 12 -
-
-
- - -
-
-
MASTER POWER
-
-
-
-
-
-
-
-
-
- EIN - AUS -
-
-
-
- ${seg7(powerOn ? " ON" : powerOff ? "OFF" : "---", { color: powerOn ? "green" : "red", size: 0.5 })} -
-
-
- -
-
DOME COMMAND
-
- - - -
-
-
- - -
-
SCHEDULE PROGRAMMER
-
-
- ÖFFNEN -
${seg7(this._seg7DateTime(openSched), { color: "amber", size: 0.45 })}
- - - -
-
- SCHLIESSEN -
${seg7(this._seg7DateTime(closeSched), { color: "amber", size: 0.45 })}
- - - -
-
- - - -
-
-
- -
- SONNENWEG · SCHWARZENBACH AM WALD - 50.298° N · 11.048° E - SW−CTRL/2.0 — RETRO PANEL -
-
-
- `; - - this._bindClick("open-btn", () => this._callEntity("cover", "open_cover", this.config.roof)); - this._bindClick("stop-btn", () => this._callEntity("cover", "stop_cover", this.config.roof)); - this._bindClick("close-btn", () => this._callEntity("cover", "close_cover", this.config.roof)); - this._bindClick("nautical-btn", () => this._call("sternwarte", "set_nautical_schedule")); - this._bindClick("clear-schedule-btn", () => { - this._resetScheduleDrafts(); - this._call("sternwarte", "clear_schedule"); - this._requestRender(); - }); - this._bindClick("open-schedule-btn", () => this._setDateTime(openSchedEid, "open-schedule-input", "open")); - this._bindClick("close-schedule-btn", () => this._setDateTime(closeSchedEid, "close-schedule-input", "close")); - this._bindClick("open-schedule-clear-btn", () => this._clearScheduleField("open", "sternwarte", "clear_open_schedule")); - this._bindClick("close-schedule-clear-btn", () => this._clearScheduleField("close", "sternwarte", "clear_close_schedule")); - this._bindMoreInfo("wind-btn", this.config.wind); - this._bindMoreInfo("pos-gauge", this.config.roofPosition); - this._bindMoreInfo("moon-scope", this.config.roofStatus); - this._bindScheduleInput("open", "open-schedule-input", openSched); - this._bindScheduleInput("close", "close-schedule-input", closeSched); - - /* power toggle */ - const tog = this.shadowRoot.getElementById("power-toggle"); - if (tog) { - const flip = () => { - const svc = powerOn ? "turn_off" : "turn_on"; - this._callEntity("switch", svc, powerEid); - }; - tog.addEventListener("click", (e) => { e.preventDefault(); flip(); }); - tog.addEventListener("keydown", (e) => { - if (e.key === "Enter" || e.key === " ") { e.preventDefault(); flip(); } - }); - } - } - - _jewel(label, color, on, blink = false) { - return ` -
-
-
-
-
${label}
-
- `; - } - - _gaugeTicks() { - let s = ""; - for (let i = 0; i <= 10; i++) { - const ang = -135 + (i / 10) * 270; - const major = i % 5 === 0; - const r1 = 75, r2 = major ? 64 : 69; - const rad = ang * Math.PI / 180; - const x1 = 100 + r1 * Math.sin(rad), y1 = 110 - r1 * Math.cos(rad); - const x2 = 100 + r2 * Math.sin(rad), y2 = 110 - r2 * Math.cos(rad); - s += ``; - } - return s; - } - - _renderStyles() { - return ` - @import url('https://fonts.googleapis.com/css2?family=Special+Elite&family=Share+Tech+Mono&display=swap'); - - :host { - display: block; - font-family: 'Share Tech Mono', 'Courier New', monospace; - } - ha-card { - background: transparent; - border: none; - box-shadow: none; - overflow: hidden; - } - - /* === overall console === */ - .console { - position: relative; - padding: 22px 22px 18px; - border-radius: 14px; - color: #f1e4c5; - background: - radial-gradient(circle at 30% 20%, rgba(255,235,180,0.06), transparent 60%), - radial-gradient(circle at 80% 90%, rgba(0,0,0,0.35), transparent 70%), - repeating-linear-gradient(90deg, rgba(255,255,255,0.025) 0 1px, transparent 1px 3px), - linear-gradient(160deg, #4d5a3e 0%, #3b4731 35%, #2c361f 100%); - box-shadow: - inset 0 0 0 2px #1c2210, - inset 0 0 0 4px #6b7a55, - inset 0 0 60px rgba(0,0,0,0.5), - 0 8px 24px rgba(0,0,0,0.45); - text-shadow: 0 1px 0 rgba(0,0,0,0.4); - } - .rivets { - position: absolute; - width: 14px; height: 14px; - border-radius: 50%; - background: - radial-gradient(circle at 30% 30%, #f1ebd2 0%, #8a8064 35%, #2a2515 75%, #000 100%); - box-shadow: - inset -1px -1px 2px rgba(0,0,0,0.7), - 0 1px 2px rgba(0,0,0,0.6); - } - .r-tl { top: 8px; left: 8px; } - .r-tr { top: 8px; right: 8px; } - .r-bl { bottom: 8px; left: 8px; } - .r-br { bottom: 8px; right: 8px; } - - /* === header / dymo === */ - .header { - display: flex; - align-items: center; - justify-content: space-between; - gap: 12px; - margin-bottom: 14px; - } - .dymo { - font-family: 'Special Elite', 'Courier New', serif; - font-size: 14px; - letter-spacing: 0.18em; - color: #2a200f; - background: - repeating-linear-gradient(90deg, rgba(255,255,255,0.06) 0 1px, transparent 1px 2px), - linear-gradient(180deg, #d8c98a 0%, #bba85f 100%); - padding: 8px 18px; - border-radius: 4px; - border: 1px solid #6b5a25; - box-shadow: - inset 0 1px 0 rgba(255,255,255,0.4), - inset 0 -2px 0 rgba(0,0,0,0.2), - 0 2px 4px rgba(0,0,0,0.45); - text-shadow: none; - } - .header-right { - display: flex; align-items: center; gap: 12px; - } - .serial { - font-size: 10px; - letter-spacing: 0.25em; - opacity: 0.6; - font-family: 'Special Elite', serif; - } - .pwr-led { - width: 14px; height: 14px; - border-radius: 50%; - background: #2a0a0a; - box-shadow: - inset 0 0 0 2px #1a1a1a, - inset 0 0 4px rgba(0,0,0,0.8), - 0 0 0 3px #4a3f1f; - } - .pwr-led.on { - background: radial-gradient(circle at 30% 30%, #fff 0%, #6cf06c 40%, #1f8a1f 100%); - box-shadow: - inset 0 0 0 2px #1a1a1a, - inset 0 0 4px rgba(0,0,0,0.4), - 0 0 12px rgba(108,240,108,0.7), - 0 0 0 3px #4a3f1f; - } - .pwr-led.off { - background: radial-gradient(circle at 30% 30%, #ffd9d9 0%, #c43030 50%, #4a0000 100%); - box-shadow: - inset 0 0 0 2px #1a1a1a, - inset 0 0 4px rgba(0,0,0,0.4), - 0 0 8px rgba(196,48,48,0.5), - 0 0 0 3px #4a3f1f; - } - - /* === alarm strip === */ - .alarm-strip { - display: flex; align-items: center; justify-content: center; - gap: 14px; - margin-bottom: 14px; - padding: 8px 14px; - background: - repeating-linear-gradient(45deg, #1a1305 0 12px, #5a2c0c 12px 24px); - border: 2px solid #1a1305; - border-radius: 4px; - animation: alarm-flash 1s linear infinite; - } - .alarm-text { - font-weight: 700; - font-size: 14px; - letter-spacing: 0.25em; - color: #ffeacc; - } - .alarm-lamp { - width: 14px; height: 14px; - border-radius: 50%; - background: radial-gradient(circle at 30% 30%, #fff 0%, #ff5a30 50%, #6a1500 100%); - box-shadow: 0 0 12px rgba(255,90,48,0.9); - animation: alarm-flash 0.7s linear infinite alternate; - } - @keyframes alarm-flash { - 0%, 100% { opacity: 1; } - 50% { opacity: 0.55; } - } - - /* === bezels (inset metal panels) === */ - .bezel { - position: relative; - padding: 22px 14px 14px; - border-radius: 6px; - background: - repeating-linear-gradient(90deg, rgba(255,255,255,0.03) 0 1px, transparent 1px 2px), - linear-gradient(160deg, #2a2418 0%, #1b170e 100%); - box-shadow: - inset 0 2px 4px rgba(0,0,0,0.7), - inset 0 -1px 0 rgba(255,255,255,0.05), - 0 1px 0 rgba(255,255,255,0.06); - border: 1px solid #0a0805; - } - .bezel-label { - position: absolute; - top: -8px; left: 12px; - padding: 2px 8px; - font-family: 'Special Elite', serif; - font-size: 10px; - letter-spacing: 0.22em; - color: #f1e4c5; - background: linear-gradient(180deg, #6b7a55 0%, #4d5a3e 100%); - border: 1px solid #1c2210; - border-radius: 2px; - box-shadow: 0 1px 2px rgba(0,0,0,0.5); - text-shadow: 0 1px 0 rgba(0,0,0,0.4); - } - - /* === rows === */ - .row { display: grid; gap: 14px; margin-bottom: 14px; } - .row-displays { - grid-template-columns: minmax(0, 2.2fr) minmax(0, 1.4fr) minmax(0, 1fr); - } - .row-controls { - grid-template-columns: minmax(0, 1fr) minmax(0, 2fr); - } - .col-stack { display: grid; grid-template-rows: auto auto; gap: 14px; } - - /* === 7-seg === */ - .seg7 { - display: inline-flex; - align-items: flex-end; - gap: 2px; - padding: 4px 6px; - --d7-base-w: 30px; - --d7-base-h: 50px; - } - .d7, .d7-colon, .d7-dot { - width: calc(var(--d7-base-w) * var(--d7-size, 1)); - height: calc(var(--d7-base-h) * var(--d7-size, 1)); - display: inline-block; - } - .seg { - fill: rgba(0,0,0,0.5); - opacity: 0.10; - transition: opacity 80ms linear; - } - .seg.on { opacity: 1; } - .col-red .seg.on { - fill: #ff2a18; - filter: drop-shadow(0 0 2px #ff5538) drop-shadow(0 0 4px rgba(255,40,20,0.6)); - } - .col-amber .seg.on { - fill: #ffaa1a; - filter: drop-shadow(0 0 2px #ffc04a) drop-shadow(0 0 4px rgba(255,170,30,0.6)); - } - .col-green .seg.on { - fill: #4cff60; - filter: drop-shadow(0 0 2px #80ff90) drop-shadow(0 0 4px rgba(76,255,96,0.5)); - } - .col-red .dot { fill: #ff2a18; filter: drop-shadow(0 0 2px #ff5538); } - .col-amber .dot { fill: #ffaa1a; filter: drop-shadow(0 0 2px #ffc04a); } - .col-green .dot { fill: #4cff60; filter: drop-shadow(0 0 2px #80ff90); } - - /* === display panel === */ - .display-bezel { - background: - repeating-linear-gradient(0deg, rgba(255,255,255,0.02) 0 1px, transparent 1px 3px), - linear-gradient(160deg, #100a04 0%, #1c130a 100%); - } - .display-grid { - display: grid; - gap: 6px; - } - .seg-row { - display: flex; - align-items: center; - gap: 10px; - padding: 6px 10px; - background: - radial-gradient(ellipse at center, rgba(255,255,255,0.03) 0%, transparent 70%), - #050200; - border-radius: 3px; - box-shadow: - inset 0 2px 6px rgba(0,0,0,0.9), - inset 0 0 0 1px #2a1f0a; - } - .seg-cap { - font-family: 'Special Elite', serif; - font-size: 10px; - letter-spacing: 0.2em; - color: #c5a960; - min-width: 36px; - } - .seg-unit { - font-family: 'Special Elite', serif; - font-size: 11px; - letter-spacing: 0.15em; - color: #c5a960; - margin-left: auto; - } - - /* === lamps === */ - .lamp-bezel { - background: - repeating-linear-gradient(45deg, rgba(255,255,255,0.02) 0 2px, transparent 2px 4px), - linear-gradient(160deg, #2c2618 0%, #1b170e 100%); - } - .lamps { - display: grid; - grid-template-columns: repeat(3, minmax(0, 1fr)); - gap: 10px 6px; - } - .jewel { - display: flex; flex-direction: column; align-items: center; gap: 4px; - } - .jewel-bezel { - width: 38px; height: 38px; - border-radius: 50%; - background: - radial-gradient(circle at 30% 30%, #fff 0%, #c0b89a 25%, #5a4f30 70%, #1a1408 100%); - padding: 4px; - box-shadow: - inset 0 1px 2px rgba(255,255,255,0.4), - inset 0 -2px 4px rgba(0,0,0,0.6), - 0 2px 4px rgba(0,0,0,0.6); - } - .jewel-lens { - width: 100%; height: 100%; - border-radius: 50%; - position: relative; - } - .jewel-lens::after { - content: ""; - position: absolute; top: 4px; left: 6px; - width: 8px; height: 6px; - border-radius: 50%; - background: rgba(255,255,255,0.45); - filter: blur(1px); - } - .lens-green.off { background: radial-gradient(circle at 35% 35%, #2d4a30 0%, #0c1a0e 70%); } - .lens-green.on { background: radial-gradient(circle at 35% 35%, #c4ffc8 0%, #4cff60 35%, #1a8a28 100%); box-shadow: 0 0 16px rgba(76,255,96,0.7); } - .lens-amber.off { background: radial-gradient(circle at 35% 35%, #4a3a14 0%, #1a1408 70%); } - .lens-amber.on { background: radial-gradient(circle at 35% 35%, #fff5c4 0%, #ffc14a 35%, #b07b10 100%); box-shadow: 0 0 16px rgba(255,193,74,0.7); } - .lens-red.off { background: radial-gradient(circle at 35% 35%, #4a1414 0%, #1a0808 70%); } - .lens-red.on { background: radial-gradient(circle at 35% 35%, #ffd0d0 0%, #ff4030 35%, #8a0a0a 100%); box-shadow: 0 0 16px rgba(255,64,48,0.75); } - .lens-white.off { background: radial-gradient(circle at 35% 35%, #3a3528 0%, #14110a 70%); } - .lens-white.on { background: radial-gradient(circle at 35% 35%, #fff 0%, #fce8b4 50%, #b08020 100%); box-shadow: 0 0 16px rgba(255,232,180,0.7); } - .blink { animation: lamp-blink 0.9s linear infinite; } - @keyframes lamp-blink { 0%, 100% { opacity: 1; } 50% { opacity: 0.35; } } - - .jewel-label { - font-family: 'Special Elite', serif; - font-size: 9px; - letter-spacing: 0.18em; - color: #d8c98a; - text-align: center; - } - - /* === gauge === */ - .gauge-bezel { - background: - radial-gradient(ellipse at 50% 0%, rgba(255,255,255,0.04), transparent 60%), - linear-gradient(160deg, #1f1a0e 0%, #100c06 100%); - cursor: pointer; - outline: none; - } - .gauge-bezel:focus-visible { box-shadow: inset 0 2px 4px rgba(0,0,0,0.7), 0 0 0 2px #c5a960; } - .gauge { width: 100%; height: auto; display: block; } - - /* === moon scope === */ - .moon-bezel { - display: flex; flex-direction: column; align-items: center; gap: 8px; - cursor: pointer; - } - .scope { - width: 96px; height: 96px; - border-radius: 50%; - padding: 6px; - background: - radial-gradient(circle at 30% 30%, #f1ebd2 0%, #8a8064 30%, #2a2515 80%, #000 100%); - box-shadow: - inset 0 2px 4px rgba(255,255,255,0.4), - inset 0 -3px 6px rgba(0,0,0,0.7), - 0 3px 6px rgba(0,0,0,0.6); - } - .scope-glass { - width: 100%; height: 100%; - border-radius: 50%; - position: relative; - overflow: hidden; - background: #050810; - box-shadow: inset 0 0 0 2px #1a1408; - } - .moon-img { - width: 100%; height: 100%; - display: block; - border-radius: 50%; - } - .moon-img.placeholder { - background: radial-gradient(circle at 30% 30%, #aaa 0%, #444 60%, #111 100%); - } - .scope-cross { - position: absolute; inset: 0; - background: - linear-gradient(90deg, transparent 49.5%, rgba(255,200,80,0.18) 49.5% 50.5%, transparent 50.5%), - linear-gradient(0deg, transparent 49.5%, rgba(255,200,80,0.18) 49.5% 50.5%, transparent 50.5%); - pointer-events: none; - } - .scope-readout { - display: flex; align-items: center; gap: 4px; - } - - /* === twilight strip === */ - .twilight-bezel { - background: - linear-gradient(160deg, #1f1a0e 0%, #100c06 100%); - } - .strip { - position: relative; - height: 38px; - border-radius: 3px; - overflow: hidden; - box-shadow: - inset 0 2px 4px rgba(0,0,0,0.7), - inset 0 0 0 1px #2a1f0a; - } - .strip-paper { - position: absolute; inset: 0; - opacity: 0.92; - } - .strip-grid { - position: absolute; inset: 0; - background: - repeating-linear-gradient(90deg, transparent 0 calc(100%/24 - 1px), rgba(0,0,0,0.35) calc(100%/24 - 1px) calc(100%/24)), - repeating-linear-gradient(0deg, transparent 0 8px, rgba(255,255,255,0.04) 8px 9px); - pointer-events: none; - } - .strip-mark { - position: absolute; - top: -2px; bottom: -2px; - width: 3px; - margin-left: -1px; - z-index: 2; - box-shadow: 0 0 6px currentColor; - } - .strip-mark.open { background: #4cff60; color: rgba(76,255,96,0.8); } - .strip-mark.close { background: #ffaa1a; color: rgba(255,170,26,0.8); } - .strip-now { - position: absolute; - top: -3px; bottom: -3px; - width: 4px; - margin-left: -2px; - background: #ff2a18; - box-shadow: 0 0 8px rgba(255,42,24,0.85); - z-index: 3; - } - .strip-ticks { - position: absolute; inset: 0; - pointer-events: none; - } - .strip-ticks span { - position: absolute; - top: 50%; transform: translate(-50%, -50%); - font-family: 'Special Elite', serif; - font-size: 10px; - color: rgba(255,255,255,0.85); - text-shadow: 0 0 3px rgba(0,0,0,0.8); - } - - /* === toggle switch (bat-handle) === */ - .toggle-bezel { - display: flex; flex-direction: column; align-items: center; - } - .toggle-area { - position: relative; - display: flex; flex-direction: column; align-items: center; gap: 8px; - padding-top: 6px; - } - .toggle-frame { - position: relative; - width: 70px; height: 130px; - display: flex; align-items: center; justify-content: center; - } - .toggle-base { - position: absolute; - top: 50%; left: 50%; - transform: translate(-50%, -50%); - width: 46px; height: 46px; - border-radius: 50%; - background: - radial-gradient(circle at 30% 30%, #f1ebd2 0%, #8a8064 35%, #1a1408 100%); - box-shadow: - inset 0 1px 2px rgba(255,255,255,0.5), - inset 0 -3px 6px rgba(0,0,0,0.6), - 0 3px 6px rgba(0,0,0,0.6); - } - .toggle-base::after { - content: ""; - position: absolute; - top: 50%; left: 50%; - transform: translate(-50%, -50%); - width: 16px; height: 16px; - border-radius: 50%; - background: radial-gradient(circle, #1a1408 0%, #000 100%); - box-shadow: inset 0 2px 3px rgba(0,0,0,0.9); - } - .toggle-handle { - position: absolute; - top: 50%; left: 50%; - width: 14px; height: 70px; - margin-left: -7px; - margin-top: -35px; - transform-origin: 50% 50%; - transition: transform 220ms cubic-bezier(0.5, 1.6, 0.4, 1); - } - .toggle.up .toggle-handle { transform: translateY(-22px) rotate(0deg); } - .toggle.down .toggle-handle { transform: translateY(22px) rotate(180deg); } - .toggle-handle::before { - content: ""; - position: absolute; - left: 50%; top: 30px; - width: 10px; height: 40px; - margin-left: -5px; - background: linear-gradient(90deg, #4a4a4a 0%, #c8c8c8 35%, #f4f4f4 50%, #c8c8c8 65%, #4a4a4a 100%); - border-radius: 2px; - box-shadow: 0 0 4px rgba(0,0,0,0.5); - } - .toggle-tip { - position: absolute; - left: 50%; top: 0; - width: 22px; height: 30px; - margin-left: -11px; - background: - radial-gradient(ellipse at 35% 30%, #fff 0%, #d8d8d8 30%, #6a6a6a 70%, #2a2a2a 100%); - border-radius: 50% 50% 45% 45%; - box-shadow: - inset 0 -3px 6px rgba(0,0,0,0.5), - 0 2px 4px rgba(0,0,0,0.6); - } - .toggle-marks { - position: absolute; inset: 0; - pointer-events: none; - } - .toggle-marks .mk { - position: absolute; - left: 50%; transform: translateX(-50%); - font-family: 'Special Elite', serif; - font-size: 11px; - letter-spacing: 0.2em; - color: #d8c98a; - } - .toggle-marks .mk.top { top: 4px; color: #4cff60; } - .toggle-marks .mk.bot { bottom: 4px; color: #ff6040; } - .toggle { cursor: pointer; outline: none; } - .toggle:focus-visible .toggle-base { box-shadow: 0 0 0 3px #c5a960, inset 0 1px 2px rgba(255,255,255,0.5), inset 0 -3px 6px rgba(0,0,0,0.6); } - - /* guard bracket */ - .guard { - position: absolute; - top: -2px; left: 50%; transform: translateX(-50%); - width: 60px; height: 110px; - border: 3px solid #c43030; - border-bottom: none; - border-radius: 30px 30px 0 0; - box-shadow: - inset 0 0 0 1px rgba(255,255,255,0.15), - 0 2px 4px rgba(0,0,0,0.5); - background: transparent; - pointer-events: none; - transition: transform 280ms ease; - z-index: 2; - } - .guard.lifted { - transform: translateX(-50%) translateY(-12px) rotateX(-65deg); - transform-origin: 50% 100%; - } - .power-stat { - text-align: center; - } - - /* === push buttons === */ - .buttons { - display: grid; - grid-template-columns: repeat(3, 1fr); - gap: 14px; - align-items: center; - justify-items: center; - padding: 8px 0; - } - .pbtn { - position: relative; - width: 90px; height: 90px; - border-radius: 50%; - border: none; - cursor: pointer; - font-family: 'Special Elite', serif; - font-size: 11px; - letter-spacing: 0.2em; - color: #fff; - text-shadow: 0 1px 2px rgba(0,0,0,0.7); - background: - radial-gradient(circle at 30% 30%, var(--btn-hi, #fff) 0%, var(--btn-mid, #888) 35%, var(--btn-lo, #222) 100%); - box-shadow: - inset 0 2px 4px rgba(255,255,255,0.4), - inset 0 -4px 8px rgba(0,0,0,0.6), - 0 0 0 6px #1c1610, - 0 0 0 7px #6b5a25, - 0 5px 10px rgba(0,0,0,0.6); - transition: transform 80ms ease, box-shadow 80ms ease; - } - .pbtn:active:not(:disabled) { - transform: translateY(2px); - box-shadow: - inset 0 4px 8px rgba(0,0,0,0.6), - inset 0 -1px 2px rgba(255,255,255,0.2), - 0 0 0 6px #1c1610, - 0 0 0 7px #6b5a25, - 0 2px 4px rgba(0,0,0,0.6); - } - .pbtn:disabled { - cursor: not-allowed; - filter: grayscale(0.7) brightness(0.55); - } - .pbtn-cap { - display: block; - margin-top: 4px; - font-weight: 700; - } - .pbtn-green { - --btn-hi: #d8ffd8; --btn-mid: #4cd060; --btn-lo: #0a4a18; - box-shadow: - inset 0 2px 4px rgba(255,255,255,0.5), - inset 0 -4px 8px rgba(0,0,0,0.5), - 0 0 0 6px #1c1610, - 0 0 0 7px #6b5a25, - 0 0 18px rgba(76,208,96,0.45), - 0 5px 10px rgba(0,0,0,0.6); - } - .pbtn-amber { - --btn-hi: #fff0c8; --btn-mid: #e69a2a; --btn-lo: #5a3208; - box-shadow: - inset 0 2px 4px rgba(255,255,255,0.5), - inset 0 -4px 8px rgba(0,0,0,0.5), - 0 0 0 6px #1c1610, - 0 0 0 7px #6b5a25, - 0 0 18px rgba(230,154,42,0.45), - 0 5px 10px rgba(0,0,0,0.6); - } - .pbtn-mushroom { - width: 110px; height: 110px; - --btn-hi: #ffdada; --btn-mid: #d62020; --btn-lo: #4a0606; - box-shadow: - inset 0 3px 6px rgba(255,255,255,0.5), - inset 0 -6px 12px rgba(0,0,0,0.6), - 0 0 0 8px #1c1610, - 0 0 0 9px #c43030, - 0 0 22px rgba(214,32,32,0.55), - 0 6px 12px rgba(0,0,0,0.7); - } - .pbtn-mushroom .pbtn-cap { - font-size: 13px; - letter-spacing: 0.3em; - } - .pbtn-mushroom:disabled { - filter: grayscale(0.5) brightness(0.6); - } - - /* === schedule programmer === */ - .sched-bezel { - background: - repeating-linear-gradient(0deg, rgba(255,255,255,0.02) 0 1px, transparent 1px 3px), - linear-gradient(160deg, #1f1a0e 0%, #100c06 100%); - } - .sched-grid { - display: grid; gap: 10px; - } - .sched-row { - display: grid; - grid-template-columns: 90px minmax(0, 1fr) minmax(150px, 200px) max-content max-content; - gap: 10px; - align-items: center; - } - .sched-cap { - font-family: 'Special Elite', serif; - font-size: 11px; - letter-spacing: 0.18em; - color: #c5a960; - } - .sched-readout { - padding: 4px 8px; - background: #050200; - border-radius: 3px; - box-shadow: - inset 0 2px 4px rgba(0,0,0,0.9), - inset 0 0 0 1px #2a1f0a; - min-height: 28px; - display: flex; align-items: center; - } - input[type="datetime-local"] { - width: 100%; - box-sizing: border-box; - padding: 7px 10px; - border-radius: 3px; - border: 1px solid #2a1f0a; - background: - repeating-linear-gradient(0deg, transparent 0 4px, rgba(255,255,255,0.03) 4px 5px), - #1a1408; - color: #f1e4c5; - font-family: 'Share Tech Mono', monospace; - font-size: 13px; - letter-spacing: 0.05em; - box-shadow: inset 0 2px 3px rgba(0,0,0,0.6); - } - input[type="datetime-local"]::-webkit-calendar-picker-indicator { - filter: invert(0.85) sepia(1) saturate(2) hue-rotate(-10deg); - cursor: pointer; - } - .kbtn { - font-family: 'Special Elite', serif; - font-size: 11px; - letter-spacing: 0.18em; - padding: 9px 12px; - border: 1px solid #1a1408; - border-radius: 3px; - background: - linear-gradient(180deg, #d8c98a 0%, #a89559 50%, #76632a 100%); - color: #1a1408; - cursor: pointer; - box-shadow: - inset 0 1px 0 rgba(255,255,255,0.5), - inset 0 -2px 0 rgba(0,0,0,0.25), - 0 2px 3px rgba(0,0,0,0.5); - text-shadow: 0 1px 0 rgba(255,255,255,0.3); - transition: transform 80ms ease, box-shadow 80ms ease; - } - .kbtn:active { - transform: translateY(1px); - box-shadow: - inset 0 2px 3px rgba(0,0,0,0.4), - inset 0 -1px 0 rgba(255,255,255,0.15), - 0 1px 1px rgba(0,0,0,0.5); - } - .kbtn-secondary { - background: linear-gradient(180deg, #6b7a55 0%, #4d5a3e 50%, #2c361f 100%); - color: #f1e4c5; - text-shadow: 0 1px 0 rgba(0,0,0,0.4); - } - .kbtn-info { - background: linear-gradient(180deg, #5a8ac4 0%, #3d6aaf 50%, #234783 100%); - color: #f1e4c5; - text-shadow: 0 1px 0 rgba(0,0,0,0.4); - } - .sched-actions { - display: flex; gap: 10px; flex-wrap: wrap; - margin-top: 4px; - } - - /* === footer === */ - .footer { - margin-top: 16px; - padding-top: 10px; - display: flex; - justify-content: space-between; - gap: 12px; - font-family: 'Special Elite', serif; - font-size: 10px; - letter-spacing: 0.2em; - color: rgba(241,228,197,0.55); - border-top: 1px dashed rgba(241,228,197,0.2); - flex-wrap: wrap; - } - - /* === responsive === */ - @media (max-width: 880px) { - .row-displays { grid-template-columns: 1fr; } - .row-controls { grid-template-columns: 1fr; } - .lamps { grid-template-columns: repeat(3, 1fr); } - .sched-row { - grid-template-columns: 1fr; - } - } - @media (max-width: 520px) { - .console { padding: 16px; } - .dymo { font-size: 11px; padding: 6px 12px; letter-spacing: 0.12em; } - .lamps { grid-template-columns: repeat(2, 1fr); } - .pbtn { width: 72px; height: 72px; font-size: 9px; } - .pbtn-mushroom { width: 90px; height: 90px; } - .buttons { gap: 8px; } - } - `; - } -} - -if (!customElements.get("sternwarte-card-retro")) { - customElements.define("sternwarte-card-retro", SternwarteCardRetro); -} - -window.customCards = window.customCards || []; -if (!window.customCards.some((c) => c?.type === "sternwarte-card-retro")) { - window.customCards.push({ - type: "sternwarte-card-retro", - name: "Sternwarte Card · Retro", - description: "Vintage Maschinen-Bedienpaneel mit 7-Segment-Anzeigen, Kippschaltern und beleuchteten Druckknöpfen", - }); -} diff --git a/sternwarte-homeassistant-card/dist/sternwarte-card.js b/sternwarte-homeassistant-card/dist/sternwarte-card.js index 8c0805a..88c2c6b 100644 --- a/sternwarte-homeassistant-card/dist/sternwarte-card.js +++ b/sternwarte-homeassistant-card/dist/sternwarte-card.js @@ -813,8 +813,10 @@ class SternwarteCard extends HTMLElement { if (!state?.state || state.state === "unknown" || state.state === "unavailable") return null; const value = this._parseEntityDate(state.state); if (!value) return null; - const localHour = value.getHours() + value.getMinutes() / 60 + value.getSeconds() / 3600; - return (localHour - 12 + 24) % 24; + const windowEnd = new Date(windowStart); + windowEnd.setDate(windowEnd.getDate() + 1); + if (value < windowStart || value >= windowEnd) return null; + return (value.getTime() - windowStart.getTime()) / 3600000; }; return { @@ -1550,11 +1552,13 @@ class SternwarteCard extends HTMLElement { +
+
@@ -1586,6 +1590,8 @@ class SternwarteCard extends HTMLElement { this._bindClick("power-on-btn", () => this._callEntity("switch", "turn_on", powerEntityId)); this._bindClick("power-off-btn", () => this._callEntity("switch", "turn_off", powerEntityId)); this._bindClick("nautical-btn", () => this._call("sternwarte", "set_nautical_schedule")); + this._bindClick("open-nautical-btn", () => this._call("sternwarte", "set_nautical_open_schedule")); + this._bindClick("close-nautical-btn", () => this._call("sternwarte", "set_nautical_close_schedule")); this._bindClick("clear-schedule-btn", () => { this._resetScheduleDrafts(); this._call("sternwarte", "clear_schedule"); diff --git a/sternwarte-homeassistant-integration/custom_components/sternwarte/__init__.py b/sternwarte-homeassistant-integration/custom_components/sternwarte/__init__.py index 602fe50..945d6d8 100644 --- a/sternwarte-homeassistant-integration/custom_components/sternwarte/__init__.py +++ b/sternwarte-homeassistant-integration/custom_components/sternwarte/__init__.py @@ -18,6 +18,8 @@ from .const import ( SERVICE_POWER_OFF, SERVICE_POWER_ON, SERVICE_SET_NAUTICAL_SCHEDULE, + SERVICE_SET_NAUTICAL_OPEN_SCHEDULE, + SERVICE_SET_NAUTICAL_CLOSE_SCHEDULE, SERVICE_STOP_ROOF, ) from .coordinator import SternwarteDataUpdateCoordinator @@ -77,6 +79,14 @@ async def _async_handle_set_nautical_schedule(hass: HomeAssistant, call: Service await _async_call_on_all(hass, "async_set_next_nautical_schedule") +async def _async_handle_set_nautical_open_schedule(hass: HomeAssistant, call: ServiceCall) -> None: + await _async_call_on_all(hass, "async_set_next_nautical_open_schedule") + + +async def _async_handle_set_nautical_close_schedule(hass: HomeAssistant, call: ServiceCall) -> None: + await _async_call_on_all(hass, "async_set_next_nautical_close_schedule") + + async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up Sternwarte from a config entry.""" coordinator = SternwarteDataUpdateCoordinator(hass, entry) @@ -106,6 +116,16 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: SERVICE_SET_NAUTICAL_SCHEDULE, partial(_async_handle_set_nautical_schedule, hass), ) + hass.services.async_register( + DOMAIN, + SERVICE_SET_NAUTICAL_OPEN_SCHEDULE, + partial(_async_handle_set_nautical_open_schedule, hass), + ) + hass.services.async_register( + DOMAIN, + SERVICE_SET_NAUTICAL_CLOSE_SCHEDULE, + partial(_async_handle_set_nautical_close_schedule, hass), + ) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True @@ -127,6 +147,8 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: SERVICE_CLEAR_OPEN_SCHEDULE, SERVICE_CLEAR_CLOSE_SCHEDULE, SERVICE_SET_NAUTICAL_SCHEDULE, + SERVICE_SET_NAUTICAL_OPEN_SCHEDULE, + SERVICE_SET_NAUTICAL_CLOSE_SCHEDULE, ): if hass.services.has_service(DOMAIN, service): hass.services.async_remove(DOMAIN, service) diff --git a/sternwarte-homeassistant-integration/custom_components/sternwarte/const.py b/sternwarte-homeassistant-integration/custom_components/sternwarte/const.py index def4b4e..9e687c9 100644 --- a/sternwarte-homeassistant-integration/custom_components/sternwarte/const.py +++ b/sternwarte-homeassistant-integration/custom_components/sternwarte/const.py @@ -49,3 +49,5 @@ ENTITY_ROOF_POSITION: Final = "roof_position" ENTITY_SCHEDULE_OPEN: Final = "schedule_open" ENTITY_SCHEDULE_CLOSE: Final = "schedule_close" ENTITY_SET_NAUTICAL_SCHEDULE: Final = "set_nautical_schedule" +SERVICE_SET_NAUTICAL_OPEN_SCHEDULE: Final = "set_nautical_open_schedule" +SERVICE_SET_NAUTICAL_CLOSE_SCHEDULE: Final = "set_nautical_close_schedule" diff --git a/sternwarte-homeassistant-integration/custom_components/sternwarte/coordinator.py b/sternwarte-homeassistant-integration/custom_components/sternwarte/coordinator.py index de1e123..aa763d9 100644 --- a/sternwarte-homeassistant-integration/custom_components/sternwarte/coordinator.py +++ b/sternwarte-homeassistant-integration/custom_components/sternwarte/coordinator.py @@ -249,6 +249,22 @@ class SternwarteDataUpdateCoordinator(DataUpdateCoordinator[SternwarteState]): await self._async_save_runtime_state() await self.async_request_refresh() + async def async_set_next_nautical_open_schedule(self) -> None: + """Set only the next nautical dusk as the opening schedule.""" + now = dt_util.now() + when = self._get_next_nautical_event(now, "dusk") + if when is None: + raise HomeAssistantError("Keine kommende nautische Abenddämmerung gefunden") + await self.async_set_open_schedule(when) + + async def async_set_next_nautical_close_schedule(self) -> None: + """Set only the next nautical dawn as the closing schedule.""" + now = dt_util.now() + when = self._get_next_nautical_event(now, "dawn") + if when is None: + raise HomeAssistantError("Keine kommende nautische Morgendämmerung gefunden") + await self.async_set_close_schedule(when) + async def async_turn_power_on(self) -> None: """Turn roof power on.""" async with self._command_lock: @@ -831,6 +847,17 @@ class SternwarteDataUpdateCoordinator(DataUpdateCoordinator[SternwarteState]): ) return dt_util.as_local(event_utc).replace(second=0, microsecond=0) + def _get_next_nautical_event(self, now: datetime, event_name: str) -> datetime | None: + for day_offset in range(3): + base_date = now + timedelta(days=day_offset) + twilight = self._calc_twilight(base_date) + if twilight["naut"] is None: + continue + event = self._event_to_local_datetime(base_date, twilight["naut"][event_name]) + if event and event > now: + return event + return None + def _get_next_nautical_schedule(self, now: datetime) -> dict[str, datetime | None] | None: for day_offset in range(3): base_date = now + timedelta(days=day_offset) diff --git a/sternwarte-homeassistant-integration/custom_components/sternwarte/services.yaml b/sternwarte-homeassistant-integration/custom_components/sternwarte/services.yaml index f1f88d3..6719a47 100644 --- a/sternwarte-homeassistant-integration/custom_components/sternwarte/services.yaml +++ b/sternwarte-homeassistant-integration/custom_components/sternwarte/services.yaml @@ -33,3 +33,11 @@ clear_close_schedule: set_nautical_schedule: name: Nautische Dämmerung setzen description: Setzt Öffnen und Schließen auf die nächste nautische Abend- und Morgendämmerung. + +set_nautical_open_schedule: + name: Nautische Öffnungszeit setzen + description: Setzt nur Öffnen auf die nächste nautische Abenddämmerung. + +set_nautical_close_schedule: + name: Nautische Schließzeit setzen + description: Setzt nur Schließen auf die nächste nautische Morgendämmerung.