From d7cbc1384a502ff5bd6ad532ea079e48aac1256c Mon Sep 17 00:00:00 2001 From: coasting24 <55407157+coasting24@users.noreply.github.com> Date: Sat, 7 Nov 2026 09:32:33 +0100 Subject: [PATCH] Plug lock per device: switch, ask first, or measure only Some smart plugs only measure (a fridge, a freezer, a server). A "Plug" setting in the card settings of every switch and every device card with a switchable plug: switch (default), ask first (a confirmation before every switch action) or measure only (no switch button, the card never switches, timer chips and the power action hidden). It applies wherever the panel switches that plug; a device card and its switch: the stricter one wins. Co-Authored-By: Claude Opus 5.5 --- src/cards.js | 20 +++++++++++++++----- src/lang/de.js | 6 ++++++ src/lang/en.js | 6 ++++++ src/popups.js | 10 ++++++---- 4 files changed, 33 insertions(+), 9 deletions(-) diff --git a/src/cards.js b/src/cards.js index 864804e..27bc813 100644 --- a/src/cards.js +++ b/src/cards.js @@ -121,6 +121,14 @@ export const hasActs = (p, id) => (cardCfg(p, id).actions || []).some(a => a && export const label = (p, e) => cardCfg(p, e.id).name || name(p.hass, e); export const isHidden = (p, id) => !!cardCfg(p, id).hidden; export const icoFor = (p, id, mdi, svg) => uico(cardCfg(p, id).icon, mdi, svg); +/** how a plug may be switched, set per card ("plug"): "" = normally, "confirm" = after asking, "lock" = never (measure + only, e.g. a fridge). A device card and its switch entity each have settings; the stricter one counts */ +export const plugMode = (p, ...keys) => { const m = keys.filter(Boolean).map(k => cardCfg(p, k).plug); return m.includes("lock") ? "lock" : m.includes("confirm") ? "confirm" : ""; }; +/** `run` (switching a plug) guarded by plugMode: null when locked, a confirmation first when asked for */ +export function guardPlug(p, keys, title, on, run) { + const m = plugMode(p, ...keys); if (m === "lock") return null; if (m !== "confirm") return run; + return () => p.openPopup({ type: "confirm", title: t("Stekker"), big: title, sub: on ? t("Echt uitzetten?") : t("Echt aanzetten?"), okLabel: on ? t("Uitzetten") : t("Aanzetten"), ok: run }); +} /** tap/hold wiring honouring the per-card "tap" override: toggle (default) or popup */ const toggleOrPopup = (p, id, toggle, popup) => pressFor(p, id, toggle, popup); @@ -216,7 +224,7 @@ export function switchCard(p, e) { const s = e.state, on = isOn(s), un = unavailable(s), d = e.id.split(".")[0]; const svc = d === "input_boolean" ? "input_boolean" : "switch"; return html`
p.call(svc, "toggle", { entity_id: e.id }), () => p.openPopup({ type: "switch", e })))}> + ${pressDir(p, toggleOrPopup(p, e.id, guardPlug(p, [e.id], label(p, e), on, () => p.call(svc, "toggle", { entity_id: e.id })), () => p.openPopup({ type: "switch", e })))}> ${un ? ua() : nothing}
${icoFor(p, e.id, "mdi:power-socket-eu", "plug")}
${ovr(p, e.id, "state") ?? (un ? t("Onbereikbaar") : on ? t("Aan") : t("Uit"))}
@@ -436,12 +444,13 @@ function plugCard(p, a, st, title, cfg, acts) { const since = sw?.state?.last_changed ? sinceText(sw.state.last_changed) : ""; const tmr = p.plugTimers?.[a.id], left = tmr ? Math.max(0, Math.round((tmr.at - Date.now()) / 60000)) : null, plain = !!a.plain; let bottom = nothing; - if (mode === "timer") bottom = html`
ev.stopPropagation()}>${[15, 30, 60].map(m => html``)}
`; + const toggle = sw ? guardPlug(p, [a.id, sw.id], title, on, () => p.call("switch", "toggle", { entity_id: sw.id })) : null; + if (mode === "timer" && (!sw || toggle)) bottom = html`
ev.stopPropagation()}>${[15, 30, 60].map(m => html``)}
`; else if (mode === "cost") bottom = price ? html`
${h?.today != null ? html`${t("Vandaag")} ${eur(h.today * price)}` : ""}${!on && W > 0.5 ? html`${h?.today != null ? dot() : nothing}${t("Sluipverbruik")} ${eur(W * 24 * 30 / 1000 * price)} / ${t("maand")}` : nothing}
` : html`
${t("Stel een kWh-prijs in bij Energie")}
`; else if (mode === "bars") bottom = h?.rows ? html`
${h.rows.map(r => html` 0.01 })} style="height:${Math.max(9, r.v / (h.maxH || 1) * 100)}%">`)}
` : html`
…
`; else if (mode === "spark" && h?.peak) bottom = html`
${t("piek {w} W om {t}", { w: h.peak.v, t: new Date(h.peak.t).toLocaleTimeString(locale(), { hour: "2-digit", minute: "2-digit" }) })}
`; return html`
0, hasspk: mode === "spark" && !!h?.spark?.length, on, unavailable: st.st === "unknown", ...infoCls(cfg) })} - ${pressDir(p, pressFor(p, a.id, sw ? () => p.call("switch", "toggle", { entity_id: sw.id }) : null, () => p.openPopup({ type: "appliance", a })))}> + ${pressDir(p, pressFor(p, a.id, toggle, () => p.openPopup({ type: "appliance", a })))}> ${st.st === "unknown" ? ua() : nothing}
${uico(cfg.icon, "mdi:power-plug-outline", "plug")}
${ovr(p, a.id, "state") ?? (left != null ? html`${t("uit over {n} min", { n: left })}` : st.st === "unknown" ? t("Geen meting") : plain ? (on ? t("Aan") : t("Uit")) : st.st === "running" ? t("Bezig") : st.st === "done" ? t("Klaar") : on ? (W > 0.5 ? t("Standby") : t("Aan")) : t("Uit"))}
@@ -463,7 +472,8 @@ export function applianceCard(p, a) { const foot = [progName, st.progress != null ? `${Math.round(st.progress)}%` : "", st.power != null ? `${Math.round(st.power)} W` : "", a.door && p.hass.states[a.door.id]?.state === "open" ? t("Deur open") : ""].filter(Boolean); const acts = []; if (a.stop && !a.plain && ["running", "waiting"].includes(st.st)) acts.push({ icon: "mdi:stop", label: t("Stop"), run: () => p.call("button", "press", { entity_id: a.stop.id }) }); - if (a.switch && a.op && !unavailable(a.switch.state)) acts.push({ icon: "mdi:power", label: isOn(a.switch.state) ? t("Uit") : t("Aan"), on: isOn(a.switch.state), run: () => p.call("switch", "toggle", { entity_id: a.switch.id }) }); + const pw = a.switch && a.op && !unavailable(a.switch.state) ? guardPlug(p, [a.id, a.switch.id], title, isOn(a.switch.state), () => p.call("switch", "toggle", { entity_id: a.switch.id })) : null; + if (pw) acts.push({ icon: "mdi:power", label: isOn(a.switch.state) ? t("Uit") : t("Aan"), on: isOn(a.switch.state), run: pw }); if (!a.op && a.power) return plugCard(p, a, st, title, cfg, acts); return html`
0, on: APPLIANCE_ACTIVE.includes(st.st), running: st.st === "running", unavailable: st.st === "unknown" })} ${pressDir(p, { tap: () => p.openPopup({ type: "appliance", a }), hold: () => p.openPopup({ type: "appliance", a }) })}> @@ -524,7 +534,7 @@ export function stripCard(p, st) {
${title}
${un ? t("Onbereikbaar") : on ? t("{n} van {m} aan", { n: on, m: st.switches.length }) : t("Alles uit")}
${st.switches.map((e, i) => html``)}
`; } diff --git a/src/lang/de.js b/src/lang/de.js index a7f9884..4bc0f7a 100644 --- a/src/lang/de.js +++ b/src/lang/de.js @@ -350,4 +350,10 @@ export default { "Geen onbereikbare apparaten, lege batterijen of updates": "Keine unerreichbaren Geräte, leeren Batterien oder Updates", "In deze ruimte": "In diesem Raum", "De entiteit hoort al bij deze ruimte; de kaart komt erbij.": "Die Entität gehört schon zu diesem Raum; die Karte kommt dazu.", + "Stekker": "Stecker", + "Echt uitzetten?": "Wirklich ausschalten?", + "Echt aanzetten?": "Wirklich einschalten?", + "met bevestiging": "mit Rückfrage", + "alleen meten": "nur messen", + "alleen meten: geen schakelknop en de kaart schakelt nooit, voor bijv. een koelkast": "nur messen: keine Schalttaste, und die Karte schaltet nie, z. B. für einen Kühlschrank", }; diff --git a/src/lang/en.js b/src/lang/en.js index d555b34..6b73b77 100644 --- a/src/lang/en.js +++ b/src/lang/en.js @@ -473,4 +473,10 @@ export default { "Geen onbereikbare apparaten, lege batterijen of updates": "No unreachable devices, empty batteries or updates", "In deze ruimte": "In this room", "De entiteit hoort al bij deze ruimte; de kaart komt erbij.": "The entity already belongs to this room; the card is added.", + "Stekker": "Plug", + "Echt uitzetten?": "Really switch off?", + "Echt aanzetten?": "Really switch on?", + "met bevestiging": "ask first", + "alleen meten": "measure only", + "alleen meten: geen schakelknop en de kaart schakelt nooit, voor bijv. een koelkast": "measure only: no switch button and the card never switches, e.g. for a fridge", }; diff --git a/src/popups.js b/src/popups.js index 2c18553..abf0370 100644 --- a/src/popups.js +++ b/src/popups.js @@ -7,7 +7,7 @@ import { name, deviceName } from "./model.js"; import { PAGES } from "./pages.js"; import { keypad, bars } from "./extras.js"; import { eventWhen, timeline, applianceState } from "./extras2.js"; -import { wasteColor, wasteWhen, condText, condIcon, condSvg, label, camStream, attFilter, ATT_FILTERS, mediaView , lightCaps, batteryOf, groupMembers, groupKind, groupCaps, cardType, anyIco, cardCfg } from "./cards.js"; +import { wasteColor, wasteWhen, condText, condIcon, condSvg, label, camStream, attFilter, ATT_FILTERS, mediaView , lightCaps, batteryOf, groupMembers, groupKind, groupCaps, cardType, anyIco, cardCfg, guardPlug } from "./cards.js"; import { roomPresets, snapshot, applyPreset, presetMatches, installUpdate, pendingUpdates } from "./features.js"; import { modeList, toggleMode } from "./extras3.js"; import { field, text, select, check, condFields } from "./settings.js"; @@ -156,6 +156,7 @@ function cardForm(p, pop) { ${dom === "switch" ? row(t("Vermogenssensor"), select(p.hass, "sensor", draft.power, v => upd({ power: v || undefined }), { empty: t("automatisch (zelfde apparaat)"), filter: s => s.attributes.unit_of_measurement === "W" }), t("maakt er een stekkerkaart van (watt, verbruik, timer)")) : nothing} ${dom === "switch" && (applianceCapable(p, id) || draft.power) ? row(t("Apparaatfuncties"), html` p.setApplianceMode(id, e.target.checked)}>`, t("bezig / klaar op basis van het vermogen")) : nothing} ${appliance ? row(t("Apparaatfuncties"), html` p.setApplianceMode(pop.a.key, e.target.checked)}>`, pop.a.op ? t("bezig / klaar volgens het apparaat zelf") : t("bezig / klaar op basis van het vermogen")) : nothing} + ${dom === "switch" || (appliance && pop.a.switch) ? row(t("Stekker"), seg("plug", [{ v: "", l: t("schakelen") }, { v: "confirm", l: t("met bevestiging") }, { v: "lock", l: t("alleen meten") }], ""), t("alleen meten: geen schakelknop en de kaart schakelt nooit, voor bijv. een koelkast")) : nothing} ${appliance && pop.a.power ? row(t("Drempel (W)"), html` upd({ threshold: e.target.value === "" || +e.target.value === 5 ? undefined : +e.target.value })}>`, t("boven = bezig")) : nothing}
${hasPress ? html`
${sh(t("Gedrag"))} @@ -224,7 +225,7 @@ export function renderPopup(p, pop) { case "switch": { const s = hass.states[pop.e.id], on = isOn(s), d = pop.e.id.split(".")[0]; return wrap(roomName, label(p, pop.e), on ? t("Aan") : t("Uit"), html` - ${modes([{ label: on ? t("Uitschakelen") : t("Inschakelen"), on, go: () => p.call(d === "input_boolean" ? "input_boolean" : d === "fan" ? "fan" : "switch", "toggle", { entity_id: pop.e.id }) }], "n2")} + ${(go => go ? modes([{ label: on ? t("Uitschakelen") : t("Inschakelen"), on, go }], "n2") : nothing)(guardPlug(p, [pop.e.id], label(p, pop.e), on, () => p.call(d === "input_boolean" ? "input_boolean" : d === "fan" ? "fan" : "switch", "toggle", { entity_id: pop.e.id })))}
    ${li("mdi:history", on ? t("Aan sinds") : t("Uit sinds"), relTime(s.last_changed))}${s.attributes.current_power_w ? li("mdi:flash-outline", t("Verbruik"), `${s.attributes.current_power_w} W`) : nothing}
`); } case "fan": { @@ -299,7 +300,8 @@ export function renderPopup(p, pop) { const fmt = e => { const s = e && hass.states[e.id]; if (!s || unavailable(s)) return null; const v = hass.formatEntityState?.(s) ?? s.state; return v === s.state && /_program_/.test(v) ? v.split("_program_").pop().replace(/_/g, " ").replace(/^\w/, c => c.toUpperCase()) : v; }; // untranslated "dishcare_dishwasher_program_auto_2" → "Auto 2" const prog = fmt(a.program), door = a.door ? hass.states[a.door.id]?.state : null, canStop = a.stop && ["running", "waiting"].includes(st.st) && hass.states[a.stop.id] && hass.states[a.stop.id].state !== "unavailable"; // a button's normal state is "unknown" - const btns = [...(sw ? [{ label: sw.state === "on" ? t("Uitzetten") : t("Aanzetten"), on: sw.state === "on", go: () => p.call("switch", "toggle", { entity_id: a.switch.id }) }] : []), ...(canStop ? [{ label: t("Programma stoppen"), go: () => p.call("button", "press", { entity_id: a.stop.id }) }] : [])]; + const swGo = sw ? guardPlug(p, [a.id, a.switch.id], title, sw.state === "on", () => p.call("switch", "toggle", { entity_id: a.switch.id })) : null; + const btns = [...(swGo ? [{ label: sw.state === "on" ? t("Uitzetten") : t("Aanzetten"), on: sw.state === "on", go: swGo }] : []), ...(canStop ? [{ label: t("Programma stoppen"), go: () => p.call("button", "press", { entity_id: a.stop.id }) }] : [])]; return wrap(roomName, title, st.label, html` ${btns.length ? modes(btns, "n2") : nothing} ${st.progress != null && ["running", "waiting"].includes(st.st) ? html`
` : nothing} @@ -307,7 +309,7 @@ export function renderPopup(p, pop) { } const ps = hass.states[a.power.id]; return wrap(roomName, cfg.name || (hass.devices?.[a.device]?.name_by_user || hass.devices?.[a.device]?.name || t("Apparaat")), st.label, html` - ${sw ? modes([{ label: sw.state === "on" ? t("Stekker uit") : t("Stekker aan"), on: sw.state === "on", go: () => p.call("switch", "toggle", { entity_id: a.switch.id }) }], "n2") : nothing} + ${(go => go ? modes([{ label: sw.state === "on" ? t("Stekker uit") : t("Stekker aan"), on: sw.state === "on", go }], "n2") : nothing)(sw ? guardPlug(p, [a.id, a.switch.id], cfg.name || (hass.devices?.[a.device]?.name_by_user || hass.devices?.[a.device]?.name || t("Apparaat")), sw.state === "on", () => p.call("switch", "toggle", { entity_id: a.switch.id })) : null)}
    ${li("mdi:flash-outline", t("Vermogen nu"), st.power != null ? `${Math.round(st.power)} W` : "–", { onclick: () => p.moreInfo(a.power.id) })}${li("mdi:tune", t("Drempel"), `${thr} W`, { sub: t("aanpassen via ⋯") })}${st.doneAt ? li("mdi:check", t("Klaar sinds"), relTime(st.doneAt.toISOString())) : nothing}${ps ? li("mdi:history", t("Laatste meting"), relTime(ps.last_updated)) : nothing}
${t("Bezig zolang het vermogen boven de drempel ligt; daarna \"Klaar\" tot het apparaat weer start.")}
`); }