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, }; class SternwarteCard extends HTMLElement { connectedCallback() { 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.render(); } getCardSize() { return 8; } getGridOptions() { return { rows: 8, columns: 18, min_rows: 7, }; } _state(entityId) { return this._hass?.states?.[entityId]; } _ensureClockTimer() { if (this._clockTimer) return; this._clockTimer = window.setInterval(() => { if (this._hass) this.render(); }, 30000); } _firstExistingEntityId(entityIds) { for (const entityId of entityIds) { if (entityId && this._state(entityId)) return entityId; } return entityIds.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 configured = this.config?.power; if (configured && this._state(configured)) { return configured; } const states = this._hass?.states || {}; const fallback = Object.keys(states).find((entityId) => { if (!entityId.startsWith("switch.")) return false; const normalized = entityId.toLowerCase(); const friendlyName = String(states[entityId]?.attributes?.friendly_name || "").toLowerCase(); return ( normalized.includes("dachsteuer") || (normalized.includes("sternwarte") && normalized.includes("power")) || friendlyName.includes("dachsteuerung") ); }); return fallback || configured || "switch.sternwarte_dachsteuerung"; } async _call(domain, service) { try { const result = this._hass.callService(domain, service, {}); Promise.resolve(result).catch(() => {}); } catch (error) {} } async _callEntity(domain, service, entityId) { if (!entityId) return; try { const result = this._hass.callService(domain, service, { entity_id: entityId }); Promise.resolve(result).catch(() => {}); } catch (error) {} } _bindClick(id, handler) { this.shadowRoot.getElementById(id)?.addEventListener("click", (event) => { event.preventDefault(); event.stopPropagation(); handler(); }); } _setDateTime(entityId, inputId) { const input = this.shadowRoot.getElementById(inputId); if (!input || !input.value) return; if (!entityId) return; this._hass.callService("datetime", "set_value", { entity_id: entityId, datetime: input.value, }); } _isoForInput(state) { if (!state || !state.state || state.state === "unknown" || state.state === "unavailable") { return ""; } const value = new Date(state.state); if (Number.isNaN(value.getTime())) return ""; const pad = (n) => String(n).padStart(2, "0"); return `${value.getFullYear()}-${pad(value.getMonth() + 1)}-${pad(value.getDate())}T${pad(value.getHours())}:${pad(value.getMinutes())}`; } _formatDateTime(state) { if (!state || !state.state || state.state === "unknown" || state.state === "unavailable") { return "\u2014"; } const value = new Date(state.state); if (Number.isNaN(value.getTime())) return "\u2014"; const pad = (n) => String(n).padStart(2, "0"); return `${pad(value.getDate())}.${pad(value.getMonth() + 1)}. ${pad(value.getHours())}:${pad(value.getMinutes())}`; } _roofStatusText() { const status = this._state(this.config.roofStatus)?.state; switch (status) { case "offen": return "Offen"; case "geschlossen": return "Geschlossen"; case "in_bewegung": return "In Bewegung"; case "sensorfehler": return "Sensorfehler"; case "unbekannt": return "Unbekannt"; default: return this._labelForRoof(); } } _roofPositionValue() { const value = Number(this._state(this.config.roofPosition)?.state); if (Number.isNaN(value)) return null; return Math.max(0, Math.min(100, Math.round(value))); } _labelForRoof() { const roof = this._state(this.config.roof); const moving = this._state(this.config.roofMoving); const closed = this._state(this.config.roofClosed); const open = this._state(this.config.roofOpen); if (moving?.state === "on") return "In Bewegung"; if (open?.state === "on" && closed?.state !== "on") return "Offen"; if (closed?.state === "on" && open?.state !== "on") return "Geschlossen"; if (roof?.state === "opening") return "\u00d6ffnet"; if (roof?.state === "closing") return "Schlie\u00dft"; return "Unbekannt"; } _boolBadge(label, entityId, onText = "Aktiv", offText = "Aus", extraClass = "") { const state = this._state(entityId); const on = state?.state === "on"; return `
${label} ${on ? onText : offText}
`; } _openBlockedReason() { const rain = this._state(this.config.rain)?.state === "on"; const moving = this._state(this.config.roofMoving)?.state === "on"; const open = this._state(this.config.roofOpen)?.state === "on"; if (rain) return "\u00d6ffnen gesperrt: Regen erkannt"; if (moving) return "\u00d6ffnen gesperrt: Dach ist in Bewegung"; if (open) return "Dach ist bereits offen"; return ""; } _powerIndicatorState() { const state = this._state(this._powerEntityId())?.state; if (state === "on") { return { className: "on", label: "Ein", lightStyle: "background:#4caf50; box-shadow:0 0 12px rgba(76, 175, 80, 0.45);", }; } if (state === "off") { return { className: "off", label: "Aus", lightStyle: "background:#c62828; box-shadow:0 0 10px rgba(198, 40, 40, 0.25);", }; } return { className: "unknown", label: "?", lightStyle: "background:#d1a94a; box-shadow:0 0 10px rgba(209, 169, 74, 0.3);", }; } _windIndicatorState() { const wind = this._state(this.config.wind); const value = Number(wind?.state); const display = wind?.state && wind.state !== "unknown" && wind.state !== "unavailable" ? `${wind.state} ${wind.attributes?.unit_of_measurement ?? "km/h"}` : "\u2014"; if (Number.isNaN(value)) { return { display, className: "", }; } if (value <= 10) { return { display, className: "wind-good", }; } if (value <= 15) { return { display, className: "wind-warn", }; } return { display, className: "wind-danger", }; } _isMESZ(date) { const year = date.getUTCFullYear(); const lastSunday = (month, hour) => { const event = new Date(Date.UTC(year, month + 1, 0, hour)); while (event.getUTCDay() !== 0) event.setUTCDate(event.getUTCDate() - 1); return event; }; return date >= lastSunday(2, 1) && date < lastSunday(9, 1); } _calcTwilight(date) { const lat = Number(this.config.astroLat); const lon = Number(this.config.astroLon); const jd = date.getTime() / 86400000 + 2440587.5; const n = jd - 2451545.0; const L = ((280.460 + 0.9856474 * n) % 360 + 360) % 360; const g = ((357.528 + 0.9856003 * n) % 360 + 360) % 360; const gR = g * Math.PI / 180; const lam = L + 1.915 * Math.sin(gR) + 0.020 * Math.sin(2 * gR); const lamR = lam * Math.PI / 180; const eps = 23.439 - 0.0000004 * n; const epsR = eps * Math.PI / 180; const dec = Math.asin(Math.sin(epsR) * Math.sin(lamR)); const RA = Math.atan2(Math.cos(epsR) * Math.sin(lamR), Math.cos(lamR)) * 180 / Math.PI / 15; const GMST0h = ((L + 180) % 360 + 360) % 360 / 15; const solarNoon = (12 - lon / 15 - (RA - GMST0h + 12 + 720) % 24 + 24) % 24; const latR = lat * Math.PI / 180; const eventUTC = (depDeg) => { const cosH = (Math.sin(-depDeg * Math.PI / 180) - Math.sin(latR) * Math.sin(dec)) / (Math.cos(latR) * Math.cos(dec)); if (cosH < -1 || cosH > 1) return null; const H = Math.acos(cosH) * 180 / Math.PI / 15; return { dawn: (solarNoon - H + 24) % 24, dusk: (solarNoon + H) % 24 }; }; return { sun: eventUTC(0.833), civil: eventUTC(6), naut: eventUTC(12), astro: eventUTC(18), }; } _julianDate(date) { return date.getTime() / 86400000 + 2440587.5; } _gmstHours(jd) { const T = (jd - 2451545) / 36525; const gmst = 280.46061837 + 360.98564736629 * (jd - 2451545) + 0.000387933 * T * T - (T * T * T) / 38710000; return (((gmst % 360) + 360) % 360) / 15; } _moonStateAt(date) { const jd = this._julianDate(date); const d = jd - 2451543.5; const lat = Number(this.config.astroLat) * Math.PI / 180; const lonHours = Number(this.config.astroLon) / 15; const toRad = (deg) => deg * Math.PI / 180; const normalizeDeg = (deg) => ((deg % 360) + 360) % 360; const sunMeanLongitude = normalizeDeg(280.460 + 0.9856474 * (jd - 2451545.0)); const sunMeanAnomaly = normalizeDeg(357.528 + 0.9856003 * (jd - 2451545.0)); const sunLongitude = normalizeDeg( sunMeanLongitude + 1.915 * Math.sin(toRad(sunMeanAnomaly)) + 0.020 * Math.sin(2 * toRad(sunMeanAnomaly)), ); const N = normalizeDeg(125.1228 - 0.0529538083 * d); const i = toRad(5.1454); const w = normalizeDeg(318.0634 + 0.1643573223 * d); const a = 60.2666; const e = 0.0549; const M = normalizeDeg(115.3654 + 13.0649929509 * d); const E = M + (180 / Math.PI) * e * Math.sin(toRad(M)) * (1 + e * Math.cos(toRad(M))); const xv = a * (Math.cos(toRad(E)) - e); const yv = a * (Math.sqrt(1 - e * e) * Math.sin(toRad(E))); const v = Math.atan2(yv, xv); const r = Math.sqrt(xv * xv + yv * yv); const xh = r * (Math.cos(toRad(N)) * Math.cos(v + toRad(w)) - Math.sin(toRad(N)) * Math.sin(v + toRad(w)) * Math.cos(i)); const yh = r * (Math.sin(toRad(N)) * Math.cos(v + toRad(w)) + Math.cos(toRad(N)) * Math.sin(v + toRad(w)) * Math.cos(i)); const zh = r * Math.sin(v + toRad(w)) * Math.sin(i); const moonLon = Math.atan2(yh, xh); const moonLat = Math.atan2(zh, Math.sqrt(xh * xh + yh * yh)); const obliquity = toRad(23.4393 - 0.0000004 * d); const xe = r * Math.cos(moonLon) * Math.cos(moonLat); const ye = r * Math.sin(moonLon) * Math.cos(moonLat); const ze = r * Math.sin(moonLat); const xeq = xe; const yeq = ye * Math.cos(obliquity) - ze * Math.sin(obliquity); const zeq = ye * Math.sin(obliquity) + ze * Math.cos(obliquity); const raHours = (((Math.atan2(yeq, xeq) * 180 / Math.PI) / 15) + 24) % 24; const dec = Math.atan2(zeq, Math.sqrt(xeq * xeq + yeq * yeq)); const lstHours = (this._gmstHours(jd) + lonHours + 24) % 24; const hourAngle = toRad((lstHours - raHours) * 15); const altitude = Math.asin( Math.sin(dec) * Math.sin(lat) + Math.cos(dec) * Math.cos(lat) * Math.cos(hourAngle), ); const elongation = Math.acos( Math.cos(moonLat) * Math.cos(moonLon - toRad(sunLongitude)), ); const illumination = Math.max(0, Math.min(1, (1 - Math.cos(elongation)) / 2)); return { altitude, illumination }; } _moonTrackState() { const now = new Date(); const cycleStart = new Date(now); cycleStart.setHours(12, 0, 0, 0); if (now.getHours() < 12) { cycleStart.setDate(cycleStart.getDate() - 1); } const stepMinutes = 10; const totalSteps = Math.round((24 * 60) / stepMinutes); const segments = []; let segmentStart = null; let currentStep = 0; const currentMoon = this._moonStateAt(now); for (; currentStep <= totalSteps; currentStep += 1) { const sample = new Date(cycleStart.getTime() + currentStep * stepMinutes * 60000); const visible = this._moonStateAt(sample).altitude > 0; if (visible && segmentStart === null) { segmentStart = currentStep; } if ((!visible || currentStep === totalSteps) && segmentStart !== null) { const endStep = visible && currentStep === totalSteps ? currentStep : currentStep - 1; const startHour = (segmentStart * stepMinutes) / 60; const endHour = ((endStep + 1) * stepMinutes) / 60; segments.push({ left: `${(startHour / 24) * 100}%`, width: `${Math.max(((endHour - startHour) / 24) * 100, 0.5)}%`, }); segmentStart = null; } } return { opacity: (0.18 + currentMoon.illumination * 0.82).toFixed(2), segments, }; } _formatHourValue(hourValue) { if (hourValue === null || hourValue === undefined) return "\u2014"; const hours = ((hourValue % 24) + 24) % 24; const h = Math.floor(hours); const m = Math.floor((hours - h) * 60); return `${String(h).padStart(2, "0")}:${String(m).padStart(2, "0")}`; } _astroState() { const now = new Date(); const tw = this._calcTwilight(now); const offset = this._isMESZ(now) ? 2 : 1; const shiftHour = (hourValue) => ((hourValue - 12 + 24) % 24); const localize = (event) => event ? { dawn: (event.dawn + offset + 24) % 24, dusk: (event.dusk + offset + 24) % 24, } : null; const localTw = { sun: localize(tw.sun), civil: localize(tw.civil), naut: localize(tw.naut), astro: localize(tw.astro), }; const nowLocal = shiftHour(now.getHours() + now.getMinutes() / 60 + now.getSeconds() / 3600); const segments = []; const colors = { night: "#16202f", astro: "#2a4978", naut: "#4a76bf", civil: "#c68c50", day: "#eed06f", }; const addSegment = (start, end, className) => { if (start === null || end === null) return; const shiftedStart = shiftHour(start); const shiftedEnd = shiftHour(end); if (shiftedEnd < shiftedStart) { segments.push({ start: shiftedStart, end: 24, color: colors[className], }); segments.push({ start: 0, end: shiftedEnd, color: colors[className], }); return; } if (shiftedEnd <= shiftedStart) return; segments.push({ start: shiftedStart, end: shiftedEnd, color: colors[className], }); }; addSegment(0, localTw.astro?.dawn ?? null, "night"); addSegment(localTw.astro?.dawn ?? null, localTw.naut?.dawn ?? null, "astro"); addSegment(localTw.naut?.dawn ?? null, localTw.civil?.dawn ?? null, "naut"); addSegment(localTw.civil?.dawn ?? null, localTw.sun?.dawn ?? null, "civil"); addSegment(localTw.sun?.dawn ?? null, localTw.sun?.dusk ?? null, "day"); addSegment(localTw.sun?.dusk ?? null, localTw.civil?.dusk ?? null, "civil"); addSegment(localTw.civil?.dusk ?? null, localTw.naut?.dusk ?? null, "naut"); addSegment(localTw.naut?.dusk ?? null, localTw.astro?.dusk ?? null, "astro"); addSegment(localTw.astro?.dusk ?? null, 24, "night"); if (!segments.length) addSegment(0, 24, "night"); segments.sort((a, b) => a.start - b.start); const gradientStops = segments.flatMap((segment) => { const start = `${(segment.start / 24) * 100}%`; const end = `${(segment.end / 24) * 100}%`; return [`${segment.color} ${start}`, `${segment.color} ${end}`]; }); const gradient = gradientStops.length ? `linear-gradient(90deg, ${gradientStops.join(", ")})` : colors.night; const toLocalHour = (state) => { if (!state?.state || state.state === "unknown" || state.state === "unavailable") return null; const value = new Date(state.state); if (Number.isNaN(value.getTime())) return null; return shiftHour(value.getHours() + value.getMinutes() / 60); }; return { now: now.toLocaleTimeString("de-DE", { hour: "2-digit", minute: "2-digit" }), gradient, markerLeft: `${(nowLocal / 24) * 100}%`, openMarkerLeft: toLocalHour(this._state(this._scheduleOpenEntityId())), closeMarkerLeft: toLocalHour(this._state(this._scheduleCloseEntityId())), }; } render() { if (!this.shadowRoot || !this._hass) return; const powerEntityId = this._powerEntityId(); const powerIndicator = this._powerIndicatorState(); const rain = this._state(this.config.rain); const openScheduleEntityId = this._scheduleOpenEntityId(); const closeScheduleEntityId = this._scheduleCloseEntityId(); const openSchedule = this._state(openScheduleEntityId); const closeSchedule = this._state(closeScheduleEntityId); const roofPosition = this._roofPositionValue(); const windIndicator = this._windIndicatorState(); const rainOn = 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 openBlockedReason = this._openBlockedReason(); const powerOn = this._state(powerEntityId)?.state === "on"; let openButtonDisabled; let closeButtonDisabled; let stopButtonDisabled; if (!powerOn) { openButtonDisabled = true; closeButtonDisabled = true; stopButtonDisabled = true; } else if (movingOn) { openButtonDisabled = true; closeButtonDisabled = true; stopButtonDisabled = false; } else if (openOn && !closedOn) { openButtonDisabled = true; closeButtonDisabled = false; stopButtonDisabled = true; } else if (closedOn && !openOn) { openButtonDisabled = false; closeButtonDisabled = true; stopButtonDisabled = true; } else if (closedOn && openOn) { openButtonDisabled = true; closeButtonDisabled = true; stopButtonDisabled = false; } else { openButtonDisabled = false; closeButtonDisabled = false; stopButtonDisabled = false; } const astro = this._astroState(); const moonTrack = this._moonTrackState(); this.shadowRoot.innerHTML = `
${rainOn ? `
Regen erkanntDas Dach wird bei Regen nicht \u00f6ffnet und bei Bedarf automatisch geschlossen.
` : ""}
\u00d6ffnen geplant ${this._formatDateTime(openSchedule)}
Schlie\u00dfen geplant ${this._formatDateTime(closeSchedule)}
Wind ${windIndicator.display}
${this._boolBadge("Dach zu", this.config.roofClosed, "Ja", "Nein")} ${this._boolBadge("Dach offen", this.config.roofOpen, "Ja", "Nein")} ${this._boolBadge("Dach bewegt", this.config.roofMoving, "Ja", "Nein", "moving")} ${this._boolBadge("Regen", this.config.rain, "Ja", "Nein", "rain")}
Jetzt${astro.now}
12
18
0
6
12
${moonTrack.segments.map((segment) => `
`).join("")} ${astro.openMarkerLeft === null ? "" : `
`} ${astro.closeMarkerLeft === null ? "" : `
`}
Dachposition ${roofPosition === null ? "\u2014" : `${roofPosition} %`}
Steuerung
${powerIndicator.label}
${openBlockedReason ? `
${openBlockedReason}
` : ""}
Zeitplanung
\u00d6ffnen geplant ${this._formatDateTime(openSchedule)}
Schlie\u00dfen geplant ${this._formatDateTime(closeSchedule)}
`; 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("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("clear-schedule-btn", () => this._call("sternwarte", "clear_schedule")); this._bindClick("open-schedule-btn", () => this._setDateTime(openScheduleEntityId, "open-schedule-input")); this._bindClick("close-schedule-btn", () => this._setDateTime(closeScheduleEntityId, "close-schedule-input")); } } if (!customElements.get("sternwarte-card")) { customElements.define("sternwarte-card", SternwarteCard); } window.customCards = window.customCards || []; if (!window.customCards.some((card) => card?.type === "sternwarte-card")) { window.customCards.push({ type: "sternwarte-card", name: "Sternwarte Card", description: "Eigene Card f\u00fcr die Sternwarte", }); }