Compare commits

..
Author SHA1 Message Date
Paul Bottein b13f5107da Rename context source to next name part 2026-09-17 22:09:42 +02:00
Paul Bottein 801743050c Omit owners in another area from entity context 2026-09-17 22:09:42 +02:00
Paul Bottein 193cbcbfac Allow removing orphaned areas in vacuum segment mapping (#54179)
* Allow removing orphaned areas in vacuum segment mapping

* Type event handler and use RTL-safe padding in vacuum area mapper

* Include the area name in the remove mapping label
2026-09-17 20:09:26 +00:00
Wendelin d43f7e58b6 Onboarding: Improve translation loading (#54176)
* onboarding: Improve translation loading

* Fix generic picker auth check

* fix _translationsReady default
2026-09-17 20:42:39 +02:00
Bruno Pantaleão Gonçalves fb419538c7 Fix login page safe area insets (#54229)
Center the login page inside the safe area

The authorize page only applied the top safe-area inset, so on devices
with side insets the card, password field, forgot password link and
help link were centered on the full viewport and clipped by the right
inset. Add the left and right insets to the content wrapper padding so
the auto margins center the card inside the safe area, and pad the
bottom inset as well.
2026-09-17 15:57:30 +02:00
c0ffeeca7 7c7549349a Add my link for serial panel under Connectivity (#54225) 2026-09-17 14:05:14 +02:00
Jan-Philipp Benecke 051f51dd26 Hide trigger index in trigger references unless triggered-by is edited (#54228) 2026-09-17 14:00:50 +02:00
Maarten Lakerveld 9a9d747899 Redesign map markers (#54025)
* Redesign map markers with zone circles and avatar cluster bubbles

Zones are drawn as colored circles containing the zone icon, using the
entity's color with the zone radius and GPS accuracy circles matching
it. Marker clusters render as a bubble of up to three rounded square
avatars with a +N chip; when all members are in the same zone and the
bubble would overlap that zone's circle, it floats above the circle,
pinned to the zone's coordinate, with a tail pointing at it. Individual
markers are rounded squares, and while history trails are shown bubble
avatars get borders in their entity color so trails can be matched.
Avatars in bubbles are clickable and open the entity, and the whole
bubble zooms in on its members.

ha-map gains per-entity hide_radius, hide_accuracy, and selected
options for hosts that manage a selection; ha-entity-marker exposes its
background and border width via CSS variables and a selected state with
a thicker border. The OSMF vector tiles carry some places twice (node
and boundary centroid); towns and larger now require a population,
keeping the node, and smaller places get a collision padding.

* Frame person and device markers, ring the selected one

Markers get a card-colored frame with a soft shadow instead of a thin
border in the entity color, and the selected marker gets a primary-color
ring outside that frame. With history trails shown the frame still takes
the entity color, so a marker can be matched to its trail, and the
markers redraw when trails appear or disappear.

* Drop the editor's drag classes

The engine sets the move cursor on the elements it actually drags, so the
zone marker and the named icon no longer carry a class for it, and the
editor's element cache no longer varies with editability.

* Keep dragged values on the zone page until they are saved

The engine used to guess at the host's save state: after a drag it
skipped one update that matched the pre-drag values, taking it for a
re-render with data the save had not yet replaced. That guess is wrong
when a save fails, since the old values are then the truth and the
marker stayed at an unsaved position. The engine now applies every host
update except while a drag is in progress.

The zone page, which knows when it is saving, keeps the dragged values
as pending edits and shows them until the saved data carries them; a
failed save drops them and the marker returns.

* Subscribe to entity colors only for maps that draw entities

Every map with a connection subscribed to the entity registry for the
creation-order colors, including editor maps that never draw an entity.
Onboarding's location picker is one, and its mocked server rejects the
registry request, failing the onboarding end-to-end tests. The
subscription now starts when a map first draws entities.

* Address code review on the marker redesign

The selected ring's color came from an inline outline-color that beat the
rule's outline shorthand by precedence, which reads as if the shorthand
won; the inline style now sets a variable the rule consumes, with the
primary color as fallback. A map whose connection context changes
resubscribes to the entity colors instead of staying bound to the old
connection. The zone page's willUpdate called the wrong lifecycle hook on
its base class.

* Tell the user when a dragged zone edit fails to save

A failed save dropped the pending edit and rethrew, which no caller
awaited, leaving an unhandled rejection and a marker that silently moved
back. The page now shows the error in a dialog instead.

* Drop the numeric cluster badge styles, follow a replaced connection for colors

Clusters are avatar bubbles now, so the styles for the old count badge
had nothing left to style. The shared entity color subscription is a
module-level singleton keyed to whichever connection subscribed first; a
map arriving with a different connection now takes the stream over
instead of reading colors from the old one.

* Fall back to the palette color for entities without one, recolor zones on theme change

An entity object without a color reached the accuracy circle's paint
with an undefined color; the map's own palette color fills in. The zone
page's markers are memoized on their data, while their colors come from
theme variables, so a theme change now bumps the color version and
recolors them at once.

* Redraw markers on a theme change, and test the entity color assignment

Marker and trail colors are resolved from theme variables when drawn, but
a theme change only swapped the base style, so the old palette stayed on
the markers until something else redrew them. Any change to the themes
now redraws entities and trails.

The color assignment gets unit tests: creation order per domain, ties
broken by registry id, the home zone kept out of the palette, muted
passive zones, a stable fallback for entities outside the registry, and
one shared registry stream released with the last subscriber.

* Count entity color subscriptions from the listener set

The listener set deduplicated a reused callback while a separate counter
counted every call, so the two could disagree and the shared registry
stream could leak or be released early. Each subscription is now its own
listener, the count is the set's size, and unsubscribing twice is a no-op.

* Release the entity color subscription when a map stops drawing entities

A map whose entities were unset kept its registry subscription, holding
the shared stream open for nothing. It is released along with the
markers, and the three release sites share one helper.

* Address code review on the marker redesign, third round

The zone page's updated() override never called its base, so once
willUpdate stopped misusing super.updated the subscribe mixin had no hook
left to run hassSubscribe when hass arrives after connection; the override
calls super.updated now.

Saves for one zone run in order, each looking the entry up when it starts,
so a second response can replace what the first produced, and a failure
only drops the pending values its own request carried. A passive zone that
is not drawn no longer anchors a cluster bubble. A map whose entity list
empties releases the color subscription, not only one whose list is unset.

* Address code review: drop settled zone save queues

The per-zone save chain kept its last resolved promise for the life of the panel.

* Remove label dedupe

The doubled place labels are a tile issue, not part of the marker redesign,
and the population filter did nothing on the OSMF tiles, which fill in a
default population for every place. It moves to its own branch.

* Address code review: keep the label mode in cluster bubbles

Bubble avatars only carried the label text and picture, so an entity set to
the icon label mode showed initials once clustered and the attribute mode lost
its unit. The icon flag and unit now travel with the cluster data.

* Address code review: color map card entities from the shared palette

The card kept its own color dictionary, so an entity could have a different
color on every map. It now uses the palette that ha-map and the zone editor
already share, ordered by registry creation; a configured color still wins.

* Address code review: take entity colors from the registry context

The color module kept its own shared registry subscription with listener
counting and connection takeover. The lazy fullEntitiesContext already does
that for the whole app, so the module is now pure: it turns the registry
entries into a palette index, memoized per registry update. Maps ask for
the context only once they draw entities, so an editor's map, as in
onboarding, never requests the registry; the map card and the zone page
consume it directly.

* Address code review: one palette sequence for zones, persons and trackers

Per-domain sequences all started at slot 0, so the first person and the
first zone shared a color and a person could match the zone it was in.

* Address code review: recolor history trails when the registry changes

The trail colors are memoized with the registry entries as a key, like the
marker colors, so trails recolor with their markers when the registry
arrives or changes.

* Address code review: follow theme changes and keep bubble focus

The map card rebuilds its entity colors and trail colors when the theme
changes, ha-map redraws editable circles on a theme change, and the
locations editor rebuilds its cached marker elements. A focused cluster
bubble keeps focus on the bubble that replaces it, and a caller naming a
marker by aria-labelledby keeps that name.

* Address code review: recolor the map card from the themes context

The card consumed the theme through hass, whose update gate only lets a
dark mode flip through, so switching between two light or two dark themes
kept the old palette. It now consumes the themes from the UI context, like
ha-map, and rebuilds its entity and trail colors whenever they change.

* Address code review: keep the editor alert clear of the zoom control in RTL

The zoom control sits at the physical top-left in either text direction,
but the alert used logical insets, so in RTL its clearance moved to the
right and it covered the control.

* Fix the bubble tail covering the middle member

The tail is a rotated square whose upper half sits under the bubble. It was
painted on top, so in an open bubble it covered the bottom of the middle
member's frame and selected ring as a white triangle. Both tails now sit
behind their bubble.

* Narrow the locations editor to the themes context

ha-locations-editor consumed the whole uiContext but only reads themes, so
it rerendered on unrelated panel, sidebar, and UI-state changes. Transform
the context to Themes, following the pattern in hui-map-card.

* Keep focus when a cluster collapses to a single marker

Regrouping restored focus only to a replacement cluster icon, but a group
that becomes a single member is shown as a standalone marker with no icon,
so focus fell back to the document. Focus the member element in that case.
2026-09-17 12:26:11 +01:00
Bruno Pantaleão Gonçalves 5b99462fdf Fix notification drawer right safe area inset (#54230)
Apply the right safe-area inset to the full-width notification drawer

The drawer spans the full viewport whenever narrow is true (viewport
under 870px), but the right inset padding on the header and list was
only applied below a 450px media query. Between those widths the close
button ended up under the right inset. Reflect narrow as an attribute
and key the padding off it so the inset follows the same rule as the
drawer width.
2026-09-17 14:22:39 +03:00
Jan-Philipp BeneckeandCopilot Autofix powered by AI 14ef770c44 Overlay trace graph step navigation on the graph (#54227)
* Overlay trace graph step navigation on the graph

* Fix height problem

* Increase margin

Co-authored-by: Copilot Autofix powered by AI <[email protected]>

---------

Co-authored-by: Copilot Autofix powered by AI <[email protected]>
2026-09-17 14:20:00 +03:00
Przemysław Kłys 6a9fca2cb9 Fix logbook labels for restored button states (#54203)
* Fix logbook labels for unavailable timestamp entities

* Fix restored button context classification
2026-09-17 06:40:09 +00:00
Petar Petrov 55ba59074e Fix energy distribution home ring overflowing to 100% grid (#54191)
* Fix energy distribution home ring overflowing to 100% grid

Size the home circle from allocated used_solar, used_battery, and
used_grid instead of net used_total so misaligned hours cannot produce
a negative grid arc.

* Add gallery demo for energy distribution home ring

Mock the issue 54185 daily totals with realistic hourly PV, battery,
and grid statistics, including an export-lagged card that overflows
the old ring math.

* Remove gallery energy distribution demo from this PR

Keep the home-ring fix and its unit tests; drop the gallery page
that was only used for local browser verification.

* Keep a zero-consumption home ring as a plain border

When solar is configured but allocated flows are all zero, leave the
arcs unset so the card can show the home outline instead of zero-length
dashes on a borderless circle.

* Keep a defined zero low-carbon home arc

The energy distribution card mounts the home SVG when solar or
lowCarbon is defined. Omitting a 0 leftover hid grid and battery
arcs on a no-solar 100% high-carbon ring.
2026-09-16 15:49:29 +02:00
Maarten Lakerveld eeb674dd5a Show the selected light control on light group members (#54167)
* Show the selected light control on light group members

When a light group's more-info dialog is switched to color temperature, the
member tiles listed below now show a color temperature slider instead of
brightness, so the children can be adjusted on the same axis as the group.

The light control fires light-main-control-changed whenever its main control
changes, and more-info-content picks the member tile feature from that.
Members that do not support color temperature keep their brightness slider,
and the color mode keeps brightness since there is no tile feature for it.
Legacy group.* entities are unchanged.

* Hide member light control when group is in RGB color mode

There is no RGB tile feature, so showing a brightness slider on group
members while the group control is set to color is inconsistent. Show no
control in that case instead, per review feedback.
2026-09-16 15:44:11 +02:00
42 changed files with 1801 additions and 448 deletions
@@ -10,15 +10,11 @@ import { computeAreaName } from "./compute_area_name";
import { computeDeviceName } from "./compute_device_name";
import {
computeEntityEntryName,
computeEntityName,
entityUseDeviceName,
} from "./compute_entity_name";
import { computeFloorName } from "./compute_floor_name";
import { computeStateName } from "./compute_state_name";
import {
getEntityContext,
getEntityEntryContext,
} from "./context/get_entity_context";
import { getEntityEntryContext } from "./context/get_entity_context";
import type { EntityNameItem, EntityNameOptions } from "./entity_name_config";
const DEFAULT_SEPARATOR = " ";
@@ -110,7 +106,8 @@ export const computeEntityNameDisplay = (
entities,
devices,
areas,
floors
floors,
{ keepAllParts: true }
);
// If after processing there is only one name, return that
@@ -121,13 +118,18 @@ export const computeEntityNameDisplay = (
return names.filter((n) => n).join(separator);
};
export interface EntityNameListOptions {
keepAllParts?: boolean;
}
export const computeEntityNameList = (
stateObj: HassEntity,
name: EntityNameItem[],
entities: HomeAssistant["entities"],
devices: HomeAssistant["devices"],
areas: HomeAssistant["areas"],
floors: HomeAssistant["floors"]
floors: HomeAssistant["floors"],
options?: EntityNameListOptions
): (string | undefined)[] => {
const entry = entities[stateObj.entity_id] as
EntityRegistryDisplayEntry | undefined;
@@ -148,7 +150,8 @@ export const computeEntityNameList = (
entities,
devices,
areas,
floors
floors,
options
);
};
@@ -158,7 +161,8 @@ export const computeEntityEntryNameList = (
entities: HomeAssistant["entities"],
devices: HomeAssistant["devices"],
areas: HomeAssistant["areas"],
floors: HomeAssistant["floors"]
floors: HomeAssistant["floors"],
options?: EntityNameListOptions
): (string | undefined)[] => {
const { device, parentDevice, area, floor } = getEntityEntryContext(
entry,
@@ -167,15 +171,33 @@ export const computeEntityEntryNameList = (
areas,
floors
);
const entityName = computeEntityEntryName(entry, devices);
const keepAllParts = options?.keepAllParts ?? false;
// Same rule as the backend: an owner only adds its name part while the
// next_name_part links reach it, so owners above the first node with its own
// area are left out. An entity without a name of its own is still named
// after its device.
const nameDevice =
keepAllParts || entry.next_name_part !== "area" || !entityName
? device
: null;
const nameParentDevice =
keepAllParts ||
(entry.next_name_part !== "area" && device?.next_name_part !== "area")
? parentDevice
: null;
return name.map((item) => {
switch (item.type) {
case "entity":
return computeEntityEntryName(entry, devices);
return entityName;
case "device":
return device ? computeDeviceName(device) : undefined;
return nameDevice ? computeDeviceName(nameDevice) : undefined;
case "parent_device":
return parentDevice ? computeDeviceName(parentDevice) : undefined;
return nameParentDevice
? computeDeviceName(nameParentDevice)
: undefined;
case "area":
return area ? computeAreaName(area) : undefined;
case "floor":
@@ -195,19 +217,27 @@ export const computeEntitySearchLabels = (
areas: HomeAssistant["areas"],
floors: HomeAssistant["floors"]
) => {
const { device, parentDevice, area } = getEntityContext(
stateObj,
entities,
devices,
areas,
floors
);
const [entityName, deviceName, parentDeviceName, areaName] =
computeEntityNameList(
stateObj,
[
{ type: "entity" },
{ type: "device" },
{ type: "parent_device" },
{ type: "area" },
],
entities,
devices,
areas,
floors,
{ keepAllParts: true }
);
return {
entityName: computeEntityName(stateObj, entities, devices) || null,
entityName: entityName || null,
friendlyName: computeStateName(stateObj) || null,
deviceName: (device && computeDeviceName(device)) || null,
parentDeviceName: (parentDevice && computeDeviceName(parentDevice)) || null,
areaName: (area && computeAreaName(area)) || null,
deviceName: deviceName || null,
parentDeviceName: parentDeviceName || null,
areaName: areaName || null,
};
};
+12 -37
View File
@@ -123,8 +123,6 @@ interface ManagedMarker {
draggable?: boolean;
onDragEnd?: (location: MapLatLng) => void;
dragging?: boolean;
/** Pre-drag location; the next update echoing it is ignored (see setLocation) */
staleLocation?: MapLatLng;
mlMarker?: MapLibreMarker;
decoration?: MapItemHandle;
removed?: boolean;
@@ -600,19 +598,10 @@ export class MapLibreMapEngine implements MapEngine {
},
clusterData: options.clusterData,
setLocation: (newLocation) => {
// The user's hand wins while dragging; the host is the truth otherwise
if (managed.dragging) {
return;
}
// Skip one update echoing the pre-drag location (the save has not returned yet)
const stale = managed.staleLocation;
managed.staleLocation = undefined;
if (
stale &&
newLocation[0] === stale[0] &&
newLocation[1] === stale[1]
) {
return;
}
managed.location = newLocation;
managed.mlMarker?.setLngLat([newLocation[1], newLocation[0]]);
},
@@ -663,7 +652,6 @@ export class MapLibreMapEngine implements MapEngine {
if (managed.draggable) {
managed.mlMarker.on("dragstart", () => {
managed.dragging = true;
managed.staleLocation = managed.location;
});
managed.mlMarker.on("dragend", () => {
managed.dragging = false;
@@ -801,10 +789,8 @@ export class MapLibreMapEngine implements MapEngine {
resizeHandle?.setAttribute("aria-valuetext", radiusText);
};
// Skip one update echoing the pre-edit values (the save has not returned yet)
// The user's hand wins while dragging; the host is the truth otherwise
let dragging = false;
let staleCenter: MapLatLng | undefined;
let staleRadius: number | undefined;
if (options.resizable) {
const east = pointEastOf(center, options.radius);
@@ -847,8 +833,6 @@ export class MapLibreMapEngine implements MapEngine {
if (keyboardRadius === undefined) {
// Host updates treat a key resize like a drag
dragging = true;
staleCenter = currentCenter;
staleRadius = currentRadius;
}
currentRadius = Math.max(
1,
@@ -875,19 +859,11 @@ export class MapLibreMapEngine implements MapEngine {
[centerMarker, resizeMarker].forEach((handleMarker) => {
handleMarker?.on("dragstart", () => {
dragging = true;
staleCenter = currentCenter;
staleRadius = currentRadius;
});
handleMarker?.on("dragend", () => {
dragging = false;
});
});
const isStale = (candidateCenter: MapLatLng, candidateRadius: number) =>
staleCenter !== undefined &&
staleRadius !== undefined &&
candidateCenter[0] === staleCenter[0] &&
candidateCenter[1] === staleCenter[1] &&
candidateRadius === staleRadius;
if (options.moveable) {
centerMarker.on("drag", () => {
@@ -944,12 +920,6 @@ export class MapLibreMapEngine implements MapEngine {
if (dragging) {
return;
}
const stale = isStale(newCenter, newRadius);
staleCenter = undefined;
staleRadius = undefined;
if (stale) {
return;
}
currentCenter = newCenter;
currentRadius = newRadius;
centerMarker.setLngLat([newCenter[1], newCenter[0]]);
@@ -1219,13 +1189,16 @@ export class MapLibreMapEngine implements MapEngine {
const clusterable = this._markers.filter(
(managed) => managed.options.cluster && !managed.removed
);
// A member that had focus hands it to the icon replacing it; read before
// the open bubble holding it is removed
// A member or bubble that had focus hands it to the icon replacing it;
// read before the bubble holding it is removed
const active = (
this._map.getContainer().getRootNode() as Document | ShadowRoot
).activeElement;
const focusedMember = active
? clusterable.find((managed) => managed.element.contains(active))
? (clusterable.find((managed) => managed.element.contains(active)) ??
this._clusterGroups.find((group) =>
group.iconMarker?.getElement().contains(active)
)?.members[0])
: undefined;
this._clusterGroups.forEach((group) => group.iconMarker?.remove());
@@ -1379,8 +1352,10 @@ export class MapLibreMapEngine implements MapEngine {
const group = this._clusterGroups.find((candidate) =>
candidate.members.includes(focusedMember)
);
if (group?.iconMarker && !group.open) {
group.iconMarker.getElement().focus();
if (group && !group.open) {
// A singleton group has no bubble icon; the member shows on its own,
// so send focus back to that member rather than to the document.
(group.iconMarker?.getElement() ?? focusedMember.element).focus();
}
}
}
+73
View File
@@ -0,0 +1,73 @@
import memoizeOne from "memoize-one";
import { getColorByIndex } from "../color/colors";
import { computeDomain } from "../entity/compute_domain";
import type { EntityRegistryEntry } from "../../data/entity/entity_registry";
/**
* Map colors for entities, by registry creation order, so an entity has the
* same color on every map. The registry comes from the fullEntitiesContext;
* entities without an entry (e.g. YAML zones) get a color derived from their
* id.
*/
export const HOME_ZONE_ENTITY_ID = "zone.home";
/** Domains whose entities are colored by creation order */
const ORDERED_DOMAINS = ["zone", "person", "device_tracker"];
// One index per registry update, shared by every map on the page. Zones,
// persons and trackers share one sequence, so a person never has the color
// of the zone it is in.
const creationIndex = memoizeOne(
(entries: EntityRegistryEntry[]): Record<string, number> => {
const index: Record<string, number> = {};
entries
.filter(
(entry) =>
ORDERED_DOMAINS.includes(computeDomain(entry.entity_id)) &&
// The home zone has a fixed color and does not take a palette slot
entry.entity_id !== HOME_ZONE_ENTITY_ID
)
.sort((a, b) => a.created_at - b.created_at || a.id.localeCompare(b.id))
.forEach((entry, i) => {
index[entry.entity_id] = i;
});
return index;
}
);
// For entities without a registry entry
const hashIndex = (entityId: string): number => {
let hash = 5381;
for (let i = 0; i < entityId.length; i++) {
hash = (hash * 33 + entityId.charCodeAt(i)) % 2147483647;
}
return hash;
};
/** The palette color of an entity on a map */
export const entityMapColor = (
entityId: string,
entries: EntityRegistryEntry[],
computedStyles: CSSStyleDeclaration
): string =>
getColorByIndex(
creationIndex(entries)[entityId] ?? hashIndex(entityId),
computedStyles
);
/** A zone's color: primary for home, muted for passive, its entity map color otherwise */
export const zoneColor = (
entityId: string,
passive: boolean,
entries: EntityRegistryEntry[],
computedStyles: CSSStyleDeclaration
): string => {
if (entityId === HOME_ZONE_ENTITY_ID) {
return computedStyles.getPropertyValue("--primary-color");
}
if (passive) {
return computedStyles.getPropertyValue("--secondary-text-color");
}
return entityMapColor(entityId, entries, computedStyles);
};
+5 -1
View File
@@ -16,7 +16,11 @@ export const setMarkerAccessibility = (
): void => {
clearMarkerAccessibility(element);
const owned: string[] = [];
if (title && !element.hasAttribute("aria-label")) {
if (
title &&
!element.hasAttribute("aria-label") &&
!element.hasAttribute("aria-labelledby")
) {
element.setAttribute("aria-label", title);
owned.push("aria-label");
}
+67
View File
@@ -0,0 +1,67 @@
import { getContrastedColorHex } from "../color/rgb";
/** The zone marker: a colored circle with the zone's icon or initials, shared by the map and the zone editor */
export const ZONE_CIRCLE_SIZE = 36;
// Content color contrasting the fill; not every theme color parses
export const contrastingZoneContent = (color: string): string => {
try {
return getContrastedColorHex(color.trim());
} catch {
return "#ffffff";
}
};
export const zoneInitials = (name: string): string =>
name
.split(" ")
.map((part) => part[0])
.join("")
.slice(0, 2);
export const createZoneMarkerElement = (options: {
color: string;
icon?: string;
/** Path for an ha-svg-icon, when there is no icon name */
iconPath?: string;
name: string;
}): HTMLElement => {
const element = document.createElement("div");
element.className = "zone-circle";
element.style.backgroundColor = options.color;
element.style.color = contrastingZoneContent(options.color);
if (options.icon) {
const icon = document.createElement("ha-icon");
icon.setAttribute("icon", options.icon);
element.appendChild(icon);
} else if (options.iconPath) {
const icon = document.createElement("ha-svg-icon");
icon.setAttribute("path", options.iconPath);
element.appendChild(icon);
} else {
const initials = document.createElement("span");
initials.textContent = zoneInitials(options.name);
element.appendChild(initials);
}
return element;
};
/** Styles for the zone marker, included by ha-map */
export const zoneMarkerStyles = `
.zone-circle {
width: ${ZONE_CIRCLE_SIZE}px;
height: ${ZONE_CIRCLE_SIZE}px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
border: 2px solid var(--card-background-color, #fff);
box-sizing: border-box;
box-shadow: var(--ha-box-shadow-s);
overflow: hidden;
font-size: var(--ha-font-size-s);
font-weight: var(--ha-font-weight-medium);
--mdc-icon-size: ${ZONE_CIRCLE_SIZE / 2}px;
}
`;
+1 -1
View File
@@ -360,7 +360,7 @@ export class HaGenericPicker extends PickerMixin(LitElement) {
this._initialFieldValue = undefined;
}
if (
this._hassConfig?.auth.external &&
this._hassConfig?.auth?.external &&
isIosApp(this._hassConfig.auth.external)
) {
this._hassConfig.auth.external.fireMessage({
@@ -1,14 +1,17 @@
import type { CSSResultGroup, PropertyValues } from "lit";
import { LitElement, css, html, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { mdiArrowRightThin } from "@mdi/js";
import { mdiArrowRightThin, mdiDelete } from "@mdi/js";
import { fireEvent } from "../common/dom/fire_event";
import type { HASSDomCurrentTargetEvent } from "../common/dom/fire_event";
import { computeAreaName } from "../common/entity/compute_area_name";
import type { Segment } from "../data/vacuum";
import { getVacuumSegments } from "../data/vacuum";
import { haStyle } from "../resources/styles";
import type { HomeAssistant } from "../types";
import "./ha-alert";
import "./ha-area-picker";
import "./ha-icon-button";
import "./ha-svg-icon";
type AreaSegmentMapping = Record<string, string[]>; // area ID -> segment IDs
@@ -76,6 +79,8 @@ export class HaVacuumSegmentAreaMapper extends LitElement {
// Group segments by group (if available)
const groupedSegments = this._groupSegments(this._segments);
const orphanedAreas = this._getOrphanedAreas();
return html`
${Object.entries(groupedSegments).map(
([groupName, segments]) => html`
@@ -83,9 +88,71 @@ export class HaVacuumSegmentAreaMapper extends LitElement {
${segments.map((segment) => this._renderSegment(segment))}
`
)}
${orphanedAreas.length ? this._renderOrphanedAreas(orphanedAreas) : nothing}
`;
}
private _getOrphanedAreas(): string[] {
if (!this.value || !this._segments || this._segments.length === 0) {
return [];
}
const liveIds = new Set(this._segments.map((segment) => segment.id));
return Object.entries(this.value)
.filter(([, segmentIds]) => segmentIds.some((id) => !liveIds.has(id)))
.map(([areaId]) => areaId);
}
private _renderOrphanedAreas(areaIds: string[]) {
return html`
<h2>
${this.hass.localize(
"ui.dialogs.vacuum_segment_mapping.orphaned_header"
)}
</h2>
<p class="orphaned-description">
${this.hass.localize(
"ui.dialogs.vacuum_segment_mapping.orphaned_description"
)}
</p>
${areaIds.map((areaId) => {
const area = this.hass.areas[areaId];
const name = (area ? computeAreaName(area) : undefined) || areaId;
return html`
<div class="orphaned-row">
<span class="orphaned-name">${name}</span>
<ha-icon-button
.path=${mdiDelete}
.label=${this.hass.localize(
"ui.dialogs.vacuum_segment_mapping.orphaned_remove",
{ name }
)}
data-area-id=${areaId}
@click=${this._removeOrphanedArea}
></ha-icon-button>
</div>
`;
})}
`;
}
private _removeOrphanedArea = (
ev: HASSDomCurrentTargetEvent<HTMLElementTagNameMap["ha-icon-button"]>
) => {
const areaId = ev.currentTarget.dataset.areaId;
if (!areaId || !this.value || !this._segments) {
return;
}
const liveIds = new Set(this._segments.map((segment) => segment.id));
const newMapping: AreaSegmentMapping = { ...this.value };
const kept = (newMapping[areaId] ?? []).filter((id) => liveIds.has(id));
if (kept.length) {
newMapping[areaId] = kept;
} else {
delete newMapping[areaId];
}
fireEvent(this, "value-changed", { value: newMapping });
};
private _groupSegments(segments: Segment[]): Record<string, Segment[]> {
const grouped: Record<string, Segment[]> = {};
@@ -209,8 +276,33 @@ export class HaVacuumSegmentAreaMapper extends LitElement {
}
h2 {
margin: 0;
margin-inline-start: var(--ha-space-4);
margin: var(--ha-space-4) var(--ha-space-4) var(--ha-space-2);
font-size: var(--ha-font-size-m);
font-weight: var(--ha-font-weight-bold);
line-height: var(--ha-line-height-normal);
color: var(--primary-text-color);
}
.orphaned-description {
margin: var(--ha-space-1) var(--ha-space-4) var(--ha-space-2);
color: var(--secondary-text-color);
font: var(--ha-font-body-s);
}
.orphaned-row {
display: flex;
align-items: center;
gap: var(--ha-space-4);
padding-block: var(--ha-space-2);
padding-inline: var(--ha-space-4) var(--ha-space-2);
}
.orphaned-name {
flex: 1;
font: var(--ha-font-body-l);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.loading {
+14 -3
View File
@@ -24,11 +24,13 @@ class HaEntityMarker extends LitElement {
@property({ attribute: "show-icon", type: Boolean }) public showIcon = false;
@property({ type: Boolean, reflect: true }) public selected = false;
protected render() {
return html`
<div
class="marker ${this.entityPicture ? "picture" : ""}"
style=${styleMap({ "border-color": this.entityColor })}
style=${styleMap({ "--ha-marker-selected-color": this.entityColor })}
@click=${this._badgeTap}
>
${
@@ -94,13 +96,22 @@ class HaEntityMarker extends LitElement {
height: var(--ha-marker-size, 48px);
font-size: var(--ha-marker-font-size, var(--ha-font-size-xl));
border-radius: var(--ha-marker-border-radius, 50%);
border: 1px solid var(--ha-marker-color, var(--primary-color));
border: var(--ha-marker-border-width, 3px) solid
var(--ha-marker-color, var(--card-background-color, #fff));
box-shadow: var(--ha-marker-shadow, var(--ha-box-shadow-s));
color: var(--primary-text-color);
background-color: var(--card-background-color);
background-color: var(
--ha-marker-background,
var(--card-background-color)
);
}
.marker.picture {
overflow: hidden;
}
/* A ring in the entity color outside the frame marks the selected marker */
:host([selected]) .marker {
outline: 3px solid var(--ha-marker-selected-color, var(--primary-color));
}
.entity-picture {
background-size: cover;
height: 100%;
+57 -12
View File
@@ -1,14 +1,17 @@
import type { ContextType } from "@lit/context";
import { consume } from "@lit/context";
import type { PropertyValues, TemplateResult } from "lit";
import { css, html, LitElement } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import { transform } from "../../common/decorators/transform";
import { fireEvent } from "../../common/dom/fire_event";
import type { HASSDomEvent } from "../../common/dom/fire_event";
import { MAP_MAX_ZOOM } from "../../common/map/base-layer";
import type { MapLatLng } from "../../common/map/map-engine";
import { circleBoundsPoints } from "../../common/map/map-engine";
import { internationalizationContext } from "../../data/context";
import { internationalizationContext, uiContext } from "../../data/context";
import type { Themes } from "../../data/ws-themes";
import type { HomeAssistantInternationalization, ThemeMode } from "../../types";
import "../ha-alert";
import "../ha-input-helper-text";
@@ -16,6 +19,10 @@ import "./ha-map";
import type { HaMap, HaMapEditableLocation } from "./ha-map";
import type { HaIcon } from "../ha-icon";
import type { HaSvgIcon } from "../ha-svg-icon";
import {
createZoneMarkerElement,
ZONE_CIRCLE_SIZE,
} from "../../common/map/zone-marker";
declare global {
// for fire event
@@ -68,6 +75,15 @@ export class HaLocationsEditor extends LitElement {
@state() private _editingAvailable = true;
// Marker elements bake in theme colors, so they are rebuilt on a theme
// change; narrow the UI context to themes to avoid unrelated rerenders
@state()
@consume({ context: uiContext, subscribe: true })
@transform<ContextType<typeof uiContext>, Themes>({
transformer: ({ themes }) => themes,
})
private _themes?: Themes;
@state()
@consume({ context: internationalizationContext, subscribe: true })
private _i18n?: HomeAssistantInternationalization;
@@ -108,7 +124,10 @@ export class HaLocationsEditor extends LitElement {
return html`
<div class="map">
<ha-map
.editableLocations=${this._editableLocations(this.locations)}
.editableLocations=${this._editableLocations(
this.locations,
this._themes
)}
.zoom=${this.zoom}
.autoFit=${this.autoFit}
.themeMode=${this.themeMode}
@@ -140,7 +159,14 @@ export class HaLocationsEditor extends LitElement {
}
private _editableLocations = memoizeOne(
(locations?: MarkerLocation[]): HaMapEditableLocation[] => {
(
locations: MarkerLocation[] | undefined,
themes: Themes | undefined
): HaMapEditableLocation[] => {
if (themes !== this._elementsThemes) {
this._elementsThemes = themes;
this._elements.clear();
}
const ids = new Set((locations ?? []).map((location) => location.id));
for (const id of this._elements.keys()) {
if (!ids.has(id)) {
@@ -152,7 +178,9 @@ export class HaLocationsEditor extends LitElement {
location: [location.latitude, location.longitude],
radius: location.radius,
element: this._elementFor(location),
elementSize: [ICON_SIZE, ICON_SIZE],
elementSize: location.radius
? [ZONE_CIRCLE_SIZE, ZONE_CIRCLE_SIZE]
: [ICON_SIZE, ICON_SIZE],
title: location.name,
color: location.radius_color,
locationEditable: location.location_editable,
@@ -165,30 +193,46 @@ export class HaLocationsEditor extends LitElement {
// Reused while unchanged, so ha-map moves markers instead of rebuilding them
private _elements = new Map<string, { key: string; element?: HTMLElement }>();
private _elementsThemes?: Themes;
private _elementFor(location: MarkerLocation): HTMLElement | undefined {
const isZone = !!location.radius;
const key = JSON.stringify([
isZone,
location.icon,
location.iconPath,
location.name,
location.location_editable,
location.radius_color,
]);
const cached = this._elements.get(location.id);
if (cached?.key === key) {
return cached.element;
}
const element = this._createIcon(location);
// The zone marker doubles as the circle's draggable center
const element = isZone
? this._createZoneMarker(location)
: this._createIcon(location);
this._elements.set(location.id, { key, element });
return element;
}
private _createZoneMarker(location: MarkerLocation): HTMLElement {
return createZoneMarkerElement({
color:
location.radius_color ||
getComputedStyle(this).getPropertyValue("--accent-color"),
icon: location.icon,
iconPath: location.iconPath,
name: location.name ?? "",
});
}
private _createIcon(location: MarkerLocation): HTMLElement | undefined {
if (!location.icon && !location.iconPath) {
return undefined;
}
const el = document.createElement("div");
el.className = `named-icon ${
location.location_editable ? "draggable" : ""
}`;
el.className = "named-icon";
if (location.name !== undefined) {
el.innerText = location.name;
}
@@ -296,12 +340,13 @@ export class HaLocationsEditor extends LitElement {
display: block;
height: 100%;
}
/* Over the map, clear of the zoom control, so a fixed-height host shows it */
/* Over the map, clear of the zoom control, so a fixed-height host shows it.
The control sits at the physical top-left in either direction. */
ha-alert {
position: absolute;
top: var(--ha-space-2);
inset-inline-start: 56px;
inset-inline-end: var(--ha-space-2);
left: 56px;
right: var(--ha-space-2);
z-index: 1;
}
`;
+294 -68
View File
@@ -1,4 +1,4 @@
import { consume } from "@lit/context";
import { consume, ContextConsumer } from "@lit/context";
import { isToday } from "date-fns";
import type { HassConfig, HassEntities } from "home-assistant-js-websocket";
import type { PropertyValues } from "lit";
@@ -31,15 +31,23 @@ import type {
} from "../../common/map/map-engine";
import { circleBoundsPoints } from "../../common/map/map-engine";
import { editableCircleStyles } from "../../common/map/editable-circle";
import { entityMapColor, zoneColor } from "../../common/map/entity-map-colors";
import {
createZoneMarkerElement,
ZONE_CIRCLE_SIZE,
zoneMarkerStyles,
} from "../../common/map/zone-marker";
import { filterXSS } from "../../common/util/xss";
import {
configContext,
connectionContext,
formattersContext,
fullEntitiesContext,
internationalizationContext,
statesContext,
uiContext,
} from "../../data/context";
import type { EntityRegistryEntry } from "../../data/entity/entity_registry";
import { ensureMapTilesToken } from "../../data/map_tiles";
import type {
HomeAssistantConfig,
@@ -211,9 +219,37 @@ export interface HaMapEntity {
unit?: string;
name?: string;
focus?: boolean;
hide_accuracy?: boolean;
hide_radius?: boolean;
selected?: boolean;
}
// Data carried by entity markers for rendering cluster bubbles
interface ClusterData {
entityId: string;
picture?: string;
label: string;
showIcon: boolean;
unit: string;
color?: string;
selected: boolean;
zoneId?: string;
}
const CLUSTER_AVATAR_SIZE = 32;
const CLUSTER_BUBBLE_PADDING = 6;
const CLUSTER_BUBBLE_GAP = 4;
const CLUSTER_MAX_AVATARS = 3;
const CLUSTER_MORE_WIDTH = 28;
const CLUSTER_MORE_MAX = 99;
const CLUSTER_TAIL_SIZE = 10;
// The tail is a rotated square on the bubble's bottom edge, reaching half its diagonal below
const CLUSTER_TAIL_HEIGHT = Math.round((CLUSTER_TAIL_SIZE * Math.SQRT2) / 2);
const CLUSTER_ZONE_SPACING = 2;
const CLUSTER_RADIUS = 40;
// Same-zone markers share the zone's bubble while they span at most this many
// pixels; further apart they show their actual positions
const ZONE_GROUP_RADIUS = 160;
@customElement("ha-map")
export class HaMap extends ReactiveElement {
@@ -264,6 +300,8 @@ export class HaMap extends ReactiveElement {
@property({ attribute: "fit-zones", type: Boolean }) public fitZones = false;
private _zonePositions: Record<string, MapLatLng> = {};
@property({ attribute: "theme-mode", type: String })
public themeMode: ThemeMode = "auto";
@@ -296,6 +334,11 @@ export class HaMap extends ReactiveElement {
private _resizeObserver?: ResizeObserver;
// Registry creation order decides the palette colors
@state() private _entityReg: EntityRegistryEntry[] = [];
private _registryConsumer?: ContextConsumer<typeof fullEntitiesContext, this>;
private _entityHandles: MapMarkerHandle[] = [];
private _zoneHandles: MapItemHandle[] = [];
@@ -323,6 +366,21 @@ export class HaMap extends ReactiveElement {
this._attachObserver();
}
// Only maps that draw entities ask for the registry; an editor's map, as in
// onboarding, never does. The context provider shares one subscription.
private _watchRegistry(): void {
if (this._registryConsumer || !this.entities?.length) {
return;
}
this._registryConsumer = new ContextConsumer(this, {
context: fullEntitiesContext,
subscribe: true,
callback: (entries) => {
this._entityReg = entries;
},
});
}
private _handleVisibilityChange = async () => {
if (!document.hidden) {
setTimeout(() => {
@@ -385,7 +443,7 @@ export class HaMap extends ReactiveElement {
}
}
if (changedProps.has("clusterMarkers")) {
if (changedProps.has("clusterMarkers") || changedProps.has("_entityReg")) {
this._drawEntities();
}
@@ -401,6 +459,11 @@ export class HaMap extends ReactiveElement {
if (changedProps.has("_loaded") || changedProps.has("paths")) {
this._drawPaths();
// Cluster bubbles show entity colors only while trails are visible
const oldPaths = changedProps.get("paths") as HaMapPaths[] | undefined;
if (!!oldPaths?.length !== !!this.paths?.length) {
this._engine?.refreshClusters();
}
}
if (changedProps.has("_loaded") || changedProps.has("editableLocations")) {
@@ -433,13 +496,19 @@ export class HaMap extends ReactiveElement {
const oldUi = changedProps.get("_ui") as HomeAssistantUI | undefined;
if (
!changedProps.has("themeMode") &&
(!changedProps.has("_ui") ||
(oldUi && oldUi.themes?.darkMode === this._ui.themes?.darkMode))
(!changedProps.has("_ui") || (oldUi && oldUi.themes === this._ui.themes))
) {
return;
}
this._updateMapStyle();
// Marker, trail and circle colors were resolved from the theme when drawn
this._drawEntities();
this._drawPaths();
if (this._editableHandles.size) {
this._removeEditableLocations();
this._drawEditableLocations();
}
}
private get _darkMode() {
@@ -1031,16 +1100,36 @@ export class HaMap extends ReactiveElement {
engine.setClustering(null);
return;
}
this._watchRegistry();
const computedStyles = getComputedStyle(this);
const zoneColor = computedStyles.getPropertyValue("--accent-color");
const passiveZoneColor = computedStyles.getPropertyValue(
"--secondary-text-color"
);
const darkPrimaryColor = computedStyles.getPropertyValue(
"--dark-primary-color"
);
// A person's state is "home" for the home zone, the zone name otherwise
const zoneByState: Record<string, string> = {};
this._zonePositions = {};
for (const entity of this.entities) {
const stateObj = states[getEntityId(entity)];
// A zone that is not drawn cannot anchor a bubble either
if (
stateObj &&
computeStateDomain(stateObj) === "zone" &&
(this.renderPassive || !stateObj.attributes.passive)
) {
zoneByState[
stateObj.entity_id === "zone.home"
? "home"
: computeStateName(stateObj)
] = stateObj.entity_id;
if (
typeof stateObj.attributes.latitude === "number" &&
typeof stateObj.attributes.longitude === "number"
) {
this._zonePositions[stateObj.entity_id] = [
stateObj.attributes.latitude,
stateObj.attributes.longitude,
];
}
}
}
for (const entity of this.entities) {
const stateObj = states[getEntityId(entity)];
@@ -1069,26 +1158,29 @@ export class HaMap extends ReactiveElement {
continue;
}
const zoneMarkerColor = passive ? passiveZoneColor : zoneColor;
const hideRadius = typeof entity !== "string" && entity.hide_radius;
// A host-set color wins, except passive zones are always muted
const markerColor =
!passive && typeof entity !== "string" && entity.color
? entity.color
: zoneColor(
stateObj.entity_id,
!!passive,
this._entityReg,
computedStyles
);
if (radius) {
if (!hideRadius && radius) {
this._zoneHandles.push(
engine.addCircle(position, { radius, color: zoneMarkerColor })
engine.addCircle(position, { radius, color: markerColor })
);
}
// create icon
const iconEl = document.createElement("div");
iconEl.className = `zone-icon ${this._darkMode ? "dark" : "light"}`;
if (icon) {
const el = document.createElement("ha-icon");
el.setAttribute("icon", icon);
iconEl.appendChild(el);
} else {
const el = document.createElement("span");
el.textContent = title;
iconEl.appendChild(el);
}
const circleEl = createZoneMarkerElement({
color: markerColor,
icon,
name: title,
});
if (this.interactiveZones) {
const openMoreInfo = (ev: Event) => {
@@ -1097,8 +1189,8 @@ export class HaMap extends ReactiveElement {
entityId: stateObj.entity_id,
});
};
iconEl.addEventListener("click", openMoreInfo);
iconEl.addEventListener("keydown", (ev) => {
circleEl.addEventListener("click", openMoreInfo);
circleEl.addEventListener("keydown", (ev) => {
if (ev.key === "Enter" || ev.key === " ") {
ev.preventDefault();
openMoreInfo(ev);
@@ -1106,10 +1198,9 @@ export class HaMap extends ReactiveElement {
});
}
const zoneIconSize = this._getMarkerSize(computedStyles) / 2;
this._zoneHandles.push(
engine.addMarker(iconEl, position, {
size: [zoneIconSize, zoneIconSize],
engine.addMarker(circleEl, position, {
size: [ZONE_CIRCLE_SIZE, ZONE_CIRCLE_SIZE],
interactive: this.interactiveZones,
title,
})
@@ -1119,7 +1210,7 @@ export class HaMap extends ReactiveElement {
this.fitZones &&
(typeof entity === "string" || entity.focus !== false)
) {
if (radius) {
if (!hideRadius && radius) {
this._focusZonePoints.push(...circleBoundsPoints(position, radius));
} else {
this._focusZonePoints.push(position);
@@ -1163,19 +1254,42 @@ export class HaMap extends ReactiveElement {
entityPicture && (typeof entity === "string" || !entity.label_mode)
? this._connection.hassUrl(entityPicture)
: "";
// A host may leave the color to the map
const entityColor =
(typeof entity !== "string" ? entity.color : undefined) ||
entityMapColor(getEntityId(entity), this._entityReg, computedStyles);
entityMarker.entityColor = entityColor;
if (typeof entity !== "string") {
entityMarker.entityColor = entity.color;
entityMarker.selected = entity.selected ?? false;
}
const clusterData: ClusterData = {
entityId: getEntityId(entity),
picture: entityMarker.entityPicture || undefined,
label: entityName,
showIcon: entityMarker.showIcon,
unit: entityMarker.entityUnit ?? "",
color: entityColor,
selected: typeof entity !== "string" && (entity.selected ?? false),
zoneId: ["person", "device_tracker"].includes(
computeStateDomain(stateObj)
)
? zoneByState[stateObj.state]
: undefined,
};
const showAccuracy =
!!gpsAccuracy && !(typeof entity !== "string" && entity.hide_accuracy);
const markerSize = this._getMarkerSize(computedStyles);
this._entityHandles.push(
engine.addMarker(entityMarker, position, {
size: [markerSize, markerSize],
title,
cluster: true,
// create circle around if entity has accuracy
decoration: gpsAccuracy
? { radius: gpsAccuracy, color: darkPrimaryColor }
clusterData,
decoration: showAccuracy
? { radius: gpsAccuracy!, color: entityColor }
: undefined,
})
);
@@ -1187,22 +1301,97 @@ export class HaMap extends ReactiveElement {
engine.setClustering(
this.clusterMarkers
? { radius: CLUSTER_RADIUS, iconBuilder: this._createClusterIcon }
? {
radius: CLUSTER_RADIUS,
iconBuilder: this._createClusterBubble,
// Everyone in a zone shares its bubble until zooming spreads them
groupKey: (marker) => (marker.clusterData as ClusterData)?.zoneId,
groupRadius: ZONE_GROUP_RADIUS,
}
: null
);
}
// Renders a marker cluster as a circle with the member count
private _createClusterIcon = (members: MapMarkerHandle[]): MapClusterIcon => {
const size = Math.round(
this._getMarkerSize(getComputedStyle(this)) * (2 / 3)
);
const element = document.createElement("div");
element.className = "marker-cluster";
const count = document.createElement("span");
count.textContent = String(members.length);
element.appendChild(count);
return { element, size: [size, size] };
// Renders a marker cluster as a bubble of its members' avatars
private _createClusterBubble = (
members: MapMarkerHandle[]
): MapClusterIcon => {
const data = members.map((member) => member.clusterData as ClusterData);
const shown = data.slice(0, CLUSTER_MAX_AVATARS);
const hidden = data.length - shown.length;
// With history trails shown, colored borders match avatars to trails
const showColors = !!this.paths?.length;
const bubble = document.createElement("div");
bubble.className = "cluster-bubble";
for (const member of shown) {
const avatar = document.createElement("ha-entity-marker");
avatar.entityId = member?.entityId;
avatar.entityName = member?.label ?? "";
avatar.entityUnit = member?.unit ?? "";
avatar.showIcon = member?.showIcon ?? false;
avatar.entityPicture = member?.picture ?? "";
avatar.entityColor = member?.color;
if (showColors) {
avatar.style.setProperty(
"--ha-marker-color",
member?.color ?? "var(--primary-color)"
);
avatar.style.setProperty("--ha-marker-border-width", "2px");
}
if (member?.selected) {
avatar.selected = true;
}
bubble.appendChild(avatar);
}
let width =
shown.length * CLUSTER_AVATAR_SIZE +
(shown.length - 1) * CLUSTER_BUBBLE_GAP +
2 * CLUSTER_BUBBLE_PADDING;
if (hidden > 0) {
const more = document.createElement("span");
more.className = "more";
more.textContent =
hidden > CLUSTER_MORE_MAX ? `${CLUSTER_MORE_MAX}+` : `+${hidden}`;
bubble.appendChild(more);
width += CLUSTER_MORE_WIDTH + CLUSTER_BUBBLE_GAP;
}
// A cluster of one zone's occupants attaches to that zone's marker
const zoneId = data[0]?.zoneId;
const zonePosition = zoneId ? this._zonePositions[zoneId] : undefined;
const atZone =
!!zoneId &&
!!zonePosition &&
data.every((member) => member?.zoneId === zoneId);
let height = CLUSTER_AVATAR_SIZE + 2 * CLUSTER_BUBBLE_PADDING;
let root: HTMLElement = bubble;
if (atZone) {
root = document.createElement("div");
root.className = "cluster-marker";
const tail = document.createElement("div");
tail.className = "cluster-bubble-tail";
root.append(bubble, tail);
height += CLUSTER_TAIL_HEIGHT;
}
return {
element: root,
size: [width, height],
// Float above the zone circle, tail pointing at it
...(atZone && zonePosition
? {
location: zonePosition,
anchor: [
width / 2,
height + ZONE_CIRCLE_SIZE / 2 + CLUSTER_ZONE_SPACING,
] as [number, number],
}
: {}),
};
};
private _drawScaleRuler(): void {
@@ -1263,6 +1452,7 @@ export class HaMap extends ReactiveElement {
display: flex;
flex-direction: column;
align-items: center;
isolation: isolate;
}
.cluster-open-members {
display: flex;
@@ -1276,6 +1466,13 @@ export class HaMap extends ReactiveElement {
border-radius: 14px;
box-shadow: var(--ha-box-shadow-s);
}
/* Both tails are a rotated square whose upper half sits under the bubble;
drawn behind it, so it never covers a member's frame or selected ring */
.cluster-open-tail,
.cluster-bubble-tail {
position: relative;
z-index: -1;
}
.cluster-open-tail {
width: 10px;
height: 10px;
@@ -1349,17 +1546,59 @@ export class HaMap extends ReactiveElement {
.leaflet-pane {
z-index: 0 !important;
}
/* Zone icons, sized like the Leaflet divIcon they replaced */
.zone-icon {
.cluster-marker {
display: flex;
flex-direction: column;
align-items: center;
isolation: isolate;
}
.cluster-bubble-tail {
width: ${CLUSTER_TAIL_SIZE}px;
height: ${CLUSTER_TAIL_SIZE}px;
margin-top: ${-CLUSTER_TAIL_SIZE / 2}px;
border-radius: 2px;
background: var(--card-background-color, #fff);
transform: rotate(45deg);
}
.cluster-bubble {
display: flex;
align-items: center;
gap: ${CLUSTER_BUBBLE_GAP}px;
padding: ${CLUSTER_BUBBLE_PADDING}px;
box-sizing: border-box;
background: var(--card-background-color, #fff);
border-radius: 14px;
box-shadow: var(--ha-box-shadow-s);
--ha-marker-size: ${CLUSTER_AVATAR_SIZE}px;
--ha-marker-color: transparent;
--ha-marker-border-width: 1px;
--ha-marker-shadow: none;
--ha-marker-font-size: var(--ha-font-size-s);
/* distinguish letter tiles from the bubble background */
--ha-marker-background: var(--ha-color-fill-neutral-quiet-resting);
}
.cluster-bubble .more {
flex: none;
width: ${CLUSTER_MORE_WIDTH}px;
height: ${CLUSTER_AVATAR_SIZE}px;
display: flex;
align-items: center;
justify-content: center;
text-align: center;
color: var(--primary-text-color);
border-radius: 10px;
background: var(--ha-color-fill-neutral-quiet-resting, #f0f0f0);
color: var(--primary-text-color, #212121);
font-size: var(--ha-font-size-s);
font-weight: var(--ha-font-weight-medium);
}
.zone-icon.dark {
color: #ffffff;
/* Markers are rounded squares to match the cluster bubble avatars */
ha-entity-marker {
--ha-marker-border-radius: var(--ha-border-radius-lg);
}
.cluster-bubble ha-entity-marker {
flex: none;
--ha-marker-border-radius: 10px;
}
${unsafeCSS(zoneMarkerStyles)}
.leaflet-control,
.leaflet-top,
.leaflet-bottom {
@@ -1410,19 +1649,6 @@ export class HaMap extends ReactiveElement {
ha-icon {
--mdc-icon-size: calc(var(--ha-marker-size, 48px) / 2);
}
.marker-cluster {
box-sizing: border-box;
background-clip: padding-box;
background-color: var(--primary-color);
border: 3px solid rgba(var(--rgb-primary-color), 0.2);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
color: var(--text-primary-color);
font-size: var(--ha-font-size-m);
}
`;
}
@@ -639,9 +639,10 @@ export class HaTargetPickerItemRow extends LitElement {
const area = device
? getDeviceArea(device, this.hass.areas, this.hass.devices)
: undefined;
const parentDevice = device?.parent_device_id
? this.hass.devices[device.parent_device_id]
: undefined;
const parentDevice =
device?.parent_device_id && device.next_name_part !== "area"
? this.hass.devices[device.parent_device_id]
: undefined;
const context = [
area ? computeAreaName(area) : undefined,
parentDevice ? computeDeviceName(parentDevice) : undefined,
+17 -3
View File
@@ -761,9 +761,9 @@ export class HatScriptGraph extends LitElement {
static get styles() {
return css`
:host {
display: flex;
flex-direction: row;
display: grid;
overflow: hidden;
position: relative;
--stroke-clr: var(--stroke-color, var(--secondary-text-color));
--active-clr: var(--active-color, var(--primary-color));
--track-clr: var(--track-color, var(--accent-color));
@@ -782,9 +782,10 @@ export class HatScriptGraph extends LitElement {
--hat-graph-branch-height: ${BRANCH_HEIGHT}px;
}
.graph-scroll {
flex: 1;
grid-area: 1 / 1;
overflow: auto;
min-width: 0;
min-height: 0;
}
.graph-container {
display: flex;
@@ -795,6 +796,19 @@ export class HatScriptGraph extends LitElement {
.actions {
display: flex;
flex-direction: column;
grid-area: 1 / 1;
justify-self: end;
align-self: start;
margin-top: var(--ha-space-2);
margin-inline-end: var(--ha-space-5);
z-index: 1;
background-color: color-mix(
in srgb,
var(--card-background-color) 70%,
transparent
);
backdrop-filter: blur(8px);
border-radius: var(--ha-border-radius-pill);
}
.parent {
margin-left: 8px;
+3 -1
View File
@@ -8,7 +8,7 @@ import type {
EntityRegistryEntry,
} from "../entity/entity_registry";
import type { EntitySources } from "../entity/entity_sources";
import type { RegistryEntry } from "../registry";
import type { NextNamePart, RegistryEntry } from "../registry";
export {
fetchDeviceRegistry,
@@ -46,6 +46,7 @@ export interface DeviceRegistryEntry extends RegistryEntry {
// Set when this device is a child (logical part) of another device.
// null for regular top-level devices.
parent_device_id: string | null;
next_name_part?: NextNamePart | null;
}
/**
@@ -68,6 +69,7 @@ export interface ChildDeviceRegistryEntry extends RegistryEntry {
area_id: string | null;
disabled_by: DeviceDisabler | null;
parent_device_id: string;
next_name_part?: NextNamePart | null;
}
/**
+4 -1
View File
@@ -8,7 +8,7 @@ import { caseInsensitiveStringCompare } from "../../common/string/compare";
import { debounce } from "../../common/util/debounce";
import type { HomeAssistant } from "../../types";
import type { LightColor } from "../light";
import type { RegistryEntry } from "../registry";
import type { NextNamePart, RegistryEntry } from "../registry";
import type { Segment } from "../vacuum";
type EntityCategory = "config" | "diagnostic";
@@ -19,6 +19,7 @@ export interface EntityRegistryDisplayEntry {
icon?: string;
device_id?: string;
area_id?: string;
next_name_part?: NextNamePart;
labels: string[];
hidden?: boolean;
entity_category?: EntityCategory;
@@ -33,6 +34,7 @@ export interface EntityRegistryDisplayEntryResponse {
ei: string;
di?: string;
ai?: string;
np?: NextNamePart;
lb: string[];
ec?: number;
en?: string;
@@ -56,6 +58,7 @@ export interface EntityRegistryEntry extends RegistryEntry {
config_subentry_id: string | null;
device_id: string | null;
area_id: string | null;
next_name_part?: NextNamePart | null;
labels: string[];
disabled_by: "user" | "device" | "integration" | "config_entry" | null;
hidden_by: Exclude<EntityRegistryEntry["disabled_by"], "config_entry">;
+17 -3
View File
@@ -5,6 +5,7 @@ import { computeDomain } from "../common/entity/compute_domain";
import { computeStateDomain } from "../common/entity/compute_state_domain";
import type { LocalizeFunc } from "../common/translations/localize";
import type { HomeAssistant } from "../types";
import { UNAVAILABLE, UNKNOWN } from "./entity/entity";
import { isNumericEntity } from "./history";
const LOGBOOK_LOCALIZE_PATH = "ui.components.logbook.messages";
@@ -280,17 +281,22 @@ const STATE_ACTION_MESSAGES: Record<
radio_frequency: "command_sent",
};
const RESTORABLE_BUTTON_DOMAINS = new Set(["button", "input_button"]);
export const localizeStateMessage = (
hass: HomeAssistant,
state: string,
stateObj: HassEntity,
domain: string,
attributes?: LogbookEntry["attributes"]
entry?: LogbookEntry
): string => {
if (state === UNKNOWN || state === UNAVAILABLE) {
return hass.formatEntityState(stateObj, state);
}
// Events show the triggered event type, falling back to a generic label when
// the type is unknown (the timestamp state is meaningless on its own).
if (domain === "event") {
const eventType = attributes?.event_type;
const eventType = entry?.attributes?.event_type;
if (eventType != null) {
return hass.formatEntityAttributeValue(stateObj, "event_type", eventType);
}
@@ -298,7 +304,15 @@ export const localizeStateMessage = (
}
const actionKey: LogbookActionMessage | undefined =
STATE_ACTION_MESSAGES[domain as keyof typeof STATE_ACTION_MESSAGES];
if (actionKey) {
const stateTimestamp = Date.parse(state);
const matchesEntryTime =
entry === undefined ||
(Number.isFinite(stateTimestamp) &&
Math.abs(stateTimestamp - entry.when * 1000) < 1000);
if (
actionKey &&
(!RESTORABLE_BUTTON_DOMAINS.has(domain) || matchesEntryTime)
) {
return hass.localize(`${LOGBOOK_LOCALIZE_PATH}.${actionKey}`);
}
// Every other domain reuses the backend state translation, so the logbook
+2
View File
@@ -1,3 +1,5 @@
export type NextNamePart = "area" | "device" | "parent_device";
export interface RegistryEntry {
created_at: number;
modified_at: number;
@@ -11,6 +11,7 @@ import type { CSSResultGroup, PropertyValues } from "lit";
import { LitElement, css, html, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { consumeLocalize } from "../../../common/decorators/consume-context-entry";
import { fireEvent, type HASSDomEvent } from "../../../common/dom/fire_event";
import { supportsFeature } from "../../../common/entity/supports-feature";
import type { LocalizeFunc } from "../../../common/translations/localize";
import "../../../components/ha-attribute-icon";
@@ -85,12 +86,26 @@ class MoreInfoLight extends LitElement {
private _setMainControl(ev: any) {
ev.stopPropagation();
this._mainControl = ev.currentTarget.control;
this._changeMainControl(ev.currentTarget.control);
}
private _resetMainControl(ev: any) {
ev.stopPropagation();
this._mainControl = "brightness";
this._changeMainControl("brightness");
}
public connectedCallback(): void {
super.connectedCallback();
// A container that outlives this control (e.g. more-info-content when the
// dialog moves between entities) resyncs with the default control.
fireEvent(this, "light-main-control-changed", {
control: this._mainControl,
});
}
private _changeMainControl(control: MainControl) {
this._mainControl = control;
fireEvent(this, "light-main-control-changed", { control });
}
private get _stateOverride() {
@@ -399,4 +414,14 @@ declare global {
interface HTMLElementTagNameMap {
"more-info-light": MoreInfoLight;
}
interface HASSDomEvents {
"light-main-control-changed": { control: MainControl };
}
interface HTMLElementEventMap {
"light-main-control-changed": HASSDomEvent<
HASSDomEvents["light-main-control-changed"]
>;
}
}
+115 -84
View File
@@ -1,6 +1,7 @@
import type { HassEntity } from "home-assistant-js-websocket";
import type { PropertyValues } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property } from "lit/decorators";
import { customElement, property, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import { dynamicElement } from "../../common/dom/dynamic-element-directive";
import { computeEntityName } from "../../common/entity/compute_entity_name";
@@ -11,6 +12,7 @@ import "../../components/ha-badge";
import type { ExtEntityRegistryEntry } from "../../data/entity/entity_registry";
import { supportsCoverPositionCardFeature } from "../../panels/lovelace/card-features/hui-cover-position-card-feature";
import { supportsLightBrightnessCardFeature } from "../../panels/lovelace/card-features/hui-light-brightness-card-feature";
import { supportsLightColorTempCardFeature } from "../../panels/lovelace/card-features/hui-light-color-temp-card-feature";
import type { LovelaceCardFeatureConfig } from "../../panels/lovelace/card-features/types";
import type { TileCardConfig } from "../../panels/lovelace/cards/types";
import { importMoreInfoControl } from "../../panels/lovelace/custom-card-helpers";
@@ -25,6 +27,8 @@ interface EntityInfo {
deviceId: string | undefined;
}
type LightMainControl = HASSDomEvents["light-main-control-changed"]["control"];
@customElement("more-info-content")
class MoreInfoContent extends LitElement {
@property({ attribute: false }) public hass?: HomeAssistant;
@@ -37,6 +41,17 @@ class MoreInfoContent extends LitElement {
@property({ attribute: false }) public data?: Record<string, any>;
// Mirrors the mode selected in the light control so group members
// show the matching slider.
@state() private _lightMainControl: LightMainControl = "brightness";
protected firstUpdated(changedProps: PropertyValues<this>) {
super.firstUpdated(changedProps);
this.addEventListener("light-main-control-changed", (ev) => {
this._lightMainControl = ev.detail.control;
});
}
protected render() {
let moreInfoType: string | undefined;
@@ -73,7 +88,10 @@ class MoreInfoContent extends LitElement {
? html`
<hui-section
.hass=${this.hass}
.config=${this._entitiesSectionConfig(memberIds)}
.config=${this._entitiesSectionConfig(
memberIds,
this._lightMainControl
)}
>
</hui-section>
`
@@ -104,97 +122,110 @@ class MoreInfoContent extends LitElement {
}
);
private _entitiesSectionConfig = memoizeOne((entityIds: string[]) => {
const hass = this.hass!;
private _entitiesSectionConfig = memoizeOne(
(entityIds: string[], lightMainControl: LightMainControl) => {
const hass = this.hass!;
// Get entity names and areas for all visible entities
const entityInfos = entityIds
.map<EntityInfo | null>((entityId) => {
const entry = hass.entities[entityId];
if (entry?.hidden) {
return null;
}
const stateObj = hass.states[entityId];
if (!stateObj) {
return null;
}
const entityName = computeEntityName(
stateObj,
hass.entities,
hass.devices
);
const { area, device } = getEntityContext(
stateObj,
hass.entities,
hass.devices,
hass.areas,
hass.floors
);
const areaId = area?.area_id;
const deviceId = device?.id;
return { entityId, entityName, areaId, deviceId };
})
.filter(Boolean) as EntityInfo[];
// Get entity names and areas for all visible entities
const entityInfos = entityIds
.map<EntityInfo | null>((entityId) => {
const entry = hass.entities[entityId];
if (entry?.hidden) {
return null;
}
const stateObj = hass.states[entityId];
if (!stateObj) {
return null;
}
const entityName = computeEntityName(
stateObj,
hass.entities,
hass.devices
);
const { area, device } = getEntityContext(
stateObj,
hass.entities,
hass.devices,
hass.areas,
hass.floors
);
const areaId = area?.area_id;
const deviceId = device?.id;
return { entityId, entityName, areaId, deviceId };
})
.filter(Boolean) as EntityInfo[];
// Check if all entities have the same entity name
const entityNames = new Set(entityInfos.map((info) => info.entityName));
const allSameEntityName = entityNames.size === 1;
// Check if all entities have the same entity name
const entityNames = new Set(entityInfos.map((info) => info.entityName));
const allSameEntityName = entityNames.size === 1;
// Check if all entities have the same area
const areaIds = new Set(entityInfos.map((info) => info.areaId));
const allSameArea = areaIds.size === 1;
// Check if all entities have the same area
const areaIds = new Set(entityInfos.map((info) => info.areaId));
const allSameArea = areaIds.size === 1;
// Check if all entities belong to the same device
const deviceIds = new Set(entityInfos.map((info) => info.deviceId));
const allSameDevice = deviceIds.size === 1;
// Check if all entities belong to the same device
const deviceIds = new Set(entityInfos.map((info) => info.deviceId));
const allSameDevice = deviceIds.size === 1;
// Build name and state content config based on conditions. The device name
// is redundant when every member belongs to the same device, so omit it
// (and fall back to the entity name so the tile still has a label).
const name: EntityNameItem[] = [];
// Build name and state content config based on conditions. The device name
// is redundant when every member belongs to the same device, so omit it
// (and fall back to the entity name so the tile still has a label).
const name: EntityNameItem[] = [];
if (!allSameDevice) {
name.push({ type: "device" });
}
if (!allSameEntityName || allSameDevice) {
name.push({ type: "entity" });
}
const stateContent = ["state"];
if (!allSameArea) {
stateContent.push("area_name");
}
const cards = entityInfos.map(({ entityId }) => {
const features: LovelaceCardFeatureConfig[] = [];
const context = { entity_id: entityId };
if (supportsCoverPositionCardFeature(hass, context)) {
features.push({
type: "cover-position",
});
} else if (supportsLightBrightnessCardFeature(hass, context)) {
features.push({
type: "light-brightness",
});
if (!allSameDevice) {
name.push({ type: "device" });
}
return {
type: "tile",
entity: entityId,
name,
state_content: stateContent,
features_position: "inline",
features,
grid_options: { columns: 12 },
} as TileCardConfig;
});
if (!allSameEntityName || allSameDevice) {
name.push({ type: "entity" });
}
return {
type: "grid",
cards,
};
});
const stateContent = ["state"];
if (!allSameArea) {
stateContent.push("area_name");
}
const cards = entityInfos.map(({ entityId }) => {
const features: LovelaceCardFeatureConfig[] = [];
const context = { entity_id: entityId };
if (supportsCoverPositionCardFeature(hass, context)) {
features.push({
type: "cover-position",
});
} else if (lightMainControl === "color") {
// There is no RGB tile feature yet, so when the group is set to
// color the members show no control rather than a mismatched
// brightness slider.
} else if (
lightMainControl === "color_temp" &&
supportsLightColorTempCardFeature(hass, context)
) {
features.push({
type: "light-color-temp",
});
} else if (supportsLightBrightnessCardFeature(hass, context)) {
features.push({
type: "light-brightness",
});
}
return {
type: "tile",
entity: entityId,
name,
state_content: stateContent,
features_position: "inline",
features,
grid_options: { columns: 12 },
} as TileCardConfig;
});
return {
type: "grid",
cards,
};
}
);
static styles = css`
hui-section {
@@ -21,6 +21,10 @@ import { computeRTLDirection } from "../../common/util/compute_rtl";
export class HuiNotificationDrawer extends KeyboardShortcutMixin(LitElement) {
@property({ attribute: false }) public hass!: HomeAssistant;
// Reflected so the styles can apply the right safe-area inset whenever the
// drawer spans the full viewport width, not only below a fixed breakpoint.
@property({ type: Boolean, reflect: true }) public narrow = false;
@state() private _notifications: PersistentNotification[] = [];
@state() public _open = false;
@@ -48,6 +52,7 @@ export class HuiNotificationDrawer extends KeyboardShortcutMixin(LitElement) {
}
showDialog({ narrow }) {
this.narrow = narrow;
this._unsubNotifications = subscribeNotifications(
this.hass.connection,
(notifications) => {
@@ -218,11 +223,9 @@ export class HuiNotificationDrawer extends KeyboardShortcutMixin(LitElement) {
display: block;
}
@media all and (max-width: 450px), all and (max-height: 500px) {
ha-header-bar {
--header-bar-padding: var(--safe-area-inset-top, 0px)
var(--safe-area-inset-right, 0px) 0 var(--safe-area-inset-left, 0px);
}
:host([narrow]) ha-header-bar {
--header-bar-padding: var(--safe-area-inset-top, 0px)
var(--safe-area-inset-right, 0px) 0 var(--safe-area-inset-left, 0px);
}
.list-container {
@@ -246,11 +249,9 @@ export class HuiNotificationDrawer extends KeyboardShortcutMixin(LitElement) {
color: var(--primary-text-color);
}
@media all and (max-width: 450px), all and (max-height: 500px) {
.notifications {
padding-right: var(--safe-area-inset-right, 0px);
padding-inline-end: var(--safe-area-inset-right, 0px);
}
:host([narrow]) .notifications {
padding-right: var(--safe-area-inset-right, 0px);
padding-inline-end: var(--safe-area-inset-right, 0px);
}
.notification {
+4 -2
View File
@@ -26,7 +26,10 @@
width: 100%;
max-width: 400px;
margin: 0 auto;
padding: 0 16px;
padding: var(--safe-area-inset-top, 0px)
calc(16px + var(--safe-area-inset-right, 0px))
var(--safe-area-inset-bottom, 0px)
calc(16px + var(--safe-area-inset-left, 0px));
box-sizing: content-box;
}
@@ -35,7 +38,6 @@
align-items: center;
justify-content: center;
margin-bottom: 32px;
padding-top: var(--safe-area-inset-top);
}
.header img {
+10
View File
@@ -1,3 +1,4 @@
import { createContext, provide } from "@lit/context";
import type { LitElement, PropertyValues } from "lit";
import { property, state } from "lit/decorators";
import type { LocalizeFunc } from "../common/translations/localize";
@@ -9,6 +10,10 @@ import { getLocalLanguage, getTranslation } from "../util/common-translation";
const empty = () => "";
export const translationsReadyContext = createContext<boolean>(
"translationsReadyContext"
);
export const litLocalizeLiteMixin = <T extends Constructor<LitElement>>(
superClass: T
) => {
@@ -16,6 +21,10 @@ export const litLocalizeLiteMixin = <T extends Constructor<LitElement>>(
// Initialized to empty will prevent undefined errors if called before connected to DOM.
@property({ attribute: false }) public localize: LocalizeFunc = empty;
@provide({ context: translationsReadyContext })
@state()
public translationsReady = false;
// Use browser language setup before login.
@property() public language: string = getLocalLanguage();
@@ -59,6 +68,7 @@ export const litLocalizeLiteMixin = <T extends Constructor<LitElement>>(
this._resources
).then((localize) => {
this.localize = localize;
this.translationsReady = true;
});
}
}
+1 -1
View File
@@ -149,7 +149,7 @@ class HaOnboarding extends litLocalizeLiteMixin(HassElement) {
href="https://www.home-assistant.io/getting-started/onboarding/"
target="_blank"
rel="noreferrer noopener"
>${this.localize("ui.panel.page-onboarding.help")}</a
>${this.localize("ui.panel.page-onboarding.help") || "Help"}</a
>
</div>`;
}
+43 -33
View File
@@ -1,18 +1,25 @@
import { consume } from "@lit/context";
import type { TemplateResult } from "lit";
import { LitElement, css, html } from "lit";
import { customElement, property } from "lit/decorators";
import { customElement, property, state } from "lit/decorators";
import "../components/ha-card";
import "../components/ha-ripple";
import "../components/ha-svg-icon";
import { translationsReadyContext } from "../mixins/lit-localize-lite-mixin";
import { renderSkeleton, skeletonStyles } from "./render-skeleton";
@customElement("onboarding-welcome-link")
class OnboardingWelcomeLink extends LitElement {
@property() public label!: string;
@property() public label?: string;
@property({ attribute: false }) public iconPath!: string;
@property({ type: Boolean }) public noninteractive = false;
@state()
@consume({ context: translationsReadyContext, subscribe: true })
private _translationsReady?;
protected render(): TemplateResult {
return html`
<ha-card
@@ -20,7 +27,7 @@ class OnboardingWelcomeLink extends LitElement {
@keydown=${this._handleKeyDown}
>
<ha-svg-icon .path=${this.iconPath}></ha-svg-icon>
${this.label}
${this._translationsReady === false ? renderSkeleton("label") : this.label}
<ha-ripple></ha-ripple>
</ha-card>
`;
@@ -32,36 +39,39 @@ class OnboardingWelcomeLink extends LitElement {
}
}
static styles = css`
:host {
cursor: pointer;
}
ha-card {
overflow: hidden;
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
font-weight: var(--ha-font-weight-medium);
padding: 32px 16px;
height: 100%;
}
ha-svg-icon {
color: var(--text-primary-color);
background: var(--welcome-link-color, var(--primary-color));
border-radius: var(--ha-border-radius-circle);
padding: 8px;
margin-bottom: 16px;
}
ha-card:focus-visible:before {
position: absolute;
display: block;
content: "";
inset: 0;
background-color: var(--secondary-text-color);
opacity: 0.08;
}
`;
static styles = [
skeletonStyles,
css`
:host {
cursor: pointer;
}
ha-card {
overflow: hidden;
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
font-weight: var(--ha-font-weight-medium);
padding: 32px 16px;
height: 100%;
}
ha-svg-icon {
color: var(--text-primary-color);
background: var(--welcome-link-color, var(--primary-color));
border-radius: var(--ha-border-radius-circle);
padding: 8px;
margin-bottom: 16px;
}
ha-card:focus-visible:before {
position: absolute;
display: block;
content: "";
inset: 0;
background-color: var(--secondary-text-color);
opacity: 0.08;
}
`,
];
}
declare global {
+72 -21
View File
@@ -1,4 +1,5 @@
import "@home-assistant/webawesome/dist/components/divider/divider";
import { consume } from "@lit/context";
import type { CSSResultGroup, TemplateResult } from "lit";
import { LitElement, css, html } from "lit";
import { customElement, state } from "lit/decorators";
@@ -9,6 +10,8 @@ import "../components/ha-button";
import "../components/ha-icon-next";
import "../components/item/ha-list-item-button";
import "../components/list/ha-list-base";
import { translationsReadyContext } from "../mixins/lit-localize-lite-mixin";
import { renderSkeleton, skeletonStyles } from "./render-skeleton";
import { onBoardingStyles } from "./styles";
@customElement("onboarding-welcome")
@@ -17,44 +20,89 @@ class OnboardingWelcome extends LitElement {
@consumeLocalize()
private _localize!: LocalizeFunc;
protected render(): TemplateResult {
return html`
<h1>${this._localize("ui.panel.page-onboarding.welcome.header")}</h1>
<p>${this._localize("ui.panel.page-onboarding.intro")}</p>
@state()
@consume({ context: translationsReadyContext, subscribe: true })
private _translationsReady!: boolean;
<ha-button @click=${this._start} class="start">
${this._localize("ui.panel.page-onboarding.welcome.start")}
protected render(): TemplateResult {
const ready = this._translationsReady;
return html`
<h1>
${
ready
? this._localize("ui.panel.page-onboarding.welcome.header")
: renderSkeleton("title")
}
</h1>
<p>
${
ready
? this._localize("ui.panel.page-onboarding.intro")
: renderSkeleton("line")
}
</p>
<ha-button @click=${this._start} class="start" .disabled=${!ready}>
${
ready
? this._localize("ui.panel.page-onboarding.welcome.start")
: renderSkeleton("button")
}
</ha-button>
<div class="divider">
<wa-divider></wa-divider>
<div>
<span
>${this._localize(
"ui.panel.page-onboarding.welcome.or_restore"
)}</span
>
<span>
${
ready
? this._localize("ui.panel.page-onboarding.welcome.or_restore")
: renderSkeleton("chip")
}
</span>
</div>
</div>
<ha-list-base>
<ha-list-item-button @click=${this._restoreBackupUpload}>
<ha-list-item-button
@click=${this._restoreBackupUpload}
.disabled=${!ready}
>
<div slot="headline">
${this._localize("ui.panel.page-onboarding.restore.upload_backup")}
${
ready
? this._localize(
"ui.panel.page-onboarding.restore.upload_backup"
)
: renderSkeleton("headline")
}
</div>
<div slot="supporting-text">
${this._localize(
"ui.panel.page-onboarding.restore.options.upload_description"
)}
${
ready
? this._localize(
"ui.panel.page-onboarding.restore.options.upload_description"
)
: renderSkeleton("line")
}
</div>
<ha-icon-next slot="end"></ha-icon-next>
</ha-list-item-button>
<ha-list-item-button @click=${this._restoreBackupCloud}>
<div slot="headline">Home Assistant Cloud</div>
<ha-list-item-button
@click=${this._restoreBackupCloud}
.disabled=${!ready}
>
<div slot="headline">
${ready ? "Home Assistant Cloud" : renderSkeleton("headline")}
</div>
<div slot="supporting-text">
${this._localize(
"ui.panel.page-onboarding.restore.ha-cloud.description"
)}
${
ready
? this._localize(
"ui.panel.page-onboarding.restore.ha-cloud.description"
)
: renderSkeleton("line")
}
</div>
<ha-icon-next slot="end"></ha-icon-next>
</ha-list-item-button>
@@ -93,10 +141,12 @@ class OnboardingWelcome extends LitElement {
margin-bottom: calc(var(--ha-space-4) * -1);
}
h1 {
width: 100%;
margin-top: var(--ha-space-4);
margin-bottom: var(--ha-space-2);
}
p {
width: 100%;
margin: 0;
}
.start {
@@ -129,6 +179,7 @@ class OnboardingWelcome extends LitElement {
--ha-row-item-padding-inline: 0;
}
`,
skeletonStyles,
];
}
}
+35
View File
@@ -0,0 +1,35 @@
import "@home-assistant/webawesome/dist/components/skeleton/skeleton";
import type { TemplateResult } from "lit";
import { css, html } from "lit";
/** Placeholder shown in place of a text while onboarding translations load. */
export const renderSkeleton = (variant: string): TemplateResult =>
html`<wa-skeleton effect="sheen" class="skeleton ${variant}"></wa-skeleton>`;
export const skeletonStyles = css`
.skeleton {
height: 1em;
vertical-align: middle;
--color: var(--ha-color-fill-neutral-normal-resting);
--sheen-color: var(--ha-color-fill-neutral-loud-resting);
}
.skeleton.title {
width: 200px;
}
.skeleton.line {
width: 100%;
}
.skeleton.headline {
width: 40%;
margin-bottom: var(--ha-space-1);
}
.skeleton.chip {
width: 80px;
}
.skeleton.button {
width: 120px;
}
.skeleton.label {
width: 80px;
}
`;
+1 -8
View File
@@ -476,13 +476,6 @@ export class HAFullCalendar extends LitElement {
const wasShowingToday = this._isShowingToday();
const nextMidnight = new TZDate(new Date(), this._calendarTimeZone());
nextMidnight.setHours(24, 0, 0, 0);
const delay = nextMidnight.getTime() - Date.now();
// Guard against a NaN/negative delay (e.g. Intl longOffset unsupported on
// Chromium < 95) so the midnight refresh can't fire in a tight loop (#54182).
if (!Number.isFinite(delay) || delay <= 0) {
return;
}
this._midnightRefreshTimeout = window.setTimeout(() => {
if (wasShowingToday) {
@@ -492,7 +485,7 @@ export class HAFullCalendar extends LitElement {
}
this._scheduleMidnightRefresh();
}, delay);
}, nextMidnight.getTime() - Date.now());
}
private _clearMidnightRefreshTimeout(): void {
@@ -925,9 +925,10 @@ class DialogAddAutomationElement
const area = device
? getDeviceArea(device, this.hass.areas, this.hass.devices)
: undefined;
const parentDevice = device?.parent_device_id
? this.hass.devices[device.parent_device_id]
: undefined;
const parentDevice =
device?.parent_device_id && device.next_name_part !== "area"
? this.hass.devices[device.parent_device_id]
: undefined;
if (area) {
subtitle = [
computeAreaName(area) || area.area_id,
+5 -5
View File
@@ -109,11 +109,11 @@ export const rowStyles = css`
text-box-edge: cap alphabetic;
overflow: hidden;
transition:
opacity 180ms ease-out,
transform 180ms ease-out,
width 180ms ease-out,
margin-inline-end 180ms ease-out,
border-width 180ms ease-out;
opacity var(--ha-animation-duration-fast) ease-out,
transform var(--ha-animation-duration-fast) ease-out,
width var(--ha-animation-duration-fast) ease-out,
margin-inline-end var(--ha-animation-duration-fast) ease-out,
border-width var(--ha-animation-duration-fast) ease-out;
}
.trigger-index-badge.hidden {
@@ -1,10 +1,11 @@
import { consume } from "@lit/context";
import { mdiLinkVariantOff } from "@mdi/js";
import { css, html, LitElement } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { ensureArray } from "../../../../common/array/ensure-array";
import { capitalizeFirstLetter } from "../../../../common/string/capitalize-first-letter";
import "../../../../components/ha-svg-icon";
import "../../../../components/ha-tooltip";
import "../../../../components/ha-trigger-icon";
import type { TriggerCondition } from "../../../../data/automation";
import { describeTrigger } from "../../../../data/automation_i18n";
@@ -32,6 +33,7 @@ export class HaAutomationTriggerReferences extends LitElement {
protected render() {
const options = this._triggers?.options ?? [];
const showIndices = this._triggers?.showIndices ?? false;
const selectedIds = ensureArray(this.condition.id).filter(Boolean);
const selectedTriggers = options.filter((option) =>
selectedIds.includes(option.id)
@@ -52,7 +54,28 @@ export class HaAutomationTriggerReferences extends LitElement {
selectedTriggers.map(
(option) => html`
<span class="trigger-reference">
<span class="trigger-index-badge">${option.index + 1}</span>
<span
id="trigger-index-badge-${option.index}"
tabindex=${showIndices ? "0" : "-1"}
class="trigger-index-badge ${showIndices ? "" : "hidden"}"
aria-label=${this.hass.localize(
"ui.panel.config.automation.editor.triggers.trigger_index_aria_label",
{ number: option.index + 1 }
)}
aria-hidden=${showIndices ? "false" : "true"}
>${option.index + 1}</span
>
${
showIndices
? html`<ha-tooltip for="trigger-index-badge-${option.index}"
><p>
${this.hass.localize(
"ui.panel.config.automation.editor.triggers.trigger_index_tooltip"
)}
</p></ha-tooltip
>`
: nothing
}
<ha-trigger-icon
.trigger=${"trigger" in option.trigger ? option.trigger.trigger : ""}
></ha-trigger-icon>
@@ -132,6 +155,22 @@ export class HaAutomationTriggerReferences extends LitElement {
line-height: 1;
text-box-trim: both;
text-box-edge: cap alphabetic;
overflow: hidden;
transition:
opacity var(--ha-animation-duration-fast) ease-out,
transform var(--ha-animation-duration-fast) ease-out,
width var(--ha-animation-duration-fast) ease-out,
margin-inline-end var(--ha-animation-duration-fast) ease-out,
border-width var(--ha-animation-duration-fast) ease-out;
}
.trigger-index-badge.hidden {
opacity: 0;
transform: translateX(calc(-8px * var(--scale-direction)));
width: 0;
margin-inline-end: calc(var(--ha-space-2) * -1);
border-width: 0;
pointer-events: none;
}
`;
}
+171 -47
View File
@@ -22,7 +22,12 @@ import type {
MarkerLocation,
} from "../../../components/map/ha-locations-editor";
import { saveCoreConfig } from "../../../data/core";
import type { EntityRegistryEntry } from "../../../data/entity/entity_registry";
import { subscribeEntityRegistry } from "../../../data/entity/entity_registry";
import {
HOME_ZONE_ENTITY_ID,
zoneColor,
} from "../../../common/map/entity-map-colors";
import type {
HomeZoneMutableParams,
Zone,
@@ -47,6 +52,19 @@ import { configSections } from "../config-sections";
import { showHomeZoneDetailDialog } from "./show-dialog-home-zone-detail";
import { showZoneDetailDialog } from "./show-dialog-zone-detail";
interface PendingEdit {
latitude?: number;
longitude?: number;
radius?: number;
}
// How close the saved value must come to a pending one to count as saved
const PENDING_TOLERANCE: Record<keyof PendingEdit, number> = {
latitude: 1e-7,
longitude: 1e-7,
radius: 0.5,
};
@customElement("ha-config-zone")
export class HaConfigZone extends SubscribeMixin(LitElement) {
@property({ attribute: false }) public hass!: HomeAssistant;
@@ -61,21 +79,36 @@ export class HaConfigZone extends SubscribeMixin(LitElement) {
@state() private _stateItems?: HassEntity[];
// Values dragged on the map, shown until the saved data reflects them, so
// a re-render while the save is in flight does not move the marker back.
// A failed save drops them and the marker returns to the saved values.
@state() private _pendingEdits: Record<string, PendingEdit> = {};
@state() private _canEditCore = false;
@query("ha-locations-editor") private _map?: HaLocationsEditor;
private _regEntities: string[] = [];
// Registry creation order decides the zone colors
@state() private _entityReg: EntityRegistryEntry[] = [];
// Storage zone id (its unique id) to entity id
@state() private _zoneEntityIds: Record<string, string> = {};
// Bumped when entity map colors change to recompute the memoized locations
@state() private _colorVersion = 0;
private _getZones = memoizeOne(
(storageItems: Zone[], stateItems: HassEntity[]): MarkerLocation[] => {
(
storageItems: Zone[],
stateItems: HassEntity[],
zoneEntityIds: Record<string, string>,
pendingEdits: Record<string, PendingEdit>,
entityReg: EntityRegistryEntry[],
_colorVersion: number
): MarkerLocation[] => {
const computedStyles = getComputedStyle(this);
const zoneRadiusColor = computedStyles.getPropertyValue("--accent-color");
const passiveRadiusColor = computedStyles.getPropertyValue(
"--secondary-text-color"
);
const homeRadiusColor =
computedStyles.getPropertyValue("--primary-color");
const stateLocations: MarkerLocation[] = stateItems.map(
(entityState) => ({
@@ -85,21 +118,28 @@ export class HaConfigZone extends SubscribeMixin(LitElement) {
latitude: entityState.attributes.latitude,
longitude: entityState.attributes.longitude,
radius: entityState.attributes.radius,
radius_color:
entityState.entity_id === "zone.home"
? homeRadiusColor
: entityState.attributes.passive
? passiveRadiusColor
: zoneRadiusColor,
...pendingEdits[entityState.entity_id],
radius_color: zoneColor(
entityState.entity_id,
!!entityState.attributes.passive,
entityReg,
computedStyles
),
location_editable:
entityState.entity_id === "zone.home" && this._canEditCore,
entityState.entity_id === HOME_ZONE_ENTITY_ID && this._canEditCore,
radius_editable:
entityState.entity_id === "zone.home" && this._canEditCore,
entityState.entity_id === HOME_ZONE_ENTITY_ID && this._canEditCore,
})
);
const storageLocations: MarkerLocation[] = storageItems.map((zone) => ({
...zone,
radius_color: zone.passive ? passiveRadiusColor : zoneRadiusColor,
...pendingEdits[zone.id],
radius_color: zoneColor(
zoneEntityIds[zone.id] ?? `zone.${zone.id}`,
!!zone.passive,
entityReg,
computedStyles
),
location_editable: true,
radius_editable: true,
}));
@@ -110,9 +150,18 @@ export class HaConfigZone extends SubscribeMixin(LitElement) {
public hassSubscribe(): UnsubscribeFunc[] {
return [
subscribeEntityRegistry(this.hass.connection!, (entities) => {
this._entityReg = entities;
this._regEntities = entities.map(
(registryEntry) => registryEntry.entity_id
);
this._zoneEntityIds = Object.fromEntries(
entities
.filter((registryEntry) => registryEntry.platform === "zone")
.map((registryEntry) => [
registryEntry.unique_id,
registryEntry.entity_id,
])
);
this._filterStates();
}),
];
@@ -265,7 +314,11 @@ export class HaConfigZone extends SubscribeMixin(LitElement) {
<ha-locations-editor
.locations=${this._getZones(
this._storageItems,
this._stateItems
this._stateItems,
this._zoneEntityIds,
this._pendingEdits,
this._entityReg,
this._colorVersion
)}
@location-updated=${this._locationUpdated}
@radius-updated=${this._radiusUpdated}
@@ -295,7 +348,8 @@ export class HaConfigZone extends SubscribeMixin(LitElement) {
}
}
protected updated() {
protected updated(changedProps: PropertyValues<this>) {
super.updated(changedProps);
if (
!this.route.path.startsWith("/edit/") ||
!this._stateItems ||
@@ -313,11 +367,99 @@ export class HaConfigZone extends SubscribeMixin(LitElement) {
}
public willUpdate(changedProps: PropertyValues<this>) {
super.updated(changedProps);
super.willUpdate(changedProps);
const oldHass = changedProps.get("hass") as HomeAssistant | undefined;
if (oldHass && this._stateItems) {
this._getStates(oldHass);
}
// Zone colors come from theme variables
if (oldHass && oldHass.themes !== this.hass.themes) {
this._colorVersion++;
}
this._settlePendingEdits();
}
// A pending edit is done once the saved data carries its values
private _settlePendingEdits() {
for (const [id, pending] of Object.entries(this._pendingEdits)) {
const saved =
this._storageItems?.find((zone) => zone.id === id) ??
this.hass.states[id]?.attributes;
if (
saved &&
(Object.keys(pending) as (keyof PendingEdit)[]).every(
(key) =>
Math.abs((saved[key] as number) - pending[key]!) <
PENDING_TOLERANCE[key]
)
) {
this._dropPendingEdit(id);
}
}
}
private _dropPendingEdit(id: string) {
const { [id]: _done, ...rest } = this._pendingEdits;
this._pendingEdits = rest;
}
// Saves for one zone run in order, so each sees the entry the previous one
// produced and a failure only drops the values its own request carried
private _saveQueue: Record<string, Promise<void>> = {};
private _saveEdit(id: string, pending: PendingEdit): Promise<void> {
this._pendingEdits = {
...this._pendingEdits,
[id]: { ...this._pendingEdits[id], ...pending },
};
const save = (this._saveQueue[id] ?? Promise.resolve())
.then(() => this._performSave(id, pending))
.finally(() => {
// Only the last save in the chain removes the queue entry
if (this._saveQueue[id] === save) {
delete this._saveQueue[id];
}
});
this._saveQueue[id] = save;
return save;
}
private async _performSave(id: string, pending: PendingEdit) {
try {
if (id === HOME_ZONE_ENTITY_ID) {
await saveCoreConfig(this.hass, pending);
return;
}
const entry = this._storageItems!.find((item) => item.id === id);
if (entry) {
await this._updateEntry(entry, pending);
}
} catch (err: any) {
// The saved values are the truth again for what this request changed;
// a later edit of other values stays pending for its own save
this._dropPendingValues(id, pending);
showAlertDialog(this, {
title: this.hass.localize("ui.panel.config.zone.can_not_edit"),
text: err.message,
});
}
}
private _dropPendingValues(id: string, failed: PendingEdit) {
const current = this._pendingEdits[id];
if (!current) {
return;
}
const rest = Object.fromEntries(
Object.entries(current).filter(
([key, value]) => failed[key as keyof PendingEdit] !== value
)
) as PendingEdit;
if (Object.keys(rest).length) {
this._pendingEdits = { ...this._pendingEdits, [id]: rest };
} else {
this._dropPendingEdit(id);
}
}
private async _fetchData() {
@@ -359,37 +501,19 @@ export class HaConfigZone extends SubscribeMixin(LitElement) {
}
}
private async _locationUpdated(ev: CustomEvent) {
if (ev.detail.id === "zone.home" && this._canEditCore) {
await saveCoreConfig(this.hass, {
latitude: ev.detail.location[0],
longitude: ev.detail.location[1],
});
return;
}
const entry = this._storageItems!.find((item) => item.id === ev.detail.id);
if (!entry) {
return;
}
this._updateEntry(entry, {
private _locationUpdated(ev: CustomEvent) {
this._saveEdit(ev.detail.id, {
latitude: ev.detail.location[0],
longitude: ev.detail.location[1],
});
}
private async _radiusUpdated(ev: CustomEvent) {
if (ev.detail.id === "zone.home" && this._canEditCore) {
await saveCoreConfig(this.hass, {
radius: Math.round(ev.detail.radius),
});
return;
}
const entry = this._storageItems!.find((item) => item.id === ev.detail.id);
if (!entry) {
return;
}
this._updateEntry(entry, {
radius: ev.detail.radius,
private _radiusUpdated(ev: CustomEvent) {
this._saveEdit(ev.detail.id, {
radius:
ev.detail.id === HOME_ZONE_ENTITY_ID
? Math.round(ev.detail.radius)
: ev.detail.radius,
});
}
@@ -416,7 +540,7 @@ export class HaConfigZone extends SubscribeMixin(LitElement) {
const entryId: string = (ev.currentTarget! as any).value;
if (this.narrow && entryId === "zone.home") {
if (this.narrow && entryId === HOME_ZONE_ENTITY_ID) {
this._editHomeZone(ev);
return;
}
@@ -475,7 +599,7 @@ export class HaConfigZone extends SubscribeMixin(LitElement) {
longitude: values.longitude,
radius: values.radius,
});
this._zoomZone("zone.home");
this._zoomZone(HOME_ZONE_ENTITY_ID);
}
private async _updateEntry(
+1 -7
View File
@@ -320,13 +320,7 @@ const computeLogbookValue = (
if (item.entity_id && item.state) {
return {
text: stateObj
? localizeStateMessage(
hass,
item.state,
stateObj,
domain!,
item.attributes
)
? localizeStateMessage(hass, item.state, stateObj, domain!, item)
: item.state,
type: "state",
};
@@ -0,0 +1,73 @@
/**
* Home-circle stroke lengths for the energy distribution card.
*
* Hourly allocation can produce used_solar + used_battery + used_grid larger
* than net used_total when some hours export more than they produce. Dividing
* by net used_total then overflows the circle and the leftover grid arc goes
* negative, which browsers paint as a solid grid ring.
*
* These arcs use the sum of the allocated home flows as the denominator so
* they always fit the circumference.
*/
export const ENERGY_DISTRIBUTION_HOME_CIRCLE_CIRCUMFERENCE = 238.76104;
export interface EnergyDistributionHomeCircleArcs {
solar?: number;
battery?: number;
lowCarbon?: number;
highCarbon?: number;
grid?: number;
}
export const computeEnergyDistributionHomeCircleArcs = ({
usedSolar = 0,
usedBattery = 0,
usedGrid = 0,
hasSolar,
hasGrid,
highCarbonConsumption,
circumference = ENERGY_DISTRIBUTION_HOME_CIRCLE_CIRCUMFERENCE,
}: {
usedSolar?: number;
usedBattery?: number;
usedGrid?: number;
hasSolar: boolean;
hasGrid: boolean;
highCarbonConsumption?: number;
circumference?: number;
}): EnergyDistributionHomeCircleArcs => {
const solar = Math.max(usedSolar, 0);
const battery = Math.max(usedBattery, 0);
const grid = Math.max(usedGrid, 0);
const ringTotal = solar + battery + grid;
const arcs: EnergyDistributionHomeCircleArcs = {};
// Leave arcs unset so the card can fall back to the plain home border
// instead of painting zero-length dashes over a borderless circle.
if (ringTotal <= 0) {
return arcs;
}
const share = (value: number): number => circumference * (value / ringTotal);
if (hasSolar) {
arcs.solar = share(solar);
}
if (battery > 0) {
arcs.battery = share(battery);
}
if (hasGrid) {
if (highCarbonConsumption !== undefined) {
const highCarbon = Math.min(Math.max(highCarbonConsumption, 0), grid);
arcs.highCarbon = share(highCarbon);
// Keep 0 defined: the card mounts the home SVG when solar or
// lowCarbon is defined, not when the low-carbon stroke is painted.
arcs.lowCarbon = share(grid - highCarbon);
arcs.grid = arcs.highCarbon;
} else {
arcs.grid = share(grid);
}
}
return arcs;
};
@@ -37,8 +37,10 @@ import type { LovelaceCard } from "../../types";
import type { EnergyDistributionCardConfig } from "../types";
import { formatNumber } from "../../../../common/number/format_number";
import { round } from "../../../../common/number/round";
const CIRCLE_CIRCUMFERENCE = 238.76104;
import {
ENERGY_DISTRIBUTION_HOME_CIRCLE_CIRCUMFERENCE as CIRCLE_CIRCUMFERENCE,
computeEnergyDistributionHomeCircleArcs,
} from "./energy-distribution-home-circle";
// Flows are differences of sums; anything that rounds to 0 Wh is noise
const hasFlow = (value: number | null): value is number =>
@@ -321,22 +323,8 @@ class HuiEnergyDistrubutionCard
const totalHomeConsumption = Math.max(0, consumption.total.used_total);
let homeSolarCircumference: number | undefined;
if (hasSolarProduction) {
homeSolarCircumference =
CIRCLE_CIRCUMFERENCE * (solarConsumption! / totalHomeConsumption);
}
let homeBatteryCircumference: number | undefined;
if (batteryConsumption) {
homeBatteryCircumference =
CIRCLE_CIRCUMFERENCE * (batteryConsumption / totalHomeConsumption);
}
let lowCarbonEnergy: number | undefined;
let homeLowCarbonCircumference: number | undefined;
let homeHighCarbonCircumference: number | undefined;
let highCarbonConsumption: number | undefined;
// This fallback is used in the demo
let electricityMapUrl = "https://app.electricitymaps.com";
@@ -360,7 +348,6 @@ class HuiEnergyDistrubutionCard
if (highCarbonEnergy !== null) {
lowCarbonEnergy = totalFromGrid - highCarbonEnergy;
let highCarbonConsumption: number;
if (gridConsumption !== totalFromGrid) {
// Only get the part that was used for consumption and not the battery
highCarbonConsumption =
@@ -368,18 +355,23 @@ class HuiEnergyDistrubutionCard
} else {
highCarbonConsumption = highCarbonEnergy;
}
homeHighCarbonCircumference =
CIRCLE_CIRCUMFERENCE * (highCarbonConsumption / totalHomeConsumption);
homeLowCarbonCircumference =
CIRCLE_CIRCUMFERENCE -
(homeSolarCircumference || 0) -
(homeBatteryCircumference || 0) -
homeHighCarbonCircumference;
}
}
const {
solar: homeSolarCircumference,
battery: homeBatteryCircumference,
lowCarbon: homeLowCarbonCircumference,
grid: homeGridCircumference,
} = computeEnergyDistributionHomeCircleArcs({
usedSolar: solarConsumption ?? 0,
usedBattery: batteryConsumption ?? 0,
usedGrid: gridConsumption,
hasSolar: hasSolarProduction,
hasGrid: Boolean(hasGrid),
highCarbonConsumption,
});
const totalLines =
gridConsumption +
(solarConsumption || 0) +
@@ -670,16 +662,8 @@ class HuiEnergyDistrubutionCard
cx="40"
cy="40"
r="38"
stroke-dasharray="${
homeHighCarbonCircumference ??
CIRCLE_CIRCUMFERENCE -
homeSolarCircumference! -
(homeBatteryCircumference || 0)
} ${
homeHighCarbonCircumference !== undefined
? CIRCLE_CIRCUMFERENCE - homeHighCarbonCircumference
: homeSolarCircumference! +
(homeBatteryCircumference || 0)
stroke-dasharray="${homeGridCircumference} ${
CIRCLE_CIRCUMFERENCE - (homeGridCircumference ?? 0)
}"
stroke-dashoffset="0"
shape-rendering="geometricPrecision"
+57 -16
View File
@@ -8,8 +8,13 @@ import type { PropertyValues } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import { getColorByIndex } from "../../../common/color/colors";
import type { ContextType } from "@lit/context";
import { consume, ContextConsumer } from "@lit/context";
import { resolveThemeColor } from "../../../common/color/compute-color";
import {
entityMapColor,
zoneColor,
} from "../../../common/map/entity-map-colors";
import { isComponentLoaded } from "../../../common/config/is_component_loaded";
import { computeDomain } from "../../../common/entity/compute_domain";
import { computeStateDomain } from "../../../common/entity/compute_state_domain";
@@ -31,6 +36,10 @@ import type {
import type { MapLatLng } from "../../../common/map/map-engine";
import type { HistoryStates } from "../../../data/history";
import { subscribeHistoryStatesTimeWindow } from "../../../data/history";
import type { Themes } from "../../../data/ws-themes";
import { fullEntitiesContext, uiContext } from "../../../data/context";
import { transform } from "../../../common/decorators/transform";
import type { EntityRegistryEntry } from "../../../data/entity/entity_registry";
import type { HomeAssistant } from "../../../types";
import { findEntities } from "../common/find-entities";
import {
@@ -58,6 +67,18 @@ interface GeoEntity {
@customElement("hui-map-card")
class HuiMapCard extends LitElement implements LovelaceCard {
constructor() {
super();
new ContextConsumer(this, {
context: fullEntitiesContext,
subscribe: true,
callback: (entries) => {
this._entityReg = entries;
this._mapEntities = this._getMapEntities();
},
});
}
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false }) public layout?: string;
@@ -76,12 +97,19 @@ class HuiMapCard extends LitElement implements LovelaceCard {
private _filteredMapEntities: HaMapEntity[] = [];
private _colorDict: Record<string, string> = {};
private _colorIndex = 0;
@state() private _error?: { code: string; message: string };
// Registry creation order decides the palette colors
@state() private _entityReg: EntityRegistryEntry[] = [];
// Palette colors are read from the theme when the entities are built
@state()
@consume({ context: uiContext, subscribe: true })
@transform<ContextType<typeof uiContext>, Themes>({
transformer: ({ themes }) => themes,
})
private _themes?: Themes;
@state() private _clusterMarkers = true;
private _subscribed?: Promise<(() => Promise<void>) | undefined>;
@@ -213,7 +241,12 @@ class HuiMapCard extends LitElement implements LovelaceCard {
<ha-map
.entities=${this._filteredMapEntities}
.zoom=${this._config.default_zoom ?? DEFAULT_ZOOM}
.paths=${this._getHistoryPaths(this._config, this._stateHistory)}
.paths=${this._getHistoryPaths(
this._config,
this._stateHistory,
this._entityReg,
this._themes
)}
.autoFit=${this._config.auto_fit || false}
.fitZones=${this._config.fit_zones || false}
.themeMode=${themeMode}
@@ -321,6 +354,10 @@ class HuiMapCard extends LitElement implements LovelaceCard {
) {
this._mapEntities = this._getMapEntities();
}
// Private state is not in keyof this
if ((changedProps as PropertyValues).has("_themes") && this.hasUpdated) {
this._mapEntities = this._getMapEntities();
}
// Filter entities by conditions
if (this._config?.conditions && this._mapEntities) {
@@ -432,18 +469,19 @@ class HuiMapCard extends LitElement implements LovelaceCard {
this._clusterMarkers = !this._clusterMarkers;
}
// The same color for an entity on every map; a config color still wins
private _getColor(entityId: string): string {
let color = this._colorDict[entityId];
if (color) {
return color;
}
const computedStyles = getComputedStyle(this);
color = getColorByIndex(this._colorIndex, computedStyles);
if (color) {
this._colorIndex++;
this._colorDict[entityId] = color;
if (computeDomain(entityId) === "zone") {
const stateObj = this.hass?.states[entityId];
return zoneColor(
entityId,
!!stateObj?.attributes.passive,
this._entityReg,
computedStyles
);
}
return color;
return entityMapColor(entityId, this._entityReg, computedStyles);
}
private _getSourceEntities(states?: HassEntities): GeoEntity[] {
@@ -503,7 +541,10 @@ class HuiMapCard extends LitElement implements LovelaceCard {
private _getHistoryPaths = memoizeOne(
(
config: MapCardConfig,
history?: HistoryStates
history: HistoryStates | undefined,
// Trail colors follow the registry order and the theme like the markers
_entityReg: EntityRegistryEntry[],
_themes: Themes | undefined
): HaMapPaths[] | undefined => {
if (!history || !(config.hours_to_show ?? DEFAULT_HOURS_TO_SHOW)) {
return undefined;
+4
View File
@@ -189,6 +189,10 @@ export const getMyRedirects = (): Redirects => ({
component: "radio_frequency",
redirect: "/config/radio-frequency",
},
config_serial: {
component: "usb",
redirect: "/config/serial",
},
config_ssdp: {
component: "ssdp",
redirect: "/config/ssdp",
+1
View File
@@ -274,6 +274,7 @@ export const connectionMixin = <T extends Constructor<HassBaseEl>>(
entity_id: entity.ei,
device_id: entity.di,
area_id: entity.ai,
next_name_part: entity.np,
labels: entity.lb,
translation_key: entity.tk,
platform: entity.pl,
+4 -1
View File
@@ -1537,7 +1537,10 @@
"title": "Map vacuum segments to areas",
"no_segments": "No segments available",
"area_label": "Area",
"description": "Configure which areas correspond to each vacuum segment"
"description": "Configure which areas correspond to each vacuum segment",
"orphaned_header": "Segments no longer on the vacuum",
"orphaned_description": "These areas were mapped to segments the vacuum no longer reports. Remove them to keep the mapping up to date.",
"orphaned_remove": "Remove mapping for {name}"
},
"codemirror": {
"open_documentation": "Open documentation"
@@ -3,7 +3,12 @@ import {
computeEntityNameDisplay,
computeEntityNameDisplayWithoutContext,
computeEntityNameList,
computeEntitySearchLabels,
DEFAULT_ENTITY_NAME,
type EntityNameItem,
} from "../../../src/common/entity/compute_entity_name_display";
import type { DeviceRegistryEntry } from "../../../src/data/device/device_registry";
import type { EntityRegistryDisplayEntry } from "../../../src/data/entity/entity_registry";
import type { HomeAssistant } from "../../../src/types";
import {
mockArea,
@@ -381,6 +386,117 @@ describe("computeEntityNameDisplay", () => {
});
});
describe("name context", () => {
const areas = {
kitchen: mockArea({ area_id: "kitchen", name: "Kitchen" }),
garage: mockArea({ area_id: "garage", name: "Garage" }),
};
const powerStrip = mockDevice({
id: "strip",
name: "Power strip",
area_id: "kitchen",
next_name_part: "area",
});
const stateObj = mockStateObj({ entity_id: "switch.freezer" });
const chain: EntityNameItem[] = [
{ type: "area" },
{ type: "parent_device" },
{ type: "device" },
{ type: "entity" },
];
const registries = (
entity: Partial<EntityRegistryDisplayEntry>,
freezer: Partial<DeviceRegistryEntry>
) => ({
entities: {
"switch.freezer": mockEntity({
entity_id: "switch.freezer",
device_id: "freezer",
...entity,
}),
},
devices: {
strip: powerStrip,
freezer: mockDevice({
id: "freezer",
name: "Freezer",
parent_device_id: "strip",
...freezer,
}),
},
});
const joinedNameList = (
{ entities, devices }: ReturnType<typeof registries>,
name: EntityNameItem[] = chain
) =>
computeEntityNameList(stateObj, name, entities, devices, areas, {})
.filter(Boolean)
.join(" ");
it("leaves out the parent device when the child device has its own area", () => {
const result = joinedNameList(
registries(
{ name: "Power", next_name_part: "device" },
{ area_id: "garage", next_name_part: "area" }
)
);
expect(result).toBe("Garage Freezer Power");
});
it("leaves out the device and its parent when the entity has its own area", () => {
const result = joinedNameList(
registries(
{ name: "Power", area_id: "garage", next_name_part: "area" },
{ next_name_part: "parent_device" }
)
);
expect(result).toBe("Garage Power");
});
it("keeps the device name for an entity without a name of its own", () => {
const result = joinedNameList(
registries(
{ area_id: "garage", next_name_part: "area" },
{ next_name_part: "parent_device" }
),
DEFAULT_ENTITY_NAME
);
expect(result).toBe("Freezer");
});
it("keeps every configured part in the formatted name", () => {
const { entities, devices } = registries(
{ name: "Power", area_id: "garage", next_name_part: "area" },
{ next_name_part: "parent_device" }
);
expect(
computeEntityNameDisplay(stateObj, chain, entities, devices, areas, {})
).toBe("Garage Power strip Freezer Power");
});
it("keeps the owners left out of the name in the search labels", () => {
const { entities, devices } = registries(
{ name: "Power", area_id: "garage", next_name_part: "area" },
{ next_name_part: "parent_device" }
);
expect(
computeEntitySearchLabels(stateObj, entities, devices, areas, {})
).toMatchObject({
entityName: "Power",
deviceName: "Freezer",
parentDeviceName: "Power strip",
areaName: "Garage",
});
});
});
describe("computeEntityNameList", () => {
it("returns list of names for each item type", () => {
const stateObj = mockStateObj({ entity_id: "light.kitchen" });
+74
View File
@@ -0,0 +1,74 @@
import { describe, expect, it } from "vitest";
import type { EntityRegistryEntry } from "../../../src/data/entity/entity_registry";
import {
entityMapColor,
HOME_ZONE_ENTITY_ID,
zoneColor,
} from "../../../src/common/map/entity-map-colors";
// Palette slot N reads back as "color-N", so an index is visible in the result
const styles = {
getPropertyValue: (name: string) => name.replace("--", ""),
} as unknown as CSSStyleDeclaration;
const entry = (entityId: string, createdAt: number, id = entityId) =>
({
entity_id: entityId,
created_at: createdAt,
id,
}) as EntityRegistryEntry;
describe("entity map colors", () => {
it("colors zones, persons and trackers in one creation order", () => {
const entries = [
entry("zone.work", 40),
entry("zone.school", 10),
entry("person.anne", 20),
entry("device_tracker.phone", 30),
entry("light.kitchen", 25),
];
expect(entityMapColor("zone.school", entries, styles)).toBe("color-1");
// A person takes the next slot, never a zone's color
expect(entityMapColor("person.anne", entries, styles)).toBe("color-2");
expect(entityMapColor("device_tracker.phone", entries, styles)).toBe(
"color-3"
);
expect(entityMapColor("zone.work", entries, styles)).toBe("color-4");
});
it("breaks creation ties by registry id", () => {
const entries = [entry("zone.b", 10, "b"), entry("zone.a", 10, "a")];
expect(entityMapColor("zone.a", entries, styles)).toBe("color-1");
expect(entityMapColor("zone.b", entries, styles)).toBe("color-2");
});
it("keeps the home zone out of the palette", () => {
const entries = [entry(HOME_ZONE_ENTITY_ID, 10), entry("zone.work", 20)];
expect(entityMapColor("zone.work", entries, styles)).toBe("color-1");
expect(zoneColor(HOME_ZONE_ENTITY_ID, false, entries, styles)).toBe(
"primary-color"
);
});
it("mutes passive zones", () => {
const entries = [entry("zone.quiet", 10)];
expect(zoneColor("zone.quiet", true, entries, styles)).toBe(
"secondary-text-color"
);
expect(zoneColor("zone.quiet", false, entries, styles)).toBe("color-1");
});
it("gives entities outside the registry a stable color", () => {
const entries = [entry("zone.work", 10)];
const color = entityMapColor("zone.yaml_zone", entries, styles);
expect(entityMapColor("zone.yaml_zone", entries, styles)).toBe(color);
expect(entityMapColor("zone.other_yaml_zone", entries, styles)).not.toBe(
color
);
});
});
+6 -10
View File
@@ -1030,10 +1030,7 @@ describe("MapLibreMapEngine", () => {
expect(handle.radius).toBeCloseTo(110, 5);
element.dispatchEvent(new KeyboardEvent("keyup", { key: "ArrowUp" }));
// The host echoes the old radius once before its save returns
handle.update([52, 4], 100);
expect(handle.radius).toBeCloseTo(110, 5);
// A later identical update is a real change
// Once committed, the host is the truth again
handle.update([52, 4], 100);
expect(handle.radius).toBe(100);
});
@@ -1100,25 +1097,24 @@ describe("MapLibreMapEngine", () => {
expect(element.getAttribute("aria-valuenow")).toBe("150000");
});
it("ignores one update echoing the values from before a drag", async () => {
it("ignores host updates only while a drag is in progress", async () => {
const { engine, ready } = await createEngine();
await ready;
const { handle, center } = addCircle(engine);
center.lngLat = [4.01, 52];
center.fire("dragstart");
// Nothing moves while a drag is in progress
// The user's hand wins while dragging
handle.update([53, 5], 500);
expect(handle.center).toEqual([52, 4]);
center.fire("drag");
center.fire("dragend");
// The host saves and echoes the old values once; that is not a move back
handle.update([52, 4], 100);
expect(handle.center).toEqual([52, 4.01]);
// A later identical update is a real change
// Afterwards the host is the truth, even when it moves the circle back
handle.update([52, 4], 100);
expect(handle.center).toEqual([52, 4]);
expect(handle.radius).toBe(100);
});
it("activates the center by click, Enter and Space, but not after a drag", async () => {
+67
View File
@@ -1,11 +1,78 @@
import { describe, it, expect } from "vitest";
import type { HassEntity } from "home-assistant-js-websocket";
import {
localizeStateMessage,
localizeTriggerSource,
parseTriggerSource,
} from "../../src/data/logbook";
import type { HomeAssistant } from "../../src/types";
const fakeLocalize = ((key: string) => `<${key}>`) as any;
const fakeHass = {
localize: fakeLocalize,
formatEntityState: (_stateObj, state: string) => `<state:${state}>`,
} as unknown as HomeAssistant;
const fakeStateObj = {
entity_id: "button.restart",
state: "unknown",
attributes: {},
} as HassEntity;
describe("localizeStateMessage", () => {
it.each(["unknown", "unavailable"])(
"preserves the %s lifecycle state for timestamp entities",
(state) => {
expect(
localizeStateMessage(fakeHass, state, fakeStateObj, "button")
).toBe(`<state:${state}>`);
}
);
it("uses the action label for a timestamp state", () => {
expect(
localizeStateMessage(
fakeHass,
"2026-09-15T11:05:05+00:00",
fakeStateObj,
"button",
{
name: "Restart",
when: Date.parse("2026-09-15T11:05:05+00:00") / 1000,
}
)
).toBe("<ui.components.logbook.messages.pressed>");
});
it("does not treat a restored timestamp with inherited context as a new action", () => {
const restoredState = "2026-09-15T10:55:25+00:00";
expect(
localizeStateMessage(fakeHass, restoredState, fakeStateObj, "button", {
name: "Restart",
when: Date.parse("2026-09-15T11:05:05+00:00") / 1000,
context_domain: "homeassistant",
context_service: "reload_config_entry",
})
).toBe(`<state:${restoredState}>`);
});
it("keeps the action label for a producer timestamp", () => {
expect(
localizeStateMessage(
fakeHass,
"2026-09-15T10:55:25+00:00",
fakeStateObj,
"image",
{
name: "Satellite image",
when: Date.parse("2026-09-15T11:05:05+00:00") / 1000,
}
)
).toBe("<ui.components.logbook.messages.updated>");
});
});
describe("localizeTriggerSource", () => {
it("replaces a known phrase with the bare translation", () => {
expect(localizeTriggerSource(fakeLocalize, "Home Assistant starting")).toBe(
@@ -0,0 +1,119 @@
/**
* Protects the energy-distribution home ring from overflowing when hourly
* allocation yields more used_solar/used_battery/used_grid than net used_total
* (https://github.com/home-assistant/frontend/issues/54185).
*/
import { assert, describe, it } from "vitest";
import {
ENERGY_DISTRIBUTION_HOME_CIRCLE_CIRCUMFERENCE as CIRCLE,
computeEnergyDistributionHomeCircleArcs,
} from "../../../../../src/panels/lovelace/cards/energy/energy-distribution-home-circle";
const sumDefinedArcs = (
arcs: ReturnType<typeof computeEnergyDistributionHomeCircleArcs>
): number =>
(arcs.solar ?? 0) +
(arcs.battery ?? 0) +
(arcs.lowCarbon ?? 0) +
(arcs.grid ?? 0);
describe("computeEnergyDistributionHomeCircleArcs", () => {
it("sizes arcs from allocated home flows so they fill the circle", () => {
const arcs = computeEnergyDistributionHomeCircleArcs({
usedSolar: 8.5,
usedBattery: 4.6,
usedGrid: 0.12,
hasSolar: true,
hasGrid: true,
});
assert.approximately(arcs.solar!, CIRCLE * (8.5 / 13.22), 1e-6);
assert.approximately(arcs.battery!, CIRCLE * (4.6 / 13.22), 1e-6);
assert.approximately(arcs.grid!, CIRCLE * (0.12 / 13.22), 1e-6);
assert.approximately(sumDefinedArcs(arcs), CIRCLE, 1e-6);
assert.isAtLeast(arcs.grid!, 0);
});
it("does not paint a full grid ring when used_solar exceeds net home energy", () => {
// Screenshot totals from #54185 with solar and export in different hours:
// used_solar 77.1, used_battery 0, used_grid 0, net used_total 13.22.
const arcs = computeEnergyDistributionHomeCircleArcs({
usedSolar: 77.1,
usedBattery: 0,
usedGrid: 0,
hasSolar: true,
hasGrid: true,
});
assert.approximately(arcs.solar!, CIRCLE, 1e-6);
assert.isUndefined(arcs.battery);
assert.equal(arcs.grid, 0);
assert.approximately(sumDefinedArcs(arcs), CIRCLE, 1e-6);
});
it("keeps a zero-consumption ring from producing NaN or negative dashes", () => {
const arcs = computeEnergyDistributionHomeCircleArcs({
usedSolar: 0,
usedBattery: 0,
usedGrid: 0,
hasSolar: true,
hasGrid: true,
});
assert.deepEqual(arcs, {});
});
it("splits grid into low-carbon and high-carbon without exceeding the grid share", () => {
const arcs = computeEnergyDistributionHomeCircleArcs({
usedSolar: 4,
usedBattery: 0,
usedGrid: 6,
hasSolar: true,
hasGrid: true,
highCarbonConsumption: 2,
});
const ringTotal = 10;
assert.approximately(arcs.solar!, CIRCLE * (4 / ringTotal), 1e-6);
assert.approximately(arcs.lowCarbon!, CIRCLE * (4 / ringTotal), 1e-6);
assert.approximately(arcs.grid!, CIRCLE * (2 / ringTotal), 1e-6);
assert.approximately(sumDefinedArcs(arcs), CIRCLE, 1e-6);
assert.isAtLeast(arcs.lowCarbon!, 0);
assert.isAtLeast(arcs.grid!, 0);
});
it("clamps high-carbon consumption to the grid share", () => {
const arcs = computeEnergyDistributionHomeCircleArcs({
usedSolar: 5,
usedBattery: 0,
usedGrid: 5,
hasSolar: true,
hasGrid: true,
highCarbonConsumption: 50,
});
assert.equal(arcs.lowCarbon, 0);
assert.approximately(arcs.grid!, CIRCLE * 0.5, 1e-6);
assert.approximately(sumDefinedArcs(arcs), CIRCLE, 1e-6);
});
it("keeps a defined zero low-carbon arc so a grid-only high-carbon ring still renders", () => {
// The card only mounts the home SVG when solar or lowCarbon is defined.
// Omitting 0 would drop battery/grid arcs in a no-solar 100% fossil grid.
const arcs = computeEnergyDistributionHomeCircleArcs({
usedSolar: 0,
usedBattery: 4,
usedGrid: 6,
hasSolar: false,
hasGrid: true,
highCarbonConsumption: 6,
});
assert.isUndefined(arcs.solar);
assert.equal(arcs.lowCarbon, 0);
assert.approximately(arcs.battery!, CIRCLE * 0.4, 1e-6);
assert.approximately(arcs.grid!, CIRCLE * 0.6, 1e-6);
assert.approximately(sumDefinedArcs(arcs), CIRCLE, 1e-6);
});
});