1403 lines
46 KiB
JavaScript
1403 lines
46 KiB
JavaScript
const DEFAULT_ENTITIES = {
|
|
roof: "cover.sternwarte_dach",
|
|
power: "switch.sternwarte_dachsteuerung",
|
|
roofClosed: "binary_sensor.sternwarte_dach_zu",
|
|
roofOpen: "binary_sensor.sternwarte_dach_offen",
|
|
roofMoving: "binary_sensor.sternwarte_dach_bewegt",
|
|
roofStatus: "sensor.sternwarte_dachstatus",
|
|
roofPosition: "sensor.sternwarte_dachposition",
|
|
rain: "binary_sensor.sternwarte_regen",
|
|
wind: "sensor.sternwarte_windgeschwindigkeit",
|
|
scheduleOpen: "datetime.sternwarte_offnen_geplant",
|
|
scheduleClose: "datetime.sternwarte_schliessen_geplant",
|
|
astroLat: 50.2984230,
|
|
astroLon: 11.0481766,
|
|
};
|
|
|
|
const ASTRONOMY_LIB_URL = (() => {
|
|
try {
|
|
return new URL("./astronomy.browser.min.js", import.meta.url).toString();
|
|
} catch (error) {
|
|
return null;
|
|
}
|
|
})();
|
|
|
|
const MOON_IMAGE_URL = (() => {
|
|
try {
|
|
return new URL("./moon_small.png", import.meta.url).toString();
|
|
} catch (error) {
|
|
return null;
|
|
}
|
|
})();
|
|
|
|
class SternwarteCard extends HTMLElement {
|
|
constructor() {
|
|
super();
|
|
this._scheduleInputState = {
|
|
open: { draft: "", editing: false, pending: false },
|
|
close: { draft: "", editing: false, pending: false },
|
|
};
|
|
this._pendingRender = false;
|
|
this._astronomyLoadPromise = null;
|
|
this._moonTexture = null;
|
|
this._moonTexturePromise = null;
|
|
}
|
|
|
|
connectedCallback() {
|
|
this._ensureAstronomy();
|
|
this._ensureMoonTexture();
|
|
this._ensureClockTimer();
|
|
}
|
|
|
|
disconnectedCallback() {
|
|
if (this._clockTimer) {
|
|
window.clearInterval(this._clockTimer);
|
|
this._clockTimer = null;
|
|
}
|
|
}
|
|
|
|
setConfig(config) {
|
|
this.config = { ...DEFAULT_ENTITIES, ...config };
|
|
if (!this.shadowRoot) {
|
|
this.attachShadow({ mode: "open" });
|
|
}
|
|
}
|
|
|
|
set hass(hass) {
|
|
this._hass = hass;
|
|
this._ensureClockTimer();
|
|
this._requestRender();
|
|
}
|
|
|
|
getCardSize() {
|
|
return 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._requestRender();
|
|
}, 30000);
|
|
}
|
|
|
|
_astronomy() {
|
|
return window.Astronomy || null;
|
|
}
|
|
|
|
_ensureAstronomy() {
|
|
const astronomy = this._astronomy();
|
|
if (astronomy) return Promise.resolve(astronomy);
|
|
if (this._astronomyLoadPromise) return this._astronomyLoadPromise;
|
|
if (!ASTRONOMY_LIB_URL) return Promise.resolve(null);
|
|
|
|
this._astronomyLoadPromise = new Promise((resolve) => {
|
|
const finish = () => {
|
|
const loaded = this._astronomy();
|
|
if (loaded && this._hass) {
|
|
this._requestRender();
|
|
}
|
|
this._astronomyLoadPromise = null;
|
|
resolve(loaded || null);
|
|
};
|
|
|
|
const existing = Array.from(document.querySelectorAll("script"))
|
|
.find((script) => script.src === ASTRONOMY_LIB_URL);
|
|
|
|
if (existing) {
|
|
existing.addEventListener("load", finish, { once: true });
|
|
existing.addEventListener("error", finish, { once: true });
|
|
return;
|
|
}
|
|
|
|
const script = document.createElement("script");
|
|
script.src = ASTRONOMY_LIB_URL;
|
|
script.async = true;
|
|
script.addEventListener("load", finish, { once: true });
|
|
script.addEventListener("error", finish, { once: true });
|
|
document.head.appendChild(script);
|
|
});
|
|
|
|
return this._astronomyLoadPromise;
|
|
}
|
|
|
|
_ensureMoonTexture() {
|
|
if (this._moonTexture) return Promise.resolve(this._moonTexture);
|
|
if (this._moonTexturePromise) return this._moonTexturePromise;
|
|
if (!MOON_IMAGE_URL) return Promise.resolve(null);
|
|
|
|
this._moonTexturePromise = new Promise((resolve) => {
|
|
const image = new Image();
|
|
image.addEventListener("load", () => {
|
|
this._moonTexture = image;
|
|
this._moonTexturePromise = null;
|
|
if (this._hass) this._requestRender();
|
|
resolve(image);
|
|
}, { once: true });
|
|
image.addEventListener("error", () => {
|
|
this._moonTexturePromise = null;
|
|
resolve(null);
|
|
}, { once: true });
|
|
image.src = MOON_IMAGE_URL;
|
|
});
|
|
|
|
return this._moonTexturePromise;
|
|
}
|
|
|
|
_requestRender() {
|
|
if (this._isScheduleEditing()) {
|
|
this._pendingRender = true;
|
|
return;
|
|
}
|
|
this._pendingRender = false;
|
|
this.render();
|
|
}
|
|
|
|
_isScheduleEditing() {
|
|
return Object.values(this._scheduleInputState).some((field) => field.editing);
|
|
}
|
|
|
|
_scheduleFieldState(key) {
|
|
return this._scheduleInputState[key];
|
|
}
|
|
|
|
_scheduleInputValue(key, fallbackState) {
|
|
const field = this._scheduleFieldState(key);
|
|
if (field.draft) return field.draft;
|
|
return this._isoForInput(fallbackState);
|
|
}
|
|
|
|
_resetScheduleDrafts() {
|
|
for (const field of Object.values(this._scheduleInputState)) {
|
|
field.draft = "";
|
|
field.editing = false;
|
|
field.pending = false;
|
|
}
|
|
}
|
|
|
|
_beginScheduleEdit(key, fallbackState) {
|
|
const field = this._scheduleFieldState(key);
|
|
field.editing = true;
|
|
field.pending = false;
|
|
if (!field.draft) {
|
|
field.draft = this._isoForInput(fallbackState);
|
|
}
|
|
}
|
|
|
|
_updateScheduleDraft(key, value) {
|
|
const field = this._scheduleFieldState(key);
|
|
field.draft = value;
|
|
field.pending = false;
|
|
}
|
|
|
|
_endScheduleEdit(key, event) {
|
|
const field = this._scheduleFieldState(key);
|
|
field.editing = false;
|
|
if (!this._isScheduleEditing() && this._pendingRender) {
|
|
this._requestRender();
|
|
}
|
|
}
|
|
|
|
_bindScheduleInput(key, inputId, fallbackState) {
|
|
const input = this.shadowRoot.getElementById(inputId);
|
|
if (!input) return;
|
|
input.addEventListener("focus", () => this._beginScheduleEdit(key, fallbackState));
|
|
input.addEventListener("input", (event) => this._updateScheduleDraft(key, event.target.value));
|
|
input.addEventListener("change", (event) => this._updateScheduleDraft(key, event.target.value));
|
|
input.addEventListener("blur", (event) => this._endScheduleEdit(key, event));
|
|
}
|
|
|
|
_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();
|
|
});
|
|
}
|
|
|
|
_showMoreInfo(entityId) {
|
|
if (!entityId) return;
|
|
const event = new Event("hass-more-info", {
|
|
bubbles: true,
|
|
composed: true,
|
|
});
|
|
event.detail = { entityId };
|
|
this.dispatchEvent(event);
|
|
}
|
|
|
|
_bindMoreInfo(id, entityId) {
|
|
const element = this.shadowRoot.getElementById(id);
|
|
if (!element || !entityId) return;
|
|
|
|
const openMoreInfo = (event) => {
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
this._showMoreInfo(entityId);
|
|
};
|
|
|
|
element.addEventListener("click", openMoreInfo);
|
|
element.addEventListener("keydown", (event) => {
|
|
if (event.key === "Enter" || event.key === " ") {
|
|
openMoreInfo(event);
|
|
}
|
|
});
|
|
}
|
|
|
|
_setDateTime(entityId, inputId, key) {
|
|
const input = this.shadowRoot.getElementById(inputId);
|
|
const field = this._scheduleFieldState(key);
|
|
const value = field?.draft || input?.value || "";
|
|
if (!value) return;
|
|
if (!entityId) return;
|
|
field.pending = true;
|
|
field.editing = false;
|
|
field.draft = value;
|
|
this._hass.callService("datetime", "set_value", {
|
|
entity_id: entityId,
|
|
datetime: value,
|
|
});
|
|
if (this._pendingRender) {
|
|
this._requestRender();
|
|
}
|
|
}
|
|
|
|
_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";
|
|
const animStyle = (on && extraClass) ? ` style="animation-delay: -${Date.now() % 1000}ms"` : "";
|
|
return `
|
|
<div class="badge ${on ? "on" : ""} ${extraClass}"${animStyle}>
|
|
<span class="badge-label">${label}</span>
|
|
<span class="badge-value">${on ? onText : offText}</span>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
_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 "";
|
|
}
|
|
|
|
_powerIndicatorClassName() {
|
|
const state = this._state(this._powerEntityId())?.state;
|
|
if (state === "on") return "on";
|
|
if (state === "off") return "off";
|
|
return "unknown";
|
|
}
|
|
|
|
_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);
|
|
}
|
|
|
|
_astroObserver() {
|
|
const astronomy = this._astronomy();
|
|
if (!astronomy) return null;
|
|
const lat = Number(this.config.astroLat);
|
|
const lon = Number(this.config.astroLon);
|
|
if (!Number.isFinite(lat) || !Number.isFinite(lon)) return null;
|
|
return new astronomy.Observer(lat, lon, 0);
|
|
}
|
|
|
|
_astroTimeToUtcHour(eventTime) {
|
|
if (!eventTime) return null;
|
|
const date = eventTime.date instanceof Date ? eventTime.date : null;
|
|
if (!date || Number.isNaN(date.getTime())) return null;
|
|
return date.getUTCHours() + date.getUTCMinutes() / 60 + date.getUTCSeconds() / 3600;
|
|
}
|
|
|
|
_calcTwilight(date) {
|
|
const astronomy = this._astronomy();
|
|
const observer = this._astroObserver();
|
|
if (!astronomy || !observer) {
|
|
return {
|
|
sun: { dawn: null, dusk: null },
|
|
civil: { dawn: null, dusk: null },
|
|
naut: { dawn: null, dusk: null },
|
|
astro: { dawn: null, dusk: null },
|
|
};
|
|
}
|
|
|
|
try {
|
|
const bodySun = astronomy.Body?.Sun ?? "Sun";
|
|
const start = new Date(date);
|
|
start.setHours(0, 0, 0, 0);
|
|
const limitDays = 2;
|
|
const altitudeEvent = (altitude) => ({
|
|
dawn: this._astroTimeToUtcHour(astronomy.SearchAltitude(bodySun, observer, +1, start, limitDays, altitude)),
|
|
dusk: this._astroTimeToUtcHour(astronomy.SearchAltitude(bodySun, observer, -1, start, limitDays, altitude)),
|
|
});
|
|
|
|
return {
|
|
sun: {
|
|
dawn: this._astroTimeToUtcHour(astronomy.SearchRiseSet(bodySun, observer, +1, start, limitDays, 0)),
|
|
dusk: this._astroTimeToUtcHour(astronomy.SearchRiseSet(bodySun, observer, -1, start, limitDays, 0)),
|
|
},
|
|
civil: altitudeEvent(-6),
|
|
naut: altitudeEvent(-12),
|
|
astro: altitudeEvent(-18),
|
|
};
|
|
} catch (error) {
|
|
return {
|
|
sun: { dawn: null, dusk: null },
|
|
civil: { dawn: null, dusk: null },
|
|
naut: { dawn: null, dusk: null },
|
|
astro: { dawn: null, dusk: null },
|
|
};
|
|
}
|
|
}
|
|
|
|
_moonStateAt(date) {
|
|
const astronomy = this._astronomy();
|
|
const observer = this._astroObserver();
|
|
if (!astronomy || !observer) {
|
|
return { altitude: Number.NEGATIVE_INFINITY, illumination: 0, phaseAngle: 0 };
|
|
}
|
|
|
|
try {
|
|
const bodyMoon = astronomy.Body?.Moon ?? "Moon";
|
|
const equator = astronomy.Equator(bodyMoon, date, observer, true, true);
|
|
const horizon = astronomy.Horizon(date, observer, equator.ra, equator.dec, "normal");
|
|
const illumination = astronomy.Illumination(bodyMoon, date);
|
|
const phaseAngle = Number(astronomy.MoonPhase(date)) || 0;
|
|
|
|
return {
|
|
altitude: (Number(horizon?.altitude) || 0) * Math.PI / 180,
|
|
illumination: Math.max(0, Math.min(1, Number(illumination?.phase_fraction) || 0)),
|
|
phaseAngle: ((phaseAngle % 360) + 360) % 360,
|
|
};
|
|
} catch (error) {
|
|
return { altitude: Number.NEGATIVE_INFINITY, illumination: 0, phaseAngle: 0 };
|
|
}
|
|
}
|
|
|
|
_moonVisualState() {
|
|
const moon = this._moonStateAt(new Date());
|
|
const phaseAngle = moon.phaseAngle ?? 0;
|
|
const waxing = phaseAngle <= 180;
|
|
const radians = phaseAngle * Math.PI / 180;
|
|
|
|
return {
|
|
illumination: Math.max(0, Math.min(1, moon.illumination || 0)),
|
|
illuminationPercent: Math.round((moon.illumination || 0) * 100),
|
|
waxing,
|
|
phaseAngle,
|
|
cosPhase: Math.cos(radians),
|
|
};
|
|
}
|
|
|
|
_renderMoonPhaseDataUrl(moonVisual, size = 64) {
|
|
const texture = this._moonTexture;
|
|
if (!texture) return "";
|
|
|
|
const canvas = document.createElement("canvas");
|
|
canvas.width = size;
|
|
canvas.height = size;
|
|
const ctx = canvas.getContext("2d");
|
|
if (!ctx) return "";
|
|
|
|
const radius = size / 2 - 1;
|
|
const x = size / 2;
|
|
const y = size / 2;
|
|
const cosPhase = Number(moonVisual.cosPhase) || 0;
|
|
|
|
ctx.save();
|
|
ctx.beginPath();
|
|
ctx.arc(x, y, radius, 0, Math.PI * 2);
|
|
ctx.clip();
|
|
|
|
ctx.fillStyle = "rgba(8,10,16,0.96)";
|
|
ctx.beginPath();
|
|
ctx.arc(x, y, radius, 0, Math.PI * 2);
|
|
ctx.fill();
|
|
|
|
ctx.globalAlpha = 0.98;
|
|
ctx.drawImage(texture, x - radius, y - radius, radius * 2, radius * 2);
|
|
ctx.globalAlpha = 1;
|
|
|
|
ctx.fillStyle = "rgba(8,10,16,0.96)";
|
|
ctx.beginPath();
|
|
ctx.arc(x, y, radius, 0, Math.PI * 2);
|
|
ctx.fill();
|
|
|
|
ctx.save();
|
|
ctx.beginPath();
|
|
ctx.arc(x, y, radius, 0, Math.PI * 2);
|
|
ctx.clip();
|
|
|
|
if (moonVisual.illumination >= 0.999) {
|
|
ctx.drawImage(texture, x - radius, y - radius, radius * 2, radius * 2);
|
|
} else if (moonVisual.illumination > 0.001) {
|
|
ctx.beginPath();
|
|
for (let i = 0; i <= 72; i += 1) {
|
|
const t = -radius + (2 * radius * i) / 72;
|
|
const xr = Math.sqrt(Math.max(0, radius * radius - t * t));
|
|
const xt = (moonVisual.waxing ? cosPhase : -cosPhase) * xr;
|
|
if (i === 0) {
|
|
ctx.moveTo(x + xt, y + t);
|
|
} else {
|
|
ctx.lineTo(x + xt, y + t);
|
|
}
|
|
}
|
|
for (let i = 72; i >= 0; i -= 1) {
|
|
const t = -radius + (2 * radius * i) / 72;
|
|
const xr = Math.sqrt(Math.max(0, radius * radius - t * t));
|
|
ctx.lineTo(x + (moonVisual.waxing ? xr : -xr), y + t);
|
|
}
|
|
ctx.closePath();
|
|
ctx.clip();
|
|
ctx.drawImage(texture, x - radius, y - radius, radius * 2, radius * 2);
|
|
}
|
|
|
|
ctx.restore();
|
|
ctx.restore();
|
|
|
|
ctx.strokeStyle = "rgba(255,255,255,0.18)";
|
|
ctx.lineWidth = 1;
|
|
ctx.beginPath();
|
|
ctx.arc(x, y, radius, 0, Math.PI * 2);
|
|
ctx.stroke();
|
|
|
|
return canvas.toDataURL("image/png");
|
|
}
|
|
|
|
_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 powerIndicatorClassName = this._powerIndicatorClassName();
|
|
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 openInputValue = this._scheduleInputValue("open", openSchedule);
|
|
const closeInputValue = this._scheduleInputValue("close", closeSchedule);
|
|
if (openInputValue === this._isoForInput(openSchedule)) {
|
|
this._scheduleFieldState("open").draft = "";
|
|
this._scheduleFieldState("open").pending = false;
|
|
}
|
|
if (closeInputValue === this._isoForInput(closeSchedule)) {
|
|
this._scheduleFieldState("close").draft = "";
|
|
this._scheduleFieldState("close").pending = false;
|
|
}
|
|
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 powerState = this._state(powerEntityId)?.state;
|
|
const powerOn = powerState === "on";
|
|
const powerOff = powerState === "off";
|
|
const powerOnButtonDisabled = powerOn;
|
|
const powerOffButtonDisabled = powerOff;
|
|
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();
|
|
const moonVisual = this._moonVisualState();
|
|
const moonImageDataUrl = this._renderMoonPhaseDataUrl(moonVisual, 56);
|
|
|
|
this.shadowRoot.innerHTML = `
|
|
<style>
|
|
:host {
|
|
display: block;
|
|
}
|
|
.wrap {
|
|
padding: 16px;
|
|
display: grid;
|
|
gap: 14px;
|
|
}
|
|
.hero {
|
|
display: grid;
|
|
grid-template-columns: 1fr;
|
|
gap: 12px;
|
|
}
|
|
.panel {
|
|
border: 1px solid var(--divider-color);
|
|
border-radius: 16px;
|
|
padding: 14px;
|
|
background: linear-gradient(180deg, rgba(255,255,255,0.045), rgba(255,255,255,0.02));
|
|
overflow: hidden;
|
|
}
|
|
.panel.control-panel.on {
|
|
border-color: rgba(76, 175, 80, 0.38);
|
|
background: linear-gradient(180deg, rgba(76, 175, 80, 0.24), rgba(76, 175, 80, 0.10));
|
|
}
|
|
.panel.control-panel.off {
|
|
border-color: rgba(198, 40, 40, 0.40);
|
|
background: linear-gradient(180deg, rgba(198, 40, 40, 0.24), rgba(198, 40, 40, 0.10));
|
|
}
|
|
.panel.control-panel.unknown {
|
|
border-color: rgba(209, 169, 74, 0.35);
|
|
background: linear-gradient(180deg, rgba(209, 169, 74, 0.18), rgba(209, 169, 74, 0.08));
|
|
}
|
|
.title {
|
|
font-size: 12px;
|
|
letter-spacing: 0.08em;
|
|
text-transform: uppercase;
|
|
opacity: 0.7;
|
|
margin-bottom: 10px;
|
|
}
|
|
.status-big {
|
|
font-size: 22px;
|
|
font-weight: 700;
|
|
line-height: 1.1;
|
|
}
|
|
.sub {
|
|
margin-top: 8px;
|
|
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));
|
|
gap: 8px;
|
|
margin-top: 4px;
|
|
}
|
|
.stat {
|
|
border-radius: 10px;
|
|
padding: 7px 9px;
|
|
background: rgba(255,255,255,0.04);
|
|
border: 1px solid rgba(255,255,255,0.08);
|
|
}
|
|
.stat.clickable {
|
|
cursor: pointer;
|
|
transition: transform 140ms ease, border-color 140ms ease, background 140ms ease;
|
|
}
|
|
.stat.clickable:hover {
|
|
transform: translateY(-1px);
|
|
border-color: rgba(255,255,255,0.2);
|
|
background: rgba(255,255,255,0.07);
|
|
}
|
|
.stat.clickable:focus-visible {
|
|
outline: 2px solid var(--primary-color);
|
|
outline-offset: 2px;
|
|
}
|
|
.stat.wind-good {
|
|
background: rgba(76, 175, 80, 0.14);
|
|
border-color: rgba(76, 175, 80, 0.3);
|
|
}
|
|
.stat.wind-warn {
|
|
background: rgba(255, 179, 0, 0.16);
|
|
border-color: rgba(255, 179, 0, 0.35);
|
|
}
|
|
.stat.wind-danger {
|
|
background: rgba(198, 40, 40, 0.16);
|
|
border-color: rgba(198, 40, 40, 0.34);
|
|
}
|
|
.stat-label {
|
|
display: block;
|
|
font-size: 12px;
|
|
opacity: 0.7;
|
|
}
|
|
.stat-value {
|
|
display: block;
|
|
margin-top: 4px;
|
|
font-size: 16px;
|
|
font-weight: 700;
|
|
}
|
|
.moon-stat {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
gap: 10px;
|
|
}
|
|
.moon-icon {
|
|
width: 28px;
|
|
height: 28px;
|
|
flex: 0 0 28px;
|
|
border-radius: 50%;
|
|
display: block;
|
|
object-fit: cover;
|
|
box-shadow: 0 0 10px rgba(246, 240, 196, 0.12);
|
|
}
|
|
.moon-percent {
|
|
text-align: right;
|
|
line-height: 1.05;
|
|
}
|
|
.moon-percent .stat-value {
|
|
margin-top: 2px;
|
|
}
|
|
.position {
|
|
margin-top: 10px;
|
|
}
|
|
.position-top {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
gap: 12px;
|
|
font-size: 13px;
|
|
margin-bottom: 8px;
|
|
}
|
|
.position-track {
|
|
height: 12px;
|
|
border-radius: 999px;
|
|
background: rgba(255,255,255,0.08);
|
|
overflow: hidden;
|
|
border: 1px solid rgba(255,255,255,0.08);
|
|
}
|
|
.position-fill {
|
|
height: 100%;
|
|
border-radius: inherit;
|
|
background: linear-gradient(90deg, #d1a94a, #69b36d);
|
|
transition: width 160ms ease;
|
|
}
|
|
.astro {
|
|
margin-top: 10px;
|
|
padding: 8px 10px 10px;
|
|
border-radius: 10px;
|
|
background: rgba(255,255,255,0.03);
|
|
border: 1px solid rgba(255,255,255,0.07);
|
|
}
|
|
.astro-meta {
|
|
display: grid;
|
|
grid-template-columns: 1fr;
|
|
gap: 8px;
|
|
font-size: 12px;
|
|
margin-bottom: 8px;
|
|
}
|
|
.astro-meta span {
|
|
display: block;
|
|
opacity: 0.7;
|
|
}
|
|
.astro-meta strong {
|
|
display: block;
|
|
margin-top: 2px;
|
|
font-size: 14px;
|
|
}
|
|
.astro-scale {
|
|
position: relative;
|
|
padding-top: 18px;
|
|
}
|
|
.astro-bar {
|
|
position: relative;
|
|
height: 32px;
|
|
border-radius: 999px;
|
|
overflow: hidden;
|
|
background: rgba(255,255,255,0.06);
|
|
border: 1px solid rgba(255,255,255,0.08);
|
|
}
|
|
.astro-tick {
|
|
position: absolute;
|
|
top: 17px;
|
|
height: 36px;
|
|
width: 2px;
|
|
margin-left: -1px;
|
|
background: rgba(255,255,255,0.5);
|
|
z-index: 2;
|
|
}
|
|
.astro-tick-label {
|
|
position: absolute;
|
|
top: 0;
|
|
transform: translateX(-50%);
|
|
font-size: 10px;
|
|
font-weight: 700;
|
|
line-height: 1;
|
|
color: rgba(255,255,255,0.72);
|
|
letter-spacing: 0.02em;
|
|
pointer-events: none;
|
|
z-index: 2;
|
|
}
|
|
.astro-now {
|
|
position: absolute;
|
|
top: -4px;
|
|
bottom: -4px;
|
|
width: 4px;
|
|
margin-left: -1px;
|
|
background: #d32f2f;
|
|
box-shadow: 0 0 8px rgba(211, 47, 47, 0.55);
|
|
}
|
|
.astro-marker {
|
|
position: absolute;
|
|
top: -3px;
|
|
bottom: -3px;
|
|
width: 4px;
|
|
margin-left: -1px;
|
|
box-shadow: 0 0 6px rgba(255, 255, 255, 0.25);
|
|
}
|
|
.astro-marker.open {
|
|
background: #4caf50;
|
|
}
|
|
.astro-marker.close {
|
|
background: #ffb300;
|
|
}
|
|
.astro-moon-track {
|
|
position: absolute;
|
|
background: rgba(255, 255, 255, 0.96);
|
|
height: 8px;
|
|
top: 2px;
|
|
bottom: auto;
|
|
margin-left: 0;
|
|
border-radius: 999px;
|
|
border: 1px solid rgba(12, 18, 28, 0.85);
|
|
box-sizing: border-box;
|
|
box-shadow: 0 0 8px rgba(255, 255, 255, 0.45);
|
|
z-index: 2;
|
|
}
|
|
.badges {
|
|
display: grid;
|
|
grid-template-columns: repeat(4, minmax(0, 1fr));
|
|
gap: 8px;
|
|
margin-top: 10px;
|
|
}
|
|
.badge {
|
|
border-radius: 10px;
|
|
padding: 8px;
|
|
background: rgba(255,255,255,0.04);
|
|
border: 1px solid rgba(255,255,255,0.08);
|
|
}
|
|
.badge.on {
|
|
background: rgba(76, 175, 80, 0.12);
|
|
border-color: rgba(76, 175, 80, 0.3);
|
|
}
|
|
.badge.moving.on {
|
|
animation: moving-badge-blink 1s linear infinite;
|
|
background: rgba(255, 179, 0, 0.2);
|
|
border-color: rgba(255, 179, 0, 0.55);
|
|
}
|
|
.badge.rain.on {
|
|
animation: rain-badge-blink 1s linear infinite;
|
|
background: rgba(220, 50, 50, 0.2);
|
|
border-color: rgba(220, 50, 50, 0.55);
|
|
}
|
|
.badge-label {
|
|
display: block;
|
|
font-size: 12px;
|
|
opacity: 0.7;
|
|
}
|
|
.badge-value {
|
|
display: block;
|
|
font-size: 16px;
|
|
font-weight: 600;
|
|
margin-top: 4px;
|
|
}
|
|
@keyframes moving-badge-blink {
|
|
0%, 100% {
|
|
opacity: 1;
|
|
box-shadow: 0 0 0 rgba(255, 179, 0, 0);
|
|
}
|
|
50% {
|
|
opacity: 0.45;
|
|
box-shadow: 0 0 14px rgba(255, 179, 0, 0.35);
|
|
}
|
|
}
|
|
@keyframes rain-badge-blink {
|
|
0%, 100% {
|
|
opacity: 1;
|
|
box-shadow: 0 0 0 rgba(220, 50, 50, 0);
|
|
}
|
|
50% {
|
|
opacity: 0.45;
|
|
box-shadow: 0 0 14px rgba(220, 50, 50, 0.5);
|
|
}
|
|
}
|
|
.controls {
|
|
display: grid;
|
|
grid-template-columns: repeat(3, 1fr);
|
|
gap: 8px;
|
|
}
|
|
button {
|
|
border: none;
|
|
border-radius: 12px;
|
|
padding: 12px 10px;
|
|
font: inherit;
|
|
font-weight: 600;
|
|
cursor: pointer;
|
|
background: var(--primary-color);
|
|
color: white;
|
|
}
|
|
button:disabled {
|
|
opacity: 0.45;
|
|
cursor: not-allowed;
|
|
}
|
|
button.secondary {
|
|
background: rgba(255,255,255,0.08);
|
|
color: var(--primary-text-color);
|
|
}
|
|
button.warn {
|
|
background: #c62828;
|
|
}
|
|
.schedule-grid {
|
|
display: grid;
|
|
grid-template-columns: minmax(0, 196px) max-content;
|
|
gap: 8px;
|
|
align-items: center;
|
|
justify-content: start;
|
|
margin-top: 10px;
|
|
}
|
|
input[type="datetime-local"] {
|
|
width: 100%;
|
|
min-width: 0;
|
|
max-width: 196px;
|
|
box-sizing: border-box;
|
|
padding: 10px 12px;
|
|
border-radius: 12px;
|
|
border: 1px solid rgba(255,255,255,0.12);
|
|
background: rgba(0,0,0,0.18);
|
|
color: var(--primary-text-color);
|
|
font: inherit;
|
|
}
|
|
.schedule-grid button {
|
|
white-space: nowrap;
|
|
}
|
|
.inline-actions {
|
|
display: flex;
|
|
gap: 8px;
|
|
flex-wrap: wrap;
|
|
margin-top: 12px;
|
|
align-items: center;
|
|
}
|
|
.schedule-overview {
|
|
display: grid;
|
|
grid-template-columns: repeat(2, 1fr);
|
|
gap: 8px;
|
|
margin-bottom: 12px;
|
|
}
|
|
.schedule-card {
|
|
border-radius: 10px;
|
|
padding: 8px 10px;
|
|
background: rgba(255,255,255,0.04);
|
|
border: 1px solid rgba(255,255,255,0.08);
|
|
}
|
|
.schedule-label {
|
|
display: block;
|
|
font-size: 12px;
|
|
opacity: 0.7;
|
|
}
|
|
.schedule-value {
|
|
display: block;
|
|
margin-top: 4px;
|
|
font-size: 14px;
|
|
font-weight: 700;
|
|
}
|
|
.hint {
|
|
margin-top: 10px;
|
|
font-size: 12px;
|
|
color: #ffcc80;
|
|
}
|
|
@media (max-width: 700px) {
|
|
.hero {
|
|
grid-template-columns: 1fr;
|
|
}
|
|
.badges {
|
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
|
}
|
|
.controls {
|
|
grid-template-columns: 1fr;
|
|
}
|
|
.schedule-grid {
|
|
grid-template-columns: 1fr;
|
|
}
|
|
input[type="datetime-local"] {
|
|
width: 100%;
|
|
}
|
|
.stats {
|
|
grid-template-columns: 1fr;
|
|
}
|
|
.schedule-overview {
|
|
grid-template-columns: 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">
|
|
<div class="stat">
|
|
<span class="stat-label">\u00d6ffnen geplant</span>
|
|
<span class="stat-value" style="font-size:16px">${this._formatDateTime(openSchedule)}</span>
|
|
</div>
|
|
<div class="stat">
|
|
<span class="stat-label">Schlie\u00dfen geplant</span>
|
|
<span class="stat-value" style="font-size:16px">${this._formatDateTime(closeSchedule)}</span>
|
|
</div>
|
|
<div
|
|
class="stat ${windIndicator.className} clickable"
|
|
id="wind-stat"
|
|
role="button"
|
|
tabindex="0"
|
|
aria-label="Windverlauf öffnen"
|
|
>
|
|
<span class="stat-label">Wind</span>
|
|
<span class="stat-value">${windIndicator.display}</span>
|
|
</div>
|
|
<div class="stat">
|
|
<span class="stat-label">Mond</span>
|
|
<div class="moon-stat">
|
|
<img
|
|
class="moon-icon"
|
|
src="${moonImageDataUrl}"
|
|
alt="Mondphase"
|
|
aria-hidden="true"
|
|
>
|
|
<div class="moon-percent">
|
|
<span class="stat-value">${moonVisual.illuminationPercent} %</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="badges">
|
|
${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")}
|
|
</div>
|
|
<div class="astro">
|
|
<div class="astro-meta">
|
|
<div><span>Jetzt</span><strong>${astro.now}</strong></div>
|
|
</div>
|
|
<div class="astro-scale">
|
|
<div class="astro-tick" style="left:0%"></div>
|
|
<div class="astro-tick-label" style="left:0%">12</div>
|
|
<div class="astro-tick" style="left:25%"></div>
|
|
<div class="astro-tick-label" style="left:25%">18</div>
|
|
<div class="astro-tick" style="left:50%"></div>
|
|
<div class="astro-tick-label" style="left:50%">0</div>
|
|
<div class="astro-tick" style="left:75%"></div>
|
|
<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}">
|
|
${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>`}
|
|
<div class="astro-now" style="left:${astro.markerLeft}"></div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="position">
|
|
<div class="position-top">
|
|
<span>Dachposition</span>
|
|
<strong>${roofPosition === null ? "\u2014" : `${roofPosition} %`}</strong>
|
|
</div>
|
|
<div class="position-track">
|
|
<div class="position-fill" style="width:${roofPosition ?? 0}%"></div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="panel control-panel ${powerIndicatorClassName}">
|
|
<div class="title">Steuerung</div>
|
|
<div class="controls">
|
|
<button type="button" id="open-btn" ${openButtonDisabled ? "disabled" : ""}>\u00d6ffnen</button>
|
|
<button type="button" class="warn" id="stop-btn" ${stopButtonDisabled ? "disabled" : ""}>Stopp</button>
|
|
<button type="button" class="secondary" id="close-btn" ${closeButtonDisabled ? "disabled" : ""}>Schlie\u00dfen</button>
|
|
</div>
|
|
<div class="inline-actions">
|
|
<button type="button" class="secondary" id="power-on-btn" ${powerOnButtonDisabled ? "disabled" : ""}>Dachsteuerung ein</button>
|
|
<button type="button" class="secondary" id="power-off-btn" ${powerOffButtonDisabled ? "disabled" : ""}>Dachsteuerung aus</button>
|
|
</div>
|
|
${openBlockedReason ? `<div class="hint">${openBlockedReason}</div>` : ""}
|
|
</div>
|
|
|
|
<div class="panel">
|
|
<div class="title">Zeitplanung</div>
|
|
<div class="schedule-overview">
|
|
<div class="schedule-card">
|
|
<span class="schedule-label">\u00d6ffnen geplant</span>
|
|
<span class="schedule-value">${this._formatDateTime(openSchedule)}</span>
|
|
</div>
|
|
<div class="schedule-card">
|
|
<span class="schedule-label">Schlie\u00dfen geplant</span>
|
|
<span class="schedule-value">${this._formatDateTime(closeSchedule)}</span>
|
|
</div>
|
|
</div>
|
|
<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>
|
|
</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>
|
|
</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>
|
|
</ha-card>
|
|
`;
|
|
|
|
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._resetScheduleDrafts();
|
|
this._call("sternwarte", "clear_schedule");
|
|
this._requestRender();
|
|
});
|
|
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._bindMoreInfo("wind-stat", this.config.wind);
|
|
this._bindScheduleInput("open", "open-schedule-input", openSchedule);
|
|
this._bindScheduleInput("close", "close-schedule-input", closeSchedule);
|
|
}
|
|
}
|
|
|
|
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",
|
|
});
|
|
}
|