Dashboard Studio 2.2.0: guest invites, personal looks, more themes, Haven widgets and tiles
- Vacuum card: the robot drawn beside its dock by state, last clean, maintenance - Formula One card: the official F1 look with a user-supplied logo - Nine more themes; per-person theme, text size and language on Admin, People - Guest invites: an expiring link and QR code for one dashboard, served on a LAN-only guest port, with the Home Assistant connection filtered both ways - Haven 2.2: home screen widgets, three quick settings tiles, several homes, and self-updates from the Haven that Dashboard Studio serves Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -897,6 +897,24 @@
|
||||
// ---------------------------------------------------------------- vacuum
|
||||
// One layout that grows with the card: compact on small cards; a robot with a battery ring,
|
||||
// stats and labelled buttons on bigger ones (see the container queries in style.css).
|
||||
/** A robot vacuum seen from above beside its dock, placed and drawn by its state. */
|
||||
function vacScene(state, charging) {
|
||||
const docked = state === "docked", cleaning = state === "cleaning", returning = state === "returning", error = state === "error";
|
||||
const x = docked ? 196 : returning ? 150 : cleaning ? 92 : 124;
|
||||
const trail = cleaning || returning ? `<path class="vs-trail" d="M224 62 C 196 104, 150 18, ${x + 30} 60"/>` : "";
|
||||
return `<svg class="vac-scene" viewBox="0 0 300 120" preserveAspectRatio="xMidYMid meet" role="img" aria-label="${esc(st(state))}">
|
||||
<rect class="vs-dock" x="232" y="18" width="52" height="84" rx="10"/>
|
||||
<rect class="vs-plate" x="226" y="44" width="10" height="32" rx="3"/>
|
||||
<circle class="vs-led ${charging ? "on" : ""}" cx="266" cy="34" r="4"/>
|
||||
${trail}
|
||||
<g class="vs-robot ${cleaning ? "busy" : ""} ${error ? "err" : ""}" transform="translate(${x} 60)">
|
||||
<circle class="vs-body" r="36"/>
|
||||
<path class="vs-bumper" d="M -14 -32.4 A 35 35 0 0 0 -14 32.4"/>
|
||||
<circle class="vs-lidar" cx="12" r="11"/>
|
||||
<g class="vs-brush" transform="translate(-21 -21)"><line x1="0" y1="-6" x2="0" y2="6"/><line x1="-5.2" y1="3" x2="5.2" y2="-3"/><line x1="-5.2" y1="-3" x2="5.2" y2="3"/></g>
|
||||
</g></svg>`;
|
||||
}
|
||||
|
||||
W.vacuum = {
|
||||
label: "Vacuum", icon: "vacuum", size: [4, 5],
|
||||
fields: [
|
||||
@@ -920,31 +938,32 @@
|
||||
const hasBatt = isFinite(batt);
|
||||
const charging = s.state === "docked" && hasBatt && batt < 100;
|
||||
const headline = cleaning ? t("Cleaning…") : returning ? t("On its way to the dock") : charging ? t("Charging") : st(s.state);
|
||||
const detail = a.status && !headline.toLowerCase().includes(String(a.status).toLowerCase()) ? a.status : "";
|
||||
// the robot's own status only when it adds something (an error, a paused job), not "Returning home" twice
|
||||
const detail = a.status && !["cleaning", "returning", "docked"].includes(s.state) && !headline.toLowerCase().includes(String(a.status).toLowerCase()) ? a.status : "";
|
||||
const mapUrl = c.map ? (domainOf(c.map) === "image" ? ha.states[c.map]?.attributes.entity_picture : `api/camera/${encodeURIComponent(c.map)}?t=${Date.now()}`) : "";
|
||||
const speeds = c.fan !== false && Array.isArray(a.fan_speed_list) ? a.fan_speed_list : [];
|
||||
|
||||
const stat = (label, value, ico) => `<div class="vac-stat">${Icons.svg(ico)}<span class="vac-stat-v num">${esc(value)}</span><span class="vac-stat-l">${esc(label)}</span></div>`;
|
||||
const stats = [
|
||||
hasBatt ? stat(t("Battery"), `${Math.round(batt)}%`, "battery") : "",
|
||||
c.area && ha.states[c.area] ? stat(t("Cleaned area"), fmtState(ha.states[c.area]), "tile") : "",
|
||||
c.duration && ha.states[c.duration] ? stat(t("Cleaning time"), fmtState(ha.states[c.duration]), "clock") : "",
|
||||
...(c.entities || []).map((x) => ha.states[x] && stat(friendly(ha.states[x]).replace(new RegExp(`^${name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\s*`, "i"), ""), fmtState(ha.states[x]), "pulse")),
|
||||
].filter(Boolean).join("");
|
||||
// The robot's own sensors: life left in percent goes under Maintenance, the rest with the last clean.
|
||||
const S = (x) => ha.states[x];
|
||||
const prefix = new RegExp(`^${name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\s*`, "i");
|
||||
const label = (x) => cap(friendly(S(x)).replace(prefix, "").replace(/\s+(left|life|remaining)$/i, ""));
|
||||
const extra = (c.entities || []).filter((x) => S(x));
|
||||
const maint = extra.filter((x) => S(x).attributes.unit_of_measurement === "%" && isFinite(Number(S(x).state)));
|
||||
const facts = [
|
||||
c.area && S(c.area) ? [t("Cleaned"), fmtState(S(c.area))] : null,
|
||||
c.duration && S(c.duration) ? [t("Time"), fmtState(S(c.duration))] : null,
|
||||
...extra.filter((x) => !maint.includes(x)).map((x) => [label(x), fmtState(S(x))]),
|
||||
].filter(Boolean);
|
||||
|
||||
const btn = (svc, label, ico, primary, disabled) => `<button class="vac-btn wbtn ${primary ? "primary" : ""}" data-act="svc" data-e="${e}" data-v="vacuum.${svc}" ${disabled ? "disabled" : ""}>${Icons.svg(ico)}<span>${esc(label)}</span></button>`;
|
||||
const btn = (svc, label, ico, primary, disabled) => `<button class="vac-btn wbtn ${primary ? "primary" : ""}" data-act="svc" data-e="${e}" data-v="vacuum.${svc}" ${disabled ? "disabled" : ""} aria-label="${esc(label)}">${Icons.svg(ico)}<span>${esc(label)}</span></button>`;
|
||||
|
||||
return `<div class="vac state-${esc(s.state)}">
|
||||
<div class="vac-head"><span class="badge">${Icons.svg("vacuum")}</span>
|
||||
<span class="tile-text"><span class="name">${esc(name)}</span><span class="state">${esc(headline)}${detail ? ` · ${esc(detail)}` : ""}</span></span>
|
||||
${hasBatt ? `<span class="vac-batt">${Icons.svg(charging ? "bolt" : "battery")}<span class="num">${Math.round(batt)}%</span></span>` : ""}</div>
|
||||
<div class="vac-hero">
|
||||
${mapUrl ? `<img class="vac-map" src="${esc(mapUrl)}" ${domainOf(c.map) === "camera" ? `data-cam="${esc(c.map)}" data-refresh="${Number(c.refresh) || 5}"` : ""} alt="${t("Cleaning map")}">`
|
||||
: `<div class="vac-hero-text"><span class="vac-headline">${esc(headline)}</span>
|
||||
${hasBatt ? `<span class="vac-meter ${batt < 20 ? "low" : ""}" role="meter" aria-valuemin="0" aria-valuemax="100" aria-valuenow="${Math.round(batt)}" aria-label="${t("Battery")}"><i style="width:${Math.round(batt)}%"></i></span>
|
||||
<span class="wt num">${charging ? t("Battery {0}%, charging", Math.round(batt)) : t("Battery {0}%", Math.round(batt))}</span>` : ""}</div>`}
|
||||
</div>
|
||||
${stats ? `<div class="vac-stats">${stats}</div>` : ""}
|
||||
return `<div class="vac state-${esc(s.state)} ${charging ? "charging" : ""}">
|
||||
<div class="vac-head"><div class="vac-title"><span class="name">${esc(name)}</span><span class="vac-status">${esc(headline)}${detail ? ` · ${esc(detail)}` : ""}</span></div>
|
||||
${hasBatt ? `<div class="vac-batt"><span class="vac-batt-v num">${Math.round(batt)}<small>%</small></span>
|
||||
<span class="vac-meter ${batt < 20 ? "low" : ""}" role="meter" aria-valuemin="0" aria-valuemax="100" aria-valuenow="${Math.round(batt)}" aria-label="${t("Battery")}"><i style="width:${Math.round(batt)}%"></i></span></div>` : ""}</div>
|
||||
<div class="vac-art">${mapUrl ? `<img class="vac-map" src="${esc(mapUrl)}" ${domainOf(c.map) === "camera" ? `data-cam="${esc(c.map)}" data-refresh="${Number(c.refresh) || 5}"` : ""} alt="${t("Cleaning map")}">` : vacScene(s.state, charging)}</div>
|
||||
${facts.length ? `<div class="vac-facts" aria-label="${t("Last clean")}">${facts.map(([l, v]) => `<div class="vac-fact"><span class="vac-fv num">${esc(v)}</span><span class="vac-fl">${esc(l)}</span></div>`).join("")}</div>` : ""}
|
||||
${maint.length ? `<div class="vac-maint">${maint.map((x) => { const v = Math.max(0, Math.min(100, Number(S(x).state))); return `<div class="vac-mrow ${v < 15 ? "low" : ""}"><span class="vac-ml">${esc(label(x))}</span><span class="vac-mbar"><i style="width:${v}%"></i></span><span class="vac-mv num">${Math.round(v)}%</span></div>`; }).join("")}</div>` : ""}
|
||||
${speeds.length ? `<div class="seg vac-fan" role="group" aria-label="${t("Suction power")}">${speeds.map((f) => `<button class="wbtn" aria-pressed="${f === a.fan_speed}" data-act="vac-fan" data-e="${e}" data-v="${esc(f)}">${esc(cap(f))}</button>`).join("")}</div>` : ""}
|
||||
<div class="vac-actions">
|
||||
${btn(cleaning ? "pause" : "start", cleaning ? t("Pause") : returning ? t("Resume") : t("Start"), cleaning ? "pause" : "play", true)}
|
||||
@@ -1549,6 +1568,12 @@
|
||||
const f1List = (s, ...keys) => { const a = s?.attributes || {}; for (const k of keys) if (Array.isArray(a[k])) return a[k]; return []; };
|
||||
const f1Driver = (r) => { const d = r.Driver || r.driver || {}; return typeof d === "string" ? d : [d.givenName, d.familyName].filter(Boolean).join(" ") || d.code || ""; };
|
||||
const f1Team = (r) => { const c = r.Constructor || (r.Constructors || [])[0] || r.constructor_name || r.team; return typeof c === "string" ? c : c?.name || ""; };
|
||||
const f1Code = (r) => { const d = r.Driver || r.driver || {}; return (typeof d === "object" && d.code) || f1Driver(r).split(" ").pop().slice(0, 3).toUpperCase(); };
|
||||
/** The teams' own colours, as on the timing tower. */
|
||||
const F1_TEAMS = [["red bull", "#3671c6"], ["mclaren", "#ff8000"], ["ferrari", "#e80020"], ["mercedes", "#27f4d2"], ["aston", "#229971"],
|
||||
["alpine", "#0093cc"], ["williams", "#64c4ff"], ["racing bulls", "#6692ff"], ["rb f1", "#6692ff"], ["visa", "#6692ff"], ["sauber", "#52e252"],
|
||||
["kick", "#52e252"], ["audi", "#bb0a30"], ["haas", "#b6babd"], ["cadillac", "#c0a062"]];
|
||||
const f1Colour = (team) => (F1_TEAMS.find(([k]) => String(team).toLowerCase().includes(k)) || [, "#8e8e93"])[1];
|
||||
|
||||
W.f1 = {
|
||||
label: "Formula One", icon: "flag", size: [4, 5],
|
||||
@@ -1559,6 +1584,8 @@
|
||||
{ k: "results", t: "entity", l: "Last race results", domains: ["sensor"] },
|
||||
{ k: "show", t: "seg", l: "Show first", o: ["drivers", "teams", "results"], d: "drivers" },
|
||||
{ k: "top", t: "number", l: "Positions to show", d: 5 },
|
||||
{ k: "look", t: "seg", l: "Look", o: ["official", "theme"], d: "official" },
|
||||
{ k: "logo", t: "text", l: "Logo image (put the official logo in Home Assistant's www folder)", ph: "/local/f1-logo.svg" },
|
||||
],
|
||||
autofill: {
|
||||
from: "next",
|
||||
@@ -1579,7 +1606,8 @@
|
||||
const m = Math.round((raceAt - Date.now()) / 60000), dd = Math.floor(m / 1440), hh = Math.floor((m % 1440) / 60);
|
||||
countdown = dd ? t("in {0} d {1} h", dd, hh) : t("in {0} h {1} min", hh, m % 60);
|
||||
}
|
||||
const when = (d) => `${d.toLocaleDateString(LOCALE, { weekday: "short", day: "numeric", month: "short" })} ${d.toLocaleTimeString(LOCALE, { hour: "2-digit", minute: "2-digit" })}`;
|
||||
const day = (d) => d.toLocaleDateString(LOCALE, { weekday: "short", day: "numeric" });
|
||||
const time = (d) => d.toLocaleTimeString(LOCALE, { hour: "2-digit", minute: "2-digit" });
|
||||
const sessions = F1_SESSIONS.map(([k, l]) => [l, iso(k)]).filter(([, v]) => v && !isNaN(new Date(v)));
|
||||
const nextSession = sessions.find(([, v]) => new Date(v) > Date.now());
|
||||
const place = [a.circuit_name || a.Circuit?.circuitName, a.circuit_locality || a.Circuit?.Location?.locality, a.circuit_country || a.Circuit?.Location?.country].filter(Boolean).join(" · ");
|
||||
@@ -1592,17 +1620,31 @@
|
||||
const list = cur[0] === "drivers" ? f1List(s, "driver_standings", "standings", "DriverStandings")
|
||||
: cur[0] === "teams" ? f1List(s, "constructor_standings", "standings", "ConstructorStandings") : f1List(s, "results", "Results", "race_results");
|
||||
rows = list.slice(0, top).map((r) => {
|
||||
const name = cur[0] === "teams" ? f1Team(r) : f1Driver(r);
|
||||
const sub = cur[0] === "teams" ? (r.wins != null ? t("{0} wins", r.wins) : "") : f1Team(r);
|
||||
const val = cur[0] === "results" ? (r.Time?.time || r.time || r.status || "") : `${r.points ?? ""} ${t("pts")}`;
|
||||
return `<div class="f1-row"><span class="f1-pos num">${esc(r.position ?? r.positionText ?? "")}</span><span class="f1-name"><b>${esc(name)}</b><span>${esc(sub)}</span></span><span class="f1-val num">${esc(val)}</span></div>`;
|
||||
const team = f1Team(r), isTeam = cur[0] === "teams";
|
||||
const name = isTeam ? team : f1Driver(r);
|
||||
const sub = isTeam ? (r.wins != null ? t("{0} wins", r.wins) : "") : team;
|
||||
const val = cur[0] === "results" ? (r.Time?.time || r.time || r.status || "") : `${r.points ?? ""}`;
|
||||
return `<div class="f1-row" style="--team:${f1Colour(team)}"><span class="f1-pos num">${esc(r.position ?? r.positionText ?? "")}</span><i class="f1-stripe" aria-hidden="true"></i>
|
||||
<span class="f1-name">${isTeam ? `<b>${esc(name)}</b>` : `<b><span class="f1-code">${esc(f1Code(r))}</span> ${esc(name)}</b>`}<span>${esc(sub)}</span></span>
|
||||
<span class="f1-val num">${esc(val)}${cur[0] === "results" ? "" : `<small>${t("pts")}</small>`}</span></div>`;
|
||||
}).join("");
|
||||
}
|
||||
// The countdown as the timing screens show it: days, hours, minutes.
|
||||
let clock = "";
|
||||
if (raceAt && raceAt > Date.now()) {
|
||||
const m = Math.round((raceAt - Date.now()) / 60000);
|
||||
clock = [[Math.floor(m / 1440), t("days")], [Math.floor((m % 1440) / 60), t("hrs")], [m % 60, t("mins")]]
|
||||
.map(([v, l]) => `<span class="f1-cd"><b class="num">${String(v).padStart(2, "0")}</b><small>${esc(l)}</small></span>`).join("");
|
||||
}
|
||||
const raceName = a.race_name || a.raceName || (next && !/^\d{4}-/.test(next.state) ? next.state : "");
|
||||
return `<div class="f1">
|
||||
${next ? `<div class="f1-next"><div class="f1-top">${Icons.svg("flag")}<span>${esc(a.season ? `${a.season} · ${t("Round {0}", a.round)}` : t("Next race"))}</span>${countdown ? `<b>${esc(countdown)}</b>` : ""}</div>
|
||||
const official = c.look !== "theme";
|
||||
return `<div class="f1 ${official ? "f1-official" : ""}">
|
||||
<div class="f1-brand">${c.logo ? `<img class="f1-logo" src="${esc(c.logo)}" alt="Formula 1">` : `<span class="f1-mark" aria-hidden="true"></span>`}
|
||||
<span class="f1-round">${esc(a.season ? t("Round {0} · {1}", a.round, a.season) : t("Next race"))}</span></div>
|
||||
${next ? `<div class="f1-next">
|
||||
<div class="f1-race">${esc(raceName || t("Next race"))}</div>${place ? `<div class="f1-place">${esc(place)}</div>` : ""}
|
||||
${sessions.length ? `<div class="f1-sessions">${sessions.map(([l, v]) => `<div class="${nextSession && nextSession[1] === v ? "next" : ""} ${new Date(v) < Date.now() ? "past" : ""}"><span>${esc(t(l))}</span><span class="num">${esc(when(new Date(v)))}</span></div>`).join("")}</div>` : ""}</div>` : ""}
|
||||
${clock ? `<div class="f1-clock" aria-label="${esc(countdown)}">${clock}</div>` : ""}
|
||||
${sessions.length ? `<div class="f1-sessions">${sessions.map(([l, v]) => { const d = new Date(v); return `<div class="f1-s ${nextSession && nextSession[1] === v ? "next" : ""} ${d < Date.now() ? "past" : ""}"><span class="f1-sd num">${esc(day(d))}</span><span class="f1-sn">${esc(t(l))}</span><span class="f1-st num">${esc(time(d))}</span></div>`; }).join("")}</div>` : ""}</div>` : ""}
|
||||
${tabs.length ? `<div class="f1-tabs" role="tablist">${tabs.map(([k, l]) => `<button class="wbtn" role="tab" data-act="card-tab" data-v="${k}" aria-selected="${cur[0] === k}">${esc(t(l))}</button>`).join("")}</div>
|
||||
<div class="f1-list">${rows || `<div class="empty">${t("No standings yet.")}</div>`}</div>` : ""}</div>`;
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user