Dashboard Studio 1.12.1: car card redesigned from scratch

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-26 15:46:25 +01:00
parent 7abf7379d8
commit 2ea437af31
6 changed files with 164 additions and 104 deletions
+4
View File
@@ -1,5 +1,9 @@
# Changelog # Changelog
## 1.12.1
- **Car card redesigned from scratch:** the car is now the battery. A side view of the car fills up with the charge level (green while charging, red when low) with the charge limit marked, under large numbers for the charge level and range, when it will be full and a status. Round buttons control the doors, climate, charging and location. Short cards show a slim battery bar instead, wide cards put the car beside the stats, and narrow phone cards stack neatly.
## 1.12.0 ## 1.12.0
- **Seven new cards:** a to-do and shopping list (add, tick off, swipe to delete), who's home (photo, home or away, for how long, which zone), a weather forecast (hourly with rain chance, the next days, sunrise and sunset), a kitchen timer (quick starts, countdown ring, a beep when done), blinds and covers (position, tilt, open or close all), an alarm panel (keypad, arming modes, warns about open doors and windows) and notifications (dismiss them, install updates). - **Seven new cards:** a to-do and shopping list (add, tick off, swipe to delete), who's home (photo, home or away, for how long, which zone), a weather forecast (hourly with rain chance, the next days, sunrise and sunset), a kitchen timer (quick starts, countdown ring, a beep when done), blinds and covers (position, tilt, open or close all), an alarm panel (keypad, arming modes, warns about open doors and windows) and notifications (dismiss them, install updates).
+1 -1
View File
@@ -163,7 +163,7 @@ Point the car widget at whichever entities your car integration provides. Each o
Two optional extra fields: **Time until full** (a sensor in minutes or an end time) and **Start/stop charging** (a switch or button). Two optional extra fields: **Time until full** (a sensor in minutes or an end time) and **Start/stop charging** (a switch or button).
The card is designed around 3×4. It shows a drawn car (or your own photo) that glows with a bolt while charging, a round gauge with the charge level, the range in the middle and the charge limit marked, a status (charging with its power and when it will be full, plugged in, parked at home or away) and round buttons for the doors, climate, charging 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/…`. The card is designed around 3×4 and is built around the car itself: a side view that fills up from the back with the charge level, green while charging, red when it's low, and orange for a fuel car. A dashed line in the car marks the charge limit. Above it are the charge level and range in large numbers, with when the car will be full (or how far it charges), and a status such as *Charging*, *Plugged in* or *Parked at home*. Round buttons along the bottom lock or unlock the doors (orange while unlocked), switch the climate (showing the temperature inside), start or stop charging, and show the location. On a short card the drawing becomes a slim battery bar, on a wide card the car sits on the left with a row of stats beside it, and a tall card adds stats for the odometer, charge limit, charging power and inside temperature. With your own car photo (by URL), the photo shows with the battery bar underneath. Images you put in Home Assistant's `www` folder are available at `/local/…`.
## Conditional widgets ## Conditional widgets
+1 -1
View File
@@ -1,5 +1,5 @@
name: Dashboard Studio name: Dashboard Studio
version: "1.12.0" version: "1.12.1"
slug: dashboard_studio slug: dashboard_studio
description: Drag-and-drop dashboard builder with a free-form grid, live entity widgets and themes. 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 url: https://git.melvanveen.nl/veenm/ha-dashboard-studio
+5
View File
@@ -380,6 +380,11 @@
"Each save is kept here, up to 30 versions. Changes within five minutes count as one.": "Elke opslag wordt hier bewaard, tot 30 versies. Wijzigingen binnen vijf minuten tellen als één.", "Each save is kept here, up to 30 versions. Changes within five minutes count as one.": "Elke opslag wordt hier bewaard, tot 30 versies. Wijzigingen binnen vijf minuten tellen als één.",
"No earlier versions yet. They appear after your next changes.": "Nog geen eerdere versies. Ze verschijnen na je volgende wijzigingen.", "No earlier versions yet. They appear after your next changes.": "Nog geen eerdere versies. Ze verschijnen na je volgende wijzigingen.",
"Restored the version from {0}. Press ⌘Z to undo.": "De versie van {0} is teruggezet. Druk op ⌘Z om ongedaan te maken.", "Restored the version from {0}. Press ⌘Z to undo.": "De versie van {0} is teruggezet. Druk op ⌘Z om ongedaan te maken.",
"Charges to {0}%": "Laadt tot {0}%",
"Low battery": "Accu bijna leeg",
"Low fuel": "Bijna leeg",
"Charge": "Laden",
"Inside": "Binnen",
"Today": "Vandaag", "Tomorrow": "Morgen", "All day": "Hele dag", "Busy": "Bezet", "Today": "Vandaag", "Tomorrow": "Morgen", "All day": "Hele dag", "Busy": "Bezet",
}; };
+80 -55
View File
@@ -974,63 +974,88 @@ body.kiosk #tabnav { top: 6px; max-height: calc(100vh - 20px); }
/* pill button with icon and label (media "Turn on") */ /* 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 { 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 .i { font-size: 18px; }
/* car: a round gauge with the range in the middle and the car in front */ /* car: the car itself fills up with the charge level */
.car3 { --car-top: color-mix(in srgb, var(--d-accent) 55%, var(--d-text)); --car-bottom: color-mix(in srgb, var(--d-accent) 70%, #000); .cv { --cv-c: var(--d-accent); display: grid; grid-template-areas: "head" "hero" "art" "stats" "ctrls"; grid-template-rows: auto auto minmax(0, 1fr) auto auto; row-gap: 10px; height: 100%; min-height: 0; min-width: 0; }
display: grid; grid-template-areas: "top" "gauge" "info"; grid-template-rows: auto minmax(0, 1fr) auto; gap: 8px; height: 100%; min-height: 0; } .cv.charging { --cv-c: var(--ok); }
.car3-top { grid-area: top; display: flex; align-items: center; gap: 8px; min-width: 0; } .cv.low { --cv-c: var(--crit); }
.car3-top .name { font-size: 17px; flex: 1; min-width: 0; } .cv.fuel:not(.low) { --cv-c: var(--warn); }
.car3-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: 62%; } .cv-head { grid-area: head; display: flex; align-items: center; gap: 8px; min-width: 0; }
.car3-status.charging { color: var(--ok); background: color-mix(in srgb, var(--ok) 16%, transparent); } .cv-name { flex: 1; min-width: 0; font-size: 15px; font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.cg { grid-area: gauge; position: relative; min-height: 0; height: 100%; max-height: 260px; max-width: 100%; justify-self: center; align-self: center; aspect-ratio: 1 / .92; } .cv-status { display: inline-flex; align-items: center; gap: 6px; font-size: 12px; font-weight: 600; color: var(--d-text-2); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 64%; }
.cg > svg { position: absolute; inset: 0; width: 100%; height: 100%; } .cv-status i { width: 7px; height: 7px; border-radius: 50%; background: currentColor; flex: none; }
.cg > svg path { fill: none; stroke-width: 12; stroke-linecap: round; } .cv-status.ok { color: var(--ok); } .cv-status.crit { color: var(--crit); } .cv-status.accent { color: var(--d-accent); }
.cg-track { stroke: var(--d-fill); } .cv-status.ok i { animation: cv-blink 1.6s ease-in-out infinite; }
.cg-fill { stroke: var(--d-text); } @keyframes cv-blink { 50% { opacity: .25; } }
.cg.charging .cg-fill { stroke: var(--ok); animation: cg-pulse 2.4s ease-in-out infinite; } .cv-hero { grid-area: hero; display: flex; align-items: flex-end; gap: 14px; min-width: 0; }
.cg.low .cg-fill { stroke: var(--crit); } .cv-big { font-size: clamp(40px, 19cqw, 68px); font-weight: 700; letter-spacing: -0.045em; line-height: .86; }
@keyframes cg-pulse { 50% { opacity: .65; } } .cv-big span { font-size: .42em; font-weight: 600; letter-spacing: 0; color: var(--d-text-2); margin-left: 2px; }
.cg-limit { stroke: var(--d-text-2); stroke-width: 3; stroke-linecap: round; } .cv-side { min-width: 0; padding-bottom: 2px; }
.cg-center { position: absolute; left: 0; right: 0; top: 22%; display: flex; flex-direction: column; align-items: center; } .cv-range { font-size: clamp(17px, 7cqw, 24px); font-weight: 650; letter-spacing: -0.02em; line-height: 1.05; white-space: nowrap; }
.cg-range { font-size: clamp(26px, 11cqh, 44px); font-weight: 700; letter-spacing: -0.02em; line-height: 1; } .cv-range small { font-size: .62em; font-weight: 600; color: var(--d-text-2); margin-left: 3px; letter-spacing: 0; }
.cg-range small { font-size: .42em; font-weight: 600; color: var(--d-text-2); margin-left: 3px; letter-spacing: 0; } .cv-sub { margin-top: 3px; font-size: 12px; font-weight: 500; color: var(--d-text-2); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.cg-pct { margin-top: 4px; font-size: 14px; font-weight: 600; color: var(--d-text-2); } .cv.charging .cv-sub { color: var(--ok); }
.cg.charging .cg-pct { color: var(--ok); } .cv-art { grid-area: art; min-height: 0; display: flex; flex-direction: column; justify-content: center; gap: 10px; }
.cg-car { position: absolute; left: 6%; right: 6%; bottom: 2%; display: flex; justify-content: center; } .cv-svg { display: block; width: 100%; height: 100%; max-height: 150px; min-height: 0; color: var(--cv-c); overflow: visible; }
.cg-car img { max-width: 100%; max-height: 72px; object-fit: contain; filter: drop-shadow(0 6px 10px rgba(0, 0, 0, .3)); } .cv-shadow { fill: var(--d-text); opacity: .12; }
.car-art2 { width: 100%; max-height: 74px; } .cv.charging .cv-shadow { fill: var(--ok); opacity: .35; }
.car-shadow { fill: rgba(0, 0, 0, .28); } .cv-shell { fill: color-mix(in srgb, var(--d-text) 9%, transparent); }
.car-body2 { fill: url(#carBody); } .cv-edge { fill: none; stroke: color-mix(in srgb, var(--d-text) 28%, transparent); stroke-width: 1.2; stroke-linejoin: round; }
.car-glass2 { fill: url(#carGlass); } .cv-glass { fill: color-mix(in srgb, var(--d-tile) 62%, transparent); stroke: color-mix(in srgb, var(--d-text) 22%, transparent); stroke-width: 1; stroke-linejoin: round; }
.car-line { stroke: rgba(255, 255, 255, .25); stroke-width: 1; } .cv-crease { stroke: color-mix(in srgb, var(--d-text) 14%, transparent); stroke-width: 1; fill: none; }
.car-light { fill: #fff6d6; } .cv-lamp { stroke: color-mix(in srgb, var(--d-text) 55%, transparent); stroke-width: 2.4; stroke-linecap: round; }
.car-tire { fill: #17181b; } .car-rim { fill: #8e9097; } .cv-tail { stroke: var(--crit); stroke-width: 2.4; stroke-linecap: round; opacity: .8; }
.car3.is-charging .cg-car::after { content: ""; position: absolute; left: 20%; right: 20%; bottom: 0; height: 40%; border-radius: 50%; background: radial-gradient(closest-side, color-mix(in srgb, var(--ok) 45%, transparent), transparent); z-index: -1; } .cv-limit { stroke: var(--d-text); stroke-opacity: .55; stroke-width: 1.6; stroke-dasharray: 3 2.5; }
.car3-info { grid-area: info; display: flex; flex-direction: column; gap: 8px; min-width: 0; } .cv-port { fill: var(--cv-c); stroke: var(--d-tile); stroke-width: 1.5; }
.cchips { display: flex; gap: 6px; flex-wrap: wrap; justify-content: center; } .cv.charging .cv-port { animation: cv-blink 1.6s ease-in-out infinite; }
.cchip { display: inline-flex; align-items: center; gap: 5px; font-size: 12px; font-weight: 600; padding: 3px 9px; border-radius: 99px; background: var(--d-fill); white-space: nowrap; } .cv-tyre { fill: color-mix(in srgb, var(--d-text) 82%, var(--d-tile)); }
.cchip .i { font-size: 14px; color: var(--d-text-2); } .cv-rim { fill: color-mix(in srgb, var(--d-text) 30%, var(--d-tile)); }
.cbs { display: flex; gap: 6px; } .cv-hub { fill: color-mix(in srgb, var(--d-text) 70%, var(--d-tile)); }
.cb { flex: 1; min-width: 0; display: flex; flex-direction: column; align-items: center; gap: 3px; padding: 8px 4px; border-radius: 14px; background: var(--d-fill); } .cv-sweep { fill: #fff; opacity: .35; animation: cv-sweep 2.2s ease-in-out infinite; }
.cb-ico { width: 32px; height: 32px; border-radius: 50%; display: grid; place-items: center; font-size: 18px; transition: background-color .2s; } @keyframes cv-sweep { from { transform: translateX(0); } to { transform: translateX(310px); } }
.cb[aria-pressed="true"] .cb-ico { background: var(--d-text); color: var(--d-tile); } .cv-img { min-height: 0; flex: 1; display: flex; align-items: center; justify-content: center; }
.cb-label { font-size: 12px; font-weight: 600; max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .cv-img img { max-width: 100%; max-height: 100%; object-fit: contain; filter: drop-shadow(0 8px 12px rgba(0, 0, 0, .25)); }
.cb-sub { font-size: 11px; color: var(--d-text-2); white-space: nowrap; } /* the slim battery bar: shown with a photo, and instead of the drawing on short cards */
/* short: gauge small on the left, numbers and buttons beside it */ .cv-bar { position: relative; height: 8px; border-radius: 99px; background: color-mix(in srgb, var(--d-text) 10%, transparent); display: none; flex: none; }
@container (max-height: 250px) { .cv-bar i { position: absolute; inset: 0 auto 0 0; border-radius: inherit; background: var(--cv-c); }
.car3 { grid-template-areas: "top top" "gauge info"; grid-template-columns: auto 1fr; grid-template-rows: auto 1fr; column-gap: 14px; } .cv-bar b { position: absolute; top: -3px; bottom: -3px; width: 2px; margin-left: -1px; border-radius: 1px; background: var(--d-text-2); }
.cg { width: auto; height: 100%; max-width: none; aspect-ratio: 1; } .cv.has-img .cv-bar { display: block; }
.cg-car { display: none; } .cv-stats { grid-area: stats; display: none; grid-template-columns: repeat(auto-fit, minmax(96px, 1fr)); gap: 6px; }
.cg-center { top: 30%; } .cv-stat { display: flex; flex-direction: column; gap: 2px; padding: 8px 10px; border-radius: 12px; background: var(--d-fill); min-width: 0; }
.car3-info { justify-content: center; } .cv-stat span { font-size: 11px; color: var(--d-text-2); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.cchips { justify-content: flex-start; } .cv-stat b { font-size: 14px; font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.cv-ctrls { grid-area: ctrls; display: grid; grid-auto-flow: column; grid-auto-columns: minmax(0, 1fr); gap: 6px; }
.cv-ctrl { display: flex; flex-direction: column; align-items: center; gap: 5px; min-width: 0; padding: 4px 2px; border-radius: 12px; }
.cv-ico { width: 40px; height: 40px; border-radius: 50%; display: grid; place-items: center; font-size: 19px; background: var(--d-fill); transition: background-color .2s, color .2s; }
.cv-ctrl.on .cv-ico { background: var(--cv-c); color: #fff; }
.cv-ctrl.warn .cv-ico { background: color-mix(in srgb, var(--warn) 22%, transparent); color: var(--warn); }
.cv-lbl { font-size: 11px; font-weight: 600; color: var(--d-text-2); max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
@media (prefers-reduced-motion: reduce) { .cv-sweep { display: none; } .cv-status.ok i, .cv.charging .cv-port { animation: none; } }
/* tall: room for a row of stats */
@container (min-height: 420px) { .cv-stats { display: grid; } }
/* short: the drawing gives way to a slim bar */
@container (max-height: 260px) {
.cv { row-gap: 8px; grid-template-rows: auto auto auto minmax(0, 1fr) auto; }
.cv-svg, .cv-img { display: none; }
.cv-bar { display: block; }
.cv-ctrls { align-self: end; }
.cv-ico { width: 34px; height: 34px; font-size: 17px; }
} }
@container (max-height: 160px) { .cbs { display: none; } } @container (max-height: 160px) { .cv-ctrls { display: none; } .cv-big { font-size: 40px; } }
/* wide: gauge on the left, the rest on the right */ /* wide: the car on the left, numbers and controls on the right */
@container (min-width: 480px) and (min-height: 251px) { @container (min-width: 460px) and (min-height: 261px) {
.car3 { grid-template-areas: "top top" "gauge info"; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); grid-template-rows: auto 1fr; column-gap: 20px; } .cv { grid-template-areas: "art head" "art hero" "art stats" "art ctrls"; grid-template-columns: minmax(0, 1.15fr) minmax(0, 1fr); grid-template-rows: auto auto 1fr auto; column-gap: 22px; }
.car3-info { justify-content: center; } .cv-hero { align-self: start; }
.cv-svg { max-height: 190px; }
.cv-stats { display: grid; align-self: center; }
} }
@container (max-width: 300px) { .cb-label, .cb-sub { display: none; } .cb { padding: 6px 2px; } } @container (max-width: 230px) {
.cv-lbl { display: none; }
.cv-hero { flex-direction: column; align-items: flex-start; gap: 6px; }
.cv-ctrls { gap: 4px; }
.cv-ico { width: 34px; height: 34px; font-size: 16px; }
.cv-status { max-width: 58%; }
}
@container (max-width: 170px) { .cv-ico { width: 28px; height: 28px; font-size: 14px; } .cv-status { font-size: 0; gap: 0; } .cv-status i { width: 8px; height: 8px; } }
/* ============================================================ phone dashboards */ /* ============================================================ phone dashboards */
body.phone-ui:not(.peek) #topbar { display: none; } body.phone-ui:not(.peek) #topbar { display: none; }
+73 -47
View File
@@ -1134,17 +1134,32 @@
// ---------------------------------------------------------------- car // ---------------------------------------------------------------- car
const truthy = (s) => !!s && (["on", "true", "charging", "plugged_in", "connected"].includes(String(s.state).toLowerCase())); const truthy = (s) => !!s && (["on", "true", "charging", "plugged_in", "connected"].includes(String(s.state).toLowerCase()));
// Built around a round gauge, like an instrument cluster: charge as an arc with the charge limit marked, // The car is the battery: a side view that fills up from the back with the charge level, the charge
// the range large in the middle, and the car in front. Sizes adapt through container queries. // limit marked in it, and the numbers above it. Sizes adapt through container queries.
const CAR_ART = `<svg class="car-art2" viewBox="0 0 200 78" aria-hidden="true"> const CAR_BODY = "M16 68H40A18 18 0 0 1 76 68H166A18 18 0 0 1 202 68H224C230 68 233 65 233 60L232 54C231 49 226 46 218 45L172 40C160 32 148 24 134 21C118 18 98 18 84 20C66 23 46 32 30 40L16 43C10 44 7 48 7 54V61C7 65 10 68 16 68Z";
<defs><linearGradient id="carBody" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="var(--car-top)"/><stop offset="1" stop-color="var(--car-bottom)"/></linearGradient> const CAR_GLASS = "M164 40C154 32 144 26 132 24C120 22 104 22 92 23L94 40ZM88 23C74 25 60 31 48 40H90Z";
<linearGradient id="carGlass" x1="0" y1="0" x2="1" y2="1"><stop offset="0" stop-color="#cfe3ff" stop-opacity=".55"/><stop offset="1" stop-color="#7a8cab" stop-opacity=".35"/></linearGradient></defs> const CAR_X0 = 7, CAR_W = 226; // where the body starts and how long it is, for the fill
<ellipse cx="100" cy="70" rx="86" ry="5" class="car-shadow"/>
<path class="car-body2" d="M14 56c0-6 4-9 12-11l30-6c10-10 22-15 40-16h24c14 0 26 5 36 13l14 5c12 2 18 6 19 12v5c0 3-2 4-5 4H16c-1.5 0-2-2-2-6z"/> function carArt(id, pct, limPct, o) {
<path class="car-glass2" d="M64 39c8-8 18-12 32-12h12v12zM112 27h8c11 0 20 4 28 11l-36 1z"/> const fx = CAR_X0 + CAR_W * Math.max(0, Math.min(100, pct ?? 0)) / 100;
<path class="car-line" d="M30 50h150"/><circle cx="182" cy="52" r="2.2" class="car-light"/> const lx = limPct != null ? CAR_X0 + CAR_W * Math.max(0, Math.min(100, limPct)) / 100 : null;
<circle cx="52" cy="62" r="12" class="car-tire"/><circle cx="52" cy="62" r="6" class="car-rim"/> const wheel = (cx) => `<circle cx="${cx}" cy="70" r="15" class="cv-tyre"/><circle cx="${cx}" cy="70" r="9.5" class="cv-rim"/><circle cx="${cx}" cy="70" r="3" class="cv-hub"/>`;
<circle cx="154" cy="62" r="12" class="car-tire"/><circle cx="154" cy="62" r="6" class="car-rim"/></svg>`; return `<svg class="cv-svg" viewBox="0 0 240 92" aria-hidden="true">
<defs><clipPath id="cvb-${id}"><path d="${CAR_BODY}"/></clipPath><clipPath id="cvf-${id}"><rect x="0" y="0" width="${fx.toFixed(1)}" height="92"/></clipPath>
<linearGradient id="cvg-${id}" x1="0" x2="1"><stop offset="0" stop-color="currentColor" stop-opacity=".55"/><stop offset="1" stop-color="currentColor"/></linearGradient></defs>
<ellipse cx="121" cy="86" rx="110" ry="3.5" class="cv-shadow"/>
<path d="${CAR_BODY}" class="cv-shell"/>
<g clip-path="url(#cvb-${id})">
<g clip-path="url(#cvf-${id})"><rect x="0" y="0" width="240" height="92" fill="url(#cvg-${id})"/>${o.charging ? `<rect class="cv-sweep" x="-70" y="0" width="70" height="92"/>` : ""}</g>
${lx != null ? `<line x1="${lx.toFixed(1)}" y1="0" x2="${lx.toFixed(1)}" y2="92" class="cv-limit"/>` : ""}
</g>
<path d="${CAR_GLASS}" class="cv-glass"/>
<path d="${CAR_BODY}" class="cv-edge"/>
<path d="M34 52L212 49" class="cv-crease"/>
<path d="M220 49.5L230 53" class="cv-lamp"/><path d="M8.5 49L17 47" class="cv-tail"/>
${o.plugged ? `<circle cx="24" cy="51" r="3.4" class="cv-port"/>` : ""}
${wheel(58)}${wheel(184)}</svg>`;
}
W.car = { W.car = {
label: "Car", icon: "car", size: [3, 4], label: "Car", icon: "car", size: [3, 4],
@@ -1166,61 +1181,72 @@
{ k: "odometer", t: "entity", l: "Odometer", domains: ["sensor"] }, { k: "odometer", t: "entity", l: "Odometer", domains: ["sensor"] },
], ],
entities: (c) => ["level", "range", "charging", "power", "plug", "limit", "toFull", "chargeSwitch", "lock", "climate", "location", "odometer"].map((k) => c[k]).filter(Boolean), entities: (c) => ["level", "range", "charging", "power", "plug", "limit", "toFull", "chargeSwitch", "lock", "climate", "location", "odometer"].map((k) => c[k]).filter(Boolean),
render(c, { ha }) { render(c, { ha, w }) {
const S = (k) => (c[k] ? ha.states[c[k]] : null); const S = (k) => (c[k] ? ha.states[c[k]] : null);
const level = S("level"), range = S("range"), lock = S("lock"), clim = S("climate"), loc = S("location"); const level = S("level"), range = S("range"), lock = S("lock"), clim = S("climate"), loc = S("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>`; 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 pct = level && isFinite(Number(level.state)) ? Math.round(Number(level.state)) : null;
const power = S("power"), plug = S("plug"), limit = S("limit"), odo = S("odometer"), toFull = S("toFull"), chg = S("chargeSwitch"); const power = S("power"), plug = S("plug"), limit = S("limit"), odo = S("odometer"), toFull = S("toFull"), chg = S("chargeSwitch");
const charging = truthy(S("charging")) || (!c.charging && power && Number(power.state) > 0) || (chg && domainOf(c.chargeSwitch) === "switch" && chg.state === "on"); const fuel = c.kind === "fuel";
const plugged = charging || truthy(plug); const charging = !fuel && (truthy(S("charging")) || (!c.charging && power && Number(power.state) > 0) || (chg && domainOf(c.chargeSwitch) === "switch" && chg.state === "on"));
const fuel = c.kind === "fuel", low = pct != null && pct <= 20; const plugged = !fuel && (charging || truthy(plug));
const limPct = limit && isFinite(Number(limit.state)) ? Number(limit.state) : null; const low = pct != null && pct <= 20;
const limPct = !fuel && limit && isFinite(Number(limit.state)) ? Number(limit.state) : null;
const unit = range ? range.attributes.unit_of_measurement || "km" : "";
// status line and the "full at" time // status, top right
let status = ""; let status = "", tone = "";
if (charging) status = `${t("Charging")}${power && Number(power.state) > 0 ? " · " + fmtState(power) : ""}`; if (charging) { status = t("Charging"); tone = "ok"; }
else if (plugged) status = t("Plugged in"); else if (plugged) { status = t("Plugged in"); tone = "accent"; }
else if (low) { status = fuel ? t("Low fuel") : t("Low battery"); tone = "crit"; }
else if (loc) status = loc.state === "home" ? t("Parked at home") : loc.state === "not_home" ? t("Away") : st(loc.state); else if (loc) status = loc.state === "home" ? t("Parked at home") : loc.state === "not_home" ? t("Away") : st(loc.state);
let fullAt = "";
// the line under the range: when it will be full, or how far it charges
let sub = "";
if (charging && toFull) { if (charging && toFull) {
const v = toFull.state, mins = /^\d{4}-\d{2}-\d{2}T/.test(v) ? (new Date(v) - Date.now()) / 60000 : Number(v) * (String(toFull.attributes.unit_of_measurement || "min").toLowerCase().startsWith("h") ? 60 : 1); const v = toFull.state, mins = /^\d{4}-\d{2}-\d{2}T/.test(v) ? (new Date(v) - Date.now()) / 60000 : Number(v) * (String(toFull.attributes.unit_of_measurement || "min").toLowerCase().startsWith("h") ? 60 : 1);
if (isFinite(mins) && mins > 0) fullAt = t("Full at {0}", new Date(Date.now() + mins * 60000).toLocaleTimeString(LOCALE, { hour: "2-digit", minute: "2-digit" })); if (isFinite(mins) && mins > 0) sub = t("Full at {0}", new Date(Date.now() + mins * 60000).toLocaleTimeString(LOCALE, { hour: "2-digit", minute: "2-digit" }));
} }
if (!sub && charging && power && Number(power.state) > 0) sub = fmtState(power);
if (!sub && limPct != null) sub = t("Charges to {0}%", Math.round(limPct));
if (!sub) sub = range ? t("Range") : "";
// the gauge: 240° arc, level filled, charge limit as a tick const hero = pct != null
const A0 = Math.PI * (150 / 180), SPAN = Math.PI * (240 / 180), R = 80; ? `<div class="cv-big num">${pct}<span>%</span></div>
const ang = (p) => A0 + SPAN * Math.max(0, Math.min(1, p / 100)); <div class="cv-side">${range ? `<div class="cv-range num">${esc(fmtNum(range.state))}<small>${esc(unit)}</small></div>` : ""}${sub ? `<div class="cv-sub">${charging ? Icons.svg("bolt", "inline") : ""}${esc(sub)}</div>` : ""}</div>`
const pt = (p, r = R) => [100 + r * Math.cos(ang(p)), 100 + r * Math.sin(ang(p))]; : range ? `<div class="cv-big num">${esc(fmtNum(range.state))}<span>${esc(unit)}</span></div>${sub && sub !== t("Range") ? `<div class="cv-side"><div class="cv-sub">${esc(sub)}</div></div>` : ""}` : "";
const arc = (p0, p1) => { const [x0, y0] = pt(p0), [x1, y1] = pt(p1); return `M${x0.toFixed(1)} ${y0.toFixed(1)} A${R} ${R} 0 ${ang(p1) - ang(p0) > Math.PI ? 1 : 0} 1 ${x1.toFixed(1)} ${y1.toFixed(1)}`; };
const tick = limPct != null && !fuel ? (() => { const [x0, y0] = pt(limPct, R - 11), [x1, y1] = pt(limPct, R + 11); return `<line x1="${x0.toFixed(1)}" y1="${y0.toFixed(1)}" x2="${x1.toFixed(1)}" y2="${y1.toFixed(1)}" class="cg-limit"/>`; })() : "";
const gauge = `<div class="cg ${charging ? "charging" : ""} ${low ? "low" : ""}" role="meter" aria-valuemin="0" aria-valuemax="100" aria-valuenow="${pct ?? 0}" aria-label="${esc(fuel ? t("Fuel level") : t("Battery level"))}">
<svg viewBox="0 0 200 200" aria-hidden="true"><path d="${arc(0, 100)}" class="cg-track"/>${pct ? `<path d="${arc(0, pct)}" class="cg-fill"/>` : ""}${tick}</svg>
<div class="cg-center">${range ? `<span class="cg-range num">${esc(fmtNum(range.state))}<small>${esc(range.attributes.unit_of_measurement || "km")}</small></span>` : ""}
${pct != null ? `<span class="cg-pct num">${charging ? Icons.svg("bolt", "inline") : ""}${pct}%${low ? ` · ${t("Low")}` : ""}</span>` : ""}</div>
<div class="cg-car">${c.image ? `<img src="${esc(c.image)}" alt="">` : CAR_ART}</div></div>`;
const btn = (act, eid, on, ico, label, sub) => `<button class="cb wbtn" data-act="${act}" data-e="${esc(eid)}" aria-label="${esc(sub ? `${label}, ${sub}` : label)}" title="${esc(label)}" ${act === "toggle" ? `aria-pressed="${on}"` : ""}> const bar = pct != null ? `<div class="cv-bar" role="presentation"><i style="width:${Math.max(0, Math.min(100, pct))}%"></i>${limPct != null ? `<b style="left:${Math.max(0, Math.min(100, limPct))}%"></b>` : ""}</div>` : "";
<span class="cb-ico">${Icons.svg(ico)}</span><span class="cb-label">${esc(label)}</span>${sub ? `<span class="cb-sub num">${esc(sub)}</span>` : ""}</button>`; const art = c.image ? `<div class="cv-img"><img src="${esc(c.image)}" alt=""></div>` : carArt(esc(w?.id || "x"), pct, limPct, { charging, plugged });
const buttons = [];
if (lock) { const locked = lock.state === "locked"; buttons.push(btn("toggle", c.lock, !locked, locked ? "lock" : "unlock", locked ? t("Locked") : t("Unlocked"))); } // controls: each shows what it controls and its state
const ctrl = (act, eid, ico, label, { on = false, warn = false, svc = "" } = {}) =>
`<button class="cv-ctrl wbtn ${on ? "on" : ""} ${warn ? "warn" : ""}" data-act="${act}" data-e="${esc(eid)}" ${svc ? `data-v="${svc}"` : ""} ${act === "toggle" ? `aria-pressed="${on}"` : ""} aria-label="${esc(label)}" title="${esc(label)}">
<span class="cv-ico">${Icons.svg(ico)}</span><span class="cv-lbl">${esc(label)}</span></button>`;
const ctrls = [];
if (lock) { const locked = lock.state === "locked"; ctrls.push(ctrl("toggle", c.lock, locked ? "lock" : "unlock", locked ? t("Locked") : t("Unlocked"), { warn: !locked })); }
if (clim) { if (clim) {
const on = isOn(clim) || clim.state === "on", inside = clim.attributes.current_temperature; const on = isOn(clim) || clim.state === "on", inside = clim.attributes.current_temperature;
buttons.push(btn("toggle", c.climate, on, on ? "fan" : "snowflake", on ? t("Climate on") : t("Climate"), inside != null ? `${inside}° ${t("inside")}` : "")); ctrls.push(ctrl("toggle", c.climate, on ? "fan" : "snowflake", inside != null ? `${fmtNum(inside)}°` : on ? t("Climate on") : t("Climate"), { on }));
} }
if (chg && !fuel) { if (chg && !fuel) {
const isBtn = domainOf(c.chargeSwitch) === "button"; ctrls.push(domainOf(c.chargeSwitch) === "button"
buttons.push(isBtn ? `<button class="cb wbtn" data-act="svc" data-e="${esc(c.chargeSwitch)}" data-v="button.press"><span class="cb-ico">${Icons.svg("bolt")}</span><span class="cb-label">${esc(friendly(chg))}</span></button>` ? ctrl("svc", c.chargeSwitch, "bolt", t("Charge"), { svc: "button.press" })
: btn("toggle", c.chargeSwitch, charging, "bolt", charging ? t("Stop charging") : t("Start charging"))); : ctrl("toggle", c.chargeSwitch, "bolt", charging ? t("Stop") : t("Charge"), { on: charging }));
} }
if (loc) buttons.push(btn("more", c.location, false, "pin", fmtState(loc))); if (loc) ctrls.push(ctrl("more", c.location, "pin", fmtState(loc)));
const chips = [fullAt && `<span class="cchip">${Icons.svg("clock")}${esc(fullAt)}</span>`, odo && `<span class="cchip">${Icons.svg("road")}${esc(fmtState(odo))}</span>`].filter(Boolean).join(""); const stat = (k, v) => `<div class="cv-stat"><span>${esc(k)}</span><b class="num">${esc(v)}</b></div>`;
const stats = [odo && stat(t("Odometer"), fmtState(odo)), limPct != null && stat(t("Charge limit"), `${Math.round(limPct)}%`),
charging && power && stat(t("Charging power"), fmtState(power)), clim?.attributes.current_temperature != null && stat(t("Inside"), `${fmtNum(clim.attributes.current_temperature)}°`)].filter(Boolean).join("");
return `<div class="car3 ${charging ? "is-charging" : ""}"> const cls = [charging && "charging", plugged && "plugged", low && "low", fuel && "fuel", c.image && "has-img"].filter(Boolean).join(" ");
<div class="car3-top"><span class="name">${esc(c.name || t("Car"))}</span>${status ? `<span class="car3-status ${charging ? "charging" : ""}">${esc(status)}</span>` : ""}</div> return `<div class="cv ${cls}">
${gauge} <div class="cv-head"><span class="cv-name">${esc(c.name || t("Car"))}</span>${status ? `<span class="cv-status ${tone}"><i aria-hidden="true"></i>${esc(status)}</span>` : ""}</div>
<div class="car3-info">${chips ? `<div class="cchips">${chips}</div>` : ""}${buttons.length ? `<div class="cbs">${buttons.join("")}</div>` : ""}</div> <div class="cv-hero" role="group" aria-label="${esc([pct != null && `${fuel ? t("Fuel level") : t("Battery level")} ${pct}%`, range && `${fmtNum(range.state)} ${unit}`].filter(Boolean).join(", "))}">${hero}</div>
<div class="cv-art">${art}${bar}</div>
${stats ? `<div class="cv-stats">${stats}</div>` : ""}
${ctrls.length ? `<div class="cv-ctrls">${ctrls.join("")}</div>` : ""}
</div>`; </div>`;
}, },
}; };