Compare commits

..
Author SHA1 Message Date
renovate[bot] 366d4b1d8d Update formatjs 2026-11-12 06:02:46 +00: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
285 changed files with 13938 additions and 7166 deletions

No files matched your search

+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
@@ -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
@@ -165,6 +165,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 +186,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>`
+66
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();
@@ -220,6 +226,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 +479,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 +599,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);
+18 -18
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.3",
"@formatjs/intl-durationformat": "0.12.4",
"@formatjs/intl-getcanonicallocales": "3.3.1",
"@formatjs/intl-listformat": "9.0.3",
"@formatjs/intl-locale": "5.4.3",
"@formatjs/intl-numberformat": "10.0.4",
"@formatjs/intl-pluralrules": "7.0.4",
"@formatjs/intl-relativetimeformat": "13.0.3",
"@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.0.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",
@@ -108,7 +108,7 @@
"leaflet.markercluster": "1.5.3",
"lit": "3.3.3",
"lit-html": "3.3.3",
"maplibre-gl": "6.16.0",
"maplibre-gl": "6.17.0",
"marked": "18.1.3",
"memoize-one": "6.0.0",
"nanoid": "6.0.2",
@@ -147,7 +147,7 @@
"@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",
@@ -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.0",
"eslint-plugin-lit": "2.3.1",
"eslint-plugin-lit-a11y": "5.1.3",
"eslint-plugin-unused-imports": "4.4.1",
@@ -208,7 +208,7 @@
"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",
+339 -291
View File
File diff suppressed because it is too large. Load diff
+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]);
+54
View File
@@ -0,0 +1,54 @@
import type { IcePath } from "./limit-ice-servers";
// The fields of the transport, candidate pair and local candidate stats
interface IceStats {
id: string;
type: string;
selectedCandidatePairId?: string;
selected?: boolean;
localCandidateId?: string;
candidateType?: string;
relayProtocol?: string;
url?: string;
}
/**
* Reads the network path a connected peer connection uses. Returns
* `undefined` if the browser doesn't report it.
*/
export const getIcePath = async (
peerConnection: RTCPeerConnection
): Promise<IcePath | undefined> => {
const stats: Record<string, IceStats> = {};
try {
(await peerConnection.getStats()).forEach((report: IceStats) => {
stats[report.id] = report;
});
} catch (_err: unknown) {
return undefined;
}
const reports = Object.values(stats);
const pairId = reports.find(
(report) => report.type === "transport" && report.selectedCandidatePairId
)?.selectedCandidatePairId;
const pair = pairId
? stats[pairId]
: // Firefox has no transport stats
reports.find(
(report) => report.type === "candidate-pair" && report.selected
);
const candidate = pair?.localCandidateId
? stats[pair.localCandidateId]
: undefined;
if (!candidate?.candidateType) {
return undefined;
}
if (candidate.url?.startsWith("turn")) {
return { relayUrl: candidate.url };
}
if (candidate.candidateType === "relay" || candidate.relayProtocol) {
// Not all browsers report which TURN URL a relayed candidate came from
return undefined;
}
return {};
};
+103
View File
@@ -0,0 +1,103 @@
import { ensureArray } from "../array/ensure-array";
const isTurn = (url: string) => url.startsWith("turn");
const isTcpTurn = (url: string) =>
url.startsWith("turns:") || url.includes("transport=tcp");
const isTls443 = (url: string) =>
url.startsWith("turns:") && /:443(\?|$)/.test(url);
// "turn:host:3478?transport=udp" -> "host"
const getHost = (url: string) =>
url
.replace(/^[a-z]+:/i, "")
.replace(/\?.*$/, "")
.replace(/:\d+$/, "")
.toLowerCase();
/**
* Reduces the URLs of every ICE server to one STUN URL, one UDP TURN URL and
* one TCP TURN URL (preferring TLS on port 443, which passes most firewalls)
* per host.
*
* The browser gathers a candidate for every URL on every network interface and
* checks each of them against every remote candidate. Redundant URLs of the
* same host do not add a new network path, but multiply the number of checks
* and delay the connection by many seconds.
*/
export const limitIceServers = (
iceServers: RTCIceServer[] | undefined
): RTCIceServer[] | undefined =>
iceServers?.map((server) => {
const urls = ensureArray(server.urls);
if (urls.length < 2) {
return server;
}
const hosts = [...new Set(urls.map(getHost))];
return {
...server,
urls: hosts.flatMap((host) => {
const hostUrls = urls.filter((url) => getHost(url) === host);
const turnUrls = hostUrls.filter(isTurn);
const tcpTurnUrls = turnUrls.filter(isTcpTurn);
return [
hostUrls.find((url) => !isTurn(url)),
turnUrls.find((url) => !isTcpTurn(url)),
tcpTurnUrls.find(isTls443) ??
tcpTurnUrls.find((url) => url.startsWith("turns:")) ??
tcpTurnUrls[0],
].filter((url) => url !== undefined);
}),
};
});
// "turns:host" -> "turns:host:5349?transport=tcp", browsers report the URL of
// a candidate with the port and transport that were left out
const normalizeUrl = (url: string) => {
const scheme = url.slice(0, url.indexOf(":")).toLowerCase();
const port =
/:(\d+)(\?|$)/.exec(url)?.[1] ?? (scheme.endsWith("s") ? "5349" : "3478");
const transport =
/[?&]transport=(\w+)/i.exec(url)?.[1].toLowerCase() ??
(scheme === "turns" ? "tcp" : "udp");
return `${scheme}:${getHost(url)}:${port}?transport=${transport}`;
};
/** The network path a connection uses, without `relayUrl` it is direct. */
export interface IcePath {
relayUrl?: string;
}
/**
* Reduces the ICE servers to what a new connection needs to take the same
* path as a connection that is already running: only the TURN URL it relays
* over, or no TCP TURN URLs when it is direct. The UDP TURN URLs stay, as
* browsers also use them to find their public address.
*
* Returns `undefined` if the relay is not part of the ICE servers.
*/
export const limitIceServersToPath = (
iceServers: RTCIceServer[] | undefined,
path: IcePath
): RTCIceServer[] | undefined => {
const { relayUrl } = path;
if (relayUrl) {
const normalizedUrl = normalizeUrl(relayUrl);
for (const server of iceServers ?? []) {
const url = ensureArray(server.urls).find(
(serverUrl) => normalizeUrl(serverUrl) === normalizedUrl
);
if (url) {
return [{ ...server, urls: [url] }];
}
}
return undefined;
}
return limitIceServers(iceServers)
?.map((server) => ({
...server,
urls: ensureArray(server.urls).filter((url) => !isTcpTurn(url)),
}))
.filter((server) => server.urls.length);
};
+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 -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(
+146 -95
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,16 @@ import {
STREAM_TYPE_WEB_RTC,
type CameraCapabilities,
type CameraEntity,
type StreamType,
} from "../data/camera";
import {
hasWebRtcWorked,
MJPEG_STREAM,
selectCameraStreams,
setWebRtcWorked,
type CameraStream,
type CameraStreamTracks,
type CameraStreamType,
} from "../data/camera-streams";
import {
apiContext,
configContext,
@@ -29,14 +37,10 @@ import "./ha-hls-player";
import "./ha-web-rtc-player";
import type { HaWebRtcPlayer } from "./ha-web-rtc-player";
const MJPEG_STREAM = "mjpeg";
export type { CameraStreamType } from "../data/camera-streams";
export type CameraStreamType = StreamType | typeof MJPEG_STREAM;
interface Stream {
type: CameraStreamType;
visible: boolean;
}
// How long WebRTC gets on its own before HLS is started as well
const WEB_RTC_FIRST_TIMEOUT = 12000;
@customElement("ha-camera-stream")
export class HaCameraStream extends LitElement {
@@ -78,13 +82,25 @@ 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;
// WebRTC worked before for this camera, so HLS is not started with it
@state() private _webRtcFirst = false;
@query("ha-web-rtc-player") private _webRtcPlayer?: HaWebRtcPlayer;
private _visibleStreamType?: Stream["type"];
private _visibleStreamType?: CameraStreamType;
private _webRtcFirstTimeout?: number;
private _capabilitiesRequest = 0;
private _streamsStartedAt = 0;
// If WebRTC loaded fast enough to be worth waiting for without HLS
private _webRtcLoadedInTime?: boolean;
private _thumbnailApi = memoizeOne(
(
@@ -132,11 +148,16 @@ export class HaCameraStream extends LitElement {
public connectedCallback() {
super.connectedCallback();
this._connected = true;
if (this.hasUpdated && this._capabilities && this.stateObj) {
// The players start again when reattached, so their results do as well
this._startStreams(this.stateObj.entity_id, this._capabilities);
}
}
public disconnectedCallback() {
super.disconnectedCallback();
this._connected = false;
this._stopWebRtcFirst();
}
protected updated(changedProps: PropertyValues<this>): void {
@@ -146,13 +167,34 @@ 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 });
}
this._rememberWebRtc(streams);
}
/**
* Remembers per camera if WebRTC is the stream that ends up playing in this
* browser, to start it without HLS the next time.
*/
private _rememberWebRtc(streams: CameraStream[]) {
if (
!this.stateObj ||
!this._webRtcStreams ||
(this._capabilities?.frontend_stream_types.length ?? 0) < 2 ||
streams.length !== 1
) {
// No choice between streams, or the choice is not final yet
return;
}
setWebRtcWorked(
this.stateObj.entity_id,
streams[0].type === STREAM_TYPE_WEB_RTC &&
this._webRtcLoadedInTime === true
);
}
protected render() {
@@ -167,7 +209,7 @@ export class HaCameraStream extends LitElement {
)}`;
}
private _renderStream(stream: Stream) {
private _renderStream(stream: CameraStream) {
if (!this.stateObj) {
return nothing;
}
@@ -230,14 +272,14 @@ export class HaCameraStream extends LitElement {
this._capabilities?.frontend_stream_types,
this._hlsStreams,
this._webRtcStreams,
this.muted
this._webRtcFirst
);
}
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 +287,62 @@ export class HaCameraStream extends LitElement {
};
return;
}
this._capabilities = await fetchCameraCapabilities(
this._api,
this.stateObj!.entity_id
);
const entityId = this.stateObj!.entity_id;
const capabilities = await fetchCameraCapabilities(this._api, entityId);
if (request !== this._capabilitiesRequest) {
return;
}
if (this.isConnected) {
// Otherwise started when connected
this._startStreams(entityId, capabilities);
}
this._capabilities = capabilities;
}
private _resetStreams() {
this._hlsStreams = undefined;
this._webRtcStreams = undefined;
this._webRtcLoadedInTime = undefined;
this._stopWebRtcFirst();
}
private _startStreams(entityId: string, capabilities: CameraCapabilities) {
this._resetStreams();
this._streamsStartedAt = performance.now();
if (
capabilities.frontend_stream_types.length > 1 &&
hasWebRtcWorked(entityId)
) {
this._webRtcFirst = true;
this._webRtcFirstTimeout = window.setTimeout(
this._webRtcFirstTimedOut,
WEB_RTC_FIRST_TIMEOUT
);
}
}
private _webRtcFirstTimedOut = () => {
if (document.hidden) {
// A player started in the background is not stopped again, so wait
this._webRtcFirstTimeout = window.setTimeout(
this._webRtcFirstTimedOut,
WEB_RTC_FIRST_TIMEOUT
);
return;
}
this._webRtcFirstTimeout = undefined;
// Start HLS as well, WebRTC keeps connecting and takes over when it loads
this._webRtcFirst = false;
if (this.stateObj) {
// Don't wait for it the next time, also when it never reports a result
setWebRtcWorked(this.stateObj.entity_id, false);
}
};
private _stopWebRtcFirst() {
clearTimeout(this._webRtcFirstTimeout);
this._webRtcFirstTimeout = undefined;
this._webRtcFirst = false;
}
private async _getPosterUrl(): Promise<void> {
@@ -269,89 +363,46 @@ 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"]>) {
clearTimeout(this._webRtcFirstTimeout);
this._webRtcFirstTimeout = undefined;
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;
}
if (ev.detail.hasVideo && this._webRtcLoadedInTime === undefined) {
this._webRtcLoadedInTime =
performance.now() - this._streamsStartedAt <= WEB_RTC_FIRST_TIMEOUT;
}
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,
webRtcFirst?: boolean
): 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,
webRtcFirst,
});
}
);
+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
@@ -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;
@@ -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;
+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`
+152 -15
View File
@@ -9,6 +9,12 @@ import { consume } from "../common/decorators/consume";
import { consumeLocalize } from "../common/decorators/consume-context-entry";
import { fireEvent } from "../common/dom/fire_event";
import type { LocalizeFunc } from "../common/translations/localize";
import { getIcePath } from "../common/webrtc/get-ice-path";
import type { IcePath } from "../common/webrtc/limit-ice-servers";
import {
limitIceServers,
limitIceServersToPath,
} from "../common/webrtc/limit-ice-servers";
import {
addWebRtcCandidate,
fetchWebRtcClientConfiguration,
@@ -24,8 +30,21 @@ 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 or never got its connection is started again
const MAX_RETRIES = 2;
// 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 +118,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 +129,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);
@@ -167,8 +192,16 @@ export class HaWebRtcPlayer extends LitElement {
// Renegotiating the existing connection is not supported by all providers
// (go2rtc answers with a new DTLS fingerprint, which Firefox rejects), so
// start a new connection and session with the microphone track instead.
// The new connection only looks for the path the running stream uses
const icePath = this._peerConnection
? await getIcePath(this._peerConnection)
: undefined;
if (request !== this._microphoneRequest || !this.isConnected) {
track.stop();
return;
}
this._captureReconnectFrame();
await this._startWebRtc(track);
await this._startWebRtc(track, icePath);
}
private _captureReconnectFrame() {
@@ -197,6 +230,17 @@ export class HaWebRtcPlayer extends LitElement {
private _cleanUpCount = 0;
private _connectTimeout?: number;
private _retryTimeout?: number;
private _retryMicrophoneTrack?: MediaStreamTrack;
private _retries = 0;
// The stream only loaded with all ICE servers, so keep using them
private _allIceServers = false;
private _handleVisibilityChange = () => {
if (document.pictureInPictureElement) {
// video is playing in picture-in-picture mode, don't do anything
@@ -212,6 +256,7 @@ export class HaWebRtcPlayer extends LitElement {
clearTimeout(this._hiddenCleanupTimeout);
this._hiddenCleanupTimeout = undefined;
} else {
this._retries = 0;
this._startWebRtc();
}
};
@@ -253,6 +298,7 @@ export class HaWebRtcPlayer extends LitElement {
public override connectedCallback() {
super.connectedCallback();
if (this.hasUpdated && this.entityid) {
this._retries = 0;
this._startWebRtc();
}
document.addEventListener("visibilitychange", this._handleVisibilityChange);
@@ -274,11 +320,14 @@ export class HaWebRtcPlayer extends LitElement {
if (!changedProperties.has("entityid")) {
return;
}
this._retries = 0;
this._allIceServers = false;
this._startWebRtc();
}
private async _startWebRtc(
localReturnAudioTrack?: MediaStreamTrack
localReturnAudioTrack?: MediaStreamTrack,
icePath?: IcePath
): Promise<void> {
this._cleanUp();
@@ -333,9 +382,21 @@ export class HaWebRtcPlayer extends LitElement {
this._logEvent("end clientConfig", this._clientConfig);
this._peerConnection = new RTCPeerConnection(
this._clientConfig.configuration
);
const { iceServers } = this._clientConfig.configuration;
const allIceServers = this._allIceServers || this._retries >= MAX_RETRIES;
this._peerConnection = new RTCPeerConnection({
...this._clientConfig.configuration,
// The last attempt uses all ICE servers, in case a removed URL is the
// only one that works in this network
// A known relay is taken from all of them. A direct path is not, as
// the URL it needs can be one that is removed.
iceServers:
(icePath &&
(icePath.relayUrl || !allIceServers) &&
limitIceServersToPath(iceServers, icePath)) ||
(allIceServers ? iceServers : limitIceServers(iceServers)),
});
if (this._clientConfig.dataChannel) {
// Some cameras (such as nest) require a data channel to establish a stream
@@ -415,7 +476,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 +484,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 +506,58 @@ 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 ||
// 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 +580,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 +609,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 +702,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 +719,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 +739,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 +799,10 @@ export class HaWebRtcPlayer extends LitElement {
this._logEvent("loadedData", data);
this._stopTimer();
if (this._retries >= MAX_RETRIES) {
this._allIceServers = true;
}
this._retries = 0;
// 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;
}
`,
];
+13 -11
View File
@@ -84,19 +84,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 {
@@ -125,11 +125,11 @@ 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 {
protected override _renderControl(content: TemplateResult): TemplateResult {
if (!this.interactive) {
return html`<div part="base" class="base" id="item">${inner}</div>`;
return content;
}
return super._renderBase(inner);
return super._renderControl(content);
}
// Always reserve the graphic, so titles line up across rows
@@ -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
+115
View File
@@ -0,0 +1,115 @@
import { addRecent } from "../common/array/add-recent";
import { getStorage } from "../common/decorators/storage";
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;
/** Only start WebRTC and keep HLS off until WebRTC fails or takes too long. */
webRtcFirst?: boolean;
}
/**
* Decides which players are rendered and which one is shown. Hidden players
* are still loading in the background.
*/
export const selectCameraStreams = ({
supportedTypes,
hlsStreams,
webRtcStreams,
webRtcFirst,
}: 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 || (webRtcFirst && !hlsStreams)) {
return [{ type: STREAM_TYPE_WEB_RTC, visible: true }];
}
return [
{ type: STREAM_TYPE_HLS, visible: true },
{ type: STREAM_TYPE_WEB_RTC, visible: false },
];
};
const WEB_RTC_ENTITIES_KEY = "cameraWebRtcEntities";
const MAX_WEB_RTC_ENTITIES = 100;
const getWebRtcEntities = (): string[] => {
const entities = getStorage().getValue<string[]>(WEB_RTC_ENTITIES_KEY);
return Array.isArray(entities) ? entities : [];
};
/**
* If WebRTC was the stream that ended up playing for this camera in this
* browser. Stored per browser, as it depends on the network of the client.
*/
export const hasWebRtcWorked = (entityId: string): boolean =>
getWebRtcEntities().includes(entityId);
export const setWebRtcWorked = (entityId: string, worked: boolean): void => {
const entities = getWebRtcEntities();
if (entities.includes(entityId) === worked) {
return;
}
getStorage().setValue(
WEB_RTC_ENTITIES_KEY,
worked
? addRecent(entities, entityId, MAX_WEB_RTC_ENTITIES)
: entities.filter((entity) => entity !== entityId)
);
};
+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;
}
}
+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[];
+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",
@@ -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) {
+39 -2
View File
@@ -45,15 +45,42 @@
}
#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-wrap: wrap;
justify-content: center;
margin: -4px 16px;
}
#ha-launch-screen .ha-launch-screen-actions button {
margin: 4px;
padding: 8px 16px;
border: 1px solid transparent;
border-radius: 18px;
background: none;
color: #009ac7;
font: inherit;
font-size: 14px;
font-weight: 500;
cursor: pointer;
}
#ha-launch-screen .ha-launch-screen-actions .ha-launch-screen-continue {
border-color: currentColor;
}
.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 +100,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
+77 -17
View File
@@ -65,23 +65,83 @@
</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);
function addButton(label, value) {
var button = document.createElement("button");
button.type = "button";
button.textContent = label;
button.addEventListener("click", function () {
try {
localStorage.setItem(ACCEPTED_KEY, value());
} catch (_err) {
// Continue this time only.
}
box.removeChild(actions);
loadApp();
});
actions.appendChild(button);
return button;
}
addButton("Continue anyway", function () {
return String(Date.now());
}).className = "ha-launch-screen-continue";
if (acceptedAt) {
addButton("Don't show again", function () {
return "always";
});
}
})();
}
</script>
+4
View File
@@ -155,6 +155,10 @@ export const DirtyStateProviderMixin =
protected updated(changedProperties: PropertyValues<this>): void {
super.updated(changedProperties);
// A late fetch can still update a removed provider
if (!this.isConnected) {
return;
}
connectedDirtyStateProviders.set(this, this._blocksReload);
publishGlobalDirtyState();
}
+166 -4
View File
@@ -1,9 +1,171 @@
import { customElement } from "lit/decorators";
import { HaHomeSummaryPanelBase } from "../home/ha-home-summary-panel-base";
import type { CSSResultGroup, PropertyValues } from "lit";
import { LitElement, css, html, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { debounce } from "../../common/util/debounce";
import { deepEqual } from "../../common/util/deep-equal";
import type { LovelaceStrategyViewConfig } from "../../data/lovelace/config/view";
import { ChildPanelReady } from "../../layouts/panel-ready";
import { haStyle } from "../../resources/styles";
import type { HomeAssistant } from "../../types";
import { generateLovelaceViewStrategy } from "../lovelace/strategies/get-strategy";
import type { Lovelace } from "../lovelace/types";
import "../lovelace/views/hui-view";
import "../lovelace/views/hui-view-background";
import "../lovelace/views/hui-view-container";
import "../../components/ha-top-app-bar-fixed";
const CLIMATE_LOVELACE_VIEW_CONFIG: LovelaceStrategyViewConfig = {
strategy: {
type: "climate",
},
};
@customElement("ha-panel-climate")
class PanelClimate extends HaHomeSummaryPanelBase<"climate"> {
protected readonly _systemDataKey = "climate";
class PanelClimate extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ type: Boolean, reflect: true }) public narrow = false;
@state() private _viewIndex = 0;
@state() private _lovelace?: Lovelace;
@state() private _searchParams = new URLSearchParams(window.location.search);
private _childPanelReady?: ChildPanelReady;
public willUpdate(changedProps: PropertyValues<this>) {
super.willUpdate(changedProps);
// Initial setup
if (!this.hasUpdated) {
this._setup();
return;
}
if (!changedProps.has("hass")) {
return;
}
const oldHass = changedProps.get("hass") as this["hass"];
if (oldHass && oldHass.localize !== this.hass.localize) {
this._setLovelace();
return;
}
if (oldHass && this.hass) {
// If the entity registry changed, ask the user if they want to refresh the config
if (
oldHass.entities !== this.hass.entities ||
oldHass.devices !== this.hass.devices ||
oldHass.areas !== this.hass.areas ||
oldHass.floors !== this.hass.floors ||
oldHass.panels !== this.hass.panels
) {
if (this.hass.config.state === "RUNNING") {
this._debounceRegistriesChanged();
return;
}
}
// If ha started, refresh the config
if (
this.hass.config.state === "RUNNING" &&
oldHass.config.state !== "RUNNING"
) {
this._setLovelace();
}
}
}
private async _setup() {
await this.hass.loadFragmentTranslation("lovelace");
this._setLovelace();
}
private _debounceRegistriesChanged = debounce(
() => this._registriesChanged(),
200
);
private _registriesChanged = async () => {
this._setLovelace();
};
protected render() {
return html`
<ha-top-app-bar-fixed
.narrow=${this.narrow}
.backButton=${this._searchParams.has("historyBack")}
>
<div slot="title">${this.hass.localize("panel.climate")}</div>
${
this._lovelace
? html`
<hui-view-container .hass=${this.hass}>
<hui-view-background .hass=${this.hass}>
</hui-view-background>
<hui-view
.hass=${this.hass}
.narrow=${this.narrow}
.lovelace=${this._lovelace}
.index=${this._viewIndex}
></hui-view
></hui-view-container>
`
: nothing
}
</ha-top-app-bar-fixed>
`;
}
private async _setLovelace() {
const viewConfig = await generateLovelaceViewStrategy(
CLIMATE_LOVELACE_VIEW_CONFIG,
this.hass
);
const config = { views: [viewConfig] };
const rawConfig = { views: [CLIMATE_LOVELACE_VIEW_CONFIG] };
if (deepEqual(config, this._lovelace?.config)) {
return;
}
this._childPanelReady ??= new ChildPanelReady(this);
this._lovelace = {
config: config,
rawConfig: rawConfig,
editMode: false,
urlPath: "climate",
mode: "generated",
locale: this.hass.locale,
enableFullEditMode: () => undefined,
saveConfig: async () => undefined,
deleteConfig: async () => undefined,
setEditMode: () => undefined,
showToast: () => undefined,
};
}
static get styles(): CSSResultGroup {
return [
haStyle,
css`
:host {
user-select: none;
}
hui-view-container {
position: relative;
display: flex;
min-height: 100vh;
box-sizing: border-box;
}
hui-view {
flex: 1 1 100%;
max-width: 100%;
}
`,
];
}
}
declare global {
@@ -2,10 +2,9 @@ import { ReactiveElement } from "lit";
import { customElement } from "lit/decorators";
import { getAreasFloorHierarchy } from "../../../common/areas/areas-floor-hierarchy";
import {
findFilteredEntities,
findEntities,
generateEntityFilter,
type EntityFilter,
type EntityFilterSources,
} from "../../../common/entity/entity_filter";
import { floorDefaultIcon } from "../../../components/ha-floor-icon";
import type { LovelaceCardConfig } from "../../../data/lovelace/config/card";
@@ -13,15 +12,9 @@ import type { LovelaceSectionRawConfig } from "../../../data/lovelace/config/sec
import type { LovelaceViewConfig } from "../../../data/lovelace/config/view";
import type { HomeAssistant } from "../../../types";
import { computeAreaTileCardConfig } from "../../lovelace/strategies/areas/helpers/areas-strategy-helper";
import {
wrapEntityVisibility,
type ComputeEntityCard,
} from "../../lovelace/strategies/helpers/entity-visibility";
export interface ClimateViewStrategyConfig {
type: "climate";
hidden_entities?: string[];
edit_visibility?: boolean;
}
export const climateEntityFilters: EntityFilter[] = [
@@ -49,53 +42,40 @@ export const climateEntityFilters: EntityFilter[] = [
},
];
export const findClimateEntities = (
sources: EntityFilterSources,
hiddenEntities?: string[]
): string[] =>
findFilteredEntities(sources, climateEntityFilters, hiddenEntities);
export const hasClimateEntities = (hass: HomeAssistant): boolean => {
const hasAreaSensor = Object.values(hass.areas).some(
(area) =>
(area.temperature_entity_id && hass.states[area.temperature_entity_id]) ||
(area.humidity_entity_id && hass.states[area.humidity_entity_id])
);
type ClimateAreaSensorKind = "temperature" | "humidity";
export const findClimateAreaSensors = (
sources: EntityFilterSources,
hiddenEntities?: string[]
): Map<string, ClimateAreaSensorKind> => {
const hidden = new Set(hiddenEntities);
const sensors = new Map<string, ClimateAreaSensorKind>();
for (const area of Object.values(sources.areas)) {
const slots = [
[area.temperature_entity_id, "temperature"],
[area.humidity_entity_id, "humidity"],
] as const;
for (const [entityId, kind] of slots) {
if (entityId && entityId in sources.states && !hidden.has(entityId)) {
sensors.set(entityId, kind);
}
}
if (hasAreaSensor) {
return true;
}
return sensors;
const entityIds = Object.keys(hass.states);
return climateEntityFilters.some((filter) =>
entityIds.some(
generateEntityFilter(
hass.states,
hass.entities,
hass.devices,
hass.areas,
hass.floors,
filter
)
)
);
};
export const hasClimateEntities = (
hass: HomeAssistant,
hiddenEntities?: string[]
): boolean =>
findClimateAreaSensors(hass, hiddenEntities).size > 0 ||
findClimateEntities(hass, hiddenEntities).length > 0;
interface ClimateCardOptions {
entities: string[];
areaSensors: Map<string, ClimateAreaSensorKind>;
computeCard: ComputeEntityCard;
}
const processAreasForClimate = (
areaIds: string[],
hass: HomeAssistant,
{ entities, areaSensors, computeCard }: ClimateCardOptions
entities: string[]
): LovelaceCardConfig[] => {
const cards: LovelaceCardConfig[] = [];
const computeTileCard = computeAreaTileCardConfig(hass, true);
for (const areaId of areaIds) {
const area = hass.areas[areaId];
@@ -116,13 +96,25 @@ const processAreasForClimate = (
// Add temperature and humidity sensors with trend graphs for areas
const temperatureEntityId = area.temperature_entity_id;
if (temperatureEntityId && areaSensors.has(temperatureEntityId)) {
areaCards.push(computeCard(temperatureEntityId));
if (temperatureEntityId && hass.states[temperatureEntityId]) {
areaCards.push({
...computeTileCard(temperatureEntityId),
name:
hass.localize("component.sensor.entity_component.temperature.name") ||
"Temperature",
features: [{ type: "trend-graph" }],
});
}
const humidityEntityId = area.humidity_entity_id;
if (humidityEntityId && areaSensors.has(humidityEntityId)) {
areaCards.push(computeCard(humidityEntityId));
if (humidityEntityId && hass.states[humidityEntityId]) {
areaCards.push({
...computeTileCard(humidityEntityId),
name:
hass.localize("component.sensor.entity_component.humidity.name") ||
"Humidity",
features: [{ type: "trend-graph" }],
});
}
// Add other climate entities
@@ -132,7 +124,18 @@ const processAreasForClimate = (
continue;
}
areaCards.push(computeCard(entityId));
const state = hass.states[entityId];
if (
state?.attributes.device_class === "temperature" ||
state?.attributes.device_class === "humidity"
) {
areaCards.push({
...computeTileCard(entityId),
features: [{ type: "trend-graph" }],
});
} else {
areaCards.push(computeTileCard(entityId));
}
}
if (areaCards.length > 0) {
@@ -156,7 +159,7 @@ const processAreasForClimate = (
const processUnassignedEntities = (
hass: HomeAssistant,
{ entities, computeCard }: ClimateCardOptions
entities: string[]
): LovelaceCardConfig[] => {
const unassignedFilter = generateEntityFilter(
hass.states,
@@ -170,9 +173,10 @@ const processUnassignedEntities = (
);
const unassignedEntities = entities.filter(unassignedFilter);
const areaCards: LovelaceCardConfig[] = [];
const computeTileCard = computeAreaTileCardConfig(hass, true);
for (const entityId of unassignedEntities) {
areaCards.push(computeCard(entityId));
areaCards.push(computeTileCard(entityId));
}
return areaCards;
@@ -181,7 +185,7 @@ const processUnassignedEntities = (
@customElement("climate-view-strategy")
export class ClimateViewStrategy extends ReactiveElement {
static async generate(
config: ClimateViewStrategyConfig,
_config: ClimateViewStrategyConfig,
hass: HomeAssistant
): Promise<LovelaceViewConfig> {
const areas = Object.values(hass.areas);
@@ -190,40 +194,20 @@ export class ClimateViewStrategy extends ReactiveElement {
const sections: LovelaceSectionRawConfig[] = [];
const hiddenEntities = new Set(config.hidden_entities);
const editVisibility = Boolean(config.edit_visibility);
const entities = findClimateEntities(
hass,
editVisibility ? undefined : config.hidden_entities
const allEntities = Object.keys(hass.states);
const climateFilters = climateEntityFilters.map((filter) =>
generateEntityFilter(
hass.states,
hass.entities,
hass.devices,
hass.areas,
hass.floors,
filter
)
);
const areaSensors = findClimateAreaSensors(
hass,
editVisibility ? undefined : config.hidden_entities
);
const computeTileCard = computeAreaTileCardConfig(hass, true);
const computeClimateCard = (entityId: string): LovelaceCardConfig => {
const tileCard = computeTileCard(entityId);
const areaSensor = areaSensors.get(entityId);
if (!areaSensor) {
return tileCard;
}
return {
...tileCard,
name:
hass.localize(
`component.sensor.entity_component.${areaSensor}.name`
) || undefined,
features: [{ type: "trend-graph" }],
};
};
const cardOptions: ClimateCardOptions = {
entities,
areaSensors,
computeCard: editVisibility
? wrapEntityVisibility(hiddenEntities, computeClimateCard)
: computeClimateCard,
};
const entities = findEntities(allEntities, climateFilters);
const floorCount =
hierarchy.floors.length + (hierarchy.areas.length ? 1 : 0);
@@ -249,7 +233,7 @@ export class ClimateViewStrategy extends ReactiveElement {
],
};
const areaCards = processAreasForClimate(areaIds, hass, cardOptions);
const areaCards = processAreasForClimate(areaIds, hass, entities);
if (areaCards.length > 0) {
section.cards!.push(...areaCards);
@@ -273,11 +257,7 @@ export class ClimateViewStrategy extends ReactiveElement {
],
};
const areaCards = processAreasForClimate(
hierarchy.areas,
hass,
cardOptions
);
const areaCards = processAreasForClimate(hierarchy.areas, hass, entities);
if (areaCards.length > 0) {
section.cards!.push(...areaCards);
@@ -286,7 +266,7 @@ export class ClimateViewStrategy extends ReactiveElement {
}
// Process unassigned entities
const unassignedCards = processUnassignedEntities(hass, cardOptions);
const unassignedCards = processUnassignedEntities(hass, entities);
if (unassignedCards.length > 0) {
const section: LovelaceSectionRawConfig = {
@@ -857,6 +857,8 @@ class HaConfigAreaPage extends LitElement {
},
this.hass.auth.data.hassUrl
)}
width="24"
height="24"
@error=${this._onImageError}
@load=${this._onImageLoad}
/>`;
@@ -1061,7 +1063,7 @@ class HaConfigAreaPage extends LitElement {
justify-content: space-around;
}
img {
.img-container img {
border-radius: var(
--ha-card-border-radius,
var(--ha-border-radius-lg)
@@ -1115,11 +1117,6 @@ class HaConfigAreaPage extends LitElement {
ha-list-item-button ha-icon-next {
color: var(--secondary-text-color);
}
img[slot="start"] {
width: 24px;
height: 24px;
object-fit: contain;
}
ha-card > *:first-child {
margin-top: -16px;
}
@@ -128,6 +128,7 @@ import "./types/ha-automation-action-wait_for_trigger";
import "./types/ha-automation-action-wait_template";
import { renderCtrlOrCmd } from "../../../../common/keyboard/ctrl-or-cmd";
import { scrollRowIntoView } from "../scroll-row-into-view";
import { AutomationPathController } from "../automation-path";
export interface ActionElement extends LitElement {
action: Action;
@@ -182,6 +183,8 @@ export default class HaAutomationActionRow extends LitElement {
@property({ type: Boolean }) public root = false;
@property({ attribute: false }) public index!: number;
@property({ type: Boolean }) public first?: boolean;
@property({ type: Boolean }) public last?: boolean;
@@ -194,6 +197,11 @@ export default class HaAutomationActionRow extends LitElement {
@property({ type: Boolean, attribute: "sort-selected" })
public sortSelected = false;
protected readonly pathController = new AutomationPathController(this, {
segment: () => [this.index],
row: () => (this.optionsInSidebar ? this : undefined),
});
@storage({
key: "automationClipboard",
state: false,
@@ -43,6 +43,7 @@ export class HaChooseAction extends LitElement implements ActionElement {
return html`
<ha-automation-option
path-key="choose"
.options=${options}
.disabled=${this.disabled}
@value-changed=${this._optionsChanged}
@@ -46,6 +46,7 @@ export class HaIfAction extends LitElement implements ActionElement {
)}:
</h4>
<ha-automation-condition
path-key="if"
.conditions=${action.if ?? []}
.disabled=${this.disabled}
@value-changed=${this._ifChanged}
@@ -60,6 +61,7 @@ export class HaIfAction extends LitElement implements ActionElement {
)}:
</h4>
<ha-automation-action
path-key="then"
.actions=${action.then ?? []}
.disabled=${this.disabled}
@value-changed=${this._thenChanged}
@@ -73,6 +75,7 @@ export class HaIfAction extends LitElement implements ActionElement {
)}:
</h4>
<ha-automation-action
path-key="else"
.actions=${action.else || []}
.disabled=${this.disabled}
@value-changed=${this._elseChanged}
@@ -35,6 +35,7 @@ export class HaParallelAction extends LitElement implements ActionElement {
return html`
<ha-automation-action
path-key="parallel"
.actions=${action.parallel}
.narrow=${this.narrow}
.disabled=${this.disabled}
@@ -8,6 +8,7 @@ import { haStyle } from "../../../../../resources/styles";
import type { HomeAssistant } from "../../../../../types";
import "../ha-automation-action";
import type { ActionElement } from "../ha-automation-action-row";
import { AutomationPathController } from "../../automation-path";
import { isTemplate } from "../../../../../common/string/has-template";
import "../../../../../components/ha-form/ha-form";
@@ -40,6 +41,11 @@ export class HaRepeatAction extends LitElement implements ActionElement {
@property({ type: Boolean, attribute: "indent" }) public indent = false;
// The form edits the repeat options, so its fields are keyed below "repeat"
protected readonly pathController = new AutomationPathController(this, {
segment: () => ["repeat"],
});
@query("ha-form")
private _formElement?: HaForm;
@@ -35,6 +35,7 @@ export class HaSequenceAction extends LitElement implements ActionElement {
return html`
<ha-automation-action
path-key="sequence"
.actions=${action.sequence ?? []}
.narrow=${this.narrow}
.disabled=${this.disabled}
@@ -90,6 +90,7 @@ export class HaWaitForTriggerAction
${
this.indent || (!this.inSidebar && !this.indent)
? html`<ha-automation-trigger
path-key="wait_for_trigger"
class=${!this.inSidebar && !this.indent ? "expansion-panel" : ""}
.triggers=${ensureArray(this.action.wait_for_trigger)}
.hass=${this.hass}
@@ -102,19 +102,6 @@ import type {
AutomationItemComboBoxItem,
} from "./types";
type PickerSources = Pick<
HomeAssistant,
| "states"
| "entities"
| "devices"
| "areas"
| "floors"
| "localize"
| "language"
| "locale"
| "translationMetadata"
>;
const TARGET_SEARCH_SECTIONS = [
"separator",
"entity",
@@ -218,18 +205,20 @@ export class HaAutomationAddSearch extends LitElement {
private _getDevicesMemoized = memoizeOne(
(
sources: PickerSources,
states: HomeAssistant["states"],
registries: ContextType<typeof registriesContext>,
i18n: ContextType<typeof internationalizationContext>,
configEntryLookup: Record<string, ConfigEntry>,
idPrefix: string
) =>
getDevices({
states: sources.states,
areas: sources.areas,
devices: sources.devices,
entities: sources.entities,
localize: sources.localize,
language: sources.locale.language,
translationMetadata: sources.translationMetadata,
states,
areas: registries.areas,
devices: registries.devices,
entities: registries.entities,
localize: i18n.localize,
language: i18n.locale.language,
translationMetadata: i18n.translationMetadata,
configEntryLookup,
idPrefix,
})
@@ -238,8 +227,23 @@ export class HaAutomationAddSearch extends LitElement {
private _getLabelsMemoized = memoizeOne(getLabels);
private _getEntitiesMemoized = memoizeOne(
(sources: PickerSources, idPrefix: string) =>
getEntities(sources, { idPrefix })
(
states: HomeAssistant["states"],
registries: ContextType<typeof registriesContext>,
i18n: ContextType<typeof internationalizationContext>,
idPrefix: string
) =>
getEntities({
states,
entities: registries.entities,
devices: registries.devices,
areas: registries.areas,
floors: registries.floors,
localize: i18n.localize,
language: i18n.language,
translationMetadata: i18n.translationMetadata,
idPrefix,
})
);
private _getAreasAndFloorsMemoized = memoizeOne(getAreasAndFloors);
@@ -247,21 +251,6 @@ export class HaAutomationAddSearch extends LitElement {
private _formatAreaFloorId = (value: AreaFloorValue): string =>
[value.type, value.id].join(TARGET_SEPARATOR);
private _getPickerSources = memoizeOne(
(
states: HomeAssistant["states"],
registries: ContextType<typeof registriesContext>,
i18n: ContextType<typeof internationalizationContext>
): PickerSources => ({
states,
...registries,
localize: i18n.localize,
language: i18n.language,
locale: i18n.locale,
translationMetadata: i18n.translationMetadata,
})
);
private _removeKeyboardShortcuts?: () => void;
protected willUpdate(changedProps: PropertyValues<this>) {
@@ -683,7 +672,9 @@ export class HaAutomationAddSearch extends LitElement {
if (!selectedSection || selectedSection === "entity") {
let entityItems = this._getEntitiesMemoized(
this._getPickerSources(this._states, this._registries, this._i18n),
this._states,
this._registries,
this._i18n,
`entity${TARGET_SEPARATOR}`
);
@@ -720,7 +711,9 @@ export class HaAutomationAddSearch extends LitElement {
if (!selectedSection || selectedSection === "device") {
let deviceItems = this._getDevicesMemoized(
this._getPickerSources(this._states, this._registries, this._i18n),
this._states,
this._registries,
this._i18n,
configEntryLookup,
`device${TARGET_SEPARATOR}`
);
Loaded 100 of 285 files, more files were not shown because too many files have changed in this diff. Show more