Compare commits

..
Author SHA1 Message Date
Claude 826fe4f7b0 Prevent kiosk mode to hide dashboard edit button 2026-09-14 21:29:48 +00:00
305 changed files with 4294 additions and 12522 deletions
-15
View File
@@ -7,17 +7,13 @@ body:
value: |
Make sure you are running the [latest version of Home Assistant][releases] before reporting an issue.
Please read our [issue reporting guide][reporting-issues] before submitting a report.
If you have a feature or enhancement request for the frontend, please [start a discussion][fr] instead of creating an issue.
**Please do not report issues for custom cards.**
[fr]: https://github.com/orgs/home-assistant/discussions
[releases]: https://github.com/home-assistant/home-assistant/releases
[reporting-issues]: https://www.home-assistant.io/help/reporting_issues/
- type: checkboxes
id: checklist
attributes:
label: Checklist
description: Please verify that you've followed these steps
@@ -34,21 +30,18 @@ body:
value: |
## The problem
- type: textarea
id: problem
validations:
required: true
attributes:
label: Describe the issue you are experiencing
description: Provide a clear and concise description of what the bug is.
- type: textarea
id: expected_behavior
validations:
required: true
attributes:
label: Describe the behavior you expected
description: Describe what you expected to happen or it should look/behave.
- type: textarea
id: steps_to_reproduce
validations:
required: true
attributes:
@@ -66,7 +59,6 @@ body:
value: |
## Environment
- type: input
id: core_version
validations:
required: true
attributes:
@@ -75,21 +67,18 @@ body:
description: >
Can be found in: [Settings -> About](https://my.home-assistant.io/redirect/info/).
- type: input
id: last_working_version
attributes:
label: What was the last working version of Home Assistant Core?
placeholder: core-
description: >
If known, otherwise leave blank.
- type: input
id: browser
attributes:
label: In which browser are you experiencing the issue?
placeholder: Google Chrome 88.0.4324.150
description: >
Provide the full name and don't forget to add the version!
- type: input
id: os
attributes:
label: Which operating system are you using to run this browser?
placeholder: macOS Big Sur (1.11)
@@ -101,7 +90,6 @@ body:
# Details
- type: textarea
id: entity_state
attributes:
label: State of relevant entities
description: >
@@ -110,7 +98,6 @@ body:
information in the Details view of the More info dialog.
render: txt
- type: textarea
id: configuration
attributes:
label: Problem-relevant frontend configuration
description: >
@@ -120,7 +107,6 @@ body:
passwords, private URLs and other credentials.
render: yaml
- type: textarea
id: javascript_errors
attributes:
label: JavaScript errors shown in your browser console/inspector
description: >
@@ -128,7 +114,6 @@ body:
browser console/inspector please provide them.
render: txt
- type: textarea
id: additional_information
attributes:
label: Additional information
description: >
+2 -2
View File
@@ -32,12 +32,12 @@ jobs:
persist-credentials: false
- name: Initialize CodeQL
uses: github/codeql-action/init@b96794f015dfd88f77b49b1c93e0fa7110f94c63 # v4.38.0
uses: github/codeql-action/init@cdf488f595d80d6e07e03d4674febd5ab45fa938 # v4.37.9
with:
languages: javascript-typescript
build-mode: none
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@b96794f015dfd88f77b49b1c93e0fa7110f94c63 # v4.38.0
uses: github/codeql-action/analyze@cdf488f595d80d6e07e03d4674febd5ab45fa938 # v4.37.9
with:
category: "/language:javascript-typescript"
-1
View File
@@ -13,7 +13,6 @@ const HARDWARE_INFO: HardwareInfo = {
hassio_board_id: "green",
manufacturer: "homeassistant",
model: "green",
revision: null,
},
dongle: null,
config_entries: [],
+22 -24
View File
@@ -32,11 +32,11 @@ const DEMO_ADDONS: HassioAddonInfo[] = [
detached: false,
homeassistant: "2025.7.0",
icon: false,
installed: true,
logo: false,
repository: "d5369777",
stage: "stable",
state: "started",
system_managed: false,
update_available: false,
url: "https://github.com/music-assistant/home-assistant-addon",
version: "2.6.3",
@@ -53,11 +53,11 @@ const DEMO_ADDONS: HassioAddonInfo[] = [
detached: false,
homeassistant: "2025.7.0",
icon: false,
installed: true,
logo: false,
repository: "5c53de3b",
stage: "stable",
state: "started",
system_managed: false,
update_available: false,
url: "https://esphome.io/",
version: "2025.7.3",
@@ -114,14 +114,13 @@ const addonDetails = (addon: HassioAddonInfo): HassioAddonDetails => ({
audio_output: null,
audio: false,
auth_api: false,
auto_uart: false,
auto_update: false,
boot: "auto",
boot_config: "auto",
changelog: false,
devices: [],
devicetree: false,
discovery: [],
dns: [],
docker_api: false,
documentation: false,
full_access: false,
@@ -134,10 +133,8 @@ const addonDetails = (addon: HassioAddonInfo): HassioAddonDetails => ({
host_ipc: false,
host_network: false,
host_pid: false,
host_uts: false,
ingress_entry: null,
ingress_panel: false,
ingress_port: null,
ingress_url: null,
ingress: false,
ip_address: "172.30.33.2",
@@ -151,16 +148,13 @@ const addonDetails = (addon: HassioAddonInfo): HassioAddonDetails => ({
protected: true,
rating: 6,
schema: CONFIG_SCHEMAS[addon.slug] ?? null,
services: [],
services_role: [],
signed: false,
startup: "application",
stdin: false,
system_managed: false,
system_managed_config_entry: null,
translations: {},
uart: false,
udev: false,
usb: false,
video: false,
watchdog: true,
webui: null,
});
@@ -187,14 +181,11 @@ const ADDON_STATS: HassioStats = {
blk_read: 12300000,
blk_write: 4500000,
cpu_percent: 1.4,
cpu_system_usage: 1420000000000,
cpu_usage: 19800000000,
memory_limit: 3900000000,
memory_percent: 4.2,
memory_usage: 163000000,
network_rx: 8900000,
network_tx: 2300000,
online_cpus: 4,
};
export const mockHassioSupervisor = (hass: MockHomeAssistant) => {
@@ -218,13 +209,9 @@ export const mockHassioSupervisor = (hass: MockHomeAssistant) => {
wait_boot: 5,
timezone: "Europe/Amsterdam",
logging: "info",
auto_update: true,
country: "NL",
debug: false,
debug_block: false,
detect_blocking_io: false,
diagnostics: true,
feature_flags: {},
addons: DEMO_ADDONS as any,
addons_repositories: [
"https://github.com/music-assistant/home-assistant-addon",
@@ -237,6 +224,22 @@ export const mockHassioSupervisor = (hass: MockHomeAssistant) => {
if (msg.endpoint === "/addons") {
const data: HassioAddonsInfo = {
addons: DEMO_ADDONS,
repositories: [
{
slug: "d5369777",
name: "Music Assistant",
source: "https://github.com/music-assistant/home-assistant-addon",
url: "https://github.com/music-assistant/home-assistant-addon",
maintainer: "Music Assistant",
},
{
slug: "5c53de3b",
name: "ESPHome",
source: "https://github.com/esphome/home-assistant-addon",
url: "https://esphome.io/",
maintainer: "ESPHome",
},
],
};
return data;
}
@@ -261,7 +264,6 @@ export const mockHassioSupervisor = (hass: MockHomeAssistant) => {
hostname: "homeassistant",
logging: "info",
machine: "green",
machine_id: "0123456789abcdef0123456789abcdef",
state: "running",
operating_system: "Home Assistant OS 18.2",
supervisor: "2026.07.1",
@@ -299,7 +301,6 @@ export const mockHassioSupervisor = (hass: MockHomeAssistant) => {
update_available: false,
version: "18.2",
version_latest: "18.2",
version_pending: null,
data_disk: "Home Assistant Green (mmcblk0)",
};
return data;
@@ -333,9 +334,8 @@ export const mockHassioSupervisor = (hass: MockHomeAssistant) => {
const data: NetworkInfo = {
interfaces: [
{
connected: true,
mac: "aa:bb:cc:dd:ee:ff",
primary: true,
privacy: false,
interface: "eth0",
enabled: true,
type: "ethernet",
@@ -354,8 +354,6 @@ export const mockHassioSupervisor = (hass: MockHomeAssistant) => {
gateway: "172.30.32.1",
interface: "hassio",
},
host_internet: true,
supervisor_internet: true,
};
return data;
}
+1
View File
@@ -3,6 +3,7 @@ import { customElement, property } from "lit/decorators";
import "../../../src/components/ha-card";
import "../../../src/dialogs/more-info/more-info-content";
import "../../../src/state-summary/state-card-content";
import "../ha-demo-options";
import type { HomeAssistant } from "../../../src/types";
import { computeShowNewMoreInfo } from "../../../src/dialogs/more-info/const";
+4
View File
@@ -126,6 +126,7 @@ export const basicTrace: DemoTrace = {
},
},
running_script: false,
limit: 10,
},
},
],
@@ -170,6 +171,7 @@ export const basicTrace: DemoTrace = {
},
},
running_script: false,
limit: 10,
},
},
],
@@ -187,6 +189,7 @@ export const basicTrace: DemoTrace = {
},
},
running_script: false,
limit: 10,
},
},
],
@@ -204,6 +207,7 @@ export const basicTrace: DemoTrace = {
},
},
running_script: false,
limit: 10,
},
},
],
+2
View File
@@ -1,7 +1,9 @@
import { html, css, LitElement } from "lit";
import { customElement } from "lit/decorators";
import "../../src/components/ha-icon-button";
import "../../src/managers/notification-manager";
import { haStyle } from "../../src/resources/styles";
import "./components/page-description";
@customElement("ha-demo-options")
class HaDemoOptions extends LitElement {
+11 -12
View File
@@ -52,15 +52,15 @@
"@codemirror/view": "6.43.11",
"@date-fns/tz": "1.5.0",
"@egjs/hammerjs": "2.0.17",
"@formatjs/intl-datetimeformat": "7.6.2",
"@formatjs/intl-displaynames": "7.3.14",
"@formatjs/intl-durationformat": "0.11.0",
"@formatjs/intl-getcanonicallocales": "3.2.12",
"@formatjs/intl-listformat": "8.3.14",
"@formatjs/intl-locale": "5.3.11",
"@formatjs/intl-numberformat": "9.4.1",
"@formatjs/intl-pluralrules": "6.3.14",
"@formatjs/intl-relativetimeformat": "12.3.14",
"@formatjs/intl-datetimeformat": "7.6.1",
"@formatjs/intl-displaynames": "7.3.13",
"@formatjs/intl-durationformat": "0.10.18",
"@formatjs/intl-getcanonicallocales": "3.2.11",
"@formatjs/intl-listformat": "8.3.13",
"@formatjs/intl-locale": "5.3.10",
"@formatjs/intl-numberformat": "9.4.0",
"@formatjs/intl-pluralrules": "6.3.13",
"@formatjs/intl-relativetimeformat": "12.3.13",
"@fullcalendar/core": "6.1.21",
"@fullcalendar/daygrid": "6.1.21",
"@fullcalendar/interaction": "6.1.21",
@@ -103,10 +103,10 @@
"echarts-extension-chart2music": "0.1.1",
"element-internals-polyfill": "3.0.2",
"fuse.js": "7.5.0",
"hls.js": "1.7.3",
"hls.js": "1.7.2",
"home-assistant-js-websocket": "9.6.0",
"idb-keyval": "6.3.0",
"intl-messageformat": "11.2.15",
"intl-messageformat": "11.2.14",
"js-yaml": "5.4.1",
"leaflet": "1.9.4",
"leaflet.markercluster": "1.5.3",
@@ -116,7 +116,6 @@
"maplibre-gl": "5.24.0",
"marked": "18.0.12",
"memoize-one": "6.0.0",
"nanoid": "6.0.1",
"node-vibrant": "4.0.4",
"object-hash": "3.0.0",
"punycode": "2.3.1",
-6
View File
@@ -1,7 +1 @@
export const stopPropagation = (ev) => ev.stopPropagation();
export const stopKeydownEnterSpacePropagation = (ev: KeyboardEvent) => {
if (ev.key === "Enter" || ev.key === " " || ev.key === "Spacebar") {
ev.stopPropagation();
}
};
@@ -1,34 +1,44 @@
import type { HassEntity } from "home-assistant-js-websocket";
import type {
EntityRegistryDisplayEntry,
EntityRegistryEntry,
} from "../../data/entity/entity_registry";
import type { HomeAssistant } from "../../types";
import { ensureArray } from "../array/ensure-array";
import { computeRTL } from "../util/compute_rtl";
import { computeAreaName } from "./compute_area_name";
import { computeDeviceName } from "./compute_device_name";
import {
computeEntityEntryName,
computeEntityName,
entityUseDeviceName,
} from "./compute_entity_name";
import { computeEntityName, entityUseDeviceName } from "./compute_entity_name";
import { computeFloorName } from "./compute_floor_name";
import { computeStateName } from "./compute_state_name";
import {
getEntityContext,
getEntityEntryContext,
} from "./context/get_entity_context";
import type { EntityNameItem, EntityNameOptions } from "./entity_name_config";
import { getEntityContext } from "./context/get_entity_context";
const DEFAULT_SEPARATOR = " ";
export { DEFAULT_ENTITY_NAME, ENTITY_NAME_TYPES } from "./entity_name_config";
export type {
EntityNameItem,
EntityNameOptions,
EntityNameType,
} from "./entity_name_config";
export const DEFAULT_ENTITY_NAME = [
{ type: "parent_device" },
{ type: "device" },
{ type: "entity" },
] satisfies EntityNameItem[];
export const ENTITY_NAME_TYPES = [
"floor",
"area",
"parent_device",
"device",
"entity",
] as const;
export type EntityNameType = (typeof ENTITY_NAME_TYPES)[number];
export type EntityNameItem =
| {
type: EntityNameType;
}
| {
type: "text";
text: string;
};
export interface EntityNameOptions {
separator?: string;
}
// Joins items that need no entity context. Returns undefined as soon as one
// item references the registries (device, area, ...).
@@ -129,49 +139,18 @@ export const computeEntityNameList = (
areas: HomeAssistant["areas"],
floors: HomeAssistant["floors"]
): (string | undefined)[] => {
const entry = entities[stateObj.entity_id] as
EntityRegistryDisplayEntry | undefined;
if (!entry) {
return name.map((item) =>
item.type === "entity"
? computeStateName(stateObj)
: item.type === "text"
? item.text
: undefined
);
}
return computeEntityEntryNameList(
entry,
name,
entities,
devices,
areas,
floors
);
};
export const computeEntityEntryNameList = (
entry: EntityRegistryDisplayEntry | EntityRegistryEntry,
name: EntityNameItem[],
entities: HomeAssistant["entities"],
devices: HomeAssistant["devices"],
areas: HomeAssistant["areas"],
floors: HomeAssistant["floors"]
): (string | undefined)[] => {
const { device, parentDevice, area, floor } = getEntityEntryContext(
entry,
const { device, parentDevice, area, floor } = getEntityContext(
stateObj,
entities,
devices,
areas,
floors
);
return name.map((item) => {
const names = name.map((item) => {
switch (item.type) {
case "entity":
return computeEntityEntryName(entry, devices);
return computeEntityName(stateObj, entities, devices);
case "device":
return device ? computeDeviceName(device) : undefined;
case "parent_device":
@@ -186,29 +165,8 @@ export const computeEntityEntryNameList = (
return "";
}
});
};
export const computeEntitySearchLabels = (
stateObj: HassEntity,
entities: HomeAssistant["entities"],
devices: HomeAssistant["devices"],
areas: HomeAssistant["areas"],
floors: HomeAssistant["floors"]
) => {
const { device, parentDevice, area } = getEntityContext(
stateObj,
entities,
devices,
areas,
floors
);
return {
entityName: computeEntityName(stateObj, entities, devices) || null,
friendlyName: computeStateName(stateObj) || null,
deviceName: (device && computeDeviceName(device)) || null,
parentDeviceName: (parentDevice && computeDeviceName(parentDevice)) || null,
areaName: (area && computeAreaName(area)) || null,
};
return names;
};
export interface EntityPickerDisplay {
-34
View File
@@ -1,34 +0,0 @@
/**
* Shape of a composed entity name, kept free of runtime dependencies so that
* modules in the core entry chunk can name an entity without pulling in the
* registry-aware formatters from `compute_entity_name_display`.
*/
export const ENTITY_NAME_TYPES = [
"floor",
"area",
"parent_device",
"device",
"entity",
] as const;
export type EntityNameType = (typeof ENTITY_NAME_TYPES)[number];
export type EntityNameItem =
| {
type: EntityNameType;
}
| {
type: "text";
text: string;
};
export interface EntityNameOptions {
separator?: string;
}
export const DEFAULT_ENTITY_NAME = [
{ type: "parent_device" },
{ type: "device" },
{ type: "entity" },
] satisfies EntityNameItem[];
@@ -186,11 +186,6 @@ export class LeafletMapEngine implements MapEngine {
this.leafletMap.fitBounds(bounds, {
maxZoom: options?.maxZoom,
animate: options?.animate,
paddingTopLeft: [options?.padding?.left ?? 0, options?.padding?.top ?? 0],
paddingBottomRight: [
options?.padding?.right ?? 0,
options?.padding?.bottom ?? 0,
],
});
}
+47 -43
View File
@@ -123,6 +123,8 @@ interface ManagedMarker {
draggable?: boolean;
onDragEnd?: (location: MapLatLng) => void;
dragging?: boolean;
/** Pre-drag location; the next update echoing it is ignored (see setLocation) */
staleLocation?: MapLatLng;
mlMarker?: MapLibreMarker;
decoration?: MapItemHandle;
removed?: boolean;
@@ -131,8 +133,6 @@ interface ManagedMarker {
interface ClusterGroup {
/** Members are shown in a bubble at their spot instead of an icon */
open?: boolean;
/** Grouped by key (a zone), so it bubbles even with a single member */
keyed?: boolean;
members: ManagedMarker[];
center: MapLatLng;
iconMarker?: MapLibreMarker;
@@ -518,26 +518,13 @@ export class MapLibreMapEngine implements MapEngine {
options?.maxZoom !== undefined
? options.maxZoom - ZOOM_OFFSET
: undefined;
// Passed per fit: easeTo's padding would stick to the map
const padding = {
top: options?.padding?.top ?? 0,
right: options?.padding?.right ?? 0,
bottom: options?.padding?.bottom ?? 0,
left: options?.padding?.left ?? 0,
};
if (minLat === maxLat && minLng === maxLng) {
// Zero-area bounds: center on the point, keeping the zoom unless given
this._map.fitBounds(
[
[minLng, minLat],
[minLng, minLat],
],
{
maxZoom: maxZoom ?? this._map.getZoom(),
animate: options?.animate,
padding,
}
);
// Zero-area bounds: center on the point
this._map.easeTo({
center: [minLng, minLat],
zoom: maxZoom ?? this._map.getZoom(),
animate: options?.animate,
});
return;
}
const pad = options?.pad ?? 0.5;
@@ -548,7 +535,7 @@ export class MapLibreMapEngine implements MapEngine {
[minLng - lngPad, minLat - latPad],
[maxLng + lngPad, maxLat + latPad],
],
{ maxZoom, animate: options?.animate, padding }
{ maxZoom, animate: options?.animate }
);
}
@@ -613,10 +600,19 @@ export class MapLibreMapEngine implements MapEngine {
},
clusterData: options.clusterData,
setLocation: (newLocation) => {
// The user's hand wins while dragging; the host is the truth otherwise
if (managed.dragging) {
return;
}
// Skip one update echoing the pre-drag location (the save has not returned yet)
const stale = managed.staleLocation;
managed.staleLocation = undefined;
if (
stale &&
newLocation[0] === stale[0] &&
newLocation[1] === stale[1]
) {
return;
}
managed.location = newLocation;
managed.mlMarker?.setLngLat([newLocation[1], newLocation[0]]);
},
@@ -667,6 +663,7 @@ export class MapLibreMapEngine implements MapEngine {
if (managed.draggable) {
managed.mlMarker.on("dragstart", () => {
managed.dragging = true;
managed.staleLocation = managed.location;
});
managed.mlMarker.on("dragend", () => {
managed.dragging = false;
@@ -804,8 +801,10 @@ export class MapLibreMapEngine implements MapEngine {
resizeHandle?.setAttribute("aria-valuetext", radiusText);
};
// The user's hand wins while dragging; the host is the truth otherwise
// Skip one update echoing the pre-edit values (the save has not returned yet)
let dragging = false;
let staleCenter: MapLatLng | undefined;
let staleRadius: number | undefined;
if (options.resizable) {
const east = pointEastOf(center, options.radius);
@@ -848,6 +847,8 @@ export class MapLibreMapEngine implements MapEngine {
if (keyboardRadius === undefined) {
// Host updates treat a key resize like a drag
dragging = true;
staleCenter = currentCenter;
staleRadius = currentRadius;
}
currentRadius = Math.max(
1,
@@ -874,11 +875,19 @@ export class MapLibreMapEngine implements MapEngine {
[centerMarker, resizeMarker].forEach((handleMarker) => {
handleMarker?.on("dragstart", () => {
dragging = true;
staleCenter = currentCenter;
staleRadius = currentRadius;
});
handleMarker?.on("dragend", () => {
dragging = false;
});
});
const isStale = (candidateCenter: MapLatLng, candidateRadius: number) =>
staleCenter !== undefined &&
staleRadius !== undefined &&
candidateCenter[0] === staleCenter[0] &&
candidateCenter[1] === staleCenter[1] &&
candidateRadius === staleRadius;
if (options.moveable) {
centerMarker.on("drag", () => {
@@ -935,6 +944,12 @@ export class MapLibreMapEngine implements MapEngine {
if (dragging) {
return;
}
const stale = isStale(newCenter, newRadius);
staleCenter = undefined;
staleRadius = undefined;
if (stale) {
return;
}
currentCenter = newCenter;
currentRadius = newRadius;
centerMarker.setLngLat([newCenter[1], newCenter[0]]);
@@ -1204,16 +1219,13 @@ export class MapLibreMapEngine implements MapEngine {
const clusterable = this._markers.filter(
(managed) => managed.options.cluster && !managed.removed
);
// A member or bubble that had focus hands it to the icon replacing it;
// read before the bubble holding it is removed
// A member that had focus hands it to the icon replacing it; read before
// the open bubble holding it is removed
const active = (
this._map.getContainer().getRootNode() as Document | ShadowRoot
).activeElement;
const focusedMember = active
? (clusterable.find((managed) => managed.element.contains(active)) ??
this._clusterGroups.find((group) =>
group.iconMarker?.getElement().contains(active)
)?.members[0])
? clusterable.find((managed) => managed.element.contains(active))
: undefined;
this._clusterGroups.forEach((group) => group.iconMarker?.remove());
@@ -1238,7 +1250,6 @@ export class MapLibreMapEngine implements MapEngine {
const groups: {
seed: { x: number; y: number };
members: ManagedMarker[];
keyed?: boolean;
}[] = [];
// Keyed groups first; one spread too wide falls through to proximity
@@ -1261,10 +1272,8 @@ export class MapLibreMapEngine implements MapEngine {
Math.max(...xs) - Math.min(...xs),
Math.max(...ys) - Math.min(...ys)
);
// A keyed group (a zone) bubbles even with a single member, so a lone
// person or device in a zone still shows in a bubble pinned to it.
if (members.length === 1 || spread <= (groupRadius ?? radius)) {
groups.push({ seed: points[0], members, keyed: true });
if (members.length > 1 && spread <= (groupRadius ?? radius)) {
groups.push({ seed: points[0], members });
} else {
ungrouped.push(...members);
}
@@ -1290,7 +1299,6 @@ export class MapLibreMapEngine implements MapEngine {
}
this._clusterGroups = groups.map((group) => ({
members: group.members,
keyed: group.keyed,
center: [
group.members.reduce((sum, m) => sum + m.location[0], 0) /
group.members.length,
@@ -1302,9 +1310,7 @@ export class MapLibreMapEngine implements MapEngine {
for (const group of this._clusterGroups) {
group.iconMarker = undefined;
// A lone non-keyed marker shows plainly; a lone zone occupant falls
// through to the bubble path so it renders in a bubble at its zone.
if (group.members.length === 1 && !group.keyed) {
if (group.members.length === 1) {
this._showMarker(group.members[0]);
continue;
}
@@ -1373,10 +1379,8 @@ export class MapLibreMapEngine implements MapEngine {
const group = this._clusterGroups.find((candidate) =>
candidate.members.includes(focusedMember)
);
if (group && !group.open) {
// A singleton group has no bubble icon; the member shows on its own,
// so send focus back to that member rather than to the document.
(group.iconMarker?.getElement() ?? focusedMember.element).focus();
if (group?.iconMarker && !group.open) {
group.iconMarker.getElement().focus();
}
}
}
-88
View File
@@ -1,88 +0,0 @@
import memoizeOne from "memoize-one";
import { getColorByIndex } from "../color/colors";
import { computeDomain } from "../entity/compute_domain";
import type { EntityRegistryEntry } from "../../data/entity/entity_registry";
/**
* Map colors for entities, by registry creation order, so an entity has the
* same color on every map. The registry comes from the fullEntitiesContext;
* entities without an entry (e.g. YAML zones) get a color derived from their
* id.
*/
export const HOME_ZONE_ENTITY_ID = "zone.home";
/** Domains whose entities are colored by creation order */
const ORDERED_DOMAINS = ["zone", "person", "device_tracker"];
// One index per registry update, shared by every map on the page. Zones,
// persons and trackers share one sequence, so a person never has the color
// of the zone it is in.
const creationIndex = memoizeOne(
(entries: EntityRegistryEntry[]): Record<string, number> => {
const index: Record<string, number> = {};
entries
.filter(
(entry) =>
ORDERED_DOMAINS.includes(computeDomain(entry.entity_id)) &&
// The home zone has a fixed color and does not take a palette slot
entry.entity_id !== HOME_ZONE_ENTITY_ID
)
.sort((a, b) => a.created_at - b.created_at || a.id.localeCompare(b.id))
.forEach((entry, i) => {
index[entry.entity_id] = i;
});
return index;
}
);
// For entities without a registry entry
const hashIndex = (entityId: string): number => {
let hash = 5381;
for (let i = 0; i < entityId.length; i++) {
hash = (hash * 33 + entityId.charCodeAt(i)) % 2147483647;
}
return hash;
};
/** The palette color of an entity on a map */
export const entityMapColor = (
entityId: string,
entries: EntityRegistryEntry[],
computedStyles: CSSStyleDeclaration
): string =>
getColorByIndex(
creationIndex(entries)[entityId] ?? hashIndex(entityId),
computedStyles
);
/** The color a new zone will take once created, from the next creation-order slot */
export const nextZoneColor = (
passive: boolean,
entries: EntityRegistryEntry[],
computedStyles: CSSStyleDeclaration
): string => {
if (passive) {
return computedStyles.getPropertyValue("--secondary-text-color");
}
return getColorByIndex(
Object.keys(creationIndex(entries)).length,
computedStyles
);
};
/** A zone's color: primary for home, muted for passive, its entity map color otherwise */
export const zoneColor = (
entityId: string,
passive: boolean,
entries: EntityRegistryEntry[],
computedStyles: CSSStyleDeclaration
): string => {
if (entityId === HOME_ZONE_ENTITY_ID) {
return computedStyles.getPropertyValue("--primary-color");
}
if (passive) {
return computedStyles.getPropertyValue("--secondary-text-color");
}
return entityMapColor(entityId, entries, computedStyles);
};
-9
View File
@@ -42,15 +42,6 @@ export interface MapFitOptions {
pad?: number;
/** Ease the camera to the bounds instead of jumping; defaults to true */
animate?: boolean;
/** Viewport pixels covered by overlays; the bounds fit inside the rest */
padding?: MapFitPadding;
}
export interface MapFitPadding {
top?: number;
right?: number;
bottom?: number;
left?: number;
}
export interface MapMarkerOptions {
+1 -5
View File
@@ -16,11 +16,7 @@ export const setMarkerAccessibility = (
): void => {
clearMarkerAccessibility(element);
const owned: string[] = [];
if (
title &&
!element.hasAttribute("aria-label") &&
!element.hasAttribute("aria-labelledby")
) {
if (title && !element.hasAttribute("aria-label")) {
element.setAttribute("aria-label", title);
owned.push("aria-label");
}
-67
View File
@@ -1,67 +0,0 @@
import { getContrastedColorHex } from "../color/rgb";
/** The zone marker: a colored circle with the zone's icon or initials, shared by the map and the zone editor */
export const ZONE_CIRCLE_SIZE = 36;
// Content color contrasting the fill; not every theme color parses
export const contrastingZoneContent = (color: string): string => {
try {
return getContrastedColorHex(color.trim());
} catch {
return "#ffffff";
}
};
export const zoneInitials = (name: string): string =>
name
.split(" ")
.map((part) => part[0])
.join("")
.slice(0, 2);
export const createZoneMarkerElement = (options: {
color: string;
icon?: string;
/** Path for an ha-svg-icon, when there is no icon name */
iconPath?: string;
name: string;
}): HTMLElement => {
const element = document.createElement("div");
element.className = "zone-circle";
element.style.backgroundColor = options.color;
element.style.color = contrastingZoneContent(options.color);
if (options.icon) {
const icon = document.createElement("ha-icon");
icon.setAttribute("icon", options.icon);
element.appendChild(icon);
} else if (options.iconPath) {
const icon = document.createElement("ha-svg-icon");
icon.setAttribute("path", options.iconPath);
element.appendChild(icon);
} else {
const initials = document.createElement("span");
initials.textContent = zoneInitials(options.name);
element.appendChild(initials);
}
return element;
};
/** Styles for the zone marker, included by ha-map */
export const zoneMarkerStyles = `
.zone-circle {
width: ${ZONE_CIRCLE_SIZE}px;
height: ${ZONE_CIRCLE_SIZE}px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
border: 2px solid var(--card-background-color, #fff);
box-sizing: border-box;
box-shadow: var(--ha-box-shadow-s);
overflow: hidden;
font-size: var(--ha-font-size-s);
font-weight: var(--ha-font-weight-medium);
--mdc-icon-size: ${ZONE_CIRCLE_SIZE / 2}px;
}
`;
-5
View File
@@ -1,5 +0,0 @@
export const GITHUB_CORE_ISSUES_URL =
"https://github.com/home-assistant/core/issues";
export const GITHUB_FRONTEND_ISSUES_URL =
"https://github.com/home-assistant/frontend/issues";
@@ -90,7 +90,6 @@ export class HaAutomationConditionSummary extends LitElement {
></ha-automation-row-options>`
: nothing
}
<slot name="references"></slot>
${
note
? html`
@@ -93,9 +93,9 @@ export class HaAutomationRowEventChip extends LitElement {
}
:host([variant="neutral"]) {
--background-color: var(--ha-color-fill-neutral-loud-resting);
--background-color-hover: var(--ha-color-fill-neutral-loud-hover);
--text-color: var(--ha-color-on-neutral-loud);
--background-color: var(--ha-color-fill-neutral-normal-resting);
--background-color-hover: var(--ha-color-fill-neutral-normal-hover);
--text-color: var(--ha-color-on-neutral-normal);
}
:host([variant="success"]) {
+5 -11
View File
@@ -152,7 +152,7 @@ export class HaAutomationRow extends LitElement {
margin-inline-end: initial;
}
:host([building-block]) .leading-icon-wrapper {
border: 1px solid var(--ha-color-border-neutral-normal);
background-color: var(--ha-color-fill-neutral-loud-resting);
border-radius: var(--ha-border-radius-md);
padding: var(--ha-space-1);
margin-top: 10px;
@@ -172,6 +172,7 @@ export class HaAutomationRow extends LitElement {
:host([building-block]) ::slotted([slot="leading-icon"].action-icon),
:host([building-block]) ::slotted(#condition-icon) {
--mdc-icon-size: var(--ha-space-5);
color: var(--white-color);
transform: rotate(-45deg);
}
:host([collapsed]) .expand-button {
@@ -184,16 +185,9 @@ export class HaAutomationRow extends LitElement {
outline-offset: -2px;
outline-width: 2px;
}
:host([disabled]) .row,
:host([disabled]) ::slotted([slot="leading-icon"]) {
color: var(--ha-color-text-disabled);
}
:host([disabled]) .leading-icon-wrapper {
opacity: 0.6;
}
:host([disabled]) ::slotted([slot="header"]) {
opacity: 0.6;
text-decoration: line-through;
:host([disabled]) .row {
border-top-right-radius: var(--ha-border-radius-square);
border-top-left-radius: var(--ha-border-radius-square);
}
.header {
position: relative;
+1 -1
View File
@@ -39,7 +39,7 @@ const FRAME_SIZES = [
].flat();
// Always rounds down, so no chart ends up with fewer frames than it asked for.
export function snapFrameSize(step: number): number {
function snapFrameSize(step: number): number {
if (step >= DAY) {
return Math.floor(step / DAY) * DAY;
}
+36 -195
View File
@@ -34,7 +34,6 @@ import type {
import { fireEvent } from "../../common/dom/fire_event";
import { listenMediaQuery } from "../../common/dom/media_query";
import { afterNextRender } from "../../common/util/render-status";
import { MobileAwareMixin } from "../../mixins/mobile-aware-mixin";
import { uiContext } from "../../data/context";
import type { Themes } from "../../data/ws-themes";
import type {
@@ -58,17 +57,7 @@ export const MIN_TIME_BETWEEN_UPDATES = 60 * 5 * 1000;
const LEGEND_OVERFLOW_LIMIT = 10;
const LEGEND_OVERFLOW_LIMIT_MOBILE = 6;
const DOUBLE_TAP_TIME = 300;
export const DEFAULT_CHART_WIDTH = 500;
// Slack so a chart is up to date before a scroll can reach it. A phone screen
// is short enough for a whole screenful; on a desktop that would cover the page.
const VISIBILITY_ROOT_MARGIN_NARROW = "100%";
const VISIBILITY_ROOT_MARGIN = "300px";
const DEFERRED_PROPS = [
"options",
"data",
"_hiddenDatasets",
"_isZoomed",
] as const;
const DEFAULT_CHART_WIDTH = 500;
type RawSeriesOption = Exclude<
NonNullable<ECOption["series"]>,
@@ -117,7 +106,7 @@ export type CustomLegendOption = ECOption["legend"] & {
};
@customElement("ha-chart-base")
export class HaChartBase extends MobileAwareMixin(LitElement) {
export class HaChartBase extends LitElement {
public chart?: EChartsType;
@property({ attribute: false }) public hass!: HomeAssistant;
@@ -194,38 +183,20 @@ export class HaChartBase extends MobileAwareMixin(LitElement) {
private _layoutTransitionActive = false;
// Both start visible so a chart on screen renders on its first update rather
// than waiting a frame for the observers. A chart that starts off screen is
// therefore still built once; only its later updates are gated.
private _intersecting = true;
private _hasSize = true;
// Reported by the ResizeObserver, so rendering and downsampling need not
// measure layout themselves once it has fired.
private _contentWidth?: number;
// @ts-ignore
private _resizeController = new ResizeController(this, {
callback: (entries) => {
// The controller also fires once with no entries when it starts observing.
const contentRect = entries[entries.length - 1]?.contentRect;
if (contentRect) {
this._contentWidth = contentRect.width;
this._hasSize = contentRect.width > 0 && contentRect.height > 0;
}
callback: () => {
if (this.chart) {
if (this._suspendResize) {
this._shouldResizeChart = true;
} else if (!this.chart.getZr().animation.isFinished()) {
return;
}
if (!this.chart.getZr().animation.isFinished()) {
this._shouldResizeChart = true;
} else {
this.chart.resize();
}
}
if (!this._suspendResize) {
this._applyDeferredWork();
}
},
});
@@ -239,24 +210,10 @@ export class HaChartBase extends MobileAwareMixin(LitElement) {
private _pendingSetup = false;
private _pendingUpdate?: Set<PropertyKey>;
private _pendingOptions?: HaECOption;
private _pendingZoom?: [number, number, boolean];
public disconnectedCallback() {
super.disconnectedCallback();
this._legendPointerCancel();
this._pendingSetup = false;
this._pendingUpdate = undefined;
this._pendingOptions = undefined;
this._pendingZoom = undefined;
// The observers are about to be torn down, so nothing would correct a stale
// value if this element is reattached inside a hidden container.
this._intersecting = false;
this._hasSize = false;
this._contentWidth = undefined;
while (this._listeners.length) {
this._listeners.pop()!();
}
@@ -270,30 +227,13 @@ export class HaChartBase extends MobileAwareMixin(LitElement) {
super.connectedCallback();
if (this.hasUpdated) {
this._pendingSetup = true;
afterNextRender(() => this._applyDeferredWork());
}
const handleVisibilityChange = () => this._applyDeferredWork();
document.addEventListener("visibilitychange", handleVisibilityChange);
this._listeners.push(() =>
document.removeEventListener("visibilitychange", handleVisibilityChange)
);
const intersectionObserver = new IntersectionObserver(
(entries) => {
this._intersecting = entries[entries.length - 1].isIntersecting;
if (!this._suspendResize) {
this._applyDeferredWork();
afterNextRender(() => {
if (this.isConnected && this._pendingSetup) {
this._pendingSetup = false;
this._setupChart();
}
},
{
rootMargin: this._isMobileSize
? VISIBILITY_ROOT_MARGIN_NARROW
: VISIBILITY_ROOT_MARGIN,
}
);
intersectionObserver.observe(this);
this._listeners.push(() => intersectionObserver.disconnect());
});
}
this._listeners.push(
listenMediaQuery("(prefers-reduced-motion)", (matches) => {
@@ -360,7 +300,6 @@ export class HaChartBase extends MobileAwareMixin(LitElement) {
this._suspendResize = this._layoutTransitionActive;
if (!this._suspendResize) {
this._resizeChartIfNeeded();
this._applyDeferredWork();
}
};
window.addEventListener("hass-layout-transition", handleLayoutTransition);
@@ -373,105 +312,24 @@ export class HaChartBase extends MobileAwareMixin(LitElement) {
}
protected firstUpdated() {
if (!this.isConnected) {
return;
}
// The only measurement taken here: neither observer has reported yet, and a
// chart first rendered inside a hidden container has to defer its setup
// rather than build against a guessed width.
this._hasSize = this.clientWidth > 0 && this.clientHeight > 0;
if (this._isVisible()) {
if (this.isConnected) {
this._setupChart();
} else {
this._pendingSetup = true;
}
}
private _isVisible() {
return (
document.visibilityState !== "hidden" &&
this._hasSize &&
this._intersecting
);
}
private _deferUpdate(changedProps: PropertyValues) {
for (const prop of DEFERRED_PROPS) {
if (!changedProps.has(prop)) {
continue;
}
if (!this._pendingUpdate) {
this._pendingUpdate = new Set();
}
if (prop === "options" && !this._pendingUpdate.has(prop)) {
// The one previous value a replay reads, and it has to stay the options
// the chart currently renders, not those of a later deferred change.
this._pendingOptions = changedProps.get(prop) as HaECOption | undefined;
}
this._pendingUpdate.add(prop);
}
}
private async _applyDeferredWork() {
if (!this._pendingSetup && !this._pendingUpdate) {
return;
}
if (!this.isConnected || !this._isVisible()) {
return;
}
if (this._pendingSetup) {
await this._setupChart();
return;
}
const pending = this._pendingUpdate!;
const previousOptions = this._pendingOptions;
this._pendingUpdate = undefined;
this._pendingOptions = undefined;
this._applyChartUpdate(pending, previousOptions);
}
public willUpdate(changedProps: PropertyValues): void {
if (!this.chart) {
return;
}
const themeChanged = changedProps.has("_themes") && this.hasUpdated;
if (!themeChanged && !DEFERRED_PROPS.some((p) => changedProps.has(p))) {
return;
}
const invisible = !this._isVisible();
if (themeChanged) {
if (invisible) {
this._pendingSetup = true;
this._pendingUpdate = undefined;
this._pendingOptions = undefined;
} else {
this._setupChart();
}
if (changedProps.has("_themes") && this.hasUpdated) {
this._setupChart();
return;
}
let chartOptions: ECOption = {};
if (changedProps.has("options")) {
// Separate 'if' from below since this must be updated before _getSeries().
// It stays out of _applyChartUpdate so a replay cannot request another
// update and turn one catch-up render into two.
// Separate 'if' from below since this must updated before _getSeries()
this._updateHiddenStatsFromOptions(this.options);
}
if (invisible) {
if (!this._pendingSetup) {
this._deferUpdate(changedProps);
}
return;
}
this._applyChartUpdate(
changedProps,
changedProps.get("options") as HaECOption | undefined
);
}
private _applyChartUpdate(
changedProps: { has: (prop: string) => boolean },
previousOptions: HaECOption | undefined
) {
let chartOptions: ECOption = {};
if (changedProps.has("data") || changedProps.has("_hiddenDatasets")) {
chartOptions.series = this._getSeries();
// New data, or a series shown again, may well be convertible where the
@@ -489,7 +347,12 @@ export class HaChartBase extends MobileAwareMixin(LitElement) {
}
if (changedProps.has("options")) {
chartOptions = { ...chartOptions, ...this._createOptions() };
if (this._compareCustomLegendOptions(previousOptions, this.options)) {
if (
this._compareCustomLegendOptions(
changedProps.get("options"),
this.options
)
) {
// custom legend changes may require a resize to layout properly
this._shouldResizeChart = true;
this._resizeAnimationDuration = 250;
@@ -600,7 +463,10 @@ export class HaChartBase extends MobileAwareMixin(LitElement) {
}
}
const overflowLimit = this._isMobileSize
const isMobile = window.matchMedia(
"all and (max-width: 450px), all and (max-height: 500px)"
).matches;
const overflowLimit = isMobile
? LEGEND_OVERFLOW_LIMIT_MOBILE
: LEGEND_OVERFLOW_LIMIT;
return html`<div
@@ -712,11 +578,7 @@ export class HaChartBase extends MobileAwareMixin(LitElement) {
// costs the user their place in the tab order, so stay programmatically
// focusable for as long as we hold focus, however we stop being sonifiable.
this._sonificationFocusHeld = true;
if (this._sonification || this._sonificationLoading) {
return;
}
await this._applyDeferredWork();
if (!this.chart) {
if (this._sonification || this._sonificationLoading || !this.chart) {
return;
}
this._sonificationLoading = true;
@@ -773,22 +635,14 @@ export class HaChartBase extends MobileAwareMixin(LitElement) {
);
private async _setupChart() {
if (this._loading) {
this._pendingSetup = true;
return;
}
if (this._loading) return;
this._loading = true;
this._pendingSetup = false;
this._pendingUpdate = undefined;
this._pendingOptions = undefined;
try {
// The connection holds a reference to the chart instance, so it cannot
// outlive it. Focusing the chart again reconnects.
this._disposeSonification();
if (this.chart) {
this.chart.dispose();
this.chart = undefined;
this._originalZrFlush = undefined;
}
const echarts = (await import("../../resources/echarts/echarts")).default;
@@ -926,14 +780,8 @@ export class HaChartBase extends MobileAwareMixin(LitElement) {
series: this._getSeries(),
});
this._updateSankeyRoam();
if (this._pendingZoom) {
const [start, end, silent] = this._pendingZoom;
this._pendingZoom = undefined;
this.chart.dispatchAction({ type: "dataZoom", start, end, silent });
}
} finally {
this._loading = false;
this._applyDeferredWork();
}
}
@@ -1079,7 +927,9 @@ export class HaChartBase extends MobileAwareMixin(LitElement) {
};
if (options.tooltip) {
const isMobile = this._isMobileSize;
const isMobile = window.matchMedia(
"all and (max-width: 450px), all and (max-height: 500px)"
).matches;
// Shallow-copy each tooltip object so wrap/mobile mutations don't leak
// back into the caller's options.tooltip reference (callers may cache the
// options object via memoizeOne, in which case in-place mutation would
@@ -1316,8 +1166,8 @@ export class HaChartBase extends MobileAwareMixin(LitElement) {
data: downSampleLineData(
data as LineSeriesOption["data"],
// 0 while inside a hidden container, e.g. a section with a visibility condition
((this._contentWidth ?? this.clientWidth) ||
DEFAULT_CHART_WIDTH) * window.devicePixelRatio,
(this.clientWidth || DEFAULT_CHART_WIDTH) *
window.devicePixelRatio,
minX,
maxX
),
@@ -1330,7 +1180,7 @@ export class HaChartBase extends MobileAwareMixin(LitElement) {
}
private _getDefaultHeight() {
return Math.max((this._contentWidth ?? this.clientWidth) / 2, 200);
return Math.max(this.clientWidth / 2, 200);
}
private _setChartOptions(options: ECOption) {
@@ -1397,16 +1247,7 @@ export class HaChartBase extends MobileAwareMixin(LitElement) {
};
public zoom(start: number, end: number, silent = false) {
if (!this.chart || this._pendingSetup) {
// Sibling charts sync their zoom imperatively, so a range that arrives
// before a deferred setup or rebuild has to be replayed rather than
// dropped. A reset to the full range is what a fresh chart is built with,
// so it just clears.
this._pendingZoom =
start === 0 && end === 100 ? undefined : [start, end, silent];
return;
}
this.chart.dispatchAction({
this.chart?.dispatchAction({
type: "dataZoom",
start,
end,
+1 -1
View File
@@ -12,6 +12,7 @@ import type { HaECOption } from "../../resources/echarts/echarts";
import { measureTextWidth } from "../../util/text";
import "./ha-chart-base";
import "./ha-chart-tooltip-marker";
import { NODE_SIZE } from "../trace/hat-graph-const";
import "../ha-alert";
export interface Node {
@@ -42,7 +43,6 @@ const OVERFLOW_MARGIN = 5;
const FONT_SIZE = 12;
const NODE_GAP = 6;
const LABEL_DISTANCE = 5;
const NODE_SIZE = 30;
const LABEL_MIN_MARGIN = 5;
const BIDI_MARKS = /[\u200E\u200F\u202A-\u202E\u2066-\u2069]/g;
@@ -1,258 +0,0 @@
import type {
CustomSeriesOption,
CustomSeriesRenderItem,
} from "echarts/types/dist/shared";
import type { HassEntities } from "home-assistant-js-websocket";
import { hex2rgb } from "../../common/color/convert-color";
import { luminosity } from "../../common/color/rgb";
import type { TimelineEntity } from "../../data/history";
import { snapFrameSize } from "./down-sample";
import { computeTimelineColor } from "./timeline-color";
export interface StateHistoryChartTimelineDataParams {
states: HassEntities;
data: TimelineEntity[];
startTime: Date;
endTime: Date;
names?: Record<string, string>;
showNames: boolean;
computedStyles: CSSStyleDeclaration;
renderItem: CustomSeriesRenderItem;
/** Chart width in device pixels; bounds how many rectangles are emitted. */
chartWidth: number;
}
export interface TimelineSegment {
state: string;
locState: string | null;
start: number;
end: number;
}
/** Resolves each frame of a run to the state covering most of that frame. */
function collapseRun(
segments: TimelineSegment[],
from: number,
to: number,
frameMs: number,
push: (segment: TimelineSegment) => void
) {
const runEnd = segments[to - 1].end;
const frameStates = new Map<
string,
{ duration: number; locState: string | null }
>();
let frameStart = segments[from].start;
let index = from;
while (frameStart < runEnd) {
const boundary = (Math.floor(frameStart / frameMs) + 1) * frameMs;
// a frame size that rounds back onto frameStart would never advance
const next = boundary > frameStart ? boundary : frameStart + frameMs;
const frameEnd = next < runEnd ? next : runEnd;
frameStates.clear();
let bestState: string | null = null;
let bestLocState: string | null = null;
let bestDuration = 0;
// Segments are narrower than a frame, so each is visited at most twice:
// index stops at the one spilling into the next frame.
let cursor = index;
while (cursor < to && segments[cursor].start < frameEnd) {
const segment = segments[cursor];
cursor++;
const overlapStart =
segment.start > frameStart ? segment.start : frameStart;
const overlapEnd = segment.end < frameEnd ? segment.end : frameEnd;
if (overlapEnd <= overlapStart) {
continue;
}
let entry = frameStates.get(segment.state);
if (entry) {
entry.duration += overlapEnd - overlapStart;
} else {
entry = {
duration: overlapEnd - overlapStart,
locState: segment.locState,
};
frameStates.set(segment.state, entry);
}
if (entry.duration > bestDuration) {
bestDuration = entry.duration;
bestState = segment.state;
bestLocState = entry.locState;
}
}
while (index < to && segments[index].end <= frameEnd) {
index++;
}
if (bestState !== null) {
push({
state: bestState,
locState: bestLocState,
start: frameStart,
end: frameEnd,
});
}
frameStart = frameEnd;
}
}
/**
* Bounds the rectangle count by the chart's pixel width. Segments at least one
* frame wide are kept as they are; narrower ones are resolved per frame, and
* neighbours resolving to the same state merge into one rectangle.
*/
export function downSampleTimelineSegments(
segments: TimelineSegment[],
frameMs: number
): TimelineSegment[] {
if (!(frameMs > 0)) {
return segments;
}
const result: TimelineSegment[] = [];
const push = (segment: TimelineSegment) => {
const last = result[result.length - 1];
if (last && last.state === segment.state && last.end === segment.start) {
last.end = segment.end;
return;
}
result.push(segment);
};
let index = 0;
while (index < segments.length) {
if (segments[index].end - segments[index].start >= frameMs) {
push({ ...segments[index] });
index++;
continue;
}
const from = index;
index++;
// A gap of its own frame or more stays a gap; a narrower one is invisible
// and is absorbed, so that a row of gap-separated slivers stays bounded.
while (
index < segments.length &&
segments[index].end - segments[index].start < frameMs &&
segments[index].start - segments[index - 1].end < frameMs
) {
index++;
}
collapseRun(segments, from, index, frameMs, push);
}
return result;
}
/**
* Transforms processed history (`TimelineEntity[]`) into ECharts custom series
* for `state-history-chart-timeline`. Pure data processing: all environment
* inputs (theme style, entity states, chart width, the render callback) are injected so
* the transform is deterministic and benchmarkable.
*/
export function generateStateHistoryChartTimelineData(
params: StateHistoryChartTimelineDataParams
): CustomSeriesOption[] {
const { states, computedStyles, startTime, endTime, renderItem } = params;
const stateHistory = params.data ?? [];
const startTimeMs = startTime.getTime();
const endTimeMs = endTime.getTime();
// Snapped, and placed on absolute time, so a chart following "now" keeps
// resolving the same frames instead of reshaping on every refresh.
const rawFrameMs = Math.ceil(
(endTimeMs - startTimeMs) / Math.floor(params.chartWidth)
);
const frameMs =
Number.isFinite(rawFrameMs) && rawFrameMs > 0
? snapFrameSize(rawFrameMs)
: 0;
const datasets: CustomSeriesOption[] = [];
const names = params.names || {};
// stateHistory is a list of lists of sorted state objects
stateHistory.forEach((stateInfo) => {
let prevState: string | null = null;
let locState: string | null = null;
let prevLastChanged = startTimeMs;
const entityDisplay: string = params.showNames
? names[stateInfo.entity_id] || stateInfo.name || stateInfo.entity_id
: "";
const segments: TimelineSegment[] = [];
stateInfo.data.forEach((entityState) => {
let newState: string | null = entityState.state;
const timeStamp = entityState.last_changed;
if (!newState) {
newState = null;
}
if (timeStamp > endTimeMs) {
// Drop datapoints that are after the requested endTime. This could happen if
// endTime is 'now' and client time is not in sync with server time.
return;
}
if (prevState === null) {
prevState = newState;
locState = entityState.state_localize;
prevLastChanged = timeStamp;
} else if (newState !== prevState) {
segments.push({
state: prevState,
locState,
start: prevLastChanged,
end: timeStamp,
});
prevState = newState;
locState = entityState.state_localize;
prevLastChanged = timeStamp;
}
});
if (prevState !== null) {
segments.push({
state: prevState,
locState,
start: prevLastChanged,
end: endTimeMs,
});
}
const stateObj = states[stateInfo.entity_id];
const dataRow = downSampleTimelineSegments(segments, frameMs).map(
(segment) => {
const color = computeTimelineColor(
segment.state,
computedStyles,
stateObj
);
return {
value: [
stateInfo.entity_id,
new Date(segment.start),
new Date(segment.end),
segment.locState,
color,
luminosity(hex2rgb(color)) > 0.5 ? "#000" : "#fff",
],
itemStyle: {
color,
},
};
}
);
datasets.push({
id: stateInfo.entity_id,
data: dataRow,
name: entityDisplay,
dimensions: ["id", "start", "end", "name", "color", "textColor"],
type: "custom",
encode: {
x: [1, 2],
y: 0,
itemName: 3,
},
renderItem,
progressive: 0,
});
});
return datasets;
}
@@ -11,14 +11,16 @@ import millisecondsToDuration from "../../common/datetime/milliseconds_to_durati
import { computeRTL } from "../../common/util/compute_rtl";
import type { TimelineEntity } from "../../data/history";
import type { HomeAssistant } from "../../types";
import { DEFAULT_CHART_WIDTH, MIN_TIME_BETWEEN_UPDATES } from "./ha-chart-base";
import { MIN_TIME_BETWEEN_UPDATES } from "./ha-chart-base";
import { itemTooltipPosition } from "./chart-tooltip-position";
import "./ha-chart-tooltip-marker";
import { computeTimelineColor } from "./timeline-color";
import type { HaECOption, HaECSeries } from "../../resources/echarts/echarts";
import echarts from "../../resources/echarts/echarts";
import { luminosity } from "../../common/color/rgb";
import { hex2rgb } from "../../common/color/convert-color";
import { measureTextWidth } from "../../util/text";
import { fireEvent, type HASSDomEvent } from "../../common/dom/fire_event";
import { generateStateHistoryChartTimelineData } from "./state-history-chart-timeline-data";
const ROW_HEIGHT = 30;
// Taller rows when the name is drawn under the bar instead of in a column.
@@ -313,20 +315,109 @@ export class StateHistoryChartTimeline extends LitElement {
}
private _generateData() {
const computedStyles = getComputedStyle(this);
let stateHistory = this.data;
if (!stateHistory) {
stateHistory = [];
}
this._chartTime = new Date();
this._chartData = generateStateHistoryChartTimelineData({
states: this.hass.states,
data: this.data,
startTime: this.startTime,
endTime: this.endTime,
names: this.names,
showNames: this.showNames,
computedStyles: getComputedStyle(this),
renderItem: this._renderItem,
// 0 while inside a hidden container, e.g. a section with a visibility condition
chartWidth:
(this.clientWidth || DEFAULT_CHART_WIDTH) * window.devicePixelRatio,
const startTime = this.startTime;
const endTime = this.endTime;
const datasets: CustomSeriesOption[] = [];
const names = this.names || {};
// stateHistory is a list of lists of sorted state objects
stateHistory.forEach((stateInfo) => {
let newLastChanged: Date;
let prevState: string | null = null;
let locState: string | null = null;
let prevLastChanged = startTime;
const entityDisplay: string = this.showNames
? names[stateInfo.entity_id] || stateInfo.name || stateInfo.entity_id
: "";
const dataRow: unknown[] = [];
stateInfo.data.forEach((entityState) => {
let newState: string | null = entityState.state;
const timeStamp = new Date(entityState.last_changed);
if (!newState) {
newState = null;
}
if (timeStamp > endTime) {
// Drop datapoints that are after the requested endTime. This could happen if
// endTime is 'now' and client time is not in sync with server time.
return;
}
if (prevState === null) {
prevState = newState;
locState = entityState.state_localize;
prevLastChanged = new Date(entityState.last_changed);
} else if (newState !== prevState) {
newLastChanged = new Date(entityState.last_changed);
const color = computeTimelineColor(
prevState,
computedStyles,
this.hass.states[stateInfo.entity_id]
);
dataRow.push({
value: [
stateInfo.entity_id,
prevLastChanged,
newLastChanged,
locState,
color,
luminosity(hex2rgb(color)) > 0.5 ? "#000" : "#fff",
],
itemStyle: {
color,
},
});
prevState = newState;
locState = entityState.state_localize;
prevLastChanged = newLastChanged;
}
});
if (prevState !== null) {
const color = computeTimelineColor(
prevState,
computedStyles,
this.hass.states[stateInfo.entity_id]
);
dataRow.push({
value: [
stateInfo.entity_id,
prevLastChanged,
endTime,
locState,
color,
luminosity(hex2rgb(color)) > 0.5 ? "#000" : "#fff",
],
itemStyle: {
color,
},
});
}
datasets.push({
id: stateInfo.entity_id,
data: dataRow,
name: entityDisplay,
dimensions: ["id", "start", "end", "name", "color", "textColor"],
type: "custom",
encode: {
x: [1, 2],
y: 0,
itemName: 3,
},
renderItem: this._renderItem,
progressive: 0,
});
});
this._chartData = datasets;
}
private _handleChartClick(
@@ -343,9 +434,6 @@ export class StateHistoryChartTimeline extends LitElement {
}
static styles = css`
:host {
display: block;
}
ha-chart-base {
--chart-max-height: none;
}
+1 -1
View File
@@ -375,7 +375,7 @@ export class StateHistoryCharts extends LitElement {
const chartBase =
chartComponent.renderRoot?.querySelector("ha-chart-base");
if (chartBase) {
if (chartBase && chartBase.chart) {
chartBase.zoom(0, 100);
}
});
+12 -38
View File
@@ -125,23 +125,7 @@ export class StatisticsChart extends LitElement {
private _yAxisFractionDigits = 1;
protected shouldUpdate(changedProps: PropertyValues<this>): boolean {
return (
changedProps.size > 1 ||
!changedProps.has("hass") ||
this._entityNamesChanged(changedProps)
);
}
// Series names are resolved once and cached in _chartData, so a hass update
// that only replaces the formatters has to regenerate them. The formatters are
// swapped as a set whenever the registries change, which is what renames a
// series.
private _entityNamesChanged(changedProps: PropertyValues): boolean {
if (!changedProps.has("hass")) {
return false;
}
const oldHass = changedProps.get("hass") as HomeAssistant | undefined;
return !!oldHass && oldHass.formatEntityName !== this.hass.formatEntityName;
return changedProps.size > 1 || !changedProps.has("hass");
}
public willUpdate(changedProps: PropertyValues) {
@@ -151,8 +135,7 @@ export class StatisticsChart extends LitElement {
changedProps.has("chartType") ||
changedProps.has("hideLegend") ||
changedProps.has("_hiddenStats") ||
changedProps.has("names") ||
this._entityNamesChanged(changedProps)
changedProps.has("names")
) {
this._generateData();
}
@@ -549,26 +532,17 @@ export class StatisticsChart extends LitElement {
this.unit = data.unit;
this._yAxisFractionDigits = data.yAxisFractionDigits;
this._chartData = data.datasets;
const legendData =
data.legendData.length > 1
? data.legendData.map(({ id, name, noLabelClick }) => ({
id,
name,
noLabelClick,
}))
: // if there is only one entity, let the base chart handle the legend
undefined;
if (
legendData?.length !== this._legendData?.length ||
legendData?.some(
(item, index) =>
item.id !== this._legendData?.[index]?.id ||
item.name !== this._legendData?.[index]?.name ||
item.noLabelClick !== this._legendData?.[index]?.noLabelClick
)
) {
if (data.legendData.length !== this._legendData?.length) {
// only update the legend if it has changed or it will trigger options update
this._legendData = legendData;
this._legendData =
data.legendData.length > 1
? data.legendData.map(({ id, name, noLabelClick }) => ({
id,
name,
noLabelClick,
}))
: // if there is only one entity, let the base chart handle the legend
undefined;
}
this._statisticIds = data.statisticIds;
}
@@ -1202,20 +1202,6 @@ export class HaDataTable extends LitElement {
overflow: auto;
}
:host([narrow]) .mdc-data-table {
width: calc(
100% + var(--safe-area-inset-left, 0px) +
var(--safe-area-inset-right, 0px)
);
margin-left: calc(-1 * var(--safe-area-inset-left, 0px));
margin-right: calc(-1 * var(--safe-area-inset-right, 0px));
overflow: visible;
}
:host([narrow]) .mdc-data-table__header-row {
overflow: visible;
}
/* Hide scrollbar for Chrome, Safari and Opera */
.mdc-data-table__header-row::-webkit-scrollbar {
display: none;
@@ -1513,16 +1499,6 @@ export class HaDataTable extends LitElement {
overflow: overlay !important;
}
:host([narrow]) .mdc-data-table__row {
box-sizing: border-box;
padding-left: var(--safe-area-inset-left, 0px);
padding-right: var(--safe-area-inset-right, 0px);
}
:host([narrow]) .mdc-data-table__row:has(.group-header) {
background-color: var(--primary-background-color);
}
.mdc-data-table__table.auto-height .scroller {
overflow-y: hidden !important;
}
+78 -37
View File
@@ -11,10 +11,9 @@ import { customElement, property, query } from "lit/decorators";
import memoizeOne from "memoize-one";
import { ensureArray } from "../../common/array/ensure-array";
import { type HASSDomEvent, fireEvent } from "../../common/dom/fire_event";
import {
computeEntityPickerDisplay,
computeEntitySearchLabels,
} from "../../common/entity/compute_entity_name_display";
import { computeEntityNameList } from "../../common/entity/compute_entity_name_display";
import { computeStateName } from "../../common/entity/compute_state_name";
import { computeRTL } from "../../common/util/compute_rtl";
import { domainToName } from "../../data/integration";
import {
getStatisticLabel,
@@ -234,6 +233,11 @@ export class HaStatisticPicker extends LitElement {
});
}
const isRTL = computeRTL(
hass.language,
hass.translationMetadata.translations
);
const output: StatisticComboBoxItem[] = [];
statisticIds.forEach((meta) => {
@@ -287,17 +291,31 @@ export class HaStatisticPicker extends LitElement {
}
const id = meta.statistic_id;
const { primary, secondary } = computeEntityPickerDisplay(
hass,
stateObj
);
const searchLabels = computeEntitySearchLabels(
stateObj,
hass.entities,
hass.devices,
hass.areas,
hass.floors
);
const friendlyName = computeStateName(stateObj); // Keep this for search
const [entityName, deviceName, parentDeviceName, areaName] =
computeEntityNameList(
stateObj,
[
{ type: "entity" },
{ type: "device" },
{ type: "parent_device" },
{ type: "area" },
],
hass.entities,
hass.devices,
hass.areas,
hass.floors
);
const primary = entityName || deviceName || id;
const secondary = [
areaName,
parentDeviceName,
entityName ? deviceName : undefined,
]
.filter(Boolean)
.join(isRTL ? " ◂ " : " ▸ ");
const sortingPrefix = `${TYPE_ORDER.indexOf("entity")}`;
output.push({
@@ -307,12 +325,14 @@ export class HaStatisticPicker extends LitElement {
secondary,
stateObj: stateObj,
type: "entity",
sorting_label: [
sortingPrefix,
searchLabels.deviceName,
searchLabels.entityName,
].join("_"),
search_labels: searchLabels,
sorting_label: [sortingPrefix, deviceName, entityName].join("_"),
search_labels: {
entityName: entityName || null,
deviceName: deviceName || null,
parentDeviceName: parentDeviceName || null,
areaName: areaName || null,
friendlyName,
},
});
});
@@ -387,18 +407,36 @@ export class HaStatisticPicker extends LitElement {
const stateObj = this.hass.states[statisticId];
if (stateObj) {
const { primary, secondary } = computeEntityPickerDisplay(
this.hass,
stateObj
);
const searchLabels = computeEntitySearchLabels(
stateObj,
this.hass.entities,
this.hass.devices,
this.hass.areas,
this.hass.floors
const [entityName, deviceName, parentDeviceName, areaName] =
computeEntityNameList(
stateObj,
[
{ type: "entity" },
{ type: "device" },
{ type: "parent_device" },
{ type: "area" },
],
this.hass.entities,
this.hass.devices,
this.hass.areas,
this.hass.floors
);
const isRTL = computeRTL(
this.hass.language,
this.hass.translationMetadata.translations
);
const primary = entityName || deviceName || statisticId;
const secondary = [
areaName,
parentDeviceName,
entityName ? deviceName : undefined,
]
.filter(Boolean)
.join(isRTL ? " ◂ " : " ▸ ");
const friendlyName = computeStateName(stateObj); // Keep this for search
const sortingPrefix = `${TYPE_ORDER.indexOf("entity")}`;
return {
id: statisticId,
@@ -407,12 +445,15 @@ export class HaStatisticPicker extends LitElement {
secondary,
stateObj: stateObj,
type: "entity",
sorting_label: [
sortingPrefix,
searchLabels.deviceName,
searchLabels.entityName,
].join("_"),
search_labels: { ...searchLabels, statisticId },
sorting_label: [sortingPrefix, deviceName, entityName].join("_"),
search_labels: {
entityName: entityName || null,
deviceName: deviceName || null,
parentDeviceName: parentDeviceName || null,
areaName: areaName || null,
friendlyName,
statisticId,
},
};
}
+30 -5
View File
@@ -3,7 +3,8 @@ import { css, html, LitElement, nothing } from "lit";
import { customElement, property } from "lit/decorators";
import Fuse from "fuse.js";
import memoizeOne from "memoize-one";
import { computeEntityPickerDisplay } from "../common/entity/compute_entity_name_display";
import { computeEntityNameList } from "../common/entity/compute_entity_name_display";
import { computeRTL } from "../common/util/compute_rtl";
import type { LocalizeFunc } from "../common/translations/localize";
import {
multiTermSortedSearch,
@@ -182,6 +183,11 @@ export class HaAreaControlsPicker extends LitElement {
const allEntityIds = Object.values(controlEntities).flat();
const uniqueEntityIds = Array.from(new Set(allEntityIds));
const isRTL = computeRTL(
this.hass.language,
this.hass.translationMetadata.translations
);
uniqueEntityIds.forEach((entityId) => {
if (isSelected(entityId)) {
return;
@@ -191,10 +197,29 @@ export class HaAreaControlsPicker extends LitElement {
return;
}
const { primary, secondary } = computeEntityPickerDisplay(
this.hass!,
stateObj
);
const [entityName, deviceName, parentDeviceName, areaName] =
computeEntityNameList(
stateObj,
[
{ type: "entity" },
{ type: "device" },
{ type: "parent_device" },
{ type: "area" },
],
this.hass!.entities,
this.hass!.devices,
this.hass!.areas,
this.hass!.floors
);
const primary = entityName || deviceName || entityId;
const secondary = [
areaName,
parentDeviceName,
entityName ? deviceName : undefined,
]
.filter(Boolean)
.join(isRTL ? " ◂ " : " ▸ ");
entityItems.push({
type: "entity",
+2 -3
View File
@@ -22,7 +22,6 @@ import {
type ConversationChatLogToolResultDelta,
type PipelineRunEvent,
} from "../data/assist_pipeline";
import type { ChatLogToolResult } from "../data/chat_log";
import {
configContext,
connectionContext,
@@ -59,7 +58,7 @@ interface AssistMessage {
{
tool_name: string;
tool_args: Record<string, unknown>;
result?: ChatLogToolResult;
result?: any;
}
>;
error?: boolean;
@@ -835,7 +834,7 @@ ${JSON.stringify(toolCall.result, null, 2)}</pre>
} else if (isToolResult(delta)) {
if (progress.hassMessage.tool_calls[delta.tool_call_id]) {
progress.hassMessage.tool_calls[delta.tool_call_id].result =
delta.result;
delta.tool_result;
this.requestUpdate("_conversation");
}
}
+2 -2
View File
@@ -42,8 +42,8 @@ export class HaCard extends LitElement {
font-family: var(--ha-card-header-font-family, inherit);
font-size: var(--ha-card-header-font-size, var(--ha-font-size-2xl));
letter-spacing: -0.012em;
line-height: var(--ha-line-height-condensed);
padding: var(--ha-space-5) var(--ha-space-4) var(--ha-space-6);
line-height: var(--ha-line-height-expanded);
padding: var(--ha-space-3) var(--ha-space-4) var(--ha-space-4);
display: block;
margin-block-start: 0;
margin-block-end: 0;
@@ -22,6 +22,7 @@ import "./ha-list";
import "./ha-list-item";
import "./voice-assistant-brand-icon";
import { voiceAssistants } from "../data/expose";
import "../panels/config/voice-assistants/expose/expose-assistant-icon";
@customElement("ha-filter-voice-assistants")
export class HaFilterVoiceAssistants extends LitElement {
@@ -3,6 +3,7 @@ import { css, html, LitElement } from "lit";
import { customElement, property, query } from "lit/decorators";
import { fireEvent } from "../../common/dom/fire_event";
import type { HASSDomTargetEvent } from "../../common/dom/fire_event";
import "../ha-check-list-item";
import "../ha-checkbox";
import type { HaCheckbox } from "../ha-checkbox";
import "../ha-dropdown";
+1 -1
View File
@@ -360,7 +360,7 @@ export class HaGenericPicker extends PickerMixin(LitElement) {
this._initialFieldValue = undefined;
}
if (
this._hassConfig?.auth?.external &&
this._hassConfig?.auth.external &&
isIosApp(this._hassConfig.auth.external)
) {
this._hassConfig.auth.external.fireMessage({
+1 -8
View File
@@ -6,7 +6,6 @@ import { css, html, LitElement, nothing } from "lit";
import { customElement, state } from "lit/decorators";
import { fireEvent } from "../common/dom/fire_event";
import {
configContext,
connectionContext,
narrowViewportContext,
uiContext,
@@ -33,10 +32,6 @@ class HaMenuButton extends LitElement {
@consume({ context: uiContext, subscribe: true })
private _ui?: ContextType<typeof uiContext>;
@state()
@consume({ context: configContext, subscribe: true })
private _config?: ContextType<typeof configContext>;
@state() private _hasNotifications = false;
@state() private _show = false;
@@ -87,8 +82,7 @@ class HaMenuButton extends LitElement {
if (
!changedProps.has("_narrow") &&
!changedProps.has("_ui") &&
!changedProps.has("_connection") &&
!changedProps.has("_config")
!changedProps.has("_connection")
) {
return;
}
@@ -100,7 +94,6 @@ class HaMenuButton extends LitElement {
const showButton =
this._ui?.kioskMode === false &&
this._config?.auth.external?.config.hasSidebar !== true &&
(this._narrow || this._ui.dockedSidebar === "always_hidden");
this._show = showButton || this._alwaysVisible;
+26
View File
@@ -0,0 +1,26 @@
import { OutlinedButton } from "@material/web/button/internal/outlined-button";
import { styles as sharedStyles } from "@material/web/button/internal/shared-styles.cssresult.js";
import { styles } from "@material/web/button/internal/outlined-styles.cssresult.js";
import { css } from "lit";
import { customElement } from "lit/decorators";
@customElement("ha-outlined-button")
export class HaOutlinedButton extends OutlinedButton {
static override styles = [
sharedStyles,
styles,
css`
:host {
--ha-icon-display: block;
--md-sys-color-primary: var(--primary-text-color);
--md-sys-color-outline: var(--outline-color);
}
`,
];
}
declare global {
interface HTMLElementTagNameMap {
"ha-outlined-button": HaOutlinedButton;
}
}
+36
View File
@@ -0,0 +1,36 @@
import { OutlinedField } from "@material/web/field/internal/outlined-field";
import { styles } from "@material/web/field/internal/outlined-styles.cssresult.js";
import { styles as sharedStyles } from "@material/web/field/internal/shared-styles.cssresult.js";
import { css } from "lit";
import { customElement } from "lit/decorators";
import { literal } from "lit/static-html";
@customElement("ha-outlined-field")
export class HaOutlinedField extends OutlinedField {
protected readonly fieldTag = literal`ha-outlined-field`;
static override styles = [
sharedStyles,
styles,
css`
.container::before {
display: block;
content: "";
position: absolute;
inset: 0;
background-color: var(--ha-outlined-field-container-color, transparent);
opacity: var(--ha-outlined-field-container-opacity, 1);
border-start-start-radius: var(--_container-shape-start-start);
border-start-end-radius: var(--_container-shape-start-end);
border-end-start-radius: var(--_container-shape-end-start);
border-end-end-radius: var(--_container-shape-end-end);
}
`,
];
}
declare global {
interface HTMLElementTagNameMap {
"ha-outlined-field": HaOutlinedField;
}
}
+17 -126
View File
@@ -1,6 +1,6 @@
import type { PropertyValues } from "lit";
import { css, html, LitElement } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import { customElement, query, state } from "lit/decorators";
import { fireEvent } from "../common/dom/fire_event";
import { BOTTOM_SHEET_ANIMATION_DURATION_MS } from "./ha-bottom-sheet";
@@ -11,80 +11,39 @@ import { BOTTOM_SHEET_ANIMATION_DURATION_MS } from "./ha-bottom-sheet";
* the sheet by dragging the handle at the top. It supports both mouse and touch
* interactions and automatically closes when dragged below a 20% of screen height.
*
* A persistent sheet never closes by dragging; it stops at its minimum height
* instead, so a host can keep a collapsed strip of content reachable.
*
* @fires bottom-sheet-closed - Fired when the bottom sheet is closed
* @fires bottom-sheet-resized - Fired with the sheet's height in pixels once
* it has opened and whenever a drag ends
*
* @cssprop --ha-bottom-sheet-border-width - Border width for the sheet
* @cssprop --ha-bottom-sheet-border-style - Border style for the sheet
* @cssprop --ha-bottom-sheet-border-color - Border color for the sheet
* @cssprop --ha-bottom-sheet-handle-padding - How far below the handle the
* grab area reaches; shrink it when content sits right under the handle
*/
@customElement("ha-resizable-bottom-sheet")
export class HaResizableBottomSheet extends LitElement {
@query("dialog") private _dialog!: HTMLDialogElement;
/** Dragging down stops at the minimum height instead of closing the sheet */
@property({ type: Boolean }) public persistent = false;
/**
* The height in pixels the sheet cannot be dragged below, e.g. what a host
* measures for the strip it wants to keep visible. Without it the sheet
* stops at 20% of the viewport.
*/
@property({ type: Number, attribute: "min-height" })
public minHeight?: number;
/**
* The largest share of the viewport the sheet opens at, in percent. It
* opens at its content height up to this, and can be dragged to 90 after.
*/
@property({ type: Number, attribute: "open-max-viewport-height" })
public openMaxViewportHeight = 70;
/**
* Whether the sheet may open smaller than 55% of the viewport, down to its
* content height and the minimum it can be dragged to.
*/
@property({ type: Boolean, attribute: "open-at-content-height" })
public openAtContentHeight = false;
private _dragging = false;
private _dragStartY = 0;
private _initialSize = 0;
private _opened = false;
@state() private _dialogMaxViewpointHeight = 70;
@state() private _dialogMaxViewpointHeight?: number;
@state() private _dialogMinViewpointHeight?: number;
@state() private _dialogMinViewpointHeight = 55;
@state() private _dialogViewportHeight?: number;
render() {
// Until it has opened, the sheet sizes to its content within the opening
// bounds; afterwards it keeps the height it settled on or was dragged to
const maxHeight =
this._dialogMaxViewpointHeight ?? this.openMaxViewportHeight;
const minHeight =
this._dialogMinViewpointHeight ??
(this.openAtContentHeight ? this._minViewportHeight() : 55);
return html`<dialog
open
@transitionend=${this._handleTransitionEnd}
style=${`
--height: ${this._dialogViewportHeight}vh;
--height: ${this._dialogViewportHeight}dvh;
--max-height: ${maxHeight}vh;
--max-height: ${maxHeight}dvh;
--min-height: ${minHeight}vh;
--min-height: ${minHeight}dvh;
--max-height: ${this._dialogMaxViewpointHeight}vh;
--max-height: ${this._dialogMaxViewpointHeight}dvh;
--min-height: ${this._dialogMinViewpointHeight}vh;
--min-height: ${this._dialogMinViewpointHeight}dvh;
`}
>
<div class="handle-wrapper">
@@ -124,9 +83,7 @@ export class HaResizableBottomSheet extends LitElement {
this._dialogViewportHeight =
(this._dialog.offsetHeight / window.innerHeight) * 100;
this._dialogMaxViewpointHeight = 90;
this._dialogMinViewpointHeight = this._minViewportHeight();
this._opened = true;
this._fireResized();
this._dialogMinViewpointHeight = 20;
} else {
// after close animation is done close dialog element and fire closed event
this._dialog.close();
@@ -145,7 +102,6 @@ export class HaResizableBottomSheet extends LitElement {
});
document.addEventListener("touchend", this._handleTouchEnd);
document.addEventListener("touchcancel", this._handleTouchEnd);
window.addEventListener("resize", this._handleViewportResize);
}
disconnectedCallback() {
@@ -157,16 +113,8 @@ export class HaResizableBottomSheet extends LitElement {
document.removeEventListener("touchmove", this._handleTouchMove);
document.removeEventListener("touchend", this._handleTouchEnd);
document.removeEventListener("touchcancel", this._handleTouchEnd);
window.removeEventListener("resize", this._handleViewportResize);
}
// minHeight is a pixel value, so its viewport-relative minimum depends on the
// window height; recompute it when the viewport changes (for example on
// rotation) so the cached percentage cannot clip the persistent peek content.
private _handleViewportResize = () => {
this._applyMinViewportHeight();
};
private _handleMouseDown = (ev: MouseEvent) => {
this._startDrag(ev.clientY);
};
@@ -208,10 +156,8 @@ export class HaResizableBottomSheet extends LitElement {
let newSize = this._initialSize + deltaVh;
newSize = Math.max(10, Math.min(90, newSize));
if (this.persistent) {
newSize = Math.max(this._minViewportHeight(), newSize);
} else if (newSize < 20 && deltaY < 0) {
// on drag down and below 20vh
// on drag down and below 20vh
if (newSize < 20 && deltaY < 0) {
this._endDrag();
this.closeSheet();
return;
@@ -220,45 +166,6 @@ export class HaResizableBottomSheet extends LitElement {
this._dialogViewportHeight = newSize;
}
private _minViewportHeight(): number {
return this.minHeight === undefined
? 20
: (this.minHeight / window.innerHeight) * 100;
}
protected updated(changedProperties: PropertyValues<this>) {
super.updated(changedProperties);
// A minimum set after opening applies right away
if (changedProperties.has("minHeight") && this._opened) {
this._applyMinViewportHeight();
}
}
// Applies the current pixel minimum as a viewport percentage and, once it has
// rendered, reports the size if it changed: raising the minimum can grow a
// sheet resting at the old one, and the host needs the new height to keep its
// overlay padding in step.
private _applyMinViewportHeight() {
if (!this._opened) {
return;
}
this._dialogMinViewpointHeight = this._minViewportHeight();
this.updateComplete.then(() => {
if (this._dialog.offsetHeight !== this._lastReportedHeight) {
this._fireResized();
}
});
}
private _lastReportedHeight?: number;
private _fireResized() {
this._lastReportedHeight = this._dialog.offsetHeight;
fireEvent(this, "bottom-sheet-resized", {
height: this._lastReportedHeight,
});
}
private _handleMouseUp = () => {
this._endDrag();
};
@@ -273,8 +180,6 @@ export class HaResizableBottomSheet extends LitElement {
}
this._dragging = false;
document.body.style.removeProperty("cursor");
// Hosts lay out around the sheet once, not on every move
this.updateComplete.then(() => this._fireResized());
}
static styles = css`
@@ -296,8 +201,7 @@ export class HaResizableBottomSheet extends LitElement {
justify-content: center;
align-items: center;
z-index: 7;
/* Extends the grab area over the content below the handle */
padding-bottom: var(--ha-bottom-sheet-handle-padding, 76px);
padding-bottom: 76px;
}
.handle-wrapper .handle::after {
content: "";
@@ -330,20 +234,8 @@ export class HaResizableBottomSheet extends LitElement {
top: 0;
inset-inline-start: 0;
position: fixed;
--sheet-inset-left: var(
--ha-bottom-sheet-inset-left,
var(--safe-area-inset-left)
);
--sheet-inset-right: var(
--ha-bottom-sheet-inset-right,
var(--safe-area-inset-right)
);
--sheet-border-width: var(--ha-bottom-sheet-border-width, 0px);
--sheet-side-borders: calc(2 * var(--sheet-border-width));
width: calc(
100% - var(--sheet-side-borders) - var(--sheet-inset-left) - var(
--sheet-inset-right
)
100% - 4px - var(--safe-area-inset-left) - var(--safe-area-inset-right)
);
max-width: 100%;
border: none;
@@ -365,14 +257,14 @@ export class HaResizableBottomSheet extends LitElement {
);
transform: translateY(100%);
transition: transform ${BOTTOM_SHEET_ANIMATION_DURATION_MS}ms ease;
border-top-width: var(--sheet-border-width);
border-right-width: var(--sheet-border-width);
border-left-width: var(--sheet-border-width);
border-top-width: var(--ha-bottom-sheet-border-width);
border-right-width: var(--ha-bottom-sheet-border-width);
border-left-width: var(--ha-bottom-sheet-border-width);
border-bottom-width: 0;
border-style: var(--ha-bottom-sheet-border-style);
border-color: var(--ha-bottom-sheet-border-color);
margin-left: var(--sheet-inset-left);
margin-right: var(--sheet-inset-right);
margin-left: var(--safe-area-inset-left);
margin-right: var(--safe-area-inset-right);
}
dialog.show {
@@ -388,6 +280,5 @@ declare global {
interface HASSDomEvents {
"bottom-sheet-closed": undefined;
"bottom-sheet-resized": { height: number };
}
}
@@ -127,12 +127,11 @@ export class HaLocationSelector extends LitElement {
? this.hass.config.longitude
: value.longitude,
radius: selector.location?.radius ? value?.radius || 1000 : undefined,
radius_color: selector.location?.color || zoneRadiusColor,
name: selector.location?.name,
radius_color: zoneRadiusColor,
icon:
selector.location?.icon ||
// No icon: show the name's initials, else a default marker
(selector.location?.name ? undefined : "mdi:map-marker"),
selector.location?.icon || selector.location?.radius
? "mdi:map-marker-radius"
: "mdi:map-marker",
location_editable: true,
radius_editable:
!!selector.location?.radius && !selector.location?.radius_readonly,
+42
View File
@@ -0,0 +1,42 @@
import { SubMenu } from "@material/web/menu/internal/submenu/sub-menu";
import { styles } from "@material/web/menu/internal/submenu/sub-menu-styles.cssresult.js";
import { css } from "lit";
import { customElement } from "lit/decorators";
@customElement("ha-sub-menu")
export class HaSubMenu extends SubMenu {
async show() {
super.show();
this.menu.hasOverflow = false;
}
static override styles = [
styles,
css`
:host {
--ha-icon-display: block;
--md-sys-color-primary: var(--primary-text-color);
--md-sys-color-on-primary: var(--primary-text-color);
--md-sys-color-secondary: var(--secondary-text-color);
--md-sys-color-surface: var(--card-background-color);
--md-sys-color-on-surface: var(--primary-text-color);
--md-sys-color-on-surface-variant: var(--secondary-text-color);
--md-sys-color-secondary-container: rgba(
var(--rgb-primary-color),
0.15
);
--md-sys-color-on-secondary-container: var(--text-primary-color);
--mdc-icon-size: 16px;
--md-sys-color-on-primary-container: var(--primary-text-color);
--md-sys-color-on-secondary-container: var(--primary-text-color);
}
`,
];
}
declare global {
interface HTMLElementTagNameMap {
"ha-sub-menu": HaSubMenu;
}
}
+2 -14
View File
@@ -52,23 +52,12 @@ export const haTopAppBarFixedStyles = css`
.row {
box-sizing: border-box;
display: flex;
width: calc(100% + var(--safe-area-inset-right, 0px));
padding-right: var(--safe-area-inset-right, 0px);
width: 100%;
align-items: center;
height: var(--header-height);
border-bottom: var(--app-header-border-bottom);
}
:host([narrow]) .row,
:host([narrow]) .sub-row {
width: calc(
100% + var(--safe-area-inset-left, 0px) +
var(--safe-area-inset-right, 0px)
);
margin-left: calc(-1 * var(--safe-area-inset-left, 0px));
padding-left: var(--safe-area-inset-left, 0px);
}
.top-app-bar.has-sub-row .row {
border-bottom: 0;
}
@@ -76,8 +65,7 @@ export const haTopAppBarFixedStyles = css`
.sub-row {
box-sizing: border-box;
display: block;
width: calc(100% + var(--safe-area-inset-right, 0px));
padding-right: var(--safe-area-inset-right, 0px);
width: 100%;
overflow: hidden;
border-bottom: var(--app-header-border-bottom);
}
@@ -1,17 +1,14 @@
import type { CSSResultGroup, PropertyValues } from "lit";
import { LitElement, css, html, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { mdiArrowRightThin, mdiDelete } from "@mdi/js";
import { mdiArrowRightThin } from "@mdi/js";
import { fireEvent } from "../common/dom/fire_event";
import type { HASSDomCurrentTargetEvent } from "../common/dom/fire_event";
import { computeAreaName } from "../common/entity/compute_area_name";
import type { Segment } from "../data/vacuum";
import { getVacuumSegments } from "../data/vacuum";
import { haStyle } from "../resources/styles";
import type { HomeAssistant } from "../types";
import "./ha-alert";
import "./ha-area-picker";
import "./ha-icon-button";
import "./ha-svg-icon";
type AreaSegmentMapping = Record<string, string[]>; // area ID -> segment IDs
@@ -79,8 +76,6 @@ export class HaVacuumSegmentAreaMapper extends LitElement {
// Group segments by group (if available)
const groupedSegments = this._groupSegments(this._segments);
const orphanedAreas = this._getOrphanedAreas();
return html`
${Object.entries(groupedSegments).map(
([groupName, segments]) => html`
@@ -88,71 +83,9 @@ export class HaVacuumSegmentAreaMapper extends LitElement {
${segments.map((segment) => this._renderSegment(segment))}
`
)}
${orphanedAreas.length ? this._renderOrphanedAreas(orphanedAreas) : nothing}
`;
}
private _getOrphanedAreas(): string[] {
if (!this.value || !this._segments || this._segments.length === 0) {
return [];
}
const liveIds = new Set(this._segments.map((segment) => segment.id));
return Object.entries(this.value)
.filter(([, segmentIds]) => segmentIds.some((id) => !liveIds.has(id)))
.map(([areaId]) => areaId);
}
private _renderOrphanedAreas(areaIds: string[]) {
return html`
<h2>
${this.hass.localize(
"ui.dialogs.vacuum_segment_mapping.orphaned_header"
)}
</h2>
<p class="orphaned-description">
${this.hass.localize(
"ui.dialogs.vacuum_segment_mapping.orphaned_description"
)}
</p>
${areaIds.map((areaId) => {
const area = this.hass.areas[areaId];
const name = (area ? computeAreaName(area) : undefined) || areaId;
return html`
<div class="orphaned-row">
<span class="orphaned-name">${name}</span>
<ha-icon-button
.path=${mdiDelete}
.label=${this.hass.localize(
"ui.dialogs.vacuum_segment_mapping.orphaned_remove",
{ name }
)}
data-area-id=${areaId}
@click=${this._removeOrphanedArea}
></ha-icon-button>
</div>
`;
})}
`;
}
private _removeOrphanedArea = (
ev: HASSDomCurrentTargetEvent<HTMLElementTagNameMap["ha-icon-button"]>
) => {
const areaId = ev.currentTarget.dataset.areaId;
if (!areaId || !this.value || !this._segments) {
return;
}
const liveIds = new Set(this._segments.map((segment) => segment.id));
const newMapping: AreaSegmentMapping = { ...this.value };
const kept = (newMapping[areaId] ?? []).filter((id) => liveIds.has(id));
if (kept.length) {
newMapping[areaId] = kept;
} else {
delete newMapping[areaId];
}
fireEvent(this, "value-changed", { value: newMapping });
};
private _groupSegments(segments: Segment[]): Record<string, Segment[]> {
const grouped: Record<string, Segment[]> = {};
@@ -276,33 +209,8 @@ export class HaVacuumSegmentAreaMapper extends LitElement {
}
h2 {
margin: var(--ha-space-4) var(--ha-space-4) var(--ha-space-2);
font-size: var(--ha-font-size-m);
font-weight: var(--ha-font-weight-bold);
line-height: var(--ha-line-height-normal);
color: var(--primary-text-color);
}
.orphaned-description {
margin: var(--ha-space-1) var(--ha-space-4) var(--ha-space-2);
color: var(--secondary-text-color);
font: var(--ha-font-body-s);
}
.orphaned-row {
display: flex;
align-items: center;
gap: var(--ha-space-4);
padding-block: var(--ha-space-2);
padding-inline: var(--ha-space-4) var(--ha-space-2);
}
.orphaned-name {
flex: 1;
font: var(--ha-font-body-l);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
margin: 0;
margin-inline-start: var(--ha-space-4);
}
.loading {
+3 -14
View File
@@ -24,13 +24,11 @@ class HaEntityMarker extends LitElement {
@property({ attribute: "show-icon", type: Boolean }) public showIcon = false;
@property({ type: Boolean, reflect: true }) public selected = false;
protected render() {
return html`
<div
class="marker ${this.entityPicture ? "picture" : ""}"
style=${styleMap({ "--ha-marker-selected-color": this.entityColor })}
style=${styleMap({ "border-color": this.entityColor })}
@click=${this._badgeTap}
>
${
@@ -96,22 +94,13 @@ class HaEntityMarker extends LitElement {
height: var(--ha-marker-size, 48px);
font-size: var(--ha-marker-font-size, var(--ha-font-size-xl));
border-radius: var(--ha-marker-border-radius, 50%);
border: var(--ha-marker-border-width, 3px) solid
var(--ha-marker-color, var(--card-background-color, #fff));
box-shadow: var(--ha-marker-shadow, var(--ha-box-shadow-s));
border: 1px solid var(--ha-marker-color, var(--primary-color));
color: var(--primary-text-color);
background-color: var(
--ha-marker-background,
var(--card-background-color)
);
background-color: var(--card-background-color);
}
.marker.picture {
overflow: hidden;
}
/* A ring in the entity color outside the frame marks the selected marker */
:host([selected]) .marker {
outline: 3px solid var(--ha-marker-selected-color, var(--primary-color));
}
.entity-picture {
background-size: cover;
height: 100%;
+12 -57
View File
@@ -1,17 +1,14 @@
import type { ContextType } from "@lit/context";
import { consume } from "@lit/context";
import type { PropertyValues, TemplateResult } from "lit";
import { css, html, LitElement } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import { transform } from "../../common/decorators/transform";
import { fireEvent } from "../../common/dom/fire_event";
import type { HASSDomEvent } from "../../common/dom/fire_event";
import { MAP_MAX_ZOOM } from "../../common/map/base-layer";
import type { MapLatLng } from "../../common/map/map-engine";
import { circleBoundsPoints } from "../../common/map/map-engine";
import { internationalizationContext, uiContext } from "../../data/context";
import type { Themes } from "../../data/ws-themes";
import { internationalizationContext } from "../../data/context";
import type { HomeAssistantInternationalization, ThemeMode } from "../../types";
import "../ha-alert";
import "../ha-input-helper-text";
@@ -19,10 +16,6 @@ import "./ha-map";
import type { HaMap, HaMapEditableLocation } from "./ha-map";
import type { HaIcon } from "../ha-icon";
import type { HaSvgIcon } from "../ha-svg-icon";
import {
createZoneMarkerElement,
ZONE_CIRCLE_SIZE,
} from "../../common/map/zone-marker";
declare global {
// for fire event
@@ -75,15 +68,6 @@ export class HaLocationsEditor extends LitElement {
@state() private _editingAvailable = true;
// Marker elements bake in theme colors, so they are rebuilt on a theme
// change; narrow the UI context to themes to avoid unrelated rerenders
@state()
@consume({ context: uiContext, subscribe: true })
@transform<ContextType<typeof uiContext>, Themes>({
transformer: ({ themes }) => themes,
})
private _themes?: Themes;
@state()
@consume({ context: internationalizationContext, subscribe: true })
private _i18n?: HomeAssistantInternationalization;
@@ -124,10 +108,7 @@ export class HaLocationsEditor extends LitElement {
return html`
<div class="map">
<ha-map
.editableLocations=${this._editableLocations(
this.locations,
this._themes
)}
.editableLocations=${this._editableLocations(this.locations)}
.zoom=${this.zoom}
.autoFit=${this.autoFit}
.themeMode=${this.themeMode}
@@ -159,14 +140,7 @@ export class HaLocationsEditor extends LitElement {
}
private _editableLocations = memoizeOne(
(
locations: MarkerLocation[] | undefined,
themes: Themes | undefined
): HaMapEditableLocation[] => {
if (themes !== this._elementsThemes) {
this._elementsThemes = themes;
this._elements.clear();
}
(locations?: MarkerLocation[]): HaMapEditableLocation[] => {
const ids = new Set((locations ?? []).map((location) => location.id));
for (const id of this._elements.keys()) {
if (!ids.has(id)) {
@@ -178,9 +152,7 @@ export class HaLocationsEditor extends LitElement {
location: [location.latitude, location.longitude],
radius: location.radius,
element: this._elementFor(location),
elementSize: location.radius
? [ZONE_CIRCLE_SIZE, ZONE_CIRCLE_SIZE]
: [ICON_SIZE, ICON_SIZE],
elementSize: [ICON_SIZE, ICON_SIZE],
title: location.name,
color: location.radius_color,
locationEditable: location.location_editable,
@@ -193,46 +165,30 @@ export class HaLocationsEditor extends LitElement {
// Reused while unchanged, so ha-map moves markers instead of rebuilding them
private _elements = new Map<string, { key: string; element?: HTMLElement }>();
private _elementsThemes?: Themes;
private _elementFor(location: MarkerLocation): HTMLElement | undefined {
const isZone = !!location.radius;
const key = JSON.stringify([
isZone,
location.icon,
location.iconPath,
location.name,
location.radius_color,
location.location_editable,
]);
const cached = this._elements.get(location.id);
if (cached?.key === key) {
return cached.element;
}
// The zone marker doubles as the circle's draggable center
const element = isZone
? this._createZoneMarker(location)
: this._createIcon(location);
const element = this._createIcon(location);
this._elements.set(location.id, { key, element });
return element;
}
private _createZoneMarker(location: MarkerLocation): HTMLElement {
return createZoneMarkerElement({
color:
location.radius_color ||
getComputedStyle(this).getPropertyValue("--accent-color"),
icon: location.icon,
iconPath: location.iconPath,
name: location.name ?? "",
});
}
private _createIcon(location: MarkerLocation): HTMLElement | undefined {
if (!location.icon && !location.iconPath) {
return undefined;
}
const el = document.createElement("div");
el.className = "named-icon";
el.className = `named-icon ${
location.location_editable ? "draggable" : ""
}`;
if (location.name !== undefined) {
el.innerText = location.name;
}
@@ -340,13 +296,12 @@ export class HaLocationsEditor extends LitElement {
display: block;
height: 100%;
}
/* Over the map, clear of the zoom control, so a fixed-height host shows it.
The control sits at the physical top-left in either direction. */
/* Over the map, clear of the zoom control, so a fixed-height host shows it */
ha-alert {
position: absolute;
top: var(--ha-space-2);
left: 56px;
right: var(--ha-space-2);
inset-inline-start: 56px;
inset-inline-end: var(--ha-space-2);
z-index: 1;
}
`;
+71 -360
View File
@@ -1,4 +1,4 @@
import { consume, ContextConsumer } from "@lit/context";
import { consume } from "@lit/context";
import { isToday } from "date-fns";
import type { HassConfig, HassEntities } from "home-assistant-js-websocket";
import type { PropertyValues } from "lit";
@@ -18,9 +18,7 @@ import { getEntityLocation } from "../../common/entity/get_entity_location";
import { supportsWebGL2 } from "../../common/map/base-layer";
import type {
MapClusterIcon,
MapControlPosition,
MapEngine,
MapFitPadding,
MapItemHandle,
MapLatLng,
MapMarkerHandle,
@@ -33,24 +31,15 @@ import type {
} from "../../common/map/map-engine";
import { circleBoundsPoints } from "../../common/map/map-engine";
import { editableCircleStyles } from "../../common/map/editable-circle";
import { entityMapColor, zoneColor } from "../../common/map/entity-map-colors";
import {
createZoneMarkerElement,
ZONE_CIRCLE_SIZE,
zoneMarkerStyles,
} from "../../common/map/zone-marker";
import { deepEqual } from "../../common/util/deep-equal";
import { filterXSS } from "../../common/util/xss";
import {
configContext,
connectionContext,
formattersContext,
fullEntitiesContext,
internationalizationContext,
statesContext,
uiContext,
} from "../../data/context";
import type { EntityRegistryEntry } from "../../data/entity/entity_registry";
import { ensureMapTilesToken } from "../../data/map_tiles";
import type {
HomeAssistantConfig,
@@ -222,37 +211,9 @@ export interface HaMapEntity {
unit?: string;
name?: string;
focus?: boolean;
hide_accuracy?: boolean;
hide_radius?: boolean;
selected?: boolean;
}
// Data carried by entity markers for rendering cluster bubbles
interface ClusterData {
entityId: string;
picture?: string;
label: string;
showIcon: boolean;
unit: string;
color?: string;
selected: boolean;
zoneId?: string;
}
const CLUSTER_AVATAR_SIZE = 32;
const CLUSTER_BUBBLE_PADDING = 6;
const CLUSTER_BUBBLE_GAP = 4;
const CLUSTER_MAX_AVATARS = 3;
const CLUSTER_MORE_WIDTH = 28;
const CLUSTER_MORE_MAX = 99;
const CLUSTER_TAIL_SIZE = 10;
// The tail is a rotated square on the bubble's bottom edge, reaching half its diagonal below
const CLUSTER_TAIL_HEIGHT = Math.round((CLUSTER_TAIL_SIZE * Math.SQRT2) / 2);
const CLUSTER_ZONE_SPACING = 2;
const CLUSTER_RADIUS = 40;
// Same-zone markers share the zone's bubble while they span at most this many
// pixels; further apart they show their actual positions
const ZONE_GROUP_RADIUS = 160;
@customElement("ha-map")
export class HaMap extends ReactiveElement {
@@ -303,14 +264,6 @@ export class HaMap extends ReactiveElement {
@property({ attribute: "fit-zones", type: Boolean }) public fitZones = false;
/** Part of the map an overlay covers; automatic fits keep clear of it */
@property({ attribute: false }) public fitPadding?: MapFitPadding;
@property({ attribute: "zoom-position" })
public zoomPosition: MapControlPosition = "topleft";
private _zonePositions: Record<string, MapLatLng> = {};
@property({ attribute: "theme-mode", type: String })
public themeMode: ThemeMode = "auto";
@@ -343,11 +296,6 @@ export class HaMap extends ReactiveElement {
private _resizeObserver?: ResizeObserver;
// Registry creation order decides the palette colors
@state() private _entityReg: EntityRegistryEntry[] = [];
private _registryConsumer?: ContextConsumer<typeof fullEntitiesContext, this>;
private _entityHandles: MapMarkerHandle[] = [];
private _zoneHandles: MapItemHandle[] = [];
@@ -375,21 +323,6 @@ export class HaMap extends ReactiveElement {
this._attachObserver();
}
// Only maps that draw entities ask for the registry; an editor's map, as in
// onboarding, never does. The context provider shares one subscription.
private _watchRegistry(): void {
if (this._registryConsumer || !this.entities?.length) {
return;
}
this._registryConsumer = new ContextConsumer(this, {
context: fullEntitiesContext,
subscribe: true,
callback: (entries) => {
this._entityReg = entries;
},
});
}
private _handleVisibilityChange = async () => {
if (!document.hidden) {
setTimeout(() => {
@@ -452,22 +385,10 @@ export class HaMap extends ReactiveElement {
}
}
if (changedProps.has("clusterMarkers") || changedProps.has("_entityReg")) {
if (changedProps.has("clusterMarkers")) {
this._drawEntities();
}
// An overlay that grew or shrank may cover the fitted markers
if (
changedProps.has("fitPadding") &&
!deepEqual(changedProps.get("fitPadding"), this.fitPadding)
) {
autoFitRequired = !this._pauseAutoFit;
}
if (changedProps.has("zoomPosition")) {
this._engine?.setZoomControlPosition(this.zoomPosition);
}
const oldConfig = changedProps.get("_config") as HassConfig | undefined;
if (
changedProps.has("_loaded") ||
@@ -480,11 +401,6 @@ export class HaMap extends ReactiveElement {
if (changedProps.has("_loaded") || changedProps.has("paths")) {
this._drawPaths();
// Cluster bubbles show entity colors only while trails are visible
const oldPaths = changedProps.get("paths") as HaMapPaths[] | undefined;
if (!!oldPaths?.length !== !!this.paths?.length) {
this._engine?.refreshClusters();
}
}
if (changedProps.has("_loaded") || changedProps.has("editableLocations")) {
@@ -517,19 +433,13 @@ export class HaMap extends ReactiveElement {
const oldUi = changedProps.get("_ui") as HomeAssistantUI | undefined;
if (
!changedProps.has("themeMode") &&
(!changedProps.has("_ui") || (oldUi && oldUi.themes === this._ui.themes))
(!changedProps.has("_ui") ||
(oldUi && oldUi.themes?.darkMode === this._ui.themes?.darkMode))
) {
return;
}
this._updateMapStyle();
// Marker, trail and circle colors were resolved from the theme when drawn
this._drawEntities();
this._drawPaths();
if (this._editableHandles.size) {
this._removeEditableLocations();
this._drawEditableLocations();
}
}
private get _darkMode() {
@@ -618,7 +528,7 @@ export class HaMap extends ReactiveElement {
darkMode: this._darkMode,
token,
rasterOnly: this._forceLeaflet,
zoomControlPosition: this.zoomPosition,
zoomControlPosition: "topleft",
events: {
click: (location) => this._handleEngineClick(location),
zoomStart: () => {
@@ -713,7 +623,6 @@ export class HaMap extends ReactiveElement {
public fitMap(options?: {
zoom?: number;
pad?: number;
padding?: MapFitPadding;
unpause_autofit?: boolean;
}): void {
if (options?.unpause_autofit) {
@@ -757,7 +666,6 @@ export class HaMap extends ReactiveElement {
this._engine!.fitBounds(points, {
maxZoom: options?.zoom || this.zoom,
pad: options?.pad ?? 0.5,
padding: options?.padding ?? this.fitPadding,
animate: this._hasFitted,
});
});
@@ -805,11 +713,8 @@ export class HaMap extends ReactiveElement {
public fitBounds(
boundingbox: MapLatLng[],
options?: { zoom?: number; pad?: number; padding?: MapFitPadding }
options?: { zoom?: number; pad?: number }
) {
// An explicit fit is user intent, even while it waits for the engine or
// a size; an auto-fit must not take its place in the meantime
this._pauseAutoFit = true;
if (!this._engine) {
// Engine still loading (see _loadMap); runs once it is
this._pendingFit = () => this.fitBounds(boundingbox, options);
@@ -823,7 +728,6 @@ export class HaMap extends ReactiveElement {
maxZoom: options?.zoom || this.zoom,
pad: options?.pad ?? 0.5,
animate: this._hasFitted,
padding: options?.padding,
});
});
this._hasFitted = true;
@@ -1127,36 +1031,16 @@ export class HaMap extends ReactiveElement {
engine.setClustering(null);
return;
}
this._watchRegistry();
const computedStyles = getComputedStyle(this);
// A person's state is "home" for the home zone, the zone name otherwise
const zoneByState: Record<string, string> = {};
this._zonePositions = {};
for (const entity of this.entities) {
const stateObj = states[getEntityId(entity)];
// A zone that is not drawn cannot anchor a bubble either
if (
stateObj &&
computeStateDomain(stateObj) === "zone" &&
(this.renderPassive || !stateObj.attributes.passive)
) {
zoneByState[
stateObj.entity_id === "zone.home"
? "home"
: computeStateName(stateObj)
] = stateObj.entity_id;
if (
typeof stateObj.attributes.latitude === "number" &&
typeof stateObj.attributes.longitude === "number"
) {
this._zonePositions[stateObj.entity_id] = [
stateObj.attributes.latitude,
stateObj.attributes.longitude,
];
}
}
}
const zoneColor = computedStyles.getPropertyValue("--accent-color");
const passiveZoneColor = computedStyles.getPropertyValue(
"--secondary-text-color"
);
const darkPrimaryColor = computedStyles.getPropertyValue(
"--dark-primary-color"
);
for (const entity of this.entities) {
const stateObj = states[getEntityId(entity)];
@@ -1185,29 +1069,26 @@ export class HaMap extends ReactiveElement {
continue;
}
const hideRadius = typeof entity !== "string" && entity.hide_radius;
// A host-set color wins, except passive zones are always muted
const markerColor =
!passive && typeof entity !== "string" && entity.color
? entity.color
: zoneColor(
stateObj.entity_id,
!!passive,
this._entityReg,
computedStyles
);
const zoneMarkerColor = passive ? passiveZoneColor : zoneColor;
if (!hideRadius && radius) {
if (radius) {
this._zoneHandles.push(
engine.addCircle(position, { radius, color: markerColor })
engine.addCircle(position, { radius, color: zoneMarkerColor })
);
}
const circleEl = createZoneMarkerElement({
color: markerColor,
icon,
name: title,
});
// create icon
const iconEl = document.createElement("div");
iconEl.className = `zone-icon ${this._darkMode ? "dark" : "light"}`;
if (icon) {
const el = document.createElement("ha-icon");
el.setAttribute("icon", icon);
iconEl.appendChild(el);
} else {
const el = document.createElement("span");
el.textContent = title;
iconEl.appendChild(el);
}
if (this.interactiveZones) {
const openMoreInfo = (ev: Event) => {
@@ -1216,8 +1097,8 @@ export class HaMap extends ReactiveElement {
entityId: stateObj.entity_id,
});
};
circleEl.addEventListener("click", openMoreInfo);
circleEl.addEventListener("keydown", (ev) => {
iconEl.addEventListener("click", openMoreInfo);
iconEl.addEventListener("keydown", (ev) => {
if (ev.key === "Enter" || ev.key === " ") {
ev.preventDefault();
openMoreInfo(ev);
@@ -1225,9 +1106,10 @@ export class HaMap extends ReactiveElement {
});
}
const zoneIconSize = this._getMarkerSize(computedStyles) / 2;
this._zoneHandles.push(
engine.addMarker(circleEl, position, {
size: [ZONE_CIRCLE_SIZE, ZONE_CIRCLE_SIZE],
engine.addMarker(iconEl, position, {
size: [zoneIconSize, zoneIconSize],
interactive: this.interactiveZones,
title,
})
@@ -1237,7 +1119,7 @@ export class HaMap extends ReactiveElement {
this.fitZones &&
(typeof entity === "string" || entity.focus !== false)
) {
if (!hideRadius && radius) {
if (radius) {
this._focusZonePoints.push(...circleBoundsPoints(position, radius));
} else {
this._focusZonePoints.push(position);
@@ -1281,42 +1163,19 @@ export class HaMap extends ReactiveElement {
entityPicture && (typeof entity === "string" || !entity.label_mode)
? this._connection.hassUrl(entityPicture)
: "";
// A host may leave the color to the map
const entityColor =
(typeof entity !== "string" ? entity.color : undefined) ||
entityMapColor(getEntityId(entity), this._entityReg, computedStyles);
entityMarker.entityColor = entityColor;
if (typeof entity !== "string") {
entityMarker.selected = entity.selected ?? false;
entityMarker.entityColor = entity.color;
}
const clusterData: ClusterData = {
entityId: getEntityId(entity),
picture: entityMarker.entityPicture || undefined,
label: entityName,
showIcon: entityMarker.showIcon,
unit: entityMarker.entityUnit ?? "",
color: entityColor,
selected: typeof entity !== "string" && (entity.selected ?? false),
zoneId: ["person", "device_tracker"].includes(
computeStateDomain(stateObj)
)
? zoneByState[stateObj.state]
: undefined,
};
const showAccuracy =
!!gpsAccuracy && !(typeof entity !== "string" && entity.hide_accuracy);
const markerSize = this._getMarkerSize(computedStyles);
this._entityHandles.push(
engine.addMarker(entityMarker, position, {
size: [markerSize, markerSize],
title,
cluster: true,
clusterData,
decoration: showAccuracy
? { radius: gpsAccuracy!, color: entityColor }
// create circle around if entity has accuracy
decoration: gpsAccuracy
? { radius: gpsAccuracy, color: darkPrimaryColor }
: undefined,
})
);
@@ -1328,97 +1187,22 @@ export class HaMap extends ReactiveElement {
engine.setClustering(
this.clusterMarkers
? {
radius: CLUSTER_RADIUS,
iconBuilder: this._createClusterBubble,
// Everyone in a zone shares its bubble until zooming spreads them
groupKey: (marker) => (marker.clusterData as ClusterData)?.zoneId,
groupRadius: ZONE_GROUP_RADIUS,
}
? { radius: CLUSTER_RADIUS, iconBuilder: this._createClusterIcon }
: null
);
}
// Renders a marker cluster as a bubble of its members' avatars
private _createClusterBubble = (
members: MapMarkerHandle[]
): MapClusterIcon => {
const data = members.map((member) => member.clusterData as ClusterData);
const shown = data.slice(0, CLUSTER_MAX_AVATARS);
const hidden = data.length - shown.length;
// With history trails shown, colored borders match avatars to trails
const showColors = !!this.paths?.length;
const bubble = document.createElement("div");
bubble.className = "cluster-bubble";
for (const member of shown) {
const avatar = document.createElement("ha-entity-marker");
avatar.entityId = member?.entityId;
avatar.entityName = member?.label ?? "";
avatar.entityUnit = member?.unit ?? "";
avatar.showIcon = member?.showIcon ?? false;
avatar.entityPicture = member?.picture ?? "";
avatar.entityColor = member?.color;
if (showColors) {
avatar.style.setProperty(
"--ha-marker-color",
member?.color ?? "var(--primary-color)"
);
avatar.style.setProperty("--ha-marker-border-width", "2px");
}
if (member?.selected) {
avatar.selected = true;
}
bubble.appendChild(avatar);
}
let width =
shown.length * CLUSTER_AVATAR_SIZE +
(shown.length - 1) * CLUSTER_BUBBLE_GAP +
2 * CLUSTER_BUBBLE_PADDING;
if (hidden > 0) {
const more = document.createElement("span");
more.className = "more";
more.textContent =
hidden > CLUSTER_MORE_MAX ? `${CLUSTER_MORE_MAX}+` : `+${hidden}`;
bubble.appendChild(more);
width += CLUSTER_MORE_WIDTH + CLUSTER_BUBBLE_GAP;
}
// A cluster of one zone's occupants attaches to that zone's marker
const zoneId = data[0]?.zoneId;
const zonePosition = zoneId ? this._zonePositions[zoneId] : undefined;
const atZone =
!!zoneId &&
!!zonePosition &&
data.every((member) => member?.zoneId === zoneId);
let height = CLUSTER_AVATAR_SIZE + 2 * CLUSTER_BUBBLE_PADDING;
let root: HTMLElement = bubble;
if (atZone) {
root = document.createElement("div");
root.className = "cluster-marker";
const tail = document.createElement("div");
tail.className = "cluster-bubble-tail";
root.append(bubble, tail);
height += CLUSTER_TAIL_HEIGHT;
}
return {
element: root,
size: [width, height],
// Float above the zone circle, tail pointing at it
...(atZone && zonePosition
? {
location: zonePosition,
anchor: [
width / 2,
height + ZONE_CIRCLE_SIZE / 2 + CLUSTER_ZONE_SPACING,
] as [number, number],
}
: {}),
};
// Renders a marker cluster as a circle with the member count
private _createClusterIcon = (members: MapMarkerHandle[]): MapClusterIcon => {
const size = Math.round(
this._getMarkerSize(getComputedStyle(this)) * (2 / 3)
);
const element = document.createElement("div");
element.className = "marker-cluster";
const count = document.createElement("span");
count.textContent = String(members.length);
element.appendChild(count);
return { element, size: [size, size] };
};
private _drawScaleRuler(): void {
@@ -1453,19 +1237,12 @@ export class HaMap extends ReactiveElement {
}
#map {
height: 100%;
/* A cluster bubble and its tail cast a single shadow around their
combined silhouette (drop-shadow on the wrapper), so no shadow seam
appears between the bubble and its tail. */
--ha-cluster-shadow: drop-shadow(0 1px 2px rgba(0, 0, 0, 0.08))
drop-shadow(0 1px 3px rgba(0, 0, 0, 0.12));
}
#map.clickable {
cursor: pointer;
}
#map.dark {
background: #090909;
--ha-cluster-shadow: drop-shadow(0 1px 2px rgba(0, 0, 0, 0.4))
drop-shadow(0 1px 3px rgba(0, 0, 0, 0.5));
}
#map.forced-dark {
color: #ffffff;
@@ -1486,8 +1263,6 @@ export class HaMap extends ReactiveElement {
display: flex;
flex-direction: column;
align-items: center;
isolation: isolate;
filter: var(--ha-cluster-shadow);
}
.cluster-open-members {
display: flex;
@@ -1499,13 +1274,7 @@ export class HaMap extends ReactiveElement {
max-width: calc(6 * var(--ha-marker-size, 48px) + 5 * 4px + 12px);
background: var(--card-background-color, #fff);
border-radius: 14px;
}
/* Both tails are a rotated square whose upper half sits under the bubble;
drawn behind it, so it never covers a member's frame or selected ring */
.cluster-open-tail,
.cluster-bubble-tail {
position: relative;
z-index: -1;
box-shadow: var(--ha-box-shadow-s);
}
.cluster-open-tail {
width: 10px;
@@ -1532,19 +1301,6 @@ export class HaMap extends ReactiveElement {
top: 0;
left: 0;
}
.maplibregl-ctrl-bottom-left,
.maplibregl-ctrl-bottom-right {
/* Lets a card keep the attribution and scale clear of an overlay */
margin-bottom: var(--ha-map-bottom-inset, 0);
}
.maplibregl-ctrl-top-left,
.maplibregl-ctrl-bottom-left {
margin-left: var(--ha-map-left-inset, 0);
}
.maplibregl-ctrl-top-right,
.maplibregl-ctrl-bottom-right {
margin-right: var(--ha-map-right-inset, 0);
}
.dark .maplibregl-ctrl.maplibregl-ctrl-group {
background-color: #1c1c1c;
}
@@ -1593,74 +1349,16 @@ export class HaMap extends ReactiveElement {
.leaflet-pane {
z-index: 0 !important;
}
.cluster-marker {
display: flex;
flex-direction: column;
align-items: center;
isolation: isolate;
filter: var(--ha-cluster-shadow);
}
/* The wrapper carries the shadow around the bubble-plus-tail outline, so
the bubble itself drops its own to avoid a seam at the tail. */
.cluster-marker .cluster-bubble {
filter: none;
}
.cluster-bubble-tail {
width: ${CLUSTER_TAIL_SIZE}px;
height: ${CLUSTER_TAIL_SIZE}px;
margin-top: ${-CLUSTER_TAIL_SIZE / 2}px;
border-radius: 2px;
background: var(--card-background-color, #fff);
transform: rotate(45deg);
}
.cluster-bubble {
display: flex;
align-items: center;
gap: ${CLUSTER_BUBBLE_GAP}px;
padding: ${CLUSTER_BUBBLE_PADDING}px;
box-sizing: border-box;
background: var(--card-background-color, #fff);
border-radius: 14px;
filter: var(--ha-cluster-shadow);
--ha-marker-size: ${CLUSTER_AVATAR_SIZE}px;
--ha-marker-color: transparent;
--ha-marker-border-width: 1px;
--ha-marker-shadow: none;
--ha-marker-font-size: var(--ha-font-size-s);
/* distinguish letter tiles from the bubble background */
--ha-marker-background: var(--ha-color-fill-neutral-quiet-resting);
}
.cluster-bubble .more {
flex: none;
width: ${CLUSTER_MORE_WIDTH}px;
height: ${CLUSTER_AVATAR_SIZE}px;
/* Zone icons, sized like the Leaflet divIcon they replaced */
.zone-icon {
display: flex;
align-items: center;
justify-content: center;
border-radius: 10px;
background: var(--ha-color-fill-neutral-quiet-resting, #f0f0f0);
color: var(--primary-text-color, #212121);
font-size: var(--ha-font-size-s);
font-weight: var(--ha-font-weight-medium);
text-align: center;
color: var(--primary-text-color);
}
/* Markers are rounded squares to match the cluster bubble avatars */
ha-entity-marker {
--ha-marker-border-radius: var(--ha-border-radius-lg);
}
.cluster-bubble ha-entity-marker {
flex: none;
--ha-marker-border-radius: 10px;
}
${unsafeCSS(zoneMarkerStyles)}
.leaflet-bottom {
/* Lets a card keep the attribution and scale clear of an overlay */
margin-bottom: var(--ha-map-bottom-inset, 0);
}
.leaflet-left {
margin-left: var(--ha-map-left-inset, 0);
}
.leaflet-right {
margin-right: var(--ha-map-right-inset, 0);
.zone-icon.dark {
color: #ffffff;
}
.leaflet-control,
.leaflet-top,
@@ -1712,6 +1410,19 @@ export class HaMap extends ReactiveElement {
ha-icon {
--mdc-icon-size: calc(var(--ha-marker-size, 48px) / 2);
}
.marker-cluster {
box-sizing: border-box;
background-clip: padding-box;
background-color: var(--primary-color);
border: 3px solid rgba(var(--rgb-primary-color), 0.2);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
color: var(--text-primary-color);
font-size: var(--ha-font-size-m);
}
`;
}
@@ -33,6 +33,7 @@ import "../ha-list";
import "../ha-spinner";
import "../ha-svg-icon";
import "../ha-tip";
import "./ha-media-player-browse";
import "./ha-media-upload-button";
import type { MediaManageDialogParams } from "./show-media-manage-dialog";
@@ -7,11 +7,9 @@ import { customElement, property, query } from "lit/decorators";
import { styleMap } from "lit/directives/style-map";
import { fireEvent, type HASSDomEvent } from "../../common/dom/fire_event";
import { computeDomain } from "../../common/entity/compute_domain";
import {
computeEntityPickerDisplay,
computeEntitySearchLabels,
} from "../../common/entity/compute_entity_name_display";
import { computeEntityNameList } from "../../common/entity/compute_entity_name_display";
import { computeStateDomain } from "../../common/entity/compute_state_domain";
import { computeStateName } from "../../common/entity/compute_state_name";
import { supportsFeature } from "../../common/entity/supports-feature";
import {
areasContext,
@@ -109,6 +107,9 @@ export class HaMediaPlayerPicker extends LitElement {
const webBrowserLabel = this._i18n.localize(
"ui.components.media-browser.web-browser"
);
const lang = this._i18n.language || "en";
const isRTL =
this._i18n.translationMetadata.translations[lang]?.isRTL || false;
return [
{
@@ -127,22 +128,34 @@ export class HaMediaPlayerPicker extends LitElement {
...Object.values(this._states)
.filter(this._filterPlayerEntities)
.map<MediaPlayerComboBoxItem>((stateObj) => {
const friendlyName = computeStateName(stateObj);
const [entityName, deviceName, parentDeviceName, areaName] =
computeEntityNameList(
stateObj,
[
{ type: "entity" },
{ type: "device" },
{ type: "parent_device" },
{ type: "area" },
],
this._entities,
this._devices,
this._areas,
this._floors
);
const entityId = stateObj.entity_id;
const domainName = domainToName(
this._i18n.localize,
computeDomain(entityId)
);
const { primary, secondary } = computeEntityPickerDisplay(
{
entities: this._entities,
devices: this._devices,
areas: this._areas,
floors: this._floors,
language: this._i18n.language,
translationMetadata: this._i18n.translationMetadata,
},
stateObj
);
const primary = entityName || deviceName || entityId;
const secondary = [
areaName,
parentDeviceName,
entityName ? deviceName : undefined,
]
.filter(Boolean)
.join(isRTL ? " ◂ " : " ▸ ");
return {
id: entityId,
@@ -152,14 +165,12 @@ export class HaMediaPlayerPicker extends LitElement {
domain_name: domainName,
sorting_label: [primary, secondary].filter(Boolean).join("_"),
search_labels: {
...computeEntitySearchLabels(
stateObj,
this._entities,
this._devices,
this._areas,
this._floors
),
entityName: entityName || null,
deviceName: deviceName || null,
parentDeviceName: parentDeviceName || null,
areaName: areaName || null,
domainName: domainName || null,
friendlyName: friendlyName || null,
entityId,
},
stateObj,
@@ -7,7 +7,8 @@ import memoizeOne from "memoize-one";
import { consumeEntityState } from "../../common/decorators/consume-context-entry";
import { fireEvent } from "../../common/dom/fire_event";
import { computeEntityPickerDisplay } from "../../common/entity/compute_entity_name_display";
import { computeEntityNameList } from "../../common/entity/compute_entity_name_display";
import { computeRTL } from "../../common/util/compute_rtl";
import {
areasContext,
devicesContext,
@@ -53,24 +54,40 @@ class HaMediaPlayerToggle extends LitElement {
private _computeDisplayData = memoizeOne(
(
entityId: string,
entities: ContextType<typeof entitiesContext>,
devices: ContextType<typeof devicesContext>,
areas: ContextType<typeof areasContext>,
floors: ContextType<typeof floorsContext>,
i18n: ContextType<typeof internationalizationContext>,
isRTL: boolean,
stateObj: HassEntity
) =>
computeEntityPickerDisplay(
{
) => {
const [entityName, deviceName, parentDeviceName, areaName] =
computeEntityNameList(
stateObj,
[
{ type: "entity" },
{ type: "device" },
{ type: "parent_device" },
{ type: "area" },
],
entities,
devices,
areas,
floors,
language: i18n.language,
translationMetadata: i18n.translationMetadata,
},
stateObj
)
floors
);
const primary = entityName || deviceName || entityId;
const secondary = [
areaName,
parentDeviceName,
entityName ? deviceName : undefined,
]
.filter(Boolean)
.join(isRTL ? " ◂ " : " ▸ ");
return { primary, secondary };
}
);
protected render() {
@@ -87,12 +104,18 @@ class HaMediaPlayerToggle extends LitElement {
icon = mdiSpeakerPause;
}
const isRTL = computeRTL(
this._i18n.language,
this._i18n.translationMetadata.translations
);
const { primary, secondary } = this._computeDisplayData(
this.entityId,
this._entities,
this._devices,
this._areas,
this._floors,
this._i18n,
isRTL,
stateObj
);
@@ -19,6 +19,7 @@ import type { HassDialog } from "../../../dialogs/make-dialog-manager";
import type { HomeAssistant } from "../../../types";
import type { HaDevicePickerDeviceFilterFunc } from "../../device/ha-device-picker";
import "../../ha-adaptive-dialog";
import "../../ha-dialog-header";
import "../../ha-icon-button";
import "../../ha-icon-next";
import "../../ha-svg-icon";
@@ -26,11 +26,12 @@ import {
computeDeviceNameDisplay,
} from "../../common/entity/compute_device_name";
import { computeDomain } from "../../common/entity/compute_domain";
import { computeEntityPickerDisplay } from "../../common/entity/compute_entity_name_display";
import { computeEntityName } from "../../common/entity/compute_entity_name";
import {
getDeviceArea,
getDeviceAreaId,
} from "../../common/entity/context/get_device_context";
import { getEntityContext } from "../../common/entity/context/get_entity_context";
import { computeRTL } from "../../common/util/compute_rtl";
import type { AreaRegistryEntry } from "../../data/area/area_registry";
import { getConfigEntry } from "../../data/config_entries";
@@ -671,12 +672,40 @@ export class HaTargetPickerItemRow extends LitElement {
}
if (type === "entity") {
const stateObject: HassEntity | undefined = this.hass.states[item];
const { primary, secondary } = stateObject
? computeEntityPickerDisplay(this.hass, stateObject)
: { primary: item, secondary: undefined };
const entityName = stateObject
? computeEntityName(stateObject, this.hass.entities, this.hass.devices)
: item;
const { area, device, parentDevice } = stateObject
? getEntityContext(
stateObject,
this.hass.entities,
this.hass.devices,
this.hass.areas,
this.hass.floors
)
: { area: undefined, device: undefined, parentDevice: undefined };
const deviceName = device ? computeDeviceName(device) : undefined;
const parentDeviceName = parentDevice
? computeDeviceName(parentDevice)
: undefined;
const areaName = area ? computeAreaName(area) : undefined;
const context = [
areaName,
parentDeviceName,
entityName ? deviceName : undefined,
]
.filter(Boolean)
.join(
computeRTL(
this.hass.language,
this.hass.translationMetadata.translations
)
? " ◂ "
: " ▸ "
);
return {
name: primary,
context: secondary,
name: entityName || deviceName || item,
context,
stateObject,
notFound: !stateObject && item !== "all" && item !== "none",
};
+9 -24
View File
@@ -69,10 +69,7 @@ export class HaTracePathDetails extends LitElement {
@property({ attribute: false })
public renderedNodes: Record<string, any> = {};
@property({ attribute: false }) public trackedNodes!: Record<
string,
NodeInfo
>;
@property({ attribute: false }) public trackedNodes!: Record<string, any>;
@state() private _view: (typeof TRACE_PATH_TABS)[number] = "step_config";
@@ -195,16 +192,11 @@ export class HaTracePathDetails extends LitElement {
const nestPath = curPath
.substring(this.selected.path.length + 1)
.split("/");
let currentDetail: unknown = this.selected.config;
let currentDetail = this.selected.config;
for (const part of nestPath) {
if (typeof currentDetail !== "object" || currentDetail === null) {
break;
if (!["undefined", "string"].includes(typeof currentDetail[part])) {
currentDetail = currentDetail[part];
}
const child = (currentDetail as Record<string, unknown>)[part];
if (child === undefined || typeof child === "string") {
break;
}
currentDetail = child;
}
parts.push(
@@ -290,9 +282,6 @@ export class HaTracePathDetails extends LitElement {
: html`<pre>${dump(rest)}</pre>`
}
${
typeof currentDetail === "object" &&
currentDetail !== null &&
"entity_id" in currentDetail &&
currentDetail.entity_id &&
curPath
.substring(this.selected.path.length + 1)
@@ -337,7 +326,7 @@ export class HaTracePathDetails extends LitElement {
this._entityReg,
currentDetail,
undefined,
undefined,
false,
this._manifests
)
: selectedType === "chooseOption"
@@ -497,9 +486,7 @@ export class HaTracePathDetails extends LitElement {
const trackedPaths = Object.keys(this.trackedNodes);
const index = trackedPaths.indexOf(this.selected.path);
// Synthetic choose-option nodes have no direct trace records, so there is
// no start timestamp to slice the logbook with.
if (index === -1 || !startTrace) {
if (index === -1) {
return html`<div class="padded-box">
${this.hass!.localize(
"ui.panel.config.automation.trace.path.step_not_executed"
@@ -509,11 +496,7 @@ export class HaTracePathDetails extends LitElement {
let entries: LogbookEntry[];
const nextTrace =
index < trackedPaths.length - 1
? paths[trackedPaths[index + 1]]
: undefined;
if (!nextTrace) {
if (index === trackedPaths.length - 1) {
// it's the last entry. Find all logbook entries after start.
const startTime = new Date(startTrace[0].timestamp);
const idx = this.logbookEntries.findIndex(
@@ -525,6 +508,8 @@ export class HaTracePathDetails extends LitElement {
entries = this.logbookEntries.slice(idx);
}
} else {
const nextTrace = paths[trackedPaths[index + 1]];
const startTime = new Date(startTrace[0].timestamp);
const endTime = new Date(nextTrace[0].timestamp);
+21 -84
View File
@@ -1,7 +1,7 @@
import type { PropertyValues } from "lit";
import { css, html, LitElement, nothing, svg } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import { customElement, property, state } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import type { HASSDomTargetEvent } from "../../common/dom/fire_event";
import { BRANCH_HEIGHT, SPACING } from "./hat-graph-const";
interface BranchConfig {
@@ -10,9 +10,6 @@ interface BranchConfig {
start: boolean;
end: boolean;
track: boolean;
// A branch the run entered but never finished, because a step in it raised.
// Its incoming curve is tracked, everything leaving it is not.
trackEnd: boolean;
}
/**
@@ -35,85 +32,34 @@ export class HatGraphBranch extends LitElement {
private _maxHeight = 0;
@query("#branches slot") private _slot?: HTMLSlotElement;
// The branch children are Lit rendered by the parent, so their track
// attribute can flip (another trace, another run) without a slot change,
// and nested nodes can change without the assigned elements changing.
private _trackObserver = new MutationObserver((mutations) => {
if (
mutations.some(
(m) =>
m.type === "childList" || (m.target as Element).parentElement === this
)
) {
this._updateBranches();
}
});
public connectedCallback() {
super.connectedCallback();
this._trackObserver.observe(this, {
subtree: true,
attributes: true,
childList: true,
attributeFilter: ["track", "unfinished"],
});
}
public disconnectedCallback() {
super.disconnectedCallback();
this._trackObserver.disconnect();
}
protected updated(changedProps: PropertyValues) {
super.updated(changedProps);
// The branches are read from the DOM, so they are refreshed on every
// update to pick up size changes as well.
this._updateBranches();
}
private _updateBranches() {
if (!this._slot) {
return;
}
private _updateBranches(ev: HASSDomTargetEvent<HTMLSlotElement>) {
let total_width = 0;
const heights: number[] = [];
const branches: BranchConfig[] = [];
this._slot.assignedElements().forEach((c) => {
(ev.target as HTMLSlotElement).assignedElements().forEach((c) => {
const width = c.clientWidth;
const height = c.clientHeight;
const track = c.hasAttribute("track");
branches.push({
x: width / 2 + total_width,
height,
start: c.hasAttribute("graph-start"),
end: c.hasAttribute("graph-end"),
track,
trackEnd: track && !c.hasAttribute("unfinished"),
track: c.hasAttribute("track"),
});
total_width += width;
heights.push(height);
});
const maxHeight = Math.max(...heights);
// Tracked branches are drawn last, so they are never covered by the
// untracked ones where the paths overlap.
branches.sort(
(a, b) =>
Number(a.trackEnd) - Number(b.trackEnd) ||
Number(a.track) - Number(b.track)
);
if (
total_width === this._totalWidth &&
maxHeight === this._maxHeight &&
JSON.stringify(branches) === JSON.stringify(this._branches)
) {
// Nothing changed, don't trigger another update.
return;
}
this._totalWidth = total_width;
this._maxHeight = maxHeight;
this._branches = branches;
this._maxHeight = Math.max(...heights);
this._branches = branches.sort((a, b) => {
if (a.track && !b.track) {
return 1;
}
if (a.track && b.track) {
return 0;
}
return -1;
});
}
render() {
@@ -133,9 +79,7 @@ export class HatGraphBranch extends LitElement {
})}
d="
M ${this._totalWidth / 2} 0
C ${this._totalWidth / 2} ${BRANCH_HEIGHT / 2}
${branch.x} ${BRANCH_HEIGHT / 2}
${branch.x} ${BRANCH_HEIGHT}
L ${branch.x} ${BRANCH_HEIGHT}
"/>
`
)}
@@ -150,7 +94,7 @@ export class HatGraphBranch extends LitElement {
return svg`
<path
class=${classMap({
track: branch.trackEnd,
track: branch.track,
})}
d="
M ${branch.x} ${branch.height}
@@ -171,14 +115,12 @@ export class HatGraphBranch extends LitElement {
return svg`
<path
class=${classMap({
track: branch.trackEnd,
track: branch.track,
})}
d="
M ${branch.x} 0
V ${SPACING}
C ${branch.x} ${SPACING + BRANCH_HEIGHT / 2}
${this._totalWidth / 2} ${SPACING + BRANCH_HEIGHT / 2}
${this._totalWidth / 2} ${BRANCH_HEIGHT + SPACING}
L ${this._totalWidth / 2} ${BRANCH_HEIGHT + SPACING}
"/>
`;
})}
@@ -219,19 +161,14 @@ export class HatGraphBranch extends LitElement {
}
#bottom {
height: calc(var(--hat-graph-branch-height) + var(--hat-graph-spacing));
position: relative;
z-index: 2;
}
path {
stroke: var(--connector-clr, var(--stroke-clr));
stroke-width: 1;
stroke-dasharray: 4 3;
stroke: var(--stroke-clr);
stroke-width: 2;
fill: none;
}
path.track {
stroke: var(--track-clr);
stroke-width: 2;
stroke-dasharray: none;
}
:host([disabled]) path {
stroke: var(--disabled-clr);
+1 -8
View File
@@ -1,10 +1,3 @@
export const SPACING = 10;
export const NODE_SIZE = 36;
export const NODE_RADIUS = 12;
export const NODE_SIZE = 30;
export const BRANCH_HEIGHT = 20;
export const ICON_SIZE = 24;
// Building blocks are rotated 45deg, so their diagonal (size * sqrt(2)) is
// bigger than the node box: the node svg is allowed to overflow for them.
export const BUILDING_BLOCK_SIZE = 30;
export const BUILDING_BLOCK_RADIUS = 6;
export const BUILDING_BLOCK_ICON_SIZE = 20;
+24 -120
View File
@@ -3,24 +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 {
BUILDING_BLOCK_ICON_SIZE,
BUILDING_BLOCK_RADIUS,
BUILDING_BLOCK_SIZE,
ICON_SIZE,
NODE_RADIUS,
NODE_SIZE,
SPACING,
} from "./hat-graph-const";
/** Matches the 16px live-test indicator used by automation rows. */
const BADGE_RADIUS = 8;
/** Point on the rounded corner arc, so the badge straddles the node border. */
const BADGE_X = NODE_SIZE / 2 - NODE_RADIUS + NODE_RADIUS / Math.SQRT2;
const BADGE_Y = -BADGE_X;
/** The repeat count sits centered on the bottom edge. */
const COUNT_BADGE_Y = NODE_SIZE / 2;
const COUNT_BADGE_RADIUS = 9;
import { NODE_SIZE, SPACING } from "./hat-graph-const";
/**
* @attribute active
@@ -32,7 +15,7 @@ export class HatGraphNode extends LitElement {
@property({ type: Boolean, reflect: true }) public disabled = false;
@property({ type: Boolean, reflect: true }) public error = false;
@property({ type: Boolean }) public error = false;
@property({ attribute: "not-enabled", reflect: true, type: Boolean })
notEnabled = false;
@@ -43,10 +26,6 @@ export class HatGraphNode extends LitElement {
@property({ attribute: "graph-start", reflect: true, type: Boolean })
graphStart = false;
/** Renders the node as a filled diamond, like building block rows in the editor. */
@property({ attribute: "building-block", reflect: true, type: Boolean })
buildingBlock = false;
@property({ type: Boolean, attribute: "nofocus" }) noFocus = false;
@property({ reflect: true, type: Number }) badge?: number;
@@ -64,9 +43,6 @@ export class HatGraphNode extends LitElement {
protected render(): TemplateResult {
const height = NODE_SIZE + (this.graphStart ? 2 : SPACING + 1);
const width = SPACING + NODE_SIZE;
const size = this.buildingBlock ? BUILDING_BLOCK_SIZE : NODE_SIZE;
const iconSize = this.buildingBlock ? BUILDING_BLOCK_ICON_SIZE : ICON_SIZE;
// A rotated building block is wider than its side.
return html`
<svg
class=${isSafari ? "safari" : ""}
@@ -93,24 +69,17 @@ export class HatGraphNode extends LitElement {
`
}
<g class="node">
<rect
x=${-size / 2}
y=${-size / 2}
width=${size}
height=${size}
rx=${this.buildingBlock ? BUILDING_BLOCK_RADIUS : NODE_RADIUS}
transform=${this.buildingBlock ? "rotate(45)" : nothing}
/>
<circle cx="0" cy="0" r=${NODE_SIZE / 2} />
${
this.error
? svg`
<g class="error">
<circle
cx=${BADGE_X}
cy=${BADGE_Y}
r=${BADGE_RADIUS}
cx="-12"
cy=${-NODE_SIZE / 2}
r="8"
></circle>
<path transform="translate(${BADGE_X - 6} ${BADGE_Y - 6}) scale(.5)" class="exclamation" d=${mdiExclamationThick}/>
<path transform="translate(-18 -21) scale(.5)" class="exclamation" d=${mdiExclamationThick}/>
</g>
`
: nothing
@@ -120,46 +89,27 @@ export class HatGraphNode extends LitElement {
? svg`
<g class="number">
<circle
cx="0"
cy=${COUNT_BADGE_Y}
r=${COUNT_BADGE_RADIUS}
cx="12"
cy=${-NODE_SIZE / 2}
r="8"
></circle>
<text
x="0"
y=${COUNT_BADGE_Y}
x="12"
y=${-NODE_SIZE / 2}
text-anchor="middle"
dominant-baseline="central"
alignment-baseline="middle"
>${this.badge > 9 ? "9+" : this.badge}</text>
</g>
`
: nothing
}
<g
class="icon-wrapper"
style="pointer-events: none"
transform="translate(-${iconSize / 2} -${iconSize / 2}) scale(${
iconSize / ICON_SIZE
})"
>
<g style="pointer-events: none" transform="translate(-12 -12)">
${
this.iconPath
? svg`<path class="icon" d=${this.iconPath}/>`
: svg`<foreignObject><span class="icon"><slot name="icon"></slot></span></foreignObject>`
}
</g>
${
this.notEnabled
? svg`
<line
class="strike"
x1=${-iconSize / 2}
y1="0"
x2=${iconSize / 2}
y2="0"
/>
`
: nothing
}
</g>
</svg>
`;
@@ -172,11 +122,6 @@ export class HatGraphNode extends LitElement {
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]) {
height: calc(var(--hat-graph-node-size) + 2px);
}
@@ -184,81 +129,40 @@ export class HatGraphNode extends LitElement {
--stroke-clr: var(--track-clr);
--icon-clr: var(--default-icon-clr);
}
/* A step that raised is drawn red, but only the node itself: the run did
reach it, so the connector above it stays on the tracked path colour. */
:host([error]) {
--icon-clr: var(--default-icon-clr);
}
:host([error]) rect {
--stroke-clr: var(--error-color);
}
:host([active]) rect {
:host([active]) circle {
--stroke-clr: var(--active-clr);
--icon-clr: var(--default-icon-clr);
stroke-width: 3;
}
:host(:focus) {
outline: none;
}
:host(:hover) rect {
:host(:hover) circle {
--stroke-clr: var(--hover-clr);
--icon-clr: var(--default-icon-clr);
}
:host([not-triggered]) rect {
:host([not-triggered]) circle {
stroke-dasharray: 4 3;
}
:host([not-enabled]) {
--stroke-clr: var(--ha-color-border-neutral-normal);
--icon-clr: var(--ha-color-text-disabled);
:host([not-enabled]) circle {
--stroke-clr: var(--disabled-clr);
}
/* One step off the surface in whichever direction the theme runs: #e5e5e5
on a white node body, #282828 on a near black one. */
:host([not-enabled]) rect {
fill: var(--secondary-background-color);
}
/* 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]) .strike {
opacity: 0.6;
}
.strike {
stroke: var(--icon-clr);
stroke-width: 2;
stroke-linecap: round;
}
:host([not-enabled][active]) rect {
:host([not-enabled][active]) circle {
--stroke-clr: var(--disabled-active-clr);
}
:host([not-enabled]:hover) rect {
:host([not-enabled]:hover) circle {
--stroke-clr: var(--disabled-hover-clr);
}
/* Rotated building blocks and corner badges reach outside the node box. */
svg {
overflow: visible;
}
svg:not(.safari) {
width: 100%;
height: 100%;
}
rect,
circle,
path.connector {
stroke: var(--stroke-clr);
stroke-width: 2;
fill: none;
}
path.connector {
stroke: var(--connector-clr, var(--stroke-clr));
stroke-width: 1;
stroke-dasharray: 4 3;
}
:host([track]) path.connector {
stroke: var(--stroke-clr);
stroke-width: 2;
stroke-dasharray: none;
}
rect {
circle {
fill: var(--background-clr);
stroke: var(--circle-clr, var(--stroke-clr));
}
@@ -276,7 +180,7 @@ export class HatGraphNode extends LitElement {
stroke-width: 0;
}
.number text {
font-size: var(--ha-font-size-s);
font-size: var(--ha-font-size-xs);
fill: var(--text-primary-color);
}
path.icon {
+2 -9
View File
@@ -38,19 +38,12 @@ export class HatGraphSpacer extends LitElement {
:host([track]) {
--stroke-clr: var(--track-clr);
}
:host([track]) path {
stroke: var(--stroke-clr);
stroke-width: 2;
stroke-dasharray: none;
}
:host-context([disabled]) {
--stroke-clr: var(--disabled-clr);
}
path {
stroke: var(--connector-clr, var(--stroke-clr));
stroke-width: 1;
stroke-dasharray: 4 3;
stroke-dashoffset: 4px;
stroke: var(--stroke-clr);
stroke-width: 2;
fill: none;
}
`;
File diff suppressed because it is too large Load Diff
-20
View File
@@ -17,9 +17,7 @@ import {
mdiTrafficLight,
} from "@mdi/js";
import type { AutomationElementGroupCollection } from "./automation";
import { CONDITION_BUILDING_BLOCKS } from "./condition";
import type { Action } from "./script";
import { getActionType } from "./script";
export const ACTION_ICONS = {
condition: mdiAbTesting,
@@ -44,24 +42,6 @@ export const ACTION_ICONS = {
set_conversation_response: mdiBullhorn,
} as const;
// Plain function on purpose: the trace tree calls this once per action node
// with a distinct object each time, so a single-entry memoize-one cache
// would never hit.
export const getAutomationActionType = (action: Action | undefined) => {
if (!action) {
return undefined;
}
if ("action" in action) {
return getActionType(action);
}
if (CONDITION_BUILDING_BLOCKS.some((key) => key in action)) {
return "condition" as const;
}
return Object.keys(ACTION_ICONS).find(
(option) => option in action
) as keyof typeof ACTION_ICONS;
};
export const YAML_ONLY_ACTION_TYPES = new Set<keyof typeof ACTION_ICONS>([
"variables",
]);
+1 -3
View File
@@ -1,5 +1,4 @@
import type { HomeAssistant } from "../types";
import type { ChatLogToolResult } from "./chat_log";
import type { ConversationResult } from "./conversation";
import type { SpeechMetadata } from "./stt";
@@ -131,8 +130,7 @@ export interface ConversationChatLogToolResultDelta {
agent_id: string;
tool_call_id: string;
tool_name: string;
result: ChatLogToolResult;
created: string;
tool_result: unknown;
}
interface PipelineIntentProgressEvent extends PipelineEventBase {
type: "intent-progress";
+6 -10
View File
@@ -114,9 +114,7 @@ export interface StateTrigger extends BaseTrigger {
entity_id: string | string[];
attribute?: string;
from?: string | string[];
not_from?: string | string[];
to?: string | string[];
not_to?: string | string[];
for?: string | number | ForDict;
}
@@ -155,7 +153,7 @@ export interface ConversationTrigger extends BaseTrigger {
export interface SunTrigger extends BaseTrigger {
trigger: "sun";
offset?: string | number | ForDict;
offset: number;
event: "sunrise" | "sunset";
}
@@ -192,11 +190,9 @@ export interface TagTrigger extends BaseTrigger {
device_id?: string;
}
export type TimeTriggerAt = string | { entity_id: string; offset?: string };
export interface TimeTrigger extends BaseTrigger {
trigger: "time";
at: TimeTriggerAt | TimeTriggerAt[];
at: string | { entity_id: string; offset?: string };
weekday?: string | string[];
}
@@ -215,9 +211,9 @@ export interface EventTrigger extends BaseTrigger {
export interface CalendarTrigger extends BaseTrigger {
trigger: "calendar";
event?: "start" | "end";
event: "start" | "end";
entity_id: string;
offset?: string | number | ForDict;
offset: string;
}
export type LegacyTrigger =
@@ -305,7 +301,7 @@ export interface TemplateCondition extends BaseCondition {
export interface TriggerCondition extends BaseCondition {
condition: "trigger";
id: string | string[];
id: string;
}
type ShorthandBaseCondition = Omit<BaseCondition, "condition">;
@@ -656,7 +652,7 @@ export const testCondition = (
condition: Condition | Condition[],
variables?: Record<string, unknown>
) =>
hass.callWS<{ result: boolean; template_errors?: string[] }>({
hass.callWS<{ result: boolean }>({
type: "test_condition",
condition,
variables,
+1 -9
View File
@@ -149,8 +149,6 @@ const formatNumericLimitValue = (
export interface DescribeOptions {
// Skip the user defined alias and describe the underlying config.
ignoreAlias?: boolean;
// Rows with trigger-reference chips render the IDs separately.
hideTriggerIds?: boolean;
}
export const describeTrigger = (
@@ -829,7 +827,7 @@ const describeLegacyTrigger = (
let offsetChoice = "other";
let offset = "";
if (typeof trigger.offset === "string" && trigger.offset) {
if (trigger.offset) {
offsetChoice = trigger.offset.startsWith("-") ? "before" : "after";
const parts = trigger.offset.startsWith("-")
? trigger.offset.substring(1).split(":")
@@ -925,12 +923,6 @@ const tryDescribeCondition = (
return condition.alias;
}
if (condition.condition === "trigger" && options?.hideTriggerIds) {
return hass.localize(
`${conditionsTranslationBaseKey}.trigger.description.summary`
);
}
if (!condition.condition) {
const shorthands: ("and" | "or" | "not")[] = ["and", "or", "not"];
for (const key of shorthands) {
+3 -4
View File
@@ -19,7 +19,6 @@ export interface BlueprintMetaData {
description?: string;
source_url?: string;
author?: string;
homeassistant?: { min_version?: string };
}
export interface BlueprintInput {
@@ -40,7 +39,7 @@ export interface BlueprintInputSection {
export interface BlueprintImportResult {
suggested_filename: string;
raw_data: string;
exists: boolean;
exists?: boolean;
blueprint: Blueprint;
validation_errors: string[] | null;
}
@@ -64,7 +63,7 @@ export const saveBlueprint = (
source_url?: string,
allow_override?: boolean
) =>
hass.callWS<{ overrides_existing: boolean }>({
hass.callWS({
type: "blueprint/save",
domain,
path,
@@ -78,7 +77,7 @@ export const deleteBlueprint = (
domain: BlueprintDomain,
path: string
) =>
hass.callWS<undefined>({
hass.callWS<BlueprintImportResult>({
type: "blueprint/delete",
domain,
path,
+2 -7
View File
@@ -37,17 +37,12 @@ export interface ChatLogAssistantContent {
tool_calls?: any[];
}
export interface ChatLogToolResult {
data: Record<string, unknown>;
error: boolean;
}
export interface ChatLogToolResultContent {
role: "tool_result";
agent_id: string;
tool_call_id: string;
tool_name: string;
result: ChatLogToolResult;
tool_result: any;
created: Date;
}
@@ -97,7 +92,7 @@ interface ChatLogToolResultContentWire {
agent_id: string;
tool_call_id: string;
tool_name: string;
result: ChatLogToolResult;
tool_result: any;
created: string;
}
+5 -3
View File
@@ -22,10 +22,12 @@ export interface DeviceAutomation {
subtype?: string;
event?: string;
enabled?: boolean;
metadata?: { secondary?: boolean };
metadata?: { secondary: boolean };
}
export type DeviceAction = DeviceAutomation;
export interface DeviceAction extends DeviceAutomation {
entity_id: string;
}
export interface DeviceCondition extends DeviceAutomation {
condition: "device";
@@ -37,7 +39,7 @@ export type DeviceTrigger = DeviceAutomation &
};
export interface DeviceCapabilities {
extra_fields?: HaFormSchema[];
extra_fields: HaFormSchema[];
}
export const fetchDeviceActions = (callWS: CallWS, deviceId: string) =>
+7
View File
@@ -21,6 +21,7 @@ import {
} from "../common/datetime/calc_date";
import type { DateRange } from "../common/datetime/calc_date_range";
import { calcDateRange } from "../common/datetime/calc_date_range";
import { DEFAULT_ENTITY_NAME } from "../common/entity/compute_entity_name_display";
import { formatNumber } from "../common/number/format_number";
import { normalizeValueBySIPrefix } from "../common/number/normalize-by-si-prefix";
import { groupBy } from "../common/util/group-by";
@@ -327,6 +328,12 @@ export const computeEnergyLabel = (
return customName;
}
const stateObj = hass.states[statisticId];
if (stateObj) {
return hass.formatEntityName(stateObj, DEFAULT_ENTITY_NAME);
}
return getStatisticLabel(hass, statisticId, statisticsMetaData);
};
+36 -12
View File
@@ -1,12 +1,11 @@
import type { HassEntity } from "home-assistant-js-websocket";
import { getEntityAreaId } from "../../common/entity/context/get_entity_context";
import { computeDomain } from "../../common/entity/compute_domain";
import {
computeEntityPickerDisplay,
computeEntitySearchLabels,
} from "../../common/entity/compute_entity_name_display";
import { computeEntityNameList } from "../../common/entity/compute_entity_name_display";
import { computeStateName } from "../../common/entity/compute_state_name";
import type { RelatedIdSets } from "../../common/search/related-context";
import { caseInsensitiveStringCompare } from "../../common/string/compare";
import { computeRTL } from "../../common/util/compute_rtl";
import type { PickerComboBoxItem } from "../../components/ha-picker-combo-box";
import type { FuseWeightedKey } from "../../resources/fuseMultiTerm";
import type { HomeAssistant } from "../../types";
@@ -124,11 +123,31 @@ export const getEntities = (
// These values are the same for every entity, so compute them once instead
// of inside the map over (potentially thousands of) entities.
const isRTL = computeRTL(
hass.language,
hass.translationMetadata.translations
);
const domainNames = new Map<string, string>();
items = entityIds.map<EntityComboBoxItem>((entityId) => {
const stateObj = hass.states[entityId];
const friendlyName = computeStateName(stateObj); // Keep this for search
const [entityName, deviceName, parentDeviceName, areaName] =
computeEntityNameList(
stateObj,
[
{ type: "entity" },
{ type: "device" },
{ type: "parent_device" },
{ type: "area" },
],
hass.entities,
hass.devices,
hass.areas,
hass.floors
);
const domain = computeDomain(entityId);
let domainName = domainNames.get(domain);
if (domainName === undefined) {
@@ -136,7 +155,14 @@ export const getEntities = (
domainNames.set(domain, domainName);
}
const { primary, secondary } = computeEntityPickerDisplay(hass, stateObj);
const primary = entityName || deviceName || entityId;
const secondary = [
areaName,
parentDeviceName,
entityName ? deviceName : undefined,
]
.filter(Boolean)
.join(isRTL ? " ◂ " : " ▸ ");
return {
id: `${idPrefix}${entityId}`,
@@ -145,14 +171,12 @@ export const getEntities = (
domain_name: domainName,
sorting_label: [primary, secondary].filter(Boolean).join("_"),
search_labels: {
...computeEntitySearchLabels(
stateObj,
hass.entities,
hass.devices,
hass.areas,
hass.floors
),
entityName: entityName || null,
deviceName: deviceName || null,
parentDeviceName: parentDeviceName || null,
areaName: areaName || null,
domainName: domainName || null,
friendlyName: friendlyName || null,
entityId: entityId,
},
stateObj: stateObj,
+11 -11
View File
@@ -28,24 +28,24 @@ export interface HardwareInfo {
export interface HardwareInfoEntry {
board: HardwareInfoBoardInfo | null;
dongle: HardwareInfoDongleInfo | null;
config_entries: string[] | null;
name: string | null;
url: string | null;
config_entries: string[];
name: string;
url?: string;
}
export interface HardwareInfoBoardInfo {
manufacturer: string;
model: string | null;
revision: string | null;
hassio_board_id: string | null;
model?: string;
revision?: string;
hassio_board_id?: string;
}
export interface HardwareInfoDongleInfo {
manufacturer: string | null;
description: string | null;
pid: string;
vid: string;
serial_number: string | null;
manufacturer: string;
description: string;
pid?: string;
vid?: string;
serial_number?: string;
}
export interface SystemStatusStreamMessage {
+12 -18
View File
@@ -17,12 +17,11 @@ export type AddonCapability = Exclude<
>;
export type AddonStage = "stable" | "experimental" | "deprecated";
export type AddonAppArmour = "disable" | "default" | "profile";
export type AddonRole =
"default" | "homeassistant" | "backup" | "manager" | "admin";
export type AddonRole = "default" | "homeassistant" | "manager" | "admin";
export type AddonStartup =
"initialize" | "system" | "services" | "application" | "once";
export type AddonState =
"startup" | "started" | "stopped" | "unknown" | "error";
"startup" | "started" | "stopped" | "unknown" | "error" | null;
export type AddonRepository = "core" | "local" | string;
interface AddonFieldTranslation {
@@ -42,15 +41,15 @@ export interface HassioAddonInfo {
build: boolean;
description: string;
detached: boolean;
homeassistant: string | null;
homeassistant: string;
icon: boolean;
installed: boolean;
logo: boolean;
name: string;
repository: AddonRepository;
slug: string;
stage: AddonStage;
state: AddonState;
system_managed: boolean;
update_available: boolean;
url: string | null;
version_latest: string;
@@ -64,14 +63,13 @@ export interface HassioAddonDetails extends HassioAddonInfo {
audio_output: null | string;
audio: boolean;
auth_api: boolean;
auto_uart: boolean;
auto_update: boolean;
boot: "auto" | "manual";
boot_config: "auto" | "manual" | "manual_only";
changelog: boolean;
devices: string[];
devicetree: boolean;
discovery: string[];
dns: string[];
docker_api: boolean;
documentation: boolean;
full_access: boolean;
@@ -84,10 +82,8 @@ export interface HassioAddonDetails extends HassioAddonInfo {
host_ipc: boolean;
host_network: boolean;
host_pid: boolean;
host_uts: boolean;
ingress_entry: null | string;
ingress_panel: boolean | null;
ingress_port: number | null;
ingress_panel: boolean;
ingress_url: null | string;
ingress: boolean;
ip_address: string;
@@ -95,29 +91,27 @@ export interface HassioAddonDetails extends HassioAddonInfo {
long_description: null | string;
machine: any;
network_description: null | Record<string, string>;
network: null | Record<string, number | null>;
network: null | Record<string, number>;
options: Record<string, unknown>;
privileged: any;
protected: boolean;
rating: number;
schema: HaFormSchema[] | null;
services: string[];
services_role: string[];
signed: boolean;
slug: string;
startup: AddonStartup;
stdin: boolean;
system_managed: boolean;
system_managed_config_entry: string | null;
translations: Record<string, AddonTranslations>;
uart: boolean;
udev: boolean;
usb: boolean;
video: boolean;
watchdog: boolean;
watchdog: null | boolean;
webui: null | string;
}
export interface HassioAddonsInfo {
addons: HassioAddonInfo[];
repositories: HassioAddonRepository[];
}
export interface HassioAddonSetSecurityParams {
@@ -207,7 +201,7 @@ export const validateHassioAddonOption = async (
callWS: CallWS,
slug: string,
data?: any
): Promise<{ message: string; valid: boolean; pwned: boolean | null }> => {
): Promise<{ message: string; valid: boolean }> => {
return callWS({
type: "supervisor/api",
endpoint: `/addons/${slug}/options/validate`,
+1 -4
View File
@@ -13,15 +13,12 @@ export interface HassioResponse<T> {
export interface HassioStats {
blk_read: number;
blk_write: number;
cpu_percent: number | null;
cpu_system_usage: number;
cpu_usage: number;
cpu_percent: number;
memory_limit: number;
memory_percent: number;
memory_usage: number;
network_rx: number;
network_tx: number;
online_cpus: number;
}
export const extractApiErrorMessage = (error: any): string =>
+4 -6
View File
@@ -1,15 +1,13 @@
import type { HomeAssistant } from "../../types";
type HassioDockerRegistries = Record<string, { username: string }>;
type HassioDockerRegistryCredentials = Record<
type HassioDockerRegistries = Record<
string,
{ username: string; password: string }
{ username: string; password?: string }
>;
export const fetchHassioDockerRegistries = async (
hass: HomeAssistant
): Promise<{ registries: HassioDockerRegistries }> =>
): Promise<HassioDockerRegistries> =>
hass.callWS({
type: "supervisor/api",
endpoint: `/docker/registries`,
@@ -18,7 +16,7 @@ export const fetchHassioDockerRegistries = async (
export const addHassioDockerRegistry = async (
hass: HomeAssistant,
data: HassioDockerRegistryCredentials
data: HassioDockerRegistries
) => {
await hass.callWS({
type: "supervisor/api",
-1
View File
@@ -15,7 +15,6 @@ interface HassioHardwareAudioList {
export interface HardwareDevice {
attributes: Record<string, string>;
by_id: null | string;
children: string[];
dev_path: string;
name: string;
subsystem: string;
+9 -10
View File
@@ -2,19 +2,19 @@ import type { HomeAssistant } from "../../types";
export interface HassioHostInfo {
agent_version: string;
chassis: string | null;
cpe: string | null;
deployment: string | null;
chassis: string;
cpe: string;
deployment: string;
disk_life_time: number | null;
disk_free: number;
disk_total: number;
disk_used: number;
features: string[];
hostname: string | null;
kernel: string | null;
operating_system: string | null;
boot_timestamp: number | null;
startup_time: number | null;
hostname: string;
kernel: string;
operating_system: string;
boot_timestamp: number;
startup_time: number;
}
export interface HassioHassOSInfo {
@@ -23,8 +23,7 @@ export interface HassioHassOSInfo {
update_available: boolean;
version_latest: string | null;
version: string | null;
version_pending: string | null;
data_disk: string | null;
data_disk: string;
}
export interface Datadisk {
+4 -9
View File
@@ -5,18 +5,15 @@ interface IpConfiguration {
gateway: string | null;
method: "disabled" | "static" | "auto";
nameservers: string[];
ready: boolean | null;
route_metric: number | null;
}
export interface NetworkInterface {
connected: boolean;
mac: string;
primary: boolean;
privacy: boolean;
interface: string;
enabled: boolean;
ipv4?: Partial<IpConfiguration> | null;
ipv6?: Partial<IpConfiguration> | null;
ipv4?: Partial<IpConfiguration>;
ipv6?: Partial<IpConfiguration>;
type: "ethernet" | "wireless" | "vlan";
wifi?: Partial<WifiConfiguration> | null;
}
@@ -44,15 +41,13 @@ export interface WifiConfiguration {
mode: "infrastructure" | "mesh" | "adhoc" | "ap";
auth: "open" | "wep" | "wpa-psk";
ssid: string;
signal: number | null;
signal: number;
psk?: string;
}
export interface NetworkInfo {
interfaces: NetworkInterface[];
docker: DockerNetwork;
host_internet: boolean | null;
supervisor_internet: boolean;
}
export const fetchNetworkInfo = async (
+2 -20
View File
@@ -1,28 +1,10 @@
import type { HomeAssistant, TranslationDict } from "../../types";
export interface ResolutionIssue {
type: string;
context: string;
reference: string | null;
reference_extra: Record<string, unknown> | null;
uuid: string;
}
export interface ResolutionSuggestion extends ResolutionIssue {
auto: boolean;
}
export interface ResolutionCheck {
enabled: boolean;
slug: string;
}
export interface HassioResolution {
unsupported: (keyof TranslationDict["ui"]["dialogs"]["unsupported"]["reasons"])[];
unhealthy: (keyof TranslationDict["ui"]["dialogs"]["unhealthy"]["reasons"])[];
issues: ResolutionIssue[];
suggestions: ResolutionSuggestion[];
checks: ResolutionCheck[];
issues: string[];
suggestions: string[];
}
export const fetchHassioResolution = async (
+13 -36
View File
@@ -1,59 +1,37 @@
import type { HomeAssistant, PanelInfo } from "../../types";
import type { SupervisorArch } from "../supervisor/supervisor";
import type { AddonState } from "./addon";
import { supervisorUrl, type HassioResponse } from "./common";
export interface HassioHomeAssistantInfo {
arch: SupervisorArch | null;
arch: SupervisorArch;
audio_input: string | null;
audio_output: string | null;
backups_exclude_database: boolean;
boot: boolean;
duplicate_log_file: boolean;
image: string;
ip_address: string;
machine: string | null;
machine: string;
port: number;
ssl: boolean;
update_available: boolean;
version_latest: string | null;
version: string | null;
version_latest: string;
version: string;
wait_boot: number;
watchdog: boolean;
}
export interface HassioSupervisorInfoAddon {
icon: boolean;
name: string;
repository: string;
slug: string;
state: AddonState;
update_available: boolean;
version: string;
version_latest: string;
}
export interface HassioSupervisorInfoRepository {
name: string;
slug: string;
}
export interface HassioSupervisorInfo {
addons: HassioSupervisorInfoAddon[];
addons_repositories: HassioSupervisorInfoRepository[];
addons: string[];
addons_repositories: string[];
arch: SupervisorArch;
auto_update: boolean;
channel: string;
country: string | null;
debug: boolean;
debug_block: boolean;
detect_blocking_io: boolean;
diagnostics: boolean | null;
feature_flags: Record<string, boolean>;
healthy: boolean;
ip_address: string;
logging: string;
supported: boolean;
timezone: string | null;
timezone: string;
update_available: boolean;
version: string;
version_latest: string;
@@ -65,12 +43,11 @@ export interface HassioInfo {
channel: string;
docker: string;
features: string[];
hassos: string | null;
homeassistant: string | null;
hostname: string | null;
hassos: null;
homeassistant: string;
hostname: string;
logging: string;
machine: string | null;
machine_id: string | null;
machine: string;
state:
| "initialize"
| "setup"
@@ -80,7 +57,7 @@ export interface HassioInfo {
| "shutdown"
| "stopping"
| "close";
operating_system: string | null;
operating_system: string;
supervisor: string;
supported: boolean;
supported_arch: SupervisorArch[];
+2 -2
View File
@@ -94,14 +94,14 @@ export interface HistoryStreamMessage {
}
export const entityIdHistoryNeedsAttributes = (
hass: Pick<HomeAssistant, "states">,
hass: HomeAssistant,
entityId: string
) =>
!hass.states[entityId] ||
NEED_ATTRIBUTE_DOMAINS.includes(computeDomain(entityId));
export const fetchDateWS = (
hass: Pick<HomeAssistant, "states" | "callWS">,
hass: HomeAssistant,
startTime: Date,
endTime: Date,
entityIds: string[]
+2 -14
View File
@@ -1,11 +1,6 @@
import type { Connection } from "home-assistant-js-websocket";
import { createCollection } from "home-assistant-js-websocket";
import type { LocalizeFunc } from "../common/translations/localize";
import { GITHUB_CORE_ISSUES_URL } from "../common/url/github";
import {
createQueryString,
type QueryParamConfig,
} from "../common/url/query-params";
import { sanitizeHttpUrl } from "../common/url/sanitize-http-url";
import { debounce } from "../common/util/debounce";
import type { HomeAssistant } from "../types";
@@ -113,19 +108,12 @@ const sanitizeManifest = <T extends IntegrationManifest | undefined>(
} as T)
: manifest;
const integrationIssuesQueryParams = {
string: ["q"],
} as const satisfies QueryParamConfig;
export const integrationIssuesUrl = (
domain: string,
manifest: IntegrationManifest
) =>
sanitizeHttpUrl(manifest.issue_tracker) ||
`${GITHUB_CORE_ISSUES_URL}?${createQueryString(
{ q: `is:issue is:open label:"integration: ${domain}"` },
integrationIssuesQueryParams
)}`;
`https://github.com/home-assistant/core/issues?q=is%3Aissue+is%3Aopen+label%3A%22integration%3A+${domain}%22`;
export const domainToName = (
localize: LocalizeFunc,
@@ -167,7 +155,7 @@ export const fetchIntegrationManifestsCollection = async (
};
export const fetchIntegrationManifest = (
hass: Pick<HomeAssistant, "callWS">,
hass: HomeAssistant,
integration: string
) =>
hass
+4 -18
View File
@@ -5,7 +5,6 @@ import { computeDomain } from "../common/entity/compute_domain";
import { computeStateDomain } from "../common/entity/compute_state_domain";
import type { LocalizeFunc } from "../common/translations/localize";
import type { HomeAssistant } from "../types";
import { UNAVAILABLE, UNKNOWN } from "./entity/entity";
import { isNumericEntity } from "./history";
const LOGBOOK_LOCALIZE_PATH = "ui.components.logbook.messages";
@@ -21,7 +20,7 @@ export interface LogbookStreamMessage {
export interface LogbookEntry {
// Base data
when: number; // Python timestamp. Do *1000 to get JS timestamp.
name?: string; // Only sent when the backend resolves entity names
name: string;
message?: string;
entity_id?: string;
icon?: string;
@@ -281,22 +280,17 @@ const STATE_ACTION_MESSAGES: Record<
radio_frequency: "command_sent",
};
const RESTORABLE_BUTTON_DOMAINS = new Set(["button", "input_button"]);
export const localizeStateMessage = (
hass: HomeAssistant,
state: string,
stateObj: HassEntity,
domain: string,
entry?: LogbookEntry
attributes?: LogbookEntry["attributes"]
): string => {
if (state === UNKNOWN || state === UNAVAILABLE) {
return hass.formatEntityState(stateObj, state);
}
// Events show the triggered event type, falling back to a generic label when
// the type is unknown (the timestamp state is meaningless on its own).
if (domain === "event") {
const eventType = entry?.attributes?.event_type;
const eventType = attributes?.event_type;
if (eventType != null) {
return hass.formatEntityAttributeValue(stateObj, "event_type", eventType);
}
@@ -304,15 +298,7 @@ export const localizeStateMessage = (
}
const actionKey: LogbookActionMessage | undefined =
STATE_ACTION_MESSAGES[domain as keyof typeof STATE_ACTION_MESSAGES];
const stateTimestamp = Date.parse(state);
const matchesEntryTime =
entry === undefined ||
(Number.isFinite(stateTimestamp) &&
Math.abs(stateTimestamp - entry.when * 1000) < 1000);
if (
actionKey &&
(!RESTORABLE_BUTTON_DOMAINS.has(domain) || matchesEntryTime)
) {
if (actionKey) {
return hass.localize(`${LOGBOOK_LOCALIZE_PATH}.${actionKey}`);
}
// Every other domain reuses the backend state translation, so the logbook
+4 -5
View File
@@ -14,7 +14,6 @@ export interface IPv4ConfiguredAddress {
export interface Adapter {
name: string;
index: number | null;
enabled: boolean;
auto: boolean;
default: boolean;
@@ -28,9 +27,9 @@ export interface NetworkConfig {
}
export interface NetworkUrls {
internal: string | null;
external: string | null;
cloud: string | null;
internal: string;
external: string;
cloud: string;
}
export const getNetworkConfig = (hass: HomeAssistant) =>
@@ -42,7 +41,7 @@ export const setNetworkConfig = (
hass: HomeAssistant,
configured_adapters: string[]
) =>
hass.callWS<{ configured_adapters: string[] }>({
hass.callWS<string[]>({
type: "network/configure",
config: {
configured_adapters: configured_adapters,
+2 -3
View File
@@ -1,5 +1,5 @@
import type { Connection } from "home-assistant-js-websocket";
import { DEFAULT_ENTITY_NAME } from "../common/entity/entity_name_config";
import { computeStateName } from "../common/entity/compute_state_name";
import type { HaDurationData } from "../components/ha-duration-input";
import type { HomeAssistant } from "../types";
import { firstWeekday } from "../common/datetime/first_weekday";
@@ -348,9 +348,8 @@ export const getStatisticLabel = (
): string => {
const entity = hass.states[statisticsId];
if (entity) {
return hass.formatEntityName(entity, DEFAULT_ENTITY_NAME);
return computeStateName(entity);
}
// External statistics have no entity to resolve a name against.
return statisticsMetaData?.name || statisticsId;
};
+1 -1
View File
@@ -142,7 +142,7 @@ export interface DeviceAction extends BaseAction {
type: string;
device_id: string;
domain: string;
entity_id?: string;
entity_id: string;
}
export interface DelayActionParts extends BaseAction {
+7 -14
View File
@@ -6,7 +6,7 @@ import { formatListWithAnds } from "../common/string/format-list";
import { isTemplate } from "../common/string/has-template";
import type { HomeAssistant } from "../types";
import type { Condition } from "./automation";
import { describeCondition, type DescribeOptions } from "./automation_i18n";
import { describeCondition } from "./automation_i18n";
import { localizeDeviceAutomationAction } from "./device/device_automation";
import type { EntityRegistryEntry } from "./entity/entity_registry";
import { domainToName } from "./integration";
@@ -47,7 +47,7 @@ export const describeAction = <T extends ActionType>(
entityRegistry: EntityRegistryEntry[],
action: ActionTypes[T],
actionType?: T,
options?: DescribeOptions,
ignoreAlias = false,
manifests?: DomainManifestLookup
): string => {
try {
@@ -56,7 +56,7 @@ export const describeAction = <T extends ActionType>(
entityRegistry,
action,
actionType,
options,
ignoreAlias,
manifests
);
if (typeof description !== "string") {
@@ -79,10 +79,10 @@ const tryDescribeAction = <T extends ActionType>(
entityRegistry: EntityRegistryEntry[],
action: ActionTypes[T],
actionType?: T,
options?: DescribeOptions,
ignoreAlias = false,
manifests?: DomainManifestLookup
): string => {
if (action.alias && !options?.ignoreAlias) {
if (action.alias && !ignoreAlias) {
return action.alias;
}
if (!actionType) {
@@ -322,12 +322,7 @@ const tryDescribeAction = <T extends ActionType>(
return hass.localize(
`${actionTranslationBaseKey}.check_condition.description.full`,
{
condition: describeCondition(
action as Condition,
hass,
entityRegistry,
options
),
condition: describeCondition(action as Condition, hass, entityRegistry),
}
);
}
@@ -348,9 +343,7 @@ const tryDescribeAction = <T extends ActionType>(
if (localized) {
return localized;
}
const stateObj = config.entity_id
? hass.states[config.entity_id]
: undefined;
const stateObj = hass.states[config.entity_id];
if (config.type) {
return `${config.type} ${
stateObj ? computeStateName(stateObj) : config.entity_id
-4
View File
@@ -368,10 +368,6 @@ export interface LocationSelector {
radius?: boolean;
radius_readonly?: boolean;
icon?: string;
/** Name whose initials the marker shows when there is no icon */
name?: string;
/** Marker and radius color; defaults to the theme's zone color */
color?: string;
} | null;
}
+1 -19
View File
@@ -1,6 +1,4 @@
import { ensureArray } from "../common/array/ensure-array";
import { isValidEntityId } from "../common/entity/valid_entity_id";
import type { Context, HomeAssistant, ServiceCallRequest } from "../types";
import type { Context, HomeAssistant } from "../types";
import type { Action } from "./script";
export const callExecuteScript = (
@@ -24,19 +22,3 @@ export const serviceCallWillDisconnect = (
"update.home_assistant_core_update",
"update.home_assistant_operating_system_update",
].includes(serviceData?.entity_id));
// Core merges the target into the service data, so a target entity_id
// replaces the legacy service data one rather than adding to it. Its schema
// also accepts comma separated ids and lowercases them.
export const getServiceCallEntityIds = (
serviceData?: ServiceCallRequest["serviceData"],
target?: ServiceCallRequest["target"]
): string[] => [
...new Set(
(ensureArray(target?.entity_id ?? serviceData?.entity_id) ?? [])
.filter((id): id is string => typeof id === "string")
.flatMap((id) => id.split(","))
.map((id) => id.trim().toLowerCase())
.filter(isValidEntityId)
),
];
+5 -11
View File
@@ -14,12 +14,8 @@ export enum SupervisorMountUsage {
export enum SupervisorMountState {
ACTIVE = "active",
ACTIVATING = "activating",
DEACTIVATING = "deactivating",
FAILED = "failed",
INACTIVE = "inactive",
MAINTENANCE = "maintenance",
RELOADING = "reloading",
UNKNOWN = "unknown",
}
interface MountOptions {
@@ -30,16 +26,14 @@ export type CIFSVersion = "auto" | "1.0" | "2.0";
interface SupervisorMountBase {
name: string;
usage: SupervisorMountUsage | null;
usage: SupervisorMountUsage;
type: SupervisorMountType;
read_only: boolean;
server: string;
port?: number;
port: number;
}
export interface SupervisorMountResponse extends SupervisorMountBase {
state: SupervisorMountState | null;
user_path: string | null;
}
export interface SupervisorNFSMount extends SupervisorMountResponse {
@@ -50,7 +44,7 @@ export interface SupervisorNFSMount extends SupervisorMountResponse {
export interface SupervisorCIFSMount extends SupervisorMountResponse {
type: SupervisorMountType.CIFS;
share: string;
version?: CIFSVersion | null;
version?: CIFSVersion;
}
export type SupervisorMount = SupervisorNFSMount | SupervisorCIFSMount;
@@ -60,7 +54,7 @@ export type SupervisorNFSMountRequestParams = SupervisorNFSMount;
export interface SupervisorCIFSMountRequestParams extends SupervisorCIFSMount {
username?: string;
password?: string;
version?: CIFSVersion | null;
version?: CIFSVersion;
}
export type SupervisorMountRequestParams =
+1 -1
View File
@@ -2,7 +2,7 @@ import type { HomeAssistant } from "../../types";
export interface SupervisorUpdateConfig {
add_on_backup_before_update: boolean;
add_on_backup_retain_copies: number;
add_on_backup_retain_copies?: number;
core_backup_before_update: boolean;
}
+1 -1
View File
@@ -81,7 +81,7 @@ type SystemHealthEvent =
| SystemHealthEventFinish;
export const subscribeSystemHealthInfo = (
hass: Pick<HomeAssistant, "connection">,
hass: HomeAssistant,
callback: (info: SystemHealthInfo | undefined) => void
) => {
let data = {};
+1 -5
View File
@@ -5,7 +5,7 @@ export type SystemLogLevel =
export interface LoggedError {
name: string;
message: [string, ...string[]];
message: [string];
level: SystemLogLevel;
source: [string, number];
exception: string;
@@ -41,10 +41,6 @@ export const getLoggedErrorIntegration = (item: LoggedError) => {
return item.source[0].split("/")[2];
}
if (item.source[0].startsWith("components/")) {
return item.source[0].split("/")[1];
}
return undefined;
};
-90
View File
@@ -1,90 +0,0 @@
import {
GITHUB_CORE_ISSUES_URL,
GITHUB_FRONTEND_ISSUES_URL,
} from "../common/url/github";
import {
createQueryString,
type QueryParamConfig,
} from "../common/url/query-params";
import { sanitizeHttpUrl } from "../common/url/sanitize-http-url";
import { documentationUrlForVersion } from "../util/documentation-url";
import type { IntegrationManifest } from "./integration";
import type { LoggedError } from "./system_log";
import {
getLoggedErrorIntegration,
isCustomIntegrationError,
} from "./system_log";
const frontendIssueQueryParams = {
string: ["template", "core_version", "javascript_errors"],
} as const satisfies QueryParamConfig;
const coreIssueQueryParams = {
string: [
"template",
"version",
"installation_type",
"integration_name",
"integration_link",
"logs",
],
} as const satisfies QueryParamConfig;
const coreIssueTemplateUrl = `${GITHUB_CORE_ISSUES_URL}/new?${createQueryString(
{ template: "bug_report.yml" },
coreIssueQueryParams
)}`;
export const systemLogReportUrl = (
item: LoggedError,
coreVersion: string,
manifest?: IntegrationManifest | null,
installationType?: string
): string => {
const log = [
item.name,
item.source.join(":"),
...item.message,
item.exception,
]
.filter(Boolean)
.join("\n\n");
const includeLogs = new URLSearchParams({ log }).toString().length <= 6000;
if (/^frontend\.js(?:_dev)?(?:\.|$)/.test(item.name)) {
return `${GITHUB_FRONTEND_ISSUES_URL}/new?${createQueryString(
{
template: "bug_report.yml",
core_version: coreVersion,
javascript_errors: includeLogs ? log : undefined,
},
frontendIssueQueryParams
)}`;
}
if (getLoggedErrorIntegration(item) && !manifest) {
return coreIssueTemplateUrl;
}
if (isCustomIntegrationError(item) || manifest?.is_built_in === false) {
return sanitizeHttpUrl(manifest?.issue_tracker) || coreIssueTemplateUrl;
}
return `${GITHUB_CORE_ISSUES_URL}/new?${createQueryString(
{
template: "bug_report.yml",
version: coreVersion,
installation_type: installationType,
integration_name: manifest?.name,
integration_link: manifest
? documentationUrlForVersion(
coreVersion,
`/integrations/${encodeURIComponent(manifest.domain)}/`
)
: undefined,
logs: includeLogs ? log : undefined,
},
coreIssueQueryParams
)}`;
};
-608
View File
@@ -1,608 +0,0 @@
import { ensureArray } from "../common/array/ensure-array";
import { getAutomationActionType } from "./action";
import { flattenTriggers } from "./automation";
import type { Condition, Trigger } from "./automation";
import type {
Action,
ChooseAction,
IfAction,
Option,
ParallelAction,
RepeatAction,
SequenceAction,
} from "./script";
import type {
ActionTraceStep,
ChooseActionTraceStep,
ConditionTraceStep,
DelayActionTraceStep,
IfActionTraceStep,
TraceExtended,
WaitActionTraceStep,
} from "./trace";
export type TraceNodeType = "trigger" | "condition" | "action" | "chooseOption";
export type TraceActionNodeType =
Exclude<ReturnType<typeof getAutomationActionType>, undefined> | "other";
export interface NodeInfo {
path: string;
config: unknown;
type?: TraceNodeType;
}
export interface TraceNode<T = unknown> {
path: string;
config: T;
type: TraceNodeType;
/** Data fact: the path (or branch) was tracked by Core. */
hasTrace: boolean;
/** Visual execution state (triggers mask not-triggered, conditions use outcome). */
track: boolean;
error: boolean;
/** Own `enabled === false` or inherited from an ancestor action. */
disabled: boolean;
notTriggered?: boolean;
condition?: { executed: boolean; passed: boolean; failed: boolean };
}
export interface TraceActionNode<
T extends Action = Action,
> extends TraceNode<T> {
actionType: TraceActionNodeType;
branches: TraceBranch[];
iterations?: number;
badge?: number;
}
export interface TraceBranch {
path: string;
children: TraceActionNode[];
hasTrace: boolean;
finished: boolean;
unfinished: boolean;
disabled: boolean;
option?: Option;
}
const isDisabled = (config: unknown, parentDisabled: boolean): boolean =>
parentDisabled ||
(typeof config === "object" &&
config !== null &&
"enabled" in config &&
(config as { enabled?: boolean }).enabled === false);
/** Configuration-shaped tree annotated with the execution tracked by Core. */
export class TraceTree {
public readonly triggers?: TraceNode<Trigger>[];
public readonly conditions: TraceNode<Condition>[];
public readonly actions: TraceActionNode[];
public readonly sequence: TraceActionNode[];
/** All selectable nodes in render order, keyed by path. */
public readonly renderedNodes: Record<string, NodeInfo> = {};
/**
* Selectable nodes that were tracked, sorted in trace order. Includes
* not-triggered triggers so they remain navigable, even though their
* `track` is false and they do not render as part of the executed path.
*/
public readonly trackedNodes: Record<string, NodeInfo> = {};
public readonly trackedPaths: string[] = [];
constructor(public readonly trace: TraceExtended) {
const config = trace.config;
const triggerKey = "triggers" in config ? "triggers" : "trigger";
const conditionKey = "conditions" in config ? "conditions" : "condition";
const actionKey = "actions" in config ? "actions" : "action";
this.triggers =
triggerKey in config
? flattenTriggers(ensureArray(config[triggerKey])).map((trigger, i) =>
this._triggerNode(trigger, `trigger/${i}`)
)
: undefined;
this.conditions =
conditionKey in config
? ensureArray<Condition>(config[conditionKey] ?? []).map(
(condition, i) =>
this._conditionNode(
condition,
`condition/${i}`,
"condition",
false
)
)
: [];
this.actions =
actionKey in config
? this._actions(
ensureArray<Action>(config[actionKey]),
"action/",
false
)
: [];
this.sequence =
"sequence" in config
? this._actions(
ensureArray<Action>(config.sequence),
"sequence/",
false
)
: [];
this._indexNodes();
}
public get firstTracked(): NodeInfo | undefined {
return this.trackedNodes[this.trackedPaths[0]];
}
public getNode(path: string): NodeInfo | undefined {
return this.renderedNodes[path];
}
public previousTracked(path: string): NodeInfo | undefined {
// An unknown path yields index -2 and returns undefined, matching the
// previous graph behavior.
const index = this.trackedPaths.indexOf(path) - 1;
return index >= 0 ? this.trackedNodes[this.trackedPaths[index]] : undefined;
}
public nextTracked(path: string): NodeInfo | undefined {
// An unknown path yields index 0 and restarts from the first node,
// matching the previous graph behavior.
const index = this.trackedPaths.indexOf(path) + 1;
return index < this.trackedPaths.length
? this.trackedNodes[this.trackedPaths[index]]
: undefined;
}
private _base<T>(
config: T,
path: string,
type: TraceNodeType,
parentDisabled: boolean
): TraceNode<T> {
const hasTrace = path in this.trace.trace;
return {
config,
path,
type,
hasTrace,
track: hasTrace,
error: this.trace.trace[path]?.some((record) => record.error) ?? false,
disabled: isDisabled(config, parentDisabled),
};
}
private _triggerNode(config: Trigger, path: string): TraceNode<Trigger> {
const node = this._base(config, path, "trigger", false);
// A not-triggered trace records the trigger that evaluated a change but
// decided not to fire. It is still selectable (to view the reason), but
// must not be shown as the path that ran.
node.notTriggered = node.hasTrace && !!this.trace.not_triggered;
node.track = node.hasTrace && !node.notTriggered;
return node;
}
private _conditionNode<T>(
config: T,
path: string,
type: TraceNodeType,
parentDisabled: boolean
): TraceNode<T> {
const node = this._base(config, path, type, parentDisabled);
const records = this.trace.trace[path] as ConditionTraceStep[] | undefined;
const executed = !!records?.some((record) => record.result || record.error);
node.condition = {
executed,
passed: !!records?.some((record) => record.result?.result),
failed: !!records?.some(
(record) => record.result && !record.result.result
),
};
node.track = executed;
return node;
}
private _actions(
actions: Action[],
prefix: string,
parentDisabled: boolean
): TraceActionNode[] {
return actions.map((action, i) =>
this._actionNode(action, `${prefix}${i}`, parentDisabled)
);
}
private _branch(
path: string,
prefix: string,
steps: Action[],
parentDisabled: boolean,
hasTrace = this._hasTracedSteps(prefix)
): TraceBranch {
const children = this._actions(steps, prefix, parentDisabled);
const finished = hasTrace ? this._branchFinished(prefix, steps) : false;
return {
path,
children,
hasTrace,
finished,
unfinished: hasTrace && !finished,
disabled: parentDisabled,
};
}
/** Build an action subtree, retaining the original config for selection. */
private _actionNode<T extends Action>(
config: T,
path: string,
parentDisabled = false
): TraceActionNode<T> {
const actionType = getAutomationActionType(config) ?? "other";
const node: TraceActionNode<T> = {
...(actionType === "condition"
? this._conditionNode(config, path, "action", parentDisabled)
: this._base(config, path, "action", parentDisabled)),
actionType,
branches: [],
};
const disabled = node.disabled;
switch (actionType) {
case "choose": {
const choose = config as ChooseAction;
const records = this.trace.trace[path] as
ChooseActionTraceStep[] | undefined;
const choices =
records?.map((record) =>
record.result?.choice === "default" ||
(!record.result && !record.error)
? "default"
: record.result?.choice
) ?? [];
node.branches = ensureArray<Option>(choose.choose ?? []).map(
(option, i) => {
const branchPath = `${path}/choose/${i}`;
const prefix = `${branchPath}/sequence/`;
return {
...this._branch(
branchPath,
prefix,
ensureArray<Action>(option.sequence ?? []),
disabled,
choices.includes(i) || this._hasTracedSteps(prefix)
),
option,
};
}
);
const prefix = `${path}/default/`;
node.branches.push(
this._branch(
`${path}/default`,
prefix,
ensureArray<Action>(choose.default ?? []),
disabled,
choices.includes("default") || this._hasTracedSteps(prefix)
)
);
break;
}
case "if": {
const ifAction = config as IfAction;
const records = this.trace.trace[path] as
IfActionTraceStep[] | undefined;
node.branches = (["then", "else"] as const).map((choice) => {
const prefix = `${path}/${choice}/`;
// Core sets no result for the implicit else bypass. An error instead
// means execution aborted before choosing a branch.
const hasTrace =
!!records?.some(
(record) =>
record.result?.choice === choice ||
(choice === "else" && !record.result && !record.error)
) || this._hasTracedSteps(prefix);
return this._branch(
`${path}/${choice}`,
prefix,
ensureArray<Action>(ifAction[choice] ?? []),
disabled,
hasTrace
);
});
break;
}
case "repeat": {
const repeat = config as RepeatAction;
const prefix = `${path}/repeat/sequence/`;
const iterations = this.trace.trace[`${prefix}0`];
// Core's repeat.index is 1-based (1, 2, 3, …), so the last stored
// index equals the completed count. Fall back to the stored record
// count when the variable is absent, as iterationNumber does in
// ha-trace-path-details.
node.iterations =
(
iterations?.[iterations.length - 1]?.changed_variables?.repeat as
{ index?: number } | undefined
)?.index ?? iterations?.length;
node.badge =
node.iterations !== undefined && node.iterations > 1
? node.iterations
: undefined;
// The body is tracked by its own traced steps. An empty body has no
// step path for Core to record and nothing to show as executed, so
// it stays untracked rather than being guessed from the repeat.
node.branches = [
this._branch(
`${path}/repeat`,
prefix,
ensureArray<Action>(repeat.repeat.sequence),
disabled
),
];
break;
}
case "sequence":
node.branches = [
this._branch(
`${path}/sequence`,
`${path}/sequence/`,
ensureArray<Action>((config as SequenceAction).sequence ?? []),
disabled,
node.hasTrace
),
];
break;
case "parallel":
node.branches = ensureArray<Action>(
(config as ParallelAction).parallel
).map((branch, i) => {
const branchPath = `${path}/parallel/${i}`;
const steps = ensureArray<Action>(
"sequence" in branch
? ((branch as SequenceAction).sequence ?? [])
: branch
);
const prefix = `${branchPath}/sequence/`;
return this._branch(
branchPath,
prefix,
steps,
disabled,
// An empty branch has no step path for Core to record. It ran
// when the parent parallel action ran, matching the old graph
// which tracked the branch wrapper from the parent path.
steps.length === 0 ? node.hasTrace : this._hasTracedSteps(prefix)
);
});
break;
}
return node;
}
private _indexNodes() {
const ordered: NodeInfo[] = [];
const visitAction = (node: TraceActionNode) => {
ordered.push({
path: node.path,
config: node.config,
type: "action",
});
if (node.actionType === "choose") {
for (const branch of node.branches.slice(0, -1)) {
ordered.push({
path: branch.path,
config: branch.option,
type: "chooseOption",
});
branch.children.forEach(visitAction);
}
node.branches[node.branches.length - 1]?.children.forEach(visitAction);
} else {
node.branches.forEach((branch) => branch.children.forEach(visitAction));
}
};
this.triggers?.forEach((node) =>
ordered.push({ path: node.path, config: node.config, type: "trigger" })
);
this.conditions.forEach((node) =>
ordered.push({ path: node.path, config: node.config, type: "condition" })
);
this.actions.forEach(visitAction);
this.sequence.forEach(visitAction);
// Preserve the previous render-then-sort order: untracked paths keep
// relative render order ahead of tracked ones. Untracked paths get -1
// so they sort before tracked paths, and Array.prototype.sort is stable
// so their relative render order survives.
const traceOrder = new Map<string, number>();
let traceIndex = 0;
for (const path of Object.keys(this.trace.trace)) {
traceOrder.set(path, traceIndex++);
}
const trackedByPath = new Map<string, boolean>();
const collectTracked = (nodes: TraceActionNode[]) => {
for (const node of nodes) {
trackedByPath.set(node.path, node.hasTrace);
for (const branch of node.branches) {
if (node.actionType === "choose" && branch.option !== undefined) {
trackedByPath.set(branch.path, branch.hasTrace);
}
collectTracked(branch.children);
}
}
};
this.triggers?.forEach((node) =>
trackedByPath.set(node.path, node.hasTrace)
);
this.conditions.forEach((node) =>
trackedByPath.set(node.path, node.hasTrace)
);
collectTracked(this.actions);
collectTracked(this.sequence);
const sorted = ordered.sort(
(a, b) => (traceOrder.get(a.path) ?? -1) - (traceOrder.get(b.path) ?? -1)
);
for (const info of sorted) {
this.renderedNodes[info.path] = info;
if (trackedByPath.get(info.path)) {
this.trackedNodes[info.path] = info;
this.trackedPaths.push(info.path);
}
}
}
// A branch can be missing the result that names it (parallel runs can drop
// it), so fall back to whether any of its steps were traced.
private _hasTracedSteps(pathPrefix: string) {
return Object.keys(this.trace.trace).some((path) =>
path.startsWith(pathPrefix)
);
}
// Reaching the last step does not mean it completed or allowed continuation.
// The prefix includes the trailing slash, e.g. "sequence/0/then/".
private _branchFinished(pathPrefix: string, steps: Action[]) {
if (steps.length === 0) {
return true;
}
const lastPath = `${pathPrefix}${steps.length - 1}`;
const lastTrace = this.trace.trace[lastPath];
if (!lastTrace?.length) {
return false;
}
const finished = this._actionFinished(steps[steps.length - 1], lastTrace);
if (finished !== undefined) {
return finished;
}
// A non-error stop propagates through building blocks without adding a
// parent error. Inspect descendants, since last_step may name a sibling.
const lastTimestamp = lastTrace[lastTrace.length - 1].timestamp;
if (
Object.entries(this.trace.trace).some(
([path, records]) =>
path.startsWith(`${lastPath}/`) &&
records.some(
(record) =>
record.timestamp >= lastTimestamp &&
"result" in record &&
record.result &&
"stop" in record.result &&
!record.result.error
)
)
) {
return false;
}
// Core awaits all parallel branches before propagating an error. Once
// stopped normally, a branch without a local failure completed even if
// a sibling failed. External cancellation may still interrupt it.
return (
(this.trace.state === "stopped" &&
this.trace.script_execution !== null &&
["finished", "aborted", "error"].includes(
this.trace.script_execution
)) ||
this._hasContinuedAfter(lastPath, lastTimestamp)
);
}
// Undefined means the action has no explicit completion result; the caller
// must check run state or subsequent execution instead.
private _actionFinished(
action: Action,
trace: ActionTraceStep[]
): boolean | undefined {
if (
trace.some((tr) => tr.error) &&
!("continue_on_error" in action && action.continue_on_error)
) {
return false;
}
const lastRecord = trace[trace.length - 1];
// Disabled steps are skipped by Core and recorded generically with
// `result.enabled === false`, regardless of action type.
if (
(lastRecord as { result?: { enabled?: boolean } }).result?.enabled ===
false
) {
return true;
}
if ("stop" in action) {
return false;
}
if (
getAutomationActionType(action) === "condition" &&
(trace as ConditionTraceStep[]).some((tr) => tr.result?.result === false)
) {
return false;
}
if ("wait_template" in action || "wait_for_trigger" in action) {
if (
(trace as WaitActionTraceStep[]).some(
({ result }) =>
(result?.timeout && action.continue_on_timeout === false) ||
(result?.wait?.completed === false &&
(action.continue_on_timeout === false ||
result.wait.remaining !== 0))
)
) {
return false;
}
const result = (lastRecord as WaitActionTraceStep).result;
if (result?.wait || result?.enabled === false) {
return true;
}
}
if ("delay" in action) {
const result = (lastRecord as DelayActionTraceStep).result;
if (result && "done" in result) {
return result.done;
}
}
return undefined;
}
private _hasContinuedAfter(path: string, timestamp: string) {
// A newer parallel sibling is not evidence of completion. Look for a
// subsequent action in this sequence or after an ancestor's rejoin.
const parts = path.split("/");
for (let index = parts.length - 1; index > 0; index--) {
if (
!["action", "sequence", "then", "else", "default"].includes(
parts[index - 1]
)
) {
continue;
}
const nextPath = `${parts.slice(0, index).join("/")}/${Number(parts[index]) + 1}`;
const nextTrace = this.trace.trace[nextPath];
const nextRecord = nextTrace?.[nextTrace.length - 1];
// Repeats may retain continuation records from an earlier iteration.
if (nextRecord && nextRecord.timestamp > timestamp) {
return true;
}
}
return false;
}
}
+4 -27
View File
@@ -37,6 +37,7 @@ export interface ConditionTraceStep extends BaseTraceStep {
export interface CallServiceActionTraceStep extends BaseTraceStep {
result?: {
limit: number;
running_script: boolean;
params: Record<string, unknown>;
};
@@ -59,22 +60,6 @@ export interface StopActionTraceStep extends BaseTraceStep {
result?: { stop: string; error: boolean };
}
export interface WaitActionTraceStep extends BaseTraceStep {
result?: {
enabled?: boolean;
wait?: {
completed: boolean;
remaining: number | null;
trigger?: Record<string, unknown> | null;
};
timeout?: boolean;
};
}
export interface DelayActionTraceStep extends BaseTraceStep {
result?: { delay: number; done: boolean };
}
export interface ChooseChoiceActionTraceStep extends BaseTraceStep {
result?: { result: boolean };
}
@@ -85,10 +70,6 @@ export type ActionTraceStep =
| ConditionTraceStep
| CallServiceActionTraceStep
| ChooseActionTraceStep
| IfActionTraceStep
| StopActionTraceStep
| WaitActionTraceStep
| DelayActionTraceStep
| ChooseChoiceActionTraceStep;
interface BaseTrace {
@@ -124,11 +105,7 @@ interface BaseTrace {
| "cancelled"
// No action was executed because a trigger evaluated a relevant change but
// decided not to fire; the reason is in the trigger step of the trace
| "not_triggered"
// A script called itself, directly or through another script
| "disallowed_recursion_detected"
// The run has not stopped yet, so it has no stop reason
| null;
| "not_triggered";
}
interface BaseTraceExtended {
@@ -145,7 +122,7 @@ export interface AutomationTrace extends BaseTrace {
export interface AutomationTraceExtended
extends AutomationTrace, BaseTraceExtended {
config: ManualAutomationConfig;
blueprint_inputs?: BlueprintAutomationConfig | null;
blueprint_inputs?: BlueprintAutomationConfig;
}
export interface ScriptTrace extends BaseTrace {
@@ -154,7 +131,7 @@ export interface ScriptTrace extends BaseTrace {
export interface ScriptTraceExtended extends ScriptTrace, BaseTraceExtended {
config: ScriptConfig;
blueprint_inputs?: BlueprintScriptConfig | null;
blueprint_inputs?: BlueprintScriptConfig;
}
export type Trace = AutomationTrace | ScriptTrace;
@@ -17,6 +17,7 @@ import type { LocalizeFunc } from "../../../../common/translations/localize";
import { throttle } from "../../../../common/util/throttle";
import "../../../../components/ha-hs-color-picker";
import "../../../../components/ha-icon";
import "../../../../components/ha-icon-button-prev";
import "../../../../components/ha-labeled-slider";
import { apiContext } from "../../../../data/context";
import type { LightColor, LightEntity } from "../../../../data/light";
@@ -4,6 +4,7 @@ import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { transform } from "../../../common/decorators/transform";
import "../../../components/ha-date-input";
import "../../../components/ha-time-input";
import { apiContext, internationalizationContext } from "../../../data/context";
import { setDateValue } from "../../../data/date";
import { UNAVAILABLE, UNKNOWN } from "../../../data/entity/entity";
+13 -11
View File
@@ -131,10 +131,6 @@ class MoreInfoFan extends LitElement {
return nothing;
}
const supportsOnOff =
supportsFeature(this.stateObj, FanEntityFeature.TURN_ON) ||
supportsFeature(this.stateObj, FanEntityFeature.TURN_OFF);
const supportsSpeed = supportsFeature(
this.stateObj,
FanEntityFeature.SET_SPEED
@@ -178,15 +174,21 @@ class MoreInfoFan extends LitElement {
`
}
${
supportSpeedPercentage && supportsOnOff
supportSpeedPercentage
? html`
<div class="buttons">
<ha-outlined-icon-button
.disabled=${this.stateObj.state === UNAVAILABLE}
@click=${this._toggle}
>
<ha-svg-icon .path=${mdiPower}></ha-svg-icon>
</ha-outlined-icon-button>
${
supportSpeedPercentage
? html`
<ha-outlined-icon-button
.disabled=${this.stateObj.state === UNAVAILABLE}
@click=${this._toggle}
>
<ha-svg-icon .path=${mdiPower}></ha-svg-icon>
</ha-outlined-icon-button>
`
: nothing
}
</div>
`
: nothing
@@ -11,7 +11,6 @@ import type { CSSResultGroup, PropertyValues } from "lit";
import { LitElement, css, html, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { consumeLocalize } from "../../../common/decorators/consume-context-entry";
import { fireEvent, type HASSDomEvent } from "../../../common/dom/fire_event";
import { supportsFeature } from "../../../common/entity/supports-feature";
import type { LocalizeFunc } from "../../../common/translations/localize";
import "../../../components/ha-attribute-icon";
@@ -39,6 +38,7 @@ import "../../../state-control/light/ha-state-control-light-brightness";
import { apiContext, formattersContext } from "../../../data/context";
import "../components/ha-more-info-control-select-container";
import "../components/ha-more-info-state-header";
import "../components/lights/ha-favorite-color-button";
import "../components/lights/ha-more-info-light-favorite-colors";
import "../components/lights/light-color-rgb-picker";
import "../components/lights/light-color-temp-picker";
@@ -86,26 +86,12 @@ class MoreInfoLight extends LitElement {
private _setMainControl(ev: any) {
ev.stopPropagation();
this._changeMainControl(ev.currentTarget.control);
this._mainControl = ev.currentTarget.control;
}
private _resetMainControl(ev: any) {
ev.stopPropagation();
this._changeMainControl("brightness");
}
public connectedCallback(): void {
super.connectedCallback();
// A container that outlives this control (e.g. more-info-content when the
// dialog moves between entities) resyncs with the default control.
fireEvent(this, "light-main-control-changed", {
control: this._mainControl,
});
}
private _changeMainControl(control: MainControl) {
this._mainControl = control;
fireEvent(this, "light-main-control-changed", { control });
this._mainControl = "brightness";
}
private get _stateOverride() {
@@ -414,14 +400,4 @@ declare global {
interface HTMLElementTagNameMap {
"more-info-light": MoreInfoLight;
}
interface HASSDomEvents {
"light-main-control-changed": { control: MainControl };
}
interface HTMLElementEventMap {
"light-main-control-changed": HASSDomEvent<
HASSDomEvents["light-main-control-changed"]
>;
}
}
@@ -10,6 +10,7 @@ import { supportsFeature } from "../../../common/entity/supports-feature";
import type { LocalizeFunc } from "../../../common/translations/localize";
import "../../../components/ha-control-button";
import "../../../components/ha-control-button-group";
import "../../../components/ha-outlined-icon-button";
import "../../../components/ha-state-icon";
import { apiContext } from "../../../data/context";
import type { LockEntity } from "../../../data/lock";
@@ -31,6 +31,7 @@ import "../../../components/ha-dropdown-item";
import "../../../components/ha-icon-button";
import "../../../components/ha-list-item";
import "../../../components/ha-marquee-text";
import "../../../components/ha-select";
import type { HaSlider } from "../../../components/ha-slider";
import "../../../components/ha-svg-icon";
import "../../../components/ha-tooltip";
@@ -5,9 +5,11 @@ import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { listenMediaQuery } from "../../../common/dom/media_query";
import { computeObjectId } from "../../../common/entity/compute_object_id";
import "../../../components/entity/state-info";
import "../../../components/ha-control-button";
import "../../../components/ha-control-button-group";
import "../../../components/ha-markdown";
import "../../../components/ha-relative-time";
import "../../../components/ha-service-control";
import { UNAVAILABLE } from "../../../data/entity/entity";
import type { ExtEntityRegistryEntry } from "../../../data/entity/entity_registry";
@@ -3,6 +3,7 @@ import type { HassEntity } from "home-assistant-js-websocket";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { transform } from "../../../common/decorators/transform";
import "../../../components/ha-date-input";
import "../../../components/ha-time-input";
import { apiContext, internationalizationContext } from "../../../data/context";
import { UNAVAILABLE, UNKNOWN } from "../../../data/entity/entity";

Some files were not shown because too many files have changed in this diff Show More