Dashboard Studio 1.11.0: energy card, climate popup, car and appliance redesigns, Home Connect
- Energy card: live power flow between solar, grid, battery and home, with line width and speed following the power, battery level and today's totals; sign of grid and battery sensors can be flipped - Climate popup (room card thermostat and any climate entity): dial, -/+ that batch quick taps, modes, presets and fan modes, live updates - Car card redesigned around 3x4 with a drawn car or photo, compact, wide and tall layouts - Appliance card redesigned with a progress ring; Home Connect support: all operation states, auto-fill from the operation state sensor via the device registry, readable program names, door state, pause/ resume/stop buttons, power switch and alerts. Fixes ready/inactive being shown as finished/running - Horizontal scroll card: width in pixels or cards per view, and a width per card - Swipe card: no lost tap after a touch swipe, no broken swipes over images, lost pointer capture ends the swipe, trackpad swiping Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,5 +1,16 @@
|
||||
# Changelog
|
||||
|
||||
## 1.11.0
|
||||
|
||||
- **Energy card.** A live flow diagram of solar, the grid, a home battery and your home: the lines are as thick and as fast as the power flowing through them. Shows the battery level, and any totals for today you add.
|
||||
- **Climate popup.** Tapping a thermostat (for example in a room card) opens a dial with the target temperature, − and +, the modes, presets and fan modes. Quick taps add up and are sent together.
|
||||
- **Car card redesigned** around a 3×4 card: a drawn car (or your photo) that glows while charging, the charge level and range, the battery bar with the charge limit, and buttons for the doors, climate and location. Short cards get a compact layout, wide cards put the car beside the numbers, and tall cards add stats.
|
||||
- **Appliance card redesigned:** a progress ring around the appliance next to the program, time left, ready time, door and power. Taller cards stack it. Without a progress sensor, progress is worked out from the time left.
|
||||
- **Home Connect support in the appliance card.** Pick the *Operation state* sensor and the card fills in the rest of the device: progress, finish time, door, active program, pause/resume/stop buttons, the power switch and alerts such as rinse aid or salt running low. It knows all Home Connect states, including *starts later*, *action needed*, *error* and *stopping*; shows program names readably ("Eco 50"); shows a locked door; and has buttons to pause, resume, stop or switch the appliance on. A card with an active alert stays visible when the appliance is off.
|
||||
- Fixed: the appliance card showed an idle Home Connect appliance (*ready*) as finished, and one that was off (*inactive*) as running.
|
||||
- **Horizontal scroll card:** set the card width in pixels or as the number of cards visible at once (for example 2.5), and give single cards their own width.
|
||||
- **Swipe card fixes:** the first tap after a swipe no longer gets lost on phones, swiping over photos no longer breaks off, and a two-finger trackpad swipe now turns the page.
|
||||
|
||||
## 1.10.0
|
||||
|
||||
- **Phone dashboards.** Turn on *Designed for phones* in a dashboard's grid settings to design it in a 4-column layout at phone size. Existing widgets are converted. On a phone the dashboard fills the screen without the top bar; touch and hold an empty spot to bring the bar back. Other dashboards still reflow on phones as before.
|
||||
|
||||
@@ -56,7 +56,7 @@ Pick a weather entity in the clock's settings to show the current conditions and
|
||||
|
||||
These cards hold other cards. Select one in edit mode, pick a card type under **Cards** and press **Add**. The new card opens straight away so you can set it up; **←** takes you back. Use the arrows to reorder cards, and open a card to change or remove it.
|
||||
|
||||
- **Horizontal scroll** puts its cards in a row you can scroll sideways, by touch, trackpad or the arrow buttons that appear on hover. Set the width of the cards and an optional title. The row keeps its scroll position when something inside it changes.
|
||||
- **Horizontal scroll** puts its cards in a row you can scroll sideways, by touch, trackpad or the arrow buttons that appear on hover. Set the card width in pixels, or as the number of cards visible at once (2.5 lets half a card peek out, so it's clear the row scrolls). A single card can get its own width in its settings. Add an optional title. The row keeps its scroll position when something inside it changes.
|
||||
- **Swipe cards** shows one card at a time, filling the whole card. Swipe with a finger or drag with the mouse: the card follows along, resists at the first and last card, and a quick flick is enough. The left and right arrow keys work too. Small dots appear at the bottom while you swipe or hover, and hide again after a few seconds; tap one to jump to that card. It can also move to the next card automatically every few seconds. Touching it pauses that for 20 seconds, and it stops completely when the system's "reduce motion" setting is on.
|
||||
|
||||
Cards inside a container work like anywhere else, and appliance cards inside them still hide themselves when the appliance is off.
|
||||
@@ -69,12 +69,22 @@ When the light card points at a light group, it also lists every light in the gr
|
||||
|
||||
The card grows with its size. On a small card it's a compact row with the status, battery and buttons. On a bigger card, the robot sits in the middle with a battery ring around it, a pulse while it cleans and a bolt while it charges. Below it are tiles for battery, cleaned area, cleaning time and any extra sensors you add (such as filter or brush life), the suction power levels, and labelled buttons to start or pause, send it back to the dock and locate it. Optionally add a map: pick the camera or image entity that your vacuum integration provides (for example Roborock or Valetudo), and it refreshes every few seconds. If the battery level is a separate sensor in your integration, pick it under **Battery sensor**.
|
||||
|
||||
### Energy
|
||||
|
||||
A live picture of where your power goes. Choose the sensors you have; all are optional except one:
|
||||
|
||||
- **Solar power**, **Grid power** (positive when you take power from the grid; tick the box if your meter is the other way round), **Battery power** (positive while charging, with the same kind of box) and **Battery level**.
|
||||
- **Home use**, or leave it empty and the card works it out from the others.
|
||||
- **Totals for today**, such as kWh produced and used, shown underneath.
|
||||
|
||||
Solar, the grid, the battery and your home are connected by lines. Moving dots show where power is flowing, and the more power, the thicker and faster the line. Solar power goes to the home first, then into the battery, and the rest back to the grid.
|
||||
|
||||
### Room
|
||||
|
||||
Pick one of your Home Assistant rooms (areas) and the card gathers everything in it:
|
||||
|
||||
- the room's temperature and humidity (the sensors set on the area in Home Assistant, or any temperature and humidity sensor in the room; you can also choose them yourself)
|
||||
- chips for what's going on: how many lights are on (tap to switch them all off, or all on), the thermostat, open doors and windows, motion, and music that's playing
|
||||
- chips for what's going on: how many lights are on (tap to switch them all off, or all on), the thermostat (tap it for the climate popup with a dial, −/+, modes, presets and fan modes), open doors and windows, motion, and music that's playing
|
||||
- buttons for the lamps, switches, fans, covers and locks in the room
|
||||
|
||||
Light groups are left out so each lamp counts once. Add extra entities, or skip the room and list the entities yourself. The card glows warm while lights are on, and can have a background photo. Rooms are read from Home Assistant's area, device and entity registries.
|
||||
@@ -85,7 +95,7 @@ One card can control several players. With more than one, a row of buttons at th
|
||||
|
||||
### Appliance (washer, dryer, dishwasher)
|
||||
|
||||
Shows whether an appliance is busy, finished or paused, with the program, the time left, the time it will be ready and a progress bar. It works with any integration, such as Home Connect, Miele, LG ThinQ, SmartThings, or a smart plug:
|
||||
At two rows high, a progress ring around the appliance sits beside its name, status, program, the time left and when it will be ready, plus the door and its power or energy use. Taller cards stack the ring on top. Without a progress sensor, progress is worked out from the time left. It works with any integration, such as Home Connect, Miele, LG ThinQ, SmartThings, or a smart plug:
|
||||
|
||||
- **Status:** a sensor such as `sensor.washer_status`. Values like *running*, *washing*, *drying*, *finished* or *klaar* are recognized.
|
||||
- **Power:** if there's no status, a power sensor works too. The appliance counts as running above the number of watts you set (5 W by default).
|
||||
@@ -94,6 +104,8 @@ Shows whether an appliance is busy, finished or paused, with the program, the ti
|
||||
|
||||
By default the card only shows while the appliance is running or has just finished, and hides itself when it's off.
|
||||
|
||||
**Home Connect** (Bosch, Siemens, Neff, Gaggenau): choose the appliance's *Operation state* sensor as the status, and the card fills in the rest of the same device by itself: program progress, finish time, door state, active program, the pause, resume and stop buttons, the power switch and alerts (for example rinse aid or salt nearly empty, which Home Connect provides as event sensors that are off by default). Fields you already filled in are left alone. The card understands every Home Connect operation state: running, paused, finished, starts later (with the time it will be ready), action needed, error and stopping; *ready* and *inactive* count as off. Program names such as `dishcare_dishwasher_program_eco_50` show as "Eco 50". Depending on the state, round buttons appear to pause, resume or stop the program, or to switch the appliance on. While an alert is active, the card stays visible even when the appliance is off.
|
||||
|
||||
### Car
|
||||
|
||||
Point the car widget at whichever entities your car integration provides. Each one is optional:
|
||||
@@ -111,7 +123,7 @@ Point the car widget at whichever entities your car integration provides. Each o
|
||||
| Location | `device_tracker.<car>_location` |
|
||||
| Odometer | `sensor.<car>_odometer` |
|
||||
|
||||
The widget shows the level as a bar with the charge limit marked, the range, and a status line (charging and its power, plugged in, parked at home, or away). It also has buttons to lock or unlock the doors and to start climate. You can add a car image by URL. Images you put in Home Assistant's `www` folder are available at `/local/…`.
|
||||
The card is designed around 3×4. It shows a drawn car (or your own photo) that glows with a bolt while charging, the charge level and range, the battery bar with the charge limit marked, a status (charging and its power, plugged in, parked at home or away) and buttons for the doors, climate and location. On a short card it becomes a compact strip, on a wide card the car sits beside the numbers, and a tall card adds tiles for the odometer, charge limit and charging power. You can add a car image by URL. Images you put in Home Assistant's `www` folder are available at `/local/…`.
|
||||
|
||||
## Conditional widgets
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
name: Dashboard Studio
|
||||
version: "1.10.0"
|
||||
version: "1.11.0"
|
||||
slug: dashboard_studio
|
||||
description: Drag-and-drop dashboard builder with a free-form grid, live entity widgets and themes.
|
||||
url: https://git.melvanveen.nl/veenm/ha-dashboard-studio
|
||||
|
||||
@@ -64,7 +64,27 @@ class MockHA:
|
||||
entity("sensor.phone_battery", "76", friendly_name="Phone Battery", unit_of_measurement="%", device_class="battery"),
|
||||
entity("binary_sensor.front_door", "off", friendly_name="Front Door", device_class="door"),
|
||||
entity("binary_sensor.hallway_motion", "off", friendly_name="Hallway Motion", device_class="motion"),
|
||||
entity("climate.thermostat", "heat", friendly_name="Thermostat", current_temperature=20.5, temperature=21, min_temp=7, max_temp=30, hvac_modes=["off", "heat", "auto"]),
|
||||
entity("climate.thermostat", "heat", friendly_name="Thermostat", current_temperature=20.5, temperature=21, min_temp=7, max_temp=30, hvac_modes=["off", "heat", "auto"],
|
||||
preset_modes=["none", "eco", "comfort", "away"], preset_mode="comfort", fan_modes=["auto", "low", "high"], fan_mode="auto", hvac_action="heating", current_humidity=46, target_temp_step=0.5),
|
||||
entity("sensor.grid_power", "-680", friendly_name="Grid Power", unit_of_measurement="W", device_class="power"),
|
||||
entity("sensor.home_battery_power", "500", friendly_name="Home Battery Power", unit_of_measurement="W", device_class="power"),
|
||||
entity("sensor.home_battery_level", "64", friendly_name="Home Battery Level", unit_of_measurement="%", device_class="battery"),
|
||||
entity("sensor.solar_energy_today", "12.4", friendly_name="Produced today", unit_of_measurement="kWh", device_class="energy"),
|
||||
entity("sensor.home_energy_today", "8.1", friendly_name="Used today", unit_of_measurement="kWh", device_class="energy"),
|
||||
entity("binary_sensor.washer_door", "off", friendly_name="Washer Door", device_class="door"),
|
||||
# A dishwasher as the Home Connect integration exposes it
|
||||
entity("sensor.dishwasher_operation_state", "run", friendly_name="Dishwasher Operation state", device_class="enum",
|
||||
options=["inactive", "ready", "delayedstart", "run", "pause", "actionrequired", "finished", "error", "aborting"]),
|
||||
entity("sensor.dishwasher_program_progress", "40", friendly_name="Dishwasher Program progress", unit_of_measurement="%"),
|
||||
entity("sensor.dishwasher_program_finish_time", (datetime.now(timezone.utc) + timedelta(minutes=52)).isoformat(), friendly_name="Dishwasher Program finish time", device_class="timestamp"),
|
||||
entity("sensor.dishwasher_door", "locked", friendly_name="Dishwasher Door", device_class="enum", options=["closed", "locked", "open"]),
|
||||
entity("select.dishwasher_active_program", "dishcare_dishwasher_program_eco_50", friendly_name="Dishwasher Active program",
|
||||
options=["dishcare_dishwasher_program_eco_50", "dishcare_dishwasher_program_auto_2", "dishcare_dishwasher_program_quick_45"]),
|
||||
entity("button.dishwasher_resume_program", "unknown", friendly_name="Dishwasher Resume program"),
|
||||
entity("button.dishwasher_stop_program", "unknown", friendly_name="Dishwasher Stop program"),
|
||||
entity("switch.dishwasher_power", "on", friendly_name="Dishwasher Power"),
|
||||
entity("sensor.dishwasher_rinse_aid_nearly_empty", "present", friendly_name="Dishwasher Rinse aid nearly empty", device_class="enum"),
|
||||
entity("sensor.washer_energy", "0.4", friendly_name="Washer Energy", unit_of_measurement="kWh", device_class="energy"),
|
||||
entity("cover.garage_door", "closed", friendly_name="Garage Door"),
|
||||
entity("lock.front_door", "locked", friendly_name="Front Door Lock"),
|
||||
entity("media_player.living_room_speaker", "playing", friendly_name="Living Room Speaker", media_title="Blue in Green", media_artist="Miles Davis", media_album_name="Kind of Blue", volume_level=0.35, entity_picture="data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><defs><linearGradient id='g' x1='0' y1='0' x2='1' y2='1'><stop offset='0' stop-color='%23234'/><stop offset='1' stop-color='%23c58'/></linearGradient></defs><rect width='100' height='100' fill='url(%23g)'/><circle cx='50' cy='50' r='22' fill='none' stroke='%23fff' stroke-width='3' opacity='.7'/></svg>"),
|
||||
@@ -155,6 +175,12 @@ class MockHA:
|
||||
for eid in expanded:
|
||||
if eid not in self.states:
|
||||
continue
|
||||
if eid.startswith("button.dishwasher_"): # Home Connect program buttons
|
||||
st_ = {"stop": "aborting", "pause": "pause", "resume": "run"}[eid.split("_")[-2]]
|
||||
self.set_state("sensor.dishwasher_operation_state", st_)
|
||||
if st_ == "aborting":
|
||||
asyncio.get_event_loop().call_later(3, lambda: self.set_state("sensor.dishwasher_operation_state", "ready"))
|
||||
continue
|
||||
if domain == "vacuum" or eid.startswith("vacuum."):
|
||||
self._vacuum(eid, service, data)
|
||||
continue
|
||||
@@ -177,6 +203,9 @@ class MockHA:
|
||||
self.set_state(eid, "on" if on else "off", **extra)
|
||||
elif service == "set_temperature":
|
||||
self.set_state(eid, None, temperature=data.get("temperature"))
|
||||
elif service in ("set_preset_mode", "set_fan_mode"):
|
||||
key = "preset_mode" if service == "set_preset_mode" else "fan_mode"
|
||||
self.set_state(eid, None, **{key: data.get(key)})
|
||||
elif service == "set_hvac_mode":
|
||||
self.set_state(eid, data.get("hvac_mode"))
|
||||
elif service in ("open_cover", "close_cover"):
|
||||
@@ -261,6 +290,8 @@ class MockHA:
|
||||
lvl = float(self.states["sensor.model_y_battery"]["state"])
|
||||
if lvl < 80:
|
||||
self.set_state("sensor.model_y_battery", f"{lvl + 0.2:.1f}")
|
||||
home, solar, batt = float(self.states["sensor.power_usage"]["state"]), float(self.states["sensor.solar_production"]["state"]), float(self.states["sensor.home_battery_power"]["state"])
|
||||
self.set_state("sensor.grid_power", f"{home + batt - solar:.0f}")
|
||||
if self.states["sensor.washer_status"]["state"] == "running":
|
||||
left = max(0, int(self.states["sensor.washer_remaining_time"]["state"]) - 1)
|
||||
self.set_state("sensor.washer_remaining_time", str(left))
|
||||
@@ -329,6 +360,8 @@ class MockHA:
|
||||
"garage": ["cover.garage_door", "lock.front_door"],
|
||||
}
|
||||
rows = [{"entity_id": e, "area_id": a, "device_id": None, "disabled_by": None, "hidden_by": None, "entity_category": None} for a, es in areas.items() for e in es]
|
||||
rows += [{"entity_id": e, "area_id": "kitchen", "device_id": "hc-dishwasher", "disabled_by": None, "hidden_by": None, "entity_category": None}
|
||||
for e in self.states if e.split(".")[1].startswith("dishwasher_")]
|
||||
await ws.send_json({"id": mid, "type": "result", "success": True, "result": rows})
|
||||
elif typ == "config/device_registry/list":
|
||||
await ws.send_json({"id": mid, "type": "result", "success": True, "result": []})
|
||||
|
||||
@@ -113,12 +113,15 @@
|
||||
]).then(([areas, ents, devs]) => {
|
||||
const devArea = Object.fromEntries((devs || []).map((d) => [d.id, d.area_id]));
|
||||
const byArea = {};
|
||||
const deviceOf = {}, byDevice = {};
|
||||
for (const e of ents || []) {
|
||||
if (e.disabled_by || e.hidden_by || e.entity_category) continue; // skip config/diagnostic and hidden entities
|
||||
if (e.disabled_by) continue;
|
||||
if (e.device_id) { deviceOf[e.entity_id] = e.device_id; (byDevice[e.device_id] ||= []).push(e.entity_id); }
|
||||
if (e.hidden_by || e.entity_category) continue; // rooms skip config/diagnostic and hidden entities
|
||||
const area = e.area_id || devArea[e.device_id];
|
||||
if (area) (byArea[area] ||= []).push(e.entity_id);
|
||||
}
|
||||
this.reg = { areas: (areas || []).sort((a, b) => a.name.localeCompare(b.name)), byArea };
|
||||
this.reg = { areas: (areas || []).sort((a, b) => a.name.localeCompare(b.name)), byArea, deviceOf, byDevice };
|
||||
return this.reg;
|
||||
}).catch((err) => { this._reg = null; throw err; });
|
||||
return this._reg;
|
||||
@@ -581,6 +584,7 @@
|
||||
|
||||
let interacting = false;
|
||||
function onStateChange(entityId) {
|
||||
if (livePopup && livePopup.eid === entityId) livePopup.draw(); // an open climate popup follows the device
|
||||
if (!current) return;
|
||||
if (entityId == null) { for (const c of Object.values(caches)) c.key = null; renderGrid(); if (editMode) renderInspector(); return; }
|
||||
const affected = tab().widgets.filter((w) => entitiesOf(w).includes(entityId) || ruleEntities(w).includes(entityId));
|
||||
@@ -709,6 +713,7 @@
|
||||
el.innerHTML = `${datalists()}<button class="btn back" data-child-back>${Icons.svg("chevron", "back")}${esc(tr(Widgets[w.type].label))}</button>
|
||||
<div class="panel-title">${Icons.svg(cdef.icon)} ${esc(tr(cdef.label))}</div>
|
||||
${cdef.fields.filter((f) => !f.when || f.when(ch.cfg)).map((f) => fieldHTML(f, ch.cfg[f.k], "cfg")).join("")}
|
||||
${w.type === "hscroll" ? fieldHTML({ k: "_width", t: "number", l: "Width of this card (px)", ph: "Same as the others" }, ch.cfg._width, "cfg") : ""}
|
||||
<div class="section"><div class="section-title">${tr("Style")}</div>${STYLE_FIELDS.map((f) => fieldHTML(f, ch.style?.[f.k], "style")).join("")}</div>
|
||||
<div class="insp-actions"><button class="btn danger" data-child-op="del" data-i="${childIndex}">${Icons.svg("trash")}${tr("Remove from container")}</button></div>`;
|
||||
return;
|
||||
@@ -919,6 +924,17 @@
|
||||
if (path === "cfg") {
|
||||
if (idx != null) w.cfg[k][Number(idx)] = v;
|
||||
else w.cfg[k] = v;
|
||||
const def = Widgets[w.type];
|
||||
if (def.autofill && k === def.autofill.from && v && idx == null) {
|
||||
const fill = def.autofill.fill(v, ha);
|
||||
for (const [fk, fv] of Object.entries(fill)) if (fv && (w.cfg[fk] == null || w.cfg[fk] === "" || (Array.isArray(w.cfg[fk]) && !w.cfg[fk].length))) w.cfg[fk] = fv;
|
||||
// The type may change from its default, and a default name follows it (washer → dishwasher).
|
||||
if (fill.kind && w.cfg.kind !== fill.kind) {
|
||||
const names = { washer: "Washing machine", dryer: "Dryer", dishwasher: "Dishwasher" };
|
||||
if (!w.cfg.name || w.cfg.name === tr(names[w.cfg.kind] || "")) w.cfg.name = tr(names[fill.kind]);
|
||||
w.cfg.kind = fill.kind;
|
||||
}
|
||||
}
|
||||
// A light group lists its lights underneath, which needs room.
|
||||
if (w.type === "light" && k === "entity" && Array.isArray(ha.states[v]?.attributes.entity_id) && w.h && w.h < 4) { w.h = 4; resolveCollisions(w); }
|
||||
} else if (path === "style") { (w.style ||= {})[k] = v; }
|
||||
@@ -940,7 +956,7 @@
|
||||
|
||||
if (path === "cfg" || path === "style") renderWidget(selectedWidget());
|
||||
const fieldType = w && Widgets[w.type].fields.find((f) => f.k === k)?.t;
|
||||
if (isChange && (fieldType === "entity" || fieldType === "icon" || idx != null || t.tagName === "BUTTON")) renderInspector();
|
||||
if (isChange && (fieldType === "entity" || fieldType === "icon" || idx != null || t.tagName === "BUTTON" || Widgets[w.type].autofill?.from === k)) renderInspector();
|
||||
if (path === "theme" && (t.tagName === "BUTTON" || (isChange && COLOR_KEYS.includes(k)))) renderInspector();
|
||||
}
|
||||
|
||||
@@ -1216,9 +1232,73 @@
|
||||
(e, close) => { const x = e.target.closest("[data-x]")?.dataset.x; if (x) close(x === "ok"); });
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ climate popup
|
||||
let livePopup = null;
|
||||
const HVAC_ICON = { off: "power", heat: "flame", cool: "snowflake", heat_cool: "thermometer", auto: "sparkles", dry: "droplet", fan_only: "fan" };
|
||||
|
||||
/** A thermostat dial with −/+, modes, presets and fan modes. Taps on −/+ add up and are sent after a short pause. */
|
||||
function climatePopup(eid) {
|
||||
let pending = null, sendTimer = 0;
|
||||
const step = () => Number(ha.states[eid]?.attributes.target_temp_step) || 0.5;
|
||||
const draw = () => {
|
||||
const s = ha.states[eid];
|
||||
if (!s) return;
|
||||
const a = s.attributes, min = Number(a.min_temp ?? 7), max = Number(a.max_temp ?? 30);
|
||||
const target = pending ?? a.temperature;
|
||||
const off = s.state === "off";
|
||||
const color = { heat: "var(--warn)", cool: "var(--accent)", heat_cool: "var(--ok)", auto: "var(--ok)" }[s.state] || "var(--text-2)";
|
||||
// 270° dial: track, the target as an arc and a knob, the current temperature as a small dot
|
||||
const A0 = Math.PI * 0.75, SPAN = Math.PI * 1.5, R = 86;
|
||||
const ang = (v) => A0 + SPAN * Math.max(0, Math.min(1, (v - min) / (max - min || 1)));
|
||||
const pt = (v, r = R) => [100 + r * Math.cos(ang(v)), 100 + r * Math.sin(ang(v))];
|
||||
const arc = (v0, v1) => { const [x0, y0] = pt(v0), [x1, y1] = pt(v1); return `M${x0.toFixed(1)} ${y0.toFixed(1)} A${R} ${R} 0 ${ang(v1) - ang(v0) > Math.PI ? 1 : 0} 1 ${x1.toFixed(1)} ${y1.toFixed(1)}`; };
|
||||
const hasTarget = target != null && isFinite(Number(target));
|
||||
const cur = Number(a.current_temperature);
|
||||
const chips = (list, current, kind) => (list || []).map((m) => `<button class="chip-btn" data-cl="${kind}" data-v="${esc(m)}" aria-pressed="${m === current}">${kind === "hvac" && HVAC_ICON[m] ? Icons.svg(HVAC_ICON[m]) : ""}<span>${esc(window.I18n.state(m))}</span></button>`).join("");
|
||||
const card = $("#modal .modal-card");
|
||||
card.innerHTML = `<h3>${esc(friendly(s))}</h3>
|
||||
<p>${tr("Now")} <b class="num">${isFinite(cur) ? cur + "°" : "–"}</b>${a.current_humidity != null ? ` · ${tr("Humidity")} <b class="num">${esc(a.current_humidity)}%</b>` : ""}${a.hvac_action ? ` · ${esc(window.I18n.state(a.hvac_action))}` : ""}</p>
|
||||
<div class="cl-dial ${off ? "is-off" : ""}" style="--cl:${color}">
|
||||
<svg viewBox="0 0 200 200" aria-hidden="true">
|
||||
<path d="${arc(min, max)}" class="cl-track"/>
|
||||
${hasTarget && !off ? `<path d="${arc(min, Number(target))}" class="cl-fill"/><circle cx="${pt(Number(target))[0]}" cy="${pt(Number(target))[1]}" r="11" class="cl-knob"/>` : ""}
|
||||
${isFinite(cur) ? `<circle cx="${pt(cur, R - 18)[0]}" cy="${pt(cur, R - 18)[1]}" r="4" class="cl-cur"/>` : ""}
|
||||
</svg>
|
||||
<div class="cl-center"><span class="cl-mode">${esc(window.I18n.state(s.state))}</span>
|
||||
<span class="cl-target num" aria-live="polite">${hasTarget ? `${Number(target).toLocaleString(LOCALE, { minimumFractionDigits: step() < 1 ? 1 : 0, maximumFractionDigits: 1 })}°` : "–"}</span>
|
||||
<span class="muted small">${tr("Target")}</span></div>
|
||||
</div>
|
||||
${hasTarget ? `<div class="cl-steps"><button class="round-btn" data-cl="temp" data-v="-1" aria-label="${tr("Lower")}">${Icons.svg("minus")}</button><button class="round-btn" data-cl="temp" data-v="1" aria-label="${tr("Raise")}">${Icons.svg("plus")}</button></div>` : ""}
|
||||
${a.hvac_modes?.length ? `<div class="section-title" style="margin-top:14px">${tr("Mode")}</div><div class="cl-chips">${chips(a.hvac_modes, s.state, "hvac")}</div>` : ""}
|
||||
${a.preset_modes?.length ? `<div class="section-title" style="margin-top:14px">${tr("Preset")}</div><div class="cl-chips">${chips(a.preset_modes, a.preset_mode, "preset")}</div>` : ""}
|
||||
${a.fan_modes?.length ? `<div class="section-title" style="margin-top:14px">${tr("Fan")}</div><div class="cl-chips">${chips(a.fan_modes, a.fan_mode, "fan")}</div>` : ""}
|
||||
<div class="modal-actions"><button class="btn" data-x="close">${tr("Close")}</button></div>`;
|
||||
};
|
||||
livePopup = { eid, draw };
|
||||
const done = openModal("", (e, close) => {
|
||||
const b = e.target.closest("[data-cl], [data-x]");
|
||||
if (!b) return;
|
||||
if (b.dataset.x) { close(); return; }
|
||||
const s = ha.states[eid], kind = b.dataset.cl;
|
||||
if (kind === "temp") {
|
||||
const a = s.attributes, base = pending ?? Number(a.temperature);
|
||||
pending = Math.round(Math.max(Number(a.min_temp ?? 7), Math.min(Number(a.max_temp ?? 30), base + Number(b.dataset.v) * step())) * 10) / 10;
|
||||
draw();
|
||||
clearTimeout(sendTimer);
|
||||
sendTimer = setTimeout(() => { const v = pending; ha.callService("climate", "set_temperature", { temperature: v }, eid).then(() => { if (pending === v) pending = null; }); }, 700);
|
||||
} else if (kind === "hvac") ha.callService("climate", "set_hvac_mode", { hvac_mode: b.dataset.v }, eid);
|
||||
else if (kind === "preset") ha.callService("climate", "set_preset_mode", { preset_mode: b.dataset.v }, eid);
|
||||
else if (kind === "fan") ha.callService("climate", "set_fan_mode", { fan_mode: b.dataset.v }, eid);
|
||||
});
|
||||
draw();
|
||||
$("#modal .modal-card [data-x]")?.focus();
|
||||
done.then(() => { livePopup = null; });
|
||||
}
|
||||
|
||||
function moreInfo(eid) {
|
||||
const s = ha.states[eid];
|
||||
if (!s) return;
|
||||
if (domainOf(eid) === "climate") { climatePopup(eid); return; }
|
||||
const toggle = window.HAFmt.TOGGLE_DOMAINS.has(domainOf(eid));
|
||||
const actionLabel = domainOf(eid) === "lock" ? (s.state === "locked" ? tr("Unlock") : tr("Lock")) : ["scene", "script"].includes(domainOf(eid)) ? tr("Run") : isOn(s) ? tr("Turn off") : tr("Turn on");
|
||||
openModal(`<h3>${esc(friendly(s))}</h3><div class="muted small">${esc(eid)}</div>
|
||||
|
||||
@@ -285,6 +285,22 @@
|
||||
"What’s new": "Wat is er nieuw", "Source code": "Broncode", "this version": "deze versie",
|
||||
"The changelog couldn’t be loaded.": "De lijst met wijzigingen kon niet worden geladen.",
|
||||
"Updated to {0}. See what’s new under About in the settings.": "Bijgewerkt naar {0}. Bekijk wat er nieuw is onder Over in de instellingen.",
|
||||
"Card width": "Kaartbreedte", "pixels": "Pixels", "per-view": "Aantal zichtbaar", "Cards visible at once": "Aantal kaarten tegelijk zichtbaar",
|
||||
"e.g. 1, 2 or 2.5": "bijv. 1, 2 of 2,5", "Width of this card (px)": "Breedte van deze kaart (px)", "Same as the others": "Zelfde als de andere",
|
||||
"Target": "Doel", "Lower": "Lager", "Raise": "Hoger", "Preset": "Voorinstelling", "Fan": "Ventilator",
|
||||
"Charge limit": "Laadlimiet",
|
||||
"Energy": "Energie", "Solar power": "Zonnevermogen", "Grid power (positive = from the grid)": "Netvermogen (positief = van het net)",
|
||||
"My grid sensor is positive when sending back": "Mijn netsensor is positief bij teruglevering", "Battery power (positive = charging)": "Accuvermogen (positief = laden)",
|
||||
"My battery sensor is positive when discharging": "Mijn accusensor is positief bij ontladen", "Battery level (%)": "Accuniveau (%)",
|
||||
"Home use (optional, otherwise calculated)": "Verbruik in huis (optioneel, anders berekend)", "Totals for today, e.g. kWh produced and used": "Totalen van vandaag, bijv. opgewekte en verbruikte kWh",
|
||||
"Choose your solar, grid or battery power sensors in the inspector.": "Kies in de inspector je sensoren voor zonnepanelen, het net of de thuisaccu.",
|
||||
"Solar {0}, grid {1}, home {2}": "Zon {0}, net {1}, huis {2}", "Solar": "Zon", "To the grid": "Naar het net", "From the grid": "Van het net", "Discharging": "Ontladen",
|
||||
"Door (optional)": "Deur (optioneel)", "Energy or water use (optional)": "Energie- of waterverbruik (optioneel)", "Door open": "Deur open", "Door closed": "Deur dicht",
|
||||
"Status (with Home Connect: Operation state — the rest fills in by itself)": "Status (bij Home Connect: Bedieningsstatus — de rest vult zichzelf in)",
|
||||
"Time remaining or finish time": "Resterende tijd of eindtijd", "Pause button": "Pauzeknop", "Resume button": "Hervatknop", "Stop button": "Stopknop",
|
||||
"Power switch": "Aan/uit-schakelaar", "Alerts, e.g. salt or rinse aid low": "Meldingen, bijv. zout of glansspoelmiddel bijna op",
|
||||
"Starts later": "Start later", "Action needed": "Actie nodig", "Error": "Fout", "Stopping": "Stoppen", "Door locked": "Deur vergrendeld",
|
||||
"Ready at {0}": "Klaar om {0}", "Check the appliance": "Kijk even bij het apparaat", "Something went wrong": "Er ging iets mis", "Stop": "Stop",
|
||||
"Today": "Vandaag", "Tomorrow": "Morgen", "All day": "Hele dag", "Busy": "Bezet",
|
||||
};
|
||||
|
||||
@@ -295,7 +311,8 @@
|
||||
locking: "Vergrendelen", unlocking: "Ontgrendelen", jammed: "Vastgelopen", playing: "Speelt", paused: "Gepauzeerd",
|
||||
idle: "Inactief", standby: "Stand-by", buffering: "Bufferen", heat: "Verwarmen", cool: "Koelen", auto: "Automatisch",
|
||||
heat_cool: "Verwarmen/koelen", dry: "Drogen", fan_only: "Alleen ventilator", cleaning: "Reinigen", docked: "Gedockt",
|
||||
returning: "Keert terug", error: "Fout", charging: "Laden", above_horizon: "Boven de horizon", below_horizon: "Onder de horizon",
|
||||
returning: "Keert terug", error: "Fout", eco: "Eco", comfort: "Comfort", away: "Afwezig", sleep: "Slapen", boost: "Boost", none: "Geen",
|
||||
low: "Laag", medium: "Midden", high: "Hoog", heating: "Verwarmt", cooling: "Koelt", charging: "Laden", above_horizon: "Boven de horizon", below_horizon: "Onder de horizon",
|
||||
"clear-night": "Helder", cloudy: "Bewolkt", exceptional: "Uitzonderlijk", fog: "Mist", hail: "Hagel", lightning: "Onweer",
|
||||
"lightning-rainy": "Onweer en regen", partlycloudy: "Halfbewolkt", pouring: "Stortregen", rainy: "Regen", snowy: "Sneeuw",
|
||||
"snowy-rainy": "Natte sneeuw", sunny: "Zonnig", windy: "Winderig", "windy-variant": "Winderig", armed_away: "Ingeschakeld (afwezig)",
|
||||
|
||||
@@ -48,6 +48,7 @@
|
||||
hscroll: "M3 6h5v12H3zM10 6h5v12h-5zM17 6h4M17 18h4M21 6v12",
|
||||
swipe: "M6 4h12a1 1 0 0 1 1 1v11a1 1 0 0 1-1 1H6a1 1 0 0 1-1-1V5a1 1 0 0 1 1-1zM9 21h.01M12 21h.01M15 21h.01",
|
||||
sofa: "M5 11V8a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2v3M3 12a2 2 0 0 1 4 0v2h10v-2a2 2 0 0 1 4 0v5H3zM5 17v2M19 17v2",
|
||||
stop: "M7 7h10v10H7z",
|
||||
calendar: "M4 6a1 1 0 0 1 1-1h14a1 1 0 0 1 1 1v14a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1zM4 10h16M8 3v4M16 3v4M8 14h.01M12 14h.01M16 14h.01M8 17h.01M12 17h.01",
|
||||
clock: "M12 21a9 9 0 1 0 0-18 9 9 0 0 0 0 18zM12 7v5l3 2",
|
||||
chart: "M3 3v18h18M7 15l4-4 3 3 6-6",
|
||||
|
||||
+139
-24
@@ -218,6 +218,25 @@ textarea.input { resize: vertical; min-height: 76px; font-family: ui-monospace,
|
||||
.assign-kiosk { display: flex; align-items: center; gap: 6px; font-size: 13px; white-space: nowrap; }
|
||||
.assign-kiosk input { width: 18px; height: 18px; accent-color: var(--accent); margin: 0; }
|
||||
@media (max-width: 560px) { .assign-row[data-dev] { flex-wrap: wrap; } .assign-row[data-dev] .input { flex-basis: 100%; } }
|
||||
/* climate popup */
|
||||
.cl-dial { position: relative; width: min(240px, 70vw); margin: 8px auto 0; aspect-ratio: 1; }
|
||||
.cl-dial svg { width: 100%; height: 100%; }
|
||||
.cl-dial path { fill: none; stroke-width: 14; stroke-linecap: round; }
|
||||
.cl-track { stroke: var(--fill-strong); }
|
||||
.cl-fill { stroke: var(--cl); transition: d .3s; }
|
||||
.cl-knob { fill: #fff; stroke: var(--cl); stroke-width: 4; filter: drop-shadow(0 1px 3px rgba(0, 0, 0, .3)); }
|
||||
.cl-cur { fill: var(--text); }
|
||||
.cl-center { position: absolute; inset: 0; display: flex; flex-direction: column; align-items: center; justify-content: center; }
|
||||
.cl-target { font-size: 52px; font-weight: 700; line-height: 1; letter-spacing: -0.02em; }
|
||||
.cl-mode { font-size: 14px; font-weight: 600; color: var(--cl); margin-bottom: 4px; }
|
||||
.cl-dial.is-off .cl-target { color: var(--text-2); }
|
||||
.cl-steps { display: flex; justify-content: center; gap: 28px; margin-top: -18px; }
|
||||
.round-btn { width: 52px; height: 52px; border-radius: 50%; border: 0; background: var(--fill); font-size: 22px; display: grid; place-items: center; cursor: pointer; }
|
||||
.round-btn:hover { background: var(--fill-strong); }
|
||||
.cl-chips { display: flex; flex-wrap: wrap; gap: 6px; }
|
||||
.chip-btn { display: inline-flex; align-items: center; gap: 6px; min-height: 36px; padding: 0 14px; border-radius: 99px; border: 0; background: var(--fill); cursor: pointer; font-weight: 500; }
|
||||
.chip-btn .i { font-size: 16px; }
|
||||
.chip-btn[aria-pressed="true"] { background: var(--text); color: var(--surface); font-weight: 600; }
|
||||
.changelog { margin-top: 8px; }
|
||||
.changelog h4 { margin: 16px 0 4px; font-size: 15px; }
|
||||
.changelog ul { margin: 0; padding-left: 20px; color: var(--text); }
|
||||
@@ -506,15 +525,64 @@ body.kiosk #tabnav { top: 6px; max-height: calc(100vh - 20px); }
|
||||
@keyframes eq { 0%, 100% { transform: scaleY(0.4); transform-origin: bottom; } 50% { transform: scaleY(1); transform-origin: bottom; } }
|
||||
@container (max-height: 170px) { .mp-art { display: none; } }
|
||||
|
||||
/* appliance */
|
||||
.appl { display: flex; flex-direction: column; height: 100%; gap: 8px; justify-content: space-between; }
|
||||
.appl-status { margin-left: auto; font-size: 13px; font-weight: 600; padding: 3px 10px; border-radius: 99px; background: var(--d-fill); white-space: nowrap; }
|
||||
/* appliance: ring on the left at two rows high, stacked when taller */
|
||||
.appl { display: flex; align-items: center; gap: 16px; height: 100%; min-width: 0; }
|
||||
.appl-ring { position: relative; flex: none; height: min(100%, 132px); aspect-ratio: 1; display: grid; place-items: center; }
|
||||
.appl-ring > svg { position: absolute; inset: 0; transform: rotate(-90deg); }
|
||||
.appl-ring circle { fill: none; stroke-width: 7; }
|
||||
.appl-ring .track { stroke: var(--d-fill); }
|
||||
.appl-ring .fill { stroke: var(--d-accent); stroke-linecap: round; transition: stroke-dasharray .6s; }
|
||||
.phase-done .appl-ring .fill { stroke: var(--ok); }
|
||||
.appl-ico { width: 64%; aspect-ratio: 1; border-radius: 50%; display: grid; place-items: center; font-size: clamp(22px, 22cqh, 40px); background: var(--d-fill); }
|
||||
.phase-running .appl-ico { background: color-mix(in srgb, var(--d-accent) 18%, transparent); color: var(--d-accent); }
|
||||
.phase-done .appl-ico { background: color-mix(in srgb, var(--ok) 18%, transparent); color: var(--ok); }
|
||||
.appl-pct { position: absolute; bottom: -2px; font-size: 11px; font-weight: 700; padding: 1px 6px; border-radius: 99px; background: var(--w-bg, var(--d-tile)); color: var(--d-text-2); }
|
||||
.appl-text { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 3px; }
|
||||
.appl-top { display: flex; align-items: center; gap: 8px; min-width: 0; }
|
||||
.appl-top .name { flex: 1; min-width: 0; }
|
||||
.appl-status { font-size: 12px; font-weight: 600; padding: 3px 10px; border-radius: 99px; background: var(--d-fill); white-space: nowrap; flex: none; }
|
||||
.appl.phase-running .appl-status { background: var(--d-accent); color: var(--d-on-accent, #fff); }
|
||||
.theme-outline .w:not(.transparent) { border-color: var(--d-separator); }
|
||||
.appl.phase-done .appl-status { background: color-mix(in srgb, var(--ok) 18%, transparent); color: var(--ok); }
|
||||
.appl-big { font-size: 22px; font-weight: 700; }
|
||||
.appl-big .wt { font-size: 14px; font-weight: 400; }
|
||||
.appl .car-fill { background: var(--d-accent); }
|
||||
.appl-program { font-size: 13px; color: var(--d-text-2); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.appl-main { margin-top: 2px; }
|
||||
.appl-big { font-size: clamp(20px, 15cqh, 30px); font-weight: 700; line-height: 1.1; }
|
||||
.appl-off { color: var(--d-text-2); }
|
||||
.appl-sub { font-size: 13px; color: var(--d-text-2); }
|
||||
.appl-facts { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 4px; }
|
||||
.appl-main { display: flex; align-items: center; gap: 8px; }
|
||||
.appl-main-text { flex: 1; min-width: 0; display: flex; flex-wrap: wrap; align-items: baseline; column-gap: 8px; }
|
||||
.appl-ctrls { display: flex; gap: 6px; flex: none; }
|
||||
.appl-btn { width: 40px; height: 40px; border-radius: 50%; display: grid; place-items: center; background: var(--d-fill); font-size: 18px; }
|
||||
.appl-btn:hover { background: color-mix(in srgb, var(--d-text) 16%, transparent); }
|
||||
.appl.phase-attention .appl-status { background: color-mix(in srgb, var(--warn) 18%, transparent); color: var(--warn); }
|
||||
.appl.phase-error .appl-status { background: color-mix(in srgb, var(--crit) 18%, transparent); color: var(--crit); }
|
||||
.appl.phase-scheduled .appl-status, .appl.phase-stopping .appl-status { background: var(--d-fill); }
|
||||
.phase-attention .appl-ico { color: var(--warn); } .phase-error .appl-ico { color: var(--crit); }
|
||||
.appl-fact { white-space: nowrap; }
|
||||
.appl-fact span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
/* two rows high: one line of facts, tighter spacing */
|
||||
@container (max-height: 180px) {
|
||||
.appl-text { gap: 0; }
|
||||
.appl-facts { flex-wrap: nowrap; overflow: hidden; margin-top: 3px; }
|
||||
.appl-fact { min-width: 0; flex-shrink: 1; }
|
||||
.appl-big { font-size: 22px; }
|
||||
.appl-btn { width: 36px; height: 36px; font-size: 16px; }
|
||||
}
|
||||
.appl-fact { display: inline-flex; align-items: center; gap: 5px; font-size: 12px; font-weight: 600; padding: 3px 9px; border-radius: 99px; background: var(--d-fill); }
|
||||
.appl-fact .i { font-size: 14px; }
|
||||
.appl-fact.warn { color: var(--warn); background: color-mix(in srgb, var(--warn) 16%, transparent); }
|
||||
.theme-outline .w:not(.transparent) { border-color: var(--d-separator); }
|
||||
@container (max-width: 260px) { .appl-ring { display: none; } }
|
||||
@container (min-height: 260px) {
|
||||
.appl { flex-direction: column; justify-content: center; text-align: center; }
|
||||
.appl-ring { height: min(55%, 170px); }
|
||||
.appl-text { align-items: center; flex: none; width: 100%; }
|
||||
.appl-top { justify-content: center; }
|
||||
.appl-top .name { flex: none; }
|
||||
.appl-main, .appl-main-text, .appl-facts { justify-content: center; }
|
||||
.appl-main { flex-direction: column; }
|
||||
.appl-main-text { flex: none; }
|
||||
}
|
||||
|
||||
/* containers: horizontal scroll and swipe */
|
||||
.w.child { position: relative; flex: none; height: 100%; }
|
||||
@@ -525,7 +593,7 @@ body.kiosk #tabnav { top: 6px; max-height: calc(100vh - 20px); }
|
||||
scroll-snap-type: x mandatory; scroll-padding: 0 2px; scrollbar-width: none; overscroll-behavior-x: contain;
|
||||
}
|
||||
.hs-track::-webkit-scrollbar { display: none; }
|
||||
.hs-track > .w.child { width: var(--item-w, 260px); scroll-snap-align: start; }
|
||||
.hs-track > .w.child { width: var(--own-w, var(--item-w, 260px)); scroll-snap-align: start; }
|
||||
.hs-track:focus-visible { box-shadow: var(--focus); border-radius: var(--d-radius, 20px); }
|
||||
.hs-nav {
|
||||
position: absolute; top: 50%; z-index: 4; width: 36px; height: 36px; border-radius: 50%; display: grid; place-items: center;
|
||||
@@ -542,6 +610,7 @@ body.kiosk #tabnav { top: 6px; max-height: calc(100vh - 20px); }
|
||||
.sw-track > .w.child { flex: 0 0 100%; width: 100%; }
|
||||
.sw.dragging .sw-track { cursor: grabbing; }
|
||||
.sw.dragging .w.child { pointer-events: none; }
|
||||
.sw img { -webkit-user-drag: none; user-select: none; }
|
||||
.sw-track:focus-visible { box-shadow: inset var(--focus); }
|
||||
/* Dots float over the cards and only show while swiping, hovering or focused. */
|
||||
.sw-dots {
|
||||
@@ -588,6 +657,26 @@ body.kiosk #tabnav { top: 6px; max-height: calc(100vh - 20px); }
|
||||
.light-members { flex: 1; min-height: 0; margin: 0 -6px; }
|
||||
.light:has(.light-members) { justify-content: flex-start; }
|
||||
|
||||
/* energy flow */
|
||||
.energy { display: flex; flex-direction: column; height: 100%; gap: 10px; }
|
||||
.ef-diagram { position: relative; flex: 1; min-height: 0; }
|
||||
.ef-diagram > svg { position: absolute; inset: 0; width: 100%; height: 100%; overflow: visible; }
|
||||
.ef-line, .ef-flow { fill: none; vector-effect: non-scaling-stroke; }
|
||||
.ef-line { stroke: var(--d-fill); stroke-width: 2; }
|
||||
.ef-flow { stroke-dasharray: 2 12; stroke-linecap: round; animation: ef-flow linear infinite; }
|
||||
.ef-flow.solar { stroke: #f5a524; } .ef-flow.grid { stroke: var(--d-text-2); } .ef-flow.battery { stroke: var(--ok); }
|
||||
@keyframes ef-flow { to { stroke-dashoffset: -28; } }
|
||||
.ef-node { position: absolute; transform: translate(-50%, -50%); display: flex; flex-direction: column; align-items: center; text-align: center; gap: 1px; width: 33%; }
|
||||
.ef-solar { left: 50%; top: 14%; } .ef-grid { left: 14%; top: 50%; } .ef-home { left: 86%; top: 50%; } .ef-battery { left: 50%; top: 86%; }
|
||||
.ef-ico { width: clamp(34px, 13cqmin, 52px); aspect-ratio: 1; border-radius: 50%; display: grid; place-items: center; font-size: clamp(17px, 6cqmin, 24px); background: var(--w-bg, var(--d-tile)); border: 2px solid; }
|
||||
.ef-solar .ef-ico { border-color: #f5a524; color: #f5a524; } .ef-grid .ef-ico { border-color: var(--d-text-2); } .ef-home .ef-ico { border-color: var(--d-accent); color: var(--d-accent); } .ef-battery .ef-ico { border-color: var(--ok); color: var(--ok); }
|
||||
.ef-val { font-size: clamp(13px, 5cqmin, 17px); font-weight: 700; white-space: nowrap; }
|
||||
.ef-label { font-size: 11px; color: var(--d-text-2); white-space: nowrap; }
|
||||
.ef-val, .ef-label { position: relative; padding: 0 5px; border-radius: 5px; background: var(--w-bg, var(--d-tile)); }
|
||||
.surface-plain .ef-val, .surface-plain .ef-label, .w.transparent .ef-val, .w.transparent .ef-label { background: var(--d-bg); }
|
||||
.ef-totals { flex: none; display: flex; flex-wrap: wrap; gap: 6px 14px; font-size: 12px; color: var(--d-text-2); justify-content: center; }
|
||||
.ef-total b { color: var(--d-text); font-size: 13px; }
|
||||
|
||||
/* room */
|
||||
.room { position: relative; display: flex; flex-direction: column; gap: 12px; height: 100%; min-height: 0; }
|
||||
.room.on-image { margin: -16px; padding: 16px; border-radius: inherit; color: #fff; --d-text-2: rgba(255, 255, 255, 0.86); --d-fill: rgba(255, 255, 255, 0.18);
|
||||
@@ -680,28 +769,54 @@ body.kiosk #tabnav { top: 6px; max-height: calc(100vh - 20px); }
|
||||
.cal-now { font-size: 12px; font-weight: 700; padding: 2px 8px; border-radius: 99px; color: var(--d-accent); background: color-mix(in srgb, currentColor 16%, transparent); align-self: center; }
|
||||
@container (max-width: 260px) { .cal-ev { flex-wrap: wrap; } .cal-when { width: auto; order: 2; flex-basis: 100%; padding-left: 14px; } }
|
||||
|
||||
/* car */
|
||||
.car { position: relative; display: flex; flex-direction: column; height: 100%; gap: 8px; }
|
||||
.car-img { position: absolute; right: -4px; top: -6px; width: 46%; height: 58%; object-fit: contain; object-position: right top; pointer-events: none; }
|
||||
.car.has-img .car-head, .car.has-img .car-main { max-width: 56%; }
|
||||
.car-head { display: flex; flex-direction: column; min-width: 0; }
|
||||
.car-head .name { font-size: 17px; }
|
||||
.car-main { display: flex; align-items: baseline; gap: 12px; flex-wrap: wrap; margin-top: auto; }
|
||||
.car-range { font-size: 17px; font-weight: 600; }
|
||||
.car-range .wt { font-size: 14px; font-weight: 400; }
|
||||
/* pill button with icon and label (media "Turn on") */
|
||||
.car-act { display: inline-flex; align-items: center; gap: 8px; min-height: 44px; padding: 0 14px; border-radius: 12px; background: var(--d-fill); font-size: 14px; font-weight: 500; }
|
||||
.car-act .i { font-size: 18px; }
|
||||
/* car: built around 3×4; compact when short, side by side when wide */
|
||||
.car2 { display: grid; grid-template-areas: "top" "visual" "info"; grid-template-rows: auto 1fr auto; gap: 10px; height: 100%; min-height: 0; }
|
||||
.car-top { grid-area: top; display: flex; align-items: center; gap: 8px; min-width: 0; }
|
||||
.car-top .name { font-size: 17px; flex: 1; min-width: 0; }
|
||||
.car-status { font-size: 12px; font-weight: 600; padding: 3px 9px; border-radius: 99px; background: var(--d-fill); color: var(--d-text-2); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 60%; }
|
||||
.car-status.charging { color: var(--ok); background: color-mix(in srgb, var(--ok) 16%, transparent); }
|
||||
.car-visual { grid-area: visual; position: relative; min-height: 0; display: grid; place-items: center; }
|
||||
.car-photo { width: 100%; height: 100%; object-fit: contain; }
|
||||
.car-art { width: 100%; max-height: 100%; }
|
||||
.car-body { fill: var(--d-fill); stroke: var(--d-text-2); stroke-width: 1.5; }
|
||||
.car-glass { fill: color-mix(in srgb, var(--d-text) 12%, transparent); }
|
||||
.car-wheel { fill: var(--d-text); opacity: .85; }
|
||||
.car-hub { fill: var(--d-tile); }
|
||||
.car-visual.is-charging::before { content: ""; position: absolute; inset: 15% 10%; border-radius: 50%; background: radial-gradient(closest-side, color-mix(in srgb, var(--ok) 35%, transparent), transparent); animation: glow 2.4s ease-in-out infinite; }
|
||||
@keyframes glow { 50% { opacity: .45; } }
|
||||
.car-bolt { position: absolute; right: 8%; top: 4%; width: 30px; height: 30px; border-radius: 50%; display: grid; place-items: center; background: var(--ok); color: #04210f; font-size: 17px; }
|
||||
.car-info { grid-area: info; display: flex; flex-direction: column; gap: 10px; min-width: 0; }
|
||||
.car-nums { display: flex; align-items: baseline; gap: 12px; flex-wrap: wrap; }
|
||||
.car-pct { font-size: clamp(32px, 14cqh, 52px); font-weight: 700; line-height: 1; letter-spacing: -0.02em; }
|
||||
.car-range { font-size: 17px; font-weight: 600; color: var(--d-text-2); }
|
||||
.car2 .level { color: var(--crit); align-self: center; }
|
||||
.car-bar { position: relative; height: 12px; border-radius: 6px; background: var(--d-fill); overflow: visible; flex: none; }
|
||||
.car-fill { position: absolute; inset: 0 auto 0 0; border-radius: 6px; background: var(--d-text); transition: width .4s; }
|
||||
.car-bar.charging .car-fill { background: var(--ok); background-image: linear-gradient(110deg, transparent 40%, rgba(255, 255, 255, 0.45) 50%, transparent 60%); background-size: 200% 100%; animation: charge 2.4s linear infinite; }
|
||||
.car-bar.low .car-fill { background: var(--crit); }
|
||||
.car-limit { position: absolute; top: -3px; bottom: -3px; width: 2px; margin-left: -1px; border-radius: 1px; background: var(--d-text-2); }
|
||||
@keyframes charge { from { background-position: 100% 0; } to { background-position: -100% 0; } }
|
||||
.car-actions { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }
|
||||
.car-act { display: inline-flex; align-items: center; gap: 8px; min-height: 44px; padding: 0 14px; border-radius: 12px; background: var(--d-fill); font-size: 14px; font-weight: 500; }
|
||||
.car-act .i { font-size: 18px; }
|
||||
.car-act[aria-pressed="true"] { background: var(--d-text); color: var(--d-tile); }
|
||||
.car-odo { margin-left: auto; }
|
||||
@container (max-width: 340px) { .car-img { display: none; } .car.has-img .car-head, .car.has-img .car-main { max-width: none; } .car-odo { display: none; } }
|
||||
@container (max-height: 200px) { .car-actions { display: none; } }
|
||||
.car-stats { display: grid; grid-template-columns: repeat(auto-fit, minmax(90px, 1fr)); gap: 8px; }
|
||||
.car-stat { display: flex; flex-direction: column; padding: 8px 10px; border-radius: 12px; background: var(--d-fill); min-width: 0; }
|
||||
.car-stat-v { font-size: 15px; font-weight: 700; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.car-stat-l { font-size: 12px; color: var(--d-text-2); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.car-actions2 { display: flex; gap: 6px; }
|
||||
.car-btn { flex: 1; min-width: 0; display: flex; flex-direction: column; align-items: center; gap: 4px; padding: 8px 4px; border-radius: 14px; background: var(--d-fill); }
|
||||
.car-btn-ico { width: 32px; height: 32px; border-radius: 50%; display: grid; place-items: center; font-size: 18px; }
|
||||
.car-btn[aria-pressed="true"] .car-btn-ico { background: var(--d-text); color: var(--d-tile); }
|
||||
.car-btn-label { font-size: 12px; font-weight: 600; max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
/* sizes */
|
||||
@container (max-height: 380px) { .car-stats { display: none; } }
|
||||
@container (max-height: 250px) and (max-width: 479px) { .car2 { grid-template-areas: "top" "info"; grid-template-rows: auto 1fr; } .car-visual { display: none; } .car-info { justify-content: flex-end; } }
|
||||
@container (max-height: 160px) { .car-actions2 { display: none; } }
|
||||
@container (min-width: 480px) and (min-height: 150px) {
|
||||
.car2 { grid-template-areas: "top top" "visual info"; grid-template-columns: 1.1fr 1fr; grid-template-rows: auto 1fr; column-gap: 18px; }
|
||||
.car-info { justify-content: center; }
|
||||
}
|
||||
@container (max-width: 230px) { .car-btn-label { display: none; } .car-range { font-size: 15px; } }
|
||||
|
||||
/* ============================================================ phone dashboards */
|
||||
body.phone-ui:not(.peek) #topbar { display: none; }
|
||||
|
||||
+247
-55
@@ -477,6 +477,73 @@
|
||||
lit: (c, { ha, cache }) => (ha.states[mediaSelected(mediaIds(c), ha, cache || {})]?.state === "playing" ? "on" : false),
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------- energy
|
||||
// Live power flow between solar, grid, battery and home. Lines are as thick and as fast as the power.
|
||||
function watts(s) {
|
||||
const v = Number(s?.state);
|
||||
if (!isFinite(v)) return null;
|
||||
const u = String(s.attributes.unit_of_measurement || "W").toLowerCase();
|
||||
return u === "kw" ? v * 1000 : u === "mw" ? v * 1e6 : v;
|
||||
}
|
||||
const fmtW = (w) => (Math.abs(w) >= 1000 ? `${(w / 1000).toLocaleString(LOCALE, { maximumFractionDigits: 1 })} kW` : `${Math.round(w).toLocaleString(LOCALE)} W`);
|
||||
|
||||
W.energy = {
|
||||
label: "Energy", icon: "bolt", size: [4, 4],
|
||||
fields: [
|
||||
{ k: "solar", t: "entity", l: "Solar power", domains: ["sensor"] },
|
||||
{ k: "grid", t: "entity", l: "Grid power (positive = from the grid)", domains: ["sensor"] },
|
||||
{ k: "invertGrid", t: "check", l: "My grid sensor is positive when sending back", d: false },
|
||||
{ k: "battery", t: "entity", l: "Battery power (positive = charging)", domains: ["sensor"] },
|
||||
{ k: "invertBattery", t: "check", l: "My battery sensor is positive when discharging", d: false },
|
||||
{ k: "batteryLevel", t: "entity", l: "Battery level (%)", domains: ["sensor"] },
|
||||
{ k: "home", t: "entity", l: "Home use (optional, otherwise calculated)", domains: ["sensor"] },
|
||||
{ k: "today", t: "entities", l: "Totals for today, e.g. kWh produced and used", domains: ["sensor"] },
|
||||
],
|
||||
entities: (c) => [c.solar, c.grid, c.battery, c.batteryLevel, c.home, ...(c.today || [])].filter(Boolean),
|
||||
render(c, { ha }) {
|
||||
if (!c.solar && !c.grid && !c.battery) return `<div class="empty">${t("Choose your solar, grid or battery power sensors in the inspector.")}</div>`;
|
||||
const S = (k) => (c[k] ? ha.states[c[k]] : null);
|
||||
const solar = Math.max(0, watts(S("solar")) ?? 0);
|
||||
let grid = watts(S("grid")) ?? 0; if (c.invertGrid) grid = -grid;
|
||||
let batt = watts(S("battery")) ?? 0; if (c.invertBattery) batt = -batt;
|
||||
const imp = Math.max(0, grid), exp = Math.max(0, -grid), charge = Math.max(0, batt), discharge = Math.max(0, -batt);
|
||||
const home = Math.max(0, watts(S("home")) ?? solar + imp + discharge - exp - charge);
|
||||
// Who feeds what: solar goes to the home first, then the battery, the rest to the grid.
|
||||
const sToH = Math.min(solar, home), sRest = solar - sToH, sToB = Math.min(sRest, charge), sToG = Math.max(0, sRest - sToB);
|
||||
const gToH = Math.min(imp, Math.max(0, home - sToH - discharge)), gToB = Math.max(0, charge - sToB), bToH = Math.min(discharge, Math.max(0, home - sToH));
|
||||
const has = { solar: !!c.solar, grid: !!c.grid, battery: !!c.battery };
|
||||
const lvl = Number(S("batteryLevel")?.state);
|
||||
|
||||
const flow = (on, d, w, cls) => {
|
||||
if (!on) return "";
|
||||
const speed = Math.max(0.5, Math.min(5, 2400 / (w + 150))); // seconds per cycle: more power, faster dots
|
||||
const width = w > 1 ? Math.max(1.5, Math.min(5, 1.2 + Math.log10(w + 1))) : 1;
|
||||
return `<path d="${d}" class="ef-line ${cls}"/>${w > 1 ? `<path d="${d}" class="ef-flow ${cls}" style="stroke-width:${width.toFixed(1)};animation-duration:${speed.toFixed(2)}s"/>` : ""}`;
|
||||
};
|
||||
const lines = [
|
||||
flow(has.solar, "M50 14 Q86 14 86 50", sToH, "solar"),
|
||||
flow(has.solar && has.grid, "M50 14 Q14 14 14 50", sToG, "solar"),
|
||||
flow(has.solar && has.battery, "M50 14 L50 86", sToB, "solar"),
|
||||
flow(has.grid, "M14 50 L86 50", gToH, "grid"),
|
||||
flow(has.grid && has.battery, "M14 50 Q14 86 50 86", gToB, "grid"),
|
||||
flow(has.battery, "M50 86 Q86 86 86 50", bToH, "battery"),
|
||||
].join("");
|
||||
const node = (key, ico, label, value, extra = "") => `<div class="ef-node ef-${key}"><span class="ef-ico">${Icons.svg(ico)}</span>
|
||||
<span class="ef-val num">${esc(value)}</span><span class="ef-label">${esc(label)}${extra}</span></div>`;
|
||||
const totals = (c.today || []).map((e) => ha.states[e] && `<span class="ef-total"><b class="num">${esc(fmtState(ha.states[e]))}</b> ${esc(friendly(ha.states[e]))}</span>`).filter(Boolean).join("");
|
||||
|
||||
return `<div class="energy ${has.battery ? "" : "no-battery"} ${has.solar ? "" : "no-solar"}">
|
||||
<div class="ef-diagram" role="img" aria-label="${esc(t("Solar {0}, grid {1}, home {2}", fmtW(solar), grid >= 0 ? fmtW(imp) : "−" + fmtW(exp), fmtW(home)))}">
|
||||
<svg viewBox="0 0 100 100" preserveAspectRatio="none" aria-hidden="true">${lines}</svg>
|
||||
${has.solar ? node("solar", "sun", t("Solar"), fmtW(solar)) : ""}
|
||||
${has.grid ? node("grid", "plug", exp > imp ? t("To the grid") : t("From the grid"), fmtW(exp > imp ? exp : imp)) : ""}
|
||||
${node("home", "home", t("Home"), fmtW(home))}
|
||||
${has.battery ? node("battery", "battery", charge > 1 ? t("Charging") : discharge > 1 ? t("Discharging") : t("Battery"), isFinite(lvl) ? `${Math.round(lvl)}%` : fmtW(Math.abs(batt)), isFinite(lvl) && Math.abs(batt) > 1 ? ` · ${fmtW(Math.abs(batt))}` : "") : ""}
|
||||
</div>
|
||||
${totals ? `<div class="ef-totals">${totals}</div>` : ""}</div>`;
|
||||
},
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------- room
|
||||
// Everything in one Home Assistant area at a glance: climate, what's on or open, and quick controls.
|
||||
function roomEntities(c, ha) {
|
||||
@@ -614,12 +681,19 @@
|
||||
// Works with any integration: a status entity, and/or a power sensor above a threshold,
|
||||
// plus optional remaining time (minutes, hours or an end timestamp), progress and program.
|
||||
const RUNNING_RE = /run|wash|dry|rins|spin|heat|clean|activ|program|bezig|draait|centrifug|spoel|droog|^on$|^true$/;
|
||||
const DONE_RE = /finish|end|done|complete|ready|klaar|gereed|beëindigd/;
|
||||
const PAUSE_RE = /pause|gepauzeerd|delayed|uitgesteld/;
|
||||
const DONE_RE = /finish|end|done|complete|klaar|gereed|beëindigd/;
|
||||
const PAUSE_RE = /pause|gepauzeerd/;
|
||||
// Home Connect's operation states, matched exactly first (https://www.home-assistant.io/integrations/home_connect/).
|
||||
// "ready" means ready to start and "inactive" switched off: both count as off.
|
||||
const HC_STATES = { run: "running", pause: "paused", finished: "done", delayedstart: "scheduled", actionrequired: "attention", error: "error", aborting: "stopping", inactive: "off", ready: "off" };
|
||||
|
||||
function appliancePhase(c, ha) {
|
||||
const status = String(ha.states[c.status]?.state || "").toLowerCase();
|
||||
if (status && !["unavailable", "unknown"].includes(status)) {
|
||||
if (HC_STATES[status]) return HC_STATES[status];
|
||||
if (/^(off|idle|standby|none)$/.test(status)) return "off";
|
||||
if (/delayed|uitgesteld|scheduled/.test(status)) return "scheduled";
|
||||
if (/error|fault|fout/.test(status)) return "error";
|
||||
if (DONE_RE.test(status)) return "done";
|
||||
if (PAUSE_RE.test(status)) return "paused";
|
||||
if (RUNNING_RE.test(status)) return "running";
|
||||
@@ -642,40 +716,122 @@
|
||||
}
|
||||
const fmtDuration = (m) => (m >= 60 ? t("{0} h {1} min", Math.floor(m / 60), m % 60) : t("{0} min", m));
|
||||
|
||||
// "dishcare_dishwasher_program_eco_50" → "Eco 50" (Home Connect program keys); other values are shown as they are.
|
||||
const prettyProgram = (v) => {
|
||||
const s = String(v);
|
||||
return s.includes("_program_") ? cap(s.split("_program_").pop().replace(/_/g, " ")) : st(s);
|
||||
};
|
||||
const ALERT_ON = new Set(["present", "confirmed", "on", "true"]);
|
||||
|
||||
W.appliance = {
|
||||
label: "Appliance", icon: "washer", size: [4, 2],
|
||||
fields: [
|
||||
{ k: "name", t: "text", l: "Name", d: "Washing machine" },
|
||||
{ k: "kind", t: "seg", l: "Appliance", o: ["washer", "dryer", "dishwasher", "other"], d: "washer" },
|
||||
{ k: "status", t: "entity", l: "Status", domains: ["sensor", "binary_sensor", "switch", "select", "input_select", "input_boolean"] },
|
||||
{ k: "status", t: "entity", l: "Status (with Home Connect: Operation state — the rest fills in by itself)", domains: ["sensor", "binary_sensor", "switch", "select", "input_select", "input_boolean"] },
|
||||
{ k: "power", t: "entity", l: "Power (W), if there is no status", domains: ["sensor"] },
|
||||
{ k: "threshold", t: "number", l: "Running above (W)", d: 5 },
|
||||
{ k: "remaining", t: "entity", l: "Time remaining or end time", domains: ["sensor"] },
|
||||
{ k: "remaining", t: "entity", l: "Time remaining or finish time", domains: ["sensor"] },
|
||||
{ k: "progress", t: "entity", l: "Progress (%)", domains: ["sensor", "number"] },
|
||||
{ k: "program", t: "entity", l: "Program", domains: ["sensor", "select", "input_select"] },
|
||||
{ k: "door", t: "entity", l: "Door (optional)", domains: ["binary_sensor", "sensor"] },
|
||||
{ k: "energy", t: "entity", l: "Energy or water use (optional)", domains: ["sensor"] },
|
||||
{ k: "pauseBtn", t: "entity", l: "Pause button", domains: ["button"] },
|
||||
{ k: "resumeBtn", t: "entity", l: "Resume button", domains: ["button"] },
|
||||
{ k: "stopBtn", t: "entity", l: "Stop button", domains: ["button"] },
|
||||
{ k: "powerSwitch", t: "entity", l: "Power switch", domains: ["switch"] },
|
||||
{ k: "alerts", t: "entities", l: "Alerts, e.g. salt or rinse aid low", domains: ["sensor", "binary_sensor", "event"] },
|
||||
{ k: "hideWhenOff", t: "check", l: "Only show while running or finished", d: true },
|
||||
],
|
||||
entities: (c) => [c.status, c.power, c.remaining, c.progress, c.program].filter(Boolean),
|
||||
autoHide: (c, { ha }) => !!c.hideWhenOff && !!(c.status || c.power || c.remaining) && appliancePhase(c, ha) === "off",
|
||||
render(c, { ha }) {
|
||||
// Picking the status fills in the other entities of the same device (Home Connect names them predictably).
|
||||
autofill: {
|
||||
from: "status",
|
||||
fill(eid, ha) {
|
||||
const dev = ha.reg?.deviceOf?.[eid];
|
||||
const base = eid.split(".")[1].replace(/_(operation_state|status|state)$/, "");
|
||||
const sibs = dev ? ha.reg.byDevice[dev] : Object.keys(ha.states).filter((e) => e.split(".")[1].startsWith(base));
|
||||
const find = (dom, re) => sibs.find((e) => domainOf(e) === dom && re.test(e)) || "";
|
||||
return {
|
||||
progress: find("sensor", /progress/), remaining: find("sensor", /finish_time|remaining|end_time/),
|
||||
door: find("sensor", /door/) || find("binary_sensor", /door/), program: find("select", /active_program/) || find("sensor", /program$/),
|
||||
pauseBtn: find("button", /pause/), resumeBtn: find("button", /resume/), stopBtn: find("button", /stop|abort/),
|
||||
powerSwitch: find("switch", /_power$/), energy: find("sensor", /energy|water/),
|
||||
alerts: sibs.filter((e) => /salt|rinse_aid|i_dos|poor_|machine_care/.test(e)),
|
||||
kind: /dish/.test(eid) ? "dishwasher" : /dryer|droger/.test(eid) && !/washer_dryer/.test(eid) ? "dryer" : undefined,
|
||||
};
|
||||
},
|
||||
},
|
||||
entities: (c) => [c.status, c.power, c.remaining, c.progress, c.program, c.door, c.energy, c.pauseBtn, c.resumeBtn, c.stopBtn, c.powerSwitch, ...(c.alerts || [])].filter(Boolean),
|
||||
autoHide: (c, { ha }) => !!c.hideWhenOff && !!(c.status || c.power || c.remaining) && appliancePhase(c, ha) === "off"
|
||||
&& !(c.alerts || []).some((e) => ALERT_ON.has(String(ha.states[e]?.state).toLowerCase())),
|
||||
// Two rows high: a progress ring around the appliance on the left, the story on the right.
|
||||
// Taller cards stack the ring on top (see the container queries in style.css).
|
||||
render(c, { ha, cache }) {
|
||||
if (!c.status && !c.power && !c.remaining) return `<div class="empty">${t("Choose the appliance’s status, power or remaining-time entity in the inspector.")}</div>`;
|
||||
const phase = appliancePhase(c, ha);
|
||||
const label = { running: t("In progress"), done: t("Finished"), paused: t("Paused"), off: t("Off") }[phase];
|
||||
const left = phase === "running" || phase === "paused" ? remainingMinutes(c, ha) : null;
|
||||
const prog = Number(ha.states[c.progress]?.state);
|
||||
const label = { running: t("In progress"), done: t("Finished"), paused: t("Paused"), off: t("Off"), scheduled: t("Starts later"), attention: t("Action needed"), error: t("Error"), stopping: t("Stopping") }[phase];
|
||||
const active = ["running", "paused", "scheduled", "attention"].includes(phase);
|
||||
const left = active ? remainingMinutes(c, ha) : null;
|
||||
// Progress from the integration, or worked out from the time left (relative to the most seen this run).
|
||||
if (phase === "off") cache.maxLeft = 0;
|
||||
if (left != null && phase !== "scheduled") cache.maxLeft = Math.max(cache.maxLeft || 0, left);
|
||||
let prog = Number(ha.states[c.progress]?.state);
|
||||
if (!isFinite(prog) || !c.progress) prog = left != null && cache.maxLeft > 0 && phase !== "scheduled" ? 100 * (1 - left / cache.maxLeft) : NaN;
|
||||
if (phase === "done") prog = 100;
|
||||
const program = ha.states[c.program]?.state;
|
||||
const showProgram = program && !["unknown", "unavailable", "none", "off", ""].includes(String(program).toLowerCase());
|
||||
const ends = left != null ? new Date(Date.now() + left * 60000).toLocaleTimeString(LOCALE, { hour: "2-digit", minute: "2-digit" }) : "";
|
||||
const ico = { washer: "washer", dryer: "dryer", dishwasher: "dishwasher", other: "plug" }[c.kind] || "washer";
|
||||
const showProgram = program && !["unknown", "unavailable", "none", "off", ""].includes(String(program).toLowerCase());
|
||||
return `<div class="appl phase-${phase}">
|
||||
<div class="tile-row"><span class="badge">${Icons.svg(ico)}</span>
|
||||
<span class="tile-text"><span class="name">${esc(c.name || t("Appliance"))}</span>${showProgram ? `<span class="state">${esc(st(program))}</span>` : ""}</span>
|
||||
<span class="appl-status">${esc(label)}</span></div>
|
||||
${phase === "done" ? `<div class="appl-big">${t("Ready to unload")}</div>` : left != null ? `<div class="appl-big"><span class="num">${esc(fmtDuration(left))}</span> <span class="wt">${t("left · ready at {0}", ends)}</span></div>` : ""}
|
||||
${isFinite(prog) && c.progress && phase !== "off" ? `<div class="car-bar" role="progressbar" aria-valuemin="0" aria-valuemax="100" aria-valuenow="${Math.round(prog)}" aria-label="${t("Progress")}"><span class="car-fill" style="width:${Math.max(0, Math.min(100, prog))}%"></span></div>` : ""}
|
||||
</div>`;
|
||||
const ringIco = phase === "done" ? "check" : phase === "attention" || phase === "error" ? "shield" : ico;
|
||||
|
||||
const R = 44, C = 2 * Math.PI * R, frac = isFinite(prog) ? Math.max(0, Math.min(1, prog / 100)) : 0;
|
||||
const showRing = isFinite(prog) && phase !== "off";
|
||||
const ring = `<div class="appl-ring" ${showRing ? `role="progressbar" aria-valuemin="0" aria-valuemax="100" aria-valuenow="${Math.round(prog)}" aria-label="${t("Progress")}"` : ""}>
|
||||
<svg viewBox="0 0 100 100" aria-hidden="true"><circle cx="50" cy="50" r="${R}" class="track"/>
|
||||
${showRing ? `<circle cx="50" cy="50" r="${R}" class="fill" stroke-dasharray="${(C * frac).toFixed(1)} ${C.toFixed(1)}"/>` : ""}</svg>
|
||||
<span class="appl-ico">${Icons.svg(ringIco)}</span>
|
||||
${isFinite(prog) && phase === "running" ? `<span class="appl-pct num">${Math.round(prog)}%</span>` : ""}</div>`;
|
||||
|
||||
// Door: Home Connect's door state is open / closed / locked; a binary sensor is on when open.
|
||||
const door = ha.states[c.door], energy = ha.states[c.energy], power = ha.states[c.power];
|
||||
const doorState = door ? (domainOf(c.door) === "binary_sensor" ? (isOn(door) ? "open" : "closed") : String(door.state).toLowerCase()) : null;
|
||||
const alerts = (c.alerts || []).filter((e) => ALERT_ON.has(String(ha.states[e]?.state).toLowerCase()));
|
||||
// Alert names start with the device's name ("Dishwasher Rinse aid nearly empty"); take that from the status entity.
|
||||
const device = friendly(ha.states[c.status]).replace(/\s*(operation state|bedieningsstatus|status|state)$/i, "");
|
||||
const strip = (txt) => (device && txt.toLowerCase().startsWith(device.toLowerCase() + " ") ? txt.slice(device.length + 1) : txt);
|
||||
const facts = [
|
||||
...alerts.map((e) => `<span class="appl-fact warn">${Icons.svg("shield")}<span>${esc(cap(strip(friendly(ha.states[e]))))}</span></span>`),
|
||||
doorState && ["open", "closed", "locked"].includes(doorState) && `<span class="appl-fact ${doorState === "open" ? "warn" : ""}">${Icons.svg(doorState === "open" ? "door-open" : doorState === "locked" ? "lock" : "door")}${esc({ open: t("Door open"), closed: t("Door closed"), locked: t("Door locked") }[doorState])}</span>`,
|
||||
power && phase === "running" && `<span class="appl-fact">${Icons.svg("bolt")}${esc(fmtState(power))}</span>`,
|
||||
energy && `<span class="appl-fact">${Icons.svg("pulse")}${esc(fmtState(energy))}</span>`,
|
||||
].filter(Boolean).join("");
|
||||
|
||||
// Controls: Home Connect offers pause, resume and stop as buttons, and power as a switch.
|
||||
const ctl = (eid, ico, lbl, act = "svc", v = "button.press") => (eid && ha.states[eid] ? `<button class="appl-btn wbtn" data-act="${act}" data-e="${esc(eid)}" ${act === "svc" ? `data-v="${v}"` : ""} aria-label="${esc(lbl)}" title="${esc(lbl)}">${Icons.svg(ico)}</button>` : "");
|
||||
const powerOff = c.powerSwitch && ha.states[c.powerSwitch]?.state === "off";
|
||||
const ctrls = [
|
||||
phase === "running" && ctl(c.pauseBtn, "pause", t("Pause")),
|
||||
phase === "paused" && ctl(c.resumeBtn, "play", t("Resume")),
|
||||
["running", "paused", "scheduled"].includes(phase) && ctl(c.stopBtn, "stop", t("Stop")),
|
||||
phase === "off" && powerOff && ctl(c.powerSwitch, "power", t("Turn on"), "toggle"),
|
||||
].filter(Boolean).join("");
|
||||
|
||||
const main = phase === "done" ? `<span class="appl-big">${t("Ready to unload")}</span>`
|
||||
: phase === "scheduled" ? `<span class="appl-big">${left != null ? t("Ready at {0}", ends) : t("Starts later")}</span>`
|
||||
: phase === "attention" ? `<span class="appl-big">${t("Check the appliance")}</span>`
|
||||
: phase === "error" ? `<span class="appl-big">${t("Something went wrong")}</span>`
|
||||
: left != null ? `<span class="appl-big num">${esc(fmtDuration(left))}</span><span class="appl-sub">${t("left · ready at {0}", ends)}</span>`
|
||||
: phase === "running" ? `<span class="appl-big">${t("In progress")}</span>` : `<span class="appl-big appl-off">${esc(label)}</span>`;
|
||||
|
||||
return `<div class="appl phase-${phase}">${ring}
|
||||
<div class="appl-text">
|
||||
<div class="appl-top"><span class="name">${esc(c.name || t("Appliance"))}</span><span class="appl-status">${esc(label)}</span></div>
|
||||
${showProgram ? `<span class="appl-program">${esc(prettyProgram(program))}</span>` : ""}
|
||||
<div class="appl-main"><div class="appl-main-text">${main}</div>${ctrls ? `<div class="appl-ctrls">${ctrls}</div>` : ""}</div>
|
||||
${facts ? `<div class="appl-facts">${facts}</div>` : ""}
|
||||
</div></div>`;
|
||||
},
|
||||
lit: (c, { ha }) => (["running", "done"].includes(appliancePhase(c, ha)) ? "on" : false),
|
||||
lit: (c, { ha }) => (["running", "done", "attention"].includes(appliancePhase(c, ha)) ? "on" : false),
|
||||
};
|
||||
|
||||
W.camera = {
|
||||
@@ -693,7 +849,7 @@
|
||||
const truthy = (s) => !!s && (["on", "true", "charging", "plugged_in", "connected"].includes(String(s.state).toLowerCase()));
|
||||
|
||||
W.car = {
|
||||
label: "Car", icon: "car", size: [6, 3],
|
||||
label: "Car", icon: "car", size: [3, 4],
|
||||
fields: [
|
||||
{ k: "name", t: "text", l: "Name", d: "My car" },
|
||||
{ k: "image", t: "text", l: "Car image URL (optional)", ph: "https://… or /local/car.png" },
|
||||
@@ -710,15 +866,18 @@
|
||||
{ k: "odometer", t: "entity", l: "Odometer", domains: ["sensor"] },
|
||||
],
|
||||
entities: (c) => ["level", "range", "charging", "power", "plug", "limit", "lock", "climate", "location", "odometer"].map((k) => c[k]).filter(Boolean),
|
||||
// Layout follows the card's size (container queries in style.css): designed around 3×4,
|
||||
// a compact strip when short, and the car beside the numbers when wide.
|
||||
render(c, { ha }) {
|
||||
const st = (k) => (c[k] ? ha.states[c[k]] : null);
|
||||
const level = st("level"), range = st("range"), lock = st("lock"), clim = st("climate"), loc = st("location");
|
||||
const st_ = (k) => (c[k] ? ha.states[c[k]] : null);
|
||||
const level = st_("level"), range = st_("range"), lock = st_("lock"), clim = st_("climate"), loc = st_("location");
|
||||
if (!c.level && !c.range && !c.lock) return `<div class="empty">${t("Set the car’s level, range or lock entity in the inspector.")}</div>`;
|
||||
const pct = level && isFinite(Number(level.state)) ? Math.round(Number(level.state)) : null;
|
||||
const power = st("power"), plug = st("plug"), limit = st("limit");
|
||||
const charging = truthy(st("charging")) || (!c.charging && power && Number(power.state) > 0);
|
||||
const power = st_("power"), plug = st_("plug"), limit = st_("limit"), odo = st_("odometer");
|
||||
const charging = truthy(st_("charging")) || (!c.charging && power && Number(power.state) > 0);
|
||||
const plugged = charging || truthy(plug);
|
||||
const low = pct != null && pct <= 20;
|
||||
const fuel = c.kind === "fuel";
|
||||
|
||||
let status;
|
||||
if (charging) status = `${t("Charging")}${power && Number(power.state) > 0 ? " · " + fmtState(power) : ""}`;
|
||||
@@ -728,34 +887,45 @@
|
||||
else status = "";
|
||||
|
||||
const limPct = limit && isFinite(Number(limit.state)) ? Number(limit.state) : null;
|
||||
const bar = pct == null ? "" : `
|
||||
<div class="car-bar ${charging ? "charging" : ""} ${low ? "low" : ""}" role="meter" aria-valuemin="0" aria-valuemax="100" aria-valuenow="${pct}" aria-label="${c.kind === "fuel" ? t("Fuel level") : t("Battery level")}">
|
||||
<span class="car-fill" style="width:${pct}%"></span>
|
||||
${limPct != null && c.kind !== "fuel" ? `<span class="car-limit" style="left:${limPct}%" title="${t("Charge limit {0}%", limPct)}"></span>` : ""}
|
||||
</div>`;
|
||||
const actions = [];
|
||||
if (lock) {
|
||||
const locked = lock.state === "locked";
|
||||
actions.push(`<button class="car-act wbtn" data-act="toggle" data-e="${esc(c.lock)}" aria-pressed="${!locked}">${Icons.svg(locked ? "lock" : "unlock")}<span>${locked ? t("Locked") : t("Unlocked")}</span></button>`);
|
||||
}
|
||||
if (clim) {
|
||||
const on = isOn(clim) || clim.state === "on";
|
||||
actions.push(`<button class="car-act wbtn" data-act="toggle" data-e="${esc(c.climate)}" aria-pressed="${on}">${Icons.svg(on ? "fan" : "snowflake")}<span>${on ? t("Climate on") : t("Climate")}</span></button>`);
|
||||
}
|
||||
if (loc) actions.push(`<button class="car-act wbtn" data-act="more" data-e="${esc(c.location)}">${Icons.svg("pin")}<span>${esc(fmtState(loc))}</span></button>`);
|
||||
const odo = st("odometer");
|
||||
// Drawn car for when there's no photo: a side view, with a glow and a bolt while charging.
|
||||
const drawn = `<svg class="car-art" viewBox="0 0 200 84" aria-hidden="true">
|
||||
<path class="car-body" d="M10 60c1-10 9-15 22-17l26-15c7-4 15-6 24-6h40c10 0 18 3 25 9l15 12c13 1 24 4 28 11l2 8c0 4-2 6-6 6H12c-3 0-4-3-2-8z"/>
|
||||
<path class="car-glass" d="M64 31c6-3 12-4 19-4h16v18H52zM105 27h17c8 0 14 2 20 7l9 11h-46z"/>
|
||||
<circle class="car-wheel" cx="46" cy="68" r="13"/><circle class="car-wheel" cx="154" cy="68" r="13"/>
|
||||
<circle class="car-hub" cx="46" cy="68" r="5"/><circle class="car-hub" cx="154" cy="68" r="5"/></svg>`;
|
||||
const visual = `<div class="car-visual ${charging ? "is-charging" : ""}">${c.image ? `<img class="car-photo" src="${esc(c.image)}" alt="">` : drawn}
|
||||
${charging ? `<span class="car-bolt" aria-hidden="true">${Icons.svg("bolt")}</span>` : ""}</div>`;
|
||||
|
||||
return `<div class="car ${c.image ? "has-img" : ""}">
|
||||
${c.image ? `<img class="car-img" src="${esc(c.image)}" alt="">` : ""}
|
||||
<div class="car-head"><div class="name">${esc(c.name || t("Car"))}</div><div class="wt">${charging ? Icons.svg("bolt", "inline") : ""}${esc(status)}</div></div>
|
||||
<div class="car-main">
|
||||
${pct != null ? `<span class="big num">${pct}<span class="unit">%</span></span>` : ""}
|
||||
${range ? `<span class="car-range">${esc(fmtState(range))}<span class="wt"> ${t("range")}</span></span>` : ""}
|
||||
${low ? `<span class="level">${t("Low")}</span>` : ""}
|
||||
</div>
|
||||
${bar}
|
||||
${actions.length || odo ? `<div class="car-actions">${actions.join("")}${odo ? `<span class="wt car-odo">${esc(fmtState(odo))}</span>` : ""}</div>` : ""}
|
||||
const bar = pct == null ? "" : `
|
||||
<div class="car-bar ${charging ? "charging" : ""} ${low ? "low" : ""}" role="meter" aria-valuemin="0" aria-valuemax="100" aria-valuenow="${pct}" aria-label="${fuel ? t("Fuel level") : t("Battery level")}">
|
||||
<span class="car-fill" style="width:${pct}%"></span>
|
||||
${limPct != null && !fuel ? `<span class="car-limit" style="left:${limPct}%" title="${t("Charge limit {0}%", limPct)}"></span>` : ""}
|
||||
</div>`;
|
||||
|
||||
const action = (eid, on, ico, label) => `<button class="car-btn wbtn" data-act="${eid === c.location ? "more" : "toggle"}" data-e="${esc(eid)}" ${eid === c.location ? "" : `aria-pressed="${on}"`}>
|
||||
<span class="car-btn-ico">${Icons.svg(ico)}</span><span class="car-btn-label">${esc(label)}</span></button>`;
|
||||
const actions = [];
|
||||
if (lock) { const locked = lock.state === "locked"; actions.push(action(c.lock, !locked, locked ? "lock" : "unlock", locked ? t("Locked") : t("Unlocked"))); }
|
||||
if (clim) { const on = isOn(clim) || clim.state === "on"; actions.push(action(c.climate, on, on ? "fan" : "snowflake", on ? t("Climate on") : t("Climate"))); }
|
||||
if (loc) actions.push(action(c.location, false, "pin", fmtState(loc)));
|
||||
|
||||
const stat = (label, value) => `<div class="car-stat"><span class="car-stat-v num">${esc(value)}</span><span class="car-stat-l">${esc(label)}</span></div>`;
|
||||
const stats = [
|
||||
odo && stat(t("Odometer"), fmtState(odo)),
|
||||
limPct != null && !fuel && stat(t("Charge limit"), `${limPct}%`),
|
||||
charging && power && stat(t("Charging power"), fmtState(power)),
|
||||
].filter(Boolean).join("");
|
||||
|
||||
return `<div class="car2 ${c.image ? "has-photo" : ""}">
|
||||
<div class="car-top"><span class="name">${esc(c.name || t("Car"))}</span>${status ? `<span class="car-status ${charging ? "charging" : ""}">${charging ? Icons.svg("bolt", "inline") : ""}${esc(status)}</span>` : ""}</div>
|
||||
${visual}
|
||||
<div class="car-info">
|
||||
<div class="car-nums">${pct != null ? `<span class="car-pct num">${pct}<span class="unit">%</span></span>` : ""}
|
||||
${range ? `<span class="car-range num">${esc(fmtState(range))}</span>` : ""}${low ? `<span class="level">${t("Low")}</span>` : ""}</div>
|
||||
${bar}
|
||||
${stats ? `<div class="car-stats">${stats}</div>` : ""}
|
||||
${actions.length ? `<div class="car-actions2">${actions.join("")}</div>` : ""}
|
||||
</div></div>`;
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1000,20 +1170,25 @@
|
||||
// Scroll and swipe cards hold other cards in cfg.children. They draw placeholders;
|
||||
// ctx.renderChildren fills them using the normal widget renderer.
|
||||
const slots = (c, ctx) => (c.children || []).filter((ch) => ctx.isVisible(ch));
|
||||
const slot = (ch) => `<div class="w child" data-id="${esc(ch.id)}"></div>`;
|
||||
const slot = (ch) => `<div class="w child" data-id="${esc(ch.id)}" ${Number(ch.cfg?._width) > 0 ? `style="--own-w:${Number(ch.cfg._width)}px"` : ""}></div>`;
|
||||
const noKids = (c) => `<div class="pad"><div class="empty">${(c.children || []).length ? t("None of these cards are showing right now.") : t("Add cards in the inspector.")}</div></div>`;
|
||||
|
||||
W.hscroll = {
|
||||
label: "Horizontal scroll", icon: "hscroll", size: [12, 3], container: true, transparent: true, flush: true,
|
||||
fields: [
|
||||
{ k: "title", t: "text", l: "Title", d: "" },
|
||||
{ k: "itemWidth", t: "number", l: "Card width (px)", d: 260 },
|
||||
{ k: "widthMode", t: "seg", l: "Card width", o: ["pixels", "per-view"], d: "pixels" },
|
||||
{ k: "itemWidth", t: "number", l: "Card width (px)", d: 260, when: (c) => c.widthMode !== "per-view" },
|
||||
{ k: "perView", t: "number", l: "Cards visible at once", d: 2.5, ph: "e.g. 1, 2 or 2.5", when: (c) => c.widthMode === "per-view" },
|
||||
],
|
||||
render(c, ctx) {
|
||||
const kids = slots(c, ctx);
|
||||
if (!kids.length) return noKids(c);
|
||||
return `<div class="hs">${c.title ? `<div class="wt hs-title">${esc(c.title)}</div>` : ""}
|
||||
<div class="hs-track" style="--item-w:${Math.max(120, Number(c.itemWidth) || 260)}px" tabindex="0" role="group" aria-label="${esc(c.title || t("Horizontal scroll"))}">${kids.map(slot).join("")}</div>
|
||||
<div class="hs-track" style="--item-w:${c.widthMode === "per-view"
|
||||
// N cards per view, gaps included; a half card peeking out hints that the row scrolls
|
||||
? `calc((100% - var(--d-gap, 14px) * ${Math.max(1, Number(c.perView) || 2.5) - 1}) / ${Math.max(1, Number(c.perView) || 2.5)})`
|
||||
: `${Math.max(120, Number(c.itemWidth) || 260)}px`}" tabindex="0" role="group" aria-label="${esc(c.title || t("Horizontal scroll"))}">${kids.map(slot).join("")}</div>
|
||||
<button class="hs-nav prev wbtn" data-act="hs-prev" aria-label="${t("Scroll left")}">${Icons.svg("chevron", "back")}</button>
|
||||
<button class="hs-nav next wbtn" data-act="hs-next" aria-label="${t("Scroll right")}">${Icons.svg("chevron", "fwd")}</button></div>`;
|
||||
},
|
||||
@@ -1109,12 +1284,29 @@
|
||||
else if (d.dx > sw.clientWidth / 4 || speed > 0.35) p--;
|
||||
go(p);
|
||||
showDots();
|
||||
cache.swallowClick = true; // the release after a swipe must not press a button underneath
|
||||
// The release after a swipe must not press a button underneath. Only for a moment: touch screens
|
||||
// send no click after a swipe, and a lasting flag would eat the next real tap.
|
||||
cache.swallowUntil = Date.now() + 350;
|
||||
if (cache.dirty) { cache.dirty = false; setTimeout(() => ctx.rerender(), 400); }
|
||||
};
|
||||
track.addEventListener("pointerup", end);
|
||||
track.addEventListener("pointercancel", end);
|
||||
track.addEventListener("click", (e) => { if (cache.swallowClick) { cache.swallowClick = false; e.stopPropagation(); e.preventDefault(); } }, true);
|
||||
track.addEventListener("lostpointercapture", end);
|
||||
track.addEventListener("dragstart", (e) => e.preventDefault()); // images would start a native drag and cancel the swipe
|
||||
track.addEventListener("click", (e) => { if (Date.now() < (cache.swallowUntil || 0)) { e.stopPropagation(); e.preventDefault(); } }, true);
|
||||
// Two-finger swipe on a trackpad: horizontal wheel movement turns one page at a time.
|
||||
let wheelSum = 0, wheelLock = 0;
|
||||
sw.addEventListener("wheel", (e) => {
|
||||
if (Math.abs(e.deltaX) <= Math.abs(e.deltaY) || n < 2) return;
|
||||
e.preventDefault();
|
||||
if (Date.now() < wheelLock) return;
|
||||
wheelSum += e.deltaX;
|
||||
if (Math.abs(wheelSum) > 50) {
|
||||
cache.pausedUntil = Date.now() + 20000;
|
||||
cache.go(page + (wheelSum > 0 ? 1 : -1));
|
||||
wheelSum = 0; wheelLock = Date.now() + 450;
|
||||
}
|
||||
}, { passive: false });
|
||||
track.addEventListener("keydown", (e) => {
|
||||
if (e.key === "ArrowRight") { e.preventDefault(); cache.go(page + 1); }
|
||||
if (e.key === "ArrowLeft") { e.preventDefault(); cache.go(page - 1); }
|
||||
|
||||
Reference in New Issue
Block a user