mirror of
https://github.com/home-assistant/frontend.git
synced 2026-09-25 22:40:24 +00:00
Compare commits
64
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6f3436c61f | ||
|
|
5ea505ec41 | ||
|
|
7302b7e709 | ||
|
|
716c4e464a | ||
|
|
78a64d4ab2 | ||
|
|
ff5df1bfc8 | ||
|
|
03c2ed1f46 | ||
|
|
e964058614 | ||
|
|
da7e8e7a24 | ||
|
|
66d985fb27 | ||
|
|
98395405f3 | ||
|
|
465f4a8227 | ||
|
|
87763fb59d | ||
|
|
f4bb4e242d | ||
|
|
eb91411cfe | ||
|
|
1767632811 | ||
|
|
7c5dd1841b | ||
|
|
d8effd44b2 | ||
|
|
182563d943 | ||
|
|
e5f1cb0b1f | ||
|
|
b31da04913 | ||
|
|
d35c398bfd | ||
|
|
71f9285e70 | ||
|
|
117392d71e | ||
|
|
666f88c4da | ||
|
|
b12f78c652 | ||
|
|
8e10d53012 | ||
|
|
1813f386de | ||
|
|
80202cd645 | ||
|
|
c786f7d2e6 | ||
|
|
1ac4382dc5 | ||
|
|
d0220b6a72 | ||
|
|
bf21bfa777 | ||
|
|
1fc565b602 | ||
|
|
7ac932ff7e | ||
|
|
996ccd50d6 | ||
|
|
24fde161c4 | ||
|
|
d005f694e1 | ||
|
|
299e612646 | ||
|
|
5fa14d4173 | ||
|
|
a4d1cb0a57 | ||
|
|
43c3b51829 | ||
|
|
66a454821a | ||
|
|
4f713aa645 | ||
|
|
cedc2d5324 | ||
|
|
15290231ea | ||
|
|
5960dfea0e | ||
|
|
d830220eff | ||
|
|
c6f72c9cf5 | ||
|
|
60f7728d54 | ||
|
|
6014ee7744 | ||
|
|
f32833fcbe | ||
|
|
a9295ac21b | ||
|
|
66cc3f62c5 | ||
|
|
fb1a75edd4 | ||
|
|
8f7184023a | ||
|
|
f3aba8f142 | ||
|
|
9c28c21792 | ||
|
|
ccceddfd35 | ||
|
|
e633e5fb3e | ||
|
|
9a311b0518 | ||
|
|
c07f421f74 | ||
|
|
62acda8c08 | ||
|
|
193cbcbfac |
@@ -118,7 +118,8 @@ For user-facing changes, establish the existing design context as part of fronte
|
||||
- Before reviewing a pull request, read its existing comments, reviews, and threads, including their status, resolver, and Copilot resolution reason when available.
|
||||
- Prioritise substantive human feedback, especially from authors marked `MEMBER`, and validate agent-generated feedback against the code and repository guidance.
|
||||
- Do not duplicate unresolved findings as new inline comments; reference any that still need action in the review summary. Treat resolved feedback as closed only when the resolution reason or surrounding discussion supports that outcome; otherwise validate it against the current code before suppressing it. Respect **Won't fix** and **Incorrect** reasons.
|
||||
- Identify behavioral regressions, bugs, accessibility issues, and missing tests first.
|
||||
- Identify behavioral regressions, bugs, accessibility issues, and missing tests that `ha-frontend-testing` calls for first.
|
||||
- Do not ask for new tests on visual components. If the visuals clearly changed and the PR has no screenshots or videos, suggest adding them instead.
|
||||
- Record the applicable UI/UX evidence for user-facing changes, whether or not further input is needed.
|
||||
- Keep style-only comments secondary unless they affect maintainability or user experience.
|
||||
- Prefer small, direct fixes over large refactors during review follow-up.
|
||||
|
||||
@@ -51,10 +51,10 @@ Managed app, demo, gallery, and E2E app workflows share one lifetime lock, so on
|
||||
|
||||
## When To Add Tests
|
||||
|
||||
- Write tests for code that computes something: data processing, utility functions, config validation, and what happens when the user interacts with a component.
|
||||
- Do not write tests that check what a component looks like: its text, CSS classes, styles, or slots. Do not write tests that check the default value of an option.
|
||||
- A component that only takes data from contexts and helpers and puts it in a template does not need a test.
|
||||
- If you are not sure a test is useful, describe the test and what it would catch, and let the user decide.
|
||||
- Write tests for code that computes something: data processing, utilities, config validation, and strategies.
|
||||
- Do not write rendering tests. This includes views, panels, and components whose text, styles, slots, or option defaults are checked, or that only put context and helper data into a template.
|
||||
- Do not try to cover every scenario, especially for behaviour that changes often.
|
||||
- If you are not sure a test is useful, describe it and what it would catch, and let the user decide.
|
||||
- Tests never talk to a real Home Assistant. Replace `callWS`, `callApi`, and the connection with fakes.
|
||||
|
||||
## Dev Servers
|
||||
|
||||
@@ -7,13 +7,17 @@ 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
|
||||
@@ -30,18 +34,21 @@ 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:
|
||||
@@ -59,6 +66,7 @@ body:
|
||||
value: |
|
||||
## Environment
|
||||
- type: input
|
||||
id: core_version
|
||||
validations:
|
||||
required: true
|
||||
attributes:
|
||||
@@ -67,18 +75,21 @@ 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)
|
||||
@@ -90,6 +101,7 @@ body:
|
||||
# Details
|
||||
|
||||
- type: textarea
|
||||
id: entity_state
|
||||
attributes:
|
||||
label: State of relevant entities
|
||||
description: >
|
||||
@@ -98,6 +110,7 @@ body:
|
||||
information in the Details view of the More info dialog.
|
||||
render: txt
|
||||
- type: textarea
|
||||
id: configuration
|
||||
attributes:
|
||||
label: Problem-relevant frontend configuration
|
||||
description: >
|
||||
@@ -107,6 +120,7 @@ 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: >
|
||||
@@ -114,6 +128,7 @@ 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@cdf488f595d80d6e07e03d4674febd5ab45fa938 # v4.37.9
|
||||
uses: github/codeql-action/init@b96794f015dfd88f77b49b1c93e0fa7110f94c63 # v4.38.0
|
||||
with:
|
||||
languages: javascript-typescript
|
||||
build-mode: none
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@cdf488f595d80d6e07e03d4674febd5ab45fa938 # v4.37.9
|
||||
uses: github/codeql-action/analyze@b96794f015dfd88f77b49b1c93e0fa7110f94c63 # v4.38.0
|
||||
with:
|
||||
category: "/language:javascript-typescript"
|
||||
|
||||
@@ -13,6 +13,7 @@ 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,13 +114,14 @@ 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,
|
||||
@@ -133,8 +134,10 @@ 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",
|
||||
@@ -148,13 +151,16 @@ const addonDetails = (addon: HassioAddonInfo): HassioAddonDetails => ({
|
||||
protected: true,
|
||||
rating: 6,
|
||||
schema: CONFIG_SCHEMAS[addon.slug] ?? null,
|
||||
services_role: [],
|
||||
services: [],
|
||||
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,
|
||||
});
|
||||
@@ -181,11 +187,14 @@ 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) => {
|
||||
@@ -209,9 +218,13 @@ 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",
|
||||
@@ -224,22 +237,6 @@ 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;
|
||||
}
|
||||
@@ -264,6 +261,7 @@ 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",
|
||||
@@ -301,6 +299,7 @@ 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;
|
||||
@@ -334,8 +333,9 @@ 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,6 +354,8 @@ export const mockHassioSupervisor = (hass: MockHomeAssistant) => {
|
||||
gateway: "172.30.32.1",
|
||||
interface: "hassio",
|
||||
},
|
||||
host_internet: true,
|
||||
supervisor_internet: true,
|
||||
};
|
||||
return data;
|
||||
}
|
||||
|
||||
@@ -126,7 +126,6 @@ export const basicTrace: DemoTrace = {
|
||||
},
|
||||
},
|
||||
running_script: false,
|
||||
limit: 10,
|
||||
},
|
||||
},
|
||||
],
|
||||
@@ -171,7 +170,6 @@ export const basicTrace: DemoTrace = {
|
||||
},
|
||||
},
|
||||
running_script: false,
|
||||
limit: 10,
|
||||
},
|
||||
},
|
||||
],
|
||||
@@ -189,7 +187,6 @@ export const basicTrace: DemoTrace = {
|
||||
},
|
||||
},
|
||||
running_script: false,
|
||||
limit: 10,
|
||||
},
|
||||
},
|
||||
],
|
||||
@@ -207,7 +204,6 @@ export const basicTrace: DemoTrace = {
|
||||
},
|
||||
},
|
||||
running_script: false,
|
||||
limit: 10,
|
||||
},
|
||||
},
|
||||
],
|
||||
|
||||
@@ -29,7 +29,7 @@ export const computeEntityEntryName = (
|
||||
fallbackStateObj?: HassEntity
|
||||
): string | undefined => {
|
||||
const name =
|
||||
entry.name ??
|
||||
entry.name ||
|
||||
("original_name" in entry && entry.original_name != null
|
||||
? String(entry.original_name)
|
||||
: undefined);
|
||||
|
||||
@@ -186,6 +186,11 @@ 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,
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -131,6 +131,8 @@ 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;
|
||||
@@ -516,13 +518,26 @@ 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
|
||||
this._map.easeTo({
|
||||
center: [minLng, minLat],
|
||||
zoom: maxZoom ?? this._map.getZoom(),
|
||||
animate: options?.animate,
|
||||
});
|
||||
// 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,
|
||||
}
|
||||
);
|
||||
return;
|
||||
}
|
||||
const pad = options?.pad ?? 0.5;
|
||||
@@ -533,7 +548,7 @@ export class MapLibreMapEngine implements MapEngine {
|
||||
[minLng - lngPad, minLat - latPad],
|
||||
[maxLng + lngPad, maxLat + latPad],
|
||||
],
|
||||
{ maxZoom, animate: options?.animate }
|
||||
{ maxZoom, animate: options?.animate, padding }
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1223,6 +1238,7 @@ 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
|
||||
@@ -1245,8 +1261,10 @@ export class MapLibreMapEngine implements MapEngine {
|
||||
Math.max(...xs) - Math.min(...xs),
|
||||
Math.max(...ys) - Math.min(...ys)
|
||||
);
|
||||
if (members.length > 1 && spread <= (groupRadius ?? radius)) {
|
||||
groups.push({ seed: points[0], members });
|
||||
// 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 });
|
||||
} else {
|
||||
ungrouped.push(...members);
|
||||
}
|
||||
@@ -1272,6 +1290,7 @@ 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,
|
||||
@@ -1283,7 +1302,9 @@ export class MapLibreMapEngine implements MapEngine {
|
||||
|
||||
for (const group of this._clusterGroups) {
|
||||
group.iconMarker = undefined;
|
||||
if (group.members.length === 1) {
|
||||
// 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) {
|
||||
this._showMarker(group.members[0]);
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -56,6 +56,21 @@ export const entityMapColor = (
|
||||
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,
|
||||
|
||||
@@ -42,6 +42,15 @@ 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 {
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
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";
|
||||
@@ -93,9 +93,9 @@ export class HaAutomationRowEventChip extends LitElement {
|
||||
}
|
||||
|
||||
:host([variant="neutral"]) {
|
||||
--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);
|
||||
--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);
|
||||
}
|
||||
|
||||
:host([variant="success"]) {
|
||||
|
||||
@@ -152,7 +152,7 @@ export class HaAutomationRow extends LitElement {
|
||||
margin-inline-end: initial;
|
||||
}
|
||||
:host([building-block]) .leading-icon-wrapper {
|
||||
background-color: var(--ha-color-fill-neutral-loud-resting);
|
||||
border: 1px solid var(--ha-color-border-neutral-normal);
|
||||
border-radius: var(--ha-border-radius-md);
|
||||
padding: var(--ha-space-1);
|
||||
margin-top: 10px;
|
||||
@@ -172,7 +172,6 @@ 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 {
|
||||
@@ -185,9 +184,16 @@ export class HaAutomationRow extends LitElement {
|
||||
outline-offset: -2px;
|
||||
outline-width: 2px;
|
||||
}
|
||||
:host([disabled]) .row {
|
||||
border-top-right-radius: var(--ha-border-radius-square);
|
||||
border-top-left-radius: var(--ha-border-radius-square);
|
||||
: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;
|
||||
}
|
||||
.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.
|
||||
function snapFrameSize(step: number): number {
|
||||
export function snapFrameSize(step: number): number {
|
||||
if (step >= DAY) {
|
||||
return Math.floor(step / DAY) * DAY;
|
||||
}
|
||||
|
||||
@@ -34,6 +34,7 @@ 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 {
|
||||
@@ -57,7 +58,17 @@ 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;
|
||||
const DEFAULT_CHART_WIDTH = 500;
|
||||
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;
|
||||
|
||||
type RawSeriesOption = Exclude<
|
||||
NonNullable<ECOption["series"]>,
|
||||
@@ -106,7 +117,7 @@ export type CustomLegendOption = ECOption["legend"] & {
|
||||
};
|
||||
|
||||
@customElement("ha-chart-base")
|
||||
export class HaChartBase extends LitElement {
|
||||
export class HaChartBase extends MobileAwareMixin(LitElement) {
|
||||
public chart?: EChartsType;
|
||||
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
@@ -183,20 +194,38 @@ export class HaChartBase extends 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: () => {
|
||||
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;
|
||||
}
|
||||
if (this.chart) {
|
||||
if (this._suspendResize) {
|
||||
this._shouldResizeChart = true;
|
||||
return;
|
||||
}
|
||||
if (!this.chart.getZr().animation.isFinished()) {
|
||||
} else if (!this.chart.getZr().animation.isFinished()) {
|
||||
this._shouldResizeChart = true;
|
||||
} else {
|
||||
this.chart.resize();
|
||||
}
|
||||
}
|
||||
if (!this._suspendResize) {
|
||||
this._applyDeferredWork();
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
@@ -210,10 +239,24 @@ export class HaChartBase extends 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()!();
|
||||
}
|
||||
@@ -227,14 +270,31 @@ export class HaChartBase extends LitElement {
|
||||
super.connectedCallback();
|
||||
if (this.hasUpdated) {
|
||||
this._pendingSetup = true;
|
||||
afterNextRender(() => {
|
||||
if (this.isConnected && this._pendingSetup) {
|
||||
this._pendingSetup = false;
|
||||
this._setupChart();
|
||||
}
|
||||
});
|
||||
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();
|
||||
}
|
||||
},
|
||||
{
|
||||
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) => {
|
||||
if (this._reducedMotion !== matches) {
|
||||
@@ -300,6 +360,7 @@ export class HaChartBase extends LitElement {
|
||||
this._suspendResize = this._layoutTransitionActive;
|
||||
if (!this._suspendResize) {
|
||||
this._resizeChartIfNeeded();
|
||||
this._applyDeferredWork();
|
||||
}
|
||||
};
|
||||
window.addEventListener("hass-layout-transition", handleLayoutTransition);
|
||||
@@ -312,24 +373,105 @@ export class HaChartBase extends LitElement {
|
||||
}
|
||||
|
||||
protected firstUpdated() {
|
||||
if (this.isConnected) {
|
||||
this._setupChart();
|
||||
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()) {
|
||||
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;
|
||||
}
|
||||
if (changedProps.has("_themes") && this.hasUpdated) {
|
||||
this._setupChart();
|
||||
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();
|
||||
}
|
||||
return;
|
||||
}
|
||||
let chartOptions: ECOption = {};
|
||||
if (changedProps.has("options")) {
|
||||
// Separate 'if' from below since this must updated before _getSeries()
|
||||
// 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.
|
||||
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
|
||||
@@ -347,12 +489,7 @@ export class HaChartBase extends LitElement {
|
||||
}
|
||||
if (changedProps.has("options")) {
|
||||
chartOptions = { ...chartOptions, ...this._createOptions() };
|
||||
if (
|
||||
this._compareCustomLegendOptions(
|
||||
changedProps.get("options"),
|
||||
this.options
|
||||
)
|
||||
) {
|
||||
if (this._compareCustomLegendOptions(previousOptions, this.options)) {
|
||||
// custom legend changes may require a resize to layout properly
|
||||
this._shouldResizeChart = true;
|
||||
this._resizeAnimationDuration = 250;
|
||||
@@ -463,10 +600,7 @@ export class HaChartBase extends LitElement {
|
||||
}
|
||||
}
|
||||
|
||||
const isMobile = window.matchMedia(
|
||||
"all and (max-width: 450px), all and (max-height: 500px)"
|
||||
).matches;
|
||||
const overflowLimit = isMobile
|
||||
const overflowLimit = this._isMobileSize
|
||||
? LEGEND_OVERFLOW_LIMIT_MOBILE
|
||||
: LEGEND_OVERFLOW_LIMIT;
|
||||
return html`<div
|
||||
@@ -578,7 +712,11 @@ export class HaChartBase extends 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 || !this.chart) {
|
||||
if (this._sonification || this._sonificationLoading) {
|
||||
return;
|
||||
}
|
||||
await this._applyDeferredWork();
|
||||
if (!this.chart) {
|
||||
return;
|
||||
}
|
||||
this._sonificationLoading = true;
|
||||
@@ -635,14 +773,22 @@ export class HaChartBase extends LitElement {
|
||||
);
|
||||
|
||||
private async _setupChart() {
|
||||
if (this._loading) return;
|
||||
if (this._loading) {
|
||||
this._pendingSetup = true;
|
||||
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;
|
||||
|
||||
@@ -780,8 +926,14 @@ export class HaChartBase extends 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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -927,9 +1079,7 @@ export class HaChartBase extends LitElement {
|
||||
};
|
||||
|
||||
if (options.tooltip) {
|
||||
const isMobile = window.matchMedia(
|
||||
"all and (max-width: 450px), all and (max-height: 500px)"
|
||||
).matches;
|
||||
const isMobile = this._isMobileSize;
|
||||
// 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
|
||||
@@ -1166,8 +1316,8 @@ export class HaChartBase extends LitElement {
|
||||
data: downSampleLineData(
|
||||
data as LineSeriesOption["data"],
|
||||
// 0 while inside a hidden container, e.g. a section with a visibility condition
|
||||
(this.clientWidth || DEFAULT_CHART_WIDTH) *
|
||||
window.devicePixelRatio,
|
||||
((this._contentWidth ?? this.clientWidth) ||
|
||||
DEFAULT_CHART_WIDTH) * window.devicePixelRatio,
|
||||
minX,
|
||||
maxX
|
||||
),
|
||||
@@ -1180,7 +1330,7 @@ export class HaChartBase extends LitElement {
|
||||
}
|
||||
|
||||
private _getDefaultHeight() {
|
||||
return Math.max(this.clientWidth / 2, 200);
|
||||
return Math.max((this._contentWidth ?? this.clientWidth) / 2, 200);
|
||||
}
|
||||
|
||||
private _setChartOptions(options: ECOption) {
|
||||
@@ -1247,7 +1397,16 @@ export class HaChartBase extends LitElement {
|
||||
};
|
||||
|
||||
public zoom(start: number, end: number, silent = false) {
|
||||
this.chart?.dispatchAction({
|
||||
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({
|
||||
type: "dataZoom",
|
||||
start,
|
||||
end,
|
||||
|
||||
@@ -12,7 +12,6 @@ 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 {
|
||||
@@ -43,6 +42,7 @@ 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;
|
||||
|
||||
|
||||
@@ -0,0 +1,258 @@
|
||||
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,16 +11,14 @@ 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 { MIN_TIME_BETWEEN_UPDATES } from "./ha-chart-base";
|
||||
import { DEFAULT_CHART_WIDTH, 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.
|
||||
@@ -315,109 +313,20 @@ export class StateHistoryChartTimeline extends LitElement {
|
||||
}
|
||||
|
||||
private _generateData() {
|
||||
const computedStyles = getComputedStyle(this);
|
||||
let stateHistory = this.data;
|
||||
|
||||
if (!stateHistory) {
|
||||
stateHistory = [];
|
||||
}
|
||||
|
||||
this._chartTime = new Date();
|
||||
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 = 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,
|
||||
});
|
||||
|
||||
this._chartData = datasets;
|
||||
}
|
||||
|
||||
private _handleChartClick(
|
||||
@@ -434,6 +343,9 @@ 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 && chartBase.chart) {
|
||||
if (chartBase) {
|
||||
chartBase.zoom(0, 100);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -125,7 +125,23 @@ export class StatisticsChart extends LitElement {
|
||||
private _yAxisFractionDigits = 1;
|
||||
|
||||
protected shouldUpdate(changedProps: PropertyValues<this>): boolean {
|
||||
return changedProps.size > 1 || !changedProps.has("hass");
|
||||
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;
|
||||
}
|
||||
|
||||
public willUpdate(changedProps: PropertyValues) {
|
||||
@@ -135,7 +151,8 @@ export class StatisticsChart extends LitElement {
|
||||
changedProps.has("chartType") ||
|
||||
changedProps.has("hideLegend") ||
|
||||
changedProps.has("_hiddenStats") ||
|
||||
changedProps.has("names")
|
||||
changedProps.has("names") ||
|
||||
this._entityNamesChanged(changedProps)
|
||||
) {
|
||||
this._generateData();
|
||||
}
|
||||
@@ -532,17 +549,26 @@ export class StatisticsChart extends LitElement {
|
||||
this.unit = data.unit;
|
||||
this._yAxisFractionDigits = data.yAxisFractionDigits;
|
||||
this._chartData = data.datasets;
|
||||
if (data.legendData.length !== this._legendData?.length) {
|
||||
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
|
||||
)
|
||||
) {
|
||||
// only update the legend if it has changed or it will trigger options update
|
||||
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._legendData = legendData;
|
||||
}
|
||||
this._statisticIds = data.statisticIds;
|
||||
}
|
||||
|
||||
@@ -32,8 +32,11 @@ import { internationalizationContext } from "../../data/context";
|
||||
import type { FrontendLocaleData } from "../../data/translation";
|
||||
import { haStyleScrollbar } from "../../resources/styles";
|
||||
import { loadVirtualizer } from "../../resources/virtualizer";
|
||||
import "../animation/ha-fade-in";
|
||||
import "../ha-checkbox";
|
||||
import type { HaCheckbox } from "../ha-checkbox";
|
||||
import "../skeleton/ha-skeleton-icon";
|
||||
import "../skeleton/ha-skeleton-text";
|
||||
import "../ha-svg-icon";
|
||||
import "../input/ha-input-search";
|
||||
import { filterData, sortData } from "./sort-filter";
|
||||
@@ -111,6 +114,25 @@ export type SortableColumnContainer = Record<string, ClonedDataTableColumnData>;
|
||||
const UNDEFINED_GROUP_KEY = "zzzzz_undefined";
|
||||
const AUTO_FOCUS_ALLOWED_ACTIVE_TAGS = ["BODY", "HTML", "HOME-ASSISTANT"];
|
||||
|
||||
// Default row height, used to fill the viewport with skeleton rows.
|
||||
const ROW_HEIGHT = 52;
|
||||
|
||||
const cellClasses = (column: DataTableColumnData) => ({
|
||||
"mdc-data-table__cell--flex": column.type === "flex",
|
||||
"mdc-data-table__cell--numeric": column.type === "numeric",
|
||||
"mdc-data-table__cell--icon": column.type === "icon",
|
||||
"mdc-data-table__cell--icon-button": column.type === "icon-button",
|
||||
"mdc-data-table__cell--overflow-menu": column.type === "overflow-menu",
|
||||
"mdc-data-table__cell--overflow": column.type === "overflow",
|
||||
forceLTR: Boolean(column.forceLTR),
|
||||
});
|
||||
|
||||
const cellStyles = (column: DataTableColumnData) => ({
|
||||
minWidth: column.minWidth,
|
||||
maxWidth: column.maxWidth,
|
||||
flex: column.flex || 1,
|
||||
});
|
||||
|
||||
@customElement("ha-data-table")
|
||||
export class HaDataTable extends LitElement {
|
||||
@state()
|
||||
@@ -123,6 +145,8 @@ export class HaDataTable extends LitElement {
|
||||
|
||||
@property({ type: Array }) public data: DataTableRowData[] = [];
|
||||
|
||||
@property({ type: Boolean }) public loading = false;
|
||||
|
||||
@property({ type: Boolean }) public selectable = false;
|
||||
|
||||
@property({ type: Boolean }) public clickable = false;
|
||||
@@ -165,6 +189,9 @@ export class HaDataTable extends LitElement {
|
||||
|
||||
@state() private _filteredData?: DataTableRowData[];
|
||||
|
||||
// Row count of the data that _filteredData was computed from
|
||||
@state() private _filteredDataSourceLength = 0;
|
||||
|
||||
@state() private _headerHeight = 0;
|
||||
|
||||
@query("slot[name='header']") private _header!: HTMLSlotElement;
|
||||
@@ -515,18 +542,79 @@ export class HaDataTable extends LitElement {
|
||||
</div>
|
||||
${
|
||||
!this._filteredData?.length
|
||||
? html`
|
||||
<div class="mdc-data-table__content">
|
||||
<div class="mdc-data-table__row" role="row">
|
||||
<div
|
||||
class="mdc-data-table__cell grows center"
|
||||
role="cell"
|
||||
>
|
||||
${
|
||||
!this._filteredData
|
||||
? this._i18n?.localize?.("ui.common.loading") ||
|
||||
? this.loading ||
|
||||
!this._filteredData ||
|
||||
(this.data.length && !this._filteredDataSourceLength)
|
||||
? html`
|
||||
<div class="mdc-data-table__content" role="row">
|
||||
<ha-fade-in .duration=${300} easing="ease-in">
|
||||
<div role="cell">
|
||||
<div
|
||||
role="progressbar"
|
||||
aria-label=${
|
||||
this._i18n?.localize?.("ui.common.loading") ||
|
||||
"Loading"
|
||||
: this.data.length
|
||||
}
|
||||
>
|
||||
${Array.from(
|
||||
{
|
||||
length: this.autoHeight
|
||||
? 1
|
||||
: Math.ceil(window.innerHeight / ROW_HEIGHT),
|
||||
},
|
||||
() => html`
|
||||
<div class="mdc-data-table__row">
|
||||
${
|
||||
this.selectable
|
||||
? html`<div
|
||||
class="mdc-data-table__cell mdc-data-table__cell--checkbox"
|
||||
></div>`
|
||||
: nothing
|
||||
}
|
||||
${Object.entries(columns).map(
|
||||
([key, column]) =>
|
||||
(this.narrow &&
|
||||
!column.main &&
|
||||
!column.showNarrow) ||
|
||||
!this._isColumnVisible(key, column)
|
||||
? nothing
|
||||
: html`
|
||||
<div
|
||||
class="mdc-data-table__cell ${classMap(
|
||||
cellClasses(column)
|
||||
)}"
|
||||
style=${styleMap(cellStyles(column))}
|
||||
>
|
||||
${
|
||||
column.type === "icon"
|
||||
? html`<ha-skeleton-icon></ha-skeleton-icon>`
|
||||
: column.type ===
|
||||
"icon-button" ||
|
||||
column.type ===
|
||||
"overflow-menu"
|
||||
? nothing
|
||||
: html`<ha-skeleton-text></ha-skeleton-text>`
|
||||
}
|
||||
</div>
|
||||
`
|
||||
)}
|
||||
</div>
|
||||
`
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</ha-fade-in>
|
||||
</div>
|
||||
`
|
||||
: html`
|
||||
<div class="mdc-data-table__content">
|
||||
<div class="mdc-data-table__row" role="row">
|
||||
<div
|
||||
class="mdc-data-table__cell grows center"
|
||||
role="cell"
|
||||
>
|
||||
${
|
||||
this.data.length
|
||||
? this._i18n?.localize?.(
|
||||
"ui.components.data-table.no_match_filter"
|
||||
) || "No rows matching current filters"
|
||||
@@ -535,11 +623,11 @@ export class HaDataTable extends LitElement {
|
||||
"ui.components.data-table.no-data"
|
||||
) ||
|
||||
"No data"
|
||||
}
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
`
|
||||
: html`
|
||||
<lit-virtualizer
|
||||
scroller
|
||||
@@ -633,22 +721,8 @@ export class HaDataTable extends LitElement {
|
||||
@mouseover=${this._setTitle}
|
||||
@focus=${this._setTitle}
|
||||
role=${column.main ? "rowheader" : "cell"}
|
||||
class="mdc-data-table__cell ${classMap({
|
||||
"mdc-data-table__cell--flex": column.type === "flex",
|
||||
"mdc-data-table__cell--numeric": column.type === "numeric",
|
||||
"mdc-data-table__cell--icon": column.type === "icon",
|
||||
"mdc-data-table__cell--icon-button":
|
||||
column.type === "icon-button",
|
||||
"mdc-data-table__cell--overflow-menu":
|
||||
column.type === "overflow-menu",
|
||||
"mdc-data-table__cell--overflow": column.type === "overflow",
|
||||
forceLTR: Boolean(column.forceLTR),
|
||||
})}"
|
||||
style=${styleMap({
|
||||
minWidth: column.minWidth,
|
||||
maxWidth: column.maxWidth,
|
||||
flex: column.flex || 1,
|
||||
})}
|
||||
class="mdc-data-table__cell ${classMap(cellClasses(column))}"
|
||||
style=${styleMap(cellStyles(column))}
|
||||
>
|
||||
${
|
||||
column.template
|
||||
@@ -721,10 +795,11 @@ export class HaDataTable extends LitElement {
|
||||
!this._lastUpdate ||
|
||||
(timeBetweenUpdate > 500 && timeBetweenRequest < 500);
|
||||
|
||||
let filteredData = this.data;
|
||||
const sourceData = this.data;
|
||||
let filteredData = sourceData;
|
||||
if (this._filter) {
|
||||
filteredData = await this._memFilterData(
|
||||
this.data,
|
||||
sourceData,
|
||||
this._sortColumns,
|
||||
this._filter.trim()
|
||||
);
|
||||
@@ -760,8 +835,13 @@ export class HaDataTable extends LitElement {
|
||||
return;
|
||||
}
|
||||
|
||||
if (startTime < this._lastUpdate) {
|
||||
return;
|
||||
}
|
||||
|
||||
this._lastUpdate = startTime;
|
||||
this._filteredData = data;
|
||||
this._filteredDataSourceLength = sourceData.length;
|
||||
}
|
||||
|
||||
private _groupData = memoizeOne(
|
||||
@@ -1202,6 +1282,20 @@ 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;
|
||||
@@ -1303,7 +1397,8 @@ export class HaDataTable extends LitElement {
|
||||
.mdc-data-table__cell--icon:first-child ha-svg-icon,
|
||||
.mdc-data-table__cell--icon:first-child ha-state-icon,
|
||||
.mdc-data-table__cell--icon:first-child ha-domain-icon,
|
||||
.mdc-data-table__cell--icon:first-child ha-service-icon {
|
||||
.mdc-data-table__cell--icon:first-child ha-service-icon,
|
||||
.mdc-data-table__cell--icon:first-child ha-skeleton-icon {
|
||||
margin-left: 8px;
|
||||
margin-inline-start: 8px;
|
||||
margin-inline-end: initial;
|
||||
@@ -1499,6 +1594,16 @@ 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;
|
||||
}
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
type ConversationChatLogToolResultDelta,
|
||||
type PipelineRunEvent,
|
||||
} from "../data/assist_pipeline";
|
||||
import type { ChatLogToolResult } from "../data/chat_log";
|
||||
import {
|
||||
configContext,
|
||||
connectionContext,
|
||||
@@ -58,7 +59,7 @@ interface AssistMessage {
|
||||
{
|
||||
tool_name: string;
|
||||
tool_args: Record<string, unknown>;
|
||||
result?: any;
|
||||
result?: ChatLogToolResult;
|
||||
}
|
||||
>;
|
||||
error?: boolean;
|
||||
@@ -834,7 +835,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.tool_result;
|
||||
delta.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-expanded);
|
||||
padding: var(--ha-space-3) var(--ha-space-4) var(--ha-space-4);
|
||||
line-height: var(--ha-line-height-condensed);
|
||||
padding: var(--ha-space-5) var(--ha-space-4) var(--ha-space-6);
|
||||
display: block;
|
||||
margin-block-start: 0;
|
||||
margin-block-end: 0;
|
||||
|
||||
@@ -166,8 +166,8 @@ export class HaIcon extends LitElement {
|
||||
return;
|
||||
}
|
||||
|
||||
const iconPromise = fetch(`/static/mdi/${chunk}.json`).then((response) =>
|
||||
response.json()
|
||||
const iconPromise = fetch(`${__STATIC_PATH__}mdi/${chunk}.json`).then(
|
||||
(response) => response.json()
|
||||
);
|
||||
chunks[chunk] = iconPromise;
|
||||
this._setPath(iconPromise, iconName, requestedIcon);
|
||||
|
||||
@@ -6,6 +6,7 @@ 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,
|
||||
@@ -32,6 +33,10 @@ 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;
|
||||
@@ -82,7 +87,8 @@ class HaMenuButton extends LitElement {
|
||||
if (
|
||||
!changedProps.has("_narrow") &&
|
||||
!changedProps.has("_ui") &&
|
||||
!changedProps.has("_connection")
|
||||
!changedProps.has("_connection") &&
|
||||
!changedProps.has("_config")
|
||||
) {
|
||||
return;
|
||||
}
|
||||
@@ -94,6 +100,7 @@ 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;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { PropertyValues } from "lit";
|
||||
import { css, html, LitElement } from "lit";
|
||||
import { customElement, query, state } from "lit/decorators";
|
||||
import { customElement, property, query, state } from "lit/decorators";
|
||||
import { fireEvent } from "../common/dom/fire_event";
|
||||
import { BOTTOM_SHEET_ANIMATION_DURATION_MS } from "./ha-bottom-sheet";
|
||||
|
||||
@@ -11,39 +11,80 @@ 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;
|
||||
|
||||
@state() private _dialogMaxViewpointHeight = 70;
|
||||
private _opened = false;
|
||||
|
||||
@state() private _dialogMinViewpointHeight = 55;
|
||||
@state() private _dialogMaxViewpointHeight?: number;
|
||||
|
||||
@state() private _dialogMinViewpointHeight?: number;
|
||||
|
||||
@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: ${this._dialogMaxViewpointHeight}vh;
|
||||
--max-height: ${this._dialogMaxViewpointHeight}dvh;
|
||||
--min-height: ${this._dialogMinViewpointHeight}vh;
|
||||
--min-height: ${this._dialogMinViewpointHeight}dvh;
|
||||
--max-height: ${maxHeight}vh;
|
||||
--max-height: ${maxHeight}dvh;
|
||||
--min-height: ${minHeight}vh;
|
||||
--min-height: ${minHeight}dvh;
|
||||
`}
|
||||
>
|
||||
<div class="handle-wrapper">
|
||||
@@ -83,7 +124,9 @@ export class HaResizableBottomSheet extends LitElement {
|
||||
this._dialogViewportHeight =
|
||||
(this._dialog.offsetHeight / window.innerHeight) * 100;
|
||||
this._dialogMaxViewpointHeight = 90;
|
||||
this._dialogMinViewpointHeight = 20;
|
||||
this._dialogMinViewpointHeight = this._minViewportHeight();
|
||||
this._opened = true;
|
||||
this._fireResized();
|
||||
} else {
|
||||
// after close animation is done close dialog element and fire closed event
|
||||
this._dialog.close();
|
||||
@@ -102,6 +145,7 @@ export class HaResizableBottomSheet extends LitElement {
|
||||
});
|
||||
document.addEventListener("touchend", this._handleTouchEnd);
|
||||
document.addEventListener("touchcancel", this._handleTouchEnd);
|
||||
window.addEventListener("resize", this._handleViewportResize);
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
@@ -113,8 +157,16 @@ 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);
|
||||
};
|
||||
@@ -156,8 +208,10 @@ export class HaResizableBottomSheet extends LitElement {
|
||||
let newSize = this._initialSize + deltaVh;
|
||||
newSize = Math.max(10, Math.min(90, newSize));
|
||||
|
||||
// on drag down and below 20vh
|
||||
if (newSize < 20 && deltaY < 0) {
|
||||
if (this.persistent) {
|
||||
newSize = Math.max(this._minViewportHeight(), newSize);
|
||||
} else if (newSize < 20 && deltaY < 0) {
|
||||
// on drag down and below 20vh
|
||||
this._endDrag();
|
||||
this.closeSheet();
|
||||
return;
|
||||
@@ -166,6 +220,45 @@ 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();
|
||||
};
|
||||
@@ -180,6 +273,8 @@ 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`
|
||||
@@ -201,7 +296,8 @@ export class HaResizableBottomSheet extends LitElement {
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
z-index: 7;
|
||||
padding-bottom: 76px;
|
||||
/* Extends the grab area over the content below the handle */
|
||||
padding-bottom: var(--ha-bottom-sheet-handle-padding, 76px);
|
||||
}
|
||||
.handle-wrapper .handle::after {
|
||||
content: "";
|
||||
@@ -234,8 +330,20 @@ 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% - 4px - var(--safe-area-inset-left) - var(--safe-area-inset-right)
|
||||
100% - var(--sheet-side-borders) - var(--sheet-inset-left) - var(
|
||||
--sheet-inset-right
|
||||
)
|
||||
);
|
||||
max-width: 100%;
|
||||
border: none;
|
||||
@@ -257,14 +365,14 @@ export class HaResizableBottomSheet extends LitElement {
|
||||
);
|
||||
transform: translateY(100%);
|
||||
transition: transform ${BOTTOM_SHEET_ANIMATION_DURATION_MS}ms ease;
|
||||
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-top-width: var(--sheet-border-width);
|
||||
border-right-width: var(--sheet-border-width);
|
||||
border-left-width: var(--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(--safe-area-inset-left);
|
||||
margin-right: var(--safe-area-inset-right);
|
||||
margin-left: var(--sheet-inset-left);
|
||||
margin-right: var(--sheet-inset-right);
|
||||
}
|
||||
|
||||
dialog.show {
|
||||
@@ -280,5 +388,6 @@ declare global {
|
||||
|
||||
interface HASSDomEvents {
|
||||
"bottom-sheet-closed": undefined;
|
||||
"bottom-sheet-resized": { height: number };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -127,11 +127,12 @@ export class HaLocationSelector extends LitElement {
|
||||
? this.hass.config.longitude
|
||||
: value.longitude,
|
||||
radius: selector.location?.radius ? value?.radius || 1000 : undefined,
|
||||
radius_color: zoneRadiusColor,
|
||||
radius_color: selector.location?.color || zoneRadiusColor,
|
||||
name: selector.location?.name,
|
||||
icon:
|
||||
selector.location?.icon || selector.location?.radius
|
||||
? "mdi:map-marker-radius"
|
||||
: "mdi:map-marker",
|
||||
selector.location?.icon ||
|
||||
// No icon: show the name's initials, else a default marker
|
||||
(selector.location?.name ? undefined : "mdi:map-marker"),
|
||||
location_editable: true,
|
||||
radius_editable:
|
||||
!!selector.location?.radius && !selector.location?.radius_readonly,
|
||||
|
||||
@@ -52,12 +52,23 @@ export const haTopAppBarFixedStyles = css`
|
||||
.row {
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
width: 100%;
|
||||
width: calc(100% + var(--safe-area-inset-right, 0px));
|
||||
padding-right: var(--safe-area-inset-right, 0px);
|
||||
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;
|
||||
}
|
||||
@@ -65,7 +76,8 @@ export const haTopAppBarFixedStyles = css`
|
||||
.sub-row {
|
||||
box-sizing: border-box;
|
||||
display: block;
|
||||
width: 100%;
|
||||
width: calc(100% + var(--safe-area-inset-right, 0px));
|
||||
padding-right: var(--safe-area-inset-right, 0px);
|
||||
overflow: hidden;
|
||||
border-bottom: var(--app-header-border-bottom);
|
||||
}
|
||||
|
||||
@@ -1,14 +1,17 @@
|
||||
import type { CSSResultGroup, PropertyValues } from "lit";
|
||||
import { LitElement, css, html, nothing } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import { mdiArrowRightThin } from "@mdi/js";
|
||||
import { mdiArrowRightThin, mdiDelete } from "@mdi/js";
|
||||
import { fireEvent } from "../common/dom/fire_event";
|
||||
import type { HASSDomCurrentTargetEvent } from "../common/dom/fire_event";
|
||||
import { computeAreaName } from "../common/entity/compute_area_name";
|
||||
import type { Segment } from "../data/vacuum";
|
||||
import { getVacuumSegments } from "../data/vacuum";
|
||||
import { haStyle } from "../resources/styles";
|
||||
import type { HomeAssistant } from "../types";
|
||||
import "./ha-alert";
|
||||
import "./ha-area-picker";
|
||||
import "./ha-icon-button";
|
||||
import "./ha-svg-icon";
|
||||
|
||||
type AreaSegmentMapping = Record<string, string[]>; // area ID -> segment IDs
|
||||
@@ -76,6 +79,8 @@ export class HaVacuumSegmentAreaMapper extends LitElement {
|
||||
// Group segments by group (if available)
|
||||
const groupedSegments = this._groupSegments(this._segments);
|
||||
|
||||
const orphanedAreas = this._getOrphanedAreas();
|
||||
|
||||
return html`
|
||||
${Object.entries(groupedSegments).map(
|
||||
([groupName, segments]) => html`
|
||||
@@ -83,9 +88,71 @@ export class HaVacuumSegmentAreaMapper extends LitElement {
|
||||
${segments.map((segment) => this._renderSegment(segment))}
|
||||
`
|
||||
)}
|
||||
${orphanedAreas.length ? this._renderOrphanedAreas(orphanedAreas) : nothing}
|
||||
`;
|
||||
}
|
||||
|
||||
private _getOrphanedAreas(): string[] {
|
||||
if (!this.value || !this._segments || this._segments.length === 0) {
|
||||
return [];
|
||||
}
|
||||
const liveIds = new Set(this._segments.map((segment) => segment.id));
|
||||
return Object.entries(this.value)
|
||||
.filter(([, segmentIds]) => segmentIds.some((id) => !liveIds.has(id)))
|
||||
.map(([areaId]) => areaId);
|
||||
}
|
||||
|
||||
private _renderOrphanedAreas(areaIds: string[]) {
|
||||
return html`
|
||||
<h2>
|
||||
${this.hass.localize(
|
||||
"ui.dialogs.vacuum_segment_mapping.orphaned_header"
|
||||
)}
|
||||
</h2>
|
||||
<p class="orphaned-description">
|
||||
${this.hass.localize(
|
||||
"ui.dialogs.vacuum_segment_mapping.orphaned_description"
|
||||
)}
|
||||
</p>
|
||||
${areaIds.map((areaId) => {
|
||||
const area = this.hass.areas[areaId];
|
||||
const name = (area ? computeAreaName(area) : undefined) || areaId;
|
||||
return html`
|
||||
<div class="orphaned-row">
|
||||
<span class="orphaned-name">${name}</span>
|
||||
<ha-icon-button
|
||||
.path=${mdiDelete}
|
||||
.label=${this.hass.localize(
|
||||
"ui.dialogs.vacuum_segment_mapping.orphaned_remove",
|
||||
{ name }
|
||||
)}
|
||||
data-area-id=${areaId}
|
||||
@click=${this._removeOrphanedArea}
|
||||
></ha-icon-button>
|
||||
</div>
|
||||
`;
|
||||
})}
|
||||
`;
|
||||
}
|
||||
|
||||
private _removeOrphanedArea = (
|
||||
ev: HASSDomCurrentTargetEvent<HTMLElementTagNameMap["ha-icon-button"]>
|
||||
) => {
|
||||
const areaId = ev.currentTarget.dataset.areaId;
|
||||
if (!areaId || !this.value || !this._segments) {
|
||||
return;
|
||||
}
|
||||
const liveIds = new Set(this._segments.map((segment) => segment.id));
|
||||
const newMapping: AreaSegmentMapping = { ...this.value };
|
||||
const kept = (newMapping[areaId] ?? []).filter((id) => liveIds.has(id));
|
||||
if (kept.length) {
|
||||
newMapping[areaId] = kept;
|
||||
} else {
|
||||
delete newMapping[areaId];
|
||||
}
|
||||
fireEvent(this, "value-changed", { value: newMapping });
|
||||
};
|
||||
|
||||
private _groupSegments(segments: Segment[]): Record<string, Segment[]> {
|
||||
const grouped: Record<string, Segment[]> = {};
|
||||
|
||||
@@ -209,8 +276,33 @@ export class HaVacuumSegmentAreaMapper extends LitElement {
|
||||
}
|
||||
|
||||
h2 {
|
||||
margin: 0;
|
||||
margin-inline-start: var(--ha-space-4);
|
||||
margin: var(--ha-space-4) var(--ha-space-4) var(--ha-space-2);
|
||||
font-size: var(--ha-font-size-m);
|
||||
font-weight: var(--ha-font-weight-bold);
|
||||
line-height: var(--ha-line-height-normal);
|
||||
color: var(--primary-text-color);
|
||||
}
|
||||
|
||||
.orphaned-description {
|
||||
margin: var(--ha-space-1) var(--ha-space-4) var(--ha-space-2);
|
||||
color: var(--secondary-text-color);
|
||||
font: var(--ha-font-body-s);
|
||||
}
|
||||
|
||||
.orphaned-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--ha-space-4);
|
||||
padding-block: var(--ha-space-2);
|
||||
padding-inline: var(--ha-space-4) var(--ha-space-2);
|
||||
}
|
||||
|
||||
.orphaned-name {
|
||||
flex: 1;
|
||||
font: var(--ha-font-body-l);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.loading {
|
||||
|
||||
@@ -18,7 +18,9 @@ import { getEntityLocation } from "../../common/entity/get_entity_location";
|
||||
import { supportsWebGL2 } from "../../common/map/base-layer";
|
||||
import type {
|
||||
MapClusterIcon,
|
||||
MapControlPosition,
|
||||
MapEngine,
|
||||
MapFitPadding,
|
||||
MapItemHandle,
|
||||
MapLatLng,
|
||||
MapMarkerHandle,
|
||||
@@ -37,6 +39,7 @@ import {
|
||||
ZONE_CIRCLE_SIZE,
|
||||
zoneMarkerStyles,
|
||||
} from "../../common/map/zone-marker";
|
||||
import { deepEqual } from "../../common/util/deep-equal";
|
||||
import { filterXSS } from "../../common/util/xss";
|
||||
import {
|
||||
configContext,
|
||||
@@ -300,6 +303,12 @@ 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 })
|
||||
@@ -447,6 +456,18 @@ export class HaMap extends ReactiveElement {
|
||||
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") ||
|
||||
@@ -597,7 +618,7 @@ export class HaMap extends ReactiveElement {
|
||||
darkMode: this._darkMode,
|
||||
token,
|
||||
rasterOnly: this._forceLeaflet,
|
||||
zoomControlPosition: "topleft",
|
||||
zoomControlPosition: this.zoomPosition,
|
||||
events: {
|
||||
click: (location) => this._handleEngineClick(location),
|
||||
zoomStart: () => {
|
||||
@@ -692,6 +713,7 @@ export class HaMap extends ReactiveElement {
|
||||
public fitMap(options?: {
|
||||
zoom?: number;
|
||||
pad?: number;
|
||||
padding?: MapFitPadding;
|
||||
unpause_autofit?: boolean;
|
||||
}): void {
|
||||
if (options?.unpause_autofit) {
|
||||
@@ -735,6 +757,7 @@ 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,
|
||||
});
|
||||
});
|
||||
@@ -782,8 +805,11 @@ export class HaMap extends ReactiveElement {
|
||||
|
||||
public fitBounds(
|
||||
boundingbox: MapLatLng[],
|
||||
options?: { zoom?: number; pad?: number }
|
||||
options?: { zoom?: number; pad?: number; padding?: MapFitPadding }
|
||||
) {
|
||||
// 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);
|
||||
@@ -797,6 +823,7 @@ export class HaMap extends ReactiveElement {
|
||||
maxZoom: options?.zoom || this.zoom,
|
||||
pad: options?.pad ?? 0.5,
|
||||
animate: this._hasFitted,
|
||||
padding: options?.padding,
|
||||
});
|
||||
});
|
||||
this._hasFitted = true;
|
||||
@@ -1426,12 +1453,19 @@ 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;
|
||||
@@ -1453,6 +1487,7 @@ export class HaMap extends ReactiveElement {
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
isolation: isolate;
|
||||
filter: var(--ha-cluster-shadow);
|
||||
}
|
||||
.cluster-open-members {
|
||||
display: flex;
|
||||
@@ -1464,7 +1499,6 @@ 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;
|
||||
box-shadow: var(--ha-box-shadow-s);
|
||||
}
|
||||
/* Both tails are a rotated square whose upper half sits under the bubble;
|
||||
drawn behind it, so it never covers a member's frame or selected ring */
|
||||
@@ -1498,6 +1532,19 @@ 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;
|
||||
}
|
||||
@@ -1551,6 +1598,12 @@ export class HaMap extends ReactiveElement {
|
||||
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;
|
||||
@@ -1568,7 +1621,7 @@ export class HaMap extends ReactiveElement {
|
||||
box-sizing: border-box;
|
||||
background: var(--card-background-color, #fff);
|
||||
border-radius: 14px;
|
||||
box-shadow: var(--ha-box-shadow-s);
|
||||
filter: var(--ha-cluster-shadow);
|
||||
--ha-marker-size: ${CLUSTER_AVATAR_SIZE}px;
|
||||
--ha-marker-color: transparent;
|
||||
--ha-marker-border-width: 1px;
|
||||
@@ -1599,6 +1652,16 @@ export class HaMap extends ReactiveElement {
|
||||
--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);
|
||||
}
|
||||
.leaflet-control,
|
||||
.leaflet-top,
|
||||
.leaflet-bottom {
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import type { CSSResultGroup } from "lit";
|
||||
import { css } from "lit";
|
||||
import { customElement } from "lit/decorators";
|
||||
import { HaSkeleton } from "./ha-skeleton";
|
||||
|
||||
/**
|
||||
* Placeholder for an icon. Follows `ha-svg-icon`'s `--mdc-icon-size`
|
||||
* (24px by default), including inherited size overrides.
|
||||
*/
|
||||
@customElement("ha-skeleton-icon")
|
||||
export class HaSkeletonIcon extends HaSkeleton {
|
||||
static get styles(): CSSResultGroup {
|
||||
return [
|
||||
super.styles,
|
||||
css`
|
||||
:host {
|
||||
display: inline-flex;
|
||||
vertical-align: middle;
|
||||
flex: none;
|
||||
width: var(--mdc-icon-size, 24px);
|
||||
height: var(--mdc-icon-size, 24px);
|
||||
min-height: 0;
|
||||
--ha-skeleton-border-radius: var(--ha-border-radius-circle);
|
||||
}
|
||||
`,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"ha-skeleton-icon": HaSkeletonIcon;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import type { CSSResultGroup } from "lit";
|
||||
import { css } from "lit";
|
||||
import { customElement } from "lit/decorators";
|
||||
import { HaSkeleton } from "./ha-skeleton";
|
||||
|
||||
/**
|
||||
* Placeholder for a line of text, matching the tile secondary text skeleton.
|
||||
* Its height follows the surrounding font size. Set `width` in CSS to fit the
|
||||
* expected text.
|
||||
*
|
||||
* @cssprop --ha-skeleton-text-width - The width of the placeholder. Defaults to `140px`.
|
||||
*/
|
||||
@customElement("ha-skeleton-text")
|
||||
export class HaSkeletonText extends HaSkeleton {
|
||||
static get styles(): CSSResultGroup {
|
||||
return [
|
||||
super.styles,
|
||||
css`
|
||||
:host {
|
||||
display: inline-flex;
|
||||
vertical-align: middle;
|
||||
width: var(--ha-skeleton-text-width, 140px);
|
||||
max-width: 100%;
|
||||
height: 1em;
|
||||
min-height: 0;
|
||||
}
|
||||
`,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"ha-skeleton-text": HaSkeletonText;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import WaSkeleton from "@home-assistant/webawesome/dist/components/skeleton/skeleton";
|
||||
import type { CSSResultGroup } from "lit";
|
||||
import { css } from "lit";
|
||||
import { customElement } from "lit/decorators";
|
||||
|
||||
/**
|
||||
* Placeholder shown while content loads. Sized by its container unless a
|
||||
* more specific variant such as `ha-skeleton-text` or `ha-skeleton-icon` is used.
|
||||
*
|
||||
* @cssprop --ha-skeleton-color - The fill color. Defaults to `var(--ha-color-fill-neutral-normal-resting)`.
|
||||
* @cssprop --ha-skeleton-sheen-color - The sheen color when `effect="sheen"`. Defaults to `var(--ha-color-fill-neutral-loud-resting)`.
|
||||
* @cssprop --ha-skeleton-border-radius - The corner radius. Defaults to `var(--ha-border-radius-sm)`.
|
||||
*/
|
||||
@customElement("ha-skeleton")
|
||||
export class HaSkeleton extends WaSkeleton {
|
||||
override effect: WaSkeleton["effect"] = "pulse";
|
||||
|
||||
static get styles(): CSSResultGroup {
|
||||
return [
|
||||
WaSkeleton.styles,
|
||||
css`
|
||||
:host {
|
||||
--color: var(
|
||||
--ha-skeleton-color,
|
||||
var(--ha-color-fill-neutral-normal-resting)
|
||||
);
|
||||
--sheen-color: var(
|
||||
--ha-skeleton-sheen-color,
|
||||
var(--ha-color-fill-neutral-loud-resting)
|
||||
);
|
||||
--wa-border-radius-pill: var(
|
||||
--ha-skeleton-border-radius,
|
||||
var(--ha-border-radius-sm)
|
||||
);
|
||||
}
|
||||
@media (forced-colors: active) {
|
||||
:host {
|
||||
--color: GrayText;
|
||||
}
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
:host([effect="pulse"]) .indicator,
|
||||
:host([effect="sheen"]) .indicator {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
`,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"ha-skeleton": HaSkeleton;
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import "@home-assistant/webawesome/dist/components/skeleton/skeleton";
|
||||
import "../skeleton/ha-skeleton-text";
|
||||
import { css, html, LitElement } from "lit";
|
||||
import { customElement, property } from "lit/decorators";
|
||||
|
||||
@@ -49,7 +49,7 @@ export class HaTileInfo extends LitElement {
|
||||
${
|
||||
this.secondaryLoading
|
||||
? html`<div class="secondary">
|
||||
<wa-skeleton class="placeholder" effect="pulse"></wa-skeleton>
|
||||
<ha-skeleton-text></ha-skeleton-text>
|
||||
</div>`
|
||||
: html`<slot name="secondary" class="secondary">
|
||||
<span>${this.secondary}</span>
|
||||
@@ -150,14 +150,6 @@ export class HaTileInfo extends LitElement {
|
||||
letter-spacing: var(--tile-info-secondary-letter-spacing);
|
||||
color: var(--tile-info-secondary-color);
|
||||
}
|
||||
.placeholder {
|
||||
width: 140px;
|
||||
max-width: 100%;
|
||||
height: var(--tile-info-secondary-font-size);
|
||||
--wa-border-radius-pill: var(--ha-border-radius-sm);
|
||||
--color: var(--ha-color-fill-neutral-normal-resting);
|
||||
--sheen-color: var(--ha-color-fill-neutral-loud-resting);
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
|
||||
@@ -69,7 +69,10 @@ export class HaTracePathDetails extends LitElement {
|
||||
@property({ attribute: false })
|
||||
public renderedNodes: Record<string, any> = {};
|
||||
|
||||
@property({ attribute: false }) public trackedNodes!: Record<string, any>;
|
||||
@property({ attribute: false }) public trackedNodes!: Record<
|
||||
string,
|
||||
NodeInfo
|
||||
>;
|
||||
|
||||
@state() private _view: (typeof TRACE_PATH_TABS)[number] = "step_config";
|
||||
|
||||
@@ -192,11 +195,16 @@ export class HaTracePathDetails extends LitElement {
|
||||
const nestPath = curPath
|
||||
.substring(this.selected.path.length + 1)
|
||||
.split("/");
|
||||
let currentDetail = this.selected.config;
|
||||
let currentDetail: unknown = this.selected.config;
|
||||
for (const part of nestPath) {
|
||||
if (!["undefined", "string"].includes(typeof currentDetail[part])) {
|
||||
currentDetail = currentDetail[part];
|
||||
if (typeof currentDetail !== "object" || currentDetail === null) {
|
||||
break;
|
||||
}
|
||||
const child = (currentDetail as Record<string, unknown>)[part];
|
||||
if (child === undefined || typeof child === "string") {
|
||||
break;
|
||||
}
|
||||
currentDetail = child;
|
||||
}
|
||||
|
||||
parts.push(
|
||||
@@ -282,6 +290,9 @@ 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)
|
||||
@@ -486,7 +497,9 @@ export class HaTracePathDetails extends LitElement {
|
||||
const trackedPaths = Object.keys(this.trackedNodes);
|
||||
const index = trackedPaths.indexOf(this.selected.path);
|
||||
|
||||
if (index === -1) {
|
||||
// Synthetic choose-option nodes have no direct trace records, so there is
|
||||
// no start timestamp to slice the logbook with.
|
||||
if (index === -1 || !startTrace) {
|
||||
return html`<div class="padded-box">
|
||||
${this.hass!.localize(
|
||||
"ui.panel.config.automation.trace.path.step_not_executed"
|
||||
@@ -496,7 +509,11 @@ export class HaTracePathDetails extends LitElement {
|
||||
|
||||
let entries: LogbookEntry[];
|
||||
|
||||
if (index === trackedPaths.length - 1) {
|
||||
const nextTrace =
|
||||
index < trackedPaths.length - 1
|
||||
? paths[trackedPaths[index + 1]]
|
||||
: undefined;
|
||||
if (!nextTrace) {
|
||||
// it's the last entry. Find all logbook entries after start.
|
||||
const startTime = new Date(startTrace[0].timestamp);
|
||||
const idx = this.logbookEntries.findIndex(
|
||||
@@ -508,8 +525,6 @@ 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, state } from "lit/decorators";
|
||||
import { customElement, property, query, 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,6 +10,9 @@ 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;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -32,34 +35,85 @@ export class HatGraphBranch extends LitElement {
|
||||
|
||||
private _maxHeight = 0;
|
||||
|
||||
private _updateBranches(ev: HASSDomTargetEvent<HTMLSlotElement>) {
|
||||
@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;
|
||||
}
|
||||
let total_width = 0;
|
||||
const heights: number[] = [];
|
||||
const branches: BranchConfig[] = [];
|
||||
(ev.target as HTMLSlotElement).assignedElements().forEach((c) => {
|
||||
this._slot.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: c.hasAttribute("track"),
|
||||
track,
|
||||
trackEnd: track && !c.hasAttribute("unfinished"),
|
||||
});
|
||||
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 = 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;
|
||||
});
|
||||
this._maxHeight = maxHeight;
|
||||
this._branches = branches;
|
||||
}
|
||||
|
||||
render() {
|
||||
@@ -79,7 +133,9 @@ export class HatGraphBranch extends LitElement {
|
||||
})}
|
||||
d="
|
||||
M ${this._totalWidth / 2} 0
|
||||
L ${branch.x} ${BRANCH_HEIGHT}
|
||||
C ${this._totalWidth / 2} ${BRANCH_HEIGHT / 2}
|
||||
${branch.x} ${BRANCH_HEIGHT / 2}
|
||||
${branch.x} ${BRANCH_HEIGHT}
|
||||
"/>
|
||||
`
|
||||
)}
|
||||
@@ -94,7 +150,7 @@ export class HatGraphBranch extends LitElement {
|
||||
return svg`
|
||||
<path
|
||||
class=${classMap({
|
||||
track: branch.track,
|
||||
track: branch.trackEnd,
|
||||
})}
|
||||
d="
|
||||
M ${branch.x} ${branch.height}
|
||||
@@ -115,12 +171,14 @@ export class HatGraphBranch extends LitElement {
|
||||
return svg`
|
||||
<path
|
||||
class=${classMap({
|
||||
track: branch.track,
|
||||
track: branch.trackEnd,
|
||||
})}
|
||||
d="
|
||||
M ${branch.x} 0
|
||||
V ${SPACING}
|
||||
L ${this._totalWidth / 2} ${BRANCH_HEIGHT + SPACING}
|
||||
C ${branch.x} ${SPACING + BRANCH_HEIGHT / 2}
|
||||
${this._totalWidth / 2} ${SPACING + BRANCH_HEIGHT / 2}
|
||||
${this._totalWidth / 2} ${BRANCH_HEIGHT + SPACING}
|
||||
"/>
|
||||
`;
|
||||
})}
|
||||
@@ -161,14 +219,19 @@ 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(--stroke-clr);
|
||||
stroke-width: 2;
|
||||
stroke: var(--connector-clr, var(--stroke-clr));
|
||||
stroke-width: 1;
|
||||
stroke-dasharray: 4 3;
|
||||
fill: none;
|
||||
}
|
||||
path.track {
|
||||
stroke: var(--track-clr);
|
||||
stroke-width: 2;
|
||||
stroke-dasharray: none;
|
||||
}
|
||||
:host([disabled]) path {
|
||||
stroke: var(--disabled-clr);
|
||||
|
||||
@@ -1,3 +1,10 @@
|
||||
export const SPACING = 10;
|
||||
export const NODE_SIZE = 30;
|
||||
export const NODE_SIZE = 36;
|
||||
export const NODE_RADIUS = 12;
|
||||
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,7 +3,24 @@ 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 { NODE_SIZE, SPACING } from "./hat-graph-const";
|
||||
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;
|
||||
|
||||
/**
|
||||
* @attribute active
|
||||
@@ -15,7 +32,7 @@ export class HatGraphNode extends LitElement {
|
||||
|
||||
@property({ type: Boolean, reflect: true }) public disabled = false;
|
||||
|
||||
@property({ type: Boolean }) public error = false;
|
||||
@property({ type: Boolean, reflect: true }) public error = false;
|
||||
|
||||
@property({ attribute: "not-enabled", reflect: true, type: Boolean })
|
||||
notEnabled = false;
|
||||
@@ -26,6 +43,10 @@ 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;
|
||||
@@ -43,6 +64,9 @@ 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" : ""}
|
||||
@@ -69,17 +93,24 @@ export class HatGraphNode extends LitElement {
|
||||
`
|
||||
}
|
||||
<g class="node">
|
||||
<circle cx="0" cy="0" r=${NODE_SIZE / 2} />
|
||||
<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}
|
||||
/>
|
||||
${
|
||||
this.error
|
||||
? svg`
|
||||
<g class="error">
|
||||
<circle
|
||||
cx="-12"
|
||||
cy=${-NODE_SIZE / 2}
|
||||
r="8"
|
||||
cx=${BADGE_X}
|
||||
cy=${BADGE_Y}
|
||||
r=${BADGE_RADIUS}
|
||||
></circle>
|
||||
<path transform="translate(-18 -21) scale(.5)" class="exclamation" d=${mdiExclamationThick}/>
|
||||
<path transform="translate(${BADGE_X - 6} ${BADGE_Y - 6}) scale(.5)" class="exclamation" d=${mdiExclamationThick}/>
|
||||
</g>
|
||||
`
|
||||
: nothing
|
||||
@@ -89,27 +120,46 @@ export class HatGraphNode extends LitElement {
|
||||
? svg`
|
||||
<g class="number">
|
||||
<circle
|
||||
cx="12"
|
||||
cy=${-NODE_SIZE / 2}
|
||||
r="8"
|
||||
cx="0"
|
||||
cy=${COUNT_BADGE_Y}
|
||||
r=${COUNT_BADGE_RADIUS}
|
||||
></circle>
|
||||
<text
|
||||
x="12"
|
||||
y=${-NODE_SIZE / 2}
|
||||
x="0"
|
||||
y=${COUNT_BADGE_Y}
|
||||
text-anchor="middle"
|
||||
alignment-baseline="middle"
|
||||
dominant-baseline="central"
|
||||
>${this.badge > 9 ? "9+" : this.badge}</text>
|
||||
</g>
|
||||
`
|
||||
: nothing
|
||||
}
|
||||
<g style="pointer-events: none" transform="translate(-12 -12)">
|
||||
<g
|
||||
class="icon-wrapper"
|
||||
style="pointer-events: none"
|
||||
transform="translate(-${iconSize / 2} -${iconSize / 2}) scale(${
|
||||
iconSize / ICON_SIZE
|
||||
})"
|
||||
>
|
||||
${
|
||||
this.iconPath
|
||||
? svg`<path class="icon" d=${this.iconPath}/>`
|
||||
: svg`<foreignObject><span class="icon"><slot name="icon"></slot></span></foreignObject>`
|
||||
}
|
||||
</g>
|
||||
${
|
||||
this.notEnabled
|
||||
? svg`
|
||||
<line
|
||||
class="strike"
|
||||
x1=${-iconSize / 2}
|
||||
y1="0"
|
||||
x2=${iconSize / 2}
|
||||
y2="0"
|
||||
/>
|
||||
`
|
||||
: nothing
|
||||
}
|
||||
</g>
|
||||
</svg>
|
||||
`;
|
||||
@@ -122,6 +172,11 @@ 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);
|
||||
}
|
||||
@@ -129,40 +184,81 @@ export class HatGraphNode extends LitElement {
|
||||
--stroke-clr: var(--track-clr);
|
||||
--icon-clr: var(--default-icon-clr);
|
||||
}
|
||||
:host([active]) circle {
|
||||
/* 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 {
|
||||
--stroke-clr: var(--active-clr);
|
||||
--icon-clr: var(--default-icon-clr);
|
||||
stroke-width: 3;
|
||||
}
|
||||
:host(:focus) {
|
||||
outline: none;
|
||||
}
|
||||
:host(:hover) circle {
|
||||
:host(:hover) rect {
|
||||
--stroke-clr: var(--hover-clr);
|
||||
--icon-clr: var(--default-icon-clr);
|
||||
}
|
||||
:host([not-triggered]) circle {
|
||||
:host([not-triggered]) rect {
|
||||
stroke-dasharray: 4 3;
|
||||
}
|
||||
:host([not-enabled]) circle {
|
||||
--stroke-clr: var(--disabled-clr);
|
||||
:host([not-enabled]) {
|
||||
--stroke-clr: var(--ha-color-border-neutral-normal);
|
||||
--icon-clr: var(--ha-color-text-disabled);
|
||||
}
|
||||
:host([not-enabled][active]) circle {
|
||||
/* 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 {
|
||||
--stroke-clr: var(--disabled-active-clr);
|
||||
}
|
||||
:host([not-enabled]:hover) circle {
|
||||
:host([not-enabled]:hover) rect {
|
||||
--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%;
|
||||
}
|
||||
circle,
|
||||
rect,
|
||||
path.connector {
|
||||
stroke: var(--stroke-clr);
|
||||
stroke-width: 2;
|
||||
fill: none;
|
||||
}
|
||||
circle {
|
||||
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 {
|
||||
fill: var(--background-clr);
|
||||
stroke: var(--circle-clr, var(--stroke-clr));
|
||||
}
|
||||
@@ -180,7 +276,7 @@ export class HatGraphNode extends LitElement {
|
||||
stroke-width: 0;
|
||||
}
|
||||
.number text {
|
||||
font-size: var(--ha-font-size-xs);
|
||||
font-size: var(--ha-font-size-s);
|
||||
fill: var(--text-primary-color);
|
||||
}
|
||||
path.icon {
|
||||
|
||||
@@ -38,12 +38,19 @@ 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(--stroke-clr);
|
||||
stroke-width: 2;
|
||||
stroke: var(--connector-clr, var(--stroke-clr));
|
||||
stroke-width: 1;
|
||||
stroke-dasharray: 4 3;
|
||||
stroke-dashoffset: 4px;
|
||||
fill: none;
|
||||
}
|
||||
`;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -17,7 +17,9 @@ 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,
|
||||
@@ -42,6 +44,24 @@ 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,4 +1,5 @@
|
||||
import type { HomeAssistant } from "../types";
|
||||
import type { ChatLogToolResult } from "./chat_log";
|
||||
import type { ConversationResult } from "./conversation";
|
||||
import type { SpeechMetadata } from "./stt";
|
||||
|
||||
@@ -130,7 +131,8 @@ export interface ConversationChatLogToolResultDelta {
|
||||
agent_id: string;
|
||||
tool_call_id: string;
|
||||
tool_name: string;
|
||||
tool_result: unknown;
|
||||
result: ChatLogToolResult;
|
||||
created: string;
|
||||
}
|
||||
interface PipelineIntentProgressEvent extends PipelineEventBase {
|
||||
type: "intent-progress";
|
||||
|
||||
@@ -114,7 +114,9 @@ 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;
|
||||
}
|
||||
|
||||
@@ -153,7 +155,7 @@ export interface ConversationTrigger extends BaseTrigger {
|
||||
|
||||
export interface SunTrigger extends BaseTrigger {
|
||||
trigger: "sun";
|
||||
offset: number;
|
||||
offset?: string | number | ForDict;
|
||||
event: "sunrise" | "sunset";
|
||||
}
|
||||
|
||||
@@ -190,9 +192,11 @@ 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: string | { entity_id: string; offset?: string };
|
||||
at: TimeTriggerAt | TimeTriggerAt[];
|
||||
weekday?: string | string[];
|
||||
}
|
||||
|
||||
@@ -211,9 +215,9 @@ export interface EventTrigger extends BaseTrigger {
|
||||
|
||||
export interface CalendarTrigger extends BaseTrigger {
|
||||
trigger: "calendar";
|
||||
event: "start" | "end";
|
||||
event?: "start" | "end";
|
||||
entity_id: string;
|
||||
offset: string;
|
||||
offset?: string | number | ForDict;
|
||||
}
|
||||
|
||||
export type LegacyTrigger =
|
||||
@@ -652,7 +656,7 @@ export const testCondition = (
|
||||
condition: Condition | Condition[],
|
||||
variables?: Record<string, unknown>
|
||||
) =>
|
||||
hass.callWS<{ result: boolean }>({
|
||||
hass.callWS<{ result: boolean; template_errors?: string[] }>({
|
||||
type: "test_condition",
|
||||
condition,
|
||||
variables,
|
||||
|
||||
@@ -829,7 +829,7 @@ const describeLegacyTrigger = (
|
||||
|
||||
let offsetChoice = "other";
|
||||
let offset = "";
|
||||
if (trigger.offset) {
|
||||
if (typeof trigger.offset === "string" && trigger.offset) {
|
||||
offsetChoice = trigger.offset.startsWith("-") ? "before" : "after";
|
||||
const parts = trigger.offset.startsWith("-")
|
||||
? trigger.offset.substring(1).split(":")
|
||||
|
||||
@@ -19,6 +19,7 @@ export interface BlueprintMetaData {
|
||||
description?: string;
|
||||
source_url?: string;
|
||||
author?: string;
|
||||
homeassistant?: { min_version?: string };
|
||||
}
|
||||
|
||||
export interface BlueprintInput {
|
||||
@@ -39,7 +40,7 @@ export interface BlueprintInputSection {
|
||||
export interface BlueprintImportResult {
|
||||
suggested_filename: string;
|
||||
raw_data: string;
|
||||
exists?: boolean;
|
||||
exists: boolean;
|
||||
blueprint: Blueprint;
|
||||
validation_errors: string[] | null;
|
||||
}
|
||||
@@ -63,7 +64,7 @@ export const saveBlueprint = (
|
||||
source_url?: string,
|
||||
allow_override?: boolean
|
||||
) =>
|
||||
hass.callWS({
|
||||
hass.callWS<{ overrides_existing: boolean }>({
|
||||
type: "blueprint/save",
|
||||
domain,
|
||||
path,
|
||||
@@ -77,7 +78,7 @@ export const deleteBlueprint = (
|
||||
domain: BlueprintDomain,
|
||||
path: string
|
||||
) =>
|
||||
hass.callWS<BlueprintImportResult>({
|
||||
hass.callWS<undefined>({
|
||||
type: "blueprint/delete",
|
||||
domain,
|
||||
path,
|
||||
|
||||
@@ -37,12 +37,17 @@ 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;
|
||||
tool_result: any;
|
||||
result: ChatLogToolResult;
|
||||
created: Date;
|
||||
}
|
||||
|
||||
@@ -92,7 +97,7 @@ interface ChatLogToolResultContentWire {
|
||||
agent_id: string;
|
||||
tool_call_id: string;
|
||||
tool_name: string;
|
||||
tool_result: any;
|
||||
result: ChatLogToolResult;
|
||||
created: string;
|
||||
}
|
||||
|
||||
|
||||
@@ -22,12 +22,10 @@ export interface DeviceAutomation {
|
||||
subtype?: string;
|
||||
event?: string;
|
||||
enabled?: boolean;
|
||||
metadata?: { secondary: boolean };
|
||||
metadata?: { secondary?: boolean };
|
||||
}
|
||||
|
||||
export interface DeviceAction extends DeviceAutomation {
|
||||
entity_id: string;
|
||||
}
|
||||
export type DeviceAction = DeviceAutomation;
|
||||
|
||||
export interface DeviceCondition extends DeviceAutomation {
|
||||
condition: "device";
|
||||
@@ -39,7 +37,7 @@ export type DeviceTrigger = DeviceAutomation &
|
||||
};
|
||||
|
||||
export interface DeviceCapabilities {
|
||||
extra_fields: HaFormSchema[];
|
||||
extra_fields?: HaFormSchema[];
|
||||
}
|
||||
|
||||
export const fetchDeviceActions = (callWS: CallWS, deviceId: string) =>
|
||||
|
||||
+11
-11
@@ -28,24 +28,24 @@ export interface HardwareInfo {
|
||||
export interface HardwareInfoEntry {
|
||||
board: HardwareInfoBoardInfo | null;
|
||||
dongle: HardwareInfoDongleInfo | null;
|
||||
config_entries: string[];
|
||||
name: string;
|
||||
url?: string;
|
||||
config_entries: string[] | null;
|
||||
name: string | null;
|
||||
url: string | null;
|
||||
}
|
||||
|
||||
export interface HardwareInfoBoardInfo {
|
||||
manufacturer: string;
|
||||
model?: string;
|
||||
revision?: string;
|
||||
hassio_board_id?: string;
|
||||
model: string | null;
|
||||
revision: string | null;
|
||||
hassio_board_id: string | null;
|
||||
}
|
||||
|
||||
export interface HardwareInfoDongleInfo {
|
||||
manufacturer: string;
|
||||
description: string;
|
||||
pid?: string;
|
||||
vid?: string;
|
||||
serial_number?: string;
|
||||
manufacturer: string | null;
|
||||
description: string | null;
|
||||
pid: string;
|
||||
vid: string;
|
||||
serial_number: string | null;
|
||||
}
|
||||
|
||||
export interface SystemStatusStreamMessage {
|
||||
|
||||
+18
-12
@@ -17,11 +17,12 @@ export type AddonCapability = Exclude<
|
||||
>;
|
||||
export type AddonStage = "stable" | "experimental" | "deprecated";
|
||||
export type AddonAppArmour = "disable" | "default" | "profile";
|
||||
export type AddonRole = "default" | "homeassistant" | "manager" | "admin";
|
||||
export type AddonRole =
|
||||
"default" | "homeassistant" | "backup" | "manager" | "admin";
|
||||
export type AddonStartup =
|
||||
"initialize" | "system" | "services" | "application" | "once";
|
||||
export type AddonState =
|
||||
"startup" | "started" | "stopped" | "unknown" | "error" | null;
|
||||
"startup" | "started" | "stopped" | "unknown" | "error";
|
||||
export type AddonRepository = "core" | "local" | string;
|
||||
|
||||
interface AddonFieldTranslation {
|
||||
@@ -41,15 +42,15 @@ export interface HassioAddonInfo {
|
||||
build: boolean;
|
||||
description: string;
|
||||
detached: boolean;
|
||||
homeassistant: string;
|
||||
homeassistant: string | null;
|
||||
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;
|
||||
@@ -63,13 +64,14 @@ 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;
|
||||
@@ -82,8 +84,10 @@ export interface HassioAddonDetails extends HassioAddonInfo {
|
||||
host_ipc: boolean;
|
||||
host_network: boolean;
|
||||
host_pid: boolean;
|
||||
host_uts: boolean;
|
||||
ingress_entry: null | string;
|
||||
ingress_panel: boolean;
|
||||
ingress_panel: boolean | null;
|
||||
ingress_port: number | null;
|
||||
ingress_url: null | string;
|
||||
ingress: boolean;
|
||||
ip_address: string;
|
||||
@@ -91,27 +95,29 @@ export interface HassioAddonDetails extends HassioAddonInfo {
|
||||
long_description: null | string;
|
||||
machine: any;
|
||||
network_description: null | Record<string, string>;
|
||||
network: null | Record<string, number>;
|
||||
network: null | Record<string, number | null>;
|
||||
options: Record<string, unknown>;
|
||||
privileged: any;
|
||||
protected: boolean;
|
||||
rating: number;
|
||||
schema: HaFormSchema[] | null;
|
||||
services_role: string[];
|
||||
services: string[];
|
||||
signed: boolean;
|
||||
slug: string;
|
||||
startup: AddonStartup;
|
||||
stdin: boolean;
|
||||
system_managed: boolean;
|
||||
system_managed_config_entry: string | null;
|
||||
translations: Record<string, AddonTranslations>;
|
||||
watchdog: null | boolean;
|
||||
uart: boolean;
|
||||
udev: boolean;
|
||||
usb: boolean;
|
||||
video: boolean;
|
||||
watchdog: boolean;
|
||||
webui: null | string;
|
||||
}
|
||||
|
||||
export interface HassioAddonsInfo {
|
||||
addons: HassioAddonInfo[];
|
||||
repositories: HassioAddonRepository[];
|
||||
}
|
||||
|
||||
export interface HassioAddonSetSecurityParams {
|
||||
@@ -201,7 +207,7 @@ export const validateHassioAddonOption = async (
|
||||
callWS: CallWS,
|
||||
slug: string,
|
||||
data?: any
|
||||
): Promise<{ message: string; valid: boolean }> => {
|
||||
): Promise<{ message: string; valid: boolean; pwned: boolean | null }> => {
|
||||
return callWS({
|
||||
type: "supervisor/api",
|
||||
endpoint: `/addons/${slug}/options/validate`,
|
||||
|
||||
@@ -13,12 +13,15 @@ export interface HassioResponse<T> {
|
||||
export interface HassioStats {
|
||||
blk_read: number;
|
||||
blk_write: number;
|
||||
cpu_percent: number;
|
||||
cpu_percent: number | null;
|
||||
cpu_system_usage: number;
|
||||
cpu_usage: 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,13 +1,15 @@
|
||||
import type { HomeAssistant } from "../../types";
|
||||
|
||||
type HassioDockerRegistries = Record<
|
||||
type HassioDockerRegistries = Record<string, { username: string }>;
|
||||
|
||||
type HassioDockerRegistryCredentials = Record<
|
||||
string,
|
||||
{ username: string; password?: string }
|
||||
{ username: string; password: string }
|
||||
>;
|
||||
|
||||
export const fetchHassioDockerRegistries = async (
|
||||
hass: HomeAssistant
|
||||
): Promise<HassioDockerRegistries> =>
|
||||
): Promise<{ registries: HassioDockerRegistries }> =>
|
||||
hass.callWS({
|
||||
type: "supervisor/api",
|
||||
endpoint: `/docker/registries`,
|
||||
@@ -16,7 +18,7 @@ export const fetchHassioDockerRegistries = async (
|
||||
|
||||
export const addHassioDockerRegistry = async (
|
||||
hass: HomeAssistant,
|
||||
data: HassioDockerRegistries
|
||||
data: HassioDockerRegistryCredentials
|
||||
) => {
|
||||
await hass.callWS({
|
||||
type: "supervisor/api",
|
||||
|
||||
@@ -15,6 +15,7 @@ interface HassioHardwareAudioList {
|
||||
export interface HardwareDevice {
|
||||
attributes: Record<string, string>;
|
||||
by_id: null | string;
|
||||
children: string[];
|
||||
dev_path: string;
|
||||
name: string;
|
||||
subsystem: string;
|
||||
|
||||
+10
-9
@@ -2,19 +2,19 @@ import type { HomeAssistant } from "../../types";
|
||||
|
||||
export interface HassioHostInfo {
|
||||
agent_version: string;
|
||||
chassis: string;
|
||||
cpe: string;
|
||||
deployment: string;
|
||||
chassis: string | null;
|
||||
cpe: string | null;
|
||||
deployment: string | null;
|
||||
disk_life_time: number | null;
|
||||
disk_free: number;
|
||||
disk_total: number;
|
||||
disk_used: number;
|
||||
features: string[];
|
||||
hostname: string;
|
||||
kernel: string;
|
||||
operating_system: string;
|
||||
boot_timestamp: number;
|
||||
startup_time: number;
|
||||
hostname: string | null;
|
||||
kernel: string | null;
|
||||
operating_system: string | null;
|
||||
boot_timestamp: number | null;
|
||||
startup_time: number | null;
|
||||
}
|
||||
|
||||
export interface HassioHassOSInfo {
|
||||
@@ -23,7 +23,8 @@ export interface HassioHassOSInfo {
|
||||
update_available: boolean;
|
||||
version_latest: string | null;
|
||||
version: string | null;
|
||||
data_disk: string;
|
||||
version_pending: string | null;
|
||||
data_disk: string | null;
|
||||
}
|
||||
|
||||
export interface Datadisk {
|
||||
|
||||
@@ -5,15 +5,18 @@ 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>;
|
||||
ipv6?: Partial<IpConfiguration>;
|
||||
ipv4?: Partial<IpConfiguration> | null;
|
||||
ipv6?: Partial<IpConfiguration> | null;
|
||||
type: "ethernet" | "wireless" | "vlan";
|
||||
wifi?: Partial<WifiConfiguration> | null;
|
||||
}
|
||||
@@ -41,13 +44,15 @@ export interface WifiConfiguration {
|
||||
mode: "infrastructure" | "mesh" | "adhoc" | "ap";
|
||||
auth: "open" | "wep" | "wpa-psk";
|
||||
ssid: string;
|
||||
signal: number;
|
||||
signal: number | null;
|
||||
psk?: string;
|
||||
}
|
||||
|
||||
export interface NetworkInfo {
|
||||
interfaces: NetworkInterface[];
|
||||
docker: DockerNetwork;
|
||||
host_internet: boolean | null;
|
||||
supervisor_internet: boolean;
|
||||
}
|
||||
|
||||
export const fetchNetworkInfo = async (
|
||||
|
||||
@@ -1,10 +1,28 @@
|
||||
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: string[];
|
||||
suggestions: string[];
|
||||
issues: ResolutionIssue[];
|
||||
suggestions: ResolutionSuggestion[];
|
||||
checks: ResolutionCheck[];
|
||||
}
|
||||
|
||||
export const fetchHassioResolution = async (
|
||||
|
||||
@@ -1,37 +1,59 @@
|
||||
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;
|
||||
arch: SupervisorArch | null;
|
||||
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;
|
||||
machine: string | null;
|
||||
port: number;
|
||||
ssl: boolean;
|
||||
update_available: boolean;
|
||||
version_latest: string;
|
||||
version: string;
|
||||
wait_boot: number;
|
||||
version_latest: string | null;
|
||||
version: string | null;
|
||||
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: string[];
|
||||
addons_repositories: string[];
|
||||
addons: HassioSupervisorInfoAddon[];
|
||||
addons_repositories: HassioSupervisorInfoRepository[];
|
||||
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;
|
||||
timezone: string | null;
|
||||
update_available: boolean;
|
||||
version: string;
|
||||
version_latest: string;
|
||||
@@ -43,11 +65,12 @@ export interface HassioInfo {
|
||||
channel: string;
|
||||
docker: string;
|
||||
features: string[];
|
||||
hassos: null;
|
||||
homeassistant: string;
|
||||
hostname: string;
|
||||
hassos: string | null;
|
||||
homeassistant: string | null;
|
||||
hostname: string | null;
|
||||
logging: string;
|
||||
machine: string;
|
||||
machine: string | null;
|
||||
machine_id: string | null;
|
||||
state:
|
||||
| "initialize"
|
||||
| "setup"
|
||||
@@ -57,7 +80,7 @@ export interface HassioInfo {
|
||||
| "shutdown"
|
||||
| "stopping"
|
||||
| "close";
|
||||
operating_system: string;
|
||||
operating_system: string | null;
|
||||
supervisor: string;
|
||||
supported: boolean;
|
||||
supported_arch: SupervisorArch[];
|
||||
|
||||
+2
-2
@@ -94,14 +94,14 @@ export interface HistoryStreamMessage {
|
||||
}
|
||||
|
||||
export const entityIdHistoryNeedsAttributes = (
|
||||
hass: HomeAssistant,
|
||||
hass: Pick<HomeAssistant, "states">,
|
||||
entityId: string
|
||||
) =>
|
||||
!hass.states[entityId] ||
|
||||
NEED_ATTRIBUTE_DOMAINS.includes(computeDomain(entityId));
|
||||
|
||||
export const fetchDateWS = (
|
||||
hass: HomeAssistant,
|
||||
hass: Pick<HomeAssistant, "states" | "callWS">,
|
||||
startTime: Date,
|
||||
endTime: Date,
|
||||
entityIds: string[]
|
||||
|
||||
+14
-2
@@ -1,6 +1,11 @@
|
||||
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";
|
||||
@@ -108,12 +113,19 @@ 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) ||
|
||||
`https://github.com/home-assistant/core/issues?q=is%3Aissue+is%3Aopen+label%3A%22integration%3A+${domain}%22`;
|
||||
`${GITHUB_CORE_ISSUES_URL}?${createQueryString(
|
||||
{ q: `is:issue is:open label:"integration: ${domain}"` },
|
||||
integrationIssuesQueryParams
|
||||
)}`;
|
||||
|
||||
export const domainToName = (
|
||||
localize: LocalizeFunc,
|
||||
@@ -155,7 +167,7 @@ export const fetchIntegrationManifestsCollection = async (
|
||||
};
|
||||
|
||||
export const fetchIntegrationManifest = (
|
||||
hass: HomeAssistant,
|
||||
hass: Pick<HomeAssistant, "callWS">,
|
||||
integration: string
|
||||
) =>
|
||||
hass
|
||||
|
||||
+1
-1
@@ -21,7 +21,7 @@ export interface LogbookStreamMessage {
|
||||
export interface LogbookEntry {
|
||||
// Base data
|
||||
when: number; // Python timestamp. Do *1000 to get JS timestamp.
|
||||
name: string;
|
||||
name?: string; // Only sent when the backend resolves entity names
|
||||
message?: string;
|
||||
entity_id?: string;
|
||||
icon?: string;
|
||||
|
||||
+5
-4
@@ -14,6 +14,7 @@ export interface IPv4ConfiguredAddress {
|
||||
|
||||
export interface Adapter {
|
||||
name: string;
|
||||
index: number | null;
|
||||
enabled: boolean;
|
||||
auto: boolean;
|
||||
default: boolean;
|
||||
@@ -27,9 +28,9 @@ export interface NetworkConfig {
|
||||
}
|
||||
|
||||
export interface NetworkUrls {
|
||||
internal: string;
|
||||
external: string;
|
||||
cloud: string;
|
||||
internal: string | null;
|
||||
external: string | null;
|
||||
cloud: string | null;
|
||||
}
|
||||
|
||||
export const getNetworkConfig = (hass: HomeAssistant) =>
|
||||
@@ -41,7 +42,7 @@ export const setNetworkConfig = (
|
||||
hass: HomeAssistant,
|
||||
configured_adapters: string[]
|
||||
) =>
|
||||
hass.callWS<string[]>({
|
||||
hass.callWS<{ configured_adapters: string[] }>({
|
||||
type: "network/configure",
|
||||
config: {
|
||||
configured_adapters: configured_adapters,
|
||||
|
||||
+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 {
|
||||
|
||||
@@ -348,7 +348,9 @@ const tryDescribeAction = <T extends ActionType>(
|
||||
if (localized) {
|
||||
return localized;
|
||||
}
|
||||
const stateObj = hass.states[config.entity_id];
|
||||
const stateObj = config.entity_id
|
||||
? hass.states[config.entity_id]
|
||||
: undefined;
|
||||
if (config.type) {
|
||||
return `${config.type} ${
|
||||
stateObj ? computeStateName(stateObj) : config.entity_id
|
||||
|
||||
@@ -368,6 +368,10 @@ 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;
|
||||
}
|
||||
|
||||
|
||||
@@ -14,8 +14,12 @@ export enum SupervisorMountUsage {
|
||||
|
||||
export enum SupervisorMountState {
|
||||
ACTIVE = "active",
|
||||
ACTIVATING = "activating",
|
||||
DEACTIVATING = "deactivating",
|
||||
FAILED = "failed",
|
||||
UNKNOWN = "unknown",
|
||||
INACTIVE = "inactive",
|
||||
MAINTENANCE = "maintenance",
|
||||
RELOADING = "reloading",
|
||||
}
|
||||
|
||||
interface MountOptions {
|
||||
@@ -26,14 +30,16 @@ export type CIFSVersion = "auto" | "1.0" | "2.0";
|
||||
|
||||
interface SupervisorMountBase {
|
||||
name: string;
|
||||
usage: SupervisorMountUsage;
|
||||
usage: SupervisorMountUsage | null;
|
||||
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 {
|
||||
@@ -44,7 +50,7 @@ export interface SupervisorNFSMount extends SupervisorMountResponse {
|
||||
export interface SupervisorCIFSMount extends SupervisorMountResponse {
|
||||
type: SupervisorMountType.CIFS;
|
||||
share: string;
|
||||
version?: CIFSVersion;
|
||||
version?: CIFSVersion | null;
|
||||
}
|
||||
|
||||
export type SupervisorMount = SupervisorNFSMount | SupervisorCIFSMount;
|
||||
@@ -54,7 +60,7 @@ export type SupervisorNFSMountRequestParams = SupervisorNFSMount;
|
||||
export interface SupervisorCIFSMountRequestParams extends SupervisorCIFSMount {
|
||||
username?: string;
|
||||
password?: string;
|
||||
version?: CIFSVersion;
|
||||
version?: CIFSVersion | null;
|
||||
}
|
||||
|
||||
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: HomeAssistant,
|
||||
hass: Pick<HomeAssistant, "connection">,
|
||||
callback: (info: SystemHealthInfo | undefined) => void
|
||||
) => {
|
||||
let data = {};
|
||||
|
||||
@@ -5,7 +5,7 @@ export type SystemLogLevel =
|
||||
|
||||
export interface LoggedError {
|
||||
name: string;
|
||||
message: [string];
|
||||
message: [string, ...string[]];
|
||||
level: SystemLogLevel;
|
||||
source: [string, number];
|
||||
exception: string;
|
||||
@@ -41,6 +41,10 @@ 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;
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
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
|
||||
)}`;
|
||||
};
|
||||
@@ -0,0 +1,608 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
+27
-4
@@ -37,7 +37,6 @@ export interface ConditionTraceStep extends BaseTraceStep {
|
||||
|
||||
export interface CallServiceActionTraceStep extends BaseTraceStep {
|
||||
result?: {
|
||||
limit: number;
|
||||
running_script: boolean;
|
||||
params: Record<string, unknown>;
|
||||
};
|
||||
@@ -60,6 +59,22 @@ 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 };
|
||||
}
|
||||
@@ -70,6 +85,10 @@ export type ActionTraceStep =
|
||||
| ConditionTraceStep
|
||||
| CallServiceActionTraceStep
|
||||
| ChooseActionTraceStep
|
||||
| IfActionTraceStep
|
||||
| StopActionTraceStep
|
||||
| WaitActionTraceStep
|
||||
| DelayActionTraceStep
|
||||
| ChooseChoiceActionTraceStep;
|
||||
|
||||
interface BaseTrace {
|
||||
@@ -105,7 +124,11 @@ 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";
|
||||
| "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;
|
||||
}
|
||||
|
||||
interface BaseTraceExtended {
|
||||
@@ -122,7 +145,7 @@ export interface AutomationTrace extends BaseTrace {
|
||||
export interface AutomationTraceExtended
|
||||
extends AutomationTrace, BaseTraceExtended {
|
||||
config: ManualAutomationConfig;
|
||||
blueprint_inputs?: BlueprintAutomationConfig;
|
||||
blueprint_inputs?: BlueprintAutomationConfig | null;
|
||||
}
|
||||
|
||||
export interface ScriptTrace extends BaseTrace {
|
||||
@@ -131,7 +154,7 @@ export interface ScriptTrace extends BaseTrace {
|
||||
|
||||
export interface ScriptTraceExtended extends ScriptTrace, BaseTraceExtended {
|
||||
config: ScriptConfig;
|
||||
blueprint_inputs?: BlueprintScriptConfig;
|
||||
blueprint_inputs?: BlueprintScriptConfig | null;
|
||||
}
|
||||
|
||||
export type Trace = AutomationTrace | ScriptTrace;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import "@home-assistant/webawesome/dist/components/skeleton/skeleton";
|
||||
import "../../../../components/skeleton/ha-skeleton";
|
||||
import "../../../../components/skeleton/ha-skeleton-text";
|
||||
import { consume } from "@lit/context";
|
||||
import type { HassConfig } from "home-assistant-js-websocket";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
@@ -112,14 +113,14 @@ export class HaMoreInfoUpdateBackup extends LitElement {
|
||||
${
|
||||
!createBackupTexts
|
||||
? html`<ha-fade-in slot="headline" .delay=${500}
|
||||
><wa-skeleton effect="sheen"></wa-skeleton
|
||||
><ha-skeleton-text></ha-skeleton-text
|
||||
></ha-fade-in>`
|
||||
: nothing
|
||||
}
|
||||
${
|
||||
this._createBackupLoading
|
||||
? html`<ha-fade-in class="skeleton-end" slot="end" .delay=${500}
|
||||
><wa-skeleton effect="sheen"></wa-skeleton
|
||||
><ha-skeleton></ha-skeleton
|
||||
></ha-fade-in>`
|
||||
: html`<ha-switch
|
||||
slot="end"
|
||||
@@ -307,6 +308,7 @@ export class HaMoreInfoUpdateBackup extends LitElement {
|
||||
width: 48px;
|
||||
height: 24px;
|
||||
display: block;
|
||||
--ha-skeleton-border-radius: var(--ha-border-radius-pill);
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -567,6 +567,7 @@ class MoreInfoWeather extends LitElement {
|
||||
.attribution {
|
||||
text-align: center;
|
||||
margin-top: var(--ha-space-4);
|
||||
direction: ltr;
|
||||
}
|
||||
|
||||
.time-ago,
|
||||
@@ -642,6 +643,7 @@ class MoreInfoWeather extends LitElement {
|
||||
.attribute {
|
||||
font-size: var(--ha-font-size-m);
|
||||
line-height: 1;
|
||||
direction: ltr;
|
||||
}
|
||||
|
||||
.name-state {
|
||||
|
||||
@@ -358,24 +358,6 @@ export class MoreInfoDialog extends DirtyStateProviderMixin<
|
||||
this._setView("settings");
|
||||
}
|
||||
|
||||
private _computeViewTitle(): string | undefined {
|
||||
switch (this._currView) {
|
||||
case "details":
|
||||
return this.hass.localize("ui.dialogs.more_info_control.details");
|
||||
case "related":
|
||||
return this.hass.localize("ui.dialogs.more_info_control.related");
|
||||
case "add_to":
|
||||
return this.hass.localize("ui.dialogs.more_info_control.add_to.item");
|
||||
case "settings":
|
||||
return (
|
||||
this._childView?.viewTitle ||
|
||||
this.hass.localize("ui.dialogs.more_info_control.settings")
|
||||
);
|
||||
default:
|
||||
return this._childView?.viewTitle;
|
||||
}
|
||||
}
|
||||
|
||||
private _showChildView(ev: CustomEvent): void {
|
||||
this._pushChildView(ev.detail as ChildView);
|
||||
}
|
||||
@@ -608,7 +590,14 @@ export class MoreInfoDialog extends DirtyStateProviderMixin<
|
||||
const addToMenuItem = this.hass.localize(
|
||||
"ui.dialogs.more_info_control.add_to.item"
|
||||
);
|
||||
const viewTitle = this._computeViewTitle();
|
||||
const viewTitle =
|
||||
this._currView === "details"
|
||||
? this.hass.localize("ui.dialogs.more_info_control.details")
|
||||
: this._currView === "related"
|
||||
? this.hass.localize("ui.dialogs.more_info_control.related")
|
||||
: this._currView === "add_to"
|
||||
? addToMenuItem
|
||||
: this._childView?.viewTitle;
|
||||
const defaultTitle = breadcrumb[breadcrumb.length - 1] || entityId;
|
||||
if (!viewTitle) {
|
||||
breadcrumb.pop();
|
||||
|
||||
@@ -154,6 +154,12 @@ export class HaTabsSubpageDataTable extends KeyboardShortcutMixin(LitElement) {
|
||||
*/
|
||||
@property({ type: Boolean }) public empty = false;
|
||||
|
||||
/**
|
||||
* Show a loading state instead of the empty message until data is ready.
|
||||
* @type {Boolean}
|
||||
*/
|
||||
@property({ type: Boolean }) public loading = false;
|
||||
|
||||
@property({ attribute: false }) public route!: Route;
|
||||
|
||||
/**
|
||||
@@ -492,7 +498,7 @@ export class HaTabsSubpageDataTable extends KeyboardShortcutMixin(LitElement) {
|
||||
: nothing
|
||||
}
|
||||
${
|
||||
this.empty
|
||||
this.empty && !this.loading
|
||||
? html`<div class="center">
|
||||
<slot name="empty">${this.noDataText}</slot>
|
||||
</div>`
|
||||
@@ -514,6 +520,7 @@ export class HaTabsSubpageDataTable extends KeyboardShortcutMixin(LitElement) {
|
||||
.narrow=${this.narrow}
|
||||
.columns=${this.columns}
|
||||
.data=${this.data}
|
||||
.loading=${this.loading}
|
||||
.noDataText=${this.noDataText}
|
||||
.filter=${this.filter}
|
||||
.selectable=${this._selectMode}
|
||||
@@ -877,19 +884,32 @@ export class HaTabsSubpageDataTable extends KeyboardShortcutMixin(LitElement) {
|
||||
}
|
||||
|
||||
.narrow-header-row {
|
||||
--header-row-inset-start: var(--safe-area-inset-left, 0px);
|
||||
--header-row-inset-end: var(--safe-area-inset-right, 0px);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
min-width: 100%;
|
||||
gap: var(--ha-space-4);
|
||||
padding: 0 16px;
|
||||
padding: 0;
|
||||
padding-inline-start: calc(16px + var(--header-row-inset-start));
|
||||
box-sizing: border-box;
|
||||
overflow-x: scroll;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
.narrow-header-row:dir(rtl) {
|
||||
--header-row-inset-start: var(--safe-area-inset-right, 0px);
|
||||
--header-row-inset-end: var(--safe-area-inset-left, 0px);
|
||||
}
|
||||
|
||||
.narrow-header-row::after {
|
||||
content: "";
|
||||
flex: 0 0 var(--header-row-inset-end);
|
||||
}
|
||||
|
||||
.narrow-header-row .flex {
|
||||
flex: 1;
|
||||
margin-left: -16px;
|
||||
margin-inline-start: -16px;
|
||||
}
|
||||
|
||||
.selection-bar {
|
||||
|
||||
@@ -352,7 +352,9 @@ export class HassTabsSubpage extends LitElement {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
padding: 0 16px;
|
||||
padding: 0 calc(16px + var(--safe-area-inset-right))
|
||||
var(--safe-area-inset-bottom)
|
||||
calc(16px + var(--safe-area-inset-left));
|
||||
box-sizing: border-box;
|
||||
background-color: var(--sidebar-background-color);
|
||||
border-top: 1px solid var(--divider-color);
|
||||
@@ -360,7 +362,6 @@ export class HassTabsSubpage extends LitElement {
|
||||
z-index: 2;
|
||||
font-size: var(--ha-font-size-s);
|
||||
width: 100%;
|
||||
padding-bottom: var(--safe-area-inset-bottom);
|
||||
}
|
||||
|
||||
#tabbar:not(.bottom-bar) {
|
||||
@@ -396,14 +397,13 @@ export class HassTabsSubpage extends LitElement {
|
||||
.content {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
margin-right: var(--safe-area-inset-right);
|
||||
margin-inline-end: var(--safe-area-inset-right);
|
||||
box-sizing: border-box;
|
||||
padding-right: var(--safe-area-inset-right);
|
||||
overflow: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
:host([narrow]) .content {
|
||||
margin-left: var(--safe-area-inset-left);
|
||||
margin-inline-start: var(--safe-area-inset-left);
|
||||
padding-left: var(--safe-area-inset-left);
|
||||
}
|
||||
:host([narrow][show-tabs]) .content {
|
||||
/* Bottom bar reuses header height */
|
||||
|
||||
@@ -300,7 +300,16 @@ class NotificationManager extends LitElement {
|
||||
border: none;
|
||||
overflow: visible;
|
||||
background: transparent;
|
||||
transform: translateX(calc(-50% * var(--scale-direction)));
|
||||
transform: translateX(
|
||||
calc(
|
||||
-50% * var(--scale-direction) +
|
||||
var(--safe-area-offset-left, 0px) - var(
|
||||
--safe-area-offset-right,
|
||||
0px
|
||||
)
|
||||
)
|
||||
);
|
||||
max-width: var(--safe-width);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
|
||||
@@ -1,35 +1,29 @@
|
||||
import "@home-assistant/webawesome/dist/components/skeleton/skeleton";
|
||||
import type { TemplateResult } from "lit";
|
||||
import { css, html } from "lit";
|
||||
import "../components/skeleton/ha-skeleton-text";
|
||||
|
||||
/** Placeholder shown in place of a text while onboarding translations load. */
|
||||
export const renderSkeleton = (variant: string): TemplateResult =>
|
||||
html`<wa-skeleton effect="sheen" class="skeleton ${variant}"></wa-skeleton>`;
|
||||
html`<ha-skeleton-text class="skeleton ${variant}"></ha-skeleton-text>`;
|
||||
|
||||
export const skeletonStyles = css`
|
||||
.skeleton {
|
||||
height: 1em;
|
||||
vertical-align: middle;
|
||||
--color: var(--ha-color-fill-neutral-normal-resting);
|
||||
--sheen-color: var(--ha-color-fill-neutral-loud-resting);
|
||||
}
|
||||
.skeleton.title {
|
||||
width: 200px;
|
||||
--ha-skeleton-text-width: 200px;
|
||||
}
|
||||
.skeleton.line {
|
||||
width: 100%;
|
||||
--ha-skeleton-text-width: 100%;
|
||||
}
|
||||
.skeleton.headline {
|
||||
width: 40%;
|
||||
--ha-skeleton-text-width: 40%;
|
||||
margin-bottom: var(--ha-space-1);
|
||||
}
|
||||
.skeleton.chip {
|
||||
width: 80px;
|
||||
--ha-skeleton-text-width: 80px;
|
||||
}
|
||||
.skeleton.button {
|
||||
width: 120px;
|
||||
--ha-skeleton-text-width: 120px;
|
||||
}
|
||||
.skeleton.label {
|
||||
width: 80px;
|
||||
--ha-skeleton-text-width: 80px;
|
||||
}
|
||||
`;
|
||||
|
||||
@@ -476,6 +476,13 @@ export class HAFullCalendar extends LitElement {
|
||||
const wasShowingToday = this._isShowingToday();
|
||||
const nextMidnight = new TZDate(new Date(), this._calendarTimeZone());
|
||||
nextMidnight.setHours(24, 0, 0, 0);
|
||||
const delay = nextMidnight.getTime() - Date.now();
|
||||
|
||||
// Guard against a NaN/negative delay (e.g. Intl longOffset unsupported on
|
||||
// Chromium < 95) so the midnight refresh can't fire in a tight loop (#54182).
|
||||
if (!Number.isFinite(delay) || delay <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
this._midnightRefreshTimeout = window.setTimeout(() => {
|
||||
if (wasShowingToday) {
|
||||
@@ -485,7 +492,7 @@ export class HAFullCalendar extends LitElement {
|
||||
}
|
||||
|
||||
this._scheduleMidnightRefresh();
|
||||
}, nextMidnight.getTime() - Date.now());
|
||||
}, delay);
|
||||
}
|
||||
|
||||
private _clearMidnightRefreshTimeout(): void {
|
||||
|
||||
@@ -37,6 +37,8 @@ export class HaConfigApplicationCredentials extends LitElement {
|
||||
|
||||
@state() public _applicationCredentials: ApplicationCredential[] = [];
|
||||
|
||||
@state() private _loading = true;
|
||||
|
||||
@property({ attribute: "is-wide", type: Boolean }) public isWide = false;
|
||||
|
||||
@property({ type: Boolean }) public narrow = false;
|
||||
@@ -154,6 +156,7 @@ export class HaConfigApplicationCredentials extends LitElement {
|
||||
back-path="/config"
|
||||
.tabs=${configSections.devices}
|
||||
.columns=${this._columns(this.hass.localize)}
|
||||
.loading=${this._loading}
|
||||
.data=${this._getApplicationCredentials(
|
||||
this._applicationCredentials,
|
||||
this.hass.localize
|
||||
@@ -278,7 +281,13 @@ export class HaConfigApplicationCredentials extends LitElement {
|
||||
}
|
||||
|
||||
private async _fetchApplicationCredentials() {
|
||||
this._applicationCredentials = await fetchApplicationCredentials(this.hass);
|
||||
try {
|
||||
this._applicationCredentials = await fetchApplicationCredentials(
|
||||
this.hass
|
||||
);
|
||||
} finally {
|
||||
this._loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
private _addApplicationCredential() {
|
||||
|
||||
@@ -897,7 +897,9 @@ class SupervisorAppInfo extends MobileAwareMixin(LitElement) {
|
||||
this._uninstalling
|
||||
}
|
||||
@change=${this._panelToggled}
|
||||
.checked=${this._currentAddon.ingress_panel}
|
||||
.checked=${
|
||||
this._currentAddon.ingress_panel || false
|
||||
}
|
||||
haptic
|
||||
></ha-switch>
|
||||
</ha-row-item>
|
||||
|
||||
@@ -204,7 +204,7 @@ class DialogAreaAddTo extends LitElement {
|
||||
haStyleDialog,
|
||||
css`
|
||||
ha-adaptive-dialog {
|
||||
--dialog-content-padding: 0;
|
||||
--dialog-content-padding: 0 0 var(--ha-space-6);
|
||||
}
|
||||
`,
|
||||
];
|
||||
|
||||
@@ -9,15 +9,15 @@ import {
|
||||
} from "../../../../data/automation";
|
||||
import "../../../../components/ha-yaml-editor";
|
||||
import type { HaYamlEditor } from "../../../../components/ha-yaml-editor";
|
||||
import { COLLAPSIBLE_ACTION_ELEMENTS } from "../../../../data/action";
|
||||
import {
|
||||
COLLAPSIBLE_ACTION_ELEMENTS,
|
||||
getAutomationActionType,
|
||||
} from "../../../../data/action";
|
||||
import { migrateAutomationAction, type Action } from "../../../../data/script";
|
||||
import type { HomeAssistant } from "../../../../types";
|
||||
import "../ha-automation-editor-warning";
|
||||
import { editorStyles, indentStyle } from "../styles";
|
||||
import {
|
||||
getAutomationActionType,
|
||||
type ActionElement,
|
||||
} from "./ha-automation-action-row";
|
||||
import type { ActionElement } from "./ha-automation-action-row";
|
||||
|
||||
@customElement("ha-automation-action-editor")
|
||||
export default class HaAutomationActionEditor extends LitElement {
|
||||
|
||||
@@ -56,6 +56,7 @@ import {
|
||||
ACTION_BUILDING_BLOCKS,
|
||||
ACTION_COMBINED_BLOCKS,
|
||||
ACTION_ICONS,
|
||||
getAutomationActionType,
|
||||
YAML_ONLY_ACTION_TYPES,
|
||||
} from "../../../../data/action";
|
||||
import type {
|
||||
@@ -81,7 +82,7 @@ import type {
|
||||
RepeatAction,
|
||||
ServiceAction,
|
||||
} from "../../../../data/script";
|
||||
import { getActionType, isAction } from "../../../../data/script";
|
||||
import { isAction } from "../../../../data/script";
|
||||
import { describeAction } from "../../../../data/script_i18n";
|
||||
import type { TargetSelector } from "../../../../data/selector";
|
||||
import { callExecuteScript } from "../../../../data/service";
|
||||
@@ -115,23 +116,6 @@ import "./types/ha-automation-action-stop";
|
||||
import "./types/ha-automation-action-wait_for_trigger";
|
||||
import "./types/ha-automation-action-wait_template";
|
||||
|
||||
export const getAutomationActionType = memoizeOne(
|
||||
(action: Action | undefined) => {
|
||||
if (!action) {
|
||||
return undefined;
|
||||
}
|
||||
if ("action" in action) {
|
||||
return getActionType(action) as "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 interface ActionElement extends LitElement {
|
||||
action: Action;
|
||||
expandAll?: () => void;
|
||||
@@ -441,6 +425,16 @@ export default class HaAutomationActionRow extends LitElement {
|
||||
: nothing
|
||||
}
|
||||
</h3>
|
||||
<ha-automation-row-event-chip
|
||||
.show=${this.action.enabled === false && !this._running}
|
||||
slot="event"
|
||||
variant="neutral"
|
||||
class="event-chip"
|
||||
aria-live="polite"
|
||||
>
|
||||
${this.hass.localize("ui.panel.config.automation.editor.actions.disabled")}
|
||||
</ha-automation-row-event-chip>
|
||||
|
||||
<ha-automation-row-event-chip
|
||||
.show=${this._running}
|
||||
.variant=${this._runResult?.variant}
|
||||
@@ -748,17 +742,6 @@ export default class HaAutomationActionRow extends LitElement {
|
||||
|
||||
return html`
|
||||
<ha-card outlined>
|
||||
${
|
||||
this.action.enabled === false
|
||||
? html`
|
||||
<div class="disabled-bar">
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.automation.editor.actions.disabled"
|
||||
)}
|
||||
</div>
|
||||
`
|
||||
: nothing
|
||||
}
|
||||
${
|
||||
this.optionsInSidebar
|
||||
? html`<ha-automation-row
|
||||
|
||||
@@ -13,6 +13,7 @@ import "../../../../components/ha-svg-icon";
|
||||
import {
|
||||
ACTION_BUILDING_BLOCKS,
|
||||
VIRTUAL_ACTIONS,
|
||||
getAutomationActionType,
|
||||
} from "../../../../data/action";
|
||||
import { getValueFromDynamic, isDynamic } from "../../../../data/automation";
|
||||
import type { Action } from "../../../../data/script";
|
||||
@@ -25,7 +26,7 @@ import {
|
||||
import { AutomationSortableListMixin } from "../ha-automation-sortable-list-mixin";
|
||||
import { automationRowsStyles } from "../styles";
|
||||
import type HaAutomationActionRow from "./ha-automation-action-row";
|
||||
import { getAutomationActionType } from "./ha-automation-action-row";
|
||||
import "./ha-automation-action-row";
|
||||
|
||||
@customElement("ha-automation-action")
|
||||
export default class HaAutomationAction extends AutomationSortableListMixin<Action>(
|
||||
|
||||
@@ -63,7 +63,7 @@ export class HaDeviceAction extends LitElement {
|
||||
private _extraFieldsData = memoizeOne(
|
||||
(action: DeviceAction, capabilities: DeviceCapabilities) => {
|
||||
const extraFieldsData: Record<string, any> = {};
|
||||
capabilities.extra_fields.forEach((item) => {
|
||||
capabilities.extra_fields?.forEach((item) => {
|
||||
if (action[item.name] !== undefined) {
|
||||
extraFieldsData![item.name] = action[item.name];
|
||||
}
|
||||
|
||||
@@ -223,6 +223,16 @@ export default class HaAutomationConditionRow extends LitElement {
|
||||
: nothing
|
||||
}
|
||||
</ha-automation-condition-summary>
|
||||
<ha-automation-row-event-chip
|
||||
.show=${this.condition.enabled === false && !this._testing}
|
||||
slot="event"
|
||||
variant="neutral"
|
||||
class="event-chip"
|
||||
aria-live="polite"
|
||||
>
|
||||
${this.hass.localize("ui.panel.config.automation.editor.actions.disabled")}
|
||||
</ha-automation-row-event-chip>
|
||||
|
||||
<ha-automation-row-event-chip
|
||||
.show=${this._testing}
|
||||
.variant=${this._testingResult ? "success" : "warning"}
|
||||
@@ -506,17 +516,6 @@ export default class HaAutomationConditionRow extends LitElement {
|
||||
!this._collapsed,
|
||||
})}
|
||||
>
|
||||
${
|
||||
this.condition.enabled === false
|
||||
? html`
|
||||
<div class="disabled-bar">
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.automation.editor.actions.disabled"
|
||||
)}
|
||||
</div>
|
||||
`
|
||||
: nothing
|
||||
}
|
||||
${
|
||||
this.optionsInSidebar
|
||||
? html`<ha-automation-row
|
||||
|
||||
@@ -64,7 +64,7 @@ export class HaDeviceCondition extends LitElement {
|
||||
private _extraFieldsData = memoizeOne(
|
||||
(condition: DeviceCondition, capabilities: DeviceCapabilities) => {
|
||||
const extraFieldsData: Record<string, any> = {};
|
||||
capabilities.extra_fields.forEach((item) => {
|
||||
capabilities.extra_fields?.forEach((item) => {
|
||||
if (condition[item.name] !== undefined) {
|
||||
extraFieldsData![item.name] = condition[item.name];
|
||||
}
|
||||
|
||||
@@ -676,6 +676,7 @@ export class HaAutomationTrace extends LitElement {
|
||||
}
|
||||
|
||||
.graph {
|
||||
background-color: var(--primary-background-color);
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
@@ -25,7 +25,10 @@ import { handleStructError } from "../../../../common/structs/handle-errors";
|
||||
import type { LocalizeKeys } from "../../../../common/translations/localize";
|
||||
import type { HaDropdownSelectEvent } from "../../../../components/ha-dropdown";
|
||||
import "../../../../components/ha-dropdown-item";
|
||||
import { ACTION_BUILDING_BLOCKS } from "../../../../data/action";
|
||||
import {
|
||||
ACTION_BUILDING_BLOCKS,
|
||||
getAutomationActionType,
|
||||
} from "../../../../data/action";
|
||||
import type { ActionSidebarConfig } from "../../../../data/automation";
|
||||
import type { DomainManifestLookup } from "../../../../data/integration";
|
||||
import { domainToName } from "../../../../data/integration";
|
||||
@@ -37,11 +40,11 @@ import type {
|
||||
import type { HomeAssistant } from "../../../../types";
|
||||
import { isMac } from "../../../../util/is_mac";
|
||||
import type HaAutomationConditionEditor from "../action/ha-automation-action-editor";
|
||||
import { getAutomationActionType } from "../action/ha-automation-action-row";
|
||||
import { getRepeatType } from "../action/types/ha-automation-action-repeat";
|
||||
import "../ha-automation-note";
|
||||
import { overflowStyles, sidebarEditorStyles } from "../styles";
|
||||
import "./ha-automation-sidebar-card";
|
||||
import "../action/ha-automation-action-editor";
|
||||
|
||||
@customElement("ha-automation-sidebar-action")
|
||||
export default class HaAutomationSidebarAction extends LitElement {
|
||||
|
||||
@@ -46,18 +46,6 @@ export const rowStyles = css`
|
||||
ha-card {
|
||||
transition: outline 0.2s;
|
||||
}
|
||||
.disabled-bar {
|
||||
background: var(--divider-color, #e0e0e0);
|
||||
text-align: center;
|
||||
border-top-right-radius: var(
|
||||
--ha-card-border-radius,
|
||||
var(--ha-border-radius-lg)
|
||||
);
|
||||
border-top-left-radius: var(
|
||||
--ha-card-border-radius,
|
||||
var(--ha-border-radius-lg)
|
||||
);
|
||||
}
|
||||
.warning ul {
|
||||
margin: 4px 0;
|
||||
}
|
||||
|
||||
@@ -173,7 +173,7 @@ export default class HaAutomationTriggerRow extends LitElement {
|
||||
@query("ha-automation-row")
|
||||
private _automationRowElement?: HaAutomationRow;
|
||||
|
||||
@query("ha-automation-row-event-chip")
|
||||
@query(".triggered-chip")
|
||||
private _eventChipElement?: HaAutomationRowEventChip;
|
||||
|
||||
@storage({
|
||||
@@ -345,10 +345,24 @@ export default class HaAutomationTriggerRow extends LitElement {
|
||||
: nothing
|
||||
}
|
||||
</h3>
|
||||
<ha-automation-row-event-chip
|
||||
.show=${
|
||||
"enabled" in this.trigger &&
|
||||
this.trigger.enabled === false &&
|
||||
!this._triggered
|
||||
}
|
||||
slot="event"
|
||||
variant="neutral"
|
||||
class="event-chip"
|
||||
aria-live="polite"
|
||||
>
|
||||
${this.hass.localize("ui.panel.config.automation.editor.actions.disabled")}
|
||||
</ha-automation-row-event-chip>
|
||||
|
||||
<ha-automation-row-event-chip
|
||||
.show=${this._triggered}
|
||||
slot="event"
|
||||
class="event-chip"
|
||||
class="event-chip triggered-chip"
|
||||
interactive
|
||||
aria-live="polite"
|
||||
@click=${this._showTriggeredInfo}
|
||||
@@ -622,17 +636,6 @@ export default class HaAutomationTriggerRow extends LitElement {
|
||||
|
||||
return html`
|
||||
<ha-card outlined class=${this._selected ? "selected" : ""}>
|
||||
${
|
||||
"enabled" in this.trigger && this.trigger.enabled === false
|
||||
? html`
|
||||
<div class="disabled-bar">
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.automation.editor.actions.disabled"
|
||||
)}
|
||||
</div>
|
||||
`
|
||||
: nothing
|
||||
}
|
||||
${
|
||||
this.optionsInSidebar
|
||||
? html`<ha-automation-row
|
||||
|
||||
@@ -66,9 +66,9 @@ export class HaDeviceTrigger extends LitElement {
|
||||
private _extraFieldsData = memoizeOne(
|
||||
(trigger: DeviceTrigger, capabilities: DeviceCapabilities) => {
|
||||
const extraFieldsData = computeInitialHaFormData(
|
||||
capabilities.extra_fields
|
||||
capabilities.extra_fields ?? []
|
||||
);
|
||||
capabilities.extra_fields.forEach((item) => {
|
||||
capabilities.extra_fields?.forEach((item) => {
|
||||
if (trigger[item.name] !== undefined) {
|
||||
extraFieldsData![item.name] = trigger[item.name];
|
||||
}
|
||||
|
||||
@@ -194,9 +194,9 @@ class HaConfigSystemNavigation extends LitElement {
|
||||
const hardwareInfo: HardwareInfo = await this.hass.callWS({
|
||||
type: "hardware/info",
|
||||
});
|
||||
this._boardName = hardwareInfo?.hardware.find(
|
||||
(hw) => hw.board !== null
|
||||
)?.name;
|
||||
this._boardName =
|
||||
hardwareInfo?.hardware.find((hw) => hw.board !== null)?.name ??
|
||||
undefined;
|
||||
} else if (isHassioLoaded) {
|
||||
const osData: HassioHassOSInfo = await fetchHassioHassOsInfo(this.hass);
|
||||
if (osData.board) {
|
||||
|
||||
@@ -464,6 +464,15 @@ class HaConfigDashboard extends SubscribeMixin(LitElement) {
|
||||
border-width: 1px 0;
|
||||
border-radius: var(--ha-border-radius-square);
|
||||
box-shadow: unset;
|
||||
box-sizing: border-box;
|
||||
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));
|
||||
padding-left: var(--safe-area-inset-left, 0px);
|
||||
padding-right: var(--safe-area-inset-right, 0px);
|
||||
}
|
||||
ha-config-section {
|
||||
margin-top: -42px;
|
||||
|
||||
@@ -222,7 +222,7 @@ export class DialogDeviceAddTo extends LitElement {
|
||||
haStyleDialog,
|
||||
css`
|
||||
ha-adaptive-dialog {
|
||||
--dialog-content-padding: 0;
|
||||
--dialog-content-padding: 0 0 var(--ha-space-6);
|
||||
}
|
||||
`,
|
||||
];
|
||||
|
||||
@@ -168,10 +168,6 @@ export class HaDeviceLinkedDevicesCard extends LitElement {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.card-header {
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
.card-content {
|
||||
color: var(--secondary-text-color);
|
||||
padding-bottom: var(--ha-space-2);
|
||||
|
||||
@@ -15,6 +15,7 @@ import { customElement, property, query, state } from "lit/decorators";
|
||||
import memoizeOne from "memoize-one";
|
||||
import { storage } from "../../../common/decorators/storage";
|
||||
import type { HASSDomEvent } from "../../../common/dom/fire_event";
|
||||
import { fireEvent } from "../../../common/dom/fire_event";
|
||||
import { computeDeviceNameDisplay } from "../../../common/entity/compute_device_name";
|
||||
import { computeFloorName } from "../../../common/entity/compute_floor_name";
|
||||
import { computeStateDomain } from "../../../common/entity/compute_state_domain";
|
||||
@@ -42,6 +43,8 @@ import type {
|
||||
import "../../../components/data-table/ha-data-table-labels";
|
||||
import "../../../components/entity/ha-battery-icon";
|
||||
import "../../../components/ha-alert";
|
||||
import "../../../components/skeleton/ha-skeleton-icon";
|
||||
import "../../../components/skeleton/ha-skeleton-text";
|
||||
import "../../../components/ha-button";
|
||||
import "../../../components/ha-dropdown";
|
||||
import type { HaDropdownSelectEvent } from "../../../components/ha-dropdown";
|
||||
@@ -115,13 +118,15 @@ export class HaConfigDeviceDashboard extends LitElement {
|
||||
|
||||
@property({ attribute: "is-wide", type: Boolean }) public isWide = false;
|
||||
|
||||
@property({ attribute: false }) public entries!: ConfigEntry[];
|
||||
@property({ attribute: false }) public entries?: ConfigEntry[];
|
||||
|
||||
@property({ attribute: false }) public entriesFailed = false;
|
||||
|
||||
@state() private _subEntries?: SubEntry[];
|
||||
|
||||
@state()
|
||||
@consume({ context: fullEntitiesContext, subscribe: true })
|
||||
entities: EntityRegistryEntry[] = [];
|
||||
entities?: EntityRegistryEntry[];
|
||||
|
||||
@property({ attribute: false }) public manifests!: IntegrationManifest[];
|
||||
|
||||
@@ -238,13 +243,13 @@ export class HaConfigDeviceDashboard extends LitElement {
|
||||
this._filters = this._storageFilters;
|
||||
this._setFiltersFromUrl();
|
||||
}
|
||||
if (changedProps.has("_selected")) {
|
||||
if (changedProps.has("_selected") || changedProps.has("entries")) {
|
||||
this._selectedCanDelete = this._selected.filter((d) => {
|
||||
const device = this.hass.devices[d];
|
||||
const entries = device.config_entries;
|
||||
return entries.some(
|
||||
(entryId) =>
|
||||
this.entries.find((e) => e.entry_id === entryId)
|
||||
this.entries?.find((e) => e.entry_id === entryId)
|
||||
?.supports_remove_device
|
||||
);
|
||||
});
|
||||
@@ -307,6 +312,10 @@ export class HaConfigDeviceDashboard extends LitElement {
|
||||
};
|
||||
}
|
||||
|
||||
private _reloadConfigEntries() {
|
||||
fireEvent(this, "reload-config-entries");
|
||||
}
|
||||
|
||||
private _clearFilter() {
|
||||
this._filters = {};
|
||||
if (!this._fromUrl) {
|
||||
@@ -317,8 +326,9 @@ export class HaConfigDeviceDashboard extends LitElement {
|
||||
private _devicesAndFilterDomains = memoizeOne(
|
||||
(
|
||||
devices: HomeAssistant["devices"],
|
||||
entries: ConfigEntry[],
|
||||
entities: EntityRegistryEntry[],
|
||||
entries: ConfigEntry[] | undefined,
|
||||
entriesFailed: boolean,
|
||||
entities: EntityRegistryEntry[] = [],
|
||||
areas: HomeAssistant["areas"],
|
||||
manifests: IntegrationManifest[],
|
||||
filters: DataTableFilters,
|
||||
@@ -346,7 +356,8 @@ export class HaConfigDeviceDashboard extends LitElement {
|
||||
}
|
||||
|
||||
const entryLookup: Record<string, ConfigEntry> = {};
|
||||
for (const entry of entries) {
|
||||
|
||||
for (const entry of entries ?? []) {
|
||||
entryLookup[entry.entry_id] = entry;
|
||||
}
|
||||
|
||||
@@ -371,7 +382,7 @@ export class HaConfigDeviceDashboard extends LitElement {
|
||||
)
|
||||
);
|
||||
|
||||
const configEntries = entries.filter(
|
||||
const configEntries = (entries ?? []).filter(
|
||||
(entry) =>
|
||||
entry.entry_id &&
|
||||
(filter.value as string[]).includes(entry.entry_id)
|
||||
@@ -412,7 +423,7 @@ export class HaConfigDeviceDashboard extends LitElement {
|
||||
Array.isArray(filter.value) &&
|
||||
filter.value.length
|
||||
) {
|
||||
const entryIds = entries
|
||||
const entryIds = (entries ?? [])
|
||||
.filter((entry) =>
|
||||
(filter.value as string[]).includes(entry.domain)
|
||||
)
|
||||
@@ -531,16 +542,21 @@ export class HaConfigDeviceDashboard extends LitElement {
|
||||
`<${localize("ui.panel.config.devices.data_table.unknown")}>`,
|
||||
area: areaName,
|
||||
floor: floorName,
|
||||
integration: deviceEntries.length
|
||||
? deviceEntries
|
||||
.map(
|
||||
(entry) =>
|
||||
localize(`component.${entry.domain}.title`) || entry.domain
|
||||
)
|
||||
.join(", ")
|
||||
: this.hass.localize(
|
||||
"ui.panel.config.devices.data_table.no_integration"
|
||||
),
|
||||
integration: !entries
|
||||
? localize("ui.common.loading")
|
||||
: entriesFailed
|
||||
? `<${localize("ui.panel.config.devices.data_table.unknown")}>`
|
||||
: deviceEntries.length
|
||||
? deviceEntries
|
||||
.map(
|
||||
(entry) =>
|
||||
localize(`component.${entry.domain}.title`) ||
|
||||
entry.domain
|
||||
)
|
||||
.join(", ")
|
||||
: this.hass.localize(
|
||||
"ui.panel.config.devices.data_table.no_integration"
|
||||
),
|
||||
domains: deviceEntries.map((entry) => entry.domain),
|
||||
parent_device_name: parentDevice
|
||||
? computeDeviceNameDisplay(
|
||||
@@ -599,21 +615,23 @@ export class HaConfigDeviceDashboard extends LitElement {
|
||||
moveable: false,
|
||||
showNarrow: true,
|
||||
template: (device) =>
|
||||
device.domains.length
|
||||
? html`<img
|
||||
alt=""
|
||||
crossorigin="anonymous"
|
||||
referrerpolicy="no-referrer"
|
||||
src=${brandsUrl(
|
||||
{
|
||||
domain: device.domains[0],
|
||||
type: "icon",
|
||||
darkOptimized: this.hass.themes?.darkMode,
|
||||
},
|
||||
this.hass.auth.data.hassUrl
|
||||
)}
|
||||
/>`
|
||||
: "",
|
||||
!this.entries
|
||||
? html`<ha-skeleton-icon></ha-skeleton-icon>`
|
||||
: device.domains.length
|
||||
? html`<img
|
||||
alt=""
|
||||
crossorigin="anonymous"
|
||||
referrerpolicy="no-referrer"
|
||||
src=${brandsUrl(
|
||||
{
|
||||
domain: device.domains[0],
|
||||
type: "icon",
|
||||
darkOptimized: this.hass.themes?.darkMode,
|
||||
},
|
||||
this.hass.auth.data.hassUrl
|
||||
)}
|
||||
/>`
|
||||
: "",
|
||||
},
|
||||
name: {
|
||||
title: localize("ui.panel.config.devices.data_table.device"),
|
||||
@@ -642,7 +660,9 @@ export class HaConfigDeviceDashboard extends LitElement {
|
||||
.labels=${device.label_entries}
|
||||
></ha-data-table-labels>
|
||||
`
|
||||
: nothing
|
||||
: device.labels.length && !this._labels
|
||||
? html`<ha-skeleton-text></ha-skeleton-text>`
|
||||
: nothing
|
||||
}
|
||||
`,
|
||||
},
|
||||
@@ -654,6 +674,10 @@ export class HaConfigDeviceDashboard extends LitElement {
|
||||
filterable: true,
|
||||
groupable: true,
|
||||
minWidth: "120px",
|
||||
template: (device) =>
|
||||
!this.entries
|
||||
? html`<ha-skeleton-text></ha-skeleton-text>`
|
||||
: device.integration,
|
||||
},
|
||||
device_family_name: {
|
||||
title: localize("ui.panel.config.devices.data_table.parent_device"),
|
||||
@@ -697,6 +721,9 @@ export class HaConfigDeviceDashboard extends LitElement {
|
||||
minWidth: "101px",
|
||||
valueColumn: "battery_level",
|
||||
template: (device) => {
|
||||
if (!this.entities) {
|
||||
return html`<ha-skeleton-text></ha-skeleton-text>`;
|
||||
}
|
||||
const batteryEntityPair = device.battery_entity;
|
||||
const battery =
|
||||
batteryEntityPair && batteryEntityPair[0]
|
||||
@@ -831,6 +858,7 @@ export class HaConfigDeviceDashboard extends LitElement {
|
||||
const { devicesOutput } = this._devicesAndFilterDomains(
|
||||
this.hass.devices,
|
||||
this.entries,
|
||||
this.entriesFailed,
|
||||
this.entities,
|
||||
this.hass.areas,
|
||||
this.manifests,
|
||||
@@ -895,6 +923,22 @@ export class HaConfigDeviceDashboard extends LitElement {
|
||||
<ha-svg-icon slot="start" .path=${mdiPlus}></ha-svg-icon>
|
||||
${this.hass.localize("ui.panel.config.devices.add_device")}
|
||||
</ha-button>
|
||||
${
|
||||
this.entriesFailed
|
||||
? html`<ha-alert slot="top-header" alert-type="error">
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.devices.config_entries_load_failed"
|
||||
)}
|
||||
<ha-button
|
||||
slot="action"
|
||||
appearance="plain"
|
||||
@click=${this._reloadConfigEntries}
|
||||
>
|
||||
${this.hass.localize("ui.panel.config.devices.retry")}
|
||||
</ha-button>
|
||||
</ha-alert>`
|
||||
: nothing
|
||||
}
|
||||
${
|
||||
Array.isArray(this._filters.config_entry?.value) &&
|
||||
this._filters.config_entry?.value.length
|
||||
@@ -903,10 +947,13 @@ export class HaConfigDeviceDashboard extends LitElement {
|
||||
"ui.panel.config.devices.filtering_by_config_entry"
|
||||
)}
|
||||
${
|
||||
this.entries?.find(
|
||||
(entry) =>
|
||||
entry.entry_id === this._filters.config_entry!.value![0]
|
||||
)?.title || this._filters.config_entry.value[0]
|
||||
!this.entries
|
||||
? html`<ha-skeleton-text></ha-skeleton-text>`
|
||||
: this.entries.find(
|
||||
(entry) =>
|
||||
entry.entry_id ===
|
||||
this._filters.config_entry!.value![0]
|
||||
)?.title || this._filters.config_entry.value[0]
|
||||
}${
|
||||
this._filters.config_entry.value.length === 1 &&
|
||||
Array.isArray(this._filters.sub_entry?.value) &&
|
||||
@@ -1118,6 +1165,7 @@ export class HaConfigDeviceDashboard extends LitElement {
|
||||
this._devicesAndFilterDomains(
|
||||
this.hass.devices,
|
||||
this.entries,
|
||||
this.entriesFailed,
|
||||
this.entities,
|
||||
this.hass.areas,
|
||||
this.manifests,
|
||||
@@ -1357,6 +1405,13 @@ ${rejected
|
||||
ha-assist-chip {
|
||||
--ha-assist-chip-container-shape: 10px;
|
||||
}
|
||||
ha-alert[slot="top-header"] {
|
||||
display: block;
|
||||
margin: var(--ha-space-2) var(--ha-space-4);
|
||||
}
|
||||
ha-alert ha-skeleton-text {
|
||||
--ha-skeleton-text-width: 100px;
|
||||
}
|
||||
ha-dropdown::part(menu),
|
||||
ha-dropdown::part(submenu) {
|
||||
--auto-size-available-width: calc(50vw - var(--ha-space-4));
|
||||
|
||||
@@ -31,13 +31,21 @@ class HaConfigDevices extends HassRouterPage {
|
||||
},
|
||||
};
|
||||
|
||||
@state() private _configEntries: ConfigEntry[] = [];
|
||||
@state() private _configEntries?: ConfigEntry[];
|
||||
|
||||
@state() private _configEntriesFailed = false;
|
||||
|
||||
@state() private _manifests: IntegrationManifest[] = [];
|
||||
|
||||
protected firstUpdated(changedProps: PropertyValues<this>) {
|
||||
super.firstUpdated(changedProps);
|
||||
this._loadData();
|
||||
protected willUpdate(changedProps: PropertyValues<this>) {
|
||||
super.willUpdate(changedProps);
|
||||
|
||||
if (!this.hasUpdated) {
|
||||
this.addEventListener("reload-config-entries", () =>
|
||||
this._loadConfigEntries()
|
||||
);
|
||||
this._loadData();
|
||||
}
|
||||
}
|
||||
|
||||
protected updatePageEl(pageEl) {
|
||||
@@ -45,9 +53,12 @@ class HaConfigDevices extends HassRouterPage {
|
||||
|
||||
if (this._currentPage === "device") {
|
||||
pageEl.deviceId = this.routeTail.path.substr(1);
|
||||
pageEl.entries = this._configEntries ?? [];
|
||||
} else {
|
||||
pageEl.entries = this._configEntries;
|
||||
pageEl.entriesFailed = this._configEntriesFailed;
|
||||
}
|
||||
|
||||
pageEl.entries = this._configEntries;
|
||||
pageEl.manifests = this._manifests;
|
||||
pageEl.narrow = this.narrow;
|
||||
pageEl.isWide = this.isWide;
|
||||
@@ -55,12 +66,36 @@ class HaConfigDevices extends HassRouterPage {
|
||||
}
|
||||
|
||||
private async _loadData() {
|
||||
this._configEntries = await getConfigEntries(this.hass);
|
||||
this._manifests = await fetchIntegrationManifests(this.hass);
|
||||
await Promise.all([
|
||||
this._loadConfigEntries(),
|
||||
fetchIntegrationManifests(this.hass)
|
||||
.then((manifests) => {
|
||||
this._manifests = manifests;
|
||||
})
|
||||
.catch(() => {
|
||||
// The pages remain usable without integration manifests.
|
||||
}),
|
||||
]);
|
||||
}
|
||||
|
||||
private async _loadConfigEntries() {
|
||||
this._configEntriesFailed = false;
|
||||
this._configEntries = undefined;
|
||||
|
||||
try {
|
||||
this._configEntries = await getConfigEntries(this.hass);
|
||||
} catch {
|
||||
this._configEntriesFailed = true;
|
||||
this._configEntries = [];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HASSDomEvents {
|
||||
"reload-config-entries": undefined;
|
||||
}
|
||||
|
||||
interface HTMLElementTagNameMap {
|
||||
"ha-config-devices": HaConfigDevices;
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user