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
Markus TuominenandClaude Fable 5.1 95a009641f Fix integration icons being clipped into circles on the area page (#55820)
Stop rounding and distorting integration icons on the area page

The generic img rule meant for the area picture also applied to the 24px
brand icons in the device list, clipping square integration logos (for
example Awair) into circles. Scope that rule to the area picture and size
the icons with width/height attributes like the device page does.

Co-authored-by: Claude Fable 5.1 <[email protected]>
2026-11-10 10:17:22 +01:00
Wendelin 36f59f8495 Pass callWS to entity and device registry update helpers (#55819) 2026-11-10 11:02:12 +02:00
Stefan AgnerandClaude Opus 5.5 8a13bba521 Only match an OTBR to a Thread network it is part of (#55803)
* Only match an OTBR to a Thread network it is part of

findOTBRInfoForDataset returned the dataset's preferred border router
without checking that it is still on that network. When the border
router had moved to another network, the Thread panel offered to reset
it from the card of a network it no longer belongs to, which would
replace the border router's current network.

Require the extended PAN ID to match for the preferred border router
too, and drop the active dataset check in the network information page
that worked around the same problem.

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

* Update findOTBRInfoForDataset tests for the network check

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

* Keep findOTBRInfoForDataset fallback tests independent of key order

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

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-11-10 11:00:30 +02:00
Maarten Lakerveld cc7b1952c0 End slider and color wheel interaction when a drag is cancelled (#55804)
End slider and color wheel interaction when a pan is cancelled

ha-control-slider and ha-hs-color-picker restored their value on
pancancel without firing an event, so consumers never learned that the
interaction ended. The light color pickers then kept ignoring state
updates until the next interaction. Fire the same end-of-interaction
event as panend (and as ha-control-scrubber already does), and restore
the wheel's local value along with its cursor position.
2026-11-10 10:59:38 +02:00
karwosts 2edf6f91d0 Allow configurable time format for timer.finishes_at in state-display (#55813)
Allow custom time format for timer.finishes_at
2026-11-10 09:26:27 +01:00
jimjaeger 9d52517ae9 Retry translation fragments after a failed request (#55716)
* Retry translation fragments after a failed request

* Clear translation mock before each test
2026-11-10 08:51:58 +02:00
renovate[bot] 27a7f6335f Update dependency @vvo/tzdb to v6.203.0 (#55814)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-11-10 08:25:58 +02:00
renovate[bot] cd44bfcebc Update dependency eslint-plugin-import-x to v4.18.0 (#55815)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-11-10 08:25:55 +02:00
Elias Lecomte 8819844740 Fix single-day selection in the date range picker (#55718)
* Fix single-day selection in the date range picker

* Limit single-tap date selection to devices without hover
2026-11-10 08:23:21 +02:00
WendelinandClaude Opus 5.5 008adf1533 Move the create helper dialog and helper forms to contexts (#55794)
* Narrow helper create and area update data functions to the API they use

The create helper dialog can then pass the API context instead of the
full hass object.

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

* Consume contexts in helper forms instead of hass

The helper forms only read translations, locale and config, so they now
consume the internationalization and config contexts. This avoids
rerendering them on every unrelated hass change.

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

* Consume contexts in the create helper dialog instead of hass

The dialog reads translations, config, the API and the connection from
their contexts and no longer passes hass to the helper forms.

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

* Refactor API calls to use HomeAssistantApi for improved type safety and consistency across data handling

---------

Co-authored-by: Claude <[email protected]>
2026-11-09 20:54:31 +01:00
renovate[bot] 8c3d0f2f1e Update dependency serve to v14.2.7 (#55810)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-11-09 20:52:23 +01:00
renovate[bot] 3f4f89480f Update tsparticles to v4.5.3 (#55811)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-11-09 19:51:19 +00:00
Matthias de BaatandClaude Opus 5.5 432762a2ea Add design to the AI policy (#55795)
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-11-09 20:45:01 +01:00
WendelinandClaude Opus 5.5 013cf8d7e6 Give automation editor rows a config path (#55801)
* Give automation editor rows a config path

Each list and row in the automation and script editor extends the path
of its ancestor with its own segment through a Lit context, e.g.
["actions", 0, "then", 1]. Rows that open in the sidebar register in a
per-editor registry by path, as groundwork to find the row a config path
belongs to, e.g. to point at an error in it.

Move the generic path type and helpers from the Lovelace editor to
common/util/config-path and use ConfigPath everywhere.

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

* Test config path helpers on their own

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

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-11-09 20:17:34 +02:00
Joost LekkerkerkerandClaude 1de489cca9 Sort device connections by type in device info card (#55797)
Claude-Session: https://claude.ai/code/session_01SLHDn4zYGvMHi4C8v3m45c

Co-authored-by: Claude <[email protected]>
2026-11-09 16:58:30 +01:00
Maarten Lakerveld 97af661779 Use the Backups menu caption as the backup page title (#55799)
The Settings > System menu said "Backups" while the page header said
"Backup". Reuse the caption key like the other system pages do, so
the two can't drift apart again.
2026-11-09 16:41:09 +01:00
Erwin Douna f702e369fc Add a health dialog to config entries (#55736)
* Add a health dialog to config entries

* Address review feedback

* Address review feedback

* Address review feedback

* Address review feedback

* Use the integration filter for the logs link
2026-11-09 16:40:04 +01:00
Maarten Lakerveld 78e0a92ddd Include disabled helpers in the helpers area filter (#55796)
The helpers table lists disabled helpers, but its area filter left them
out, so a disabled helper without an area disappeared when picking
No area, and one in an area disappeared when picking that area. Match
the entities page, which already passes include-disabled-entities.
2026-11-09 16:24:11 +01:00
Andreas d742259444 Fix stale color and slider jump after quick drag in light color pickers (#55780)
* Fix stale color sent after quick drag in light color pickers

When the color temperature slider is dragged and released within the
500 ms throttle window, the release handler skipped sending because the
picker value already equalled the final value, relying on the pending
throttled call. A state update from the leading throttled call then
reset the picker value (no longer interacting), so the trailing call
sent the stale initial value and the light ended up at the wrong color
temperature.

Cancel the pending throttled call on release and always send the final
value. Apply the same cancel to the RGB picker, where a pending trailing
call could send a stale color after the final one.

* Keep released value in light color pickers until state settles

After release, keep showing the released value for a short time and
ignore state updates in between, so that late state updates from calls
sent while dragging no longer make the slider jump back before the
final value is reported.

* Don't sync light color pickers while a new drag is active

If a new drag starts before the release hold of the previous one ends,
the hold timer must not overwrite the picker value with the reported
state, as that makes the control jump and lets a pending throttled call
send the older value. The release of the new drag starts a new hold.

* Address review: keep brightness normalization in sync, hold native input

Keep the brightness normalization of RGB-only lights in sync with the
reported state while the picker values are held (during a drag or the
release hold), as it is applied to the next color sent. Otherwise
successive color selections could dim the light repeatedly.

The native color input has no release event and sends throttled, so
refresh the release hold on every input. This keeps state updates from
calls sent while picking from overwriting the pending value.

* Use PendingValueSync in light color pickers and end interaction on release

Replace the fixed release hold timer with PendingValueSync, so state
updates are ignored only while a call is queued or in flight. Also clear
_isInteracting in the release handlers, so a keyboard adjustment or a
cancelled pan no longer stops the picker from following the state.
2026-11-09 16:22:09 +01:00
OllieandClaude Opus 5.5 623ca14356 Configure remote Bluetooth adapters from the adapters page (#55742)
* Configure remote Bluetooth adapters from the adapters page

Remote scanners such as ESPHome Bluetooth proxies have their scanning
mode and other settings in the options flow of the integration that
provides them. Show the configure button for remote adapters on the
Bluetooth adapters page and open that integration's options flow, found
through the scanner device's via device, so users no longer have to find
the device under Devices & services.

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

* Prefer the providing integration's device when finding the source entry

Other integrations may add config entries to the scanner's own device,
so look at the via device first and only fall back to the scanner's
device.

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

* Only use the via device and skip disabled source entries

Drop the fallback to the scanner's own device, which could pick up an
unrelated integration's entry, and don't offer to configure a remote
adapter whose providing config entry is disabled or has no options flow.

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

* Check for a configurable source entry in the template

Keep _getSourceConfigEntry a plain lookup, as suggested in review, and
skip the cog for disabled or non-configurable source entries where it
is rendered.

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

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-11-09 16:10:41 +01:00
336 changed files with 15391 additions and 7954 deletions

No files matched your search

+1 -1
View File
@@ -1 +1 @@
24.22.2
24.22.3
+8
View File
@@ -28,10 +28,18 @@ AI can be a helpful tool for writing code and documentation. However, due to the
All contributions must be reviewed and understood by the contributor before submission. You should be able to explain every change in a pull request you submit. Pull requests that appear to be unreviewed AI output will be closed without review.
## Design contributions
AI can be a helpful tool to explore ideas and build mockups and prototypes. But AI tends to give the most common answer, not the one that fits Home Assistant and its users. So we require a human who brings the point of view and owns the design decisions.
You should be able to explain the problem your design solves and why you made your choices. If a mockup or prototype was mostly generated with AI, say so when you share it. Check your work against the [design guidelines](https://contributors.home-assistant.io/design/), as AI tools often make up components, colors, or spacing that don't exist in Home Assistant. Design proposals that look like unreviewed AI output may be closed or hidden.
## Our use of AI
Some of our projects use AI tools to assist with code reviews, issue triaging, reporting, and other project management tasks. These tools may leave comments on pull requests or issues. As with any automated tooling, these comments are not always correct.
We also use AI to help keep the design system, the Figma kit, and the code in sync. The design decisions behind them are made by people.
If an AI tool leaves a comment on your contribution, treat it as you would any other review comment. If you believe it is incorrect, say so; a brief explanation is sufficient. Maintainers always have the final say. If in doubt, ask a maintainer.
## Enforcement
+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);
+24 -24
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",
@@ -77,18 +77,18 @@
"@mdi/svg": "7.4.47",
"@replit/codemirror-indentation-markers": "6.5.3",
"@swc/helpers": "0.5.24",
"@tsparticles/engine": "4.5.2",
"@tsparticles/preset-links": "4.5.2",
"@versatiles/style": "8.1.1",
"@tsparticles/engine": "4.5.3",
"@tsparticles/preset-links": "4.5.3",
"@versatiles/style": "9.1.0",
"@vibrant/color": "4.0.4",
"@vvo/tzdb": "6.202.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.17.1",
"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",
@@ -208,14 +208,14 @@
"pinst": "3.0.0",
"prettier": "3.9.10",
"rspack-manifest-plugin": "5.2.2",
"serve": "14.2.6",
"serve": "14.2.7",
"sinon": "22.1.0",
"tar": "7.5.22",
"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"
}
}
+643 -608
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;
};
+7
View File
@@ -0,0 +1,7 @@
/** Where a value is in a config, e.g. `["actions", 0, "then", 1]`. */
export type ConfigPath = (string | number)[];
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]);
+56 -12
View File
@@ -30,10 +30,40 @@ export class HaAutomationRow extends LitElement {
@property({ type: Boolean, reflect: true })
public dim = 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"
@@ -53,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>
`;
}
@@ -119,7 +139,7 @@ export class HaAutomationRow extends LitElement {
public focus() {
requestAnimationFrame(() => {
this._rowElement?.focus();
(this._linkElement ?? this._rowElement)?.focus();
});
}
@@ -144,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);
+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)
);
@@ -97,6 +97,8 @@ export class DateRangePicker extends MobileAwareMixin(LitElement) {
@state() private _dateValue?: string;
@state() private _rangeIncomplete = false;
@state() private _timeValue = {
from: { hours: 0, minutes: 0 },
to: { hours: 23, minutes: 59 },
@@ -195,6 +197,7 @@ export class DateRangePicker extends MobileAwareMixin(LitElement) {
.locale=${this._i18n.locale.language}
.focusedDate=${this._focusDate}
@focusday=${this._focusChanged}
@rangestart=${this._handleRangeStart}
@change=${this._handleChange}
show-outside-days
.firstDayOfWeek=${firstWeekdayIndex(this._i18n.locale)}
@@ -253,7 +256,9 @@ export class DateRangePicker extends MobileAwareMixin(LitElement) {
<ha-button appearance="plain" @click=${this._cancel}
>${this._i18n.localize("ui.common.cancel")}</ha-button
>
<ha-button .disabled=${!this._dateValue} @click=${this._save}
<ha-button
.disabled=${!this._dateValue || this._rangeIncomplete}
@click=${this._save}
>${this._i18n.localize(
"ui.components.date-range-picker.select"
)}</ha-button
@@ -280,7 +285,7 @@ export class DateRangePicker extends MobileAwareMixin(LitElement) {
}
private _save() {
if (!this._dateValue) {
if (!this._dateValue || this._rangeIncomplete) {
return;
}
@@ -331,9 +336,21 @@ export class DateRangePicker extends MobileAwareMixin(LitElement) {
this._focusDate = ev.target.focusedDate;
}
private _handleRangeStart(
ev: HASSDomTargetEvent<HTMLElementTagNameMap["calendar-range"]>
) {
this._rangeIncomplete = matchMedia("(hover: hover)").matches;
if (this._rangeIncomplete) {
return;
}
const day = ev.target.tentative;
this._dateValue = `${day}/${day}`;
}
private _handleChange(
ev: HASSDomTargetEvent<HTMLElementTagNameMap["calendar-range"]>
) {
this._rangeIncomplete = false;
const dateElement = ev.target as HTMLElementTagNameMap["calendar-range"];
this._dateValue = dateElement.value;
this._focusDate = dateElement.focusedDate;
+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
@@ -62,10 +62,6 @@ export class HaControlSelect extends LitElement {
});
}
public override focus(): void {
this._focusOption(this._tabbableIndex);
}
private _handleBlur(ev: FocusEvent) {
// Only reset if focus is leaving the entire component
if (!this.contains(ev.relatedTarget as Node)) {
+1
View File
@@ -219,6 +219,7 @@ export class HaControlSlider extends LitElement {
this.pressed = false;
this._hideTooltip();
this.value = savedValue;
fireEvent(this, "slider-moved", { value: undefined });
});
this._mc.on("panmove", (e) => {
if (this.disabled) return;
+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 });
}
}
+4
View File
@@ -214,15 +214,19 @@ class HaHsColorPicker extends LitElement {
this._mc.add(new Tap({ event: "singletap" }));
let savedPosition;
let savedValue: [number, number] | undefined;
this._mc.on("panstart", (e) => {
if (this.disabled) return;
this._pressed = e.pointerType;
savedPosition = this._cursorPosition;
savedValue = this._localValue;
});
this._mc.on("pancancel", () => {
if (this.disabled) return;
this._pressed = undefined;
this._cursorPosition = savedPosition;
this._localValue = savedValue;
fireEvent(this, "cursor-moved", { value: undefined });
});
this._mc.on("panmove", (e) => {
if (this.disabled) return;
+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);
@@ -21,6 +21,8 @@ export class HaActionSelector extends LitElement {
@property({ attribute: false }) public value?: Action;
@property() public name?: string;
@property() public label?: string;
@property({ type: Boolean, reflect: true }) public disabled = false;
@@ -51,6 +53,7 @@ export class HaActionSelector extends LitElement {
return html`
${this.label ? html`<label>${this.label}</label>` : nothing}
<ha-automation-action
.pathKey=${this.name}
.disabled=${this.disabled}
.actions=${this._actions(this.value)}
.hass=${this.hass}
@@ -16,6 +16,8 @@ export class HaConditionSelector extends LitElement {
@property({ attribute: false }) public value?: Condition;
@property() public name?: string;
@property() public label?: string;
@property({ type: Boolean, reflect: true }) public disabled = false;
@@ -27,6 +29,7 @@ export class HaConditionSelector extends LitElement {
return html`
${this.label ? html`<label>${this.label}</label>` : nothing}
<ha-automation-condition
.pathKey=${this.name}
.disabled=${this.disabled}
.conditions=${this.value || []}
.hass=${this.hass}
@@ -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)
@@ -17,6 +17,8 @@ export class HaTriggerSelector extends LitElement {
@property({ attribute: false }) public value?: Trigger;
@property() public name?: string;
@property() public label?: string;
@property({ type: Boolean, reflect: true }) public disabled = false;
@@ -32,6 +34,7 @@ export class HaTriggerSelector extends LitElement {
return html`
${this.label ? html`<label>${this.label}</label>` : nothing}
<ha-automation-trigger
.pathKey=${this.name}
.disabled=${this.disabled}
.triggers=${this._triggers(this.value)}
.hass=${this.hass}
+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";
+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;
}
}
+128
View File
@@ -0,0 +1,128 @@
import type { HassEntities } from "home-assistant-js-websocket";
import type { ConfigEntry } from "./config_entries";
import type { DataEntryFlowProgress } from "./data_entry_flow";
import type { EntityRegistryEntry } from "./entity/entity_registry";
import type { IntegrationSetup } from "./integration";
import type { RepairsIssue } from "./repairs";
import type { LoggedError } from "./system_log";
import { getLoggedErrorIntegration } from "./system_log";
export type HealthStatus = "ok" | "warning" | "error" | "info";
export type OverallHealthStatus = Exclude<HealthStatus, "info">;
export interface ConfigEntryHealth {
status: OverallHealthStatus;
state: HealthStatus;
entities: {
status: HealthStatus;
total: number;
unavailable: EntityRegistryEntry[];
};
repairs: { status: HealthStatus; issues: RepairsIssue[] };
logs: { status: HealthStatus; errors: number; warnings: number };
flows: { status: HealthStatus; flows: DataEntryFlowProgress[] };
setupSeconds?: number;
}
const ATTENTION_SOURCES = ["reauth", "reconfigure"];
const STATE_STATUS: Record<ConfigEntry["state"], HealthStatus> = {
loaded: "ok",
setup_in_progress: "info",
unload_in_progress: "info",
not_loaded: "info",
setup_retry: "warning",
setup_error: "error",
migration_error: "error",
failed_unload: "error",
};
const worst = (statuses: HealthStatus[]): OverallHealthStatus =>
statuses.includes("error")
? "error"
: statuses.includes("warning")
? "warning"
: "ok";
export const computeConfigEntryHealth = (
entry: ConfigEntry,
entities: EntityRegistryEntry[],
states: HassEntities,
issues: RepairsIssue[],
log: LoggedError[],
flows: DataEntryFlowProgress[],
setups: IntegrationSetup[],
// Library loggers from the manifest, so dependency errors count too.
loggers: string[] = []
): ConfigEntryHealth => {
const enabled = entities.filter((entity) => !entity.disabled_by);
// Entities without a state yet (e.g. while the entry is not loaded) count as unavailable.
const unavailable = enabled.filter(
(entity) =>
!states[entity.entity_id] ||
states[entity.entity_id].state === "unavailable"
);
const entryIssues = issues.filter(
(issue) =>
!issue.ignored && (issue.issue_domain ?? issue.domain) === entry.domain
);
const entryLog = log.filter(
(item) =>
getLoggedErrorIntegration(item) === entry.domain ||
loggers.some(
(logger) => item.name === logger || item.name.startsWith(`${logger}.`)
)
);
const sumCount = (levels: LoggedError["level"][]) =>
entryLog
.filter((item) => levels.includes(item.level))
.reduce((total, item) => total + item.count, 0);
const errors = sumCount(["error", "critical"]);
const warnings = sumCount(["warning"]);
const entryFlows = flows.filter(
(flow) =>
flow.handler === entry.domain &&
flow.context.entry_id === entry.entry_id &&
ATTENTION_SOURCES.includes(flow.context.source)
);
// A disabled entry that failed to unload still needs a restart.
const state: HealthStatus =
entry.disabled_by && entry.state !== "failed_unload"
? "info"
: STATE_STATUS[entry.state];
const health: ConfigEntryHealth = {
status: "ok",
state,
entities: {
status: entry.disabled_by || !unavailable.length ? "ok" : "warning",
total: enabled.length,
unavailable,
},
repairs: {
status: entryIssues.some((issue) => issue.severity !== "warning")
? "error"
: entryIssues.length
? "warning"
: "ok",
issues: entryIssues,
},
logs: {
status: errors ? "error" : warnings ? "warning" : "ok",
errors,
warnings,
},
flows: { status: entryFlows.length ? "error" : "ok", flows: entryFlows },
setupSeconds: setups.find((setup) => setup.domain === entry.domain)
?.seconds,
};
health.status = worst([
state,
health.entities.status,
health.repairs.status,
health.logs.status,
health.flows.status,
]);
return health;
};
+7 -7
View File
@@ -1,6 +1,6 @@
import type { Connection } from "home-assistant-js-websocket";
import type { LocalizeFunc } from "../common/translations/localize";
import type { HomeAssistant } from "../types";
import type { HomeAssistant, HomeAssistantApi } from "../types";
import type {
DataEntryFlowProgress,
DataEntryFlowStep,
@@ -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,
@@ -78,10 +78,10 @@ export const deleteConfigFlow = (
// The backend indexes a single key, and only these two exist.
export const getConfigFlowHandlers = (
hass: HomeAssistant,
callApi: HomeAssistantApi["callApi"],
type?: "helper" | "integration"
) =>
hass.callApi<string[]>(
callApi<string[]>(
"GET",
`config/config_entries/flow_handlers${type ? `?type=${type}` : ""}`
);
+3 -3
View File
@@ -1,4 +1,4 @@
import type { HomeAssistant } from "../types";
import type { HomeAssistant, HomeAssistantApi } from "../types";
export interface Counter {
id: string;
@@ -25,10 +25,10 @@ export const fetchCounter = (hass: HomeAssistant) =>
hass.callWS<Counter[]>({ type: "counter/list" });
export const createCounter = (
hass: HomeAssistant,
callWS: HomeAssistantApi["callWS"],
values: CounterMutableParams
) =>
hass.callWS<Counter>({
callWS<Counter>({
type: "counter/create",
...values,
});
+3 -3
View File
@@ -1,6 +1,6 @@
import type { Connection } from "home-assistant-js-websocket";
import { caseInsensitiveStringCompare } from "../../common/string/compare";
import type { HomeAssistant } from "../../types";
import type { HomeAssistant, HomeAssistantApi } from "../../types";
import type { ConfigEntry } from "../config_entries";
import type {
EntityRegistryDisplayEntry,
@@ -231,11 +231,11 @@ export const devicesInArea = (devices: DeviceRegistryEntry[], areaId: string) =>
devices.filter((device) => device.area_id === areaId);
export const updateDeviceRegistryEntry = (
hass: HomeAssistant,
callWS: HomeAssistantApi["callWS"],
deviceId: string,
updates: Partial<DeviceRegistryEntryMutableParams>
) =>
hass.callWS<DeviceRegistryEntry>({
callWS<DeviceRegistryEntry>({
type: "config/device_registry/update",
device_id: deviceId,
...updates,
+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,
},
+9 -9
View File
@@ -5,7 +5,7 @@ import memoizeOne from "memoize-one";
import { computeDomain } from "../../common/entity/compute_domain";
import { caseInsensitiveStringCompare } from "../../common/string/compare";
import { debounce } from "../../common/util/debounce";
import type { HomeAssistant } from "../../types";
import type { HomeAssistant, HomeAssistantApi } from "../../types";
import type { LightColor } from "../light";
import type { NextNamePart, RegistryEntry } from "../registry";
import type { Segment } from "../vacuum";
@@ -284,11 +284,11 @@ export const getExtendedEntityRegistryEntries = (
});
export const updateEntityRegistryEntry = (
hass: Pick<HomeAssistant, "callWS">,
callWS: HomeAssistantApi["callWS"],
entityId: string,
updates: Partial<EntityRegistryEntryUpdateParams>
): Promise<UpdateEntityRegistryEntryResult> =>
hass.callWS({
callWS({
type: "config/entity_registry/update",
entity_id: entityId,
...updates,
@@ -388,11 +388,11 @@ export interface AutomaticEntityIdOverrides {
}
export const getAutomaticEntityIds = (
hass: HomeAssistant,
callWS: HomeAssistantApi["callWS"],
entity_ids: string[],
overrides?: Record<string, AutomaticEntityIdOverrides>
) =>
hass.callWS<Record<string, string | null>>({
callWS<Record<string, string | null>>({
type: "config/entity_registry/get_automatic_entity_ids",
entity_ids,
...(overrides && { overrides }),
@@ -403,22 +403,22 @@ export const getAutomaticEntityIds = (
* the area. Returns the entity ID of each entity after the change.
*/
export const updateEntitiesArea = async (
hass: HomeAssistant,
callWS: HomeAssistantApi["callWS"],
entityIds: string[],
areaId: string
): Promise<Record<string, string>> => {
await Promise.all(
entityIds.map((entityId) =>
updateEntityRegistryEntry(hass, entityId, { area_id: areaId })
updateEntityRegistryEntry(callWS, entityId, { area_id: areaId })
)
);
const newEntityIds = await getAutomaticEntityIds(hass, entityIds);
const newEntityIds = await getAutomaticEntityIds(callWS, entityIds);
const result: Record<string, string> = {};
for (const entityId of entityIds) {
const newEntityId = newEntityIds[entityId];
if (newEntityId) {
// eslint-disable-next-line no-await-in-loop
await updateEntityRegistryEntry(hass, entityId, {
await updateEntityRegistryEntry(callWS, entityId, {
new_entity_id: newEntityId,
});
}
-9
View File
@@ -49,20 +49,12 @@ export interface SecurityAlertEntityConfig {
export interface SecurityFrontendSystemData {
alert_entities?: SecurityAlertEntityConfig[];
favorite_entities?: string[];
hidden_entities?: string[];
}
export interface MaintenanceFrontendSystemData {
hidden_entities?: string[];
}
export interface ClimateFrontendSystemData {
hidden_entities?: string[];
}
export type HomeSummaryPanelSystemDataKey =
"security" | "maintenance" | "climate";
export interface EnergyFrontendSystemData {
// Stable "<view>.<card-type>" keys of energy dashboard cards the user has
// hidden. An absent key or array means nothing is hidden (all cards visible),
@@ -82,7 +74,6 @@ declare global {
energy: EnergyFrontendSystemData;
security: SecurityFrontendSystemData;
maintenance: MaintenanceFrontendSystemData;
climate: ClimateFrontendSystemData;
}
}
+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);
}
};
+3 -3
View File
@@ -1,4 +1,4 @@
import type { HomeAssistant } from "../types";
import type { HomeAssistant, HomeAssistantApi } from "../types";
export interface InputBoolean {
id: string;
@@ -17,10 +17,10 @@ export const fetchInputBoolean = (hass: HomeAssistant) =>
hass.callWS<InputBoolean[]>({ type: "input_boolean/list" });
export const createInputBoolean = (
hass: HomeAssistant,
callWS: HomeAssistantApi["callWS"],
values: InputBooleanMutableParams
) =>
hass.callWS<InputBoolean>({
callWS<InputBoolean>({
type: "input_boolean/create",
...values,
});
+3 -3
View File
@@ -1,4 +1,4 @@
import type { HomeAssistant } from "../types";
import type { HomeAssistant, HomeAssistantApi } from "../types";
export interface InputButton {
id: string;
@@ -15,10 +15,10 @@ export const fetchInputButton = (hass: HomeAssistant) =>
hass.callWS<InputButton[]>({ type: "input_button/list" });
export const createInputButton = (
hass: HomeAssistant,
callWS: HomeAssistantApi["callWS"],
values: InputButtonMutableParams
) =>
hass.callWS<InputButton>({
callWS<InputButton>({
type: "input_button/create",
...values,
});
+2 -2
View File
@@ -45,10 +45,10 @@ export const fetchInputDateTime = (hass: HomeAssistant) =>
hass.callWS<InputDateTime[]>({ type: "input_datetime/list" });
export const createInputDateTime = (
hass: HomeAssistant,
callWS: HomeAssistantApi["callWS"],
values: InputDateTimeMutableParams
) =>
hass.callWS<InputDateTime>({
callWS<InputDateTime>({
type: "input_datetime/create",
...values,
});
+3 -3
View File
@@ -1,4 +1,4 @@
import type { HomeAssistant } from "../types";
import type { HomeAssistant, HomeAssistantApi } from "../types";
export interface InputNumber {
id: string;
@@ -29,10 +29,10 @@ export const fetchInputNumber = (hass: HomeAssistant) =>
hass.callWS<InputNumber[]>({ type: "input_number/list" });
export const createInputNumber = (
hass: HomeAssistant,
callWS: HomeAssistantApi["callWS"],
values: InputNumberMutableParams
) =>
hass.callWS<InputNumber>({
callWS<InputNumber>({
type: "input_number/create",
...values,
});
+3 -3
View File
@@ -2,7 +2,7 @@ import type {
HassEntityAttributeBase,
HassEntityBase,
} from "home-assistant-js-websocket";
import type { HomeAssistant } from "../types";
import type { HomeAssistant, HomeAssistantApi } from "../types";
interface InputSelectEntityAttributes extends HassEntityAttributeBase {
options: string[];
@@ -41,10 +41,10 @@ export const fetchInputSelect = (hass: HomeAssistant) =>
hass.callWS<InputSelect[]>({ type: "input_select/list" });
export const createInputSelect = (
hass: HomeAssistant,
callWS: HomeAssistantApi["callWS"],
values: InputSelectMutableParams
) =>
hass.callWS<InputSelect>({
callWS<InputSelect>({
type: "input_select/create",
...values,
});
+3 -3
View File
@@ -1,4 +1,4 @@
import type { HomeAssistant } from "../types";
import type { HomeAssistant, HomeAssistantApi } from "../types";
export interface InputText {
id: string;
@@ -36,10 +36,10 @@ export const fetchInputText = (hass: HomeAssistant) =>
hass.callWS<InputText[]>({ type: "input_text/list" });
export const createInputText = (
hass: HomeAssistant,
callWS: HomeAssistantApi["callWS"],
values: InputTextMutableParams
) =>
hass.callWS<InputText>({
callWS<InputText>({
type: "input_text/create",
...values,
});
+2 -2
View File
@@ -196,8 +196,8 @@ export const fetchIntegrationManifest = (
.callWS<IntegrationManifest>({ type: "manifest/get", integration })
.then(sanitizeManifest);
export const fetchIntegrationSetups = (hass: HomeAssistant) =>
hass.callWS<IntegrationSetup[]>({ type: "integration/setup_info" });
export const fetchIntegrationSetups = (callWS: HomeAssistant["callWS"]) =>
callWS<IntegrationSetup[]>({ type: "integration/setup_info" });
export const fetchIntegrationLogInfo = (conn) =>
conn.sendMessagePromise({
+3 -3
View File
@@ -1,9 +1,9 @@
import type { Connection, HassEventBase } from "home-assistant-js-websocket";
import { getCollection } from "home-assistant-js-websocket";
import type { ConfigPath } from "../common/util/config-path";
import type { HuiBadge } from "../panels/lovelace/badges/hui-badge";
import type { HuiCard } from "../panels/lovelace/cards/hui-card";
import type { HuiSection } from "../panels/lovelace/sections/hui-section";
import type { LovelacePath } from "../panels/lovelace/editor/lovelace-path";
import type { Lovelace } from "../panels/lovelace/types";
import type { HomeAssistant } from "../types";
import type { LovelaceSectionConfig } from "./lovelace/config/section";
@@ -21,7 +21,7 @@ export interface LovelaceViewElement extends HTMLElement {
narrow?: boolean;
// Temporary compatibility: custom view layouts still read the view index
index?: number;
path?: LovelacePath;
path?: ConfigPath;
cards?: HuiCard[];
badges?: HuiBadge[];
sections?: HuiSection[];
@@ -34,7 +34,7 @@ export interface LovelaceSectionElement extends HTMLElement {
hass?: HomeAssistant;
lovelace?: Lovelace;
preview?: boolean;
path?: LovelacePath;
path?: ConfigPath;
cards?: HuiCard[];
badges?: HuiBadge[];
sections?: HuiSection[];
+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 = (
+3 -2
View File
@@ -21,8 +21,9 @@ export const findOTBRInfoForDataset = (
}
const preferredAddress = dataset.preferred_extended_address;
if (preferredAddress && otbrInfo[preferredAddress]) {
return otbrInfo[preferredAddress];
const preferred = preferredAddress ? otbrInfo[preferredAddress] : undefined;
if (preferred?.extended_pan_id === dataset.extended_pan_id) {
return preferred;
}
return Object.values(otbrInfo).find(
+2 -2
View File
@@ -15,7 +15,7 @@ export const regenerateEntityIds = async (
hass: HomeAssistant,
entities: string[]
): Promise<void> => {
const entityIdsMapping = await getAutomaticEntityIds(hass, entities);
const entityIdsMapping = await getAutomaticEntityIds(hass.callWS, entities);
const entityIdsEntries = Object.entries(entityIdsMapping);
@@ -95,7 +95,7 @@ export const regenerateEntityIds = async (
entityIdsEntries
.filter(([oldId, newId]) => newId && oldId !== newId)
.forEach(([oldEntityId, newEntityId]) =>
updateEntityRegistryEntry(hass, oldEntityId, {
updateEntityRegistryEntry(hass.callWS, oldEntityId, {
new_entity_id: newEntityId!,
}).catch((err: any) => {
showAlertDialog(element, {
+3 -3
View File
@@ -1,4 +1,4 @@
import type { HomeAssistant } from "../types";
import type { HomeAssistant, HomeAssistantApi } from "../types";
export const weekdays = [
"sunday",
@@ -33,10 +33,10 @@ export const fetchSchedule = (hass: HomeAssistant) =>
hass.callWS<Schedule[]>({ type: "schedule/list" });
export const createSchedule = (
hass: HomeAssistant,
callWS: HomeAssistantApi["callWS"],
values: ScheduleMutableParams
) =>
hass.callWS<Schedule>({
callWS<Schedule>({
type: "schedule/create",
...values,
});
+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,
});
+2 -2
View File
@@ -23,8 +23,8 @@ export interface LoggedError {
first_occurred: number;
}
export const fetchSystemLog = async (hass: HomeAssistant) => {
const log = await hass.callWS<LoggedError[]>({ type: "system_log/list" });
export const fetchSystemLog = async (callWS: HomeAssistant["callWS"]) => {
const log = await callWS<LoggedError[]>({ type: "system_log/list" });
for (const error of log) {
error.level = error.level.toLowerCase() as LoggedError["level"];
}
+6 -3
View File
@@ -11,7 +11,7 @@ import secondsToDuration from "../common/datetime/seconds_to_duration";
import { formatNumericDuration } from "../common/datetime/format_duration";
import type { FormatEntityStateFunc } from "../common/translations/entity-state";
import type { HaDurationData } from "../components/ha-duration-input";
import type { HomeAssistant } from "../types";
import type { HomeAssistant, HomeAssistantApi } from "../types";
import type { FrontendLocaleData } from "./translation";
export type TimerEntity = HassEntityBase & {
@@ -48,8 +48,11 @@ export interface TimerMutableParams {
export const fetchTimer = (hass: HomeAssistant) =>
hass.callWS<Timer[]>({ type: "timer/list" });
export const createTimer = (hass: HomeAssistant, values: TimerMutableParams) =>
hass.callWS<Timer>({
export const createTimer = (
callWS: HomeAssistantApi["callWS"],
values: TimerMutableParams
) =>
callWS<Timer>({
type: "timer/create",
...values,
});
+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;
},
@@ -303,7 +303,7 @@ class StepFlowCreateEntry extends LitElement {
if (Object.keys(this._deviceUpdate).length) {
const deviceUpdates = Object.entries(this._deviceUpdate).map(
([deviceId, update]) =>
updateDeviceRegistryEntry(this.hass, deviceId, {
updateDeviceRegistryEntry(this.hass.callWS, deviceId, {
name_by_user: update.name,
area_id: update.area,
}).catch((err: unknown) => {
@@ -333,14 +333,14 @@ class StepFlowCreateEntry extends LitElement {
});
const entityIdsMapping = await getAutomaticEntityIds(
this.hass,
this.hass.callWS,
entityIds
);
Object.entries(entityIdsMapping).forEach(([oldEntityId, newEntityId]) => {
if (newEntityId) {
entityUpdates.push(
updateEntityRegistryEntry(this.hass, oldEntityId, {
updateEntityRegistryEntry(this.hass.callWS, oldEntityId, {
new_entity_id: newEntityId,
}).catch((err) =>
showAlertDialog(this, {
@@ -423,7 +423,7 @@ class StepFlowCreateEntry extends LitElement {
): Promise<ExtEntityRegistryEntry[]> {
try {
const newEntityIds = await updateEntitiesArea(
this.hass,
this.hass.callWS,
this._entitiesWithoutDevice(entities).map((entity) => entity.entity_id),
areaId
);
@@ -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;
}
}
@@ -146,7 +146,7 @@ export class HaMoreInfoCoverFavoritePositions extends LitElement {
}
const result = await updateEntityRegistryEntry(
this._api,
this._api.callWS,
this.entry.entity_id,
{
options_domain: "cover",
@@ -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;
}
@@ -75,7 +75,7 @@ export class HaMoreInfoLightFavoriteColors extends LitElement {
private async _save(newFavoriteColors: LightColor[]): Promise<void> {
const result = await updateEntityRegistryEntry(
this._api,
this._api.callWS,
this.entry!.entity_id,
{
options_domain: "light",
@@ -15,6 +15,7 @@ import {
import { consumeLocalize } from "../../../../common/decorators/consume-context-entry";
import { fireEvent } from "../../../../common/dom/fire_event";
import type { LocalizeFunc } from "../../../../common/translations/localize";
import { PendingValueSync } from "../../../../common/util/pending-value-sync";
import { throttle } from "../../../../common/util/throttle";
import "../../../../components/ha-hs-color-picker";
import "../../../../components/ha-icon";
@@ -59,6 +60,8 @@ class LightRgbColorPicker extends LitElement {
@state() private _isInteracting?: boolean;
private _pendingSync = new PendingValueSync();
protected render() {
if (!this.stateObj) {
return nothing;
@@ -181,23 +184,29 @@ class LightRgbColorPicker extends LitElement {
`;
}
private _updateBrightnessAdjusted() {
const stateObj = this.stateObj;
this._brightnessAdjusted = undefined;
if (
stateObj.attributes.color_mode === LightColorMode.RGB &&
stateObj.attributes.rgb_color &&
!lightSupportsColorMode(stateObj, LightColorMode.RGBWW) &&
!lightSupportsColorMode(stateObj, LightColorMode.RGBW)
) {
const maxVal = Math.max(...stateObj.attributes.rgb_color);
if (maxVal < 255) {
this._brightnessAdjusted = maxVal;
}
}
}
private _updateSliderValues() {
const stateObj = this.stateObj;
if (stateObj.state === "on") {
this._brightnessAdjusted = undefined;
if (
stateObj.attributes.color_mode === LightColorMode.RGB &&
stateObj.attributes.rgb_color &&
!lightSupportsColorMode(stateObj, LightColorMode.RGBWW) &&
!lightSupportsColorMode(stateObj, LightColorMode.RGBW)
) {
const maxVal = Math.max(...stateObj.attributes.rgb_color);
if (maxVal < 255) {
this._brightnessAdjusted = maxVal;
}
}
this._updateBrightnessAdjusted();
this._wvSliderValue =
stateObj.attributes.color_mode === LightColorMode.RGBW &&
@@ -235,11 +244,23 @@ class LightRgbColorPicker extends LitElement {
public willUpdate(changedProps: PropertyValues) {
super.willUpdate(changedProps);
if (this._isInteracting || !changedProps.has("stateObj")) {
if (!changedProps.has("stateObj")) {
return;
}
this._updateSliderValues();
const oldStateObj = changedProps.get("stateObj") as LightEntity | undefined;
if (oldStateObj?.entity_id !== this.stateObj?.entity_id) {
this._throttleUpdateColor.cancel();
this._pendingSync.reset();
this._isInteracting = false;
this._updateSliderValues();
} else if (!this._isInteracting && this._pendingSync.shouldSync()) {
this._updateSliderValues();
} else if (this.stateObj.state === "on") {
// Keep the picker values, but the brightness normalization has to
// follow the reported state, as it is applied to the next color sent.
this._updateBrightnessAdjusted();
}
}
private _hsColorCursorMoved(ev: CustomEvent) {
@@ -250,6 +271,7 @@ class LightRgbColorPicker extends LitElement {
return;
}
this._hsPickerValue = color;
this._pendingSync.schedule();
this._throttleUpdateColor();
}
@@ -312,13 +334,23 @@ class LightRgbColorPicker extends LitElement {
this._colorBrightnessSliderValue = hsv[2] / 2.55;
}
this._pendingSync.schedule();
this._throttleUpdateColor();
}
private _hsColorChanged(ev: CustomEvent) {
this._isInteracting = false;
if (!ev.detail.value) {
// Nothing is sent, so stop blocking state updates.
this._throttleUpdateColor.cancel();
this._pendingSync.reset();
this._updateSliderValues();
return;
}
// A pending throttled call would otherwise send an older color after the
// final one.
this._throttleUpdateColor.cancel();
this._hsPickerValue = ev.detail.value;
this._updateColor();
@@ -366,11 +398,15 @@ class LightRgbColorPicker extends LitElement {
private _applyColor(color: LightColor, params?: Record<string, any>) {
fireEvent(this, "color-changed", color);
this._api.callService("light", "turn_on", {
entity_id: this.stateObj!.entity_id,
...color,
...params,
});
return this._pendingSync.run(
() =>
this._api.callService("light", "turn_on", {
entity_id: this.stateObj!.entity_id,
...color,
...params,
}),
() => this._updateSliderValues()
);
}
private _colorBrightnessSliderChanged(ev: CustomEvent) {
@@ -13,6 +13,7 @@ import {
} from "../../../../common/color/convert-light-color";
import { fireEvent } from "../../../../common/dom/fire_event";
import { stateColorCss } from "../../../../common/entity/state_color";
import { PendingValueSync } from "../../../../common/util/pending-value-sync";
import { throttle } from "../../../../common/util/throttle";
import "../../../../components/ha-control-slider";
import {
@@ -66,6 +67,8 @@ class LightColorTempPicker extends LitElement {
@state() private _isInteracting?: boolean;
private _pendingSync = new PendingValueSync();
protected render() {
if (!this.stateObj) {
return nothing;
@@ -125,11 +128,19 @@ class LightColorTempPicker extends LitElement {
public willUpdate(changedProps: PropertyValues<this>) {
super.willUpdate(changedProps);
if (this._isInteracting || !changedProps.has("stateObj")) {
if (!changedProps.has("stateObj")) {
return;
}
this._updateSliderValues();
const oldStateObj = changedProps.get("stateObj");
if (oldStateObj?.entity_id !== this.stateObj?.entity_id) {
this._throttleUpdateColorTemp.cancel();
this._pendingSync.reset();
this._isInteracting = false;
this._updateSliderValues();
} else if (!this._isInteracting && this._pendingSync.shouldSync()) {
this._updateSliderValues();
}
}
private _ctColorCursorMoved(ev: CustomEvent) {
@@ -142,6 +153,7 @@ class LightColorTempPicker extends LitElement {
}
this._ctPickerValue = ct;
this._pendingSync.schedule();
this._throttleUpdateColorTemp();
}
@@ -153,7 +165,16 @@ class LightColorTempPicker extends LitElement {
private _ctColorChanged(ev: CustomEvent) {
const ct = ev.detail.value;
if (isNaN(ct) || this._ctPickerValue === ct) {
this._isInteracting = false;
// A pending throttled call would otherwise send an older value after the
// final one.
this._throttleUpdateColorTemp.cancel();
if (isNaN(ct)) {
// Nothing is sent, so stop blocking state updates.
this._pendingSync.reset();
this._updateSliderValues();
return;
}
@@ -170,11 +191,15 @@ class LightColorTempPicker extends LitElement {
private _applyColor(color: LightColor, params?: Record<string, any>) {
fireEvent(this, "color-changed", color);
this._api.callService("light", "turn_on", {
entity_id: this.stateObj!.entity_id,
...color,
...params,
});
return this._pendingSync.run(
() =>
this._api.callService("light", "turn_on", {
entity_id: this.stateObj!.entity_id,
...color,
...params,
}),
() => this._updateSliderValues()
);
}
static get styles(): CSSResultGroup {
@@ -112,7 +112,7 @@ export class HaMoreInfoTimerPresets extends LitElement {
};
const result = await updateEntityRegistryEntry(
this._api,
this._api.callWS,
this.entry.entity_id,
{
options_domain: "timer",
@@ -82,7 +82,7 @@ export class HaMoreInfoViewVacuumSegmentMapping extends LitElement {
};
try {
await updateEntityRegistryEntry(this.hass, this.params.entityId, {
await updateEntityRegistryEntry(this.hass.callWS, this.params.entityId, {
options_domain: "vacuum",
options: options,
});
@@ -110,7 +110,7 @@ export class HaMoreInfoValveFavoritePositions extends LitElement {
currentOptions.favorite_positions = this._favoritePositions;
const result = await updateEntityRegistryEntry(
this._api,
this._api.callWS,
this.entry.entity_id,
{
options_domain: "valve",
@@ -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),
});
+1 -1
View File
@@ -132,7 +132,7 @@ const copyFavoriteOptionsToEntities = async (
if (selected?.entity) {
const result = await Promise.allSettled(
selected.entity.map((entityId: string) =>
updateEntityRegistryEntry(hass, entityId, {
updateEntityRegistryEntry(hass.callWS, entityId, {
options_domain: domain,
options,
})
+1 -1
View File
@@ -531,7 +531,7 @@ export class MoreInfoDialog extends DirtyStateProviderMixin<
}
const result = await updateEntityRegistryEntry(
this.hass,
this.hass.callWS,
favoritesContext.entry.entity_id,
{
options_domain: favoritesHandler.domain,
@@ -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;
@@ -50,7 +50,7 @@ export class HaVoiceAssistantSetupStepArea extends LitElement {
});
return;
}
await updateDeviceRegistryEntry(this.hass, this.deviceId, {
await updateDeviceRegistryEntry(this.hass.callWS, this.deviceId, {
area_id: area,
});
this._nextStep();
@@ -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}` : ""}`
@@ -351,7 +351,7 @@ export class HaVoiceAssistantSetupStepSuccess extends LitElement {
private async _done() {
if (this._deviceName) {
try {
updateDeviceRegistryEntry(this.hass, this.deviceId, {
updateDeviceRegistryEntry(this.hass.callWS, this.deviceId, {
name_by_user: this._deviceName,
});
} catch (error: any) {
+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>
Loaded 100 of 336 files, more files were not shown because too many files have changed in this diff. Show more