Compare commits

...

84 Commits

Author SHA1 Message Date
Marcel van der Veldt f5c6420fbc Search a media player's own library from the media browser (#53402)
* Search a media player's own library from the media browser

The media browser search only ever asked the media sources, so searching
inside a media player's own library (Music Assistant, Sonos, Squeezebox,
Jellyfin) failed. Ask the entity instead when the current item belongs to
it, the same way browsing already does.

* Update src/data/media_source.ts

* Remove blank line in media_source.ts

Removed unnecessary blank line at the top of media_source.ts

---------

Co-authored-by: Bram Kragten <mail@bramkragten.nl>
Co-authored-by: Simon Lamon <32477463+silamon@users.noreply.github.com>
2026-07-30 15:44:28 +00:00
karwosts 71ec76185f Fix schedule editor dirty tracking (#53401) 2026-07-30 17:37:18 +02:00
Petar Petrov f08ab2f331 Show which energy power statistic is missing (#53404) 2026-07-30 16:41:28 +02:00
Petar Petrov 77b0b9b5b8 Fix numeric input feature editor showing the wrong default style (#53398) 2026-07-30 16:37:16 +02:00
Aidan Timson 4fcc1adf3a Managed background builds and shared process management (#53374)
* Add managed modern production builds

* Harden managed build process controls

* Update managed process testing guidance

* Harden managed process file operations

* Share generated output process ownership

* Clean stale build state on status

* Address managed build review feedback

Co-authored-by: timmo001 <28114703+timmo001@users.noreply.github.com>

* Use single compress task

* 1

Co-authored-by: Petar Petrov <MindFreeze@users.noreply.github.com>

* Forward SIGHUP to foreground processes

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: Petar Petrov <MindFreeze@users.noreply.github.com>
2026-07-30 13:25:10 +03:00
renovate[bot] 9699851c26 Update dependency jsdom to v30 (#53395)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-07-30 12:15:24 +02:00
Raman Gupta a318593c9f Add live updates to Z-Wave JS node config parameters (#53013)
* Fix Z-Wave JS config parameter value type

Type ZWaveJSNodeConfigParam.value as number | null instead of any,
matching what the backend sends. This surfaced two comparison bugs:

- The enumerated picker no-op check compared the stored number against
  the picker's string value, so it never matched and re-selecting the
  current option re-sent the command to the device.
- The numeric input guard coerced null (unknown value) to 0, so
  entering 0 for a parameter with an unknown value was ignored.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Treat empty numeric input as invalid instead of 0

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Add live updates to Z-Wave JS node config parameters

Subscribe to config parameter value updates so the page reflects
changes made outside the UI (Z-Wave JS UI, another browser tab, or
the device itself) and so queued changes to sleeping nodes resolve
to a success message when the node wakes and applies them.

- Manage the subscription across connect/disconnect and device
  navigation, deriving deviceId reactively from the route
- Show success results briefly, then clear them automatically
- Clear stale error results when the parameter changes externally
- Discard fetch responses that arrive after navigating to another node

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Address review comments

- Use slice() instead of deprecated substr()
- Clear pending result timeouts when navigating to another node
- Handle subscription promise rejections so an older backend without
  the subscribe command degrades gracefully instead of logging
  unhandled rejections

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-30 12:14:26 +02:00
Aidan Timson 749dd180ae Launch screen tweaks (#53380)
* Polish the launch screen

- Use the system sans-serif font so the launch screen no longer blocks on
  loading Roboto
- Reduce the gap between the logo and the loading text
- Dim the loading text to 66% opacity
- Match the OHF logo variant to the applied theme instead of the system
  color scheme
- Make the launch screen text and buttons unselectable

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Drop unit test

* Use classMap

* Drop view transition for css transition to avoid flash

---------

Co-authored-by: marcinbauer85 <marcinbauer85@gmail.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 12:13:49 +02:00
Maarten Lakerveld 247780e4c8 Tools > Template use WA Split Panel (#53021)
* Add Web Awesome Split Panel component

* Use SplitPanel for template page. Add vertical/horizontal option. Cleanup, use flexbox.

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
2026-07-30 10:39:41 +02:00
Paul Bottein 7991090313 Allow area card name to wrap to two lines in vertical layout (#53370)
* Allow area card name to wrap to two lines in vertical layout

* Align vertical tile icons in grid sections

* Cut single big word
2026-07-30 10:03:43 +03:00
Aidan Timson 2efa0bd86d Prefetch home dashboard resources concurrently (#53373)
Load Home dashboard resources concurrently
2026-07-30 10:01:13 +03:00
Aidan Timson 0fad4f0097 Remove empty items on narrow data table secondary lines (#53382)
* Skip empty cells on the narrow data table secondary line

In narrow mode the main column renders every other visible column on a
secondary line, but the dot separator was inserted based on the column
index instead of whether the cell rendered anything. A row whose extra
columns are all empty showed a secondary line consisting only of dots,
and an empty column between two filled ones produced a double dot.

Filter empty cells out before joining, and return `nothing` instead of
`html`${nothing}`` for missing timestamps so those cells are detectably
empty too.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Extract / clarify data table column visibility

* Fix unbound method and add all-empty secondary line test

Co-authored-by: timmo001 <28114703+timmo001@users.noreply.github.com>

* Fix prettier formatting in ha-data-table test

Co-authored-by: timmo001 <28114703+timmo001@users.noreply.github.com>

---------

Co-authored-by: marcinbauer85 <marcinbauer85@gmail.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
2026-07-30 07:07:43 +02:00
Marcin Bauer 2b90c9a628 Show the automation state on mobile in the automations table (#53375)
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-29 19:48:51 +02:00
renovate[bot] f3848df19d Update dependency globals to v17.8.0 (#53387)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-07-29 19:48:26 +02:00
renovate[bot] e2a91c358d Update Node.js to v24.18.1 (#53386)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-07-29 19:48:22 +02:00
Paul Bottein 8793cb58b9 Show integration logo and name in the replace device dialog (#53384) 2026-07-29 19:24:39 +02:00
Bram Kragten 2442bf662a Strip empty strings from http config (#53377) 2026-07-29 15:33:02 +02:00
Bram Kragten e42be9f4d6 Bumped version to 20260729.0 2026-07-29 14:52:39 +02:00
Bram Kragten a096f8383d Merge branch 'rc' into dev 2026-07-29 14:50:42 +02:00
Simon Lamon a3e59d64fc Pass browserlist targets to lightningcss (#53128)
* Pass browserlist targets to lightningcss

* Fix pass-browser-list branch (#53243)

Add failsafe for empty browserlist. Add comment to describe prefix issue in literals. Use default browserlistEnv naming convention. SW uses more efficient browserlist only targeting browsers that support sw.

* Increase JS budgets

---------

Co-authored-by: Maarten Lakerveld <maarten.lakerveld@openhomefoundation.org>
2026-07-29 14:33:33 +02:00
Petar Petrov e90e8169cd Fix save button state in the energy battery and grid dialogs (#53371)
Editing only the battery capacity left the dirty state untouched, so
Save stayed disabled and the change could not be saved. Add the missing
dirty state update.

The Save button also read power config validity off the
ha-energy-power-config child element. Lit evaluates every binding in
render() before committing any of them, so disabled was computed from
the child's pre-update state and lagged one render behind. Derive
validity from the dialog's own power type and config instead, through a
shared helper extracted alongside the other pure power config helpers,
and cover that helper with unit tests.

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: bramkragten <5662298+bramkragten@users.noreply.github.com>
2026-07-29 12:29:48 +00:00
Petar Petrov 959a798b5c Reject non same-origin back paths before using them as links (#53376) 2026-07-29 14:13:47 +02:00
Petar Petrov 9c117d446e Show the generated power helper sensor in energy power settings (#53368) 2026-07-29 12:59:31 +02:00
Petar Petrov 11913292b1 Fix required field validation being skipped in the app bundle (#53359)
* Fix required field validation being skipped in the app bundle

* Route blur and invalid handling through the guarded validity check
2026-07-29 13:51:35 +03:00
Bram Kragten 52f2b5abed Add link on the device page to shared hardware devices (#53363) 2026-07-29 09:37:09 +02:00
Petar Petrov e658113ef4 Fix gauge value missing when the card is revealed by a visibility condition (#53321)
* Fix gauge value text missing when revealed by a visibility condition

* Use a resize controller to retry the measurement

* Give the gauge host a block display so its size is observed
2026-07-29 07:10:32 +02:00
Maarten Lakerveld c35e316ef7 Conditions/triggers/actions use the same logic for automation picker sorting (#53136)
* Conditions/triggers/actions use the same logic for automation picker sorting

* Move all Integrations to a separate integrations group.
2026-07-29 06:54:12 +02:00
Paul Bottein 946c40d8a3 Add entity ID format setting for new entities (#53268) 2026-07-28 22:03:59 +02:00
Paulus Schoutsen 7ca7c08090 Add horizontal padding to vertical tile content (#53277)
* Add horizontal padding to vertical tile content

The vertical tile layout overrode the content padding to 10px 0,
removing the horizontal padding entirely. This allowed the tile name
in vertical cards (e.g. area cards on the default dashboard) to
truncate flush against the card edge. Keep the same 10px side padding
the horizontal layout uses.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011ciaPX4SwWZp1mB2mtvbqd

* Reduce vertical tile side padding to 4px

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011ciaPX4SwWZp1mB2mtvbqd

* Use 2px side padding for vertical tile content

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011ciaPX4SwWZp1mB2mtvbqd

* Apply suggestion from @piitaya

Co-authored-by: Paul Bottein <paul.bottein@gmail.com>

---------

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Simon Lamon <32477463+silamon@users.noreply.github.com>
Co-authored-by: Paul Bottein <paul.bottein@gmail.com>
2026-07-28 18:38:09 +00:00
Aidan Timson 73865161e7 Move demo guidance into a skill (#53355)
Move demo agent guidance into a skill
2026-07-28 20:29:09 +02:00
Petar Petrov f9d1059ff6 Fire haptic feedback when pressing the button card feature (#53361) 2026-07-28 18:41:34 +02:00
Krisjanis Lejejs 691e77f948 Update cloud account page thank you text (#53354) 2026-07-28 15:54:11 +01:00
Bram Kragten 6667d0e57e Surface replaced composite devices in device and target pickers (#53169)
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-28 15:59:17 +02:00
Aidan Timson 70ec53ff43 Wait for initial panel readiness (#53351)
* Wait for initial panel readiness

* Keep panel readiness internal
2026-07-28 14:03:04 +01:00
Petar Petrov e34c1b9857 Fix launch screen styles breaking the index template (#53347) 2026-07-28 08:50:24 +01:00
Marcin Bauer 06580f3dbc Increase loading logomark pulse scale (#53349)
Bump the dot pulse in the launch screen logomark from scale(1.2) to
scale(1.3) so the animation reads more clearly.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-28 07:29:52 +00:00
Petar Petrov fe8f32423f Fix flaky demo e2e sidebar helper on narrow viewports (#53348)
Fix race in demo e2e sidebar helper on narrow viewports

openDemoSidebar decided whether the sidebar was behind a modal drawer by
sampling ha-menu-button visibility. That element renders nothing until its
Lit contexts resolve, so right after load it reads as hidden on the narrow
layout too. The helper then took the wide-layout branch, never opened the
drawer, and the following panel assertion waited out its timeout on an
attached-but-hidden #sidebar-panel-map.

Read the layout from ha-drawer's type instead, which home-assistant-main
sets while committing its first render, and wait for the drawer to report
open after clicking the menu button.
2026-07-28 09:17:22 +02:00
Paulus Schoutsen b14343b831 Make the demo a Home Assistant Green running Home Assistant OS (#53326)
* Make the apps panel work in the demo

The apps panel is gated on the hassio component, so the demo redirected to
the "what is an app?" explainer instead of showing an app store. Enable
hassio and mock the supervisor endpoints behind it, with Music Assistant
and ESPHome Device Builder installed and running.

Enabling hassio also unlocks the supervisor parts of the system, storage,
hardware and network pages, so those endpoints are mocked too, otherwise
they render error banners.

Add a callApiRaw mock to provideHass so the app log tab can stream logs;
it wraps the existing REST mocks in a Response.

HassioAddonDetails.rating was typed "1-8" but is a number 1-8, as its only
consumers (RATING_ICON lookup, Number() comparisons) and the equivalent
field on StoreAddonDetails already assume.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T4Xz1qN5chrxgPnC5V4PGz

* Show Home Assistant Green on OS 18.2 in the demo, drop app changelogs

Present the demo as a Home Assistant Green running Home Assistant OS 18.2,
and align the supervisor version and channel with it instead of the stale
2021 dev build the unused stub carried.

BOARD_NAMES had no entry for green, so the hardware page fell back to
"Generic hardware" with a raw "green" board id.

Mock /info as well; it is fetched by the about page once hassio is loaded.

Drop the demo changelogs. The app info page rendered the changelog link for
every installed app, ignoring the changelog flag the supervisor sends to say
whether the app ships one, so gate the link on it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T4Xz1qN5chrxgPnC5V4PGz

* Mock the hardware integration in the demo instead of BOARD_NAMES

The hardware page only gets a board picture and documentation link from the
hardware integration's info; the BOARD_NAMES map is a bare-name fallback for
when that integration is not loaded, which is why the page rendered a name
with no image. Mock hardware/info with what homeassistant_green reports, so
the board name, brands image and documentation link resolve the way they do
on a real Green, and revert the BOARD_NAMES entry added for the fallback.

Also mock hardware/subscribe_system_status so the processor and memory
charts have data, rounded the way the integration rounds psutil's values.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T4Xz1qN5chrxgPnC5V4PGz

* Inline the changelog link and drop the demo component comment

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T4Xz1qN5chrxgPnC5V4PGz

* Stream demo system status every second

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T4Xz1qN5chrxgPnC5V4PGz

* Keep enabling hassio from the supervisor stub

The gallery calls mockHassioSupervisor from five pages and relies on it to
load the hassio component; dropping the push left the app selector and the
automation editors' hassio-gated pickers with nothing to fetch. Restore it,
guarded, since the demo now lists hassio in its own components.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T4Xz1qN5chrxgPnC5V4PGz

* Note in the demo guide that the gallery imports its stubs

Twenty-five gallery pages import from demo/src/stubs, so a change that looks
fine in the demo can break the gallery. Also record why the demo is not a
reliable check: the gallery sees in-place hass.config mutations, the demo
does not.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T4Xz1qN5chrxgPnC5V4PGz

* Guard the store changelog link too

Per review: the not-installed branch should honour the same changelog flag.
The supervisor reports it for store apps as well, alongside documentation,
so declare it on StoreAddonDetails.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T4Xz1qN5chrxgPnC5V4PGz

---------

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Petar Petrov <MindFreeze@users.noreply.github.com>
2026-07-28 09:42:58 +03:00
Bram Kragten 2dabc28e7f Bumped version to 20260624.6 2026-07-17 16:42:23 +02:00
Paul Bottein fd70fcef2f Fix history graph editor crash on unavailable entity (#53141) 2026-07-17 16:42:08 +02:00
Paul Bottein 9bf46415f1 Fix save button not enabling on whitespace-only entity name changes (#53138) 2026-07-17 16:42:07 +02:00
Jan-Philipp Benecke c552af4c12 Remove custom app header text color in zha group page (#53101) 2026-07-17 16:42:06 +02:00
Bram Kragten c573669786 Bumped version to 20260624.5 2026-07-10 14:57:54 +02:00
Petar Petrov 4e1ccab159 Restore per-second updating of ha-relative-time (#53070)
Restore per-second ticking for ha-relative-time
2026-07-10 14:57:14 +02:00
Petar Petrov 347d63bce9 Wrap device automation picker option labels instead of truncating (#53065) 2026-07-10 14:57:13 +02:00
Petar Petrov a1d7b31732 Fix cloning when drag-sorting a section to the last position (#53050) 2026-07-10 14:57:12 +02:00
Petar Petrov 4d61df7ed7 Fix target picker "Add target" popover opening off to the right (#53041) 2026-07-10 14:57:11 +02:00
Petar Petrov 991dda70fc Only show solar/battery legend in energy usage graph when configured (#53030) 2026-07-10 14:57:10 +02:00
Petar Petrov 58f5480ca3 Fix escape key discarding unsaved changes in dashboard editor (#53029) 2026-07-10 14:57:09 +02:00
Petar Petrov 1f20bc0749 Fix number/input_number slider clipped in entities card at certain zooms (#53017) 2026-07-10 14:57:09 +02:00
Petar Petrov 0846cb3be3 Fix tile trend graph rendering blank when shown by a visibility condition (#53016) 2026-07-10 14:57:08 +02:00
Petar Petrov 33afb77367 Fix My redirect for AI tasks to /config/ai-tasks (#53015) 2026-07-10 14:57:07 +02:00
Petar Petrov b2f85e2595 Fix history/logbook entity selection leaking across browser tabs (#53014)
Keep history and logbook entity selection independent per tab
2026-07-10 14:57:06 +02:00
Simon Lamon 09956a7d9c No need to show rename when alias is already provided (#53003)
* No need to show rename when alias is already provided

* Update src/panels/config/script/ha-script-editor.ts

* Apply suggestion from @MindFreeze

---------

Co-authored-by: Petar Petrov <MindFreeze@users.noreply.github.com>
2026-07-10 14:57:05 +02:00
Simon Lamon 8507e222f8 Add entity update tracking again in Automation and Script editor (#53002) 2026-07-10 14:57:04 +02:00
Petar Petrov 5737480398 Match Statistic card line height to Entity card (#52981) 2026-07-10 14:57:03 +02:00
Petar Petrov 44163b9ccb Fix focus loss in text selector with multiple (#52980) 2026-07-10 14:57:03 +02:00
Bram Kragten e79cd0c5b2 Bumped version to 20260624.4 2026-07-03 12:29:13 +02:00
Petar Petrov 52379b39e0 Fix My link for adding an add-on repository not doing anything (#52945) 2026-07-03 12:28:54 +02:00
Franck Nijhof 656e1bea8e Refresh the template tool documentation panel (#52941)
The About templates panel still pointed at the upstream Jinja2 docs and a
single extensions page. Rewrite the intro and link to the current templating
documentation instead: the learning guide (introduction, working with states,
debugging) and the searchable template functions reference.
2026-07-03 12:28:53 +02:00
Franck Nijhof 4ef3ed2f02 Link the config pane help icon to the dedicated docs page (#52940)
For built-in triggers, conditions, and actions, the help icon in the editor
config pane (and Developer Tools) linked to the integration page. Point it at
the dedicated page for that specific trigger/condition/action instead, e.g.
/triggers/air_quality.co2_changed. Custom integrations keep their own
documentation URL.
2026-07-03 12:28:27 +02:00
Petar Petrov fbad0ba885 Fix double bar in energy devices detail graph at start of day (#52939) 2026-07-03 12:25:06 +02:00
Petar Petrov c892691344 Add My links for infrared and radio frequency config panels (#52931) 2026-07-03 12:25:05 +02:00
Petar Petrov 811b7c7d98 Allow negative number entry in number selector on iOS (#52925)
Allow entering negative numbers in number selector on iOS
2026-07-03 12:24:37 +02:00
Bram Kragten 91dee86697 Bumped version to 20260624.3 2026-07-01 13:34:46 +02:00
Paul Bottein 9877377cb9 Show the event type in the logbook for event entities (#52863) 2026-07-01 13:34:34 +02:00
Bram Kragten 7a1c8c556f Bumped version to 20260624.2 2026-06-30 15:58:00 +02:00
Bram Kragten 2c3d8eb230 Show a warning when deprecated automation options where used and migr… (#52915)
Show a warning when deprecated automation options where used and migrated
2026-06-30 15:57:34 +02:00
Aidan Timson 67489affe7 Fix loading spinner position for more info weather forecast (#52903) 2026-06-30 15:57:33 +02:00
Paul Bottein 7fcbd8e245 Show dedicated icons for Cloud and Cast in Actvity and add tooltip (#52896) 2026-06-30 15:57:32 +02:00
Jan-Philipp Benecke 12a88231f3 Fix overflow issue in mobile automation target picker (#52883)
Fix overflow issue in mobile target picker
2026-06-30 15:57:31 +02:00
Paulus Schoutsen f66619fae6 Fix duplicate logbook entries in more info dialog after reconnect (#52880)
* Fix duplicate logbook entries after reconnect in more info dialog

When a more info dialog is left open while the app is backgrounded, the
WebSocket connection drops and reconnects on resume. The logbook stream
subscription relied on home-assistant-js-websocket's auto-resubscribe,
which replays the original subscription with its stale start_time. The
backend then resends the entire historical chunk, and ha-logbook appends
streamed events without deduplicating, so every entry was shown twice
(and a third time after another background/reconnect cycle).

Mirror the approach already used for the history stream: disable the
library's auto-resubscribe for the logbook event stream and have
ha-logbook listen for the connection "ready" event, resubscribing from a
clean state on reconnect instead of appending a replayed history chunk.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WYMwT7ZQJrjyzrNfGQyWaU

* Simplify logbook reconnect comments

---------

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Paul Bottein <paul.bottein@gmail.com>
2026-06-30 15:57:30 +02:00
Bram Kragten 43ff58010a Bumped version to 20260624.1 2026-06-25 16:16:23 +02:00
Copilot c391d571d7 Localize "(default)" label in Edit sidebar dialog (#52868)
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
2026-06-25 16:15:51 +02:00
Petar Petrov 18e15f8a99 Group Sankey flow siblings under their parent to fix segment crossovers (#52867) 2026-06-25 16:15:50 +02:00
Paul Bottein dfdd55b649 Show dash for unavailable number entity in slider row (#52866) 2026-06-25 16:15:49 +02:00
Paul Bottein bed98776c3 Fix logbook padding and margin (#52864) 2026-06-25 16:15:48 +02:00
Paul Bottein ad37f1bb58 Show action labels instead of timestamps in the logbook (#52861) 2026-06-25 16:15:47 +02:00
Franck Nijhof 2a00b0d0ec Use choose selector for legacy trigger fields (#52859)
* Use choose selector for legacy trigger fields

Replace the duration-only selector on the `for` field in the state,
numeric_state, and template triggers with a choose selector that
offers both duration and template options.

Replace the hand-rolled lower_limit/upper_limit select toggle for
above/below in the numeric_state trigger with a choose selector
that switches between a fixed number and an entity reference.

Add translation entries for the choose selector toggle button labels.

* Shorten the numeric state value toggle label

Use "Value of an entity" instead of "Numeric value of another entity" for
the numeric state trigger toggle, so it stays compact.
2026-06-25 16:15:46 +02:00
Paul Bottein 20efc35da3 Keep self-closing slashes when minifying svg`` templates (#52857)
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: Claude <noreply@anthropic.com>
2026-06-25 16:15:45 +02:00
Michael Hansen ac71b4c400 Add demo voice assistants and exposed entities (#52855) 2026-06-25 16:15:44 +02:00
Franck Nijhof b85422e652 Use the Jinja block comment for toggle-comment in templates (#52854)
The jinja2 editor mode is rendered on a YAML base, so Ctrl+/ inserted a "#"
line comment, which does nothing useful in a template. Give the jinja2
language a Jinja block comment token so toggle-comment wraps with {# #},
while the plain YAML mode keeps its # comment.
2026-06-25 16:15:43 +02:00
Paulus Schoutsen 4ff69aab8f Show supported frequencies column in radio frequency devices list (#52851)
Add a "Frequencies" column to the radio frequency devices (proxy) list so
users can see which frequency bands each transmitter supports. The supported
frequency ranges are formatted into a human-readable, locale-aware string
(picking Hz/kHz/MHz/GHz automatically) with a helper in the data layer.


Claude-Session: https://claude.ai/code/session_01SYyMTtBdrt7EBrVEt869Uw

Co-authored-by: Claude <noreply@anthropic.com>
2026-06-25 16:15:42 +02:00
Bram Kragten ebb15d1118 Show warning when priming will not work for condition (#52709)
* Show warning when priming will not work for condition

* rename

* change to warning icon with tooltip

* review

* Update duration_to_seconds.test.ts
2026-06-25 16:15:41 +02:00
119 changed files with 6147 additions and 1432 deletions
+65
View File
@@ -0,0 +1,65 @@
---
name: ha-frontend-demo
description: Home Assistant standalone demo structure, configurations, URL navigation, mocked APIs, shared demo stubs used by gallery, and verification. Use when changing files under demo/, changing gallery consumers of demo/src/stubs/, or running and validating the demo.
---
# HA Frontend Demo
Use this skill for standalone demo work and changes to shared demo stubs consumed by the gallery. Follow the persistent repository guidance in `AGENTS.md` and load matching specialist skills alongside this skill, especially `ha-frontend-testing` when running or validating the demo.
## Purpose
The demo is the full Home Assistant frontend running against a mocked backend and is published at <https://demo.home-assistant.io>. It needs no Home Assistant server, which makes it the easiest way to load the real UI in a browser, for example to take screenshots.
## Running The Demo
Run commands from the repository root:
```bash
yarn dev:demo # Development server on http://localhost:8090
yarn dev:demo --background # Detached; also supports --status/--stop/--logs
```
Use the E2E workflows documented in `ha-frontend-testing` when validating the demo.
## Opening A Specific Demo
The demo contains multiple demo configurations. Select one directly with the `demo` query parameter, for example `http://localhost:8090/?demo=<slug>`. The valid slugs are defined in `demoConfigs` in `demo/src/configs/demo-configs.ts`, so "the second demo" means the slug of the second entry in that list. An unknown slug falls back to the default, which is the first entry.
## Opening A Specific Page
The demo build uses hash-based routing: the frontend path goes in the URL hash, and the `demo` query parameter goes before the `#`. The URL format is:
```text
http://localhost:8090/?demo=<slug>#/<path>
```
Useful paths:
- `/lovelace/0`: The selected demo's dashboard, also the default when no hash is given.
- `/energy`: The energy dashboard. Its tabs are views: `/energy/overview` (Summary), `/energy/electricity`, `/energy/gas`, `/energy/water`, and `/energy/now`.
- `/map`, `/history`, `/todo`, `/config`: Other sidebar panels.
For example, the water tab of the energy dashboard of the second demo is:
```text
http://localhost:8090/?demo=<second slug>#/energy/water
```
## Structure
- `demo/src/ha-demo.ts`: Root element that sets up all backend mocks.
- `demo/src/configs/<slug>/`: One directory per demo configuration, containing entities, dashboard, and theme data.
- `demo/src/configs/demo-configs.ts`: Registry of demo configurations and URL slug handling.
- `demo/src/stubs/`: Mocked WebSocket and REST APIs.
- `demo/script/develop_demo`, `demo/script/build_demo`: Development server and static build wrappers.
## Shared Gallery Stubs
`demo/src/stubs/` is shared, not demo-private: gallery pages import from it directly. Before changing or removing anything a stub does, search for its callers across `demo/src/` and `gallery/src/`, then check the affected gallery pages as well as the demo.
The two consumers use a stub differently, so demo behavior does not predict gallery behavior. A gallery page calls stubs against the `hass` from `provideHass`, where `hass.config` is the shared `demoConfig` object. A stub that mutates `hass.config` in place is therefore visible to the page. `demo/src/ha-demo.ts` copies `components` into a new array before the stubs run, so the same mutation never reaches the demo. A change can look correct in the demo while quietly breaking a gallery page.
## Verification
Load `ha-frontend-testing` and use its demo and gallery workflows. Validate both consumers when changing shared stubs. Documentation-only changes do not require code tests unless examples or commands change.
+20 -1
View File
@@ -20,6 +20,7 @@ yarn lint # ESLint + Prettier + TypeScript + Lit
yarn format # Auto-fix ESLint + Prettier
yarn lint:types # TypeScript compiler, run without file arguments
yarn test # Vitest
yarn build # Full production build
yarn dev # App dev server
yarn dev:serve # Local serving dev server
```
@@ -28,6 +29,24 @@ Never run `tsc` or `yarn lint:types` with file arguments. File arguments make `t
For focused type feedback on one file, use editor diagnostics instead of a file-scoped `tsc` command.
## Production Builds
Production builds support foreground and managed background execution:
```bash
yarn build # Full foreground build
yarn build --background # Full managed background build
yarn build --modern # Modern frontend_latest bundle only
yarn build --modern --background # Modern managed background build
yarn build --status
yarn build --logs [--follow]
yarn build --stop
```
Use `yarn build --modern --background` for production bundle-size or browser performance comparisons that only need modern browser output. It runs the normal metadata and static preparation, minifies and compresses the modern `frontend_latest` bundle and shared static assets, and generates modern-only entry pages and service workers. It deliberately skips the legacy bundle and its service worker.
Do not pass `--help`, `--background`, or `--modern` to `script/build_frontend`; that raw script does not parse arguments and always starts the full foreground build. Use `yarn build` for managed builds. It prevents another foreground or background build from starting while one is using the shared generated files under `build/` and `hass_frontend/`.
## Unit And Utility Tests
- Add or update Vitest tests for data processing, utility code, and behavior that can be tested without a browser.
@@ -41,7 +60,7 @@ For focused type feedback on one file, use editor diagnostics instead of a file-
`yarn dev:serve` also serves locally and supports `-c` for the core URL and `-p` for the port. The default is 8124, or 8123 in a devcontainer.
Dev server commands support `--background`, `--status`, `--stop`, and `--logs [--follow]`. Prefer managed background mode while iterating so the watcher stays available across test runs without occupying the terminal.
Dev server commands support `--background`, `--status`, `--stop`, and `--logs [--follow]`. Prefer managed background mode while iterating so the watcher stays available across test runs without occupying the terminal. `yarn dev` and `yarn dev:serve` share one managed process slot because both write the app output.
## Playwright E2E
+1 -1
View File
@@ -1 +1 @@
24.18.0
24.18.1
+1 -2
View File
@@ -2,8 +2,6 @@
You are helping develop the Home Assistant frontend. This repository is a TypeScript application built from Lit-based Web Components for the Home Assistant web UI.
For the standalone demo — including how to open a specific demo configuration or page via URL — read `demo/AGENTS.md`.
## Essential Commands
```bash
@@ -47,6 +45,7 @@ Detailed guidance lives in project skills under `.agents/skills/`. Load the matc
- `ha-frontend-user-facing-text`: localization, terminology, sentence case, and Home Assistant text style.
- `ha-frontend-review`: PR template use, review checklist, and recurring review issues.
- `ha-frontend-gallery`: gallery pages, demos, sidebar structure, content, and verification.
- `ha-frontend-demo`: standalone demo structure, configurations, navigation, shared stubs, and verification.
## Pull Requests
+1 -1
View File
@@ -12,7 +12,7 @@ This is the repository for the official [Home Assistant](https://home-assistant.
- Initial setup: `script/setup`
- Development: [Instructions](https://developers.home-assistant.io/docs/frontend/development/)
- Production build: `script/build_frontend`
- Production build: `yarn build`
- Gallery: `cd gallery && script/develop_gallery`
## Frontend development
+267
View File
@@ -0,0 +1,267 @@
// Manage a Home Assistant frontend production build with an agent-friendly
// interface, matching build-scripts/dev-server.mjs.
//
// node build-scripts/build-manager.mjs [--modern] [mode]
//
// (no mode) Run in the foreground.
// --background Start detached, print the pid, then exit and leave it
// running.
// --status Report whether a managed build is running.
// --stop Stop a managed build.
// --logs [--follow] Print (or follow) the background build log.
//
// --modern Build only the modern frontend_latest bundle.
import path from "node:path";
import { fileURLToPath } from "node:url";
import {
LIFECYCLE_MODE_FLAGS,
acquireProcessRecord,
isProcessRecordAlive,
outputLog,
processStartTime,
readProcessRecord,
releaseProcessRecord,
runCli,
spawnDetachedToLog,
spawnForeground,
terminateProcess,
waitFor,
writeProcessRecord,
} from "./managed-process.mjs";
const repoRoot = path.resolve(
path.dirname(fileURLToPath(import.meta.url)),
".."
);
const gulpBin = path.join(repoRoot, "node_modules", ".bin", "gulp");
const stateDir = path.join(repoRoot, "node_modules", ".cache", "ha-build");
const logFile = path.join(stateDir, "build.log");
const lockFile = path.join(
repoRoot,
"node_modules",
".cache",
"ha-generated-output.lock"
);
const usage = () => {
process.stderr.write(
"Usage: node build-scripts/build-manager.mjs [--modern] " +
"[--background | --status | --stop | --logs [--follow]]\n"
);
};
const parseArgs = (argv) => {
const args = {
mode: "foreground",
modes: [],
follow: false,
modern: false,
unknown: [],
};
for (const arg of argv) {
if (LIFECYCLE_MODE_FLAGS.has(arg)) {
args.mode = LIFECYCLE_MODE_FLAGS.get(arg);
args.modes.push(arg);
} else if (arg === "--modern") {
args.modern = true;
} else if (arg === "--follow") {
args.follow = true;
} else {
args.unknown.push(arg);
}
}
return args;
};
const hints = () =>
" Stop: yarn build --stop\n" +
" Status: yarn build --status\n" +
" Logs: yarn build --logs\n";
const readBuild = () => readProcessRecord(lockFile);
const releaseBuild = (token) => releaseProcessRecord(lockFile, token);
const acquireBuild = (modern, foreground) => {
const token = `${process.pid}-${Date.now()}-${Math.random()}`;
const result = acquireProcessRecord(lockFile, {
pid: process.pid,
startTime: processStartTime(process.pid),
processGroup: false,
foreground,
kind: "build",
modern,
starting: true,
token,
});
return result.acquired ? { token } : { existing: result.existing };
};
const updateBuild = (token, child, processGroup) => {
const existing = readBuild();
if (existing?.token !== token) {
throw Error("Frontend build lock ownership was lost during startup.");
}
writeProcessRecord(lockFile, {
...existing,
pid: child.pid,
startTime: processStartTime(child.pid),
processGroup,
starting: false,
});
};
const taskFor = (modern) => (modern ? "build-app-modern" : "build-app");
const reportExisting = (existing) => {
if (existing?.kind === "dev") {
const command = existing.suite === "app-serve" ? "dev:serve" : "dev";
process.stdout.write(
`Dev server (${existing.suite}) already running` +
`${existing.pid ? ` (pid ${existing.pid})` : ""}.\n` +
` Stop: yarn ${command} --stop\n` +
` Status: yarn ${command} --status\n` +
` Logs: yarn ${command} --logs\n`
);
return;
}
process.stdout.write(
`Frontend ${existing?.modern ? "modern " : ""}build already running` +
`${existing?.pid ? ` (pid ${existing.pid})` : ""}.\n${hints()}`
);
};
const runForeground = async (modern) => {
const lock = acquireBuild(modern, true);
if (!lock.token) {
reportExisting(lock.existing);
return 1;
}
try {
return await spawnForeground({
cmd: gulpBin,
args: [taskFor(modern)],
cwd: repoRoot,
processGroup: true,
onSpawn: (child) => updateBuild(lock.token, child, true),
});
} finally {
releaseBuild(lock.token);
}
};
const runBackground = async (modern) => {
const lock = acquireBuild(modern, false);
if (!lock.token) {
reportExisting(lock.existing);
return 1;
}
try {
const child = await spawnDetachedToLog({
cmd: gulpBin,
args: [taskFor(modern)],
cwd: repoRoot,
logFile,
});
updateBuild(lock.token, child, true);
process.stdout.write(
`Started ${modern ? "modern " : ""}frontend build (pid ${child.pid})\n` +
hints()
);
return 0;
} catch (err) {
releaseBuild(lock.token);
throw err;
}
};
const runStatus = () => {
const existing = readBuild();
if (existing?.kind === "build" && isProcessRecordAlive(existing)) {
process.stdout.write(
`Frontend ${existing.modern ? "modern " : ""}build running (pid ${existing.pid}).\n`
);
} else {
if (existing?.kind === "build") {
releaseBuild(existing.token);
}
process.stdout.write("Frontend build not running.\n");
}
return 0;
};
const runStop = async () => {
let existing = readBuild();
if (existing?.kind !== "build") {
process.stdout.write("Frontend build not running.\n");
return 0;
}
if (existing?.starting) {
const token = existing.token;
await waitFor(
() => {
const current = readBuild();
return !current?.starting || current.token !== token;
},
100,
5000
);
existing = readBuild();
}
if (
!existing ||
existing.kind !== "build" ||
!isProcessRecordAlive(existing)
) {
if (existing) releaseBuild(existing.token);
process.stdout.write("Frontend build not running.\n");
return 0;
}
if (
!(await terminateProcess({
pid: existing.pid,
processGroup: existing.processGroup,
isStopped: () => !isProcessRecordAlive(existing),
}))
) {
process.stderr.write(
`Failed to stop frontend build (pid ${existing.pid}). Stop it manually.\n`
);
return 1;
}
releaseBuild(existing.token);
process.stdout.write(`Stopped frontend build (pid ${existing.pid}).\n`);
return 0;
};
const runLogs = (follow) =>
outputLog(logFile, follow, `No frontend build log yet (${logFile}).\n`);
const main = async () => {
const args = parseArgs(process.argv.slice(2));
if (args.unknown.length) {
process.stderr.write(`Unknown arguments: ${args.unknown.join(" ")}\n`);
usage();
return 1;
}
if (
args.modes.length > 1 ||
(args.follow && args.mode !== "logs") ||
(args.modern && !["foreground", "background"].includes(args.mode))
) {
process.stderr.write("Invalid combination of build arguments.\n");
usage();
return 1;
}
const handlers = {
foreground: () => runForeground(args.modern),
background: () => runBackground(args.modern),
status: runStatus,
stop: runStop,
logs: () => runLogs(args.follow),
};
return handlers[args.mode]();
};
runCli(main);
+8 -8
View File
@@ -1,15 +1,15 @@
{
"_comment": "Initial JS budget (raw/uncompressed bytes) for the cold-load critical entrypoints. Enforced by build-scripts/check-bundle-size.cjs in CI. Re-seed after an intentional change with `--update --headroom=<percent>`.",
"frontend-modern": {
"app": 561513,
"core": 54473,
"authorize": 544272,
"onboarding": 647136
"app": 595204,
"core": 57741,
"authorize": 576928,
"onboarding": 685964
},
"frontend-legacy": {
"app": 790323,
"core": 237208,
"authorize": 765464,
"onboarding": 918679
"app": 861452,
"core": 258557,
"authorize": 834356,
"onboarding": 1001360
}
}
+338 -255
View File
@@ -20,10 +20,27 @@
// no health endpoint, and plain yarn dev has no port at all, so these
// track a pidfile and treat the first "Build done" log line as ready.
import { spawn, execFileSync } from "node:child_process";
import { execFileSync } from "node:child_process";
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import {
LIFECYCLE_MODE_FLAGS,
acquireProcessRecord,
isProcessRecordAlive,
outputLog,
processStartTime,
readProcessRecord,
releaseProcessRecord,
removeProcessRecord,
runCli,
sleep,
spawnDetachedToLog,
spawnForeground,
terminateProcess,
waitFor,
writeProcessRecord,
} from "./managed-process.mjs";
const repoRoot = path.resolve(
path.dirname(fileURLToPath(import.meta.url)),
@@ -36,48 +53,74 @@ const developAndServeScript = path.join(
"develop_and_serve"
);
const logDir = path.join(repoRoot, "node_modules", ".cache", "ha-dev-server");
const outputLockFile = path.join(
repoRoot,
"node_modules",
".cache",
"ha-generated-output.lock"
);
// Each suite names its yarn alias (for hints), a liveness model, and how to
// spawn it. health suites carry a fixed port; process suites carry the log line
// that means "ready" and, for app-serve, forward extra args to the script.
const SUITES = {
"e2e-app": {
alias: "test:e2e:app:dev",
liveness: "health",
port: 8095,
spawn: { cmd: gulpBin, args: ["develop-e2e-test-app"] },
},
demo: {
alias: "dev:demo",
liveness: "health",
port: 8090,
spawn: { cmd: gulpBin, args: ["develop-demo"] },
},
gallery: {
alias: "dev:gallery",
liveness: "health",
port: 8100,
spawn: { cmd: gulpBin, args: ["develop-gallery"] },
},
app: {
alias: "dev",
liveness: "process",
readyLog: /Build done @/,
spawn: { cmd: gulpBin, args: ["develop-app"] },
},
"app-serve": {
alias: "dev:serve",
liveness: "process",
acceptsArgs: true,
readyLog: /Build done @/,
spawn: { cmd: developAndServeScript, args: [] },
},
};
const SUITES = new Map([
[
"e2e-app",
{
alias: "test:e2e:app:dev",
liveness: "health",
port: 8095,
spawn: { cmd: gulpBin, args: ["develop-e2e-test-app"] },
},
],
[
"demo",
{
alias: "dev:demo",
liveness: "health",
port: 8090,
spawn: { cmd: gulpBin, args: ["develop-demo"] },
},
],
[
"gallery",
{
alias: "dev:gallery",
liveness: "health",
port: 8100,
spawn: { cmd: gulpBin, args: ["develop-gallery"] },
},
],
[
"app",
{
alias: "dev",
liveness: "process",
readyLog: /Build done @/,
spawn: { cmd: gulpBin, args: ["develop-app"] },
processKey: "app",
},
],
[
"app-serve",
{
alias: "dev:serve",
liveness: "process",
acceptsArgs: true,
readyLog: /Build done @/,
spawn: { cmd: developAndServeScript, args: [] },
processKey: "app",
},
],
]);
// Cover a cold build on a slow machine before the server starts listening.
// Override with HA_DEV_SERVER_TIMEOUT (seconds).
const readyTimeoutSeconds = Number(process.env.HA_DEV_SERVER_TIMEOUT || "180");
const READY_TIMEOUT_MS =
Number(process.env.HA_DEV_SERVER_TIMEOUT || "180") * 1000;
Number.isFinite(readyTimeoutSeconds) && readyTimeoutSeconds > 0
? readyTimeoutSeconds * 1000
: 180_000;
// Detect a coding agent from a small set of environment markers set by common
// agent CLIs (env-only; no process-ancestry detection).
@@ -104,7 +147,7 @@ const detectAgent = () => {
};
const usage = () => {
const suites = Object.keys(SUITES).join("|");
const suites = [...SUITES.keys()].join("|");
process.stderr.write(
`Usage: node build-scripts/dev-server.mjs --suite <${suites}> ` +
`[--background | --status | --stop | --logs [--follow]]\n`
@@ -115,6 +158,7 @@ const parseArgs = (argv) => {
const args = {
mode: "foreground",
follow: false,
modes: [],
suite: undefined,
passthrough: [],
};
@@ -124,39 +168,99 @@ const parseArgs = (argv) => {
case "--suite":
args.suite = argv[++i];
break;
case "--background":
args.mode = "background";
break;
case "--status":
args.mode = "status";
break;
case "--stop":
args.mode = "stop";
break;
case "--logs":
args.mode = "logs";
break;
case "--follow":
args.follow = true;
break;
default:
// Anything unrecognised is forwarded to the underlying script.
args.passthrough.push(arg);
if (LIFECYCLE_MODE_FLAGS.has(arg)) {
args.mode = LIFECYCLE_MODE_FLAGS.get(arg);
args.modes.push(arg);
} else {
// Anything unrecognised is forwarded to the underlying script.
args.passthrough.push(arg);
}
}
}
return args;
};
const sleep = (ms) =>
new Promise((resolve) => {
setTimeout(resolve, ms);
});
const logFileFor = (suite) => path.join(logDir, `${suite}.log`);
const pidFileFor = (suite) => path.join(logDir, `${suite}.pid`);
const pidFileFor = (suite) =>
path.join(logDir, `${SUITES.get(suite).processKey ?? suite}.pid`);
const removePidFileIf = (suite, matches) => {
const existing = readProcessRecord(pidFileFor(suite));
if (!existing) {
removeProcessRecord(pidFileFor(suite));
return true;
}
if (matches(existing)) {
releaseProcessRecord(outputLockFile, existing.token, () => {
if (readProcessRecord(pidFileFor(suite))?.token === existing.token) {
removeProcessRecord(pidFileFor(suite));
}
});
return true;
}
return false;
};
const acquireProcessSuite = (suite) => {
const pidFile = pidFileFor(suite);
const token = `${process.pid}-${Date.now()}-${Math.random()}`;
const record = {
pid: process.pid,
startTime: processStartTime(process.pid),
kind: "dev",
suite,
starting: true,
token,
};
const result = acquireProcessRecord(outputLockFile, record);
if (!result.acquired) {
return { existing: result.existing };
}
try {
writeProcessRecord(pidFile, record);
} catch (err) {
releaseProcessRecord(outputLockFile, token);
throw err;
}
return { token };
};
const updateProcessSuite = (suite, token, child) => {
const existing = readPidFile(suite);
if (existing?.token !== token) {
throw Error(
`Dev server (${suite}) process ownership was lost during startup.`
);
}
const record = {
...existing,
pid: child.pid,
startTime: processStartTime(child.pid),
starting: false,
};
writePidFile(suite, record);
const outputOwner = readProcessRecord(outputLockFile);
if (outputOwner?.token !== token) {
throw Error(
`Dev server (${suite}) output ownership was lost during startup.`
);
}
writeProcessRecord(outputLockFile, record);
};
const releaseProcessSuite = (suite, token) => {
releaseProcessRecord(outputLockFile, token, () => {
if (readPidFile(suite)?.token === token) {
removeProcessRecord(pidFileFor(suite));
}
});
};
const hints = (suite) => {
const alias = `yarn ${SUITES[suite].alias}`;
const alias = `yarn ${SUITES.get(suite).alias}`;
return (
` Stop: ${alias} --stop\n` +
` Status: ${alias} --status\n` +
@@ -164,46 +268,26 @@ const hints = (suite) => {
);
};
const reportProcessConflict = (suite, existing) => {
if (existing?.kind === "build") {
process.stdout.write(
`Frontend build already running${existing.pid ? ` (pid ${existing.pid})` : ""}. ` +
"Stop it with yarn build --stop.\n"
);
return;
}
process.stdout.write(
`Dev server (${existing?.suite ?? suite}) already running` +
`${urlSuffix(existing?.port)} ` +
`${existing?.pid ? `(pid ${existing.pid})` : ""}\n` +
hints(existing?.suite ?? suite)
);
};
// --- shared spawning and lifecycle ------------------------------------------
// Signal the whole process group (the background server is its group leader),
// falling back to the bare pid if that is not permitted.
const killProcessTree = (pid, sig) => {
try {
process.kill(-pid, sig);
} catch {
try {
process.kill(pid, sig);
} catch {
// Already gone.
}
}
};
const urlSuffix = (port) => (port ? ` at http://localhost:${port}` : "");
// Run a server in the foreground, inheriting stdio; resolve with its exit code.
const spawnInherit = (cmd, args) =>
new Promise((resolve) => {
const child = spawn(cmd, args, { cwd: repoRoot, stdio: "inherit" });
child.on("exit", (code) => resolve(code ?? 0));
});
// Spawn a detached server that writes stdout and stderr to the suite's log file.
const spawnDetachedToLog = (suite, cmd, args) => {
fs.mkdirSync(logDir, { recursive: true });
const logFile = logFileFor(suite);
const fd = fs.openSync(logFile, "w");
const child = spawn(cmd, args, {
cwd: repoRoot,
detached: true,
stdio: ["ignore", fd, fd],
});
fs.closeSync(fd);
child.unref();
return { child, logFile };
};
// Poll until the server is ready, the child exits, or we time out. Prints the
// progress dots and outcome; returns 0 when ready, 1 otherwise. onExit runs if
// the child dies before it is ready (used to clear a stale pidfile).
@@ -214,8 +298,7 @@ const awaitReady = async ({ suite, child, logFile, port, isReady, onExit }) => {
});
const deadline = Date.now() + READY_TIMEOUT_MS;
process.stdout.write(`Starting ${suite} dev server`);
/* eslint-disable no-await-in-loop -- poll until the server is ready */
while (Date.now() < deadline) {
const poll = async () => {
if (childExited) {
process.stdout.write("\n");
process.stderr.write(
@@ -232,38 +315,37 @@ const awaitReady = async ({ suite, child, logFile, port, isReady, onExit }) => {
);
return 0;
}
if (Date.now() >= deadline) {
return undefined;
}
process.stdout.write(".");
await sleep(1000);
return poll();
};
const result = await poll();
if (result !== undefined) {
return result;
}
/* eslint-enable no-await-in-loop */
process.stdout.write("\n");
process.stderr.write(
`Dev server (${suite}) did not become ready within ${
READY_TIMEOUT_MS / 1000
}s. See ${logFile}\n`
);
const stopped = await terminateProcess({
pid: child.pid,
isStopped: () => childExited,
});
if (stopped) {
onExit?.();
}
return 1;
};
// Stop a running background server: SIGTERM, wait for it to go, then SIGKILL.
// isStopped reports when it is gone; onStopped runs on success (pidfile cleanup).
const terminate = async (suite, pid, isStopped, onStopped) => {
killProcessTree(pid, "SIGTERM");
const deadline = Date.now() + 10_000;
/* eslint-disable no-await-in-loop -- poll until the server is gone */
while (Date.now() < deadline) {
await sleep(300);
if (await isStopped()) {
onStopped?.();
process.stdout.write(`Stopped dev server (${suite}) (pid ${pid}).\n`);
return 0;
}
}
/* eslint-enable no-await-in-loop */
// Escalate if it is still up.
killProcessTree(pid, "SIGKILL");
await sleep(300);
if (!(await isStopped())) {
if (!(await terminateProcess({ pid, isStopped }))) {
process.stderr.write(
`Failed to stop dev server (${suite}) (pid ${pid}). Stop it manually.\n`
);
@@ -284,9 +366,11 @@ const terminate = async (suite, pid, isStopped, onStopped) => {
const PROBE_HOSTS = ["localhost", "127.0.0.1", "[::1]"];
const probe = async (port, timeoutMs = 1000) => {
let sawResponse = false;
/* eslint-disable no-await-in-loop -- probe localhost addresses in order, stopping at the first that answers */
for (const host of PROBE_HOSTS) {
const probeHost = async (index, sawResponse) => {
const host = PROBE_HOSTS[index];
if (!host) {
return sawResponse ? { state: "foreign" } : { state: "free" };
}
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs);
try {
@@ -305,9 +389,9 @@ const probe = async (port, timeoutMs = 1000) => {
} finally {
clearTimeout(timer);
}
}
/* eslint-enable no-await-in-loop */
return sawResponse ? { state: "foreign" } : { state: "free" };
return probeHost(index + 1, sawResponse);
};
return probeHost(0, false);
};
// Find the pid listening on a port via the first available tool (no state file).
@@ -351,13 +435,23 @@ const runForegroundHealth = async (suite, cfg) => {
);
return 0;
}
if (status.state === "ours") {
process.stderr.write(
`Port ${port} is serving the ${status.suite ?? "unknown"} dev server; not ${suite}.\n`
);
return 1;
}
if (status.state === "foreign") {
process.stderr.write(
`Port ${port} is in use by another process; not the ${suite} dev server.\n`
);
return 1;
}
return spawnInherit(cfg.spawn.cmd, cfg.spawn.args);
return spawnForeground({
cmd: cfg.spawn.cmd,
args: cfg.spawn.args,
cwd: repoRoot,
});
};
const runBackgroundHealth = async (suite, cfg) => {
@@ -371,6 +465,12 @@ const runBackgroundHealth = async (suite, cfg) => {
);
return 0;
}
if (preflight.state === "ours") {
process.stderr.write(
`Port ${port} is serving the ${preflight.suite ?? "unknown"} dev server; not ${suite}.\n`
);
return 1;
}
if (preflight.state === "foreign") {
process.stderr.write(
`Port ${port} is in use by another process; not the ${suite} dev server.\n`
@@ -378,11 +478,13 @@ const runBackgroundHealth = async (suite, cfg) => {
return 1;
}
const { child, logFile } = spawnDetachedToLog(
suite,
cfg.spawn.cmd,
cfg.spawn.args
);
const logFile = logFileFor(suite);
const child = await spawnDetachedToLog({
cmd: cfg.spawn.cmd,
args: cfg.spawn.args,
cwd: repoRoot,
logFile,
});
return awaitReady({
suite,
child,
@@ -443,42 +545,12 @@ const runStopHealth = async (suite, cfg) => {
// --- process liveness (pidfile + log-readiness) -----------------------------
const isAlive = (pid) => {
if (!Number.isInteger(pid) || pid <= 0) {
return false;
}
try {
process.kill(pid, 0);
return true;
} catch (err) {
// EPERM means the process exists but is owned by someone else.
return err.code === "EPERM";
}
};
const readPidFile = (suite) => {
try {
const data = JSON.parse(fs.readFileSync(pidFileFor(suite), "utf8"));
if (data && Number.isInteger(data.pid)) {
return data;
}
} catch {
// Missing or corrupt.
}
return undefined;
return readProcessRecord(pidFileFor(suite));
};
const writePidFile = (suite, data) => {
fs.mkdirSync(logDir, { recursive: true });
fs.writeFileSync(pidFileFor(suite), JSON.stringify(data));
};
const removePidFile = (suite) => {
try {
fs.rmSync(pidFileFor(suite));
} catch {
// Already gone.
}
writeProcessRecord(pidFileFor(suite), data);
};
const logIsReady = (logFile, readyLog) => {
@@ -492,11 +564,13 @@ const logIsReady = (logFile, readyLog) => {
// app-serve serves on 8124 by default (8123 in a devcontainer), or whatever -p
// the caller passed. Used only to show a URL; liveness comes from the pidfile.
const resolveServePort = (passthrough) => {
const i = passthrough.indexOf("-p");
if (i !== -1) {
const port = Number(passthrough[i + 1]);
if (Number.isInteger(port) && port > 0) {
return port;
for (let i = passthrough.length - 1; i >= 0; i--) {
const arg = passthrough[i];
if (arg === "-p" || arg.startsWith("-p")) {
const port = Number(arg === "-p" ? passthrough[i + 1] : arg.slice(2));
if (Number.isInteger(port) && port > 0) {
return port;
}
}
}
return process.env.DEVCONTAINER ? 8123 : 8124;
@@ -508,62 +582,72 @@ const spawnArgs = (cfg, passthrough) => [
];
const runForegroundProcess = async (suite, cfg, passthrough) => {
const existing = readPidFile(suite);
if (existing && isAlive(existing.pid)) {
process.stdout.write(
`Dev server (${suite}) already running in the background ` +
`(pid ${existing.pid}). Stop it with yarn ${cfg.alias} --stop.\n`
);
const lock = acquireProcessSuite(suite);
if (!lock.token) {
reportProcessConflict(suite, lock.existing);
return 0;
}
if (existing) {
removePidFile(suite);
try {
return await spawnForeground({
cmd: cfg.spawn.cmd,
args: spawnArgs(cfg, passthrough),
cwd: repoRoot,
processGroup: true,
onSpawn: (child) => updateProcessSuite(suite, lock.token, child),
});
} finally {
releaseProcessSuite(suite, lock.token);
}
return spawnInherit(cfg.spawn.cmd, spawnArgs(cfg, passthrough));
};
const runBackgroundProcess = async (suite, cfg, passthrough) => {
const existing = readPidFile(suite);
if (existing && isAlive(existing.pid)) {
process.stdout.write(
`Dev server (${suite}) already running${urlSuffix(existing.port)} ` +
`(pid ${existing.pid})\n${hints(suite)}`
);
const lock = acquireProcessSuite(suite);
if (!lock.token) {
reportProcessConflict(suite, lock.existing);
return 0;
}
if (existing) {
removePidFile(suite);
try {
const logFile = logFileFor(suite);
const child = await spawnDetachedToLog({
cmd: cfg.spawn.cmd,
args: spawnArgs(cfg, passthrough),
cwd: repoRoot,
logFile,
});
const port = cfg.acceptsArgs ? resolveServePort(passthrough) : cfg.port;
updateProcessSuite(suite, lock.token, child);
writePidFile(suite, { ...readPidFile(suite), port });
return awaitReady({
suite,
child,
logFile,
port,
isReady: () => logIsReady(logFile, cfg.readyLog),
onExit: () => releaseProcessSuite(suite, lock.token),
});
} catch (err) {
releaseProcessSuite(suite, lock.token);
throw err;
}
const { child, logFile } = spawnDetachedToLog(
suite,
cfg.spawn.cmd,
spawnArgs(cfg, passthrough)
);
const port = cfg.acceptsArgs ? resolveServePort(passthrough) : cfg.port;
writePidFile(suite, { pid: child.pid, port });
return awaitReady({
suite,
child,
logFile,
port,
isReady: () => logIsReady(logFile, cfg.readyLog),
onExit: () => removePidFile(suite),
});
};
const runStatusProcess = async (suite) => {
const existing = readPidFile(suite);
if (existing && isAlive(existing.pid)) {
if (existing && isProcessRecordAlive(existing)) {
process.stdout.write(
`Dev server (${suite}) running${urlSuffix(existing.port)} ` +
`Dev server (${existing.suite ?? suite}) running${urlSuffix(existing.port)} ` +
`(pid ${existing.pid})\n`
);
} else {
if (existing) {
removePidFile(suite);
removePidFileIf(
suite,
(current) =>
current.token === existing.token && !isProcessRecordAlive(current)
);
}
process.stdout.write(`Dev server (${suite}) not running.\n`);
}
@@ -571,56 +655,64 @@ const runStatusProcess = async (suite) => {
};
const runStopProcess = async (suite) => {
const existing = readPidFile(suite);
if (!existing || !isAlive(existing.pid)) {
let existing = readPidFile(suite);
if (existing?.starting) {
const token = existing.token;
await waitFor(
() => {
const current = readPidFile(suite);
return !current?.starting || current.token !== token;
},
100,
5000
);
existing = readPidFile(suite);
}
if (!existing || !isProcessRecordAlive(existing)) {
// Idempotent: stopping something that is not running is a success.
if (existing) {
removePidFile(suite);
removePidFileIf(
suite,
(current) =>
current.token === existing.token && !isProcessRecordAlive(current)
);
}
process.stdout.write(`Dev server (${suite}) not running.\n`);
return 0;
}
const { pid } = existing;
const activeSuite = existing.suite ?? suite;
return terminate(
suite,
activeSuite,
pid,
() => !isAlive(pid),
() => removePidFile(suite)
() => !isProcessRecordAlive(existing),
() => releaseProcessSuite(activeSuite, existing.token)
);
};
// --- shared -----------------------------------------------------------------
const runLogs = (suite, follow) => {
const logFile = logFileFor(suite);
if (!fs.existsSync(logFile)) {
process.stdout.write(
`No log for the ${suite} dev server yet (${logFile}).\n`
);
return Promise.resolve(0);
}
if (!follow) {
process.stdout.write(fs.readFileSync(logFile, "utf8"));
return Promise.resolve(0);
}
return new Promise((resolve) => {
const tail = spawn("tail", ["-f", logFile], { stdio: "inherit" });
tail.on("error", () => {
// No tail available; fall back to a one-shot dump.
process.stdout.write(fs.readFileSync(logFile, "utf8"));
resolve(0);
});
tail.on("exit", (code) => resolve(code ?? 0));
});
const activeSuite = readPidFile(suite)?.suite ?? suite;
return outputLog(
logFileFor(activeSuite),
follow,
`No log for the ${activeSuite} dev server yet (${logFileFor(activeSuite)}).\n`
);
};
const main = async () => {
const args = parseArgs(process.argv.slice(2));
const cfg = SUITES[args.suite];
const cfg = SUITES.get(args.suite);
if (!cfg) {
usage();
return 1;
}
if (args.modes.length > 1 || (args.follow && args.mode !== "logs")) {
process.stderr.write("Invalid combination of lifecycle arguments.\n");
usage();
return 1;
}
if (args.passthrough.length && !cfg.acceptsArgs) {
process.stderr.write(
`Ignoring unexpected arguments: ${args.passthrough.join(" ")}\n`
@@ -644,35 +736,26 @@ const main = async () => {
}
}
const health = cfg.liveness === "health";
switch (mode) {
case "background":
return health
? runBackgroundHealth(args.suite, cfg)
: runBackgroundProcess(args.suite, cfg, args.passthrough);
case "status":
return health
? runStatusHealth(args.suite, cfg)
: runStatusProcess(args.suite);
case "stop":
return health
? runStopHealth(args.suite, cfg)
: runStopProcess(args.suite);
case "logs":
return runLogs(args.suite, args.follow);
default:
return health
? runForegroundHealth(args.suite, cfg)
: runForegroundProcess(args.suite, cfg, args.passthrough);
if (mode === "logs") {
return runLogs(args.suite, args.follow);
}
const handlers =
cfg.liveness === "health"
? {
foreground: () => runForegroundHealth(args.suite, cfg),
background: () => runBackgroundHealth(args.suite, cfg),
status: () => runStatusHealth(args.suite, cfg),
stop: () => runStopHealth(args.suite, cfg),
}
: {
foreground: () =>
runForegroundProcess(args.suite, cfg, args.passthrough),
background: () =>
runBackgroundProcess(args.suite, cfg, args.passthrough),
status: () => runStatusProcess(args.suite),
stop: () => runStopProcess(args.suite),
};
return handlers[mode]();
};
main().then(
(code) => {
process.exitCode = code;
},
(err) => {
process.stderr.write(`${err?.stack || err}\n`);
process.exitCode = 1;
}
);
runCli(main);
+23
View File
@@ -51,6 +51,29 @@ gulp.task(
)
);
gulp.task(
"build-app-modern",
gulp.series(
async function setEnv() {
process.env.NODE_ENV = "production";
},
"clean",
gulp.parallel(
"gen-icons-json",
"build-translations",
"build-locale-data",
"gen-licenses"
),
"copy-static-app",
"rspack-prod-app-modern",
gulp.parallel(
"gen-pages-app-prod-modern",
"gen-service-worker-app-prod-modern"
),
...(env.isTestBuild() || env.isStatsBuild() ? [] : ["compress-app"])
)
);
gulp.task(
"analyze-app",
gulp.series(
+15 -2
View File
@@ -147,9 +147,11 @@ const genPagesProdTask =
{
...commonVars,
latestEntryJS: entries.map((entry) => latestManifest[`${entry}.js`]),
es5EntryJS: entries.map((entry) => es5Manifest[`${entry}.js`]),
es5EntryJS: outputES5
? entries.map((entry) => es5Manifest[`${entry}.js`])
: [],
latestCustomPanelJS: latestManifest["custom-panel.js"],
es5CustomPanelJS: es5Manifest["custom-panel.js"],
es5CustomPanelJS: outputES5 ? es5Manifest["custom-panel.js"] : "",
}
);
minifiedHTML.push(
@@ -184,6 +186,17 @@ gulp.task(
)
);
gulp.task(
"gen-pages-app-prod-modern",
genPagesProdTask(
APP_PAGE_ENTRIES,
paths.root_dir,
paths.app_output_root,
paths.app_output_latest,
undefined
)
);
const CAST_PAGE_ENTRIES = {
"faq.html": ["launcher"],
"index.html": ["launcher"],
+11
View File
@@ -160,6 +160,17 @@ gulp.task("rspack-prod-app", () =>
)
);
gulp.task("rspack-prod-app-modern", () =>
prodBuild(
createAppConfig({
isProdBuild: true,
isStatsBuild: env.isStatsBuild(),
isTestBuild: env.isTestBuild(),
latestBuild: true,
})
)
);
gulp.task("rspack-dev-server-demo", () =>
runDevServer({
compiler: rspack(
+10 -3
View File
@@ -34,9 +34,9 @@ gulp.task("gen-service-worker-app-dev", async () => {
);
});
gulp.task("gen-service-worker-app-prod", () =>
const genServiceWorker = (builds) =>
Promise.all(
Object.entries(SW_MAP).map(async ([outPath, build]) => {
builds.map(async ([outPath, build]) => {
const manifest = JSON.parse(
await readFile(join(outPath, "manifest.json"), "utf-8")
);
@@ -83,5 +83,12 @@ gulp.task("gen-service-worker-app-prod", () =>
await symlink(basename(swDest), swOld);
}
})
)
);
gulp.task("gen-service-worker-app-prod", () =>
genServiceWorker(Object.entries(SW_MAP))
);
gulp.task("gen-service-worker-app-prod-modern", () =>
genServiceWorker([[paths.app_output_latest, "modern"]])
);
+358
View File
@@ -0,0 +1,358 @@
import { spawn, execFileSync } from "node:child_process";
import fs from "node:fs";
import path from "node:path";
export const LIFECYCLE_MODE_FLAGS = new Map([
["--background", "background"],
["--status", "status"],
["--stop", "stop"],
["--logs", "logs"],
]);
export const sleep = (ms) =>
new Promise((resolve) => {
setTimeout(resolve, ms);
});
export const waitFor = async (predicate, intervalMs, timeoutMs) => {
const deadline = Date.now() + timeoutMs;
const poll = async () => {
if (await predicate()) {
return true;
}
if (Date.now() >= deadline) {
return false;
}
await sleep(intervalMs);
return poll();
};
return poll();
};
export const isProcessAlive = (pid) => {
if (!Number.isInteger(pid) || pid <= 0) {
return false;
}
try {
process.kill(pid, 0);
return true;
} catch (err) {
// EPERM means the process exists but is owned by someone else.
return err.code === "EPERM";
}
};
export const processStartTime = (pid) => {
if (!isProcessAlive(pid)) {
return undefined;
}
try {
const stat = fs.readFileSync(`/proc/${pid}/stat`, "utf8");
return stat.slice(stat.lastIndexOf(")") + 2).split(" ")[19];
} catch {
try {
return execFileSync("ps", ["-o", "lstart=", "-p", String(pid)], {
encoding: "utf8",
stdio: ["ignore", "pipe", "ignore"],
}).trim();
} catch {
return undefined;
}
}
};
export const isProcessRecordAlive = ({ pid, startTime }) =>
Boolean(startTime) && processStartTime(pid) === startTime;
export const readProcessRecord = (file) => {
try {
const data = JSON.parse(fs.readFileSync(file, "utf8"));
return data && Number.isInteger(data.pid) ? data : undefined;
} catch (err) {
if (err.code === "ENOENT" || err instanceof SyntaxError) {
return undefined;
}
throw err;
}
};
export const writeProcessRecord = (file, data) => {
fs.mkdirSync(path.dirname(file), { recursive: true });
const temporary = `${file}.${process.pid}.${Date.now()}.tmp`;
try {
fs.writeFileSync(temporary, JSON.stringify(data));
fs.renameSync(temporary, file);
} finally {
removeFileIfExists(temporary);
}
};
export const removeProcessRecord = (file) => {
removeFileIfExists(file);
};
export const acquireProcessRecord = (file, data) => {
fs.mkdirSync(path.dirname(file), { recursive: true });
for (let attempt = 0; attempt < 2; attempt++) {
try {
const fd = fs.openSync(file, "wx");
try {
fs.writeFileSync(fd, JSON.stringify(data));
} finally {
fs.closeSync(fd);
}
return { acquired: true };
} catch (err) {
if (err.code !== "EEXIST") {
throw err;
}
const existing = readProcessRecord(file);
if (existing && isProcessRecordAlive(existing)) {
return { acquired: false, existing };
}
if (!existing && isRecentFile(file)) {
return { acquired: false };
}
const removed = withExclusiveFileLockSync(`${file}.cleanup`, () => {
const current = readProcessRecord(file);
if (
current &&
(current.token !== existing?.token || isProcessRecordAlive(current))
) {
return false;
}
removeProcessRecord(file);
return true;
});
if (!removed.acquired || !removed.value) {
return { acquired: false, existing: readProcessRecord(file) };
}
}
}
return { acquired: false, existing: readProcessRecord(file) };
};
export const releaseProcessRecord = (file, token, onRelease) => {
if (!token) {
return;
}
withExclusiveFileLockSync(`${file}.cleanup`, () => {
const existing = readProcessRecord(file);
if (!existing || existing.token === token) {
onRelease?.();
if (existing) {
removeProcessRecord(file);
}
}
});
};
const removeFileIfExists = (file) => {
try {
fs.rmSync(file);
} catch (err) {
if (err.code !== "ENOENT") {
throw err;
}
}
};
const isRecentFile = (file) => {
try {
return Date.now() - fs.statSync(file).mtimeMs < 5000;
} catch (err) {
if (err.code === "ENOENT") {
return false;
}
throw err;
}
};
export const withExclusiveFileLockSync = (file, operation) => {
fs.mkdirSync(path.dirname(file), { recursive: true });
for (let attempt = 0; attempt < 2; attempt++) {
let fd;
try {
fd = fs.openSync(file, "wx");
} catch (err) {
if (err.code !== "EEXIST") {
throw err;
}
const owner = readProcessRecord(file);
if (owner && isProcessRecordAlive(owner)) {
return { acquired: false };
}
if (!owner && isRecentFile(file)) {
return { acquired: false };
}
removeFileIfExists(file);
continue;
}
try {
fs.writeFileSync(
fd,
JSON.stringify({
pid: process.pid,
startTime: processStartTime(process.pid),
})
);
return { acquired: true, value: operation() };
} finally {
try {
fs.closeSync(fd);
} finally {
removeFileIfExists(file);
}
}
}
return { acquired: false };
};
export const spawnForeground = ({
cmd,
args,
cwd,
processGroup = false,
onSpawn,
}) =>
new Promise((resolve, reject) => {
const child = spawn(cmd, args, {
cwd,
detached: processGroup,
stdio: "inherit",
});
let settled = false;
const forwardSigint = () =>
signalProcess(child.pid, "SIGINT", processGroup);
const forwardSigterm = () =>
signalProcess(child.pid, "SIGTERM", processGroup);
const forwardSighup = () =>
signalProcess(child.pid, "SIGHUP", processGroup);
const removeSignalHandlers = () => {
process.off("SIGINT", forwardSigint);
process.off("SIGTERM", forwardSigterm);
process.off("SIGHUP", forwardSighup);
};
if (processGroup) {
process.on("SIGINT", forwardSigint);
process.on("SIGTERM", forwardSigterm);
process.on("SIGHUP", forwardSighup);
}
child.once("spawn", () => {
try {
onSpawn?.(child);
} catch (err) {
settled = true;
signalProcess(child.pid, "SIGTERM", processGroup);
removeSignalHandlers();
reject(err);
}
});
child.once("error", (err) => {
if (!settled) {
settled = true;
removeSignalHandlers();
process.stderr.write(`Failed to start ${cmd}: ${err.message}\n`);
resolve(1);
}
});
child.once("exit", (code) => {
if (!settled) {
settled = true;
removeSignalHandlers();
resolve(code ?? 1);
}
});
});
export const spawnDetachedToLog = ({ cmd, args, cwd, logFile }) =>
new Promise((resolve, reject) => {
fs.mkdirSync(path.dirname(logFile), { recursive: true });
const fd = fs.openSync(logFile, "w");
let child;
try {
child = spawn(cmd, args, {
cwd,
detached: true,
stdio: ["ignore", fd, fd],
});
} finally {
fs.closeSync(fd);
}
child.once("spawn", () => {
child.unref();
resolve(child);
});
child.once("error", reject);
});
const signalProcess = (pid, signal, processGroup) => {
if (processGroup) {
try {
process.kill(-pid, signal);
return;
} catch {
// Fall back to the process itself.
}
}
try {
process.kill(pid, signal);
} catch {
// Already gone.
}
};
export const terminateProcess = async ({
pid,
isStopped,
processGroup = true,
graceMs = 10_000,
}) => {
signalProcess(pid, "SIGTERM", processGroup);
if (await waitFor(isStopped, 300, graceMs)) {
return true;
}
signalProcess(pid, "SIGKILL", processGroup);
await sleep(300);
return await isStopped();
};
export const outputLog = (logFile, follow, missingMessage) => {
if (!fs.existsSync(logFile)) {
process.stdout.write(missingMessage);
return Promise.resolve(0);
}
if (!follow) {
process.stdout.write(fs.readFileSync(logFile, "utf8"));
return Promise.resolve(0);
}
return new Promise((resolve) => {
const tail = spawn("tail", ["-f", logFile], { stdio: "inherit" });
let settled = false;
tail.once("error", () => {
if (!settled) {
settled = true;
process.stdout.write(fs.readFileSync(logFile, "utf8"));
resolve(0);
}
});
tail.once("exit", (code) => {
if (!settled) {
settled = true;
resolve(code ?? 1);
}
});
});
};
export const runCli = (main) => {
main().then(
(code) => {
process.exitCode = code;
},
(err) => {
process.stderr.write(`${err?.stack || err}\n`);
process.exitCode = 1;
}
);
};
@@ -12,18 +12,42 @@
const remapping = require("@ampproject/remapping");
// minify-literals is ESM-only, so load it via dynamic import from this CJS loader.
let minifyPromise;
const getMinifier = () => {
if (!minifyPromise) {
minifyPromise = import("minify-literals").then((m) => m.minifyHTMLLiterals);
// Also map to cache loader promises per environment (e.g., 'modern', 'legacy')
const loaderInitPromises = new Map();
const initLoader = (browserslistEnv) => {
if (!loaderInitPromises.has(browserslistEnv)) {
loaderInitPromises.set(
browserslistEnv,
Promise.all([
import("minify-literals"),
import("browserslist"),
import("lightningcss"),
]).then(([minifyModule, browserslistModule, lightningcssModule]) => {
const browserslist = browserslistModule.default;
const { browserslistToTargets } = lightningcssModule;
const rawTargets = browserslist(null, { env: browserslistEnv });
if (!rawTargets.length) {
throw new Error(
`No browsers resolved for browserslist environment "${browserslistEnv}"`
);
}
const lightningcssTargets = browserslistToTargets(rawTargets);
return {
minifyHTMLLiterals: minifyModule.minifyHTMLLiterals,
lightningcssTargets,
};
})
);
}
return minifyPromise;
return loaderInitPromises.get(browserslistEnv);
};
// HTML options mirror the previous babel-plugin-template-html-minifier config
// (html-minifier-next is option-compatible with html-minifier-terser). CSS in
// css`` templates and inline <style> is handled by minify-literals' lightningcss
// default.
// css`` templates and inline <style> is handled by minify-literals'
// lightningcss. We pass in the targets from browserslist so lightningcss
// can minify CSS appropriately for the build environment.
//
// `keepClosingSlash` is required for `svg`` templates: SVG elements such as
// `<path />` and `<circle />` are not void elements in HTML, so dropping the
@@ -40,11 +64,16 @@ const htmlOptions = {
module.exports = function minifyTemplateLiteralsLoader(source, map, meta) {
const callback = this.async();
getMinifier()
.then((minifyHTMLLiterals) =>
const { browserslistEnv } = this.getOptions();
initLoader(browserslistEnv)
.then(({ minifyHTMLLiterals, lightningcssTargets }) =>
minifyHTMLLiterals(source, {
fileName: this.resourcePath,
html: htmlOptions,
css: {
targets: lightningcssTargets,
},
})
)
.then((result) => {
+5
View File
@@ -96,6 +96,11 @@ const createRspackConfig = ({
__dirname,
"minify-template-literals-loader.cjs"
),
options: {
browserslistEnv: latestBuild
? "modern"
: `legacy${info.issuerLayer === "sw" ? "-sw" : ""}`,
},
},
!latestBuild &&
info.resource.startsWith(
-46
View File
@@ -1,46 +0,0 @@
# Demo Agent Instructions
This file applies to all files under `demo/`. Follow the root `AGENTS.md` for repository-wide standards.
The demo is the full Home Assistant frontend running against a mocked backend (published at https://demo.home-assistant.io). It needs no Home Assistant server, which makes it the easiest way to load the real UI in a browser, for example to take screenshots.
## Running the demo
Run from the repository root:
```bash
yarn dev:demo # dev server on http://localhost:8090
yarn dev:demo --background # detached; also supports --status/--stop/--logs
```
## Opening a specific demo
The demo contains multiple demo configurations. Select one directly with the `demo` query parameter, e.g. `http://localhost:8090/?demo=<slug>`. The valid slugs are defined in `demoConfigs` in `src/configs/demo-configs.ts`, so "the second demo" means the slug of the second entry in that list. An unknown slug falls back to the default (the first entry).
## Opening a specific page
The demo build uses hash-based routing: the frontend path goes in the URL hash, and the `demo` query parameter goes before the `#`. The URL format is:
```
http://localhost:8090/?demo=<slug>#/<path>
```
Useful paths:
- `/lovelace/0` — the selected demo's dashboard (also the default when no hash is given)
- `/energy` — energy dashboard. Its tabs are views: `/energy/overview` (Summary), `/energy/electricity`, `/energy/gas`, `/energy/water`, `/energy/now`
- `/map`, `/history`, `/todo`, `/config` — other sidebar panels
Example — the water tab of the energy dashboard of the second demo:
```
http://localhost:8090/?demo=<second slug>#/energy/water
```
## Structure
- `src/ha-demo.ts`: Root element; sets up all backend mocks.
- `src/configs/<slug>/`: One directory per demo configuration (entities, dashboard, theme).
- `src/configs/demo-configs.ts`: Registry of demo configurations and URL slug handling.
- `src/stubs/`: Mocked WebSocket/REST APIs.
- `script/develop_demo`, `script/build_demo`: Dev server and static build wrappers.
-1
View File
@@ -1 +0,0 @@
AGENTS.md
+6 -4
View File
@@ -16,6 +16,8 @@ import { mockEntityRegistry } from "./stubs/entity_registry";
import { mockEvents } from "./stubs/events";
import { mockFloorRegistry, setDemoFloors } from "./stubs/floor_registry";
import { mockFrontend } from "./stubs/frontend";
import { mockHardware } from "./stubs/hardware";
import { mockHassioSupervisor } from "./stubs/hassio_supervisor";
import { mockIntegration } from "./stubs/integration";
import { mockLabelRegistry } from "./stubs/label_registry";
import { mockIcons } from "./stubs/icons";
@@ -73,10 +75,6 @@ export class HaDemo extends HomeAssistantAppEl {
// `contextMixin`, so let provideHass skip them to avoid duplicate providers.
const hass = provideHass(this, initial, true, false);
// The cloud account page only fetches backup config and the webhook count
// when those integrations are loaded. Enable them here (demo only) so the
// mocked backup/config/info and webhook/list are queried. usage_prediction
// is needed for common-controls sections in strategy dashboards.
hass.updateHass({
config: {
...hass.config,
@@ -86,6 +84,8 @@ export class HaDemo extends HomeAssistantAppEl {
"webhook",
"usage_prediction",
"assist_pipeline",
"hassio",
"hardware",
],
},
});
@@ -113,6 +113,8 @@ export class HaDemo extends HomeAssistantAppEl {
mockEvents(hass);
mockMediaPlayer(hass);
mockFrontend(hass);
mockHardware(hass);
mockHassioSupervisor(hass);
mockIcons(hass);
mockEnergy(hass);
mockPersistentNotification(hass);
+4 -10
View File
@@ -34,16 +34,8 @@
content="width=device-width, initial-scale=1, shrink-to-fit=no"
/>
<meta name="theme-color" content="#03a9f4" />
<link rel="preload" href="/static/fonts/roboto/Roboto-Regular.woff2" as="font" type="font/woff2" crossorigin>
<%= renderTemplate("_social_meta.html.template") %>
<style>
@font-face {
font-family: "Roboto Launch Screen";
font-display: block;
src: url("/static/fonts/roboto/Roboto-Regular.woff2") format("woff2");
font-weight: 400;
font-style: normal;
}
html {
background-color: var(--primary-background-color, #fafafa);
color: var(--primary-text-color, #212121);
@@ -64,12 +56,14 @@
padding: 0;
}
#ha-launch-screen {
font-family: "Roboto Launch Screen", sans-serif;
font-family: ui-sans-serif, system-ui, sans-serif;
height: 100%;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
user-select: none;
-webkit-user-select: none;
transition: opacity var(--ha-animation-duration-normal, 250ms) ease-out;
}
#ha-launch-screen.removing {
@@ -104,7 +98,7 @@
}
#ha-launch-screen .ha-launch-screen-spacer-bottom {
flex: 1;
padding-top: 48px;
padding-top: 16px;
}
.ohf-logo {
margin: max(var(--safe-area-inset-bottom, 0px), 48px) 0;
+48
View File
@@ -0,0 +1,48 @@
import type {
HardwareInfo,
SystemStatusStreamMessage,
} from "../../../src/data/hardware";
import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
// Mirrors what homeassistant_green reports, so the hardware page resolves the
// board name and the brands image the same way it does on a real Green.
const HARDWARE_INFO: HardwareInfo = {
hardware: [
{
board: {
hassio_board_id: "green",
manufacturer: "homeassistant",
model: "green",
},
dongle: null,
config_entries: [],
name: "Home Assistant Green",
url: "https://support.nabucasa.com/hc/en-us/categories/24638797677853-Home-Assistant-Green",
},
],
};
export const mockHardware = (hass: MockHomeAssistant) => {
hass.mockWS("hardware/info", () => HARDWARE_INFO);
hass.mockWS(
"hardware/subscribe_system_status",
(_msg, _currentHass, onChange) => {
// Rounded like the hardware integration rounds psutil's values.
const send = () => {
const usedMb = 1560 + Math.round(Math.random() * 80);
const message: SystemStatusStreamMessage = {
cpu_percent: Math.round((8 + Math.random() * 6) * 10) / 10,
memory_free_mb: 4096 - usedMb,
memory_used_mb: usedMb,
memory_used_percent: Math.round((usedMb / 4096) * 1000) / 10,
timestamp: new Date().toISOString(),
};
onChange?.(message);
};
send();
const interval = window.setInterval(send, 1000);
return () => clearInterval(interval);
}
);
};
+362 -36
View File
@@ -1,59 +1,385 @@
import type { HassioSupervisorInfo } from "../../../src/data/hassio/supervisor";
import type {
HassioAddonDetails,
HassioAddonInfo,
HassioAddonsInfo,
} from "../../../src/data/hassio/addon";
import type { HassioStats } from "../../../src/data/hassio/common";
import type {
HassioHassOSInfo,
HassioHostInfo,
HostDisksUsage,
} from "../../../src/data/hassio/host";
import type { NetworkInfo } from "../../../src/data/hassio/network";
import type {
HassioInfo,
HassioSupervisorInfo,
} from "../../../src/data/hassio/supervisor";
import type { SupervisorMounts } from "../../../src/data/supervisor/mounts";
import type { SupervisorUpdateConfig } from "../../../src/data/supervisor/update";
import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
// `icon`/`logo` are false on purpose: the panel would otherwise request
// /api/hassio/addons/<slug>/icon, which the demo has no backend for.
const DEMO_ADDONS: HassioAddonInfo[] = [
{
name: "Music Assistant",
slug: "d5369777_music_assistant",
description:
"Music library manager for all your media sources and streaming services, with support for a wide range of players",
advanced: false,
available: true,
build: false,
detached: false,
homeassistant: "2025.7.0",
icon: false,
installed: true,
logo: false,
repository: "d5369777",
stage: "stable",
state: "started",
update_available: false,
url: "https://github.com/music-assistant/home-assistant-addon",
version: "2.6.3",
version_latest: "2.6.3",
},
{
name: "ESPHome Device Builder",
slug: "5c53de3b_esphome",
description:
"Manage and program your ESP8266/ESP32 based microcontrollers directly via WiFi and with a simple, yet powerful configuration file syntax",
advanced: false,
available: true,
build: false,
detached: false,
homeassistant: "2025.7.0",
icon: false,
installed: true,
logo: false,
repository: "5c53de3b",
stage: "stable",
state: "started",
update_available: false,
url: "https://esphome.io/",
version: "2025.7.3",
version_latest: "2025.7.3",
},
];
const LONG_DESCRIPTIONS: Record<string, string> = {
d5369777_music_assistant: `## Music Assistant
Music Assistant brings all your music sources together in one library and streams
them to the players you already own.
- Combines local files with streaming services into a single searchable library
- Plays to Sonos, Chromecast, AirPlay, Squeezebox, DLNA and Home Assistant media players
- Group players together for synced multi-room audio
- Exposes players and playlists to Home Assistant automations and voice assistants`,
"5c53de3b_esphome": `## ESPHome Device Builder
ESPHome turns an ESP8266 or ESP32 into a Home Assistant device using a short YAML
configuration instead of hand-written firmware.
- Compile and flash firmware straight from the browser, over WiFi after the first flash
- Hundreds of supported sensors, displays, lights and switches
- Devices are discovered by Home Assistant automatically, with no cloud in between
- Configuration lives next to your Home Assistant config, so it is covered by backups`,
};
// Supervisor schema format (converted to selectors by the config tab). Music
// Assistant is configured in its own UI, so it has no add-on options.
const CONFIG_SCHEMAS: Record<string, HassioAddonDetails["schema"]> = {
"5c53de3b_esphome": [
{ name: "ssl", type: "boolean", required: true },
{ name: "certfile", type: "string", required: true },
{ name: "keyfile", type: "string", required: true },
{ name: "leave_front_door_open", type: "boolean", required: false },
{ name: "status_use_ping", type: "boolean", required: false },
],
};
const CONFIG_OPTIONS: Record<string, Record<string, unknown>> = {
"5c53de3b_esphome": {
ssl: false,
certfile: "fullchain.pem",
keyfile: "privkey.pem",
},
};
const addonDetails = (addon: HassioAddonInfo): HassioAddonDetails => ({
...addon,
apparmor: "default",
arch: ["aarch64", "amd64"],
audio_input: null,
audio_output: null,
audio: false,
auth_api: false,
auto_uart: false,
auto_update: false,
boot: "auto",
changelog: false,
devices: [],
devicetree: false,
discovery: [],
docker_api: false,
documentation: false,
full_access: false,
gpio: false,
hassio_api: false,
hassio_role: "default",
hostname: addon.slug.replace(/_/g, "-"),
homeassistant_api: false,
host_dbus: false,
host_ipc: false,
host_network: false,
host_pid: false,
ingress_entry: null,
ingress_panel: false,
ingress_url: null,
ingress: false,
ip_address: "172.30.33.2",
kernel_modules: false,
long_description: LONG_DESCRIPTIONS[addon.slug],
machine: [],
network_description: null,
network: null,
options: CONFIG_OPTIONS[addon.slug] ?? {},
privileged: [],
protected: true,
rating: 6,
schema: CONFIG_SCHEMAS[addon.slug] ?? null,
services_role: [],
signed: false,
startup: "application",
stdin: false,
system_managed: false,
system_managed_config_entry: null,
translations: {},
watchdog: true,
webui: null,
});
const LOGS: Record<string, string> = {
d5369777_music_assistant: `[server] Starting Music Assistant Server 2.6.3
[server] Loaded provider: filesystem_local
[server] Loaded provider: spotify
[server] Loaded provider: sonos
[players] Discovered player: Living Room (Sonos)
[players] Discovered player: Kitchen (Chromecast)
[server] Music Assistant is ready
`,
"5c53de3b_esphome": `[esphome] Starting ESPHome Device Builder 2025.7.3
[esphome] Dashboard running on port 6052
[esphome] Found 3 configurations
[esphome] bedroom-sensor is online (2025.7.3)
[esphome] garage-door is online (2025.7.3)
[esphome] office-display is online (2025.7.3)
`,
};
const ADDON_STATS: HassioStats = {
blk_read: 12300000,
blk_write: 4500000,
cpu_percent: 1.4,
memory_limit: 3900000000,
memory_percent: 4.2,
memory_usage: 163000000,
network_rx: 8900000,
network_tx: 2300000,
};
export const mockHassioSupervisor = (hass: MockHomeAssistant) => {
hass.config.components.push("hassio");
// Gallery pages rely on this to enable the hassio-gated pickers. The demo
// lists hassio in its own components, hence the guard against duplicates.
if (!hass.config.components.includes("hassio")) {
hass.config.components.push("hassio");
}
hass.mockWS("supervisor/api", (msg) => {
if (msg.endpoint === "/supervisor/info") {
const data: HassioSupervisorInfo = {
version: "2021.10.dev0805",
version_latest: "2021.10.dev0806",
update_available: true,
channel: "dev",
version: "2026.07.1",
version_latest: "2026.07.1",
update_available: false,
channel: "stable",
arch: "aarch64",
supported: true,
healthy: true,
ip_address: "172.30.32.2",
wait_boot: 5,
timezone: "America/Los_Angeles",
timezone: "Europe/Amsterdam",
logging: "info",
debug: false,
debug_block: false,
diagnostics: true,
addons: [
{
name: "Visual Studio Code",
slug: "a0d7b954_vscode",
description:
"Fully featured VSCode experience, to edit your HA config in the browser, including auto-completion!",
state: "started",
version: "3.6.2",
version_latest: "3.6.2",
update_available: false,
repository: "a0d7b954",
icon: false,
logo: true,
},
{
name: "Z-Wave JS",
slug: "core_zwave_js",
description:
"Control a ZWave network with Home Assistant Z-Wave JS",
state: "started",
version: "0.1.45",
version_latest: "0.1.45",
update_available: false,
repository: "core",
icon: true,
logo: true,
},
] as any,
addons: DEMO_ADDONS as any,
addons_repositories: [
"https://github.com/hassio-addons/repository",
"https://github.com/music-assistant/home-assistant-addon",
"https://github.com/esphome/home-assistant-addon",
] as any,
};
return data;
}
if (msg.endpoint === "/addons") {
const data: HassioAddonsInfo = {
addons: DEMO_ADDONS,
repositories: [
{
slug: "d5369777",
name: "Music Assistant",
source: "https://github.com/music-assistant/home-assistant-addon",
url: "https://github.com/music-assistant/home-assistant-addon",
maintainer: "Music Assistant",
},
{
slug: "5c53de3b",
name: "ESPHome",
source: "https://github.com/esphome/home-assistant-addon",
url: "https://esphome.io/",
maintainer: "ESPHome",
},
],
};
return data;
}
const addonMatch = msg.endpoint.match(/^\/addons\/([^/]+)\/(info|stats)$/);
if (addonMatch) {
const addon = DEMO_ADDONS.find((item) => item.slug === addonMatch[1]);
if (!addon) {
return Promise.reject(`Addon ${addonMatch[1]} not found`);
}
return addonMatch[2] === "stats" ? ADDON_STATS : addonDetails(addon);
}
if (msg.endpoint === "/info") {
const data: HassioInfo = {
arch: "aarch64",
channel: "stable",
docker: "27.5.1",
features: ["reboot", "shutdown", "network", "hostname", "os_agent"],
hassos: null,
homeassistant: "2026.7.2",
hostname: "homeassistant",
logging: "info",
machine: "green",
state: "running",
operating_system: "Home Assistant OS 18.2",
supervisor: "2026.07.1",
supported: true,
supported_arch: ["aarch64", "armv7", "armhf"],
timezone: "Europe/Amsterdam",
};
return data;
}
if (msg.endpoint === "/host/info") {
const data: HassioHostInfo = {
agent_version: "1.8.0",
chassis: "embedded",
cpe: "cpe:2.3:o:home-assistant:haos:18.2:*:production:*:*:*:aarch64:*",
deployment: "production",
disk_life_time: 6,
disk_free: 22.3,
disk_total: 31.2,
disk_used: 8.9,
features: ["reboot", "shutdown", "network", "hostname", "os_agent"],
hostname: "homeassistant",
kernel: "6.12.48-haos",
operating_system: "Home Assistant OS 18.2",
boot_timestamp: 1751932800000000,
startup_time: 12.4,
};
return data;
}
if (msg.endpoint === "/os/info") {
const data: HassioHassOSInfo = {
board: "green",
boot: "A",
update_available: false,
version: "18.2",
version_latest: "18.2",
data_disk: "Home Assistant Green (mmcblk0)",
};
return data;
}
if (msg.endpoint === "/host/disks/default/usage") {
const data: HostDisksUsage = {
id: "root",
label: "Total",
total_bytes: 31200000000,
used_bytes: 8900000000,
children: [
{ id: "media", label: "Media", used_bytes: 4100000000 },
{ id: "addons", label: "Apps", used_bytes: 2600000000 },
{ id: "backup", label: "Backups", used_bytes: 1400000000 },
{ id: "share", label: "Share", used_bytes: 800000000 },
],
};
return data;
}
if (msg.endpoint === "/mounts") {
const data: SupervisorMounts = {
default_backup_mount: null,
mounts: [],
};
return data;
}
if (msg.endpoint === "/network/info") {
const data: NetworkInfo = {
interfaces: [
{
primary: true,
privacy: false,
interface: "eth0",
enabled: true,
type: "ethernet",
ipv4: {
address: ["192.168.1.10/24"],
gateway: "192.168.1.1",
method: "auto",
nameservers: ["192.168.1.1"],
},
wifi: null,
},
],
docker: {
address: "172.30.32.0/23",
dns: "172.30.32.3",
gateway: "172.30.32.1",
interface: "hassio",
},
};
return data;
}
if (msg.endpoint === "/store/reload") {
return null;
}
return Promise.reject(`${msg.method} ${msg.endpoint} is not implemented`);
});
hass.mockWS("hassio/update/config/info", (): SupervisorUpdateConfig => ({
add_on_backup_before_update: true,
add_on_backup_retain_copies: 1,
core_backup_before_update: true,
}));
hass.mockAPI(/^hassio\/host\/logs\/boots$/, () => ({
data: { boots: { "0": "2026-07-26T09:00:00.000000+00:00" } },
}));
hass.mockAPI(/^hassio\/addons\/[^/]+\/logs/, (_hass, _method, path) => {
const slug = path.split("/")[2];
// X-First-Cursor tells error-log-card there is nothing older to page to.
return new Response(LOGS[slug], {
headers: { "X-First-Cursor": "demo" },
});
});
};
@@ -0,0 +1,23 @@
---
title: Replaced device selectors
subtitle: How device and target selectors surface devices that were split into separate devices
---
A device that used to belong to multiple config entries is split into one
device per config entry. The original composite device is removed from the
registry, so existing references to it (targets in automations, device
selectors) point at a device that no longer exists.
When a selector holds such a reference, it shows a **replaced** state instead of
a plain "not found", and offers to point the reference at the replacement
device(s). The candidate replacements are filtered through the selector's own
filters, so in practice usually a single device matches:
- **Target selector** — the replaced device row offers **Replace**, which adds
every replacement device that matches the target filters and removes the old
reference.
- **Device selector** — when exactly one replacement matches, **Replace** swaps
to it in one click; when several match, a dialog lets you pick one.
All samples below reference the removed composite device `old_composite`, which
was split into a light device and a switch device.
@@ -0,0 +1,318 @@
import type { HassServiceTarget } from "home-assistant-js-websocket";
import type { TemplateResult } from "lit";
import { css, html, LitElement } from "lit";
import { customElement, state } from "lit/decorators";
import { mockConfigEntries } from "../../../../demo/src/stubs/config_entries";
import { mockDeviceRegistry } from "../../../../demo/src/stubs/device_registry";
import { mockEntityRegistry } from "../../../../demo/src/stubs/entity_registry";
import { mockHassioSupervisor } from "../../../../demo/src/stubs/hassio_supervisor";
import type { HASSDomEvent } from "../../../../src/common/dom/fire_event";
import "../../../../src/components/ha-selector/ha-selector";
import "../../../../src/components/ha-settings-row";
import "../../../../src/components/ha-target-picker";
import type { AreaRegistryEntry } from "../../../../src/data/area/area_registry";
import type { DeviceRegistryEntry } from "../../../../src/data/device/device_registry";
import type { EntityRegistryDisplayEntry } from "../../../../src/data/entity/entity_registry";
import type { Selector } from "../../../../src/data/selector";
import {
showDialog,
type ShowDialogParams,
} from "../../../../src/dialogs/make-dialog-manager";
import { provideHass } from "../../../../src/fake_data/provide_hass";
import type { ProvideHassElement } from "../../../../src/mixins/provide-hass-lit-mixin";
import type { HomeAssistant } from "../../../../src/types";
import "../../components/demo-black-white-row";
// The composite device "old_composite" is intentionally NOT in the registry:
// it was split into "device_light" and "device_switch". References to the old
// id (targets, device selectors) should surface a "replaced" state.
const DEVICES: DeviceRegistryEntry[] = [
{
area_id: "bedroom",
configuration_url: null,
config_entries: ["config_entry_light"],
config_entries_subentries: {},
connections: [],
disabled_by: null,
entry_type: null,
id: "device_light",
identifiers: [["demo", "light"] as [string, string]],
manufacturer: null,
model: null,
model_id: null,
name_by_user: null,
name: "Living room lamp",
sw_version: null,
hw_version: null,
via_device_id: null,
serial_number: null,
labels: [],
created_at: 0,
modified_at: 0,
primary_config_entry: null,
},
{
area_id: "backyard",
configuration_url: null,
config_entries: ["config_entry_switch"],
config_entries_subentries: {},
connections: [],
disabled_by: null,
entry_type: null,
id: "device_switch",
identifiers: [["demo", "switch"] as [string, string]],
manufacturer: null,
model: null,
model_id: null,
name_by_user: null,
name: "Garden socket",
sw_version: null,
hw_version: null,
via_device_id: null,
serial_number: null,
labels: [],
created_at: 0,
modified_at: 0,
primary_config_entry: null,
},
];
const ENTITIES = [
{
entity_id: "light.living_room_lamp",
state: "on",
attributes: { friendly_name: "Living room lamp" },
},
{
entity_id: "switch.garden_socket",
state: "off",
attributes: { friendly_name: "Garden socket" },
},
];
// Registry display entries link the demo entities to the split devices so the
// pickers can filter split candidates by domain.
const ENTITY_REGISTRY: Record<string, EntityRegistryDisplayEntry> = {
"light.living_room_lamp": {
entity_id: "light.living_room_lamp",
name: "Living room lamp",
device_id: "device_light",
area_id: "bedroom",
platform: "demo",
labels: [],
},
"switch.garden_socket": {
entity_id: "switch.garden_socket",
name: "Garden socket",
device_id: "device_switch",
area_id: "backyard",
platform: "demo",
labels: [],
},
};
const AREAS: AreaRegistryEntry[] = [
{
area_id: "backyard",
floor_id: null,
name: "Backyard",
icon: null,
picture: null,
aliases: [],
labels: [],
temperature_entity_id: null,
humidity_entity_id: null,
created_at: 0,
modified_at: 0,
},
{
area_id: "bedroom",
floor_id: null,
name: "Bedroom",
icon: "mdi:bed",
picture: null,
aliases: [],
labels: [],
temperature_entity_id: null,
humidity_entity_id: null,
created_at: 0,
modified_at: 0,
},
];
// Maps the removed composite device to the devices that replaced it.
const COMPOSITE_SPLITS = {
old_composite: {
split_ids: ["device_light", "device_switch"],
primary_id: "device_light",
},
};
interface Sample {
name: string;
description: string;
selector: Selector;
value: unknown;
// Render ha-target-picker directly in compact (chip) mode instead of the
// ha-selector, which does not expose the compact option.
compact?: boolean;
}
const SAMPLES: Sample[] = [
{
name: "Target",
description:
"Migrate adds every replacement device that matches the target filters (here both).",
selector: { target: {} },
value: { device_id: ["old_composite"] },
},
{
name: "Target (compact)",
description:
"In compact mode the replaced reference is shown as a warning chip.",
selector: { target: {} },
value: { device_id: ["old_composite"] },
compact: true,
},
{
name: "Device (unfiltered, multiple matches)",
description:
"Both replacement devices qualify, so Replace opens a dialog to pick one.",
selector: { device: {} },
value: "old_composite",
},
{
name: "Device (filtered to lights, single match)",
description:
"Only the light device passes the filter, so Replace swaps to it in one click.",
selector: { device: { entity: [{ domain: "light" }] } },
value: "old_composite",
},
{
name: "Device (multiple)",
description: "Each slot resolves independently to a matching replacement.",
selector: { device: { multiple: true } },
value: ["old_composite"],
},
];
@customElement("demo-components-ha-selector-replaced-device")
class DemoHaSelectorReplacedDevice
extends LitElement
implements ProvideHassElement
{
@state() public hass!: HomeAssistant;
private _values = SAMPLES.map((sample) => sample.value);
constructor() {
super();
const hass = provideHass(this);
hass.updateTranslations(null, "en");
hass.updateTranslations("config", "en");
hass.addEntities(ENTITIES);
mockEntityRegistry(hass);
mockDeviceRegistry(hass, DEVICES);
mockConfigEntries(hass);
mockHassioSupervisor(hass);
// Provide the demo areas and link the demo entities to the split devices.
// Set them directly via updateHass (typed against the real registry types)
// instead of the area stub, whose demo-specific type differs.
const areas: Record<string, AreaRegistryEntry> = {};
AREAS.forEach((area) => {
areas[area.area_id] = area;
});
hass.updateHass({ areas, entities: ENTITY_REGISTRY });
hass.mockWS(
"config/device_registry/list_composite_splits",
() => COMPOSITE_SPLITS
);
hass.mockWS("extract_from_target", () => ({
referenced_entities: [],
referenced_devices: [],
referenced_areas: [],
}));
hass.mockWS("auth/sign_path", (params) => params);
}
public provideHass(el) {
el.hass = this.hass;
}
public connectedCallback() {
super.connectedCallback();
this.addEventListener("show-dialog", this._dialogManager);
}
public disconnectedCallback() {
super.disconnectedCallback();
this.removeEventListener("show-dialog", this._dialogManager);
}
private _dialogManager = (e: HASSDomEvent<ShowDialogParams<unknown>>) => {
const { dialogTag, dialogImport, dialogParams, addHistory, parentElement } =
e.detail;
showDialog(
this,
dialogTag,
dialogParams,
dialogImport,
parentElement,
addHistory
);
};
protected render(): TemplateResult {
return html`
${SAMPLES.map(
(sample, idx) => html`
<demo-black-white-row .title=${sample.name}>
${["light", "dark"].map(
(slot) => html`
<ha-settings-row narrow slot=${slot}>
<span slot="heading">${sample.name}</span>
<span slot="description">${sample.description}</span>
${
sample.compact
? html`<ha-target-picker
compact
.hass=${this.hass}
.value=${this._values[idx] as HassServiceTarget}
.sampleIdx=${idx}
@value-changed=${this._handleValueChanged}
></ha-target-picker>`
: html`<ha-selector
.hass=${this.hass}
.selector=${sample.selector}
.value=${this._values[idx]}
.sampleIdx=${idx}
@value-changed=${this._handleValueChanged}
></ha-selector>`
}
</ha-settings-row>
`
)}
</demo-black-white-row>
`
)}
`;
}
private _handleValueChanged(ev) {
const idx = ev.target.sampleIdx;
this._values[idx] = ev.detail.value;
this.requestUpdate();
}
static styles = css`
ha-settings-row {
--settings-row-content-width: 100%;
}
`;
}
declare global {
interface HTMLElementTagNameMap {
"demo-components-ha-selector-replaced-device": DemoHaSelectorReplacedDevice;
}
}
+5 -5
View File
@@ -7,7 +7,7 @@
"name": "home-assistant-frontend",
"version": "1.0.0",
"scripts": {
"build": "script/build_frontend",
"build": "node build-scripts/build-manager.mjs",
"lint:eslint": "eslint \"**/src/**/*.{js,ts,html}\" --cache --cache-strategy=content --cache-location=node_modules/.cache/eslint/.eslintcache --ignore-pattern=.gitignore --max-warnings=0",
"format:eslint": "eslint \"**/src/**/*.{js,ts,html}\" --cache --cache-strategy=content --cache-location=node_modules/.cache/eslint/.eslintcache --ignore-pattern=.gitignore --fix",
"lint:prettier": "prettier . --cache --check",
@@ -186,14 +186,14 @@
"fs-extra": "11.4.0",
"generate-license-file": "4.2.1",
"glob": "13.0.6",
"globals": "17.7.0",
"globals": "17.8.0",
"gulp": "5.0.1",
"gulp-brotli": "3.0.0",
"gulp-json-transform": "0.5.0",
"gulp-rename": "2.1.0",
"html-minifier-terser": "7.2.0",
"husky": "9.1.7",
"jsdom": "29.1.1",
"jsdom": "30.0.0",
"jszip": "3.10.1",
"license-checker-rseidelsohn": "5.0.1",
"lint-staged": "17.2.0",
@@ -224,12 +224,12 @@
"clean-css": "5.3.3",
"@lit/reactive-element": "2.1.2",
"@fullcalendar/daygrid": "6.1.21",
"globals": "17.7.0",
"globals": "17.8.0",
"tslib": "2.8.1",
"@material/mwc-list@^0.27.0": "patch:@material/mwc-list@npm%3A0.27.0#~/.yarn/patches/@material-mwc-list-npm-0.27.0-5344fc9de4.patch"
},
"packageManager": "yarn@4.17.1",
"volta": {
"node": "24.18.0"
"node": "24.18.1"
}
}
@@ -5,19 +5,19 @@
.dot-top { animation: pulse-top 1300ms 350ms linear infinite; }
@keyframes pulse-left {
0% { transform: translate(50px, 69.634804px) scale(1); animation-timing-function: cubic-bezier(.39, .575, .565, 1); }
15.384615% { transform: translate(50px, 69.634804px) scale(1.2); animation-timing-function: cubic-bezier(.39, .575, .565, 1); }
15.384615% { transform: translate(50px, 69.634804px) scale(1.3); animation-timing-function: cubic-bezier(.39, .575, .565, 1); }
38.461538%, 100% { transform: translate(50px, 69.634804px) scale(1); }
}
@keyframes pulse-right {
0%, 15.384615% { transform: translate(90px, 58.634798px) scale(1); }
15.384615% { animation-timing-function: cubic-bezier(.39, .575, .565, 1); }
30.769231% { transform: translate(90px, 58.634798px) scale(1.2); animation-timing-function: cubic-bezier(.39, .575, .565, 1); }
30.769231% { transform: translate(90px, 58.634798px) scale(1.3); animation-timing-function: cubic-bezier(.39, .575, .565, 1); }
53.846154%, 100% { transform: translate(90px, 58.634798px) scale(1); }
}
@keyframes pulse-top {
0%, 30.769231% { transform: translate(70px, 37.6348px) scale(1); }
30.769231% { animation-timing-function: cubic-bezier(.39, .575, .565, 1); }
46.153846% { transform: translate(70px, 37.6348px) scale(1.2); animation-timing-function: cubic-bezier(.39, .575, .565, 1); }
46.153846% { transform: translate(70px, 37.6348px) scale(1.3); animation-timing-function: cubic-bezier(.39, .575, .565, 1); }
69.230769%, 100% { transform: translate(70px, 37.6348px) scale(1); }
}
</style>

Before

Width:  |  Height:  |  Size: 2.7 KiB

After

Width:  |  Height:  |  Size: 2.7 KiB

+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "home-assistant-frontend"
version = "20260624.0"
version = "20260729.0"
license = "Apache-2.0"
license-files = ["LICENSE*"]
description = "The Home Assistant frontend"
@@ -0,0 +1,15 @@
import type { EntityIdPart } from "../../data/entity_id_format";
import { slugify } from "../string/slugify";
export const computeEntityIdFormatExample = (
format: EntityIdPart[],
examples: Record<EntityIdPart, string>
): string => {
const parts = format
.map((item) => examples[item])
.filter(Boolean)
.map((part) => slugify(part, "_"))
.filter(Boolean);
return parts.join("_") || "unknown";
};
@@ -1,8 +1,29 @@
let supported: boolean | undefined;
const detect = (): boolean => {
if (
!globalThis.ElementInternals ||
!globalThis.HTMLElement?.prototype.attachInternals
) {
return false;
}
// Native internals keep their WebIDL brand even when `attachInternals` is
// wrapped (e.g. by `@webcomponents/scoped-custom-element-registry`, which
// broke the previous `[native code]` source check in the app bundle).
// `element-internals-polyfill` swaps in a plain class, which has no brand,
// and must not count as native: login on legacy browsers relies on
// validation being skipped there (#51338).
return (
Object.prototype.toString.call(globalThis.ElementInternals.prototype) ===
"[object ElementInternals]"
);
};
/**
* Indicates whether the current browser has native ElementInternals support.
* Probed on first use so importing this module has no side effects.
*/
export const nativeElementInternalsSupported =
Boolean(globalThis.ElementInternals) &&
globalThis.HTMLElement?.prototype.attachInternals
?.toString()
.includes("[native code]");
export const supportsNativeElementInternals = (): boolean => {
supported ??= detect();
return supported;
};
@@ -0,0 +1,26 @@
import { mainWindow } from "../dom/get_main_window";
/**
* Checks that a path resolves to the origin the frontend is served from, so it
* is safe to use as a link target. Rejects URIs that carry their own scheme,
* like `javascript:`, and URLs pointing at another origin. Resolves against the
* main window, because that is where `navigate()` applies the path.
*/
const isSameOriginPath = (path: string): boolean => {
try {
const { origin } = mainWindow.location;
return new URL(path, origin).origin === origin;
} catch (_err) {
return false;
}
};
/**
* Returns the path if it is safe to navigate to, `undefined` otherwise. Use for
* paths that can be influenced by a URL parameter or by dashboard config before
* they end up in an `href` or in `navigate()`.
*/
export const sanitizeNavigationPath = (
path: string | null | undefined
): string | undefined =>
path != null && isSameOriginPath(path) ? path : undefined;
+1 -1
View File
@@ -126,7 +126,7 @@ export class HaProgressButton extends LitElement {
visibility: hidden;
}
:host([appearance="brand"]) ha-svg-icon {
.progress ha-svg-icon {
color: var(--white-color);
}
`;
+1
View File
@@ -27,6 +27,7 @@ export class HaInputChip extends InputChip {
);
--ha-input-chip-selected-container-opacity: 1;
--md-input-chip-label-text-font: Roboto, sans-serif;
--md-input-chip-label-text-weight: 400;
}
/** Set the size of mdc icons **/
::slotted([slot="icon"]) {
+39 -33
View File
@@ -12,6 +12,7 @@ import {
} from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import { ifDefined } from "lit/directives/if-defined";
import { join } from "lit/directives/join";
import { styleMap } from "lit/directives/style-map";
import memoizeOne from "memoize-one";
import { STRINGS_SEPARATOR_DOT } from "../../common/const";
@@ -483,13 +484,7 @@ export class HaDataTable extends LitElement {
: ""
}
${Object.entries(columns).map(([key, column]) => {
if (
column.hidden ||
(this.columnOrder && this.columnOrder.includes(key)
? (this.hiddenColumns?.includes(key) ??
column.defaultHidden)
: column.defaultHidden)
) {
if (!this._isColumnVisible(key, column)) {
return nothing;
}
const sorted = key === this.sortColumn;
@@ -657,10 +652,7 @@ export class HaDataTable extends LitElement {
${Object.entries(columns).map(([key, column]) => {
if (
(narrow && !column.main && !column.showNarrow) ||
column.hidden ||
(this.columnOrder && this.columnOrder.includes(key)
? (this.hiddenColumns?.includes(key) ?? column.defaultHidden)
: column.defaultHidden)
!this._isColumnVisible(key, column)
) {
return nothing;
}
@@ -692,28 +684,19 @@ export class HaDataTable extends LitElement {
: narrow && column.main
? html`<div class="primary">${row[key]}</div>
<div class="secondary">
${Object.entries(columns)
.filter(
([key2, column2]) =>
!column2.hidden &&
!column2.main &&
!column2.showNarrow &&
!(this.columnOrder &&
this.columnOrder.includes(key2)
? (this.hiddenColumns?.includes(key2) ??
column2.defaultHidden)
: column2.defaultHidden)
)
.map(
([key2, column2], i) =>
html`${
i !== 0 ? STRINGS_SEPARATOR_DOT : nothing
}${
column2.template
? column2.template(row)
: row[key2]
}`
)}
${join(
Object.entries(columns)
.filter(([key2, column2]) =>
this._isSecondaryColumnVisible(key2, column2)
)
.map(([key2, column2]) =>
column2.template
? column2.template(row)
: row[key2]
)
.filter(this._hasCellValue),
STRINGS_SEPARATOR_DOT
)}
</div>
${
column.extraTemplate
@@ -733,6 +716,29 @@ export class HaDataTable extends LitElement {
`;
};
private _isColumnVisible(key: string, column: DataTableColumnData): boolean {
if (column.hidden) {
return false;
}
if (!this.columnOrder?.includes(key)) {
return !column.defaultHidden;
}
return !(this.hiddenColumns?.includes(key) ?? column.defaultHidden);
}
private _isSecondaryColumnVisible(
key: string,
column: DataTableColumnData
): boolean {
if (column.main || column.showNarrow) {
return false;
}
return this._isColumnVisible(key, column);
}
private _hasCellValue = (value: unknown): boolean =>
value !== undefined && value !== null && value !== "" && value !== nothing;
private async _sortFilterData() {
const startTime = new Date().getTime();
const timeBetweenUpdate = startTime - this._lastUpdate;
@@ -0,0 +1,190 @@
import { mdiDevices } from "@mdi/js";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import { fireEvent } from "../../common/dom/fire_event";
import { computeAreaName } from "../../common/entity/compute_area_name";
import { computeDeviceName } from "../../common/entity/compute_device_name";
import { getDeviceArea } from "../../common/entity/context/get_device_context";
import { getConfigEntries, type ConfigEntry } from "../../data/config_entries";
import { domainToName } from "../../data/integration";
import type { HomeAssistant } from "../../types";
import type { HassDialog } from "../../dialogs/make-dialog-manager";
import { brandsUrl } from "../../util/brands-url";
import "../ha-dialog";
import "../ha-svg-icon";
import "../item/ha-list-item-button";
import "../list/ha-list-base";
import type { DeviceReplacedDialogParams } from "./show-dialog-device-replaced";
@customElement("dialog-device-replaced")
export class DialogDeviceReplaced
extends LitElement
implements HassDialog<DeviceReplacedDialogParams>
{
@state() private _params?: DeviceReplacedDialogParams;
@state() private _open = false;
@state() private _configEntryLookup?: Record<string, ConfigEntry>;
@property({ attribute: false }) public hass!: HomeAssistant;
public async showDialog(params: DeviceReplacedDialogParams): Promise<void> {
this._params = params;
this._open = true;
this._loadConfigEntries();
}
private async _loadConfigEntries(): Promise<void> {
const configEntries = await getConfigEntries(this.hass);
this._configEntryLookup = Object.fromEntries(
configEntries.map((entry) => [entry.entry_id, entry])
);
}
public closeDialog(): boolean {
this._open = false;
return true;
}
private _dialogClosed(): void {
this._params = undefined;
fireEvent(this, "dialog-closed", { dialog: this.localName });
}
private _pick(ev: Event): void {
const item = (ev.target as HTMLElement).closest("ha-list-item-button") as
(HTMLElement & { deviceId?: string }) | null;
if (!item?.deviceId) {
return;
}
this._params?.onResolved(item.deviceId);
this.closeDialog();
}
private _items = memoizeOne(
(
candidates: string[],
primaryId: string | null,
devices: HomeAssistant["devices"],
areas: HomeAssistant["areas"],
configEntryLookup: Record<string, ConfigEntry> | undefined
) =>
candidates.map((deviceId) => {
const device = devices[deviceId];
const area = device ? getDeviceArea(device, areas) : undefined;
const configEntry = device?.primary_config_entry
? configEntryLookup?.[device.primary_config_entry]
: undefined;
return {
deviceId,
name: device ? computeDeviceName(device) : deviceId,
area: area ? computeAreaName(area) : undefined,
domain: configEntry?.domain,
domainName: configEntry
? domainToName(this.hass.localize, configEntry.domain)
: undefined,
isPrimary: deviceId === primaryId,
};
})
);
protected render() {
if (!this._params || !this.hass) {
return nothing;
}
return html`
<ha-dialog
.open=${this._open}
.headerTitle=${this.hass.localize(
"ui.components.device-picker.replaced_dialog.title"
)}
@closed=${this._dialogClosed}
>
<p class="description">
${this.hass.localize(
"ui.components.device-picker.replaced_dialog.description"
)}
</p>
<ha-list-base @click=${this._pick}>
${this._items(
this._params.candidates,
this._params.primaryId,
this.hass.devices,
this.hass.areas,
this._configEntryLookup
).map((item) => {
const supportingText = [
item.area,
item.domainName,
item.isPrimary
? this.hass.localize(
"ui.components.device-picker.replaced_dialog.recommended"
)
: undefined,
]
.filter(Boolean)
.join(" • ");
return html`
<ha-list-item-button .deviceId=${item.deviceId}>
${
item.domain
? html`<img
slot="start"
alt=""
crossorigin="anonymous"
referrerpolicy="no-referrer"
src=${brandsUrl(
{
domain: item.domain,
type: "icon",
darkOptimized: this.hass.themes?.darkMode,
},
this.hass.auth.data.hassUrl
)}
/>`
: html`<ha-svg-icon
slot="start"
.path=${mdiDevices}
></ha-svg-icon>`
}
<span slot="headline">${item.name}</span>
${
supportingText
? html`<span slot="supporting-text"
>${supportingText}</span
>`
: nothing
}
</ha-list-item-button>
`;
})}
</ha-list-base>
</ha-dialog>
`;
}
static styles = css`
ha-dialog {
--dialog-content-padding: 0;
--ha-row-item-padding-inline: var(--ha-space-6);
}
.description {
margin: 0;
padding: 0 var(--ha-space-6) var(--ha-space-4);
color: var(--secondary-text-color);
}
img[slot="start"] {
width: 24px;
height: 24px;
}
`;
}
declare global {
interface HTMLElementTagNameMap {
"dialog-device-replaced": DialogDeviceReplaced;
}
}
@@ -12,6 +12,7 @@ import { fullEntitiesContext } from "../../data/context";
import type { DeviceAutomation } from "../../data/device/device_automation";
import {
deviceAutomationsEqual,
deviceAutomationsSimilar,
sortDeviceAutomations,
} from "../../data/device/device_automation";
import type { EntityRegistryEntry } from "../../data/entity/entity_registry";
@@ -201,12 +202,22 @@ export abstract class HaDeviceAutomationPicker<
: // No device, clear the list of automations
[];
// If there is no value, or if we have changed the device ID, reset the value.
// If there is no value, or if we have changed the device ID, reset the
// value. When the device changed (for example after replacing a removed
// device), try to keep the same automation type/subtype on the new device
// before falling back to the first available automation.
if (!this.value || this.value.device_id !== this.deviceId) {
const equivalent =
this.value && this.deviceId
? this._automations.find((automation) =>
deviceAutomationsSimilar(automation, this.value!)
)
: undefined;
this._setValue(
this._automations.length
? this._automations[0]
: this._createNoAutomation(this.deviceId)
equivalent ||
(this._automations.length
? this._automations[0]
: this._createNoAutomation(this.deviceId))
);
}
this._renderEmpty = true;
+244 -46
View File
@@ -1,6 +1,7 @@
import { mdiAlertOutline } from "@mdi/js";
import type { RenderItemFunction } from "@lit-labs/virtualizer/virtualize";
import type { HassEntity } from "home-assistant-js-websocket";
import { html, LitElement, nothing, type PropertyValues } from "lit";
import { css, html, LitElement, nothing, type PropertyValues } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import { fireEvent } from "../../common/dom/fire_event";
@@ -13,12 +14,20 @@ import {
getDevices,
type DevicePickerItem,
} from "../../data/device/device_picker";
import type { DeviceRegistryEntry } from "../../data/device/device_registry";
import {
fetchDeviceCompositeSplits,
type DeviceCompositeSplits,
type DeviceRegistryEntry,
} from "../../data/device/device_registry";
import type { HaEntityPickerEntityFilterFunc } from "../../data/entity/entity";
import type { HomeAssistant } from "../../types";
import { brandsUrl } from "../../util/brands-url";
import "../ha-alert";
import "../ha-button";
import "../ha-generic-picker";
import type { HaGenericPicker } from "../ha-generic-picker";
import "../ha-svg-icon";
import { showDeviceReplacedDialog } from "./show-dialog-device-replaced";
export type HaDevicePickerDeviceFilterFunc = (
device: DeviceRegistryEntry
@@ -95,6 +104,10 @@ export class HaDevicePicker extends LitElement {
@state() private _configEntryLookup: Record<string, ConfigEntry> = {};
@state() private _compositeSplits?: DeviceCompositeSplits;
private _loadingCompositeSplits = false;
private _getDevicesMemoized = memoizeOne(
(
_devices: HomeAssistant["devices"],
@@ -123,6 +136,29 @@ export class HaDevicePicker extends LitElement {
this._loadConfigEntries();
}
protected willUpdate(changedProperties: PropertyValues<this>): void {
if (
!this.hass ||
!this.value ||
this._compositeSplits !== undefined ||
this._loadingCompositeSplits
) {
return;
}
const oldHass = changedProperties.get("hass") as HomeAssistant | undefined;
const devicesChanged =
changedProperties.has("hass") && this.hass.devices !== oldHass?.devices;
if (
(changedProperties.has("value") || devicesChanged) &&
!this.hass.devices[this.value]
) {
// The selected device is not in the registry; it might be a legacy
// composite device that was split into separate devices. Fetch the
// split map so we can offer to replace the reference.
this._loadCompositeSplits();
}
}
private async _loadConfigEntries() {
const configEntries = await getConfigEntries(this.hass);
this._configEntryLookup = Object.fromEntries(
@@ -130,6 +166,43 @@ export class HaDevicePicker extends LitElement {
);
}
private async _loadCompositeSplits() {
this._loadingCompositeSplits = true;
try {
this._compositeSplits = await fetchDeviceCompositeSplits(this.hass);
} catch (_err) {
this._compositeSplits = {};
} finally {
this._loadingCompositeSplits = false;
}
}
private _getReplacement = memoizeOne(
(
value: string | undefined,
_devices: HomeAssistant["devices"],
compositeSplits: DeviceCompositeSplits | undefined,
items: (DevicePickerItem | string)[]
) => {
if (!value || !compositeSplits || this.hass.devices[value]) {
return undefined;
}
const split = compositeSplits[value];
if (!split) {
return undefined;
}
// Keep only the split devices that pass this picker's filters. In
// practice usually exactly one of the split devices matches.
const selectableIds = new Set(
items
.filter((item): item is DevicePickerItem => typeof item !== "string")
.map((item) => item.id)
);
const candidates = split.split_ids.filter((id) => selectableIds.has(id));
return { candidates, primaryId: split.primary_id };
}
);
private _getItems = () =>
this._getDevicesMemoized(
this.hass.devices,
@@ -144,49 +217,66 @@ export class HaDevicePicker extends LitElement {
);
private _valueRenderer = memoizeOne(
(configEntriesLookup: Record<string, ConfigEntry>) => (value: string) => {
const deviceId = value;
const device = this.hass.devices[deviceId];
(
configEntriesLookup: Record<string, ConfigEntry>,
replacementName: string | undefined
) =>
(value: string) => {
const deviceId = value;
const device = this.hass.devices[deviceId];
if (!device) {
return html`<span slot="headline">${deviceId}</span>`;
}
const area = getDeviceArea(device, this.hass.areas);
const deviceName = device ? computeDeviceName(device) : undefined;
const areaName = area ? computeAreaName(area) : undefined;
const primary = deviceName;
const secondary = areaName;
const configEntry = device.primary_config_entry
? configEntriesLookup[device.primary_config_entry]
: undefined;
return html`
${
configEntry
? html`<img
if (!device) {
// When the device was replaced and a replacement is available, show
// the replacement device's name. Otherwise fall back to the normal
// "not found" display of the raw id.
if (replacementName) {
return html`
<ha-svg-icon
slot="start"
alt=""
crossorigin="anonymous"
referrerpolicy="no-referrer"
src=${brandsUrl(
{
domain: configEntry.domain,
type: "icon",
darkOptimized: this.hass.themes?.darkMode,
},
this.hass.auth.data.hassUrl
)}
/>`
: nothing
style="color: var(--warning-color)"
.path=${mdiAlertOutline}
></ha-svg-icon>
<span slot="headline">${replacementName}</span>
`;
}
return html`<span slot="headline">${deviceId}</span>`;
}
<span slot="headline">${primary}</span>
<span slot="supporting-text">${secondary}</span>
`;
}
const area = getDeviceArea(device, this.hass.areas);
const deviceName = device ? computeDeviceName(device) : undefined;
const areaName = area ? computeAreaName(area) : undefined;
const primary = deviceName;
const secondary = areaName;
const configEntry = device.primary_config_entry
? configEntriesLookup[device.primary_config_entry]
: undefined;
return html`
${
configEntry
? html`<img
slot="start"
alt=""
crossorigin="anonymous"
referrerpolicy="no-referrer"
src=${brandsUrl(
{
domain: configEntry.domain,
type: "icon",
darkOptimized: this.hass.themes?.darkMode,
},
this.hass.auth.data.hassUrl
)}
/>`
: nothing
}
<span slot="headline">${primary}</span>
<span slot="supporting-text">${secondary}</span>
`;
}
);
private _rowRenderer: RenderItemFunction<DevicePickerItem> = (item) => html`
@@ -235,7 +325,39 @@ export class HaDevicePicker extends LitElement {
this.placeholder ??
this.hass.localize("ui.components.device-picker.placeholder");
const valueRenderer = this._valueRenderer(this._configEntryLookup);
// Only resolve a replacement (which needs the full item list) when the
// value is a missing device that we know was replaced, to avoid computing
// the item list on every render for the common case.
const replacement =
this.value &&
!this.hass.devices[this.value] &&
this._compositeSplits?.[this.value]
? this._getReplacement(
this.value,
this.hass.devices,
this._compositeSplits,
this._getItems()
)
: undefined;
// Only treat the value as "replaced" when there is an available
// replacement device; otherwise fall back to normal "not found" behavior.
const canReplace = !!replacement?.candidates.length;
const replacementName = canReplace
? computeDeviceName(
this.hass.devices[
replacement!.primaryId &&
replacement!.candidates.includes(replacement!.primaryId)
? replacement!.primaryId
: replacement!.candidates[0]
]
)
: undefined;
const valueRenderer = this._valueRenderer(
this._configEntryLookup,
replacementName
);
return html`
<ha-generic-picker
@@ -256,15 +378,84 @@ export class HaDevicePicker extends LitElement {
.hideClearIcon=${this.hideClearIcon}
.valueRenderer=${valueRenderer}
.searchKeys=${deviceComboBoxKeys}
.unknownItemText=${this.hass.localize(
"ui.components.device-picker.unknown"
)}
.unknownItemText=${
replacement?.candidates.length
? this.hass.localize(
"ui.components.device-picker.device_replaced_count",
{ count: replacement.candidates.length }
)
: this.hass.localize("ui.components.device-picker.unknown")
}
@value-changed=${this._valueChanged}
>
</ha-generic-picker>
${canReplace ? this._renderReplacedAlert(replacement!) : nothing}
`;
}
private _renderReplacedAlert(replacement: {
candidates: string[];
primaryId: string | null;
}) {
const { candidates } = replacement;
const replacementName =
candidates.length === 1
? computeDeviceName(this.hass.devices[candidates[0]])
: undefined;
return html`
<ha-alert alert-type="warning">
${
replacementName
? this.hass.localize(
"ui.components.device-picker.device_replaced_by_one",
{ device: replacementName }
)
: this.hass.localize(
"ui.components.device-picker.device_replaced_by_multiple",
{ count: candidates.length }
)
}
<ha-button
slot="action"
appearance="plain"
@click=${this._handleReplace}
>
${this.hass.localize("ui.components.device-picker.replace_device")}
</ha-button>
</ha-alert>
`;
}
private _handleReplace = () => {
const replacement = this._getReplacement(
this.value,
this.hass.devices,
this._compositeSplits,
this._getItems()
);
if (!replacement?.candidates.length) {
return;
}
const { candidates, primaryId } = replacement;
if (candidates.length === 1) {
this._setValue(candidates[0]);
return;
}
showDeviceReplacedDialog(this, {
originalDeviceId: this.value!,
candidates,
primaryId,
onResolved: (deviceId) => this._setValue(deviceId),
});
};
private _setValue(value: string) {
this.value = value;
fireEvent(this, "value-changed", { value });
}
public async open() {
await this.updateComplete;
await this._picker?.open();
@@ -281,6 +472,13 @@ export class HaDevicePicker extends LitElement {
this.hass.localize("ui.components.device-picker.no_match", {
term: html`<b>${search}</b>`,
});
static styles = css`
ha-alert {
display: block;
margin-top: 8px;
}
`;
}
declare global {
@@ -0,0 +1,22 @@
import { fireEvent } from "../../common/dom/fire_event";
export interface DeviceReplacedDialogParams {
/** The removed composite device that is being referenced. */
originalDeviceId: string;
/** The split devices the reference can be pointed at. */
candidates: string[];
/** The split device that took over the composite's primary config entry. */
primaryId: string | null;
/** Called with the device the user picked as replacement. */
onResolved: (deviceId: string) => void;
}
export const showDeviceReplacedDialog = (
element: HTMLElement,
params: DeviceReplacedDialogParams
) =>
fireEvent(element, "show-dialog", {
dialogTag: "dialog-device-replaced",
dialogImport: () => import("./dialog-device-replaced"),
dialogParams: params,
});
@@ -85,6 +85,10 @@ export class HaStatisticPicker extends LitElement {
@property() public helper?: string;
@property({ attribute: "error-message" }) public errorMessage?: string;
@property({ type: Boolean }) public invalid = false;
@property() public placeholder?: string;
@property({ attribute: "statistic-types" })
@@ -526,6 +530,9 @@ export class HaStatisticPicker extends LitElement {
.autofocus=${this.autofocus}
.allowCustomValue=${this.allowCustomEntity}
.disabled=${this.disabled}
.required=${this.required}
.invalid=${this.invalid}
.errorMessage=${this.errorMessage}
.label=${this.label}
use-top-label
.placeholder=${placeholder}
+10 -2
View File
@@ -57,7 +57,10 @@ import "./ha-code-editor-completion-items";
import type { CompletionItem } from "./ha-code-editor-completion-items";
import "./ha-icon";
import "./ha-icon-button-toolbar";
import type { HaIconButtonToolbar } from "./ha-icon-button-toolbar";
import type {
HaIconButtonToolbar,
HaIconButtonToolbarItem,
} from "./ha-icon-button-toolbar";
declare global {
interface HASSDomEvents {
@@ -115,6 +118,9 @@ export class HaCodeEditor extends ReactiveElement {
@property({ type: Boolean, attribute: "has-test" })
public hasTest = false;
@property({ attribute: false })
public toolbarItems?: (HaIconButtonToolbarItem | string)[];
@property({ attribute: false }) public testing = false;
@property({ type: String }) public placeholder?: string;
@@ -351,7 +357,8 @@ export class HaCodeEditor extends ReactiveElement {
changedProps.has("_canCopy") ||
changedProps.has("_canUndo") ||
changedProps.has("_canRedo") ||
changedProps.has("testing")
changedProps.has("testing") ||
changedProps.has("toolbarItems")
) {
this._updateToolbarButtons();
}
@@ -529,6 +536,7 @@ export class HaCodeEditor extends ReactiveElement {
}
this._editorToolbar.items = [
...(this.toolbarItems ?? []),
...(this.hasTest && !this._isFullscreen
? [
{
+37 -10
View File
@@ -1,6 +1,7 @@
import { ResizeController } from "@lit-labs/observers/resize-controller";
import type { PropertyValues } from "lit";
import { css, LitElement, svg } from "lit";
import { customElement, property, state } from "lit/decorators";
import { customElement, property, query, state } from "lit/decorators";
import { styleMap } from "lit/directives/style-map";
import { formatNumber } from "../common/number/format_number";
import { blankBeforePercent } from "../common/translations/blank_before_percent";
@@ -46,15 +47,32 @@ export class HaGauge extends LitElement {
@state() private _segment_label?: string = "";
@query(".text") private _textSvg?: SVGSVGElement;
@query(".value-text") private _valueText?: SVGTextElement;
private _sortedLevels?: LevelDefinition[];
private _rescaleOnConnect = false;
// Set when the value text could not be measured because we have no layout box
// yet, either disconnected or inside a hidden container.
private _rescalePending = false;
// Measure again once we get a layout box, e.g. when a section hidden by a
// visibility condition is revealed. Nothing else re-renders the gauge then.
// @ts-ignore side-effect-only controller, its value is never read
private _resizeController = new ResizeController(this, {
skipInitial: true,
callback: (entries) => {
if (this._rescalePending && entries[0]?.contentRect.width) {
this._rescaleSvg();
}
},
});
public connectedCallback(): void {
super.connectedCallback();
if (this._rescaleOnConnect) {
if (this._rescalePending && this.hasUpdated) {
this._rescaleSvg();
this._rescaleOnConnect = false;
}
}
@@ -221,15 +239,22 @@ export class HaGauge extends LitElement {
// fit the text
// That way it will auto-scale correctly
if (!this.isConnected) {
// Retry this later if we're disconnected, otherwise we get a 0 bbox and missing label
this._rescaleOnConnect = true;
if (!this._textSvg || !this._valueText || !this.isConnected) {
this._rescalePending = true;
return;
}
const svgRoot = this.shadowRoot!.querySelector(".text")!;
const box = svgRoot.querySelector("text")!.getBBox()!;
svgRoot.setAttribute(
const box = this._valueText.getBBox();
// An empty box means we have no layout, so keep the last known good viewBox
// and retry later. A viewBox with a 0 width or height would hide the label.
if (!box.width || !box.height) {
this._rescalePending = true;
return;
}
this._rescalePending = false;
this._textSvg.setAttribute(
"viewBox",
`${box.x} ${box.y} ${box.width} ${box.height}`
);
@@ -248,6 +273,8 @@ export class HaGauge extends LitElement {
static styles = css`
:host {
/* a non replaced inline element never reports a size to a resize observer */
display: block;
position: relative;
}
+17 -3
View File
@@ -304,14 +304,21 @@ export class HaGenericPicker extends PickerMixin(LitElement) {
private _renderHelper() {
const showError = this.invalid && this.errorMessage;
const showHelper = !showError && this.helper;
if (!showError && !showHelper) {
if (!showError && !this.helper) {
return nothing;
}
return html`<ha-input-helper-text .disabled=${this.disabled}>
${showError ? this.errorMessage : this.helper}
${
showError
? html`<span class="error">${this.errorMessage}</span> ${
this.helper
? html`<span class="helper">${this.helper}</span>`
: nothing
}`
: this.helper
}
</ha-input-helper-text>`;
}
@@ -448,6 +455,13 @@ export class HaGenericPicker extends PickerMixin(LitElement) {
:host([invalid]) ha-input-helper-text {
color: var(--mdc-theme-error, var(--error-color, #b00020));
}
ha-input-helper-text .error,
ha-input-helper-text .helper {
display: block;
}
ha-input-helper-text .helper {
color: var(--secondary-text-color);
}
wa-popover {
--wa-space-l: 0;
+9 -8
View File
@@ -1,6 +1,7 @@
import type { PropertyValues } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { styleMap } from "lit/directives/style-map";
import type { HomeAssistant } from "../types";
import { subscribeLabFeature } from "../data/labs";
import { SubscribeMixin } from "../mixins/subscribe-mixin";
@@ -81,14 +82,14 @@ export class HaSnowflakes extends SubscribeMixin(LitElement) {
class="snowflake ${
this.narrow && flake.id >= 30 ? "hide-narrow" : ""
}"
style="
left: ${flake.left}%;
width: ${flake.size}px;
height: ${flake.size}px;
animation-duration: ${flake.duration}s;
animation-delay: ${flake.delay}s;
--rotation: ${flake.rotation}deg;
"
style=${styleMap({
left: `${flake.left}%`,
width: `${flake.size}px`,
height: `${flake.size}px`,
"animation-duration": `${flake.duration}s`,
"animation-delay": `${flake.delay}s`,
"--rotation": `${flake.rotation}deg`,
})}
viewBox="0 0 16 16"
fill="none"
xmlns="http://www.w3.org/2000/svg"
+68
View File
@@ -0,0 +1,68 @@
import SplitPanel from "@home-assistant/webawesome/dist/components/split-panel/split-panel";
import type { CSSResultGroup } from "lit";
import { css } from "lit";
import { customElement } from "lit/decorators";
@customElement("ha-split-panel")
export class HaSplitPanel extends SplitPanel {
static get styles(): CSSResultGroup {
return [
SplitPanel.styles,
css`
:host {
--divider-width: var(--ha-split-panel-divider-width, 2px);
--divider-hit-area: var(--ha-split-panel-divider-hit-area, 12px);
--min: var(--ha-split-panel-min, 0);
--max: var(--ha-split-panel-max, 100%);
}
.divider {
background-color: var(--divider-color);
transition: background-color var(--ha-animation-duration-fast)
ease-out;
}
/* Grip affordance so the divider reads as draggable. The divider
already centers its children via flexbox, so keep this in flow.
Consumers slotting their own divider handle can hide it with
--ha-split-panel-grip-display: none. */
.divider::before {
content: "";
width: 2px;
height: var(--ha-space-8);
display: var(--ha-split-panel-grip-display, block);
border-radius: var(--ha-border-radius-pill, 9999px);
background-color: var(--secondary-text-color);
opacity: 0.5;
transition: opacity var(--ha-animation-duration-fast) ease-out;
}
/* In vertical orientation the divider is horizontal, so the grip pill
lies flat instead of standing upright. */
:host([orientation="vertical"]) .divider::before {
width: var(--ha-space-8);
height: 2px;
}
@media (hover: hover) {
:host(:not([disabled])) .divider:hover {
background-color: var(--primary-color);
}
:host(:not([disabled])) .divider:hover::before {
opacity: 1;
}
}
:host(:not([disabled])) .divider:focus-visible {
background-color: var(--primary-color);
}
`,
];
}
}
declare global {
interface HTMLElementTagNameMap {
"ha-split-panel": HaSplitPanel;
}
}
+55
View File
@@ -27,6 +27,10 @@ import {
getDevices,
type DevicePickerItem,
} from "../data/device/device_picker";
import {
fetchDeviceCompositeSplits,
type DeviceCompositeSplits,
} from "../data/device/device_registry";
import type { HaEntityPickerEntityFilterFunc } from "../data/entity/entity";
import {
entityComboBoxKeys,
@@ -122,6 +126,10 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
@state() private _configEntryLookup: Record<string, ConfigEntry> = {};
@state() private _compositeSplits?: DeviceCompositeSplits;
private _loadingCompositeSplits = false;
@state()
@consume({ context: labelsContext, subscribe: true })
private _labelRegistry!: LabelRegistryEntry[];
@@ -211,6 +219,24 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
this._loadConfigEntries();
}
const devicesChanged =
changedProps.has("hass") &&
this.hass.devices !== changedProps.get("hass")?.devices;
if (
(changedProps.has("value") || devicesChanged) &&
this.hass &&
this._compositeSplits === undefined &&
!this._loadingCompositeSplits &&
this.value?.device_id &&
ensureArray(this.value.device_id).some(
(deviceId) => !this.hass.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();
}
if (
this._pendingEntityId &&
changedProps.has("hass") &&
@@ -297,6 +323,7 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
.hass=${this.hass}
type="device"
.itemId=${device_id}
.compositeSplits=${this._compositeSplits}
@remove-target-item=${this._handleRemove}
@expand-target-item=${this._handleExpand}
></ha-target-picker-value-chip>
@@ -390,6 +417,7 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
<ha-target-picker-item-group
@remove-target-item=${this._handleRemove}
@replace-target-item=${this._handleReplace}
@migrate-target-item=${this._handleMigrate}
type="device"
.hass=${this.hass}
.items=${{ device: deviceIds }}
@@ -398,6 +426,7 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
.includeDomains=${this.includeDomains}
.includeDeviceClasses=${this.includeDeviceClasses}
.primaryEntitiesOnly=${this.primaryEntitiesOnly}
.compositeSplits=${this._compositeSplits}
>
</ha-target-picker-item-group>
`
@@ -1175,6 +1204,31 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
);
}
private async _loadCompositeSplits() {
this._loadingCompositeSplits = true;
try {
this._compositeSplits = await fetchDeviceCompositeSplits(this.hass);
} catch (_err) {
this._compositeSplits = {};
} finally {
this._loadingCompositeSplits = false;
}
}
private _handleMigrate(
ev: HASSDomEvent<HASSDomEvents["migrate-target-item"]>
) {
const { id, replacements } = ev.detail;
let value = this._removeItem(this.value, "device", id);
for (const replacement of replacements) {
value = this._addTargetToValue(value, {
type: "device",
id: replacement,
});
}
fireEvent(this, "value-changed", { value });
}
private _renderRow = (
item:
| PickerComboBoxItem
@@ -1357,6 +1411,7 @@ declare global {
"remove-target-item": TargetItem;
"expand-target-item": TargetItem;
"replace-target-item": TargetItem;
"migrate-target-item": { id: string; replacements: string[] };
"remove-target-group": string;
}
}
+9 -5
View File
@@ -1,7 +1,7 @@
import { type LitElement, css } from "lit";
import { property, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import { nativeElementInternalsSupported } from "../../common/feature-detect/support-native-element-internals";
import { supportsNativeElementInternals } from "../../common/feature-detect/support-native-element-internals";
import type { Constructor } from "../../types";
/**
@@ -198,7 +198,7 @@ export const WaInputMixin = <T extends Constructor<LitElement>>(
}
public checkValidity(): boolean {
return nativeElementInternalsSupported
return supportsNativeElementInternals()
? (this._formControl?.checkValidity() ?? true)
: true;
}
@@ -211,7 +211,7 @@ export const WaInputMixin = <T extends Constructor<LitElement>>(
protected _handleInput(): void {
this.value = this._formControl?.value ?? undefined;
if (this._invalid && this._formControl?.checkValidity()) {
if (this._invalid && this.checkValidity()) {
this._invalid = false;
}
}
@@ -222,12 +222,16 @@ export const WaInputMixin = <T extends Constructor<LitElement>>(
protected _handleBlur(): void {
if (this.autoValidate) {
this._invalid = !this._formControl?.checkValidity();
this._invalid = !this.checkValidity();
}
}
protected _handleInvalid(): void {
this._invalid = true;
// Polyfilled internals dispatch `invalid` themselves, so only trust the
// event when validity comes from the platform (#51338).
if (supportsNativeElementInternals()) {
this._invalid = true;
}
}
protected _renderLabel = memoizeOne((label: string, required: boolean) => {
@@ -34,10 +34,12 @@ import {
browseMediaPlayer,
BROWSER_PLAYER,
MediaClassBrowserSettings,
searchMediaPlayer,
} from "../../data/media-player";
import {
browseLocalMediaPlayer,
isManualMediaSourceContentId,
isMediaSourceContentId,
MANUAL_MEDIA_SOURCE_PREFIX,
searchMedia,
} from "../../data/media_source";
@@ -869,13 +871,31 @@ export class HaMediaPlayerBrowse extends LitElement {
const mediaFilterClasses = this._mediaClassFilter.length
? this._mediaClassFilter
: undefined;
// A player's tree can embed media sources, which resolve their own searches;
// everything else in it uses integration specific ids only the entity knows.
const searchEntityId =
this.entityId &&
this.entityId !== BROWSER_PLAYER &&
!isMediaSourceContentId(navigateId.media_content_id ?? "")
? this.entityId
: undefined;
try {
const { result } = await searchMedia(
this.hass,
navigateId.media_content_id,
searchQuery,
mediaFilterClasses
);
const { result } = searchEntityId
? await searchMediaPlayer(
this.hass,
searchEntityId,
searchQuery,
navigateId.media_content_id,
navigateId.media_content_type,
mediaFilterClasses
)
: await searchMedia(
this.hass,
navigateId.media_content_id,
searchQuery,
mediaFilterClasses
);
// Ignore the response if a newer search started or we navigated away
if (requestId !== this._searchRequestId) {
return;
@@ -1,5 +1,6 @@
import { css, html, LitElement, nothing } from "lit";
import { customElement, property } from "lit/decorators";
import type { DeviceCompositeSplits } from "../../data/device/device_registry";
import type { HaEntityPickerEntityFilterFunc } from "../../data/entity/entity";
import type { TargetType, TargetTypeFloorless } from "../../data/target";
import type { HomeAssistant } from "../../types";
@@ -52,6 +53,9 @@ export class HaTargetPickerItemGroup extends LitElement {
@property({ type: Boolean, attribute: "primary-entities-only" })
public primaryEntitiesOnly?: boolean;
@property({ attribute: false })
public compositeSplits?: DeviceCompositeSplits;
protected render() {
let count = 0;
Object.values(this.items).forEach((items) => {
@@ -87,6 +91,7 @@ export class HaTargetPickerItemGroup extends LitElement {
.includeDomains=${this.includeDomains}
.includeDeviceClasses=${this.includeDeviceClasses}
.primaryEntitiesOnly=${this.primaryEntitiesOnly}
.compositeSplits=${this.compositeSplits}
></ha-target-picker-item-row>`
)
: nothing
@@ -34,7 +34,10 @@ import { computeRTL } from "../../common/util/compute_rtl";
import type { AreaRegistryEntry } from "../../data/area/area_registry";
import { getConfigEntry } from "../../data/config_entries";
import { labelsContext } from "../../data/context";
import type { DeviceRegistryEntry } from "../../data/device/device_registry";
import type {
DeviceCompositeSplits,
DeviceRegistryEntry,
} from "../../data/device/device_registry";
import type { HaEntityPickerEntityFilterFunc } from "../../data/entity/entity";
import type { FloorRegistryEntry } from "../../data/floor_registry";
import { domainToName } from "../../data/integration";
@@ -54,6 +57,7 @@ import type { HomeAssistant } from "../../types";
import { brandsUrl } from "../../util/brands-url";
import type { HaDevicePickerDeviceFilterFunc } from "../device/ha-device-picker";
import { floorDefaultIconPath } from "../ha-floor-icon";
import "../ha-button";
import "../ha-icon-button";
import "../ha-state-icon";
import "../ha-svg-icon";
@@ -108,6 +112,9 @@ export class HaTargetPickerItemRow extends LitElement {
@property({ type: Boolean, attribute: "primary-entities-only" })
public primaryEntitiesOnly?: boolean;
@property({ attribute: false })
public compositeSplits?: DeviceCompositeSplits;
@state() private _iconImg?: string;
@state() private _domainName?: string;
@@ -128,6 +135,15 @@ export class HaTargetPickerItemRow extends LitElement {
const { name, context, iconPath, fallbackIconPath, stateObject, notFound } =
this._itemData(this.type, this.itemId);
const replacement =
this.type === "device" && notFound
? this._getReplacement(this.itemId)
: undefined;
// Only surface the "replaced" state when there is at least one available
// replacement device to migrate to. If every replacement device was
// deleted (or filtered out), fall back to the plain "not found" state.
const canMigrate = !!replacement?.candidates.length;
const showEntities = this.type !== "entity" && !notFound;
const entries = this.parentEntries || this._entries;
@@ -174,15 +190,20 @@ export class HaTargetPickerItemRow extends LitElement {
}
</div>
<div slot="headline">${name}</div>
<div slot="headline">${(canMigrate && replacement?.name) || name}</div>
${
notFound || (context && !this.hideContext)
? html`<span slot="supporting-text"
>${
notFound
? this.hass.localize(
`ui.components.target-picker.${this.type}_not_found`
)
? canMigrate
? this.hass.localize(
"ui.components.target-picker.device_replaced",
{ count: replacement!.candidates.length }
)
: this.hass.localize(
`ui.components.target-picker.${this.type}_not_found`
)
: context
}</span
>`
@@ -229,6 +250,24 @@ export class HaTargetPickerItemRow extends LitElement {
`
: nothing
}
${
canMigrate
? html`
<ha-button
class="migrate"
slot="end"
appearance="plain"
variant="warning"
size="s"
@click=${this._migrate}
>
${this.hass.localize(
"ui.components.target-picker.replace_device"
)}
</ha-button>
`
: nothing
}
${
!this.expand && !this.subEntry
? html`
@@ -707,6 +746,51 @@ export class HaTargetPickerItemRow extends LitElement {
});
}
// Returns the split devices that replaced a removed composite device and
// pass this row's filters, or undefined if the item is not a replaced device.
private _getReplacement(item: string) {
const split = this.compositeSplits?.[item];
if (!split || this.hass.devices[item]) {
return undefined;
}
const candidates = split.split_ids.filter((id) => {
const device = this.hass.devices[id];
return (
device &&
deviceMeetsFilter(
device,
this.hass.entities,
this.deviceFilter,
this.includeDomains,
this.includeDeviceClasses,
this.hass.states,
this.entityFilter,
!this.primaryEntitiesOnly
)
);
});
// Display the replaced reference using the primary replacement device's
// name instead of the removed composite device id. Fall back to the first
// available candidate if the primary device itself was deleted.
const nameDevice =
(split.primary_id && this.hass.devices[split.primary_id]) ||
(candidates.length ? this.hass.devices[candidates[0]] : undefined);
const name = nameDevice ? computeDeviceName(nameDevice) : undefined;
return { candidates, name };
}
private _migrate = (ev: MouseEvent) => {
ev.stopPropagation();
const replacement = this._getReplacement(this.itemId);
if (!replacement?.candidates.length) {
return;
}
fireEvent(this, "migrate-target-item", {
id: this.itemId,
replacements: replacement.candidates,
});
};
private _openDetails(ev: MouseEvent) {
ev.stopPropagation();
showTargetDetailsDialog(this, {
@@ -746,6 +830,11 @@ export class HaTargetPickerItemRow extends LitElement {
color: var(--ha-color-on-warning-normal);
}
.migrate {
align-self: center;
white-space: nowrap;
}
.replaceable {
cursor: pointer;
}
@@ -1,6 +1,7 @@
import "@home-assistant/webawesome/dist/components/tag/tag";
import { consume } from "@lit/context";
import {
mdiAlertOutline,
mdiDevices,
mdiHome,
mdiLabel,
@@ -9,6 +10,7 @@ import {
} from "@mdi/js";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import memoizeOne from "memoize-one";
import { computeCssColor } from "../../common/color/compute-color";
import { hex2rgb } from "../../common/color/convert-color";
@@ -19,6 +21,7 @@ import { computeStateName } from "../../common/entity/compute_state_name";
import { slugify } from "../../common/string/slugify";
import { getConfigEntry } from "../../data/config_entries";
import { labelsContext } from "../../data/context";
import type { DeviceCompositeSplits } from "../../data/device/device_registry";
import { domainToName } from "../../data/integration";
import type { LabelRegistryEntry } from "../../data/label/label_registry";
import type { TargetType } from "../../data/target";
@@ -39,6 +42,9 @@ export class HaTargetPickerValueChip extends LitElement {
@property({ attribute: "item-id" }) public itemId!: string;
@property({ attribute: false })
public compositeSplits?: DeviceCompositeSplits;
@state() private _domainName?: string;
@state() private _iconImg?: string;
@@ -51,38 +57,74 @@ export class HaTargetPickerValueChip extends LitElement {
const { name, iconPath, fallbackIconPath, stateObject, color } =
this._itemData(this.type, this.itemId);
const split =
this.type === "device" && !this.hass.devices?.[this.itemId]
? this.compositeSplits?.[this.itemId]
: undefined;
// Show the replaced reference using the primary replacement device's name,
// falling back to the first still-existing split device if the primary
// device itself was deleted. If no replacement device exists at all, fall
// back to the normal "not found" display.
const replacementDevice = split
? (split.primary_id && this.hass.devices?.[split.primary_id]) ||
split.split_ids
.map((id) => this.hass.devices?.[id])
.find((device) => device)
: undefined;
const replaced = !!replacementDevice;
const replacedName = replacementDevice
? computeDeviceNameDisplay(
replacementDevice,
this.hass.localize,
this.hass.states
)
: undefined;
return html`
<wa-tag
pill
with-remove
class=${this.type}
class=${classMap({ [this.type]: true, replaced })}
style=${color ? `--color: rgb(${color});` : ""}
@wa-remove=${this._removeItem}
>
<div class="icon">
${
iconPath
? html`<ha-icon .icon=${iconPath}></ha-icon>`
: this._iconImg
? html`<img
alt=${this._domainName || ""}
width="24"
crossorigin="anonymous"
referrerpolicy="no-referrer"
src=${this._iconImg}
/>`
: fallbackIconPath
? html`<ha-svg-icon .path=${fallbackIconPath}></ha-svg-icon>`
: stateObject
? html`<ha-state-icon
.stateObj=${stateObject}
></ha-state-icon>`
: nothing
replaced
? html`<ha-svg-icon .path=${mdiAlertOutline}></ha-svg-icon>`
: iconPath
? html`<ha-icon .icon=${iconPath}></ha-icon>`
: this._iconImg
? html`<img
alt=${this._domainName || ""}
width="24"
crossorigin="anonymous"
referrerpolicy="no-referrer"
src=${this._iconImg}
/>`
: fallbackIconPath
? html`<ha-svg-icon
.path=${fallbackIconPath}
></ha-svg-icon>`
: stateObject
? html`<ha-state-icon
.stateObj=${stateObject}
></ha-state-icon>`
: nothing
}
</div>
<span class="name"> ${name} </span>
<span class="name">
${
replaced
? replacedName ||
this.hass.localize(
"ui.components.target-picker.replaced_device"
)
: name
}
</span>
${
this.type === "entity"
this.type === "entity" || replaced
? nothing
: html`<ha-tooltip .for="expand-${slugify(this.itemId)}"
>${this.hass.localize(
@@ -125,7 +167,7 @@ export class HaTargetPickerValueChip extends LitElement {
if (type === "device") {
const device = this.hass.devices?.[itemId];
if (device.primary_config_entry) {
if (device?.primary_config_entry) {
this._getDeviceDomain(device.primary_config_entry);
}
@@ -240,6 +282,11 @@ export class HaTargetPickerValueChip extends LitElement {
--background-color: var(--color);
--icon-primary-color: var(--primary-text-color);
}
wa-tag.replaced {
border-color: var(--ha-color-border-warning-normal, var(--warning-color));
--background-color: var(--warning-color);
color: var(--ha-color-on-warning-normal, var(--warning-color));
}
.name {
overflow: hidden;
+17 -2
View File
@@ -15,6 +15,10 @@ export class HaTileContainer extends LitElement {
@property({ type: Boolean })
public vertical = false;
/* reserve a consistent height for the info block instead of sizing to content, so sibling tiles stay aligned */
@property({ type: Boolean, attribute: "fixed-info-height" })
public fixedInfoHeight = false;
@property({ attribute: false })
public interactive = false;
@@ -34,7 +38,10 @@ export class HaTileContainer extends LitElement {
protected render() {
const containerOrientationClass =
this.featurePosition === "inline" ? "horizontal" : "";
const contentClasses = { vertical: this.vertical };
const contentClasses = {
vertical: this.vertical,
"fixed-info-height": this.fixedInfoHeight,
};
return html`
<div
@@ -112,7 +119,15 @@ export class HaTileContainer extends LitElement {
flex-direction: column;
text-align: center;
justify-content: center;
padding: 10px 0;
padding: 10px var(--ha-space-2);
}
.vertical.fixed-info-height {
/* pin sizing so every tile in a grid reserves the same height, wrapping or not, secondary or not */
gap: 2px;
--ha-tile-info-gap: 2px;
--ha-tile-info-primary-line-height: var(--ha-space-4);
--ha-tile-info-primary-min-height: var(--ha-space-8);
--ha-tile-info-min-height: var(--ha-space-12);
}
.vertical ::slotted([slot="info"]) {
width: 100%;
+31 -3
View File
@@ -15,9 +15,13 @@ import { customElement, property } from "lit/decorators";
*
* @property {boolean} secondaryLoading - Whether the secondary text is loading. Shows a skeleton placeholder.
*
* @cssprop --ha-tile-info-gap - The vertical gap between the primary and secondary text. defaults to `0`.
* @cssprop --ha-tile-info-min-height - Minimum height of the primary/secondary block. Set this to reserve space for a missing secondary so it doesn't shift surrounding content. defaults to `auto`.
* @cssprop --ha-tile-info-primary-min-height - Minimum height of the primary text block, independent of `--ha-tile-info-primary-line-clamp`. Lets tiles that never wrap still match the height of tiles that do. defaults to `auto` (sizes to the actual rendered lines).
* @cssprop --ha-tile-info-primary-font-size - The font size of the primary text. defaults to `var(--ha-font-size-m)`.
* @cssprop --ha-tile-info-primary-font-weight - The font weight of the primary text. defaults to `var(--ha-font-weight-medium)`.
* @cssprop --ha-tile-info-primary-line-height - The line height of the primary text. defaults to `var(--ha-line-height-normal)`.
* @cssprop --ha-tile-info-primary-line-clamp - The maximum number of lines for the primary text before truncating with an ellipsis. defaults to `1`.
* @cssprop --ha-tile-info-primary-letter-spacing - The letter spacing of the primary text. defaults to `0.1px`.
* @cssprop --ha-tile-info-primary-color - The color of the primary text. defaults to `var(--primary-text-color)`.
* @cssprop --ha-tile-info-secondary-font-size - The font size of the secondary text. defaults to `var(--ha-font-size-s)`.
@@ -59,6 +63,8 @@ export class HaTileInfo extends LitElement {
display: block;
width: 100%;
min-width: 0;
--tile-info-gap: var(--ha-tile-info-gap, 0);
--tile-info-min-height: var(--ha-tile-info-min-height, auto);
--tile-info-primary-font-size: var(
--ha-tile-info-primary-font-size,
var(--ha-font-size-m)
@@ -71,6 +77,11 @@ export class HaTileInfo extends LitElement {
--ha-tile-info-primary-line-height,
var(--ha-line-height-normal)
);
--tile-info-primary-line-clamp: var(--ha-tile-info-primary-line-clamp, 1);
--tile-info-primary-min-height: var(
--ha-tile-info-primary-min-height,
auto
);
--tile-info-primary-letter-spacing: var(
--ha-tile-info-primary-letter-spacing,
0.1px
@@ -106,28 +117,45 @@ export class HaTileInfo extends LitElement {
flex-direction: column;
align-items: flex-start;
justify-content: center;
gap: var(--tile-info-gap);
min-height: var(--tile-info-min-height);
}
span,
::slotted(*) {
.secondary span,
::slotted([slot="secondary"]) {
text-overflow: ellipsis;
overflow: hidden;
white-space: nowrap;
width: 100%;
}
.primary span,
::slotted([slot="primary"]) {
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: var(--tile-info-primary-line-clamp);
overflow: hidden;
overflow-wrap: anywhere;
width: 100%;
}
.primary {
display: flex;
align-items: center;
width: 100%;
font-size: var(--tile-info-primary-font-size);
font-weight: var(--tile-info-primary-font-weight);
line-height: var(--tile-info-primary-line-height);
letter-spacing: var(--tile-info-primary-letter-spacing);
color: var(--tile-info-primary-color);
min-height: var(--tile-info-primary-min-height);
}
.secondary {
display: flex;
align-items: center;
width: 100%;
font-size: var(--tile-info-secondary-font-size);
font-weight: var(--tile-info-secondary-font-weight);
line-height: var(--tile-info-secondary-line-height);
letter-spacing: var(--tile-info-secondary-letter-spacing);
color: var(--tile-info-secondary-color);
width: 100%;
}
.placeholder {
width: 140px;
+13 -11
View File
@@ -49,23 +49,25 @@ export const YAML_ONLY_ACTION_TYPES = new Set<keyof typeof ACTION_ICONS>([
export const ACTION_COLLECTIONS: AutomationElementGroupCollection[] = [
{
groups: {
device_id: {},
dynamicGroups: {},
},
},
{
titleKey: "ui.panel.config.automation.editor.actions.groups.helpers.label",
groups: {
event: {},
service: {},
set_conversation_response: {},
helpers: {},
},
},
{
titleKey: "ui.panel.config.automation.editor.actions.groups.other.label",
titleKey: "ui.panel.config.automation.editor.actions.groups.generic.label",
generic: true,
groups: {
event: {},
service: {},
set_conversation_response: {},
other: {},
device_id: {},
},
},
{
titleKey:
"ui.panel.config.automation.editor.actions.groups.integrations.label",
groups: {
integrationGroups: {},
},
},
] as const;
+2 -3
View File
@@ -21,7 +21,6 @@ export const CONDITION_COLLECTIONS: AutomationElementGroupCollection[] = [
helpers: {},
template: {},
trigger: {},
other: {},
},
},
{
@@ -35,9 +34,9 @@ export const CONDITION_COLLECTIONS: AutomationElementGroupCollection[] = [
},
{
titleKey:
"ui.panel.config.automation.editor.conditions.groups.custom_integrations.label",
"ui.panel.config.automation.editor.conditions.groups.integrations.label",
groups: {
customDynamicGroups: {},
integrationGroups: {},
},
},
] as const;
+47 -1
View File
@@ -2,9 +2,10 @@ import type { HassEntities } from "home-assistant-js-websocket";
import { computeStateName } from "../../common/entity/compute_state_name";
import type { LocalizeFunc } from "../../common/translations/localize";
import type { HaFormSchema } from "../../components/ha-form/types";
import type { CallWS } from "../../types";
import type { CallWS, HomeAssistant } from "../../types";
import type { BaseTrigger } from "../automation";
import { migrateAutomationTrigger } from "../automation";
import type { DeviceCompositeSplits } from "./device_registry";
import type { EntityRegistryEntry } from "../entity/entity_registry";
import {
entityRegistryByEntityId,
@@ -158,6 +159,51 @@ export const deviceAutomationsEqual = (
return true;
};
// Decides how a device automation editor should handle its referenced device.
// A missing device that was replaced by a split device stays editable so the
// device picker can offer to fix the reference; a genuinely unknown device
// cannot be edited visually. Returns "loading" while the split map is unknown.
export const deviceAutomationEditorMode = (
hass: HomeAssistant,
deviceId: string | undefined,
compositeSplits: DeviceCompositeSplits | undefined
): "editable" | "loading" | "unknown-device" => {
if (!deviceId || deviceId in hass.devices) {
return "editable";
}
if (compositeSplits === undefined) {
return "loading";
}
// Only editable if at least one of the split (replacement) devices still
// exists; otherwise the reference is stale and cannot be fixed here.
const split = compositeSplits[deviceId];
return split?.split_ids.some((id) => id in hass.devices)
? "editable"
: "unknown-device";
};
// Like deviceAutomationsEqual, but ignores device_id and entity_id so an
// automation can be matched to the equivalent one on a different device (for
// example when a referenced device was replaced by a split device).
export const deviceAutomationsSimilar = (
a: DeviceAutomation,
b: DeviceAutomation
) => {
if (typeof a !== typeof b) {
return false;
}
return deviceAutomationIdentifiers
.filter((property) => property !== "device_id" && property !== "entity_id")
.every((property) => {
const inA = property in a;
const inB = property in b;
if (!inA && !inB) {
return true;
}
return Object.is(a[property], b[property]);
});
};
const compareEntityIdWithEntityRegId = (
entityRegistry: EntityRegistryEntry[],
entityIdA?: string,
+64
View File
@@ -1,3 +1,4 @@
import type { Connection } from "home-assistant-js-websocket";
import { computeStateName } from "../../common/entity/compute_state_name";
import { caseInsensitiveStringCompare } from "../../common/string/compare";
import type { HomeAssistant } from "../../types";
@@ -54,6 +55,69 @@ export interface DeviceRegistryEntryMutableParams {
labels?: string[];
}
/**
* Describes how a legacy composite device (that lived on multiple config
* entries) was split into separate devices. The composite device no longer
* exists in the registry; references to it (in automations, targets, ...) now
* need to point at one or more of the split devices instead.
*/
export interface DeviceCompositeSplit {
/** Ids of the devices that replaced the composite device. */
split_ids: string[];
/** The split device that took over the composite's primary config entry. */
primary_id: string | null;
}
/** Map of removed composite device id -> its split information. */
export type DeviceCompositeSplits = Record<string, DeviceCompositeSplit>;
// The composite split migration in core is a one-time operation, so the split
// map is static for the lifetime of the connection. Cache the request per
// connection so it is fetched once and shared across all pickers, instead of
// being requested again by every device/target picker.
const compositeSplitsCache = new WeakMap<
Connection,
Promise<DeviceCompositeSplits>
>();
export const fetchDeviceCompositeSplits = (
hass: Pick<HomeAssistant, "connection" | "callWS">
): Promise<DeviceCompositeSplits> => {
const conn = hass.connection;
let request = compositeSplitsCache.get(conn);
if (!request) {
request = hass
.callWS<DeviceCompositeSplits>({
type: "config/device_registry/list_composite_splits",
})
.catch((err) => {
// Don't cache failures so the next caller retries.
compositeSplitsCache.delete(conn);
throw err;
});
compositeSplitsCache.set(conn, request);
}
return request;
};
/**
* Fetch the devices that are linked to the given device because they share at
* least one connection or identifier. These are separate devices (one per
* config entry) that represent the same physical hardware, managed by
* different integrations.
*/
export const fetchLinkedDevices = (
hass: Pick<HomeAssistant, "callWS">,
deviceId: string
): Promise<string[]> =>
hass
.callWS<{ linked_devices: string[] }>({
type: "config/device_registry/list_linked_devices",
device_id: deviceId,
})
.then((result) => result.linked_devices);
export const fallbackDeviceName = (
hass: HomeAssistant,
entities: EntityRegistryEntry[] | EntityRegistryDisplayEntry[] | string[]
@@ -0,0 +1,22 @@
import type { HomeAssistantApi } from "../../types";
import type { EntityIdFormat } from "../entity_id_format";
export interface EntityRegistrySettings {
entity_id_parts: EntityIdFormat | null;
}
export const fetchEntityRegistrySettings = (
api: HomeAssistantApi
): Promise<EntityRegistrySettings> =>
api.callWS<EntityRegistrySettings>({
type: "config/entity_registry/settings/get",
});
export const updateEntityRegistrySettings = (
api: HomeAssistantApi,
updates: Partial<EntityRegistrySettings>
): Promise<EntityRegistrySettings> =>
api.callWS<EntityRegistrySettings>({
type: "config/entity_registry/settings/update",
...updates,
});
+12
View File
@@ -0,0 +1,12 @@
export type EntityIdPart = "area" | "device" | "entity" | "floor";
export type EntityIdFormat = EntityIdPart[];
export const DEFAULT_ENTITY_ID_FORMAT: EntityIdFormat = [
"area",
"device",
"entity",
];
export const isDefaultEntityIdFormat = (format: EntityIdFormat): boolean =>
JSON.stringify(format) === JSON.stringify(DEFAULT_ENTITY_ID_FORMAT);
+1 -1
View File
@@ -95,7 +95,7 @@ export interface HassioAddonDetails extends HassioAddonInfo {
options: Record<string, unknown>;
privileged: any;
protected: boolean;
rating: "1-8";
rating: number;
schema: HaFormSchema[] | null;
services_role: string[];
signed: boolean;
+23
View File
@@ -208,6 +208,29 @@ export const browseMediaPlayer = (
media_content_type: mediaContentType,
});
export interface SearchMediaResult {
result: MediaPlayerItem[];
}
export const searchMediaPlayer = (
hass: HomeAssistant,
entityId: string,
searchQuery: string,
mediaContentId?: string,
mediaContentType?: string,
mediaFilterClasses?: string[]
): Promise<SearchMediaResult> =>
hass.callWS<SearchMediaResult>({
type: "media_player/search_media",
entity_id: entityId,
search_query: searchQuery,
// the backend requires these two to be passed together, and JSON
// serialization drops them both when the current item is the root
media_content_id: mediaContentId,
media_content_type: mediaContentType,
media_filter_classes: mediaFilterClasses,
});
export const getCurrentProgress = (stateObj: MediaPlayerEntity): number => {
let progress = stateObj.attributes.media_position!;
+1 -5
View File
@@ -1,5 +1,5 @@
import type { HomeAssistant } from "../types";
import type { MediaPlayerItem } from "./media-player";
import type { MediaPlayerItem, SearchMediaResult } from "./media-player";
export interface ResolvedMediaSource {
url: string;
@@ -24,10 +24,6 @@ export const browseLocalMediaPlayer = (
media_content_id: mediaContentId,
});
export interface SearchMediaResult {
result: MediaPlayerItem[];
}
export const searchMedia = (
hass: HomeAssistant,
mediaContentId: string | undefined,
+1
View File
@@ -26,6 +26,7 @@ export interface StoreAddonDetails extends StoreAddon {
apparmor: boolean;
arch: SupervisorArch[];
auth_api: boolean;
changelog: boolean;
detached: boolean;
docker_api: boolean;
documentation: boolean;
+2 -3
View File
@@ -35,7 +35,6 @@ export const TRIGGER_COLLECTIONS: AutomationElementGroupCollection[] = [
webhook: {},
persistent_notification: {},
helpers: {},
other: {},
},
},
{
@@ -48,9 +47,9 @@ export const TRIGGER_COLLECTIONS: AutomationElementGroupCollection[] = [
},
{
titleKey:
"ui.panel.config.automation.editor.triggers.groups.custom_integrations.label",
"ui.panel.config.automation.editor.triggers.groups.integrations.label",
groups: {
customDynamicGroups: {},
integrationGroups: {},
},
},
] as const;
+15
View File
@@ -314,6 +314,11 @@ export interface ZWaveJSSetConfigParamResult {
error?: string;
}
export interface ZwaveJSNodeConfigParameterUpdate {
id: string;
value: number | null;
}
export interface ZWaveJSDataCollectionStatus {
enabled: boolean;
opted_in: boolean;
@@ -732,6 +737,16 @@ export const fetchZwaveNodeConfigParameters = (
device_id,
});
export const subscribeZwaveNodeConfigParameterUpdates = (
hass: HomeAssistant,
device_id: string,
callback: (update: ZwaveJSNodeConfigParameterUpdate) => void
): Promise<UnsubscribeFunc> =>
hass.connection.subscribeMessage(callback, {
type: "zwave_js/subscribe_config_parameter_updates",
device_id,
});
export const setZwaveNodeConfigParameter = (
hass: HomeAssistant,
device_id: string,
+11
View File
@@ -481,6 +481,17 @@ export const provideHass = (
? response[1](hass(), method, path, parameters)
: Promise.reject(`API Mock for ${path} is not implemented`);
},
// Mocks return a plain body; wrap it so callers can stream it like a fetch
// Response. Callbacks may return a Response themselves to set headers.
async callApiRaw(method, path, parameters, headers) {
const result = await hassObj.callApi<any>(
method,
path,
parameters,
headers
);
return result instanceof Response ? result : new Response(result);
},
hassUrl: (path?) => path,
fetchWithAuth: () => Promise.reject("Not implemented"),
sendWS: (msg) => hassObj.connection.sendMessage(msg),
+7 -28
View File
@@ -18,38 +18,15 @@
<meta name="referrer" content="same-origin" />
<meta name="theme-color" content="{{ theme_color }}" />
<meta name="color-scheme" content="dark light" />
<link rel="preload" href="/static/fonts/roboto/Roboto-Regular.woff2" as="font" type="font/woff2" crossorigin>
<%= renderTemplate("_style_base.html.template") %>
<style>
@font-face {
font-family: "Roboto Launch Screen";
font-display: block;
src: url("/static/fonts/roboto/Roboto-Regular.woff2") format("woff2");
font-weight: 400;
font-style: normal;
}
@keyframes fade-out {
from {
opacity: 1;
}
to {
opacity: 0;
}
}
::view-transition-group(launch-screen) {
animation-duration: var(--ha-animation-duration-normal, 250ms);
animation-timing-function: ease-out;
}
::view-transition-old(launch-screen) {
animation: fade-out var(--ha-animation-duration-normal, 250ms) ease-out;
}
html {
background-color: var(--primary-background-color, #fafafa);
color: var(--primary-text-color, #212121);
height: 100vh;
}
#ha-launch-screen {
font-family: "Roboto Launch Screen", sans-serif;
font-family: ui-sans-serif, system-ui, sans-serif;
position: fixed;
top: 0;
left: 0;
@@ -61,7 +38,8 @@
flex-direction: column;
justify-content: center;
align-items: center;
view-transition-name: launch-screen;
user-select: none;
-webkit-user-select: none;
background-color: var(--primary-background-color, #fafafa);
z-index: 100;
transition: opacity var(--ha-animation-duration-normal, 250ms) ease-out;
@@ -98,7 +76,7 @@
}
#ha-launch-screen .ha-launch-screen-spacer-bottom {
flex: 1;
padding-top: 48px;
padding-top: 16px;
}
.ohf-logo {
margin: max(var(--safe-area-inset-bottom, 0px), 48px) 0;
@@ -132,11 +110,12 @@
}
}
@media (max-height: 560px) {
#ha-launch-screen .ha-launch-screen-spacer-top {
/* body selector to avoid minification causing bad jinja2 */
body #ha-launch-screen .ha-launch-screen-spacer-top {
margin-top: 24px;
padding-top: 24px;
}
#ha-launch-screen .ha-launch-screen-spacer-bottom {
body #ha-launch-screen .ha-launch-screen-spacer-bottom {
padding-top: 16px;
}
.ohf-logo {
+6 -2
View File
@@ -1,5 +1,6 @@
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import type { LocalizeFunc } from "../common/translations/localize";
import "../components/ha-button";
@@ -59,7 +60,7 @@ export class HaInitPage extends LitElement {
: nothing
}
`
: html`<p>
: html`<p class=${classMap({ "loading-text": !this.migration })}>
${
this.migration
? html`<span class="migration-text"
@@ -68,7 +69,7 @@ export class HaInitPage extends LitElement {
"Database upgrade is in progress, Home Assistant will not start until the upgrade is completed.\n\nThe upgrade may need a long time to complete, please be patient."
}</span
>`
: this.localize?.("ui.init.loading") || "Loading data"
: this.localize?.("ui.init.loading") || "Loading..."
}
</p>`;
}
@@ -120,6 +121,9 @@ export class HaInitPage extends LitElement {
.migration-text {
white-space: pre-line;
}
.loading-text {
opacity: 0.66;
}
`;
}
+18 -1
View File
@@ -5,6 +5,7 @@ import memoizeOne from "memoize-one";
import { navigate } from "../common/navigate";
import { computeRouteTail } from "../common/url/route";
import type { Route } from "../types";
import { PanelReady } from "./panel-ready";
const extractPage = (path: string, defaultPage: string) => {
if (path === "") {
@@ -22,6 +23,7 @@ export interface RouteOptions {
// Function to load the page.
load?: () => Promise<unknown>;
cache?: boolean;
waitForReady?: boolean;
}
export interface RouterOptions {
@@ -53,6 +55,8 @@ export class HassRouterPage extends ReactiveElement {
private _currentLoadProm?: Promise<void>;
private _panelReady = new PanelReady();
private _cache = {};
private _initialLoadDone = false;
@@ -180,6 +184,10 @@ export class HassRouterPage extends ReactiveElement {
// If we don't show loading screen, just show the panel.
// It will be automatically upgraded when loading done.
if (!routerOptions.showLoading) {
const loadComplete = () => {
this._currentLoadProm = undefined;
};
this._currentLoadProm = loadProm.then(loadComplete, loadComplete);
this._createPanel(routerOptions, newPage, routeOptions);
return;
}
@@ -287,7 +295,15 @@ export class HassRouterPage extends ReactiveElement {
* Promise that resolves when the page has rendered.
*/
protected get pageRendered(): Promise<void> {
return this.updateComplete.then(() => this._currentLoadProm);
return this.updateComplete
.then(() => this._currentLoadProm)
.then(() => {
const page = this.lastElementChild;
return Promise.all([
this._panelReady.ready,
page instanceof HassRouterPage ? page.pageRendered : undefined,
]).then(() => undefined);
});
}
protected createElement(tag: string) {
@@ -312,6 +328,7 @@ export class HassRouterPage extends ReactiveElement {
}
const panelEl = this._cache[page] || this.createElement(routeOptions.tag);
this._panelReady.track(panelEl, routeOptions.waitForReady);
this.updatePageEl(panelEl);
this.appendChild(panelEl);
+5 -2
View File
@@ -4,6 +4,7 @@ import { customElement, eventOptions, property } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import { restoreScroll } from "../common/decorators/restore-scroll";
import { goBack } from "../common/navigate";
import { sanitizeNavigationPath } from "../common/url/sanitize-navigation-path";
import "../components/ha-icon-button-arrow-prev";
import "../components/ha-menu-button";
import { haStyleScrollbar } from "../resources/styles";
@@ -29,16 +30,18 @@ class HassSubpage extends LitElement {
@restoreScroll(".content") private _savedScrollPos?: number;
protected render(): TemplateResult {
const backPath = sanitizeNavigationPath(this.backPath);
return html`
<div class="toolbar ${classMap({ narrow: this.narrow })}">
<div class="toolbar-content">
${
this.mainPage || history.state?.root
? html`<ha-menu-button></ha-menu-button>`
: this.backPath
: backPath
? html`
<ha-icon-button-arrow-prev
href=${this.backPath}
href=${backPath}
></ha-icon-button-arrow-prev>
`
: html`
+6 -3
View File
@@ -15,6 +15,7 @@ import { restoreScroll } from "../common/decorators/restore-scroll";
import { isNavigationClick } from "../common/dom/is-navigation-click";
import { goBack, navigate } from "../common/navigate";
import type { LocalizeFunc } from "../common/translations/localize";
import { sanitizeNavigationPath } from "../common/url/sanitize-navigation-path";
import "../components/ha-icon-button-arrow-prev";
import "../components/ha-menu-button";
import "../components/ha-svg-icon";
@@ -164,17 +165,19 @@ export class HassTabsSubpage extends LitElement {
this._narrow,
this.localizeFunc || this.hass.localize
);
const backPath = sanitizeNavigationPath(this.backPath);
return html`
<div class="toolbar ${classMap({ narrow: this._narrow })}">
<slot name="toolbar">
<div class="toolbar-content">
${
this.mainPage || (!this.backPath && history.state?.root)
this.mainPage || (!backPath && history.state?.root)
? html`<ha-menu-button></ha-menu-button>`
: this.backPath
: backPath
? html`
<ha-icon-button-arrow-prev
.href=${this.backPath}
.href=${backPath}
></ha-icon-button-arrow-prev>
`
: html`
+4 -14
View File
@@ -17,10 +17,7 @@ import { HassElement } from "../state/hass-element";
import QuickBarMixin from "../state/quick-bar-mixin";
import type { HomeAssistant, Route } from "../types";
import { storeState } from "../util/ha-pref-storage";
import {
removeLaunchScreen,
renderLaunchScreenContent,
} from "../util/launch-screen";
import { renderLaunchScreenContent } from "../util/launch-screen";
import { checkOnboardingSurveyToast } from "../util/onboarding-survey";
import {
registerServiceWorker,
@@ -136,16 +133,9 @@ export class HomeAssistantAppEl extends QuickBarMixin(HassElement) {
) {
this.render = this.renderHass;
this.update = super.update;
// Apps with a native splash screen keep covering the frontend until
// frontend/loaded, so the launch screen stays up (invisibly) until the
// first panel has rendered and partial-panel-resolver removes it and
// fires frontend/loaded.
if (
!this.hass.auth.external?.config.hasSplashscreen &&
removeLaunchScreen()
) {
this.hass.auth.external?.fireMessage({ type: "frontend/loaded" });
}
// partial-panel-resolver removes the launch screen after the first panel
// is ready. Native apps request instant removal because their own splash
// screen covers the frontend until frontend/loaded is sent.
}
super.update(changedProps);
}
+30
View File
@@ -0,0 +1,30 @@
import { ReactiveElement } from "lit";
import { fireEvent } from "../common/dom/fire_event";
declare global {
interface HASSDomEvents {
"hass-panel-ready": undefined;
}
}
export const panelIsReady = async (element: HTMLElement) => {
if (element instanceof ReactiveElement) {
// Ensure pending Lit changes are rendered before revealing the panel.
await element.updateComplete;
}
fireEvent(element, "hass-panel-ready");
};
export class PanelReady {
public ready?: Promise<void>;
public track(element: HTMLElement, waitForReady = false) {
this.ready = waitForReady
? new Promise((resolve) => {
element.addEventListener("hass-panel-ready", () => resolve(), {
once: true,
});
})
: undefined;
}
}
+7 -2
View File
@@ -8,6 +8,7 @@ import type { PropertyValues } from "lit";
import { customElement, property, state } from "lit/decorators";
import { deepActiveElement } from "../common/dom/deep-active-element";
import { deepEqual } from "../common/util/deep-equal";
import { promiseTimeout } from "../common/util/promise-timeout";
import { narrowViewportContext } from "../data/context";
import { getDefaultPanel } from "../data/panel";
import type { CustomPanelInfo } from "../data/panel_custom";
@@ -17,6 +18,7 @@ import type { RouteOptions, RouterOptions } from "./hass-router-page";
import { HassRouterPage } from "./hass-router-page";
const CACHE_URL_PATHS = ["lovelace", "home", "config"];
const PANEL_READY_TIMEOUT = 2000;
const COMPONENTS = {
app: () => import("../panels/app/ha-panel-app"),
energy: () => import("../panels/energy/ha-panel-energy"),
@@ -219,9 +221,12 @@ class PartialPanelResolver extends HassRouterPage {
)
) {
await this.rebuild();
await this.pageRendered;
await promiseTimeout(PANEL_READY_TIMEOUT, this.pageRendered).catch(
() => undefined
);
// Only fire frontend/loaded when this call actually removed the launch
// screen, so later panel updates do not fire it again.
// screen, so later panel updates do not fire it again. Native apps remove
// it instantly because their own splash screen is still visible.
if (
removeLaunchScreen(!!this.hass.auth.external?.config.hasSplashscreen)
) {
@@ -225,20 +225,33 @@ class SupervisorAppInfo extends MobileAwareMixin(LitElement) {
"ui.panel.config.apps.dashboard.current_version",
{ version: this._currentAddon.version }
)}
<div class="changelog" @click=${this._openChangelog}>
(<span class="changelog-link"
>${this.i18n.localize(
"ui.panel.config.apps.dashboard.changelog"
)}</span
>)
</div>
${
this._currentAddon.changelog
? html`<div
class="changelog"
@click=${this._openChangelog}
>
(<span class="changelog-link"
>${this.i18n.localize(
"ui.panel.config.apps.dashboard.changelog"
)}</span
>)
</div>`
: nothing
}
`
: html`${this._currentAddon.version_latest}
<span class="changelog-link" @click=${this._openChangelog}
>${this.i18n.localize(
"ui.panel.config.apps.dashboard.changelog"
)}</span
>`
${
this._currentAddon.changelog
? html`<span
class="changelog-link"
@click=${this._openChangelog}
>${this.i18n.localize(
"ui.panel.config.apps.dashboard.changelog"
)}</span
>`
: nothing
}`
}
</div>
</div>
@@ -13,11 +13,16 @@ import type {
DeviceCapabilities,
} from "../../../../../data/device/device_automation";
import {
deviceAutomationEditorMode,
deviceAutomationsEqual,
fetchDeviceActionCapabilities,
localizeExtraFieldsComputeHelperCallback,
localizeExtraFieldsComputeLabelCallback,
} from "../../../../../data/device/device_automation";
import {
fetchDeviceCompositeSplits,
type DeviceCompositeSplits,
} from "../../../../../data/device/device_registry";
import type { EntityRegistryEntry } from "../../../../../data/entity/entity_registry";
import type { HomeAssistant } from "../../../../../types";
@@ -33,6 +38,10 @@ export class HaDeviceAction extends LitElement {
@state() private _capabilities?: DeviceCapabilities;
@state() private _compositeSplits?: DeviceCompositeSplits;
private _loadingCompositeSplits = false;
@state()
@consume({ context: fullEntitiesContext, subscribe: true })
_entityReg: EntityRegistryEntry[] = [];
@@ -59,14 +68,19 @@ export class HaDeviceAction extends LitElement {
}
);
public shouldUpdate(changedProperties: PropertyValues<this>) {
if (!changedProperties.has("action")) {
return true;
public shouldUpdate(_changedProperties: PropertyValues<this>) {
const mode = deviceAutomationEditorMode(
this.hass,
this.action.device_id,
this._compositeSplits
);
if (mode === "loading") {
// The device is missing; wait for the composite split map before deciding
// whether it is a replaced device (editable) or genuinely unknown (YAML).
this._loadCompositeSplits();
return false;
}
if (
this.action.device_id &&
!(this.action.device_id in this.hass.devices)
) {
if (mode === "unknown-device") {
fireEvent(
this,
"ui-mode-not-available",
@@ -81,6 +95,20 @@ export class HaDeviceAction extends LitElement {
return true;
}
private async _loadCompositeSplits() {
if (this._loadingCompositeSplits) {
return;
}
this._loadingCompositeSplits = true;
try {
this._compositeSplits = await fetchDeviceCompositeSplits(this.hass);
} catch (_err) {
this._compositeSplits = {};
} finally {
this._loadingCompositeSplits = false;
}
}
protected render() {
const deviceId = this._deviceId || this.action.device_id;
@@ -183,12 +183,7 @@ const ENTITY_DOMAINS_OTHER = new Set([
const ENTITY_DOMAINS_MAIN = new Set(["notify"]);
const DYNAMIC_KEYWORDS = [
"dynamicGroups",
"helpers",
"other",
"customDynamicGroups",
];
const DYNAMIC_KEYWORDS = ["dynamicGroups", "helpers", "integrationGroups"];
const DYNAMIC_TO_GENERIC = new Set([`${DYNAMIC_PREFIX}event`]);
@@ -197,7 +192,13 @@ const DYNAMIC_TO_GENERIC = new Set([`${DYNAMIC_PREFIX}event`]);
// drills into its items, like selecting the matching group in the "by type" tab.
const TIME_LOCATION_GROUPS = ["time", "sun"];
type CollectionGroupType = "helper" | "other" | "dynamic" | "customDynamic";
type CollectionGroupType = "helper" | "dynamic" | "integration";
interface DomainClassificationOptions {
type: AddAutomationElementDialogParams["type"];
usedDomains?: Set<string>;
activeSystemDomains?: Set<string>;
}
@customElement("add-automation-element-dialog")
class DialogAddAutomationElement
@@ -501,7 +502,9 @@ class DialogAddAutomationElement
);
private get _systemDomains() {
if (!this._manifests) {
// System domains are derived from trigger/condition descriptions, so
// they don't apply to actions.
if (!this._manifests || this._params?.type === "action") {
return undefined;
}
const descriptions =
@@ -1029,7 +1032,6 @@ class DialogAddAutomationElement
this._params!.type,
this._selectedGroup,
this._selectedCollectionIndex ?? 0,
this._domains,
this.hass.localize,
this.hass.services,
this._manifests,
@@ -1123,6 +1125,13 @@ class DialogAddAutomationElement
const exclusiveDomains = this._getExclusiveDomains(type);
const domainList =
type === "trigger"
? Object.keys(triggerDescriptions ?? {}).map(getTriggerDomain)
: type === "condition"
? Object.keys(conditionDescriptions ?? {}).map(getConditionDomain)
: Object.keys(services ?? {});
collections.forEach((collection, index) => {
let collectionGroups = Object.entries(collection.groups);
const groups: AddAutomationElementListItem[] = [];
@@ -1134,70 +1143,23 @@ class DialogAddAutomationElement
if (collection.groups.helpers) {
types.push("helper");
}
if (collection.groups.other) {
types.push("other");
}
if (collection.groups.customDynamicGroups) {
types.push("customDynamic");
if (collection.groups.integrationGroups) {
types.push("integration");
}
if (
type === "trigger" &&
Object.keys(collection.groups).some((item) =>
DYNAMIC_KEYWORDS.includes(item)
)
) {
if (types.length) {
groups.push(
...this._triggerGroups(
...this._dynamicDomainGroups(
localize,
triggerDescriptions,
domainList,
manifests,
domains,
types,
exclusiveDomains
)
);
collectionGroups = collectionGroups.filter(
([key]) => !DYNAMIC_KEYWORDS.includes(key)
);
} else if (
type === "condition" &&
Object.keys(collection.groups).some((item) =>
DYNAMIC_KEYWORDS.includes(item)
)
) {
groups.push(
...this._conditionGroups(
localize,
conditionDescriptions,
manifests,
domains,
types,
exclusiveDomains
)
);
collectionGroups = collectionGroups.filter(
([key]) => !DYNAMIC_KEYWORDS.includes(key)
);
} else if (
type === "action" &&
Object.keys(collection.groups).some((item) =>
DYNAMIC_KEYWORDS.includes(item)
)
) {
groups.push(
...this._serviceGroups(
localize,
services,
manifests,
domains,
collection.groups.dynamicGroups
? undefined
: collection.groups.helpers
? "helper"
: "other"
{
type,
usedDomains: domains,
activeSystemDomains: this._systemDomains?.active,
exclusiveDomains,
}
)
);
@@ -1289,7 +1251,6 @@ class DialogAddAutomationElement
type: AddAutomationElementDialogParams["type"],
group: string,
collectionIndex: number,
domains: Set<string> | undefined,
localize: LocalizeFunc,
services: HomeAssistant["services"],
manifests?: DomainManifestLookup,
@@ -1339,144 +1300,69 @@ class DialogAddAutomationElement
}
}
if (type === "action") {
if (!this._selectedGroup) {
result.unshift(
...this._serviceGroups(
localize,
services,
manifests,
domains,
undefined
)
);
} else if (this._selectedGroup === "helpers") {
result.unshift(
...this._serviceGroups(
localize,
services,
manifests,
domains,
"helper"
)
);
} else if (this._selectedGroup === "other") {
result.unshift(
...this._serviceGroups(
localize,
services,
manifests,
domains,
"other"
)
);
}
}
return result.sort((a, b) =>
stringCompare(a.name, b.name, this.hass.locale.language)
);
}
);
private _serviceGroups = (
localize: LocalizeFunc,
services: HomeAssistant["services"],
manifests: DomainManifestLookup | undefined,
domains: Set<string> | undefined,
type: "helper" | "other" | undefined
): AddAutomationElementListItem[] => {
if (!services || !manifests) {
return [];
}
const result: AddAutomationElementListItem[] = [];
Object.keys(services).forEach((domain) => {
const manifest = manifests[domain];
const domainUsed = !domains ? true : domains.has(domain);
if (
(type === undefined &&
(ENTITY_DOMAINS_MAIN.has(domain) ||
(manifest?.integration_type === "entity" &&
domainUsed &&
!ENTITY_DOMAINS_OTHER.has(domain)))) ||
(type === "helper" && manifest?.integration_type === "helper") ||
(type === "other" &&
!ENTITY_DOMAINS_MAIN.has(domain) &&
(ENTITY_DOMAINS_OTHER.has(domain) ||
(!domainUsed && manifest?.integration_type === "entity") ||
!["helper", "entity"].includes(manifest?.integration_type || "")))
) {
result.push({
icon: html`
<ha-domain-icon .domain=${domain} brand-fallback></ha-domain-icon>
`,
key: `${DYNAMIC_PREFIX}${domain}`,
name: domainToName(localize, domain, manifest),
description: "",
});
}
});
return result.sort((a, b) =>
stringCompare(a.name, b.name, this.hass.locale.language)
);
};
private _domainMatchesGroupType(
private _classifyDomain(
domain: string,
manifest: DomainManifestLookup[string] | undefined,
domainUsed: boolean,
types: CollectionGroupType[]
): boolean {
const matchDynamic =
((types.includes("dynamic") && (!manifest || manifest.is_built_in)) ||
(types.includes("customDynamic") &&
!(manifest?.is_built_in ?? true))) &&
(ENTITY_DOMAINS_MAIN.has(domain) ||
(manifest?.integration_type === "entity" &&
!ENTITY_DOMAINS_OTHER.has(domain) &&
(domainUsed || (this._systemDomains?.active.has(domain) ?? false))) ||
(manifest?.integration_type === "system" &&
(this._systemDomains?.active.has(domain) ?? false)));
options: DomainClassificationOptions
): CollectionGroupType | undefined {
const integrationType = manifest?.integration_type;
const matchHelper =
types.includes("helper") && manifest?.integration_type === "helper";
if (integrationType === "helper") {
return "helper";
}
const matchOther =
types.includes("other") &&
!ENTITY_DOMAINS_MAIN.has(domain) &&
(ENTITY_DOMAINS_OTHER.has(domain) ||
!["helper", "entity", "system"].includes(
manifest?.integration_type || ""
));
if (ENTITY_DOMAINS_MAIN.has(domain) || integrationType === "entity") {
// Core entity domains. Actions always list them; triggers/conditions
// only when matching entities exist or a system domain covers them.
if (
options.type === "action" ||
!options.usedDomains ||
options.usedDomains.has(domain) ||
ENTITY_DOMAINS_OTHER.has(domain) ||
(options.activeSystemDomains?.has(domain) ?? false)
) {
return "dynamic";
}
return undefined;
}
return matchDynamic || matchHelper || matchOther;
if (integrationType === "system" && options.type !== "action") {
return options.activeSystemDomains?.has(domain) ? "dynamic" : undefined;
}
// Integrations that bring their own elements, built-in (like Apple TV,
// FFmpeg) and custom (like HACS) alike.
return "integration";
}
private _triggerGroups = (
private _dynamicDomainGroups = (
localize: LocalizeFunc,
triggers: TriggerDescriptions,
domains: string[],
manifests: DomainManifestLookup | undefined,
domains: Set<string> | undefined,
types: CollectionGroupType[],
exclusiveDomains: Set<string>
options: DomainClassificationOptions & { exclusiveDomains?: Set<string> }
): AddAutomationElementListItem[] => {
if (!triggers || !manifests) {
if (!manifests) {
return [];
}
const result: AddAutomationElementListItem[] = [];
const addedDomains = new Set<string>();
Object.keys(triggers).forEach((trigger) => {
const domain = getTriggerDomain(trigger);
if (addedDomains.has(domain) || exclusiveDomains.has(domain)) {
domains.forEach((domain) => {
if (addedDomains.has(domain) || options.exclusiveDomains?.has(domain)) {
return;
}
addedDomains.add(domain);
const manifest = manifests[domain];
const domainUsed = !domains ? true : domains.has(domain);
const groupType = this._classifyDomain(domain, manifest, options);
if (this._domainMatchesGroupType(domain, manifest, domainUsed, types)) {
if (groupType && types.includes(groupType)) {
result.push({
icon: html`
<ha-domain-icon .domain=${domain} brand-fallback></ha-domain-icon>
@@ -1525,46 +1411,6 @@ class DialogAddAutomationElement
}
);
private _conditionGroups = (
localize: LocalizeFunc,
conditions: ConditionDescriptions,
manifests: DomainManifestLookup | undefined,
domains: Set<string> | undefined,
types: CollectionGroupType[],
exclusiveDomains: Set<string>
): AddAutomationElementListItem[] => {
if (!conditions || !manifests) {
return [];
}
const result: AddAutomationElementListItem[] = [];
const addedDomains = new Set<string>();
Object.keys(conditions).forEach((condition) => {
const domain = getConditionDomain(condition);
if (addedDomains.has(domain) || exclusiveDomains.has(domain)) {
return;
}
addedDomains.add(domain);
const manifest = manifests[domain];
const domainUsed = !domains ? true : domains.has(domain);
if (this._domainMatchesGroupType(domain, manifest, domainUsed, types)) {
result.push({
icon: html`
<ha-domain-icon .domain=${domain} brand-fallback></ha-domain-icon>
`,
key: `${DYNAMIC_PREFIX}${domain}`,
name: domainToName(localize, domain, manifest),
description: "",
});
}
});
return result.sort((a, b) =>
stringCompare(a.name, b.name, this.hass.locale.language)
);
};
private _conditions = memoizeOne(
(
localize: LocalizeFunc,
@@ -1665,26 +1511,13 @@ class DialogAddAutomationElement
);
}
if (group && !["helpers", "other"].includes(group)) {
if (group) {
return [];
}
Object.keys(services)
.sort()
.forEach((dmn) => {
const manifest = manifests?.[dmn];
if (group === "helpers" && manifest?.integration_type !== "helper") {
return;
}
if (
group === "other" &&
(ENTITY_DOMAINS_OTHER.has(dmn) ||
["helper", "entity"].includes(manifest?.integration_type || ""))
) {
return;
}
addDomain(dmn);
});
.forEach((dmn) => addDomain(dmn));
return result;
}
@@ -1695,25 +1528,20 @@ class DialogAddAutomationElement
);
private _getDomainType(domain: string) {
if (
ENTITY_DOMAINS_MAIN.has(domain) ||
(this._manifests?.[domain]?.integration_type === "entity" &&
!ENTITY_DOMAINS_OTHER.has(domain) &&
(this._domains?.has(domain) ||
(this._systemDomains?.active.has(domain) ?? false)))
) {
return "dynamicGroups";
}
if (this._manifests?.[domain]?.integration_type === "helper") {
const groupType = this._classifyDomain(domain, this._manifests?.[domain], {
type: this._params!.type,
usedDomains: this._domains,
activeSystemDomains: this._systemDomains?.active,
});
if (groupType === "helper") {
return "helpers";
}
if (
this._manifests?.[domain]?.integration_type === "system" &&
this._systemDomains?.active.has(domain)
) {
return "dynamicGroups";
if (groupType === "integration") {
return "integrationGroups";
}
return "other";
// "dynamic", plus domains hidden in the by-type list (like unused entity
// domains) that can still surface when browsing by target.
return "dynamicGroups";
}
private _sortDomainsByCollection(
@@ -13,11 +13,16 @@ import type {
DeviceCondition,
} from "../../../../../data/device/device_automation";
import {
deviceAutomationEditorMode,
deviceAutomationsEqual,
fetchDeviceConditionCapabilities,
localizeExtraFieldsComputeHelperCallback,
localizeExtraFieldsComputeLabelCallback,
} from "../../../../../data/device/device_automation";
import {
fetchDeviceCompositeSplits,
type DeviceCompositeSplits,
} from "../../../../../data/device/device_registry";
import type { EntityRegistryEntry } from "../../../../../data/entity/entity_registry";
import type { HomeAssistant } from "../../../../../types";
@@ -33,6 +38,10 @@ export class HaDeviceCondition extends LitElement {
@state() private _capabilities?: DeviceCapabilities;
@state() private _compositeSplits?: DeviceCompositeSplits;
private _loadingCompositeSplits = false;
@state()
@consume({ context: fullEntitiesContext, subscribe: true })
_entityReg: EntityRegistryEntry[] = [];
@@ -60,14 +69,19 @@ export class HaDeviceCondition extends LitElement {
}
);
public shouldUpdate(changedProperties: PropertyValues<this>) {
if (!changedProperties.has("condition")) {
return true;
public shouldUpdate(_changedProperties: PropertyValues<this>) {
const mode = deviceAutomationEditorMode(
this.hass,
this.condition.device_id,
this._compositeSplits
);
if (mode === "loading") {
// The device is missing; wait for the composite split map before deciding
// whether it is a replaced device (editable) or genuinely unknown (YAML).
this._loadCompositeSplits();
return false;
}
if (
this.condition.device_id &&
!(this.condition.device_id in this.hass.devices)
) {
if (mode === "unknown-device") {
fireEvent(
this,
"ui-mode-not-available",
@@ -82,6 +96,20 @@ export class HaDeviceCondition extends LitElement {
return true;
}
private async _loadCompositeSplits() {
if (this._loadingCompositeSplits) {
return;
}
this._loadingCompositeSplits = true;
try {
this._compositeSplits = await fetchDeviceCompositeSplits(this.hass);
} catch (_err) {
this._compositeSplits = {};
} finally {
this._loadingCompositeSplits = false;
}
}
protected render() {
const deviceId = this._deviceId || this.condition.device_id;
@@ -346,17 +346,19 @@ class HaAutomationPicker extends SubscribeMixin(LitElement) {
maxWidth: "82px",
sortable: true,
groupable: true,
hidden: narrow,
type: "overflow",
title: this.hass.localize("ui.panel.config.automation.picker.state"),
template: (automation) => html`
<ha-switch
@click=${stopPropagation}
@change=${this._handleSwitchToggle}
.automation=${automation}
.checked=${automation.state === "on"}
></ha-switch>
`,
template: (automation) =>
narrow
? automation.formatted_state
: html`
<ha-switch
@click=${stopPropagation}
@change=${this._handleSwitchToggle}
.automation=${automation}
.checked=${automation.state === "on"}
></ha-switch>
`,
},
actions: {
lastFixed: true,
@@ -33,11 +33,16 @@ import type { ConfigEntry } from "../../../../data/config_entries";
import {
apiContext,
configEntriesContext,
connectionContext,
internationalizationContext,
labelsContext,
registriesContext,
statesContext,
} from "../../../../data/context";
import {
fetchDeviceCompositeSplits,
type DeviceCompositeSplits,
} from "../../../../data/device/device_registry";
import type { LabelRegistryEntry } from "../../../../data/label/label_registry";
import {
deviceMeetsTargetSelector,
@@ -89,9 +94,16 @@ export class HaAutomationRowTargets extends LitElement {
@consume({ context: apiContext, subscribe: true })
private _api!: ContextType<typeof apiContext>;
@consume({ context: connectionContext, subscribe: true })
private _connection!: ContextType<typeof connectionContext>;
@consume({ context: statesContext, subscribe: true })
private _states!: ContextType<typeof statesContext>;
@state() private _compositeSplits?: DeviceCompositeSplits;
private _loadingCompositeSplits = false;
private _countCache = new Map<
string,
Promise<number | undefined> | number | undefined
@@ -108,6 +120,43 @@ export class HaAutomationRowTargets extends LitElement {
) {
this._rerenderCount = true;
}
if (
(changedProps.has("target") || changedProps.has("_registries")) &&
this._compositeSplits === undefined &&
!this._loadingCompositeSplits &&
this._hasMissingDevice()
) {
// A referenced device is missing from the registry; it might be a legacy
// composite device that was split. Fetch the split map so we can flag it.
this._loadCompositeSplits();
}
}
private _hasMissingDevice(): boolean {
const deviceIds = this.target?.device_id
? ensureArray(this.target.device_id)
: [];
return deviceIds.some(
(id) => !isTemplate(id) && !this._registries?.devices?.[id]
);
}
private async _loadCompositeSplits() {
if (!this._api || !this._connection) {
return;
}
this._loadingCompositeSplits = true;
try {
this._compositeSplits = await fetchDeviceCompositeSplits({
connection: this._connection.connection,
callWS: this._api.callWS,
});
} catch (_err) {
this._compositeSplits = {};
} finally {
this._loadingCompositeSplits = false;
}
}
protected updated(changedProps: PropertyValues) {
@@ -342,7 +391,8 @@ export class HaAutomationRowTargets extends LitElement {
error = false,
targetId?: string,
targetType?: string,
countTemplate: unknown = nothing
countTemplate: unknown = nothing,
title?: string
) {
if (!this.interactive || !targetId || !targetType) {
return html`<div
@@ -351,6 +401,7 @@ export class HaAutomationRowTargets extends LitElement {
warning,
error,
})}
title=${title ?? nothing}
.targetId=${targetId}
.targetType=${targetType}
.label=${label}
@@ -366,6 +417,7 @@ export class HaAutomationRowTargets extends LitElement {
warning,
error,
})}
title=${title ?? nothing}
.targetId=${targetId}
.targetType=${targetType}
.label=${label}
@@ -385,6 +437,7 @@ export class HaAutomationRowTargets extends LitElement {
let icon: string | undefined;
let label: string;
let warning = false;
let title: string | undefined;
let badgeTargetId: string | undefined = targetId;
let badgeTargetType: string | undefined = targetType;
let countTemplate: unknown = nothing;
@@ -408,17 +461,28 @@ export class HaAutomationRowTargets extends LitElement {
const exists = this._checkTargetExists(targetType, targetId);
if (!exists) {
icon = mdiAlert;
label = getTargetText(
this._registries,
this._states,
this._i18n.localize,
targetType,
targetId,
this._getLabel
);
warning = true;
badgeTargetId = undefined;
badgeTargetType = undefined;
if (targetType === "device" && this._compositeSplits?.[targetId]) {
// The device was replaced by one or more split devices; make clear
// this reference needs to be updated, distinct from "unknown device".
label = this._i18n.localize(
"ui.panel.config.automation.editor.target_summary.device_replaced"
);
title = this._i18n.localize(
"ui.panel.config.automation.editor.target_summary.device_replaced_description"
);
} else {
label = getTargetText(
this._registries,
this._states,
this._i18n.localize,
targetType,
targetId,
this._getLabel
);
}
} else {
label = getTargetText(
this._registries,
@@ -456,6 +520,7 @@ export class HaAutomationRowTargets extends LitElement {
targetType: badgeTargetType,
label,
}}
title=${title ?? nothing}
class=${classMap({
warning,
})}
@@ -470,7 +535,8 @@ export class HaAutomationRowTargets extends LitElement {
false,
badgeTargetId,
badgeTargetType,
countTemplate
countTemplate,
title
);
}
@@ -15,11 +15,16 @@ import type {
DeviceTrigger,
} from "../../../../../data/device/device_automation";
import {
deviceAutomationEditorMode,
deviceAutomationsEqual,
fetchDeviceTriggerCapabilities,
localizeExtraFieldsComputeHelperCallback,
localizeExtraFieldsComputeLabelCallback,
} from "../../../../../data/device/device_automation";
import {
fetchDeviceCompositeSplits,
type DeviceCompositeSplits,
} from "../../../../../data/device/device_registry";
import type { EntityRegistryEntry } from "../../../../../data/entity/entity_registry";
import type { HomeAssistant } from "../../../../../types";
@@ -35,6 +40,10 @@ export class HaDeviceTrigger extends LitElement {
@state() private _capabilities?: DeviceCapabilities;
@state() private _compositeSplits?: DeviceCompositeSplits;
private _loadingCompositeSplits = false;
@state()
@consume({ context: fullEntitiesContext, subscribe: true })
_entityReg: EntityRegistryEntry[] = [];
@@ -64,14 +73,19 @@ export class HaDeviceTrigger extends LitElement {
}
);
public shouldUpdate(changedProperties: PropertyValues<this>) {
if (!changedProperties.has("trigger")) {
return true;
public shouldUpdate(_changedProperties: PropertyValues<this>) {
const mode = deviceAutomationEditorMode(
this.hass,
this.trigger.device_id,
this._compositeSplits
);
if (mode === "loading") {
// The device is missing; wait for the composite split map before deciding
// whether it is a replaced device (editable) or genuinely unknown (YAML).
this._loadCompositeSplits();
return false;
}
if (
this.trigger.device_id &&
!(this.trigger.device_id in this.hass.devices)
) {
if (mode === "unknown-device") {
fireEvent(
this,
"ui-mode-not-available",
@@ -86,6 +100,20 @@ export class HaDeviceTrigger extends LitElement {
return true;
}
private async _loadCompositeSplits() {
if (this._loadingCompositeSplits) {
return;
}
this._loadingCompositeSplits = true;
try {
this._compositeSplits = await fetchDeviceCompositeSplits(this.hass);
} catch (_err) {
this._compositeSplits = {};
} finally {
this._loadingCompositeSplits = false;
}
}
protected render() {
const deviceId = this._deviceId || this.trigger.device_id;
@@ -203,12 +203,10 @@ export function getModifiedAtTableColumn<T>(
}
const renderDateTimeColumn = (valueDateTime: number, hass: HomeAssistant) =>
html`${
valueDateTime
? formatShortDateTimeWithConditionalYear(
new Date(valueDateTime * 1000),
hass.locale,
hass.config
)
: nothing
}`;
valueDateTime
? formatShortDateTimeWithConditionalYear(
new Date(valueDateTime * 1000),
hass.locale,
hass.config
)
: nothing;
+9
View File
@@ -22,6 +22,7 @@ import {
mdiPuzzle,
mdiRadioTower,
mdiRemote,
mdiRenameOutline,
mdiRobot,
mdiScrewdriver,
mdiScriptText,
@@ -494,6 +495,14 @@ export const configSections: Record<string, PageNavigation[]> = {
component: "backup",
adminOnly: true,
},
{
path: "/config/entity-id-format",
translationKey: "entity_id_format",
iconPath: mdiRenameOutline,
iconColor: "#24a5cb",
core: true,
adminOnly: true,
},
{
path: "/config/analytics",
translationKey: "analytics",
@@ -0,0 +1,215 @@
import { consume, type ContextType } from "@lit/context";
import { mdiRestore } from "@mdi/js";
import type { PropertyValues } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, state } from "lit/decorators";
import { consumeLocalize } from "../../../common/decorators/consume-context-entry";
import { computeEntityIdFormatExample } from "../../../common/entity/compute_entity_id_format_example";
import type { LocalizeFunc } from "../../../common/translations/localize";
import type { HaProgressButton } from "../../../components/buttons/ha-progress-button";
import "../../../components/buttons/ha-progress-button";
import "../../../components/ha-alert";
import "../../../components/ha-button";
import "../../../components/ha-card";
import "../../../components/ha-svg-icon";
import { apiContext, configContext } from "../../../data/context";
import {
fetchEntityRegistrySettings,
updateEntityRegistrySettings,
} from "../../../data/entity/entity_registry_settings";
import {
DEFAULT_ENTITY_ID_FORMAT,
isDefaultEntityIdFormat,
type EntityIdFormat,
type EntityIdPart,
} from "../../../data/entity_id_format";
import { haStyle } from "../../../resources/styles";
import { documentationUrl } from "../../../util/documentation-url";
import "./ha-entity-id-format-editor";
const EXAMPLE_DOMAIN = "sensor";
@customElement("ha-config-entity-id-format")
export class HaConfigEntityIdFormat extends LitElement {
@state()
@consumeLocalize()
private _localize!: LocalizeFunc;
@state()
@consume({ context: apiContext, subscribe: true })
private _api!: ContextType<typeof apiContext>;
@state()
@consume({ context: configContext, subscribe: true })
private _config!: ContextType<typeof configContext>;
@state() private _format?: EntityIdFormat;
@state() private _error?: string;
protected async firstUpdated(changedProps: PropertyValues<this>) {
super.firstUpdated(changedProps);
try {
const settings = await fetchEntityRegistrySettings(this._api);
this._format = settings.entity_id_parts ?? DEFAULT_ENTITY_ID_FORMAT;
} catch (err: any) {
this._error = err.message;
}
}
private _examples: Record<EntityIdPart, string> = {
area: "Living room",
device: "Thermostat",
entity: "Temperature",
floor: "Ground floor",
};
protected render() {
return html`
<ha-card
outlined
.header=${this._localize(
"ui.panel.config.entity_id_format.card.header"
)}
>
<div class="card-content">
${
this._error
? html`<ha-alert alert-type="error">${this._error}</ha-alert>`
: nothing
}
<p class="description">
${this._localize(
"ui.panel.config.entity_id_format.card.description"
)}
<a
href=${documentationUrl(
this._config,
"/docs/configuration/customizing-devices/"
)}
target="_blank"
rel="noreferrer"
>${this._localize(
"ui.panel.config.entity_id_format.card.learn_more"
)}</a
>
</p>
${
this._format
? html`
<ha-entity-id-format-editor
.value=${this._format}
.label=${this._localize(
"ui.panel.config.entity_id_format.card.editor.label"
)}
@value-changed=${this._formatChanged}
></ha-entity-id-format-editor>
${this._renderPreview()}
`
: nothing
}
</div>
<div class="card-actions">
<ha-button
appearance="plain"
@click=${this._reset}
.disabled=${!this._format || isDefaultEntityIdFormat(this._format)}
>
<ha-svg-icon slot="start" .path=${mdiRestore}></ha-svg-icon>
${this._localize("ui.panel.config.entity_id_format.card.reset")}
</ha-button>
<ha-progress-button
appearance="filled"
@click=${this._save}
.disabled=${!this._format}
>
${this._localize("ui.common.save")}
</ha-progress-button>
</div>
</ha-card>
`;
}
private _renderPreview() {
const example = computeEntityIdFormatExample(this._format!, this._examples);
return html`
<div class="preview">
<span class="preview-label">
${this._localize("ui.panel.config.entity_id_format.card.preview")}
</span>
<code>${EXAMPLE_DOMAIN}.${example}</code>
</div>
`;
}
private _formatChanged(ev: CustomEvent) {
this._format = ev.detail.value;
}
private _reset() {
this._format = [...DEFAULT_ENTITY_ID_FORMAT];
}
private async _save(ev: Event) {
const button = ev.currentTarget as HaProgressButton;
if (button.progress) {
return;
}
button.progress = true;
this._error = undefined;
try {
const format = this._format!;
const settings = await updateEntityRegistrySettings(this._api, {
entity_id_parts: isDefaultEntityIdFormat(format) ? null : format,
});
this._format = settings.entity_id_parts ?? DEFAULT_ENTITY_ID_FORMAT;
button.actionSuccess();
} catch (err: any) {
button.actionError();
this._error = err.message;
} finally {
button.progress = false;
}
}
static styles = [
haStyle,
css`
:host {
display: block;
}
.description {
margin-top: 0;
color: var(--secondary-text-color);
}
.preview {
display: flex;
flex-direction: column;
gap: var(--ha-space-1);
margin-top: var(--ha-space-5);
}
.preview-label {
font-weight: 500;
}
.preview code {
display: block;
padding: var(--ha-space-3);
border-radius: var(--ha-border-radius-md);
background-color: var(--secondary-background-color);
font-family: var(--ha-font-family-code);
overflow-wrap: anywhere;
}
.card-actions {
display: flex;
justify-content: space-between;
align-items: center;
}
`,
];
}
declare global {
interface HTMLElementTagNameMap {
"ha-config-entity-id-format": HaConfigEntityIdFormat;
}
}
@@ -0,0 +1,51 @@
import { css, html, LitElement } from "lit";
import { customElement, property } from "lit/decorators";
import "../../../layouts/hass-subpage";
import type { HomeAssistant } from "../../../types";
import "./ha-config-entity-id-format";
@customElement("ha-config-section-entity-id-format")
export class HaConfigSectionEntityIdFormat extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ type: Boolean }) public narrow = false;
protected render() {
return html`
<hass-subpage
back-path="/config/system"
.hass=${this.hass}
.narrow=${this.narrow}
.header=${this.hass.localize(
"ui.panel.config.entity_id_format.caption"
)}
>
<div class="content">
<ha-config-entity-id-format></ha-config-entity-id-format>
</div>
</hass-subpage>
`;
}
static styles = css`
.content {
padding: var(--ha-space-7) var(--ha-space-5) 0;
max-width: 1040px;
margin: 0 auto;
display: flex;
flex-direction: column;
gap: var(--ha-space-5);
}
ha-config-entity-id-format {
max-width: 600px;
margin: 0 auto;
width: 100%;
}
`;
}
declare global {
interface HTMLElementTagNameMap {
"ha-config-section-entity-id-format": HaConfigSectionEntityIdFormat;
}
}
@@ -0,0 +1,348 @@
import type { RenderItemFunction } from "@lit-labs/virtualizer/virtualize";
import { mdiDragHorizontalVariant, mdiPlus } from "@mdi/js";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import { repeat } from "lit/directives/repeat";
import memoizeOne from "memoize-one";
import { consumeLocalize } from "../../../common/decorators/consume-context-entry";
import { fireEvent } from "../../../common/dom/fire_event";
import type { LocalizeFunc } from "../../../common/translations/localize";
import "../../../components/chips/ha-assist-chip";
import "../../../components/chips/ha-chip-set";
import "../../../components/chips/ha-input-chip";
import "../../../components/ha-combo-box-item";
import "../../../components/ha-generic-picker";
import type { HaGenericPicker } from "../../../components/ha-generic-picker";
import type { PickerComboBoxItem } from "../../../components/ha-picker-combo-box";
import "../../../components/ha-sortable";
import "../../../components/ha-svg-icon";
import type {
EntityIdFormat,
EntityIdPart,
} from "../../../data/entity_id_format";
import type { ValueChangedEvent } from "../../../types";
const STRUCTURAL_TYPES = ["area", "device", "entity", "floor"] as const;
const REQUIRED_TYPES: readonly EntityIdPart[] = ["device", "entity"];
const rowRenderer: RenderItemFunction<PickerComboBoxItem> = (item) => html`
<ha-combo-box-item type="button" compact>
<span slot="headline">${item.primary}</span>
</ha-combo-box-item>
`;
const encodeType = (type: EntityIdPart) => `___${type}___`;
const decodeType = (value: string): EntityIdPart | undefined => {
const type =
value.startsWith("___") && value.endsWith("___")
? value.slice(3, -3)
: value;
return (STRUCTURAL_TYPES as readonly string[]).includes(type)
? (type as EntityIdPart)
: undefined;
};
@customElement("ha-entity-id-format-editor")
export class HaEntityIdFormatEditor extends LitElement {
@state()
@consumeLocalize()
private _localize!: LocalizeFunc;
@property({ attribute: false }) public value: EntityIdFormat = [];
@property() public label?: string;
@property({ type: Boolean, reflect: true }) public disabled = false;
@query("ha-generic-picker") private _picker?: HaGenericPicker;
@state() private _editIndex?: number;
protected render() {
return html`
<div class="container">
${this.label ? html`<label>${this.label}</label>` : nothing}
<ha-generic-picker
.disabled=${this.disabled}
.getItems=${this._getFilteredItems}
.rowRenderer=${rowRenderer}
.value=${this._getPickerValue()}
@value-changed=${this._pickerValueChanged}
.searchLabel=${this._localize(
"ui.panel.config.entity_id_format.card.editor.search"
)}
>
<div slot="field" class="field">
<ha-sortable
no-style
@item-moved=${this._moveItem}
.disabled=${this.disabled}
handle-selector="button.primary.action"
filter=".add"
>
<ha-chip-set>
${repeat(
this.value,
(item) => item,
(item: EntityIdPart, idx) => {
const label = this._formatType(item);
if (REQUIRED_TYPES.includes(item)) {
return html`
<ha-assist-chip
filled
class="required"
.label=${label}
.disabled=${this.disabled}
>
<ha-svg-icon
slot="icon"
.path=${mdiDragHorizontalVariant}
></ha-svg-icon>
</ha-assist-chip>
`;
}
return html`
<ha-input-chip
data-idx=${idx}
@remove=${this._removeItem}
@click=${this._editItem}
.label=${label}
.selected=${!this.disabled}
.disabled=${this.disabled}
>
<ha-svg-icon
slot="icon"
.path=${mdiDragHorizontalVariant}
></ha-svg-icon>
</ha-input-chip>
`;
}
)}
${
this.disabled
? nothing
: html`
<ha-assist-chip
@click=${this._addItem}
.disabled=${this.disabled}
label=${this._localize(
"ui.panel.config.entity_id_format.card.editor.add"
)}
class="add"
>
<ha-svg-icon
slot="icon"
.path=${mdiPlus}
></ha-svg-icon>
</ha-assist-chip>
`
}
</ha-chip-set>
</ha-sortable>
</div>
</ha-generic-picker>
</div>
`;
}
private async _addItem(ev: Event) {
ev.stopPropagation();
this._editIndex = undefined;
await this.updateComplete;
await this._picker?.open();
}
private async _editItem(ev: Event) {
ev.stopPropagation();
const idx = parseInt(
(ev.currentTarget as HTMLElement).dataset.idx || "",
10
);
this._editIndex = idx;
await this.updateComplete;
await this._picker?.open();
}
private _moveItem(ev: CustomEvent) {
ev.stopPropagation();
const { oldIndex, newIndex } = ev.detail;
const newValue = this.value.concat();
const element = newValue.splice(oldIndex, 1)[0];
newValue.splice(newIndex, 0, element);
this._setValue(newValue);
}
private _removeItem(ev: Event) {
ev.stopPropagation();
const value = [...this.value];
const idx = parseInt((ev.target as HTMLElement).dataset.idx || "", 10);
value.splice(idx, 1);
this._setValue(value);
}
private _pickerValueChanged(ev: ValueChangedEvent<string>): void {
ev.stopPropagation();
const value = ev.detail.value;
if (this.disabled || !value) {
return;
}
const type = decodeType(value);
if (!type) {
return;
}
const newValue = [...this.value];
if (this._editIndex != null) {
newValue[this._editIndex] = type;
this._editIndex = undefined;
} else {
newValue.push(type);
}
this._setValue(newValue);
if (this._picker) {
this._picker.value = undefined;
}
}
private _setValue(value: EntityIdFormat) {
this.value = value;
fireEvent(this, "value-changed", { value });
}
private _formatType = (type: EntityIdPart) =>
this._localize(`ui.components.entity.entity-name-picker.types.${type}`);
private _getItems = memoizeOne(
(localize: LocalizeFunc): PickerComboBoxItem[] =>
STRUCTURAL_TYPES.map((type) => {
const primary = localize(
`ui.components.entity.entity-name-picker.types.${type}`
);
const id = encodeType(type);
return {
id,
primary,
search_labels: { primary, id },
sorting_label: primary,
};
})
);
private _getPickerValue(): string | undefined {
if (this._editIndex != null) {
const item = this.value[this._editIndex];
return item ? encodeType(item) : undefined;
}
return undefined;
}
private _getFilteredItems = (): PickerComboBoxItem[] => {
const items = this._getItems(this._localize);
const currentValue =
this._editIndex != null
? encodeType(this.value[this._editIndex])
: undefined;
const usedValues = new Set(this.value.map((item) => encodeType(item)));
return items.filter(
(item) => !usedValues.has(item.id) || item.id === currentValue
);
};
static styles = css`
:host {
position: relative;
width: 100%;
}
.container {
display: flex;
flex-direction: column;
gap: var(--ha-space-2);
}
label {
display: block;
font-weight: 500;
}
ha-generic-picker {
width: 100%;
}
.field {
position: relative;
background-color: var(--mdc-text-field-fill-color, whitesmoke);
border-radius: var(--ha-border-radius-sm);
border-end-end-radius: var(--ha-border-radius-square);
border-end-start-radius: var(--ha-border-radius-square);
}
.field:after {
display: block;
content: "";
position: absolute;
pointer-events: none;
bottom: 0;
left: 0;
right: 0;
height: 1px;
width: 100%;
background-color: var(
--mdc-text-field-idle-line-color,
rgba(0, 0, 0, 0.42)
);
}
:host([disabled]) .field:after {
background-color: var(
--mdc-text-field-disabled-line-color,
rgba(0, 0, 0, 0.42)
);
}
.field:focus-within:after {
height: 2px;
background-color: var(--mdc-theme-primary);
}
ha-chip-set {
padding: var(--ha-space-3);
}
ha-assist-chip.required {
--ha-assist-chip-filled-container-color: rgba(
var(--rgb-primary-text-color),
0.15
);
}
.add {
order: 1;
}
.sortable-fallback {
display: none;
opacity: 0;
}
.sortable-ghost {
opacity: 0.4;
}
.sortable-drag {
cursor: grabbing;
}
`;
}
declare global {
interface HTMLElementTagNameMap {
"ha-entity-id-format-editor": HaEntityIdFormatEditor;
}
}
@@ -0,0 +1,196 @@
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 { computeDeviceNameDisplay } from "../../../../common/entity/compute_device_name";
import { caseInsensitiveStringCompare } from "../../../../common/string/compare";
import "../../../../components/ha-card";
import "../../../../components/ha-icon-next";
import "../../../../components/ha-list-item";
import type { ConfigEntry } from "../../../../data/config_entries";
import type { DeviceRegistryEntry } from "../../../../data/device/device_registry";
import { fetchLinkedDevices } from "../../../../data/device/device_registry";
import { domainToName } from "../../../../data/integration";
import type { HomeAssistant } from "../../../../types";
import { brandsUrl } from "../../../../util/brands-url";
@customElement("ha-device-linked-devices-card")
export class HaDeviceLinkedDevicesCard extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false }) public deviceId!: string;
@property({ attribute: false }) public entries!: ConfigEntry[];
@state() private _linkedDeviceIds: string[] = [];
private _entryLookup = memoizeOne(
(entries: ConfigEntry[]): Record<string, ConfigEntry> => {
const lookup: Record<string, ConfigEntry> = {};
for (const entry of entries) {
lookup[entry.entry_id] = entry;
}
return lookup;
}
);
private _linkedDevices = memoizeOne(
(
linkedDeviceIds: string[],
devices: Record<string, DeviceRegistryEntry>
): DeviceRegistryEntry[] =>
linkedDeviceIds
.map((id) => devices[id])
.filter((device): device is DeviceRegistryEntry => Boolean(device))
.sort((d1, d2) =>
caseInsensitiveStringCompare(
computeDeviceNameDisplay(d1, this.hass.localize, this.hass.states),
computeDeviceNameDisplay(d2, this.hass.localize, this.hass.states),
this.hass.locale.language
)
)
);
protected willUpdate(changedProps: PropertyValues<this>) {
super.willUpdate(changedProps);
if (changedProps.has("deviceId")) {
this._fetchLinkedDevices();
}
}
protected render() {
const linkedDevices = this._linkedDevices(
this._linkedDeviceIds,
this.hass.devices
);
if (linkedDevices.length === 0) {
return nothing;
}
const entryLookup = this._entryLookup(this.entries ?? []);
return html`
<ha-card>
<h1 class="card-header">
${this.hass.localize("ui.panel.config.devices.linked_devices.heading")}
</h1>
<div class="card-content">
${this.hass.localize(
"ui.panel.config.devices.linked_devices.description"
)}
</div>
${linkedDevices.map((linkedDevice) => {
const domain = this._deviceDomain(linkedDevice, entryLookup);
const integrationName = domain
? domainToName(this.hass.localize, domain)
: undefined;
return html`
<a href=${`/config/devices/device/${linkedDevice.id}`}>
<ha-list-item
graphic=${domain ? "icon" : nothing}
hasMeta
.twoline=${!!integrationName}
>
${
domain
? html`<img
slot="graphic"
loading="lazy"
alt=""
src=${brandsUrl(
{
domain,
type: "icon",
darkOptimized: this.hass.themes?.darkMode,
},
this.hass.auth.data.hassUrl
)}
crossorigin="anonymous"
referrerpolicy="no-referrer"
/>`
: nothing
}
${computeDeviceNameDisplay(
linkedDevice,
this.hass.localize,
this.hass.states
)}
${
integrationName
? html`<span slot="secondary">${integrationName}</span>`
: nothing
}
<ha-icon-next slot="meta"></ha-icon-next>
</ha-list-item>
</a>
`;
})}
</ha-card>
`;
}
private _deviceDomain(
device: DeviceRegistryEntry,
entryLookup: Record<string, ConfigEntry>
): string | undefined {
const entryId =
device.primary_config_entry ?? device.config_entries[0] ?? undefined;
const entry = entryId ? entryLookup[entryId] : undefined;
return entry?.domain;
}
private async _fetchLinkedDevices() {
const deviceId = this.deviceId;
if (!deviceId) {
this._linkedDeviceIds = [];
return;
}
let linkedDeviceIds: string[];
try {
linkedDeviceIds = await fetchLinkedDevices(this.hass, deviceId);
} catch (_err) {
linkedDeviceIds = [];
}
if (this.deviceId !== deviceId) {
// Device changed while fetching, ignore stale result.
return;
}
this._linkedDeviceIds = linkedDeviceIds;
}
static styles = css`
:host {
display: block;
}
ha-card {
overflow: hidden;
}
.card-header {
padding-bottom: 0;
}
.card-content {
color: var(--secondary-text-color);
padding-bottom: var(--ha-space-2);
}
a {
text-decoration: none;
color: var(--primary-text-color);
}
ha-list-item img {
width: 24px;
height: 24px;
}
`;
}
declare global {
interface HTMLElementTagNameMap {
"ha-device-linked-devices-card": HaDeviceLinkedDevicesCard;
}
}
@@ -102,6 +102,7 @@ import { fileDownload } from "../../../util/file_download";
import "../../logbook/ha-logbook";
import "./device-detail/ha-device-entities-card";
import "./device-detail/ha-device-info-card";
import "./device-detail/ha-device-linked-devices-card";
import "./device-detail/ha-device-via-devices-card";
import { showDeviceAddToDialog } from "./device-detail/show-dialog-device-add-to";
import {
@@ -892,6 +893,11 @@ export class HaConfigDevicePage extends LitElement {
: ""
}
</ha-device-info-card>
<ha-device-linked-devices-card
.hass=${this.hass}
.deviceId=${this.deviceId}
.entries=${this.entries}
></ha-device-linked-devices-card>
`;
const entitiesColumn = html`
@@ -1,6 +1,6 @@
import type { CSSResultGroup } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import { customElement, property, state } from "lit/decorators";
import { fireEvent } from "../../../../common/dom/fire_event";
import "../../../../components/entity/ha-statistic-picker";
import "../../../../components/ha-button";
@@ -29,10 +29,11 @@ import "./ha-energy-power-config";
import {
buildPowerExcludeList,
getInitialPowerConfig,
getPowerHelperEntityId,
getPowerTypeFromConfig,
type HaEnergyPowerConfig,
isPowerConfigValid,
type PowerType,
} from "./ha-energy-power-config";
} from "./power-config";
import type { EnergySettingsBatteryDialogParams } from "./show-dialogs-energy";
import type { HaInput } from "../../../../components/input/ha-input";
@@ -67,8 +68,6 @@ export class DialogEnergyBatterySettings
@state() private _error?: string;
@query("ha-energy-power-config") private _powerConfigEl?: HaEnergyPowerConfig;
private _excludeList?: string[];
private _excludeListPower?: string[];
@@ -229,6 +228,10 @@ export class DialogEnergyBatterySettings
.powerType=${this._powerType}
.powerConfig=${this._powerConfig}
.excludeList=${this._excludeListPower}
.helperEntityId=${getPowerHelperEntityId(
this._params.source,
this._powerConfig
)}
.localizeBaseKey=${"ui.panel.config.energy.battery.dialog"}
@power-config-changed=${this._handlePowerConfigChanged}
></ha-energy-power-config>
@@ -295,11 +298,7 @@ export class DialogEnergyBatterySettings
}
// Check power config validity
if (this._powerConfigEl && !this._powerConfigEl.isValid()) {
return false;
}
return true;
return isPowerConfigValid(this._powerType, this._powerConfig);
}
private async _updateMetadata(statId: string) {
@@ -374,6 +373,7 @@ export class DialogEnergyBatterySettings
if (this._source.capacity === undefined) {
delete this._source.capacity;
}
this._updateFormDirtyState();
}
private async _save() {
@@ -1,6 +1,6 @@
import type { CSSResultGroup } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import { customElement, property, state } from "lit/decorators";
import { fireEvent } from "../../../../common/dom/fire_event";
import "../../../../components/entity/ha-entity-picker";
import "../../../../components/entity/ha-statistic-picker";
@@ -33,10 +33,11 @@ import "./ha-energy-power-config";
import {
buildPowerExcludeList,
getInitialPowerConfig,
getPowerHelperEntityId,
getPowerTypeFromConfig,
type HaEnergyPowerConfig,
isPowerConfigValid,
type PowerType,
} from "./ha-energy-power-config";
} from "./power-config";
import type { EnergySettingsGridDialogParams } from "./show-dialogs-energy";
import type { HaInput } from "../../../../components/input/ha-input";
@@ -77,8 +78,6 @@ export class DialogEnergyGridSettings
@state() private _error?: string;
@query("ha-energy-power-config") private _powerConfigEl?: HaEnergyPowerConfig;
private _excludeList?: string[];
private _excludeListPower?: string[];
@@ -471,6 +470,10 @@ export class DialogEnergyGridSettings
.powerType=${this._powerType}
.powerConfig=${this._powerConfig}
.excludeList=${this._excludeListPower}
.helperEntityId=${getPowerHelperEntityId(
this._params.source,
this._powerConfig
)}
.localizeBaseKey=${"ui.panel.config.energy.grid.dialog"}
@power-config-changed=${this._handlePowerConfigChanged}
></ha-energy-power-config>
@@ -508,10 +511,8 @@ export class DialogEnergyGridSettings
}
// Check power config validity (if power is configured)
if (hasPower) {
if (this._powerConfigEl && !this._powerConfigEl.isValid()) {
return false;
}
if (hasPower && !isPowerConfigValid(this._powerType, this._powerConfig)) {
return false;
}
return true;
@@ -1,99 +1,24 @@
import { mdiInformationOutline } from "@mdi/js";
import type { CSSResultGroup, PropertyValues, TemplateResult } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { fireEvent } from "../../../../common/dom/fire_event";
import { stopPropagation } from "../../../../common/dom/stop_propagation";
import type { LocalizeKeys } from "../../../../common/translations/localize";
import "../../../../components/entity/ha-statistic-picker";
import "../../../../components/ha-svg-icon";
import "../../../../components/ha-tooltip";
import "../../../../components/radio/ha-radio-group";
import type { HaRadioGroup } from "../../../../components/radio/ha-radio-group";
import "../../../../components/radio/ha-radio-option";
import type { PowerConfig } from "../../../../data/energy";
import { getSensorDeviceClassConvertibleUnits } from "../../../../data/sensor";
import { buttonLinkStyle } from "../../../../resources/styles";
import type { HomeAssistant, ValueChangedEvent } from "../../../../types";
export type PowerType = "none" | "standard" | "inverted" | "two_sensors";
import type { PowerType } from "./power-config";
const powerUnitClasses = ["power"];
/**
* Extracts the power type from a PowerConfig object.
*/
export function getPowerTypeFromConfig(
powerConfig?: PowerConfig,
statRate?: string
): PowerType {
if (powerConfig) {
if (powerConfig.stat_rate_inverted) {
return "inverted";
}
if (powerConfig.stat_rate_from || powerConfig.stat_rate_to) {
return "two_sensors";
}
if (powerConfig.stat_rate) {
return "standard";
}
} else if (statRate) {
// Legacy format - treat as standard
return "standard";
}
return "none";
}
/**
* Creates an initial PowerConfig from existing config or legacy stat_rate.
*/
export function getInitialPowerConfig(
powerConfig?: PowerConfig,
statRate?: string
): PowerConfig {
if (powerConfig) {
return { ...powerConfig };
}
if (statRate) {
return { stat_rate: statRate };
}
return {};
}
/**
* Builds an exclude list for power statistics from existing sources.
*/
export function buildPowerExcludeList(
sources: { stat_rate?: string; power_config?: PowerConfig }[],
currentPowerConfig: PowerConfig,
currentStatRate?: string
): string[] {
const powerIds: string[] = [];
sources.forEach((entry) => {
if (entry.stat_rate) powerIds.push(entry.stat_rate);
if (entry.power_config) {
if (entry.power_config.stat_rate) {
powerIds.push(entry.power_config.stat_rate);
}
if (entry.power_config.stat_rate_inverted) {
powerIds.push(entry.power_config.stat_rate_inverted);
}
if (entry.power_config.stat_rate_from) {
powerIds.push(entry.power_config.stat_rate_from);
}
if (entry.power_config.stat_rate_to) {
powerIds.push(entry.power_config.stat_rate_to);
}
}
});
const currentPowerIds = [
currentPowerConfig.stat_rate,
currentPowerConfig.stat_rate_inverted,
currentPowerConfig.stat_rate_from,
currentPowerConfig.stat_rate_to,
currentStatRate,
].filter(Boolean) as string[];
return powerIds.filter((id) => !currentPowerIds.includes(id));
}
declare global {
interface HASSDomEvents {
"power-config-changed": { powerType: PowerType; powerConfig: PowerConfig };
@@ -110,10 +35,14 @@ export class HaEnergyPowerConfig extends LitElement {
@property({ attribute: false }) public excludeList?: string[];
/** Entity id of the power sensor generated for the saved config, if any. */
@property({ attribute: false }) public helperEntityId?: string;
/**
* Base key for localization lookups.
* Should include keys for: sensor_type, type_none, type_standard, type_inverted,
* type_two_sensors, power, power_helper, type_inverted_description, power_from, power_to
* Should include keys for: sensor_type, sensor_type_para, type_none, type_standard,
* type_inverted, type_two_sensors, power, power_helper, type_inverted_description,
* power_from, power_to, helper_sensor_note, helper_sensor_in_use
*/
@property({ attribute: false }) public localizeBaseKey =
"ui.panel.config.energy.battery.dialog";
@@ -132,6 +61,10 @@ export class HaEnergyPowerConfig extends LitElement {
}
}
private get _requiredError(): string {
return this.hass.localize("ui.common.error_required");
}
protected render(): TemplateResult {
return html`
<p class="power-section-label">
@@ -164,11 +97,13 @@ export class HaEnergyPowerConfig extends LitElement {
${this.hass.localize(
`${this.localizeBaseKey}.type_inverted` as LocalizeKeys
)}
${this._renderHelperSensorNote("inverted")}
</ha-radio-option>
<ha-radio-option value="two_sensors">
${this.hass.localize(
`${this.localizeBaseKey}.type_two_sensors` as LocalizeKeys
)}
${this._renderHelperSensorNote("two_sensors")}
</ha-radio-option>
</ha-radio-group>
@@ -188,6 +123,9 @@ export class HaEnergyPowerConfig extends LitElement {
`${this.localizeBaseKey}.power_helper` as LocalizeKeys,
{ unit: this._powerUnits?.join(", ") || "" }
)}
required
.invalid=${!this.powerConfig.stat_rate}
.errorMessage=${this._requiredError}
></ha-statistic-picker>
`
: nothing
@@ -207,11 +145,16 @@ export class HaEnergyPowerConfig extends LitElement {
.helper=${this.hass.localize(
`${this.localizeBaseKey}.type_inverted_description` as LocalizeKeys
)}
required
.invalid=${!this.powerConfig.stat_rate_inverted}
.errorMessage=${this._requiredError}
></ha-statistic-picker>
`
: nothing
}
${
// These two exclude each other, so they keep their clear button
// (and therefore no required marker) to stay swappable.
this.powerType === "two_sensors"
? html`
<ha-statistic-picker
@@ -226,6 +169,8 @@ export class HaEnergyPowerConfig extends LitElement {
this.powerConfig.stat_rate_to,
].filter((id): id is string => Boolean(id))}
@value-changed=${this._fromPowerChanged}
.invalid=${!this.powerConfig.stat_rate_from}
.errorMessage=${this._requiredError}
></ha-statistic-picker>
<ha-statistic-picker
.hass=${this.hass}
@@ -239,13 +184,56 @@ export class HaEnergyPowerConfig extends LitElement {
this.powerConfig.stat_rate_from,
].filter((id): id is string => Boolean(id))}
@value-changed=${this._toPowerChanged}
.invalid=${!this.powerConfig.stat_rate_to}
.errorMessage=${this._requiredError}
></ha-statistic-picker>
`
: nothing
}
${this._renderHelperSensorInUse()}
`;
}
private _renderHelperSensorNote(powerType: PowerType) {
const id = `helper-sensor-note-${powerType}`;
return html`
<ha-svg-icon
id=${id}
tabindex="0"
class="note-icon"
.path=${mdiInformationOutline}
@click=${stopPropagation}
></ha-svg-icon>
<ha-tooltip .for=${id} placement="top">
${this.hass.localize(
`${this.localizeBaseKey}.helper_sensor_note` as LocalizeKeys
)}
</ha-tooltip>
`;
}
private _renderHelperSensorInUse() {
if (!this.helperEntityId) {
return nothing;
}
return html`
<p class="helper-sensor-in-use">
${this.hass.localize(
`${this.localizeBaseKey}.helper_sensor_in_use` as LocalizeKeys,
{
entity: html`<button class="link" @click=${this._showHelperSensor}>
${this.helperEntityId}
</button>`,
}
)}
</p>
`;
}
private _showHelperSensor() {
fireEvent(this, "hass-more-info", { entityId: this.helperEntityId! });
}
private _handlePowerTypeChanged(ev: Event) {
const newPowerType = (ev.currentTarget as HaRadioGroup).value as PowerType;
// Clear power config when switching types
@@ -289,48 +277,46 @@ export class HaEnergyPowerConfig extends LitElement {
});
}
/**
* Validates that the power config is complete for the selected type.
*/
public isValid(): boolean {
switch (this.powerType) {
case "none":
return true;
case "standard":
return !!this.powerConfig.stat_rate;
case "inverted":
return !!this.powerConfig.stat_rate_inverted;
case "two_sensors":
return (
!!this.powerConfig.stat_rate_from && !!this.powerConfig.stat_rate_to
);
default:
return false;
}
}
static readonly styles: CSSResultGroup = css`
ha-statistic-picker {
display: block;
margin-bottom: var(--ha-space-4);
}
ha-statistic-picker:last-of-type {
margin-bottom: 0;
}
ha-radio-group {
margin-bottom: var(--ha-space-4);
}
.power-section-label {
margin-top: var(--ha-space-4);
margin-bottom: var(--ha-space-2);
}
.power-section-description {
margin-top: 0;
margin-bottom: var(--ha-space-2);
color: var(--secondary-text-color);
font-size: 0.875em;
}
`;
static readonly styles: CSSResultGroup = [
buttonLinkStyle,
css`
ha-statistic-picker {
display: block;
margin-bottom: var(--ha-space-4);
}
ha-statistic-picker:last-of-type {
margin-bottom: 0;
}
ha-radio-group {
margin-bottom: var(--ha-space-4);
}
.power-section-label {
margin-top: var(--ha-space-4);
margin-bottom: var(--ha-space-2);
}
.power-section-description {
margin-top: 0;
margin-bottom: var(--ha-space-2);
color: var(--secondary-text-color);
font-size: 0.875em;
}
.note-icon {
margin-inline-start: var(--ha-space-1);
color: var(--secondary-text-color);
--mdc-icon-size: 18px;
}
.helper-sensor-in-use {
margin: var(--ha-space-2) 0 0 0;
color: var(--secondary-text-color);
font-size: 0.875em;
}
.helper-sensor-in-use button.link {
color: var(--primary-color);
/* entity ids offer no break opportunities of their own */
overflow-wrap: anywhere;
}
`,
];
}
declare global {
@@ -0,0 +1,127 @@
import type { PowerConfig } from "../../../../data/energy";
import { deepEqual } from "../../../../common/util/deep-equal";
export type PowerType = "none" | "standard" | "inverted" | "two_sensors";
/**
* Extracts the power type from a PowerConfig object.
*/
export function getPowerTypeFromConfig(
powerConfig?: PowerConfig,
statRate?: string
): PowerType {
if (powerConfig) {
if (powerConfig.stat_rate_inverted) {
return "inverted";
}
if (powerConfig.stat_rate_from || powerConfig.stat_rate_to) {
return "two_sensors";
}
if (powerConfig.stat_rate) {
return "standard";
}
} else if (statRate) {
// Legacy format - treat as standard
return "standard";
}
return "none";
}
/**
* Creates an initial PowerConfig from existing config or legacy stat_rate.
*/
export function getInitialPowerConfig(
powerConfig?: PowerConfig,
statRate?: string
): PowerConfig {
if (powerConfig) {
return { ...powerConfig };
}
if (statRate) {
return { stat_rate: statRate };
}
return {};
}
/**
* Checks that the power config is complete for the selected power type.
*/
export function isPowerConfigValid(
powerType: PowerType,
powerConfig: PowerConfig
): boolean {
switch (powerType) {
case "none":
return true;
case "standard":
return !!powerConfig.stat_rate;
case "inverted":
return !!powerConfig.stat_rate_inverted;
case "two_sensors":
return !!powerConfig.stat_rate_from && !!powerConfig.stat_rate_to;
default:
return false;
}
}
/**
* Builds an exclude list for power statistics from existing sources.
*/
export function buildPowerExcludeList(
sources: { stat_rate?: string; power_config?: PowerConfig }[],
currentPowerConfig: PowerConfig,
currentStatRate?: string
): string[] {
const powerIds: string[] = [];
sources.forEach((entry) => {
if (entry.stat_rate) powerIds.push(entry.stat_rate);
if (entry.power_config) {
if (entry.power_config.stat_rate) {
powerIds.push(entry.power_config.stat_rate);
}
if (entry.power_config.stat_rate_inverted) {
powerIds.push(entry.power_config.stat_rate_inverted);
}
if (entry.power_config.stat_rate_from) {
powerIds.push(entry.power_config.stat_rate_from);
}
if (entry.power_config.stat_rate_to) {
powerIds.push(entry.power_config.stat_rate_to);
}
}
});
const currentPowerIds = [
currentPowerConfig.stat_rate,
currentPowerConfig.stat_rate_inverted,
currentPowerConfig.stat_rate_from,
currentPowerConfig.stat_rate_to,
currentStatRate,
].filter(Boolean) as string[];
return powerIds.filter((id) => !currentPowerIds.includes(id));
}
/**
* Returns the entity id of the power sensor the backend generated for a saved
* source, if any. Inverted and two sensor configs get such a helper, its entity
* id is stored in `stat_rate`. Returns nothing while the config differs from the
* saved one, as the helper doesn't match the edited config yet.
*/
export function getPowerHelperEntityId(
source: { stat_rate?: string; power_config?: PowerConfig } | undefined,
currentPowerConfig: PowerConfig
): string | undefined {
if (!source?.stat_rate || !source.power_config) {
return undefined;
}
const savedPowerType = getPowerTypeFromConfig(source.power_config);
if (savedPowerType !== "inverted" && savedPowerType !== "two_sensors") {
return undefined;
}
if (!deepEqual(source.power_config, currentPowerConfig)) {
return undefined;
}
return source.stat_rate;
}
+5
View File
@@ -82,6 +82,7 @@ class HaPanelConfig extends HassRouterPage {
info: {
tag: "ha-config-info",
load: () => import("./info/ha-config-info"),
waitForReady: true,
},
// customize was removed in 2021.12, fallback to dashboard
customize: "dashboard",
@@ -170,6 +171,10 @@ class HaPanelConfig extends HassRouterPage {
tag: "ha-config-section-ai-tasks",
load: () => import("./core/ha-config-section-ai-tasks"),
},
"entity-id-format": {
tag: "ha-config-section-entity-id-format",
load: () => import("./core/ha-config-section-entity-id-format"),
},
zha: {
tag: "zha-config-dashboard-router",
load: () =>
@@ -300,6 +300,7 @@ class HaScheduleForm extends LitElement {
const newValue = { ...this._item };
const endFormatted = formatTime24h(end, this.hass.locale, this.hass.config);
newValue[day] = [...newValue[day]];
newValue[day][index] = {
...newValue[day][index],
from: value.from,
@@ -337,6 +338,7 @@ class HaScheduleForm extends LitElement {
};
if (newDay === day) {
newValue[day] = [...newValue[day]];
newValue[day][index] = event;
} else {
newValue[day].splice(index, 1);
+2
View File
@@ -24,6 +24,7 @@ import { fetchHassioInfo } from "../../../data/hassio/supervisor";
import { subscribeSystemHealthInfo } from "../../../data/system_health";
import { showShortcutsDialog } from "../../../dialogs/shortcuts/show-shortcuts-dialog";
import "../../../layouts/hass-subpage";
import { panelIsReady } from "../../../layouts/panel-ready";
import { mdiHomeAssistant } from "../../../resources/home-assistant-logo-svg";
import { haStyle } from "../../../resources/styles";
import type { HomeAssistant, Route } from "../../../types";
@@ -289,6 +290,7 @@ class HaConfigInfo extends LitElement {
if (info?.homeassistant) {
this._installationMethod = info.homeassistant.info.installation_type;
unsubSystemHealth.then((unsub) => unsub());
panelIsReady(this);
}
});
}
@@ -4,6 +4,7 @@ import {
mdiCloseCircle,
mdiProgressClock,
} from "@mdi/js";
import type { UnsubscribeFunc } from "home-assistant-js-websocket";
import type { CSSResultGroup, PropertyValues, TemplateResult } from "lit";
import { LitElement, css, html, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
@@ -25,10 +26,10 @@ import "../../../../../components/ha-settings-row";
import "../../../../../components/ha-svg-icon";
import "../../../../../components/input/ha-input";
import type {
ZWaveJSNodeCapabilities,
ZWaveJSNodeConfigParam,
ZWaveJSNodeConfigParams,
ZWaveJSSetConfigParamResult,
ZwaveJSNodeConfigParameterUpdate,
ZwaveJSNodeMetadata,
} from "../../../../../data/zwave_js";
import {
@@ -37,6 +38,7 @@ import {
fetchZwaveNodeMetadata,
invokeZWaveCCApi,
setZwaveNodeConfigParameter,
subscribeZwaveNodeConfigParameterUpdates,
} from "../../../../../data/zwave_js";
import { showConfirmationDialog } from "../../../../../dialogs/generic/show-dialog-box";
import "../../../../../layouts/hass-error-screen";
@@ -59,7 +61,7 @@ const icons = {
@customElement("zwave_js-node-config")
class ZWaveJSNodeConfig extends LitElement {
public hass!: HomeAssistant;
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false }) public route!: Route;
@@ -83,13 +85,41 @@ class ZWaveJSNodeConfig extends LitElement {
@state() private _resetDialogProgress = false;
private _unsubConfigParamUpdates?: Promise<UnsubscribeFunc>;
private _resultTimeouts: Record<string, number> = {};
public connectedCallback(): void {
super.connectedCallback();
this.deviceId = this.route.path.substr(1);
this._subscribeConfigParameterUpdates();
}
public disconnectedCallback(): void {
super.disconnectedCallback();
this._unsubscribeConfigParameterUpdates();
this._clearAllResultTimeouts();
}
protected willUpdate(changedProps: PropertyValues<this>): void {
super.willUpdate(changedProps);
if (!changedProps.has("route") || !this.route) {
return;
}
const deviceId = this.route.path.slice(1);
if (deviceId !== this.deviceId) {
this.deviceId = deviceId;
this._config = undefined;
this._clearAllResultTimeouts();
this._results = {};
this._error = undefined;
}
}
protected updated(changedProps: PropertyValues<this>): void {
if (!this._config || changedProps.has("deviceId")) {
if (changedProps.has("deviceId")) {
this._fetchData();
this._subscribeConfigParameterUpdates();
} else if (!this._config) {
this._fetchData();
}
}
@@ -443,6 +473,58 @@ class ZWaveJSNodeConfig extends LitElement {
<p>${item.value}</p>`;
}
private _subscribeConfigParameterUpdates(): void {
this._unsubscribeConfigParameterUpdates();
if (!this.isConnected || !this.hass || !this.deviceId) {
return;
}
this._unsubConfigParamUpdates = subscribeZwaveNodeConfigParameterUpdates(
this.hass,
this.deviceId,
this._handleConfigParameterUpdate
);
this._unsubConfigParamUpdates.catch(() => {
// The backend doesn't support the subscription; the page still works,
// it just won't receive live updates
this._unsubConfigParamUpdates = undefined;
});
}
private _unsubscribeConfigParameterUpdates(): void {
if (this._unsubConfigParamUpdates) {
this._unsubConfigParamUpdates
.then((unsub) => unsub())
.catch(() => {
// The subscription never succeeded, so there is nothing to clean up
});
this._unsubConfigParamUpdates = undefined;
}
}
private _handleConfigParameterUpdate = (
update: ZwaveJSNodeConfigParameterUpdate
): void => {
const param = this._config?.[update.id];
if (!param) {
return;
}
const status = this._results[update.id]?.status;
if (status === "queued") {
// The device applied the queued change; the accepted result is cleared
// after a short delay by _setResult so the success message shows.
// The value was already set optimistically when the change was queued,
// so this runs even if the reported value matches the stored one.
this._setResult(update.id, "accepted");
} else if (status === "error" && param.value !== update.value) {
// The parameter changed, so the previous error no longer applies
this._setResult(update.id, undefined);
}
if (param.value !== update.value) {
param.value = update.value;
this._config = { ...this._config };
}
};
private _isEnumeratedBool(item: ZWaveJSNodeConfigParam): boolean {
// Some Z-Wave config values use a states list with two options where index 0 = Disabled and 1 = Enabled
// We want those to be considered boolean and show a toggle switch
@@ -609,16 +691,38 @@ class ZWaveJSNodeConfig extends LitElement {
}
}
private _clearResultTimeout(key: string): void {
if (key in this._resultTimeouts) {
clearTimeout(this._resultTimeouts[key]);
delete this._resultTimeouts[key];
}
}
private _clearAllResultTimeouts(): void {
Object.values(this._resultTimeouts).forEach((timeout) =>
clearTimeout(timeout)
);
this._resultTimeouts = {};
}
private _setResult(key: string, value: string | undefined) {
this._clearResultTimeout(key);
if (value === undefined) {
delete this._results[key];
this.requestUpdate();
} else {
this._results = { ...this._results, [key]: { status: value } };
if (value === "accepted") {
// Show the success message briefly, then clear it
this._resultTimeouts[key] = window.setTimeout(() => {
this._setResult(key, undefined);
}, 2000);
}
}
}
private _setError(key: string, message: string) {
this._clearResultTimeout(key);
const errorParam = { status: "error", error: message };
this._results = { ...this._results, [key]: errorParam };
}
@@ -634,12 +738,17 @@ class ZWaveJSNodeConfig extends LitElement {
return;
}
let capabilities: ZWaveJSNodeCapabilities | undefined;
[this._nodeMetadata, this._config, capabilities] = await Promise.all([
const [nodeMetadata, config, capabilities] = await Promise.all([
fetchZwaveNodeMetadata(this.hass, device.id),
fetchZwaveNodeConfigParameters(this.hass, device.id),
fetchZwaveNodeCapabilities(this.hass, device.id),
]);
if (device.id !== this.deviceId) {
// The user navigated to another node while the data was loading
return;
}
this._nodeMetadata = nodeMetadata;
this._config = config;
this._canResetAll =
capabilities &&
Object.values(capabilities).some((endpoint) =>
@@ -397,8 +397,20 @@ class HaConfigHttpForm extends LitElement {
this._error = undefined;
this._fieldErrors = {};
this._showNoChanges = false;
// Drop empty entries from multi-value fields, and omit the field entirely
// once it is empty so the backend applies its default. Otherwise a cleared
// "IP address to bind to" would submit [""] / [], which binds to nothing.
const config = Object.fromEntries(
Object.entries(this._config).map(([key, value]) => {
if (Array.isArray(value)) {
const filtered = value.filter(Boolean);
return [key, filtered.length ? filtered : undefined];
}
return [key, value];
})
) as HttpConfig;
try {
const result = await saveHttpConfig(this.hass, this._config);
const result = await saveHttpConfig(this.hass, config);
if (!result.restart) {
this._showNoChanges = true;
}
+356 -214
View File
@@ -1,17 +1,27 @@
import {
mdiRestore,
mdiTrashCanOutline,
mdiViewSplitHorizontal,
mdiViewSplitVertical,
} from "@mdi/js";
import memoizeOne from "memoize-one";
import type { UnsubscribeFunc } from "home-assistant-js-websocket";
import type { CSSResultGroup } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import type { HASSDomEvent } from "../../../../common/dom/fire_event";
import { customElement, property, state } from "lit/decorators";
import { stopPropagation } from "../../../../common/dom/stop_propagation";
import type { LocalizeKeys } from "../../../../common/translations/localize";
import { debounce } from "../../../../common/util/debounce";
import "../../../../components/ha-alert";
import "../../../../components/ha-button";
import "../../../../components/ha-card";
import "../../../../components/ha-code-editor";
import "../../../../components/ha-expansion-panel";
import type { HaIconButtonToolbarItem } from "../../../../components/ha-icon-button-toolbar";
import "../../../../components/ha-label";
import "../../../../components/ha-spinner";
import "../../../../components/ha-split-panel";
import type { HaSplitPanel } from "../../../../components/ha-split-panel";
import "../../../../components/ha-svg-icon";
import "../../../../components/ha-tip";
import type { RenderTemplateResult } from "../../../../data/ws-templates";
import { subscribeRenderTemplate } from "../../../../data/ws-templates";
@@ -50,11 +60,18 @@ const TEMPLATE_DOCS_LINKS: { key: string; path: string }[] = [
{ key: "docs_functions", path: "/template-functions/" },
];
const STORAGE_KEY_TEMPLATE = "panel-dev-template-template";
const STORAGE_KEY_SPLIT_POSITION = "panel-dev-template-split-position";
const STORAGE_KEY_SPLIT_ORIENTATION = "panel-dev-template-split-orientation";
const DEFAULT_SPLIT_POSITION = 50;
type SplitOrientation = "horizontal" | "vertical";
@customElement("tools-template")
class HaPanelDevTemplate extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ type: Boolean }) public narrow = false;
@property({ type: Boolean, reflect: true }) public narrow = false;
@state() private _error?: string;
@@ -66,9 +83,9 @@ class HaPanelDevTemplate extends LitElement {
@state() private _unsubRenderTemplate?: Promise<UnsubscribeFunc>;
@state() private _descriptionExpanded = false;
@state() private _splitPosition = DEFAULT_SPLIT_POSITION;
@query("ha-tip") private _editorTip?: HTMLElement;
@state() private _splitOrientation: SplitOrientation = "horizontal";
private _template = "";
@@ -78,8 +95,6 @@ class HaPanelDevTemplate extends LitElement {
// its late-arriving results discarded.
private _subscribeRequestId = 0;
private _tipResizeObserver?: ResizeObserver;
public connectedCallback() {
super.connectedCallback();
if (this._template && !this._unsubRenderTemplate) {
@@ -90,18 +105,25 @@ class HaPanelDevTemplate extends LitElement {
public disconnectedCallback() {
super.disconnectedCallback();
this._unsubscribeTemplate();
this._tipResizeObserver?.disconnect();
this._tipResizeObserver = undefined;
}
protected firstUpdated() {
if (localStorage && localStorage["panel-dev-template-template"]) {
this._template = localStorage["panel-dev-template-template"];
if (localStorage && localStorage[STORAGE_KEY_TEMPLATE]) {
this._template = localStorage[STORAGE_KEY_TEMPLATE];
} else {
this._template = DEMO_TEMPLATE;
}
const storedPosition = localStorage?.[STORAGE_KEY_SPLIT_POSITION];
if (storedPosition) {
const parsed = parseFloat(storedPosition);
if (!isNaN(parsed) && parsed >= 0 && parsed <= 100) {
this._splitPosition = parsed;
}
}
if (localStorage?.[STORAGE_KEY_SPLIT_ORIENTATION] === "vertical") {
this._splitOrientation = "vertical";
}
this._subscribeTemplate();
this._observeTipHeight();
this._inited = true;
}
@@ -114,15 +136,20 @@ class HaPanelDevTemplate extends LitElement {
: "dict"
: type;
const editorCard = this._renderEditorCard();
const resultCard = this._renderResultCard(type, resultType);
// On narrow viewports side-by-side is too cramped, so force the (still
// resizable) stacked layout and hide the orientation toggle.
const orientation = this.narrow ? "vertical" : this._splitOrientation;
return html`
<div class="content">
<div class="about">
<ha-expansion-panel
.header=${this.hass.localize(
"ui.panel.config.tools.tabs.templates.about"
)}
outlined
.expanded=${this._descriptionExpanded}
@expanded-changed=${this._expandedChanged}
>
<div class="description">
<p>
@@ -164,92 +191,159 @@ class HaPanelDevTemplate extends LitElement {
</div>
</ha-expansion-panel>
</div>
<div
class="content ${classMap({
layout: !this.narrow,
horizontal: !this.narrow,
})}"
style="--description-expanded: ${this._descriptionExpanded ? 1 : 0}"
>
<ha-card
class="edit-pane"
header=${this.hass.localize(
"ui.panel.config.tools.tabs.templates.editor"
)}
>
<div class="card-content">
<ha-code-editor
mode="jinja2"
.value=${this._template}
.error=${this._error}
autofocus
autocomplete-entities
autocomplete-icons
@value-changed=${this._templateChanged}
dir="ltr"
></ha-code-editor>
</div>
<div class="card-actions">
<ha-button appearance="plain" @click=${this._restoreDemo}>
${this.hass.localize(
"ui.panel.config.tools.tabs.templates.reset"
)}
</ha-button>
<ha-button appearance="plain" @click=${this._clear}>
${this.hass.localize("ui.common.clear")}
</ha-button>
</div>
<ha-tip>
${this.hass.localize(
"ui.panel.config.tools.tabs.templates.keyboard_tip",
{
autocomplete: html`<kbd>Ctrl</kbd>+<kbd>Space</kbd>`,
}
)}
</ha-tip>
</ha-card>
<ha-card
class="render-pane"
header=${this.hass.localize(
"ui.panel.config.tools.tabs.templates.result"
)}
>
<div class="card-content ha-scrollbar">
${
this._rendering
? html`<ha-spinner
class="render-spinner"
size="small"
></ha-spinner>`
: ""
}
${
this._error
? html`<ha-alert
alert-type=${this._errorLevel?.toLowerCase() || "error"}
>${this._error}</ha-alert
>`
: nothing
}
${
this._templateResult
? html`<pre
class="rendered ${classMap({
[resultType]: resultType,
})}"
>
<ha-split-panel
class="panes ${orientation === "vertical" ? "vertical" : ""}"
.position=${this._splitPosition}
.orientation=${orientation}
snap="50%"
@wa-reposition=${this._splitRepositioned}
>
<div slot="start" class="pane">${editorCard}</div>
<div slot="end" class="pane">${resultCard}</div>
${this.narrow ? nothing : this._renderOrientationToggle()}
</ha-split-panel>
`;
}
private _renderOrientationToggle() {
const label = this.hass.localize(
this._splitOrientation === "vertical"
? "ui.panel.config.tools.tabs.templates.layout_side_by_side"
: "ui.panel.config.tools.tabs.templates.layout_stacked"
);
return html`
<button
type="button"
slot="divider"
class="divider-toggle"
.title=${label}
aria-label=${label}
@mousedown=${stopPropagation}
@touchstart=${stopPropagation}
@click=${this._toggleOrientation}
>
<ha-svg-icon
.path=${
this._splitOrientation === "vertical"
? mdiViewSplitVertical
: mdiViewSplitHorizontal
}
></ha-svg-icon>
</button>
`;
}
// Reset/clear live in the editor toolbar next to the built-in undo/redo,
// copy, search and fullscreen buttons; the trailing divider separates them.
private _editorToolbarItems = memoizeOne(
(
localize: HomeAssistant["localize"]
): (HaIconButtonToolbarItem | string)[] => [
{
id: "restore-demo",
label: localize("ui.panel.config.tools.tabs.templates.reset"),
path: mdiRestore,
action: () => this._restoreDemo(),
},
{
id: "clear",
label: localize("ui.common.clear"),
path: mdiTrashCanOutline,
action: () => this._clear(),
},
"divider",
]
);
private _renderEditorCard() {
return html`
<ha-card
class="edit-pane"
header=${this.hass.localize(
"ui.panel.config.tools.tabs.templates.editor"
)}
>
<div class="card-content">
<ha-code-editor
mode="jinja2"
.value=${this._template}
.error=${this._error}
.toolbarItems=${this._editorToolbarItems(this.hass.localize)}
autofocus
autocomplete-entities
autocomplete-icons
@value-changed=${this._templateChanged}
dir="ltr"
></ha-code-editor>
</div>
${
this.narrow
? nothing
: html`
<ha-tip>
${this.hass.localize(
"ui.panel.config.tools.tabs.templates.keyboard_tip",
{
autocomplete: html`<kbd>Ctrl</kbd>+<kbd>Space</kbd>`,
}
)}
</ha-tip>
`
}
</ha-card>
`;
}
private _renderResultCard(type: string, resultType: string) {
const showEmptyState =
!this._error && !this._rendering && !this._template?.trim();
return html`
<ha-card
class="render-pane"
header=${this.hass.localize(
"ui.panel.config.tools.tabs.templates.result"
)}
>
<div class="card-content ha-scrollbar">
${
this._rendering
? html`<ha-spinner
class="render-spinner"
size="small"
></ha-spinner>`
: ""
}
${
this._error
? html`<ha-alert
alert-type=${this._errorLevel?.toLowerCase() || "error"}
>${this._error}</ha-alert
>`
: nothing
}
${
showEmptyState
? html`<div class="empty">
${this.hass.localize(
"ui.panel.config.tools.tabs.templates.result_placeholder"
)}
</div>`
: this._templateResult
? html`
<ha-label dense>
${this.hass.localize(
"ui.panel.config.tools.tabs.templates.result_type"
)}:
${resultType}
</ha-label>
<pre class="rendered">
${
type === "object"
? JSON.stringify(this._templateResult.result, null, 2)
: this._templateResult.result
}</pre>
<p>
${this.hass.localize(
"ui.panel.config.tools.tabs.templates.result_type"
)}:
${resultType}
</p>
${
this._templateResult.listeners.time
? html`
@@ -316,109 +410,179 @@ ${
)}
</span>`
: nothing
}`
}
`
: nothing
}
</div>
</ha-card>
</div>
}
</div>
</ha-card>
`;
}
private _observeTipHeight() {
if (!this._editorTip || this._tipResizeObserver) {
return;
}
this._tipResizeObserver = new ResizeObserver((entries) => {
const height =
entries[0]?.borderBoxSize?.[0]?.blockSize ??
entries[0]?.contentRect.height;
if (height) {
this.style.setProperty("--tip-height", `${height}px`);
}
});
this._tipResizeObserver.observe(this._editorTip);
private _splitRepositioned(ev: Event) {
this._splitPosition = (ev.target as HaSplitPanel).position;
this._storeSplitPosition();
}
private _expandedChanged(
ev: HASSDomEvent<HASSDomEvents["expanded-changed"]>
) {
this._descriptionExpanded = ev.detail.expanded;
private _toggleOrientation() {
this._splitOrientation =
this._splitOrientation === "vertical" ? "horizontal" : "vertical";
if (this._inited) {
localStorage[STORAGE_KEY_SPLIT_ORIENTATION] = this._splitOrientation;
}
}
private _storeSplitPosition = debounce(
() => {
if (!this._inited) {
return;
}
localStorage[STORAGE_KEY_SPLIT_POSITION] = String(this._splitPosition);
},
500,
false
);
static get styles(): CSSResultGroup {
return [
haStyle,
haStyleScrollbar,
css`
:host {
user-select: none;
display: flex;
flex-direction: column;
height: 100%;
}
.content {
gap: var(--ha-space-4);
.about {
flex: none;
padding: var(--ha-space-4);
}
.content:has(ha-expansion-panel) {
padding-bottom: 0;
}
.content.horizontal {
--panel-header-height: calc(
var(--header-height) + 1em * 2 + var(--ha-line-height-normal) *
var(--ha-font-size-m) + 1px + 2px
);
--description-pane-height: calc(
var(--ha-space-4) + 48px +
(
var(--ha-line-height-normal) * var(--ha-font-size-m) * 3 +
var(--ha-space-1) * 2
) *
var(--description-expanded) + var(--ha-card-border-width, 1px) * 2
);
--card-header-height: calc(
var(--ha-space-3) + var(--ha-space-4) +
var(--ha-line-height-expanded) *
var(--ha-card-header-font-size, var(--ha-font-size-2xl))
);
--card-actions-height: calc(1px + var(--ha-space-2) * 2 + 40px);
--tip-height-minimal: calc(
var(--mdc-icon-size, 24px) + var(--ha-space-4)
);
--edit-pane-height: calc(
100vh - var(--panel-header-height) - var(
--description-pane-height
) - var(--ha-space-4) *
2
);
--code-mirror-max-height: calc(
var(--edit-pane-height) - var(--card-header-height) +
var(--ha-space-2) - var(--card-actions-height) - var(
--tip-height,
var(--tip-height-minimal)
) - var(--ha-space-4) - var(--ha-card-border-width, 1px) *
2
);
.about a {
color: var(--primary-color);
}
.panes {
flex: 1;
min-height: 0;
box-sizing: border-box;
padding: var(--ha-space-4);
--ha-split-panel-min: 20%;
--ha-split-panel-max: 80%;
--ha-split-panel-divider-hit-area: var(--ha-space-4);
}
/* On wide viewports we slot our own handle (the orientation toggle)
into the divider, so hide the default grip. On narrow there is no
toggle, so keep the default grip as the resize affordance. */
:host(:not([narrow])) .panes {
--ha-split-panel-grip-display: none;
}
/* Orientation toggle that lives on the divider. Clicking it toggles
orientation; resizing is done by dragging the divider elsewhere. */
.divider-toggle {
position: relative;
z-index: 1;
flex: none;
display: inline-flex;
align-items: center;
justify-content: center;
box-sizing: border-box;
width: 24px;
height: 24px;
margin: 0;
padding: 0;
border: 1px solid var(--divider-color);
border-radius: 50%;
background-color: var(--card-background-color);
color: var(--secondary-text-color);
cursor: pointer;
--mdc-icon-size: 16px;
transition:
color var(--ha-animation-duration-fast) ease-out,
border-color var(--ha-animation-duration-fast) ease-out;
}
@media (hover: hover) {
.divider-toggle:hover {
color: var(--primary-color);
border-color: var(--primary-color);
}
}
.divider-toggle:focus-visible {
outline: none;
color: var(--primary-color);
border-color: var(--primary-color);
}
.pane {
display: flex;
min-width: 0;
height: 100%;
box-sizing: border-box;
contain: size;
}
.pane[slot="start"] {
padding-inline-end: var(--ha-space-4);
}
.pane[slot="end"] {
padding-inline-start: var(--ha-space-4);
}
.panes.vertical .pane[slot="start"] {
padding-inline-end: 0;
padding-block-end: var(--ha-space-4);
}
.panes.vertical .pane[slot="end"] {
padding-inline-start: 0;
padding-block-start: var(--ha-space-4);
}
.pane ha-card {
flex: 1;
min-width: 0;
}
ha-card {
margin-bottom: var(--ha-space-4);
display: flex;
flex-direction: column;
height: 100%;
margin: 0;
}
.edit-pane .card-content {
flex: 1;
min-height: 0;
display: flex;
}
.edit-pane ha-code-editor {
flex: 1;
min-height: 0;
width: 100%;
--code-mirror-height: 100%;
}
.render-pane .card-content {
flex: 1;
min-height: 0;
overflow: auto;
display: flex;
flex-direction: column;
gap: var(--ha-space-2);
}
.edit-pane {
direction: var(--direction);
}
.edit-pane a {
color: var(--primary-color);
}
.content.horizontal > * {
width: 50%;
margin-bottom: 0px;
}
.render-spinner {
position: absolute;
top: var(--ha-space-2);
@@ -428,10 +592,24 @@ ${
}
ha-alert {
margin-bottom: var(--ha-space-2);
display: block;
}
.render-pane ha-label {
align-self: flex-start;
}
.empty {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
min-height: 120px;
padding: var(--ha-space-4);
text-align: center;
color: var(--secondary-text-color);
}
.rendered {
font-family: var(--ha-font-family-code);
-webkit-font-smoothing: var(--ha-font-smoothing);
@@ -439,6 +617,7 @@ ${
clear: both;
white-space: pre-wrap;
background-color: var(--secondary-background-color);
border-radius: var(--ha-border-radius-md);
padding: var(--ha-space-2);
margin-top: 0;
margin-bottom: 0;
@@ -447,7 +626,7 @@ ${
p,
ul {
margin-block-end: 0;
margin-block: 0;
}
.description > p {
margin-block-start: 0;
@@ -468,26 +647,6 @@ ${
color: var(--secondary-text-color);
}
.render-pane .card-content {
user-select: text;
}
.content.horizontal .render-pane .card-content {
overflow: auto;
max-height: calc(
var(--code-mirror-max-height) +
47px - var(--ha-card-border-radius, var(--ha-border-radius-lg))
);
}
.content.horizontal .render-pane {
overflow: hidden;
padding-bottom: var(
--ha-card-border-radius,
var(--ha-border-radius-lg)
);
}
.all_listeners {
color: var(--warning-color);
}
@@ -507,19 +666,6 @@ ${
background-color: var(--secondary-background-color);
white-space: nowrap;
}
@media all and (max-width: 870px) {
.content ha-card {
max-width: 100%;
}
}
.card-actions {
display: flex;
}
.card-actions > ha-button:last-child {
margin-inline-start: auto;
}
`,
];
}
@@ -615,7 +761,7 @@ ${
if (!this._inited) {
return;
}
localStorage["panel-dev-template-template"] = this._template;
localStorage[STORAGE_KEY_TEMPLATE] = this._template;
}
private async _restoreDemo() {
@@ -631,7 +777,7 @@ ${
}
this._template = DEMO_TEMPLATE;
this._subscribeTemplate();
delete localStorage["panel-dev-template-template"];
delete localStorage[STORAGE_KEY_TEMPLATE];
}
private async _clear() {
@@ -647,12 +793,8 @@ ${
}
this._unsubscribeTemplate();
this._template = "";
// Reset to empty result. Setting to 'undefined' results in a different visual
// behaviour compared to manually emptying the template input box.
this._templateResult = {
result: "",
listeners: { all: false, entities: [], domains: [], time: false },
};
// An empty template shows the placeholder empty state.
this._templateResult = undefined;
}
}
+4 -1
View File
@@ -2,6 +2,7 @@ import type { CSSResultGroup, PropertyValues } from "lit";
import { LitElement, css, html } from "lit";
import { customElement, property, state } from "lit/decorators";
import { navigate } from "../../common/navigate";
import { sanitizeNavigationPath } from "../../common/url/sanitize-navigation-path";
import "../../components/ha-alert";
import "../../components/ha-icon-button-arrow-prev";
import "../../components/ha-menu-button";
@@ -161,7 +162,9 @@ class PanelEnergy extends LitElement {
.route=${this.route}
.panel=${this.panel}
.backButton=${this._searchParms.has("historyBack")}
.backPath=${this._searchParms.get("backPath") || "/"}
.backPath=${
sanitizeNavigationPath(this._searchParms.get("backPath")) || "/"
}
@reload-energy-panel=${this._reloadConfig}
>
</hui-root>
+13
View File
@@ -130,6 +130,19 @@ class PanelHome extends LitElement {
}
private async _setup() {
void import("../lovelace/strategies/home/home-dashboard-strategy").catch(
() => undefined
);
void import("../lovelace/strategies/home/home-overview-view-strategy")
.then(({ preloadHomeEnergyPreferences }) => {
const path = this.route?.path?.split("/")[1];
return !path || path === "overview"
? preloadHomeEnergyPreferences(this.hass)
: undefined;
})
.catch(() => undefined);
void import("../lovelace/views/hui-sections-view").catch(() => undefined);
this._updateExtraActionItems();
this._loadConfigPromise = this._loadConfig();
await this._loadConfigPromise;
@@ -11,6 +11,7 @@ import type { LocalizeFunc } from "../../../common/translations/localize";
import "../../../components/ha-control-button";
import "../../../components/ha-control-button-group";
import { apiContext, servicesContext } from "../../../data/context";
import { forwardHaptic } from "../../../data/haptics";
import {
hasRequiredScriptFieldsForServices,
requiredScriptFieldsFilledForServices,
@@ -96,6 +97,8 @@ class HuiButtonCardFeature extends LitElement implements LovelaceCardFeature {
: {}),
};
forwardHaptic(this, "light");
this._api.callService(domain, service, serviceData);
}
@@ -620,6 +620,9 @@ export class HuiAreaCard extends LitElement implements LovelaceCard {
"--tile-color": color,
};
const fixedInfoHeight =
this.layout === "grid" && this._config.grid_options?.rows !== "auto";
return html`
<ha-card style=${styleMap(style)}>
${
@@ -683,6 +686,7 @@ export class HuiAreaCard extends LitElement implements LovelaceCard {
<ha-tile-container
.featurePosition=${featurePosition}
.vertical=${Boolean(this._config.vertical)}
.fixedInfoHeight=${fixedInfoHeight}
.interactive=${Boolean(this._hasCardAction)}
@action=${this._handleAction}
>
@@ -699,6 +703,9 @@ export class HuiAreaCard extends LitElement implements LovelaceCard {
</ha-tile-icon>
<ha-tile-info
slot="info"
class=${ifDefined(
this._config.vertical && fixedInfoHeight ? "twoline" : undefined
)}
.primary=${primary}
.secondary=${secondary}
></ha-tile-info>
@@ -818,6 +825,10 @@ export class HuiAreaCard extends LitElement implements LovelaceCard {
justify-content: center;
color: white;
}
ha-tile-info.twoline {
--ha-tile-info-primary-line-clamp: 2;
--ha-tile-info-primary-line-height: var(--ha-space-4);
}
`,
];
}
@@ -40,6 +40,8 @@ export class HuiShortcutCard extends LitElement implements LovelaceCard {
@property({ attribute: false }) public hass?: HomeAssistant;
@property({ attribute: false }) public layout?: string;
@state() private _config?: ShortcutCardConfig;
private _navInfo = new NavigationPathInfoController(this);
@@ -128,10 +130,14 @@ export class HuiShortcutCard extends LitElement implements LovelaceCard {
const style = color ? { "--tile-color": color } : {};
const fixedInfoHeight =
this.layout === "grid" && this._config.grid_options?.rows !== "auto";
return html`
<ha-card style=${styleMap(style)}>
<ha-tile-container
.vertical=${Boolean(this._config.vertical)}
.fixedInfoHeight=${fixedInfoHeight}
.interactive=${this._hasCardAction}
.actionHandlerOptions=${{
hasHold: hasAction(this._config.hold_action),

Some files were not shown because too many files have changed in this diff Show More