Compare commits

..
Author SHA1 Message Date
Petar Petrov 0ed9333dc2 Keep history line after unavailable gap in downsampled charts 2026-11-14 15:59:23 +02:00
renovate[bot] 2706bd89a1 Update dependency vite to v8.3.8 (#55906)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-11-14 08:55:55 +02:00
renovate[bot] bd434eeb0d Update dependency @rspack/core to v2.3.2 (#55899)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-11-13 17:11:21 +02:00
Petar Petrov 16f01e1081 Fix legends fitting as narrow in the card editor preview (#55886) 2026-11-13 14:28:15 +01:00
Dan SpencerandClaude Opus 5.5 050251db23 Offer a blueprint's trigger IDs in Triggered by conditions (#55802)
* Offer a blueprint's trigger IDs in Triggered by conditions

In an automation that uses a blueprint, a Triggered by condition in one
of its inputs listed no triggers, and flagged the IDs it already used as
missing. The editor took the list from the automation's own triggers,
and a blueprint automation has only `use_blueprint`.

For a blueprint automation, fetch the blueprint with its inputs filled
in (`blueprint/substitute`) and list the IDs its triggers set, fetching
again when the inputs change. A trigger without an ID isn't offered,
since the editor can't give one to a blueprint's trigger, and an ID
shared by several triggers is listed once, as the condition matches any
of them.

The condition is in one of the blueprint's inputs, which the trigger
controller doesn't edit, so its `select` returns false there, as
`updateTriggerId` does for a trigger it doesn't own, and the condition
editor saves the pick through `value-changed` instead.

An option shows its trigger number only while trigger rows show theirs
(`showIndices`): the numbers point at trigger rows, and a blueprint
automation has none.

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

* Ignore an empty use_blueprint while editing YAML

`use_blueprint:` with no value parses to null, and requesting its
triggers threw on `.path`, aborting the editor's update.

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

* Hold back trigger warnings until a blueprint's triggers are known

While a blueprint's triggers load, or when its inputs can't be substituted
yet, a Triggered by condition can't tell a missing ID from one it hasn't
seen. The trigger context now carries optionsStatus: "loaded", "loading" or
"failed". Until it's loaded, the condition and the row's summary list the
selected IDs without the missing-trigger warning or chips. An empty
condition shows "Loading", or a hint to set the blueprint's required inputs
once substitution has failed. After a failure the last triggers stay listed.

use_blueprint is also compared by value, since YAML mode parses a new object
on every edit and an edit elsewhere fetched the triggers again.

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

* Address review: spinner, refetch warnings and index badges

- Show a delayed ha-spinner while a blueprint's triggers load, following
  the frontend's loading pattern, instead of visible "Loading" text.
- Mark every request as loading, keeping the last triggers listed, so an
  input change that renames a trigger's ID doesn't read as missing until
  the new triggers arrive.
- Publish showIndices false for a blueprint automation. Switching to YAML
  removes the manual editor's sidebar without reporting it, which left
  the blueprint's condition numbering trigger rows that don't exist.
- Drop inline comments from the blueprint trigger code.

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

* Show trigger IDs when trigger rows aren't numbered

A Triggered by condition tells options apart by description, custom ID or
index. In a blueprint automation there are no indices, so two triggers
that describe the same with generated-prefixed IDs looked identical while
selecting different IDs. Without indices, every option's ID is shown.

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

* Say when a blueprint's triggers have no IDs

A blueprint whose triggers have no IDs loads with nothing to offer, and
the condition said "Add a trigger first", which can't be done in a
blueprint automation. optionsStatus is now blueprintStatus, left out for
an automation with its own triggers, so the condition can tell the two
apart and say the blueprint's triggers have no IDs.

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

* Rename blueprint_triggers_pending to blueprint_triggers_unavailable

It's only shown once substitution has failed, not while loading.

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

* Drop controller tests that cover one-line branches

Keeps the tests for the response race, the statuses and refetching.

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

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-11-13 06:26:48 +00:00
renovate[bot] 13128609de Update dependency @versatiles/style to v9.1.0 (#55893)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-11-13 08:22:31 +02:00
renovate[bot] 0f70813ada Update dependency marked to v18.2.0 (#55894)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-11-13 08:22:27 +02:00
Matthias 5762da9d22 Refresh the ingress session when the app panel becomes visible (#54863)
The ingress session is kept alive by a 60 s timer only. Timers do not run
while the device sleeps, so after the browser comes back to the foreground
the session may already be expired, and the Supervisor refuses every
request to the app until the next tick. An app that reconnects its
websocket right away stays unreachable for up to a minute.

Refresh the session on visibilitychange and resume as well. The refresh is
shared with the timer: validate the session, create a new one when that
fails, and never run twice at once. A failed refresh no longer surfaces as
an unhandled rejection, the next one retries.
2026-11-13 06:07:00 +00:00
renovate[bot] 33f709cd21 Update Node.js to v24.22.3 (#55892)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-11-13 06:54:27 +01:00
renovate[bot] 1f77e4cd59 Update dependency typescript-eslint to v8.72.2 (#55891)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-11-13 06:54:10 +01:00
Paul Bottein 77170b447f Fix favorites centering in the more info dialog (#55884) 2026-11-12 17:26:25 +02:00
c0ffeeca7 e2bb744202 MCP: change AI agent to AI app (#55885) 2026-11-12 17:25:49 +02:00
Wendelin 0eec83448a Revert camera ICE server filter and WebRTC-first memory (#55881)
* Revert camera ICE server filter and WebRTC-first memory

Follow-up to #55861 after review feedback:

- Use the full ICE server list from core again. The slow starts come from
  go2rtc, which stalls while it sets up TURN relays with the cloud ICE
  servers, so filtering in the browser does not help.
- Start HLS and WebRTC together again, remove the per-camera WebRTC-first
  memory and its 12 second HLS delay.
- Retry a WebRTC stream at most once, only after it played or on a backend
  error. A new connection that gets no answer or finds no path fails
  directly, so HLS or MJPEG takes over.

* Ignore stream URL errors of a removed HLS player

Switching cameras while the old HLS player was still fetching its stream
URL could mark HLS as failed for the new camera.
2026-11-12 14:51:53 +01:00
WendelinandClaude Sonnet 5.5 1864f6f802 Move the media browser list item to its own file (#55877)
Co-authored-by: Claude Sonnet 5.5 <[email protected]>
2026-11-12 13:26:28 +01:00
renovate[bot] 9a19cc3bf6 Update dependency vite to v8.3.7 (#55880)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-11-12 13:24:58 +01:00
WendelinandClaude Fable 5.2 ec51fb4f8d Add attributes to wrap text in list rows (#55864)
The headline and supporting text of ha-row-item are truncated to one
line. Many pages overrode that with white-space rules on the headline
and supporting-text parts.

Add the wrap-headline and wrap-supporting-text attributes and use them
in place of those CSS overrides.

Co-authored-by: Claude Fable 5.2 <[email protected]>
2026-11-12 13:20:40 +02:00
Paul Bottein 6a1a9bfb1b Suggest area icons based on the area name (#55708)
* Suggest area icons from the room in the area name

* Fix stale icon list, closed dialog and AI icon override

* Keep an untouched area dialog clean after the icon prefill

* Keep the suggested section visible when a suggested icon is selected

* Move area icon keywords to the translations

* Move room keywords to a shared translation key

* Remove home cinema and gym from the room list

* Note the Lokalise draft effect of editing room keywords

* Ship room keyword drafts and group keywords under rooms.keywords

* Add more room types for area icon suggestions

* Fetch room keyword drafts through the Lokalise keys API
2026-11-12 10:51:12 +01:00
Maarten Lakerveld 16807d2765 Make "Don't show again" a checkbox on the browser warning (#55863)
As a second button it was easy to press by accident. The reminder now has a
checkbox, and "Continue anyway" applies it.
2026-11-12 09:48:17 +01:00
renovate[bot] 9caad65c91 Update dependency eslint-plugin-import-x to v4.18.1 (#55874)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-11-12 08:16:21 +02:00
Bram KragtenandClaude Opus 5.5 af523f1087 Fix Matter add device dialog stuck on the spinner (#55869)
The dialog waited for the companion app's commission finish message on
every step, but only the app's own flow sends it. Steps that commission
through core or another app now advance as soon as the device appears.
A failed finish message after the device appeared also advances now.

The new device watcher unsubscribed twice after a find, which dropped
another subscriber from the device registry collection. After a few
adds the registry stopped updating and the dialog never saw the device.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-11-12 08:12:21 +02:00
renovate[bot] 349d5f7125 Update dependency intl-messageformat to v12.1.5 (#55872)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-11-12 07:01:41 +01:00
renovate[bot] de64bd3c59 Update formatjs (#55873)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-11-12 07:01:21 +01:00
Zedas 5253eef284 Allow a few pixels of finger movement in the action handler (#55826)
* Allow a few pixels of finger movement in the action handler

* Cancel the action handler press when the browser takes over the touch

The document scroll listener only hears the page itself, not nested
scrollers. pointercancel is composed and fires when the browser starts
scrolling a touch, so it covers those as well.
2026-11-11 22:51:14 +01:00
WendelinandClaude Fable 5.2 b1164739c3 Make camera WebRTC streams connect faster and more reliably (#55861)
* Make camera WebRTC streams connect faster and more reliably

- Limit the ICE server URLs the browser uses to one STUN, one UDP TURN
  and one TCP TURN URL per host. The last retry uses the full list.
- Start WebRTC first for cameras where it worked before in this browser,
  with HLS as fallback after 12 seconds.
- Always prefer WebRTC over HLS once it plays.
- Restart a WebRTC stream up to two times when it fails, loses its
  connection or does not connect in time, then report the failure so HLS
  takes over.
- Fix the ICE candidates added to the offer.

Co-Authored-By: Claude Fable 5.2 <[email protected]>

* Address review and connect the microphone over the known path

- Always forget a camera when WebRTC shows no video within the deadline.
- Fall back to MJPEG when a WebRTC only camera delivers audio but no
  video.
- Keep using all ICE servers once a stream only loaded with them.
- Start the microphone connection with only the ICE servers the running
  stream needs for its path.

Co-Authored-By: Claude Fable 5.2 <[email protected]>

* Keep all ICE servers for a direct microphone path that needed them

Co-Authored-By: Claude Fable 5.2 <[email protected]>

* Improve microphone connection handling in WebRTC player

---------

Co-authored-by: Claude Fable 5.2 <[email protected]>
2026-11-11 16:35:47 +01:00
Petar Petrov 80a516c673 Allow selecting values in the energy sources table (#55852) 2026-11-11 16:07:46 +01:00
Petar Petrov d5d0f45919 Move energy settings from dialogs to pages (#55410)
* Move energy settings from dialogs to an editor sidebar

Sources and devices on all energy configuration tabs now open in a
sidebar like automation steps, with the selected row highlighted and a
save button for unsaved changes. Cost tracking and power measurement type
use dropdowns, and the editor inputs are grouped with dividers. The energy
setup wizard keeps its dialogs, which now reuse the same editors.

* Read the energy editors' data from contexts

The energy editors, the power measurement config and the upstream device
picker consume contexts instead of hass, so the setup wizard dialog no
longer passes it. createConfigFlow, handleConfigFlowStep and
getSensorDeviceClassConvertibleUnits take callApi or callWS.

The sidebar slots the editor in, so switching to the bottom sheet keeps
unsaved changes, and the lists are inert while the editor saves.

* Confirm unsaved energy edits on the back arrow

The toolbar back arrow asks before discarding the editor's changes, like
the automation editor does, and the sidebar is inert while the editor
saves, like the lists.

* Edit energy sources and devices on pages of their own

Sources and devices open on a page with a back arrow, their name as the
title and the form in a card, instead of in a sidebar. The lists go back
to the centered column, and the energy setup wizard keeps its dialogs.

* Keep energy page saves and deletes apart

Save and Delete are disabled while the other runs, since both write all
preferences, and a page that was left no longer navigates when its save
or delete finishes.
2026-11-11 15:53:24 +01:00
Petar Petrov f922bc59a5 Drop hass from the state card content (#55853)
stateCardType, canToggleState and canToggleDomain take states and services; HassMediaPlayerEntity takes callService.
2026-11-11 15:36:31 +01:00
Petar Petrov a71a79b01b Drop hass from the target details dialog (#55850)
The dialog reads localize, callWS, states and entities from contexts, and resolveItemExclusions takes callWS.
2026-11-11 15:21:23 +01:00
Petar Petrov 9b8409d7ec Show model ID in the devices table when the model is missing (#55856) 2026-11-11 15:17:41 +01:00
Petar Petrov fc8d21b920 Point links in a duplicated dashboard at the copy (#55859) 2026-11-11 15:15:13 +01:00
Maarten Lakerveld fce0f4da4e Migrate the image cropper dialog to cropperjs 2 (#55570)
* Migrate the image cropper dialog to cropperjs 2

Cropper.js 2 is a rewrite built on shadow-DOM custom elements: the v1
class API (getData, getImageData, getCroppedCanvas, the ready and crop
callbacks) and the cropper.css stylesheet no longer exist, so the plain
Renovate bump (#55562) fails type checking and every rspack build.

Render the v2 elements directly from the dialog template instead of
wrapping an <img>. The canvas has no intrinsic size, so decode the file
first and give the canvas the image aspect ratio; the image then fills
it exactly. Initialise after the dialog has finished its scale-in
animation, because the elements measure themselves with
getBoundingClientRect, and refit the image and selection when the
canvas is resized.

Keep the v1 behaviour the callers rely on: the selection cannot leave
the image or the visible canvas, the image cannot be moved or zoomed out
from under it (viewMode 1), resizing stops at 50px, the rounded preview
is CSS only so the exported file stays rectangular, and the export is
rendered at the source resolution. Dirty tracking compares the crop
rectangle in source image pixels, like getData() did in v1.

* Clamp cropper drags to the edge and show image load errors

Cropper.js 2 moves the selection and image in relative steps per pointer
event. Rejecting a step that crossed the image edge dropped its movement on
both axes, so on fast drags the selection fell behind the cursor and got
stuck along an edge, mostly with portrait images. Clamp moves, resizes and
image pans to the edge instead, as v1 did. Fixed-ratio resizes shrink
around the anchored side to keep the ratio.

Show an error alert when the picture cannot be decoded instead of an empty
dialog.

* Keep the Shift-locked aspect ratio when a free crop hits the edge

With a free aspect ratio, Shift locks the ratio during a resize. The
out-of-bounds correction clipped the proposal instead of scaling it, so
the selection lost that ratio at the image or canvas edge.
2026-11-11 14:44:01 +01:00
WendelinandClaude Opus 5.5 2391c700c6 Expose action buttons in list rows to screen readers (#55831)
* Keep action controls in list rows reachable for screen readers

ha-list-item-button wrapped the whole row, including the start and end
slots, in its native button or link. Controls placed in those slots
were nested inside it and hidden from assistive technology.

Only the headline and supporting text are inside the native element now,
and its hit area is stretched over the row. Slotted controls sit above
it; icons, badges, text and empty dividers sit below, so they still
activate the row with native link behaviour.

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

* Rebind the list row ripple when the control is replaced

Switching href swaps the inner <a> and <button>, and media browser rows
drop the control when not interactive. The ripple kept listening to the
removed element, so the new control had no hover or press feedback.

Render the ripple after the control and recreate it with the control.

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

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-11-11 15:26:10 +02:00
R HOCH RandCopilot Autofix powered by AI 8cd79a6776 Add button to duplicate dashboards (#55832)
* Add option to duplicate dashboards

* Add error handling for missing dashboard config

Handle config not found error when fetching dashboard.

Co-authored-by: Copilot Autofix powered by AI <[email protected]>

---------

Co-authored-by: Copilot Autofix powered by AI <[email protected]>
2026-11-11 13:25:56 +02:00
Petar Petrov b4f6e4e2ea Drop hass from the target picker (#55855)
* Drop hass from the target picker

getEntities takes an options object of separate values; ha-target-picker, ha-sources-picker, ha-selector-target and the exclusions controller consume contexts.

* Rerender the target picker only when a pending entity appears

Consume statesContext without @state and narrow a second consumer to the pending helper entity.

* Redraw target picker icons when the brands token arrives

Also refetch target selector entity sources after a reconnect, like the entity selector.
2026-11-11 11:00:13 +01:00
Lorenzo Marroccoli 50bb1cf53e Prefer model ID over firmware in integration device rows (#55845)
* Prefer model ID over firmware in integration device rows

* Include model ID in integration device search
2026-11-11 11:59:54 +02:00
Petar Petrov 70a1946874 Fix lazy context provider error and reconnect handling (#55854)
* Fix lazy context provider error and detached consumer handling

Retry failed subscriptions, drop consumers that left before the first value, and give config entry updates a new array.

* Reset config entries on a new snapshot

Replace the list when a resubscribe sends all entries again, and log failed subscriptions.

* Clear config entries on an empty snapshot

Core sends an empty list when no entries match the subscription.
2026-11-11 10:42:49 +01:00
Paul Bottein 3da9ba8119 Add default option and icons to the entity type select (#55833)
* Add default option and icons to the entity type select

* Simplify the entity type select code

* Store the device class override separately from the effective class
2026-11-11 09:57:22 +01:00
Petar Petrov eb8ac4139b Translate YAML editor error reasons (#55836)
Translate common YAML parse error reasons in the editor
2026-11-11 09:46:18 +01:00
Petar Petrov f9aae72325 Use "remove" wording when removing devices (#55835)
* Use "remove" wording when removing devices

* Keep "delete" wording for config entries
2026-11-11 09:18:30 +01:00
Christian Wiedel 81279aa6d2 Allow checking off to-do items without a name (#55847)
* Allow checking off to-do items without a name

* Keep whitespace-only names rejected by core
2026-11-11 10:08:27 +02:00
Eric RouleauandCopilot 33b8afffca Show area in ZHA device bindings and neighbors (#55812)
* Show area in ZHA device bindings and neighbors

The 2026.11 naming change means device names no longer carry
their area, but the Manage Zigbee Device panel still showed only
the device name in two places, making identically named devices
indistinguishable:

- The Bindable devices dropdown in the Bindings tab now shows
  the area as the option's secondary line.
- The Neighbors tab gains a sortable, filterable Area column.

Area resolution is shared in getZHADeviceAreaName(): it resolves
via the device registry (so child devices inherit their parent's
area, matching the ZHA network visualization) and falls back to
the area reported by ZHA itself.

* Address Copilot review: registry-authoritative area, memoize by registries

- Treat an existing device registry entry as authoritative: if it
  has no effective area, the device has no area, even if ZHA still
  reports one from its cached response. The ZHA area_id fallback
  now applies only when the device has no registry entry at all,
  so clearing a device's area no longer leaves stale area text.
- Narrow getZHADeviceAreaName() to take the area and device
  registries instead of the whole hass object, and key the
  Neighbors memo on those registries. Entity-state updates replace
  hass, which rebuilt every neighbor row and reran table
  sorting/filtering on unrelated updates.

* Use device picker for ZHA bindings

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

* Reuse getZHADeviceAreaName helper in other places that also did a similar registry lookup

---------

Co-authored-by: Copilot <[email protected]>
2026-11-11 08:46:05 +02:00
renovate[bot] b157dd174e Update dependency maplibre-gl to v6.17.0 (#55844)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-11-11 08:25:17 +02:00
renovate[bot] 4a0381e82d Update dependency @vvo/tzdb to v6.204.0 (#55843)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-11-11 08:17:15 +02:00
renovate[bot] 92e248c913 Update dependency @versatiles/style to v9 (#55846)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-11-11 08:15:54 +02:00
Maarten Lakerveld 5b1ecdb0b0 Let browsers below the legacy floor continue at their own risk (#55829)
* Let browsers below the legacy floor continue at their own risk

Browsers below the floor get a "Continue anyway" button on the launch
screen instead of a dead end, e.g. for a wall dashboard. The warning returns
monthly, and from the first reminder on offers "Don't show again".

* Space the launch screen without max() on Chrome below 79

The launch screen's top spacer and OHF logo margins use max() with var()
inside. Chrome below 79 drops those after the cascade, leaving no margin,
so the logo touched the bottom edge (Chrome 67 to 78 are in the legacy
floor). Plain margins now come first, the max() rules behind @supports.

* Keep the browser warning up while the app loads

Once the user continued, the launch screen went blank until the legacy
build took over, which can take a while on these devices. Keep the message
and only drop the buttons.

* Say the browser is no longer supported instead of too old

With the option to continue it does run, so "too old to run Home Assistant"
contradicted the button. The same text now reads right before and after
continuing.

* Use a plain color for the launch screen buttons

Theme variables are not set yet while the launch screen shows, so
var(--primary-color) always fell back anyway.
2026-11-10 16:53:24 +01:00
Zedas ee35dd8cf6 Send the final media player volume only once (#55828) 2026-11-10 15:04:46 +01:00
Daan VervackeandAidan Timson 42ce434a94 Add selection mode with bulk remove to the Marketplace repositories page (#55435)
* Add selection mode with bulk remove to the Marketplace repositories page

* Guard bulk remove against double clicks and drop stale selections

* Clear the table selection when a selected repository gets installed

* Restore the comments that still apply

* Use a Marketplace string for the bulk action menu

* Drop rendering-only tests

* Uncheck data table rows that can no longer be selected

* Use one Remove chip and let the table drop installed rows

---------

Co-authored-by: Aidan Timson <[email protected]>
2026-11-10 14:40:47 +01:00
Zedas 06d564dbd8 End the ZHA permit subscription when the search ends (#55822)
* End the ZHA permit subscription when the search ends

* End the ZHA search when the connection drops

* Don't send a stale ZHA search after a reconnect
2026-11-10 14:19:10 +01:00
Petar Petrov 93f0c2c45a Hold the last value across gaps in history graphs (#55817)
* Hold the last value across gaps in history graphs

* Hold values across gaps in every graph, without an opt-in

* Hold the real last state across gaps and keep their curves tight

* Only hold stretches the raw history was quiet for

* Keep the temperature forecast curving across gaps

* Decide which stretches to hold once, from the raw history

* Hold neither across unavailable states nor below the sampling frame
2026-11-10 13:53:49 +01:00
renovate[bot] fb40d07621 Update dependency @html-eslint/eslint-plugin to v0.68.0 (#55825)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-11-10 11:53:04 +00:00
Zedas a6073e44f7 Translate the fold markers in the code editor (#55821) 2026-11-10 11:44:59 +00:00
Paulus SchoutsenandClaude Opus 5.5 df69f3c7ee Show which integrations use an integration loaded as a dependency (#55499)
* Show which integrations use an integration loaded as a dependency

Some integrations, like Modbus, have no config flow and are loaded
because another integration lists them in its manifest dependencies.
The frontend treated these as YAML integrations. The integration page
told the user to configure it in configuration.yaml, and the dashboard
showed a YAML card for it.

The integration page now fetches the loaded manifests for integrations
without a config flow. If a loaded integration depends on the domain,
the page shows "Used by <integrations>" instead of the YAML text. The
dashboard does not show a YAML card for a domain that a loaded
integration depends on.

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

* Show the integrations that use a dependency as linked cards

Replace the "Used by" text with a "Used by" section. It lists each
loaded integration that depends on this integration, with its logo and
a link to its integration page. The entries section shows "No entries"
instead of the YAML text for such an integration.

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

* Show only the "Used by" section for a dependency

An integration that other loaded integrations depend on, and that has no
config entries, now shows only the "Used by" section. The page does not
show the empty entries section. It also does not show the "Add entry"
button, which only opens the "set up in YAML" dialog for these
integrations.

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

* Match the demo Modbus and LoRaWAN manifests to core

Modbus and LoRaWAN have no config flow, and the integrations that use
them list them as dependencies. The demo now does the same, so their
integration pages show the "Used by" section.

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

* Hide system integrations from the "Used by" section

System integrations, like Default Configuration, depend on many
integrations. Listing them in "Used by" does not help the user.

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

* Read the dependents from the manifests context

The integration page now finds the integrations that depend on it from
the shared manifests context, instead of its own manifest/list call.
It subscribes to the context only for an integration without a config
flow, so other integration pages do not make the context fetch the
manifests.

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

* Link the integration page to the integration's settings panel

The Connectivity settings panels, like Zigbee and Modbus, are not
reachable from the integration page unless the integration has a config
entry. A user that opens the integration page looks for these settings.

The integration page now shows a button that opens the panel of the
integration. The button uses the panel name from the Connectivity page.
LoRaWAN, Modbus, Radio frequency, and USB (Serial) are added to the map
of integrations with a panel.

An integration with a panel and no config flow does not show the
"Add entry" button. For these integrations, it only opens the "set up
in YAML" dialog, and the panel is where to set them up.

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

* Hide the YAML texts for integrations set up in their panel

An integration with a settings panel and no config flow, like Infrared,
is set up in that panel. Its integration page told the user to set it up
in configuration.yaml. The page now shows only the settings button for
such an integration when it has no entries.

The demo Infrared and Radio frequency manifests now match core, so these
pages show it.

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

* Make the settings button primary when it is the only action

An integration that is set up in its panel shows no "Add entry" or
"Add device" button, so the settings button is its only action.

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

* Show the settings button only when there are no entries

Each config entry row of an integration with a panel already has a
link to that panel, so the button repeats it on pages like Zigbee.

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

* Link the MCP integration pages to the AI settings

The AI settings show how to connect to the MCP server and the URL of
each of its APIs. The MCP client and MCP server integration pages now
have an "AI settings" button that opens them. The button is the
primary action when the page has no "Add entry" button.

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

* Scroll the AI settings to the MCP card from the MCP pages

The AI settings page now scrolls to the section named in the URL hash,
like the backup settings page. The "AI settings" button on the MCP
integration pages links to the MCP card, which is below the AI task
card.

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

* Keep the demo route when opening the AI settings

The demo routes with the URL hash. The AI settings page cleared the
hash even when it did not name a section, so a reload of the demo lost
the page. It now clears the hash only after it scrolls to a section.

The "AI settings" button links to the page without a section in the
demo, because a link with a hash skips the demo route conversion.

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

* Move the settings panel and MCP buttons to their own PRs

This PR keeps the "Used by" section for integrations that other
integrations depend on. The settings panel button and the MCP link to
the AI settings move to separate PRs.

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

* Load the Modbus dependents in the demo

The dashboard reads the dependencies of loaded integrations only. The
demo listed Modbus as loaded, but not Flexit, SolarEdge Modbus, and
Fronius, so it showed a YAML card for Modbus.

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

* Sort the "Used by" list and wait for it before the YAML texts

The manifests come from a set in core, so their order can change
between restarts. The "Used by" list is now sorted by name.

Until the manifests load, the page does not know the dependents. It
showed the YAML texts and "Add entry" first and then replaced them with
"Used by". The page now holds back those texts and the button for an
integration without a config flow until the manifests load.

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

* Apply review feedback

- Make the settings button primary whenever it is the only action,
  like the MCP button. Thread, or MQTT with its one entry, have no
  "Add entry" button.
- Set the demo LoRaWAN manifest to the service type, like core, so the
  demo dashboard checks its dependents.
- Fix the punctuation of the YAML text.

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

* Show the entries while the dependents load

The page waits for the manifests of the loaded integrations before it
shows the YAML texts. If that request fails, the wait never ends. Only
a page without entries can show "Used by", so only such a page waits.
A page with entries now shows them even when the manifests do not load.

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

* Clear the manifest when the integration page changes domain

The integration page is reused when you follow a "Used by" link. It
kept the previous integration's manifest until the new one loaded, so
going back to Modbus from Flexit briefly showed "Add entry" and the
empty entries section. The page now clears the manifest on a domain
change, and a page without entries waits for the manifest before it
shows the YAML texts and "Add entry".

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

---------

Co-authored-by: Claude <[email protected]>
2026-11-10 10:55:56 +01:00
226 changed files with 11805 additions and 7819 deletions

No files matched your search

+1 -1
View File
@@ -1 +1 @@
24.22.2
24.22.3
+107 -42
View File
@@ -157,6 +157,99 @@ async function pollProcess(lokaliseApi, projectId, processId) {
}
/* eslint-enable no-await-in-loop */
const DRAFTS_ACCEPTED_KEYS = [
// Written per language rather than translated from English, so an English edit doesn't make them outdated
"ui.components.rooms.keywords",
];
async function exportTranslations(lokaliseApi, projectId) {
const exportProcess = await lokaliseApi.files().async_download(projectId, {
format: "json",
original_filenames: false,
replace_breaks: false,
json_unescaped_slashes: true,
export_empty_as: "skip",
filter_data: ["verified"],
});
const finishedProcess = await pollProcess(
lokaliseApi,
projectId,
exportProcess.process_id
);
const bundleUrl = finishedProcess.details.download_url;
console.log(`Downloading translations from: ${bundleUrl}`);
const response = await fetch(bundleUrl);
if (response.status !== 200 && response.status !== 0) {
throw new Error(response.statusText);
}
console.log(`Extracting translations...`);
const contents = await JSZip.loadAsync(await response.arrayBuffer());
const files = {};
await Promise.all(
Object.keys(contents.files).map(async (filename) => {
const file = contents.file(filename);
if (!file) {
// no file, probably a directory
return;
}
files[filename.split("/").splice(-1)[0]] = await file.async("string");
})
);
return files;
}
async function fetchDrafts(lokaliseApi, projectId) {
const english = JSON.parse(
await fs.readFile("src/translations/en.json", { encoding })
);
const keyNames = DRAFTS_ACCEPTED_KEYS.flatMap((key) => {
const keys = key.split(".");
const drafts = keys.reduce((obj, part) => obj?.[part], english) ?? {};
return Object.keys(drafts).map((name) => [...keys, name].join("::"));
});
if (!keyNames.length) {
return [];
}
const { items } = await lokaliseApi.keys().list({
project_id: projectId,
filter_keys: keyNames.join(","),
include_translations: 1,
limit: keyNames.length,
});
return items.flatMap((key) =>
key.translations
.filter(({ translation }) => translation)
.map(({ language_iso, translation }) => ({
filename: `${language_iso}.json`,
path: key.key_name.web.split("::"),
translation,
}))
);
}
function acceptDrafts(files, drafts) {
const data = {};
drafts.forEach(({ filename, path: keyPath, translation }) => {
data[filename] ??= files[filename] ? JSON.parse(files[filename]) : {};
const parent = keyPath.slice(0, -1).reduce((obj, part) => {
obj[part] ??= {};
return obj[part];
}, data[filename]);
parent[keyPath.at(-1)] = translation;
});
Object.entries(data).forEach(([filename, content]) => {
files[filename] = JSON.stringify(content, null, 2);
});
}
gulp.task("fetch-lokalise", async function () {
let apiKey;
try {
@@ -193,52 +286,24 @@ gulp.task("fetch-lokalise", async function () {
await Promise.all(
projects.map(async ([project, projectId]) => {
try {
const exportProcess = await lokaliseApi
.files()
.async_download(projectId, {
format: "json",
original_filenames: false,
replace_breaks: false,
json_unescaped_slashes: true,
export_empty_as: "skip",
filter_data: ["verified"],
});
const finishedProcess = await pollProcess(
lokaliseApi,
projectId,
exportProcess.process_id
);
const bundleUrl = finishedProcess.details.download_url;
console.log(`Downloading translations from: ${bundleUrl}`);
const response = await fetch(bundleUrl);
if (response.status !== 200 && response.status !== 0) {
throw new Error(response.statusText);
const [files, drafts] = await Promise.all([
exportTranslations(lokaliseApi, projectId),
project === "frontend"
? fetchDrafts(lokaliseApi, projectId)
: undefined,
]);
if (drafts) {
acceptDrafts(files, drafts);
}
console.log(`Extracting translations...`);
const contents = await JSZip.loadAsync(await response.arrayBuffer());
await mkdirPromise;
await Promise.all(
Object.keys(contents.files).map(async (filename) => {
const file = contents.file(filename);
if (!file) {
// no file, probably a directory
return;
}
const content = await file.async("nodebuffer");
await fs.writeFile(
path.join(inDir, project, filename.split("/").splice(-1)[0]),
content,
{ flag: "w", encoding }
);
})
Object.entries(files).map(([filename, content]) =>
fs.writeFile(path.join(inDir, project, filename), content, {
flag: "w",
encoding,
})
)
);
} catch (err) {
console.error(err);
@@ -44,14 +44,21 @@ export const lorawanFixtures: ConnectivityFixtures = {
],
commands: ["lorawan/"],
manifests: [
manifest("lorawan", "LoRaWAN", { integration_type: "helper" }),
manifest("lorawan", "LoRaWAN", {
config_flow: false,
integration_type: "service",
}),
manifest("sensecap_lorawan", "SenseCAP", {
integration_type: "device",
dependencies: ["lorawan"],
}),
manifest("chirpstack", "ChirpStack", { integration_type: "hub" }),
manifest("chirpstack", "ChirpStack", {
integration_type: "hub",
dependencies: ["lorawan"],
}),
manifest("thethingsnetwork_lorawan", "The Things Network", {
integration_type: "hub",
dependencies: ["lorawan"],
}),
],
configEntries: [
@@ -10,19 +10,26 @@ export const FRONIUS_ENTRY_ID = "mock-fronius";
export const RS485_PORT = "/dev/ttyUSB1";
export const modbusFixtures: ConnectivityFixtures = {
components: ["modbus"],
components: ["modbus", "flexit", "solaredge_modbus", "fronius"],
commands: ["modbus/"],
manifests: [
manifest("modbus", "Modbus", {
config_flow: false,
iot_class: "local_polling",
}),
manifest("flexit", "Flexit", {
integration_type: "device",
dependencies: ["modbus"],
iot_class: "local_polling",
}),
manifest("solaredge_modbus", "SolarEdge Modbus", {
integration_type: "device",
dependencies: ["modbus"],
iot_class: "local_polling",
}),
manifest("fronius", "Fronius", {
integration_type: "device",
dependencies: ["modbus"],
iot_class: "local_polling",
}),
],
-1
View File
@@ -25,7 +25,6 @@ class DemoMoreInfo extends LitElement {
!computeShowNewMoreInfo(state)
? html`<state-card-content
.stateObj=${state}
.hass=${this.hass}
in-dialog
></state-card-content>`
: nothing
+24 -5
View File
@@ -117,11 +117,17 @@ shared slots/attributes below.
**Attributes**
| Name | Type | Default | Description |
| ----------------- | ------- | ------- | --------------------------------------- |
| `headline` | String | — | Primary text. Overridden by the slot. |
| `supporting-text` | String | — | Secondary text. Overridden by the slot. |
| `disabled` | Boolean | `false` | Dims the row and blocks pointer events. |
| Name | Type | Default | Description |
| ---------------------- | ------- | ------- | -------------------------------------------------- |
| `headline` | String | — | Primary text. Overridden by the slot. |
| `supporting-text` | String | — | Secondary text. Overridden by the slot. |
| `wrap-headline` | Boolean | `false` | Wrap the headline instead of truncating it. |
| `wrap-supporting-text` | Boolean | `false` | Wrap the supporting text instead of truncating it. |
| `disabled` | Boolean | `false` | Dims the row and blocks pointer events. |
The headline and the supporting text stay on one line and are truncated with an
ellipsis by default. Set `wrap-headline` or `wrap-supporting-text` on rows whose
text has to stay readable in full, like settings with a description.
**CSS parts**
@@ -165,6 +171,18 @@ Interactive row. Renders an inner `<a>` when `href` is set, otherwise a
roving tabindex, the host is the tab stop and the inner element carries
`tabindex="-1"`.
Only the headline and supporting text are inside the `<a>` or `<button>`; its
hit area is stretched over the whole row. The `start` and `end` slots sit next
to it, so controls placed there, like an `ha-icon-button` or `ha-dropdown`,
stay separate controls for screen readers. Slotted elements are raised above
the hit area so they can be clicked, except icons, badges, `span`s and empty
`div`s (dividers), which let clicks and hover through to the row. A focusable
element (with `tabindex`) is always raised. To keep another element clickable
on its own, like a drag handle, give it `z-index: 1` in the consumer's styles.
Subclasses customize the row by overriding `_renderStart()`,
`_renderContent()` and `_renderEnd()`. `_renderInner()` is not used.
**Attributes**
- `href` (String) — when set, renders an `<a>` instead of a `<button>`.
@@ -174,6 +192,7 @@ roving tabindex, the host is the tab stop and the inner element carries
**CSS parts**
- `control` — the inner `<a>` or `<button>`.
- `ripple` — the ripple effect element.
### `<ha-list-item-option>`
+80
View File
@@ -2,10 +2,12 @@ import {
mdiAccount,
mdiChevronRight,
mdiCog,
mdiDelete,
mdiHome,
mdiInformationOutline,
mdiMapMarker,
mdiOpenInNew,
mdiPencil,
mdiViewDashboard,
mdiWifi,
} from "@mdi/js";
@@ -13,6 +15,8 @@ import type { TemplateResult } from "lit";
import { css, html, LitElement } from "lit";
import { customElement, state } from "lit/decorators";
import "../../../../src/components/ha-card";
import "../../../../src/components/ha-icon-button";
import "../../../../src/components/ha-icon-next";
import "../../../../src/components/ha-svg-icon";
import "../../../../src/components/item/ha-list-item-base";
import "../../../../src/components/item/ha-list-item-button";
@@ -77,6 +81,8 @@ const treeKey = (group: TreeGroup, child: TreeChild) =>
export class DemoHaList extends LitElement {
@state() private _buttonClicks = 0;
@state() private _lastAction = "none";
@state() private _single: number | Set<number> = -1;
@state() private _multiLine: number | Set<number> = new Set();
@@ -185,6 +191,20 @@ export class DemoHaList extends LitElement {
<ha-list-item-base headline="Disabled row" disabled>
<ha-svg-icon slot="start" .path=${mdiHome}></ha-svg-icon>
</ha-list-item-base>
<ha-list-item-base
headline="Truncated by default, a long headline stays on one line and ends with an ellipsis"
supporting-text="Long supporting text is truncated as well, so every row in the list keeps the same height"
>
<ha-svg-icon slot="start" .path=${mdiHome}></ha-svg-icon>
</ha-list-item-base>
<ha-list-item-base
wrap-headline
wrap-supporting-text
headline="With wrap-headline, a long headline wraps onto as many lines as it needs"
supporting-text="With wrap-supporting-text, long supporting text wraps too, which suits settings with a description"
>
<ha-svg-icon slot="start" .path=${mdiHome}></ha-svg-icon>
</ha-list-item-base>
</ha-list-base>
</ha-card>
@@ -220,6 +240,51 @@ export class DemoHaList extends LitElement {
</ha-list-base>
</ha-card>
<ha-card header="Row with actions in the end slot">
<p class="card-text">
Last action: <code>${this._lastAction}</code>. The action buttons are
separate controls; icons and the divider activate the row.
</p>
<ha-list-base aria-label="Rows with actions">
<ha-list-item-button data-action="row" @click=${this._onActionClick}>
<ha-svg-icon slot="start" .path=${mdiHome}></ha-svg-icon>
<span slot="headline">Button row</span>
<span slot="supporting-text">Click the row or an action</span>
<ha-icon-next slot="end"></ha-icon-next>
<div slot="end" class="vertical-divider"></div>
<ha-icon-button
slot="end"
data-action="edit"
.path=${mdiPencil}
label="Edit"
@click=${this._onActionClick}
></ha-icon-button>
<ha-icon-button
slot="end"
data-action="delete"
.path=${mdiDelete}
label="Delete"
@click=${this._onActionClick}
></ha-icon-button>
</ha-list-item-button>
<ha-list-item-button href="#components/ha-list">
<ha-svg-icon slot="start" .path=${mdiMapMarker}></ha-svg-icon>
<span slot="headline">Link row</span>
<span slot="supporting-text"
>Cmd/Ctrl-click on the icon or chevron opens a new tab</span
>
<ha-icon-button
slot="end"
data-action="link-edit"
.path=${mdiPencil}
label="Edit"
@click=${this._onActionClick}
></ha-icon-button>
<ha-icon-next slot="end"></ha-icon-next>
</ha-list-item-button>
</ha-list-base>
</ha-card>
<h2>ha-list-selectable + ha-list-item-option</h2>
<p>
Selectable list (<code>role="listbox"</code>). Items must be
@@ -428,6 +493,12 @@ selected: ${JSON.stringify(this._toJson(this._multiCheckEnd))}</pre>
return value instanceof Set ? [...value] : value;
}
private _onActionClick = (ev: MouseEvent) => {
ev.stopPropagation();
this._lastAction =
(ev.currentTarget as HTMLElement).dataset.action ?? "none";
};
private _onButtonClick = () => {
this._buttonClicks++;
};
@@ -542,6 +613,15 @@ selected: ${JSON.stringify(this._toJson(this._multiCheckEnd))}</pre>
ha-card {
max-width: 560px;
}
.card-text {
margin: 0;
padding: 0 var(--ha-space-4) var(--ha-space-2);
}
.vertical-divider {
align-self: stretch;
width: 1px;
background: var(--divider-color);
}
pre {
padding: var(--ha-space-4);
background: var(--secondary-background-color);
+21 -21
View File
@@ -54,15 +54,15 @@
"@codemirror/view": "6.43.15",
"@date-fns/tz": "1.5.0",
"@egjs/hammerjs": "2.0.17",
"@formatjs/intl-datetimeformat": "8.0.2",
"@formatjs/intl-displaynames": "8.0.1",
"@formatjs/intl-durationformat": "0.12.3",
"@formatjs/intl-getcanonicallocales": "3.3.0",
"@formatjs/intl-listformat": "9.0.1",
"@formatjs/intl-locale": "5.4.2",
"@formatjs/intl-numberformat": "10.0.2",
"@formatjs/intl-pluralrules": "7.0.2",
"@formatjs/intl-relativetimeformat": "13.0.1",
"@formatjs/intl-datetimeformat": "8.0.3",
"@formatjs/intl-displaynames": "8.0.2",
"@formatjs/intl-durationformat": "0.12.4",
"@formatjs/intl-getcanonicallocales": "3.3.1",
"@formatjs/intl-listformat": "9.0.2",
"@formatjs/intl-locale": "5.4.3",
"@formatjs/intl-numberformat": "10.0.3",
"@formatjs/intl-pluralrules": "7.0.3",
"@formatjs/intl-relativetimeformat": "13.0.2",
"@home-assistant/webawesome": "3.14.0-ha.2",
"@lezer/common": "1.5.5",
"@lezer/highlight": "1.2.5",
@@ -79,16 +79,16 @@
"@swc/helpers": "0.5.24",
"@tsparticles/engine": "4.5.3",
"@tsparticles/preset-links": "4.5.3",
"@versatiles/style": "8.1.1",
"@versatiles/style": "9.1.0",
"@vibrant/color": "4.0.4",
"@vvo/tzdb": "6.203.0",
"@vvo/tzdb": "6.204.0",
"@webcomponents/scoped-custom-element-registry": "0.0.10",
"barcode-detector": "3.2.3",
"cally": "0.9.2",
"color-name": "2.1.1",
"comlink": "4.4.2",
"core-js": "3.51.0",
"cropperjs": "1.7.0",
"cropperjs": "2.3.1",
"culori": "4.0.2",
"date-fns": "4.4.0",
"decimal.js": "10.6.0",
@@ -102,14 +102,14 @@
"hls.js": "1.7.4",
"home-assistant-js-websocket": "9.8.1",
"idb-keyval": "6.3.0",
"intl-messageformat": "12.1.4",
"intl-messageformat": "12.1.5",
"js-yaml": "5.5.0",
"leaflet": "1.9.4",
"leaflet.markercluster": "1.5.3",
"lit": "3.3.3",
"lit-html": "3.3.3",
"maplibre-gl": "6.16.0",
"marked": "18.1.3",
"maplibre-gl": "6.17.0",
"marked": "18.2.0",
"memoize-one": "6.0.0",
"nanoid": "6.0.2",
"node-vibrant": "4.0.4",
@@ -147,13 +147,13 @@
"@eslint/js": "10.0.1",
"@gfx/zopfli": "1.0.15",
"@home-assistant/components": "workspace:*",
"@html-eslint/eslint-plugin": "0.67.0",
"@html-eslint/eslint-plugin": "0.68.0",
"@lokalise/node-api": "17.0.1",
"@octokit/auth-oauth-device": "8.0.5",
"@octokit/rest": "22.0.1",
"@playwright/test": "1.65.0",
"@rsdoctor/rspack-plugin": "1.6.5",
"@rspack/core": "2.3.1",
"@rspack/core": "2.3.2",
"@rspack/dev-server": "2.2.2",
"@types/babel__plugin-transform-runtime": "7.9.5",
"@types/chromecast-caf-receiver": "7.0.1",
@@ -180,7 +180,7 @@
"eslint": "10.14.0",
"eslint-config-prettier": "10.1.8",
"eslint-import-resolver-webpack": "0.13.11",
"eslint-plugin-import-x": "4.18.0",
"eslint-plugin-import-x": "4.18.1",
"eslint-plugin-lit": "2.3.1",
"eslint-plugin-lit-a11y": "5.1.3",
"eslint-plugin-unused-imports": "4.4.1",
@@ -214,8 +214,8 @@
"terser-webpack-plugin": "5.6.1",
"ts-lit-plugin": "2.0.2",
"typescript": "6.0.3",
"typescript-eslint": "8.72.1",
"vite": "8.3.6",
"typescript-eslint": "8.72.2",
"vite": "8.3.8",
"vite-tsconfig-paths": "6.1.1",
"vitest": "5.1.0",
"webpack-stats-plugin": "1.1.3",
@@ -224,6 +224,6 @@
},
"packageManager": "[email protected]",
"volta": {
"node": "24.22.2"
"node": "24.22.3"
}
}
+515 -426
View File
File diff suppressed because it is too large. Load diff
@@ -89,10 +89,19 @@ export class LegendFitController {
private _fit() {
const list = this._element;
const rowWidth = list?.getBoundingClientRect().width;
if (!list || !rowWidth) {
if (!list) {
return;
}
const style = getComputedStyle(list);
const rowWidth = parseFloat(style.width);
// Bounding rects include transforms, such as a dialog scaling in, which
// never resize the list, so measure in layout pixels
const scale = list.getBoundingClientRect().width / rowWidth;
if (!rowWidth || !scale) {
return;
}
const layoutWidth = (element: Element) =>
element.getBoundingClientRect().width / scale;
const children = Array.from(list.children);
const more = children.find((child) =>
child.classList.contains("legend-more")
@@ -107,9 +116,7 @@ export class LegendFitController {
}
this._more = more;
}
const widths = children
.filter((child) => child !== more)
.map((child) => child.getBoundingClientRect().width);
const widths = children.filter((child) => child !== more).map(layoutWidth);
// Values resize the items on every state update. Fitting each item at its
// widest since the last resize keeps the more button from flickering.
if (rowWidth !== this._rowWidth || widths.length !== this._widths.length) {
@@ -122,9 +129,9 @@ export class LegendFitController {
}
const fit = fitInRows(
this._widths,
more?.getBoundingClientRect().width ?? 0,
more ? layoutWidth(more) : 0,
rowWidth,
parseFloat(getComputedStyle(list).columnGap) || 0,
parseFloat(style.columnGap) || 0,
rowWidth < NARROW_WIDTH ? NARROW_MAX_ROWS : MAX_ROWS
);
if (fit !== this.fit) {
+29
View File
@@ -0,0 +1,29 @@
const INTERACTIVE_SELECTOR = [
"a[href]",
"button",
"input",
"select",
"textarea",
"summary",
"label",
"[contenteditable]:not([contenteditable='false'])",
"[tabindex]:not([tabindex='-1'])",
"[role='button']",
"[role='link']",
"[role='checkbox']",
"[role='radio']",
"[role='switch']",
"[role='slider']",
"[role='tab']",
"[role='menuitem']",
"[role='option']",
"[role='combobox']",
"[role='textbox']",
].join(",");
/**
* Whether `node` is an element that handles clicks or keyboard input on its
* own, such as a button, link, form control or a focusable widget.
*/
export const isInteractiveElement = (node: EventTarget): boolean =>
node instanceof Element && node.matches(INTERACTIVE_SELECTOR);
+5 -5
View File
@@ -1,12 +1,12 @@
import type { HomeAssistant } from "../../types";
import type { HassServices } from "home-assistant-js-websocket";
import { getToggleAction } from "./get_toggle_action";
export const canToggleDomain = (hass: HomeAssistant, domain: string) => {
const services = hass.services[domain];
if (!services) {
export const canToggleDomain = (services: HassServices, domain: string) => {
const domainServices = services[domain];
if (!domainServices) {
return false;
}
const actionOn = getToggleAction(domain, true);
const actionOff = getToggleAction(domain, false);
return actionOn in services && actionOff in services;
return actionOn in domainServices && actionOff in domainServices;
};
+13 -6
View File
@@ -1,23 +1,30 @@
import type { HassEntity } from "home-assistant-js-websocket";
import type { HomeAssistant } from "../../types";
import type {
HassEntities,
HassEntity,
HassServices,
} from "home-assistant-js-websocket";
import { canToggleDomain } from "./can_toggle_domain";
import { computeStateDomain } from "./compute_state_domain";
import { supportsFeature } from "./supports-feature";
import { SPECIAL_TOGGLE_ACTIONS } from "./get_toggle_action";
export const canToggleState = (hass: HomeAssistant, stateObj: HassEntity) => {
export const canToggleState = (
states: HassEntities,
services: HassServices,
stateObj: HassEntity
) => {
const domain = computeStateDomain(stateObj);
if (domain === "group") {
if (
stateObj.attributes?.entity_id?.some((entity) => {
const entityStateObj = hass.states[entity];
const entityStateObj = states[entity];
if (!entityStateObj) {
return false;
}
const entityDomain = computeStateDomain(entityStateObj);
return canToggleDomain(hass, entityDomain);
return canToggleDomain(services, entityDomain);
})
) {
return stateObj.state === "on" || stateObj.state === "off";
@@ -35,5 +42,5 @@ export const canToggleState = (hass: HomeAssistant, stateObj: HassEntity) => {
);
}
return canToggleDomain(hass, domain);
return canToggleDomain(services, domain);
};
+11 -4
View File
@@ -1,11 +1,18 @@
import type { HassEntity } from "home-assistant-js-websocket";
import type {
HassEntities,
HassEntity,
HassServices,
} from "home-assistant-js-websocket";
import { UNAVAILABLE } from "../../data/entity/entity";
import type { HomeAssistant } from "../../types";
import { DOMAINS_WITH_CARD } from "../const";
import { canToggleState } from "./can_toggle_state";
import { computeStateDomain } from "./compute_state_domain";
export const stateCardType = (hass: HomeAssistant, stateObj: HassEntity) => {
export const stateCardType = (
states: HassEntities,
services: HassServices,
stateObj: HassEntity
) => {
if (stateObj.state === UNAVAILABLE) {
return "display";
}
@@ -16,7 +23,7 @@ export const stateCardType = (hass: HomeAssistant, stateObj: HassEntity) => {
return domain;
}
if (
canToggleState(hass, stateObj) &&
canToggleState(states, services, stateObj) &&
stateObj.attributes.control !== "hidden"
) {
return "toggle";
@@ -0,0 +1,33 @@
import type { LocalizeFunc } from "./localize";
const YAML_ERROR_REASON_KEYS = new Map(
Object.entries({
"duplicated mapping key": "duplicated_mapping_key",
"bad indentation of a mapping entry": "bad_mapping_indentation",
"bad indentation of a sequence entry": "bad_sequence_indentation",
"deficient indentation": "deficient_indentation",
"tab characters must not be used in indentation": "tab_indentation",
"can not read a block mapping entry; a multiline key may not be an implicit key":
"multiline_implicit_key",
"a whitespace character is expected after the key-value separator within a block mapping":
"missing_key_value_space",
"expected ':' after a mapping key": "missing_colon",
"missed comma between flow collection entries": "missing_comma",
"unexpected end of the stream within a flow collection":
"unterminated_flow_collection",
"unexpected end of the stream within a single quoted scalar":
"unterminated_single_quote",
"unexpected end of the stream within a double quoted scalar":
"unterminated_double_quote",
"end of the stream or a document separator is expected":
"unexpected_content",
} as const)
);
export const localizeYamlErrorReason = (
localize: LocalizeFunc,
reason: string | undefined
): string | undefined => {
const key = reason ? YAML_ERROR_REASON_KEYS.get(reason) : undefined;
return key ? localize(`ui.components.yaml-editor.reasons.${key}`) : reason;
};
-16
View File
@@ -5,19 +5,3 @@ export const stringifyPath = (path: ConfigPath): string => path.join("/");
export const pathEquals = (a: ConfigPath, b: ConfigPath): boolean =>
a.length === b.length && a.every((segment, index) => segment === b[index]);
export const pathStartsWith = (path: ConfigPath, prefix: ConfigPath): boolean =>
prefix.length <= path.length &&
prefix.every((segment, index) => segment === path[index]);
/** Formats a path the way it reads in YAML, e.g. `actions[1].then[0].delay`. */
export const formatPath = (path: ConfigPath): string =>
path
.map((segment, index) =>
typeof segment === "number"
? `[${segment}]`
: index === 0
? segment
: `.${segment}`
)
.join("");
+55 -23
View File
@@ -30,13 +30,40 @@ export class HaAutomationRow extends LitElement {
@property({ type: Boolean, reflect: true })
public dim = false;
@property({ type: Boolean, reflect: true })
public error = false;
/** Makes the row a link, with the icons slot outside of it */
@property() public href?: string;
@query(".row")
private _rowElement?: HTMLDivElement;
@query(".link")
private _linkElement?: HTMLAnchorElement;
protected render(): TemplateResult {
const content = html`
<div class="leading-icon-wrapper">
<slot name="leading-icon"></slot>
</div>
<div class="header">
<slot name="header"></slot>
<slot name="event"></slot>
</div>
`;
const icons = html`
<div class="icons">
<slot name="icons"></slot>
</div>
`;
if (this.href) {
return html`
<div class="row has-link">
<a class="link" href=${this.href}>${content}</a>
${icons}
</div>
`;
}
return html`
<div
class="row"
@@ -56,17 +83,7 @@ export class HaAutomationRow extends LitElement {
`
: nothing
}
<div class="leading-icon-wrapper">
<slot name="leading-icon"></slot>
</div>
<div class="header">
<slot name="header"></slot>
<slot name="event"></slot>
</div>
<div class="icons">
<slot name="icons"></slot>
</div>
${content} ${icons}
</div>
`;
}
@@ -122,7 +139,7 @@ export class HaAutomationRow extends LitElement {
public focus() {
requestAnimationFrame(() => {
this._rowElement?.focus();
(this._linkElement ?? this._rowElement)?.focus();
});
}
@@ -147,6 +164,30 @@ export class HaAutomationRow extends LitElement {
outline: var(--wa-focus-ring);
outline-offset: -2px;
}
.row.has-link {
padding-left: 0;
padding-inline-start: 0;
cursor: default;
}
.link {
display: flex;
align-items: flex-start;
align-self: stretch;
flex: 1;
min-width: 0;
padding-left: var(--ha-space-3);
padding-inline-start: var(--ha-space-3);
padding-inline-end: initial;
color: inherit;
text-decoration: none;
cursor: pointer;
outline: none;
border-radius: inherit;
}
.link:focus-visible {
outline: var(--wa-focus-ring);
outline-offset: -2px;
}
.expand-button {
transition: transform 150ms cubic-bezier(0.4, 0, 0.2, 1);
color: var(--ha-color-on-neutral-quiet);
@@ -235,15 +276,6 @@ export class HaAutomationRow extends LitElement {
:host([highlight]) .row:hover {
background-color: rgba(var(--rgb-primary-color), 0.16);
}
:host([error]) .row {
background-color: var(--ha-color-fill-danger-quiet-resting);
}
:host([error][selected]) .row,
:host([error][selected]) .row:focus {
outline: 2px solid var(--ha-color-border-danger-loud);
outline-offset: -2px;
background-color: var(--ha-color-fill-danger-normal-resting);
}
.icons,
.leading-icon-wrapper,
+6 -5
View File
@@ -19,6 +19,7 @@ interface MinMaxFrame {
maxPoint: Point;
maxX: number;
maxY: number;
lastX: number;
gapPoint: Point | undefined;
}
@@ -68,6 +69,7 @@ function newFrame(
maxPoint: point,
maxX: x,
maxY: y,
lastX: x,
gapPoint,
};
}
@@ -174,6 +176,7 @@ export function downSampleLineData<
if (!frame) {
frames.set(frameIndex, newFrame(point, x, y, undefined));
} else if (frame.hasValue) {
frame.lastX = x;
// Match the original strict-less / strict-greater comparisons so the
// first occurrence wins on ties.
if (y < frame.minY) {
@@ -189,6 +192,7 @@ export function downSampleLineData<
} else {
// the frame held nothing but a marker so far
frame.hasValue = true;
frame.lastX = x;
frame.minPoint = point;
frame.minX = x;
frame.minY = y;
@@ -213,11 +217,8 @@ export function downSampleLineData<
if (frame.gapPoint !== undefined) {
// A marker followed by a value in its own frame is a gap that closed
// within one frame, which is about one device pixel: too narrow to show.
// The kept points are exactly min and max, so comparing against the
// later of the two catches that without any work on the ingest path. A
// marker-only frame compares against its own x and always passes.
const lastValueX = frame.minX > frame.maxX ? frame.minX : frame.maxX;
if (Number(getPointData(frame.gapPoint)[0]) >= lastValueX) {
// That value may be neither min nor max, so check the frame's last x.
if (Number(getPointData(frame.gapPoint)[0]) >= frame.lastX) {
result.push(frame.gapPoint as T);
}
}
+6 -2
View File
@@ -401,9 +401,13 @@ export class HaDataTable extends LitElement {
}
if (properties.has("data")) {
// Clean up checked rows that no longer exist in the data
// Clean up checked rows that no longer exist or can no longer be selected
if (this._checkedRows.length) {
const validIds = new Set(this.data.map((row) => String(row[this.id])));
const validIds = new Set(
this.data
.filter((row) => row.selectable !== false)
.map((row) => String(row[this.id]))
);
const validCheckedRows = this._checkedRows.filter((id) =>
validIds.has(id)
);
@@ -14,12 +14,6 @@ class HaDeviceActionPicker extends HaDeviceAutomationPicker<DeviceAction> {
);
}
protected get NOT_SUPPORTED_TEXT() {
return this._i18n.localize(
"ui.panel.config.devices.automation.actions.not_supported"
);
}
protected get UNKNOWN_AUTOMATION_TEXT() {
return this._i18n.localize(
"ui.panel.config.devices.automation.actions.unknown_action"
@@ -49,10 +49,6 @@ export abstract class HaDeviceAutomationPicker<
@property({ type: Object }) public value?: T;
@property({ type: Boolean }) public invalid = false;
@property({ attribute: "error-message" }) public errorMessage?: string;
@state() private _automations?: T[];
// Trigger an empty render so we start with a clean DOM.
@@ -110,13 +106,6 @@ export abstract class HaDeviceAutomationPicker<
);
}
/** Error shown when the picked device has nothing to choose from. */
protected get NOT_SUPPORTED_TEXT() {
return this._i18n.localize(
"ui.panel.config.devices.automation.actions.not_supported"
);
}
private _localizeDeviceAutomation: (
localize: LocalizeFunc,
states: HassEntities,
@@ -174,12 +163,6 @@ export abstract class HaDeviceAutomationPicker<
.value=${value}
.disabled=${this._automations?.length === 0}
.loading=${this._automations === undefined}
.invalid=${this.invalid}
.errorMessage=${
this._automations?.length === 0
? this.NOT_SUPPORTED_TEXT
: this.errorMessage
}
.getItems=${this._getItems(
value,
this._automations,
@@ -14,12 +14,6 @@ class HaDeviceConditionPicker extends HaDeviceAutomationPicker<DeviceCondition>
);
}
protected get NOT_SUPPORTED_TEXT() {
return this._i18n.localize(
"ui.panel.config.devices.automation.conditions.not_supported"
);
}
protected get UNKNOWN_AUTOMATION_TEXT() {
return this._i18n.localize(
"ui.panel.config.devices.automation.conditions.unknown_condition"
@@ -107,10 +107,6 @@ export class HaDevicePicker extends LitElement {
@property() public helper?: string;
@property({ type: Boolean }) public invalid = false;
@property({ attribute: "error-message" }) public errorMessage?: string;
@property() public placeholder?: string;
@property({ type: String, attribute: "search-label" })
@@ -525,8 +521,6 @@ export class HaDevicePicker extends LitElement {
.autofocus=${this.autofocus}
.disabled=${this.disabled}
.helper=${this.helper}
.invalid=${this.invalid}
.errorMessage=${this.errorMessage}
.label=${this.label}
.searchLabel=${this.searchLabel}
.notFoundLabel=${this._notFoundLabel}
@@ -14,12 +14,6 @@ class HaDeviceTriggerPicker extends HaDeviceAutomationPicker<DeviceTrigger> {
);
}
protected get NOT_SUPPORTED_TEXT() {
return this._i18n.localize(
"ui.panel.config.devices.automation.triggers.not_supported"
);
}
protected get UNKNOWN_AUTOMATION_TEXT() {
return this._i18n.localize(
"ui.panel.config.devices.automation.triggers.unknown_trigger"
+18 -19
View File
@@ -371,25 +371,24 @@ export class HaEntityPicker extends LitElement {
excludeEntities?: string[],
value?: string
) =>
getEntities(
{
states,
...registries,
language: i18n.language,
translationMetadata: i18n.translationMetadata,
localize: i18n.localize,
},
{
includeDomains,
excludeDomains,
entityFilter,
includeDeviceClasses,
includeUnitOfMeasurement,
includeEntities,
excludeEntities,
value,
}
)
getEntities({
states,
entities: registries.entities,
devices: registries.devices,
areas: registries.areas,
floors: registries.floors,
localize: i18n.localize,
language: i18n.language,
translationMetadata: i18n.translationMetadata,
includeDomains,
excludeDomains,
entityFilter,
includeDeviceClasses,
includeUnitOfMeasurement,
includeEntities,
excludeEntities,
value,
})
);
private _sortByRelatedContext = memoizeOne(
+55 -94
View File
@@ -6,7 +6,7 @@ import { repeat } from "lit/directives/repeat";
import { styleMap } from "lit/directives/style-map";
import memoizeOne from "memoize-one";
import { consume } from "../common/decorators/consume";
import { fireEvent } from "../common/dom/fire_event";
import { fireEvent, type HASSDomEvent } from "../common/dom/fire_event";
import { supportsFeature } from "../common/entity/supports-feature";
import {
CameraEntityFeature,
@@ -17,8 +17,14 @@ import {
STREAM_TYPE_WEB_RTC,
type CameraCapabilities,
type CameraEntity,
type StreamType,
} from "../data/camera";
import {
MJPEG_STREAM,
selectCameraStreams,
type CameraStream,
type CameraStreamTracks,
type CameraStreamType,
} from "../data/camera-streams";
import {
apiContext,
configContext,
@@ -29,14 +35,7 @@ import "./ha-hls-player";
import "./ha-web-rtc-player";
import type { HaWebRtcPlayer } from "./ha-web-rtc-player";
const MJPEG_STREAM = "mjpeg";
export type CameraStreamType = StreamType | typeof MJPEG_STREAM;
interface Stream {
type: CameraStreamType;
visible: boolean;
}
export type { CameraStreamType } from "../data/camera-streams";
@customElement("ha-camera-stream")
export class HaCameraStream extends LitElement {
@@ -78,13 +77,15 @@ export class HaCameraStream extends LitElement {
@state() private _capabilities?: CameraCapabilities;
@state() private _hlsStreams?: { hasAudio: boolean; hasVideo: boolean };
@state() private _hlsStreams?: CameraStreamTracks;
@state() private _webRtcStreams?: { hasAudio: boolean; hasVideo: boolean };
@state() private _webRtcStreams?: CameraStreamTracks;
@query("ha-web-rtc-player") private _webRtcPlayer?: HaWebRtcPlayer;
private _visibleStreamType?: Stream["type"];
private _visibleStreamType?: CameraStreamType;
private _capabilitiesRequest = 0;
private _thumbnailApi = memoizeOne(
(
@@ -132,6 +133,10 @@ export class HaCameraStream extends LitElement {
public connectedCallback() {
super.connectedCallback();
this._connected = true;
if (this.hasUpdated) {
// The players start again when reattached, so their results do as well
this._resetStreams();
}
}
public disconnectedCallback() {
@@ -146,9 +151,8 @@ export class HaCameraStream extends LitElement {
capabilities: this._capabilities,
});
}
const visibleStreamType = this._currentStreams().find(
(stream) => stream.visible
)?.type;
const streams = this._currentStreams();
const visibleStreamType = streams.find((stream) => stream.visible)?.type;
if (visibleStreamType !== this._visibleStreamType) {
this._visibleStreamType = visibleStreamType;
fireEvent(this, "stream-type-changed", { type: visibleStreamType });
@@ -167,7 +171,7 @@ export class HaCameraStream extends LitElement {
)}`;
}
private _renderStream(stream: Stream) {
private _renderStream(stream: CameraStream) {
if (!this.stateObj) {
return nothing;
}
@@ -229,15 +233,14 @@ export class HaCameraStream extends LitElement {
return this._streams(
this._capabilities?.frontend_stream_types,
this._hlsStreams,
this._webRtcStreams,
this.muted
this._webRtcStreams
);
}
private async _getCapabilities() {
this._capabilities = undefined;
this._hlsStreams = undefined;
this._webRtcStreams = undefined;
this._resetStreams();
const request = ++this._capabilitiesRequest;
if (!supportsFeature(this.stateObj!, CameraEntityFeature.STREAM)) {
this._capabilities = {
frontend_stream_types: [],
@@ -245,10 +248,19 @@ export class HaCameraStream extends LitElement {
};
return;
}
this._capabilities = await fetchCameraCapabilities(
const capabilities = await fetchCameraCapabilities(
this._api,
this.stateObj!.entity_id
);
if (request !== this._capabilitiesRequest) {
return;
}
this._capabilities = capabilities;
}
private _resetStreams() {
this._hlsStreams = undefined;
this._webRtcStreams = undefined;
}
private async _getPosterUrl(): Promise<void> {
@@ -269,89 +281,38 @@ export class HaCameraStream extends LitElement {
fireEvent(this, "load");
}
private _handleHlsStreams(ev: CustomEvent) {
private _handleHlsStreams(ev: HASSDomEvent<HASSDomEvents["streams"]>) {
this._hlsStreams = ev.detail;
}
private _handleWebRtcStreams(ev: CustomEvent) {
private _handleWebRtcStreams(ev: HASSDomEvent<HASSDomEvents["streams"]>) {
if (
!ev.detail.hasVideo &&
!ev.detail.hasAudio &&
this._capabilities?.frontend_stream_types.length === 1 &&
typeof RTCPeerConnection !== "undefined"
) {
// Without another stream type, keep the player to show why it failed.
// Only a browser without WebRTC falls back to MJPEG.
return;
}
this._webRtcStreams = ev.detail;
}
private _streams = memoizeOne(
(
supportedTypes?: StreamType[],
hlsStreams?: { hasAudio: boolean; hasVideo: boolean },
webRtcStreams?: { hasAudio: boolean; hasVideo: boolean },
muted?: boolean
): Stream[] => {
supportedTypes?: CameraCapabilities["frontend_stream_types"],
hlsStreams?: CameraStreamTracks,
webRtcStreams?: CameraStreamTracks
): CameraStream[] => {
if (__DEMO__) {
return [{ type: MJPEG_STREAM, visible: true }];
}
if (!supportedTypes) {
return [];
}
if (supportedTypes.length === 0) {
// doesn't support any stream type, fallback to mjpeg
return [{ type: MJPEG_STREAM, visible: true }];
}
if (supportedTypes.length === 1) {
// only 1 stream type, no need to choose
if (
(supportedTypes[0] === STREAM_TYPE_HLS &&
hlsStreams?.hasVideo === false) ||
(supportedTypes[0] === STREAM_TYPE_WEB_RTC &&
webRtcStreams?.hasVideo === false)
) {
// stream failed to load, fallback to mjpeg
return [{ type: MJPEG_STREAM, visible: true }];
}
return [{ type: supportedTypes[0], visible: true }];
}
if (hlsStreams && webRtcStreams) {
// fully loaded
if (
hlsStreams.hasVideo &&
hlsStreams.hasAudio &&
!webRtcStreams.hasAudio &&
!muted
) {
// webRTC stream is missing audio and audio is not muted, use HLS
return [{ type: STREAM_TYPE_HLS, visible: true }];
}
if (webRtcStreams.hasVideo) {
return [{ type: STREAM_TYPE_WEB_RTC, visible: true }];
}
// both streams failed to load, fallback to mjpeg
return [{ type: MJPEG_STREAM, visible: true }];
}
if (hlsStreams?.hasVideo !== webRtcStreams?.hasVideo) {
// one of the two streams is loaded, or errored
// choose the one that has video or is still loading
if (hlsStreams?.hasVideo) {
return [
{ type: STREAM_TYPE_HLS, visible: true },
{ type: STREAM_TYPE_WEB_RTC, visible: false },
];
}
if (hlsStreams?.hasVideo === false) {
return [{ type: STREAM_TYPE_WEB_RTC, visible: true }];
}
if (webRtcStreams?.hasVideo) {
return [
{ type: STREAM_TYPE_WEB_RTC, visible: true },
{ type: STREAM_TYPE_HLS, visible: false },
];
}
if (webRtcStreams?.hasVideo === false) {
return [{ type: STREAM_TYPE_HLS, visible: true }];
}
}
return [
{ type: STREAM_TYPE_HLS, visible: true },
{ type: STREAM_TYPE_WEB_RTC, visible: false },
];
return selectCameraStreams({
supportedTypes,
hlsStreams,
webRtcStreams,
});
}
);
+7 -11
View File
@@ -2,6 +2,7 @@ import type { ContextType } from "@lit/context";
import type { CSSResultGroup, TemplateResult } from "lit";
import { css, html, LitElement } from "lit";
import { customElement, property } from "lit/decorators";
import { styleMap } from "lit/directives/style-map";
import { consume } from "../common/decorators/consume";
import { configContext } from "../data/context";
import "./ha-icon-next";
@@ -34,17 +35,14 @@ class HaConfigNavigationList extends LitElement {
.href=${externalApp ? undefined : page.path}
@click=${externalApp ? this._handleExternalApp : undefined}
>
<div
<ha-svg-icon
slot="start"
class=${page.iconColor ? "icon-background" : ""}
.style="background-color: ${page.iconColor || "undefined"}"
>
<ha-svg-icon
.path=${page.iconPath}
.secondaryPath=${page.iconSecondaryPath}
.viewBox=${page.iconViewBox}
></ha-svg-icon>
</div>
style=${styleMap({ backgroundColor: page.iconColor })}
.path=${page.iconPath}
.secondaryPath=${page.iconSecondaryPath}
.viewBox=${page.iconViewBox}
></ha-svg-icon>
<span slot="headline">${page.name}</span>
${
this.hasSecondary
@@ -82,8 +80,6 @@ class HaConfigNavigationList extends LitElement {
}
.icon-background {
border-radius: var(--ha-border-radius-circle);
}
.icon-background ha-svg-icon {
color: #fff;
}
`;
+4
View File
@@ -112,6 +112,9 @@ export class HaGenericPicker extends PickerMixin(LitElement) {
@property({ type: Boolean, attribute: "no-sort" }) public noSort = false;
@property({ attribute: "pin-block" })
public pinBlock?: "start" | "center" | "end" | "nearest";
// Skip the "unknown value" highlight and note for a value that is not in the
// list but that the value renderer presents on its own.
@property({ type: Boolean, attribute: "no-unknown-state" })
@@ -347,6 +350,7 @@ export class HaGenericPicker extends PickerMixin(LitElement) {
.searchKeys=${this.searchKeys}
.customValueLabel=${this.customValueLabel}
.noSort=${this.noSort}
.pinBlock=${this.pinBlock}
></ha-picker-combo-box>
`;
}
+7 -2
View File
@@ -180,13 +180,14 @@ class HaHLSPlayer extends LitElement {
return;
}
if (!this.entityid) {
const entityId = this.entityid;
if (!entityId) {
return;
}
try {
const { url } = await fetchStreamUrl(
{ callWS: this._api.callWS, hassUrl: this._connection.hassUrl },
this.entityid
entityId
);
this._url = this._connection.hassUrl(url);
@@ -198,6 +199,10 @@ class HaHLSPlayer extends LitElement {
// eslint-disable-next-line
console.error(err);
if (!this.isConnected || entityId !== this.entityid) {
// Removed or switched camera while fetching, the result is outdated
return;
}
fireEvent(this, "streams", { hasAudio: false, hasVideo: false });
}
}
+62 -18
View File
@@ -1,13 +1,17 @@
import type { RenderItemFunction } from "@lit-labs/virtualizer/virtualize";
import type { TemplateResult } from "lit";
import type { PropertyValues, TemplateResult } from "lit";
import { LitElement, css, html } from "lit";
import { customElement, property } from "lit/decorators";
import { customElement, property, query, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import { consumeLocalize } from "../common/decorators/consume-context-entry";
import { fireEvent } from "../common/dom/fire_event";
import { caseInsensitiveStringCompare } from "../common/string/compare";
import type { LocalizeFunc } from "../common/translations/localize";
import { customIcons } from "../data/custom_icons";
import type { ValueChangedEvent } from "../types";
import "./ha-combo-box-item";
import "./ha-generic-picker";
import type { HaGenericPicker } from "./ha-generic-picker";
import "./ha-icon";
import type { PickerComboBoxItem } from "./ha-picker-combo-box";
@@ -50,16 +54,17 @@ const createIconItem = (icon: IconData, prefix: string): PickerComboBoxItem => {
const loadIcons = async () => {
ICONS_LOADED = true;
const iconList = await import("../../build/mdi/iconList.json");
ICONS = iconList.default.map((icon) => createIconItem(icon, "mdi"));
const sortIcons = (icons: PickerComboBoxItem[]) =>
icons.sort((a, b) => caseInsensitiveStringCompare(a.id, b.id));
const customIconLoads: Promise<PickerComboBoxItem[]>[] = [];
Object.keys(customIcons).forEach((iconSet) => {
customIconLoads.push(loadCustomIconItems(iconSet));
});
(await Promise.all(customIconLoads)).forEach((customIconItems) => {
ICONS.push(...customIconItems);
});
const iconList = await import("../../build/mdi/iconList.json");
const mdiIcons = iconList.default.map((icon) => createIconItem(icon, "mdi"));
ICONS = sortIcons([...mdiIcons]);
const customIconItems = await Promise.all(
Object.keys(customIcons).map(loadCustomIconItems)
);
ICONS = sortIcons([...mdiIcons, ...customIconItems.flat()]);
};
const loadCustomIconItems = async (
@@ -81,8 +86,8 @@ const loadCustomIconItems = async (
const rowRenderer: RenderItemFunction<PickerComboBoxItem> = (item) => html`
<ha-combo-box-item>
<ha-icon .icon=${item.id} slot="start"></ha-icon>
${item.id}
<ha-icon .icon=${item.icon ?? item.id} slot="start"></ha-icon>
${item.icon ?? item.id}
</ha-combo-box-item>
`;
@@ -104,12 +109,32 @@ export class HaIconPicker extends LitElement {
@property({ type: Boolean }) public invalid = false;
private _getIconPickerItems = (): PickerComboBoxItem[] => ICONS;
@property({ attribute: false }) public suggestedIcons?: string[];
@state() @consumeLocalize() private _localize!: LocalizeFunc;
@query("ha-generic-picker") private _picker?: HaGenericPicker;
private _getIconPickerItems = () =>
this._itemsWithSuggestions(ICONS, this.suggestedIcons, this._localize);
protected updated(changedProps: PropertyValues) {
super.updated(changedProps);
if (changedProps.has("suggestedIcons") || changedProps.has("_localize")) {
this._picker?.refreshItems();
}
}
protected render(): TemplateResult {
return html`
<ha-generic-picker
allow-custom-value
no-sort
.pinBlock=${
this.value && this.suggestedIcons?.includes(this.value)
? "end"
: undefined
}
.getItems=${this._getIconPickerItems}
.helper=${this.helper}
.disabled=${this.disabled}
@@ -128,15 +153,35 @@ export class HaIconPicker extends LitElement {
`;
}
private _itemsWithSuggestions = memoizeOne(
(
icons: PickerComboBoxItem[],
suggestedIcons: string[] | undefined,
localize: LocalizeFunc
): (PickerComboBoxItem | string)[] => {
if (!suggestedIcons?.length) {
return icons;
}
const suggested = new Set(suggestedIcons);
return [
localize("ui.components.icon_picker.suggested"),
...suggestedIcons.map((icon) => ({ id: icon, primary: icon, icon })),
localize("ui.components.icon_picker.all_icons"),
...icons.filter((item) => !suggested.has(item.id)),
];
}
);
// Filter can take a significant chunk of frame (up to 3-5 ms)
private _filterIcons = memoizeOne(
(
filter: string,
filteredItems: PickerComboBoxItem[],
allItems: PickerComboBoxItem[]
_allItems: PickerComboBoxItem[]
): PickerComboBoxItem[] => {
const normalizedFilter = filter.toLowerCase().replace(/\s+/g, "-");
const iconItems = allItems?.length ? allItems : filteredItems;
// The full list also holds the suggestions and their section titles
const iconItems = ICONS.length ? ICONS : filteredItems;
if (!normalizedFilter.length) {
return iconItems;
@@ -175,8 +220,7 @@ export class HaIconPicker extends LitElement {
protected firstUpdated() {
if (!ICONS_LOADED) {
loadIcons().then(() => {
this._getIconPickerItems = (): PickerComboBoxItem[] => ICONS;
this.requestUpdate();
this._picker?.refreshItems();
});
}
}
@@ -406,6 +406,8 @@ export class HaItemDisplayEditor extends LitElement {
cursor: move;
padding: 8px;
margin: -8px;
/* Keep the handle above the row's hit area so dragging can start */
z-index: 1;
}
.separator {
width: 1px;
+12 -2
View File
@@ -201,6 +201,9 @@ export class HaPickerComboBox extends ScrollableFadeMixin(LitElement) {
@property({ type: Boolean, attribute: "no-sort" }) public noSort = false;
@property({ attribute: "pin-block" })
public pinBlock?: "start" | "center" | "end" | "nearest";
@query(".list") private _list?: HaListBase;
@query("ha-input-search") private _searchFieldElement?: HaInputSearch;
@@ -453,6 +456,7 @@ export class HaPickerComboBox extends ScrollableFadeMixin(LitElement) {
this.value
)}
.pinIndex=${this.value ? this._getInitialSelectedIndex() : undefined}
.pinBlock=${this.pinBlock ?? "center"}
@mousedown=${this._preventBlur}
@focus=${this._focusList}
@blur=${this._resetSelectedItem}
@@ -685,6 +689,11 @@ export class HaPickerComboBox extends ScrollableFadeMixin(LitElement) {
fireEvent(this, "index-selected", { index, item, newTab });
}
// Fuse indexes every row as text when the first one is a section title
private _searchableItems = memoizeOne((items: PickerComboBoxItem[]) =>
items.filter((item) => typeof item !== "string")
);
private _fuseIndex = memoizeOne(
(states: PickerComboBoxItem[], searchKeys?: FuseWeightedKey[]) =>
Fuse.createIndex(searchKeys || DEFAULT_SEARCH_KEYS, states)
@@ -708,10 +717,11 @@ export class HaPickerComboBox extends ScrollableFadeMixin(LitElement) {
return;
}
const index = this._fuseIndex(this._allItems, this.searchKeys);
const searchableItems = this._searchableItems(this._allItems);
const index = this._fuseIndex(searchableItems, this.searchKeys);
let filteredItems = multiTermSortedSearch<PickerComboBoxItem>(
this._allItems,
searchableItems,
searchString,
(item) => item.id,
index
+3 -2
View File
@@ -50,6 +50,7 @@ const wait = (ms: number) =>
* @cssprop --ha-ripple-hover-color - Hover color.
* @cssprop --ha-ripple-pressed-color - Pressed color.
* @cssprop --ha-ripple-hover-opacity - Hover opacity. Defaults to 0.08.
* @cssprop --ha-ripple-hover-duration - Fade duration of the hover color. Defaults to 15ms.
* @cssprop --ha-ripple-pressed-opacity - Pressed opacity. Defaults to 0.12.
*/
@customElement("ha-ripple")
@@ -396,8 +397,8 @@ export class HaRipple extends LitElement {
var(--ha-ripple-color, var(--secondary-text-color))
);
transition:
opacity 15ms linear,
background-color 15ms linear;
opacity var(--ha-ripple-hover-duration, 15ms) linear,
background-color var(--ha-ripple-hover-duration, 15ms) linear;
}
.hovered .hover {
opacity: var(--ha-ripple-hover-opacity, 0.08);
@@ -1,9 +1,18 @@
import type { ContextType } from "@lit/context";
import type { HassEntity } from "home-assistant-js-websocket";
import type { PropertyValues } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import { ensureArray } from "../../common/array/ensure-array";
import { consume } from "../../common/decorators/consume";
import {
apiContext,
connectionContext,
devicesContext,
entitiesContext,
statesContext,
} from "../../data/context";
import type { DeviceRegistryEntry } from "../../data/device/device_registry";
import { getDeviceIntegrationLookup } from "../../data/device/device_registry";
import type { EntitySources } from "../../data/entity/entity_sources";
@@ -15,7 +24,7 @@ import {
filterSelectorEntities,
} from "../../data/selector";
import type { HassServiceTargetWithExclude } from "../../data/target";
import type { HomeAssistant } from "../../types";
import type { HomeAssistant, HomeAssistantConnection } from "../../types";
import "../ha-target-picker";
type TargetSelectorFilters = NonNullable<TargetSelector["target"]>;
@@ -28,8 +37,6 @@ const ALL_DEVICES = () => true;
@customElement("ha-selector-target")
export class HaTargetSelector extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false }) public selector!: TargetSelector;
@property({ type: Object }) public value?: HassServiceTargetWithExclude;
@@ -44,9 +51,27 @@ export class HaTargetSelector extends LitElement {
@state() private _createDomains: string[] | undefined;
@consume({ context: apiContext, subscribe: true })
private _api?: ContextType<typeof apiContext>;
@state()
@consume({ context: connectionContext, subscribe: true })
private _connection?: ContextType<typeof connectionContext>;
@consume({ context: statesContext, subscribe: true })
private _states?: ContextType<typeof statesContext>;
@consume({ context: entitiesContext, subscribe: true })
private _entities?: ContextType<typeof entitiesContext>;
@consume({ context: devicesContext, subscribe: true })
private _devices?: ContextType<typeof devicesContext>;
private _fetchedEntitySources = false;
private _deviceIntegrationLookup = memoizeOne(
(entities: HomeAssistant["entities"]) =>
getDeviceIntegrationLookup(Object.values(entities))
(entities: HomeAssistant["entities"] | undefined) =>
getDeviceIntegrationLookup(Object.values(entities ?? {}))
);
private _hasIntegration(selector: TargetSelector) {
@@ -62,18 +87,31 @@ export class HaTargetSelector extends LitElement {
);
}
protected updated(changedProperties: PropertyValues<this>): void {
protected updated(changedProperties: PropertyValues): void {
super.updated(changedProperties);
const oldConnection = changedProperties.get("_connection") as
HomeAssistantConnection | undefined;
if (
changedProperties.has("selector") &&
this._hasIntegration(this.selector) &&
!this._entitySources
oldConnection &&
this._connection &&
(oldConnection.connection !== this._connection.connection ||
(!oldConnection.connected && this._connection.connected))
) {
fetchEntitySourcesWithCache(this.hass.callWS, this.hass.states).then(
(sources) => {
this._fetchedEntitySources = false;
}
if (
this._api &&
!this._fetchedEntitySources &&
this._hasIntegration(this.selector)
) {
this._fetchedEntitySources = true;
fetchEntitySourcesWithCache(this._api.callWS, this._states ?? {})
.then((sources) => {
this._entitySources = sources;
}
);
})
.catch(() => {
this._entitySources ??= {};
});
}
if (changedProperties.has("selector")) {
this._createDomains = computeCreateDomains(this.selector);
@@ -87,7 +125,6 @@ export class HaTargetSelector extends LitElement {
return html` ${this.label ? html`<label>${this.label}</label>` : nothing}
<ha-target-picker
.hass=${this.hass}
.value=${this.value}
.helper=${this.helper}
.deviceFilter=${this._deviceFilter(this.selector.target?.device)}
@@ -118,8 +155,8 @@ export class HaTargetSelector extends LitElement {
filter,
entity,
entitySources,
this.hass.entities,
this.hass.devices
this._entities,
this._devices
)
);
}
@@ -132,7 +169,7 @@ export class HaTargetSelector extends LitElement {
}
return (device: DeviceRegistryEntry): boolean => {
const deviceIntegrations = this._hasIntegration(this.selector)
? this._deviceIntegrationLookup(this.hass.entities)
? this._deviceIntegrationLookup(this._entities)
: undefined;
return ensureArray(filters).some((filter) =>
filterSelectorDevices(filter, device, deviceIntegrations)
+29 -98
View File
@@ -41,7 +41,6 @@ import "./ha-selector/ha-selector";
import type { HaSelector } from "./ha-selector/ha-selector";
import "./ha-service-picker";
import "./ha-service-section-icon";
import "./ha-input-helper-text";
import "./ha-settings-row";
import "./ha-yaml-editor";
import type { HaYamlEditor } from "./ha-yaml-editor";
@@ -120,15 +119,6 @@ export class HaServiceControl extends LitElement {
@property({ attribute: "hide-description", type: Boolean })
public hideDescription = false;
/** Error shown below the target picker. */
@property({ attribute: false }) public targetError?: string;
/** Errors shown below the data fields, keyed by field. */
@property({ attribute: false }) public dataErrors?: Record<string, string>;
/** Error of the data as a whole. */
@property({ attribute: false }) public dataError?: string;
@state() private _value!: this["value"];
@state() private _checkedKeys = new Set();
@@ -552,14 +542,13 @@ export class HaServiceControl extends LitElement {
${
serviceData && "target" in serviceData
? html`<ha-selector
class="target-selector"
.hass=${this.hass}
.selector=${targetSelector}
.disabled=${this.disabled}
@value-changed=${this._targetChanged}
.value=${this._value?.target}
></ha-selector>
${this._renderError(this.targetError)}`
class="target-selector"
.hass=${this.hass}
.selector=${targetSelector}
.disabled=${this.disabled}
@value-changed=${this._targetChanged}
.value=${this._value?.target}
></ha-selector>`
: entityId
? html`<ha-entity-picker
.disabled=${this.disabled}
@@ -605,10 +594,7 @@ export class HaServiceControl extends LitElement {
this._hasFilteredFields(fields, targetEntities)
? html`<ha-expansion-panel
left-chevron
.expanded=${
!dataField.collapsed ||
fields.some((field) => this.dataErrors?.[field.key])
}
.expanded=${!dataField.collapsed}
.header=${
this.hass.localize(
`component.${domain}.services.${serviceName}.sections.${dataField.key}.name`,
@@ -640,12 +626,7 @@ export class HaServiceControl extends LitElement {
</ha-expansion-panel>`
: nothing;
})
}
${this._unshownErrors(
serviceData,
targetEntities,
Boolean(shouldRenderServiceDataYaml)
).map((error) => this._renderError(error))}`;
} `;
}
private _getSectionDescription(
@@ -709,25 +690,6 @@ export class HaServiceControl extends LitElement {
? this.hass.services[domain][serviceName].description_placeholders
: undefined;
const fieldError = this.dataErrors?.[dataField.key];
const selectorTemplate = html`<ha-selector
.context=${this._selectorContext(targetEntities)}
.disabled=${
this.disabled ||
(showOptional &&
!this._checkedKeys.has(dataField.key) &&
(!this._value?.data || this._value.data[dataField.key] === undefined))
}
.hass=${this.hass}
.selector=${selector}
.key=${dataField.key}
@value-changed=${this._serviceDataChanged}
.value=${this._value?.data ? this._value.data[dataField.key] : undefined}
.placeholder=${dataField.default}
.localizeValue=${this._localizeValueCallback}
.required=${dataField.required}
></ha-selector>`;
return dataField.selector
? html`<ha-settings-row
.narrow=${
@@ -780,50 +742,30 @@ export class HaServiceControl extends LitElement {
}
></ha-markdown>
</span>
<div class="field">
${selectorTemplate}${this._renderError(fieldError)}
</div>
<ha-selector
.context=${this._selectorContext(targetEntities)}
.disabled=${
this.disabled ||
(showOptional &&
!this._checkedKeys.has(dataField.key) &&
(!this._value?.data ||
this._value.data[dataField.key] === undefined))
}
.hass=${this.hass}
.selector=${selector}
.key=${dataField.key}
@value-changed=${this._serviceDataChanged}
.value=${
this._value?.data ? this._value.data[dataField.key] : undefined
}
.placeholder=${dataField.default}
.localizeValue=${this._localizeValueCallback}
.required=${dataField.required}
></ha-selector>
</ha-settings-row>`
: "";
};
// Errors without a field to show them on, e.g. of a field no target
// supports or of a target the action doesn't have a picker for
private _unshownErrors(
serviceData: ExtHassService | undefined,
targetEntities: string[] | null,
dataYaml: boolean
): string[] {
const targetShown = Boolean(serviceData && "target" in serviceData);
const shownFields = new Set(
dataYaml
? []
: (serviceData?.flatFields ?? [])
.filter(
(field) =>
field.selector &&
(!field.filter ||
this._filterField(field.filter, targetEntities))
)
.map((field) => field.key)
);
return [
...(this.targetError && !targetShown ? [this.targetError] : []),
...(this.dataError ? [this.dataError] : []),
...Object.entries(this.dataErrors ?? {})
.filter(([key]) => !shownFields.has(key))
.map(([key, error]) => `${key}: ${error}`),
];
}
private _renderError(error: string | undefined) {
return error
? html`<ha-input-helper-text class="error"
>${error}</ha-input-helper-text
>`
: nothing;
}
private _selectorContext = memoizeOne((targetEntities: string[] | null) => ({
filter_entity: targetEntities || undefined,
}));
@@ -1049,17 +991,6 @@ export class HaServiceControl extends LitElement {
ha-settings-row {
padding: var(--service-control-padding, 0 var(--ha-space-4));
}
/* Every field has this wrapper, so the selector isn't rebuilt when its
error comes or goes */
.field {
display: flex;
flex-direction: column;
}
ha-input-helper-text.error {
color: var(--ha-color-on-danger-quiet);
padding-top: var(--ha-space-1);
padding-bottom: var(--ha-space-2);
}
ha-settings-row[narrow] {
padding-bottom: var(--ha-space-2);
}
+14 -6
View File
@@ -1,10 +1,13 @@
import type { ContextType } from "@lit/context";
import type { HassServiceTarget } from "home-assistant-js-websocket";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property } from "lit/decorators";
import { customElement, property, state } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import memoizeOne from "memoize-one";
import { ensureArray } from "../common/array/ensure-array";
import { consume } from "../common/decorators/consume";
import { fireEvent } from "../common/dom/fire_event";
import { entitiesContext, statesContext } from "../data/context";
import type { DataTableFiltersValue } from "../data/data_table_filters";
import type { HaEntityPickerEntityFilterFunc } from "../data/entity/entity";
import { entityTypeFilterFunc } from "../data/entity/entity_type";
@@ -108,8 +111,6 @@ export const applySourceFilters = (
*/
@customElement("ha-sources-picker")
export class HaSourcesPicker extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false }) public value: HassServiceTarget = {};
/** The filters the page applies, so the picker counts what they keep. */
@@ -125,6 +126,14 @@ export class HaSourcesPicker extends LitElement {
@property({ type: Boolean }) public disabled = false;
@state()
@consume({ context: statesContext, subscribe: true })
private _states!: ContextType<typeof statesContext>;
@state()
@consume({ context: entitiesContext, subscribe: true })
private _entities!: ContextType<typeof entitiesContext>;
protected render() {
const noTargets = countTargets(this.value) === 0;
@@ -136,13 +145,12 @@ export class HaSourcesPicker extends LitElement {
}
<ha-target-picker
class=${classMap({ "no-padding-top": noTargets })}
.hass=${this.hass}
.value=${this.value}
.entityFilter=${this.entityFilter}
.activeFilter=${this._activeFilter(
this.filters,
this.hass.states,
this.hass.entities,
this._states,
this._entities,
this.entitySources
)}
.primaryEntitiesOnly=${false}
+165 -84
View File
@@ -1,3 +1,4 @@
import type { ContextType } from "@lit/context";
import "@home-assistant/webawesome/dist/components/popover/popover";
import { mdiPlaylistMinus, mdiPlus, mdiTextureBox } from "@mdi/js";
import Fuse from "fuse.js";
@@ -8,6 +9,7 @@ import { customElement, property, query, state } from "lit/decorators";
import { styleMap } from "lit/directives/style-map";
import memoizeOne from "memoize-one";
import { consume } from "../common/decorators/consume";
import { transform } from "../common/decorators/transform";
import { ensureArray } from "../common/array/ensure-array";
import type { HASSDomEvent } from "../common/dom/fire_event";
import { fireEvent } from "../common/dom/fire_event";
@@ -21,7 +23,17 @@ import {
type FloorComboBoxItem,
} from "../data/area_floor_picker";
import { getConfigEntries, type ConfigEntry } from "../data/config_entries";
import { labelsContext } from "../data/context";
import {
apiContext,
brandsAccessTokenContext,
configContext,
connectionContext,
internationalizationContext,
labelsContext,
registriesContext,
statesContext,
uiContext,
} from "../data/context";
import {
deviceComboBoxKeys,
getDevices,
@@ -47,7 +59,6 @@ import {
type TargetType,
type TargetTypeFloorless,
} from "../data/target";
import { SubscribeMixin } from "../mixins/subscribe-mixin";
import { isHelperDomain } from "../panels/config/helpers/const";
import { showHelperDetailDialog } from "../panels/config/helpers/show-dialog-helper-detail";
import {
@@ -55,7 +66,11 @@ import {
multiTermSortedSearch,
type FuseWeightedKey,
} from "../resources/fuseMultiTerm";
import type { HomeAssistant, ValueChangedEvent } from "../types";
import type {
HomeAssistant,
HomeAssistantUI,
ValueChangedEvent,
} from "../types";
import { brandsUrl } from "../util/brands-url";
import type { HaDevicePickerDeviceFilterFunc } from "./device/ha-device-picker";
import "./ha-button";
@@ -89,9 +104,7 @@ const isTargetType = (value: string): value is TargetType =>
value === "floor";
@customElement("ha-target-picker")
export class HaTargetPicker extends SubscribeMixin(LitElement) {
@property({ attribute: false }) public hass!: HomeAssistant;
export class HaTargetPicker extends LitElement {
@property({ attribute: false }) public value?: HassServiceTargetWithExclude;
@property() public helper?: string;
@@ -157,6 +170,47 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
@consume({ context: labelsContext, subscribe: true })
private _labelRegistry!: LabelRegistryEntry[];
@consume({ context: statesContext, subscribe: true })
private _states!: ContextType<typeof statesContext>;
@state()
@consume({ context: statesContext, subscribe: true })
@transform<HomeAssistant["states"], boolean>({
transformer: function (this: HaTargetPicker, states) {
return !!this._pendingEntityId && !!states?.[this._pendingEntityId];
},
watch: ["_pendingEntityId"],
})
private _pendingEntityLoaded = false;
@state()
@consume({ context: registriesContext, subscribe: true })
private _registries!: ContextType<typeof registriesContext>;
@state()
@consume({ context: internationalizationContext, subscribe: true })
private _i18n!: ContextType<typeof internationalizationContext>;
@state()
@consume({ context: uiContext, subscribe: true })
@transform<HomeAssistantUI, boolean | undefined>({
transformer: (ui) => ui?.themes?.darkMode,
})
private _darkMode?: boolean;
@consume({ context: configContext, subscribe: true })
private _config?: ContextType<typeof configContext>;
@consume({ context: apiContext, subscribe: true })
private _api?: ContextType<typeof apiContext>;
@consume({ context: connectionContext, subscribe: true })
private _connection?: ContextType<typeof connectionContext>;
@state()
@consume({ context: brandsAccessTokenContext, subscribe: true })
private _brandsAccessToken?: string;
@query("ha-generic-picker") private _picker?: HaGenericPicker;
@query("ha-generic-picker.exclude") private _excludePicker?: HaGenericPicker;
@@ -167,7 +221,9 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
private _getDevicesMemoized = memoizeOne(
(
hass: HomeAssistant,
states: HomeAssistant["states"],
registries: ContextType<typeof registriesContext>,
i18n: ContextType<typeof internationalizationContext>,
configEntryLookup: Record<string, ConfigEntry>,
includeDomains?: string[],
includeDeviceClasses?: string[],
@@ -178,13 +234,13 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
idPrefix?: string
) =>
getDevices({
states: hass.states,
areas: hass.areas,
devices: hass.devices,
entities: hass.entities,
localize: hass.localize,
language: hass.locale.language,
translationMetadata: hass.translationMetadata,
states,
areas: registries.areas,
devices: registries.devices,
entities: registries.entities,
localize: i18n.localize,
language: i18n.locale.language,
translationMetadata: i18n.translationMetadata,
configEntryLookup,
includeDomains,
includeDeviceClasses,
@@ -201,7 +257,9 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
private _getEntitiesMemoized = memoizeOne(
(
hass: HomeAssistant,
states: HomeAssistant["states"],
registries: ContextType<typeof registriesContext>,
i18n: ContextType<typeof internationalizationContext>,
includeDomains?: string[],
excludeDomains?: string[],
entityFilter?: HaEntityPickerEntityFilterFunc,
@@ -212,7 +270,15 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
value?: string,
idPrefix?: string
) =>
getEntities(hass, {
getEntities({
states,
entities: registries.entities,
devices: registries.devices,
areas: registries.areas,
floors: registries.floors,
localize: i18n.localize,
language: i18n.language,
translationMetadata: i18n.translationMetadata,
includeDomains,
excludeDomains,
entityFilter,
@@ -228,7 +294,7 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
private _getAreasAndFloorsMemoized = memoizeOne(getAreasAndFloors);
private get _showEntityId() {
return this.hass.userData?.showEntityIdPicker;
return this._config?.userData?.showEntityIdPicker;
}
private _fuseIndexes = {
@@ -248,36 +314,34 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
@state() private _pendingEntityId?: string;
public willUpdate(changedProps: PropertyValues<this>) {
public willUpdate(changedProps: PropertyValues) {
super.willUpdate(changedProps);
if (!this.hasUpdated) {
this._loadConfigEntries();
}
const devices = this._registries?.devices;
const devicesChanged =
changedProps.has("hass") &&
this.hass.devices !== changedProps.get("hass")?.devices;
changedProps.has("_registries") &&
devices !==
(
changedProps.get("_registries") as
ContextType<typeof registriesContext> | undefined
)?.devices;
if (
(changedProps.has("value") || devicesChanged) &&
this.hass &&
devices &&
this._compositeSplits === undefined &&
!this._loadingCompositeSplits &&
this.value?.device_id &&
ensureArray(this.value.device_id).some(
(deviceId) => !this.hass.devices[deviceId]
)
ensureArray(this.value.device_id).some((deviceId) => !devices[deviceId])
) {
// A referenced device is missing from the registry; it might be a legacy
// composite device that was split. Fetch the split map to offer a fix.
this._loadCompositeSplits();
}
const registriesChanged =
changedProps.has("hass") &&
(devicesChanged ||
this.hass.entities !== changedProps.get("hass")?.entities ||
this.hass.areas !== changedProps.get("hass")?.areas);
if (
changedProps.has("value") ||
changedProps.has("allowExclude") ||
@@ -285,18 +349,12 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
changedProps.has("deviceFilter") ||
changedProps.has("includeDomains") ||
changedProps.has("includeDeviceClasses") ||
changedProps.has("entityFilter") ||
registriesChanged
changedProps.has("entityFilter")
) {
this._exclusions.resolve();
}
if (
this._pendingEntityId &&
changedProps.has("hass") &&
this.hass.states !== changedProps.get("hass")?.states &&
this.hass.states[this._pendingEntityId]
) {
if (this._pendingEntityId && this._pendingEntityLoaded) {
this._addTarget(this._pendingEntityId, "entity");
this._pendingEntityId = undefined;
}
@@ -453,7 +511,7 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
return nothing;
}
return html`
<div class="exclusion-list-label">${this.hass.localize(label)}</div>
<div class="exclusion-list-label">${this._i18n.localize(label)}</div>
<ha-list-base>
${items.map(
(item) =>
@@ -486,16 +544,16 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
.helper=${this.helper}
.sections=${sections}
.notFoundLabel=${this._noTargetFoundLabel}
.emptyLabel=${this.hass.localize(
.emptyLabel=${this._i18n.localize(
"ui.components.target-picker.no_targets"
)}
.selectedSection=${this._selectedSection}
.popoverAnchor=${this._replaceTargetAnchor}
.rowRenderer=${this._renderRow}
.rowRenderer=${this._rowRenderer(this._brandsAccessToken)}
.getItems=${this._getItems}
@value-changed=${this._targetPicked}
@picker-closed=${this._handlePickerClosed}
.addButtonLabel=${this.hass.localize(
.addButtonLabel=${this._i18n.localize(
"ui.components.target-picker.add_target"
)}
.getAdditionalItems=${this._getAdditionalItems}
@@ -510,11 +568,11 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
.disabled=${this.disabled}
.sections=${sections}
.notFoundLabel=${this._noTargetFoundLabel}
.emptyLabel=${this.hass.localize(
.emptyLabel=${this._i18n.localize(
"ui.components.target-picker.no_targets"
)}
.selectedSection=${this._excludeSection}
.rowRenderer=${this._renderRow}
.rowRenderer=${this._rowRenderer(this._brandsAccessToken)}
.getItems=${this._getExcludeItems}
@value-changed=${this._excludePicked}
>
@@ -529,7 +587,7 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
.path=${mdiPlaylistMinus}
slot="start"
></ha-svg-icon>
${this.hass.localize(
${this._i18n.localize(
"ui.components.target-picker.exclude_target"
)}
</ha-button>
@@ -545,20 +603,20 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
return [
{
id: "entity",
label: this.hass.localize("ui.components.target-picker.type.entities"),
label: this._i18n.localize("ui.components.target-picker.type.entities"),
},
{
id: "device",
label: this.hass.localize("ui.components.target-picker.type.devices"),
label: this._i18n.localize("ui.components.target-picker.type.devices"),
},
{
id: "area",
label: this.hass.localize("ui.components.target-picker.type.areas"),
label: this._i18n.localize("ui.components.target-picker.type.areas"),
},
"separator" as const,
{
id: "label",
label: this.hass.localize("ui.components.target-picker.type.labels"),
label: this._i18n.localize("ui.components.target-picker.type.labels"),
},
];
}
@@ -687,7 +745,7 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
this._excludeSection = section as TargetTypeFloorless | undefined;
return this._getItemsMemoized(
this.hass.localize,
this._i18n,
this._excludeEntityFilter(
this.entityFilter,
this._exclusions.includedEntities
@@ -699,7 +757,8 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
undefined,
searchString,
this._configEntryLookup,
this.hass.entities,
this._states,
this._registries,
this._excludeSection,
this._excludePickerIncludedDevices(this.value)
);
@@ -816,7 +875,7 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
domain,
dialogClosedCallback: (item) => {
if (item.entityId) {
if (this.hass.states[item.entityId]) {
if (this._states?.[item.entityId]) {
this._addTarget(item.entityId, "entity");
} else {
this._pendingEntityId = item.entityId;
@@ -903,7 +962,7 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
this._selectedSection = section as TargetTypeFloorless | undefined;
return this._getItemsMemoized(
this.hass.localize,
this._i18n,
this.entityFilter,
this.deviceFilter,
this.includeDomains,
@@ -912,14 +971,15 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
this._replaceTarget,
searchString,
this._configEntryLookup,
this.hass.entities,
this._states,
this._registries,
this._selectedSection
);
};
private _getItemsMemoized = memoizeOne(
(
localize: HomeAssistant["localize"],
i18n: ContextType<typeof internationalizationContext>,
entityFilter: this["entityFilter"],
deviceFilter: this["deviceFilter"],
includeDomains: this["includeDomains"],
@@ -928,11 +988,13 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
replaceTarget: TargetItem | undefined,
searchTerm: string,
configEntryLookup: Record<string, ConfigEntry>,
_entities: HomeAssistant["entities"],
states: HomeAssistant["states"],
registries: ContextType<typeof registriesContext>,
filterType?: TargetTypeFloorless,
// Hidden without hiding their child devices.
hiddenDeviceIds?: string[]
) => {
const localize = i18n.localize;
const replacingEntityId =
replaceTarget?.type === "entity" ? replaceTarget.id : undefined;
const replacingDeviceId =
@@ -950,7 +1012,9 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
if (!filterType || filterType === "entity") {
let entityItems = this._getEntitiesMemoized(
this.hass,
states,
registries,
i18n,
includeDomains,
undefined,
entityFilter,
@@ -1004,7 +1068,7 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
...(hiddenDeviceIds ?? []),
...(selectedDeviceIds ?? []),
...(selectedDeviceIds
? Object.values(this.hass.devices)
? Object.values(registries.devices)
.filter(
(device) =>
device.parent_device_id !== null &&
@@ -1015,7 +1079,9 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
]
: undefined;
let deviceItems = this._getDevicesMemoized(
this.hass,
states,
registries,
i18n,
configEntryLookup,
includeDomains,
includeDeviceClasses,
@@ -1066,11 +1132,11 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
if (!filterType || filterType === "area") {
let areasAndFloors = this._getAreasAndFloorsMemoized(
this.hass.states,
this.hass.floors,
this.hass.areas,
this.hass.devices,
this.hass.entities,
states,
registries.floors,
registries.areas,
registries.devices,
registries.entities,
memoizeOne((value: AreaFloorValue): string =>
[value.type, value.id].join(SEPARATOR)
),
@@ -1131,10 +1197,10 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
if (!filterType || filterType === "label") {
let labels = this._getLabelsMemoized(
this.hass.states,
this.hass.areas,
this.hass.devices,
this.hass.entities,
states,
registries.areas,
registries.devices,
registries.entities,
this._labelRegistry,
includeDomains,
undefined,
@@ -1195,30 +1261,32 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
});
}
private _getAdditionalItems = () => this._getCreateItems(this.createDomains);
private _getAdditionalItems = () =>
this._getCreateItems(this.createDomains, this._i18n.localize);
private _getCreateItems = memoizeOne(
(createDomains: this["createDomains"]) => {
(
createDomains: this["createDomains"],
localize: ContextType<typeof internationalizationContext>["localize"]
) => {
if (!createDomains?.length) {
return [];
}
return createDomains.map((domain) => {
const primary = this.hass.localize(
const primary = localize(
"ui.components.entity.entity-picker.create_helper",
{
domain: isHelperDomain(domain)
? this.hass.localize(`ui.panel.config.helpers.types.${domain}`)
: domainToName(this.hass.localize, domain),
? localize(`ui.panel.config.helpers.types.${domain}`)
: domainToName(localize, domain),
}
);
return {
id: CREATE_ID + domain,
primary: primary,
secondary: this.hass.localize(
"ui.components.entity.entity-picker.new_entity"
),
secondary: localize("ui.components.entity.entity-picker.new_entity"),
icon_path: mdiPlus,
} satisfies EntityComboBoxItem;
});
@@ -1226,18 +1294,24 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
);
private async _loadConfigEntries() {
const configEntries = await getConfigEntries(this.hass);
if (!this._api) {
return;
}
const configEntries = await getConfigEntries(this._api);
this._configEntryLookup = Object.fromEntries(
configEntries.map((entry) => [entry.entry_id, entry])
);
}
private async _loadCompositeSplits() {
if (!this._api || !this._connection) {
return;
}
this._loadingCompositeSplits = true;
try {
this._compositeSplits = await fetchDeviceCompositeSplits(
this.hass.connection,
this.hass.callWS
this._connection.connection,
this._api.callWS
);
} catch (_err) {
this._compositeSplits = {};
@@ -1262,6 +1336,13 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
});
}
// A new renderer when the brands token arrives, so open lists redraw icons.
private _rowRenderer = memoizeOne(
(_brandsAccessToken: string | undefined) =>
(item: Parameters<HaTargetPicker["_renderRow"]>[0]) =>
this._renderRow(item)
);
private _renderRow = (
item:
| PickerComboBoxItem
@@ -1281,8 +1362,8 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
type === "device" && !!(item as DevicePickerItem).is_child;
if (type === "area" || type === "floor" || isChildDeviceRow) {
rtl = computeRTL(
this.hass.language,
this.hass.translationMetadata.translations
this._i18n.language,
this._i18n.translationMetadata.translations
);
hasFloor =
type === "area" && !!(item as FloorComboBoxItem).area?.floor_id;
@@ -1348,9 +1429,9 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
{
domain: (item as DevicePickerItem).domain!,
type: "icon",
darkOptimized: this.hass.themes.darkMode,
darkOptimized: this._darkMode,
},
this.hass.auth.data.hassUrl
this._config?.auth?.data.hassUrl
)}
/>
`
@@ -1396,7 +1477,7 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
};
private _noTargetFoundLabel = (search: string) =>
this.hass.localize("ui.components.target-picker.no_target_found", {
this._i18n.localize("ui.components.target-picker.no_target_found", {
term: html`<b>‘${search}’</b>`,
});
@@ -1404,7 +1485,7 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
caseInsensitiveStringCompare(
(entityA as PickerComboBoxItem).sorting_label!,
(entityB as PickerComboBoxItem).sorting_label!,
this.hass?.locale.language ?? navigator.language
this._i18n?.locale.language ?? navigator.language
);
static styles = css`
+129 -12
View File
@@ -24,8 +24,23 @@ import { showToast } from "../util/toast";
const HIDDEN_CLEANUP_DELAY = 60000;
// How long the backend gets to answer the offer, it has to connect to the camera
const ANSWER_TIMEOUT = 20000;
// How long finding a network path gets after the answer. Browsers can take
// minutes to give up on their own, or never do.
const ICE_TIMEOUT = 30000;
// How often a stream that lost its connection or got a backend error is
// started again. A new connection that gets no answer or finds no path is not
// retried, as a retry would most likely end the same way.
const MAX_RETRIES = 1;
// Gives the backend time to tear down the failed session before the next one
const RETRY_DELAY = 1000;
interface WebRtcPlayerError {
type: "not_supported" | "start_failed" | "connect_failed";
type: "not_supported" | "start_failed" | "connect_failed" | "no_connection";
message?: string;
}
@@ -99,7 +114,7 @@ export class HaWebRtcPlayer extends LitElement {
if (this._microphoneDenied) {
return "denied";
}
if (this._microphoneConnecting) {
if (this._microphoneConnecting || this._retryMicrophoneTrack) {
return "connecting";
}
return this._localReturnAudioTrack ? "on" : "off";
@@ -110,11 +125,17 @@ export class HaWebRtcPlayer extends LitElement {
}
public async toggleMicrophone() {
if (this._localReturnAudioTrack || this._microphoneConnecting) {
if (
this._localReturnAudioTrack ||
this._microphoneConnecting ||
this._retryMicrophoneTrack
) {
// Stop (not disable) so the browser releases the microphone, also cancels connecting
this._microphoneRequest++;
this._localReturnAudioTrack?.stop();
this._localReturnAudioTrack = undefined;
this._retryMicrophoneTrack?.stop();
this._retryMicrophoneTrack = undefined;
this._microphoneConnecting = false;
this._fireMicrophoneChanged();
await this._microphoneSender?.replaceTrack(null).catch(() => undefined);
@@ -197,6 +218,18 @@ export class HaWebRtcPlayer extends LitElement {
private _cleanUpCount = 0;
private _connectTimeout?: number;
private _retryTimeout?: number;
private _retryMicrophoneTrack?: MediaStreamTrack;
private _retries = 0;
// The stream showed video since it was started, so a lost connection is
// worth a retry
private _hasPlayed = false;
private _handleVisibilityChange = () => {
if (document.pictureInPictureElement) {
// video is playing in picture-in-picture mode, don't do anything
@@ -212,7 +245,7 @@ export class HaWebRtcPlayer extends LitElement {
clearTimeout(this._hiddenCleanupTimeout);
this._hiddenCleanupTimeout = undefined;
} else {
this._startWebRtc();
this._restartWebRtc();
}
};
@@ -253,7 +286,7 @@ export class HaWebRtcPlayer extends LitElement {
public override connectedCallback() {
super.connectedCallback();
if (this.hasUpdated && this.entityid) {
this._startWebRtc();
this._restartWebRtc();
}
document.addEventListener("visibilitychange", this._handleVisibilityChange);
}
@@ -274,6 +307,13 @@ export class HaWebRtcPlayer extends LitElement {
if (!changedProperties.has("entityid")) {
return;
}
this._restartWebRtc();
}
/** Starts a new stream, not a retry of the current one. */
private _restartWebRtc() {
this._retries = 0;
this._hasPlayed = false;
this._startWebRtc();
}
@@ -415,7 +455,7 @@ export class HaWebRtcPlayer extends LitElement {
while (this._candidatesList.length) {
const candidate = this._candidatesList.pop();
if (candidate) {
candidates += `a=${candidate}\r\n`;
candidates += `a=${candidate.candidate}\r\n`;
}
}
@@ -423,6 +463,8 @@ export class HaWebRtcPlayer extends LitElement {
this._logEvent("start webRtcOffer", offer_sdp);
this._startConnectTimeout(ANSWER_TIMEOUT);
this._unsub = webRtcOffer(
this._connection,
this.entityid,
@@ -443,11 +485,61 @@ export class HaWebRtcPlayer extends LitElement {
"ice connection state change",
this._peerConnection?.iceConnectionState
);
if (this._peerConnection?.iceConnectionState === "failed") {
this._peerConnection.restartIce();
const iceState = this._peerConnection?.iceConnectionState;
if (iceState === "connected" || iceState === "completed") {
clearTimeout(this._connectTimeout);
this._connectTimeout = undefined;
} else if (iceState === "failed") {
// An ICE restart needs a new offer, which the backend answers from a new
// peer that can't continue this connection, so start a new one instead.
this._retryOrFail({ type: "no_connection" });
}
};
private _startConnectTimeout(timeout: number) {
clearTimeout(this._connectTimeout);
this._connectTimeout = window.setTimeout(() => {
this._connectTimeout = undefined;
this._logEvent("connect timeout");
this._retryOrFail({ type: "no_connection" });
}, timeout);
}
private _retryOrFail(error: WebRtcPlayerError) {
if (
this._retries >= MAX_RETRIES ||
// No answer or no path for a stream that never played, a retry would
// most likely end the same way
(error.type === "no_connection" && !this._hasPlayed) ||
// A microphone connect the user started is handled by starting again
// without the microphone. A retry keeps the microphone of the stream.
(this._localReturnAudioTrack &&
this._microphoneConnecting &&
this._retries === 0)
) {
this._failStart(error);
return;
}
this._retries++;
this._logEvent("retry", this._retries, error);
// Keep showing the last frame instead of the poster while starting again
this._captureReconnectFrame();
// Taken out before the clean up, so a running microphone is not stopped
const microphoneTrack = this._localReturnAudioTrack;
this._localReturnAudioTrack = undefined;
this._cleanUp();
if (microphoneTrack) {
this._retryMicrophoneTrack = microphoneTrack;
this._fireMicrophoneChanged();
}
this._retryTimeout = window.setTimeout(() => {
this._retryTimeout = undefined;
const track = this._retryMicrophoneTrack;
this._retryMicrophoneTrack = undefined;
this._startWebRtc(track);
}, RETRY_DELAY);
}
private async _handleOfferEvent(
peerConnection: RTCPeerConnection,
event: WebRtcOfferEvent
@@ -470,6 +562,10 @@ export class HaWebRtcPlayer extends LitElement {
this._candidatesList = [];
}
if (event.type === "answer") {
const iceState = peerConnection.iceConnectionState;
if (iceState !== "connected" && iceState !== "completed") {
this._startConnectTimeout(ICE_TIMEOUT);
}
this._logEvent("answer", event.answer);
this._handleAnswer(peerConnection, event);
@@ -495,7 +591,8 @@ export class HaWebRtcPlayer extends LitElement {
}
}
if (event.type === "error") {
this._failStart({ type: "start_failed", message: event.message });
// Often temporary, like the backend still closing the previous session
this._retryOrFail({ type: "start_failed", message: event.message });
}
}
@@ -587,8 +684,13 @@ export class HaWebRtcPlayer extends LitElement {
}
private _failStart(error: WebRtcPlayerError) {
// The camera can reject the microphone, keep the video running without it
if (this._localReturnAudioTrack && this._microphoneConnecting) {
// The camera can reject the microphone, keep the video running without it.
// After retries it is the connection that failed, not the microphone.
if (
this._localReturnAudioTrack &&
this._microphoneConnecting &&
this._retries === 0
) {
showToast(this, {
message: this._localize(
"ui.components.web-rtc-player.microphone_connect_failed",
@@ -599,12 +701,19 @@ export class HaWebRtcPlayer extends LitElement {
return;
}
this._error = error;
this._reconnectPoster = undefined;
this._cleanUp();
// Tell the camera stream this player has no video, so it can fall back
fireEvent(this, "streams", { hasAudio: false, hasVideo: false });
}
private _cleanUp() {
this._cleanUpCount++;
this._microphoneRequest++;
clearTimeout(this._connectTimeout);
this._connectTimeout = undefined;
clearTimeout(this._retryTimeout);
this._retryTimeout = undefined;
if (this._remoteStream) {
this._remoteStream.getTracks().forEach((track) => {
track.stop();
@@ -612,9 +721,15 @@ export class HaWebRtcPlayer extends LitElement {
this._remoteStream = undefined;
}
if (this._localReturnAudioTrack || this._microphoneConnecting) {
if (
this._localReturnAudioTrack ||
this._microphoneConnecting ||
this._retryMicrophoneTrack
) {
this._localReturnAudioTrack?.stop();
this._localReturnAudioTrack = undefined;
this._retryMicrophoneTrack?.stop();
this._retryMicrophoneTrack = undefined;
this._microphoneConnecting = false;
this._fireMicrophoneChanged();
}
@@ -666,6 +781,8 @@ export class HaWebRtcPlayer extends LitElement {
this._logEvent("loadedData", data);
this._stopTimer();
this._retries = 0;
this._hasPlayed = true;
// Without a track the microphone is still waiting for permission
if (this._microphoneConnecting && this._localReturnAudioTrack) {
+8 -3
View File
@@ -6,6 +6,7 @@ import { customElement, property, query, state } from "lit/decorators";
import type { ContextType } from "@lit/context";
import { consume } from "../common/decorators/consume";
import { fireEvent } from "../common/dom/fire_event";
import { localizeYamlErrorReason } from "../common/translations/localize-yaml-error";
import { copyToClipboard } from "../common/util/copy-clipboard";
import { haStyle } from "../resources/styles";
import { showToast } from "../util/toast";
@@ -180,7 +181,7 @@ export class HaYamlEditor extends LitElement {
let errorMsg: string | undefined;
let yamlError: {
mark?: { position: number; line: number; column: number };
message?: string;
reason?: string;
} | null = null;
if (this._yaml) {
@@ -189,8 +190,12 @@ export class HaYamlEditor extends LitElement {
} catch (err: any) {
// Invalid YAML
isValid = false;
yamlError = err;
errorMsg = `${this._i18n!.localize("ui.components.yaml-editor.error", { reason: err.reason })}${err.mark ? ` (${this._i18n!.localize("ui.components.yaml-editor.error_location", { line: err.mark.line + 1, column: err.mark.column + 1 })})` : ""}`;
const reason = localizeYamlErrorReason(
this._i18n!.localize,
err.reason
);
yamlError = { mark: err.mark, reason };
errorMsg = `${this._i18n!.localize("ui.components.yaml-editor.error", { reason })}${err.mark ? ` (${this._i18n!.localize("ui.components.yaml-editor.error_location", { line: err.mark.line + 1, column: err.mark.column + 1 })})` : ""}`;
}
} else {
parsed = {};
+116 -11
View File
@@ -2,6 +2,8 @@ import type { CSSResultGroup, TemplateResult } from "lit";
import { css, html } from "lit";
import { customElement, property, query } from "lit/decorators";
import { ifDefined } from "lit/directives/if-defined";
import { keyed } from "lit/directives/keyed";
import { isInteractiveElement } from "../../common/dom/is-interactive-element";
import "../ha-ripple";
import { HaListItemBase } from "./ha-list-item-base";
@@ -15,6 +17,19 @@ import { HaListItemBase } from "./ha-list-item-base";
* roving tabindex, the host is the tab stop and the inner element carries
* `tabindex="-1"`. For a non-interactive row, use `ha-list-item-base`.
*
* Only the headline and supporting text are rendered inside the `<a>` or
* `<button>`, and its hit area is stretched over the whole row. The `start`
* and `end` slots sit next to it, so controls placed there stay separate
* controls for assistive technology. Slotted elements are raised above the
* stretched hit area, except for icons, badges, plain text and empty dividers
* without `tabindex`, so clicks and hover on those reach the row. To keep such
* an element clickable on its own, like a drag handle, give it `z-index: 1`.
*
* Subclasses customize the row with `_renderStart()`, `_renderContent()` and
* `_renderEnd()`. `_renderInner()` is not used.
*
* @csspart base - The row container.
* @csspart control - The inner `<a>` or `<button>`.
* @csspart ripple - The ripple effect element.
*
* @attr {string} href - URL. When set, renders an `<a>` instead of a `<button>`.
@@ -40,11 +55,27 @@ export class HaListItemButton extends HaListItemBase {
this._item?.click();
}
protected _renderBase(inner: TemplateResult): TemplateResult {
protected override render(): TemplateResult {
return this._renderBase(this._renderContent());
}
protected _renderBase(content: TemplateResult): TemplateResult {
// The ripple binds to #item on connect, so render it after the control and
// recreate it with the control
return html`<div part="base" class="base" @click=${this._handleBaseClick}>
${this._renderStart()}${this._renderControl(content)}${keyed(
`${this.href !== undefined}-${this.interactive}`,
this._renderRipple()
)}${this._renderEnd()}
</div>`;
}
/** Renders the native `<a>` or `<button>` around the row content. */
protected _renderControl(content: TemplateResult): TemplateResult {
if (this.href !== undefined) {
return html`<a
part="base"
class="base interactive"
part="control"
class="control"
id="item"
href=${ifDefined(this.disabled ? undefined : this.href)}
target=${ifDefined(this.target)}
@@ -53,18 +84,18 @@ export class HaListItemButton extends HaListItemBase {
tabindex="-1"
aria-disabled=${this.disabled ? "true" : "false"}
>
${this._renderRipple()}${inner}
${content}
</a>`;
}
return html`<button
part="base"
class="base interactive"
part="control"
class="control"
id="item"
type="button"
?disabled=${this.disabled}
tabindex="-1"
>
${this._renderRipple()}${inner}
${content}
</button>`;
}
@@ -76,18 +107,48 @@ export class HaListItemButton extends HaListItemBase {
></ha-ripple>`;
}
// Raised slot content that is not a control, like a wrapper without
// controls, still activates the row
private _handleBaseClick(ev: MouseEvent): void {
const item = this._item;
if (!item || this.disabled) {
return;
}
const path = ev.composedPath();
const baseIndex = path.indexOf(ev.currentTarget as EventTarget);
for (const node of path.slice(0, baseIndex)) {
if (node === item || isInteractiveElement(node)) {
return;
}
}
ev.stopPropagation();
// A copy of the event keeps modifier keys, so links can open in a new tab
item.dispatchEvent(new MouseEvent("click", ev));
}
static styles: CSSResultGroup = [
HaListItemBase.styles,
css`
:host {
cursor: pointer;
--ha-ripple-color: var(--primary-text-color);
/* Smooth the hover when moving between controls in the row */
--ha-ripple-hover-duration: var(--ha-animation-duration-fast);
}
:host([disabled]) {
cursor: default;
}
.base.interactive {
width: 100%;
.base {
isolation: isolate;
}
.control {
display: flex;
align-items: center;
align-self: stretch;
flex: 1 1 auto;
min-width: 0;
margin: 0;
padding: 0;
border: none;
background: transparent;
color: inherit;
@@ -97,8 +158,52 @@ export class HaListItemButton extends HaListItemBase {
appearance: none;
cursor: inherit;
}
:host([disabled]) .base.interactive {
color: var(--disabled-text-color);
/* Stretch the hit area of the control over the whole row */
.control::before {
content: "";
position: absolute;
inset: 0;
border-radius: var(--ha-list-item-focus-radius);
}
.control:focus-visible {
outline: none;
}
.control:focus-visible::before {
outline: var(--ha-list-item-focus-width) solid var(--ha-color-focus);
outline-offset: var(--ha-list-item-focus-offset);
}
/*
* Slotted content is painted above the hit area, so it can be clicked.
* Icons, badges, text and empty dividers go below it, so clicks on
* them reach the row.
* Some icons are positioned, so they need a negative z-index.
*/
::slotted([slot="start"]),
::slotted([slot="end"]) {
position: relative;
z-index: 1;
}
::slotted(
:is(
ha-svg-icon,
ha-icon,
ha-icon-next,
ha-state-icon,
ha-domain-icon,
ha-floor-icon,
ha-spinner,
ha-progress-ring,
ha-user-badge,
ha-person-badge,
ha-tree-indicator,
ha-app-icon,
state-badge,
img,
span,
div:empty
):not([tabindex])
) {
z-index: -1;
}
`,
];
+25 -11
View File
@@ -34,6 +34,8 @@ import type { HASSDomTargetEvent } from "../../common/dom/fire_event";
*
* @attr {string} headline - Primary text. Overridden by the `headline` slot.
* @attr {string} supporting-text - Secondary text. Overridden by the `supporting-text` slot.
* @attr {boolean} wrap-headline - Wraps the headline onto multiple lines instead of truncating it.
* @attr {boolean} wrap-supporting-text - Wraps the supporting text onto multiple lines instead of truncating it.
* @attr {boolean} disabled - Dims the row and blocks pointer events.
*/
@customElement("ha-row-item")
@@ -43,6 +45,12 @@ export class HaRowItem extends LitElement {
@property({ type: String, attribute: "supporting-text" })
public supportingText?: string;
@property({ type: Boolean, reflect: true, attribute: "wrap-headline" })
public wrapHeadline = false;
@property({ type: Boolean, reflect: true, attribute: "wrap-supporting-text" })
public wrapSupportingText = false;
@property({ type: Boolean, reflect: true }) public disabled = false;
protected readonly _slotController = new HasSlotController(
@@ -84,19 +92,21 @@ export class HaRowItem extends LitElement {
}
protected _renderInner(): TemplateResult {
return html`
${this._renderStart()}${this._renderContent()}${this._renderEnd()}
`;
}
protected _renderContent(): TemplateResult {
const hasContent = this._slotController.test("content");
return html`
${this._renderStart()}
<div part="content" class="content">
${
hasContent
? html`<slot name="content"></slot>`
: this._renderDefaultContent()
}
</div>
${this._renderEnd()}
`;
return html`<div part="content" class="content">
${
hasContent
? html`<slot name="content"></slot>`
: this._renderDefaultContent()
}
</div>`;
}
protected _renderStart(): TemplateResult {
@@ -190,6 +200,10 @@ export class HaRowItem extends LitElement {
text-overflow: ellipsis;
white-space: nowrap;
}
:host([wrap-headline]) .headline,
:host([wrap-supporting-text]) .supporting {
white-space: normal;
}
`;
}
@@ -0,0 +1,128 @@
import type { CSSResultGroup, TemplateResult } from "lit";
import { css, html, nothing } from "lit";
import { customElement, property } from "lit/decorators";
import { HaListItemButton } from "../item/ha-list-item-button";
import "./ha-media-browser-thumbnail";
/**
* @element ha-media-player-browse-list-item
* @extends {HaListItemButton}
*
* @summary
* Row of the media browser list. Always reserves a graphic next to the title
* so titles line up, and shows a thumbnail with an optional play button over
* it. Without `interactive` the row is plain text, used for the "not shown"
* row. The row is rendered inside the shadow root of `ha-list-virtualized`, so
* it carries its own styles.
*
* @slot start - Play or pick button, shown over the thumbnail.
* @slot headline - Title of the row.
*
* @csspart start - The graphic container.
*
* @attr {string} thumbnail - URL of the thumbnail. Shown inside the graphic.
*/
@customElement("ha-media-player-browse-list-item")
export class HaMediaPlayerBrowseListItem extends HaListItemButton {
@property() public thumbnail?: string;
// The "not shown" row is plain text, not a button
protected override _renderControl(content: TemplateResult): TemplateResult {
if (!this.interactive) {
return content;
}
return super._renderControl(content);
}
// Always reserve the graphic, so titles line up across rows
protected override _renderStart(): TemplateResult {
return html`<div part="start" class="start">
<div class="graphic">
${
this.thumbnail
? html`<ha-media-browser-thumbnail
.url=${this.thumbnail}
></ha-media-browser-thumbnail>`
: nothing
}
<slot name="start"></slot>
</div>
</div>`;
}
static styles: CSSResultGroup = [
HaListItemButton.styles,
css`
:host {
direction: var(--direction);
--ha-row-item-padding-block: 0;
--ha-row-item-min-height: 48px;
}
:host(.show-images) {
--ha-row-item-padding-block: var(--ha-space-1);
--ha-row-item-min-height: 64px;
}
:host(.not-shown) {
font-style: italic;
color: var(--secondary-text-color);
cursor: default;
}
::slotted(ha-svg-icon) {
color: var(--secondary-text-color);
}
.graphic {
position: relative;
display: flex;
align-items: center;
justify-content: center;
width: 40px;
height: 40px;
border-radius: var(--ha-border-radius-sm);
overflow: hidden;
}
:host(.show-images) .graphic {
width: 56px;
height: 56px;
}
ha-media-browser-thumbnail {
position: absolute;
inset: 0;
}
::slotted(.play) {
position: absolute;
inset: 0;
margin: auto;
width: 40px;
height: 40px;
opacity: 0;
transition: all 0.5s;
background-color: rgba(var(--rgb-card-background-color), 0.5);
border-radius: var(--ha-border-radius-circle);
--ha-icon-button-size: 40px;
}
:host(:hover) ::slotted(.play) {
opacity: 1;
color: var(--primary-text-color);
}
::slotted(.play.show) {
opacity: 1;
background-color: transparent;
}
`,
];
}
declare global {
interface HTMLElementTagNameMap {
"ha-media-player-browse-list-item": HaMediaPlayerBrowseListItem;
}
}
@@ -77,7 +77,6 @@ import "../ha-svg-icon";
import "../ha-tooltip";
import "../input/ha-input-search";
import type { HaInputSearch } from "../input/ha-input-search";
import { HaListItemButton } from "../item/ha-list-item-button";
import "../list/ha-list-virtualized";
import type {
HaListVirtualized,
@@ -88,6 +87,7 @@ import type { ManualMediaPickedEvent } from "./ha-browse-media-manual";
import "./ha-browse-media-tts";
import type { TtsMediaPickedEvent } from "./ha-browse-media-tts";
import "./ha-media-browser-thumbnail";
import "./ha-media-player-browse-list-item";
declare global {
interface HASSDomEvents {
@@ -120,105 +120,6 @@ type MediaBrowseListRow =
count: number;
});
@customElement("ha-media-player-browse-list-item")
export class HaMediaPlayerBrowseListItem extends HaListItemButton {
@property() public thumbnail?: string;
// The "not shown" row is plain text, not a button
protected override _renderBase(inner: TemplateResult): TemplateResult {
if (!this.interactive) {
return html`<div part="base" class="base" id="item">${inner}</div>`;
}
return super._renderBase(inner);
}
// Always reserve the graphic, so titles line up across rows
protected override _renderStart(): TemplateResult {
return html`<div part="start" class="start">
<div class="graphic">
${
this.thumbnail
? html`<ha-media-browser-thumbnail
.url=${this.thumbnail}
></ha-media-browser-thumbnail>`
: nothing
}
<slot name="start"></slot>
</div>
</div>`;
}
static styles: CSSResultGroup = [
HaListItemButton.styles,
css`
:host {
direction: var(--direction);
--ha-row-item-padding-block: 0;
--ha-row-item-min-height: 48px;
}
:host(.show-images) {
--ha-row-item-padding-block: var(--ha-space-1);
--ha-row-item-min-height: 64px;
}
:host(.not-shown) {
font-style: italic;
color: var(--secondary-text-color);
cursor: default;
}
::slotted(ha-svg-icon) {
color: var(--secondary-text-color);
}
.graphic {
position: relative;
display: flex;
align-items: center;
justify-content: center;
width: 40px;
height: 40px;
border-radius: var(--ha-border-radius-sm);
overflow: hidden;
}
:host(.show-images) .graphic {
width: 56px;
height: 56px;
}
ha-media-browser-thumbnail {
position: absolute;
inset: 0;
}
::slotted(.play) {
position: absolute;
inset: 0;
margin: auto;
width: 40px;
height: 40px;
opacity: 0;
transition: all 0.5s;
background-color: rgba(var(--rgb-card-background-color), 0.5);
border-radius: var(--ha-border-radius-circle);
--ha-icon-button-size: 40px;
}
:host(:hover) ::slotted(.play) {
opacity: 1;
color: var(--primary-text-color);
}
::slotted(.play.show) {
opacity: 1;
background-color: transparent;
}
`,
];
}
/** Folders with at least this many items get a filter field when their source cannot search. */
const FILTER_MIN_CHILDREN = 10;
@@ -1945,6 +1846,5 @@ export class HaMediaPlayerBrowse extends LitElement {
declare global {
interface HTMLElementTagNameMap {
"ha-media-player-browse": HaMediaPlayerBrowse;
"ha-media-player-browse-list-item": HaMediaPlayerBrowseListItem;
}
}
@@ -1,11 +1,20 @@
import type { ContextType } from "@lit/context";
import type { HassEntity } from "home-assistant-js-websocket";
import type { PropertyValues } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { customElement, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import { ensureArray } from "../../../common/array/ensure-array";
import { consume } from "../../../common/decorators/consume";
import { consumeLocalize } from "../../../common/decorators/consume-context-entry";
import type { HASSDomEvent } from "../../../common/dom/fire_event";
import { fireEvent } from "../../../common/dom/fire_event";
import type { LocalizeFunc } from "../../../common/translations/localize";
import {
apiContext,
entitiesContext,
statesContext,
} from "../../../data/context";
import type { DeviceRegistryEntry } from "../../../data/device/device_registry";
import { getDeviceIntegrationLookup } from "../../../data/device/device_registry";
import type { HaEntityPickerEntityFilterFunc } from "../../../data/entity/entity";
@@ -33,7 +42,16 @@ import type { TargetDetailsDialogParams } from "./show-dialog-target-details";
@customElement("ha-dialog-target-details")
class DialogTargetDetails extends LitElement implements HassDialog {
@property({ attribute: false }) public hass!: HomeAssistant;
@state() @consumeLocalize() private _localize!: LocalizeFunc;
@consume({ context: apiContext, subscribe: true })
private _api!: ContextType<typeof apiContext>;
@consume({ context: statesContext, subscribe: true })
private _states!: ContextType<typeof statesContext>;
@consume({ context: entitiesContext, subscribe: true })
private _entities!: ContextType<typeof entitiesContext>;
@state() private _params?: TargetDetailsDialogParams;
@@ -50,8 +68,8 @@ class DialogTargetDetails extends LitElement implements HassDialog {
private _exclusionsRequest = 0;
private _deviceIntegrationLookup = memoizeOne(
(entities: HomeAssistant["entities"]) =>
getDeviceIntegrationLookup(Object.values(entities))
(entities: HomeAssistant["entities"] | null) =>
getDeviceIntegrationLookup(Object.values(entities ?? {}))
);
public showDialog(params: TargetDetailsDialogParams): void {
@@ -59,8 +77,17 @@ class DialogTargetDetails extends LitElement implements HassDialog {
this._initialExclusions = params.exclusions ?? [];
this._exclusions = this._initialExclusions;
this._opened = true;
if (!params.exclusions && params.exclude) {
this._resolveExclusions(params);
}
protected willUpdate(changedProperties: PropertyValues): void {
super.willUpdate(changedProperties);
if (
changedProperties.has("_params") &&
this._params &&
!this._params.exclusions &&
this._params.exclude
) {
this._resolveExclusions(this._params);
}
}
@@ -69,7 +96,7 @@ class DialogTargetDetails extends LitElement implements HassDialog {
let exclusions: TargetExclusion[];
try {
exclusions = await resolveItemExclusions(
this.hass,
this._api.callWS,
{ type: params.type, id: params.itemId },
params.exclude,
params.selector
@@ -128,8 +155,8 @@ class DialogTargetDetails extends LitElement implements HassDialog {
private async _loadEntitySources(): Promise<void> {
try {
this._entitySources = await fetchEntitySourcesWithCache(
this.hass.callWS,
this.hass.states
this._api.callWS,
this._states ?? {}
);
} catch (err) {
// eslint-disable-next-line no-console
@@ -155,7 +182,7 @@ class DialogTargetDetails extends LitElement implements HassDialog {
return true;
}
const deviceIntegrations = this._hasIntegration({ target })
? this._deviceIntegrationLookup(this.hass.entities)
? this._deviceIntegrationLookup(this._entities)
: undefined;
return ensureArray(target.device).some((d) =>
filterSelectorDevices(d, device, deviceIntegrations)
@@ -217,19 +244,19 @@ class DialogTargetDetails extends LitElement implements HassDialog {
return html`
<ha-adaptive-dialog
.open=${this._opened}
header-title=${this.hass.localize(
header-title=${this._localize(
"ui.components.target-picker.target_details"
)}
@closed=${this._dialogClosed}
>
<div class="type-wrapper">
<div class="type-label">
${this.hass.localize(
${this._localize(
`ui.components.target-picker.type.${this._params.type}`
)}
</div>
<ha-list-base
.ariaLabel=${`${this.hass.localize(`ui.components.target-picker.type.${this._params.type}`)}: ${this._params.title}`}
.ariaLabel=${`${this._localize(`ui.components.target-picker.type.${this._params.type}`)}: ${this._params.title}`}
wrap-focus
>
${
@@ -262,10 +289,10 @@ class DialogTargetDetails extends LitElement implements HassDialog {
appearance="plain"
@click=${this.closeDialog}
>
${this.hass.localize("ui.common.cancel")}
${this._localize("ui.common.cancel")}
</ha-button>
<ha-button slot="primaryAction" @click=${this._apply}>
${this.hass.localize("ui.common.apply")}
${this._localize("ui.common.apply")}
</ha-button>
</ha-dialog-footer>
`
@@ -3,14 +3,24 @@ import memoizeOne from "memoize-one";
import {
consumeContext,
ContextController,
ContextSubscriptionController,
} from "../../common/decorators/consume";
import {
apiContext,
registriesContext,
statesContext,
} from "../../data/context";
import type { HaEntityPickerEntityFilterFunc } from "../../data/entity/entity";
import {
explainTarget,
type HassServiceTargetWithExclude,
type TargetItem,
} from "../../data/target";
import type { HomeAssistant } from "../../types";
import type {
HomeAssistant,
HomeAssistantApi,
HomeAssistantRegistries,
} from "../../types";
import type { HaDevicePickerDeviceFilterFunc } from "../device/ha-device-picker";
import { filterTargetEntries } from "./filter-target-entries";
import { targetExpandGroupContext } from "./target-expand-group-context";
@@ -26,7 +36,6 @@ import {
export type TargetExclusionsHost = ReactiveControllerHost &
HTMLElement & {
hass: HomeAssistant;
value?: HassServiceTargetWithExclude;
allowExclude: boolean;
primaryEntitiesOnly?: boolean;
@@ -55,11 +64,31 @@ export class TargetExclusionsController extends ContextController {
@consumeContext({ context: targetExpandGroupContext, subscribe: true })
private _expandGroup?: boolean;
@consumeContext({ context: registriesContext, subscribe: true })
private _registries?: HomeAssistantRegistries;
@consumeContext({
context: apiContext,
subscribe: true,
transform: (api) => api?.callWS,
})
private _callWS?: HomeAssistantApi["callWS"];
private _states?: HomeAssistant["states"] | null;
private _request = 0;
constructor(host: TargetExclusionsHost) {
super(host);
this._host = host;
// States change all the time, so they only resolve again once loaded.
new ContextSubscriptionController(host, statesContext, (states) => {
const loaded = !this._states && !!states;
this._states = states;
if (loaded) {
this.resolve();
}
});
}
protected contextUpdated() {
@@ -78,9 +107,9 @@ export class TargetExclusionsController extends ContextController {
*/
public async resolve({ force = false }: { force?: boolean } = {}) {
const request = ++this._request;
const { hass, value, allowExclude } = this._host;
const { value, allowExclude } = this._host;
if (
!hass ||
!this._ready ||
!value ||
(!force &&
!hasIncludedTargets(value.exclude) &&
@@ -140,7 +169,7 @@ export class TargetExclusionsController extends ContextController {
item: TargetItem
): Promise<TargetExclusion | undefined> {
const { value } = this._host;
if (!value) {
if (!this._ready || !value) {
return undefined;
}
const explained = await this._explain({
@@ -152,9 +181,19 @@ export class TargetExclusionsController extends ContextController {
]?.[0];
}
private get _ready(): boolean {
return !!(
this._callWS &&
this._states &&
this._registries?.entities &&
this._registries.devices &&
this._registries.areas
);
}
private _explain(target: HassServiceTargetWithExclude) {
return explainTarget(
this._host.hass.callWS,
this._callWS!,
target,
this.expandGroup,
this._host.primaryEntitiesOnly ?? true
@@ -170,8 +209,9 @@ export class TargetExclusionsController extends ContextController {
// What the picker's filters leave of a target, narrowed like the target's
// row, so what an exclusion takes out matches what the row counts.
private _keeps(): TargetNarrowing {
const states = this._states!;
const { entities, devices, areas } = this._registries!;
const {
hass,
deviceFilter,
entityFilter,
includeDomains,
@@ -180,10 +220,10 @@ export class TargetExclusionsController extends ContextController {
} = this._host;
return (include) =>
filterTargetEntries(
hass.states,
hass.entities,
hass.devices,
hass.areas,
states,
entities,
devices,
areas,
include,
{
referenced_areas: include.referenced_areas,
@@ -145,7 +145,7 @@ export const computeExclusionsPerTarget = (
* `expandGroup` follows what the target is used for.
*/
export const resolveItemExclusions = async (
hass: HomeAssistant,
callWS: HomeAssistant["callWS"],
item: TargetItem,
exclude: HassServiceTarget | undefined,
primaryEntitiesOnly?: boolean,
@@ -155,7 +155,7 @@ export const resolveItemExclusions = async (
return [];
}
const explained = await explainTarget(
hass.callWS,
callWS,
{ [`${item.type}_id`]: [item.id], exclude },
expandGroup,
primaryEntitiesOnly
@@ -112,7 +112,7 @@ export class HaTracePathDetails extends LitElement {
initialValue: {
options: [],
showIndices: false,
select: () => undefined,
select: () => true,
fixDuplicateIds: async () => undefined,
updateTriggerId: () => false,
},
+201
View File
@@ -0,0 +1,201 @@
import type { LocalizeFunc } from "../common/translations/localize";
export const ROOM_ICONS = {
living_room: [
"mdi:sofa",
"mdi:sofa-single",
"mdi:television",
"mdi:fireplace",
"mdi:floor-lamp",
],
bedroom: ["mdi:bed", "mdi:bed-king", "mdi:bed-double", "mdi:lamp"],
kids_room: ["mdi:teddy-bear", "mdi:toy-brick", "mdi:bunk-bed", "mdi:cradle"],
kitchen: [
"mdi:stove",
"mdi:fridge",
"mdi:countertop",
"mdi:chef-hat",
"mdi:pot-steam",
"mdi:coffee-maker",
],
dining_room: [
"mdi:silverware-fork-knife",
"mdi:table-chair",
"mdi:table-furniture",
"mdi:glass-wine",
],
bathroom: [
"mdi:shower",
"mdi:bathtub",
"mdi:shower-head",
"mdi:toothbrush",
"mdi:hair-dryer",
],
toilet: ["mdi:toilet", "mdi:paper-roll"],
office: [
"mdi:desk",
"mdi:laptop",
"mdi:monitor",
"mdi:chair-rolling",
"mdi:bookshelf",
],
laundry_room: [
"mdi:washing-machine",
"mdi:tumble-dryer",
"mdi:iron",
"mdi:hanger",
],
hallway: ["mdi:door", "mdi:door-open", "mdi:coat-rack", "mdi:key"],
stairs: ["mdi:stairs", "mdi:stairs-up", "mdi:stairs-down"],
garage: ["mdi:garage", "mdi:garage-variant", "mdi:car", "mdi:tools"],
garden: [
"mdi:flower",
"mdi:tree",
"mdi:grass",
"mdi:sprout",
"mdi:watering-can",
"mdi:greenhouse",
],
terrace: [
"mdi:balcony",
"mdi:grill",
"mdi:umbrella-outline",
"mdi:coach-lamp",
"mdi:patio-heater",
],
basement: [
"mdi:home-floor-negative-1",
"mdi:stairs-down",
"mdi:water-boiler",
],
attic: ["mdi:home-roof", "mdi:package-variant-closed"],
storage: ["mdi:archive", "mdi:package-variant-closed", "mdi:warehouse"],
utility_room: [
"mdi:water-boiler",
"mdi:heat-pump",
"mdi:server-network",
"mdi:flash",
],
pool: ["mdi:pool", "mdi:swim"],
media_room: [
"mdi:theater",
"mdi:projector",
"mdi:television",
"mdi:movie-open",
],
game_room: [
"mdi:gamepad-variant",
"mdi:billiards",
"mdi:cards-playing",
"mdi:dice-multiple",
],
music_room: ["mdi:piano", "mdi:guitar-acoustic", "mdi:music"],
library: ["mdi:bookshelf", "mdi:book-open-variant", "mdi:library"],
bar: [
"mdi:glass-cocktail",
"mdi:glass-mug-variant",
"mdi:bottle-wine",
"mdi:beer",
],
dressing_room: [
"mdi:hanger",
"mdi:wardrobe",
"mdi:tshirt-crew",
"mdi:shoe-heel",
],
workshop: ["mdi:tools", "mdi:hammer-wrench", "mdi:toolbox", "mdi:saw-blade"],
server_room: ["mdi:server-network", "mdi:server", "mdi:router-network"],
safe_room: ["mdi:shield-home", "mdi:shield-lock", "mdi:safe"],
prayer_room: ["mdi:hands-pray", "mdi:meditation", "mdi:candle"],
sauna: ["mdi:spa", "mdi:hot-tub", "mdi:thermometer-high"],
outdoor_kitchen: ["mdi:grill", "mdi:grill-outline", "mdi:pizza"],
driveway: ["mdi:road-variant", "mdi:car", "mdi:gate"],
} as const satisfies Record<string, readonly string[]>;
export type RoomType = keyof typeof ROOM_ICONS;
type RoomKeywords = Partial<Record<RoomType, string[]>>;
export const ROOM_TYPES = Object.keys(ROOM_ICONS) as RoomType[];
export const isRoomType = (value: string): value is RoomType =>
(ROOM_TYPES as string[]).includes(value);
// Languages written without spaces between words
const NO_WORD_SPACING =
/[\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}\p{Script=Thai}]/u;
const normalizeRoomText = (text: string): string =>
text
.normalize("NFD")
.replace(/\p{M}/gu, "")
.toLowerCase()
.replace(/['’]/g, "")
.replace(/[^\p{L}\p{N}]+/gu, " ")
.trim();
interface RoomMatch {
room: RoomType;
start: number;
end: number;
}
const findMatches = (name: string, keywords: RoomKeywords): RoomMatch[] => {
const text = ` ${normalizeRoomText(name)} `;
const candidates: RoomMatch[] = [];
for (const room of ROOM_TYPES) {
for (const keyword of keywords[room] ?? []) {
const phrase = normalizeRoomText(keyword);
if (!phrase) {
continue;
}
const needle = NO_WORD_SPACING.test(phrase) ? phrase : ` ${phrase} `;
const start = text.indexOf(needle);
if (start !== -1) {
candidates.push({ room, start, end: start + needle.length });
}
}
}
// The longest phrase wins, so "guest bedroom" is not also read as "bedroom"
candidates.sort((a, b) => b.end - b.start - (a.end - a.start));
const kept: RoomMatch[] = [];
for (const match of candidates) {
const overlaps = kept.some(
(other) => match.start < other.end - 1 && other.start < match.end - 1
);
if (!overlaps) {
kept.push(match);
}
}
return kept.sort((a, b) => a.start - b.start);
};
const matchRoomTypes = (
names: string[],
keywords: RoomKeywords
): RoomType[] => {
const rooms = new Set<RoomType>();
for (const name of names) {
for (const match of findMatches(name, keywords)) {
rooms.add(match.room);
}
}
return [...rooms];
};
const roomIconSuggestions = (rooms: RoomType[]): string[] => [
...new Set(rooms.flatMap((room) => ROOM_ICONS[room])),
];
export const getAreaIconSuggestions = (
names: string[],
localize: LocalizeFunc
): string[] => {
const keywords: RoomKeywords = Object.fromEntries(
ROOM_TYPES.map((room) => [
room,
localize(`ui.components.rooms.keywords.${room}`).split("|"),
])
);
return roomIconSuggestions(matchRoomTypes(names, keywords));
};
+80
View File
@@ -0,0 +1,80 @@
export const STREAM_TYPE_HLS = "hls";
export const STREAM_TYPE_WEB_RTC = "web_rtc";
export type StreamType = typeof STREAM_TYPE_HLS | typeof STREAM_TYPE_WEB_RTC;
export const MJPEG_STREAM = "mjpeg";
export type CameraStreamType = StreamType | typeof MJPEG_STREAM;
export interface CameraStream {
type: CameraStreamType;
visible: boolean;
}
/** What a player reported, `hasVideo: false` means it failed to load. */
export interface CameraStreamTracks {
hasAudio: boolean;
hasVideo: boolean;
}
export interface CameraStreamSelectionOptions {
supportedTypes?: StreamType[];
hlsStreams?: CameraStreamTracks;
webRtcStreams?: CameraStreamTracks;
}
/**
* Decides which players are rendered and which one is shown. Hidden players
* are still loading in the background.
*/
export const selectCameraStreams = ({
supportedTypes,
hlsStreams,
webRtcStreams,
}: CameraStreamSelectionOptions): CameraStream[] => {
if (!supportedTypes) {
return [];
}
if (supportedTypes.length === 0) {
// doesn't support any stream type, fallback to mjpeg
return [{ type: MJPEG_STREAM, visible: true }];
}
if (supportedTypes.length === 1) {
// only 1 stream type, no need to choose
if (
(supportedTypes[0] === STREAM_TYPE_HLS &&
hlsStreams?.hasVideo === false) ||
(supportedTypes[0] === STREAM_TYPE_WEB_RTC &&
webRtcStreams?.hasVideo === false)
) {
// stream failed to load, fallback to mjpeg
return [{ type: MJPEG_STREAM, visible: true }];
}
return [{ type: supportedTypes[0], visible: true }];
}
const hlsFailed = hlsStreams?.hasVideo === false;
if (webRtcStreams?.hasVideo) {
// WebRTC is preferred whenever it plays, it has the lowest latency
return [{ type: STREAM_TYPE_WEB_RTC, visible: true }];
}
if (webRtcStreams?.hasVideo === false) {
if (hlsFailed) {
// both streams failed to load, fallback to mjpeg
return [{ type: MJPEG_STREAM, visible: true }];
}
return [{ type: STREAM_TYPE_HLS, visible: true }];
}
// WebRTC is still loading
if (hlsFailed) {
return [{ type: STREAM_TYPE_WEB_RTC, visible: true }];
}
return [
{ type: STREAM_TYPE_HLS, visible: true },
{ type: STREAM_TYPE_WEB_RTC, visible: false },
];
};
+6 -4
View File
@@ -7,13 +7,15 @@ import { timeCacheEntityPromiseFunc } from "../common/util/time-cache-entity-pro
import type { HomeAssistant, HomeAssistantApi } from "../types";
import { fileDownload } from "../util/file_download";
import { getSignedPath } from "./auth";
import type { StreamType } from "./camera-streams";
export const CAMERA_ORIENTATIONS = [1, 2, 3, 4, 6, 8];
export const STREAM_TYPE_HLS = "hls";
export const STREAM_TYPE_WEB_RTC = "web_rtc";
export type StreamType = typeof STREAM_TYPE_HLS | typeof STREAM_TYPE_WEB_RTC;
export {
STREAM_TYPE_HLS,
STREAM_TYPE_WEB_RTC,
type StreamType,
} from "./camera-streams";
export { CameraEntityFeature } from "./feature/camera_entity_feature";
-70
View File
@@ -1,13 +1,5 @@
import type { ConfigPath } from "../common/util/config-path";
import type { HomeAssistant } from "../types";
export interface ConfigValidationError {
message: string;
/** Where the problem is, `[]` when core does not know. */
path: ConfigPath;
code?: string | null;
}
export interface ValidConfig {
valid: true;
error: null;
@@ -16,14 +8,10 @@ export interface ValidConfig {
export interface InvalidConfig {
valid: false;
error: string;
/** Paths start at the item index of the section. */
errors: ConfigValidationError[];
}
type ValidKeys = "triggers" | "actions" | "conditions";
export type ValidationSectionKeys = Partial<Record<ValidKeys, string>>;
export const validateConfig = <T extends Partial<Record<ValidKeys, unknown>>>(
hass: HomeAssistant,
config: T
@@ -32,61 +20,3 @@ export const validateConfig = <T extends Partial<Record<ValidKeys, unknown>>>(
type: "validate_config",
...config,
});
/**
* Flattens a `validate_config` result into errors with paths from the config
* root. `sectionKeys` names the config key of a section when it differs from
* the key it is validated under, e.g. `{ actions: "sequence" }` for scripts.
*/
export const validationResultErrors = (
result: Partial<Record<ValidKeys, ValidConfig | InvalidConfig>>,
sectionKeys: ValidationSectionKeys = {}
): ConfigValidationError[] =>
(Object.keys(result) as ValidKeys[]).flatMap((key) => {
const section = result[key];
if (!section || section.valid) {
return [];
}
const sectionKey = sectionKeys[key] ?? key;
return section.errors.map((error) => ({
...error,
path: [sectionKey, ...error.path],
}));
});
export const validateConfigErrors = async (
hass: HomeAssistant,
config: Partial<Record<ValidKeys, unknown>>,
sectionKeys?: ValidationSectionKeys
): Promise<ConfigValidationError[]> =>
validationResultErrors(await validateConfig(hass, config), sectionKeys);
export interface ConfigSaveError {
message: string;
errors: ConfigValidationError[];
}
/**
* Reads the errors of a failed config save. Errors that are not about the
* config, like a network error, only have a message.
*/
export const parseConfigSaveError = (err: unknown): ConfigSaveError => {
const { body, error } = (err ?? {}) as { body?: unknown; error?: unknown };
const bodyObject =
body && typeof body === "object"
? (body as { message?: unknown; errors?: unknown })
: undefined;
const message = String(
bodyObject?.message ||
error ||
(typeof body === "string" && body) ||
(err instanceof Error && err.message) ||
err
);
return {
message,
errors: Array.isArray(bodyObject?.errors)
? (bodyObject.errors as ConfigValidationError[])
: [],
};
};
+10 -4
View File
@@ -220,24 +220,30 @@ export class ConfigEntryStream {
private _entries: ConfigEntry[] = [];
processMessage(message: ConfigEntryUpdate[]) {
let entries =
message.length === 0 ||
message.some((configEntry) => configEntry.type === null)
? []
: [...this._entries];
message.forEach((configEntry) => {
if (configEntry.type === null || configEntry.type === "added") {
this._entries.push(configEntry.entry);
entries.push(configEntry.entry);
return;
}
if (configEntry.type === "removed") {
this._entries = this._entries.filter(
entries = entries.filter(
(entry) => entry.entry_id !== configEntry.entry.entry_id
);
return;
}
if (configEntry.type === "updated") {
const newEntry = configEntry.entry;
this._entries = this._entries.map((entry) =>
entries = entries.map((entry) =>
entry.entry_id === newEntry.entry_id ? newEntry : entry
);
}
});
return this._entries;
this._entries = entries;
return entries;
}
}
+4 -4
View File
@@ -30,11 +30,11 @@ const HEADERS = {
};
export const createConfigFlow = (
hass: HomeAssistant,
callApi: HomeAssistant["callApi"],
handler: string,
entry_id?: string
) =>
hass.callApi<DataEntryFlowStep>(
callApi<DataEntryFlowStep>(
"POST",
"config/config_entries/flow",
{
@@ -53,11 +53,11 @@ export const fetchConfigFlow = (hass: HomeAssistant, flowId: string) =>
);
export const handleConfigFlowStep = (
hass: HomeAssistant,
callApi: HomeAssistant["callApi"],
flowId: string,
data: Record<string, any>
) =>
hass.callApi<DataEntryFlowStep>(
callApi<DataEntryFlowStep>(
"POST",
`config/config_entries/flow/${flowId}`,
data,
+23 -23
View File
@@ -6,6 +6,7 @@ import {
computeEntitySearchLabels,
} from "../../common/entity/compute_entity_name_display";
import type { RelatedIdSets } from "../../common/search/related-context";
import type { LocalizeFunc } from "../../common/translations/localize";
import { caseInsensitiveStringCompare } from "../../common/string/compare";
import type { PickerComboBoxItem } from "../../components/ha-picker-combo-box";
import type { FuseWeightedKey } from "../../resources/fuseMultiTerm";
@@ -51,6 +52,14 @@ export const entityComboBoxKeys: FuseWeightedKey[] = [
];
export interface GetEntitiesOptions {
states: HomeAssistant["states"];
entities: HomeAssistant["entities"];
devices: HomeAssistant["devices"];
areas: HomeAssistant["areas"];
floors: HomeAssistant["floors"];
localize: LocalizeFunc;
language: string;
translationMetadata: HomeAssistant["translationMetadata"];
includeDomains?: string[];
excludeDomains?: string[];
entityFilter?: HaEntityPickerEntityFilterFunc;
@@ -63,20 +72,11 @@ export interface GetEntitiesOptions {
}
export const getEntities = (
hass: Pick<
HomeAssistant,
| "states"
| "entities"
| "devices"
| "areas"
| "floors"
| "language"
| "translationMetadata"
| "localize"
>,
options?: GetEntitiesOptions
options: GetEntitiesOptions
): EntityComboBoxItem[] => {
const {
states,
localize,
includeDomains,
excludeDomains,
entityFilter,
@@ -86,11 +86,11 @@ export const getEntities = (
excludeEntities,
value,
idPrefix = "",
} = options ?? {};
} = options;
let items: EntityComboBoxItem[];
let entityIds = Object.keys(hass.states);
let entityIds = Object.keys(states);
// These run over every entity, so use Sets for O(1) membership instead of
// repeated Array.includes scans.
@@ -126,21 +126,21 @@ export const getEntities = (
// of inside the map over (potentially thousands of) entities.
const domainNames = new Map<string, string>();
const displayOptions = {
entities: hass.entities,
devices: hass.devices,
areas: hass.areas,
floors: hass.floors,
language: hass.language,
translationMetadata: hass.translationMetadata,
entities: options.entities,
devices: options.devices,
areas: options.areas,
floors: options.floors,
language: options.language,
translationMetadata: options.translationMetadata,
};
items = entityIds.map<EntityComboBoxItem>((entityId) => {
const stateObj = hass.states[entityId];
const stateObj = states[entityId];
const domain = computeDomain(entityId);
let domainName = domainNames.get(domain);
if (domainName === undefined) {
domainName = domainToName(hass.localize, domain);
domainName = domainToName(localize, domain);
domainNames.set(domain, domainName);
}
@@ -156,7 +156,7 @@ export const getEntities = (
domain_name: domainName,
sorting_label: [primary, secondary].filter(Boolean).join("_"),
search_labels: {
...computeEntitySearchLabels(stateObj, hass),
...computeEntitySearchLabels(stateObj, displayOptions),
domainName: domainName || null,
entityId: entityId,
},
+15
View File
@@ -60,3 +60,18 @@ export const validateHassioSession = async (
): Promise<void> => {
await hass.callWS({ type: "supervisor/ingress/validate_session", session });
};
// Extends the session, or replaces it with a new one for the app if it has
// expired. Returns the session to keep using.
export const refreshHassioSession = async (
hass: Pick<HomeAssistant, "callWS">,
slug: string,
session: string
): Promise<string> => {
try {
await validateHassioSession(hass, session);
return session;
} catch (_err: any) {
return createHassioSession(hass, slug);
}
};
+11 -7
View File
@@ -209,7 +209,15 @@ export const watchForNewMatterDevice = (
callback: (device: DeviceRegistryEntry) => void
): UnsubscribeFunc => {
let curMatterDevices: Set<string> | undefined;
const unsubDeviceReg = subscribeDeviceRegistry(hass.connection, (entries) => {
let unsubDeviceReg: UnsubscribeFunc | undefined;
// Callers also unsubscribe after a find. The registry collection counts its
// subscribers, so a second unsubscribe would stop its updates for everyone.
const unsubscribe = () => {
unsubDeviceReg?.();
unsubDeviceReg = undefined;
curMatterDevices = undefined;
};
unsubDeviceReg = subscribeDeviceRegistry(hass.connection, (entries) => {
if (!curMatterDevices) {
curMatterDevices = new Set(
Object.values(entries)
@@ -226,15 +234,11 @@ export const watchForNewMatterDevice = (
!curMatterDevices!.has(device.id)
);
if (newMatterDevices.length) {
unsubDeviceReg();
curMatterDevices = undefined;
unsubscribe();
callback(newMatterDevices[0]);
}
});
return () => {
unsubDeviceReg();
curMatterDevices = undefined;
};
return unsubscribe;
};
export const redirectOnNewMatterDevice = (
+2 -2
View File
@@ -20,10 +20,10 @@ export interface SensorDeviceClassUnits {
}
export const getSensorDeviceClassConvertibleUnits = (
hass: HomeAssistant,
callWS: HomeAssistant["callWS"],
deviceClass: string
): Promise<SensorDeviceClassUnits> =>
hass.callWS({
callWS({
type: "sensor/device_class_convertible_units",
device_class: deviceClass,
});
+1 -1
View File
@@ -101,7 +101,7 @@ export const updateItem = (
"update_item",
{
item: item.uid,
rename: item.summary,
rename: item.summary || undefined,
status: item.status,
description: item.description,
due_datetime: item.due?.includes("T") ? item.due : undefined,
@@ -26,7 +26,7 @@ export const showConfigFlowDialog = (
showDevices: true,
createFlow: async (hass, handler) => {
const [step] = await Promise.all([
createConfigFlow(hass, handler, dialogParams.entryId),
createConfigFlow(hass.callApi, handler, dialogParams.entryId),
hass.loadFragmentTranslation("config"),
hass.loadBackendTranslation("config", handler),
hass.loadBackendTranslation("selector", handler),
@@ -51,7 +51,7 @@ export const showConfigFlowDialog = (
return step;
},
handleFlowStep: async (hass, flowId, data) => {
const step = await handleConfigFlowStep(hass, flowId, data);
const step = await handleConfigFlowStep(hass.callApi, flowId, data);
await loadFlowStepTranslations(hass, step);
return step;
},
@@ -1,12 +1,21 @@
import type { ContextType } from "@lit/context";
import Cropper from "cropperjs";
// @ts-ignore
import cropperCss from "cropperjs/dist/cropper.css";
import type { CSSResultGroup, TemplateResult } from "lit";
import { css, html, LitElement, nothing, unsafeCSS } from "lit";
import "cropperjs";
import type {
CropperCanvas,
CropperGrid,
CropperHandle,
CropperImage,
CropperSelection,
CropperShade,
} from "cropperjs";
import type { CSSResultGroup, PropertyValues, TemplateResult } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, query, state } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import { ifDefined } from "lit/directives/if-defined";
import { styleMap } from "lit/directives/style-map";
import { consume } from "../../common/decorators/consume";
import "../../components/ha-alert";
import "../../components/ha-button";
import "../../components/ha-dialog-footer";
import "../../components/ha-dialog";
@@ -16,76 +25,314 @@ import { haStyleDialog } from "../../resources/styles";
import { DialogMixin } from "../dialog-mixin";
import type { HaImageCropperDialogParams } from "./show-image-cropper-dialog";
interface Rect {
x: number;
y: number;
width: number;
height: number;
}
const MIN_SELECTION_SIZE = 50;
const INITIAL_COVERAGE = 0.8;
const EPSILON = 0.05;
const clamp = (value: number, min: number, max: number) =>
Math.min(Math.max(value, min), max);
const contains = (outer: Rect, inner: Rect): boolean =>
inner.x >= outer.x - EPSILON &&
inner.y >= outer.y - EPSILON &&
inner.x + inner.width <= outer.x + outer.width + EPSILON &&
inner.y + inner.height <= outer.y + outer.height + EPSILON;
const intersect = (a: Rect, b: Rect): Rect => {
const x = Math.max(a.x, b.x);
const y = Math.max(a.y, b.y);
return {
x,
y,
width: Math.min(a.x + a.width, b.x + b.width) - x,
height: Math.min(a.y + a.height, b.y + b.height) - y,
};
};
const sameSize = (a: Rect, b: Rect): boolean =>
Math.abs(a.width - b.width) < EPSILON &&
Math.abs(a.height - b.height) < EPSILON;
const moveInto = (rect: Rect, bounds: Rect): Rect => ({
...rect,
x: clamp(rect.x, bounds.x, bounds.x + bounds.width - rect.width),
y: clamp(rect.y, bounds.y, bounds.y + bounds.height - rect.height),
});
const anchorOf = (
start: number,
size: number,
nextStart: number,
nextSize: number
): number => {
if (Math.abs(nextStart - start) < EPSILON) {
return nextStart;
}
if (Math.abs(nextStart + nextSize - (start + size)) < EPSILON) {
return start + size;
}
return nextStart + nextSize / 2;
};
const shrinkInto = (current: Rect, next: Rect, bounds: Rect): Rect => {
const ax = anchorOf(current.x, current.width, next.x, next.width);
const ay = anchorOf(current.y, current.height, next.y, next.height);
let scale = 1;
if (next.x < bounds.x) {
scale = Math.min(scale, (ax - bounds.x) / (ax - next.x));
}
if (next.x + next.width > bounds.x + bounds.width) {
scale = Math.min(
scale,
(bounds.x + bounds.width - ax) / (next.x + next.width - ax)
);
}
if (next.y < bounds.y) {
scale = Math.min(scale, (ay - bounds.y) / (ay - next.y));
}
if (next.y + next.height > bounds.y + bounds.height) {
scale = Math.min(
scale,
(bounds.y + bounds.height - ay) / (next.y + next.height - ay)
);
}
return {
x: ax + (next.x - ax) * scale,
y: ay + (next.y - ay) * scale,
width: next.width * scale,
height: next.height * scale,
};
};
const scaleRect = ({ x, y, width, height }: Rect, ratio: number): Rect => ({
x: x * ratio,
y: y * ratio,
width: width * ratio,
height: height * ratio,
});
@customElement("image-cropper-dialog")
export class HaImagecropperDialog extends DirtyStateProviderMixin<Cropper.Data>()(
export class HaImagecropperDialog extends DirtyStateProviderMixin<Rect>()(
DialogMixin<HaImageCropperDialogParams>(LitElement)
) {
@state()
@consume({ context: internationalizationContext, subscribe: true })
private _i18n!: ContextType<typeof internationalizationContext>;
@query("img") private _image!: HTMLImageElement;
@query("cropper-image") private _image?: CropperImage;
private _cropper?: Cropper;
@query("cropper-selection") private _selection?: CropperSelection;
@state() private _imageSize?: { width: number; height: number };
@state() private _shown = false;
@state() private _cropReady = false;
@state() private _loadError = false;
@state() private _isTargetAspectRatio?: boolean;
private _imageUrl?: string;
private _resizeObserver?: ResizeObserver;
private _canvasWidth?: number;
private _refitting = false;
private _cropped = false;
private _closing = false;
private _shiftLocked = false;
public disconnectedCallback(): void {
super.disconnectedCallback();
if (!this._cropped) {
this.params?.cancelCallback?.();
}
this._cropper?.destroy();
this._cropper = undefined;
if (this._imageUrl) {
URL.revokeObjectURL(this._imageUrl);
this._imageUrl = undefined;
}
this._resizeObserver?.disconnect();
this._resizeObserver = undefined;
}
protected firstUpdated() {
if (!this.params) {
return;
}
this._image.src = URL.createObjectURL(this.params.file);
this._cropper = new Cropper(this._image, {
aspectRatio: this.params.options.aspectRatio,
viewMode: 1,
dragMode: "move",
minCropBoxWidth: 50,
ready: () => {
this._isTargetAspectRatio = this._checkMatchAspectRatio();
URL.revokeObjectURL(this._image!.src);
this._initDirtyTracking({ type: "deep" }, this._cropper!.getData());
this._cropReady = true;
},
crop: () => {
if (this._cropReady) {
this._updateDirtyState(this._cropper!.getData());
}
},
});
this._imageUrl = URL.createObjectURL(this.params.file);
this._loadImageSize(this._imageUrl);
}
private _checkMatchAspectRatio(): boolean {
private async _loadImageSize(url: string) {
const image = new Image();
image.src = url;
try {
await image.decode();
} catch {
this._loadError = true;
return;
}
if (!this.isConnected) {
return;
}
this._imageSize = {
width: image.naturalWidth,
height: image.naturalHeight,
};
this._isTargetAspectRatio = this._checkMatchAspectRatio(
image.naturalWidth,
image.naturalHeight
);
}
protected updated(changedProps: PropertyValues) {
super.updated(changedProps);
if (
!this._cropReady &&
(changedProps.has("_imageSize") || changedProps.has("_shown")) &&
this._image
) {
this._initCropper(this._image);
}
}
private async _initCropper(image: CropperImage) {
try {
await image.$ready();
} catch {
this._loadError = true;
return;
}
if (!this.isConnected || image !== this._image) {
return;
}
const selection = this._selection!;
if (!selection.width) {
await new Promise<void>((resolve) => {
selection.addEventListener("change", () => resolve(), { once: true });
});
if (!this.isConnected || image !== this._image) {
return;
}
}
this._initDirtyTracking(
{ type: "deep" },
this._cropData(this._selectionRect(), this._imageRect())
);
this._cropReady = true;
this._resizeObserver = new ResizeObserver((entries) => {
this._refit(entries[0].contentRect);
});
this._resizeObserver.observe(image.parentElement!);
}
private _selectionRect(): Rect {
const { x, y, width, height } = this._selection!;
return { x, y, width, height };
}
private _canvasRect(): Rect {
const { width, height } =
this._image!.parentElement!.getBoundingClientRect();
return { x: 0, y: 0, width, height };
}
private _imageRect(): Rect {
const canvasRect = this._image!.parentElement!.getBoundingClientRect();
const imageRect = this._image!.getBoundingClientRect();
return {
x: imageRect.x - canvasRect.x,
y: imageRect.y - canvasRect.y,
width: imageRect.width,
height: imageRect.height,
};
}
private _cropData(selection: Rect, image: Rect): Rect {
const scale = image.width / this._imageSize!.width;
return {
x: Math.round((selection.x - image.x) / scale),
y: Math.round((selection.y - image.y) / scale),
width: Math.round(selection.width / scale),
height: Math.round(selection.height / scale),
};
}
private _refit(canvas: { width: number; height: number }) {
const previous = this._canvasWidth;
this._canvasWidth = canvas.width;
if (!previous || !canvas.width || previous === canvas.width) {
return;
}
const current = this._selectionRect();
const ratio = Math.min(
canvas.width / previous,
canvas.height / current.height
);
const image = scaleRect(this._imageRect(), ratio);
const selection = scaleRect(current, ratio);
const dy =
image.height <= canvas.height
? (canvas.height - image.height) / 2 - image.y
: clamp(
0,
-selection.y,
canvas.height - selection.y - selection.height
);
image.y += dy;
selection.y += dy;
const { width, height } = this._imageSize!;
this._refitting = true;
this._image!.$setTransform(
image.width / width,
0,
0,
image.height / height,
image.x + image.width / 2 - width / 2,
image.y + image.height / 2 - height / 2
);
this._selection!.$change(
selection.x,
selection.y,
selection.width,
selection.height
);
this._refitting = false;
}
private _checkMatchAspectRatio(
naturalWidth: number,
naturalHeight: number
): boolean {
const targetRatio = this.params?.options.aspectRatio;
if (!targetRatio) {
return true;
}
const imageData = this._cropper!.getImageData();
if (imageData.aspectRatio === targetRatio) {
if (naturalWidth / naturalHeight === targetRatio) {
return true;
}
// If the image is not exactly the aspect ratio see if it is within a pixel.
if (imageData.naturalWidth > imageData.naturalHeight) {
const targetHeight = imageData.naturalWidth / targetRatio;
return Math.abs(targetHeight - imageData.naturalHeight) <= 1;
if (naturalWidth > naturalHeight) {
const targetHeight = naturalWidth / targetRatio;
return Math.abs(targetHeight - naturalHeight) <= 1;
}
const targetWidth = imageData.naturalHeight * targetRatio;
return Math.abs(targetWidth - imageData.naturalWidth) <= 1;
const targetWidth = naturalHeight * targetRatio;
return Math.abs(targetWidth - naturalWidth) <= 1;
}
protected render(): TemplateResult | typeof nothing {
@@ -93,23 +340,74 @@ export class HaImagecropperDialog extends DirtyStateProviderMixin<Cropper.Data>(
return nothing;
}
const label = this._i18n.localize("ui.dialogs.image_cropper.crop_image");
return html`
<ha-dialog
open
header-title=${this._i18n.localize(
"ui.dialogs.image_cropper.crop_image"
)}
header-title=${label}
.preventScrimClose=${this.isDirtyState}
@wa-hide=${this._handleHide}
@after-show=${this._handleAfterShow}
>
<div
class="container ${classMap({
round: Boolean(this.params.options.round),
})}"
>
<img
alt=${this._i18n.localize("ui.dialogs.image_cropper.crop_image")}
/>
${
this._loadError
? html`<ha-alert alert-type="error">
${this._i18n.localize("ui.dialogs.image_cropper.load_error")}
</ha-alert>`
: this._imageSize && this._shown
? html`
<cropper-canvas
background
@actionmove=${this._handleCanvasActionMove}
style=${styleMap({
aspectRatio: `${this._imageSize.width} / ${this._imageSize.height}`,
})}
>
<cropper-image
src=${this._imageUrl!}
alt=${label}
scalable
translatable
@change=${this._handleImageChange}
></cropper-image>
<cropper-shade hidden></cropper-shade>
<cropper-handle action="move" plain></cropper-handle>
<cropper-selection
aspect-ratio=${ifDefined(
this.params.options.aspectRatio || undefined
)}
initial-coverage=${INITIAL_COVERAGE}
precise
movable
resizable
outlined
@change=${this._handleSelectionChange}
>
<cropper-grid
role="grid"
bordered
covered
></cropper-grid>
<cropper-handle action="move" plain></cropper-handle>
<cropper-handle action="n-resize"></cropper-handle>
<cropper-handle action="e-resize"></cropper-handle>
<cropper-handle action="s-resize"></cropper-handle>
<cropper-handle action="w-resize"></cropper-handle>
<cropper-handle action="ne-resize"></cropper-handle>
<cropper-handle action="nw-resize"></cropper-handle>
<cropper-handle action="se-resize"></cropper-handle>
<cropper-handle action="sw-resize"></cropper-handle>
</cropper-selection>
</cropper-canvas>
`
: nothing
}
</div>
<ha-dialog-footer slot="footer">
<ha-button
@@ -127,9 +425,7 @@ export class HaImagecropperDialog extends DirtyStateProviderMixin<Cropper.Data>(
appearance="plain"
@click=${this._useOriginal}
>
${this._i18n.localize(
"ui.dialogs.image_cropper.use_original"
)}
${this._i18n.localize("ui.dialogs.image_cropper.use_original")}
</ha-button>
`
: nothing
@@ -146,17 +442,99 @@ export class HaImagecropperDialog extends DirtyStateProviderMixin<Cropper.Data>(
`;
}
private _handleAfterShow() {
this._shown = true;
}
private _handleHide(ev: Event) {
// Ignore hide events from nested overlays and blocked dismissals.
if (ev.eventPhase !== Event.AT_TARGET || ev.defaultPrevented) {
return;
}
this._closing = true;
}
private _cropImage() {
const canvas = this._cropper?.getCroppedCanvas();
if (!canvas) {
private _handleCanvasActionMove(
ev: CustomEvent<{ relatedEvent?: PointerEvent }>
) {
this._shiftLocked = ev.detail.relatedEvent?.shiftKey ?? false;
}
private _handleSelectionChange(ev: CustomEvent<Rect>) {
if (!this._cropReady || this._refitting) {
return;
}
const selection = this._selectionRect();
const image = this._imageRect();
const bounds = intersect(image, this._canvasRect());
const next = ev.detail;
if (next.width < MIN_SELECTION_SIZE && next.width < selection.width) {
ev.preventDefault();
return;
}
if (!contains(bounds, next)) {
ev.preventDefault();
const fitted = sameSize(next, selection)
? moveInto(next, bounds)
: this.params?.options.aspectRatio || this._shiftLocked
? shrinkInto(selection, next, bounds)
: intersect(next, bounds);
this._selection!.$change(fitted.x, fitted.y, fitted.width, fitted.height);
return;
}
this._updateDirtyState(this._cropData(next, image));
}
private _handleImageChange(ev: CustomEvent<Rect>) {
if (!this._cropReady || this._refitting) {
return;
}
const selection = this._selectionRect();
const next = ev.detail;
if (!contains(next, selection)) {
ev.preventDefault();
const current = this._imageRect();
if (sameSize(next, current)) {
const x = clamp(
next.x,
selection.x + selection.width - next.width,
selection.x
);
const y = clamp(
next.y,
selection.y + selection.height - next.height,
selection.y
);
if (x !== current.x || y !== current.y) {
this._image!.$move(x - current.x, y - current.y);
}
}
return;
}
this._updateDirtyState(this._cropData(selection, next));
}
private async _cropImage() {
const selection = this._selection;
if (!selection || !this._cropReady) {
return;
}
const image = this._imageRect();
const rect = this._selectionRect();
const x = clamp(rect.x, image.x, image.x + image.width - rect.width);
const y = clamp(rect.y, image.y, image.y + image.height - rect.height);
if (x !== rect.x || y !== rect.y) {
selection.$change(x, y);
}
const crop = this._cropData(this._selectionRect(), image);
let canvas: HTMLCanvasElement;
try {
canvas = await selection.$toCanvas({
width: crop.width,
beforeDraw: (_context, target) => {
target.height = crop.height;
},
});
} catch {
return;
}
const params = this.params!;
@@ -188,22 +566,22 @@ export class HaImagecropperDialog extends DirtyStateProviderMixin<Cropper.Data>(
return [
haStyleDialog,
css`
${unsafeCSS(cropperCss)}
.container {
max-width: 640px;
}
img {
max-width: 100%;
cropper-canvas {
width: 100%;
direction: ltr;
--theme-color: var(--primary-color);
}
.container.round .cropper-view-box,
.container.round .cropper-face {
cropper-handle {
--theme-color: var(--primary-color);
}
.container.round cropper-selection,
.container.round cropper-shade,
.container.round cropper-grid {
border-radius: var(--ha-picture-border-radius);
}
.cropper-line,
.cropper-point,
.cropper-point.point-se::before {
background-color: var(--primary-color);
}
`,
];
}
@@ -212,5 +590,11 @@ export class HaImagecropperDialog extends DirtyStateProviderMixin<Cropper.Data>(
declare global {
interface HTMLElementTagNameMap {
"image-cropper-dialog": HaImagecropperDialog;
"cropper-canvas": CropperCanvas;
"cropper-image": CropperImage;
"cropper-shade": CropperShade;
"cropper-handle": CropperHandle;
"cropper-selection": CropperSelection;
"cropper-grid": CropperGrid;
}
}
@@ -167,16 +167,12 @@ export class HaMoreInfoFavorites extends LitElement {
display: flex;
align-items: center;
justify-content: center;
margin: calc(var(--ha-space-2) * -1);
gap: var(--ha-space-4);
flex-wrap: wrap;
max-width: var(--favorite-items-max-width, 250px);
user-select: none;
}
.container > * {
margin: var(--ha-space-2);
}
.favorite {
display: block;
}
@@ -88,10 +88,7 @@ class MoreInfoGroup extends LitElement {
return "";
}
return html`
<state-card-content
.stateObj=${entityState}
.hass=${this.hass}
></state-card-content>
<state-card-content .stateObj=${entityState}></state-card-content>
`;
})}`;
}
@@ -124,7 +124,12 @@ class MoreInfoMediaPlayer extends LitElement {
private _volumeController = new VolumeSliderController({
getSlider: () => this._volumeSlider,
step: this._volumeStep,
onSetVolume: (value) => this._setVolume(value),
onSetVolume: (value) => {
// The final value is set right away, so drop the pending update from
// the input events before it, or the same value is sent twice
this._debouncedVolumeSet.cancel();
this._setVolume(value);
},
onSetVolumeDebounced: (value) => this._debouncedVolumeSet(value),
});
@@ -74,7 +74,6 @@ export class MoreInfoInfo extends LitElement {
<state-card-content
in-dialog
.stateObj=${stateObj}
.hass=${this.hass}
></state-card-content>
`
}
+11 -1
View File
@@ -642,7 +642,17 @@ export class QuickBar extends LitElement {
);
private _getEntitiesMemoized = memoizeOne((hass: HomeAssistant) =>
getEntities(hass, { idPrefix: `entity${SEPARATOR}` })
getEntities({
states: hass.states,
entities: hass.entities,
devices: hass.devices,
areas: hass.areas,
floors: hass.floors,
localize: hass.localize,
language: hass.language,
translationMetadata: hass.translationMetadata,
idPrefix: `entity${SEPARATOR}`,
})
);
private _getDevicesMemoized = memoizeOne(
+21 -26
View File
@@ -150,9 +150,11 @@ class DialogRestart extends LitElement {
"ui.dialogs.restart.reload.description"
)}
</div>
<div slot="start" class="icon-background reload">
<ha-svg-icon .path=${mdiAutoFix}></ha-svg-icon>
</div>
<ha-svg-icon
slot="start"
class="icon-background reload"
.path=${mdiAutoFix}
></ha-svg-icon>
<ha-icon-next slot="end"></ha-icon-next>
</ha-list-item-button>
<ha-list-item-button
@@ -160,9 +162,11 @@ class DialogRestart extends LitElement {
@click=${this._handleAction}
.disabled=${this._loadingBackupInfo}
>
<div slot="start" class="icon-background restart">
<ha-svg-icon .path=${mdiRefresh}></ha-svg-icon>
</div>
<ha-svg-icon
slot="start"
class="icon-background restart"
.path=${mdiRefresh}
></ha-svg-icon>
<div slot="headline">
${this.hass.localize("ui.dialogs.restart.restart.title")}
</div>
@@ -188,14 +192,11 @@ class DialogRestart extends LitElement {
@click=${this._handleAction}
.disabled=${this._loadingBackupInfo}
>
<div
<ha-svg-icon
slot="start"
class="icon-background reboot"
>
<ha-svg-icon
.path=${mdiPowerCycle}
></ha-svg-icon>
</div>
.path=${mdiPowerCycle}
></ha-svg-icon>
<div slot="headline">
${this.hass.localize(
"ui.dialogs.restart.reboot.title"
@@ -213,12 +214,11 @@ class DialogRestart extends LitElement {
@click=${this._handleAction}
.disabled=${this._loadingBackupInfo}
>
<div
<ha-svg-icon
slot="start"
class="icon-background shutdown"
>
<ha-svg-icon .path=${mdiPower}></ha-svg-icon>
</div>
.path=${mdiPower}
></ha-svg-icon>
<div slot="headline">
${this.hass.localize(
"ui.dialogs.restart.shutdown.title"
@@ -239,12 +239,11 @@ class DialogRestart extends LitElement {
@click=${this._handleAction}
.disabled=${this._loadingBackupInfo}
>
<div
<ha-svg-icon
slot="start"
class="icon-background restart-safe-mode"
>
<ha-svg-icon .path=${mdiLifebuoy}></ha-svg-icon>
</div>
.path=${mdiLifebuoy}
></ha-svg-icon>
<div slot="headline">
${this.hass.localize(
"ui.dialogs.restart.restart-safe-mode.title"
@@ -436,12 +435,8 @@ class DialogRestart extends LitElement {
.icon-background {
border-radius: var(--ha-border-radius-circle);
color: #fff;
display: flex;
width: 40px;
height: 40px;
display: flex;
align-items: center;
justify-content: center;
/* 24px icon + padding = 40px circle */
padding: var(--ha-space-2);
}
.reload {
background-color: #5f8a49;
@@ -310,7 +310,7 @@ export class HaVoiceAssistantSetupStepLocal extends LitElement {
if (configFlow) {
const step = await handleConfigFlowStep(
this.hass,
this.hass.callApi,
configFlow.flow_id,
{}
);
@@ -343,11 +343,15 @@ export class HaVoiceAssistantSetupStepLocal extends LitElement {
}
private async _createConfigEntry(type: "tts" | "stt") {
const configFlow = await createConfigFlow(this.hass, "wyoming");
const step = await handleConfigFlowStep(this.hass, configFlow.flow_id, {
host: type === "tts" ? this._ttsHostName : this._sttHostName,
port: type === "tts" ? this._ttsPort : this._sttPort,
});
const configFlow = await createConfigFlow(this.hass.callApi, "wyoming");
const step = await handleConfigFlowStep(
this.hass.callApi,
configFlow.flow_id,
{
host: type === "tts" ? this._ttsHostName : this._sttHostName,
port: type === "tts" ? this._ttsPort : this._sttPort,
}
);
if (step.type !== "create_entry") {
throw new Error(
`${this.hass.localize("ui.panel.config.voice_assistants.satellite_wizard.local.errors.failed_create_entry", { app: type === "tts" ? this._ttsProviderName : this._sttProviderName })}${"errors" in step ? `: ${step.errors?.base}` : ""}`
+47 -2
View File
@@ -45,15 +45,50 @@
}
#ha-launch-screen .ha-launch-screen-spacer-top {
flex: 1;
margin-top: calc( 2 * max(var(--safe-area-inset-top, 0px), 48px) + 46px );
margin-top: 142px;
padding-top: 48px;
}
#ha-launch-screen .ha-launch-screen-spacer-bottom {
flex: 1;
padding-top: 16px;
}
#ha-launch-screen-info-box p {
max-width: 400px;
margin: 0 16px 16px;
text-align: center;
}
/* Margins, not flex `gap` (Safari 14.1). */
#ha-launch-screen .ha-launch-screen-actions {
display: flex;
flex-direction: column;
align-items: center;
margin: 0 16px;
}
#ha-launch-screen .ha-launch-screen-actions label {
display: flex;
align-items: center;
margin-top: 8px;
padding: 8px 16px;
font-size: 14px;
cursor: pointer;
}
#ha-launch-screen .ha-launch-screen-actions input {
margin: 0 8px 0 0;
accent-color: #009ac7;
}
#ha-launch-screen .ha-launch-screen-actions button {
padding: 8px 16px;
border: 1px solid currentColor;
border-radius: 18px;
background: none;
color: #009ac7;
font: inherit;
font-size: 14px;
font-weight: 500;
cursor: pointer;
}
.ohf-logo {
margin: max(var(--safe-area-inset-bottom, 0px), 48px) 0;
margin: 48px 0;
display: flex;
flex-direction: column;
align-items: center;
@@ -73,6 +108,16 @@
aspect-ratio: 237 / 24;
height: auto;
}
/* max() with var() fails after the cascade, so plain fallbacks above
need @supports (Chrome below 79). `body`: Jinja2, see below. */
@supports (margin: max(1px, 2px)) {
body #ha-launch-screen .ha-launch-screen-spacer-top {
margin-top: calc( 2 * max(var(--safe-area-inset-top, 0px), 48px) + 46px );
}
.ohf-logo {
margin: max(var(--safe-area-inset-bottom, 0px), 48px) 0;
}
}
@media (prefers-color-scheme: dark) {
/*
* Core renders the app's index.html through Jinja2, where the minified
+80 -17
View File
@@ -65,23 +65,86 @@
</script>
<script>
if (!window.latestJS) {
// Below the [legacy] browserslist floor the entry bundles cannot be
// parsed, or dialogs cannot open, and nothing would ever leave the
// launch screen. Test the features that set the floors: BigInt for
// Chrome 67, native <dialog> for Safari 15.4 and Firefox 98. Not
// transpiled, so keep this ES5.
if (
typeof BigInt === "undefined" ||
typeof document.createElement("dialog").showModal !== "function"
) {
document.getElementById("ha-launch-screen-info-box").textContent =
"This browser is too old to run Home Assistant.";
} else {
window.customPanelJS = "<%= es5CustomPanelJS %>";
<% for (const entry of es5EntryJS) { %>
_ls("<%= entry %>", true);
<% } %>
}
// Floor check: BigInt for Chrome 67, <dialog> for Safari 15.4 and
// Firefox 98. Not transpiled, so keep this ES5.
(function () {
var ACCEPTED_KEY = "haUnsupportedBrowserAccepted";
var REMIND_AFTER = 30 * 24 * 60 * 60 * 1000;
function loadApp() {
window.customPanelJS = "<%= es5CustomPanelJS %>";
<% for (const entry of es5EntryJS) { %>
_ls("<%= entry %>", true);
<% } %>
}
if (
typeof BigInt !== "undefined" &&
typeof document.createElement("dialog").showModal === "function"
) {
loadApp();
return;
}
// Shown while the app loads too.
var box = document.getElementById("ha-launch-screen-info-box");
var message = document.createElement("p");
message.textContent =
"This browser is no longer supported. Some parts of Home Assistant may not work.";
box.appendChild(message);
// Stored: "always", or when the user last continued (ms).
var stored = null;
try {
stored = localStorage.getItem(ACCEPTED_KEY);
} catch (_err) {
// Ask on every load.
}
if (stored === "always") {
loadApp();
return;
}
var acceptedAt = Number(stored) || 0;
// A clock behind the stored time asks again.
var age = Date.now() - acceptedAt;
if (acceptedAt && age >= 0 && age < REMIND_AFTER) {
loadApp();
return;
}
var actions = document.createElement("div");
actions.className = "ha-launch-screen-actions";
box.appendChild(actions);
var always = null;
var button = document.createElement("button");
button.type = "button";
button.textContent = "Continue anyway";
button.addEventListener("click", function () {
try {
localStorage.setItem(
ACCEPTED_KEY,
always && always.checked ? "always" : String(Date.now())
);
} catch (_err) {
// Continue this time only.
}
box.removeChild(actions);
loadApp();
});
actions.appendChild(button);
// From the first reminder on. A checkbox, so it is not chosen by
// accident.
if (acceptedAt) {
var label = document.createElement("label");
always = document.createElement("input");
always.type = "checkbox";
label.appendChild(always);
label.appendChild(document.createTextNode("Don't show again"));
actions.appendChild(label);
}
})();
}
</script>
+4
View File
@@ -155,6 +155,10 @@ export const DirtyStateProviderMixin =
protected updated(changedProperties: PropertyValues<this>): void {
super.updated(changedProperties);
// A late fetch can still update a removed provider
if (!this.isConnected) {
return;
}
connectedDirtyStateProviders.set(this, this._blocksReload);
publishGlobalDirtyState();
}
+51 -8
View File
@@ -16,7 +16,7 @@ import type { IngressAppInfo } from "../../data/hassio/ingress";
import {
createHassioSession,
fetchIngressAppInfo,
validateHassioSession,
refreshHassioSession,
} from "../../data/hassio/ingress";
import {
showAlertDialog,
@@ -60,6 +60,10 @@ class HaPanelApp extends LitElement {
private _sessionKeepAlive?: number;
private _session?: { slug: string; session: string };
private _sessionRefresh?: Promise<void>;
private _fetchDataTimeout?: number;
private _autoRetryUntil?: number;
@@ -92,17 +96,22 @@ class HaPanelApp extends LitElement {
super.connectedCallback();
window.addEventListener("message", this._handleIframeMessage);
window.addEventListener("resize", this._handleResize);
document.addEventListener("visibilitychange", this._handleVisibility);
document.addEventListener("resume", this._handleVisibility);
}
public disconnectedCallback() {
super.disconnectedCallback();
window.removeEventListener("message", this._handleIframeMessage);
window.removeEventListener("resize", this._handleResize);
document.removeEventListener("visibilitychange", this._handleVisibility);
document.removeEventListener("resume", this._handleVisibility);
if (this._sessionKeepAlive) {
clearInterval(this._sessionKeepAlive);
this._sessionKeepAlive = undefined;
}
this._session = undefined;
if (this._fetchDataTimeout) {
clearTimeout(this._fetchDataTimeout);
this._fetchDataTimeout = undefined;
@@ -342,17 +351,51 @@ class HaPanelApp extends LitElement {
if (this._sessionKeepAlive) {
clearInterval(this._sessionKeepAlive);
}
this._sessionKeepAlive = window.setInterval(async () => {
try {
await validateHassioSession(this.hass, session);
} catch (_err: any) {
session = await createHassioSession(this.hass, addonSlug);
}
}, 60000);
this._session = { slug: addonSlug, session };
this._sessionKeepAlive = window.setInterval(this._refreshSession, 60000);
this._addon = addon;
}
// The keep-alive timer does not run while the device sleeps, so the session
// may have expired by the time the page is visible again. Refresh it right
// away instead of leaving the app unreachable until the next tick.
// "resume" only fires in Chromium after a frozen page, other browsers rely
// on "visibilitychange".
private _handleVisibility = () => {
if (!document.hidden) {
this._refreshSession();
}
};
private _refreshSession = (): Promise<void> => {
const current = this._session;
if (!current) {
return Promise.resolve();
}
if (this._sessionRefresh) {
return this._sessionRefresh;
}
this._sessionRefresh = refreshHassioSession(
this.hass,
current.slug,
current.session
)
.then((session) => {
// the panel may have switched to another app meanwhile
if (this._session === current) {
this._session = { slug: current.slug, session };
}
})
.catch((_err: any) => {
// Connection to Home Assistant is gone, the next refresh will retry
})
.finally(() => {
this._sessionRefresh = undefined;
});
return this._sessionRefresh;
};
private async _checkLoaded(ev: Event): Promise<void> {
const iframe = ev.target as HTMLIFrameElement;
@@ -816,7 +816,7 @@ class SupervisorAppInfo extends MobileAwareMixin(LitElement) {
this._currentAddon.version
? html`
<wa-divider></wa-divider>
<ha-row-item>
<ha-row-item wrap-supporting-text>
<span slot="headline">
${this.i18n.localize(
"ui.panel.config.apps.dashboard.option.boot.title"
@@ -842,7 +842,7 @@ class SupervisorAppInfo extends MobileAwareMixin(LitElement) {
${
this._currentAddon.startup !== "once"
? html`
<ha-row-item>
<ha-row-item wrap-supporting-text>
<span slot="headline">
${this.i18n.localize(
"ui.panel.config.apps.dashboard.option.watchdog.title"
@@ -867,7 +867,7 @@ class SupervisorAppInfo extends MobileAwareMixin(LitElement) {
`
: nothing
}
<ha-row-item>
<ha-row-item wrap-supporting-text>
<span slot="headline">
${this.i18n.localize(
"ui.panel.config.apps.dashboard.option.auto_update.title"
@@ -893,7 +893,7 @@ class SupervisorAppInfo extends MobileAwareMixin(LitElement) {
!this._computeCannotIngressSidebar &&
this._currentAddon.ingress
? html`
<ha-row-item>
<ha-row-item wrap-supporting-text>
<span slot="headline">
${this.i18n.localize(
"ui.panel.config.apps.dashboard.option.ingress_panel.title"
@@ -923,7 +923,7 @@ class SupervisorAppInfo extends MobileAwareMixin(LitElement) {
${
this._computeUsesProtectedOptions
? html`
<ha-row-item>
<ha-row-item wrap-supporting-text>
<span slot="headline">
${this.i18n.localize(
"ui.panel.config.apps.dashboard.option.protected.title"
@@ -954,7 +954,7 @@ class SupervisorAppInfo extends MobileAwareMixin(LitElement) {
${
this._currentAddon.version && this._currentAddon.state === "started"
? html`<wa-divider></wa-divider>
<ha-row-item>
<ha-row-item wrap-supporting-text>
<span slot="supporting-text">
${this.i18n.localize(
"ui.panel.config.apps.dashboard.hostname"
@@ -1630,10 +1630,6 @@ class SupervisorAppInfo extends MobileAwareMixin(LitElement) {
color: var(--ha-color-text-secondary);
}
.controls ha-row-item::part(supporting-text) {
white-space: normal;
}
.controls .content {
display: flex;
flex-direction: column;
@@ -1,5 +1,5 @@
import type { HassEntity } from "home-assistant-js-websocket";
import type { CSSResultGroup } from "lit";
import type { CSSResultGroup, PropertyValues } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { fireEvent } from "../../../common/dom/fire_event";
@@ -29,6 +29,10 @@ import type {
AreaRegistryEntryMutableParams,
} from "../../../data/area/area_registry";
import { deleteAreaRegistryEntry } from "../../../data/area/area_registry";
import {
getAreaIconSuggestions,
ROOM_ICONS,
} from "../../../data/area_icon_suggestions";
import {
SENSOR_DEVICE_CLASS_HUMIDITY,
SENSOR_DEVICE_CLASS_TEMPERATURE,
@@ -96,6 +100,10 @@ class DialogAreaDetail
@state() private _submitting = false;
@state() private _suggestedIcons?: string[];
private _iconFollowsName = false;
@state() private _open = false;
@state() private _suggestionInclude: MetadataSuggestionInclude = {
@@ -128,6 +136,7 @@ class DialogAreaDetail
this._temperatureEntity = null;
this._humidityEntity = null;
}
this._iconFollowsName = !this._params.entry;
this._open = true;
this._initDirtyTracking({ type: "deep" }, this._currentState());
await this.updateComplete;
@@ -151,6 +160,30 @@ class DialogAreaDetail
return true;
}
protected willUpdate(changedProps: PropertyValues) {
super.willUpdate(changedProps);
if (changedProps.has("_name") || changedProps.has("_aliases")) {
this._updateSuggestedIcons();
}
}
private _updateSuggestedIcons() {
const icons = getAreaIconSuggestions(
[this._name ?? "", ...(this._aliases ?? [])],
this.hass.localize
);
this._suggestedIcons = icons;
if (this._iconFollowsName) {
const untouched = !this.isDirtyState;
this._icon = icons[0] ?? null;
if (untouched) {
this._initDirtyTracking({ type: "deep" }, this._currentState());
} else {
this._updateDirtyState(this._currentState());
}
}
}
private _entityName(entityId: string | null): string | null {
const stateObj = entityId ? this.hass.states[entityId] : undefined;
return stateObj ? this.hass.formatEntityName(stateObj) : null;
@@ -193,6 +226,7 @@ class DialogAreaDetail
.value=${this._icon}
@value-changed=${this._iconChanged}
.label=${this.hass.localize("ui.panel.config.areas.editor.icon")}
.suggestedIcons=${this._suggestedIcons}
></ha-icon-picker>
<ha-floor-picker
@@ -300,6 +334,7 @@ class DialogAreaDetail
this._suggestionInclude = {
...this._suggestionInclude,
name: this._name.trim() === "",
room: !this._icon || this._iconFollowsName,
};
return generateMetadataSuggestionTask<{
@@ -349,6 +384,11 @@ class DialogAreaDetail
this._floor = processed.floor;
}
if (processed.room && (!this._icon || this._iconFollowsName)) {
this._icon = ROOM_ICONS[processed.room][0];
this._iconFollowsName = false;
}
this._updateDirtyState(this._currentState());
}
@@ -464,6 +504,7 @@ class DialogAreaDetail
private _iconChanged(ev) {
this._error = undefined;
this._icon = ev.detail.value;
this._iconFollowsName = false;
this._updateDirtyState(this._currentState());
}
@@ -129,7 +129,6 @@ import "./types/ha-automation-action-wait_template";
import { renderCtrlOrCmd } from "../../../../common/keyboard/ctrl-or-cmd";
import { scrollRowIntoView } from "../scroll-row-into-view";
import { AutomationPathController } from "../automation-path";
import { renderRowErrorIcon } from "../automation-row-error-icon";
export interface ActionElement extends LitElement {
action: Action;
@@ -499,11 +498,6 @@ export default class HaAutomationActionRow extends LitElement {
${this._runResult?.title}
</ha-automation-row-event-chip>
${renderRowErrorIcon(
this.pathController.errors,
this.pathController.path,
this.hass.localize("ui.panel.config.automation.editor.errors.label")
)}
<slot name="icons" slot="icons"></slot>
<ha-dropdown
@@ -804,7 +798,6 @@ export default class HaAutomationActionRow extends LitElement {
...ACTION_COMBINED_BLOCKS,
].includes(blockType!)}
.sortSelected=${this.sortSelected}
.error=${this.pathController.errors.length > 0}
.dim=${this._running}
@click=${this._toggleSidebar}
@toggle-collapsed=${this._toggleCollapse}
@@ -27,18 +27,9 @@ import {
} from "../../../../../data/device/device_registry";
import type { EntityRegistryEntry } from "../../../../../data/entity/entity_registry";
import type { HomeAssistant } from "../../../../../types";
import {
deviceAutomationErrorText,
deviceErrorText,
} from "../../automation-errors";
import { AutomationFieldErrorsController } from "../../automation-field-errors";
@customElement("ha-automation-action-device_id")
export class HaDeviceAction extends LitElement {
private readonly _fieldErrors = new AutomationFieldErrorsController(this, {
inlineFields: ["device_id", "domain"],
});
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ type: Boolean }) public disabled = false;
@@ -140,20 +131,12 @@ export class HaDeviceAction extends LitElement {
}
protected render() {
const deviceError = this._fieldErrors.fieldError("device_id");
const automationError = this._fieldErrors.fieldError("domain");
const deviceId = this._deviceId || this.action.device_id;
return html`
<ha-device-picker
.value=${deviceId}
.replacementDeviceIds=${this._replacementDeviceIds}
.invalid=${!!deviceError}
.errorMessage=${
deviceError
? deviceErrorText(this.hass.localize, deviceError)
: undefined
}
.disabled=${this.disabled}
@value-changed=${this._devicePicked}
label=${this.hass.localize(
@@ -161,16 +144,6 @@ export class HaDeviceAction extends LitElement {
)}
></ha-device-picker>
<ha-device-action-picker
.invalid=${!!automationError}
.errorMessage=${
automationError
? deviceAutomationErrorText(
this.hass.localize,
"actions",
automationError
)
: undefined
}
.value=${this.action}
.deviceId=${deviceId}
.disabled=${this.disabled}
@@ -12,8 +12,6 @@ import type { ServiceAction } from "../../../../../data/script";
import { serviceActionStruct } from "../../../../../data/script";
import type { LocalizeFunc } from "../../../../../common/translations/localize";
import type { HomeAssistant } from "../../../../../types";
import { targetErrorText } from "../../automation-errors";
import { AutomationFieldErrorsController } from "../../automation-field-errors";
import type { ActionElement } from "../ha-automation-action-row";
@customElement("ha-automation-action-service")
@@ -30,10 +28,6 @@ export class HaServiceAction extends LitElement implements ActionElement {
@state() private _responseChecked = false;
private readonly _fieldErrors = new AutomationFieldErrorsController(this, {
inlineFields: ["target", "data"],
});
public static get defaultConfig(): ServiceAction {
return { action: "", data: {} };
}
@@ -86,8 +80,6 @@ export class HaServiceAction extends LitElement implements ActionElement {
? this._action.action.split(".", 2)
: [undefined, undefined];
const targetError = this._fieldErrors.fieldError("target");
const optionalResponse =
domain && service
? !!this.hass.services[domain]?.[service]?.response?.optional
@@ -100,13 +92,6 @@ export class HaServiceAction extends LitElement implements ActionElement {
.value=${this._action}
.disabled=${this.disabled}
.hidePicker=${!!this._action.metadata}
.targetError=${
targetError
? targetErrorText(this.hass.localize, targetError)
: undefined
}
.dataErrors=${this._fieldErrors.nestedFieldErrors("data")}
.dataError=${this._fieldErrors.ownFieldError("data")?.message}
@value-changed=${this._actionChanged}
></ha-service-control>
${
@@ -7,7 +7,6 @@ import type { WaitAction } from "../../../../../data/script";
import type { HomeAssistant } from "../../../../../types";
import type { ActionElement } from "../ha-automation-action-row";
import "../../../../../components/ha-form/ha-form";
import { AutomationFieldErrorsController } from "../../automation-field-errors";
import type {
HaFormSchema,
SchemaUnion,
@@ -16,10 +15,6 @@ import type { TimeoutType } from "../../types";
@customElement("ha-automation-action-wait_template")
export class HaWaitAction extends LitElement implements ActionElement {
private readonly _fieldErrors = new AutomationFieldErrorsController(this, {
inlineFields: ["wait_template", "timeout", "continue_on_timeout"],
});
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false }) public action!: WaitAction;
@@ -70,7 +65,6 @@ export class HaWaitAction extends LitElement implements ActionElement {
: "duration";
return html`
<ha-form
.error=${this._fieldErrors.fields}
.hass=${this.hass}
.data=${this.action}
.schema=${this._schema(timeoutType, timeout !== undefined)}
@@ -102,19 +102,6 @@ import type {
AutomationItemComboBoxItem,
} from "./types";
type PickerSources = Pick<
HomeAssistant,
| "states"
| "entities"
| "devices"
| "areas"
| "floors"
| "localize"
| "language"
| "locale"
| "translationMetadata"
>;
const TARGET_SEARCH_SECTIONS = [
"separator",
"entity",
@@ -218,18 +205,20 @@ export class HaAutomationAddSearch extends LitElement {
private _getDevicesMemoized = memoizeOne(
(
sources: PickerSources,
states: HomeAssistant["states"],
registries: ContextType<typeof registriesContext>,
i18n: ContextType<typeof internationalizationContext>,
configEntryLookup: Record<string, ConfigEntry>,
idPrefix: string
) =>
getDevices({
states: sources.states,
areas: sources.areas,
devices: sources.devices,
entities: sources.entities,
localize: sources.localize,
language: sources.locale.language,
translationMetadata: sources.translationMetadata,
states,
areas: registries.areas,
devices: registries.devices,
entities: registries.entities,
localize: i18n.localize,
language: i18n.locale.language,
translationMetadata: i18n.translationMetadata,
configEntryLookup,
idPrefix,
})
@@ -238,8 +227,23 @@ export class HaAutomationAddSearch extends LitElement {
private _getLabelsMemoized = memoizeOne(getLabels);
private _getEntitiesMemoized = memoizeOne(
(sources: PickerSources, idPrefix: string) =>
getEntities(sources, { idPrefix })
(
states: HomeAssistant["states"],
registries: ContextType<typeof registriesContext>,
i18n: ContextType<typeof internationalizationContext>,
idPrefix: string
) =>
getEntities({
states,
entities: registries.entities,
devices: registries.devices,
areas: registries.areas,
floors: registries.floors,
localize: i18n.localize,
language: i18n.language,
translationMetadata: i18n.translationMetadata,
idPrefix,
})
);
private _getAreasAndFloorsMemoized = memoizeOne(getAreasAndFloors);
@@ -247,21 +251,6 @@ export class HaAutomationAddSearch extends LitElement {
private _formatAreaFloorId = (value: AreaFloorValue): string =>
[value.type, value.id].join(TARGET_SEPARATOR);
private _getPickerSources = memoizeOne(
(
states: HomeAssistant["states"],
registries: ContextType<typeof registriesContext>,
i18n: ContextType<typeof internationalizationContext>
): PickerSources => ({
states,
...registries,
localize: i18n.localize,
language: i18n.language,
locale: i18n.locale,
translationMetadata: i18n.translationMetadata,
})
);
private _removeKeyboardShortcuts?: () => void;
protected willUpdate(changedProps: PropertyValues<this>) {
@@ -683,7 +672,9 @@ export class HaAutomationAddSearch extends LitElement {
if (!selectedSection || selectedSection === "entity") {
let entityItems = this._getEntitiesMemoized(
this._getPickerSources(this._states, this._registries, this._i18n),
this._states,
this._registries,
this._i18n,
`entity${TARGET_SEPARATOR}`
);
@@ -720,7 +711,9 @@ export class HaAutomationAddSearch extends LitElement {
if (!selectedSection || selectedSection === "device") {
let deviceItems = this._getDevicesMemoized(
this._getPickerSources(this._states, this._registries, this._i18n),
this._states,
this._registries,
this._i18n,
configEntryLookup,
`device${TARGET_SEPARATOR}`
);
@@ -1,350 +0,0 @@
import { createContext } from "@lit/context";
import { ensureArray } from "../../../common/array/ensure-array";
import type { LocalizeFunc } from "../../../common/translations/localize";
import {
formatPath,
pathEquals,
pathStartsWith,
type ConfigPath,
} from "../../../common/util/config-path";
import type { ConfigValidationError } from "../../../data/config";
import type {
AutomationPathRow,
AutomationRowRegistry,
} from "./automation-path";
/** The validation errors of the config being edited, paths from its root. */
export const automationErrorsContext =
createContext<ConfigValidationError[]>("automation-errors");
export const NO_ERRORS: ConfigValidationError[] = [];
/**
* The row an error belongs to: the deepest rendered row on its path. Rows
* above it only contain the error, they are expanded to show it.
*/
export const errorOwner = (
registry: AutomationRowRegistry,
error: ConfigValidationError
): AutomationPathRow | undefined => {
const rows = registry.rowsAlong(error.path);
return rows[rows.length - 1];
};
export const ownedErrors = (
registry: AutomationRowRegistry,
errors: ConfigValidationError[],
row: AutomationPathRow
): ConfigValidationError[] => {
const owned = errors.filter((error) => errorOwner(registry, error) === row);
return owned.length ? owned : NO_ERRORS;
};
export const fieldErrors = (
errors: ConfigValidationError[],
path: ConfigPath
): Record<string, string> => {
const fields: Record<string, string> = {};
errors.forEach((error) => {
const field = errorField(error, path);
if (field !== undefined && !(field in fields)) {
fields[field] = error.message;
}
});
return fields;
};
/** The field of the config at `path` an error is about, if any. */
export const errorField = (
error: ConfigValidationError,
path: ConfigPath
): string | undefined =>
error.path.length > path.length && pathStartsWith(error.path, path)
? String(error.path[path.length])
: undefined;
export const errorText = (
error: ConfigValidationError,
path: ConfigPath = []
): string => {
const relative = pathStartsWith(error.path, path)
? error.path.slice(path.length)
: error.path;
return relative.length
? `${formatPath(relative)}: ${error.message}`
: error.message;
};
export const errorsEqual = (
a: ConfigValidationError[],
b: ConfigValidationError[]
): boolean =>
a.length === b.length &&
a.every(
(error, index) =>
error.message === b[index].message &&
error.code === b[index].code &&
pathEquals(error.path, b[index].path)
);
const TRIGGER_LIST_KEYS = ["triggers", "wait_for_trigger"];
const isTriggerSublist = (value: unknown): value is { triggers: unknown } =>
typeof value === "object" &&
value !== null &&
Object.keys(value).length === 1 &&
"triggers" in value;
// Core validates triggers after flattening `triggers:` sublists one level
// deep, so its trigger indexes count the triggers of the flat list.
const flatTriggerPaths = (triggers: unknown[]): ConfigPath[] =>
triggers.flatMap((trigger, index) =>
isTriggerSublist(trigger)
? (ensureArray(trigger.triggers) ?? []).map((_, subIndex) => [
index,
"triggers",
subIndex,
])
: [[index]]
);
const isConfigObject = (value: unknown): value is Record<string, unknown> =>
typeof value === "object" && value !== null && !Array.isArray(value);
const childAt = (value: unknown, segment: string | number): unknown =>
typeof value === "object" && value !== null
? (value as Record<string | number, unknown>)[segment]
: undefined;
const valueAt = (config: unknown, path: ConfigPath): unknown =>
path.reduce<unknown>(childAt, config);
/** Maps an error path of core's validated config onto the edited config. */
export const configErrorPath = (
config: unknown,
path: ConfigPath
): ConfigPath => {
const result: ConfigPath = [];
let value = config;
for (let index = 0; index < path.length; index++) {
const segment = path[index];
const parentKey = result[result.length - 1];
// Core wraps each action of the `parallel` shorthand in a sequence
if (
segment === "sequence" &&
path[index + 1] === 0 &&
result[result.length - 2] === "parallel" &&
isConfigObject(value) &&
!("sequence" in value)
) {
index++;
continue;
}
const mapped =
typeof segment === "number" &&
Array.isArray(value) &&
TRIGGER_LIST_KEYS.includes(parentKey as string)
? flatTriggerPaths(value)[segment]
: undefined;
(mapped ?? [segment]).forEach((part) => {
result.push(part);
value = childAt(value, part);
});
}
return result;
};
export const mapErrorsToConfig = (
config: unknown,
errors: ConfigValidationError[]
): ConfigValidationError[] =>
errors.map((error) => ({
...error,
path: configErrorPath(config, error.path),
}));
// Rows sit at list items that are objects, not at entries of value lists
// like `entity_id`
const errorItemPath = (config: unknown, path: ConfigPath): ConfigPath => {
let value = config;
let end = 0;
path.forEach((segment, index) => {
value = childAt(value, segment);
if (typeof segment === "number" && isConfigObject(value)) {
end = index + 1;
}
});
return path.slice(0, end);
};
/** A config item with an error, to recognize it after edits. */
export interface KnownErrorItem {
path: ConfigPath;
item?: object;
content?: string;
}
const knownItemAt = (config: unknown, path: ConfigPath): KnownErrorItem => {
const item = valueAt(config, path);
return isConfigObject(item)
? { path, item, content: JSON.stringify(item) }
: { path };
};
export const knownErrorItems = (
config: unknown,
errors: ConfigValidationError[]
): KnownErrorItem[] => {
const known: KnownErrorItem[] = [];
errors.forEach((error) => {
const path = errorItemPath(config, error.path);
if (!known.some((entry) => pathEquals(entry.path, path))) {
known.push(knownItemAt(config, path));
}
});
return known;
};
interface ConfigItems {
paths: Map<object, ConfigPath>;
contents: Map<string, ConfigPath[]>;
}
// The list entries that are objects, the items rows show
const configItems = (config: unknown): ConfigItems => {
const items: ConfigItems = { paths: new Map(), contents: new Map() };
const walk = (value: unknown, path: ConfigPath) => {
if (typeof value !== "object" || value === null) {
return;
}
if (typeof path[path.length - 1] === "number" && isConfigObject(value)) {
items.paths.set(value, path);
const content = JSON.stringify(value);
items.contents.set(content, [
...(items.contents.get(content) ?? []),
path,
]);
}
Object.entries(value).forEach(([key, child]) => {
walk(child, [...path, Array.isArray(value) ? Number(key) : key]);
});
};
walk(config, []);
return items;
};
const followItem = (
entry: KnownErrorItem,
previous: ConfigItems,
current: ConfigItems,
config: unknown
): KnownErrorItem | undefined => {
// Unchanged or moved
const path = entry.item && current.paths.get(entry.item);
if (path) {
return { ...entry, path };
}
// Moved after the YAML was parsed again
const samePaths = entry.content
? current.contents.get(entry.content)
: undefined;
if (samePaths?.length === 1) {
return knownItemAt(config, samePaths[0]);
}
// Identical items, e.g. two copies of a row, keep their place
if (samePaths?.some((other) => pathEquals(other, entry.path))) {
return knownItemAt(config, entry.path);
}
const now = valueAt(config, entry.path);
if (!isConfigObject(now)) {
return undefined;
}
// An item that was already there took the place, e.g. after a delete
const beforePaths = previous.paths.has(now)
? [previous.paths.get(now)!]
: (previous.contents.get(JSON.stringify(now)) ?? []);
if (
beforePaths.length &&
!beforePaths.some((other) => pathEquals(other, entry.path))
) {
return undefined;
}
// Edited in place
return knownItemAt(config, entry.path);
};
/** Follows known items from `previous` to `config` through moves and edits. */
export const followKnownErrorItems = (
known: KnownErrorItem[],
previous: unknown,
config: unknown
): KnownErrorItem[] => {
const previousItems = configItems(previous);
const currentItems = configItems(config);
return known
.map((entry) => followItem(entry, previousItems, currentItems, config))
.filter((entry): entry is KnownErrorItem => entry !== undefined);
};
/**
* Moves errors of `previous` to where their items are in `config`. Errors of
* items that are gone are dropped.
*/
export const followErrors = (
errors: ConfigValidationError[],
previous: unknown,
config: unknown
): ConfigValidationError[] => {
const previousItems = configItems(previous);
const currentItems = configItems(config);
return errors.flatMap((error) => {
const path = errorItemPath(previous, error.path);
const moved = followItem(
knownItemAt(previous, path),
previousItems,
currentItems,
config
);
return moved
? [{ ...error, path: [...moved.path, ...error.path.slice(path.length)] }]
: [];
});
};
/** The errors on items that already had one. */
export const errorsOnKnownItems = (
known: KnownErrorItem[],
config: unknown,
errors: ConfigValidationError[]
): ConfigValidationError[] =>
errors.filter((error) => {
const path = errorItemPath(config, error.path);
return known.some((entry) => pathEquals(entry.path, path));
});
export const targetErrorText = (
localize: LocalizeFunc,
error: ConfigValidationError
): string =>
localize(
error.code === "required"
? "ui.panel.config.automation.editor.errors.target_required"
: "ui.panel.config.automation.editor.errors.target_invalid"
);
export const deviceErrorText = (
localize: LocalizeFunc,
error: ConfigValidationError
): string =>
error.code === "required"
? localize("ui.panel.config.automation.editor.errors.device_required")
: error.message;
export const deviceAutomationErrorText = (
localize: LocalizeFunc,
kind: "triggers" | "conditions" | "actions",
error: ConfigValidationError
): string =>
error.code === "required"
? localize(`ui.panel.config.devices.automation.${kind}.required`)
: error.message;
@@ -1,186 +0,0 @@
import { createContext } from "@lit/context";
import {
consumeContext,
ContextController,
} from "../../../common/decorators/consume";
import { pathEquals, type ConfigPath } from "../../../common/util/config-path";
import type { ConfigValidationError } from "../../../data/config";
import {
automationErrorsContext,
errorField,
errorsEqual,
fieldErrors,
NO_ERRORS,
ownedErrors,
} from "./automation-errors";
import {
automationPathContext,
automationRowRegistryContext,
type AutomationRowRegistry,
} from "./automation-path";
const fieldsEqual = (a: Record<string, string>, b: Record<string, string>) => {
const keys = Object.keys(a);
return (
keys.length === Object.keys(b).length &&
keys.every((key) => a[key] === b[key])
);
};
/**
* @summary The fields whose errors an editor shows next to the field, so
* the sidebar doesn't list them again.
*/
export class InlineErrorFields {
private _fields = new Map<object, string[]>();
constructor(private readonly _onChange: () => void) {}
public set(owner: object, fields: string[]) {
this._fields.set(owner, fields);
this._onChange();
}
public delete(owner: object) {
if (this._fields.delete(owner)) {
this._onChange();
}
}
public has(field: string): boolean {
return [...this._fields.values()].some((fields) => fields.includes(field));
}
}
export const automationInlineErrorFieldsContext =
createContext<InlineErrorFields>("automation-inline-error-fields");
interface AutomationFieldErrorsOptions {
/** Fields the host shows the error of, see `InlineErrorFields`. */
inlineFields?: string[];
}
/**
* @summary Validation errors of the config at the host's path.
*
* Consumes the errors and the path of the closest ancestor and updates the
* host when the errors it exposes change.
*/
export class AutomationFieldErrorsController extends ContextController {
public fields: Record<string, string> = {};
/** Errors that belong to the row at exactly the path, see `errorOwner`. */
public rowErrors: ConfigValidationError[] = NO_ERRORS;
public path: ConfigPath = [];
@consumeContext({ context: automationErrorsContext, subscribe: true })
private _errors: ConfigValidationError[] = NO_ERRORS;
@consumeContext({ context: automationPathContext, subscribe: true })
private _path: ConfigPath = [];
@consumeContext({ context: automationRowRegistryContext })
private _registry?: AutomationRowRegistry;
@consumeContext({ context: automationInlineErrorFieldsContext })
private _inlineErrorFields?: InlineErrorFields;
private readonly _inlineFields?: string[];
private _claimedIn?: InlineErrorFields;
constructor(
host: ConstructorParameters<typeof ContextController>[0],
options: AutomationFieldErrorsOptions = {}
) {
super(host);
this._inlineFields = options.inlineFields;
}
private _nestedFieldErrors = new Map<string, Record<string, string>>();
/** First error message of each key inside a field, keyed by the key. */
public nestedFieldErrors(field: string): Record<string, string> {
const errors = fieldErrors(this._errors, [...this._path, field]);
const previous = this._nestedFieldErrors.get(field);
// The same object while nothing changed, so the host's child doesn't update
if (previous && fieldsEqual(previous, errors)) {
return previous;
}
this._nestedFieldErrors.set(field, errors);
return errors;
}
/** The error of a field as a whole, not of something inside it. */
public ownFieldError(field: string): ConfigValidationError | undefined {
return this._errors.find((error) =>
pathEquals(error.path, [...this._path, field])
);
}
/** The first error of a field of the config at the path. */
public fieldError(field: string): ConfigValidationError | undefined {
return this._errors.find(
(error) => errorField(error, this._path) === field
);
}
private _unsubscribeRegistry?: () => void;
public hostConnected() {
this._unsubscribeRegistry ??= this._registry?.subscribe(() => this._sync());
this._claimInlineFields();
this._sync();
}
public hostDisconnected() {
this._unsubscribeRegistry?.();
this._unsubscribeRegistry = undefined;
this._claimedIn?.delete(this);
this._claimedIn = undefined;
}
protected contextUpdated() {
this._claimInlineFields();
this._sync();
}
private _claimInlineFields() {
if (
!this._inlineFields ||
!this._inlineErrorFields ||
this._claimedIn === this._inlineErrorFields
) {
return;
}
this._claimedIn?.delete(this);
this._inlineErrorFields.set(this, this._inlineFields);
this._claimedIn = this._inlineErrorFields;
}
private _sync() {
const fields = this._errors.length
? fieldErrors(this._errors, this._path)
: {};
const row =
this._errors.length && this._path.length
? this._registry?.rowAt(this._path)
: undefined;
const rowErrors =
row && this._registry
? ownedErrors(this._registry, this._errors, row)
: NO_ERRORS;
if (
this.path === this._path &&
fieldsEqual(fields, this.fields) &&
errorsEqual(rowErrors, this.rowErrors)
) {
return;
}
this.path = this._path;
this.fields = fields;
this.rowErrors = rowErrors;
this.host.requestUpdate();
}
}
@@ -9,13 +9,6 @@ import {
stringifyPath,
type ConfigPath,
} from "../../../common/util/config-path";
import type { ConfigValidationError } from "../../../data/config";
import {
automationErrorsContext,
errorsEqual,
NO_ERRORS,
ownedErrors,
} from "./automation-errors";
export const automationPathContext =
createContext<ConfigPath>("automation-path");
@@ -41,19 +34,14 @@ export class AutomationRowRegistry {
{ path: ConfigPath; rows: AutomationPathRow[] }
>();
private _listeners = new Set<() => void>();
private _notifyScheduled = false;
public register(path: ConfigPath, row: AutomationPathRow) {
const key = stringifyPath(path);
const entry = this._rows.get(key);
if (!entry) {
this._rows.set(key, { path, rows: [row] });
} else {
entry.rows = [...entry.rows.filter((claimed) => claimed !== row), row];
return;
}
this._changed();
entry.rows = [...entry.rows.filter((claimed) => claimed !== row), row];
}
public unregister(path: ConfigPath, row: AutomationPathRow) {
@@ -66,33 +54,6 @@ export class AutomationRowRegistry {
if (!entry.rows.length) {
this._rows.delete(key);
}
this._changed();
}
/**
* Calls `listener` after rows were registered or unregistered, once for all
* changes made in the same task. Returns the function to unsubscribe.
*/
public subscribe(listener: () => void): () => void {
this._listeners.add(listener);
return () => {
this._listeners.delete(listener);
};
}
private _changed() {
if (this._notifyScheduled || !this._listeners.size) {
return;
}
this._notifyScheduled = true;
queueMicrotask(() => {
this._notifyScheduled = false;
this._listeners.forEach((listener) => listener());
});
}
public rowAt(path: ConfigPath): AutomationPathRow | undefined {
return owner(this._rows.get(stringifyPath(path))?.rows);
}
public pathOf(row: AutomationPathRow): ConfigPath | undefined {
@@ -100,11 +61,6 @@ export class AutomationRowRegistry {
?.path;
}
public selectedPath(): ConfigPath | undefined {
return [...this._rows.values()].find((entry) => owner(entry.rows)?.selected)
?.path;
}
/** The rows containing `path`, outermost first. */
public rowsAlong(path: ConfigPath): AutomationPathRow[] {
return path
@@ -127,19 +83,11 @@ interface AutomationPathControllerOptions {
/**
* Consumes the path of the closest ancestor and provides it extended by the
* host's own segment to its descendants. For a row, also finds the validation
* errors the row owns and updates the host when they change.
* host's own segment to its descendants.
*/
export class AutomationPathController extends ContextController {
public path: ConfigPath = [];
public errors: ConfigValidationError[] = NO_ERRORS;
@consumeContext({ context: automationErrorsContext, subscribe: true })
private _configErrors: ConfigValidationError[] = NO_ERRORS;
private _unsubscribeRegistry?: () => void;
@consumeContext({ context: automationPathContext, subscribe: true })
private _parentPath: ConfigPath = [];
@@ -176,12 +124,6 @@ export class AutomationPathController extends ContextController {
public hostConnected() {
// A row moved by a drag reconnects without an update
this._sync();
if (this._options.row && this._registry && !this._unsubscribeRegistry) {
// Another row on the path of an error can take it over or give it back
this._unsubscribeRegistry = this._registry.subscribe(() =>
this._syncErrors()
);
}
}
private _sync() {
@@ -191,26 +133,12 @@ export class AutomationPathController extends ContextController {
this._provider.setValue(path);
}
this._syncRegistration();
this._syncErrors();
}
public hostDisconnected() {
this._unsubscribeRegistry?.();
this._unsubscribeRegistry = undefined;
this._unregister();
}
private _syncErrors() {
const errors =
this._registeredRow && this._registry && this._configErrors.length
? ownedErrors(this._registry, this._configErrors, this._registeredRow)
: NO_ERRORS;
if (!errorsEqual(errors, this.errors)) {
this.errors = errors;
this.host.requestUpdate();
}
}
private _syncRegistration() {
if (!this._registry || !this._options.row) {
return;
@@ -1,27 +0,0 @@
import { mdiAlertCircle } from "@mdi/js";
import { html, nothing } from "lit";
import type { ConfigPath } from "../../../common/util/config-path";
import "../../../components/ha-svg-icon";
import "../../../components/ha-tooltip";
import type { ConfigValidationError } from "../../../data/config";
import { errorText } from "./automation-errors";
export const renderRowErrorIcon = (
errors: ConfigValidationError[],
path: ConfigPath,
label: string
) =>
errors.length
? html`<ha-svg-icon
id="error-icon"
slot="icons"
tabindex="0"
class="error-indicator"
.path=${mdiAlertCircle}
role="img"
aria-label=${label}
></ha-svg-icon>
<ha-tooltip for="error-icon" slot="icons">
${errors.map((error) => html`<p>${errorText(error, path)}</p>`)}
</ha-tooltip>`
: nothing;
@@ -97,7 +97,6 @@ import "./types/ha-automation-condition-zone";
import { renderCtrlOrCmd } from "../../../../common/keyboard/ctrl-or-cmd";
import { scrollRowIntoView } from "../scroll-row-into-view";
import { AutomationPathController } from "../automation-path";
import { renderRowErrorIcon } from "../automation-row-error-icon";
export interface ConditionElement extends LitElement {
condition: Condition;
@@ -263,11 +262,6 @@ export default class HaAutomationConditionRow extends LitElement {
)}
</ha-automation-row-event-chip>
${renderRowErrorIcon(
this.pathController.errors,
this.pathController.path,
this.hass.localize("ui.panel.config.automation.editor.errors.label")
)}
<slot name="icons" slot="icons"></slot>
<ha-dropdown
@@ -535,7 +529,6 @@ export default class HaAutomationConditionRow extends LitElement {
this.condition.condition
)}
.sortSelected=${this.sortSelected}
.error=${this.pathController.errors.length > 0}
.dim=${this._testing}
@click=${this._toggleSidebar}
@toggle-collapsed=${this._toggleCollapse}
@@ -27,18 +27,9 @@ import {
} from "../../../../../data/device/device_registry";
import type { EntityRegistryEntry } from "../../../../../data/entity/entity_registry";
import type { HomeAssistant } from "../../../../../types";
import {
deviceAutomationErrorText,
deviceErrorText,
} from "../../automation-errors";
import { AutomationFieldErrorsController } from "../../automation-field-errors";
@customElement("ha-automation-condition-device")
export class HaDeviceCondition extends LitElement {
private readonly _fieldErrors = new AutomationFieldErrorsController(this, {
inlineFields: ["device_id", "domain"],
});
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ type: Object }) public condition!: DeviceCondition;
@@ -141,20 +132,12 @@ export class HaDeviceCondition extends LitElement {
}
protected render() {
const deviceError = this._fieldErrors.fieldError("device_id");
const automationError = this._fieldErrors.fieldError("domain");
const deviceId = this._deviceId || this.condition.device_id;
return html`
<ha-device-picker
.value=${deviceId}
.replacementDeviceIds=${this._replacementDeviceIds}
.invalid=${!!deviceError}
.errorMessage=${
deviceError
? deviceErrorText(this.hass.localize, deviceError)
: undefined
}
@value-changed=${this._devicePicked}
.disabled=${this.disabled}
.label=${this.hass.localize(
@@ -162,16 +145,6 @@ export class HaDeviceCondition extends LitElement {
)}
></ha-device-picker>
<ha-device-condition-picker
.invalid=${!!automationError}
.errorMessage=${
automationError
? deviceAutomationErrorText(
this.hass.localize,
"conditions",
automationError
)
: undefined
}
.value=${this.condition}
.deviceId=${deviceId}
@value-changed=${this._deviceConditionPicked}
@@ -34,9 +34,6 @@ import {
} from "../../../../../data/target";
import type { HomeAssistant } from "../../../../../types";
import { documentationUrl } from "../../../../../util/documentation-url";
import "../../../../../components/ha-input-helper-text";
import { targetErrorText } from "../../automation-errors";
import { AutomationFieldErrorsController } from "../../automation-field-errors";
import { targetExpandGroupContext } from "../../../../../components/target-picker/target-expand-group-context";
// Mirrors `MAX_HISTORY_PRIMING_LOOKBACK` in homeassistant/helpers/condition.py:
@@ -56,10 +53,6 @@ export class HaPlatformCondition extends LitElement {
@provide({ context: targetExpandGroupContext })
protected _targetExpandGroup = false;
private readonly _fieldErrors = new AutomationFieldErrorsController(this, {
inlineFields: ["target"],
});
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false }) public condition!: PlatformCondition;
@@ -241,7 +234,6 @@ export class HaPlatformCondition extends LitElement {
></ha-selector>`
: nothing
}
${this._renderTargetError()}
${
shouldRenderDataYaml
? html`<ha-yaml-editor
@@ -266,15 +258,6 @@ export class HaPlatformCondition extends LitElement {
`;
}
private _renderTargetError() {
const error = this._fieldErrors.fieldError("target");
return error
? html`<ha-input-helper-text class="target-error">
${targetErrorText(this.hass.localize, error)}
</ha-input-helper-text>`
: nothing;
}
private _targetSelector = memoizeOne(
(targetSelector: TargetSelector["target"] | null | undefined) =>
targetSelector ? { target: { ...targetSelector } } : { target: {} }
@@ -697,10 +680,6 @@ export class HaPlatformCondition extends LitElement {
1px solid var(--divider-color)
);
}
.target-error {
color: var(--ha-color-on-danger-quiet);
padding-bottom: var(--ha-space-2);
}
ha-yaml-editor {
padding: var(--ha-space-4) 0;
}
@@ -17,7 +17,6 @@ import { ensureArray } from "../../../../../common/array/ensure-array";
import { createDurationData } from "../../../../../common/datetime/create_duration_data";
import { fireEvent } from "../../../../../common/dom/fire_event";
import "../../../../../components/ha-form/ha-form";
import { AutomationFieldErrorsController } from "../../automation-field-errors";
import type { SchemaUnion } from "../../../../../components/ha-form/types";
import type { StateCondition } from "../../../../../data/automation";
import { STATE_CONDITION_HIDDEN_ATTRIBUTES } from "../../../../../data/entity/entity_attributes";
@@ -72,10 +71,6 @@ const SCHEMA = memoizeOne(
@customElement("ha-automation-condition-state")
export class HaStateCondition extends LitElement implements ConditionElement {
private readonly _fieldErrors = new AutomationFieldErrorsController(this, {
inlineFields: ["entity_id", "attribute", "state", "for"],
});
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false }) public condition!: StateCondition;
@@ -123,7 +118,6 @@ export class HaStateCondition extends LitElement implements ConditionElement {
return html`
<ha-form
.error=${this._fieldErrors.fields}
.hass=${this.hass}
.data=${data}
.schema=${SCHEMA(hasAttribute)}
@@ -6,8 +6,10 @@ import { consume } from "../../../../../common/decorators/consume";
import { ensureArray } from "../../../../../common/array/ensure-array";
import { fireEvent } from "../../../../../common/dom/fire_event";
import { capitalizeFirstLetter } from "../../../../../common/string/capitalize-first-letter";
import "../../../../../components/animation/ha-fade-in";
import "../../../../../components/ha-alert";
import "../../../../../components/ha-button";
import "../../../../../components/ha-spinner";
import "../../../../../components/ha-svg-icon";
import "../../../../../components/ha-trigger-icon";
import "../../../../../components/item/ha-list-item-option";
@@ -17,6 +19,7 @@ import type { TriggerCondition } from "../../../../../data/automation";
import {
automationTriggerContext,
hasCustomTriggerId,
triggerOptionsResolved,
type AutomationTriggerContext,
type TriggerIdOption,
} from "../../trigger/automation-trigger-id";
@@ -53,16 +56,33 @@ export class HaTriggerCondition extends LitElement {
const selectedIds = (ensureArray(this.condition.id) ?? []).filter(Boolean);
const triggerIdOptions = this._triggers?.options ?? [];
const missingIds = this._missingIds(this.condition.id, triggerIdOptions);
const status = this._triggers?.blueprintStatus;
const resolved = triggerOptionsResolved(this._triggers);
if (!triggerIdOptions.length && !selectedIds.length) {
return this.hass.localize(
"ui.panel.config.automation.editor.conditions.type.trigger.no_triggers"
);
switch (status) {
case "loading":
return html`<ha-fade-in .delay=${500}>
<ha-spinner size="small"></ha-spinner>
</ha-fade-in>`;
case "failed":
return this.hass.localize(
"ui.panel.config.automation.editor.conditions.type.trigger.blueprint_triggers_unavailable"
);
case "loaded":
return this.hass.localize(
"ui.panel.config.automation.editor.conditions.type.trigger.blueprint_no_trigger_ids"
);
case undefined:
return this.hass.localize(
"ui.panel.config.automation.editor.conditions.type.trigger.no_triggers"
);
}
}
return html`
${
missingIds.length
resolved && missingIds.length
? html`<ha-alert alert-type="warning">
${this.hass.localize(
"ui.panel.config.automation.editor.conditions.type.trigger.missing_triggers"
@@ -106,7 +126,13 @@ export class HaTriggerCondition extends LitElement {
appearance="checkbox"
>
<span slot="start" class="trigger-row-leading">
<span class="trigger-index-badge">${option.index + 1}</span>
${
this._triggers?.showIndices
? html`<span class="trigger-index-badge"
>${option.index + 1}</span
>`
: nothing
}
<ha-trigger-icon
.trigger=${
"trigger" in option.trigger ? option.trigger.trigger : ""
@@ -119,7 +145,9 @@ export class HaTriggerCondition extends LitElement {
)}
</span>
${
option.duplicate || hasCustomTriggerId(option)
option.duplicate ||
hasCustomTriggerId(option) ||
!this._triggers?.showIndices
? html`<span
slot="supporting-text"
class="trigger-id ${option.duplicate ? "duplicate" : ""}"
@@ -152,14 +180,22 @@ export class HaTriggerCondition extends LitElement {
.disabled=${this.disabled}
appearance="checkbox"
>
<span slot="start" class="missing-trigger-badge">
<ha-svg-icon .path=${mdiLinkVariantOff}></ha-svg-icon>
<span class="missing-trigger-id">${id}</span>
</span>
${
resolved
? html`<span slot="start" class="missing-trigger-badge">
<ha-svg-icon .path=${mdiLinkVariantOff}></ha-svg-icon>
<span class="missing-trigger-id">${id}</span>
</span>`
: nothing
}
<span slot="headline">
${this.hass.localize(
"ui.panel.config.automation.editor.conditions.type.trigger.missing_trigger"
)}
${
resolved
? this.hass.localize(
"ui.panel.config.automation.editor.conditions.type.trigger.missing_trigger"
)
: id
}
</span>
</ha-list-item-option>
`
@@ -197,8 +233,7 @@ export class HaTriggerCondition extends LitElement {
? ids
: [...ids, id]
: ids.filter((_id) => _id !== id);
if (this._triggers) {
this._triggers.select(this.condition, selectedIds);
if (this._triggers?.select(this.condition, selectedIds)) {
return;
}
fireEvent(this, "value-changed", {
@@ -56,6 +56,7 @@ import {
triggerAutomationActions,
} from "../../../data/automation";
import { substituteBlueprint } from "../../../data/blueprint";
import { validateConfig } from "../../../data/config";
import { UNAVAILABLE } from "../../../data/entity/entity";
import {
type EntityRegistryEntry,
@@ -70,7 +71,7 @@ import "../../../layouts/hass-subpage";
import { KeyboardShortcutMixin } from "../../../mixins/keyboard-shortcut-mixin";
import { PreventUnsavedMixin } from "../../../mixins/prevent-unsaved-mixin";
import { haStyle } from "../../../resources/styles";
import type { ValueChangedEvent } from "../../../types";
import type { Entries, ValueChangedEvent } from "../../../types";
import { isMac } from "../../../util/is_mac";
import { showEditorToast } from "./editor-toast";
import { showAssignCategoryDialog } from "../category/show-dialog-assign-category";
@@ -145,6 +146,15 @@ export class HaAutomationEditor extends AutomationScriptEditorMixin<AutomationCo
this._manualEditor?.resetPastedConfig();
this._updateConfig(config);
},
fetchBlueprintTriggers: async ({ path, input }) => {
const result = await substituteBlueprint(
this.hass,
"automation",
path,
input || {}
);
return normalizeAutomationConfig(result.substituted_config).triggers;
},
});
private _newAutomationId?: string;
@@ -158,14 +168,7 @@ export class HaAutomationEditor extends AutomationScriptEditorMixin<AutomationCo
const { id, ...rest } = normalizeAutomationConfig(config, report);
return { id, ...rest };
},
validationSections: (config) =>
"use_blueprint" in config
? {}
: {
triggers: config.triggers,
conditions: config.conditions,
actions: config.actions,
},
checkValidation: () => this._checkValidation(),
};
private _undoRedoController = new UndoRedoController<AutomationConfig>(this, {
@@ -507,7 +510,16 @@ export class HaAutomationEditor extends AutomationScriptEditorMixin<AutomationCo
@value-changed=${this._valueChanged}
@save-automation=${this._handleSaveAutomation}
>
${this.renderConfigErrorsAlert("alerts")}
${
this.errors
? html`<ha-alert
alert-type="error"
slot="alerts"
>
${this.errors}
</ha-alert>`
: nothing
}
</blueprint-automation-editor>
`
: html`
@@ -519,7 +531,6 @@ export class HaAutomationEditor extends AutomationScriptEditorMixin<AutomationCo
.config=${this.config}
.disabled=${this.readOnly}
.saving=${this.saving}
.configErrors=${this.configErrors}
@value-changed=${this._valueChanged}
@save-automation=${this._handleSaveAutomation}
@editor-save=${this._handleSaveAutomation}
@@ -528,17 +539,26 @@ export class HaAutomationEditor extends AutomationScriptEditorMixin<AutomationCo
<div class="alert-wrapper" slot="alerts">
${this._renderDeprecatedMigratedAlert()}
${
stateObj?.state === UNAVAILABLE
this.errors || stateObj?.state === UNAVAILABLE
? html`<ha-alert
alert-type="error"
.title=${this.hass.localize(
"ui.panel.config.automation.editor.unavailable"
)}
.title=${
stateObj?.state === UNAVAILABLE
? this.hass.localize(
"ui.panel.config.automation.editor.unavailable"
)
: undefined
}
>
<ha-svg-icon
slot="icon"
.path=${mdiRobotConfused}
></ha-svg-icon>
${this.errors || this.validationErrors}
${
stateObj?.state === UNAVAILABLE
? html`<ha-svg-icon
slot="icon"
.path=${mdiRobotConfused}
></ha-svg-icon>`
: nothing
}
</ha-alert>`
: nothing
}
@@ -617,7 +637,6 @@ export class HaAutomationEditor extends AutomationScriptEditorMixin<AutomationCo
`
: this.mode === "yaml"
? html`${this._renderDeprecatedMigratedAlert()}
${this.renderConfigErrorsAlert()}
${
stateObj?.state === "off"
? html`
@@ -737,7 +756,7 @@ export class HaAutomationEditor extends AutomationScriptEditorMixin<AutomationCo
entityRegistryUpdate: this.entityRegistryUpdate,
}
);
this.checkValidation();
this._checkValidation();
});
this.currentEntityId = this.entityId;
this.readOnly = true;
@@ -765,6 +784,32 @@ export class HaAutomationEditor extends AutomationScriptEditorMixin<AutomationCo
this.currentEntityId = automation?.entity_id;
}
private async _checkValidation() {
this.validationErrors = undefined;
if (!this.currentEntityId || !this.config) {
return;
}
const stateObj = this.hass.states[this.currentEntityId];
if (stateObj?.state !== UNAVAILABLE) {
return;
}
const validation = await validateConfig(this.hass, {
triggers: this.config.triggers,
conditions: this.config.conditions,
actions: this.config.actions,
});
this.validationErrors = (
Object.entries(validation) as Entries<typeof validation>
).map(([key, value]) =>
value.valid
? ""
: html`${this.hass.localize(
`ui.panel.config.automation.editor.${key}.name`
)}:
${value.error}<br />`
);
}
private _valueChanged(ev: ValueChangedEvent<AutomationConfig>) {
ev.stopPropagation();
const config = ev.detail.value;
@@ -794,6 +839,7 @@ export class HaAutomationEditor extends AutomationScriptEditorMixin<AutomationCo
config: this.config,
entityRegistryUpdate: this.entityRegistryUpdate,
});
this.errors = undefined;
}
private _showInfo() {
@@ -883,6 +929,7 @@ export class HaAutomationEditor extends AutomationScriptEditorMixin<AutomationCo
config: this.config!,
entityRegistryUpdate: this.entityRegistryUpdate,
});
this.errors = undefined;
}
protected async confirmUnsavedChanged(addHistory = true): Promise<boolean> {
@@ -963,8 +1010,9 @@ export class HaAutomationEditor extends AutomationScriptEditorMixin<AutomationCo
this._yamlEditor?.setValue(this.config);
}
this.readOnly = true;
this.errors = undefined;
} catch (err: any) {
showEditorToast(this, { message: err.message });
this.errors = err.message;
}
}
@@ -1077,7 +1125,7 @@ export class HaAutomationEditor extends AutomationScriptEditorMixin<AutomationCo
private async _saveAutomation(id): Promise<void> {
this.saving = true;
const config = this.config!;
this.validationErrors = undefined;
let entityRegPromise: Promise<EntityRegistryEntry> | undefined;
if (this.entityRegistryUpdate !== undefined && !this.currentEntityId) {
@@ -1088,7 +1136,7 @@ export class HaAutomationEditor extends AutomationScriptEditorMixin<AutomationCo
}
try {
await saveAutomationConfig(this.hass, id, config);
await saveAutomationConfig(this.hass, id, this.config!);
if (this.entityRegistryUpdate !== undefined) {
let entityId = this.currentEntityId;
@@ -1135,9 +1183,11 @@ export class HaAutomationEditor extends AutomationScriptEditorMixin<AutomationCo
this._markDirtyStateClean();
this.deprecatedConfigMigrated = false;
this.clearConfigErrors();
} catch (errors: unknown) {
this.showSaveErrors(errors, config);
} catch (errors: any) {
this.errors = errors.body?.message || errors.error || errors.body;
showEditorToast(this, {
message: errors.body?.message || errors.error || errors.body,
});
throw errors;
} finally {
this.saving = false;
@@ -4,26 +4,16 @@ import type {
PropertyValues,
TemplateResult,
} from "lit";
import { css, html, nothing } from "lit";
import { ifDefined } from "lit/directives/if-defined";
import { property, query, state } from "lit/decorators";
import { css, html } from "lit";
import { property, state } from "lit/decorators";
import { consume } from "../../../common/decorators/consume";
import { transform } from "../../../common/decorators/transform";
import { goBack, navigate } from "../../../common/navigate";
import { debounce } from "../../../common/util/debounce";
import { afterNextRender } from "../../../common/util/render-status";
import "../../../components/animation/ha-fade-in";
import "../../../components/ha-alert";
import "../../../components/ha-spinner"; // used by renderLoading() provided to both editors
import type { AutomationMigrationReport } from "../../../data/automation";
import {
parseConfigSaveError,
validateConfigErrors,
type ConfigValidationError,
type ValidationSectionKeys,
} from "../../../data/config";
import { fireRelatedContext, fullEntitiesContext } from "../../../data/context";
import { UNAVAILABLE } from "../../../data/entity/entity";
import type { EntityRegistryEntry } from "../../../data/entity/entity_registry";
import {
showAlertDialog,
@@ -32,28 +22,7 @@ import {
import { showMoreInfoDialog } from "../../../dialogs/more-info/show-ha-more-info-dialog";
import { DirtyStateProviderMixin } from "../../../mixins/dirty-state-provider-mixin";
import type { Constructor, HomeAssistant, Route } from "../../../types";
import { showToast } from "../../../util/toast";
import {
errorsEqual,
errorsOnKnownItems,
errorText,
followErrors,
followKnownErrorItems,
knownErrorItems,
mapErrorsToConfig,
NO_ERRORS,
type KnownErrorItem,
} from "./automation-errors";
import type { EntityRegistryUpdate } from "./automation-save-dialog/show-dialog-automation-save";
import { showEditorToast } from "./editor-toast";
const CONFIG_ERRORS_TOAST_ID = "config-errors";
const REVALIDATE_DELAY = 500;
interface ErrorsEditorElement extends HTMLElement {
revealErrors(): Promise<void>;
}
/** Minimum config shape shared by both AutomationConfig and ScriptConfig. */
interface BaseEditorConfig {
@@ -88,10 +57,6 @@ export const automationScriptEditorStyles: CSSResult = css`
p {
margin-bottom: 0;
}
.config-errors p {
margin: 0;
white-space: pre-wrap;
}
ha-button[slot="fab"] {
position: fixed;
right: calc(16px + var(--safe-area-inset-right, 0px));
@@ -117,11 +82,7 @@ export const automationScriptEditorStyles: CSSResult = css`
export interface EditorDomainHooks<TConfig> {
fetchFileConfig(hass: HomeAssistant, id: string): Promise<TConfig>;
normalizeConfig(raw: TConfig, report?: AutomationMigrationReport): TConfig;
/** The parts of the config `validate_config` checks, none for blueprints. */
validationSections(
config: TConfig
): Partial<Record<"triggers" | "conditions" | "actions", unknown>>;
validationSectionKeys?: ValidationSectionKeys;
checkValidation(): Promise<void>;
domain: "automation" | "script";
}
@@ -150,6 +111,8 @@ export const AutomationScriptEditorMixin = <TConfig extends BaseEditorConfig>(
@consume({ context: fullEntitiesContext, subscribe: true })
entityRegistry?: EntityRegistryEntry[];
@state() protected errors?: string;
@state() protected yamlErrors?: string;
@state() protected currentEntityId?: string;
@@ -162,7 +125,7 @@ export const AutomationScriptEditorMixin = <TConfig extends BaseEditorConfig>(
@state() protected deprecatedConfigMigrated = false;
@state() protected configErrors: ConfigValidationError[] = NO_ERRORS;
@state() protected validationErrors?: (string | TemplateResult)[];
@state() protected config?: TConfig;
@@ -194,14 +157,6 @@ export const AutomationScriptEditorMixin = <TConfig extends BaseEditorConfig>(
private _relatedContextAreaId?: string;
@query("manual-automation-editor, manual-script-editor")
private _errorsEditor?: ErrorsEditorElement;
// Validation responses of an older run are dropped
private _validationRun = 0;
private _knownErrorItems: KnownErrorItem[] = [];
protected willUpdate(changedProps: PropertyValues): void {
super.willUpdate(changedProps);
if (
@@ -212,212 +167,6 @@ export const AutomationScriptEditorMixin = <TConfig extends BaseEditorConfig>(
}
}
protected updated(changedProps: PropertyValues): void {
super.updated(changedProps);
if (changedProps.has("config") && this.configErrors.length) {
this._knownErrorItems = followKnownErrorItems(
this._knownErrorItems,
changedProps.get("config"),
this.config
);
this._revalidate();
}
}
public disconnectedCallback() {
super.disconnectedCallback();
this._revalidate.cancel();
if (this.configErrors.length) {
this._closeErrorsToast();
}
}
/** Shows the errors of saving `submitted`, the config sent to core. */
protected async showSaveErrors(err: unknown, submitted: TConfig) {
const { message, errors } = parseConfigSaveError(err);
if (!errors.length) {
this._showSaveFailedToast(message);
return;
}
this._validationRun++;
const submittedErrors = mapErrorsToConfig(submitted, errors);
// Edits made while saving moved or removed some of the items
const changed = submitted !== this.config;
this._setConfigErrors(
this.config,
changed
? followErrors(submittedErrors, submitted, this.config)
: submittedErrors
);
if (changed) {
// Followed errors miss items that were renamed and moved, so check
// everything again
await this._validateConfig(false);
}
this._showSaveFailedToast(
errors.length === 1
? errors[0].message
: this.hass.localize(
"ui.panel.config.automation.editor.errors.problems_found",
{ count: errors.length }
)
);
await this._revealErrors();
}
protected async checkValidation() {
if (!this.currentEntityId || !this.config) {
return;
}
if (this.hass.states[this.currentEntityId]?.state !== UNAVAILABLE) {
return;
}
const config = this.config;
const sections = this.domainHooks.validationSections(config);
if (!Object.keys(sections).length) {
return;
}
const run = ++this._validationRun;
let errors: ConfigValidationError[];
try {
errors = await validateConfigErrors(
this.hass,
sections,
this.domainHooks.validationSectionKeys
);
} catch (_err: unknown) {
// The unavailable alert still shows, only the rows stay unmarked
return;
}
if (run !== this._validationRun || config !== this.config) {
return;
}
this._setConfigErrors(config, mapErrorsToConfig(config, errors));
}
private _setConfigErrors(
config: TConfig | undefined,
errors: ConfigValidationError[]
) {
this.configErrors = errors;
this._knownErrorItems = knownErrorItems(config, errors);
}
private async _revealErrors() {
await this.updateComplete;
if (this.mode === "gui") {
await this._errorsEditor?.revealErrors();
}
}
/** All errors, for the editors that have no rows to show them on. */
protected renderConfigErrorsAlert(slot?: string) {
return this.configErrors.length
? html`<ha-alert
alert-type="error"
class="config-errors"
slot=${ifDefined(slot)}
>
${this.configErrors.map((error) => html`<p>${errorText(error)}</p>`)}
</ha-alert>`
: nothing;
}
private _showSaveFailedToast(problems: string) {
showEditorToast(this, {
id: CONFIG_ERRORS_TOAST_ID,
message: this.hass.localize(
"ui.panel.config.automation.editor.errors.save_failed",
{ problems }
),
});
}
private _closeErrorsToast() {
// A removed editor can't reach the toast manager by bubbling
const host = this.isConnected
? this
: document.querySelector<HTMLElement>("home-assistant");
if (!host) {
return;
}
showToast(host, {
id: CONFIG_ERRORS_TOAST_ID,
message: "",
duration: 0,
});
}
// Updates the errors of the items that had one, so fixed problems clear
// while editing. New problems elsewhere, and errors outside the validated
// sections like an invalid mode, wait for the next save.
private _revalidate = debounce(() => {
this._validateConfig(true);
}, REVALIDATE_DELAY);
/**
* Validates the sections of the current config. With `onlyKnown`, only
* errors on items that already had one are kept.
*/
private async _validateConfig(onlyKnown: boolean) {
this._revalidate.cancel();
const config = this.config;
if (!config || (onlyKnown && !this.configErrors.length)) {
return;
}
const sections = this.domainHooks.validationSections(config);
// Blueprint inputs can't be validated on their own, so their errors
// only last until the next edit
if (!Object.keys(sections).length) {
this.clearConfigErrors();
return;
}
const sectionKeys = this.domainHooks.validationSectionKeys ?? {};
const run = ++this._validationRun;
let sectionErrors: ConfigValidationError[];
try {
sectionErrors = await validateConfigErrors(
this.hass,
sections,
sectionKeys
);
} catch (_err: unknown) {
return;
}
if (run !== this._validationRun || config !== this.config) {
return;
}
const mapped = mapErrorsToConfig(config, sectionErrors);
const validated = Object.keys(sections).map(
(key) => sectionKeys[key] ?? key
);
const errors = [
...this.configErrors.filter(
(error) => !validated.includes(error.path[0] as string)
),
...(onlyKnown
? errorsOnKnownItems(this._knownErrorItems, config, mapped)
: mapped),
];
if (!errors.length) {
this.clearConfigErrors();
return;
}
this._setConfigErrors(
config,
errorsEqual(errors, this.configErrors) ? this.configErrors : errors
);
}
protected clearConfigErrors() {
this._validationRun++;
this._revalidate.cancel();
if (this.configErrors.length) {
this.configErrors = NO_ERRORS;
this._closeErrorsToast();
}
}
private _setRelatedContext(): void {
const areaId = this.currentEntityId
? this.entityRegistry?.find(
@@ -528,7 +277,6 @@ export const AutomationScriptEditorMixin = <TConfig extends BaseEditorConfig>(
protected async loadConfig(id: string) {
const hooks = this.domainHooks;
const domain = hooks.domain;
this.clearConfigErrors();
try {
const config = await hooks.fetchFileConfig(this.hass, id);
if (!this.isConnected) {
@@ -548,7 +296,7 @@ export const AutomationScriptEditorMixin = <TConfig extends BaseEditorConfig>(
entityRegistryUpdate: this.entityRegistryUpdate,
}
);
this.checkValidation();
hooks.checkValidation();
} catch (err: any) {
if (!this.isConnected) {
return;
@@ -1,4 +1,3 @@
import { ContextProvider } from "@lit/context";
import { css, html, LitElement, nothing, type PropertyValues } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import { tinykeys } from "tinykeys";
@@ -6,7 +5,6 @@ import { consume } from "../../../common/decorators/consume";
import { fireEvent } from "../../../common/dom/fire_event";
import type { HASSDomEvent } from "../../../common/dom/fire_event";
import { computeRTL } from "../../../common/util/compute_rtl";
import { pathEquals } from "../../../common/util/config-path";
import "../../../components/ha-snap-bottom-sheet";
import type { HaSnapBottomSheet } from "../../../components/ha-snap-bottom-sheet";
import {
@@ -24,11 +22,6 @@ import { manifestsContext } from "../../../data/context";
import type { DomainManifestLookup } from "../../../data/integration";
import { isTriggerList } from "../../../data/trigger";
import type { HomeAssistant } from "../../../types";
import {
automationPathContext,
automationRowRegistryContext,
type AutomationRowRegistry,
} from "./automation-path";
import "./sidebar/ha-automation-sidebar-action";
import type HaAutomationSidebarAction from "./sidebar/ha-automation-sidebar-action";
import "./sidebar/ha-automation-sidebar-condition";
@@ -68,17 +61,6 @@ export default class HaAutomationSidebar extends LitElement {
@consume({ context: manifestsContext, subscribe: true })
private _manifests?: DomainManifestLookup;
@consume({ context: automationRowRegistryContext })
private _registry?: AutomationRowRegistry;
// Editors in the sidebar are not inside their row, so they get its path here
private readonly _pathProvider = new ContextProvider(this, {
context: automationPathContext,
initialValue: [],
});
private _unsubscribeRegistry?: () => void;
@query("ha-snap-bottom-sheet")
private _bottomSheetElement?: HaSnapBottomSheet;
@@ -101,28 +83,6 @@ export default class HaAutomationSidebar extends LitElement {
private _tinykeysUnsub?: () => void;
public connectedCallback() {
super.connectedCallback();
// A row dragged to another place while it is open changes its path
this._unsubscribeRegistry = this._registry?.subscribe(() =>
this._syncPath()
);
}
protected willUpdate(changedProperties: PropertyValues<this>) {
super.willUpdate(changedProperties);
if (changedProperties.has("config")) {
this._syncPath();
}
}
private _syncPath() {
const path = this.config ? (this._registry?.selectedPath() ?? []) : [];
if (!pathEquals(path, this._pathProvider.value)) {
this._pathProvider.setValue(path);
}
}
protected updated(changedProperties: PropertyValues<this>) {
super.updated(changedProperties);
if (
@@ -145,8 +105,6 @@ export default class HaAutomationSidebar extends LitElement {
disconnectedCallback() {
super.disconnectedCallback();
this._unsubscribeRegistry?.();
this._unsubscribeRegistry = undefined;
this._unregisterResizeHandlers();
this._tinykeysUnsub?.();
}
@@ -2,8 +2,8 @@ import { provide } from "@lit/context";
import { mdiContentSave } from "@mdi/js";
import {
html,
nothing,
type LitElement,
type nothing,
type PropertyValues,
type TemplateResult,
} from "lit";
@@ -16,7 +16,6 @@ import type { HASSDomEvent } from "../../../common/dom/fire_event";
import { fireEvent } from "../../../common/dom/fire_event";
import { replaceCurrentUrl } from "../../../common/navigate";
import { constructUrlCurrentPath } from "../../../common/url/construct-url";
import { nextRender } from "../../../common/util/render-status";
import {
extractSearchParam,
removeSearchParam,
@@ -25,7 +24,6 @@ import "../../../components/ha-button";
import "../../../components/ha-icon-button";
import "../../../components/ha-markdown";
import type { SidebarConfig } from "../../../data/automation";
import type { ConfigValidationError } from "../../../data/config";
import {
dirtyStateContext,
type DirtyStateContext,
@@ -36,19 +34,10 @@ import type {
ValueChangedEvent,
} from "../../../types";
import { showToast } from "../../../util/toast";
import "../../../components/ha-alert";
import {
automationErrorsContext,
errorOwner,
errorText,
NO_ERRORS,
} from "./automation-errors";
import {
AutomationRowRegistry,
automationRowRegistryContext,
type AutomationPathRow,
} from "./automation-path";
import { scrollRowIntoView } from "./scroll-row-into-view";
import "./ha-automation-sidebar";
import type HaAutomationSidebar from "./ha-automation-sidebar";
@@ -101,10 +90,6 @@ export const ManualEditorMixin = <TConfig>(
@provide({ context: automationRowRegistryContext })
protected rowRegistry = new AutomationRowRegistry();
@provide({ context: automationErrorsContext })
@property({ attribute: false })
public configErrors: ConfigValidationError[] = NO_ERRORS;
declare protected collapsableElements?: NodeListOf<
Element & {
expandAll(): void;
@@ -118,44 +103,16 @@ export const ManualEditorMixin = <TConfig>(
throw new Error("Not implemented");
};
private _unsubscribeRows?: () => void;
// Rows register while they render, so errors only count as without a
// row once the rows had a frame to render
@state() private _rowsRendered = false;
public connectedCallback() {
super.connectedCallback();
window.addEventListener("paste", this.handlePaste);
// Which errors have a row changes as rows render
this._unsubscribeRows = this.rowRegistry.subscribe(() => {
if (this.configErrors.length) {
this.requestUpdate();
}
});
}
public disconnectedCallback() {
window.removeEventListener("paste", this.handlePaste);
this._unsubscribeRows?.();
this._unsubscribeRows = undefined;
super.disconnectedCallback();
}
private _renderUnownedErrors() {
if (!this._rowsRendered) {
return nothing;
}
const unowned = this.configErrors.filter(
(error) => !errorOwner(this.rowRegistry, error)
);
return unowned.length
? html`<ha-alert alert-type="error" class="unowned-errors">
${unowned.map((error) => html`<p>${errorText(error)}</p>`)}
</ha-alert>`
: nothing;
}
protected renderContent(): TemplateResult | typeof nothing {
throw new Error("Not implemented");
}
@@ -184,7 +141,7 @@ export const ManualEditorMixin = <TConfig>(
>
<div class="content">
<slot name="alerts"></slot>
${this._renderUnownedErrors()} ${this.renderContent()}
${this.renderContent()}
</div>
<div class="fab-positioner">
<ha-button
@@ -222,9 +179,6 @@ export const ManualEditorMixin = <TConfig>(
protected firstUpdated(changedProps: PropertyValues<this>): void {
super.firstUpdated(changedProps);
nextRender().then(() => {
this._rowsRendered = true;
});
this.style.setProperty(
"--sidebar-dynamic-width",
@@ -349,54 +303,6 @@ export const ManualEditorMixin = <TConfig>(
});
}
/**
* Expands the rows along the path of every error, then scrolls to the
* first one and opens it in the sidebar. Does nothing while a row with an
* error is open, so the user stays where they are.
*/
public async revealErrors() {
await this.updateComplete;
const errorRows = () =>
this.configErrors.map((error) =>
this.rowRegistry.rowsAlong(error.path)
);
const owners = (rows: AutomationPathRow[][]) =>
rows
.map((along) => along[along.length - 1])
.filter((owner) => owner !== undefined);
let rows = errorRows();
if (owners(rows).some((owner) => owner.selected)) {
return;
}
const depthOf = (along: AutomationPathRow[][]) =>
along.reduce((sum, path) => sum + path.length, 0);
// Collapsed rows don't render their children, so expand one level at a
// time until no deeper row shows up
let depth = -1;
while (depthOf(rows) > depth) {
depth = depthOf(rows);
rows.flat().forEach((row) => row.expand());
// eslint-disable-next-line no-await-in-loop
await nextRender();
rows = errorRows();
}
// Errors aren't in config order, so take the topmost row
const first = owners(rows).reduce<AutomationPathRow | undefined>(
(top, row) =>
!top ||
row.getBoundingClientRect().top < top.getBoundingClientRect().top
? row
: top,
undefined
);
if (!first) {
return;
}
first.openSidebar();
await nextRender();
scrollRowIntoView(first);
}
public expandAll() {
this.collapsableElements?.forEach((element) => {
element.expandAll();
@@ -65,7 +65,6 @@ import {
import "../trigger/ha-automation-trigger-references";
import { getUiSupportWarnings, isConfigObject } from "../ui-support";
import { AutomationPathController } from "../automation-path";
import { renderRowErrorIcon } from "../automation-row-error-icon";
@customElement("ha-automation-option-row")
export default class HaAutomationOptionRow extends LitElement {
@@ -273,11 +272,6 @@ export default class HaAutomationOptionRow extends LitElement {
}
</h3>
${renderRowErrorIcon(
this.pathController.errors,
this.pathController.path,
this.hass.localize("ui.panel.config.automation.editor.errors.label")
)}
<slot name="icons" slot="icons"></slot>
${
@@ -508,7 +502,6 @@ export default class HaAutomationOptionRow extends LitElement {
.collapsed=${this._collapsed}
.selected=${this._selected}
.sortSelected=${this.sortSelected}
.error=${this.pathController.errors.length > 0}
@click=${this._toggleSidebar}
@toggle-collapsed=${this._toggleCollapse}
@delete-row=${this._removeOption}
@@ -1,4 +1,3 @@
import { provide } from "@lit/context";
import { mdiClose, mdiDotsVertical } from "@mdi/js";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, query } from "lit/decorators";
@@ -13,13 +12,6 @@ import "../../../../components/ha-icon-button";
import { ScrollableFadeMixin } from "../../../../mixins/scrollable-fade-mixin";
import { haStyleScrollbar } from "../../../../resources/styles";
import type { HomeAssistant } from "../../../../types";
import "../../../../components/ha-alert";
import { errorField, errorText } from "../automation-errors";
import {
automationInlineErrorFieldsContext,
AutomationFieldErrorsController,
InlineErrorFields,
} from "../automation-field-errors";
import "../ha-automation-editor-warning";
export interface SidebarOverflowMenuEntry {
@@ -52,22 +44,11 @@ export default class HaAutomationSidebarCard extends ScrollableFadeMixin(
@query(".card-content") private _contentElement!: HTMLDivElement;
private readonly _errors = new AutomationFieldErrorsController(this);
@provide({ context: automationInlineErrorFieldsContext })
protected inlineErrorFields = new InlineErrorFields(() =>
this.requestUpdate()
);
protected get scrollableElement(): HTMLElement | null {
return this._contentElement;
}
protected render() {
const errors = this._errors.rowErrors.filter((error) => {
const field = errorField(error, this._errors.path);
return field === undefined || !this.inlineErrorFields.has(field);
});
return html`
<ha-card
outlined
@@ -109,15 +90,6 @@ export default class HaAutomationSidebarCard extends ScrollableFadeMixin(
</ha-automation-editor-warning>`
: nothing
}
${
errors.length
? html`<ha-alert alert-type="error" class="errors">
${errors.map(
(error) => html`<p>${errorText(error, this._errors.path)}</p>`
)}
</ha-alert>`
: nothing
}
<div class="card-content ha-scrollbar">
<slot></slot>
${this.renderScrollableFades(this.isWide)}
@@ -176,15 +148,6 @@ export default class HaAutomationSidebarCard extends ScrollableFadeMixin(
padding-bottom: max(var(--safe-area-inset-bottom, 0px), 32px);
}
.errors {
margin: 0 var(--ha-space-4) var(--ha-space-4);
}
.errors p {
margin: 0;
white-space: pre-wrap;
}
.fade-top {
top: var(--ha-space-17);
}
-22
View File
@@ -124,19 +124,6 @@ export const rowStyles = css`
white-space: pre-wrap;
margin: 0;
}
.error-indicator {
color: var(--ha-color-on-danger-quiet);
padding: var(--ha-space-3);
}
.error-indicator + ha-tooltip::part(body) {
cursor: default;
max-width: 300px;
}
.error-indicator + ha-tooltip p {
white-space: pre-wrap;
margin: 0;
}
`;
export const editorStyles = css`
@@ -293,15 +280,6 @@ export const manualEditorStyles = css`
.header a {
color: var(--secondary-text-color);
}
.unowned-errors {
display: block;
margin-bottom: var(--ha-space-2);
}
.unowned-errors p {
margin: 0;
white-space: pre-wrap;
}
`;
export const automationRowsStyles = css`
@@ -3,11 +3,14 @@ import type { LitElement, ReactiveController } from "lit";
import memoizeOne from "memoize-one";
import type {
AutomationConfig,
BlueprintAutomationConfig,
SidebarConfig,
Trigger,
TriggerCondition,
} from "../../../../data/automation";
import { ContextSubscriptionController } from "../../../../common/decorators/consume";
import { debounce } from "../../../../common/util/debounce";
import { deepEqual } from "../../../../common/util/deep-equal";
import type { LocalizeFunc } from "../../../../common/translations/localize";
import { internationalizationContext } from "../../../../data/context";
import { showConfirmationDialog } from "../../../../dialogs/generic/show-dialog-box";
@@ -16,12 +19,15 @@ import {
automationTriggerContext,
cleanupRemovedGeneratedTriggerReferences,
cleanupUnusedGeneratedTriggerIds,
getBlueprintTriggerIdOptions,
getTriggerIdOptions,
getExplicitTriggerIds,
isGeneratedTriggerId,
makeDuplicateTriggerIdsUnique,
replaceLeafTrigger,
updateTriggerCondition,
type TriggerIdOption,
type BlueprintTriggersStatus,
} from "./automation-trigger-id";
import { preserveAutomationRowKeys } from "../ha-automation-sortable-list-mixin";
@@ -31,6 +37,10 @@ interface AutomationTriggerControllerOptions {
canEdit: () => boolean;
/** Apply one complete change through the editor's undo and dirty-state handling. */
commit: (config: AutomationConfig) => void;
/** Resolve a blueprint automation's triggers, with its inputs substituted. */
fetchBlueprintTriggers: (
useBlueprint: BlueprintAutomationConfig["use_blueprint"]
) => Promise<Trigger | Trigger[] | undefined>;
}
/**
@@ -43,6 +53,9 @@ interface AutomationTriggerControllerOptions {
*
* The transformation helpers only change configuration. This controller preserves
* the corresponding row keys so an open sidebar continues to track the edited row.
*
* A blueprint automation has no triggers of its own: options come from the
* blueprint's substituted triggers, and conditions in its inputs change themselves.
*/
export class AutomationTriggerController implements ReactiveController {
// Only read in event handlers, so it does not need to rerender the host.
@@ -54,6 +67,18 @@ export class AutomationTriggerController implements ReactiveController {
// changes, so rendering and selection use the same IDs across host updates.
private _getTriggerIdOptions = memoizeOne(getTriggerIdOptions);
private _getBlueprintTriggerIdOptions = memoizeOne(
getBlueprintTriggerIdOptions
);
/** The blueprint use whose triggers were last requested. */
private _blueprintRequested?: BlueprintAutomationConfig["use_blueprint"];
private _blueprintTriggers?: Trigger | Trigger[];
/** "failed" while required inputs are empty. */
private _blueprintStatus: BlueprintTriggersStatus = "loading";
constructor(
private _host: LitElement,
private _options: AutomationTriggerControllerOptions
@@ -78,15 +103,39 @@ export class AutomationTriggerController implements ReactiveController {
_host.addController(this);
}
public hostDisconnected() {
this._fetchBlueprintTriggers.cancel();
this._blueprintRequested = undefined;
}
/** Publish options before descendants render, including after undo or redo. */
public hostUpdate() {
const config = this._options.getConfig();
if (!config) {
return;
}
const options = this._getTriggerIdOptions(config.triggers);
if (options !== this._provider.value.options) {
this._provider.setValue({ ...this._provider.value, options });
let options: TriggerIdOption[];
let blueprintStatus: BlueprintTriggersStatus | undefined;
let showIndices = this._provider.value.showIndices;
if ("use_blueprint" in config && config.use_blueprint) {
this._requestBlueprintTriggers(config.use_blueprint);
options = this._getBlueprintTriggerIdOptions(this._blueprintTriggers);
blueprintStatus = this._blueprintStatus;
showIndices = false;
} else {
options = this._getTriggerIdOptions(config.triggers);
}
if (
options !== this._provider.value.options ||
blueprintStatus !== this._provider.value.blueprintStatus ||
showIndices !== this._provider.value.showIndices
) {
this._provider.setValue({
...this._provider.value,
options,
blueprintStatus,
showIndices,
});
}
}
@@ -138,11 +187,55 @@ export class AutomationTriggerController implements ReactiveController {
return cleaned;
}
private _selectTriggerIds = (condition: TriggerCondition, ids: string[]) => {
const config = this._options.getConfig();
if (!config || !this._options.canEdit()) {
/** Request the blueprint's triggers again whenever its path or inputs change. */
private _requestBlueprintTriggers(
useBlueprint: BlueprintAutomationConfig["use_blueprint"]
) {
if (deepEqual(useBlueprint, this._blueprintRequested)) {
return;
}
if (useBlueprint.path !== this._blueprintRequested?.path) {
this._blueprintTriggers = undefined;
}
this._blueprintStatus = "loading";
this._blueprintRequested = useBlueprint;
this._fetchBlueprintTriggers(useBlueprint);
}
private _fetchBlueprintTriggers = debounce(
async (useBlueprint: BlueprintAutomationConfig["use_blueprint"]) => {
let triggers: Trigger | Trigger[] | undefined;
try {
triggers = await this._options.fetchBlueprintTriggers(useBlueprint);
} catch (_err) {
if (useBlueprint === this._blueprintRequested) {
this._blueprintStatus = "failed";
this._host.requestUpdate();
}
return;
}
if (useBlueprint !== this._blueprintRequested) {
return;
}
this._blueprintTriggers = triggers;
this._blueprintStatus = "loaded";
this._host.requestUpdate();
},
500,
true
);
private _selectTriggerIds = (
condition: TriggerCondition,
ids: string[]
): boolean => {
const config = this._options.getConfig();
if (config && "use_blueprint" in config) {
return false;
}
if (!config || !this._options.canEdit()) {
return true;
}
const triggers = assignGeneratedTriggerIds(
config.triggers,
this._getTriggerIdOptions(config.triggers),
@@ -157,6 +250,7 @@ export class AutomationTriggerController implements ReactiveController {
// Generated IDs that no condition or action references are no longer in use.
// Strip them so the YAML does not accumulate stale identifiers.
this._commit(cleanupUnusedGeneratedTriggerIds(updated));
return true;
};
private _fixDuplicateTriggerIds = async () => {
@@ -38,13 +38,28 @@ export interface TriggerIdOption {
duplicate: boolean;
}
/**
* Whether a blueprint's substituted triggers are known. While "loading" or
* "failed", options keep the last triggers fetched, if any.
*/
export type BlueprintTriggersStatus = "loaded" | "loading" | "failed";
/** Controller-provided options and operations for editors, sidebars, and row summaries. */
export interface AutomationTriggerContext {
options: TriggerIdOption[];
/**
* Left out for an automation with its own triggers. Set for a blueprint
* automation, whose missing IDs can only be flagged once "loaded".
*/
blueprintStatus?: BlueprintTriggersStatus;
/** True while the user is editing a `triggered_by` condition; trigger rows show their index badges only in this state. */
showIndices: boolean;
/** Pass the original condition object so identical conditions remain distinguishable. */
select: (condition: TriggerCondition, ids: string[]) => void;
/**
* Pass the original condition object so identical conditions remain distinguishable.
* Return false when the condition is not one of the automation's conditions, as in
* a blueprint automation, whose conditions live in its inputs.
*/
select: (condition: TriggerCondition, ids: string[]) => boolean;
/** Requests confirmation before migrating duplicate IDs throughout the automation. */
fixDuplicateIds: () => Promise<void>;
/**
@@ -54,6 +69,13 @@ export interface AutomationTriggerContext {
updateTriggerId: (original: Trigger, updated: Trigger) => boolean;
}
/** Whether options list every trigger, so an ID not among them is really missing. */
export const triggerOptionsResolved = (
context: AutomationTriggerContext | undefined
): boolean =>
context?.blueprintStatus === undefined ||
context.blueprintStatus === "loaded";
export const automationTriggerContext =
createContext<AutomationTriggerContext>("automationTriggers");
@@ -140,6 +162,27 @@ export const getTriggerIdOptions = (
});
};
/**
* Build options from a blueprint's substituted triggers. The blueprint owns its
* triggers, so only those with a stored ID can be referenced, and triggers sharing
* an ID are one option: a condition on that ID matches any of them.
*/
export const getBlueprintTriggerIdOptions = (
triggers: Trigger | Trigger[] | undefined
): TriggerIdOption[] => {
const options: TriggerIdOption[] = [];
const seenIds = new Set<string>();
flattenTriggers(triggers).forEach((trigger, index) => {
const id = getTriggerId(trigger);
if (!id || seenIds.has(id)) {
return;
}
seenIds.add(id);
options.push({ id, trigger, index, draft: false, duplicate: false });
});
return options;
};
/**
* Transform leaf triggers recursively, retaining list wrappers and scalar/array shape.
* Copy only changed branches; callbacks must return replacements rather than mutate leaves.
@@ -14,6 +14,7 @@ import type { EntityRegistryEntry } from "../../../../data/entity/entity_registr
import type { HomeAssistant } from "../../../../types";
import {
automationTriggerContext,
triggerOptionsResolved,
type AutomationTriggerContext,
} from "./automation-trigger-id";
@@ -41,6 +42,7 @@ export class HaAutomationTriggerReferences extends LitElement {
const missingIds = selectedIds.filter(
(id) => !options.some((option) => option.id === id)
);
const resolved = triggerOptionsResolved(this._triggers);
if (options.length && !selectedIds.length) {
return html`<span class="trigger-reference no-selection">
@@ -87,17 +89,21 @@ export class HaAutomationTriggerReferences extends LitElement {
</span>
`
),
missingIds.map(
() => html`
<span class="trigger-reference missing">
<ha-svg-icon .path=${mdiLinkVariantOff}></ha-svg-icon>
<span>
${this.hass.localize(
"ui.panel.config.automation.editor.conditions.type.trigger.missing_trigger"
)}
</span>
</span>
`
missingIds.map((id) =>
resolved
? html`
<span class="trigger-reference missing">
<ha-svg-icon .path=${mdiLinkVariantOff}></ha-svg-icon>
<span>
${this.hass.localize(
"ui.panel.config.automation.editor.conditions.type.trigger.missing_trigger"
)}
</span>
</span>
`
: html`<span class="trigger-reference">
<span class="trigger-reference-label">${id}</span>
</span>`
),
];
}
@@ -121,7 +121,6 @@ import "./types/ha-automation-trigger-zone";
import { renderCtrlOrCmd } from "../../../../common/keyboard/ctrl-or-cmd";
import { scrollRowIntoView } from "../scroll-row-into-view";
import { AutomationPathController } from "../automation-path";
import { renderRowErrorIcon } from "../automation-row-error-icon";
import { targetExpandGroupContext } from "../../../../components/target-picker/target-expand-group-context";
export interface TriggerElement extends LitElement {
@@ -401,11 +400,6 @@ export default class HaAutomationTriggerRow extends LitElement {
)}
</ha-automation-row-event-chip>
${renderRowErrorIcon(
this.pathController.errors,
this.pathController.path,
this.hass.localize("ui.panel.config.automation.editor.errors.label")
)}
<slot name="icons" slot="icons"></slot>
<ha-dropdown
@@ -670,7 +664,6 @@ export default class HaAutomationTriggerRow extends LitElement {
.selected=${this._selected}
.highlight=${this.highlight}
.sortSelected=${this.sortSelected}
.error=${this.pathController.errors.length > 0}
.dim=${this._triggered}
@click=${this._toggleSidebar}
>${
@@ -29,18 +29,9 @@ import {
} from "../../../../../data/device/device_registry";
import type { EntityRegistryEntry } from "../../../../../data/entity/entity_registry";
import type { HomeAssistant } from "../../../../../types";
import {
deviceAutomationErrorText,
deviceErrorText,
} from "../../automation-errors";
import { AutomationFieldErrorsController } from "../../automation-field-errors";
@customElement("ha-automation-trigger-device")
export class HaDeviceTrigger extends LitElement {
private readonly _fieldErrors = new AutomationFieldErrorsController(this, {
inlineFields: ["device_id", "domain"],
});
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ type: Object }) public trigger!: DeviceTrigger;
@@ -145,20 +136,12 @@ export class HaDeviceTrigger extends LitElement {
}
protected render() {
const deviceError = this._fieldErrors.fieldError("device_id");
const automationError = this._fieldErrors.fieldError("domain");
const deviceId = this._deviceId || this.trigger.device_id;
return html`
<ha-device-picker
.value=${deviceId}
.replacementDeviceIds=${this._replacementDeviceIds}
.invalid=${!!deviceError}
.errorMessage=${
deviceError
? deviceErrorText(this.hass.localize, deviceError)
: undefined
}
@value-changed=${this._devicePicked}
.disabled=${this.disabled}
.label=${this.hass.localize(
@@ -166,16 +149,6 @@ export class HaDeviceTrigger extends LitElement {
)}
></ha-device-picker>
<ha-device-trigger-picker
.invalid=${!!automationError}
.errorMessage=${
automationError
? deviceAutomationErrorText(
this.hass.localize,
"triggers",
automationError
)
: undefined
}
.value=${this.trigger}
.deviceId=${deviceId}
@value-changed=${this._deviceTriggerPicked}
@@ -4,7 +4,6 @@ import memoizeOne from "memoize-one";
import { ensureArray } from "../../../../../common/array/ensure-array";
import { fireEvent } from "../../../../../common/dom/fire_event";
import "../../../../../components/ha-form/ha-form";
import { AutomationFieldErrorsController } from "../../automation-field-errors";
import type { SchemaUnion } from "../../../../../components/ha-form/types";
import type { NumericStateTrigger } from "../../../../../data/automation";
import type { HomeAssistant } from "../../../../../types";
@@ -184,17 +183,6 @@ const SCHEMA = [
@customElement("ha-automation-trigger-numeric_state")
export class HaNumericStateTrigger extends LitElement {
private readonly _fieldErrors = new AutomationFieldErrorsController(this, {
inlineFields: [
"entity_id",
"attribute",
"value_template",
"above",
"below",
"for",
],
});
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false }) public trigger!: NumericStateTrigger;
@@ -242,7 +230,6 @@ export class HaNumericStateTrigger extends LitElement {
return html`
<ha-form
.error=${this._fieldErrors.fields}
.hass=${this.hass}
.data=${data}
.schema=${SCHEMA}
Loaded 100 of 226 files, more files were not shown because too many files have changed in this diff. Show more