Compare commits
10
Commits
eb4c5f22b8
...
7418941cd5
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7418941cd5 | ||
|
|
a6825e1685 | ||
|
|
ed0cff2010 | ||
|
|
6c2b42784a | ||
|
|
826b386296 | ||
|
|
e57d53b61a | ||
|
|
2d8ec835c6 | ||
|
|
698dc0aa82 | ||
|
|
b497820c30 | ||
|
|
ebf1d849f0 |
+32
@@ -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
|
||||
@@ -22,3 +22,37 @@ Wenn ihr das später wirklich über HACS verwenden wollt, legt ihr für beide Or
|
||||
## 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.
|
||||
|
||||
## 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?
|
||||
|
||||
@@ -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
|
||||
@@ -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.
|
||||
|
||||
## 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
|
||||
|
||||
Dieser Ordner ist als Root eines späteren HACS-Dashboard-Repositorys vorbereitet.
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+328
-112
@@ -70,14 +70,14 @@ class SternwarteCard extends HTMLElement {
|
||||
}
|
||||
|
||||
getCardSize() {
|
||||
return 8;
|
||||
return 10;
|
||||
}
|
||||
|
||||
getGridOptions() {
|
||||
return {
|
||||
rows: 8,
|
||||
rows: 10,
|
||||
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) {
|
||||
const field = this._scheduleFieldState(key);
|
||||
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) {
|
||||
if (!state || !state.state || state.state === "unknown" || state.state === "unavailable") {
|
||||
return "";
|
||||
}
|
||||
const value = new Date(state.state);
|
||||
if (Number.isNaN(value.getTime())) return "";
|
||||
const value = this._parseEntityDate(state.state);
|
||||
if (!value) 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())}`;
|
||||
}
|
||||
@@ -344,8 +370,8 @@ class SternwarteCard extends HTMLElement {
|
||||
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 value = this._parseEntityDate(state.state);
|
||||
if (!value) return "\u2014";
|
||||
const pad = (n) => String(n).padStart(2, "0");
|
||||
return `${pad(value.getDate())}.${pad(value.getMonth() + 1)}. ${pad(value.getHours())}:${pad(value.getMinutes())}`;
|
||||
}
|
||||
@@ -388,12 +414,15 @@ class SternwarteCard extends HTMLElement {
|
||||
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 on = state?.state === "on";
|
||||
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 `
|
||||
<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-value">${on ? onText : offText}</span>
|
||||
</div>
|
||||
@@ -417,6 +446,35 @@ class SternwarteCard extends HTMLElement {
|
||||
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() {
|
||||
const wind = this._state(this.config.wind);
|
||||
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() {
|
||||
const moon = this._moonStateAt(new Date());
|
||||
const phaseAngle = moon.phaseAngle ?? 0;
|
||||
@@ -663,7 +746,7 @@ class SternwarteCard extends HTMLElement {
|
||||
}
|
||||
|
||||
return {
|
||||
opacity: (0.18 + currentMoon.illumination * 0.82).toFixed(2),
|
||||
opacity: "0.38",
|
||||
segments,
|
||||
};
|
||||
}
|
||||
@@ -678,20 +761,12 @@ class SternwarteCard extends HTMLElement {
|
||||
|
||||
_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 windowStart = new Date(now);
|
||||
windowStart.setHours(12, 0, 0, 0);
|
||||
if (now < windowStart) {
|
||||
windowStart.setDate(windowStart.getDate() - 1);
|
||||
}
|
||||
const nowLocal = (now.getTime() - windowStart.getTime()) / 3600000;
|
||||
const segments = [];
|
||||
const colors = {
|
||||
night: "#16202f",
|
||||
@@ -700,65 +775,58 @@ class SternwarteCard extends HTMLElement {
|
||||
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;
|
||||
const sampleMinutes = 5;
|
||||
const totalSamples = Math.floor((24 * 60) / sampleMinutes);
|
||||
let activePhase = null;
|
||||
let segmentStart = 0;
|
||||
const pushSegment = (startHour, endHour, phaseName) => {
|
||||
if (!phaseName || endHour <= startHour) return;
|
||||
segments.push({
|
||||
start: shiftedStart,
|
||||
end: shiftedEnd,
|
||||
color: colors[className],
|
||||
start: startHour,
|
||||
end: endHour,
|
||||
color: colors[phaseName],
|
||||
});
|
||||
};
|
||||
|
||||
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");
|
||||
for (let index = 0; index <= totalSamples; index += 1) {
|
||||
const minutesFromStart = Math.min(index * sampleMinutes, 24 * 60);
|
||||
const sampleDate = new Date(windowStart.getTime() + minutesFromStart * 60000);
|
||||
const phase = this._astroPhaseAt(sampleDate);
|
||||
if (activePhase === null) {
|
||||
activePhase = phase;
|
||||
segmentStart = minutesFromStart / 60;
|
||||
continue;
|
||||
}
|
||||
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);
|
||||
|
||||
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) => {
|
||||
const toChartHour = (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);
|
||||
const value = this._parseEntityDate(state.state);
|
||||
if (!value) return null;
|
||||
const localHour = value.getHours() + value.getMinutes() / 60 + value.getSeconds() / 3600;
|
||||
return (localHour - 12 + 24) % 24;
|
||||
};
|
||||
|
||||
return {
|
||||
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}%`,
|
||||
openMarkerLeft: toLocalHour(this._state(this._scheduleOpenEntityId())),
|
||||
closeMarkerLeft: toLocalHour(this._state(this._scheduleCloseEntityId())),
|
||||
openMarkerLeft: toChartHour(this._state(this._scheduleOpenEntityId())),
|
||||
closeMarkerLeft: toChartHour(this._state(this._scheduleCloseEntityId())),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -767,7 +835,6 @@ class SternwarteCard extends HTMLElement {
|
||||
|
||||
const powerEntityId = this._powerEntityId();
|
||||
const powerIndicatorClassName = this._powerIndicatorClassName();
|
||||
const rain = this._state(this.config.rain);
|
||||
const openScheduleEntityId = this._scheduleOpenEntityId();
|
||||
const closeScheduleEntityId = this._scheduleCloseEntityId();
|
||||
const openSchedule = this._state(openScheduleEntityId);
|
||||
@@ -784,7 +851,7 @@ class SternwarteCard extends HTMLElement {
|
||||
}
|
||||
const roofPosition = this._roofPositionValue();
|
||||
const windIndicator = this._windIndicatorState();
|
||||
const rainOn = rain?.state === "on";
|
||||
const inputPanelStates = this._inputPanelStates();
|
||||
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";
|
||||
@@ -878,22 +945,6 @@ class SternwarteCard extends HTMLElement {
|
||||
font-size: 13px;
|
||||
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 {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
@@ -1013,20 +1064,30 @@ class SternwarteCard extends HTMLElement {
|
||||
}
|
||||
.astro-scale {
|
||||
position: relative;
|
||||
padding-top: 14px;
|
||||
padding-top: 18px;
|
||||
}
|
||||
.astro-bar {
|
||||
position: relative;
|
||||
height: 16px;
|
||||
height: 32px;
|
||||
border-radius: 999px;
|
||||
overflow: hidden;
|
||||
background: rgba(255,255,255,0.06);
|
||||
background: #16202f;
|
||||
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 {
|
||||
position: absolute;
|
||||
top: 13px;
|
||||
height: 20px;
|
||||
top: 17px;
|
||||
height: 36px;
|
||||
width: 2px;
|
||||
margin-left: -1px;
|
||||
background: rgba(255,255,255,0.5);
|
||||
@@ -1046,36 +1107,42 @@ class SternwarteCard extends HTMLElement {
|
||||
}
|
||||
.astro-now {
|
||||
position: absolute;
|
||||
top: -3px;
|
||||
bottom: -3px;
|
||||
top: -4px;
|
||||
bottom: -4px;
|
||||
width: 3px;
|
||||
margin-left: -1px;
|
||||
background: #d32f2f;
|
||||
box-shadow: 0 0 8px rgba(211, 47, 47, 0.55);
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
box-shadow: 0 0 8px rgba(255, 255, 255, 0.45);
|
||||
z-index: 5;
|
||||
}
|
||||
.astro-marker {
|
||||
position: absolute;
|
||||
top: -2px;
|
||||
bottom: -2px;
|
||||
width: 3px;
|
||||
margin-left: -1px;
|
||||
box-shadow: 0 0 6px rgba(255, 255, 255, 0.25);
|
||||
top: -4px;
|
||||
bottom: -4px;
|
||||
width: 6px;
|
||||
margin-left: -2px;
|
||||
border-radius: 999px;
|
||||
z-index: 4;
|
||||
}
|
||||
.astro-marker.open {
|
||||
background: #4caf50;
|
||||
background: #20c45a;
|
||||
box-shadow: 0 0 10px rgba(32, 196, 90, 0.7);
|
||||
}
|
||||
.astro-marker.close {
|
||||
background: #ffb300;
|
||||
background: #e53935;
|
||||
box-shadow: 0 0 10px rgba(229, 57, 53, 0.72);
|
||||
}
|
||||
.astro-moon-track {
|
||||
position: absolute;
|
||||
background: rgba(255, 255, 255, 0.96);
|
||||
height: 2px;
|
||||
top: 2px;
|
||||
height: 6px;
|
||||
top: 3px;
|
||||
bottom: auto;
|
||||
margin-left: 0;
|
||||
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;
|
||||
}
|
||||
.badges {
|
||||
@@ -1090,6 +1157,19 @@ class SternwarteCard extends HTMLElement {
|
||||
background: rgba(255,255,255,0.04);
|
||||
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 {
|
||||
background: rgba(76, 175, 80, 0.12);
|
||||
border-color: rgba(76, 175, 80, 0.3);
|
||||
@@ -1163,7 +1243,7 @@ class SternwarteCard extends HTMLElement {
|
||||
}
|
||||
.schedule-grid {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 196px) max-content;
|
||||
grid-template-columns: minmax(0, 196px) max-content max-content;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
justify-content: start;
|
||||
@@ -1203,6 +1283,115 @@ class SternwarteCard extends HTMLElement {
|
||||
background: rgba(255,255,255,0.04);
|
||||
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 {
|
||||
display: block;
|
||||
font-size: 12px;
|
||||
@@ -1241,12 +1430,18 @@ class SternwarteCard extends HTMLElement {
|
||||
.schedule-overview {
|
||||
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>
|
||||
<ha-card header="Sternwarte">
|
||||
<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="panel">
|
||||
<div class="stats">
|
||||
@@ -1287,7 +1482,7 @@ class SternwarteCard extends HTMLElement {
|
||||
${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")}
|
||||
${this._boolBadge("Regen", this.config.rain, "Ja", "Nein", "rain", { id: "rain-badge", clickable: true })}
|
||||
</div>
|
||||
<div class="astro">
|
||||
<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" style="left:100%"></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("")}
|
||||
${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>`}
|
||||
@@ -1353,16 +1549,33 @@ class SternwarteCard extends HTMLElement {
|
||||
<div class="schedule-grid">
|
||||
<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-clear-btn">\u00d6ffnen l\u00f6schen</button>
|
||||
</div>
|
||||
<div class="schedule-grid">
|
||||
<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-clear-btn">Schlie\u00dfen l\u00f6schen</button>
|
||||
</div>
|
||||
<div class="inline-actions">
|
||||
<button type="button" class="secondary" id="nautical-btn">Nautisch setzen</button>
|
||||
<button type="button" class="secondary" id="clear-schedule-btn">Zeitplanung l\u00f6schen</button>
|
||||
</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>
|
||||
</ha-card>
|
||||
`;
|
||||
@@ -1380,7 +1593,10 @@ class SternwarteCard extends HTMLElement {
|
||||
});
|
||||
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("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("rain-badge", this.config.rain);
|
||||
this._bindScheduleInput("open", "open-schedule-input", openSchedule);
|
||||
this._bindScheduleInput("close", "close-schedule-input", closeSchedule);
|
||||
}
|
||||
|
||||
@@ -10,6 +10,8 @@ from homeassistant.core import HomeAssistant, ServiceCall
|
||||
|
||||
from .const import (
|
||||
DOMAIN,
|
||||
SERVICE_CLEAR_CLOSE_SCHEDULE,
|
||||
SERVICE_CLEAR_OPEN_SCHEDULE,
|
||||
SERVICE_CLEAR_SCHEDULE,
|
||||
SERVICE_CLOSE_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")
|
||||
|
||||
|
||||
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:
|
||||
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_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_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(
|
||||
DOMAIN,
|
||||
SERVICE_SET_NAUTICAL_SCHEDULE,
|
||||
@@ -104,6 +124,8 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
SERVICE_POWER_ON,
|
||||
SERVICE_POWER_OFF,
|
||||
SERVICE_CLEAR_SCHEDULE,
|
||||
SERVICE_CLEAR_OPEN_SCHEDULE,
|
||||
SERVICE_CLEAR_CLOSE_SCHEDULE,
|
||||
SERVICE_SET_NAUTICAL_SCHEDULE,
|
||||
):
|
||||
if hass.services.has_service(DOMAIN, service):
|
||||
|
||||
BIN
Binary file not shown.
BIN
Binary file not shown.
@@ -33,6 +33,8 @@ SERVICE_STOP_ROOF = "stop_roof"
|
||||
SERVICE_POWER_ON = "power_on"
|
||||
SERVICE_POWER_OFF = "power_off"
|
||||
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"
|
||||
|
||||
ENTITY_ROOF_COVER: Final = "roof"
|
||||
|
||||
@@ -200,6 +200,14 @@ class SternwarteDataUpdateCoordinator(DataUpdateCoordinator[SternwarteState]):
|
||||
await self._async_save_runtime_state()
|
||||
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:
|
||||
"""Persist close schedule."""
|
||||
when = self._normalize_user_schedule_datetime(when)
|
||||
|
||||
@@ -74,6 +74,12 @@ class SternwarteRoofStatusSensor(SternwarteEntity, SensorEntity):
|
||||
return "sensorfehler"
|
||||
return "unbekannt"
|
||||
|
||||
@property
|
||||
def extra_state_attributes(self) -> dict[str, object]:
|
||||
return {
|
||||
"inputs": list(self.coordinator.data.inputs),
|
||||
}
|
||||
|
||||
|
||||
class SternwarteWindSpeedSensor(SternwarteWindBaseSensor):
|
||||
_attr_name = "Windgeschwindigkeit"
|
||||
|
||||
@@ -22,6 +22,14 @@ clear_schedule:
|
||||
name: Zeitplanung löschen
|
||||
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:
|
||||
name: Nautische Dämmerung setzen
|
||||
description: Setzt Öffnen und Schließen auf die nächste nautische Abend- und Morgendämmerung.
|
||||
|
||||
Reference in New Issue
Block a user