Compare commits

..
10 Commits
Author SHA1 Message Date
Eskimue 7418941cd5 Verbessert 2026-07-26 09:10:53 +02:00
Eskimue a6825e1685 Add gitignore and stop tracking Python bytecode 2026-07-26 08:58:24 +02:00
Eskimue ed0cff2010 Gui Fixes 2026-05-19 08:50:19 +02:00
Eskimue 6c2b42784a Readme angepasst 2026-05-19 08:41:36 +02:00
Eskimue 826b386296 Dämmerungsgrafik gefixt 2026-05-19 08:38:34 +02:00
Eskimue e57d53b61a GUI verbessert 2026-05-14 08:55:46 +02:00
Eskimue 2d8ec835c6 Eingänge werden angezeigt 2026-05-14 07:34:59 +02:00
Eskimue 698dc0aa82 Regen jetzt klickbar 2026-05-13 20:03:01 +02:00
Eskimue b497820c30 Retro Crad erstellt, muss aber deutlishc verbessert werden 2026-05-09 10:19:15 +02:00
Eskimue ebf1d849f0 GUI verbessert 2026-05-09 09:54:16 +02:00
13 changed files with 2087 additions and 112 deletions
+32
View File
@@ -0,0 +1,32 @@
# Python
__pycache__/
*.py[cod]
*$py.class
# Virtuelle Umgebungen
.venv/
venv/
env/
ENV/
# Test- und Tool-Caches
.pytest_cache/
.mypy_cache/
.ruff_cache/
.tox/
.nox/
# Build-Artefakte
build/
*.egg-info/
# Lokale Projekt- und Editor-Dateien
.claude/
.vscode/
.idea/
*.swp
*.swo
# Betriebssystem-Dateien
.DS_Store
Thumbs.db
+34
View File
@@ -22,3 +22,37 @@ Wenn ihr das später wirklich über HACS verwenden wollt, legt ihr für beide Or
## Hinweis ## 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. 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.
## Test-Home-Assistant
Für Tests gibt es eine komplette Test-Home-Assistant-Umgebung mit SMB-Zugriff unter `\\192.168.1.71`.
Über diese SMB-Freigabe besteht Zugriff auf die gesamte Test-Home-Assistant-Instanz. Für die Lovelace-Card ist dort insbesondere der Ordner `\\192.168.1.71\config\www` relevant.
- SMB-Ziel: `\\192.168.1.71`
- Card-Ordner: `\\192.168.1.71\config\www`
- Benutzer: `Eskimue`
- Passwort: `Preside`
In dieser Freigabe liegen die Card-Dateien, die Home Assistant direkt als Dashboard-Ressourcen lädt:
- `\\192.168.1.71\config\www\sternwarte-card.js`
- `\\192.168.1.71\config\www\sternwarte-card-retro.js`
- `\\192.168.1.71\config\www\astronomy.browser.min.js`
- `\\192.168.1.71\config\www\moon_small.png`
Arbeitsweise:
- Änderungen an der Card werden nach dem Anpassen direkt auf diese Test-Instanz kopiert.
- Relevant sind dafür in der Regel die Dateien aus `sternwarte-homeassistant-card/dist/`.
- Nach dem Kopieren muss das Home-Assistant-Frontend hart neu geladen werden, damit kein alter Browser-Cache im Weg ist.
- Für das Kopieren gibt es im Repo-Root den Helfer `deploy-test.ps1`.
- Aufruf aus dem Repo-Root: `.\deploy-test.ps1`
Wichtige Besonderheiten der Sternwarten-Card:
- Der Dämmerungsbalken arbeitet auf einer lokalen `12:00 → 12:00`-Achse.
- Die Dämmerungsphasen werden direkt aus der Sonnenhöhe bestimmt, nicht nur aus einzelnen Dawn-/Dusk-Zeitpunkten.
- Das ist wichtig für Sommerfälle ohne astronomische Nacht.
- Die Marker `Öffnen geplant` und `Schließen geplant` werden für diese Achse nach lokaler Uhrzeit eingeordnet.
- Wenn diese Marker fehlen, ist die erste Prüffrage: Wurde versehentlich wieder gegen ein echtes Datumsfenster statt gegen die `12 → 12`-Achse gerechnet?
+45
View File
@@ -0,0 +1,45 @@
$ErrorActionPreference = "Stop"
$repoRoot = Split-Path -Parent $MyInvocation.MyCommand.Path
$sourceDir = Join-Path $repoRoot "sternwarte-homeassistant-card\dist"
$targetDir = "\\192.168.1.71\config\www"
$files = @(
"sternwarte-card.js",
"sternwarte-card-retro.js",
"astronomy.browser.min.js",
"moon_small.png"
)
if (-not (Test-Path -LiteralPath $sourceDir)) {
throw "Quellordner nicht gefunden: $sourceDir"
}
if (-not (Test-Path -LiteralPath $targetDir)) {
throw "Zielordner nicht erreichbar: $targetDir"
}
Write-Host "Kopiere Dateien nach $targetDir ..." -ForegroundColor Cyan
foreach ($file in $files) {
$sourcePath = Join-Path $sourceDir $file
$targetPath = Join-Path $targetDir $file
if (-not (Test-Path -LiteralPath $sourcePath)) {
throw "Quelldatei nicht gefunden: $sourcePath"
}
Copy-Item -LiteralPath $sourcePath -Destination $targetPath -Force
$sourceHash = (Get-FileHash -LiteralPath $sourcePath -Algorithm SHA256).Hash
$targetHash = (Get-FileHash -LiteralPath $targetPath -Algorithm SHA256).Hash
if ($sourceHash -ne $targetHash) {
throw "Hash-Prüfung fehlgeschlagen für $file"
}
Write-Host "OK $file" -ForegroundColor Green
}
Write-Host ""
Write-Host "Deploy abgeschlossen." -ForegroundColor Green
Write-Host "Bitte das Home-Assistant-Frontend hart neu laden." -ForegroundColor Yellow
+22
View File
@@ -12,6 +12,28 @@ dist/
`sternwarte-card.js` lädt `astronomy.browser.min.js` automatisch nach, um Dämmerungszeiten und Mondposition präziser zu berechnen. `sternwarte-card.js` lädt `astronomy.browser.min.js` automatisch nach, um Dämmerungszeiten und Mondposition präziser zu berechnen.
## Dämmerungsbalken
Der Dämmerungsbalken in `dist/sternwarte-card.js` arbeitet bewusst nicht nur mit einzelnen Dawn-/Dusk-Zeitpunkten, sondern mit einem lokalen `12:00 → 12:00`-Fenster.
Wichtige Punkte:
- Die Phasen `Tag`, `bürgerliche Dämmerung`, `nautische Dämmerung`, `astronomische Dämmerung` und `Nacht` werden direkt aus der Sonnenhöhe bestimmt.
- Dafür wird das komplette lokale 24-Stunden-Fenster in kleinen Schritten durchgerechnet.
- Diese Logik ist robuster für Sommerfälle, in denen es keine astronomische Nacht mehr gibt.
Marker im Balken:
- `Öffnen geplant` wird grün dargestellt.
- `Schließen geplant` wird rot dargestellt.
- Der Marker `Jetzt` ist bewusst hell/weiß, damit er nicht mit `Schließen` verwechselt wird.
Wichtig für spätere Änderungen:
- Die Balkenachse ist eine Uhrzeitachse `12 → 12`, keine reine Datumsachse.
- Die Marker für `Öffnen geplant` und `Schließen geplant` müssen daher anhand der lokalen Uhrzeit auf diese Achse gelegt werden, nicht anhand eines festen Datumsfensters.
- Wenn die Marker verschwinden, zuerst prüfen, ob versehentlich wieder mit einem echten Zeitfenster statt mit der `12 → 12`-Achse gerechnet wird.
## Für HACS ## Für HACS
Dieser Ordner ist als Root eines späteren HACS-Dashboard-Repositorys vorbereitet. Dieser Ordner ist als Root eines späteren HACS-Dashboard-Repositorys vorbereitet.
File diff suppressed because it is too large Load Diff
+328 -112
View File
@@ -70,14 +70,14 @@ class SternwarteCard extends HTMLElement {
} }
getCardSize() { getCardSize() {
return 8; return 10;
} }
getGridOptions() { getGridOptions() {
return { return {
rows: 8, rows: 10,
columns: 18, columns: 18,
min_rows: 7, min_rows: 9,
}; };
} }
@@ -186,6 +186,15 @@ class SternwarteCard extends HTMLElement {
} }
} }
_clearScheduleField(key, domain, service) {
const field = this._scheduleFieldState(key);
field.draft = "";
field.editing = false;
field.pending = false;
this._call(domain, service);
this._requestRender();
}
_beginScheduleEdit(key, fallbackState) { _beginScheduleEdit(key, fallbackState) {
const field = this._scheduleFieldState(key); const field = this._scheduleFieldState(key);
field.editing = true; field.editing = true;
@@ -330,12 +339,29 @@ class SternwarteCard extends HTMLElement {
} }
} }
_parseEntityDate(rawValue) {
if (!rawValue || rawValue === "unknown" || rawValue === "unavailable") return null;
if (rawValue instanceof Date) {
return Number.isNaN(rawValue.getTime()) ? null : rawValue;
}
if (typeof rawValue !== "string") {
const value = new Date(rawValue);
return Number.isNaN(value.getTime()) ? null : value;
}
const normalized = rawValue.trim().replace(" ", "T");
const hasTimezone = /(?:Z|[+-]\d{2}:\d{2})$/.test(normalized);
const localCandidate = hasTimezone ? normalized : normalized;
const value = new Date(localCandidate);
return Number.isNaN(value.getTime()) ? null : value;
}
_isoForInput(state) { _isoForInput(state) {
if (!state || !state.state || state.state === "unknown" || state.state === "unavailable") { if (!state || !state.state || state.state === "unknown" || state.state === "unavailable") {
return ""; return "";
} }
const value = new Date(state.state); const value = this._parseEntityDate(state.state);
if (Number.isNaN(value.getTime())) return ""; if (!value) return "";
const pad = (n) => String(n).padStart(2, "0"); 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())}`; return `${value.getFullYear()}-${pad(value.getMonth() + 1)}-${pad(value.getDate())}T${pad(value.getHours())}:${pad(value.getMinutes())}`;
} }
@@ -344,8 +370,8 @@ class SternwarteCard extends HTMLElement {
if (!state || !state.state || state.state === "unknown" || state.state === "unavailable") { if (!state || !state.state || state.state === "unknown" || state.state === "unavailable") {
return "\u2014"; return "\u2014";
} }
const value = new Date(state.state); const value = this._parseEntityDate(state.state);
if (Number.isNaN(value.getTime())) return "\u2014"; if (!value) return "\u2014";
const pad = (n) => String(n).padStart(2, "0"); const pad = (n) => String(n).padStart(2, "0");
return `${pad(value.getDate())}.${pad(value.getMonth() + 1)}. ${pad(value.getHours())}:${pad(value.getMinutes())}`; return `${pad(value.getDate())}.${pad(value.getMonth() + 1)}. ${pad(value.getHours())}:${pad(value.getMinutes())}`;
} }
@@ -388,12 +414,15 @@ class SternwarteCard extends HTMLElement {
return "Unbekannt"; return "Unbekannt";
} }
_boolBadge(label, entityId, onText = "Aktiv", offText = "Aus", extraClass = "") { _boolBadge(label, entityId, onText = "Aktiv", offText = "Aus", extraClass = "", options = {}) {
const state = this._state(entityId); const state = this._state(entityId);
const on = state?.state === "on"; const on = state?.state === "on";
const animStyle = (on && extraClass) ? ` style="animation-delay: -${Date.now() % 1000}ms"` : ""; const animStyle = (on && extraClass) ? ` style="animation-delay: -${Date.now() % 1000}ms"` : "";
const idAttr = options.id ? ` id="${options.id}"` : "";
const clickableClass = options.clickable ? " clickable" : "";
const roleAttr = options.clickable ? ` role="button" tabindex="0" aria-label="${label} öffnen"` : "";
return ` return `
<div class="badge ${on ? "on" : ""} ${extraClass}"${animStyle}> <div class="badge ${on ? "on" : ""} ${extraClass}${clickableClass}"${idAttr}${roleAttr}${animStyle}>
<span class="badge-label">${label}</span> <span class="badge-label">${label}</span>
<span class="badge-value">${on ? onText : offText}</span> <span class="badge-value">${on ? onText : offText}</span>
</div> </div>
@@ -417,6 +446,35 @@ class SternwarteCard extends HTMLElement {
return "unknown"; return "unknown";
} }
_inputPanelStates() {
const roofStatus = this._state(this.config.roofStatus);
const rawInputs = Array.isArray(roofStatus?.attributes?.inputs)
? roofStatus.attributes.inputs
: [];
const knownInputs = {
7: { label: "Regen", entityId: this.config.rain, accent: "warn" },
9: { label: "Dach bewegt", entityId: this.config.roofMoving, accent: "motion" },
10: { label: "Dach zu", entityId: this.config.roofClosed, accent: "ok" },
11: { label: "Dach offen", entityId: this.config.roofOpen, accent: "ok" },
};
return Array.from({ length: 12 }, (_, index) => {
const known = knownInputs[index] || {};
const entityState = known.entityId ? this._state(known.entityId) : null;
const rawValue = typeof rawInputs[index] === "boolean" ? rawInputs[index] : null;
const entityValue = entityState?.state === "on" ? true : entityState?.state === "off" ? false : null;
const on = rawValue ?? entityValue;
return {
key: `input-${index}`,
label: known.label || "",
shortLabel: `IN ${index}`,
accent: known.accent || "neutral",
on,
};
});
}
_windIndicatorState() { _windIndicatorState() {
const wind = this._state(this.config.wind); const wind = this._state(this.config.wind);
const value = Number(wind?.state); const value = Number(wind?.state);
@@ -539,6 +597,31 @@ class SternwarteCard extends HTMLElement {
} }
} }
_sunAltitudeDegAt(date) {
const astronomy = this._astronomy();
const observer = this._astroObserver();
if (!astronomy || !observer) return Number.NEGATIVE_INFINITY;
try {
const bodySun = astronomy.Body?.Sun ?? "Sun";
const equator = astronomy.Equator(bodySun, date, observer, true, true);
const horizon = astronomy.Horizon(date, observer, equator.ra, equator.dec, "normal");
return Number(horizon?.altitude);
} catch (error) {
return Number.NEGATIVE_INFINITY;
}
}
_astroPhaseAt(date) {
const altitude = this._sunAltitudeDegAt(date);
if (!Number.isFinite(altitude)) return "night";
if (altitude >= -0.833) return "day";
if (altitude >= -6) return "civil";
if (altitude >= -12) return "naut";
if (altitude >= -18) return "astro";
return "night";
}
_moonVisualState() { _moonVisualState() {
const moon = this._moonStateAt(new Date()); const moon = this._moonStateAt(new Date());
const phaseAngle = moon.phaseAngle ?? 0; const phaseAngle = moon.phaseAngle ?? 0;
@@ -663,7 +746,7 @@ class SternwarteCard extends HTMLElement {
} }
return { return {
opacity: (0.18 + currentMoon.illumination * 0.82).toFixed(2), opacity: "0.38",
segments, segments,
}; };
} }
@@ -678,20 +761,12 @@ class SternwarteCard extends HTMLElement {
_astroState() { _astroState() {
const now = new Date(); const now = new Date();
const tw = this._calcTwilight(now); const windowStart = new Date(now);
const offset = this._isMESZ(now) ? 2 : 1; windowStart.setHours(12, 0, 0, 0);
const shiftHour = (hourValue) => ((hourValue - 12 + 24) % 24); if (now < windowStart) {
const localize = (event) => event ? { windowStart.setDate(windowStart.getDate() - 1);
dawn: (event.dawn + offset + 24) % 24, }
dusk: (event.dusk + offset + 24) % 24, const nowLocal = (now.getTime() - windowStart.getTime()) / 3600000;
} : 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 segments = [];
const colors = { const colors = {
night: "#16202f", night: "#16202f",
@@ -700,65 +775,58 @@ class SternwarteCard extends HTMLElement {
civil: "#c68c50", civil: "#c68c50",
day: "#eed06f", day: "#eed06f",
}; };
const addSegment = (start, end, className) => { const sampleMinutes = 5;
if (start === null || end === null) return; const totalSamples = Math.floor((24 * 60) / sampleMinutes);
const shiftedStart = shiftHour(start); let activePhase = null;
const shiftedEnd = shiftHour(end); let segmentStart = 0;
if (shiftedEnd < shiftedStart) { const pushSegment = (startHour, endHour, phaseName) => {
segments.push({ if (!phaseName || endHour <= startHour) return;
start: shiftedStart,
end: 24,
color: colors[className],
});
segments.push({
start: 0,
end: shiftedEnd,
color: colors[className],
});
return;
}
if (shiftedEnd <= shiftedStart) return;
segments.push({ segments.push({
start: shiftedStart, start: startHour,
end: shiftedEnd, end: endHour,
color: colors[className], color: colors[phaseName],
}); });
}; };
addSegment(0, localTw.astro?.dawn ?? null, "night"); for (let index = 0; index <= totalSamples; index += 1) {
addSegment(localTw.astro?.dawn ?? null, localTw.naut?.dawn ?? null, "astro"); const minutesFromStart = Math.min(index * sampleMinutes, 24 * 60);
addSegment(localTw.naut?.dawn ?? null, localTw.civil?.dawn ?? null, "naut"); const sampleDate = new Date(windowStart.getTime() + minutesFromStart * 60000);
addSegment(localTw.civil?.dawn ?? null, localTw.sun?.dawn ?? null, "civil"); const phase = this._astroPhaseAt(sampleDate);
addSegment(localTw.sun?.dawn ?? null, localTw.sun?.dusk ?? null, "day"); if (activePhase === null) {
addSegment(localTw.sun?.dusk ?? null, localTw.civil?.dusk ?? null, "civil"); activePhase = phase;
addSegment(localTw.civil?.dusk ?? null, localTw.naut?.dusk ?? null, "naut"); segmentStart = minutesFromStart / 60;
addSegment(localTw.naut?.dusk ?? null, localTw.astro?.dusk ?? null, "astro"); continue;
addSegment(localTw.astro?.dusk ?? null, 24, "night"); }
if (!segments.length) addSegment(0, 24, "night"); if (phase !== activePhase) {
pushSegment(segmentStart, minutesFromStart / 60, activePhase);
activePhase = phase;
segmentStart = minutesFromStart / 60;
}
}
if (activePhase !== null) {
pushSegment(segmentStart, 24, activePhase);
}
segments.sort((a, b) => a.start - b.start); segments.sort((a, b) => a.start - b.start);
const gradientStops = segments.flatMap((segment) => { const toChartHour = (state) => {
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; if (!state?.state || state.state === "unknown" || state.state === "unavailable") return null;
const value = new Date(state.state); const value = this._parseEntityDate(state.state);
if (Number.isNaN(value.getTime())) return null; if (!value) return null;
return shiftHour(value.getHours() + value.getMinutes() / 60); const localHour = value.getHours() + value.getMinutes() / 60 + value.getSeconds() / 3600;
return (localHour - 12 + 24) % 24;
}; };
return { return {
now: now.toLocaleTimeString("de-DE", { hour: "2-digit", minute: "2-digit" }), now: now.toLocaleTimeString("de-DE", { hour: "2-digit", minute: "2-digit" }),
gradient, phaseSegments: segments.map((segment) => ({
left: `${(segment.start / 24) * 100}%`,
width: `${Math.max(((segment.end - segment.start) / 24) * 100, 0.35)}%`,
color: segment.color,
})),
markerLeft: `${(nowLocal / 24) * 100}%`, markerLeft: `${(nowLocal / 24) * 100}%`,
openMarkerLeft: toLocalHour(this._state(this._scheduleOpenEntityId())), openMarkerLeft: toChartHour(this._state(this._scheduleOpenEntityId())),
closeMarkerLeft: toLocalHour(this._state(this._scheduleCloseEntityId())), closeMarkerLeft: toChartHour(this._state(this._scheduleCloseEntityId())),
}; };
} }
@@ -767,7 +835,6 @@ class SternwarteCard extends HTMLElement {
const powerEntityId = this._powerEntityId(); const powerEntityId = this._powerEntityId();
const powerIndicatorClassName = this._powerIndicatorClassName(); const powerIndicatorClassName = this._powerIndicatorClassName();
const rain = this._state(this.config.rain);
const openScheduleEntityId = this._scheduleOpenEntityId(); const openScheduleEntityId = this._scheduleOpenEntityId();
const closeScheduleEntityId = this._scheduleCloseEntityId(); const closeScheduleEntityId = this._scheduleCloseEntityId();
const openSchedule = this._state(openScheduleEntityId); const openSchedule = this._state(openScheduleEntityId);
@@ -784,7 +851,7 @@ class SternwarteCard extends HTMLElement {
} }
const roofPosition = this._roofPositionValue(); const roofPosition = this._roofPositionValue();
const windIndicator = this._windIndicatorState(); const windIndicator = this._windIndicatorState();
const rainOn = rain?.state === "on"; const inputPanelStates = this._inputPanelStates();
const movingOn = this._state(this.config.roofMoving)?.state === "on"; const movingOn = this._state(this.config.roofMoving)?.state === "on";
const openOn = this._state(this.config.roofOpen)?.state === "on"; const openOn = this._state(this.config.roofOpen)?.state === "on";
const closedOn = this._state(this.config.roofClosed)?.state === "on"; const closedOn = this._state(this.config.roofClosed)?.state === "on";
@@ -878,22 +945,6 @@ class SternwarteCard extends HTMLElement {
font-size: 13px; font-size: 13px;
opacity: 0.78; opacity: 0.78;
} }
.alert {
border-radius: 14px;
padding: 12px 14px;
border: 1px solid rgba(198, 40, 40, 0.35);
background: linear-gradient(180deg, rgba(198, 40, 40, 0.18), rgba(198, 40, 40, 0.08));
color: var(--primary-text-color);
font-size: 14px;
font-weight: 600;
}
.alert-sub {
display: block;
margin-top: 4px;
font-size: 12px;
font-weight: 400;
opacity: 0.82;
}
.stats { .stats {
display: grid; display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr)); grid-template-columns: repeat(4, minmax(0, 1fr));
@@ -1013,20 +1064,30 @@ class SternwarteCard extends HTMLElement {
} }
.astro-scale { .astro-scale {
position: relative; position: relative;
padding-top: 14px; padding-top: 18px;
} }
.astro-bar { .astro-bar {
position: relative; position: relative;
height: 16px; height: 32px;
border-radius: 999px; border-radius: 999px;
overflow: hidden; overflow: hidden;
background: rgba(255,255,255,0.06); background: #16202f;
border: 1px solid rgba(255,255,255,0.08); border: 1px solid rgba(255,255,255,0.08);
box-shadow: inset 0 0 0 1px rgba(255,255,255,0.03);
}
.astro-phase {
position: absolute;
top: 0;
bottom: 0;
box-shadow:
inset -1px 0 0 rgba(255,255,255,0.14),
inset 0 1px 0 rgba(255,255,255,0.08),
inset 0 -10px 18px rgba(0,0,0,0.14);
} }
.astro-tick { .astro-tick {
position: absolute; position: absolute;
top: 13px; top: 17px;
height: 20px; height: 36px;
width: 2px; width: 2px;
margin-left: -1px; margin-left: -1px;
background: rgba(255,255,255,0.5); background: rgba(255,255,255,0.5);
@@ -1046,36 +1107,42 @@ class SternwarteCard extends HTMLElement {
} }
.astro-now { .astro-now {
position: absolute; position: absolute;
top: -3px; top: -4px;
bottom: -3px; bottom: -4px;
width: 3px; width: 3px;
margin-left: -1px; margin-left: -1px;
background: #d32f2f; background: rgba(255, 255, 255, 0.95);
box-shadow: 0 0 8px rgba(211, 47, 47, 0.55); box-shadow: 0 0 8px rgba(255, 255, 255, 0.45);
z-index: 5;
} }
.astro-marker { .astro-marker {
position: absolute; position: absolute;
top: -2px; top: -4px;
bottom: -2px; bottom: -4px;
width: 3px; width: 6px;
margin-left: -1px; margin-left: -2px;
box-shadow: 0 0 6px rgba(255, 255, 255, 0.25); border-radius: 999px;
z-index: 4;
} }
.astro-marker.open { .astro-marker.open {
background: #4caf50; background: #20c45a;
box-shadow: 0 0 10px rgba(32, 196, 90, 0.7);
} }
.astro-marker.close { .astro-marker.close {
background: #ffb300; background: #e53935;
box-shadow: 0 0 10px rgba(229, 57, 53, 0.72);
} }
.astro-moon-track { .astro-moon-track {
position: absolute; position: absolute;
background: rgba(255, 255, 255, 0.96); background: rgba(255, 255, 255, 0.96);
height: 2px; height: 6px;
top: 2px; top: 3px;
bottom: auto; bottom: auto;
margin-left: 0; margin-left: 0;
border-radius: 999px; border-radius: 999px;
box-shadow: 0 0 8px rgba(255, 255, 255, 0.45); border: 1px solid rgba(12, 18, 28, 0.78);
box-sizing: border-box;
box-shadow: 0 0 6px rgba(255, 255, 255, 0.24);
z-index: 2; z-index: 2;
} }
.badges { .badges {
@@ -1090,6 +1157,19 @@ class SternwarteCard extends HTMLElement {
background: rgba(255,255,255,0.04); background: rgba(255,255,255,0.04);
border: 1px solid rgba(255,255,255,0.08); border: 1px solid rgba(255,255,255,0.08);
} }
.badge.clickable {
cursor: pointer;
transition: transform 140ms ease, border-color 140ms ease, background 140ms ease;
}
.badge.clickable:hover {
transform: translateY(-1px);
border-color: rgba(255,255,255,0.2);
background: rgba(255,255,255,0.07);
}
.badge.clickable:focus-visible {
outline: 2px solid var(--primary-color);
outline-offset: 2px;
}
.badge.on { .badge.on {
background: rgba(76, 175, 80, 0.12); background: rgba(76, 175, 80, 0.12);
border-color: rgba(76, 175, 80, 0.3); border-color: rgba(76, 175, 80, 0.3);
@@ -1163,7 +1243,7 @@ class SternwarteCard extends HTMLElement {
} }
.schedule-grid { .schedule-grid {
display: grid; display: grid;
grid-template-columns: minmax(0, 196px) max-content; grid-template-columns: minmax(0, 196px) max-content max-content;
gap: 8px; gap: 8px;
align-items: center; align-items: center;
justify-content: start; justify-content: start;
@@ -1203,6 +1283,115 @@ class SternwarteCard extends HTMLElement {
background: rgba(255,255,255,0.04); background: rgba(255,255,255,0.04);
border: 1px solid rgba(255,255,255,0.08); border: 1px solid rgba(255,255,255,0.08);
} }
.input-panel {
position: relative;
border-radius: 16px;
padding: 7px;
border: 1px solid rgba(120, 220, 255, 0.18);
background:
radial-gradient(circle at top, rgba(120, 220, 255, 0.12), transparent 42%),
linear-gradient(180deg, rgba(7, 16, 18, 0.98), rgba(3, 8, 10, 0.96));
box-shadow:
inset 0 1px 0 rgba(255,255,255,0.06),
inset 0 -14px 28px rgba(0,0,0,0.35),
0 10px 24px rgba(0,0,0,0.18);
}
.input-panel::before {
content: "";
position: absolute;
inset: 5px;
border-radius: 10px;
border: 1px solid rgba(255,255,255,0.035);
pointer-events: none;
}
.input-panel-grid {
display: grid;
grid-template-columns: repeat(12, minmax(0, 1fr));
gap: 4px;
}
.input-led {
position: relative;
display: grid;
grid-template-columns: 1fr;
grid-template-rows: 8px 1fr;
gap: 1px;
align-items: center;
justify-items: center;
padding: 2px;
aspect-ratio: 1 / 1;
min-height: 28px;
border-radius: 4px;
border: 1px solid rgba(255,255,255,0.06);
background:
linear-gradient(180deg, rgba(255,255,255,0.05), rgba(255,255,255,0.01)),
rgba(0,0,0,0.26);
}
.input-led.warn.is-on {
border-color: rgba(255, 96, 96, 0.38);
background:
linear-gradient(180deg, rgba(255, 96, 96, 0.16), rgba(255, 96, 96, 0.04)),
rgba(0,0,0,0.28);
}
.input-led.ok.is-on,
.input-led.motion.is-on,
.input-led.neutral.is-on {
border-color: rgba(120, 255, 180, 0.28);
background:
linear-gradient(180deg, rgba(120, 255, 180, 0.14), rgba(120, 255, 180, 0.04)),
rgba(0,0,0,0.28);
}
.input-led-light {
width: 7px;
height: 7px;
border-radius: 50%;
background: #162126;
border: 1px solid rgba(255,255,255,0.14);
box-shadow:
inset 0 1px 2px rgba(255,255,255,0.08),
inset 0 -2px 4px rgba(0,0,0,0.45);
}
.input-led.is-on .input-led-light {
background: #78ffb4;
border-color: rgba(210,255,230,0.9);
box-shadow:
0 0 12px rgba(120,255,180,0.8),
0 0 24px rgba(120,255,180,0.42),
inset 0 0 4px rgba(255,255,255,0.55);
}
.input-led.warn.is-on .input-led-light {
background: #ff6767;
border-color: rgba(255, 220, 220, 0.95);
box-shadow:
0 0 12px rgba(255, 96, 96, 0.82),
0 0 24px rgba(255, 96, 96, 0.46),
inset 0 0 4px rgba(255,255,255,0.5);
}
.input-led.is-unknown .input-led-light {
background: #49545a;
border-color: rgba(255,255,255,0.18);
box-shadow:
inset 0 0 4px rgba(255,255,255,0.08);
}
.input-led-meta {
min-width: 0;
align-self: center;
text-align: center;
}
.input-led-index {
display: block;
font-size: 7px;
line-height: 1;
font-weight: 700;
opacity: 0.92;
}
.input-led-state {
display: block;
margin-top: 1px;
font-size: 0;
letter-spacing: 0;
text-transform: uppercase;
opacity: 0;
}
.schedule-label { .schedule-label {
display: block; display: block;
font-size: 12px; font-size: 12px;
@@ -1241,12 +1430,18 @@ class SternwarteCard extends HTMLElement {
.schedule-overview { .schedule-overview {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
.input-panel-grid {
grid-template-columns: repeat(6, minmax(0, 1fr));
}
}
@media (max-width: 480px) {
.input-panel-grid {
grid-template-columns: repeat(4, minmax(0, 1fr));
}
} }
</style> </style>
<ha-card header="Sternwarte"> <ha-card header="Sternwarte">
<div class="wrap"> <div class="wrap">
${rainOn ? `<div class="alert">Regen erkannt<span class="alert-sub">Das Dach wird bei Regen nicht \u00f6ffnet und bei Bedarf automatisch geschlossen.</span></div>` : ""}
<div class="hero"> <div class="hero">
<div class="panel"> <div class="panel">
<div class="stats"> <div class="stats">
@@ -1287,7 +1482,7 @@ class SternwarteCard extends HTMLElement {
${this._boolBadge("Dach zu", this.config.roofClosed, "Ja", "Nein")} ${this._boolBadge("Dach zu", this.config.roofClosed, "Ja", "Nein")}
${this._boolBadge("Dach offen", this.config.roofOpen, "Ja", "Nein")} ${this._boolBadge("Dach offen", this.config.roofOpen, "Ja", "Nein")}
${this._boolBadge("Dach bewegt", this.config.roofMoving, "Ja", "Nein", "moving")} ${this._boolBadge("Dach bewegt", this.config.roofMoving, "Ja", "Nein", "moving")}
${this._boolBadge("Regen", this.config.rain, "Ja", "Nein", "rain")} ${this._boolBadge("Regen", this.config.rain, "Ja", "Nein", "rain", { id: "rain-badge", clickable: true })}
</div> </div>
<div class="astro"> <div class="astro">
<div class="astro-meta"> <div class="astro-meta">
@@ -1304,7 +1499,8 @@ class SternwarteCard extends HTMLElement {
<div class="astro-tick-label" style="left:75%">6</div> <div class="astro-tick-label" style="left:75%">6</div>
<div class="astro-tick" style="left:100%"></div> <div class="astro-tick" style="left:100%"></div>
<div class="astro-tick-label" style="left:100%">12</div> <div class="astro-tick-label" style="left:100%">12</div>
<div class="astro-bar" style="background:${astro.gradient}"> <div class="astro-bar">
${astro.phaseSegments.map((segment) => `<div class="astro-phase" style="left:${segment.left};width:${segment.width};background:${segment.color}"></div>`).join("")}
${moonTrack.segments.map((segment) => `<div class="astro-moon-track" style="left:${segment.left};width:${segment.width};opacity:${moonTrack.opacity}"></div>`).join("")} ${moonTrack.segments.map((segment) => `<div class="astro-moon-track" style="left:${segment.left};width:${segment.width};opacity:${moonTrack.opacity}"></div>`).join("")}
${astro.openMarkerLeft === null ? "" : `<div class="astro-marker open" style="left:${(astro.openMarkerLeft / 24) * 100}%"></div>`} ${astro.openMarkerLeft === null ? "" : `<div class="astro-marker open" style="left:${(astro.openMarkerLeft / 24) * 100}%"></div>`}
${astro.closeMarkerLeft === null ? "" : `<div class="astro-marker close" style="left:${(astro.closeMarkerLeft / 24) * 100}%"></div>`} ${astro.closeMarkerLeft === null ? "" : `<div class="astro-marker close" style="left:${(astro.closeMarkerLeft / 24) * 100}%"></div>`}
@@ -1353,16 +1549,33 @@ class SternwarteCard extends HTMLElement {
<div class="schedule-grid"> <div class="schedule-grid">
<input id="open-schedule-input" type="datetime-local" value="${openInputValue}"> <input id="open-schedule-input" type="datetime-local" value="${openInputValue}">
<button type="button" class="secondary" id="open-schedule-btn">\u00d6ffnen setzen</button> <button type="button" class="secondary" id="open-schedule-btn">\u00d6ffnen setzen</button>
<button type="button" class="secondary" id="open-schedule-clear-btn">\u00d6ffnen l\u00f6schen</button>
</div> </div>
<div class="schedule-grid"> <div class="schedule-grid">
<input id="close-schedule-input" type="datetime-local" value="${closeInputValue}"> <input id="close-schedule-input" type="datetime-local" value="${closeInputValue}">
<button type="button" class="secondary" id="close-schedule-btn">Schlie\u00dfen setzen</button> <button type="button" class="secondary" id="close-schedule-btn">Schlie\u00dfen setzen</button>
<button type="button" class="secondary" id="close-schedule-clear-btn">Schlie\u00dfen l\u00f6schen</button>
</div> </div>
<div class="inline-actions"> <div class="inline-actions">
<button type="button" class="secondary" id="nautical-btn">Nautisch setzen</button> <button type="button" class="secondary" id="nautical-btn">Nautisch setzen</button>
<button type="button" class="secondary" id="clear-schedule-btn">Zeitplanung l\u00f6schen</button> <button type="button" class="secondary" id="clear-schedule-btn">Zeitplanung l\u00f6schen</button>
</div> </div>
</div> </div>
<div class="input-panel">
<div class="title">Eing\u00e4nge</div>
<div class="input-panel-grid">
${inputPanelStates.map((input) => `
<div class="input-led ${input.accent} ${input.on === true ? "is-on" : ""} ${input.on === null ? "is-unknown" : ""}">
<span class="input-led-light" aria-hidden="true"></span>
<div class="input-led-meta">
<span class="input-led-index">${input.shortLabel}</span>
<span class="input-led-state">${input.on === true ? "AN" : input.on === false ? "AUS" : "UNBEKANNT"}</span>
</div>
</div>
`).join("")}
</div>
</div>
</div> </div>
</ha-card> </ha-card>
`; `;
@@ -1380,7 +1593,10 @@ class SternwarteCard extends HTMLElement {
}); });
this._bindClick("open-schedule-btn", () => this._setDateTime(openScheduleEntityId, "open-schedule-input", "open")); this._bindClick("open-schedule-btn", () => this._setDateTime(openScheduleEntityId, "open-schedule-input", "open"));
this._bindClick("close-schedule-btn", () => this._setDateTime(closeScheduleEntityId, "close-schedule-input", "close")); this._bindClick("close-schedule-btn", () => this._setDateTime(closeScheduleEntityId, "close-schedule-input", "close"));
this._bindClick("open-schedule-clear-btn", () => this._clearScheduleField("open", "sternwarte", "clear_open_schedule"));
this._bindClick("close-schedule-clear-btn", () => this._clearScheduleField("close", "sternwarte", "clear_close_schedule"));
this._bindMoreInfo("wind-stat", this.config.wind); this._bindMoreInfo("wind-stat", this.config.wind);
this._bindMoreInfo("rain-badge", this.config.rain);
this._bindScheduleInput("open", "open-schedule-input", openSchedule); this._bindScheduleInput("open", "open-schedule-input", openSchedule);
this._bindScheduleInput("close", "close-schedule-input", closeSchedule); this._bindScheduleInput("close", "close-schedule-input", closeSchedule);
} }
@@ -10,6 +10,8 @@ from homeassistant.core import HomeAssistant, ServiceCall
from .const import ( from .const import (
DOMAIN, DOMAIN,
SERVICE_CLEAR_CLOSE_SCHEDULE,
SERVICE_CLEAR_OPEN_SCHEDULE,
SERVICE_CLEAR_SCHEDULE, SERVICE_CLEAR_SCHEDULE,
SERVICE_CLOSE_ROOF, SERVICE_CLOSE_ROOF,
SERVICE_OPEN_ROOF, SERVICE_OPEN_ROOF,
@@ -63,6 +65,14 @@ async def _async_handle_clear_schedule(hass: HomeAssistant, call: ServiceCall) -
await _async_call_on_all(hass, "async_clear_schedule") await _async_call_on_all(hass, "async_clear_schedule")
async def _async_handle_clear_open_schedule(hass: HomeAssistant, call: ServiceCall) -> None:
await _async_call_on_all(hass, "async_clear_open_schedule")
async def _async_handle_clear_close_schedule(hass: HomeAssistant, call: ServiceCall) -> None:
await _async_call_on_all(hass, "async_clear_close_schedule")
async def _async_handle_set_nautical_schedule(hass: HomeAssistant, call: ServiceCall) -> None: async def _async_handle_set_nautical_schedule(hass: HomeAssistant, call: ServiceCall) -> None:
await _async_call_on_all(hass, "async_set_next_nautical_schedule") await _async_call_on_all(hass, "async_set_next_nautical_schedule")
@@ -81,6 +91,16 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
hass.services.async_register(DOMAIN, SERVICE_POWER_ON, partial(_async_handle_power_on, 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_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_CLEAR_SCHEDULE, partial(_async_handle_clear_schedule, hass))
hass.services.async_register(
DOMAIN,
SERVICE_CLEAR_OPEN_SCHEDULE,
partial(_async_handle_clear_open_schedule, hass),
)
hass.services.async_register(
DOMAIN,
SERVICE_CLEAR_CLOSE_SCHEDULE,
partial(_async_handle_clear_close_schedule, hass),
)
hass.services.async_register( hass.services.async_register(
DOMAIN, DOMAIN,
SERVICE_SET_NAUTICAL_SCHEDULE, SERVICE_SET_NAUTICAL_SCHEDULE,
@@ -104,6 +124,8 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
SERVICE_POWER_ON, SERVICE_POWER_ON,
SERVICE_POWER_OFF, SERVICE_POWER_OFF,
SERVICE_CLEAR_SCHEDULE, SERVICE_CLEAR_SCHEDULE,
SERVICE_CLEAR_OPEN_SCHEDULE,
SERVICE_CLEAR_CLOSE_SCHEDULE,
SERVICE_SET_NAUTICAL_SCHEDULE, SERVICE_SET_NAUTICAL_SCHEDULE,
): ):
if hass.services.has_service(DOMAIN, service): if hass.services.has_service(DOMAIN, service):
@@ -33,6 +33,8 @@ SERVICE_STOP_ROOF = "stop_roof"
SERVICE_POWER_ON = "power_on" SERVICE_POWER_ON = "power_on"
SERVICE_POWER_OFF = "power_off" SERVICE_POWER_OFF = "power_off"
SERVICE_CLEAR_SCHEDULE = "clear_schedule" SERVICE_CLEAR_SCHEDULE = "clear_schedule"
SERVICE_CLEAR_OPEN_SCHEDULE = "clear_open_schedule"
SERVICE_CLEAR_CLOSE_SCHEDULE = "clear_close_schedule"
SERVICE_SET_NAUTICAL_SCHEDULE = "set_nautical_schedule" SERVICE_SET_NAUTICAL_SCHEDULE = "set_nautical_schedule"
ENTITY_ROOF_COVER: Final = "roof" ENTITY_ROOF_COVER: Final = "roof"
@@ -200,6 +200,14 @@ class SternwarteDataUpdateCoordinator(DataUpdateCoordinator[SternwarteState]):
await self._async_save_runtime_state() await self._async_save_runtime_state()
await self.async_request_refresh() await self.async_request_refresh()
async def async_clear_open_schedule(self) -> None:
"""Clear only the open schedule."""
await self.async_set_open_schedule(None)
async def async_clear_close_schedule(self) -> None:
"""Clear only the close schedule."""
await self.async_set_close_schedule(None)
async def async_set_close_schedule(self, when: datetime | None) -> None: async def async_set_close_schedule(self, when: datetime | None) -> None:
"""Persist close schedule.""" """Persist close schedule."""
when = self._normalize_user_schedule_datetime(when) when = self._normalize_user_schedule_datetime(when)
@@ -74,6 +74,12 @@ class SternwarteRoofStatusSensor(SternwarteEntity, SensorEntity):
return "sensorfehler" return "sensorfehler"
return "unbekannt" return "unbekannt"
@property
def extra_state_attributes(self) -> dict[str, object]:
return {
"inputs": list(self.coordinator.data.inputs),
}
class SternwarteWindSpeedSensor(SternwarteWindBaseSensor): class SternwarteWindSpeedSensor(SternwarteWindBaseSensor):
_attr_name = "Windgeschwindigkeit" _attr_name = "Windgeschwindigkeit"
@@ -22,6 +22,14 @@ clear_schedule:
name: Zeitplanung löschen name: Zeitplanung löschen
description: Löscht die geplanten Öffnen- und Schließen-Termine. description: Löscht die geplanten Öffnen- und Schließen-Termine.
clear_open_schedule:
name: Öffnen-Termin löschen
description: Löscht nur den geplanten Öffnen-Termin.
clear_close_schedule:
name: Schließen-Termin löschen
description: Löscht nur den geplanten Schließen-Termin.
set_nautical_schedule: set_nautical_schedule:
name: Nautische Dämmerung setzen name: Nautische Dämmerung setzen
description: Setzt Öffnen und Schließen auf die nächste nautische Abend- und Morgendämmerung. description: Setzt Öffnen und Schließen auf die nächste nautische Abend- und Morgendämmerung.