Compare commits

...
Author SHA1 Message Date
Bram Kragten 025db7ffe7 Bumped version to 20260930.1 2026-10-05 11:02:45 +02:00
Paulus SchoutsenandClaude f9c958c80c Hide MCP URL hint until the server is enabled (#54532)
When the MCP server is not set up, the card has no URLs to show.
The description told the user to add one of the URLs below. Show
only the documentation link until the server is enabled.


Claude-Session: https://claude.ai/code/session_01MeyiXPWWRNRpD2XHVrtPSr

Co-authored-by: Claude <[email protected]>
2026-10-05 11:02:26 +02:00
Jan-Philipp Benecke 652897a3cb Handle undefined trigger condition IDs in rendering (#54527)
* Handle undefined trigger condition IDs in rendering

* Handle trigger conditions without ID in editor and updates
2026-10-05 11:02:25 +02:00
Maarten Lakerveld 3426449315 Keep padded map fits within the poles (#54526)
The MapLibre engine pads a fit by a fraction of its span. A zone whose
circle reaches a pole, or markers far apart, pushed the padded
latitude past 90, and MapLibre throws on such bounds, so the fit never
happened and the console filled with errors. The padded latitude is
now clamped to the poles.

Fixes #54498
2026-10-05 11:02:24 +02:00
Jan-Philipp Benecke 8e19b0249d Fix misplaced trace graph icons on iOS (#54520) 2026-10-05 11:02:23 +02:00
Paul Bottein bba034eeca Show entered and left in map zone activity (#54516)
* Show arrived and left in map zone activity

* Use entered instead of arrived in map zone activity
2026-10-05 11:02:21 +02:00
karwosts 31f2767038 Remove spurious color clear button (#54515)
* Improve number selectors in section settings

* Fix sections editor default color
2026-10-05 11:00:21 +02:00
Petar Petrov e0571044ae Detect the ESPHome Z-Wave discovery flow by the device MAC (#54513) 2026-10-05 10:57:27 +02:00
Maarten Lakerveld 0f00286641 Fix map overview tabs, activity timeline, cluster counts, zone fit and the new-zone route (#54512)
* Show four avatars in a cluster bubble before counting

A bubble capped at three avatars added a +1 chip for the fourth
member, taking a fourth slot to hide one avatar. Four members now show
as four avatars; from five on, three avatars and a count fill the same
four slots.

* Open the new zone dialog on every visit to /config/zone/new

The route was only handled in firstUpdated. The config panel is kept
alive between visits and the router reuses the zone page when it is
already the current one, so "Create zone from current location" did
nothing once the zone list had been opened earlier in the session. The
route is now handled on every route change, like /edit/ already was.

The dialog also opened before the more-info dialog it was launched
from had finished leaving the history, and that departure closed the
new dialog again. The page now awaits the replace navigation, which
waits for closing dialogs, before opening it.

* Hide the map overview while the dashboard is being edited

In panel view edit mode the map card kept rendering its overview. On
phones that is a bottom sheet fixed over the bottom of the screen,
which covered the card's edit bar and the view's add button, so the
map dashboard could not be edited in portrait. The overview is for
using the map, not configuring it, so it is left out while editing.

* Show only the overview tabs that have something to list

People and Zones were always offered, with an empty message when the
map card's entities held none of them; only Devices was hidden when
empty. All three now appear only with entries, the first remaining tab
takes over when the selected one empties, and the overview tells the
card so its marker filter follows. The empty-tab messages are gone
with the empty tabs.

* List the person of the current user first, as "Me"

The overview's People tab sorted everyone alike. The person linked to
the user looking at the map now comes first and is named "Me" in the
list and the detail header, through a localized string. Their initials
still come from their real name.

* Draw the overview activity as a timeline

The activity list reused logbook rows: the person's name on every row,
the state pushed to the right, dividers between rows. It is now a
timeline of its own. Each row names where the person went, with the
clock time and how long ago underneath, hung off a dot in the zone's
color with a rail to the next row. A zone's rows name who came or
left and where they are. Five rows show at first, with a "Show more"
button for the rest. The overview no longer needs the broad hass
object, which only the logbook row took.

* Fit the map to its zones when they are all it shows

Zones only joined the auto-fit with the fit_zones option. The panel
map does not set it, so on the Zones tab, with no people or devices
left, the fit found nothing and jumped straight to the home
coordinates at the default zoom. Zones now stand in whenever nothing
else is on the map, which goes through the animated fit like the
other tabs. With fit_zones they still join a fit of people and devices.
2026-10-05 10:57:26 +02:00
Petar Petrov 9cc166d3cf Fix double side padding around more-info history and activity (#54511) 2026-10-05 10:57:25 +02:00
Bram Kragten 3ad1ed5aa6 Resolve split devices in the logbook card (#54507) 2026-10-05 10:57:24 +02:00
Wendelin 8ee84c2462 Use optional short issue title localization to repairs dialog (#54506)
Add short issue title localization to repairs dialog
2026-10-05 10:57:22 +02:00
Paul Bottein eca108032f Fix area switch for entities without their own name (#54503) 2026-10-05 10:57:21 +02:00
Paul Bottein 3bfe67f784 Give zones their own map colors and prefer white zone icons (#54499)
* Give zones their own map color sequence

* Prefer white zone icons when contrast allows
2026-10-05 10:57:20 +02:00
Paul Bottein c27191de6b Show people and zones on the demo map (#54496) 2026-10-05 10:57:19 +02:00
Bram Kragten 31f626c877 Bump saturation of the default light mode map (#54492)
bump saturation of the default light mode map
2026-10-05 10:57:17 +02:00
Tom Mittelstädtand1358 b081db7020 Give tentative calendar events a striped background (#54483)
* Give tentative calendar events a striped background

A tentative bar gets a light fill in its calendar color with diagonal stripes in the text color, instead of the outline alone. A tentative dot keeps its color and size and gets stripes in the calendar's contrast color. The stripes set a tentative event apart from past days, which are already drawn faded. They repeat on whole pixels, so every stripe looks the same.

* Strengthen tentative dot stripes and add a forced-colors fallback

The stripes on a tentative dot are its only cue, so they now reach at least 3:1 against the dot. In forced colors the stripes and the dot's fill are dropped, so a tentative bar falls back to a dashed border and a tentative dot to a ring of the same size.

---------

Co-authored-by: 1358 <[email protected]>
2026-10-05 10:57:16 +02:00
37 changed files with 1227 additions and 378 deletions
+2
View File
@@ -29,6 +29,7 @@ import { mockLabelRegistry } from "./stubs/label_registry";
import { mockIcons } from "./stubs/icons";
import { mockHistory } from "./stubs/history";
import { mockLovelace } from "./stubs/lovelace";
import { zoneRegistryEntries } from "./stubs/map";
import { mockMediaPlayer } from "./stubs/media_player";
import { mockPersistentNotification } from "./stubs/persistent_notification";
import { mockRecorder } from "./stubs/recorder";
@@ -181,6 +182,7 @@ export class HaDemo extends HomeAssistantAppEl {
modified_at: 0,
},
...connectivityEntityRegistryEntries,
...zoneRegistryEntries,
]);
hass.addEntities(energyEntities());
-50
View File
@@ -1,53 +1,3 @@
export const mapEntities = () =>
Object.values({
"zone.home": {
entity_id: "zone.home",
state: "zoning",
attributes: {
hidden: true,
latitude: 52.3631339,
longitude: 4.8903147,
radius: 200,
friendly_name: "Home",
icon: "hademo:home",
},
},
"zone.uva": {
entity_id: "zone.buckhead",
state: "zoning",
attributes: {
hidden: true,
radius: 400,
friendly_name: "UvA",
icon: "hademo:school",
latitude: 52.3558182,
longitude: 4.9535376,
},
},
"person.arsaboo": {
entity_id: "person.arsaboo",
state: "not_home",
attributes: {
radius: 50,
friendly_name: "Arsaboo",
latitude: 52.3579946,
longitude: 4.8664597,
entity_picture: "/assets/arsaboo/images/arsaboo.jpg",
},
},
"person.melody": {
entity_id: "person.melody",
state: "not_home",
attributes: {
radius: 50,
friendly_name: "Melody",
latitude: 52.3408927,
longitude: 4.8711073,
entity_picture: "/assets/arsaboo/images/melody.jpg",
},
},
});
export const energyEntities = () =>
Object.values({
"sensor.grid_fossil_fuel_percentage": {
+2 -2
View File
@@ -9,7 +9,7 @@ import {
import "../custom-cards/cast-demo-row";
import "../custom-cards/ha-demo-card";
import "../custom-cards/ha-demo-next-card";
import { mapEntities } from "./entities";
import { mockMap } from "./map";
export const mockLovelace = (
hass: MockHomeAssistant,
@@ -17,7 +17,7 @@ export const mockLovelace = (
) => {
hass.mockWS("lovelace/config", ({ url_path }) => {
if (url_path === "map") {
hass.addEntities(mapEntities());
mockMap(hass);
return {
strategy: {
type: "map",
+243
View File
@@ -0,0 +1,243 @@
import type { EntityRegistryEntry } from "../../../src/data/entity/entity_registry";
import type { EntityHistoryState } from "../../../src/data/history";
import type { Zone } from "../../../src/data/zone";
import { demoConfig } from "../../../src/fake_data/demo_config";
import type { EntityInput } from "../../../src/fake_data/entities/types";
import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
const HOUR = 3600 * 1000;
type ZoneData = Omit<Zone, "id">;
const HOME: ZoneData = {
name: "Home",
icon: "mdi:home",
latitude: demoConfig.latitude,
longitude: demoConfig.longitude,
radius: 100,
passive: false,
};
export const zones: Zone[] = [
{
id: "gym",
name: "Gym",
icon: "mdi:dumbbell",
latitude: 52.358,
longitude: 4.8686,
radius: 150,
passive: false,
},
{
id: "school",
name: "School",
icon: "mdi:school",
latitude: 52.3558182,
longitude: 4.9535376,
radius: 250,
passive: false,
},
{
id: "supermarket",
name: "Supermarket",
icon: "mdi:cart",
latitude: 52.3793,
longitude: 4.8994,
radius: 100,
passive: false,
},
{
id: "work",
name: "Work",
icon: "mdi:briefcase",
latitude: 52.3909184,
longitude: 4.8530821,
radius: 200,
passive: false,
},
];
interface DemoPerson {
id: string;
name: string;
picture: string;
latitude: number;
longitude: number;
// Hours ago and the state entered then, oldest first. The first entry is
// the state before the 24 hour activity window, the last one the current
// state.
timeline: [number, string][];
}
const PEOPLE: DemoPerson[] = [
{
id: "arsaboo",
name: "Arsaboo",
picture: "/assets/arsaboo/images/arsaboo.jpg",
latitude: 52.3732,
longitude: 4.8904,
timeline: [
[30, "home"],
[10, "not_home"],
[9.5, "Work"],
[5, "not_home"],
[4.6, "Gym"],
[3.4, "not_home"],
[3.1, "Supermarket"],
[2.8, "not_home"],
[2.4, "home"],
],
},
{
id: "melody",
name: "Melody",
picture: "/assets/arsaboo/images/melody.jpg",
latitude: 52.373,
longitude: 4.8901,
timeline: [
[30, "home"],
[9, "not_home"],
[8.6, "School"],
[4, "not_home"],
[3.6, "home"],
],
},
{
id: "oscar",
name: "Oscar",
picture: "/assets/kernehed/oscar.jpg",
latitude: 52.391,
longitude: 4.8532,
timeline: [
[30, "home"],
[9, "not_home"],
[8.7, "Work"],
[4.5, "not_home"],
[4.2, "Supermarket"],
[3.8, "not_home"],
[3.5, "Work"],
],
},
{
id: "bella",
name: "Bella",
picture: "/assets/kernehed/bella.jpg",
latitude: 52.365,
longitude: 4.8795,
timeline: [
[30, "home"],
[8, "not_home"],
[7.6, "Work"],
[1.2, "not_home"],
[0.9, "Gym"],
[0.3, "not_home"],
],
},
];
const CAR: EntityInput = {
entity_id: "device_tracker.car",
state: "not_home",
attributes: {
friendly_name: "Car",
icon: "mdi:car",
source_type: "gps",
latitude: 52.3805,
longitude: 4.8718,
gps_accuracy: 10,
},
};
const lastMove = (person: DemoPerson) =>
person.timeline[person.timeline.length - 1];
const zoneEntity = (entityId: string, zone: ZoneData): EntityInput => {
// A person's state is "home" for the home zone, the zone name otherwise
const personState = entityId === "zone.home" ? "home" : zone.name;
const persons = PEOPLE.filter(
(person) => lastMove(person)[1] === personState
).map((person) => `person.${person.id}`);
return {
entity_id: entityId,
state: String(persons.length),
attributes: {
latitude: zone.latitude,
longitude: zone.longitude,
radius: zone.radius,
passive: zone.passive,
persons,
editable: true,
icon: zone.icon,
friendly_name: zone.name,
},
};
};
export const zoneEntities = (): EntityInput[] => [
zoneEntity("zone.home", HOME),
...zones.map((zone) => zoneEntity(`zone.${zone.id}`, zone)),
];
// UI zones are in the entity registry, so the zone editor does not also list
// them as YAML zones
export const zoneRegistryEntries: EntityRegistryEntry[] = zones.map((zone) => ({
config_entry_id: null,
config_subentry_id: null,
device_id: null,
area_id: null,
disabled_by: null,
entity_id: `zone.${zone.id}`,
id: `zone.${zone.id}`,
name: null,
icon: null,
labels: [],
categories: {},
platform: "zone",
hidden_by: null,
entity_category: null,
has_entity_name: false,
unique_id: zone.id,
options: null,
created_at: 0,
modified_at: 0,
}));
const personEntity = (person: DemoPerson, now: number): EntityInput => {
const [hoursAgo, state] = lastMove(person);
return {
entity_id: `person.${person.id}`,
state,
last_changed: new Date(now - hoursAgo * HOUR).toISOString(),
attributes: {
friendly_name: person.name,
entity_picture: person.picture,
latitude: person.latitude,
longitude: person.longitude,
gps_accuracy: 20,
},
};
};
const personHistory = (person: DemoPerson, now: number): EntityHistoryState[] =>
person.timeline.map(([hoursAgo, state]) => {
const time = (now - hoursAgo * HOUR) / 1000;
return { s: state, a: {}, lu: time, lc: time };
});
export const mockMap = (hass: MockHomeAssistant) => {
const now = Date.now();
hass.addEntities([
...zoneEntities(),
...PEOPLE.map((person) => personEntity(person, now)),
CAR,
]);
hass.mockWS(
"history/history_during_period",
({ entity_ids }: { entity_ids?: string[] }) =>
Object.fromEntries(
PEOPLE.filter(
(person) => !entity_ids || entity_ids.includes(`person.${person.id}`)
).map((person) => [`person.${person.id}`, personHistory(person, now)])
)
);
};
+2 -22
View File
@@ -1,27 +1,7 @@
import type { Zone } from "../../../src/data/zone";
import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
const zones: Zone[] = [
{
id: "home",
name: "Home",
icon: "mdi:home",
latitude: 52.3731339,
longitude: 4.8903147,
radius: 100,
passive: false,
},
{
id: "work",
name: "Work",
icon: "mdi:briefcase",
latitude: 52.3909184,
longitude: 4.8530821,
radius: 200,
passive: false,
},
];
import { zoneEntities, zones } from "./map";
export const mockZone = (hass: MockHomeAssistant) => {
hass.mockWS("zone/list", () => zones);
hass.addEntities(zoneEntities());
};
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "home-assistant-frontend"
version = "20260930.0"
version = "20260930.1"
license = "Apache-2.0"
license-files = ["LICENSE*"]
description = "The Home Assistant frontend"
@@ -621,10 +621,11 @@ export class MapLibreMapEngine implements MapEngine {
const pad = options?.pad ?? 0.5;
const latPad = (maxLat - minLat) * pad;
const lngPad = (maxLng - minLng) * pad;
// MapLibre rejects a latitude beyond the poles
return {
bounds: [
[minLng - lngPad, minLat - latPad],
[maxLng + lngPad, maxLat + latPad],
[minLng - lngPad, Math.max(-90, minLat - latPad)],
[maxLng + lngPad, Math.min(90, maxLat + latPad)],
] as [[number, number], [number, number]],
options: { maxZoom, padding },
};
+33 -33
View File
@@ -4,39 +4,42 @@ 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.
* Map colors for entities, from their place in the registry, so an entity has
* the same color on every map. Zones have their own sequence. Persons and
* trackers share another, persons first, so trackers discovered over time
* shift neither. 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"];
const byCreation = (a: EntityRegistryEntry, b: EntityRegistryEntry) =>
a.created_at - b.created_at || a.id.localeCompare(b.id);
// 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;
}
);
const domainEntries = (entries: EntityRegistryEntry[], domain: string) =>
entries
.filter((entry) => computeDomain(entry.entity_id) === domain)
.sort(byCreation);
// For entities without a registry entry
const paletteIndex = memoizeOne((entries: EntityRegistryEntry[]) => {
// The home zone has a fixed color and does not take a palette slot
const zones = domainEntries(entries, "zone").filter(
(entry) => entry.entity_id !== HOME_ZONE_ENTITY_ID
);
const personsAndTrackers = [
...domainEntries(entries, "person"),
...domainEntries(entries, "device_tracker"),
];
const byEntityId: Record<string, number> = {};
[zones, personsAndTrackers].forEach((sequence) =>
sequence.forEach((entry, i) => {
byEntityId[entry.entity_id] = i;
})
);
return { byEntityId, zoneCount: zones.length };
});
// For entities outside both sequences
const hashIndex = (entityId: string): number => {
let hash = 5381;
for (let i = 0; i < entityId.length; i++) {
@@ -52,11 +55,11 @@ export const entityMapColor = (
computedStyles: CSSStyleDeclaration
): string =>
getColorByIndex(
creationIndex(entries)[entityId] ?? hashIndex(entityId),
paletteIndex(entries).byEntityId[entityId] ?? hashIndex(entityId),
computedStyles
);
/** The color a new zone will take once created, from the next creation-order slot */
/** The color a new zone will take once created, from the slot after the last zone */
export const nextZoneColor = (
passive: boolean,
entries: EntityRegistryEntry[],
@@ -65,10 +68,7 @@ export const nextZoneColor = (
if (passive) {
return computedStyles.getPropertyValue("--secondary-text-color");
}
return getColorByIndex(
Object.keys(creationIndex(entries)).length,
computedStyles
);
return getColorByIndex(paletteIndex(entries).zoneCount, computedStyles);
};
/** A zone's color: primary for home, muted for passive, its entity map color otherwise */
+38 -38
View File
@@ -8,46 +8,46 @@
/** Drawn on the light palette */
export const HA_MAP_COLORS = {
background: "#f4efe6",
land: "#f4efe6",
water: "#bcd9e8",
labelWater: "#5b86a3",
natureWood: "#cfe1bf",
natureGrass: "#e2ecda",
naturePark: "#cfe1bf",
natureLeisure: "#e2ecda",
natureAgriculture: "#e8eee2",
natureWetland: "#e5ebe0",
natureSand: "#f1eadf",
natureRock: "#f1eadf",
glacier: "#f9f6f1",
areaResidential: "#efe8dc",
areaCommercial: "#f4e1db",
areaIndustrial: "#ede5ce",
areaWaste: "#ece3d5",
areaBurial: "#efe8dc",
siteParking: "#ede6d8",
siteSports: "#cfe1bf26",
building: "#e7ddca",
buildingBg: "#dcccb2",
background: "#f5efe5",
land: "#f5efe5",
water: "#b8daec",
labelWater: "#5487aa",
natureWood: "#cfe4bc",
natureGrass: "#e2eed8",
naturePark: "#cfe4bc",
natureLeisure: "#e2eed8",
natureAgriculture: "#e8efe1",
natureWetland: "#e5ecdf",
natureSand: "#f3eadd",
natureRock: "#f3eadd",
glacier: "#faf6f0",
areaResidential: "#f1e9da",
areaCommercial: "#f7e0d8",
areaIndustrial: "#f0e7cb",
areaWaste: "#eee3d3",
areaBurial: "#f1e9da",
siteParking: "#efe7d6",
siteSports: "#cfe4bc26",
building: "#eadec7",
buildingBg: "#e0cdae",
roadStreet: "#fcfaf8",
roadStreetBg: "#e6dac7",
roadTrunk: "#fbe6c2",
roadTrunkBg: "#e5cea5",
roadMotorway: "#ebd4ab",
roadMotorwayBg: "#deba78",
transitRail: "#e9dfce",
transitSubway: "#eae1d1",
transitCycle: "#f2ede3",
transitFoot: "#f2ede3",
boundary: "#d6c3a4",
boundaryDisputed: "#e2d6c0",
label: "#4a463d",
roadStreetBg: "#e9dbc4",
roadTrunk: "#ffe7be",
roadTrunkBg: "#ebd09f",
roadMotorway: "#f1d6a5",
roadMotorwayBg: "#e8bd6e",
transitRail: "#ece0cb",
transitSubway: "#ece2cf",
transitCycle: "#f3eee2",
transitFoot: "#f3eee2",
boundary: "#dbc49f",
boundaryDisputed: "#e5d7bd",
label: "#4b473c",
labelHalo: "#faf8f5cc",
labelSymbol: "#8d8676",
labelPoi: "#8d867666",
labelShield: "#faf8f4",
labelHousenumber: "#8d86764d",
labelSymbol: "#8f8774",
labelPoi: "#8f877466",
labelShield: "#fbf8f3",
labelHousenumber: "#8f87744d",
};
/** Drawn on the dark palette, in full: a light value left standing here would
+9 -3
View File
@@ -1,13 +1,19 @@
import { getContrastedColorHex } from "../color/rgb";
import { wcagContrast } from "culori";
import { theme2hex } from "../color/convert-color";
/** 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
// WCAG minimum contrast for icons and other graphics
const MIN_CONTENT_CONTRAST = 3;
// White content while it contrasts enough with the fill, black otherwise;
// not every theme color parses
export const contrastingZoneContent = (color: string): string => {
try {
return getContrastedColorHex(color.trim());
const whiteContrast = wcagContrast(theme2hex(color.trim()), "#ffffff");
return whiteContrast >= MIN_CONTENT_CONTRAST ? "#ffffff" : "#000000";
} catch {
return "#ffffff";
}
+12 -8
View File
@@ -259,7 +259,8 @@ const CLUSTER_BUBBLE_PADDING = 6;
const CLUSTER_BUBBLE_GAP = 4;
// Space an opened bubble keeps from the map edges
const CLUSTER_BUBBLE_MARGIN = 12;
const CLUSTER_MAX_AVATARS = 3;
// Beyond this the last slot becomes a count
const CLUSTER_MAX_AVATARS = 4;
const CLUSTER_MORE_WIDTH = 28;
const CLUSTER_MORE_MAX = 99;
const CLUSTER_TAIL_SIZE = 10;
@@ -836,9 +837,12 @@ export class HaMap extends ReactiveElement {
return;
}
// Zones join the fit when asked, or when they are all there is
const zonePoints =
this.fitZones || !this._focusPoints.length ? this._focusZonePoints : [];
if (
!this._focusPoints.length &&
!this._focusZonePoints.length &&
!zonePoints.length &&
!this.editableLocations?.length
) {
this._withProgrammaticFit(() => {
@@ -851,7 +855,7 @@ export class HaMap extends ReactiveElement {
return;
}
const points = [...this._focusPoints, ...this._focusZonePoints];
const points = [...this._focusPoints, ...zonePoints];
// Editable locations contribute their bounds, radius included
this.editableLocations?.forEach((editable) => {
@@ -1349,10 +1353,7 @@ export class HaMap extends ReactiveElement {
})
);
if (
this.fitZones &&
(typeof entity === "string" || entity.focus !== false)
) {
if (typeof entity === "string" || entity.focus !== false) {
if (!hideRadius && radius) {
this._focusZonePoints.push(...circleBoundsPoints(position, radius));
} else {
@@ -1493,7 +1494,10 @@ export class HaMap extends ReactiveElement {
expanded = false
): MapClusterIcon => {
const data = members.map((member) => member.clusterData as ClusterData);
const shown = expanded ? data : data.slice(0, CLUSTER_MAX_AVATARS);
const shown =
expanded || data.length <= CLUSTER_MAX_AVATARS
? data
: data.slice(0, CLUSTER_MAX_AVATARS - 1);
const hidden = data.length - shown.length;
// With history trails shown, colored borders match avatars to trails
+26 -27
View File
@@ -3,6 +3,7 @@ import type { PropertyValues, TemplateResult } from "lit";
import { LitElement, css, html, nothing, svg } from "lit";
import { customElement, property } from "lit/decorators";
import { isSafari } from "../../util/is_safari";
import "../ha-svg-icon";
import {
BUILDING_BLOCK_ICON_SIZE,
BUILDING_BLOCK_RADIUS,
@@ -66,17 +67,17 @@ export class HatGraphNode extends LitElement {
const width = SPACING + NODE_SIZE;
const size = this.buildingBlock ? BUILDING_BLOCK_SIZE : NODE_SIZE;
const iconSize = this.buildingBlock ? BUILDING_BLOCK_ICON_SIZE : ICON_SIZE;
// Distance from the top of the svg to the node center (viewBox origin).
const centerY = this.graphStart
? Math.ceil(height / 2)
: Math.ceil((NODE_SIZE + SPACING * 2) / 2);
// A rotated building block is wider than its side.
return html`
<svg
class=${isSafari ? "safari" : ""}
width="${width}px"
height="${height}px"
viewBox="-${Math.ceil(width / 2)} -${
this.graphStart
? Math.ceil(height / 2)
: Math.ceil((NODE_SIZE + SPACING * 2) / 2)
} ${width} ${height}"
viewBox="-${Math.ceil(width / 2)} -${centerY} ${width} ${height}"
>
${
this.graphStart
@@ -134,19 +135,6 @@ export class HatGraphNode extends LitElement {
`
: nothing
}
<g
class="icon-wrapper"
style="pointer-events: none"
transform="translate(-${iconSize / 2} -${iconSize / 2}) scale(${
iconSize / ICON_SIZE
})"
>
${
this.iconPath
? svg`<path class="icon" d=${this.iconPath}/>`
: svg`<foreignObject><span class="icon"><slot name="icon"></slot></span></foreignObject>`
}
</g>
${
this.notEnabled
? svg`
@@ -162,6 +150,15 @@ export class HatGraphNode extends LitElement {
}
</g>
</svg>
<span class="icon" style="top: ${centerY}px">
<slot name="icon">
${
this.iconPath
? html`<ha-svg-icon .path=${this.iconPath}></ha-svg-icon>`
: nothing
}
</slot>
</span>
`;
}
@@ -169,12 +166,12 @@ export class HatGraphNode extends LitElement {
:host {
display: flex;
flex-direction: column;
position: relative;
min-width: calc(var(--hat-graph-node-size) + var(--hat-graph-spacing));
height: calc(var(--hat-graph-node-size) + var(--hat-graph-spacing) + 1px);
}
/* The count badge overlaps the next node's connector. */
:host([badge]) {
position: relative;
z-index: 1;
}
:host([graph-start]) {
@@ -219,7 +216,7 @@ export class HatGraphNode extends LitElement {
/* Not the whole node group: SVG composites group opacity as a unit, which
would fade the error and count badges along with the rest. The body is
left at full strength so the grey does not wash out. */
:host([not-enabled]) .icon-wrapper,
:host([not-enabled]) .icon,
:host([not-enabled]) .strike {
opacity: 0.6;
}
@@ -279,15 +276,17 @@ export class HatGraphNode extends LitElement {
font-size: var(--ha-font-size-s);
fill: var(--text-primary-color);
}
path.icon {
fill: var(--icon-clr);
}
foreignObject {
width: 24px;
height: 24px;
}
.icon {
position: absolute;
left: 50%;
transform: translate(-50%, -50%);
display: flex;
pointer-events: none;
color: var(--icon-clr);
--mdc-icon-size: 24px;
}
:host([building-block]) .icon {
--mdc-icon-size: 20px;
}
`;
}
+31 -1
View File
@@ -7,6 +7,7 @@ import {
import { computeDomain } from "../common/entity/compute_domain";
import { computeObjectId } from "../common/entity/compute_object_id";
import type { LocalizeKeys } from "../common/translations/localize";
import type { DataEntryFlowProgress } from "./data_entry_flow";
import type { ESPHomeDeviceCapabilities, ESPHomeSerialProxy } from "./esphome";
import type { ESPHomeFrontendUserData } from "./frontend";
import type { SerialPortUsage } from "./usb";
@@ -178,6 +179,7 @@ export const deriveESPHomeSetupStatus = (
sendspinSupported?: boolean;
sendspinEnabled?: boolean;
serialConfigured?: boolean;
zwaveFlowInProgress?: boolean;
}
): ESPHomeSetupStatus => {
const status: ESPHomeSetupStatus = {};
@@ -199,7 +201,10 @@ export const deriveESPHomeSetupStatus = (
if (capabilities.zwave_proxy.supported) {
if (capabilities.zwave_proxy.config_entry_id) {
status.connectivity = "completed";
} else if (capabilities.zwave_proxy.home_id !== 0) {
} else if (
capabilities.zwave_proxy.home_id !== 0 ||
options.zwaveFlowInProgress
) {
status.connectivity = "detected";
} else {
status.connectivity = "not-started";
@@ -217,6 +222,31 @@ export const deriveESPHomeSetupStatus = (
return status;
};
// Core stores the device MAC through format_mac; the discovery key keeps the raw value.
const normalizeMac = (mac: string): string =>
mac.replace(/[^0-9a-f]/gi, "").toLowerCase();
/** The zwave_js discovery flow ESPHome started for this device, keyed by its MAC. */
export const findESPHomeZWaveFlow = (
flows: readonly DataEntryFlowProgress[],
macAddress: string | undefined
): DataEntryFlowProgress | undefined => {
const mac = macAddress ? normalizeMac(macAddress) : undefined;
if (!mac) {
return undefined;
}
return flows.find((flow) => {
const discoveryKey = flow.context.discovery_key;
return (
flow.handler === "zwave_js" &&
flow.context.source === "esphome" &&
discoveryKey?.domain === "esphome" &&
typeof discoveryKey.key === "string" &&
normalizeMac(discoveryKey.key) === mac
);
});
};
export const getESPHomeSetupCapabilityIds = (
status: ESPHomeSetupStatus
): ESPHomeCapabilityId[] =>
@@ -30,6 +30,7 @@ import {
configContext,
configEntriesContext,
connectionContext,
devicesContext,
entitiesContext,
internationalizationContext,
statesContext,
@@ -46,6 +47,7 @@ import {
ESPHOME_CAPABILITY_ACCENTS,
ESPHOME_CAPABILITY_ICONS,
ESPHOME_CAPABILITY_TITLE_KEYS,
findESPHomeZWaveFlow,
getESPHomeAudioControls,
getESPHomeSetupCapabilityIds,
isESPHomeSerialConfigured,
@@ -65,7 +67,7 @@ import { extractApiErrorMessage } from "../../data/hassio/common";
import { listSerialPortsWithUsage, type SerialPortUsage } from "../../data/usb";
import { showAddIntegrationDialog } from "../../panels/config/integrations/show-add-integration-dialog";
import { haStyle, haStyleDialog } from "../../resources/styles";
import type { HomeAssistantUI } from "../../types";
import type { HomeAssistant, HomeAssistantUI } from "../../types";
import { documentationUrl } from "../../util/documentation-url";
import { getWsErrorMessage } from "../../util/ws-error";
import { showConfigFlowDialog } from "../config-flow/show-dialog-config-flow";
@@ -159,6 +161,20 @@ class DialogESPHomeDeviceSetup extends DialogMixin<ESPHomeDeviceSetupDialogParam
@consume({ context: configEntriesContext, subscribe: true })
private _configEntries?: ContextType<typeof configEntriesContext>;
@state()
@consume({ context: devicesContext, subscribe: true })
@transform<HomeAssistant["devices"], string | undefined>({
transformer: function (this: DialogESPHomeDeviceSetup, devices) {
const deviceId = this.params?.deviceId;
return deviceId
? devices[deviceId]?.connections.find(([type]) => type === "mac")?.[1]
: undefined;
},
})
private _macAddress?: string;
@state() private _zwaveFlows: DataEntryFlowProgress[] = [];
@state() private _capabilities?: ESPHomeDeviceCapabilities;
/** Last successful USB usage scan. Undefined until a scan completes. */
@@ -738,6 +754,8 @@ class DialogESPHomeDeviceSetup extends DialogMixin<ESPHomeDeviceSetupDialogParam
this._capabilities.serial_proxies,
this._serialPorts
),
zwaveFlowInProgress:
findESPHomeZWaveFlow(this._zwaveFlows, this._macAddress) !== undefined,
});
// A failed scan left no usage data. Keep the row, but do not show
// Configured or Set up from that missing result.
@@ -766,6 +784,12 @@ class DialogESPHomeDeviceSetup extends DialogMixin<ESPHomeDeviceSetupDialogParam
return;
}
this._capabilities = capabilities;
if (capabilities.zwave_proxy.supported) {
await this._refreshZWaveFlows();
if (!this.isConnected) {
return;
}
}
await this._refreshSerialPorts();
if (!this.isConnected) {
return;
@@ -1098,12 +1122,25 @@ class DialogESPHomeDeviceSetup extends DialogMixin<ESPHomeDeviceSetupDialogParam
});
}
private async _refreshZWaveFlows() {
if (!this._connection) {
return;
}
try {
const flows = await fetchConfigFlowInProgress(
this._connection.connection
);
this._zwaveFlows = flows.filter((flow) => flow.handler === "zwave_js");
} catch {
// Keep the last list.
}
}
private async _setupZWave(ev: Event) {
ev.stopPropagation();
if (!this._connection || !this._capabilities || !this._i18n) {
return;
}
const homeId = String(this._capabilities.zwave_proxy.home_id);
let flows: DataEntryFlowProgress[];
try {
flows = await fetchConfigFlowInProgress(this._connection.connection);
@@ -1118,12 +1155,7 @@ class DialogESPHomeDeviceSetup extends DialogMixin<ESPHomeDeviceSetupDialogParam
if (!this.isConnected) {
return;
}
const flow = flows.find(
(item) =>
item.handler === "zwave_js" &&
item.context?.source === "esphome" &&
item.context?.unique_id === homeId
);
const flow = findESPHomeZWaveFlow(flows, this._macAddress);
if (!flow) {
this._error = this._i18n.localize(
"ui.panel.config.devices.esphome.setup_error_zwave"
@@ -152,6 +152,11 @@ export class MoreInfoInfo extends LitElement {
margin-bottom: var(--ha-space-4);
}
ha-more-info-history,
ha-more-info-logbook {
margin-inline: calc(var(--ha-space-6) * -1);
}
ha-alert {
display: block;
}
+2 -2
View File
@@ -56,8 +56,8 @@ export class MockBaseEntity {
this.areaId = input.area_id;
this.deviceId = input.device_id;
this.platform = input.platform;
this.lastChanged = randomTime();
this.lastUpdated = randomTime();
this.lastChanged = input.last_changed ?? randomTime();
this.lastUpdated = input.last_changed ?? randomTime();
const attributes: EntityAttributes = input.attributes || {};
+27
View File
@@ -0,0 +1,27 @@
import { MockBaseEntity } from "./base-entity";
import type { EntityAttributes } from "./types";
const LOCATION_ATTRIBUTES = [
"latitude",
"longitude",
"gps_accuracy",
"radius",
"passive",
"persons",
"editable",
"source",
"source_type",
"in_zones",
];
export class MockLocationEntity extends MockBaseEntity {
protected _getStateAttributes(): EntityAttributes {
const stateAttrs: EntityAttributes = {};
for (const key of LOCATION_ATTRIBUTES) {
if (key in this.attributes) {
stateAttrs[key] = this.attributes[key];
}
}
return stateAttrs;
}
}
+4
View File
@@ -11,6 +11,7 @@ import { MockLawnMowerEntity } from "./lawn-mower-entity";
import { MockInputSelectEntity } from "./input-select-entity";
import { MockInputTextEntity } from "./input-text-entity";
import { MockLightEntity } from "./light-entity";
import { MockLocationEntity } from "./location-entity";
import { MockLockEntity } from "./lock-entity";
import { MockMediaPlayerEntity } from "./media-player-entity";
import { MockToggleEntity } from "./toggle-entity";
@@ -25,6 +26,7 @@ const TYPES: Record<string, EntityConstructor> = {
alarm_control_panel: MockAlarmControlPanelEntity,
climate: MockClimateEntity,
cover: MockCoverEntity,
device_tracker: MockLocationEntity,
fan: MockFanEntity,
group: MockGroupEntity,
humidifier: MockHumidifierEntity,
@@ -36,10 +38,12 @@ const TYPES: Record<string, EntityConstructor> = {
light: MockLightEntity,
lock: MockLockEntity,
media_player: MockMediaPlayerEntity,
person: MockLocationEntity,
switch: MockToggleEntity,
vacuum: MockVacuumEntity,
valve: MockValveEntity,
water_heater: MockWaterHeaterEntity,
zone: MockLocationEntity,
};
export const getEntity = (input: EntityInput): MockBaseEntity => {
+2
View File
@@ -17,6 +17,8 @@ export type EntityInput = Pick<
device_id?: string;
/** Integration that provides the entity, defaults to "demo" */
platform?: string;
/** When the state last changed, a random recent time by default */
last_changed?: string;
};
/**
+76 -9
View File
@@ -363,6 +363,11 @@ export class HAFullCalendar extends LitElement {
config.dateClick = (info) => this._handleDateClick(info);
config.eventClick = (info) => this._handleEventClick(info);
// fullcalendar sets the event colors only inline, where styles cannot mix them
config.eventDidMount = ({ el, event }) => {
el.style.setProperty("--event-color", event.borderColor);
el.style.setProperty("--event-text-color", event.textColor);
};
this.calendar = new Calendar(
this.shadowRoot!.getElementById("calendar")!,
@@ -768,30 +773,92 @@ export class HAFullCalendar extends LitElement {
padding: 0 1px;
}
.tentative {
--tentative-tint: color-mix(
in srgb,
var(--event-color) 25%,
transparent
);
/* In the text color, so the stripes show on every calendar color */
--tentative-stripe: color-mix(
in srgb,
var(--primary-text-color) 12%,
transparent
);
}
/* A dot keeps its size and color and gets stripes in the color that
contrasts with it, 1.5px wide every 4px */
.tentative .fc-daygrid-event-dot,
.tentative .fc-list-event-dot {
--tentative-dot-stripe: color-mix(
in srgb,
var(--event-text-color, #fff) 80%,
transparent
);
border: none;
border-radius: 50%;
background-color: var(--event-color);
background-image: linear-gradient(
45deg,
var(--tentative-dot-stripe) 0 9.375%,
transparent 9.375% 40.625%,
var(--tentative-dot-stripe) 40.625% 59.375%,
transparent 59.375% 90.625%,
var(--tentative-dot-stripe) 90.625%
);
background-size: 4px 4px;
/* Half a tile, so no stripe runs through the middle like a slash */
background-position: 2px 0;
}
.tentative .fc-daygrid-event-dot {
/* A long title would otherwise squeeze the ring into an oval */
/* A long title would otherwise squeeze the dot into an oval */
flex-shrink: 0;
border-width: 2px;
width: calc(var(--fc-daygrid-event-dot-width, 8px) - 4px);
height: calc(var(--fc-daygrid-event-dot-width, 8px) - 4px);
width: var(--fc-daygrid-event-dot-width, 8px);
height: var(--fc-daygrid-event-dot-width, 8px);
}
.tentative .fc-list-event-dot {
border-width: 2px;
width: calc(var(--fc-list-event-dot-width, 10px) - 4px);
height: calc(var(--fc-list-event-dot-width, 10px) - 4px);
width: var(--fc-list-event-dot-width, 10px);
height: var(--fc-list-event-dot-width, 10px);
}
/* fullcalendar sets the colors as inline styles */
.fc-h-event.tentative {
background-color: transparent !important;
border-style: dashed;
background-color: var(--tentative-tint) !important;
/* A tile of whole pixels puts every stripe on the same pixels,
2.8px wide every 14px */
background-image: linear-gradient(
45deg,
var(--tentative-stripe) 0 5%,
transparent 5% 45%,
var(--tentative-stripe) 45% 55%,
transparent 55% 95%,
var(--tentative-stripe) 95%
);
background-size: 14px 14px;
}
.fc-h-event.tentative .fc-event-main {
color: var(--primary-text-color) !important;
}
/* Forced colors drop the stripes and the fill of a dot, so a tentative
event falls back to its outline */
@media (forced-colors: active) {
.fc-h-event.tentative {
border-style: dashed;
}
.tentative .fc-daygrid-event-dot,
.tentative .fc-list-event-dot {
box-sizing: border-box;
border-style: solid;
border-width: 2px;
}
}
.fc-day-past .fc-daygrid-day-events {
opacity: 0.5;
}
@@ -49,7 +49,7 @@ export class HaTriggerCondition extends LitElement {
}
protected render() {
const selectedIds = ensureArray(this.condition.id).filter(Boolean);
const selectedIds = (ensureArray(this.condition.id) ?? []).filter(Boolean);
const triggerIdOptions = this._triggers?.options ?? [];
const missingIds = this._missingIds(this.condition.id, triggerIdOptions);
@@ -174,7 +174,7 @@ export class HaTriggerCondition extends LitElement {
};
private _selectId(id: string, checked: boolean) {
const ids = ensureArray(this.condition.id).filter(Boolean);
const ids = (ensureArray(this.condition.id) ?? []).filter(Boolean);
const selectedIds = checked
? ids.includes(id)
? ids
@@ -202,7 +202,9 @@ export class HaTriggerCondition extends LitElement {
private _missingIds = memoizeOne(
(ids: TriggerCondition["id"], triggerIdOptions: TriggerIdOption[]) => {
const availableIds = new Set(triggerIdOptions.map((option) => option.id));
return ensureArray(ids).filter((id) => id && !availableIds.has(id));
return (ensureArray(ids) ?? []).filter(
(id) => id && !availableIds.has(id)
);
}
);
@@ -196,7 +196,7 @@ const mapReferencedTriggerIds = (
mapper: (id: string) => string | string[] | undefined
): TriggerCondition => {
let changed = false;
const ids = ensureArray(condition.id).flatMap((id) => {
const ids = (ensureArray(condition.id) ?? []).flatMap((id) => {
const mappedId = mapper(id);
if (mappedId !== id) {
changed = true;
@@ -259,8 +259,8 @@ class AutomationTriggerConditionMapper {
[key]: this._mapConditions((expanded as LogicalCondition).conditions),
};
}
if (condition.condition === "trigger" && "id" in condition) {
return this._update(condition);
if (condition.condition === "trigger") {
return this._update(condition as TriggerCondition);
}
if (
"conditions" in condition &&
@@ -34,7 +34,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 selectedIds = (ensureArray(this.condition.id) ?? []).filter(Boolean);
const selectedTriggers = options.filter((option) =>
selectedIds.includes(option.id)
);
+13 -8
View File
@@ -100,14 +100,19 @@ export class MCPPref extends LitElement {
</h1>
<div class="card-content">
<p>
${this.hass.localize("ui.panel.config.mcp.description", {
documentation_link: html`<a
href=${documentationUrl(this.hass, "/integrations/mcp_server/")}
target="_blank"
rel="noreferrer"
>${this.hass.localize("ui.panel.config.mcp.documentation")}</a
>`,
})}
${this.hass.localize(
enabled
? "ui.panel.config.mcp.description"
: "ui.panel.config.mcp.description_disabled",
{
documentation_link: html`<a
href=${documentationUrl(this.hass, "/integrations/mcp_server/")}
target="_blank"
rel="noreferrer"
>${this.hass.localize("ui.panel.config.mcp.documentation")}</a
>`,
}
)}
</p>
${
this._entry === undefined && this._error === undefined
@@ -255,6 +255,8 @@ export class EntityRegistrySettingsEditor extends LitElement {
@state() private _noDeviceArea?: boolean;
@state() private _ownAreaWithoutName = false;
private _origEntityId!: string;
private _deviceClassOptions?: string[][];
@@ -279,6 +281,7 @@ export class EntityRegistrySettingsEditor extends LitElement {
this.entry.device_class || this.entry.original_device_class;
this._origEntityId = this.entry.entity_id;
this._areaId = this.entry.area_id;
this._ownAreaWithoutName = this._useDeviceName && !!this.entry.area_id;
this._labels = this.entry.labels;
this._entityId = this.entry.entity_id;
this._disabledBy = this.entry.disabled_by;
@@ -1233,7 +1236,7 @@ export class EntityRegistrySettingsEditor extends LitElement {
>
<span slot="supporting-text"
>${this.hass.localize(
this._useDeviceArea
!this._hasOwnName
? "ui.dialogs.entity_registry.editor.use_device_area_required"
: "ui.dialogs.entity_registry.editor.change_device_settings",
{
@@ -1750,10 +1753,15 @@ export class EntityRegistrySettingsEditor extends LitElement {
private _useDeviceNameChanged(ev: HASSDomCurrentTargetEvent<HaSwitch>): void {
this._useDeviceName = ev.currentTarget.checked;
this._ownAreaWithoutName = false;
}
private get _hasOwnName(): boolean {
return !!(this._computeName() ?? this._originalName);
}
private get _useDeviceArea(): boolean {
return !!this._device && this._useDeviceName;
return !!this._device && !this._hasOwnName && !this._ownAreaWithoutName;
}
private get _originalName(): string {
@@ -9,14 +9,14 @@ import {
sanitizeLinkUrl,
} from "../../../common/url/sanitize-http-url";
import "../../../components/ha-alert";
import "../../../components/ha-dialog";
import "../../../components/ha-button";
import "../../../components/ha-svg-icon";
import "../../../components/ha-dialog";
import "../../../components/ha-dialog-footer";
import "../../../dialogs/repairs-flow/dialog-repairs-issue-subtitle";
import "../../../components/ha-markdown";
import "../../../components/ha-svg-icon";
import type { RepairsIssue } from "../../../data/repairs";
import { ignoreRepairsIssue } from "../../../data/repairs";
import "../../../dialogs/repairs-flow/dialog-repairs-issue-subtitle";
import { haStyleDialog } from "../../../resources/styles";
import type { HomeAssistant } from "../../../types";
import type { RepairsIssueDialogParams } from "./show-repair-issue-dialog";
@@ -61,16 +61,20 @@ class DialogRepairsIssue extends LitElement {
);
const learnMoreUrl = sanitizeLinkUrl(this._issue.learn_more_url);
const dialogTitle =
this.hass.localize(
`component.${this._issue.domain}.issues.${this._issue.translation_key || this._issue.issue_id}.title`,
this._issue.translation_placeholders || {}
) || this.hass!.localize("ui.panel.config.repairs.dialog.title");
const issueTitle = this.hass.localize(
`component.${this._issue.domain}.issues.${this._issue.translation_key || this._issue.issue_id}.title`,
this._issue.translation_placeholders || {}
);
const shortIssueTitle = this.hass.localize(
`component.${this._issue.domain}.issues.${this._issue.translation_key || this._issue.issue_id}.short_title`,
this._issue.translation_placeholders || {}
);
return html`
<ha-dialog
.open=${this._open}
header-title=${dialogTitle}
header-title=${shortIssueTitle || this.hass.localize("ui.panel.config.repairs.dialog.title")}
aria-describedby="dialog-repairs-issue-description"
@closed=${this._dialogClosed}
>
@@ -80,6 +84,7 @@ class DialogRepairsIssue extends LitElement {
.issue=${this._issue}
></dialog-repairs-issue-subtitle>
<div class="dialog-content">
${!shortIssueTitle ? html`<h2 class="issue-title">${issueTitle}</h2>` : nothing}
${
this._issue.breaks_in_ha_version
? html`
@@ -180,6 +185,13 @@ class DialogRepairsIssue extends LitElement {
.dialog-content {
padding-top: 0;
}
.issue-title {
margin: 0 0 var(--ha-space-4);
font-size: var(--ha-font-size-xl);
font-weight: var(--ha-font-weight-medium);
line-height: var(--ha-line-height-condensed);
overflow-wrap: anywhere;
}
ha-alert {
margin-bottom: var(--ha-space-4);
display: block;
+11 -4
View File
@@ -409,14 +409,15 @@ export class HaConfigZone extends SubscribeMixin(LitElement) {
Boolean(this.hass.user?.is_admin) &&
["storage", "default"].includes(this.hass.config.config_source);
this._fetchData();
if (this.route.path === "/new") {
navigate("/config/zone", { replace: true });
this._createZone();
}
}
protected updated(changedProps: PropertyValues<this>) {
super.updated(changedProps);
// The page is kept between visits, so routes are handled on every change
if (changedProps.has("route") && this.route.path === "/new") {
this._openNewZone();
return;
}
if (
!this.route.path.startsWith("/edit/") ||
!this._stateItems ||
@@ -588,6 +589,12 @@ export class HaConfigZone extends SubscribeMixin(LitElement) {
this._openDialog();
}
private async _openNewZone() {
if (await navigate("/config/zone", { replace: true })) {
this._createZone();
}
}
private _itemClicked(ev: CustomEvent) {
if (!shouldHandleRequestSelectedEvent(ev)) {
return;
+137 -26
View File
@@ -10,9 +10,14 @@ import { isComponentLoaded } from "../../../common/config/is_component_loaded";
import { applyThemesOnElement } from "../../../common/dom/apply_themes_on_element";
import { getEntityEntryContext } from "../../../common/entity/context/get_entity_context";
import { createSearchParam } from "../../../common/url/search-params";
import "../../../components/ha-alert";
import "../../../components/ha-card";
import "../../../components/ha-icon-next";
import "../../../components/ha-tooltip";
import {
fetchDeviceCompositeSplits,
type DeviceCompositeSplits,
} from "../../../data/device/device_registry";
import { resolveEntityIDs } from "../../../data/selector";
import type { HomeAssistant } from "../../../types";
import "../../logbook/ha-logbook";
@@ -72,6 +77,10 @@ export class HuiLogbookCard extends LitElement implements LovelaceCard {
@state() private _stateFilter?: string[];
@state() private _compositeSplits?: DeviceCompositeSplits;
private _loadingCompositeSplits = false;
private _showMoreLinkId = `logbook-${Math.random().toString(36).substring(2, 9)}`;
public getCardSize(): number {
@@ -144,7 +153,7 @@ export class HuiLogbookCard extends LitElement implements LovelaceCard {
}
private _showMoreUrl(): string {
const target = this._targetPickerValue;
const target = this._getTarget();
const params: Record<string, string> = {
start_date: startOfYesterday().toISOString(),
back: "1",
@@ -167,17 +176,71 @@ export class HuiLogbookCard extends LitElement implements LovelaceCard {
return `/logbook?${createSearchParam(params)}`;
}
private _getEntityIds(): string[] | undefined {
const entities = this._getMemoizedEntityIds(
this._targetPickerValue,
// An empty list must stay empty: ha-logbook treats undefined as "no
// filter" and would show all activity when the targets resolve to nothing.
private _getEntityIds(): string[] {
return this._getMemoizedEntityIds(
this._getTarget(),
this.hass.entities,
this.hass.devices,
this.hass.areas
);
if (entities.length === 0) {
return undefined;
}
private _getMissingDeviceIds = memoizeOne(
(
targetPickerValue: HassServiceTarget,
devices: HomeAssistant["devices"]
): string[] =>
devices && targetPickerValue.device_id
? ensureArray(targetPickerValue.device_id).filter(
(deviceId) => !devices[deviceId]
)
: []
);
private _getTarget(): HassServiceTarget {
return this._getMemoizedTarget(
this._targetPickerValue,
this.hass.devices,
this._compositeSplits
);
}
// Swap legacy composite devices for the devices they were split into.
private _getMemoizedTarget = memoizeOne(
(
targetPickerValue: HassServiceTarget,
devices: HomeAssistant["devices"],
compositeSplits: DeviceCompositeSplits | undefined
): HassServiceTarget => {
if (!devices || !compositeSplits || !targetPickerValue.device_id) {
return targetPickerValue;
}
const deviceIds = new Set<string>();
for (const deviceId of ensureArray(targetPickerValue.device_id)) {
const splitIds = devices[deviceId]
? undefined
: compositeSplits[deviceId]?.split_ids.filter((id) => devices[id]);
if (splitIds?.length) {
splitIds.forEach((id) => deviceIds.add(id));
} else {
deviceIds.add(deviceId);
}
}
return { ...targetPickerValue, device_id: [...deviceIds] };
}
);
private async _loadCompositeSplits() {
this._loadingCompositeSplits = true;
try {
this._compositeSplits = await fetchDeviceCompositeSplits(this.hass);
} catch (_err) {
this._compositeSplits = {};
} finally {
this._loadingCompositeSplits = false;
}
return entities;
}
private _getMemoizedEntityIds = memoizeOne(
@@ -195,12 +258,8 @@ export class HuiLogbookCard extends LitElement implements LovelaceCard {
if (nameDetail !== "auto") {
return nameDetail;
}
const entityIds = this._getEntityIds();
if (!entityIds) {
return undefined;
}
return this._getAutoNameDetail(
entityIds,
this._getEntityIds(),
this.hass.entities,
this.hass.devices,
this.hass.areas,
@@ -251,6 +310,41 @@ export class HuiLogbookCard extends LitElement implements LovelaceCard {
}
}
protected willUpdate(changedProperties: PropertyValues<this>) {
super.willUpdate(changedProperties);
if (
this.hass &&
this._compositeSplits === undefined &&
!this._loadingCompositeSplits &&
this._getMissingDeviceIds(this._targetPickerValue, this.hass.devices)
.length
) {
// A targeted device is missing from the registry; it might be a legacy
// composite device that was split.
this._loadCompositeSplits();
}
}
private _renderMissingDevicesWarning() {
if (this._compositeSplits === undefined) {
return nothing;
}
const missing = this._getMissingDeviceIds(
this._getTarget(),
this.hass.devices
);
if (!missing.length) {
return nothing;
}
return html`
<ha-alert alert-type="warning">
${this.hass.localize("ui.card.logbook.device_not_found", {
count: missing.length,
})}
</ha-alert>
`;
}
protected updated(changedProperties: PropertyValues) {
super.updated(changedProperties);
if (!this._config || !this.hass) {
@@ -306,21 +400,32 @@ export class HuiLogbookCard extends LitElement implements LovelaceCard {
</h1>`
: nothing
}
${this._renderMissingDevicesWarning()}
<div class="content">
<ha-logbook
class=${classMap({
"is-grid": this.layout === "grid",
"is-panel": this.layout === "panel",
})}
.hass=${this.hass}
.time=${this._time}
.entityIds=${this._getEntityIds()}
.stateFilter=${this._stateFilter}
.nameDetail=${this._getNameDetail()}
narrow
no-icon
virtualize
></ha-logbook>
${
// Wait for the split map so a replaced device doesn't flash as
// having no activity.
this._compositeSplits === undefined &&
this._getMissingDeviceIds(
this._targetPickerValue,
this.hass.devices
).length
? nothing
: html`<ha-logbook
class=${classMap({
"is-grid": this.layout === "grid",
"is-panel": this.layout === "panel",
})}
.hass=${this.hass}
.time=${this._time}
.entityIds=${this._getEntityIds()}
.stateFilter=${this._stateFilter}
.nameDetail=${this._getNameDetail()}
narrow
no-icon
virtualize
></ha-logbook>`
}
</div>
</ha-card>
`;
@@ -351,9 +456,15 @@ export class HuiLogbookCard extends LitElement implements LovelaceCard {
.content {
height: 100%;
min-height: 0;
padding: 0 0 16px;
}
ha-alert {
display: block;
margin: var(--ha-space-2) var(--ha-space-4) 0;
}
.no-header .content {
padding-top: 16px;
}
+8 -6
View File
@@ -343,10 +343,9 @@ class HuiMapCard extends LitElement implements LovelaceCard {
></ha-icon-button>
</div>
${
this.layout === PANEL_VIEW_LAYOUT
this.layout === PANEL_VIEW_LAYOUT && !this.preview
? html`<hui-map-overview
id="overview"
.hass=${this.hass}
.entities=${this._overviewEntities}
.selected=${this._overviewSelected}
.tab=${this._overviewTab}
@@ -403,6 +402,9 @@ class HuiMapCard extends LitElement implements LovelaceCard {
protected willUpdate(changedProps: PropertyValues<this>): void {
super.willUpdate(changedProps);
if (changedProps.has("preview") && this.preview) {
this._overviewSize = { width: 0, height: 0 };
}
if (
this._config?.show_all &&
!this._config?.entities &&
@@ -457,10 +459,10 @@ class HuiMapCard extends LitElement implements LovelaceCard {
: undefined,
this.preview || this._overviewTab === "zones"
);
this._filteredMapEntities = this._filterByOverviewTab(
this._overviewEntities,
this._overviewTab
);
// Without the overview, while editing, every marker shows
this._filteredMapEntities = this.preview
? this._overviewEntities
: this._filterByOverviewTab(this._overviewEntities, this._overviewTab);
}
}
+213 -79
View File
@@ -1,5 +1,9 @@
import { mdiHistory } from "@mdi/js";
import type { HassEntities, HassEntity } from "home-assistant-js-websocket";
import type {
HassConfig,
HassEntities,
HassEntity,
} from "home-assistant-js-websocket";
import type { PropertyValues } from "lit";
import { css, html, LitElement, nothing } from "lit";
import {
@@ -12,6 +16,7 @@ import {
import { classMap } from "lit/directives/class-map";
import { styleMap } from "lit/directives/style-map";
import { consume } from "../../../../common/decorators/consume";
import { transform } from "../../../../common/decorators/transform";
import { contrastingZoneContent } from "../../../../common/map/zone-marker";
import {
HOME_ZONE_ENTITY_ID,
@@ -35,12 +40,13 @@ import "../../../../components/ha-spinner";
import "../../../../components/ha-state-icon";
import "../../../../components/ha-svg-icon";
import type { HaMapEntity } from "../../../../components/map/ha-map";
import "../../../logbook/ha-logbook-entry";
import type { LogbookEntry } from "../../../../data/logbook";
import "../../../../components/ha-button";
import { formatTime } from "../../../../common/datetime/format_time";
import type { ActivityEntry } from "./map-activity";
import { personActivity, zoneActivity } from "./map-activity";
import {
apiContext,
configContext,
connectionContext,
formattersContext,
fullEntitiesContext,
@@ -52,7 +58,8 @@ import type { HistoryStates } from "../../../../data/history";
import { fetchDateWS } from "../../../../data/history";
import { computeUserInitials } from "../../../../data/user";
import type {
HomeAssistant,
CurrentUser,
HomeAssistantConfig,
HomeAssistantApi,
HomeAssistantConnection,
HomeAssistantFormatters,
@@ -62,6 +69,7 @@ import type {
export type OverviewTab = "people" | "devices" | "zones";
const ACTIVITY_HOURS = 24;
const ACTIVITY_INITIAL_ENTRIES = 5;
declare global {
interface HASSDomEvents {
@@ -76,9 +84,6 @@ declare global {
export class HuiMapOverview extends LitElement {
@property({ attribute: false }) public entities: HaMapEntity[] = [];
// Only handed to ha-logbook-entry, which takes the broad object
@property({ attribute: false }) public hass?: HomeAssistant;
@property({ attribute: false }) public selected?: string;
@property({ attribute: false }) public tab: OverviewTab = "people";
@@ -103,6 +108,20 @@ export class HuiMapOverview extends LitElement {
@consume({ context: apiContext, subscribe: true })
private _api!: HomeAssistantApi;
@state()
@consume({ context: configContext, subscribe: true })
@transform<HomeAssistantConfig, CurrentUser | undefined>({
transformer: ({ user }) => user,
})
private _user?: CurrentUser;
@state()
@consume({ context: configContext, subscribe: true })
@transform<HomeAssistantConfig, HassConfig>({
transformer: ({ config }) => config,
})
private _config!: HassConfig;
// Registry creation order decides the zone colors
@state()
@consume({ context: fullEntitiesContext, subscribe: true })
@@ -136,6 +155,8 @@ export class HuiMapOverview extends LitElement {
@state() private _activityFailed = false;
@state() private _activityExpanded = false;
// Counts activity loads so only the newest one may show its result, even
// when the same entity is selected again while an older load is pending
private _activityRequest = 0;
@@ -173,6 +194,13 @@ export class HuiMapOverview extends LitElement {
protected updated(changedProps: PropertyValues<this>): void {
super.updated(changedProps);
this._watchPeek();
// The host filters the map by the tab it set; tell it when that one is gone
const tabs = Object.keys(
this._itemsPerTab(this._getPeople(), this._getDevices(), this._getZones())
) as OverviewTab[];
if (tabs.length && !tabs.includes(this.tab)) {
fireEvent(this, "map-overview-tab", { tab: tabs[0] });
}
if (changedProps.has("selected")) {
this._moveFocus(changedProps.get("selected"));
}
@@ -244,6 +272,10 @@ export class HuiMapOverview extends LitElement {
!!stateObj && computeStateDomain(stateObj) === "person"
)
.sort((a, b) => {
const aMe = this._isMe(a);
if (aMe !== this._isMe(b)) {
return aMe ? -1 : 1;
}
const aLocated = !!getEntityLocation(a, this._states);
const bLocated = !!getEntityLocation(b, this._states);
if (aLocated !== bLocated) {
@@ -256,6 +288,20 @@ export class HuiMapOverview extends LitElement {
});
}
private _isMe(stateObj: HassEntity): boolean {
return (
!!this._user &&
computeStateDomain(stateObj) === "person" &&
stateObj.attributes.user_id === this._user.id
);
}
private _personName(stateObj: HassEntity): string {
return this._isMe(stateObj)
? this._i18n.localize("ui.panel.lovelace.cards.map.overview.me")
: computeStateName(stateObj);
}
private _getDevices(): HassEntity[] {
return this.entities
.map((entity) => this._states[entity.entity_id])
@@ -319,6 +365,7 @@ export class HuiMapOverview extends LitElement {
if (changedProps.has("selected")) {
this._activity = undefined;
this._activityFailed = false;
this._activityExpanded = false;
const request = ++this._activityRequest;
if (this.selected) {
this._loadActivity(this.selected, request);
@@ -458,7 +505,7 @@ export class HuiMapOverview extends LitElement {
)}
@click=${this._handleMoreInfo}
>
${computeStateName(stateObj)}
${this._personName(stateObj)}
</button>
<span class="detail-state">
${
@@ -501,15 +548,7 @@ export class HuiMapOverview extends LitElement {
: "ui.panel.lovelace.cards.map.overview.no_activity"
)}
</span>`
: html`<div class="timeline">
${this._activity.map((entry, index, all) =>
this._renderActivityEntry(
stateObj,
entry,
index === all.length - 1
)
)}
</div>`
: this._renderTimeline(stateObj, this._activity)
}
</div>
</div>
@@ -540,57 +579,110 @@ export class HuiMapOverview extends LitElement {
);
}
// A logbook row: the person, the zone they are in now, and when. On the
// zone tab the row belongs to the person who arrived or left.
private _renderTimeline(stateObj: HassEntity, activity: ActivityEntry[]) {
const shown = this._activityExpanded
? activity
: activity.slice(0, ACTIVITY_INITIAL_ENTRIES);
return html`
<div class="timeline">
${shown.map((entry, index) =>
this._renderActivityEntry(stateObj, entry, index === shown.length - 1)
)}
</div>
${
activity.length > shown.length
? html`<ha-button
appearance="plain"
size="s"
class="show-more"
@click=${this._showAllActivity}
>
${this._i18n.localize(
"ui.panel.lovelace.cards.map.overview.show_more"
)}
</ha-button>`
: nothing
}
`;
}
private _renderActivityEntry(
stateObj: HassEntity,
entry: ActivityEntry,
last: boolean
) {
if (!this.hass) {
return nothing;
}
const entityId = entry.personId ?? stateObj.entity_id;
const subject = this._states[entityId];
const item: LogbookEntry = {
when: entry.when.getTime() / 1000,
name: subject ? computeStateName(subject) : entityId,
entity_id: entityId,
state: entry.state,
};
// Zone changes take the zone's color; arrivals and departures keep the
// logbook's own colors
const stateColor = entry.personId
? undefined
: this._zoneColorForState(entry.state);
const person = entry.personId ? this._states[entry.personId] : undefined;
const headline = entry.personId
? person
? this._personName(person)
: entry.personId
: this._formatters.formatEntityState(stateObj, entry.state);
const color =
entry.personId && !entry.arrived
? undefined
: this._zoneColorForState(entry.state);
return html`
<ha-logbook-entry
.hass=${this.hass}
.item=${item}
.lastOfDay=${last}
.nodeColor=${stateColor}
narrow
no-detail
></ha-logbook-entry>
<div class=${classMap({ entry: true, last })}>
<span
class="dot"
style=${styleMap({ "--dot-color": color })}
aria-hidden="true"
></span>
<span class="entry-headline">${headline}</span>
<span class="entry-when">
${
entry.personId
? html`${this._i18n.localize(
entry.arrived
? "ui.panel.lovelace.cards.map.overview.entered"
: "ui.panel.lovelace.cards.map.overview.left"
)}
· `
: nothing
}
${formatTime(entry.when, this._i18n.locale, this._config)} ·
<ha-relative-time .datetime=${entry.when}></ha-relative-time>
</span>
</div>
`;
}
private _showAllActivity() {
this._activityExpanded = true;
}
private _itemsPerTab(
people: HassEntity[],
devices: HassEntity[],
zones: HassEntity[]
): Partial<Record<OverviewTab, HassEntity[]>> {
return {
...(people.length ? { people } : {}),
...(devices.length ? { devices } : {}),
...(zones.length ? { zones } : {}),
};
}
private _renderList(
people: HassEntity[],
devices: HassEntity[],
zones: HassEntity[]
) {
const itemsPerTab: Partial<Record<OverviewTab, HassEntity[]>> = {
people,
// A devices tab only for devices with a location, or while it is selected
...(devices.length || this.tab === "devices" ? { devices } : {}),
zones,
};
const itemsPerTab = this._itemsPerTab(people, devices, zones);
const tabs = Object.keys(itemsPerTab) as OverviewTab[];
// The selected tab stays selected even when empty; never switch away from it
const tab = tabs.includes(this.tab) ? this.tab : "people";
const tab = tabs.includes(this.tab) ? this.tab : tabs[0];
const items = itemsPerTab[tab]!;
// A lone heading also serves as the phone sheet's peek strip
if (tabs.length === 1) {
return html`
<div class="heading peek" id="tab-${tab}">
${this._i18n.localize(`ui.panel.lovelace.cards.map.overview.${tab}`)}
</div>
${this._renderItems(tab, items)}
`;
}
return html`
<div class="tabs peek">
<span
@@ -612,7 +704,6 @@ export class HuiMapOverview extends LitElement {
aria-selected=${tab === tabId}
tabindex=${tab === tabId ? 0 : -1}
data-tab=${tabId}
.disabled=${!itemsPerTab[tabId]!.length && tabId !== tab}
@click=${this._handleTabClick}
>
${this._i18n.localize(
@@ -623,33 +714,31 @@ export class HuiMapOverview extends LitElement {
)}
</div>
</div>
${this._renderItems(tab, items)}
`;
}
private _renderItems(tab: OverviewTab, items: HassEntity[]) {
return html`
<div
class="list"
id="tabpanel"
role="tabpanel"
aria-labelledby="tab-${tab}"
>
${
items.length
? html`<ha-md-list>
${items.map((stateObj) =>
tab === "zones"
? this._renderZone(stateObj)
: this._renderEntity(stateObj)
)}
</ha-md-list>`
: html`<div class="empty">
${this._i18n.localize(
`ui.panel.lovelace.cards.map.overview.no_${tab}`
)}
</div>`
}
<ha-md-list>
${items.map((stateObj) =>
tab === "zones"
? this._renderZone(stateObj)
: this._renderEntity(stateObj)
)}
</ha-md-list>
</div>
`;
}
private _renderEntity(stateObj: HassEntity) {
const name = computeStateName(stateObj);
const name = this._personName(stateObj);
const location = getEntityLocation(stateObj, this._states);
const picture = stateObj.attributes.entity_picture;
@@ -670,7 +759,7 @@ export class HuiMapOverview extends LitElement {
/>`
: computeStateDomain(stateObj) === "person"
? html`<span class="initials"
>${computeUserInitials(name)}</span
>${computeUserInitials(computeStateName(stateObj))}</span
>`
: html`<ha-state-icon .stateObj=${stateObj}></ha-state-icon>`
}
@@ -817,6 +906,14 @@ export class HuiMapOverview extends LitElement {
padding-left: max(var(--ha-space-3), var(--safe-area-inset-left));
}
.heading {
flex: none;
padding: var(--ha-space-2) var(--ha-space-3);
font-size: var(--ha-font-size-l);
font-weight: var(--ha-font-weight-medium);
color: var(--primary-text-color);
}
.tabs {
position: relative;
flex: none;
@@ -890,16 +987,6 @@ export class HuiMapOverview extends LitElement {
flex-direction: column;
}
.empty {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
padding: var(--ha-space-6) var(--ha-space-3);
color: var(--secondary-text-color);
text-align: center;
}
ha-md-list {
flex: 1;
min-height: 0;
@@ -1041,6 +1128,53 @@ export class HuiMapOverview extends LitElement {
.timeline {
margin-top: var(--ha-space-2);
--rail-size: 10px;
}
.entry {
position: relative;
display: flex;
flex-direction: column;
padding-inline-start: calc(var(--rail-size) + var(--ha-space-3));
padding-bottom: var(--ha-space-4);
}
.entry.last {
padding-bottom: 0;
}
.entry::before {
content: "";
position: absolute;
inset-inline-start: calc(var(--rail-size) / 2 - 1px);
top: var(--rail-size);
bottom: 0;
width: 2px;
background: var(--divider-color);
}
.entry.last::before {
display: none;
}
.dot {
position: absolute;
inset-inline-start: 0;
top: 4px;
width: var(--rail-size);
height: var(--rail-size);
border-radius: var(--ha-border-radius-circle);
background: var(--dot-color, var(--secondary-text-color));
}
.entry-headline {
font-weight: var(--ha-font-weight-medium);
color: var(--primary-text-color);
}
.entry-when {
color: var(--secondary-text-color);
font-size: var(--ha-font-size-s);
}
.show-more {
display: block;
margin-top: var(--ha-space-3);
padding-top: var(--ha-space-2);
border-top: 1px solid var(--divider-color);
}
`;
}
@@ -63,6 +63,7 @@ export class HuiDialogEditSection extends LitElement {
name: "background_color",
selector: {
ui_color: {
default_color: "default",
extra_options: [
{
value: "default",
@@ -106,7 +107,7 @@ export class HuiDialogEditSection extends LitElement {
const data: SettingsData = {
column_span: this.config.column_span || 1,
background_enabled: backgroundEnabled,
background_color: background?.color ?? "default",
background_color: background?.color,
background_opacity:
background?.opacity ?? DEFAULT_SECTION_BACKGROUND_OPACITY,
theme: this.config.theme,
+10 -5
View File
@@ -223,6 +223,9 @@
"open_door_confirm": "Really open?",
"open_door_done": "Done"
},
"logbook": {
"device_not_found": "{count, plural,\n one {A device used by this card no longer exists.}\n other {# devices used by this card no longer exist.}\n}"
},
"home-summary": {
"all_lights_off": "All off",
"count_active_alerts": "{count} {count, plural,\n one {active alert}\n other {active alerts}\n}",
@@ -2081,8 +2084,8 @@
"note": "Note: This might not work yet with all integrations.",
"use_device_area": "Use device area",
"use_device_area_confirm_title": "Use the area of the device?",
"use_device_area_confirm_text": "This entity is in {area}. Because it uses the device name, it will use the area of its device instead.",
"use_device_area_required": "An entity that uses the device name always uses the area of its device. You can {link} in the device settings.",
"use_device_area_confirm_text": "This entity is in {area}. Because it has no name of its own, it will use the area of its device instead.",
"use_device_area_required": "An entity without its own name always uses the area of its device. You can {link} in the device settings.",
"change_device_settings": "You can {link} in the device settings",
"change_device_area_link": "change the device area",
"configure_state": "{integration} options",
@@ -2986,6 +2989,7 @@
"mcp": {
"header": "Model Context Protocol",
"description": "Add one of the URLs below to your AI agent. See the {documentation_link} for agent-specific instructions.",
"description_disabled": "See the {documentation_link} to learn how to connect your AI agent to Home Assistant.",
"documentation": "documentation",
"enable": "[%key:ui::common::enable%]",
"disable": "[%key:ui::common::disable%]",
@@ -9524,12 +9528,13 @@
"people": "People",
"devices": "Devices",
"zones": "Zones",
"no_people": "No people",
"no_devices": "No devices with a location",
"no_zones": "No zones",
"me": "Me",
"activity": "Activity",
"show_more": "Show more",
"no_activity": "No recent activity",
"activity_unavailable": "Activity couldn't be loaded",
"entered": "Entered",
"left": "Left",
"people_in_zone": "{count, plural, =0 {No one here} one {{count} person} other {{count} people}}"
}
},
+42 -22
View File
@@ -19,23 +19,34 @@ const entry = (entityId: string, createdAt: number, id = entityId) =>
id,
}) as EntityRegistryEntry;
describe("entity map colors", () => {
it("colors zones, persons and trackers in one creation order", () => {
describe("entityMapColor", () => {
it("colors zones by creation date, whatever the trackers", () => {
const entries = [
entry("zone.work", 40),
entry("zone.work", 30),
entry("device_tracker.beacon", 5),
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("zone.work", entries, styles)).toBe("color-2");
});
it("colors persons before trackers, then by creation date", () => {
const entries = [
entry("device_tracker.phone", 30),
entry("device_tracker.beacon", 5),
entry("person.anne", 20),
entry("zone.work", 1),
];
expect(entityMapColor("person.anne", entries, styles)).toBe("color-1");
expect(entityMapColor("device_tracker.beacon", 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", () => {
@@ -49,6 +60,23 @@ describe("entity map colors", () => {
const entries = [entry(HOME_ZONE_ENTITY_ID, 10), entry("zone.work", 20)];
expect(entityMapColor("zone.work", 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
);
});
});
describe("zoneColor", () => {
it("uses the primary color for the home zone", () => {
const entries = [entry(HOME_ZONE_ENTITY_ID, 10)];
expect(zoneColor(HOME_ZONE_ENTITY_ID, false, entries, styles)).toBe(
"primary-color"
);
@@ -62,16 +90,18 @@ describe("entity map colors", () => {
);
expect(zoneColor("zone.quiet", false, entries, styles)).toBe("color-1");
});
});
it("previews the next slot for a new zone", () => {
describe("nextZoneColor", () => {
it("takes the slot after the last zone", () => {
const entries = [
entry(HOME_ZONE_ENTITY_ID, 10), // excluded from the palette
entry(HOME_ZONE_ENTITY_ID, 10),
entry("zone.work", 20),
entry("person.anne", 30),
entry("device_tracker.phone", 40),
];
// Two ordered entities take color-1 and color-2, so the next is color-3
expect(nextZoneColor(false, entries, styles)).toBe("color-3");
expect(nextZoneColor(false, entries, styles)).toBe("color-2");
});
it("mutes a new passive zone", () => {
@@ -79,14 +109,4 @@ describe("entity map colors", () => {
"secondary-text-color"
);
});
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
);
});
});
@@ -629,6 +629,25 @@ describe("MapLibreMapEngine", () => {
left: 0,
});
});
it("keeps padded bounds within the poles", async () => {
const { engine, map, ready } = await createEngine();
await ready;
// Points 150 degrees apart; half of that as padding would overshoot
engine.fitBounds(
[
[-75, -20],
[75, 20],
],
{ pad: 0.5 }
);
const [bounds] = map.fitBounds.mock.calls[0];
expect(bounds[0][1]).toBe(-90);
expect(bounds[1][1]).toBe(90);
expect(bounds[0][0]).toBe(-40);
expect(bounds[1][0]).toBe(40);
});
});
describe("markers", () => {
+59 -3
View File
@@ -54,13 +54,17 @@ const STATES = {
const leafletMap = (el: HaMap) => (el as any)._engine?.leafletMap;
const createMap = async (
options: { clusterMarkers?: boolean; states?: HassEntities } = {}
options: {
clusterMarkers?: boolean;
states?: HassEntities;
entities?: string[];
} = {}
): Promise<HaMap> => {
const el = document.createElement("ha-map");
el.entities = [
el.entities = options.entities ?? [
"device_tracker.paulus",
"device_tracker.anne_therese",
] as string[];
];
el.clusterMarkers = options.clusterMarkers ?? false;
(el as any)._states = options.states ?? STATES;
(el as any)._config = {
@@ -117,6 +121,35 @@ describe("ha-map", () => {
expect(map.getCenter().lng).toBeCloseTo(4.8925, 2);
});
it("fits to the zones when they are all the map shows", async () => {
const el = await createMap({
states: {
"zone.work": {
entity_id: "zone.work",
state: "0",
attributes: {
friendly_name: "Work",
latitude: 52.3,
longitude: 4.8,
radius: 100,
},
context: { id: "3", user_id: null, parent_id: null },
last_changed: "2026-01-01T00:00:00Z",
last_updated: "2026-01-01T00:00:00Z",
},
} as unknown as HassEntities,
entities: ["zone.work"],
});
setMapSize(el, 800, 500);
fireResizeObservers();
await el.updateComplete;
const map = leafletMap(el)!;
// Centred on the zone, not jumped to the home coordinates
expect(map.getCenter().lat).toBeCloseTo(52.3, 2);
expect(map.getCenter().lng).toBeCloseTo(4.8, 2);
});
it("does not defer fitting when the container already has a size", async () => {
const originalWidth = Object.getOwnPropertyDescriptor(
Element.prototype,
@@ -238,6 +271,29 @@ describe("ha-map", () => {
expect((after as any).entityName).toBe("PM");
});
it("shows up to four avatars, and three with a count beyond that", async () => {
const el = await createMap({ clusterMarkers: true, states: NEARBY });
const build = (count: number) =>
(el as any)._createClusterBubble(
Array.from({ length: count }, (_, i) => ({
clusterData: {
entityId: `person.p${i}`,
title: `P${i}`,
label: "P",
},
})),
[52.372, 4.89]
).element as HTMLElement;
const four = build(4);
expect(four.querySelectorAll("ha-entity-marker")).toHaveLength(4);
expect(four.querySelector(".more")).toBeNull();
const five = build(5);
expect(five.querySelectorAll("ha-entity-marker")).toHaveLength(3);
expect(five.querySelector(".more")?.textContent).toBe("+2");
});
it("reuses a detached avatar and drops its stale trail color", async () => {
const el = await createMap({ clusterMarkers: true, states: NEARBY });
const build = () =>
+79
View File
@@ -9,6 +9,7 @@ import {
countRemainingESPHomeCapabilities,
deriveESPHomeSetupStatus,
deviceHasMediaPlayerEntity,
findESPHomeZWaveFlow,
getESPHomeAudioControls,
getESPHomeSetupBannerState,
getESPHomeSetupCapabilityIds,
@@ -18,6 +19,7 @@ import {
isESPHomeSetupDeferred,
withDeferredESPHomeDevice,
} from "../../src/data/esphome_setup";
import type { DataEntryFlowProgress } from "../../src/data/data_entry_flow";
import type { SerialPortUsage } from "../../src/data/usb";
const capabilities = (
@@ -49,6 +51,7 @@ const deriveOptions = (
sendspinSupported: boolean;
sendspinEnabled: boolean;
serialConfigured: boolean;
zwaveFlowInProgress: boolean;
}> = {}
) => ({
mediaPlayerSupported: false,
@@ -259,6 +262,17 @@ describe("deriveESPHomeSetupStatus", () => {
).toBe("completed");
});
it("marks connectivity detected when a discovery flow exists despite home_id 0", () => {
expect(
deriveESPHomeSetupStatus(
capabilities({
zwave_proxy: { supported: true, home_id: 0 },
}),
deriveOptions({ zwaveFlowInProgress: true })
).connectivity
).toBe("detected");
});
it("marks serial completed only when an advertised UART is in use", () => {
const caps = capabilities({
serial_proxies: [serialProxy()],
@@ -287,6 +301,71 @@ describe("deriveESPHomeSetupStatus", () => {
});
});
describe("findESPHomeZWaveFlow", () => {
const flow = (
context: DataEntryFlowProgress["context"],
handler = "zwave_js"
): DataEntryFlowProgress => ({
flow_id: `${handler}-${JSON.stringify(context)}`,
handler,
step_id: "installation_type",
context,
});
const fromDevice = flow({
source: "esphome",
unique_id: "3551671779",
discovery_key: { domain: "esphome", key: "20:F8:3B:17:08:A8", version: 1 },
});
it("matches the flow by the device MAC, ignoring case and separators", () => {
expect(findESPHomeZWaveFlow([fromDevice], "20:f8:3b:17:08:a8")).toBe(
fromDevice
);
const rawKey = flow({
...fromDevice.context,
discovery_key: { domain: "esphome", key: "20F83B1708A8", version: 1 },
});
expect(findESPHomeZWaveFlow([rawKey], "20:f8:3b:17:08:a8")).toBe(rawKey);
});
it("ignores flows from other devices", () => {
expect(
findESPHomeZWaveFlow(
[
fromDevice,
flow({ ...fromDevice.context, source: "usb" }),
flow(fromDevice.context, "zha"),
],
"aa:bb:cc:dd:ee:ff"
)
).toBeUndefined();
});
it("does not match flows without an ESPHome discovery key", () => {
const legacy = flow({ source: "esphome", unique_id: "3551671779" });
const otherKey = flow({
source: "esphome",
discovery_key: { domain: "usb", key: "20:F8:3B:17:08:A8", version: 1 },
});
expect(
findESPHomeZWaveFlow([legacy, otherKey], "20:F8:3B:17:08:A8")
).toBeUndefined();
expect(findESPHomeZWaveFlow([fromDevice], undefined)).toBeUndefined();
});
it("ignores same-MAC flows from other sources and integrations", () => {
expect(
findESPHomeZWaveFlow(
[
flow({ ...fromDevice.context, source: "usb" }),
flow(fromDevice.context, "zha"),
],
"20:F8:3B:17:08:A8"
)
).toBeUndefined();
});
});
describe("isESPHomeSerialConfigured", () => {
const url = "esphome-hass://esphome/entry?port_name=uart0";
@@ -196,6 +196,42 @@ describe("automation trigger IDs", () => {
});
});
it("updates a trigger condition that omits its ID", () => {
// Core requires an ID, but the editor can hold one without it before saving.
const original = { condition: "trigger" } as TriggerCondition;
const config: AutomationConfig = {
triggers: [{ trigger: "event", event_type: "test", id: "manual-id" }],
conditions: [original],
actions: [],
};
const updated = updateTriggerCondition(
config,
original,
{ ...original, id: ["manual-id"] },
config.triggers
);
expect(updated.conditions).toEqual([
{ condition: "trigger", id: ["manual-id"] },
]);
});
it("leaves trigger conditions that omit their ID untouched during cleanup", () => {
const config: AutomationConfig = {
triggers: [],
conditions: [{ condition: "trigger" }],
actions: [],
};
expect(
cleanupRemovedGeneratedTriggerReferences(
config,
new Set([`${GENERATED_TRIGGER_ID_PREFIX}aB3x`])
)
).toBe(config);
});
it("removes dangling generated references when no triggers remain", () => {
const generatedId = `${GENERATED_TRIGGER_ID_PREFIX}aB3x`;
const config: AutomationConfig = {