Compare commits

..
Author SHA1 Message Date
Petar Petrov a4d5a07e71 Drop unused bindings and styles from ha-auth-textfield 2026-09-20 18:46:02 +03:00
Petar Petrov f92926cb3b Restore light-DOM login inputs for password managers 2026-09-20 17:57:11 +03:00
karwosts 299e612646 Make helpers table robust to entity-id changes (#54261)
* Remove duplicate entityReg subscription in helpers

* initialize to undefined

* Make helpers table robust to entity-id changes
2026-09-20 11:04:50 +02:00
Jan-Philipp Benecke 5fa14d4173 Refresh trace graph primitives to match automation editor (#54253) 2026-09-20 11:04:37 +02:00
Petar Petrov a4d1cb0a57 Downsample the history timeline to the chart's pixel width (#54252)
* Extract the history timeline chart data transform

Move the body of state-history-chart-timeline's _generateData into
state-history-chart-timeline-data.ts, mirroring the line chart's
state-history-chart-line-data.ts. Environment inputs are passed in, so the
transform is a pure function that can be tested and benchmarked on its own.

No behaviour change.

* Downsample the history timeline to the chart's pixel width

The timeline emitted one rectangle per state change with no downsampling, so an
entity with tens of thousands of recorded changes produced tens of thousands of
rectangles and blocked the main thread for seconds inside ECharts' layout.

Bound the output by the chart's device-pixel width, the way ha-chart-base
already does for line series: segments at least one frame wide keep their exact
bounds, and runs of narrower ones resolve per frame to the state covering most
of that frame, merging with neighbours that resolve to the same state. Charts
whose state changes are all wider than a frame, which is the common case, keep
exactly the rectangles they had before.

The host also becomes a block box so it reports its own width; as an inline box
its clientWidth was always 0.

* Narrow the timeline data transform's hass parameter to the states it reads

* Pass entity states to the timeline data transform instead of hass
2026-09-20 10:49:25 +02:00
dependabot[bot] 43c3b51829 Bump the codeql-action group across 1 directory with 2 updates (#54274)
Bumps the codeql-action group with 2 updates in the / directory: [github/codeql-action/init](https://github.com/github/codeql-action) and [github/codeql-action/analyze](https://github.com/github/codeql-action).


Updates `github/codeql-action/init` from 4.37.9 to 4.38.0
- [Release notes](https://github.com/github/codeql-action/releases)
- [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md)
- [Commits](https://github.com/github/codeql-action/compare/cdf488f595d80d6e07e03d4674febd5ab45fa938...b96794f015dfd88f77b49b1c93e0fa7110f94c63)

Updates `github/codeql-action/analyze` from 4.37.9 to 4.38.0
- [Release notes](https://github.com/github/codeql-action/releases)
- [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md)
- [Commits](https://github.com/github/codeql-action/compare/cdf488f595d80d6e07e03d4674febd5ab45fa938...b96794f015dfd88f77b49b1c93e0fa7110f94c63)

---
updated-dependencies:
- dependency-name: github/codeql-action/init
  dependency-version: 4.38.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: codeql-action
- dependency-name: github/codeql-action/analyze
  dependency-version: 4.38.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: codeql-action
...

Signed-off-by: dependabot[bot] <[email protected]>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-20 09:04:49 +02:00
Jan-Philipp Benecke 66a454821a Show disabled automations as event chips instead of disabled bar (#54248) 2026-09-20 08:59:34 +03:00
Paulus SchoutsenandClaude Opus 5 4f713aa645 Fix card header line height when the title wraps (#54258)
* Tighten MCP card header line height

The shared ha-card header uses --ha-line-height-expanded (2), which leaves a
large gap between lines once "Model Context Protocol" wraps on narrow screens
or at a larger font scale. Use --ha-line-height-condensed for this header, the
same value the ZHA group pages already use.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Cg5a2sBXVms8HCs2t96DNE

* Fix card header line height for all ha-card

The shared header used --ha-line-height-expanded (2), so at 24px every line
box was 48px tall. That leading doubled as the header's vertical spacing, so
a header that wraps got a 48px gap between its lines.

Separate the two concerns: --ha-line-height-condensed sets the leading, and
the padding carries the spacing the half-leading used to provide. A
single-line header goes from 76px to 73px, so existing cards keep their
look, while each extra line now adds 29px instead of 48px.

Drop the per-card override in mcp-pref, which the default now covers.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Cg5a2sBXVms8HCs2t96DNE

---------

Co-authored-by: Claude Opus 5 <[email protected]>
2026-09-20 08:57:03 +03:00
Rick van Hattem cedc2d5324 Add extended date range presets to Activity (#54273) 2026-09-19 18:55:08 -07:00
karwosts 15290231ea Remove duplicate entityReg subscription in helpers (#54257)
* Remove duplicate entityReg subscription in helpers

* initialize to undefined
2026-09-19 23:35:04 +02:00
Bruno Pantaleão Gonçalves 5960dfea0e Let the table rows reach the edges of the screen (#54267)
* Let the table rows reach the edges of the screen

The rows stopped at the safe area, so their dividers and the shading
behind a group header ended in a line down the middle of the screen. The
rows now span the full width while their content keeps the inset, so
nothing readable moves and nothing sits under the cutout.

* Bleed the row and group header backgrounds too

* Bleed the table itself so every row variant lines up
2026-09-19 23:10:14 +02:00
Bruno Pantaleão Gonçalves d830220eff Let the filters bar reach the edges of the screen (#54266)
* Let the filters bar reach the edges of the screen

The row of filter and sort controls stopped at the safe area, so on a
device with a horizontal inset it sat in a narrower strip than the list
below it, its bottom border stopping short of the edge. The bar now
spans the full width, its controls scroll under the inset, and a spacer
at the end keeps the last one clear of it by the inset plus the usual
16px. A scrolled flex row drops its end padding, so that space has to be
an item rather than padding.

The subpage keeps the inset as padding inside its scroller instead of an
outer margin, so the bar can bleed into it, and a narrow data table
gives the header row the full width rather than clipping it. The rows of
the table itself stay inside the safe area as before.

* Address review on the filters bar safe area
2026-09-19 22:55:22 +02:00
Bruno Pantaleão Gonçalves c6f72c9cf5 Let the map fill the screen on the map dashboard (#54263)
* Let the map fill the screen on the map dashboard

A map in a panel view stopped at the safe area, leaving a strip of empty
card beside it, and the drawer stopped there too. Map tiles carry no text
to read, so the map and the drawer now run to the edges of the screen,
while everything that is read or tapped stays inside the safe area: the
drawer's content, the card's buttons, and the map's own controls, which
already had insets for the drawer and now take the safe area as well.

The view container exposes the insets it applies as custom properties so
the card can cancel exactly those, and the bottom sheet takes its side
insets from properties that default to the safe area.

* Address review on the map full bleed
2026-09-19 22:37:43 +02:00
Bruno Pantaleão Gonçalves 60f7728d54 Let the settings cards reach the edges of the screen (#54269)
On a phone the cards on the settings page are full-width bands with a
border on top and bottom. They stopped at the safe area, so on a device
with a horizontal inset every band ended in a line down the middle of
the screen. The bands now span the full width and pad their contents by
the inset, so the rows inside them do not move.
2026-09-19 22:22:28 +02:00
Bruno Pantaleão Gonçalves 6014ee7744 Draw the header border across the safe area (#54265)
The header's background already reaches the edges of the screen, but the
row that carries the bottom border sits inside the header's safe area
padding, so on a device with a horizontal inset the border stopped short
of the edge. The row now spans that padding while its content stays
where it was.
2026-09-19 22:07:22 +02:00
Bruno Pantaleão Gonçalves f32833fcbe Keep the bottom tab bar inside the horizontal safe area (#54262)
On a device with a left or right inset the tabs ran underneath it, so the
last tab was clipped by the rounded corner. The bar keeps its full width,
so its background still reaches the edges of the screen, and only the
tabs inside it are inset.
2026-09-19 21:54:59 +02:00
Bruno Pantaleão Gonçalves a9295ac21b Centre toasts in the safe area (#54270)
Toasts were centred on the viewport, so on a device whose safe area is
not symmetric they sat off centre and ran under the cutout. They now
take the same centring offset the dialogs use, and the stack is capped
at the safe width so a wide toast cannot reach past the inset either.
2026-09-19 21:52:17 +02:00
Petar Petrov 66cc3f62c5 Refresh voice expose table when an entity alias changes (#54233) 2026-09-19 09:40:32 +02:00
Petar Petrov fb1a75edd4 Defer chart work while the chart is not visible (#54251)
* Defer chart work while the chart is not visible

ha-chart-base did its full update path no matter whether anyone could see
the chart: every data or options change re-ran the line downsampling and
chart.setOption, and the resulting animation kept repainting the canvas.
That applied to charts in a section hidden by a visibility condition, to
charts scrolled far off a long page, and to every chart in a background
tab, where live history charts kept paying it on each stream message.

Gate the work on rendered size, an IntersectionObserver with one viewport
of margin, and document.visibilityState. While invisible the chart records
which properties changed and replays them in a single setOption once it is
visible again. The initial setup is deferred the same way, so a chart that
first renders hidden is built at its real width, and a synced zoom that
arrives before that setup is replayed instead of dropped.

* Track chart visibility from the observers instead of measuring

Reading getBoundingClientRect() in willUpdate forced a synchronous layout
for every chart on every data or options change, right after the parent had
committed DOM. Keep the state the observers already report instead: whether
the resize entry has a non-zero content box, and whether the intersection
observer sees the element. Both start true so a chart on screen still
renders on its first update, and firstUpdated keeps one measurement so a
chart that first renders inside a hidden container defers its setup.

The observer margin is one screenful only on a phone; a desktop gets a fixed
band, where a whole viewport would cover most of the page.

A reset to the full range now clears a stored zoom rather than replaying it,
and the global reset no longer skips a chart that has not been built yet, so
a deferred chart cannot come back zoomed after everything else was reset.

* Queue a synced zoom while a chart rebuild is pending

* Use the observed chart width for the default height and downsampling

* Use MobileAwareMixin for the chart's mobile checks
2026-09-19 09:34:14 +02:00
Maarten Lakerveld 8f7184023a Improve zone editor (#54031)
* Show a zone's own icon and color on the zone dialog map

The location selector's icon option was never applied: the expression
parsed as (icon || radius) ? radius-icon : marker-icon, so the zone
dialog always showed the radius marker instead of the zone's icon. The
selector also gains a color option, and both zone dialogs pass the
zone's icon and its map color (the config panel hands the dialog the
zone's entity id for that), so the map in the dialog looks like the zone
does everywhere else, including the home zone and a live passive toggle.

* Show zone markers in the zone list

The zones list on the zone config page shows each zone as it looks on
the map: its color with its icon, or initials without one, instead of
a plain icon.

* Sort zones alphabetically with home first

The zone config page listed UI zones sorted and YAML zones after them in
state order; the map overview sorted all zones by name. Both now show the
home zone first and the rest alphabetically.

* Color the zone list and dialogs from the registry context

The zone list avatar and the zone dialog map take the registry entries; the
home zone dialog needs none, its color is fixed.

* Show zone initials in the editor and resolve color from registry

Fall back to the zone's initials (matching the list and map) when it has
no icon, and to a plain map-marker when there is no name yet, instead of
the map-marker-radius pin.

Resolve the zone's entity id reactively from the registry context the
dialog already consumes, so the marker color is correct even when the
registry loads after the dialog opens (e.g. direct /edit/ deep link).

* Preview the palette color a new zone will take

Creating a zone has no entity yet, so the marker fell back to the accent
color and then changed on save. Derive the prospective creation-order
color from the registry so the create preview matches the saved zone.

* Test the new zone's previewed color

Cover nextZoneColor: the next creation-order slot for an active zone (home
excluded) and the muted color for a passive one, so an off-by-one in the
slot count can't silently make the create preview differ from the saved zone.
2026-09-19 09:30:31 +02:00
Maarten Lakerveld f3aba8f142 Activity timeline dots colored by zone in map panel (#54030)
* Color activity timeline dots by zone

Zone changes in a person's or device's activity timeline show the dot in
that zone's color, the same color as its marker on the map and its row
in the zones tab; away and unknown stay grey. Arrivals and departures in
a zone's own timeline keep their green and grey.

* Color activity dots from the registry context

The zone color of a timeline entry takes the registry entries the overview
already consumes.
2026-09-19 08:23:36 +03:00
tzagimandYosi Levy 9c28c21792 Fix RTL bidi corruption of min/max temperature in more-info weather dialog (#54255)
* Fix RTL bidi corruption of min/max temperature in more-info weather dialog

* Fix direction also on attribution style

---------

Co-authored-by: Yosi Levy <[email protected]>
2026-09-19 08:15:50 +03:00
Jan-Philipp Benecke ccceddfd35 Add missing labels to previous and next tracked node in trace graph (#54244) 2026-09-19 08:13:19 +03:00
Jan-Philipp Benecke e633e5fb3e Introduce trace tree model for automation/script trace graphs (#54246)
* Introduce trace tree model for the automation/script trace graphs

* Process code review comments

* Process missed comments
2026-09-18 20:25:07 +02:00
WendelinandSimon Lamon 9a311b0518 Migrate md lists in 7 files (#54220)
* Fix network info secondary text size

* Migrate profile

* Migrate config-entries to list-base

* Revert wrapper a for ha-dropdown-item

* Revert wrapper a for ha-dropdown-item

* Copliot review

* Prettier

---------

Co-authored-by: Simon Lamon <[email protected]>
2026-09-18 16:20:11 +03:00
c07f421f74 Regenerate statistics chart data when the formatters change (#54232)
* Regenerate statistics chart data when the formatters change

statistics-chart resolves its series names once and caches them in
_chartData, and it rebuilt only when the fetched statistics changed. A
rename therefore left the legend showing the old name.

The formatters are replaced as a set whenever the registries change, so
comparing formatEntityName identity is enough to catch it. This covers
the statistics graph card and the more-info history dialog; the energy
dashboard cards cache their series the same way and are handled
separately.

Co-Authored-By: Claude Opus 5 <[email protected]>

* Refresh statistics chart legend labels

Co-authored-by: MindFreeze <[email protected]>

---------

Co-authored-by: Claude Opus 5 <[email protected]>
Co-authored-by: copilot-swe-agent[bot] <[email protected]>
Co-authored-by: MindFreeze <[email protected]>
2026-09-18 09:25:57 +02:00
Maarten Lakerveld 62acda8c08 New Map Panel (#54029)
* Add people, devices, and zones overview to the map card in panel view

When the map card is the single card of a panel view it shows a
floating overview with People, Devices, and Zones tabs styled as an
animated segmented control; on mobile it becomes a bottom sheet drawer
that can be collapsed by tap or swipe. People list their zone or Away
state with the last update time, devices only appear when they have a
location, and zones show their occupant count while passive zones are
hidden in this view.

Selecting a row or map marker opens a detail view with a back button,
a name that opens the more info dialog, and a 24h activity timeline:
zone changes for a person or device, arrivals and departures per person
for a zone. Selecting focuses the map (street level for people, the
radius for zones, shown only while selected), marks the selected marker
with a thicker border, shows the accuracy circle when the position is
imprecise, and clicking the map deselects. In panel layout the overview
sits at the start side, so the zoom control and map buttons move to the
other side: ha-map gains a zoom-position property for that, and honours
--ha-map-bottom-inset so the attribution and scale stay clear of the
bottom sheet.

* Resubscribe the map card's colors when the connection changes

The card kept its entity color subscription on whichever connection it
first saw. It now remembers that connection and subscribes anew when
hass brings a different one, as ha-map already does.

* Pause auto-fit before a deferred fit, and never select an empty tab

An explicit fit that had to wait for the engine or for a size did not
pause auto-fit until it ran, so an auto-fit queued in between could take
its place. The pause comes first now.

The overview's selected tab could be an empty, disabled one, showing an
empty list while another tab had content and leaving aria-selected on a
disabled button; the selection falls back to the first tab with items,
which the tab stop already did.

* Reuse the cards' show-more-info label in the map overview

The overview added its own copy of a label the other cards already have
translated; it uses the shared key and the duplicate is gone.

* Keep a focused marker clear of the overview

Focusing a person or zone fitted the full viewport, so the marker could
land under the overview: the start side on desktop, the bottom sheet on
phones. Fits now take viewport padding, both engines apply it per fit,
and the card passes the overview's size as padding on the side it covers,
honoring right-to-left layouts.

* Report activity that could not load, and list only events inside the window

A failed history request, or an instance without the history integration,
read as no recent activity; the panel now says the activity could not be
loaded. The first history sample is the state at the window's start, not
a change inside it, so a person who has not moved for days no longer shows
that old state as recent activity: samples before the window only seed
the previous state, for zones and people alike.

* Take the map card and overview colors from the registry context

The card no longer runs its own color subscription, and the overview reads
the registry entries for its zone list.

* Keep automatic fits clear of the overview

Only a selection fit knew about the drawer; the first fit and every
auto-fit used the whole viewport, so markers could land under the drawer.
ha-map takes a fit padding that automatic fits apply, and refits when it
changes, so the map settles once the drawer reports its size.

* Let the activity timeline scroll inside the drawer

On phones the drawer body is capped at 60vh and clips its overflow for the
collapse animation, so activity entries beyond that were unreachable. The
activity box now scrolls within the drawer.

* Keep the scale ruler and attribution beside the drawer on wide layouts

The bottom inset that lifts the map controls above the drawer only applied
on phones, so on desktop a tall drawer covered the scale ruler. ha-map gains
left and right insets for its bottom controls, and the card reserves the
drawer's width on the side it occupies.

* Extract the activity timeline parsing and test it

The window seeding, unavailable skipping and zone transition rules moved
into pure functions with tests, so a regression in the People or Zones
timeline no longer needs manual checking.

* Show the activity timeline as logbook rows

The overview rendered its own timeline items. It now uses ha-logbook-entry
in its narrow layout, so activity reads like the logbook everywhere: the
person, the zone they are in, and when. Arrivals and departures on the zone
tab are the same rows for the person who moved. The card hands hass to the
overview only for these rows.

* Let the resizable bottom sheet stay open at a minimum height

A persistent sheet stops at a pixel minimum instead of closing when dragged
down, can open at its content height within a configurable share of the
viewport rather than at least 55%, reports its height after opening and
resizing, and lets a host shrink the grab area below the handle for content
that sits right under it. The map overview uses all of this on phones; the
automation editor keeps the previous behavior.

* Show the overview as a bottom sheet on phones

The overview had its own collapsible panel with a handle button and swipe
handling. On phones it now renders inside ha-resizable-bottom-sheet in its
persistent mode, so it resizes like the automation editor's sheet and stops
at a strip with the tabs instead of closing. The sheet reports its height
for the map's insets and fit padding; wide layouts keep the floating panel.

* Let only the newest activity load show its result

Comparing the selected entity after the fetch let an older load for the
same entity pass when the user selected it, moved away, and came back,
so a stale result or failure flag could overwrite the newer one. Number
the loads and let only the most recent one commit its result.

* Include people without a location in the map overview

show_all builds the map entity list from located entities only, so people
whose location is currently unknown never reached the overview's people
list (with its no-location rows) or its zone activity history. Build a
separate overview list that adds those people back, while the map markers
keep the location filter.

* Move focus with the map overview detail view

Selecting an item swapped the list for the detail view (and back) without
moving focus, so keyboard and screen-reader users lost their place on the
removed element. Focus the detail heading when it opens, and restore focus
to the originating item when returning to the list.

* Keep the bottom sheet's minimum height correct after opening

minHeight is a pixel value converted to a viewport percentage and cached
once the sheet opens. Recompute it when the viewport changes (e.g. rotation)
so the cached percentage can no longer clip the persistent peek content, and
emit bottom-sheet-resized once a raised minimum has grown the sheet, so the
host updates its overlay padding instead of holding a stale value until the
next drag.

* Address Copilot review on the map overview

- Keep locationless people in the map entities so a marker appears once
  they are located, not only after a rebuild
- Restore focus to the active tab when a back navigation's row is on
  another tab
- Only reserve overview padding in panel layout

* Draw the cluster bubble and its tail with one shadow

The bubble carried its own box-shadow while the tail sat behind it, so the
bubble's shadow fell across the tail and showed a seam. The bubble and tail now
share one drop-shadow on the wrapper, so the tail reads as part of the bubble.

* Bubble a lone person or device in a zone

A zone group only bubbled with more than one member, so a single person or
device in a zone showed as a plain marker instead of a bubble at the zone.
Keyed (zone) groups now bubble even with one member, pinned to the zone; a
marker that is not in a zone still shows plainly.

* Recover located-later device trackers in the show_all overview

show_all freezes the located entities at generation, so a standalone device
tracker that gains a location afterwards stayed out of the Devices tab until
reload. Recover eligible standalone trackers alongside people, skipping those
that belong to a person.
2026-09-17 22:28:18 +01:00
Paul Bottein 193cbcbfac Allow removing orphaned areas in vacuum segment mapping (#54179)
* Allow removing orphaned areas in vacuum segment mapping

* Type event handler and use RTL-safe padding in vacuum area mapper

* Include the area name in the remove mapping label
2026-09-17 20:09:26 +00:00
83 changed files with 5777 additions and 1317 deletions
+2 -2
View File
@@ -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"
+1 -1
View File
@@ -127,7 +127,7 @@ export class HaAuthFlow extends LitElement {
width: 100%;
}
</style>
<form>${this._renderForm()}</form>
<form @submit=${this._handleSubmit}>${this._renderForm()}</form>
`;
}
+56 -7
View File
@@ -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>
`;
}
}
+250
View File
@@ -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;
}
}
+1 -1
View File
@@ -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,
],
});
}
+31 -10
View File
@@ -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;
}
+15
View File
@@ -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,
+9
View File
@@ -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"]) {
+11 -5
View File
@@ -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;
+1 -1
View File
@@ -39,7 +39,7 @@ const FRAME_SIZES = [
].flat();
// Always rounds down, so no chart ends up with fewer frames than it asked for.
function snapFrameSize(step: number): number {
export function snapFrameSize(step: number): number {
if (step >= DAY) {
return Math.floor(step / DAY) * DAY;
}
+196 -37
View File
@@ -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,
+1 -1
View File
@@ -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;
}
+1 -1
View File
@@ -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);
}
});
+38 -12
View File
@@ -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;
}
+2 -2
View File
@@ -42,8 +42,8 @@ export class HaCard extends LitElement {
font-family: var(--ha-card-header-font-family, inherit);
font-size: var(--ha-card-header-font-size, var(--ha-font-size-2xl));
letter-spacing: -0.012em;
line-height: var(--ha-line-height-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;
+126 -17
View File
@@ -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,
+14 -2
View File
@@ -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 {
+67 -4
View File
@@ -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 {
+23 -8
View File
@@ -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);
+84 -21
View File
@@ -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);
+8 -1
View File
@@ -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;
+120 -24
View File
@@ -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 {
+9 -2
View File
@@ -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;
}
`;
+232 -436
View File
@@ -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);
}
}
+20
View File
@@ -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
View File
@@ -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[]
+4
View File
@@ -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;
}
+604
View File
@@ -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;
}
}
+20
View File
@@ -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 {
+8 -19
View File
@@ -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();
+15 -2
View File
@@ -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 {
+6 -6
View File
@@ -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 */
+10 -1
View File
@@ -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 {
-12
View File
@@ -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;
+58 -45
View File
@@ -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;
}
`,
];
}
@@ -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}
+42 -4
View File
@@ -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: {} } },
+175 -95
View File
@@ -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);
}
+6 -2
View File
@@ -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
+1
View File
@@ -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>
+368 -15
View File
@@ -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);
};
+16 -6
View File
@@ -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;
}
`,
];
}
+20 -5
View File
@@ -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…",
+77
View File
@@ -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 = {
+18
View File
@@ -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)],
]);
});
});
+47
View File
@@ -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();
}
});
+634
View File
@@ -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([]);
});
});