mirror of
https://github.com/home-assistant/frontend.git
synced 2026-09-26 15:00:42 +00:00
Compare commits
28
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a4d5a07e71 | ||
|
|
f92926cb3b | ||
|
|
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 |
@@ -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"
|
||||
|
||||
@@ -127,7 +127,7 @@ export class HaAuthFlow extends LitElement {
|
||||
width: 100%;
|
||||
}
|
||||
</style>
|
||||
<form>${this._renderForm()}</form>
|
||||
<form @submit=${this._handleSubmit}>${this._renderForm()}</form>
|
||||
`;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,17 +1,66 @@
|
||||
import { customElement } from "lit/decorators";
|
||||
import type { TemplateResult } from "lit";
|
||||
import { html } from "lit";
|
||||
import { customElement, query } from "lit/decorators";
|
||||
import { fireEvent } from "../common/dom/fire_event";
|
||||
import { HaFormString } from "../components/ha-form/ha-form-string";
|
||||
import "../components/ha-icon-button";
|
||||
import "../components/input/ha-input";
|
||||
import "./ha-auth-textfield";
|
||||
import type { HaAuthTextField } from "./ha-auth-textfield";
|
||||
|
||||
@customElement("ha-auth-form-string")
|
||||
export class HaAuthFormString extends HaFormString {
|
||||
protected createRenderRoot() {
|
||||
@query("ha-auth-textfield") private _textfield?: HaAuthTextField;
|
||||
|
||||
protected override createRenderRoot() {
|
||||
return this;
|
||||
}
|
||||
|
||||
public connectedCallback(): void {
|
||||
super.connectedCallback();
|
||||
this.style.position = "relative";
|
||||
public override focus(): void {
|
||||
this._textfield?.focus();
|
||||
}
|
||||
|
||||
public override reportValidity(): boolean {
|
||||
const textfield = this._textfield;
|
||||
if (!textfield) {
|
||||
return true;
|
||||
}
|
||||
const valid = textfield.reportValidity();
|
||||
// Adopt a value a password manager wrote without an event reaching us.
|
||||
const value = textfield.value ?? "";
|
||||
if ((this.data ?? "") !== value) {
|
||||
fireEvent(this, "value-changed", { value });
|
||||
}
|
||||
return valid;
|
||||
}
|
||||
|
||||
protected override render(): TemplateResult {
|
||||
return html`
|
||||
<ha-auth-textfield
|
||||
.passwordToggle=${this.isPassword}
|
||||
.type=${!this.isPassword ? this.stringType : "password"}
|
||||
.label=${this.label}
|
||||
.value=${this.data || ""}
|
||||
.hint=${this.helper}
|
||||
.disabled=${this.disabled}
|
||||
.required=${!!this.schema.required}
|
||||
.autoValidate=${!!this.schema.required}
|
||||
.name=${this.schema.name}
|
||||
.autofocus=${!!this.schema.autofocus}
|
||||
.autocomplete=${this.schema.autocomplete}
|
||||
.validationMessage=${
|
||||
this.schema.required
|
||||
? this.localize?.("ui.panel.page-authorize.form.error_required")
|
||||
: undefined
|
||||
}
|
||||
.showPasswordLabel=${this.localize?.(
|
||||
"ui.panel.page-authorize.form.show_password"
|
||||
)}
|
||||
.hidePasswordLabel=${this.localize?.(
|
||||
"ui.panel.page-authorize.form.hide_password"
|
||||
)}
|
||||
@input=${this._valueChanged}
|
||||
@change=${this._valueChanged}
|
||||
></ha-auth-textfield>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,250 @@
|
||||
/* eslint-disable lit/prefer-static-styles */
|
||||
import { mdiEye, mdiEyeOff } from "@mdi/js";
|
||||
import type { PropertyValues, TemplateResult } from "lit";
|
||||
import { html, LitElement, nothing } from "lit";
|
||||
import { customElement, property, query, state } from "lit/decorators";
|
||||
import { classMap } from "lit/directives/class-map";
|
||||
import { ifDefined } from "lit/directives/if-defined";
|
||||
import { stopPropagation } from "../common/dom/stop_propagation";
|
||||
import "../components/ha-icon-button";
|
||||
import { WaInputMixin } from "../components/input/wa-input-mixin";
|
||||
|
||||
/**
|
||||
* Text field for the login page. It renders the native input in the light DOM
|
||||
* so browsers and password manager extensions can find it (#51620).
|
||||
*/
|
||||
@customElement("ha-auth-textfield")
|
||||
export class HaAuthTextField extends WaInputMixin(LitElement) {
|
||||
@property() public type: "text" | "password" | "email" | "url" = "text";
|
||||
|
||||
@property({ type: Boolean, attribute: "password-toggle" })
|
||||
public passwordToggle = false;
|
||||
|
||||
@property({ attribute: false }) public showPasswordLabel?: string;
|
||||
|
||||
@property({ attribute: false }) public hidePasswordLabel?: string;
|
||||
|
||||
@state() private _passwordVisible = false;
|
||||
|
||||
@query("input") private _input?: HTMLInputElement;
|
||||
|
||||
protected override get _formControl(): HTMLInputElement | undefined {
|
||||
return this._input;
|
||||
}
|
||||
|
||||
protected override createRenderRoot() {
|
||||
return this;
|
||||
}
|
||||
|
||||
protected override firstUpdated(changedProps: PropertyValues<this>): void {
|
||||
super.firstUpdated(changedProps);
|
||||
if (this.autofocus) {
|
||||
this.focus();
|
||||
}
|
||||
}
|
||||
|
||||
public override focus(): void {
|
||||
this._input?.focus();
|
||||
}
|
||||
|
||||
public override checkValidity(): boolean {
|
||||
return this._input?.checkValidity() ?? true;
|
||||
}
|
||||
|
||||
public override reportValidity(): boolean {
|
||||
// Adopt a value a password manager wrote to the input without events.
|
||||
this._handleInput();
|
||||
return super.reportValidity();
|
||||
}
|
||||
|
||||
protected override render(): TemplateResult {
|
||||
const invalid = this.invalid || this._invalid;
|
||||
const hintId = this.name ? `${this.name}-hint` : undefined;
|
||||
|
||||
// The blank placeholder lets :placeholder-shown raise the label when a
|
||||
// password manager fills the field without firing events.
|
||||
return html`
|
||||
<style>
|
||||
ha-auth-textfield {
|
||||
display: block;
|
||||
padding-bottom: var(--ha-space-2);
|
||||
text-align: start;
|
||||
}
|
||||
ha-auth-textfield .base {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
box-sizing: border-box;
|
||||
height: 56px;
|
||||
padding: 0 var(--ha-space-4);
|
||||
background-color: var(--ha-color-form-background);
|
||||
border-radius: var(--ha-border-radius-sm) var(--ha-border-radius-sm)
|
||||
var(--ha-border-radius-square) var(--ha-border-radius-square);
|
||||
cursor: text;
|
||||
transition: background-color var(--wa-transition-normal) ease-in-out;
|
||||
}
|
||||
ha-auth-textfield .base:hover {
|
||||
background-color: var(--ha-color-form-background-hover);
|
||||
}
|
||||
ha-auth-textfield .base.disabled {
|
||||
background-color: var(--ha-color-form-background-disabled);
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
ha-auth-textfield .base::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 1px;
|
||||
background-color: var(--ha-color-border-neutral-loud);
|
||||
transition:
|
||||
height var(--wa-transition-normal) ease-in-out,
|
||||
background-color var(--wa-transition-normal) ease-in-out;
|
||||
}
|
||||
ha-auth-textfield .base:focus-within::after {
|
||||
height: 2px;
|
||||
background-color: var(--primary-color);
|
||||
}
|
||||
ha-auth-textfield .base.invalid:not(.disabled)::after {
|
||||
background-color: var(--ha-color-border-danger-normal);
|
||||
}
|
||||
ha-auth-textfield label {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 1;
|
||||
padding: var(--ha-space-5) var(--ha-space-4) 0;
|
||||
pointer-events: none;
|
||||
font-family: var(--ha-font-family-body);
|
||||
font-size: var(--ha-font-size-m);
|
||||
font-weight: var(--ha-font-weight-normal);
|
||||
line-height: var(--ha-line-height-condensed);
|
||||
color: var(--secondary-text-color);
|
||||
transition: all var(--wa-transition-normal) ease-in-out;
|
||||
}
|
||||
ha-auth-textfield input:focus + label,
|
||||
ha-auth-textfield input:not(:placeholder-shown) + label {
|
||||
padding-top: var(--ha-space-3);
|
||||
font-size: var(--ha-font-size-xs);
|
||||
}
|
||||
ha-auth-textfield .base:focus-within label {
|
||||
color: var(--primary-color);
|
||||
}
|
||||
ha-auth-textfield .base.invalid:not(.disabled) label {
|
||||
color: var(--ha-color-fill-danger-loud-resting);
|
||||
}
|
||||
ha-auth-textfield .base.disabled label {
|
||||
opacity: 0.5;
|
||||
}
|
||||
ha-auth-textfield input {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
padding: var(--ha-space-3) 0 0;
|
||||
border: none;
|
||||
outline: none;
|
||||
box-shadow: none;
|
||||
background: transparent;
|
||||
color: var(--primary-text-color);
|
||||
font-family: var(--ha-font-family-body);
|
||||
font-size: var(--ha-font-size-m);
|
||||
-webkit-appearance: none;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
ha-auth-textfield input:-webkit-autofill,
|
||||
ha-auth-textfield input:-webkit-autofill:hover,
|
||||
ha-auth-textfield input:-webkit-autofill:focus,
|
||||
ha-auth-textfield input:-webkit-autofill:active {
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: var(--primary-text-color);
|
||||
background-color: transparent;
|
||||
box-shadow: none;
|
||||
caret-color: var(--primary-text-color);
|
||||
}
|
||||
ha-auth-textfield input::-ms-reveal {
|
||||
display: none;
|
||||
}
|
||||
ha-auth-textfield ha-icon-button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
color: var(--ha-color-text-secondary);
|
||||
}
|
||||
ha-auth-textfield .hint {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
min-height: var(--ha-space-5);
|
||||
margin-inline-start: var(--ha-space-3);
|
||||
font-size: var(--ha-font-size-s);
|
||||
color: var(--ha-color-text-secondary);
|
||||
}
|
||||
ha-auth-textfield .hint.error {
|
||||
color: var(--ha-color-on-danger-quiet);
|
||||
}
|
||||
</style>
|
||||
<div class=${classMap({ base: true, invalid, disabled: this.disabled })}>
|
||||
<input
|
||||
id=${ifDefined(this.name)}
|
||||
name=${ifDefined(this.name)}
|
||||
type=${
|
||||
this.type === "password" && this._passwordVisible
|
||||
? "text"
|
||||
: this.type
|
||||
}
|
||||
placeholder=" "
|
||||
autocomplete=${ifDefined(this.autocomplete)}
|
||||
?required=${this.required}
|
||||
?disabled=${this.disabled}
|
||||
.value=${this.value ?? ""}
|
||||
aria-describedby=${ifDefined(hintId)}
|
||||
aria-invalid=${ifDefined(invalid ? "true" : undefined)}
|
||||
@input=${this._handleInput}
|
||||
@change=${this._handleChange}
|
||||
@blur=${this._handleBlur}
|
||||
/>
|
||||
<label for=${ifDefined(this.name)}
|
||||
>${this._renderLabel(this.label ?? "", this.required)}</label
|
||||
>
|
||||
${
|
||||
this.passwordToggle && !this.disabled
|
||||
? html`<ha-icon-button
|
||||
.path=${this._passwordVisible ? mdiEyeOff : mdiEye}
|
||||
.label=${
|
||||
this._passwordVisible
|
||||
? this.hidePasswordLabel
|
||||
: this.showPasswordLabel
|
||||
}
|
||||
@click=${this._togglePasswordVisibility}
|
||||
@keypress=${stopPropagation}
|
||||
></ha-icon-button>`
|
||||
: nothing
|
||||
}
|
||||
</div>
|
||||
<div
|
||||
id=${ifDefined(hintId)}
|
||||
class=${classMap({ hint: true, error: invalid })}
|
||||
role=${ifDefined(invalid ? "alert" : undefined)}
|
||||
aria-live="polite"
|
||||
>
|
||||
${
|
||||
invalid
|
||||
? this.validationMessage || this._input?.validationMessage
|
||||
: this.hint
|
||||
}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private _togglePasswordVisibility(): void {
|
||||
this._passwordVisible = !this._passwordVisible;
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"ha-auth-textfield": HaAuthTextField;
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -1202,6 +1202,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;
|
||||
@@ -1499,6 +1513,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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
`;
|
||||
|
||||
@@ -18,32 +18,32 @@ import {
|
||||
mdiRoomService,
|
||||
mdiShuffleDisabled,
|
||||
} from "@mdi/js";
|
||||
import type { PropertyValues } from "lit";
|
||||
import { LitElement, css, html, nothing } from "lit";
|
||||
import { customElement, property, query } from "lit/decorators";
|
||||
import { ensureArray } from "../../common/array/ensure-array";
|
||||
import { customElement, property, query, state } from "lit/decorators";
|
||||
import type { PropertyValues } from "lit";
|
||||
import memoizeOne from "memoize-one";
|
||||
import { consumeLocalize } from "../../common/decorators/consume-context-entry";
|
||||
import { fireEvent } from "../../common/dom/fire_event";
|
||||
import type { LocalizeFunc } from "../../common/translations/localize";
|
||||
import type { Condition, Trigger } from "../../data/automation";
|
||||
import { flattenTriggers } from "../../data/automation";
|
||||
import type {
|
||||
Action,
|
||||
ChooseAction,
|
||||
IfAction,
|
||||
ManualScriptConfig,
|
||||
ParallelAction,
|
||||
RepeatAction,
|
||||
SequenceAction,
|
||||
ServiceAction,
|
||||
WaitAction,
|
||||
WaitForTriggerAction,
|
||||
import {
|
||||
getActionType,
|
||||
type ChooseAction,
|
||||
type IfAction,
|
||||
type ParallelAction,
|
||||
type RepeatAction,
|
||||
type SequenceAction,
|
||||
type ServiceAction,
|
||||
type WaitAction,
|
||||
type WaitForTriggerAction,
|
||||
} from "../../data/script";
|
||||
import { getActionType } from "../../data/script";
|
||||
import type { TraceExtended } from "../../data/trace";
|
||||
import { TraceTree } from "../../data/trace-tree";
|
||||
import type {
|
||||
ChooseActionTraceStep,
|
||||
ConditionTraceStep,
|
||||
IfActionTraceStep,
|
||||
TraceExtended,
|
||||
} from "../../data/trace";
|
||||
NodeInfo,
|
||||
TraceActionNode,
|
||||
TraceNode,
|
||||
} from "../../data/trace-tree";
|
||||
import "../ha-icon-button";
|
||||
import "../ha-service-icon";
|
||||
import "./hat-graph-branch";
|
||||
@@ -52,13 +52,7 @@ import "./hat-graph-node";
|
||||
import "./hat-graph-spacer";
|
||||
import { ACTION_ICONS } from "../../data/action";
|
||||
|
||||
type NodeType = "trigger" | "condition" | "action" | "chooseOption" | undefined;
|
||||
|
||||
export interface NodeInfo {
|
||||
path: string;
|
||||
config: any;
|
||||
type?: NodeType;
|
||||
}
|
||||
export type { NodeInfo };
|
||||
|
||||
declare global {
|
||||
interface HASSDomEvents {
|
||||
@@ -68,6 +62,10 @@ declare global {
|
||||
|
||||
@customElement("hat-script-graph")
|
||||
export class HatScriptGraph extends LitElement {
|
||||
@state()
|
||||
@consumeLocalize()
|
||||
private _localize!: LocalizeFunc;
|
||||
|
||||
@property({ attribute: false }) public trace!: TraceExtended;
|
||||
|
||||
@property({ attribute: false }) public selected?: string;
|
||||
@@ -75,57 +73,43 @@ export class HatScriptGraph extends LitElement {
|
||||
@query("hat-graph-node[active], hat-graph-branch[active]")
|
||||
private _activeNode?: HTMLElement;
|
||||
|
||||
public renderedNodes: Record<string, NodeInfo> = {};
|
||||
private _buildTree = memoizeOne(
|
||||
(trace: TraceExtended) => new TraceTree(trace)
|
||||
);
|
||||
|
||||
public trackedNodes: Record<string, NodeInfo> = {};
|
||||
public get renderedNodes(): Record<string, NodeInfo> {
|
||||
return this._buildTree(this.trace).renderedNodes;
|
||||
}
|
||||
|
||||
private _selectNode(config, path, type?) {
|
||||
public get trackedNodes(): Record<string, NodeInfo> {
|
||||
return this._buildTree(this.trace).trackedNodes;
|
||||
}
|
||||
|
||||
private _selectNode(config: unknown, path: string, type?: NodeInfo["type"]) {
|
||||
return () => {
|
||||
fireEvent(this, "graph-node-selected", { config, path, type });
|
||||
};
|
||||
}
|
||||
|
||||
private _renderTrigger(config: Trigger, i: number) {
|
||||
const path = `trigger/${i}`;
|
||||
const tracked = this.trace && path in this.trace.trace;
|
||||
// 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.
|
||||
const notTriggered = !!(tracked && this.trace.not_triggered);
|
||||
const track = tracked && !notTriggered;
|
||||
this.renderedNodes[path] = { config, path, type: "trigger" };
|
||||
if (tracked) {
|
||||
this.trackedNodes[path] = this.renderedNodes[path];
|
||||
}
|
||||
private _renderTrigger(node: TraceNode<Trigger>) {
|
||||
const { config, path, track, hasTrace } = node;
|
||||
return html`
|
||||
<hat-graph-node
|
||||
graph-start
|
||||
?track=${track}
|
||||
?not-triggered=${notTriggered}
|
||||
?not-triggered=${node.notTriggered}
|
||||
@focus=${this._selectNode(config, path, "trigger")}
|
||||
?active=${this.selected === path}
|
||||
.iconPath=${mdiAsterisk}
|
||||
.notEnabled=${"enabled" in config && config.enabled === false}
|
||||
.error=${this.trace.trace[path]?.some((tr) => tr.error)}
|
||||
tabindex=${tracked ? "0" : "-1"}
|
||||
.notEnabled=${node.disabled}
|
||||
.error=${node.error}
|
||||
tabindex=${hasTrace ? "0" : "-1"}
|
||||
></hat-graph-node>
|
||||
`;
|
||||
}
|
||||
|
||||
private _renderCondition(config: Condition, i: number) {
|
||||
const path = `condition/${i}`;
|
||||
this.renderedNodes[path] = { config, path, type: "condition" };
|
||||
if (this.trace && path in this.trace.trace) {
|
||||
this.trackedNodes[path] = this.renderedNodes[path];
|
||||
}
|
||||
return this._renderConditionNode(config, path);
|
||||
}
|
||||
|
||||
private _typeRenderers = {
|
||||
condition: this._renderConditionNode,
|
||||
and: this._renderConditionNode,
|
||||
or: this._renderConditionNode,
|
||||
not: this._renderConditionNode,
|
||||
service: this._renderServiceNode,
|
||||
wait_template: this._renderWaitNode,
|
||||
wait_for_trigger: this._renderWaitNode,
|
||||
@@ -137,240 +121,142 @@ export class HatScriptGraph extends LitElement {
|
||||
other: this._renderOtherNode,
|
||||
};
|
||||
|
||||
private _renderActionNode(
|
||||
node: Action,
|
||||
path: string,
|
||||
graphStart = false,
|
||||
disabled = false
|
||||
) {
|
||||
private _renderActionNode(node: TraceActionNode, graphStart = false) {
|
||||
// The modern `action:` key has no dedicated renderer. The old
|
||||
// `key in node` lookup fell through to the generic node for it, so keep
|
||||
// that here for visual parity. The generic node still picks the service
|
||||
// icon through the node's action type.
|
||||
const type =
|
||||
Object.keys(this._typeRenderers).find((key) => key in node) || "other";
|
||||
this.renderedNodes[path] = { config: node, path, type: "action" };
|
||||
if (this.trace && path in this.trace.trace) {
|
||||
this.trackedNodes[path] = this.renderedNodes[path];
|
||||
}
|
||||
return this._typeRenderers[type].bind(this)(
|
||||
"action" in node.config ? "other" : (node.actionType ?? "other");
|
||||
return (this._typeRenderers[type] ?? this._renderOtherNode).bind(this)(
|
||||
node,
|
||||
path,
|
||||
graphStart,
|
||||
disabled
|
||||
graphStart
|
||||
);
|
||||
}
|
||||
|
||||
private _renderChooseNode(
|
||||
config: ChooseAction,
|
||||
path: string,
|
||||
graphStart = false,
|
||||
disabled = false
|
||||
node: TraceActionNode<ChooseAction>,
|
||||
graphStart = false
|
||||
) {
|
||||
const trace = this.trace.trace[path] as ChooseActionTraceStep[] | undefined;
|
||||
const tracePath = trace
|
||||
? trace.map((trc) =>
|
||||
trc.result === undefined || trc.result.choice === "default"
|
||||
? "default"
|
||||
: trc.result.choice
|
||||
)
|
||||
: [];
|
||||
const trackDefault = tracePath.includes("default");
|
||||
const { config, path, track } = node;
|
||||
const defaultBranch = node.branches[node.branches.length - 1];
|
||||
return html`
|
||||
<hat-graph-branch
|
||||
tabindex=${trace === undefined ? "-1" : "0"}
|
||||
tabindex=${node.hasTrace ? "0" : "-1"}
|
||||
@focus=${this._selectNode(config, path, "action")}
|
||||
?track=${trace !== undefined}
|
||||
?track=${track}
|
||||
?active=${this.selected === path}
|
||||
.notEnabled=${disabled || config.enabled === false}
|
||||
.notEnabled=${node.disabled}
|
||||
>
|
||||
<hat-graph-node
|
||||
.graphStart=${graphStart}
|
||||
.iconPath=${mdiArrowDecision}
|
||||
?track=${trace !== undefined}
|
||||
?track=${track}
|
||||
?active=${this.selected === path}
|
||||
.notEnabled=${disabled || config.enabled === false}
|
||||
.error=${this.trace.trace[path]?.some((tr) => tr.error)}
|
||||
.notEnabled=${node.disabled}
|
||||
.error=${node.error}
|
||||
slot="head"
|
||||
nofocus
|
||||
></hat-graph-node>
|
||||
|
||||
${
|
||||
config.choose
|
||||
? ensureArray(config.choose)?.map((branch, i) => {
|
||||
const branchPath = `${path}/choose/${i}`;
|
||||
const trackThis = tracePath.includes(i);
|
||||
this.renderedNodes[branchPath] = {
|
||||
config: branch,
|
||||
path: branchPath,
|
||||
type: "chooseOption",
|
||||
};
|
||||
if (trackThis) {
|
||||
this.trackedNodes[branchPath] =
|
||||
this.renderedNodes[branchPath];
|
||||
${node.branches.slice(0, -1).map(
|
||||
(branch) => html`
|
||||
<div class="graph-container" ?track=${branch.hasTrace}>
|
||||
<hat-graph-node
|
||||
.iconPath=${
|
||||
!track || branch.hasTrace
|
||||
? mdiCheckboxMarkedOutline
|
||||
: mdiCheckboxBlankOutline
|
||||
}
|
||||
return html`
|
||||
<div class="graph-container" ?track=${trackThis}>
|
||||
<hat-graph-node
|
||||
.iconPath=${
|
||||
!trace || trackThis
|
||||
? mdiCheckboxMarkedOutline
|
||||
: mdiCheckboxBlankOutline
|
||||
}
|
||||
@focus=${this._selectNode(
|
||||
branch,
|
||||
branchPath,
|
||||
"chooseOption"
|
||||
)}
|
||||
?track=${trackThis}
|
||||
?active=${this.selected === branchPath}
|
||||
.notEnabled=${disabled || config.enabled === false}
|
||||
></hat-graph-node>
|
||||
${
|
||||
branch.sequence !== null
|
||||
? ensureArray<Action>(branch.sequence).map(
|
||||
(action, j) =>
|
||||
this._renderActionNode(
|
||||
action,
|
||||
`${branchPath}/sequence/${j}`,
|
||||
false,
|
||||
disabled || config.enabled === false
|
||||
)
|
||||
)
|
||||
: ""
|
||||
}
|
||||
</div>
|
||||
`;
|
||||
})
|
||||
: ""
|
||||
}
|
||||
<div ?track=${trackDefault}>
|
||||
<hat-graph-spacer ?track=${trackDefault}></hat-graph-spacer>
|
||||
${
|
||||
config.default !== null
|
||||
? ensureArray<Action | undefined>(config.default)?.map(
|
||||
(action, i) =>
|
||||
this._renderActionNode(
|
||||
action,
|
||||
`${path}/default/${i}`,
|
||||
false,
|
||||
disabled || config.enabled === false
|
||||
)
|
||||
)
|
||||
: ""
|
||||
}
|
||||
</div>
|
||||
</hat-graph-branch>
|
||||
`;
|
||||
}
|
||||
|
||||
private _renderIfNode(
|
||||
config: IfAction,
|
||||
path: string,
|
||||
graphStart = false,
|
||||
disabled = false
|
||||
) {
|
||||
const trace = this.trace.trace[path] as IfActionTraceStep[] | undefined;
|
||||
let trackThen = false;
|
||||
let trackElse = false;
|
||||
for (const trc of trace || []) {
|
||||
if (!trackThen && trc.result?.choice === "then") {
|
||||
trackThen = true;
|
||||
}
|
||||
if ((!trackElse && trc.result?.choice === "else") || !trc.result) {
|
||||
trackElse = true;
|
||||
}
|
||||
if (trackElse && trackThen) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
return html`
|
||||
<hat-graph-branch
|
||||
tabindex=${trace === undefined ? "-1" : "0"}
|
||||
@focus=${this._selectNode(config, path, "action")}
|
||||
?track=${trace !== undefined}
|
||||
?active=${this.selected === path}
|
||||
.notEnabled=${disabled || config.enabled === false}
|
||||
>
|
||||
<hat-graph-node
|
||||
.graphStart=${graphStart}
|
||||
.iconPath=${mdiCallSplit}
|
||||
?track=${trace !== undefined}
|
||||
?active=${this.selected === path}
|
||||
.notEnabled=${disabled || config.enabled === false}
|
||||
slot="head"
|
||||
nofocus
|
||||
></hat-graph-node>
|
||||
${
|
||||
config.else
|
||||
? html`<div class="graph-container" ?track=${trackElse}>
|
||||
<hat-graph-node
|
||||
.iconPath=${mdiCallMissed}
|
||||
?track=${trackElse}
|
||||
?active=${this.selected === path}
|
||||
.notEnabled=${disabled || config.enabled === false}
|
||||
nofocus
|
||||
></hat-graph-node
|
||||
>${ensureArray<Action>(config.else).map((action, j) =>
|
||||
this._renderActionNode(
|
||||
action,
|
||||
`${path}/else/${j}`,
|
||||
false,
|
||||
disabled || config.enabled === false
|
||||
)
|
||||
@focus=${this._selectNode(
|
||||
branch.option,
|
||||
branch.path,
|
||||
"chooseOption"
|
||||
)}
|
||||
</div>`
|
||||
: html`<hat-graph-spacer ?track=${trackElse}></hat-graph-spacer>`
|
||||
}
|
||||
<div class="graph-container" ?track=${trackThen}>
|
||||
<hat-graph-node
|
||||
.iconPath=${mdiCallReceived}
|
||||
?track=${trackThen}
|
||||
?active=${this.selected === path}
|
||||
.notEnabled=${disabled || config.enabled === false}
|
||||
nofocus
|
||||
></hat-graph-node>
|
||||
${ensureArray<Action>(config.then ?? []).map((action, j) =>
|
||||
this._renderActionNode(
|
||||
action,
|
||||
`${path}/then/${j}`,
|
||||
false,
|
||||
disabled || config.enabled === false
|
||||
)
|
||||
?track=${branch.hasTrace}
|
||||
?active=${this.selected === branch.path}
|
||||
.notEnabled=${branch.disabled}
|
||||
></hat-graph-node>
|
||||
${branch.children.map((action) => this._renderActionNode(action))}
|
||||
</div>
|
||||
`
|
||||
)}
|
||||
<div ?track=${defaultBranch.hasTrace}>
|
||||
<hat-graph-spacer ?track=${defaultBranch.hasTrace}></hat-graph-spacer>
|
||||
${defaultBranch.children.map((action) =>
|
||||
this._renderActionNode(action)
|
||||
)}
|
||||
</div>
|
||||
</hat-graph-branch>
|
||||
`;
|
||||
}
|
||||
|
||||
private _renderIfNode(node: TraceActionNode<IfAction>, graphStart = false) {
|
||||
const { config, path, track } = node;
|
||||
const [thenBranch, elseBranch] = node.branches;
|
||||
return html`
|
||||
<hat-graph-branch
|
||||
tabindex=${node.hasTrace ? "0" : "-1"}
|
||||
@focus=${this._selectNode(config, path, "action")}
|
||||
?track=${track}
|
||||
?active=${this.selected === path}
|
||||
.notEnabled=${node.disabled}
|
||||
>
|
||||
<hat-graph-node
|
||||
.graphStart=${graphStart}
|
||||
.iconPath=${mdiCallSplit}
|
||||
?track=${track}
|
||||
?active=${this.selected === path}
|
||||
.notEnabled=${node.disabled}
|
||||
slot="head"
|
||||
nofocus
|
||||
></hat-graph-node>
|
||||
${
|
||||
config.else
|
||||
? html`<div class="graph-container" ?track=${elseBranch.hasTrace}>
|
||||
<hat-graph-node
|
||||
.iconPath=${mdiCallMissed}
|
||||
?track=${elseBranch.hasTrace}
|
||||
?active=${this.selected === path}
|
||||
.notEnabled=${elseBranch.disabled}
|
||||
nofocus
|
||||
></hat-graph-node
|
||||
>${elseBranch.children.map((action) =>
|
||||
this._renderActionNode(action)
|
||||
)}
|
||||
</div>`
|
||||
: html`<hat-graph-spacer
|
||||
?track=${elseBranch.hasTrace}
|
||||
></hat-graph-spacer>`
|
||||
}
|
||||
<div class="graph-container" ?track=${thenBranch.hasTrace}>
|
||||
<hat-graph-node
|
||||
.iconPath=${mdiCallReceived}
|
||||
?track=${thenBranch.hasTrace}
|
||||
?active=${this.selected === path}
|
||||
.notEnabled=${thenBranch.disabled}
|
||||
nofocus
|
||||
></hat-graph-node>
|
||||
${thenBranch.children.map((action) => this._renderActionNode(action))}
|
||||
</div>
|
||||
</hat-graph-branch>
|
||||
`;
|
||||
}
|
||||
|
||||
private _renderConditionNode(
|
||||
node: Condition,
|
||||
path: string,
|
||||
graphStart = false,
|
||||
disabled = false
|
||||
model: TraceNode<Condition>,
|
||||
graphStart = false
|
||||
) {
|
||||
const trace = this.trace.trace[path] as ConditionTraceStep[] | undefined;
|
||||
let track = false;
|
||||
let trackPass = false;
|
||||
let trackFailed = false;
|
||||
if (trace) {
|
||||
for (const trc of trace) {
|
||||
if (trc.result) {
|
||||
track = true;
|
||||
if (trc.result.result) {
|
||||
trackPass = true;
|
||||
} else {
|
||||
trackFailed = true;
|
||||
}
|
||||
}
|
||||
if (trackPass && trackFailed) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
const { config: node, path, track, hasTrace } = model;
|
||||
const passed = model.condition?.passed ?? false;
|
||||
const failed = model.condition?.failed ?? false;
|
||||
return html`
|
||||
<hat-graph-branch
|
||||
@focus=${this._selectNode(node, path, "condition")}
|
||||
?track=${track}
|
||||
?active=${this.selected === path}
|
||||
.notEnabled=${disabled || node.enabled === false}
|
||||
tabindex=${trace === undefined ? "-1" : "0"}
|
||||
.notEnabled=${model.disabled}
|
||||
tabindex=${hasTrace ? "0" : "-1"}
|
||||
short
|
||||
>
|
||||
<hat-graph-node
|
||||
@@ -378,7 +264,7 @@ export class HatScriptGraph extends LitElement {
|
||||
slot="head"
|
||||
?track=${track}
|
||||
?active=${this.selected === path}
|
||||
.notEnabled=${disabled || node.enabled === false}
|
||||
.notEnabled=${model.disabled}
|
||||
.iconPath=${mdiAbTesting}
|
||||
nofocus
|
||||
></hat-graph-node>
|
||||
@@ -387,81 +273,71 @@ export class HatScriptGraph extends LitElement {
|
||||
graph-start
|
||||
graph-end
|
||||
></div>
|
||||
<div ?track=${trackPass}></div>
|
||||
<div ?track=${passed}></div>
|
||||
<hat-graph-node
|
||||
.iconPath=${mdiClose}
|
||||
nofocus
|
||||
?track=${trackFailed}
|
||||
?track=${failed}
|
||||
?active=${this.selected === path}
|
||||
.notEnabled=${disabled || node.enabled === false}
|
||||
.notEnabled=${model.disabled}
|
||||
></hat-graph-node>
|
||||
</hat-graph-branch>
|
||||
`;
|
||||
}
|
||||
|
||||
private _renderRepeatNode(
|
||||
node: RepeatAction,
|
||||
path: string,
|
||||
graphStart = false,
|
||||
disabled = false
|
||||
model: TraceActionNode<RepeatAction>,
|
||||
graphStart = false
|
||||
) {
|
||||
const trace: any = this.trace.trace[path];
|
||||
const repeats = this.trace?.trace[`${path}/repeat/sequence/0`]?.length;
|
||||
const { config: node, path, track } = model;
|
||||
const [branch] = model.branches;
|
||||
return html`
|
||||
<hat-graph-branch
|
||||
tabindex=${trace === undefined ? "-1" : "0"}
|
||||
tabindex=${model.hasTrace ? "0" : "-1"}
|
||||
@focus=${this._selectNode(node, path, "action")}
|
||||
?track=${path in this.trace.trace}
|
||||
?track=${track}
|
||||
?active=${this.selected === path}
|
||||
.notEnabled=${disabled || node.enabled === false}
|
||||
.notEnabled=${model.disabled}
|
||||
>
|
||||
<hat-graph-node
|
||||
.graphStart=${graphStart}
|
||||
.iconPath=${mdiRefresh}
|
||||
?track=${path in this.trace.trace}
|
||||
?track=${track}
|
||||
?active=${this.selected === path}
|
||||
.notEnabled=${disabled || node.enabled === false}
|
||||
.notEnabled=${model.disabled}
|
||||
slot="head"
|
||||
nofocus
|
||||
></hat-graph-node>
|
||||
<hat-graph-node
|
||||
.iconPath=${mdiArrowUp}
|
||||
?track=${repeats > 1}
|
||||
?track=${model.badge !== undefined}
|
||||
?active=${this.selected === path}
|
||||
.notEnabled=${disabled || node.enabled === false}
|
||||
.notEnabled=${model.disabled}
|
||||
nofocus
|
||||
.badge=${repeats > 1 ? repeats : undefined}
|
||||
.badge=${model.badge}
|
||||
></hat-graph-node>
|
||||
<div ?track=${trace}>
|
||||
${ensureArray<Action>(node.repeat.sequence).map((action, i) =>
|
||||
this._renderActionNode(
|
||||
action,
|
||||
`${path}/repeat/sequence/${i}`,
|
||||
false,
|
||||
disabled || node.enabled === false
|
||||
)
|
||||
)}
|
||||
<div ?track=${model.hasTrace}>
|
||||
${branch.children.map((action) => this._renderActionNode(action))}
|
||||
</div>
|
||||
</hat-graph-branch>
|
||||
`;
|
||||
}
|
||||
|
||||
private _renderServiceNode(
|
||||
node: ServiceAction,
|
||||
path: string,
|
||||
graphStart = false,
|
||||
disabled = false
|
||||
model: TraceActionNode<ServiceAction>,
|
||||
graphStart = false
|
||||
) {
|
||||
const { config: node, path, track } = model;
|
||||
return html`
|
||||
<hat-graph-node
|
||||
.graphStart=${graphStart}
|
||||
.iconPath=${node.action ? undefined : mdiRoomService}
|
||||
@focus=${this._selectNode(node, path, "action")}
|
||||
?track=${path in this.trace.trace}
|
||||
?track=${track}
|
||||
?active=${this.selected === path}
|
||||
.notEnabled=${disabled || node.enabled === false}
|
||||
.error=${this.trace.trace[path]?.some((tr) => tr.error)}
|
||||
tabindex=${this.trace && path in this.trace.trace ? "0" : "-1"}
|
||||
.notEnabled=${model.disabled}
|
||||
.error=${model.error}
|
||||
tabindex=${model.hasTrace ? "0" : "-1"}
|
||||
>
|
||||
${
|
||||
node.action
|
||||
@@ -476,145 +352,110 @@ export class HatScriptGraph extends LitElement {
|
||||
}
|
||||
|
||||
private _renderWaitNode(
|
||||
node: WaitAction | WaitForTriggerAction,
|
||||
path: string,
|
||||
graphStart = false,
|
||||
disabled = false
|
||||
model: TraceActionNode<WaitAction | WaitForTriggerAction>,
|
||||
graphStart = false
|
||||
) {
|
||||
const { config: node, path, track } = model;
|
||||
return html`
|
||||
<hat-graph-node
|
||||
.graphStart=${graphStart}
|
||||
.iconPath=${mdiCodeBraces}
|
||||
@focus=${this._selectNode(node, path, "action")}
|
||||
?track=${path in this.trace.trace}
|
||||
?track=${track}
|
||||
?active=${this.selected === path}
|
||||
.notEnabled=${disabled || node.enabled === false}
|
||||
.error=${this.trace.trace[path]?.some((tr) => tr.error)}
|
||||
tabindex=${this.trace && path in this.trace.trace ? "0" : "-1"}
|
||||
.notEnabled=${model.disabled}
|
||||
.error=${model.error}
|
||||
tabindex=${model.hasTrace ? "0" : "-1"}
|
||||
></hat-graph-node>
|
||||
`;
|
||||
}
|
||||
|
||||
private _renderSequenceNode(
|
||||
node: SequenceAction,
|
||||
path: string,
|
||||
graphStart = false,
|
||||
disabled = false
|
||||
model: TraceActionNode<SequenceAction>,
|
||||
graphStart = false
|
||||
) {
|
||||
const trace: any = this.trace.trace[path];
|
||||
const { config: node, path, track } = model;
|
||||
const [branch] = model.branches;
|
||||
return html`
|
||||
<hat-graph-branch
|
||||
tabindex=${trace === undefined ? "-1" : "0"}
|
||||
tabindex=${model.hasTrace ? "0" : "-1"}
|
||||
@focus=${this._selectNode(node, path, "action")}
|
||||
?track=${path in this.trace.trace}
|
||||
?track=${track}
|
||||
?active=${this.selected === path}
|
||||
.notEnabled=${disabled || node.enabled === false}
|
||||
.notEnabled=${model.disabled}
|
||||
>
|
||||
<div class="graph-container" ?track=${path in this.trace.trace}>
|
||||
<div class="graph-container" ?track=${branch.hasTrace}>
|
||||
<hat-graph-node
|
||||
.graphStart=${graphStart}
|
||||
.iconPath=${mdiFormatListNumbered}
|
||||
?track=${path in this.trace.trace}
|
||||
?track=${track}
|
||||
?active=${this.selected === path}
|
||||
.notEnabled=${disabled || node.enabled === false}
|
||||
.notEnabled=${model.disabled}
|
||||
slot="head"
|
||||
nofocus
|
||||
></hat-graph-node>
|
||||
${ensureArray(node.sequence ?? []).map((action, i) =>
|
||||
this._renderActionNode(
|
||||
action,
|
||||
`${path}/sequence/${i}`,
|
||||
false,
|
||||
disabled || node.enabled === false
|
||||
)
|
||||
)}
|
||||
${branch.children.map((action) => this._renderActionNode(action))}
|
||||
</div>
|
||||
</hat-graph-branch>
|
||||
`;
|
||||
}
|
||||
|
||||
private _renderParallelNode(
|
||||
node: ParallelAction,
|
||||
path: string,
|
||||
graphStart = false,
|
||||
disabled = false
|
||||
model: TraceActionNode<ParallelAction>,
|
||||
graphStart = false
|
||||
) {
|
||||
const trace: any = this.trace.trace[path];
|
||||
const { config: node, path, track } = model;
|
||||
return html`
|
||||
<hat-graph-branch
|
||||
tabindex=${trace === undefined ? "-1" : "0"}
|
||||
tabindex=${model.hasTrace ? "0" : "-1"}
|
||||
@focus=${this._selectNode(node, path, "action")}
|
||||
?track=${path in this.trace.trace}
|
||||
?track=${track}
|
||||
?active=${this.selected === path}
|
||||
.notEnabled=${disabled || node.enabled === false}
|
||||
.notEnabled=${model.disabled}
|
||||
>
|
||||
<hat-graph-node
|
||||
.graphStart=${graphStart}
|
||||
.iconPath=${mdiShuffleDisabled}
|
||||
?track=${path in this.trace.trace}
|
||||
?track=${track}
|
||||
?active=${this.selected === path}
|
||||
.notEnabled=${disabled || node.enabled === false}
|
||||
.notEnabled=${model.disabled}
|
||||
slot="head"
|
||||
nofocus
|
||||
></hat-graph-node>
|
||||
${ensureArray<Action>(node.parallel).map((action, i) =>
|
||||
"sequence" in action
|
||||
? html`<div ?track=${path in this.trace.trace}>
|
||||
${ensureArray<Action>(
|
||||
(action as ManualScriptConfig).sequence
|
||||
).map((sAction, j) =>
|
||||
this._renderActionNode(
|
||||
sAction,
|
||||
`${path}/parallel/${i}/sequence/${j}`,
|
||||
false,
|
||||
disabled || node.enabled === false
|
||||
)
|
||||
)}
|
||||
</div>`
|
||||
: this._renderActionNode(
|
||||
action,
|
||||
`${path}/parallel/${i}/sequence/0`,
|
||||
false,
|
||||
disabled || node.enabled === false
|
||||
)
|
||||
${model.branches.map(
|
||||
(branch) =>
|
||||
html`<div ?track=${branch.hasTrace}>
|
||||
${branch.children.map((sAction) =>
|
||||
this._renderActionNode(sAction)
|
||||
)}
|
||||
</div>`
|
||||
)}
|
||||
</hat-graph-branch>
|
||||
`;
|
||||
}
|
||||
|
||||
private _renderOtherNode(
|
||||
node: Action,
|
||||
path: string,
|
||||
graphStart = false,
|
||||
disabled = false
|
||||
) {
|
||||
private _renderOtherNode(model: TraceActionNode, graphStart = false) {
|
||||
const { config: node, path, track } = model;
|
||||
return html`
|
||||
<hat-graph-node
|
||||
.graphStart=${graphStart}
|
||||
.iconPath=${ACTION_ICONS[getActionType(node)] || mdiCodeBrackets}
|
||||
@focus=${this._selectNode(node, path, "action")}
|
||||
?track=${path in this.trace.trace}
|
||||
?track=${track}
|
||||
?active=${this.selected === path}
|
||||
.error=${this.trace.trace[path]?.some((tr) => tr.error)}
|
||||
.notEnabled=${disabled || node.enabled === false}
|
||||
.error=${model.error}
|
||||
.notEnabled=${model.disabled}
|
||||
></hat-graph-node>
|
||||
`;
|
||||
}
|
||||
|
||||
protected render() {
|
||||
const triggerKey = "triggers" in this.trace.config ? "triggers" : "trigger";
|
||||
const conditionKey =
|
||||
"conditions" in this.trace.config ? "conditions" : "condition";
|
||||
const actionKey = "actions" in this.trace.config ? "actions" : "action";
|
||||
|
||||
const paths = Object.keys(this.trackedNodes);
|
||||
const triggerNodes =
|
||||
triggerKey in this.trace.config
|
||||
? flattenTriggers(ensureArray(this.trace.config[triggerKey])).map(
|
||||
(trigger, i) => this._renderTrigger(trigger, i)
|
||||
)
|
||||
: undefined;
|
||||
try {
|
||||
const tree = this._buildTree(this.trace);
|
||||
const paths = tree.trackedPaths;
|
||||
const triggerNodes = tree.triggers?.map((node) =>
|
||||
this._renderTrigger(node)
|
||||
);
|
||||
return html`
|
||||
<div class="graph-scroll ha-scrollbar">
|
||||
<div class="parent graph-container">
|
||||
@@ -628,37 +469,26 @@ export class HatScriptGraph extends LitElement {
|
||||
</hat-graph-branch>`
|
||||
: ""
|
||||
}
|
||||
${
|
||||
conditionKey in this.trace.config
|
||||
? html`${ensureArray(this.trace.config[conditionKey])?.map(
|
||||
(condition, i) => this._renderCondition(condition, i)
|
||||
)}`
|
||||
: ""
|
||||
}
|
||||
${
|
||||
actionKey in this.trace.config
|
||||
? html`${ensureArray(this.trace.config[actionKey]).map(
|
||||
(action, i) => this._renderActionNode(action, `action/${i}`)
|
||||
)}`
|
||||
: ""
|
||||
}
|
||||
${
|
||||
"sequence" in this.trace.config
|
||||
? html`${ensureArray<Action>(this.trace.config.sequence).map(
|
||||
(action, i) =>
|
||||
this._renderActionNode(action, `sequence/${i}`, i === 0)
|
||||
)}`
|
||||
: ""
|
||||
}
|
||||
${tree.conditions.map((node) => this._renderConditionNode(node))}
|
||||
${tree.actions.map((node) => this._renderActionNode(node))}
|
||||
${tree.sequence.map((node, i) =>
|
||||
this._renderActionNode(node, i === 0)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<ha-icon-button
|
||||
label=${this._localize(
|
||||
"ui.panel.config.automation.trace.previous_tracked_node"
|
||||
)}
|
||||
.disabled=${paths.length === 0 || paths[0] === this.selected}
|
||||
@click=${this._previousTrackedNode}
|
||||
.path=${mdiChevronUp}
|
||||
></ha-icon-button>
|
||||
<ha-icon-button
|
||||
label=${this._localize(
|
||||
"ui.panel.config.automation.trace.next_tracked_node"
|
||||
)}
|
||||
.disabled=${
|
||||
paths.length === 0 || paths[paths.length - 1] === this.selected
|
||||
}
|
||||
@@ -681,14 +511,6 @@ export class HatScriptGraph extends LitElement {
|
||||
}
|
||||
}
|
||||
|
||||
public willUpdate(changedProps: PropertyValues<this>) {
|
||||
super.willUpdate(changedProps);
|
||||
if (changedProps.has("trace")) {
|
||||
this.renderedNodes = {};
|
||||
this.trackedNodes = {};
|
||||
}
|
||||
}
|
||||
|
||||
protected updated(changedProps: PropertyValues<this>) {
|
||||
super.updated(changedProps);
|
||||
|
||||
@@ -709,52 +531,26 @@ export class HatScriptGraph extends LitElement {
|
||||
}
|
||||
|
||||
// If trace changed and we have no or an invalid selection, select first option.
|
||||
if (!this.selected || !(this.selected in this.trackedNodes)) {
|
||||
const firstNode = this.trackedNodes[Object.keys(this.trackedNodes)[0]];
|
||||
const tree = this._buildTree(this.trace);
|
||||
if (!this.selected || !tree.trackedNodes[this.selected]) {
|
||||
const firstNode = tree.firstTracked;
|
||||
if (firstNode) {
|
||||
fireEvent(this, "graph-node-selected", firstNode);
|
||||
}
|
||||
}
|
||||
|
||||
if (this.trace) {
|
||||
const sortKeys = Object.keys(this.trace.trace);
|
||||
const keys = Object.keys(this.renderedNodes).sort(
|
||||
(a, b) => sortKeys.indexOf(a) - sortKeys.indexOf(b)
|
||||
);
|
||||
const sortedTrackedNodes = {};
|
||||
const sortedRenderedNodes = {};
|
||||
for (const key of keys) {
|
||||
sortedRenderedNodes[key] = this.renderedNodes[key];
|
||||
if (key in this.trackedNodes) {
|
||||
sortedTrackedNodes[key] = this.trackedNodes[key];
|
||||
}
|
||||
}
|
||||
this.renderedNodes = sortedRenderedNodes;
|
||||
this.trackedNodes = sortedTrackedNodes;
|
||||
}
|
||||
}
|
||||
|
||||
private _previousTrackedNode() {
|
||||
const nodes = Object.keys(this.trackedNodes);
|
||||
const prevIndex = nodes.indexOf(this.selected!) - 1;
|
||||
if (prevIndex >= 0) {
|
||||
fireEvent(
|
||||
this,
|
||||
"graph-node-selected",
|
||||
this.trackedNodes[nodes[prevIndex]]
|
||||
);
|
||||
const prev = this._buildTree(this.trace).previousTracked(this.selected!);
|
||||
if (prev) {
|
||||
fireEvent(this, "graph-node-selected", prev);
|
||||
}
|
||||
}
|
||||
|
||||
private _nextTrackedNode() {
|
||||
const nodes = Object.keys(this.trackedNodes);
|
||||
const nextIndex = nodes.indexOf(this.selected!) + 1;
|
||||
if (nextIndex < nodes.length) {
|
||||
fireEvent(
|
||||
this,
|
||||
"graph-node-selected",
|
||||
this.trackedNodes[nodes[nextIndex]]
|
||||
);
|
||||
const next = this._buildTree(this.trace).nextTracked(this.selected!);
|
||||
if (next) {
|
||||
fireEvent(this, "graph-node-selected", next);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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",
|
||||
]);
|
||||
|
||||
+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[]
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,604 @@
|
||||
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;
|
||||
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" &&
|
||||
["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;
|
||||
}
|
||||
}
|
||||
@@ -60,6 +60,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 +86,10 @@ export type ActionTraceStep =
|
||||
| ConditionTraceStep
|
||||
| CallServiceActionTraceStep
|
||||
| ChooseActionTraceStep
|
||||
| IfActionTraceStep
|
||||
| StopActionTraceStep
|
||||
| WaitActionTraceStep
|
||||
| DelayActionTraceStep
|
||||
| ChooseChoiceActionTraceStep;
|
||||
|
||||
interface BaseTrace {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -877,19 +877,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;
|
||||
|
||||
@@ -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>(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -8,8 +8,6 @@ import { customElement, property, state } from "lit/decorators";
|
||||
import { until } from "lit/directives/until";
|
||||
import memoizeOne from "memoize-one";
|
||||
import { isComponentLoaded } from "../../../common/config/is_component_loaded";
|
||||
import type { HASSDomCurrentTargetEvent } from "../../../common/dom/fire_event";
|
||||
import { computeDeviceNameDisplay } from "../../../common/entity/compute_device_name";
|
||||
import { computeDomain } from "../../../common/entity/compute_domain";
|
||||
import { computeObjectId } from "../../../common/entity/compute_object_id";
|
||||
import { supportsFeature } from "../../../common/entity/supports-feature";
|
||||
@@ -27,8 +25,6 @@ import "../../../components/ha-area-picker";
|
||||
import "../../../components/ha-color-picker";
|
||||
import "../../../components/ha-dropdown-item";
|
||||
import "../../../components/ha-icon";
|
||||
import "../../../components/ha-icon-button";
|
||||
import "../../../components/ha-icon-button-next";
|
||||
import "../../../components/ha-icon-picker";
|
||||
import "../../../components/ha-labels-picker";
|
||||
import "../../../components/ha-select";
|
||||
@@ -198,8 +194,6 @@ export class EntityRegistrySettingsEditor extends LitElement {
|
||||
|
||||
@state() private _name!: string;
|
||||
|
||||
@state() private _useDeviceName = false;
|
||||
|
||||
@state() private _icon!: string;
|
||||
|
||||
@state() private _entityId!: EntitySettingsState["entityId"];
|
||||
@@ -261,9 +255,6 @@ export class EntityRegistrySettingsEditor extends LitElement {
|
||||
|
||||
protected willUpdate(changedProperties: PropertyValues<this>) {
|
||||
super.willUpdate(changedProperties);
|
||||
this._device = this.entry.device_id
|
||||
? this.hass.devices[this.entry.device_id]
|
||||
: undefined;
|
||||
if (
|
||||
!changedProperties.has("entry") ||
|
||||
changedProperties.get("entry")?.id === this.entry.id
|
||||
@@ -271,9 +262,7 @@ export class EntityRegistrySettingsEditor extends LitElement {
|
||||
return;
|
||||
}
|
||||
|
||||
this._name = this.entry.name || this._originalName;
|
||||
this._useDeviceName =
|
||||
!!this._device && !(this.entry.name ?? this._originalName);
|
||||
this._name = this.entry.name || "";
|
||||
this._icon = this.entry.icon || "";
|
||||
this._deviceClass =
|
||||
this.entry.device_class || this.entry.original_device_class;
|
||||
@@ -283,6 +272,9 @@ export class EntityRegistrySettingsEditor extends LitElement {
|
||||
this._entityId = this.entry.entity_id;
|
||||
this._disabledBy = this.entry.disabled_by;
|
||||
this._hiddenBy = this.entry.hidden_by;
|
||||
this._device = this.entry.device_id
|
||||
? this.hass.devices[this.entry.device_id]
|
||||
: undefined;
|
||||
this._switchAsInvert = this.entry.options?.switch_as_x?.invert === true;
|
||||
|
||||
const domain = computeDomain(this.entry.entity_id);
|
||||
@@ -395,7 +387,7 @@ export class EntityRegistrySettingsEditor extends LitElement {
|
||||
|
||||
this._dirtyState?.setState(
|
||||
{
|
||||
name: this._computeName(),
|
||||
name: this._name || null,
|
||||
icon: this._icon || null,
|
||||
entityId: this._entityId,
|
||||
areaId: this._areaId ?? null,
|
||||
@@ -473,48 +465,10 @@ export class EntityRegistrySettingsEditor extends LitElement {
|
||||
|
||||
const defaultPrecision =
|
||||
this.entry.options?.sensor?.suggested_display_precision ?? undefined;
|
||||
const defaultName = this._originalName;
|
||||
|
||||
return html`
|
||||
${
|
||||
!this.hideName && this._device
|
||||
? html`<ha-row-item>
|
||||
<span slot="headline"
|
||||
>${this.hass.localize(
|
||||
"ui.dialogs.entity_registry.editor.use_device_name"
|
||||
)}
|
||||
(${computeDeviceNameDisplay(
|
||||
this._device,
|
||||
this.hass.localize,
|
||||
this.hass.states
|
||||
)})</span
|
||||
>
|
||||
<span slot="supporting-text"
|
||||
>${this.hass.localize(
|
||||
"ui.dialogs.entity_registry.editor.change_device_settings",
|
||||
{
|
||||
link: html`<button
|
||||
class="link"
|
||||
@click=${this._openDeviceSettings}
|
||||
>
|
||||
${this.hass.localize(
|
||||
"ui.dialogs.entity_registry.editor.change_device_name_link"
|
||||
)}
|
||||
</button>`,
|
||||
}
|
||||
)}</span
|
||||
>
|
||||
<ha-switch
|
||||
slot="end"
|
||||
.checked=${this._useDeviceName}
|
||||
.disabled=${this.disabled}
|
||||
@change=${this._useDeviceNameChanged}
|
||||
></ha-switch>
|
||||
</ha-row-item>`
|
||||
: nothing
|
||||
}
|
||||
${
|
||||
this.hideName || (this._device && this._useDeviceName)
|
||||
this.hideName
|
||||
? nothing
|
||||
: html`<ha-input
|
||||
inset-label
|
||||
@@ -527,16 +481,22 @@ export class EntityRegistrySettingsEditor extends LitElement {
|
||||
@input=${this._nameChanged}
|
||||
>
|
||||
${
|
||||
this._name !== defaultName
|
||||
? html`<ha-icon-button
|
||||
slot="end"
|
||||
.path=${mdiRestore}
|
||||
.label=${this.hass.localize(
|
||||
"ui.dialogs.entity_registry.editor.restore_name"
|
||||
)}
|
||||
.disabled=${this.disabled}
|
||||
@click=${this._restoreName}
|
||||
></ha-icon-button>`
|
||||
this._device
|
||||
? html`<span slot="hint"
|
||||
>${this.hass.localize(
|
||||
"ui.dialogs.entity_registry.editor.device_name_tip",
|
||||
{
|
||||
link: html`<button
|
||||
class="link"
|
||||
@click=${this._resetNameAndOpenDeviceSettings}
|
||||
>
|
||||
${this.hass.localize(
|
||||
"ui.dialogs.entity_registry.editor.open_device_settings"
|
||||
)}
|
||||
</button>`,
|
||||
}
|
||||
)}</span
|
||||
>`
|
||||
: nothing
|
||||
}
|
||||
</ha-input>`
|
||||
@@ -1280,7 +1240,7 @@ export class EntityRegistrySettingsEditor extends LitElement {
|
||||
}
|
||||
|
||||
const params: Partial<EntityRegistryEntryUpdateParams> = {
|
||||
name: this._computeName(),
|
||||
name: this._name.trim() || null,
|
||||
icon: this._icon.trim() || null,
|
||||
area_id: this._areaId || null,
|
||||
labels: this._labels || [],
|
||||
@@ -1727,30 +1687,9 @@ export class EntityRegistrySettingsEditor extends LitElement {
|
||||
}
|
||||
}
|
||||
|
||||
private _useDeviceNameChanged(ev: HASSDomCurrentTargetEvent<HaSwitch>): void {
|
||||
this._useDeviceName = ev.currentTarget.checked;
|
||||
}
|
||||
|
||||
private get _originalName(): string {
|
||||
return String(this.entry.original_name ?? "");
|
||||
}
|
||||
|
||||
private _restoreName(): void {
|
||||
this._name = this._originalName;
|
||||
if (this._device && !this._originalName) {
|
||||
this._useDeviceName = true;
|
||||
}
|
||||
}
|
||||
|
||||
private _computeName(): string | null {
|
||||
if (this.hideName) {
|
||||
return this.entry.name;
|
||||
}
|
||||
if (this._device && this._useDeviceName) {
|
||||
return this._originalName ? "" : null;
|
||||
}
|
||||
const name = this._name.trim();
|
||||
return name && name !== this._originalName ? name : null;
|
||||
private _resetNameAndOpenDeviceSettings() {
|
||||
this._name = this.entry.name || "";
|
||||
this._openDeviceSettings();
|
||||
}
|
||||
|
||||
private _openDeviceSettings() {
|
||||
@@ -1861,7 +1800,6 @@ export class EntityRegistrySettingsEditor extends LitElement {
|
||||
|
||||
ha-input.name {
|
||||
--ha-input-start-max-width: 35%;
|
||||
--ha-input-padding-bottom: 0;
|
||||
}
|
||||
ha-input.entityId ha-icon-button:last-child {
|
||||
margin-inline-start: 0;
|
||||
|
||||
@@ -85,7 +85,6 @@ import type {
|
||||
} from "../../../data/entity/entity_registry";
|
||||
import {
|
||||
entityRegistryByEntityId,
|
||||
subscribeEntityRegistry,
|
||||
updateEntityRegistryEntry,
|
||||
} from "../../../data/entity/entity_registry";
|
||||
import { fetchEntitySourcesWithCache } from "../../../data/entity/entity_sources";
|
||||
@@ -168,18 +167,6 @@ interface HelperItem {
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
// This groups items by a key but only returns last entry per key.
|
||||
const groupByOne = <T>(
|
||||
items: T[],
|
||||
keySelector: (item: T) => string
|
||||
): Record<string, T> => {
|
||||
const result: Record<string, T> = {};
|
||||
for (const item of items) {
|
||||
result[keySelector(item)] = item;
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
const getConfigEntry = (
|
||||
entityEntries: Record<string, EntityRegistryEntry>,
|
||||
configEntries: Record<string, ConfigEntry>,
|
||||
@@ -241,8 +228,6 @@ export class HaConfigHelpers extends SubscribeMixin(LitElement) {
|
||||
|
||||
@state() private _disabledEntityEntries?: EntityRegistryEntry[];
|
||||
|
||||
@state() private _entityEntries?: Record<string, EntityRegistryEntry>;
|
||||
|
||||
@state() private _configEntries?: Record<string, ConfigEntry>;
|
||||
|
||||
@state() private _entitySource?: Record<string, string>;
|
||||
@@ -281,7 +266,7 @@ export class HaConfigHelpers extends SubscribeMixin(LitElement) {
|
||||
|
||||
@state()
|
||||
@consume({ context: fullEntitiesContext, subscribe: true })
|
||||
_entityReg: EntityRegistryEntry[] = [];
|
||||
_entityReg?: EntityRegistryEntry[];
|
||||
|
||||
@state() private _filteredHelperEntityIds?: string[] | null;
|
||||
|
||||
@@ -328,9 +313,6 @@ export class HaConfigHelpers extends SubscribeMixin(LitElement) {
|
||||
},
|
||||
{ type: ["helper"] }
|
||||
),
|
||||
subscribeEntityRegistry(this.hass.connection!, (entries) => {
|
||||
this._entityEntries = groupByOne(entries, (entry) => entry.entity_id);
|
||||
}),
|
||||
subscribeCategoryRegistry(
|
||||
this.hass.connection,
|
||||
"helpers",
|
||||
@@ -481,12 +463,34 @@ export class HaConfigHelpers extends SubscribeMixin(LitElement) {
|
||||
})
|
||||
);
|
||||
|
||||
private _helperEntityIds = memoizeOne(
|
||||
(
|
||||
entityReg: EntityRegistryEntry[],
|
||||
entitySource: Record<string, string>,
|
||||
helperManifests: Record<string, IntegrationManifest>
|
||||
) => {
|
||||
const entityIds = new Set<string>();
|
||||
//Entity registry entities have their source in the registry.
|
||||
for (const entry of entityReg) {
|
||||
if (entry.platform in helperManifests) {
|
||||
entityIds.add(entry.entity_id);
|
||||
}
|
||||
}
|
||||
|
||||
//Entities without registry get their source from fetchEntitySources
|
||||
for (const entityId of Object.keys(entitySource)) {
|
||||
entityIds.add(entityId);
|
||||
}
|
||||
|
||||
return entityIds;
|
||||
}
|
||||
);
|
||||
|
||||
private _getItems = memoizeOne(
|
||||
(
|
||||
localize: LocalizeFunc,
|
||||
stateItems: LimitedEntity[],
|
||||
disabledEntries: EntityRegistryEntry[],
|
||||
entityEntries: Record<string, EntityRegistryEntry>,
|
||||
configEntries: Record<string, ConfigEntry>,
|
||||
entityReg: EntityRegistryEntry[],
|
||||
categoryReg?: CategoryRegistryEntry[],
|
||||
@@ -501,7 +505,7 @@ export class HaConfigHelpers extends SubscribeMixin(LitElement) {
|
||||
|
||||
const states = stateItems.map((entityState) => {
|
||||
const configEntry = getConfigEntry(
|
||||
entityEntries,
|
||||
entityRegistryByEntityId(entityReg),
|
||||
configEntries,
|
||||
entityState.entity_id
|
||||
);
|
||||
@@ -518,7 +522,9 @@ export class HaConfigHelpers extends SubscribeMixin(LitElement) {
|
||||
configEntry !== undefined || entityState.attributes.editable,
|
||||
type: configEntry
|
||||
? configEntry.domain
|
||||
: this._entitySource![entityState.entity_id] ||
|
||||
: entityRegistryByEntityId(entityReg)[entityState.entity_id]
|
||||
?.platform ||
|
||||
this._entitySource![entityState.entity_id] ||
|
||||
computeDomain(entityState.entity_id),
|
||||
configEntry,
|
||||
entity: entityState,
|
||||
@@ -527,7 +533,7 @@ export class HaConfigHelpers extends SubscribeMixin(LitElement) {
|
||||
|
||||
const entries = Object.values(configEntriesCopy)
|
||||
.map((configEntry) => {
|
||||
const entityEntry = Object.values(entityEntries).find(
|
||||
const entityEntry = entityReg.find(
|
||||
(entry) => entry.config_entry_id === configEntry.entry_id
|
||||
);
|
||||
return {
|
||||
@@ -610,7 +616,7 @@ export class HaConfigHelpers extends SubscribeMixin(LitElement) {
|
||||
private _labelsForEntity(entityId: string): string[] {
|
||||
return (
|
||||
this.hass.entities[entityId]?.labels ||
|
||||
entityRegistryByEntityId(this._entityReg)[entityId]?.labels ||
|
||||
entityRegistryByEntityId(this._entityReg || [])[entityId]?.labels ||
|
||||
[]
|
||||
);
|
||||
}
|
||||
@@ -619,7 +625,7 @@ export class HaConfigHelpers extends SubscribeMixin(LitElement) {
|
||||
if (
|
||||
!this.hass ||
|
||||
this._helperEntities === undefined ||
|
||||
this._entityEntries === undefined ||
|
||||
this._entityReg === undefined ||
|
||||
this._configEntries === undefined
|
||||
) {
|
||||
return html`<hass-loading-screen></hass-loading-screen>`;
|
||||
@@ -632,7 +638,6 @@ export class HaConfigHelpers extends SubscribeMixin(LitElement) {
|
||||
this.hass.localize,
|
||||
this._helperEntities,
|
||||
this._disabledEntityEntries || [],
|
||||
this._entityEntries,
|
||||
this._configEntries,
|
||||
this._entityReg,
|
||||
this._categories,
|
||||
@@ -888,7 +893,7 @@ export class HaConfigHelpers extends SubscribeMixin(LitElement) {
|
||||
const labelItems = new Set<string>();
|
||||
this._helperEntities
|
||||
.filter((stateItem) =>
|
||||
entityRegistryByEntityId(this._entityReg)[
|
||||
entityRegistryByEntityId(this._entityReg || [])[
|
||||
stateItem.entity_id
|
||||
]?.labels.some((lbl) => filter.includes(lbl))
|
||||
)
|
||||
@@ -915,8 +920,9 @@ export class HaConfigHelpers extends SubscribeMixin(LitElement) {
|
||||
.filter(
|
||||
(stateItem) =>
|
||||
filter[0] ===
|
||||
entityRegistryByEntityId(this._entityReg)[stateItem.entity_id]
|
||||
?.categories.helpers
|
||||
entityRegistryByEntityId(this._entityReg || [])[
|
||||
stateItem.entity_id
|
||||
]?.categories.helpers
|
||||
)
|
||||
.forEach((stateItem) => categoryItems.add(stateItem.entity_id));
|
||||
(this._disabledEntityEntries || [])
|
||||
@@ -940,16 +946,17 @@ export class HaConfigHelpers extends SubscribeMixin(LitElement) {
|
||||
this._helperEntities
|
||||
.filter((stateItem) =>
|
||||
getEntityVoiceAssistantsIds(
|
||||
this._entityReg,
|
||||
this._entityReg || [],
|
||||
stateItem.entity_id
|
||||
).some((va) => (filter as string[]).includes(va))
|
||||
)
|
||||
.forEach((stateItem) => assistItems.add(stateItem.entity_id));
|
||||
(this._disabledEntityEntries || [])
|
||||
.filter((entry) =>
|
||||
getEntityVoiceAssistantsIds(this._entityReg, entry.entity_id).some(
|
||||
(va) => (filter as string[]).includes(va)
|
||||
)
|
||||
getEntityVoiceAssistantsIds(
|
||||
this._entityReg || [],
|
||||
entry.entity_id
|
||||
).some((va) => (filter as string[]).includes(va))
|
||||
)
|
||||
.forEach((entry) => assistItems.add(entry.entity_id));
|
||||
if (!items) {
|
||||
@@ -1027,7 +1034,7 @@ export class HaConfigHelpers extends SubscribeMixin(LitElement) {
|
||||
}
|
||||
|
||||
private _editCategory(helper: any) {
|
||||
const entityReg = entityRegistryByEntityId(this._entityReg)[
|
||||
const entityReg = entityRegistryByEntityId(this._entityReg || [])[
|
||||
helper.entity_id
|
||||
];
|
||||
if (!entityReg) {
|
||||
@@ -1252,17 +1259,22 @@ ${rejected
|
||||
this._setFiltersFromUrl();
|
||||
}
|
||||
|
||||
if (!this._entityEntries || !this._configEntries || !this._entitySource) {
|
||||
if (
|
||||
!this._entityReg ||
|
||||
!this._configEntries ||
|
||||
!this._entitySource ||
|
||||
!this._helperManifests
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
(changedProps.has("_helperManifests") ||
|
||||
changedProps.has("_entityEntries") ||
|
||||
changedProps.has("_entityReg") ||
|
||||
changedProps.has("_configEntries")) &&
|
||||
this._helperManifests
|
||||
) {
|
||||
this._disabledEntityEntries = Object.values(this._entityEntries).filter(
|
||||
this._disabledEntityEntries = this._entityReg.filter(
|
||||
(e) =>
|
||||
e.disabled_by &&
|
||||
(e.platform in this._helperManifests! ||
|
||||
@@ -1272,7 +1284,7 @@ ${rejected
|
||||
|
||||
let changed =
|
||||
!this._helperEntities ||
|
||||
changedProps.has("_entityEntries") ||
|
||||
changedProps.has("_entityReg") ||
|
||||
changedProps.has("_configEntries") ||
|
||||
changedProps.has("_entitySource");
|
||||
|
||||
@@ -1284,15 +1296,16 @@ ${rejected
|
||||
return;
|
||||
}
|
||||
|
||||
// Use a Set for O(1) lookups: this runs on every state change, and the
|
||||
// filter scans every state, so an array `includes` here is O(states ×
|
||||
// sources).
|
||||
const entityIds = new Set(Object.keys(this._entitySource));
|
||||
const entityIds = this._helperEntityIds(
|
||||
this._entityReg,
|
||||
this._entitySource,
|
||||
this._helperManifests
|
||||
);
|
||||
|
||||
const newHelpers = Object.values(this.hass!.states).filter(
|
||||
(entity) =>
|
||||
entityIds.has(entity.entity_id) ||
|
||||
isHelperDomain(computeStateDomain(entity))
|
||||
isHelperDomain(computeStateDomain(entity)) ||
|
||||
(entityIds.has(entity.entity_id) && !entity.attributes.restored)
|
||||
);
|
||||
|
||||
if (
|
||||
@@ -1390,7 +1403,7 @@ ${rejected
|
||||
try {
|
||||
// For old-style helpers (input_boolean, etc.), use HELPERS_CRUD
|
||||
if (isHelperDomain(helper.type)) {
|
||||
const entityReg = this._entityReg.find(
|
||||
const entityReg = this._entityReg?.find(
|
||||
(e) => e.entity_id === helper.entity_id
|
||||
);
|
||||
if (
|
||||
|
||||
@@ -11,7 +11,10 @@ import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property } from "lit/decorators";
|
||||
import { classMap } from "lit/directives/class-map";
|
||||
import { styleMap } from "lit/directives/style-map";
|
||||
import { stopPropagation } from "../../../common/dom/stop_propagation";
|
||||
import {
|
||||
stopKeydownEnterSpacePropagation,
|
||||
stopPropagation,
|
||||
} from "../../../common/dom/stop_propagation";
|
||||
import { computeDeviceNameDisplay } from "../../../common/entity/compute_device_name";
|
||||
import { getDeviceArea } from "../../../common/entity/context/get_device_context";
|
||||
import { navigate } from "../../../common/navigate";
|
||||
@@ -19,6 +22,7 @@ import { computeRTL } from "../../../common/util/compute_rtl";
|
||||
import "../../../components/ha-dropdown";
|
||||
import "../../../components/ha-dropdown-item";
|
||||
import "../../../components/ha-tree-indicator";
|
||||
import "../../../components/item/ha-list-item-button";
|
||||
import {
|
||||
disableConfigEntry,
|
||||
type ConfigEntry,
|
||||
@@ -74,8 +78,7 @@ class HaConfigEntryDeviceRow extends LitElement {
|
||||
area ? area.name : undefined,
|
||||
].filter(Boolean);
|
||||
|
||||
return html`<ha-md-list-item
|
||||
type="button"
|
||||
return html`<ha-list-item-button
|
||||
@click=${this._handleNavigateToDevice}
|
||||
class=${classMap({ disabled: Boolean(device.disabled_by) })}
|
||||
>
|
||||
@@ -125,15 +128,14 @@ class HaConfigEntryDeviceRow extends LitElement {
|
||||
: nothing
|
||||
}</span
|
||||
>
|
||||
${
|
||||
!this.narrow ? html`<ha-icon-next slot="end"> </ha-icon-next>` : nothing
|
||||
}
|
||||
${!this.narrow ? html`<ha-icon-next slot="end"></ha-icon-next>` : nothing}
|
||||
<div class="vertical-divider" slot="end" @click=${stopPropagation}></div>
|
||||
${
|
||||
!this.narrow
|
||||
? html`<ha-icon-button
|
||||
slot="end"
|
||||
@click=${this._handleEditDeviceButton}
|
||||
@keydown=${stopKeydownEnterSpacePropagation}
|
||||
.path=${mdiPencil}
|
||||
.label=${this.hass.localize(
|
||||
"ui.panel.config.integrations.config_entry.device.edit"
|
||||
@@ -145,6 +147,7 @@ class HaConfigEntryDeviceRow extends LitElement {
|
||||
<ha-dropdown
|
||||
slot="end"
|
||||
@click=${stopPropagation}
|
||||
@keydown=${stopKeydownEnterSpacePropagation}
|
||||
@wa-select=${this._handleMenuAction}
|
||||
>
|
||||
<ha-icon-button
|
||||
@@ -221,7 +224,7 @@ class HaConfigEntryDeviceRow extends LitElement {
|
||||
: nothing
|
||||
}
|
||||
</ha-dropdown>
|
||||
</ha-md-list-item> `;
|
||||
</ha-list-item-button>`;
|
||||
}
|
||||
|
||||
private _getEntities = (): EntityRegistryEntry[] =>
|
||||
@@ -376,25 +379,26 @@ class HaConfigEntryDeviceRow extends LitElement {
|
||||
static styles = [
|
||||
haStyle,
|
||||
css`
|
||||
:host {
|
||||
ha-list-item-button {
|
||||
border-top: 1px solid var(--divider-color);
|
||||
--ha-row-item-padding-inline: 56px 16px;
|
||||
}
|
||||
ha-md-list-item {
|
||||
--md-list-item-leading-space: 56px;
|
||||
--md-ripple-hover-color: transparent;
|
||||
--md-ripple-pressed-color: transparent;
|
||||
ha-icon-button,
|
||||
ha-icon-next,
|
||||
ha-svg-icon {
|
||||
color: var(--ha-color-fill-neutral-loud-resting);
|
||||
}
|
||||
:host([is-child]) ha-md-list-item {
|
||||
--md-list-item-leading-space: 88px;
|
||||
:host([is-child]) ha-list-item-button {
|
||||
--ha-row-item-padding-inline: 88px 16px;
|
||||
}
|
||||
.disabled {
|
||||
opacity: 0.5;
|
||||
}
|
||||
:host([narrow]) ha-md-list-item {
|
||||
--md-list-item-leading-space: 16px;
|
||||
:host([narrow]) ha-list-item-button {
|
||||
--ha-row-item-padding-inline: 16px;
|
||||
}
|
||||
:host([narrow][is-child]) ha-md-list-item {
|
||||
--md-list-item-leading-space: 48px;
|
||||
:host([narrow][is-child]) ha-list-item-button {
|
||||
--ha-row-item-padding-inline: 48px 16px;
|
||||
}
|
||||
ha-tree-indicator {
|
||||
width: 48px;
|
||||
@@ -405,8 +409,9 @@ class HaConfigEntryDeviceRow extends LitElement {
|
||||
width: 1px;
|
||||
background: var(--divider-color);
|
||||
}
|
||||
a {
|
||||
text-decoration: none;
|
||||
ha-list-item-button::part(end) {
|
||||
align-self: stretch;
|
||||
gap: var(--ha-space-4);
|
||||
}
|
||||
`,
|
||||
];
|
||||
|
||||
@@ -28,6 +28,9 @@ import { copyToClipboard } from "../../../common/util/copy-clipboard";
|
||||
import "../../../components/ha-dropdown";
|
||||
import type { HaDropdownSelectEvent } from "../../../components/ha-dropdown";
|
||||
import "../../../components/ha-dropdown-item";
|
||||
import "../../../components/item/ha-list-item-button";
|
||||
import "../../../components/item/ha-row-item";
|
||||
import "../../../components/list/ha-list-base";
|
||||
import {
|
||||
deleteApplicationCredential,
|
||||
fetchApplicationCredentialsConfigEntry,
|
||||
@@ -43,9 +46,9 @@ import {
|
||||
reloadConfigEntry,
|
||||
updateConfigEntry,
|
||||
} from "../../../data/config_entries";
|
||||
import { groupDevicesByParent } from "../../../data/device/device_registry";
|
||||
import type { DiagnosticInfo } from "../../../data/diagnostics";
|
||||
import { getConfigEntryDiagnosticsDownloadUrl } from "../../../data/diagnostics";
|
||||
import { groupDevicesByParent } from "../../../data/device/device_registry";
|
||||
import type { EntityRegistryEntry } from "../../../data/entity/entity_registry";
|
||||
import type { IntegrationManifest } from "../../../data/integration";
|
||||
import {
|
||||
@@ -164,8 +167,8 @@ export class HaConfigEntryRow extends LitElement {
|
||||
|
||||
const subEntries = this.data.subEntries;
|
||||
|
||||
return html`<ha-md-list>
|
||||
<ha-md-list-item
|
||||
return html` <div class="config-entry-wrapper">
|
||||
<ha-row-item
|
||||
class=${classMap({
|
||||
config_entry: true,
|
||||
"state-not-loaded": item!.state === "not_loaded",
|
||||
@@ -452,82 +455,85 @@ export class HaConfigEntryRow extends LitElement {
|
||||
: nothing
|
||||
}
|
||||
</ha-dropdown>
|
||||
</ha-md-list-item>
|
||||
${
|
||||
this._expanded
|
||||
? subEntries.length
|
||||
? html`${
|
||||
ownDevices.length
|
||||
? html`<ha-md-list class="devices">
|
||||
<ha-md-list-item
|
||||
@click=${this._toggleOwnDevices}
|
||||
type="button"
|
||||
class="toggle-devices-row ${classMap({
|
||||
expanded: this._devicesExpanded,
|
||||
})}"
|
||||
>
|
||||
<ha-icon-button
|
||||
class="expand-button ${classMap({
|
||||
</ha-row-item>
|
||||
<ha-list-base>
|
||||
${
|
||||
this._expanded
|
||||
? subEntries.length
|
||||
? html`${
|
||||
ownDevices.length
|
||||
? html`<div class="devices">
|
||||
<ha-list-item-button
|
||||
@click=${this._toggleOwnDevices}
|
||||
class="toggle-devices-row ${classMap({
|
||||
expanded: this._devicesExpanded,
|
||||
})}"
|
||||
.path=${mdiChevronDown}
|
||||
slot="start"
|
||||
>
|
||||
</ha-icon-button>
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.integrations.config_entry.devices_without_subentry"
|
||||
)}
|
||||
</ha-md-list-item>
|
||||
${
|
||||
this._devicesExpanded
|
||||
? groupDevicesByParent(ownDevices).map(
|
||||
({ device, isChild, isLastChild }) =>
|
||||
html`<ha-config-entry-device-row
|
||||
.hass=${this.hass}
|
||||
.narrow=${this.narrow}
|
||||
.entry=${item}
|
||||
.device=${device}
|
||||
.entities=${entities}
|
||||
.isChild=${isChild}
|
||||
.isLastChild=${isLastChild}
|
||||
></ha-config-entry-device-row>`
|
||||
)
|
||||
: nothing
|
||||
}
|
||||
</ha-md-list>`
|
||||
: nothing
|
||||
}
|
||||
${subEntries.map(
|
||||
(subEntryData) => html`
|
||||
<ha-config-sub-entry-row
|
||||
.hass=${this.hass}
|
||||
.narrow=${this.narrow}
|
||||
.manifest=${this.manifest}
|
||||
.diagnosticHandler=${this.diagnosticHandler}
|
||||
.entities=${this.entities}
|
||||
.entry=${item}
|
||||
.data=${subEntryData}
|
||||
data-entry-id=${item.entry_id}
|
||||
></ha-config-sub-entry-row>
|
||||
`
|
||||
)}`
|
||||
: html`
|
||||
${groupDevicesByParent(ownDevices).map(
|
||||
({ device, isChild, isLastChild }) =>
|
||||
html`<ha-config-entry-device-row
|
||||
<ha-icon-button
|
||||
class="expand-button ${classMap({
|
||||
expanded: this._devicesExpanded,
|
||||
})}"
|
||||
.path=${mdiChevronDown}
|
||||
slot="start"
|
||||
>
|
||||
</ha-icon-button>
|
||||
<span slot="headline"
|
||||
>${this.hass.localize(
|
||||
"ui.panel.config.integrations.config_entry.devices_without_subentry"
|
||||
)}</span
|
||||
>
|
||||
</ha-list-item-button>
|
||||
${
|
||||
this._devicesExpanded
|
||||
? groupDevicesByParent(ownDevices).map(
|
||||
({ device, isChild, isLastChild }) =>
|
||||
html`<ha-config-entry-device-row
|
||||
.hass=${this.hass}
|
||||
.narrow=${this.narrow}
|
||||
.entry=${item}
|
||||
.device=${device}
|
||||
.entities=${entities}
|
||||
.isChild=${isChild}
|
||||
.isLastChild=${isLastChild}
|
||||
></ha-config-entry-device-row>`
|
||||
)
|
||||
: nothing
|
||||
}
|
||||
</div>`
|
||||
: nothing
|
||||
}
|
||||
${subEntries.map(
|
||||
(subEntryData) => html`
|
||||
<ha-config-sub-entry-row
|
||||
.hass=${this.hass}
|
||||
.narrow=${this.narrow}
|
||||
.manifest=${this.manifest}
|
||||
.diagnosticHandler=${this.diagnosticHandler}
|
||||
.entities=${this.entities}
|
||||
.entry=${item}
|
||||
.device=${device}
|
||||
.entities=${entities}
|
||||
.isChild=${isChild}
|
||||
.isLastChild=${isLastChild}
|
||||
></ha-config-entry-device-row>`
|
||||
)}
|
||||
`
|
||||
: nothing
|
||||
}
|
||||
</ha-md-list>`;
|
||||
.data=${subEntryData}
|
||||
data-entry-id=${item.entry_id}
|
||||
></ha-config-sub-entry-row>
|
||||
`
|
||||
)}`
|
||||
: html`
|
||||
${groupDevicesByParent(ownDevices).map(
|
||||
({ device, isChild, isLastChild }) =>
|
||||
html`<ha-config-entry-device-row
|
||||
.hass=${this.hass}
|
||||
.narrow=${this.narrow}
|
||||
.entry=${item}
|
||||
.device=${device}
|
||||
.entities=${entities}
|
||||
.isChild=${isChild}
|
||||
.isLastChild=${isLastChild}
|
||||
></ha-config-entry-device-row>`
|
||||
)}
|
||||
`
|
||||
: nothing
|
||||
}
|
||||
</ha-list-base>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
private _configPanel = memoizeOne(getConfigPanelPath);
|
||||
@@ -849,18 +855,22 @@ export class HaConfigEntryRow extends LitElement {
|
||||
.expand-button.expanded {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
ha-md-list {
|
||||
.config-entry-wrapper {
|
||||
border: 1px solid var(--divider-color);
|
||||
border-radius: var(--ha-card-border-radius, var(--ha-border-radius-lg));
|
||||
padding: 0;
|
||||
background-color: var(--card-background-color);
|
||||
}
|
||||
:host([narrow]) {
|
||||
margin-left: -12px;
|
||||
margin-right: -12px;
|
||||
}
|
||||
ha-md-list.devices {
|
||||
.devices {
|
||||
margin: 16px;
|
||||
margin-top: 0;
|
||||
background-color: var(--card-background-color);
|
||||
}
|
||||
ha-icon-button {
|
||||
color: var(--ha-color-fill-neutral-loud-resting);
|
||||
}
|
||||
ha-icon-button.link {
|
||||
color: var(
|
||||
@@ -879,6 +889,14 @@ export class HaConfigEntryRow extends LitElement {
|
||||
ha-dropdown a {
|
||||
text-decoration: none;
|
||||
}
|
||||
.message {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--ha-space-2);
|
||||
}
|
||||
.message div {
|
||||
white-space: normal;
|
||||
}
|
||||
`,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -30,10 +30,10 @@ import { nextRender } from "../../../common/util/render-status";
|
||||
import "../../../components/ha-button";
|
||||
import "../../../components/ha-dropdown";
|
||||
import "../../../components/ha-dropdown-item";
|
||||
import "../../../components/ha-md-list";
|
||||
import "../../../components/ha-md-list-item";
|
||||
import "../../../components/input/ha-input-search";
|
||||
import type { HaInputSearch } from "../../../components/input/ha-input-search";
|
||||
import "../../../components/item/ha-list-item-base";
|
||||
import "../../../components/list/ha-list-base";
|
||||
import { getSignedPath } from "../../../data/auth";
|
||||
import type { ConfigEntry, SubEntry } from "../../../data/config_entries";
|
||||
import {
|
||||
@@ -754,11 +754,11 @@ class HaConfigIntegrationPage extends SubscribeMixin(LitElement) {
|
||||
"ui.panel.config.integrations.discovered"
|
||||
)}
|
||||
</h3>
|
||||
<ha-md-list class="discovered">
|
||||
<ha-list-base class="discovered">
|
||||
${filteredDiscoveryData.map(
|
||||
(flow) =>
|
||||
html`<ha-md-list-item class="discovered">
|
||||
${flow.localized_title}
|
||||
html`<ha-list-item-base class="discovered">
|
||||
<span slot="headline">${flow.localized_title}</span>
|
||||
<ha-button
|
||||
slot="end"
|
||||
variant="success"
|
||||
@@ -768,9 +768,9 @@ class HaConfigIntegrationPage extends SubscribeMixin(LitElement) {
|
||||
>
|
||||
${this.hass.localize("ui.common.add")}
|
||||
</ha-button>
|
||||
</ha-md-list-item>`
|
||||
</ha-list-item-base>`
|
||||
)}
|
||||
</ha-md-list>
|
||||
</ha-list-base>
|
||||
</div>
|
||||
`
|
||||
: nothing
|
||||
@@ -786,15 +786,17 @@ class HaConfigIntegrationPage extends SubscribeMixin(LitElement) {
|
||||
</h3>
|
||||
${
|
||||
filteredAttentionFlows.length
|
||||
? html`<ha-md-list class="attention">
|
||||
? html`<ha-list-base class="attention">
|
||||
${filteredAttentionFlows.map((flow) => {
|
||||
const attention = ATTENTION_SOURCES.includes(
|
||||
flow.context.source
|
||||
);
|
||||
return html`<ha-md-list-item
|
||||
return html`<ha-list-item-base
|
||||
class="config_entry ${attention ? "attention" : ""}"
|
||||
>
|
||||
${flow.localized_title}
|
||||
<span slot="headline"
|
||||
>${flow.localized_title}</span
|
||||
>
|
||||
<span slot="supporting-text"
|
||||
>${this.hass.localize(
|
||||
`ui.panel.config.integrations.${
|
||||
@@ -813,9 +815,9 @@ class HaConfigIntegrationPage extends SubscribeMixin(LitElement) {
|
||||
}`
|
||||
)}</ha-button
|
||||
>
|
||||
</ha-md-list-item>`;
|
||||
</ha-list-item-base>`;
|
||||
})}
|
||||
</ha-md-list>`
|
||||
</ha-list-base>`
|
||||
: nothing
|
||||
}
|
||||
${filteredAttentionData.map(
|
||||
@@ -1515,26 +1517,24 @@ class HaConfigIntegrationPage extends SubscribeMixin(LitElement) {
|
||||
color: var(--mdc-theme-text-icon-on-background, rgba(0, 0, 0, 0.38));
|
||||
animation: unset;
|
||||
}
|
||||
ha-md-list {
|
||||
ha-list-base {
|
||||
border: 1px solid var(--divider-color);
|
||||
border-radius: var(--ha-border-radius-md);
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
.discovered {
|
||||
--md-list-container-color: rgba(var(--rgb-success-color), 0.2);
|
||||
ha-list-base.discovered {
|
||||
background-color: rgba(var(--rgb-success-color), 0.2);
|
||||
}
|
||||
.attention {
|
||||
--md-list-container-color: rgba(var(--rgb-warning-color), 0.2);
|
||||
ha-list-base.attention {
|
||||
background-color: rgba(var(--rgb-warning-color), 0.2);
|
||||
}
|
||||
ha-md-list-item {
|
||||
--md-list-item-top-space: 4px;
|
||||
--md-list-item-bottom-space: 4px;
|
||||
ha-list-item-base.discovered {
|
||||
--ha-row-item-min-height: 72px;
|
||||
}
|
||||
ha-list-item-base.config_entry {
|
||||
position: relative;
|
||||
}
|
||||
ha-md-list-item.discovered {
|
||||
height: 72px;
|
||||
}
|
||||
ha-md-list-item.config_entry::after {
|
||||
ha-list-item-base.config_entry::after {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
@@ -1596,7 +1596,7 @@ class HaConfigIntegrationPage extends SubscribeMixin(LitElement) {
|
||||
.state-disabled [slot="supporting-text"] {
|
||||
opacity: var(--md-list-item-disabled-opacity, 0.3);
|
||||
}
|
||||
ha-md-list {
|
||||
ha-list-base {
|
||||
margin-top: 8px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
@@ -13,13 +13,14 @@ import { customElement, property, state } from "lit/decorators";
|
||||
import { classMap } from "lit/directives/class-map";
|
||||
import "../../../components/ha-dropdown";
|
||||
import "../../../components/ha-dropdown-item";
|
||||
import "../../../components/item/ha-list-item-base";
|
||||
import "../../../components/list/ha-list-base";
|
||||
import type { ConfigEntry } from "../../../data/config_entries";
|
||||
import { deleteSubEntry, updateSubEntry } from "../../../data/config_entries";
|
||||
import { groupDevicesByParent } from "../../../data/device/device_registry";
|
||||
import type { DiagnosticInfo } from "../../../data/diagnostics";
|
||||
import type { EntityRegistryEntry } from "../../../data/entity/entity_registry";
|
||||
import type { IntegrationManifest } from "../../../data/integration";
|
||||
import type { SubEntryData } from "./ha-config-integration-page";
|
||||
import { showSubConfigFlowDialog } from "../../../dialogs/config-flow/show-dialog-sub-config-flow";
|
||||
import type { HomeAssistant } from "../../../types";
|
||||
import {
|
||||
@@ -27,6 +28,7 @@ import {
|
||||
showPromptDialog,
|
||||
} from "../../lovelace/custom-card-helpers";
|
||||
import "./ha-config-entry-device-row";
|
||||
import type { SubEntryData } from "./ha-config-integration-page";
|
||||
|
||||
@customElement("ha-config-sub-entry-row")
|
||||
class HaConfigSubEntryRow extends LitElement {
|
||||
@@ -54,8 +56,8 @@ class HaConfigSubEntryRow extends LitElement {
|
||||
const services = this.data.services;
|
||||
const entities = this._getEntities();
|
||||
|
||||
return html`<ha-md-list>
|
||||
<ha-md-list-item
|
||||
return html`<div class="sub-entry-card">
|
||||
<ha-list-item-base
|
||||
class="sub-entry"
|
||||
data-entry-id=${configEntry.entry_id}
|
||||
.configEntry=${configEntry}
|
||||
@@ -188,7 +190,7 @@ class HaConfigSubEntryRow extends LitElement {
|
||||
)}
|
||||
</ha-dropdown-item>
|
||||
</ha-dropdown>
|
||||
</ha-md-list-item>
|
||||
</ha-list-item-base>
|
||||
${
|
||||
this._expanded
|
||||
? html`
|
||||
@@ -217,7 +219,7 @@ class HaConfigSubEntryRow extends LitElement {
|
||||
`
|
||||
: nothing
|
||||
}
|
||||
</ha-md-list>`;
|
||||
</div>`;
|
||||
}
|
||||
|
||||
private _toggleExpand() {
|
||||
@@ -305,14 +307,18 @@ class HaConfigSubEntryRow extends LitElement {
|
||||
.expand-button.expanded {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
ha-md-list {
|
||||
.sub-entry-card {
|
||||
border: 1px solid var(--divider-color);
|
||||
border-radius: var(--ha-card-border-radius, var(--ha-border-radius-lg));
|
||||
padding: 0;
|
||||
margin: 16px;
|
||||
margin-top: 0;
|
||||
}
|
||||
ha-md-list-item.has-subentries {
|
||||
ha-icon-button,
|
||||
ha-icon-next,
|
||||
ha-svg-icon {
|
||||
color: var(--ha-color-fill-neutral-loud-resting);
|
||||
}
|
||||
ha-list-item-base.has-subentries {
|
||||
border-bottom: 1px solid var(--divider-color);
|
||||
}
|
||||
ha-dropdown a {
|
||||
|
||||
@@ -174,6 +174,11 @@ class ZHANetworkInfoPage extends LitElement {
|
||||
max-width: 600px;
|
||||
margin: auto;
|
||||
}
|
||||
|
||||
ha-list-item-base::part(supporting-text) {
|
||||
font-size: var(--ha-font-size-m);
|
||||
white-space: normal;
|
||||
}
|
||||
`,
|
||||
];
|
||||
}
|
||||
|
||||
+5
@@ -111,6 +111,11 @@ class ZWaveJSNetworkInfoPage extends LitElement {
|
||||
max-width: 600px;
|
||||
margin: auto;
|
||||
}
|
||||
|
||||
ha-list-item-base::part(supporting-text) {
|
||||
font-size: var(--ha-font-size-m);
|
||||
white-space: normal;
|
||||
}
|
||||
`,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -652,6 +652,7 @@ export class HaScriptTrace extends LitElement {
|
||||
}
|
||||
|
||||
.graph {
|
||||
background-color: var(--primary-background-color);
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
@@ -849,14 +849,29 @@ class HaPanelDevStatistics 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;
|
||||
gap: var(--ha-space-4);
|
||||
padding: 0 var(--ha-space-4);
|
||||
padding: 0;
|
||||
padding-inline-start: calc(
|
||||
var(--ha-space-4) + var(--header-row-inset-start)
|
||||
);
|
||||
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);
|
||||
}
|
||||
|
||||
.selection-bar {
|
||||
background: rgba(var(--rgb-primary-color), 0.1);
|
||||
width: 100%;
|
||||
|
||||
@@ -78,6 +78,7 @@ class DialogVoiceSettings extends LitElement {
|
||||
|
||||
private _entityEntryUpdated(ev: CustomEvent) {
|
||||
this._params!.extEntityReg = ev.detail;
|
||||
this._params!.entityEntryUpdated?.(ev.detail);
|
||||
}
|
||||
|
||||
private _exposedEntitiesChanged() {
|
||||
|
||||
@@ -711,6 +711,9 @@ export class VoiceAssistantsExpose extends LitElement {
|
||||
exposedEntitiesChanged: () => {
|
||||
fireEvent(this, "exposed-entities-changed");
|
||||
},
|
||||
entityEntryUpdated: (entry) => {
|
||||
this._extEntities = { ...this._extEntities, [entityId]: entry };
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ export interface VoiceSettingsDialogParams {
|
||||
exposed: ExposeEntitySettings;
|
||||
extEntityReg?: ExtEntityRegistryEntry;
|
||||
exposedEntitiesChanged?: () => void;
|
||||
entityEntryUpdated?: (entry: ExtEntityRegistryEntry) => void;
|
||||
}
|
||||
|
||||
export const loadVoiceSettingsDialog = () => import("./dialog-voice-settings");
|
||||
|
||||
@@ -12,14 +12,21 @@ import { DirtyStateProviderMixin } from "../../../mixins/dirty-state-provider-mi
|
||||
import { haStyleDialog } from "../../../resources/styles";
|
||||
import type { HomeAssistant } from "../../../types";
|
||||
import type { HomeZoneDetailDialogParams } from "./show-dialog-home-zone-detail";
|
||||
import {
|
||||
HOME_ZONE_ENTITY_ID,
|
||||
zoneColor,
|
||||
} from "../../../common/map/entity-map-colors";
|
||||
|
||||
const SCHEMA = [
|
||||
{
|
||||
name: "location",
|
||||
required: true,
|
||||
selector: { location: { radius: true } },
|
||||
},
|
||||
];
|
||||
const SCHEMA = memoizeOne(
|
||||
(icon: string, color: string) =>
|
||||
[
|
||||
{
|
||||
name: "location",
|
||||
required: true,
|
||||
selector: { location: { radius: true, icon, color } },
|
||||
},
|
||||
] as const
|
||||
);
|
||||
|
||||
@customElement("dialog-home-zone-detail")
|
||||
class DialogHomeZoneDetail extends DirtyStateProviderMixin<HomeZoneMutableParams>()(
|
||||
@@ -81,7 +88,11 @@ class DialogHomeZoneDetail extends DirtyStateProviderMixin<HomeZoneMutableParams
|
||||
<ha-form
|
||||
autofocus
|
||||
.hass=${this.hass}
|
||||
.schema=${SCHEMA}
|
||||
.schema=${SCHEMA(
|
||||
this.hass.states[HOME_ZONE_ENTITY_ID]?.attributes.icon ||
|
||||
"mdi:home",
|
||||
zoneColor(HOME_ZONE_ENTITY_ID, false, [], getComputedStyle(this))
|
||||
)}
|
||||
.data=${this._formData(this._data)}
|
||||
.error=${this._error}
|
||||
.computeLabel=${this._computeLabel}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { consume } from "@lit/context";
|
||||
import type { CSSResultGroup } from "lit";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
@@ -9,12 +10,18 @@ import "../../../components/ha-dialog";
|
||||
import "../../../components/ha-form/ha-form";
|
||||
import "../../../components/ha-button";
|
||||
import type { SchemaUnion } from "../../../components/ha-form/types";
|
||||
import type { ZoneMutableParams } from "../../../data/zone";
|
||||
import type { Zone, ZoneMutableParams } from "../../../data/zone";
|
||||
import { getZoneEditorInitData } from "../../../data/zone";
|
||||
import { DirtyStateProviderMixin } from "../../../mixins/dirty-state-provider-mixin";
|
||||
import { haStyleDialog } from "../../../resources/styles";
|
||||
import type { HomeAssistant } from "../../../types";
|
||||
import type { ZoneDetailDialogParams } from "./show-dialog-zone-detail";
|
||||
import {
|
||||
nextZoneColor,
|
||||
zoneColor,
|
||||
} from "../../../common/map/entity-map-colors";
|
||||
import { fullEntitiesContext } from "../../../data/context";
|
||||
import type { EntityRegistryEntry } from "../../../data/entity/entity_registry";
|
||||
|
||||
@customElement("dialog-zone-detail")
|
||||
class DialogZoneDetail extends DirtyStateProviderMixin<ZoneMutableParams>()(
|
||||
@@ -22,6 +29,11 @@ class DialogZoneDetail extends DirtyStateProviderMixin<ZoneMutableParams>()(
|
||||
) {
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
|
||||
// Registry creation order decides the zone color
|
||||
@state()
|
||||
@consume({ context: fullEntitiesContext, subscribe: true })
|
||||
private _entityReg: EntityRegistryEntry[] = [];
|
||||
|
||||
@state() private _error?: Record<string, string>;
|
||||
|
||||
@state() private _data?: ZoneMutableParams;
|
||||
@@ -89,6 +101,22 @@ class DialogZoneDetail extends DirtyStateProviderMixin<ZoneMutableParams>()(
|
||||
!lngInvalid &&
|
||||
!radiusInvalid;
|
||||
|
||||
// From the registry context, so a deep link opening before the registry
|
||||
// loads still resolves the color
|
||||
const entityId = this._zoneEntityId(this._params.entry, this._entityReg);
|
||||
const color = entityId
|
||||
? zoneColor(
|
||||
entityId,
|
||||
!!this._data.passive,
|
||||
this._entityReg,
|
||||
getComputedStyle(this)
|
||||
)
|
||||
: nextZoneColor(
|
||||
!!this._data.passive,
|
||||
this._entityReg,
|
||||
getComputedStyle(this)
|
||||
);
|
||||
|
||||
return html`
|
||||
<ha-dialog
|
||||
.open=${this._open}
|
||||
@@ -105,7 +133,7 @@ class DialogZoneDetail extends DirtyStateProviderMixin<ZoneMutableParams>()(
|
||||
<ha-form
|
||||
autofocus
|
||||
.hass=${this.hass}
|
||||
.schema=${this._schema(this._data.icon)}
|
||||
.schema=${this._schema(this._data.icon, color, this._data.name)}
|
||||
.data=${this._formData(this._data)}
|
||||
.error=${this._error}
|
||||
.computeLabel=${this._computeLabel}
|
||||
@@ -152,8 +180,18 @@ class DialogZoneDetail extends DirtyStateProviderMixin<ZoneMutableParams>()(
|
||||
`;
|
||||
}
|
||||
|
||||
// Storage zones register their entity with the zone id as unique id
|
||||
private _zoneEntityId = memoizeOne(
|
||||
(entry: Zone | undefined, entityReg: EntityRegistryEntry[]) =>
|
||||
entry
|
||||
? entityReg.find(
|
||||
(ent) => ent.platform === "zone" && ent.unique_id === entry.id
|
||||
)?.entity_id
|
||||
: undefined
|
||||
);
|
||||
|
||||
private _schema = memoizeOne(
|
||||
(icon?: string) =>
|
||||
(icon?: string, color?: string, name?: string) =>
|
||||
[
|
||||
{
|
||||
name: "name",
|
||||
@@ -172,7 +210,7 @@ class DialogZoneDetail extends DirtyStateProviderMixin<ZoneMutableParams>()(
|
||||
{
|
||||
name: "location",
|
||||
required: true,
|
||||
selector: { location: { radius: true, icon } },
|
||||
selector: { location: { radius: true, icon, color, name } },
|
||||
},
|
||||
{ name: "passive_note", type: "constant" },
|
||||
{ name: "passive", selector: { boolean: {} } },
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { HassEntity, UnsubscribeFunc } from "home-assistant-js-websocket";
|
||||
import type { PropertyValues, TemplateResult } from "lit";
|
||||
import { LitElement, css, html, nothing } from "lit";
|
||||
import { customElement, property, query, state } from "lit/decorators";
|
||||
import { styleMap } from "lit/directives/style-map";
|
||||
import memoizeOne from "memoize-one";
|
||||
import { computeStateDomain } from "../../../common/entity/compute_state_domain";
|
||||
import { shouldHandleRequestSelectedEvent } from "../../../common/mwc/handle-request-selected-event";
|
||||
@@ -28,6 +29,10 @@ import {
|
||||
HOME_ZONE_ENTITY_ID,
|
||||
zoneColor,
|
||||
} from "../../../common/map/entity-map-colors";
|
||||
import {
|
||||
contrastingZoneContent,
|
||||
zoneInitials,
|
||||
} from "../../../common/map/zone-marker";
|
||||
import type {
|
||||
HomeZoneMutableParams,
|
||||
Zone,
|
||||
@@ -96,9 +101,157 @@ export class HaConfigZone extends SubscribeMixin(LitElement) {
|
||||
// Storage zone id (its unique id) to entity id
|
||||
@state() private _zoneEntityIds: Record<string, string> = {};
|
||||
|
||||
// Bumped when entity map colors change to recompute the memoized locations
|
||||
// Bumped on a theme change to recompute the memoized locations
|
||||
@state() private _colorVersion = 0;
|
||||
|
||||
// Home first, then alphabetical, for UI and YAML zones alike
|
||||
private _sortedItems = memoizeOne(
|
||||
(
|
||||
storageItems: Zone[],
|
||||
stateItems: HassEntity[],
|
||||
language: string
|
||||
): ({ entry: Zone } | { stateObject: HassEntity })[] => {
|
||||
const items = [
|
||||
...storageItems.map((entry) => ({
|
||||
entry,
|
||||
name: entry.name,
|
||||
home: false,
|
||||
})),
|
||||
...stateItems.map((stateObject) => ({
|
||||
stateObject,
|
||||
name: stateObject.attributes.friendly_name || stateObject.entity_id,
|
||||
home: stateObject.entity_id === HOME_ZONE_ENTITY_ID,
|
||||
})),
|
||||
];
|
||||
return items.sort((a, b) =>
|
||||
a.home !== b.home
|
||||
? a.home
|
||||
? -1
|
||||
: 1
|
||||
: stringCompare(a.name, b.name, language)
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
private _renderStorageItem(entry: Zone) {
|
||||
const hass = this.hass;
|
||||
return html`
|
||||
<ha-list-item
|
||||
.entry=${entry}
|
||||
.id=${this.narrow ? entry.id : ""}
|
||||
graphic="avatar"
|
||||
.hasMeta=${!this.narrow}
|
||||
@request-selected=${this._itemClicked}
|
||||
.value=${entry.id}
|
||||
>
|
||||
${this._renderZoneGraphic(
|
||||
this._zoneEntityIds[entry.id] ?? `zone.${entry.id}`,
|
||||
!!entry.passive,
|
||||
entry.icon,
|
||||
entry.name
|
||||
)}
|
||||
${entry.name}
|
||||
${
|
||||
!this.narrow
|
||||
? html`
|
||||
<div slot="meta">
|
||||
<ha-icon-button
|
||||
.id=${entry.id}
|
||||
.entry=${entry}
|
||||
@click=${this._openEditEntry}
|
||||
.path=${mdiPencil}
|
||||
.label=${hass.localize("ui.common.edit_item", {
|
||||
name: entry.name,
|
||||
})}
|
||||
></ha-icon-button>
|
||||
</div>
|
||||
`
|
||||
: ""
|
||||
}
|
||||
</ha-list-item>
|
||||
`;
|
||||
}
|
||||
|
||||
private _renderStateItem(stateObject: HassEntity) {
|
||||
const hass = this.hass;
|
||||
return html`
|
||||
<ha-list-item
|
||||
graphic="avatar"
|
||||
.id=${this.narrow ? stateObject.entity_id : ""}
|
||||
.hasMeta=${!this.narrow || stateObject.entity_id !== "zone.home"}
|
||||
.value=${stateObject.entity_id}
|
||||
@request-selected=${this._stateItemClicked}
|
||||
.noEdit=${stateObject.entity_id !== "zone.home" || !this._canEditCore}
|
||||
>
|
||||
${this._renderZoneGraphic(
|
||||
stateObject.entity_id,
|
||||
!!stateObject.attributes.passive,
|
||||
stateObject.attributes.icon,
|
||||
stateObject.attributes.friendly_name || stateObject.entity_id
|
||||
)}
|
||||
${stateObject.attributes.friendly_name || stateObject.entity_id}
|
||||
${
|
||||
this.narrow &&
|
||||
stateObject.entity_id === "zone.home" &&
|
||||
!this._canEditCore
|
||||
? nothing
|
||||
: html`<ha-icon-button
|
||||
.id="zone-${slugify(stateObject.entity_id)}"
|
||||
.entityId=${stateObject.entity_id}
|
||||
.noEdit=${
|
||||
stateObject.entity_id !== "zone.home" || !this._canEditCore
|
||||
}
|
||||
.path=${
|
||||
stateObject.entity_id === "zone.home" && this._canEditCore
|
||||
? mdiPencil
|
||||
: mdiPencilOff
|
||||
}
|
||||
.label=${hass.localize("ui.common.edit_item", {
|
||||
name: hass.config.location_name,
|
||||
})}
|
||||
@click=${this._editHomeZone}
|
||||
slot="meta"
|
||||
></ha-icon-button>
|
||||
<ha-tooltip
|
||||
.for="zone-${slugify(stateObject.entity_id)}"
|
||||
placement="left"
|
||||
.disabled=${stateObject.entity_id === "zone.home"}
|
||||
hoist
|
||||
>
|
||||
${hass.localize("ui.panel.config.zone.configured_in_yaml")}
|
||||
</ha-tooltip>`
|
||||
}
|
||||
</ha-list-item>
|
||||
`;
|
||||
}
|
||||
|
||||
// The zone as it looks on the map: its color, with its icon or initials
|
||||
private _renderZoneGraphic(
|
||||
entityId: string,
|
||||
passive: boolean,
|
||||
icon: string | undefined,
|
||||
name: string
|
||||
) {
|
||||
const color = zoneColor(
|
||||
entityId,
|
||||
passive,
|
||||
this._entityReg,
|
||||
getComputedStyle(this)
|
||||
);
|
||||
return html`
|
||||
<div
|
||||
slot="graphic"
|
||||
class="zone-avatar"
|
||||
style=${styleMap({
|
||||
background: color,
|
||||
color: contrastingZoneContent(color),
|
||||
})}
|
||||
>
|
||||
${icon ? html`<ha-icon .icon=${icon}></ha-icon>` : zoneInitials(name)}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private _getZones = memoizeOne(
|
||||
(
|
||||
storageItems: Zone[],
|
||||
@@ -189,100 +342,14 @@ export class HaConfigZone extends SubscribeMixin(LitElement) {
|
||||
`
|
||||
: html`
|
||||
<ha-list>
|
||||
${this._storageItems.map(
|
||||
(entry) => html`
|
||||
<ha-list-item
|
||||
.entry=${entry}
|
||||
.id=${this.narrow ? entry.id : ""}
|
||||
graphic="icon"
|
||||
.hasMeta=${!this.narrow}
|
||||
@request-selected=${this._itemClicked}
|
||||
.value=${entry.id}
|
||||
>
|
||||
<ha-icon .icon=${entry.icon} slot="graphic"></ha-icon>
|
||||
${entry.name}
|
||||
${
|
||||
!this.narrow
|
||||
? html`
|
||||
<div slot="meta">
|
||||
<ha-icon-button
|
||||
.id=${entry.id}
|
||||
.entry=${entry}
|
||||
@click=${this._openEditEntry}
|
||||
.path=${mdiPencil}
|
||||
.label=${hass.localize("ui.common.edit_item", {
|
||||
name: entry.name,
|
||||
})}
|
||||
></ha-icon-button>
|
||||
</div>
|
||||
`
|
||||
: ""
|
||||
}
|
||||
</ha-list-item>
|
||||
`
|
||||
)}
|
||||
${this._stateItems.map(
|
||||
(stateObject) => html`
|
||||
<ha-list-item
|
||||
graphic="icon"
|
||||
.id=${this.narrow ? stateObject.entity_id : ""}
|
||||
.hasMeta=${
|
||||
!this.narrow || stateObject.entity_id !== "zone.home"
|
||||
}
|
||||
.value=${stateObject.entity_id}
|
||||
@request-selected=${this._stateItemClicked}
|
||||
.noEdit=${
|
||||
stateObject.entity_id !== "zone.home" ||
|
||||
!this._canEditCore
|
||||
}
|
||||
>
|
||||
<ha-icon
|
||||
.icon=${stateObject.attributes.icon}
|
||||
slot="graphic"
|
||||
>
|
||||
</ha-icon>
|
||||
|
||||
${
|
||||
stateObject.attributes.friendly_name ||
|
||||
stateObject.entity_id
|
||||
}
|
||||
${
|
||||
this.narrow &&
|
||||
stateObject.entity_id === "zone.home" &&
|
||||
!this._canEditCore
|
||||
? nothing
|
||||
: html`<ha-icon-button
|
||||
.id="zone-${slugify(stateObject.entity_id)}"
|
||||
.entityId=${stateObject.entity_id}
|
||||
.noEdit=${
|
||||
stateObject.entity_id !== "zone.home" ||
|
||||
!this._canEditCore
|
||||
}
|
||||
.path=${
|
||||
stateObject.entity_id === "zone.home" &&
|
||||
this._canEditCore
|
||||
? mdiPencil
|
||||
: mdiPencilOff
|
||||
}
|
||||
.label=${hass.localize("ui.common.edit_item", {
|
||||
name: hass.config.location_name,
|
||||
})}
|
||||
@click=${this._editHomeZone}
|
||||
slot="meta"
|
||||
></ha-icon-button>
|
||||
<ha-tooltip
|
||||
.for="zone-${slugify(stateObject.entity_id)}"
|
||||
placement="left"
|
||||
.disabled=${stateObject.entity_id === "zone.home"}
|
||||
hoist
|
||||
>
|
||||
${hass.localize(
|
||||
"ui.panel.config.zone.configured_in_yaml"
|
||||
)}
|
||||
</ha-tooltip>`
|
||||
}
|
||||
</ha-list-item>
|
||||
`
|
||||
${this._sortedItems(
|
||||
this._storageItems,
|
||||
this._stateItems,
|
||||
hass.locale.language
|
||||
).map((item) =>
|
||||
"entry" in item
|
||||
? this._renderStorageItem(item.entry)
|
||||
: this._renderStateItem(item.stateObject)
|
||||
)}
|
||||
</ha-list>
|
||||
`;
|
||||
@@ -673,6 +740,19 @@ export class HaConfigZone extends SubscribeMixin(LitElement) {
|
||||
ha-icon-button:not([disabled]) {
|
||||
color: var(--secondary-text-color);
|
||||
}
|
||||
.zone-avatar {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-weight: var(--ha-font-weight-medium);
|
||||
}
|
||||
.zone-avatar ha-icon {
|
||||
color: inherit;
|
||||
filter: drop-shadow(0 1px 1px rgba(0, 0, 0, 0.4));
|
||||
}
|
||||
ha-icon-button {
|
||||
--mdc-theme-text-disabled-on-light: var(--disabled-text-color);
|
||||
}
|
||||
|
||||
@@ -65,6 +65,9 @@ class HaLogbookEntry extends LitElement {
|
||||
|
||||
@property({ type: Boolean, attribute: false }) public graphColor = false;
|
||||
|
||||
/** Overrides the node color, e.g. the color of the zone a person entered */
|
||||
@property({ attribute: false }) public nodeColor?: string;
|
||||
|
||||
@property({ type: String, attribute: "name-detail" })
|
||||
public nameDetail?: LogbookNameDetail;
|
||||
|
||||
@@ -384,13 +387,14 @@ class HaLogbookEntry extends LitElement {
|
||||
(domain === "sensor" && stateObj!.attributes.device_class === "enum");
|
||||
const useGraphColor = this.graphColor || !isEnumDomain;
|
||||
const color =
|
||||
layout === "inline" && !isUnavailable && this.item.state && useGraphColor
|
||||
this.nodeColor ??
|
||||
(layout === "inline" && !isUnavailable && this.item.state && useGraphColor
|
||||
? computeTimelineColor(
|
||||
this.item.state,
|
||||
(this._computedStyle ??= getComputedStyle(this)),
|
||||
stateObj
|
||||
)
|
||||
: nodeColor(item.category, stateObj);
|
||||
: nodeColor(item.category, stateObj));
|
||||
const style = color ? styleMap({ "--node-color": color }) : nothing;
|
||||
if (layout !== "timeline") {
|
||||
return html`<span
|
||||
|
||||
@@ -195,6 +195,7 @@ export class HaPanelLogbook extends LitElement {
|
||||
.startDate=${this._time.range[0]}
|
||||
.endDate=${this._time.range[1]}
|
||||
@value-changed=${this._dateRangeChanged}
|
||||
extended-presets
|
||||
time-picker
|
||||
></ha-date-range-nav>
|
||||
</div>
|
||||
|
||||
@@ -3,19 +3,18 @@ import {
|
||||
mdiGoogleCirclesCommunities,
|
||||
mdiImageFilterCenterFocus,
|
||||
} from "@mdi/js";
|
||||
import type { HassEntities } from "home-assistant-js-websocket";
|
||||
import type { HassEntities, HassEntity } from "home-assistant-js-websocket";
|
||||
import type { PropertyValues } from "lit";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property, query, state } from "lit/decorators";
|
||||
import { classMap } from "lit/directives/class-map";
|
||||
import { styleMap } from "lit/directives/style-map";
|
||||
import memoizeOne from "memoize-one";
|
||||
import type { ContextType } from "@lit/context";
|
||||
import { consume, ContextConsumer } from "@lit/context";
|
||||
import { resolveThemeColor } from "../../../common/color/compute-color";
|
||||
import {
|
||||
entityMapColor,
|
||||
zoneColor,
|
||||
} from "../../../common/map/entity-map-colors";
|
||||
import { isComponentLoaded } from "../../../common/config/is_component_loaded";
|
||||
import { computeRTL } from "../../../common/util/compute_rtl";
|
||||
import { computeDomain } from "../../../common/entity/compute_domain";
|
||||
import { computeStateDomain } from "../../../common/entity/compute_state_domain";
|
||||
import { computeStateName } from "../../../common/entity/compute_state_name";
|
||||
@@ -33,7 +32,12 @@ import type {
|
||||
HaMapPaths,
|
||||
MapCardMarkerLabelMode,
|
||||
} from "../../../components/map/ha-map";
|
||||
import type { MapLatLng } from "../../../common/map/map-engine";
|
||||
import type { MapFitPadding, MapLatLng } from "../../../common/map/map-engine";
|
||||
import { circleBoundsPoints } from "../../../common/map/map-engine";
|
||||
import {
|
||||
entityMapColor,
|
||||
zoneColor,
|
||||
} from "../../../common/map/entity-map-colors";
|
||||
import type { HistoryStates } from "../../../data/history";
|
||||
import { subscribeHistoryStatesTimeWindow } from "../../../data/history";
|
||||
import type { Themes } from "../../../data/ws-themes";
|
||||
@@ -41,6 +45,8 @@ import { fullEntitiesContext, uiContext } from "../../../data/context";
|
||||
import { transform } from "../../../common/decorators/transform";
|
||||
import type { EntityRegistryEntry } from "../../../data/entity/entity_registry";
|
||||
import type { HomeAssistant } from "../../../types";
|
||||
import type { HASSDomEvent } from "../../../common/dom/fire_event";
|
||||
import { PANEL_VIEW_LAYOUT } from "../views/const";
|
||||
import { findEntities } from "../common/find-entities";
|
||||
import {
|
||||
hasConfigChanged,
|
||||
@@ -57,6 +63,15 @@ import {
|
||||
export const DEFAULT_HOURS_TO_SHOW = 0;
|
||||
export const DEFAULT_ZOOM = 14;
|
||||
|
||||
// GPS accuracy (meters) above which the selected person's circle is shown
|
||||
const IMPRECISE_GPS_ACCURACY = 100;
|
||||
|
||||
// Margin around the overview (--ha-space-3), in pixels
|
||||
const OVERVIEW_GAP = 12;
|
||||
|
||||
const FOCUS_PERSON_ZOOM = 19;
|
||||
const FOCUS_ZONE_MAX_ZOOM = 18;
|
||||
|
||||
interface GeoEntity {
|
||||
entity_id: string;
|
||||
label_mode?: MapCardMarkerLabelMode;
|
||||
@@ -83,6 +98,8 @@ class HuiMapCard extends LitElement implements LovelaceCard {
|
||||
|
||||
@property({ attribute: false }) public layout?: string;
|
||||
|
||||
@property({ type: Boolean }) public preview = false;
|
||||
|
||||
@state() private _stateHistory?: HistoryStates;
|
||||
|
||||
@state()
|
||||
@@ -97,6 +114,11 @@ class HuiMapCard extends LitElement implements LovelaceCard {
|
||||
|
||||
private _filteredMapEntities: HaMapEntity[] = [];
|
||||
|
||||
// The overview lists people the map snapshot missed (e.g. no location yet),
|
||||
// so it holds the map entities plus those, who may have no marker of their
|
||||
// own until they are located.
|
||||
private _overviewEntities: HaMapEntity[] = [];
|
||||
|
||||
@state() private _error?: { code: string; message: string };
|
||||
|
||||
// Registry creation order decides the palette colors
|
||||
@@ -112,6 +134,13 @@ class HuiMapCard extends LitElement implements LovelaceCard {
|
||||
|
||||
@state() private _clusterMarkers = true;
|
||||
|
||||
@state() private _overviewSelected?: string;
|
||||
|
||||
// Height of the overview drawer when it sits over the bottom of the map
|
||||
@state() private _overviewSize = { width: 0, height: 0 };
|
||||
|
||||
private _overviewLoaded = false;
|
||||
|
||||
private _subscribed?: Promise<(() => Promise<void>) | undefined>;
|
||||
|
||||
private _getAllEntities(): string[] {
|
||||
@@ -237,8 +266,22 @@ class HuiMapCard extends LitElement implements LovelaceCard {
|
||||
|
||||
return html`
|
||||
<ha-card id="card" .header=${this._config.title}>
|
||||
<div id="root">
|
||||
<div
|
||||
id="root"
|
||||
class=${classMap({
|
||||
"panel-layout": this.layout === PANEL_VIEW_LAYOUT,
|
||||
rtl: computeRTL(
|
||||
this.hass.language,
|
||||
this.hass.translationMetadata.translations
|
||||
),
|
||||
})}
|
||||
@hass-more-info=${this._handleMapMoreInfo}
|
||||
>
|
||||
<ha-map
|
||||
style=${styleMap({
|
||||
"--overview-height": `${this._overviewSize.height}px`,
|
||||
"--overview-width": `${this._overviewSize.width}px`,
|
||||
})}
|
||||
.entities=${this._filteredMapEntities}
|
||||
.zoom=${this._config.default_zoom ?? DEFAULT_ZOOM}
|
||||
.paths=${this._getHistoryPaths(
|
||||
@@ -248,12 +291,23 @@ class HuiMapCard extends LitElement implements LovelaceCard {
|
||||
this._themes
|
||||
)}
|
||||
.autoFit=${this._config.auto_fit || false}
|
||||
.fitPadding=${this._overviewPadding()}
|
||||
.fitZones=${this._config.fit_zones || false}
|
||||
.zoomPosition=${
|
||||
this.layout === PANEL_VIEW_LAYOUT &&
|
||||
!computeRTL(
|
||||
this.hass.language,
|
||||
this.hass.translationMetadata.translations
|
||||
)
|
||||
? "topright"
|
||||
: "topleft"
|
||||
}
|
||||
.themeMode=${themeMode}
|
||||
.clusterMarkers=${this._clusterMarkers}
|
||||
.scaleRuler=${this._config.scale_ruler || false}
|
||||
@map-clicked=${this._handleMapClicked}
|
||||
interactive-zones
|
||||
render-passive
|
||||
.renderPassive=${this.layout !== PANEL_VIEW_LAYOUT}
|
||||
></ha-map>
|
||||
<div id="buttons">
|
||||
${
|
||||
@@ -285,6 +339,18 @@ class HuiMapCard extends LitElement implements LovelaceCard {
|
||||
tabindex="0"
|
||||
></ha-icon-button>
|
||||
</div>
|
||||
${
|
||||
this.layout === PANEL_VIEW_LAYOUT
|
||||
? html`<hui-map-overview
|
||||
id="overview"
|
||||
.hass=${this.hass}
|
||||
.entities=${this._overviewEntities}
|
||||
.selected=${this._overviewSelected}
|
||||
@map-overview-select=${this._handleOverviewSelect}
|
||||
@map-overview-resize=${this._handleOverviewResize}
|
||||
></hui-map-overview>`
|
||||
: nothing
|
||||
}
|
||||
</div>
|
||||
</ha-card>
|
||||
`;
|
||||
@@ -361,18 +427,110 @@ class HuiMapCard extends LitElement implements LovelaceCard {
|
||||
|
||||
// Filter entities by conditions
|
||||
if (this._config?.conditions && this._mapEntities) {
|
||||
const conditions = this._config.conditions;
|
||||
this._filteredMapEntities = this._mapEntities.filter((entity) => {
|
||||
const conditionWithEntity = conditions.map((condition) =>
|
||||
addEntityToCondition(condition, entity.entity_id)
|
||||
);
|
||||
return checkConditionsMet(conditionWithEntity, this.hass!, {});
|
||||
});
|
||||
this._filteredMapEntities = this._mapEntities.filter((entity) =>
|
||||
this._meetsConditions(entity.entity_id)
|
||||
);
|
||||
} else {
|
||||
this._filteredMapEntities = this._mapEntities;
|
||||
}
|
||||
|
||||
if (this.layout === PANEL_VIEW_LAYOUT) {
|
||||
if (!this._overviewLoaded) {
|
||||
this._overviewLoaded = true;
|
||||
void import("./map/hui-map-overview");
|
||||
}
|
||||
// Keep people and standalone trackers the snapshot missed so they appear
|
||||
// once they locate.
|
||||
const entities = this._config?.show_all
|
||||
? this._withMissingTracked(this._filteredMapEntities)
|
||||
: this._filteredMapEntities;
|
||||
this._filteredMapEntities = this._decorateOverviewEntities(
|
||||
entities,
|
||||
this._overviewSelected,
|
||||
this._overviewSelected
|
||||
? this.hass.states[this._overviewSelected]
|
||||
: undefined,
|
||||
this.preview
|
||||
);
|
||||
this._overviewEntities = this._filteredMapEntities;
|
||||
}
|
||||
}
|
||||
|
||||
private _meetsConditions(entityId: string): boolean {
|
||||
const conditions = this._config?.conditions;
|
||||
if (!conditions) {
|
||||
return true;
|
||||
}
|
||||
return checkConditionsMet(
|
||||
conditions.map((condition) => addEntityToCondition(condition, entityId)),
|
||||
this.hass!,
|
||||
{}
|
||||
);
|
||||
}
|
||||
|
||||
// show_all freezes located entities, so a person or standalone tracker that
|
||||
// locates later is missing. Add every eligible one and let the map and
|
||||
// overview skip it until it has coordinates. Trackers owned by a person are
|
||||
// shown through that person, so they are left out here.
|
||||
private _withMissingTracked(entities: HaMapEntity[]): HaMapEntity[] {
|
||||
const hass = this.hass;
|
||||
if (!hass) {
|
||||
return entities;
|
||||
}
|
||||
const present = new Set(entities.map((entity) => entity.entity_id));
|
||||
const personSources = new Set<string>();
|
||||
Object.values(hass.states).forEach((stateObj) => {
|
||||
if (
|
||||
computeStateDomain(stateObj) === "person" &&
|
||||
stateObj.attributes.source
|
||||
) {
|
||||
personSources.add(stateObj.attributes.source);
|
||||
}
|
||||
});
|
||||
const extra: HaMapEntity[] = [];
|
||||
Object.values(hass.states).forEach((stateObj) => {
|
||||
const entityId = stateObj.entity_id;
|
||||
const domain = computeStateDomain(stateObj);
|
||||
const eligible =
|
||||
domain === "person" ||
|
||||
(domain === "device_tracker" && !personSources.has(entityId));
|
||||
if (
|
||||
eligible &&
|
||||
!present.has(entityId) &&
|
||||
!hass.entities?.[entityId]?.hidden &&
|
||||
this._meetsConditions(entityId)
|
||||
) {
|
||||
extra.push({ entity_id: entityId, color: this._getColor(entityId) });
|
||||
}
|
||||
});
|
||||
return extra.length ? [...entities, ...extra] : entities;
|
||||
}
|
||||
|
||||
// In panel layout, only the selected zone shows its radius (all of them
|
||||
// while editing) and only an imprecise selected person its accuracy circle
|
||||
private _decorateOverviewEntities = memoizeOne(
|
||||
(
|
||||
entities: HaMapEntity[],
|
||||
selectedId: string | undefined,
|
||||
selectedStateObj: HassEntity | undefined,
|
||||
preview: boolean
|
||||
): HaMapEntity[] => {
|
||||
const selectedLocation = selectedStateObj
|
||||
? getEntityLocation(selectedStateObj, this.hass.states)
|
||||
: undefined;
|
||||
const showSelectedAccuracy =
|
||||
(selectedLocation?.gpsAccuracy ?? 0) > IMPRECISE_GPS_ACCURACY;
|
||||
return entities.map((entity) => ({
|
||||
...entity,
|
||||
hide_accuracy: !(
|
||||
showSelectedAccuracy && entity.entity_id === selectedId
|
||||
),
|
||||
hide_radius: !preview && entity.entity_id !== selectedId,
|
||||
selected: entity.entity_id === selectedId,
|
||||
}));
|
||||
}
|
||||
);
|
||||
|
||||
public connectedCallback() {
|
||||
super.connectedCallback();
|
||||
if (this.hasUpdated && this._configEntities?.length) {
|
||||
@@ -465,6 +623,130 @@ class HuiMapCard extends LitElement implements LovelaceCard {
|
||||
this._map?.fitMap({ unpause_autofit: true });
|
||||
}
|
||||
|
||||
private _handleMapClicked() {
|
||||
// A click on the map itself (not on a marker) deselects
|
||||
if (this._overviewSelected) {
|
||||
this._overviewSelected = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
private _handleMapMoreInfo(ev: HASSDomEvent<{ entityId: string | null }>) {
|
||||
if ((ev.target as HTMLElement)?.localName === "hui-map-overview") {
|
||||
// The overview asks for the dialog itself, so let it through
|
||||
return;
|
||||
}
|
||||
const entityId = ev.detail.entityId;
|
||||
if (
|
||||
this.layout !== PANEL_VIEW_LAYOUT ||
|
||||
!entityId ||
|
||||
!["person", "device_tracker", "zone"].includes(computeDomain(entityId)) ||
|
||||
(computeDomain(entityId) !== "zone" &&
|
||||
!this._filteredMapEntities.some(
|
||||
(entity) => entity.entity_id === entityId
|
||||
))
|
||||
) {
|
||||
return;
|
||||
}
|
||||
ev.stopPropagation();
|
||||
this._overviewSelected = entityId;
|
||||
this._focusEntity(entityId);
|
||||
}
|
||||
|
||||
private _handleOverviewResize(
|
||||
ev: HASSDomEvent<{ width: number; height: number }>
|
||||
) {
|
||||
const { width, height } = ev.detail;
|
||||
// A collapsed overview keeps its width; zero both so it reserves no space.
|
||||
this._overviewSize =
|
||||
width && height ? { width, height } : { width: 0, height: 0 };
|
||||
// A focused fit pauses auto-fit, so refit to apply the new padding.
|
||||
if (this._overviewSelected) {
|
||||
this._focusEntity(this._overviewSelected);
|
||||
}
|
||||
}
|
||||
|
||||
private _handleOverviewSelect(ev: HASSDomEvent<{ entityId?: string }>) {
|
||||
this._overviewSelected = ev.detail.entityId;
|
||||
if (ev.detail.entityId) {
|
||||
this._focusEntity(ev.detail.entityId);
|
||||
}
|
||||
}
|
||||
|
||||
private _focusEntity(entityId: string) {
|
||||
const stateObj = this.hass.states[entityId];
|
||||
if (!stateObj) {
|
||||
return;
|
||||
}
|
||||
if (computeStateDomain(stateObj) === "zone") {
|
||||
const { latitude, longitude, radius } = stateObj.attributes;
|
||||
this._map?.fitBounds(
|
||||
circleBoundsPoints([latitude, longitude], radius ?? 100),
|
||||
{
|
||||
pad: 0.2,
|
||||
zoom: FOCUS_ZONE_MAX_ZOOM,
|
||||
padding: this._overviewPadding(),
|
||||
}
|
||||
);
|
||||
return;
|
||||
}
|
||||
const location = getEntityLocation(stateObj, this.hass.states);
|
||||
if (!location) {
|
||||
return;
|
||||
}
|
||||
const center: MapLatLng = [location.latitude, location.longitude];
|
||||
const accuracy = location.gpsAccuracy ?? 0;
|
||||
// Fit the accuracy circle so it is not mostly off-screen, capping the zoom
|
||||
if (accuracy > IMPRECISE_GPS_ACCURACY) {
|
||||
this._map?.fitBounds(circleBoundsPoints(center, accuracy), {
|
||||
pad: 0.2,
|
||||
zoom: FOCUS_PERSON_ZOOM,
|
||||
padding: this._overviewPadding(),
|
||||
});
|
||||
return;
|
||||
}
|
||||
this._map?.fitBounds([center], {
|
||||
zoom: FOCUS_PERSON_ZOOM,
|
||||
padding: this._overviewPadding(),
|
||||
});
|
||||
}
|
||||
|
||||
// The part of the map the overview covers, so fitted markers land next to
|
||||
// it rather than under it: the bottom sheet on phones, the start side
|
||||
// otherwise (see the #overview styles). Memoized so the map only refits
|
||||
// when the drawer actually changes size.
|
||||
private _overviewPadding(): MapFitPadding | undefined {
|
||||
// The overview, and its padding, only exist in panel layout.
|
||||
if (this.layout !== PANEL_VIEW_LAYOUT) {
|
||||
return undefined;
|
||||
}
|
||||
return this._paddingFor(
|
||||
this._overviewSize.width,
|
||||
this._overviewSize.height,
|
||||
this.hass.language,
|
||||
this.hass.translationMetadata.translations
|
||||
);
|
||||
}
|
||||
|
||||
private _paddingFor = memoizeOne(
|
||||
(
|
||||
width: number,
|
||||
height: number,
|
||||
language: string,
|
||||
translations: HomeAssistant["translationMetadata"]["translations"]
|
||||
): MapFitPadding | undefined => {
|
||||
if (!width || !height) {
|
||||
return undefined;
|
||||
}
|
||||
if (window.matchMedia("(max-width: 600px)").matches) {
|
||||
return { bottom: height + OVERVIEW_GAP };
|
||||
}
|
||||
const side = width + 2 * OVERVIEW_GAP;
|
||||
return computeRTL(language, translations)
|
||||
? { right: side }
|
||||
: { left: side };
|
||||
}
|
||||
);
|
||||
|
||||
private _toggleClusterMarkers() {
|
||||
this._clusterMarkers = !this._clusterMarkers;
|
||||
}
|
||||
@@ -636,10 +918,81 @@ class HuiMapCard extends LitElement implements LovelaceCard {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* The overview panel covers the start side in panel layout */
|
||||
#root.panel-layout #buttons {
|
||||
left: auto;
|
||||
inset-inline-start: auto;
|
||||
inset-inline-end: 3px;
|
||||
}
|
||||
|
||||
#root {
|
||||
position: relative;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
#overview {
|
||||
position: absolute;
|
||||
top: var(--ha-space-3);
|
||||
inset-inline-start: var(--ha-space-3);
|
||||
width: min(360px, calc(100% - 2 * var(--ha-space-3)));
|
||||
max-height: calc(100% - 2 * var(--ha-space-3));
|
||||
display: flex;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
#root.panel-layout {
|
||||
--map-bleed-left: var(--view-container-inset-left, 0px);
|
||||
--map-bleed-right: var(--view-container-inset-right, 0px);
|
||||
--map-bleed-bottom: var(--view-container-inset-bottom, 0px);
|
||||
}
|
||||
#card:has(#root.panel-layout) {
|
||||
overflow: visible;
|
||||
}
|
||||
#root.panel-layout ha-map {
|
||||
left: calc(-1 * var(--map-bleed-left));
|
||||
right: calc(-1 * var(--map-bleed-right));
|
||||
bottom: calc(-1 * var(--map-bleed-bottom));
|
||||
width: auto;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
/* Keep the attribution and scale ruler clear of the drawer: beside it on
|
||||
wide layouts, above it on phones. The controls sit at physical corners. */
|
||||
#root.panel-layout ha-map {
|
||||
--ha-map-left-inset: calc(
|
||||
var(--overview-width, 0px) + 2 * var(--ha-space-3) +
|
||||
var(--map-bleed-left)
|
||||
);
|
||||
--ha-map-right-inset: var(--map-bleed-right);
|
||||
--ha-map-bottom-inset: var(--map-bleed-bottom);
|
||||
}
|
||||
#root.panel-layout.rtl ha-map {
|
||||
--ha-map-left-inset: var(--map-bleed-left);
|
||||
--ha-map-right-inset: calc(
|
||||
var(--overview-width, 0px) + 2 * var(--ha-space-3) +
|
||||
var(--map-bleed-right)
|
||||
);
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
#overview {
|
||||
top: auto;
|
||||
bottom: 0;
|
||||
inset-inline-start: 0;
|
||||
inset-inline-end: 0;
|
||||
width: auto;
|
||||
max-height: 70%;
|
||||
}
|
||||
|
||||
#root.panel-layout ha-map,
|
||||
#root.panel-layout.rtl ha-map {
|
||||
--ha-map-left-inset: var(--map-bleed-left);
|
||||
--ha-map-right-inset: var(--map-bleed-right);
|
||||
--ha-map-bottom-inset: calc(
|
||||
var(--overview-height, 0px) + var(--ha-space-2)
|
||||
);
|
||||
}
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,79 @@
|
||||
import type {
|
||||
EntityHistoryState,
|
||||
HistoryStates,
|
||||
} from "../../../../data/history";
|
||||
import { UNAVAILABLE, UNKNOWN } from "../../../../data/entity/entity";
|
||||
|
||||
// A person or tracker without a location; skipped so a dropout is not an event
|
||||
const NO_LOCATION_STATES: string[] = [UNAVAILABLE, UNKNOWN];
|
||||
|
||||
/** One line of the overview's activity timeline */
|
||||
export interface ActivityEntry {
|
||||
state: string;
|
||||
when: Date;
|
||||
/** Zone detail: the person that arrived at or left the zone */
|
||||
personId?: string;
|
||||
arrived?: boolean;
|
||||
}
|
||||
|
||||
export const ACTIVITY_MAX_ENTRIES = 20;
|
||||
|
||||
/**
|
||||
* A person's state changes inside the window, newest first. History starts
|
||||
* with the state at the window's start, which is not an event; no-location
|
||||
* samples are skipped so a dropout does not count as a change.
|
||||
*/
|
||||
export const personActivity = (
|
||||
history: EntityHistoryState[] | undefined,
|
||||
since: number
|
||||
): ActivityEntry[] => {
|
||||
const entries: ActivityEntry[] = [];
|
||||
let previous: string | undefined;
|
||||
for (const entry of history ?? []) {
|
||||
if (NO_LOCATION_STATES.includes(entry.s)) {
|
||||
continue;
|
||||
}
|
||||
const changed = entry.s !== previous;
|
||||
previous = entry.s;
|
||||
if (!changed || entry.lu * 1000 < since) {
|
||||
continue;
|
||||
}
|
||||
entries.push({ state: entry.s, when: new Date(entry.lu * 1000) });
|
||||
}
|
||||
return entries.reverse().slice(0, ACTIVITY_MAX_ENTRIES);
|
||||
};
|
||||
|
||||
/**
|
||||
* Arrivals at and departures from a zone by the given persons, newest first.
|
||||
* A person is in the zone while their state equals zoneState.
|
||||
*/
|
||||
export const zoneActivity = (
|
||||
history: HistoryStates | undefined,
|
||||
personIds: string[],
|
||||
zoneState: string,
|
||||
since: number
|
||||
): ActivityEntry[] => {
|
||||
const entries: ActivityEntry[] = [];
|
||||
for (const personId of personIds) {
|
||||
let wasInZone: boolean | undefined;
|
||||
for (const entry of history?.[personId] ?? []) {
|
||||
if (NO_LOCATION_STATES.includes(entry.s)) {
|
||||
continue;
|
||||
}
|
||||
const inZone = entry.s === zoneState;
|
||||
// The first sample is an event only if it is an in-window arrival.
|
||||
const isEvent = wasInZone === undefined ? inZone : inZone !== wasInZone;
|
||||
if (isEvent && entry.lu * 1000 >= since) {
|
||||
entries.push({
|
||||
state: entry.s,
|
||||
personId,
|
||||
arrived: inZone,
|
||||
when: new Date(entry.lu * 1000),
|
||||
});
|
||||
}
|
||||
wasInZone = inZone;
|
||||
}
|
||||
}
|
||||
entries.sort((a, b) => b.when.getTime() - a.when.getTime());
|
||||
return entries.slice(0, ACTIVITY_MAX_ENTRIES);
|
||||
};
|
||||
@@ -1382,6 +1382,8 @@ class HUIRoot extends LitElement {
|
||||
align-items: center;
|
||||
font-size: var(--ha-font-size-xl);
|
||||
padding: 0px 12px;
|
||||
padding-right: calc(12px + var(--safe-area-inset-right, 0px));
|
||||
width: calc(100% + var(--safe-area-inset-right, 0px));
|
||||
font-weight: var(--ha-font-weight-normal);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
@@ -1389,7 +1391,13 @@ class HUIRoot extends LitElement {
|
||||
border-bottom: none;
|
||||
}
|
||||
.narrow .toolbar {
|
||||
padding: 0 4px;
|
||||
padding: 0 calc(4px + var(--safe-area-inset-right, 0px)) 0
|
||||
calc(4px + var(--safe-area-inset-left, 0px));
|
||||
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));
|
||||
}
|
||||
.main-title {
|
||||
margin-inline-start: var(--ha-space-6);
|
||||
@@ -1534,20 +1542,22 @@ class HUIRoot extends LitElement {
|
||||
display: flex;
|
||||
min-height: 100vh;
|
||||
box-sizing: border-box;
|
||||
--view-container-inset-left: 0px;
|
||||
--view-container-inset-right: var(--safe-area-inset-right);
|
||||
--view-container-inset-bottom: var(--safe-area-inset-bottom);
|
||||
padding-top: calc(
|
||||
var(--header-height) + var(--safe-area-inset-top) +
|
||||
var(--view-container-padding-top, 0px)
|
||||
);
|
||||
padding-right: var(--safe-area-inset-right);
|
||||
padding-inline-end: var(--safe-area-inset-right);
|
||||
padding-right: var(--view-container-inset-right);
|
||||
padding-bottom: calc(
|
||||
var(--safe-area-inset-bottom) +
|
||||
var(--view-container-inset-bottom) +
|
||||
var(--view-container-padding-bottom, 0px)
|
||||
);
|
||||
}
|
||||
.narrow hui-view-container {
|
||||
padding-left: var(--safe-area-inset-left);
|
||||
padding-inline-start: var(--safe-area-inset-left);
|
||||
--view-container-inset-left: var(--safe-area-inset-left);
|
||||
padding-left: var(--view-container-inset-left);
|
||||
}
|
||||
hui-view-container > * {
|
||||
display: flex;
|
||||
|
||||
@@ -3,7 +3,6 @@ import { css, html, LitElement } from "lit";
|
||||
import { customElement, property } from "lit/decorators";
|
||||
import { isComponentLoaded } from "../../common/config/is_component_loaded";
|
||||
import "../../components/ha-card";
|
||||
import "../../components/ha-md-list";
|
||||
import { isExternal } from "../../data/external";
|
||||
import "../../layouts/hass-subpage";
|
||||
import { haStyle } from "../../resources/styles";
|
||||
@@ -46,47 +45,42 @@ class HaProfileSectionBrowser extends LitElement {
|
||||
<div class="card-content">
|
||||
${this.hass.localize("ui.panel.profile.client_settings_detail")}
|
||||
</div>
|
||||
<ha-md-list>
|
||||
${
|
||||
this.hass.dockedSidebar !== "auto" || !this.narrow
|
||||
? html`
|
||||
<ha-force-narrow-row
|
||||
.hass=${this.hass}
|
||||
></ha-force-narrow-row>
|
||||
`
|
||||
: ""
|
||||
}
|
||||
${
|
||||
"vibrate" in navigator
|
||||
? html`
|
||||
<ha-set-vibrate-row
|
||||
.hass=${this.hass}
|
||||
></ha-set-vibrate-row>
|
||||
`
|
||||
: ""
|
||||
}
|
||||
${
|
||||
!isExternal &&
|
||||
isComponentLoaded(this.hass.config, "html5.notify")
|
||||
? html`
|
||||
<ha-push-notifications-row
|
||||
.hass=${this.hass}
|
||||
></ha-push-notifications-row>
|
||||
`
|
||||
: ""
|
||||
}
|
||||
<ha-set-suspend-row .hass=${this.hass}></ha-set-suspend-row>
|
||||
${
|
||||
!isMobileClient
|
||||
? html`
|
||||
<ha-enable-shortcuts-row
|
||||
id="shortcuts"
|
||||
.hass=${this.hass}
|
||||
></ha-enable-shortcuts-row>
|
||||
`
|
||||
: ""
|
||||
}
|
||||
</ha-md-list>
|
||||
${
|
||||
this.hass.dockedSidebar !== "auto" || !this.narrow
|
||||
? html`
|
||||
<ha-force-narrow-row
|
||||
.hass=${this.hass}
|
||||
></ha-force-narrow-row>
|
||||
`
|
||||
: ""
|
||||
}
|
||||
${
|
||||
"vibrate" in navigator
|
||||
? html`
|
||||
<ha-set-vibrate-row .hass=${this.hass}></ha-set-vibrate-row>
|
||||
`
|
||||
: ""
|
||||
}
|
||||
${
|
||||
!isExternal && isComponentLoaded(this.hass.config, "html5.notify")
|
||||
? html`
|
||||
<ha-push-notifications-row
|
||||
.hass=${this.hass}
|
||||
></ha-push-notifications-row>
|
||||
`
|
||||
: ""
|
||||
}
|
||||
<ha-set-suspend-row .hass=${this.hass}></ha-set-suspend-row>
|
||||
${
|
||||
!isMobileClient
|
||||
? html`
|
||||
<ha-enable-shortcuts-row
|
||||
id="shortcuts"
|
||||
.hass=${this.hass}
|
||||
></ha-enable-shortcuts-row>
|
||||
`
|
||||
: ""
|
||||
}
|
||||
</ha-card>
|
||||
</div>
|
||||
</hass-subpage>
|
||||
@@ -110,12 +104,6 @@ class HaProfileSectionBrowser extends LitElement {
|
||||
margin: 0 auto var(--ha-space-4);
|
||||
max-width: 600px;
|
||||
}
|
||||
|
||||
ha-md-list {
|
||||
background: none;
|
||||
padding-top: 0;
|
||||
padding-bottom: 0;
|
||||
}
|
||||
`,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -4,8 +4,7 @@ import { css, html, LitElement } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import "../../components/ha-button";
|
||||
import "../../components/ha-card";
|
||||
import "../../components/ha-md-list";
|
||||
import "../../components/ha-md-list-item";
|
||||
import "../../components/item/ha-row-item";
|
||||
import type { CoreFrontendUserData } from "../../data/frontend";
|
||||
import { subscribeFrontendUserData } from "../../data/frontend";
|
||||
import { showEditSidebarDialog } from "../../dialogs/sidebar/show-dialog-edit-sidebar";
|
||||
@@ -81,40 +80,38 @@ class HaProfileSectionPreferences extends LitElement {
|
||||
.narrow=${this.narrow}
|
||||
.hass=${this.hass}
|
||||
></ha-pick-dashboard-row>
|
||||
<ha-md-list>
|
||||
<ha-md-list-item>
|
||||
<span slot="headline"
|
||||
>${this.hass.localize(
|
||||
"ui.panel.profile.customize_sidebar.header"
|
||||
)}</span
|
||||
>
|
||||
<span slot="supporting-text"
|
||||
>${this.hass.localize(
|
||||
"ui.panel.profile.customize_sidebar.description"
|
||||
)}</span
|
||||
>
|
||||
<ha-button
|
||||
slot="end"
|
||||
appearance="plain"
|
||||
size="s"
|
||||
@click=${this._customizeSidebar}
|
||||
>
|
||||
${this.hass.localize(
|
||||
"ui.panel.profile.customize_sidebar.button"
|
||||
)}
|
||||
</ha-button>
|
||||
</ha-md-list-item>
|
||||
${
|
||||
this.hass.user!.is_admin
|
||||
? html`
|
||||
<ha-entity-id-picker-row
|
||||
.hass=${this.hass}
|
||||
.coreUserData=${this._coreUserData}
|
||||
></ha-entity-id-picker-row>
|
||||
`
|
||||
: ""
|
||||
}
|
||||
</ha-md-list>
|
||||
<ha-row-item>
|
||||
<span slot="headline"
|
||||
>${this.hass.localize(
|
||||
"ui.panel.profile.customize_sidebar.header"
|
||||
)}</span
|
||||
>
|
||||
<span slot="supporting-text"
|
||||
>${this.hass.localize(
|
||||
"ui.panel.profile.customize_sidebar.description"
|
||||
)}</span
|
||||
>
|
||||
<ha-button
|
||||
slot="end"
|
||||
appearance="plain"
|
||||
size="s"
|
||||
@click=${this._customizeSidebar}
|
||||
>
|
||||
${this.hass.localize(
|
||||
"ui.panel.profile.customize_sidebar.button"
|
||||
)}
|
||||
</ha-button>
|
||||
</ha-row-item>
|
||||
${
|
||||
this.hass.user!.is_admin
|
||||
? html`
|
||||
<ha-entity-id-picker-row
|
||||
.hass=${this.hass}
|
||||
.coreUserData=${this._coreUserData}
|
||||
></ha-entity-id-picker-row>
|
||||
`
|
||||
: ""
|
||||
}
|
||||
</ha-card>
|
||||
</div>
|
||||
</hass-subpage>
|
||||
@@ -142,12 +139,6 @@ class HaProfileSectionPreferences extends LitElement {
|
||||
margin: 0 auto var(--ha-space-4);
|
||||
max-width: 600px;
|
||||
}
|
||||
|
||||
ha-md-list {
|
||||
background: none;
|
||||
padding-top: 0;
|
||||
padding-bottom: 0;
|
||||
}
|
||||
`,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -1537,7 +1537,10 @@
|
||||
"title": "Map vacuum segments to areas",
|
||||
"no_segments": "No segments available",
|
||||
"area_label": "Area",
|
||||
"description": "Configure which areas correspond to each vacuum segment"
|
||||
"description": "Configure which areas correspond to each vacuum segment",
|
||||
"orphaned_header": "Segments no longer on the vacuum",
|
||||
"orphaned_description": "These areas were mapped to segments the vacuum no longer reports. Remove them to keep the mapping up to date.",
|
||||
"orphaned_remove": "Remove mapping for {name}"
|
||||
},
|
||||
"codemirror": {
|
||||
"open_documentation": "Open documentation"
|
||||
@@ -1958,9 +1961,6 @@
|
||||
"faq": "documentation",
|
||||
"editor": {
|
||||
"name": "Name",
|
||||
"use_device_name": "Use device name",
|
||||
"change_device_name_link": "change the device name",
|
||||
"restore_name": "Restore default name",
|
||||
"icon": "Icon",
|
||||
"icon_error": "Icons should be in the format 'prefix:iconname', like 'mdi:home'",
|
||||
"default_code": "Default code",
|
||||
@@ -2048,6 +2048,7 @@
|
||||
"entity_disabled": "This entity is disabled.",
|
||||
"enable_entity": "Enable",
|
||||
"open_device_settings": "Open device settings",
|
||||
"device_name_tip": "Consider renaming the device instead to update all its entities at once. {link}",
|
||||
"switch_as_x_confirm": "This switch will be hidden and a new {domain} will be added. Your existing configurations using the switch will continue to work.",
|
||||
"switch_as_x_remove_confirm": "This {domain} will be removed and the original switch will be visible again. Your existing configurations using the {domain} will no longer work!",
|
||||
"switch_as_x_change_confirm": "This {domain_1} will be removed and will be replaced by a new {domain_2}. Your existing configurations using the {domain_1} will no longer work!",
|
||||
@@ -6184,6 +6185,8 @@
|
||||
"edit_automation": "Edit automation",
|
||||
"older_trace": "Older trace",
|
||||
"newer_trace": "Newer trace",
|
||||
"previous_tracked_node": "Previous tracked node",
|
||||
"next_tracked_node": "Next tracked node",
|
||||
"no_traces_found": "No traces found",
|
||||
"trace_no_longer_available": "Chosen trace is no longer available",
|
||||
"enter_downloaded_trace": "Enter downloaded trace",
|
||||
@@ -9396,7 +9399,19 @@
|
||||
},
|
||||
"map": {
|
||||
"reset_focus": "Reset focus",
|
||||
"toggle_grouping": "Toggle grouping"
|
||||
"toggle_grouping": "Toggle grouping",
|
||||
"overview": {
|
||||
"people": "People",
|
||||
"devices": "Devices",
|
||||
"zones": "Zones",
|
||||
"no_people": "No people",
|
||||
"no_devices": "No devices with a location",
|
||||
"no_zones": "No zones",
|
||||
"activity": "Activity",
|
||||
"no_activity": "No recent activity",
|
||||
"activity_unavailable": "Activity couldn't be loaded",
|
||||
"people_in_zone": "{count, plural, =0 {No one here} one {{count} person} other {{count} people}}"
|
||||
}
|
||||
},
|
||||
"energy": {
|
||||
"loading": "Loading…",
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import "../../src/auth/ha-auth-form-string";
|
||||
import type { HaAuthFormString } from "../../src/auth/ha-auth-form-string";
|
||||
import type { LocalizeFunc } from "../../src/common/translations/localize";
|
||||
import type { HaFormStringSchema } from "../../src/components/ha-form/types";
|
||||
|
||||
const USERNAME_SCHEMA: HaFormStringSchema = {
|
||||
name: "username",
|
||||
type: "string",
|
||||
required: true,
|
||||
autocomplete: "username",
|
||||
};
|
||||
|
||||
let forms: HTMLFormElement[] = [];
|
||||
|
||||
const mount = async (
|
||||
schema: HaFormStringSchema,
|
||||
data?: string
|
||||
): Promise<HaAuthFormString> => {
|
||||
const form = document.createElement("form");
|
||||
const el = document.createElement("ha-auth-form-string");
|
||||
el.schema = schema;
|
||||
el.data = data as string;
|
||||
el.label = "Username";
|
||||
el.localize = ((key: string) => key) as unknown as LocalizeFunc;
|
||||
form.append(el);
|
||||
document.body.append(form);
|
||||
forms.push(form);
|
||||
await el.updateComplete;
|
||||
await el.querySelector("ha-auth-textfield")!.updateComplete;
|
||||
return el;
|
||||
};
|
||||
|
||||
afterEach(() => {
|
||||
forms.forEach((form) => form.remove());
|
||||
forms = [];
|
||||
});
|
||||
|
||||
describe("ha-auth-form-string", () => {
|
||||
// Password managers only find login fields whose native input is in the
|
||||
// light DOM (#51620), so this must not silently move into a shadow root.
|
||||
it("renders the native input in the light DOM", async () => {
|
||||
const el = await mount(USERNAME_SCHEMA);
|
||||
|
||||
const input = document.querySelector<HTMLInputElement>(
|
||||
'input[name="username"]'
|
||||
);
|
||||
expect(input).not.toBeNull();
|
||||
expect(el.shadowRoot).toBeNull();
|
||||
expect(input!.getAttribute("autocomplete")).toBe("username");
|
||||
expect(input!.id).toBe("username");
|
||||
expect(el.querySelector("label")!.htmlFor).toBe("username");
|
||||
expect(el.closest("form")!.elements.namedItem("username")).toBe(input);
|
||||
});
|
||||
|
||||
it("submits a value written to the native input without events", async () => {
|
||||
const el = await mount(USERNAME_SCHEMA);
|
||||
const received: string[] = [];
|
||||
el.addEventListener("value-changed", (ev) => {
|
||||
received.push((ev as CustomEvent).detail.value);
|
||||
});
|
||||
|
||||
el.querySelector("input")!.value = "admin";
|
||||
|
||||
expect(el.reportValidity()).toBe(true);
|
||||
expect(received).toEqual(["admin"]);
|
||||
});
|
||||
|
||||
it("reports an empty required field and can focus it", async () => {
|
||||
const el = await mount(USERNAME_SCHEMA);
|
||||
|
||||
expect(el.reportValidity()).toBe(false);
|
||||
|
||||
el.focus();
|
||||
expect(document.activeElement).toBe(el.querySelector("input"));
|
||||
});
|
||||
});
|
||||
@@ -8,7 +8,6 @@ import * as computeStateNameModule from "../../../src/common/entity/compute_stat
|
||||
import * as stripPrefixModule from "../../../src/common/entity/strip_prefix_from_entity_name";
|
||||
import type { HomeAssistant } from "../../../src/types";
|
||||
import {
|
||||
mockDevice,
|
||||
mockEntity,
|
||||
mockEntityEntry,
|
||||
mockStateObj,
|
||||
@@ -133,20 +132,6 @@ describe("computeEntityEntryName", () => {
|
||||
expect(computeEntityEntryName(entry, hass.devices)).toBe("Old Name");
|
||||
});
|
||||
|
||||
it("preserves an explicitly empty name instead of the integration name", () => {
|
||||
const entry = mockEntityEntry({
|
||||
device_id: "dev1",
|
||||
name: "",
|
||||
original_name: "Temperature",
|
||||
});
|
||||
const devices = { dev1: mockDevice({ id: "dev1", name: "Living room" }) };
|
||||
|
||||
expect(computeEntityEntryName(entry, devices)).toBe("");
|
||||
expect(computeEntityEntryName({ ...entry, name: null }, devices)).toBe(
|
||||
"Temperature"
|
||||
);
|
||||
});
|
||||
|
||||
it("returns undefined if no name, original_name, or device", () => {
|
||||
const entry = mockEntity({ entity_id: "light.kitchen" });
|
||||
const hass = {
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { EntityRegistryEntry } from "../../../src/data/entity/entity_regist
|
||||
import {
|
||||
entityMapColor,
|
||||
HOME_ZONE_ENTITY_ID,
|
||||
nextZoneColor,
|
||||
zoneColor,
|
||||
} from "../../../src/common/map/entity-map-colors";
|
||||
|
||||
@@ -62,6 +63,23 @@ describe("entity map colors", () => {
|
||||
expect(zoneColor("zone.quiet", false, entries, styles)).toBe("color-1");
|
||||
});
|
||||
|
||||
it("previews the next slot for a new zone", () => {
|
||||
const entries = [
|
||||
entry(HOME_ZONE_ENTITY_ID, 10), // excluded from the palette
|
||||
entry("zone.work", 20),
|
||||
entry("person.anne", 30),
|
||||
];
|
||||
|
||||
// Two ordered entities take color-1 and color-2, so the next is color-3
|
||||
expect(nextZoneColor(false, entries, styles)).toBe("color-3");
|
||||
});
|
||||
|
||||
it("mutes a new passive zone", () => {
|
||||
expect(nextZoneColor(true, [entry("zone.work", 10)], styles)).toBe(
|
||||
"secondary-text-color"
|
||||
);
|
||||
});
|
||||
|
||||
it("gives entities outside the registry a stable color", () => {
|
||||
const entries = [entry("zone.work", 10)];
|
||||
|
||||
|
||||
@@ -604,6 +604,27 @@ describe("MapLibreMapEngine", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("fitting", () => {
|
||||
it("keeps overlays clear of the fitted bounds", async () => {
|
||||
const { engine, map, ready } = await createEngine();
|
||||
await ready;
|
||||
|
||||
engine.fitBounds([[52, 4]], { maxZoom: 15, padding: { bottom: 200 } });
|
||||
expect(map.fitBounds).toHaveBeenCalledOnce();
|
||||
const [bounds, options] = map.fitBounds.mock.calls[0];
|
||||
// A single point centers on itself at the requested zoom
|
||||
expect(bounds[0]).toEqual([4, 52]);
|
||||
expect(bounds[1]).toEqual([4, 52]);
|
||||
expect(options.maxZoom).toBe(14);
|
||||
expect(options.padding).toEqual({
|
||||
top: 0,
|
||||
right: 0,
|
||||
bottom: 200,
|
||||
left: 0,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("markers", () => {
|
||||
it("hands a removed element back without MapLibre's positioning", async () => {
|
||||
const { engine, ready } = await createEngine();
|
||||
|
||||
@@ -0,0 +1,288 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { TimelineSegment } from "../../../src/components/chart/state-history-chart-timeline-data";
|
||||
import {
|
||||
downSampleTimelineSegments,
|
||||
generateStateHistoryChartTimelineData,
|
||||
} from "../../../src/components/chart/state-history-chart-timeline-data";
|
||||
import { createMockComputedStyle } from "../../fixtures/computed-style";
|
||||
import { createMockHass } from "../../fixtures/hass";
|
||||
import type { TimelineEntity } from "../../../src/data/history";
|
||||
|
||||
const segment = (
|
||||
state: string,
|
||||
start: number,
|
||||
end: number
|
||||
): TimelineSegment => ({ state, locState: state, start, end });
|
||||
|
||||
/** Alternating on/off segments covering [start, end) with the given duty. */
|
||||
const flapping = (
|
||||
start: number,
|
||||
end: number,
|
||||
onMs: number,
|
||||
offMs: number
|
||||
): TimelineSegment[] => {
|
||||
const segments: TimelineSegment[] = [];
|
||||
let time = start;
|
||||
while (time < end) {
|
||||
segments.push(segment("on", time, Math.min(time + onMs, end)));
|
||||
time += onMs;
|
||||
if (time >= end) break;
|
||||
segments.push(segment("off", time, Math.min(time + offMs, end)));
|
||||
time += offMs;
|
||||
}
|
||||
return segments;
|
||||
};
|
||||
|
||||
const spans = (segments: TimelineSegment[]) =>
|
||||
segments.map((s) => [s.state, s.start, s.end]);
|
||||
|
||||
const assertContiguous = (result: TimelineSegment[]) => {
|
||||
result.forEach((s, i) => {
|
||||
expect(s.end).toBeGreaterThan(s.start);
|
||||
if (i > 0) {
|
||||
expect(s.start).toBe(result[i - 1].end);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
describe("downSampleTimelineSegments", () => {
|
||||
it("leaves segments of at least one frame untouched", () => {
|
||||
const segments = [
|
||||
segment("off", 0, 450),
|
||||
segment("on", 450, 930),
|
||||
segment("off", 930, 2000),
|
||||
];
|
||||
const before = spans(segments);
|
||||
|
||||
const result = downSampleTimelineSegments(segments, 100);
|
||||
|
||||
expect(spans(result)).toEqual(before);
|
||||
expect(spans(segments)).toEqual(before);
|
||||
});
|
||||
|
||||
it("keeps a single sub-frame segment straddling a frame boundary intact", () => {
|
||||
const segments = [segment("on", 90, 150)];
|
||||
expect(spans(downSampleTimelineSegments(segments, 100))).toEqual([
|
||||
["on", 90, 150],
|
||||
]);
|
||||
});
|
||||
|
||||
it("collapses sub-frame runs to the dominant state per frame", () => {
|
||||
const segments = [
|
||||
...flapping(0, 500, 15, 5),
|
||||
...flapping(500, 1000, 5, 15),
|
||||
];
|
||||
expect(segments.length).toBe(100);
|
||||
|
||||
const result = downSampleTimelineSegments(segments, 100);
|
||||
|
||||
expect(spans(result)).toEqual([
|
||||
["on", 0, 500],
|
||||
["off", 500, 1000],
|
||||
]);
|
||||
assertContiguous(result);
|
||||
});
|
||||
|
||||
it("bounds the output by the number of frames and keeps the full span", () => {
|
||||
const segments = flapping(0, 100_000, 3, 7);
|
||||
expect(segments.length).toBeGreaterThan(19_000);
|
||||
|
||||
const result = downSampleTimelineSegments(segments, 100);
|
||||
|
||||
expect(spans(result)).toEqual([["off", 0, 100_000]]);
|
||||
assertContiguous(result);
|
||||
});
|
||||
|
||||
it("keeps exact bounds of a full-width segment between sub-frame runs", () => {
|
||||
const segments = [
|
||||
...flapping(0, 450, 15, 5),
|
||||
segment("unavailable", 450, 1337),
|
||||
...flapping(1337, 1800, 5, 15),
|
||||
];
|
||||
|
||||
const result = downSampleTimelineSegments(segments, 100);
|
||||
|
||||
expect(result).toContainEqual(
|
||||
expect.objectContaining({
|
||||
state: "unavailable",
|
||||
start: 450,
|
||||
end: 1337,
|
||||
})
|
||||
);
|
||||
assertContiguous(result);
|
||||
});
|
||||
|
||||
it("merges a chosen state into a following full-width segment of that state", () => {
|
||||
const segments = [
|
||||
...flapping(0, 400, 15, 5),
|
||||
segment("on", 400, 2000),
|
||||
segment("off", 2000, 3000),
|
||||
];
|
||||
|
||||
const result = downSampleTimelineSegments(segments, 100);
|
||||
|
||||
expect(spans(result)).toEqual([
|
||||
["on", 0, 2000],
|
||||
["off", 2000, 3000],
|
||||
]);
|
||||
});
|
||||
|
||||
it("does not modify the segments it is given", () => {
|
||||
const segments = [segment("on", 0, 100), segment("on", 100, 200)];
|
||||
const before = spans(segments);
|
||||
|
||||
const result = downSampleTimelineSegments(segments, 100);
|
||||
|
||||
expect(spans(result)).toEqual([["on", 0, 200]]);
|
||||
expect(spans(segments)).toEqual(before);
|
||||
});
|
||||
|
||||
it("treats a segment exactly one frame wide as full width", () => {
|
||||
const segments = [
|
||||
segment("on", 0, 40),
|
||||
segment("boiler", 40, 140),
|
||||
segment("on", 140, 180),
|
||||
];
|
||||
|
||||
expect(spans(downSampleTimelineSegments(segments, 100))).toEqual([
|
||||
["on", 0, 40],
|
||||
["boiler", 40, 140],
|
||||
["on", 140, 180],
|
||||
]);
|
||||
});
|
||||
|
||||
it("emits nothing for a frame no segment covers", () => {
|
||||
// zero-duration segments: two state changes sharing a timestamp
|
||||
const segments = [
|
||||
segment("a", 0, 1),
|
||||
segment("z", 99, 99),
|
||||
segment("y", 198, 198),
|
||||
segment("b", 297, 298),
|
||||
];
|
||||
|
||||
const result = downSampleTimelineSegments(segments, 100);
|
||||
|
||||
expect(spans(result)).toEqual([
|
||||
["a", 0, 100],
|
||||
["b", 200, 298],
|
||||
]);
|
||||
expect(result.every((r) => r.state !== null)).toBe(true);
|
||||
});
|
||||
|
||||
it("preserves a gap between two runs", () => {
|
||||
const segments = [...flapping(0, 300, 15, 5), ...flapping(500, 800, 15, 5)];
|
||||
|
||||
const result = downSampleTimelineSegments(segments, 100);
|
||||
|
||||
expect(spans(result)).toEqual([
|
||||
["on", 0, 300],
|
||||
["on", 500, 800],
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("generateStateHistoryChartTimelineData", () => {
|
||||
const baseParams = {
|
||||
states: createMockHass().states,
|
||||
computedStyles: createMockComputedStyle(),
|
||||
showNames: true,
|
||||
renderItem: () => null,
|
||||
} as const;
|
||||
|
||||
/** Alternating states whose dominant flips halfway through the range. */
|
||||
const flappingEntity = (entityId: string, changes: number) => {
|
||||
const data: TimelineEntity["data"] = [];
|
||||
let time = 0;
|
||||
for (let i = 0; i < changes; i++) {
|
||||
const on = i % 2 === 0;
|
||||
const state = on ? "on" : "off";
|
||||
data.push({ state, state_localize: state, last_changed: time });
|
||||
const dominant = i < changes / 2 ? on : !on;
|
||||
time += dominant ? 700 : 300;
|
||||
}
|
||||
return { entity_id: entityId, name: entityId, data, end: time };
|
||||
};
|
||||
|
||||
it("bounds the rectangle count by the chart width", () => {
|
||||
const { end, ...entity } = flappingEntity("binary_sensor.flapping", 60_000);
|
||||
const result = generateStateHistoryChartTimelineData({
|
||||
...baseParams,
|
||||
data: [entity],
|
||||
startTime: new Date(0),
|
||||
endTime: new Date(end),
|
||||
chartWidth: 1000,
|
||||
});
|
||||
|
||||
const data = result[0].data as { value: [string, Date, Date, string] }[];
|
||||
expect(data.map((d) => [d.value[3], +d.value[1], +d.value[2]])).toEqual([
|
||||
["on", 0, 15_000_000],
|
||||
["off", 15_000_000, end],
|
||||
]);
|
||||
});
|
||||
|
||||
it("bounds rows whose states are separated by sub-frame gaps", () => {
|
||||
// An empty state resets the state machine, leaving a gap before the next
|
||||
// one, so these segments are not contiguous.
|
||||
const changes = 40_000;
|
||||
const data: TimelineEntity["data"] = [];
|
||||
for (let i = 0; i < changes; i++) {
|
||||
const state = i % 2 === 0 ? "" : "on";
|
||||
data.push({ state, state_localize: state, last_changed: i * 1000 });
|
||||
}
|
||||
const result = generateStateHistoryChartTimelineData({
|
||||
...baseParams,
|
||||
data: [{ entity_id: "binary_sensor.blips", name: "Blips", data }],
|
||||
startTime: new Date(0),
|
||||
endTime: new Date(changes * 1000),
|
||||
chartWidth: 1000,
|
||||
});
|
||||
|
||||
const rects = result[0].data as { value: [string, Date, Date, string] }[];
|
||||
expect(rects.map((d) => [d.value[3], +d.value[1], +d.value[2]])).toEqual([
|
||||
["on", 1000, changes * 1000],
|
||||
]);
|
||||
});
|
||||
|
||||
// deliberately off the frame grid, so collapsing would move the bounds
|
||||
const slowData: TimelineEntity["data"] = [0, 1, 2, 3, 4].map((i) => ({
|
||||
state: i % 2 === 0 ? "on" : "off",
|
||||
state_localize: i % 2 === 0 ? "On" : "Off",
|
||||
last_changed: i * 1000 + 137,
|
||||
}));
|
||||
|
||||
it("keeps every rectangle when the chart width rounds down to zero", () => {
|
||||
const result = generateStateHistoryChartTimelineData({
|
||||
...baseParams,
|
||||
data: [{ entity_id: "binary_sensor.slow", name: "Slow", data: slowData }],
|
||||
startTime: new Date(0),
|
||||
endTime: new Date(5137),
|
||||
chartWidth: 0.5,
|
||||
});
|
||||
|
||||
expect((result[0].data as unknown[]).length).toBe(5);
|
||||
});
|
||||
|
||||
it("emits one rectangle per state change when they are wide enough", () => {
|
||||
const result = generateStateHistoryChartTimelineData({
|
||||
...baseParams,
|
||||
data: [{ entity_id: "binary_sensor.slow", name: "Slow", data: slowData }],
|
||||
startTime: new Date(0),
|
||||
endTime: new Date(5137),
|
||||
chartWidth: 1000,
|
||||
});
|
||||
|
||||
expect(
|
||||
(result[0].data as { value: [string, Date, Date, string] }[]).map((d) => [
|
||||
d.value[3],
|
||||
d.value[1],
|
||||
d.value[2],
|
||||
])
|
||||
).toEqual([
|
||||
["On", new Date(137), new Date(1137)],
|
||||
["Off", new Date(1137), new Date(2137)],
|
||||
["On", new Date(2137), new Date(3137)],
|
||||
["Off", new Date(3137), new Date(4137)],
|
||||
["On", new Date(4137), new Date(5137)],
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,47 @@
|
||||
import { expect, it } from "vitest";
|
||||
import "../../src/components/trace/hat-graph-branch";
|
||||
|
||||
it("refreshes reused branch tracking and geometry without replacing the slot", async () => {
|
||||
const graph = document.createElement("hat-graph-branch");
|
||||
const branch = document.createElement("div");
|
||||
let height = 40;
|
||||
Object.defineProperties(branch, {
|
||||
clientWidth: { get: () => 50 },
|
||||
clientHeight: { get: () => height },
|
||||
});
|
||||
graph.append(branch);
|
||||
document.body.append(graph);
|
||||
try {
|
||||
await graph.updateComplete;
|
||||
await graph.updateComplete;
|
||||
expect(graph._branches[0]).toMatchObject({
|
||||
height: 40,
|
||||
track: false,
|
||||
trackEnd: false,
|
||||
});
|
||||
|
||||
branch.setAttribute("track", "");
|
||||
branch.setAttribute("unfinished", "");
|
||||
await Promise.resolve();
|
||||
await graph.updateComplete;
|
||||
expect(graph._branches[0]).toMatchObject({ track: true, trackEnd: false });
|
||||
|
||||
branch.removeAttribute("unfinished");
|
||||
height = 80;
|
||||
branch.append(document.createElement("div"));
|
||||
await Promise.resolve();
|
||||
await graph.updateComplete;
|
||||
expect(graph._branches[0]).toMatchObject({
|
||||
height: 80,
|
||||
track: true,
|
||||
trackEnd: true,
|
||||
});
|
||||
|
||||
branch.removeAttribute("track");
|
||||
await Promise.resolve();
|
||||
await graph.updateComplete;
|
||||
expect(graph._branches[0]).toMatchObject({ track: false, trackEnd: false });
|
||||
} finally {
|
||||
graph.remove();
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,634 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { Action } from "../../src/data/script";
|
||||
import type {
|
||||
ActionTraceStep,
|
||||
AutomationTraceExtended,
|
||||
ScriptTraceExtended,
|
||||
} from "../../src/data/trace";
|
||||
import { TraceTree, type TraceBranch } from "../../src/data/trace-tree";
|
||||
|
||||
const timestamp = "2026-09-17T00:00:00Z";
|
||||
const step = { path: "sequence/0/then/0", timestamp };
|
||||
const lastPath = "sequence/0/then/0";
|
||||
|
||||
const createTrace = (
|
||||
sequence: Action[],
|
||||
records: ActionTraceStep[] = [],
|
||||
overrides: Partial<ScriptTraceExtended> = {}
|
||||
): ScriptTraceExtended => ({
|
||||
domain: "script",
|
||||
item_id: "test",
|
||||
run_id: "test",
|
||||
state: "stopped",
|
||||
script_execution: "finished",
|
||||
last_step: records[records.length - 1]?.path ?? null,
|
||||
timestamp: { start: timestamp, finish: timestamp },
|
||||
context: { id: "test", user_id: null },
|
||||
config: { alias: "Test", sequence },
|
||||
trace: records.reduce<Record<string, ActionTraceStep[]>>((steps, record) => {
|
||||
(steps[record.path] ??= []).push(record);
|
||||
return steps;
|
||||
}, {}),
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const createAutomationTrace = (
|
||||
config: AutomationTraceExtended["config"],
|
||||
records: ActionTraceStep[] = []
|
||||
): AutomationTraceExtended => {
|
||||
const { blueprint_inputs: _dropped, ...scriptTrace } = createTrace(
|
||||
[],
|
||||
records
|
||||
);
|
||||
return { ...scriptTrace, domain: "automation", trigger: null, config };
|
||||
};
|
||||
|
||||
// Branch completion is observed through the public branch flags, exercising
|
||||
// the same code paths the graph renders from.
|
||||
const thenBranchOf = (
|
||||
steps: Action[],
|
||||
records: ActionTraceStep[],
|
||||
overrides: Partial<ScriptTraceExtended> = {}
|
||||
): TraceBranch =>
|
||||
new TraceTree(createTrace([{ if: [], then: steps }], records, overrides))
|
||||
.sequence[0].branches[0];
|
||||
|
||||
describe("TraceTree", () => {
|
||||
it("normalizes nested branches and retains unexecuted nodes and original configs", () => {
|
||||
const leaf = { delay: 1 };
|
||||
const choose = {
|
||||
choose: {
|
||||
conditions: [],
|
||||
sequence: { repeat: { count: 2, sequence: leaf } },
|
||||
},
|
||||
default: { stop: "Fallback" },
|
||||
};
|
||||
const leafPath =
|
||||
"sequence/0/parallel/0/sequence/0/sequence/0/choose/0/sequence/0/repeat/sequence/0";
|
||||
const tree = new TraceTree(
|
||||
createTrace(
|
||||
[
|
||||
{
|
||||
parallel: [
|
||||
{ sequence: [{ sequence: [choose] }] },
|
||||
{ action: "light.turn_on" },
|
||||
],
|
||||
},
|
||||
],
|
||||
[{ path: leafPath, timestamp, result: { delay: 1, done: true } }]
|
||||
)
|
||||
);
|
||||
|
||||
const [parallel] = tree.sequence;
|
||||
const [executed, unexecuted] = parallel.branches;
|
||||
const chooseNode = executed.children[0].branches[0].children[0];
|
||||
const [choice, fallback] = chooseNode.branches;
|
||||
const repeat = choice.children[0];
|
||||
const result = repeat.branches[0].children[0];
|
||||
expect(chooseNode.config).toBe(choose);
|
||||
expect(choice.option).toBe(choose.choose);
|
||||
expect(result.config).toBe(leaf);
|
||||
expect(result.path).toBe(leafPath);
|
||||
expect(result.hasTrace).toBe(true);
|
||||
// Descendant records still identify the chosen branch when Core drops its result.
|
||||
expect(choice.hasTrace).toBe(true);
|
||||
expect(repeat.branches[0].finished).toBe(true);
|
||||
expect(fallback.hasTrace).toBe(false);
|
||||
expect(fallback.children[0].config).toBe(choose.default);
|
||||
expect(unexecuted.children[0].path).toBe(
|
||||
"sequence/0/parallel/1/sequence/0"
|
||||
);
|
||||
// The model classifies a modern `action:` key as a service (which drives
|
||||
// the generic node's icon), while the graph keeps rendering the generic
|
||||
// node for it, as the old `key in node` lookup did.
|
||||
expect(unexecuted.children[0].actionType).toBe("service");
|
||||
expect(unexecuted.hasTrace).toBe(false);
|
||||
expect(unexecuted.finished).toBe(false);
|
||||
});
|
||||
|
||||
it.each<Action>([{ choose: [] }, { if: [], then: [] }])(
|
||||
"distinguishes an implicit bypass from an error for %j",
|
||||
(action) => {
|
||||
const trace = createTrace([action], [{ path: "sequence/0", timestamp }]);
|
||||
const branches = new TraceTree(trace).sequence[0].branches;
|
||||
const bypass = branches[branches.length - 1]!;
|
||||
expect(bypass.hasTrace).toBe(true);
|
||||
expect(bypass.finished).toBe(true);
|
||||
expect(bypass.children).toEqual([]);
|
||||
|
||||
trace.trace["sequence/0"][0].error = "Failed to evaluate condition";
|
||||
const failed = new TraceTree(trace).sequence[0];
|
||||
expect(failed.error).toBe(true);
|
||||
expect(failed.branches.every((branch) => !branch.hasTrace)).toBe(true);
|
||||
}
|
||||
);
|
||||
|
||||
it("keeps both branch choices and condition outcomes across repeat iterations", () => {
|
||||
const condition = { condition: "template", value_template: "{{ ready }}" };
|
||||
const trace = createTrace(
|
||||
[{ if: [], then: condition, else: [] }],
|
||||
[
|
||||
{ path: "sequence/0", timestamp, result: { choice: "then" } },
|
||||
{ path: "sequence/0", timestamp, result: { choice: "else" } },
|
||||
{ path: "sequence/0/then/0", timestamp, result: { result: false } },
|
||||
{ path: "sequence/0/then/0", timestamp, result: { result: true } },
|
||||
]
|
||||
);
|
||||
const [thenBranch, elseBranch] = new TraceTree(trace).sequence[0].branches;
|
||||
expect(thenBranch.hasTrace).toBe(true);
|
||||
expect(elseBranch.hasTrace).toBe(true);
|
||||
expect(thenBranch.children[0].condition).toEqual({
|
||||
executed: true,
|
||||
passed: true,
|
||||
failed: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("exposes disabled inheritance, branch state, and presentation fields", () => {
|
||||
const tree = new TraceTree(
|
||||
createTrace(
|
||||
[
|
||||
{
|
||||
enabled: false,
|
||||
choose: [{ conditions: [], sequence: [{ delay: 1 }] }],
|
||||
default: [],
|
||||
},
|
||||
],
|
||||
[
|
||||
{ path: "sequence/0", timestamp },
|
||||
{
|
||||
path: "sequence/0/choose/0/sequence/0",
|
||||
timestamp,
|
||||
result: { delay: 1, done: false },
|
||||
},
|
||||
]
|
||||
)
|
||||
);
|
||||
const [choose] = tree.sequence;
|
||||
expect(choose.actionType).toBe("choose");
|
||||
expect(choose.disabled).toBe(true);
|
||||
const [choice, fallback] = choose.branches;
|
||||
expect(choice.disabled).toBe(true);
|
||||
expect(choice.children[0].disabled).toBe(true);
|
||||
expect(choice.unfinished).toBe(true);
|
||||
expect(fallback.unfinished).toBe(false);
|
||||
expect(choice.children[0].actionType).toBe("delay");
|
||||
expect(tree.getNode("sequence/0/choose/0")).toEqual({
|
||||
path: "sequence/0/choose/0",
|
||||
config: { conditions: [], sequence: [{ delay: 1 }] },
|
||||
type: "chooseOption",
|
||||
});
|
||||
});
|
||||
|
||||
it("masks not-triggered runs but keeps them selectable with navigation", () => {
|
||||
const trace = createAutomationTrace(
|
||||
{
|
||||
alias: "Test",
|
||||
triggers: [{ trigger: "state" }],
|
||||
actions: [],
|
||||
},
|
||||
[{ path: "trigger/0", timestamp }]
|
||||
);
|
||||
const unmasked = new TraceTree({ ...trace, not_triggered: false });
|
||||
expect(unmasked.triggers?.[0]).toMatchObject({
|
||||
hasTrace: true,
|
||||
track: true,
|
||||
});
|
||||
const masked = new TraceTree({ ...trace, not_triggered: true });
|
||||
expect(masked.triggers?.[0]).toMatchObject({
|
||||
hasTrace: true,
|
||||
track: false,
|
||||
notTriggered: true,
|
||||
});
|
||||
expect(masked.firstTracked).toMatchObject({ path: "trigger/0" });
|
||||
expect(masked.previousTracked("trigger/0")).toBeUndefined();
|
||||
expect(masked.nextTracked("trigger/0")).toBeUndefined();
|
||||
// Unknown paths restart from the first tracked node, as the graph did.
|
||||
expect(masked.nextTracked("missing")).toMatchObject({ path: "trigger/0" });
|
||||
});
|
||||
|
||||
it("maps condition outcomes and repeat badges for rendering", () => {
|
||||
const tree = new TraceTree(
|
||||
createTrace(
|
||||
[
|
||||
{ condition: "template", value_template: "{{ ready }}" },
|
||||
{ repeat: { count: 3, sequence: [{ delay: 1 }] } },
|
||||
],
|
||||
[
|
||||
{
|
||||
path: "sequence/0",
|
||||
timestamp,
|
||||
result: { result: true },
|
||||
},
|
||||
{ path: "sequence/1", timestamp },
|
||||
{
|
||||
path: "sequence/1/repeat/sequence/0",
|
||||
timestamp,
|
||||
changed_variables: { repeat: { index: 3 } },
|
||||
result: { delay: 1, done: false },
|
||||
},
|
||||
]
|
||||
)
|
||||
);
|
||||
const [condition, repeat] = tree.sequence;
|
||||
expect(condition.track).toBe(true);
|
||||
expect(repeat.badge).toBe(3);
|
||||
expect(repeat.branches[0].unfinished).toBe(true);
|
||||
expect(tree.trackedPaths).toEqual([
|
||||
"sequence/0",
|
||||
"sequence/1",
|
||||
"sequence/1/repeat/sequence/0",
|
||||
]);
|
||||
});
|
||||
|
||||
it("uses canonical trace paths for plural automation keys and nested triggers", () => {
|
||||
const tree = new TraceTree(
|
||||
createAutomationTrace({
|
||||
alias: "Test",
|
||||
triggers: [
|
||||
{ triggers: [{ trigger: "state", entity_id: "light.test" }] },
|
||||
],
|
||||
conditions: { condition: "template", value_template: "{{ ready }}" },
|
||||
actions: { action: "light.turn_on" },
|
||||
})
|
||||
);
|
||||
expect(tree.triggers?.map((node) => node.path)).toEqual(["trigger/0"]);
|
||||
expect(tree.conditions.map((node) => node.path)).toEqual(["condition/0"]);
|
||||
expect(tree.actions.map((node) => node.path)).toEqual(["action/0"]);
|
||||
expect(tree.sequence).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("TraceTree branch completion", () => {
|
||||
it("marks only branches with traced steps as tracked", () => {
|
||||
const [thenBranch, elseBranch] = new TraceTree(
|
||||
createTrace(
|
||||
[{ if: [], then: [{ delay: 1 }], else: [{ delay: 2 }] }],
|
||||
[step]
|
||||
)
|
||||
).sequence[0].branches;
|
||||
expect(thenBranch.hasTrace).toBe(true);
|
||||
expect(elseBranch.hasTrace).toBe(false);
|
||||
expect(elseBranch.finished).toBe(false);
|
||||
expect(elseBranch.unfinished).toBe(false);
|
||||
});
|
||||
|
||||
it.each<Action>([
|
||||
{ wait_template: "{{ false }}", continue_on_timeout: false },
|
||||
{ wait_for_trigger: [], continue_on_timeout: false },
|
||||
])("does not complete an aborted final wait: %j", (action) => {
|
||||
const branch = thenBranchOf(
|
||||
[action],
|
||||
[
|
||||
{
|
||||
...step,
|
||||
result: { wait: { completed: false, remaining: 0 }, timeout: true },
|
||||
},
|
||||
],
|
||||
{ script_execution: "aborted" }
|
||||
);
|
||||
expect(branch.finished).toBe(false);
|
||||
expect(branch.unfinished).toBe(true);
|
||||
});
|
||||
|
||||
it.each<Action>([
|
||||
{ wait_template: "{{ false }}", continue_on_timeout: true },
|
||||
{ wait_for_trigger: [], continue_on_timeout: true },
|
||||
{ wait_template: "{{ false }}" },
|
||||
{ wait_for_trigger: [] },
|
||||
])(
|
||||
"completes a timed-out wait when continuation is enabled: %j",
|
||||
(action) => {
|
||||
// Core reports `timeout: true` together with `wait.completed: false`;
|
||||
// with `continue_on_timeout` true (or omitted, which defaults to
|
||||
// continuing) the branch rejoins instead of stalling.
|
||||
const branch = thenBranchOf(
|
||||
[action],
|
||||
[
|
||||
{
|
||||
...step,
|
||||
result: { wait: { completed: false, remaining: 0 }, timeout: true },
|
||||
},
|
||||
]
|
||||
);
|
||||
expect(branch.finished).toBe(true);
|
||||
expect(branch.unfinished).toBe(false);
|
||||
}
|
||||
);
|
||||
|
||||
it.each([
|
||||
[true, 10, false, true],
|
||||
[false, 0, undefined, true],
|
||||
[false, 0, true, true],
|
||||
[false, 0, false, false],
|
||||
[false, 10, true, false],
|
||||
[false, null, true, false],
|
||||
] as const)(
|
||||
"handles completed=%s remaining=%s continue_on_timeout=%s",
|
||||
(completed, remaining, continueOnTimeout, expected) => {
|
||||
// A parallel sibling may be last_step, so wait data must stand on its own.
|
||||
const branch = thenBranchOf(
|
||||
[
|
||||
{
|
||||
wait_template: "{{ ready }}",
|
||||
continue_on_timeout: continueOnTimeout,
|
||||
},
|
||||
],
|
||||
[{ ...step, result: { wait: { completed, remaining } } }],
|
||||
{ last_step: "sequence/1" }
|
||||
);
|
||||
expect(branch.finished).toBe(expected);
|
||||
}
|
||||
);
|
||||
|
||||
it.each(["aborted", "cancelled", "error"] as const)(
|
||||
"does not complete the terminal branch when execution is %s",
|
||||
(scriptExecution) => {
|
||||
const records: ActionTraceStep[] =
|
||||
scriptExecution === "cancelled"
|
||||
? [step]
|
||||
: [{ ...step, error: "Failed" }];
|
||||
const finishedIn = (steps: Action[], lastStep?: string) => {
|
||||
const trace = createTrace([{ if: [], then: steps }], records, {
|
||||
script_execution: scriptExecution,
|
||||
...(lastStep !== undefined ? { last_step: lastStep } : {}),
|
||||
});
|
||||
return new TraceTree(trace).sequence[0].branches[0].finished;
|
||||
};
|
||||
expect(finishedIn([{ action: "light.turn_on" }])).toBe(false);
|
||||
expect(finishedIn([{ sequence: [] }], `${lastPath}/sequence/0`)).toBe(
|
||||
false
|
||||
);
|
||||
expect(
|
||||
finishedIn([{ action: "light.turn_on" }], "sequence/0/then/01")
|
||||
).toBe(false);
|
||||
}
|
||||
);
|
||||
|
||||
it("does not complete the currently running final action", () => {
|
||||
expect(
|
||||
thenBranchOf([{ delay: 10 }], [step], { state: "running" }).finished
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it.each(["sequence/0", "then/0", "repeat/sequence/0"])(
|
||||
"does not rejoin after a nested stop at %s, even when a sibling ran last",
|
||||
(descendant) => {
|
||||
const stopPath = `${lastPath}/${descendant}`;
|
||||
const baseTrace = createTrace(
|
||||
[{ if: [], then: [{ sequence: [] }] }],
|
||||
[],
|
||||
{
|
||||
last_step: "sequence/0/else/0",
|
||||
}
|
||||
);
|
||||
baseTrace.trace = {
|
||||
[lastPath]: [step],
|
||||
[stopPath]: [
|
||||
{ ...step, path: stopPath, result: { stop: "", error: false } },
|
||||
],
|
||||
};
|
||||
const finished = () =>
|
||||
new TraceTree(baseTrace).sequence[0].branches[0].finished;
|
||||
expect(finished()).toBe(false);
|
||||
|
||||
// A stop from an earlier invocation cannot stop this one.
|
||||
baseTrace.trace[lastPath] = [
|
||||
{ ...step, timestamp: "2026-09-17T00:00:01Z" },
|
||||
];
|
||||
expect(finished()).toBe(true);
|
||||
|
||||
// A false nested condition returns control to its enclosing sequence.
|
||||
baseTrace.trace[lastPath] = [step];
|
||||
baseTrace.trace[stopPath] = [
|
||||
{ ...step, path: stopPath, result: { result: false } },
|
||||
];
|
||||
expect(finished()).toBe(true);
|
||||
}
|
||||
);
|
||||
|
||||
it.each(["running", "cancelled"] as const)(
|
||||
"does not infer parallel completion from a sibling when %s",
|
||||
(execution) => {
|
||||
const path = "sequence/0/parallel/0/sequence/0";
|
||||
const siblingPath = "sequence/0/parallel/1/sequence/0";
|
||||
const sibling: ActionTraceStep = {
|
||||
...step,
|
||||
path: siblingPath,
|
||||
timestamp: "2026-09-17T00:00:01Z",
|
||||
};
|
||||
const finishedIn = (
|
||||
steps: Action[],
|
||||
record: ActionTraceStep
|
||||
): boolean => {
|
||||
const trace = createTrace([{ parallel: [{ sequence: steps }] }], [], {
|
||||
state: execution === "running" ? "running" : "stopped",
|
||||
script_execution: execution === "running" ? "finished" : execution,
|
||||
last_step: siblingPath,
|
||||
});
|
||||
trace.trace = {
|
||||
[path]: [record],
|
||||
[siblingPath]: [sibling],
|
||||
};
|
||||
return new TraceTree(trace).sequence[0].branches[0].finished;
|
||||
};
|
||||
expect(finishedIn([{ action: "script.slow" }], { ...step, path })).toBe(
|
||||
false
|
||||
);
|
||||
expect(
|
||||
finishedIn([{ delay: 10 }], {
|
||||
...step,
|
||||
path,
|
||||
result: { delay: 10, done: false },
|
||||
})
|
||||
).toBe(false);
|
||||
expect(
|
||||
finishedIn([{ delay: 10 }], {
|
||||
...step,
|
||||
path,
|
||||
result: { delay: 10, done: true },
|
||||
})
|
||||
).toBe(true);
|
||||
}
|
||||
);
|
||||
|
||||
it.each(["aborted", "error"] as const)(
|
||||
"keeps successful parallel branches green when a sibling leaves the run %s",
|
||||
(scriptExecution) => {
|
||||
const path = "sequence/0/parallel/0/sequence/0";
|
||||
const siblingPath = "sequence/0/parallel/1/sequence/0";
|
||||
const treeFor = (lastStep: string): TraceTree => {
|
||||
const trace = createTrace(
|
||||
[
|
||||
{
|
||||
parallel: [
|
||||
{ sequence: [{ action: "script.slow" }] },
|
||||
{ sequence: [{ action: "script.failing" }] },
|
||||
],
|
||||
},
|
||||
],
|
||||
[],
|
||||
{ script_execution: scriptExecution, last_step: lastStep }
|
||||
);
|
||||
trace.trace = {
|
||||
[path]: [{ ...step, path }],
|
||||
[siblingPath]: [{ ...step, path: siblingPath, error: "Failed" }],
|
||||
};
|
||||
return new TraceTree(trace);
|
||||
};
|
||||
const tree = treeFor(siblingPath);
|
||||
expect(tree.sequence[0].branches[0].finished).toBe(true);
|
||||
expect(tree.sequence[0].branches[1].finished).toBe(false);
|
||||
|
||||
// A successful sibling can also be the last one to start an action.
|
||||
expect(treeFor(path).sequence[0].branches[0].finished).toBe(true);
|
||||
}
|
||||
);
|
||||
|
||||
it("uses continuation after a branch as rejoin evidence", () => {
|
||||
const checkRejoin = (
|
||||
trace: ScriptTraceExtended | AutomationTraceExtended,
|
||||
select: (tree: TraceTree) => TraceBranch,
|
||||
path: string,
|
||||
nextPath: string
|
||||
) => {
|
||||
trace.trace = {
|
||||
[path]: [{ ...step, path }],
|
||||
[nextPath]: [
|
||||
{ ...step, path: nextPath, timestamp: "2026-09-17T00:00:01Z" },
|
||||
],
|
||||
};
|
||||
expect(select(new TraceTree(trace)).finished).toBe(true);
|
||||
// A prior iteration's continuation cannot prove this invocation finished.
|
||||
trace.trace[path][0].timestamp = "2026-09-17T00:00:02Z";
|
||||
expect(select(new TraceTree(trace)).finished).toBe(false);
|
||||
};
|
||||
|
||||
// Continuation after a nested if rejoins its enclosing parallel branch.
|
||||
checkRejoin(
|
||||
createTrace(
|
||||
[
|
||||
{
|
||||
parallel: [
|
||||
{
|
||||
sequence: [
|
||||
{ if: [], then: [{ action: "script.slow" }] },
|
||||
{ action: "script.next" },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
[],
|
||||
{ state: "running" }
|
||||
),
|
||||
(tree) => tree.sequence[0].branches[0].children[0].branches[0],
|
||||
"sequence/0/parallel/0/sequence/0/then/0",
|
||||
"sequence/0/parallel/0/sequence/1"
|
||||
);
|
||||
|
||||
// Continuation after a parallel branch rejoins the top-level sequence.
|
||||
checkRejoin(
|
||||
createTrace(
|
||||
[
|
||||
{ parallel: [{ sequence: [{ action: "script.slow" }] }] },
|
||||
{ action: "script.next" },
|
||||
],
|
||||
[],
|
||||
{ state: "running" }
|
||||
),
|
||||
(tree) => tree.sequence[0].branches[0],
|
||||
"sequence/0/parallel/0/sequence/0",
|
||||
"sequence/1"
|
||||
);
|
||||
|
||||
// Same rejoin under the plural automation action key.
|
||||
const automationTrace = createAutomationTrace(
|
||||
{
|
||||
alias: "Test",
|
||||
triggers: [],
|
||||
actions: [
|
||||
{ parallel: [{ sequence: [{ action: "script.slow" }] }] },
|
||||
{ action: "script.next" },
|
||||
],
|
||||
},
|
||||
[]
|
||||
);
|
||||
automationTrace.state = "running";
|
||||
checkRejoin(
|
||||
automationTrace,
|
||||
(tree) => tree.actions[0].branches[0],
|
||||
"action/0/parallel/0/sequence/0",
|
||||
"action/1"
|
||||
);
|
||||
});
|
||||
|
||||
it("allows a disabled wait to be skipped", () => {
|
||||
expect(
|
||||
thenBranchOf(
|
||||
[{ wait_template: "{{ false }}", enabled: false }],
|
||||
[{ ...step, result: { enabled: false } }]
|
||||
).finished
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it.each<Action>([
|
||||
{ action: "light.turn_on", enabled: false },
|
||||
{ stop: "Done", enabled: false },
|
||||
{ condition: "template", value_template: "{{ false }}", enabled: false },
|
||||
{ delay: 10, enabled: false },
|
||||
])("completes a disabled terminal action: %j", (action) => {
|
||||
// A disabled final action is skipped by Core, so its branch is done even
|
||||
// while a parallel sibling is still running.
|
||||
const branch = thenBranchOf(
|
||||
[action],
|
||||
[{ ...step, result: { enabled: false } }],
|
||||
{ state: "running" }
|
||||
);
|
||||
expect(branch.finished).toBe(true);
|
||||
expect(branch.unfinished).toBe(false);
|
||||
});
|
||||
|
||||
it("tracks an empty parallel branch when the parent ran", () => {
|
||||
const tree = new TraceTree(
|
||||
createTrace(
|
||||
[{ parallel: [{ sequence: [] }] }],
|
||||
[{ path: "sequence/0", timestamp }]
|
||||
)
|
||||
);
|
||||
const [branch] = tree.sequence[0].branches;
|
||||
expect(branch.hasTrace).toBe(true);
|
||||
expect(branch.finished).toBe(true);
|
||||
expect(branch.unfinished).toBe(false);
|
||||
});
|
||||
|
||||
it("preserves empty, unreached, error, condition and stop handling", () => {
|
||||
const finishedIn = (steps: Action[], records: ActionTraceStep[]) => {
|
||||
const trace = createTrace([{ if: [], then: steps }], records, {
|
||||
last_step: "sequence/1",
|
||||
});
|
||||
return new TraceTree(trace).sequence[0].branches[0].finished;
|
||||
};
|
||||
expect(finishedIn([], [step])).toBe(true);
|
||||
expect(finishedIn([{ action: "light.turn_on" }], [step])).toBe(true);
|
||||
expect(finishedIn([{ stop: "Done" }], [step])).toBe(false);
|
||||
expect(
|
||||
finishedIn([{ action: "light.turn_on" }, { delay: 1 }], [step])
|
||||
).toBe(false);
|
||||
expect(
|
||||
finishedIn([{ action: "light.turn_on" }], [{ ...step, error: "Failed" }])
|
||||
).toBe(false);
|
||||
expect(
|
||||
finishedIn(
|
||||
[{ action: "light.turn_on", continue_on_error: true }],
|
||||
[{ ...step, error: "Failed" }]
|
||||
)
|
||||
).toBe(true);
|
||||
expect(
|
||||
finishedIn(
|
||||
[{ condition: "template", value_template: "{{ false }}" }],
|
||||
[{ ...step, result: { result: false } }]
|
||||
)
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,128 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { EntityHistoryState } from "../../../../../src/data/history";
|
||||
import {
|
||||
ACTIVITY_MAX_ENTRIES,
|
||||
personActivity,
|
||||
zoneActivity,
|
||||
} from "../../../../../src/panels/lovelace/cards/map/map-activity";
|
||||
|
||||
// The window starts at t=1000s; samples are (state, seconds)
|
||||
const SINCE = 1000 * 1000;
|
||||
const sample = (s: string, seconds: number): EntityHistoryState =>
|
||||
({ s, lu: seconds, a: {} }) as EntityHistoryState;
|
||||
|
||||
describe("personActivity", () => {
|
||||
it("lists state changes inside the window, newest first", () => {
|
||||
const entries = personActivity(
|
||||
[sample("home", 900), sample("not_home", 1100), sample("work", 1200)],
|
||||
SINCE
|
||||
);
|
||||
expect(entries.map((e) => e.state)).toEqual(["work", "not_home"]);
|
||||
expect(entries[0].when.getTime()).toBe(1200 * 1000);
|
||||
});
|
||||
|
||||
it("counts the first sample only when it lies inside the window", () => {
|
||||
// Before the window it is the state at the window's start, not an event
|
||||
expect(personActivity([sample("home", 900)], SINCE)).toEqual([]);
|
||||
expect(personActivity([sample("home", 1100)], SINCE)).toEqual([
|
||||
{ state: "home", when: new Date(1100 * 1000) },
|
||||
]);
|
||||
});
|
||||
|
||||
it("ignores repeated states and unavailable or unknown dropouts", () => {
|
||||
const entries = personActivity(
|
||||
[
|
||||
sample("home", 900),
|
||||
sample("home", 1100),
|
||||
sample("unavailable", 1150),
|
||||
sample("home", 1160),
|
||||
sample("unknown", 1170),
|
||||
sample("home", 1180),
|
||||
sample("not_home", 1200),
|
||||
],
|
||||
SINCE
|
||||
);
|
||||
expect(entries.map((e) => e.state)).toEqual(["not_home"]);
|
||||
});
|
||||
|
||||
it("caps the list", () => {
|
||||
const history = Array.from({ length: 30 }, (_, i) =>
|
||||
sample(`zone_${i}`, 1001 + i)
|
||||
);
|
||||
expect(personActivity(history, SINCE)).toHaveLength(ACTIVITY_MAX_ENTRIES);
|
||||
});
|
||||
});
|
||||
|
||||
describe("zoneActivity", () => {
|
||||
it("reports arrivals and departures per person, newest first", () => {
|
||||
const entries = zoneActivity(
|
||||
{
|
||||
"person.anne": [
|
||||
sample("not_home", 900),
|
||||
sample("Work", 1100),
|
||||
sample("not_home", 1300),
|
||||
],
|
||||
"person.bob": [sample("home", 900), sample("Work", 1200)],
|
||||
},
|
||||
["person.anne", "person.bob"],
|
||||
"Work",
|
||||
SINCE
|
||||
);
|
||||
expect(
|
||||
entries.map((e) => [e.personId, e.arrived, e.when.getTime() / 1000])
|
||||
).toEqual([
|
||||
["person.anne", false, 1300],
|
||||
["person.bob", true, 1200],
|
||||
["person.anne", true, 1100],
|
||||
]);
|
||||
});
|
||||
|
||||
it("does not count moving between other zones or the window's first sample", () => {
|
||||
const entries = zoneActivity(
|
||||
{
|
||||
"person.anne": [
|
||||
sample("Work", 900),
|
||||
sample("home", 950),
|
||||
sample("Gym", 1100),
|
||||
],
|
||||
},
|
||||
["person.anne"],
|
||||
"Work",
|
||||
SINCE
|
||||
);
|
||||
// Leaving Work happened before the window; Gym is not Work
|
||||
expect(entries).toEqual([]);
|
||||
});
|
||||
|
||||
it("counts an in-window first sample as an arrival", () => {
|
||||
// A newly created person has no state at the window's start, so their
|
||||
// first sample inside the window is a real arrival
|
||||
const entries = zoneActivity(
|
||||
{ "person.anne": [sample("Work", 1100)] },
|
||||
["person.anne"],
|
||||
"Work",
|
||||
SINCE
|
||||
);
|
||||
expect(entries.map((e) => [e.arrived, e.when.getTime() / 1000])).toEqual([
|
||||
[true, 1100],
|
||||
]);
|
||||
});
|
||||
|
||||
it("bridges an unavailable or unknown dropout without an event", () => {
|
||||
const entries = zoneActivity(
|
||||
{
|
||||
"person.anne": [
|
||||
sample("Work", 900),
|
||||
sample("unavailable", 1100),
|
||||
sample("Work", 1150),
|
||||
sample("unknown", 1200),
|
||||
sample("Work", 1250),
|
||||
],
|
||||
},
|
||||
["person.anne"],
|
||||
"Work",
|
||||
SINCE
|
||||
);
|
||||
expect(entries).toEqual([]);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user