mirror of
https://github.com/home-assistant/frontend.git
synced 2026-09-25 22:40:24 +00:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
826fe4f7b0 |
@@ -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: >
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -13,7 +13,6 @@ const HARDWARE_INFO: HardwareInfo = {
|
||||
hassio_board_id: "green",
|
||||
manufacturer: "homeassistant",
|
||||
model: "green",
|
||||
revision: null,
|
||||
},
|
||||
dongle: null,
|
||||
config_entries: [],
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
],
|
||||
|
||||
@@ -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
@@ -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",
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
@@ -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 {
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
`;
|
||||
@@ -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"]) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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%;
|
||||
|
||||
@@ -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
@@ -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",
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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,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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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,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
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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`,
|
||||
|
||||
@@ -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 =>
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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,
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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)
|
||||
),
|
||||
];
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -81,7 +81,7 @@ type SystemHealthEvent =
|
||||
| SystemHealthEventFinish;
|
||||
|
||||
export const subscribeSystemHealthInfo = (
|
||||
hass: Pick<HomeAssistant, "connection">,
|
||||
hass: HomeAssistant,
|
||||
callback: (info: SystemHealthInfo | undefined) => void
|
||||
) => {
|
||||
let data = {};
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
|
||||
@@ -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
|
||||
)}`;
|
||||
};
|
||||
@@ -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
@@ -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";
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user