commit 7757def083987051d3630b364ed53498ac7c895b Author: Thomas Müller Date: Mon Apr 27 15:07:30 2026 +0200 Start diff --git a/README.md b/README.md new file mode 100644 index 0000000..0df1017 --- /dev/null +++ b/README.md @@ -0,0 +1,24 @@ +# HACS-Pakete + +Dieser Ordner enthält zwei getrennte, für HACS vorbereitete Paketordner: + +- `sternwarte-homeassistant-integration` +- `sternwarte-homeassistant-card` + +## Gedachter Einsatz + +Für HACS werden Integration und Card als zwei getrennte Repositories geführt: + +1. `sternwarte-homeassistant-integration` + Enthält die Home-Assistant-Integration unter `custom_components/sternwarte/` + +2. `sternwarte-homeassistant-card` + Enthält die Lovelace-Card unter `dist/sternwarte-card.js` + +## Nächster Schritt + +Wenn ihr das später wirklich über HACS verwenden wollt, legt ihr für beide Ordner jeweils ein eigenes Git-Repository an oder kopiert den jeweiligen Inhalt in zwei getrennte GitHub-Repositories. + +## Hinweis + +Die Integration läuft bereits lokal. Für eine spätere Veröffentlichung sollten in `manifest.json` noch echte `documentation`- und `issue_tracker`-Links auf das spätere Repository gesetzt werden. diff --git a/sternwarte-homeassistant-card/README.md b/sternwarte-homeassistant-card/README.md new file mode 100644 index 0000000..3a04bc4 --- /dev/null +++ b/sternwarte-homeassistant-card/README.md @@ -0,0 +1,20 @@ +# Sternwarte Home Assistant Card + +Lovelace-Card für die Sternwarte. + +## Struktur + +```text +dist/ + sternwarte-card.js +``` + +## Für HACS + +Dieser Ordner ist als Root eines späteren HACS-Dashboard-Repositorys vorbereitet. + +Empfohlener Repository-Name: + +- `sternwarte-homeassistant-card` + +Nach dem Einbinden über HACS muss in Home Assistant noch wie üblich die Dashboard-Ressource für die Card vorhanden sein, falls HACS sie nicht bereits automatisch registriert. diff --git a/sternwarte-homeassistant-card/dist/sternwarte-card.js b/sternwarte-homeassistant-card/dist/sternwarte-card.js new file mode 100644 index 0000000..141d963 --- /dev/null +++ b/sternwarte-homeassistant-card/dist/sternwarte-card.js @@ -0,0 +1,1082 @@ +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", + }); +} diff --git a/sternwarte-homeassistant-card/hacs.json b/sternwarte-homeassistant-card/hacs.json new file mode 100644 index 0000000..95781b7 --- /dev/null +++ b/sternwarte-homeassistant-card/hacs.json @@ -0,0 +1,4 @@ +{ + "name": "Sternwarte Card", + "render_readme": true +} diff --git a/sternwarte-homeassistant-integration/README.md b/sternwarte-homeassistant-integration/README.md new file mode 100644 index 0000000..e4cd825 --- /dev/null +++ b/sternwarte-homeassistant-integration/README.md @@ -0,0 +1,29 @@ +# Sternwarte Home Assistant Integration + +Home-Assistant-Custom-Integration für die Sternwarte. + +## Struktur + +```text +custom_components/ + sternwarte/ +``` + +## Für HACS + +Dieser Ordner ist als Root eines späteren HACS-Repositorys vorbereitet. + +Empfohlener Repository-Name: + +- `sternwarte-homeassistant-integration` + +## Vor einer Veröffentlichung + +Vor dem echten Einsatz als HACS-Repository sollten in +`custom_components/sternwarte/manifest.json` +noch passende Werte für + +- `documentation` +- `issue_tracker` + +auf das spätere Repository gesetzt werden. diff --git a/sternwarte-homeassistant-integration/custom_components/sternwarte/__init__.py b/sternwarte-homeassistant-integration/custom_components/sternwarte/__init__.py new file mode 100644 index 0000000..3411691 --- /dev/null +++ b/sternwarte-homeassistant-integration/custom_components/sternwarte/__init__.py @@ -0,0 +1,111 @@ +"""Sternwarte Home Assistant integration.""" + +from __future__ import annotations + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import Platform +from functools import partial + +from homeassistant.core import HomeAssistant, ServiceCall + +from .const import ( + DOMAIN, + SERVICE_CLEAR_SCHEDULE, + SERVICE_CLOSE_ROOF, + SERVICE_OPEN_ROOF, + SERVICE_POWER_OFF, + SERVICE_POWER_ON, + SERVICE_SET_NAUTICAL_SCHEDULE, + SERVICE_STOP_ROOF, +) +from .coordinator import SternwarteDataUpdateCoordinator + +PLATFORMS: list[Platform] = [ + Platform.BINARY_SENSOR, + Platform.BUTTON, + Platform.COVER, + Platform.DATETIME, + Platform.SENSOR, + Platform.SWITCH, +] + + +async def _async_call_on_all( + hass: HomeAssistant, + entry_action: str, +) -> None: + """Run an action on all Sternwarte coordinators.""" + for coordinator in hass.data.get(DOMAIN, {}).values(): + await getattr(coordinator, entry_action)() + + +async def _async_handle_open_roof(hass: HomeAssistant, call: ServiceCall) -> None: + await _async_call_on_all(hass, "async_open_roof") + + +async def _async_handle_close_roof(hass: HomeAssistant, call: ServiceCall) -> None: + await _async_call_on_all(hass, "async_close_roof") + + +async def _async_handle_stop_roof(hass: HomeAssistant, call: ServiceCall) -> None: + await _async_call_on_all(hass, "async_stop_roof") + + +async def _async_handle_power_on(hass: HomeAssistant, call: ServiceCall) -> None: + await _async_call_on_all(hass, "async_turn_power_on") + + +async def _async_handle_power_off(hass: HomeAssistant, call: ServiceCall) -> None: + await _async_call_on_all(hass, "async_turn_power_off") + + +async def _async_handle_clear_schedule(hass: HomeAssistant, call: ServiceCall) -> None: + await _async_call_on_all(hass, "async_clear_schedule") + + +async def _async_handle_set_nautical_schedule(hass: HomeAssistant, call: ServiceCall) -> None: + await _async_call_on_all(hass, "async_set_next_nautical_schedule") + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up Sternwarte from a config entry.""" + coordinator = SternwarteDataUpdateCoordinator(hass, entry) + await coordinator.async_config_entry_first_refresh() + + hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator + + if not hass.services.has_service(DOMAIN, SERVICE_OPEN_ROOF): + hass.services.async_register(DOMAIN, SERVICE_OPEN_ROOF, partial(_async_handle_open_roof, hass)) + hass.services.async_register(DOMAIN, SERVICE_CLOSE_ROOF, partial(_async_handle_close_roof, hass)) + hass.services.async_register(DOMAIN, SERVICE_STOP_ROOF, partial(_async_handle_stop_roof, hass)) + hass.services.async_register(DOMAIN, SERVICE_POWER_ON, partial(_async_handle_power_on, hass)) + hass.services.async_register(DOMAIN, SERVICE_POWER_OFF, partial(_async_handle_power_off, hass)) + hass.services.async_register(DOMAIN, SERVICE_CLEAR_SCHEDULE, partial(_async_handle_clear_schedule, hass)) + hass.services.async_register( + DOMAIN, + SERVICE_SET_NAUTICAL_SCHEDULE, + partial(_async_handle_set_nautical_schedule, hass), + ) + + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload a config entry.""" + unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + if unload_ok: + hass.data[DOMAIN].pop(entry.entry_id, None) + if not hass.data[DOMAIN]: + for service in ( + SERVICE_OPEN_ROOF, + SERVICE_CLOSE_ROOF, + SERVICE_STOP_ROOF, + SERVICE_POWER_ON, + SERVICE_POWER_OFF, + SERVICE_CLEAR_SCHEDULE, + SERVICE_SET_NAUTICAL_SCHEDULE, + ): + if hass.services.has_service(DOMAIN, service): + hass.services.async_remove(DOMAIN, service) + return unload_ok diff --git a/sternwarte-homeassistant-integration/custom_components/sternwarte/api.py b/sternwarte-homeassistant-integration/custom_components/sternwarte/api.py new file mode 100644 index 0000000..082eb2a --- /dev/null +++ b/sternwarte-homeassistant-integration/custom_components/sternwarte/api.py @@ -0,0 +1,109 @@ +"""API client for the Sternwarte Web-IO.""" + +from __future__ import annotations + +from dataclasses import dataclass +import asyncio +import time + +from .models import SternwarteState + + +@dataclass(slots=True) +class SternwarteApi: + """Thin Web-IO client.""" + + host: str + port: int + password: str + + def _url(self, path: str) -> str: + separator = "&" if "?" in path else "?" + nonce = time.monotonic_ns() + return f"http://{self.host}:{self.port}{path}{separator}_ts={nonce}" + + async def _get_text(self, path: str) -> str: + request_path = self._url(path).split(f"{self.host}:{self.port}", 1)[-1] + request = ( + f"GET {request_path} HTTP/1.0\r\n" + f"Host: {self.host}\r\n" + "Connection: close\r\n" + "\r\n" + ).encode("ascii") + + last_error: Exception | None = None + for attempt in range(2): + try: + reader, writer = await asyncio.wait_for( + asyncio.open_connection(self.host, self.port), + timeout=5, + ) + try: + writer.write(request) + await asyncio.wait_for(writer.drain(), timeout=5) + raw = await asyncio.wait_for(reader.read(4096), timeout=5) + content_length = None + if b"\r\n\r\n" in raw: + headers, initial_body = raw.split(b"\r\n\r\n", 1) + for header_line in headers.decode("iso-8859-1", errors="ignore").split("\r\n"): + if header_line.lower().startswith("content-length:"): + try: + content_length = int(header_line.split(":", 1)[1].strip()) + except ValueError: + content_length = None + break + else: + initial_body = raw + + if content_length is not None: + remaining = max(0, content_length - len(initial_body)) + body = initial_body + ( + await asyncio.wait_for(reader.readexactly(remaining), timeout=5) + if remaining + else b"" + ) + else: + body = initial_body + finally: + writer.close() + await writer.wait_closed() + + return body.decode("utf-8", errors="ignore").replace("\x00", "").strip() + except (OSError, asyncio.TimeoutError) as err: + last_error = err + if attempt == 1: + raise + await asyncio.sleep(0.25) + raise last_error if last_error is not None else RuntimeError("Unbekannter Web-IO-Fehler") + + async def fetch_state(self) -> SternwarteState: + """Fetch and parse the current Web-IO state.""" + raw = await self._get_text(f"/allout?PW={self.password}&") + parts = raw.split(";") + + state = SternwarteState() + + out_index = parts.index("output") if "output" in parts else -1 + if out_index != -1: + value = int(parts[out_index + 1] or "0", 16) + state.outputs = [bool(value & (1 << i)) for i in range(12)] + + in_index = parts.index("input") if "input" in parts else -1 + if in_index != -1: + value = int(parts[in_index + 1] or "0", 16) + state.inputs = [bool(value & (1 << i)) for i in range(12)] + + counter_index = parts.index("counter") if "counter" in parts else -1 + if counter_index != -1: + state.counters = [int(parts[counter_index + 1 + i] or "0") for i in range(12)] + + return state + + async def set_output(self, index: int, enabled: bool) -> None: + """Set a digital output.""" + state = "ON" if enabled else "OFF" + await self._get_text(f"/outputaccess{index}?PW={self.password}&State={state}&") + + async def clear_counter(self, index: int) -> None: + """Clear a counter.""" + await self._get_text(f"/counterclear{index}?PW={self.password}&") diff --git a/sternwarte-homeassistant-integration/custom_components/sternwarte/binary_sensor.py b/sternwarte-homeassistant-integration/custom_components/sternwarte/binary_sensor.py new file mode 100644 index 0000000..57e8b4e --- /dev/null +++ b/sternwarte-homeassistant-integration/custom_components/sternwarte/binary_sensor.py @@ -0,0 +1,74 @@ +"""Binary sensors for Sternwarte.""" + +from __future__ import annotations + +from homeassistant.components.binary_sensor import BinarySensorDeviceClass, BinarySensorEntity +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .const import DOMAIN, ENTITY_RAIN, ENTITY_ROOF_CLOSED, ENTITY_ROOF_MOVING, ENTITY_ROOF_OPEN +from .entity import SternwarteEntity + + +async def async_setup_entry( + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up Sternwarte binary sensors.""" + coordinator = hass.data[DOMAIN][entry.entry_id] + async_add_entities( + [ + SternwarteClosedSensor(coordinator), + SternwarteOpenSensor(coordinator), + SternwarteMovingSensor(coordinator), + SternwarteRainSensor(coordinator), + ] + ) + + +class SternwarteClosedSensor(SternwarteEntity, BinarySensorEntity): + _attr_name = "Dach zu" + + def __init__(self, coordinator): + super().__init__(coordinator, ENTITY_ROOF_CLOSED) + + @property + def is_on(self) -> bool: + return self.coordinator.data.roof_closed + + +class SternwarteOpenSensor(SternwarteEntity, BinarySensorEntity): + _attr_name = "Dach offen" + + def __init__(self, coordinator): + super().__init__(coordinator, ENTITY_ROOF_OPEN) + + @property + def is_on(self) -> bool: + return self.coordinator.data.roof_open + + +class SternwarteMovingSensor(SternwarteEntity, BinarySensorEntity): + _attr_name = "Dach bewegt" + _attr_device_class = BinarySensorDeviceClass.MOVING + + def __init__(self, coordinator): + super().__init__(coordinator, ENTITY_ROOF_MOVING) + + @property + def is_on(self) -> bool: + return self.coordinator.data.roof_moving + + +class SternwarteRainSensor(SternwarteEntity, BinarySensorEntity): + _attr_name = "Regen" + _attr_device_class = BinarySensorDeviceClass.MOISTURE + + def __init__(self, coordinator): + super().__init__(coordinator, ENTITY_RAIN) + + @property + def is_on(self) -> bool: + return self.coordinator.data.raining diff --git a/sternwarte-homeassistant-integration/custom_components/sternwarte/button.py b/sternwarte-homeassistant-integration/custom_components/sternwarte/button.py new file mode 100644 index 0000000..17a4327 --- /dev/null +++ b/sternwarte-homeassistant-integration/custom_components/sternwarte/button.py @@ -0,0 +1,34 @@ +"""Button entities for Sternwarte.""" + +from __future__ import annotations + +from homeassistant.components.button import ButtonEntity +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .const import DOMAIN, ENTITY_SET_NAUTICAL_SCHEDULE +from .entity import SternwarteEntity + + +async def async_setup_entry( + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up Sternwarte buttons.""" + coordinator = hass.data[DOMAIN][entry.entry_id] + async_add_entities([SternwarteSetNauticalScheduleButton(coordinator)]) + + +class SternwarteSetNauticalScheduleButton(SternwarteEntity, ButtonEntity): + """Button to set the next nautical dusk/dawn schedule.""" + + _attr_name = "Nautische Dämmerung setzen" + _attr_icon = "mdi:weather-night" + + def __init__(self, coordinator): + super().__init__(coordinator, ENTITY_SET_NAUTICAL_SCHEDULE) + + async def async_press(self) -> None: + await self.coordinator.async_set_next_nautical_schedule() diff --git a/sternwarte-homeassistant-integration/custom_components/sternwarte/config_flow.py b/sternwarte-homeassistant-integration/custom_components/sternwarte/config_flow.py new file mode 100644 index 0000000..454e97d --- /dev/null +++ b/sternwarte-homeassistant-integration/custom_components/sternwarte/config_flow.py @@ -0,0 +1,33 @@ +"""Config flow for Sternwarte.""" + +from __future__ import annotations + +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.data_entry_flow import FlowResult + +from .const import CONF_HOST, CONF_PASSWORD, CONF_PORT, DEFAULT_HOST, DEFAULT_NAME, DEFAULT_PASSWORD, DEFAULT_PORT, DOMAIN + + +class SternwarteConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a config flow for Sternwarte.""" + + VERSION = 1 + + async def async_step_user(self, user_input: dict | None = None) -> FlowResult: + """Handle the initial step.""" + if user_input is not None: + await self.async_set_unique_id(user_input[CONF_HOST]) + self._abort_if_unique_id_configured() + return self.async_create_entry(title=DEFAULT_NAME, data=user_input) + + schema = vol.Schema( + { + vol.Required(CONF_HOST, default=DEFAULT_HOST): str, + vol.Required(CONF_PORT, default=DEFAULT_PORT): int, + vol.Required(CONF_PASSWORD, default=DEFAULT_PASSWORD): str, + } + ) + return self.async_show_form(step_id="user", data_schema=schema) + diff --git a/sternwarte-homeassistant-integration/custom_components/sternwarte/const.py b/sternwarte-homeassistant-integration/custom_components/sternwarte/const.py new file mode 100644 index 0000000..14e53e1 --- /dev/null +++ b/sternwarte-homeassistant-integration/custom_components/sternwarte/const.py @@ -0,0 +1,48 @@ +"""Constants for Sternwarte.""" + +from __future__ import annotations + +from typing import Final + +DOMAIN = "sternwarte" + +CONF_HOST = "host" +CONF_PORT = "port" +CONF_PASSWORD = "password" + +DEFAULT_NAME = "Sternwarte" +DEFAULT_HOST = "192.168.1.110" +DEFAULT_PORT = 80 +DEFAULT_PASSWORD = "sowo2009" + +UPDATE_INTERVAL_SECONDS = 1 +POWER_ON_DELAY_SECONDS = 30 +WIND_INTERVAL_SECONDS = 30 +WIND_COUNTER_INDEX = 4 +WIND_FACTOR = (2 / 3) * 3.6 +ROOF_POSITION_MAX = 976 +ROOF_COUNTER_CLOSE_INDEX = 0 +ROOF_COUNTER_OPEN_INDEX = 1 +STORAGE_VERSION = 1 +STORAGE_KEY = f"{DOMAIN}_schedule" + +SERVICE_OPEN_ROOF = "open_roof" +SERVICE_CLOSE_ROOF = "close_roof" +SERVICE_STOP_ROOF = "stop_roof" +SERVICE_POWER_ON = "power_on" +SERVICE_POWER_OFF = "power_off" +SERVICE_CLEAR_SCHEDULE = "clear_schedule" +SERVICE_SET_NAUTICAL_SCHEDULE = "set_nautical_schedule" + +ENTITY_ROOF_COVER: Final = "roof" +ENTITY_ROOF_POWER: Final = "roof_power" +ENTITY_ROOF_CLOSED: Final = "roof_closed" +ENTITY_ROOF_OPEN: Final = "roof_open" +ENTITY_ROOF_MOVING: Final = "roof_moving" +ENTITY_ROOF_STATUS: Final = "roof_status" +ENTITY_RAIN: Final = "rain" +ENTITY_WIND_SPEED: Final = "wind_speed" +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" diff --git a/sternwarte-homeassistant-integration/custom_components/sternwarte/coordinator.py b/sternwarte-homeassistant-integration/custom_components/sternwarte/coordinator.py new file mode 100644 index 0000000..e566a3c --- /dev/null +++ b/sternwarte-homeassistant-integration/custom_components/sternwarte/coordinator.py @@ -0,0 +1,829 @@ +"""Coordinator for Sternwarte state updates.""" + +from __future__ import annotations + +import asyncio +from collections import deque +from dataclasses import asdict +from datetime import UTC, datetime, timedelta +import logging +from math import acos, asin, atan2, cos, floor, pi, sin +from typing import Any + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.storage import Store +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator +from homeassistant.util import dt as dt_util + +from .api import SternwarteApi +from .const import ( + CONF_HOST, + CONF_PASSWORD, + CONF_PORT, + DEFAULT_NAME, + POWER_ON_DELAY_SECONDS, + ROOF_COUNTER_CLOSE_INDEX, + ROOF_COUNTER_OPEN_INDEX, + ROOF_POSITION_MAX, + STORAGE_KEY, + STORAGE_VERSION, + UPDATE_INTERVAL_SECONDS, + WIND_COUNTER_INDEX, + WIND_FACTOR, + WIND_INTERVAL_SECONDS, +) +from .models import SternwarteSchedule, SternwarteState, WindSample + +LOGGER = logging.getLogger(__name__) + + +class SternwarteDataUpdateCoordinator(DataUpdateCoordinator[SternwarteState]): + """Handle fetching Sternwarte state and runtime logic.""" + + config_entry: ConfigEntry + + def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None: + self.config_entry = entry + self.api = SternwarteApi( + host=entry.data[CONF_HOST], + port=entry.data[CONF_PORT], + password=entry.data[CONF_PASSWORD], + ) + super().__init__( + hass, + logger=LOGGER, + name=DEFAULT_NAME, + update_interval=timedelta(seconds=UPDATE_INTERVAL_SECONDS), + ) + self._command_lock = asyncio.Lock() + self._store: Store[dict[str, Any]] = Store( + hass, + STORAGE_VERSION, + f"{STORAGE_KEY}_{entry.entry_id}", + ) + self.schedule = SternwarteSchedule() + self._wind_last_counter: int | None = None + self._wind_last_time: datetime | None = None + self._wind_next_measure_at: datetime | None = None + self._wind_history: deque[WindSample] = deque(maxlen=120) + self._pending_open_task: asyncio.Task | None = None + self._pending_close_task: asyncio.Task | None = None + self._roof_was_closed = False + + async def async_config_entry_first_refresh(self) -> None: + """Load persisted runtime state before first refresh.""" + await self._async_load_runtime_state() + await super().async_config_entry_first_refresh() + + async def _async_load_runtime_state(self) -> None: + stored = await self._store.async_load() + if not stored: + return + + self.schedule = SternwarteSchedule( + open_at=stored.get("open_at", ""), + close_at=stored.get("close_at", ""), + open_done=stored.get("open_done", False), + close_done=stored.get("close_done", False), + open_power_on_at=stored.get("open_power_on_at", ""), + close_power_on_at=stored.get("close_power_on_at", ""), + rain_close_power_on_at=stored.get("rain_close_power_on_at", ""), + close_power_off_at=stored.get("close_power_off_at", ""), + pending_auto_close=stored.get("pending_auto_close", False), + ) + + self._wind_last_counter = stored.get("wind_last_counter") + self._wind_last_time = self._parse_datetime(stored.get("wind_last_time", "")) + self._wind_next_measure_at = self._parse_datetime(stored.get("wind_next_measure_at", "")) + for item in stored.get("wind_history", []): + item_time = self._parse_datetime(item.get("time", "")) + if item_time is not None: + self._wind_history.append( + WindSample(time=item_time, kmh=float(item.get("kmh", 0.0))) + ) + + async def _async_save_runtime_state(self) -> None: + payload = asdict(self.schedule) + payload["wind_last_counter"] = self._wind_last_counter + payload["wind_last_time"] = self._wind_last_time.isoformat() if self._wind_last_time else "" + payload["wind_next_measure_at"] = ( + self._wind_next_measure_at.isoformat() if self._wind_next_measure_at else "" + ) + payload["wind_history"] = [ + {"time": sample.time.isoformat(), "kmh": sample.kmh} for sample in self._wind_history + ] + await self._store.async_save(payload) + + async def _async_update_data(self) -> SternwarteState: + """Fetch state from the device and process runtime logic.""" + state = await self.api.fetch_state() + state = await self._async_process_position_state(state) + now = dt_util.utcnow() + self._update_wind_state(state, now) + state = await self._async_process_schedule(state, now) + return state + + async def _async_safe_refresh(self) -> None: + try: + await self.async_request_refresh() + except Exception: + LOGGER.exception("Status-Refresh nach Web-IO-Befehl fehlgeschlagen") + + def _parse_datetime(self, value: str) -> datetime | None: + if not value: + return None + parsed = dt_util.parse_datetime(value) + if parsed is None: + return None + if parsed.tzinfo is None: + parsed = parsed.replace(tzinfo=dt_util.DEFAULT_TIME_ZONE) + return parsed.astimezone(UTC) + + def _format_schedule_datetime(self, value: datetime | None, *, truncate_seconds: bool = True) -> str: + if value is None: + return "" + local = dt_util.as_local(value) + if truncate_seconds: + local = local.replace(second=0, microsecond=0) + else: + local = local.replace(microsecond=0) + return local.isoformat() + + def _schedule_datetime(self, value: str) -> datetime | None: + return self._parse_datetime(value) + + def _normalize_user_schedule_datetime(self, value: datetime | None) -> datetime | None: + if value is None: + return None + return dt_util.as_local(value).replace(second=0, microsecond=0) + + @property + def roof_position_percent(self) -> int: + position = max( + 0, + self.data.counters[ROOF_COUNTER_OPEN_INDEX] - self.data.counters[ROOF_COUNTER_CLOSE_INDEX], + ) + return min(100, round(position / ROOF_POSITION_MAX * 100)) + + @property + def open_schedule_at(self) -> datetime | None: + return self._schedule_datetime(self.schedule.open_at) + + @property + def close_schedule_at(self) -> datetime | None: + return self._schedule_datetime(self.schedule.close_at) + + async def async_set_open_schedule(self, when: datetime | None) -> None: + """Persist open schedule.""" + when = self._normalize_user_schedule_datetime(when) + self.schedule.open_at = self._format_schedule_datetime(when) + self.schedule.open_done = when is None + self.schedule.open_power_on_at = "" + if when is not None: + close_at = self._normalize_user_schedule_datetime(self.close_schedule_at) + if close_at is not None and close_at < when + timedelta(minutes=10): + self.schedule.close_at = "" + self.schedule.close_done = False + self.schedule.close_power_on_at = "" + self.schedule.close_power_off_at = "" + self.schedule.pending_auto_close = False + await self._async_save_runtime_state() + await self.async_request_refresh() + + async def async_clear_schedule(self) -> None: + """Clear all schedule-related runtime state.""" + self.schedule = SternwarteSchedule() + await self._async_save_runtime_state() + await self.async_request_refresh() + + async def async_set_close_schedule(self, when: datetime | None) -> None: + """Persist close schedule.""" + when = self._normalize_user_schedule_datetime(when) + open_at = self._normalize_user_schedule_datetime(self._schedule_datetime(self.schedule.open_at)) + if when is not None and open_at is not None and when < open_at + timedelta(minutes=10): + minimum_close_at = open_at + timedelta(minutes=10) + LOGGER.warning( + "Schließzeitpunkt angepasst: open_at=%s requested_close_at=%s adjusted_close_at=%s", + open_at.isoformat(), + when.isoformat(), + minimum_close_at.isoformat(), + ) + when = minimum_close_at + + self.schedule.close_at = self._format_schedule_datetime(when) + self.schedule.close_done = when is None + self.schedule.close_power_on_at = "" + self.schedule.close_power_off_at = "" + self.schedule.pending_auto_close = False + await self._async_save_runtime_state() + await self.async_request_refresh() + + async def async_set_next_nautical_schedule(self) -> None: + """Set the next nautical dusk/dawn schedule.""" + now = dt_util.now() + next_schedule = self._get_next_nautical_schedule(now) + if next_schedule is None: + raise HomeAssistantError("Keine kommende nautische Dämmerung gefunden") + + self.schedule.open_at = self._format_schedule_datetime(next_schedule["open_at"]) + self.schedule.close_at = self._format_schedule_datetime(next_schedule["close_at"]) + self.schedule.open_done = False + self.schedule.close_done = False + self.schedule.open_power_on_at = "" + self.schedule.close_power_on_at = "" + self.schedule.rain_close_power_on_at = "" + self.schedule.close_power_off_at = "" + self.schedule.pending_auto_close = False + await self._async_save_runtime_state() + await self.async_request_refresh() + + async def async_turn_power_on(self) -> None: + """Turn roof power on.""" + async with self._command_lock: + await self.api.set_output(0, True) + await self._async_safe_refresh() + + async def async_turn_power_off(self) -> None: + """Turn roof power off.""" + async with self._command_lock: + self.schedule.open_power_on_at = "" + self.schedule.close_power_on_at = "" + self.schedule.rain_close_power_on_at = "" + self.schedule.close_power_off_at = "" + self.schedule.pending_auto_close = False + await self.api.set_output(0, False) + await self._async_save_runtime_state() + await self._async_safe_refresh() + + async def async_stop_roof(self) -> None: + """Stop roof motion.""" + async with self._command_lock: + self._cancel_pending_open_task() + self._cancel_pending_close_task() + await self.api.set_output(10, True) + await self.api.set_output(9, False) + await self.api.set_output(11, False) + await self._async_safe_refresh() + + async def async_open_roof(self) -> None: + """Open the roof with required power-on sequence.""" + async with self._command_lock: + if self._is_roof_open(self.data): + return + + if self.data.raining: + raise HomeAssistantError("Regen erkannt, Dach kann nicht geöffnet werden") + + if self.data.roof_power: + self._cancel_pending_close_task() + self._cancel_pending_open_task() + await self._async_open_roof_action(self.data) + else: + LOGGER.info("Manuelles Öffnen: Dachsteuerung wird eingeschaltet, Öffnen folgt in 30 Sekunden") + await self.api.set_output(0, True) + self._schedule_pending_open_task(POWER_ON_DELAY_SECONDS) + await self._async_safe_refresh() + + async def async_close_roof(self) -> None: + """Close the roof.""" + async with self._command_lock: + if self._is_roof_closed(self.data): + return + + self._cancel_pending_open_task() + self._cancel_pending_close_task() + if self.data.roof_power: + self._cancel_pending_close_task() + await self._async_close_roof_action(self.data) + else: + LOGGER.info("Manuelles Schließen: Dachsteuerung wird eingeschaltet, Schließen folgt in 30 Sekunden") + await self.api.set_output(0, True) + self._schedule_pending_close_task(POWER_ON_DELAY_SECONDS) + await self._async_safe_refresh() + + async def _async_open_roof_action(self, state: SternwarteState) -> None: + LOGGER.info( + "Öffnungsaktion gestartet: power=%s moving=%s open=%s closed=%s", + state.roof_power, + state.roof_moving, + state.roof_open, + state.roof_closed, + ) + if state.roof_open and not state.roof_closed: + return + + if state.raining: + raise HomeAssistantError("Regen erkannt, Dach kann nicht geöffnet werden") + + if not state.roof_power: + raise HomeAssistantError("Dachsteuerung ist ausgeschaltet") + + if state.roof_moving: + raise HomeAssistantError("Dach bewegt sich bereits") + + await self.api.set_output(9, True) + await self.api.set_output(10, False) + await self.api.set_output(11, False) + + async def _async_close_roof_action(self, state: SternwarteState) -> None: + if not state.roof_power: + raise HomeAssistantError("Dachsteuerung ist ausgeschaltet") + + await self.api.set_output(11, True) + await self.api.set_output(10, False) + await self.api.set_output(9, False) + + def _can_auto_open(self, state: SternwarteState) -> bool: + return not state.roof_moving and not state.roof_open and not state.raining + + def _can_auto_close(self, state: SternwarteState) -> bool: + return state.roof_power + + def _is_roof_open(self, state: SternwarteState) -> bool: + return state.roof_open and not state.roof_closed + + def _is_roof_closed(self, state: SternwarteState) -> bool: + return state.roof_closed and not state.roof_open + + def _cancel_pending_open_task(self) -> None: + if self._pending_open_task is not None and not self._pending_open_task.done(): + self._pending_open_task.cancel() + self._pending_open_task = None + + def _schedule_pending_open_task(self, delay_seconds: int) -> None: + self._cancel_pending_open_task() + self._pending_open_task = self.hass.async_create_task( + self._async_finish_pending_open(delay_seconds) + ) + + async def _async_finish_pending_open(self, delay_seconds: int) -> None: + try: + await asyncio.sleep(delay_seconds) + should_refresh = False + async with self._command_lock: + state = await self.api.fetch_state() + self._update_wind_state(state, dt_util.utcnow()) + LOGGER.info( + "Verzögertes Öffnen nach Einschalten: power=%s moving=%s open=%s closed=%s", + state.roof_power, + state.roof_moving, + state.roof_open, + state.roof_closed, + ) + if self._is_roof_open(state): + return + if state.raining: + LOGGER.warning("Verzögertes Öffnen abgebrochen: Regen erkannt") + return + await self._async_open_roof_action(state) + should_refresh = True + if should_refresh: + await self._async_safe_refresh() + except asyncio.CancelledError: + LOGGER.info("Verzögertes Öffnen wurde abgebrochen") + raise + except Exception: + LOGGER.exception("Verzögertes Öffnen nach Einschalten fehlgeschlagen") + finally: + self._pending_open_task = None + + def _cancel_pending_close_task(self) -> None: + if self._pending_close_task is not None and not self._pending_close_task.done(): + self._pending_close_task.cancel() + self._pending_close_task = None + + def _schedule_pending_close_task(self, delay_seconds: int) -> None: + self._cancel_pending_close_task() + self._pending_close_task = self.hass.async_create_task( + self._async_finish_pending_close(delay_seconds) + ) + + async def _async_finish_pending_close(self, delay_seconds: int) -> None: + try: + await asyncio.sleep(delay_seconds) + should_refresh = False + async with self._command_lock: + state = await self.api.fetch_state() + self._update_wind_state(state, dt_util.utcnow()) + LOGGER.info( + "Verzögertes Schließen nach Einschalten: power=%s moving=%s open=%s closed=%s", + state.roof_power, + state.roof_moving, + state.roof_open, + state.roof_closed, + ) + if self._is_roof_closed(state): + return + await self._async_close_roof_action(state) + should_refresh = True + if should_refresh: + await self._async_safe_refresh() + except asyncio.CancelledError: + LOGGER.info("Verzögertes Schließen wurde abgebrochen") + raise + except Exception: + LOGGER.exception("Verzögertes Schließen nach Einschalten fehlgeschlagen") + finally: + self._pending_close_task = None + + async def _async_process_position_state(self, state: SternwarteState) -> SternwarteState: + if state.roof_closed and not self._roof_was_closed: + LOGGER.info("Dach geschlossen erkannt, Positionszähler werden zurückgesetzt") + await self.api.clear_counter(ROOF_COUNTER_CLOSE_INDEX) + await self.api.clear_counter(ROOF_COUNTER_OPEN_INDEX) + state.counters[ROOF_COUNTER_CLOSE_INDEX] = 0 + state.counters[ROOF_COUNTER_OPEN_INDEX] = 0 + self._roof_was_closed = state.roof_closed + return state + + async def _async_process_schedule( + self, + state: SternwarteState, + now: datetime, + ) -> SternwarteState: + changed = False + must_refetch = False + + async with self._command_lock: + rain_close_power_on_at = self._schedule_datetime(self.schedule.rain_close_power_on_at) + rain_close_pending = state.raining or rain_close_power_on_at is not None + if rain_close_pending: + self._cancel_pending_open_task() + if self.schedule.open_power_on_at: + self.schedule.open_power_on_at = "" + changed = True + + if self._is_roof_closed(state): + if self.schedule.rain_close_power_on_at: + LOGGER.info("Regenschließen erledigt, Dach ist bereits geschlossen") + self.schedule.rain_close_power_on_at = "" + changed = True + elif rain_close_power_on_at is not None and now >= rain_close_power_on_at: + if not state.roof_power: + LOGGER.warning( + "Regenschließen wartet weiter: Dachsteuerung nach Einschaltverzögerung noch aus" + ) + await self.api.set_output(0, True) + state.outputs[0] = True + self.schedule.rain_close_power_on_at = self._format_schedule_datetime( + dt_util.as_local(now) + timedelta(seconds=POWER_ON_DELAY_SECONDS), + truncate_seconds=False, + ) + must_refetch = True + changed = True + elif self._can_auto_close(state): + LOGGER.warning("Regen erkannt, Dach wird nach Einschaltverzögerung geschlossen") + await self._async_close_roof_action(state) + self.schedule.rain_close_power_on_at = "" + self.schedule.pending_auto_close = True + self.schedule.close_power_off_at = "" + must_refetch = True + changed = True + elif rain_close_power_on_at is None: + if not state.roof_power: + LOGGER.warning("Regen erkannt, Dachsteuerung wird eingeschaltet, Schließen folgt in 30 Sekunden") + await self.api.set_output(0, True) + state.outputs[0] = True + self.schedule.rain_close_power_on_at = self._format_schedule_datetime( + dt_util.as_local(now) + timedelta(seconds=POWER_ON_DELAY_SECONDS), + truncate_seconds=False, + ) + must_refetch = True + changed = True + elif self._can_auto_close(state): + LOGGER.warning("Regen erkannt, Dach wird sofort geschlossen") + await self._async_close_roof_action(state) + self.schedule.pending_auto_close = True + self.schedule.close_power_off_at = "" + must_refetch = True + changed = True + + if self.schedule.pending_auto_close and state.roof_closed and not self.schedule.close_power_off_at: + self.schedule.close_power_off_at = self._format_schedule_datetime( + dt_util.as_local(now) + timedelta(seconds=POWER_ON_DELAY_SECONDS), + truncate_seconds=False, + ) + changed = True + + close_power_off_at = self._schedule_datetime(self.schedule.close_power_off_at) + if close_power_off_at is not None and now >= close_power_off_at: + if state.roof_power: + await self.api.set_output(0, False) + state.outputs[0] = False + must_refetch = True + self.schedule.close_power_off_at = "" + self.schedule.pending_auto_close = False + changed = True + + close_power_on_at = self._schedule_datetime(self.schedule.close_power_on_at) + if close_power_on_at is not None and not self.schedule.close_done and now >= close_power_on_at: + if self._is_roof_closed(state): + LOGGER.info("Automatisches Schließen bereits erfüllt") + self.schedule.close_done = True + self.schedule.close_power_on_at = "" + self.schedule.pending_auto_close = False + self.schedule.close_power_off_at = "" + changed = True + elif not state.roof_power: + LOGGER.warning( + "Automatisches Schließen wartet weiter: Dachsteuerung nach Einschaltverzögerung noch aus" + ) + await self.api.set_output(0, True) + state.outputs[0] = True + self.schedule.close_power_on_at = self._format_schedule_datetime( + dt_util.as_local(now) + timedelta(seconds=POWER_ON_DELAY_SECONDS), + truncate_seconds=False, + ) + must_refetch = True + changed = True + elif self._can_auto_close(state): + LOGGER.info("Automatisches Schließen wird nach Einschaltverzögerung ausgeführt") + await self._async_close_roof_action(state) + self.schedule.close_done = True + self.schedule.close_power_on_at = "" + self.schedule.pending_auto_close = True + self.schedule.close_power_off_at = "" + must_refetch = True + changed = True + else: + LOGGER.warning( + "Automatisches Schließen noch blockiert: power=%s moving=%s open=%s closed=%s", + state.roof_power, + state.roof_moving, + state.roof_open, + state.roof_closed, + ) + refetched_state = await self.api.fetch_state() + self._update_wind_state(refetched_state, now) + if refetched_state.roof_power and self._can_auto_close(refetched_state): + LOGGER.info("Automatisches Schließen wird nach Zusatzprüfung ausgeführt") + await self._async_close_roof_action(refetched_state) + self.schedule.close_done = True + self.schedule.close_power_on_at = "" + self.schedule.pending_auto_close = True + self.schedule.close_power_off_at = "" + state = refetched_state + must_refetch = True + changed = True + else: + LOGGER.warning( + "Automatisches Schließen weiter blockiert nach Zusatzprüfung: power=%s moving=%s open=%s closed=%s", + refetched_state.roof_power, + refetched_state.roof_moving, + refetched_state.roof_open, + refetched_state.roof_closed, + ) + self.schedule.close_power_on_at = self._format_schedule_datetime( + dt_util.as_local(now) + timedelta(seconds=5), + truncate_seconds=False, + ) + changed = True + + open_power_on_at = self._schedule_datetime(self.schedule.open_power_on_at) + if open_power_on_at is not None and not self.schedule.open_done and now >= open_power_on_at: + if self._is_roof_open(state): + LOGGER.info("Automatisches Öffnen bereits erfüllt") + self.schedule.open_done = True + self.schedule.open_power_on_at = "" + changed = True + elif not state.roof_power: + LOGGER.warning( + "Automatisches Öffnen wartet weiter: Dachsteuerung nach Einschaltverzögerung noch aus" + ) + await self.api.set_output(0, True) + state.outputs[0] = True + self.schedule.open_power_on_at = self._format_schedule_datetime( + dt_util.as_local(now) + timedelta(seconds=POWER_ON_DELAY_SECONDS), + truncate_seconds=False, + ) + must_refetch = True + changed = True + elif self._can_auto_open(state): + LOGGER.info("Automatisches Öffnen wird nach Einschaltverzögerung ausgeführt") + await self._async_open_roof_action(state) + self.schedule.open_done = True + self.schedule.open_power_on_at = "" + must_refetch = True + changed = True + elif state.raining: + LOGGER.warning("Automatisches Öffnen blockiert: Regen erkannt") + else: + LOGGER.warning( + "Automatisches Öffnen noch blockiert: power=%s moving=%s open=%s closed=%s", + state.roof_power, + state.roof_moving, + state.roof_open, + state.roof_closed, + ) + refetched_state = await self.api.fetch_state() + self._update_wind_state(refetched_state, now) + if refetched_state.roof_power and self._can_auto_open(refetched_state): + LOGGER.info("Automatisches Öffnen wird nach Zusatzprüfung ausgeführt") + await self._async_open_roof_action(refetched_state) + self.schedule.open_done = True + self.schedule.open_power_on_at = "" + state = refetched_state + must_refetch = True + changed = True + else: + LOGGER.warning( + "Automatisches Öffnen weiter blockiert nach Zusatzprüfung: power=%s moving=%s open=%s closed=%s", + refetched_state.roof_power, + refetched_state.roof_moving, + refetched_state.roof_open, + refetched_state.roof_closed, + ) + self.schedule.open_power_on_at = self._format_schedule_datetime( + dt_util.as_local(now) + timedelta(seconds=5), + truncate_seconds=False, + ) + changed = True + + open_at = self._schedule_datetime(self.schedule.open_at) + if open_at is not None and not self.schedule.open_done and now >= open_at and not self.schedule.open_power_on_at: + if self._is_roof_open(state): + LOGGER.info("Automatisches Öffnen übersprungen, Dach ist bereits offen") + self.schedule.open_done = True + changed = True + elif state.roof_power and self._can_auto_open(state): + LOGGER.info("Automatisches Öffnen wird ausgeführt") + await self._async_open_roof_action(state) + self.schedule.open_done = True + must_refetch = True + changed = True + elif state.raining: + LOGGER.warning("Automatisches Öffnen blockiert: Regen erkannt") + elif not state.roof_power: + LOGGER.info("Automatisches Öffnen schaltet zuerst die Dachsteuerung ein") + await self.api.set_output(0, True) + state.outputs[0] = True + self.schedule.open_power_on_at = self._format_schedule_datetime( + dt_util.as_local(now) + timedelta(seconds=POWER_ON_DELAY_SECONDS), + truncate_seconds=False, + ) + must_refetch = True + changed = True + else: + LOGGER.info( + "Automatisches Öffnen verschoben: moving=%s open=%s closed=%s", + state.roof_moving, + state.roof_open, + state.roof_closed, + ) + + close_at = self._schedule_datetime(self.schedule.close_at) + if ( + close_at is not None + and not self.schedule.close_done + and now >= close_at + and not self.schedule.close_power_on_at + ): + if self._is_roof_closed(state): + LOGGER.info("Automatisches Schließen übersprungen, Dach ist bereits geschlossen") + self.schedule.close_done = True + self.schedule.close_power_on_at = "" + self.schedule.pending_auto_close = False + self.schedule.close_power_off_at = "" + changed = True + elif self._can_auto_close(state): + LOGGER.info("Automatisches Schließen wird ausgeführt") + await self._async_close_roof_action(state) + self.schedule.close_done = True + self.schedule.close_power_on_at = "" + self.schedule.pending_auto_close = True + self.schedule.close_power_off_at = "" + must_refetch = True + changed = True + elif not state.roof_power: + LOGGER.info("Automatisches Schließen schaltet zuerst die Dachsteuerung ein") + await self.api.set_output(0, True) + state.outputs[0] = True + self.schedule.close_power_on_at = self._format_schedule_datetime( + dt_util.as_local(now) + timedelta(seconds=POWER_ON_DELAY_SECONDS), + truncate_seconds=False, + ) + must_refetch = True + changed = True + else: + LOGGER.warning( + "Automatisches Schließen konnte nicht ausgeführt werden: power=%s moving=%s open=%s closed=%s", + state.roof_power, + state.roof_moving, + state.roof_open, + state.roof_closed, + ) + + if changed: + await self._async_save_runtime_state() + if must_refetch: + state = await self.api.fetch_state() + self._update_wind_state(state, now) + return state + + def _update_wind_state(self, state: SternwarteState, now: datetime) -> None: + counter = state.counters[WIND_COUNTER_INDEX] + if self._wind_next_measure_at is None: + self._wind_next_measure_at = now + timedelta(seconds=WIND_INTERVAL_SECONDS) + self._wind_last_counter = counter + self._wind_last_time = now + + if ( + self._wind_last_counter is not None + and self._wind_last_time is not None + and self._wind_next_measure_at is not None + and now >= self._wind_next_measure_at + ): + delta = counter - self._wind_last_counter + elapsed = max((now - self._wind_last_time).total_seconds(), 0.001) + if delta >= 0: + kmh = (delta / elapsed) * WIND_FACTOR + self._wind_history.append(WindSample(time=now, kmh=round(kmh, 1))) + self._wind_last_counter = counter + self._wind_last_time = now + self._wind_next_measure_at = now + timedelta(seconds=WIND_INTERVAL_SECONDS) + + if self._wind_history: + values = [sample.kmh for sample in self._wind_history] + state.wind_speed_kmh = round(sum(values[-3:]) / min(len(values), 3), 1) + + def _calc_twilight(self, date: datetime) -> dict[str, dict[str, float] | None]: + jd = date.timestamp() / 86400 + 2440587.5 + n = jd - 2451545.0 + + l = (280.460 + 0.9856474 * n) % 360 + g = (357.528 + 0.9856003 * n) % 360 + g_r = g * pi / 180 + + lam = l + 1.915 * sin(g_r) + 0.020 * sin(2 * g_r) + lam_r = lam * pi / 180 + + eps = 23.439 - 0.0000004 * n + eps_r = eps * pi / 180 + dec = asin(sin(eps_r) * sin(lam_r)) + + ra = atan2(cos(eps_r) * sin(lam_r), cos(lam_r)) * 180 / pi / 15 + gmst0h = ((l + 180) % 360) / 15 + solar_noon = (12 - self.hass.config.longitude / 15 - ((ra - gmst0h + 12 + 720) % 24) + 24) % 24 + lat_r = self.hass.config.latitude * pi / 180 + + def event_utc(dep_deg: float) -> dict[str, float] | None: + cos_h = ( + sin(-dep_deg * pi / 180) - sin(lat_r) * sin(dec) + ) / (cos(lat_r) * cos(dec)) + if cos_h < -1 or cos_h > 1: + return None + h = acos(cos_h) * 180 / pi / 15 + return {"dawn": (solar_noon - h + 24) % 24, "dusk": (solar_noon + h) % 24} + + return { + "sun": event_utc(0.833), + "civil": event_utc(6), + "naut": event_utc(12), + "astro": event_utc(18), + } + + def _event_to_local_datetime(self, base_date: datetime, utc_hour: float | None) -> datetime | None: + if utc_hour is None: + return None + hour = floor(utc_hour) + minute = floor((utc_hour - hour) * 60) + second = floor((((utc_hour - hour) * 60) - minute) * 60) + event_utc = datetime( + base_date.year, + base_date.month, + base_date.day, + hour, + minute, + second, + tzinfo=UTC, + ) + return dt_util.as_local(event_utc).replace(second=0, microsecond=0) + + def _get_next_nautical_schedule(self, now: datetime) -> dict[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 + + dusk = self._event_to_local_datetime(base_date, twilight["naut"]["dusk"]) + dawn = self._event_to_local_datetime(base_date, twilight["naut"]["dawn"]) + if dusk and dusk > now: + close_date = None + for close_offset in range(day_offset, day_offset + 3): + close_base = now + timedelta(days=close_offset) + close_twilight = self._calc_twilight(close_base) + if close_twilight["naut"] is None: + continue + candidate = self._event_to_local_datetime(close_base, close_twilight["naut"]["dawn"]) + if candidate and candidate > dusk: + close_date = candidate + break + if close_date is not None: + return {"open_at": dusk, "close_at": close_date} + + if day_offset == 0 and dawn and dawn > now: + return None + return None diff --git a/sternwarte-homeassistant-integration/custom_components/sternwarte/cover.py b/sternwarte-homeassistant-integration/custom_components/sternwarte/cover.py new file mode 100644 index 0000000..37e6c76 --- /dev/null +++ b/sternwarte-homeassistant-integration/custom_components/sternwarte/cover.py @@ -0,0 +1,62 @@ +"""Cover platform for Sternwarte.""" + +from __future__ import annotations + +from homeassistant.components.cover import CoverEntity, CoverEntityFeature +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .const import DOMAIN, ENTITY_ROOF_COVER +from .entity import SternwarteEntity + + +async def async_setup_entry( + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up Sternwarte cover.""" + coordinator = hass.data[DOMAIN][entry.entry_id] + async_add_entities([SternwarteRoofCover(coordinator)]) + + +class SternwarteRoofCover(SternwarteEntity, CoverEntity): + """Representation of the observatory roof.""" + + _attr_name = "Dach" + _attr_supported_features = ( + CoverEntityFeature.OPEN | CoverEntityFeature.CLOSE | CoverEntityFeature.STOP + ) + + def __init__(self, coordinator): + super().__init__(coordinator, ENTITY_ROOF_COVER) + + @property + def is_closed(self) -> bool | None: + if self.coordinator.data.roof_closed and not self.coordinator.data.roof_open: + return True + if self.coordinator.data.roof_open and not self.coordinator.data.roof_closed: + return False + return None + + @property + def is_opening(self) -> bool: + return self.coordinator.data.roof_moving and not self.coordinator.data.roof_open + + @property + def is_closing(self) -> bool: + return self.coordinator.data.roof_moving and not self.coordinator.data.roof_closed + + @property + def current_cover_position(self) -> int | None: + return self.coordinator.roof_position_percent + + async def async_open_cover(self, **kwargs) -> None: + await self.coordinator.async_open_roof() + + async def async_close_cover(self, **kwargs) -> None: + await self.coordinator.async_close_roof() + + async def async_stop_cover(self, **kwargs) -> None: + await self.coordinator.async_stop_roof() diff --git a/sternwarte-homeassistant-integration/custom_components/sternwarte/datetime.py b/sternwarte-homeassistant-integration/custom_components/sternwarte/datetime.py new file mode 100644 index 0000000..1bff471 --- /dev/null +++ b/sternwarte-homeassistant-integration/custom_components/sternwarte/datetime.py @@ -0,0 +1,74 @@ +"""Datetime entities for Sternwarte schedules.""" + +from __future__ import annotations + +from datetime import datetime + +from homeassistant.components.datetime import DateTimeEntity +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.util import dt as dt_util + +from .const import DOMAIN, ENTITY_SCHEDULE_CLOSE, ENTITY_SCHEDULE_OPEN +from .entity import SternwarteEntity + + +async def async_setup_entry( + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up Sternwarte datetime entities.""" + coordinator = hass.data[DOMAIN][entry.entry_id] + async_add_entities( + [ + SternwarteOpenScheduleEntity(coordinator), + SternwarteCloseScheduleEntity(coordinator), + ] + ) + + +class SternwarteScheduleEntity(SternwarteEntity, DateTimeEntity): + """Base datetime entity for schedules.""" + + _attr_icon = "mdi:calendar-clock" + + async def _async_set_schedule(self, value: datetime | None) -> None: + raise NotImplementedError + + async def async_set_value(self, value: datetime) -> None: + if value.tzinfo is None: + value = value.replace(tzinfo=dt_util.DEFAULT_TIME_ZONE) + await self._async_set_schedule(value) + + +class SternwarteOpenScheduleEntity(SternwarteScheduleEntity): + _attr_name = "Öffnen geplant" + + def __init__(self, coordinator): + super().__init__(coordinator, ENTITY_SCHEDULE_OPEN) + + @property + def native_value(self) -> datetime | None: + value = self.coordinator.open_schedule_at + return dt_util.as_local(value) if value is not None else None + + async def _async_set_schedule(self, value: datetime | None) -> None: + await self.coordinator.async_set_open_schedule(value) + + +class SternwarteCloseScheduleEntity(SternwarteScheduleEntity): + _attr_name = "Schließen geplant" + + def __init__(self, coordinator): + super().__init__(coordinator, ENTITY_SCHEDULE_CLOSE) + + @property + def native_value(self) -> datetime | None: + value = self.coordinator.close_schedule_at + return dt_util.as_local(value) if value is not None else None + + async def _async_set_schedule(self, value: datetime | None) -> None: + await self.coordinator.async_set_close_schedule(value) + diff --git a/sternwarte-homeassistant-integration/custom_components/sternwarte/entity.py b/sternwarte-homeassistant-integration/custom_components/sternwarte/entity.py new file mode 100644 index 0000000..772dd37 --- /dev/null +++ b/sternwarte-homeassistant-integration/custom_components/sternwarte/entity.py @@ -0,0 +1,31 @@ +"""Base entity helpers for Sternwarte.""" + +from __future__ import annotations + +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .coordinator import SternwarteDataUpdateCoordinator +from .const import DOMAIN + + +class SternwarteEntity(CoordinatorEntity[SternwarteDataUpdateCoordinator]): + """Base Sternwarte entity.""" + + _attr_has_entity_name = True + + def __init__(self, coordinator: SternwarteDataUpdateCoordinator, key: str) -> None: + super().__init__(coordinator) + self._attr_unique_id = f"{coordinator.config_entry.entry_id}_{key}" + + @property + def device_info(self) -> DeviceInfo: + """Return device information.""" + host = self.coordinator.config_entry.data["host"] + return DeviceInfo( + identifiers={(DOMAIN, self.coordinator.config_entry.entry_id)}, + name="Sternwarte", + manufacturer="W&T", + model="Web-IO 57630", + configuration_url=f"http://{host}", + ) diff --git a/sternwarte-homeassistant-integration/custom_components/sternwarte/manifest.json b/sternwarte-homeassistant-integration/custom_components/sternwarte/manifest.json new file mode 100644 index 0000000..a73802a --- /dev/null +++ b/sternwarte-homeassistant-integration/custom_components/sternwarte/manifest.json @@ -0,0 +1,11 @@ +{ + "domain": "sternwarte", + "name": "Sternwarte", + "version": "1.0.0", + "dependencies": [], + "codeowners": [], + "requirements": [], + "iot_class": "local_polling", + "integration_type": "device", + "config_flow": true +} diff --git a/sternwarte-homeassistant-integration/custom_components/sternwarte/models.py b/sternwarte-homeassistant-integration/custom_components/sternwarte/models.py new file mode 100644 index 0000000..8566b23 --- /dev/null +++ b/sternwarte-homeassistant-integration/custom_components/sternwarte/models.py @@ -0,0 +1,59 @@ +"""Shared data models for Sternwarte.""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from datetime import datetime + + +@dataclass(slots=True) +class SternwarteState: + """Parsed state from the Web-IO.""" + + inputs: list[bool] = field(default_factory=lambda: [False] * 12) + outputs: list[bool] = field(default_factory=lambda: [False] * 12) + counters: list[int] = field(default_factory=lambda: [0] * 12) + wind_speed_kmh: float = 0.0 + + @property + def roof_closed(self) -> bool: + return self.inputs[10] + + @property + def roof_open(self) -> bool: + return self.inputs[11] + + @property + def roof_moving(self) -> bool: + return self.inputs[9] + + @property + def raining(self) -> bool: + return self.inputs[7] + + @property + def roof_power(self) -> bool: + return self.outputs[0] + + +@dataclass(slots=True) +class SternwarteSchedule: + """Persistent schedule state.""" + + open_at: str = "" + close_at: str = "" + open_done: bool = False + close_done: bool = False + open_power_on_at: str = "" + close_power_on_at: str = "" + rain_close_power_on_at: str = "" + close_power_off_at: str = "" + pending_auto_close: bool = False + + +@dataclass(slots=True) +class WindSample: + """Single wind measurement.""" + + time: datetime + kmh: float diff --git a/sternwarte-homeassistant-integration/custom_components/sternwarte/sensor.py b/sternwarte-homeassistant-integration/custom_components/sternwarte/sensor.py new file mode 100644 index 0000000..34e1511 --- /dev/null +++ b/sternwarte-homeassistant-integration/custom_components/sternwarte/sensor.py @@ -0,0 +1,86 @@ +"""Sensors for Sternwarte.""" + +from __future__ import annotations + +from homeassistant.components.sensor import SensorDeviceClass, SensorEntity, SensorStateClass +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import PERCENTAGE, UnitOfSpeed +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .const import DOMAIN, ENTITY_ROOF_POSITION, ENTITY_ROOF_STATUS, ENTITY_WIND_SPEED +from .entity import SternwarteEntity + + +async def async_setup_entry( + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up Sternwarte sensors.""" + coordinator = hass.data[DOMAIN][entry.entry_id] + async_add_entities( + [ + SternwarteRoofStatusSensor(coordinator), + SternwarteRoofPositionSensor(coordinator), + SternwarteWindSpeedSensor(coordinator), + ] + ) + + +class SternwarteWindBaseSensor(SternwarteEntity, SensorEntity): + """Base wind sensor.""" + + _attr_device_class = SensorDeviceClass.WIND_SPEED + _attr_native_unit_of_measurement = UnitOfSpeed.KILOMETERS_PER_HOUR + _attr_state_class = SensorStateClass.MEASUREMENT + + +class SternwarteRoofPositionSensor(SternwarteEntity, SensorEntity): + """Roof position in percent.""" + + _attr_name = "Dachposition" + _attr_native_unit_of_measurement = PERCENTAGE + _attr_state_class = SensorStateClass.MEASUREMENT + _attr_icon = "mdi:garage-variant" + + def __init__(self, coordinator): + super().__init__(coordinator, ENTITY_ROOF_POSITION) + + @property + def native_value(self) -> int: + return self.coordinator.roof_position_percent + + +class SternwarteRoofStatusSensor(SternwarteEntity, SensorEntity): + """Roof status as text.""" + + _attr_name = "Dachstatus" + _attr_icon = "mdi:garage" + + def __init__(self, coordinator): + super().__init__(coordinator, ENTITY_ROOF_STATUS) + + @property + def native_value(self) -> str: + state = self.coordinator.data + if state.roof_moving: + return "in_bewegung" + if state.roof_open and not state.roof_closed: + return "offen" + if state.roof_closed and not state.roof_open: + return "geschlossen" + if state.roof_open and state.roof_closed: + return "sensorfehler" + return "unbekannt" + + +class SternwarteWindSpeedSensor(SternwarteWindBaseSensor): + _attr_name = "Windgeschwindigkeit" + + def __init__(self, coordinator): + super().__init__(coordinator, ENTITY_WIND_SPEED) + + @property + def native_value(self) -> float: + return round(self.coordinator.data.wind_speed_kmh, 1) diff --git a/sternwarte-homeassistant-integration/custom_components/sternwarte/services.yaml b/sternwarte-homeassistant-integration/custom_components/sternwarte/services.yaml new file mode 100644 index 0000000..69ab79c --- /dev/null +++ b/sternwarte-homeassistant-integration/custom_components/sternwarte/services.yaml @@ -0,0 +1,27 @@ +open_roof: + name: Dach öffnen + description: Öffnet das Dach der Sternwarte. + +close_roof: + name: Dach schließen + description: Schließt das Dach der Sternwarte. + +stop_roof: + name: Dach stoppen + description: Stoppt die Dachbewegung. + +power_on: + name: Dachsteuerung einschalten + description: Schaltet die Dachsteuerung ein. + +power_off: + name: Dachsteuerung ausschalten + description: Schaltet die Dachsteuerung aus. + +clear_schedule: + name: Zeitplanung löschen + description: Löscht die geplanten Öffnen- und Schließen-Termine. + +set_nautical_schedule: + name: Nautische Dämmerung setzen + description: Setzt Öffnen und Schließen auf die nächste nautische Abend- und Morgendämmerung. diff --git a/sternwarte-homeassistant-integration/custom_components/sternwarte/switch.py b/sternwarte-homeassistant-integration/custom_components/sternwarte/switch.py new file mode 100644 index 0000000..ca4251f --- /dev/null +++ b/sternwarte-homeassistant-integration/custom_components/sternwarte/switch.py @@ -0,0 +1,40 @@ +"""Switch platform for Sternwarte.""" + +from __future__ import annotations + +from homeassistant.components.switch import SwitchEntity +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .const import DOMAIN, ENTITY_ROOF_POWER +from .entity import SternwarteEntity + + +async def async_setup_entry( + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up Sternwarte switches.""" + coordinator = hass.data[DOMAIN][entry.entry_id] + async_add_entities([SternwarteRoofPowerSwitch(coordinator)]) + + +class SternwarteRoofPowerSwitch(SternwarteEntity, SwitchEntity): + """Roof power switch.""" + + _attr_name = "Dachsteuerung" + + def __init__(self, coordinator): + super().__init__(coordinator, ENTITY_ROOF_POWER) + + @property + def is_on(self) -> bool: + return self.coordinator.data.roof_power + + async def async_turn_on(self, **kwargs) -> None: + await self.coordinator.async_turn_power_on() + + async def async_turn_off(self, **kwargs) -> None: + await self.coordinator.async_turn_power_off() diff --git a/sternwarte-homeassistant-integration/hacs.json b/sternwarte-homeassistant-integration/hacs.json new file mode 100644 index 0000000..726539f --- /dev/null +++ b/sternwarte-homeassistant-integration/hacs.json @@ -0,0 +1,4 @@ +{ + "name": "Sternwarte Integration", + "render_readme": true +}