mirror of
https://github.com/home-assistant/frontend.git
synced 2026-10-09 06:34:58 +00:00
Compare commits
17
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
025db7ffe7 | ||
|
|
f9c958c80c | ||
|
|
652897a3cb | ||
|
|
3426449315 | ||
|
|
8e19b0249d | ||
|
|
bba034eeca | ||
|
|
31f2767038 | ||
|
|
e0571044ae | ||
|
|
0f00286641 | ||
|
|
9cc166d3cf | ||
|
|
3ad1ed5aa6 | ||
|
|
8ee84c2462 | ||
|
|
eca108032f | ||
|
|
3bfe67f784 | ||
|
|
c27191de6b | ||
|
|
31f626c877 | ||
|
|
b081db7020 |
@@ -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());
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
@@ -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
@@ -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 },
|
||||
};
|
||||
|
||||
@@ -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 */
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 || {};
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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 => {
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -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)
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}}"
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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 = () =>
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
Reference in New Issue
Block a user