Compare commits

..

164 Commits

Author SHA1 Message Date
Aidan Timson 17316f01cd Format 2026-07-31 11:50:04 +01:00
Aidan Timson a06ae4f984 Potential fix for pull request finding
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-07-31 11:43:39 +01:00
copilot-swe-agent[bot] 1b3bf954aa Fix syntax error: misplaced brace before finally in confirmStopConflict
Co-authored-by: timmo001 <28114703+timmo001@users.noreply.github.com>
2026-07-31 11:23:35 +01:00
Aidan Timson bfaf7b3daa Apply suggestions from code review
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-07-31 11:23:35 +01:00
Aidan Timson d015b72685 Offer to stop conflicting managed workflows 2026-07-31 11:23:35 +01:00
Aidan Timson b1ccb6355d Wait for settings overview readiness (#53352)
* Wait for settings overview readiness

* Move panel readiness setup to constructor

* Apply suggestions from code review

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>

* Cache bluetooth config entries fetch in settings navigation

* Fix child panel readiness registration race

* Avoid redundant settings navigation renders

---------

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-07-31 13:22:00 +03:00
Aidan Timson 5f2086765a Add managed translation fetching to dev servers (#53417) 2026-07-31 13:20:50 +03:00
renovate[bot] 02c51a2789 Update dependency @rspack/core to v2.1.6 (#53420)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-07-31 11:39:06 +02:00
Paul Bottein 706421f3c5 Fix tile name truncated too early (#53416) 2026-07-31 11:52:23 +03:00
Aidan Timson cbc2e34638 Fix profile theme link colour (#53415) 2026-07-31 10:47:45 +02:00
Yosi Levy 0bc0b08978 RTL fixes (#53414) 2026-07-31 08:41:52 +01:00
karwosts 48394db843 Validate dialog-form on submit (#53381)
* Validate dialog-form on submit

* remove whitespace

* put errors on the stack

* Apply suggestion from @MindFreeze

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

* Apply suggestion from @MindFreeze

* Fix botched suggestion apply in nested submit

---------

Co-authored-by: Petar Petrov <MindFreeze@users.noreply.github.com>
2026-07-31 07:14:40 +00:00
Paul Bottein 83dac08059 Use backend slugify for the entity ID format preview (#53394)
* Use backend slugify for the entity ID format preview

* Update src/panels/config/core/ha-config-entity-id-format.ts

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

* Use top-level slugify websocket command

* Fix formatting

---------

Co-authored-by: Petar Petrov <MindFreeze@users.noreply.github.com>
2026-07-31 09:39:47 +03:00
Aidan Timson 3c6b70ded8 Prevent concurrent managed frontend workflows (#53396)
* Shared build and runner for all build,dev,test flows

* Harden managed process lifecycle

* Make build workflows deterministic

* Test build management contracts

* Queue shared generated output work

* Isolate generated inputs for dev servers

* Preserve dev server child failures

* Make generated lock test deterministic

* Keep test navigator configurable

* Block concurrent frontend workflows

* Simplify workflow lock ownership

* Focus workflow locking on managed commands

* Trim workflow lock unit tests

* Consolidate dev server lifecycle handlers
2026-07-31 09:30:49 +03:00
John G. 1e414f31dc Fix typo in Google Home Matter pairing instructions (#53413)
Corrected a typo in the instructions for pairing a Matter device from Google Home with a pairing code.
2026-07-31 04:27:25 +00:00
karwosts d013051f81 Add a few missing logbook csv fields (#53412) 2026-07-31 06:19:05 +02:00
Bram Kragten eb2efbb669 use defaults provided by core for ports in strings, add url to new lo… (#53407)
* use defaults provided by core for ports in strings, add url to new location after restart

* Apply suggestions from code review

Co-authored-by: Simon Lamon <32477463+silamon@users.noreply.github.com>

---------

Co-authored-by: Simon Lamon <32477463+silamon@users.noreply.github.com>
2026-07-31 06:08:38 +02:00
Bram Kragten 7e4d745b21 Prevent http confirm dialog from getting closed (#53406) 2026-07-31 06:08:18 +02:00
Petar Petrov d01f7d98a5 Reject non-http URLs from integrations before using them as links (#53379) 2026-07-30 23:26:29 +02:00
renovate[bot] 6df6b556bd Update dependency @codemirror/view to v6.43.7 (#53409) 2026-07-30 22:34:36 +02:00
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
Paul Bottein 972ade22bf Add favorite cards to the card picker (#53338)
* Add favorite cards to the card picker

* Rename stored key
2026-07-28 08:43:00 +03:00
Aidan Timson 0e2a5e414b Implement new loading animation (#53328)
* Implement new svg animation

* Fix and speed up fade out transition

* Cleanup

* Remove unintentional scale in

* Match 250ms fade in and out

* Revert

* Fix small layout jump

* Pass localise as context isnt avaliable to a sibling (not a child of ha)

* Update ohf loading logomark

* Fix alt

* Update logomark again

* Reduce gap

* Font changes

* Load font for launch screen

* Smooth launch screen text loading

* Restore launch screen migration line breaks
2026-07-28 08:38:04 +03:00
karwosts 428a21175e Remove special case of grid[0] in energy (#53345) 2026-07-28 08:03:20 +03:00
Stefan Agner 1bcf6aa882 Fix network error briefly shown on landing page when Core comes up (#53339)
When Home Assistant Core comes up and the landing page backend is torn
down, the landing page sometimes briefly showed "Cannot get network
information" before reloading into onboarding. Two issues caused this:

- location.reload() does not stop script execution, so the error state
  was still set after the reload was triggered. Return early from both
  pollers when a reload is in progress.
- During the teardown gap where neither the Supervisor API nor Core
  responds yet, the jobs poller set _coreStatusChecked without arming
  the 60 second grace period, so the network info poller showed the
  error immediately instead of waiting. Arm the grace period in
  _checkCoreAvailability so both pollers share it consistently.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 06:48:15 +02:00
renovate[bot] b7a1f1d0db Update dependency tar to v7.5.22 (#53340)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-07-28 06:45:32 +02:00
renovate[bot] 2bbd9d37e4 Update Playwright (#53344)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-07-28 06:45:15 +02:00
renovate[bot] f9bddc1228 Update dependency eslint to v10.8.0 (#53343)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-07-28 06:44:58 +02:00
Aidan Timson 669eab4d31 Hide superseded E2E failure comments (#53331) 2026-07-27 17:21:14 +03:00
Timothy ea179bd7fe Limit and avoid duplication when loaded is sent to mobile (#53335) 2026-07-27 17:08:12 +03:00
karwosts ef50cc0332 Abbreviate sections in automation add search dialog (#53286)
* Abbreviate sections in automation add search dialog

* fixes

* Apply suggestion from @MindFreeze

---------

Co-authored-by: Petar Petrov <MindFreeze@users.noreply.github.com>
2026-07-27 13:59:43 +00:00
Aidan Timson ab0ce2a3b7 Keep Playwright Renovate updates together (#53336) 2026-07-27 13:54:53 +00:00
Petar Petrov 23bc2cf1c1 Share the sankey effective-parent walk across all four cards (#53329)
* Share the sankey effective-parent walk across all four cards

Each of the four sankey cards built its own `findEffectiveParent` to walk
`included_in_stat` up to the nearest rendered ancestor. The two cumulative
cards were byte-identical; the two instantaneous ones differed only in
comments and brace style. The pairs differed from each other only in which
id space they resolve to, which is exactly what `getId` already expresses.

Move the walk into `buildSankeyDeviceNodes`, which already receives
`devices`, `getId`, `getValue` and `minThreshold`, and drop the
`findEffectiveParent` option. A `renderedId` helper maps a stat_consumption
through `getId` to a node id, so one implementation covers both spaces.

Also folds in three incidental improvements:

- `deviceByStat` replaces each card's `deviceMap` plus the `deviceParents`
  map added in #53324.
- The walk is a bounded `for` instead of an unbounded `while`, which would
  hang on a cyclic `included_in_stat`. Cycles are prevented in the config UI
  but not validated in core.
- `getValue` is now called once per device instead of twice. For the
  cumulative cards it is `calculateStatisticSumGrowth`, which is O(points).

No behaviour change. Adds coverage for the instantaneous id space, which
had no hierarchy tests, and for a nested small consumer inside a rendered
parent's cluster.

* Keep small sankey devices attributed under a rendered ancestor

The nested-small-consumer filter from #53324 walks `included_in_stat` and
drops any small consumer whose chain reaches another small consumer. The
walk did not stop at ancestors that are rendered as their own node, so a
small device sitting under a rendered parent was dropped whenever anything
further up the chain happened to be small.

Such a consumer links to its rendered parent and never touches untracked,
so it cannot be double-counted through a small ancestor beyond it - and
dropping it does not fix the separate overlap between the rendered node and
that ancestor. It just loses a real device from the graph.

Stop the walk at the first rendered ancestor. Needs `included_in_stat` data
where a parent statistic reads smaller than its child, so it takes a gap or
a late start on the parent to trigger.
2026-07-27 14:59:02 +02:00
Marcin Bauer 85e94841a9 Improve target picker group header (#53250)
* Improve target picker group header

- Show group item count only when the group is collapsed, styled as a
  secondary-color "(N)" next to the type label
- Thin the item-groups border to 1px via --ha-border-width-sm
- Remove the "Targets" pre-label before the target selector in the
  action, trigger and condition editors, moving its spacing/divider onto
  the selector itself

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

* Update src/components/target-picker/ha-target-picker-item-group.ts

Co-authored-by: Simon Lamon <32477463+silamon@users.noreply.github.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: Simon Lamon <32477463+silamon@users.noreply.github.com>
2026-07-27 14:37:34 +02:00
Paulus Schoutsen 1de7230d9e Fall back to English when nightly translations cannot be fetched (#53272)
* Fall back to English when nightly translations cannot be fetched

Local builds no longer fail when the nightly translations fetch errors
(offline or invalid GitHub credentials); they warn and continue with
English only, like the existing no-token path. CI still fails so
releases cannot silently ship without translations.

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

* Record fetched artifact only after successful extraction

A failure mid-fetch previously left a fresh artifact.json next to wiped
translations, so builds within the next 24 hours skipped the fetch and
silently built English only. Writing the artifact file last means a
failed fetch is retried by the next build.

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

* Update build-scripts/gulp/fetch-nightly-translations.js

Co-authored-by: Simon Lamon <32477463+silamon@users.noreply.github.com>

---------

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Simon Lamon <32477463+silamon@users.noreply.github.com>
2026-07-27 11:32:26 +00:00
Josef Zweck 7620f21033 Add search functionality to media browser (#53258)
* Add search functionality to media browser

* prettier

* add debounced search, clear properly

* abort in flight search, remove dead code

* prettier

* filter

* prettier

* Hide media type filter on root media sources page

* Only show media type filter for searchable sources

* Apply media type filter only to backend search, not local browse

* Move media type filter inside the search bar

* Add client-side filters for media browser results

* Center search spinner in media browser

* Revert "Add client-side filters for media browser results"

This reverts commit 3e1fc71fbd.

* Revert "Move media type filter inside the search bar"

This reverts commit 19dcb92ad1.

* Revert "Apply media type filter only to backend search, not local browse"

This reverts commit 9b4f86878d.

* Revert "Only show media type filter for searchable sources"

This reverts commit 6dca0ce24e.

* Revert "Hide media type filter on root media sources page"

This reverts commit 62bbe34b71.

* Revert "prettier"

This reverts commit d64f90865c.

* Revert "filter"

This reverts commit 56dc5450b2.

* Reapply "filter"

This reverts commit a18edb79e4.

* Reapply "prettier"

This reverts commit 224b8823ea.

* Reapply "Hide media type filter on root media sources page"

This reverts commit 9e447e2d68.

* Reapply "Only show media type filter for searchable sources"

This reverts commit f29bd8e434.

* Reapply "Apply media type filter only to backend search, not local browse"

This reverts commit e5c021a814.

* Reapply "Move media type filter inside the search bar"

This reverts commit c83d7b77a6.

* Reapply "Add client-side filters for media browser results"

This reverts commit 436e32eeea.

* Remove local filters panel from media browser

Removes the 'Filters' collapsible panel that included:
- Local text search filter
- Local media class filter chips
- Keeps media type dropdown filter in search bar

* Fix eslint: use const instead of let for notShown variable

* Fix prettier formatting

* remove debounced search

* remove char limit

* Search button, localize

---------

Co-authored-by: Petar Petrov <MindFreeze@users.noreply.github.com>
2026-07-27 09:42:46 +00:00
Maarten Lakerveld 425a914d34 Sankey Energy: dont double count smallconsumer ancestor/children in other (#53324)
dont double count smallconsumer ancestor/children in other

Co-authored-by: Petar Petrov <MindFreeze@users.noreply.github.com>
2026-07-27 09:42:32 +00:00
Copilot f413e71d54 Fix Prettier formatting in dialog-energy-battery-settings.ts (#53325)
* Initial plan

* Fix Prettier formatting in dialog-energy-battery-settings.ts

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

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: timmo001 <28114703+timmo001@users.noreply.github.com>
2026-07-27 12:34:03 +03:00
Paulus Schoutsen 28045f6cd6 Hide the devices tile on home overview when the other devices view is empty (#53275)
* Show entities without a device in the home other devices view

The home overview counts area-less entities to decide whether to show
the devices tile, but the other devices view dropped any entity without
a device registry entry. Area-less entities that also have no device
(e.g. template or helper entities) thus produced a devices tile leading
to an empty view.

Group them under an "Others" section instead, matching how the home
area view handles entities without a device.

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

* Hide the devices tile instead of showing entities without a device

Reverse the fix direction: entities without a device registry entry
should not be rendered by the other devices view. Instead, the home
overview should not show the devices tile when that view has nothing to
render.

Extract the filtering and device grouping into a shared
getOtherDevicesEntities helper used by both the other devices view and
the overview, so the devices tile is only shown when the view actually
has content.

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

* Stop at the first matching entity when checking for other devices

The overview only needs to know whether the other devices view has any
content, so short-circuit on the first matching entity instead of
computing the full device grouping.

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

* Inline the devices check in the overview strategy

Drop the extracted helper and keep the other devices view strategy
untouched. The overview inlines the check for area-less primary
entities that belong to a device, keeping the diff to the tile
condition only.

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-07-27 11:11:46 +03:00
Simon Lamon 34f3283850 Handle 0 not as undefined in choose selector (#53264)
* Handle 0 not as undefined in new choose selector

* Also handle null
2026-07-27 08:15:04 +03:00
Petar Petrov d02c4924c4 Fix history graph rendering as a spike when shown by a visibility condition (#53314)
Fix chart down sampling collapsing the series when the chart is hidden
2026-07-27 06:54:44 +02:00
Paulus Schoutsen e71bb867be Show Assist pipelines in the demo voice assistants panel (#53318)
* Show Assist pipelines in the demo voice assistants panel

The demo already mocked assist_pipeline/pipeline/list, but assist_pipeline
was not in the demo's loaded components, so assist-pref never rendered and
the panel only showed the cloud Alexa/Google cards.

Add assist_pipeline to the demo component list and rename the second mocked
pipeline from Local to ChatGPT, keeping Home Assistant Cloud preferred.

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

* Keep the Local pipeline in the demo assist mocks

Restore the Local pipeline alongside Home Assistant Cloud and ChatGPT, listed
above ChatGPT. Home Assistant Cloud stays preferred.

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

* Vary pipeline languages in the demo assist mocks

Set the Local pipeline to Dutch and the ChatGPT pipeline to Spanish, so the
demo shows assistants in different languages. Home Assistant Cloud stays
English and preferred.

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

* Drop the added comment in the demo component override

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-07-27 06:53:52 +02:00
renovate[bot] 60163efd56 Update dependency fs-extra to v11.4.0 (#53317)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-07-27 06:52:57 +02:00
Adrian Campos 351859c3d0 Fix typo in no_triggers message (#53319) 2026-07-27 06:52:41 +02:00
Petar Petrov 5f0f1f4867 Add battery capacity option for weighted state of charge (#52355)
* Add battery capacity option for weighted state of charge

* Add spacing between state of charge and capacity inputs
2026-07-27 06:02:28 +02:00
Paulus Schoutsen 18de8b13d3 Add a home page demo to the demo (#53311)
* Fix mocked entity states and demo config switching

Three small demo/mock fixes:

- Mocked entities dropped unit_of_measurement and state_class when
  writing to the state machine, so demo sensors rendered without a unit
  and the mocked history returned a single state for them
- The energy consumption sensor entity id had a stray tab, which fails
  entity id validation in cards that render it
- Switching demos saved the dashboard config before the new registries
  and entities reached the dashboard, so dashboard strategies generated
  against the previous data

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

* Add a home page demo to the demo

Adds the home page as the second demo, generated by the real home
dashboard strategies instead of a copy, so the demo shows what Home
Assistant builds from a home: floors with their areas, area cards with
temperature, and the summary views for lights, climate, security, media
players, maintenance, weather and energy.

The demo config provides the floors, areas and entities the strategies
need, including the temperature and humidity sensors of each area, and a
lock and a sprinkler as favorites.

Switching demos is a tile in the favorites section, so the demo looks
like a dashboard instead of carrying a card that no real home page would
have. Its feature row has the button to move to the next demo.

Demo navigation is fixed alongside it: navigate resolves paths like the
browser does for pushState and keeps query parameters in the URL query
instead of inside the hash, so panels can read them, and the route now
follows popstate, which is the only event a traversal fires when the
query changes too.

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-07-26 17:41:30 +02:00
karwosts 39e70ff315 Show empty form errors (#53313) 2026-07-26 15:19:22 +00:00
Paulus Schoutsen 14fd9114d6 Allow opening a specific demo via URL and document demo navigation for agents (#53281)
* Allow opening a specific demo via URL and document demo navigation for agents

Add a demo query parameter so a specific demo configuration can be
opened directly, e.g. /?demo=arsaboo. Add demo/AGENTS.md documenting the
demo URL scheme (demo selection and hash-based page routing), linked
from the root agent guide.

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

* Merge demo slugs into the demoConfigs registry

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

* Key demoConfigs by slug

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

* Add demos index of slugs and drop confProm temporary

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

* Track selected demo by slug instead of index

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

* Restore original setDemoConfig flow and drop slug lowercasing

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-07-26 17:15:10 +02:00
renovate[bot] 406a0b2bad Update dependency lint-staged to v17.2.0 (#53312)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-07-26 16:26:33 +02:00
Paulus Schoutsen 4c86785d21 Fix mocked entity states and demo config switching (#53300)
Three small demo/mock fixes:

- Mocked entities dropped unit_of_measurement and state_class when
  writing to the state machine, so demo sensors rendered without a unit
  and the mocked history returned a single state for them
- The energy consumption sensor entity id had a stray tab, which fails
  entity id validation in cards that render it
- Switching demos saved the dashboard config before the new registries
  and entities reached the dashboard, so dashboard strategies generated
  against the previous data


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

Co-authored-by: Claude <noreply@anthropic.com>
2026-07-26 16:25:37 +02:00
renovate[bot] 2aeb522eed Update dependency color-name to v2.1.1 (#53297)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-07-26 09:18:43 +00:00
Paulus Schoutsen bef1c0def6 Recompute sections view column count when the config changes (#53301)
* Recompute sections view column count when the config changes

The sections view element is reused when navigating between two sections
views, so a view with a different max_columns kept the column count of the
previous view until a resize made the ResizeObserver fire again.

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

* Measure the host in the resize callback instead of the observer entries

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

* Clamp the column count to the config instead of caching it in the resize callback

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-07-26 09:34:56 +03:00
dependabot[bot] f7e5bdb2a0 Bump the codeql-action group across 1 directory with 2 updates (#53303)
Bumps the codeql-action group with 2 updates in the / directory: [github/codeql-action/init](https://github.com/github/codeql-action) and [github/codeql-action/analyze](https://github.com/github/codeql-action).


Updates `github/codeql-action/init` from 4.37.0 to 4.37.1
- [Release notes](https://github.com/github/codeql-action/releases)
- [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md)
- [Commits](https://github.com/github/codeql-action/compare/99df26d4f13ea111d4ec1a7dddef6063f76b97e9...7188fc363630916deb702c7fdcf4e481b751f97a)

Updates `github/codeql-action/analyze` from 4.37.0 to 4.37.1
- [Release notes](https://github.com/github/codeql-action/releases)
- [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md)
- [Commits](https://github.com/github/codeql-action/compare/99df26d4f13ea111d4ec1a7dddef6063f76b97e9...7188fc363630916deb702c7fdcf4e481b751f97a)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-26 09:29:35 +03:00
dependabot[bot] de59237cfb Bump home-assistant/actions/helpers/verify-version from f4ca6f671bd429efb108c0f2fa0ae8af0215986c to e3fb68ebda13d88a0d695082f471ba2c83d025fb (#53304)
Bump home-assistant/actions/helpers/verify-version

Bumps [home-assistant/actions/helpers/verify-version](https://github.com/home-assistant/actions) from f4ca6f671bd429efb108c0f2fa0ae8af0215986c to e3fb68ebda13d88a0d695082f471ba2c83d025fb.
- [Release notes](https://github.com/home-assistant/actions/releases)
- [Commits](https://github.com/home-assistant/actions/compare/f4ca6f671bd429efb108c0f2fa0ae8af0215986c...e3fb68ebda13d88a0d695082f471ba2c83d025fb)

---
updated-dependencies:
- dependency-name: home-assistant/actions/helpers/verify-version
  dependency-version: e3fb68ebda13d88a0d695082f471ba2c83d025fb
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-26 09:29:13 +03:00
dependabot[bot] f778572b89 Bump release-drafter/release-drafter from 7.5.1 to 7.6.0 (#53305)
Bumps [release-drafter/release-drafter](https://github.com/release-drafter/release-drafter) from 7.5.1 to 7.6.0.
- [Release notes](https://github.com/release-drafter/release-drafter/releases)
- [Commits](https://github.com/release-drafter/release-drafter/compare/4d75298e00d9e34c483e5ff8c68d0ea1c1940c1e...eada3c96a64734dd381cfbda23511034e328ddb0)

---
updated-dependencies:
- dependency-name: release-drafter/release-drafter
  dependency-version: 7.6.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-26 09:28:55 +03:00
dependabot[bot] d2c753b589 Bump actions/setup-python from 6.3.0 to 7.0.0 (#53306)
Bumps [actions/setup-python](https://github.com/actions/setup-python) from 6.3.0 to 7.0.0.
- [Release notes](https://github.com/actions/setup-python/releases)
- [Commits](https://github.com/actions/setup-python/compare/ece7cb06caefa5fff74198d8649806c4678c61a1...5fda3b95a4ea91299a34e894583c3862153e4b97)

---
updated-dependencies:
- dependency-name: actions/setup-python
  dependency-version: 7.0.0
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-26 09:28:35 +03:00
dependabot[bot] 8fbb38a25d Bump pypa/gh-action-pypi-publish from 1.14.0 to 1.14.1 (#53307)
Bumps [pypa/gh-action-pypi-publish](https://github.com/pypa/gh-action-pypi-publish) from 1.14.0 to 1.14.1.
- [Release notes](https://github.com/pypa/gh-action-pypi-publish/releases)
- [Commits](https://github.com/pypa/gh-action-pypi-publish/compare/cef221092ed1bacb1cc03d23a2d87d1d172e277b...ba38be9e461d3875417946c167d0b5f3d385a247)

---
updated-dependencies:
- dependency-name: pypa/gh-action-pypi-publish
  dependency-version: 1.14.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-26 09:27:57 +03:00
dependabot[bot] 2f6c1388e8 Bump actions/checkout from 7.0.0 to 7.0.1 (#53308)
Bumps [actions/checkout](https://github.com/actions/checkout) from 7.0.0 to 7.0.1.
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/checkout/compare/9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0...3d3c42e5aac5ba805825da76410c181273ba90b1)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-version: 7.0.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-26 09:27:39 +03:00
dependabot[bot] ced5ffdd18 Bump actions/labeler from 6.2.0 to 7.0.0 (#53309)
Bumps [actions/labeler](https://github.com/actions/labeler) from 6.2.0 to 7.0.0.
- [Release notes](https://github.com/actions/labeler/releases)
- [Commits](https://github.com/actions/labeler/compare/b8dd2d9be0f68b860e7dae5dae7d772984eacd6d...bf12e9b00b37c5c0ca2b87b79b2daf7891dbda13)

---
updated-dependencies:
- dependency-name: actions/labeler
  dependency-version: 7.0.0
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-26 09:27:15 +03:00
Paulus Schoutsen 5a6a16ed27 Fix gallery type errors by letting registry mocks take the demo area and floor shapes (#53302)
Let gallery mocks use the demo area and floor shapes

The gallery passed full AreaRegistryEntry and FloorRegistryEntry objects to
mockAreaRegistry and mockFloorRegistry. Those data parameters were dropped
when the registries became demo-config-owned, which broke the gallery build.

Instead of restoring the verbose registry-entry parameters, have the mocks
accept the same DemoArea and DemoFloor shapes the demo configs use and seed
the registry through setDemoAreas/setDemoFloors. The gallery fixtures now
only spell out the fields they care about, matching how the other registry
mocks take their initial data.


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

Co-authored-by: Claude <noreply@anthropic.com>
2026-07-25 18:15:30 -04:00
Aidan Timson 3d1ed43edc Automatically activate frontend updates, support accessible toast actions, publish global dirty state (#53124)
* Support accessible toast actions

* Publish global dirty state changes

* Automatically activate frontend updates

* Add baseline tests for update dependencies

* Cover update dependency behaviour

* Render primary toast actions as filled

* Test notification toast lifecycle

* Document dirty state provider ownership

* Publish dirty state from editor updates

* Aggregate connected dirty state providers

---------

Co-authored-by: Simon Lamon <32477463+silamon@users.noreply.github.com>
2026-07-25 11:18:34 +02:00
Paulus Schoutsen d682830a3e Register the hidden default dashboard panels in the demo (#53278)
Register the light, climate, security and maintenance summary panels in
the demo, hidden from the sidebar like the home panel. This makes them
routable so strategy-generated dashboards can navigate to them.


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

Co-authored-by: Claude <noreply@anthropic.com>
2026-07-25 11:15:05 +02:00
dependabot[bot] 863aad24fc Bump js-yaml from 5.2.1 to 5.2.2 (#53292)
Bumps [js-yaml](https://github.com/nodeca/js-yaml) from 5.2.1 to 5.2.2.
- [Changelog](https://github.com/nodeca/js-yaml/blob/master/CHANGELOG.md)
- [Commits](https://github.com/nodeca/js-yaml/compare/5.2.1...5.2.2)

---
updated-dependencies:
- dependency-name: js-yaml
  dependency-version: 5.2.2
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-25 11:13:12 +02:00
Paulus Schoutsen 3284633ac9 Let demo configs provide areas and floors (#53280)
Make areas and floors part of the individual demo config data instead
of a single global registry:

- DemoConfig gains optional floors and areas, applied on initial load
  and when switching demos, so each demo brings (and clears) its own
  registries
- Areas can set a temperature and humidity entity
- Mock entities can be assigned to an area via EntityInput.area_id,
  which flows into the mocked entity registry used by area filtering


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

Co-authored-by: Claude <noreply@anthropic.com>
2026-07-25 11:03:27 +02:00
renovate[bot] 8dd54e0aa8 Update dependency lint-staged to v17.1.1 (#53290)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-07-25 11:01:06 +02:00
renovate[bot] 90a79d458f Update dependency tar to v7.5.21 (#53287)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-07-25 11:01:04 +02:00
Joakim Plate 34dba7ea5a Normalize number entity row input (#53284)
hui-number-entity-row.ts bound the raw stateObj.state string to the
non-slider ha-input, so a state like "50.0" showed decimals on the
device page. state-card-number.ts (used by the more-info dialog)
already normalizes via Number(state).toString(); apply the same here
so both views render consistently.
2026-07-24 18:25:56 +02:00
renovate[bot] e76e7d1064 Unpin dependency glob resolution (#53282)
* Pin dependency glob to 10.5.0

* Apply suggestions from code review

Co-authored-by: Simon Lamon <32477463+silamon@users.noreply.github.com>

---------

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Simon Lamon <32477463+silamon@users.noreply.github.com>
2026-07-24 15:39:16 +00:00
Paulus Schoutsen b6675159ae Mock the usage prediction command in the demo (#53279)
Mock usage_prediction/common_control and load the usage_prediction
component so common-controls sections in strategy dashboards render
suggested entities in the demo. Entities that don't exist in the
currently loaded demo config are filtered out by the section strategy.


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

Co-authored-by: Claude <noreply@anthropic.com>
2026-07-24 17:20:29 +02:00
renovate[bot] 9d96d0b4aa Update dependency @rspack/core to v2.1.5 (#53276)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-07-24 13:07:05 +00:00
Paulus Schoutsen d900cc70c5 Fix unhandled error when the Cast SDK fails to load (#53271)
* Fix unhandled error when the Cast SDK fails to load

Resolve the Cast API as unavailable when the cast_sender.js script
fails to load (offline, blocked by an ad-blocker or firewall), instead
of leaking an unhandled promise rejection. Handle the resulting
getCastManager rejection in the demo cast row, matching hui-cast-row,
so the row hides itself.

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

* Drop redundant comment

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

* Simplify promise memoization with ??=

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

* Revert "Simplify promise memoization with ??="

This reverts commit 5527741b6a.

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-07-24 13:01:46 +00:00
pcan08 837baff26c Fix My Backups navigation (#53259) 2026-07-24 14:57:41 +02:00
Petar Petrov 43b4e247af Fix tile trend-graph feature not extending unchanging values to now (#53265)
Extend tile trend-graph feature to current time for unchanged sensors
2026-07-24 12:52:06 +02:00
renovate[bot] 17def3c5ee Update dependency prettier to v3.9.6 (#53267)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-07-24 12:46:59 +02:00
renovate[bot] ba15879662 Update dependency @rsdoctor/rspack-plugin to v1.6.1 (#53266)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-07-24 12:46:53 +02:00
renovate[bot] 0046f72641 Update dependency marked to v18.0.7 (#53263)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-07-24 05:41:12 +00:00
renovate[bot] f66ba11705 Update dependency typescript-eslint to v8.65.0 (#53262)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-07-24 08:30:42 +03:00
renovate[bot] bded4341df Update dependency sinon to v22.1.0 (#53261)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-07-24 08:30:21 +03:00
Bram Kragten 7a6091b5e2 Surface active HTTP config slot and reverted pending state (#53166) 2026-07-23 17:59:51 +02:00
Aidan Timson 6d12672c3e Update skill guidance, move gallery to skill (#53249)
* Correct frontend component and context guidance

* Clarify frontend benchmark requirements

* Move gallery guidance into a skill

* Clarify gallery guidance ownership

* Correct context API ownership
2026-07-23 17:47:02 +03:00
Aidan Timson 495562d748 Prepare copilot environment (#53241)
* Prepare Copilot build and test environment

* Cache Playwright browsers for Copilot

* Cache deps

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>

* Apply suggestions from code review

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>

* Remove indefinite playwright installs

* Spacing

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-07-23 17:03:18 +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
260 changed files with 13100 additions and 3221 deletions
+14 -9
View File
@@ -23,11 +23,10 @@ Dialog implementation requirements:
- Use `ha-dialog`.
- Use `DialogMixin`, which implements `HassDialogNext<T>`, for new dialogs. See `src/dialogs/dialog-mixin.ts`.
- Read dialog parameters from the mixin's `params` property and render the dialog open. Return `nothing` while required parameters are absent.
- Call the mixin's `closeDialog()` to close a new dialog. The mixin handles the `closed` event, fires `dialog-closed`, and removes the host element.
- Existing dialogs may implement the legacy `HassDialog<T>` interface from `src/dialogs/make-dialog-manager.ts`.
- Use `@state() private _open = false` to control visibility.
- Set `_open = true` in `showDialog()` and `_open = false` in `closeDialog()`.
- Return `nothing` while required params are absent.
- Fire `dialog-closed` in the close handler.
- Preserve the existing `showDialog()`, open-state, and close-event lifecycle when maintaining a legacy dialog; do not copy that lifecycle into a `DialogMixin` dialog.
- Use `header-title` and `header-subtitle` for simple header text.
- Use slots when standard header attributes are not enough.
- Use `ha-dialog-footer` with `primaryAction` and `secondaryAction` slots.
@@ -64,7 +63,7 @@ Use `computeLabel`, `computeError`, and `computeHelper` for translated labels, v
.data=${this._data}
.schema=${this._schema}
.error=${this._errors}
.computeLabel=${(schema) => this.hass.localize(`ui.panel.${schema.name}`)}
.computeLabel=${(schema) => this._localize(`ui.panel.${schema.name}`)}
@value-changed=${this._valueChanged}
></ha-form>
```
@@ -78,10 +77,16 @@ Use `ha-alert` for user-visible status messaging.
- Slots: `icon` for custom leading icon, `action` for custom action content.
- Content is announced by screen readers when dynamically displayed.
```html
<ha-alert alert-type="error">Error message</ha-alert>
<ha-alert alert-type="warning" title="Warning">Description</ha-alert>
<ha-alert alert-type="success" dismissable>Success message</ha-alert>
```ts
html`
<ha-alert alert-type="error">${this._localize("ui.example.error")}</ha-alert>
<ha-alert alert-type="warning" .title=${this._localize("ui.example.warning")}>
${this._localize("ui.example.description")}
</ha-alert>
<ha-alert alert-type="success" dismissable>
${this._localize("ui.example.success")}
</ha-alert>
`;
```
## Shortcuts And Tooltips
+23 -16
View File
@@ -23,27 +23,27 @@ Container components may keep `hass` when they own it and feed providers. Leaf c
## Context Selection
| Context | Replaces |
| -------------------------------------------------------------------- | -------------------------------------------------------------------------------------- |
| `statesContext` | `hass.states` |
| `entitiesContext`, `devicesContext`, `areasContext`, `floorsContext` | `hass.entities`, `hass.devices`, `hass.areas`, `hass.floors` |
| `registriesContext` | all four registries together |
| `servicesContext` | `hass.services` |
| `internationalizationContext` | `hass.localize`, `hass.locale`, `hass.language` |
| `formattersContext` | entity and attribute formatters |
| `configContext` | `hass.config`, `hass.user`, `hass.auth`, `hass.userData` |
| `connectionContext` | `hass.connection`, `hass.connected`, `hass.hassUrl` |
| `apiContext` | `hass.callService`, `hass.callApi`, `hass.callWS`, `hass.sendWS`, `hass.fetchWithAuth` |
| `uiContext` | themes, selected theme, panels, sidebar, and UI state |
| `narrowViewportContext` | narrow-layout boolean |
| Context | Replaces |
| -------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------- |
| `statesContext` | `hass.states` |
| `entitiesContext`, `devicesContext`, `areasContext`, `floorsContext` | `hass.entities`, `hass.devices`, `hass.areas`, `hass.floors` |
| `registriesContext` | all four registries together |
| `servicesContext` | `hass.services` |
| `internationalizationContext` | `hass.localize`, `hass.locale`, `hass.language` |
| `formattersContext` | entity and attribute formatters |
| `configContext` | `hass.config`, `hass.user`, `hass.auth`, `hass.userData` |
| `connectionContext` | `hass.connection`, `hass.connected`, `hass.debugConnection`, `hass.hassUrl` |
| `apiContext` | `hass.callService`, `hass.callApi`, `hass.callApiRaw`, `hass.callWS`, `hass.sendWS`, `hass.fetchWithAuth` |
| `uiContext` | themes, selected theme, panels, sidebar, and UI state |
| `narrowViewportContext` | narrow-layout boolean |
Lazy contexts subscribe on first consumer and tear down after the last consumer: `labelsContext`, `fullEntitiesContext`, `configEntriesContext`, and `manifestsContext`.
Lazy contexts subscribe on first consumer and tear down after the last consumer: `labelsContext`, `fullEntitiesContext`, `configEntriesContext`, `manifestsContext`, `triggerDescriptionsContext`, and `conditionDescriptionsContext`.
The single-field contexts such as `localizeContext`, `themesContext`, and `userContext` are deprecated. Use grouped contexts instead.
## Consumption Patterns
Use entity-scoped helpers when the component watches an entity id held on the host:
Use entity-scoped helpers when the component watches an entity ID held on the host:
```ts
@state() @consumeEntityState({ entityIdPath: ["_config", "entity"] })
@@ -56,6 +56,13 @@ private _entity?: EntityRegistryDisplayEntry;
private _localize!: LocalizeFunc;
```
Use `consumeEntityStates` when the host property contains one or more entity IDs. It filters missing entities and preserves the previous record when none of the selected entities changed.
```ts
@state() @consumeEntityStates({ entityIdPath: ["_config", "entities"] })
private _stateObjs?: Record<string, HassEntity>;
```
For a single field from a grouped context, pair `@consume` with `@transform`:
```ts
@@ -65,7 +72,7 @@ For a single field from a grouped context, pair `@consume` with `@transform`:
private _themes!: Themes;
```
Use `@transform` with `watch` when the transformer depends on a host property, such as a computed entity id. `consumeEntityState` only watches the first path segment.
Use `@transform` with `watch` when the transformer depends on a host property, such as a computed entity ID. `consumeEntityState` and `consumeEntityStates` only watch the first path segment.
To consume a whole group untransformed, omit `@transform` and type the field as `ContextType<typeof statesContext>` or the matching context type.
+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.
@@ -1,6 +1,11 @@
# Gallery Agent Instructions
---
name: ha-frontend-gallery
description: Home Assistant frontend gallery structure, pages, demos, content, and verification. Use when changing files under gallery/, including gallery markdown, TypeScript demos, sidebar entries, mock data, page generation, or gallery builds.
---
This file applies to all files under `gallery/`. Follow the root `AGENTS.md` for repository-wide Home Assistant frontend, TypeScript, Lit, accessibility, and copy standards. This file adds gallery-specific structure, page, demo, and verification guidance.
# HA Frontend Gallery
Use this skill for all work under `gallery/`. Follow the persistent repository guidance in `AGENTS.md` and load the matching specialist skills alongside this gallery-specific guidance.
## Quick Reference
@@ -13,7 +18,7 @@ yarn lint # ESLint, Prettier, TypeScript, and Lit checks
yarn lint:types # TypeScript compiler, without file arguments
```
Never run `yarn lint:types` or `tsc` with file arguments. See the root `AGENTS.md` for the generated `.js` file risk.
Never run `yarn lint:types` or `tsc` with file arguments. File arguments make `tsc` ignore `tsconfig.json` and can emit `.js` files into `src/`.
## Purpose
@@ -26,36 +31,36 @@ The gallery is a developer and designer reference for Home Assistant frontend UI
## Structure
- `sidebar.js`: Defines gallery sections, headers, and explicit page ordering.
- `script/develop_gallery`: Wrapper for the `develop-gallery` gulp task.
- `script/build_gallery`: Wrapper for the `build-gallery` gulp task.
- `src/entrypoint.js`: Creates the `<ha-gallery>` shell.
- `src/ha-gallery.ts`: Renders the drawer, page routing, markdown descriptions, demos, edit links, and RTL toggle.
- `src/html/index.html.template`: HTML template used by the gallery build.
- `src/pages/<category>/<page>.markdown`: Optional page description and frontmatter.
- `src/pages/<category>/<page>.ts`: Optional live demo module for the same page id.
- `src/components/`: Gallery-only demo wrappers like `demo-card`, `demo-cards`, `demo-more-info`, and `page-description`.
- `src/data/`: Fake `hass`, demo states, mock traces, and reusable sample data.
- `public/`: Static assets copied into the gallery output.
- `gallery/sidebar.js`: Defines gallery sections, headers, and explicit page ordering.
- `gallery/script/develop_gallery`: Wrapper for the `develop-gallery` gulp task.
- `gallery/script/build_gallery`: Wrapper for the `build-gallery` gulp task.
- `gallery/src/entrypoint.js`: Creates the `<ha-gallery>` shell.
- `gallery/src/ha-gallery.ts`: Renders the drawer, page routing, markdown descriptions, demos, edit links, and RTL toggle.
- `gallery/src/html/index.html.template`: HTML template used by the gallery build.
- `gallery/src/pages/<category>/<page>.markdown`: Optional page description and frontmatter.
- `gallery/src/pages/<category>/<page>.ts`: Optional live demo module for the same page ID.
- `gallery/src/components/`: Gallery-only demo wrappers like `demo-card`, `demo-cards`, `demo-more-info`, and `page-description`.
- `gallery/src/data/`: Fake `hass`, demo states, mock traces, and reusable sample data.
- `gallery/public/`: Static assets copied into the gallery output.
## Page Model
Gallery pages are generated by `gather-gallery-pages` in `build-scripts/gulp/gallery.js`.
- A page id is the path under `src/pages/` without the extension, like `components/ha-button`.
- A `.markdown` file and a `.ts` file with the same page id become one gallery page.
- A page ID is the path under `gallery/src/pages/` without the extension, like `components/ha-button`.
- A `.markdown` file and a `.ts` file with the same page ID become one gallery page.
- A page may have only markdown, only a TypeScript demo, or both.
- Markdown can contain YAML frontmatter with `title` and optional `subtitle`.
- Markdown that contains only frontmatter contributes metadata without rendering a description block.
- TypeScript demo modules are dynamically imported for side effects when the page is opened.
- A demo module must define a custom element named `demo-${category}-${page}` with slashes replaced by hyphens, like `demo-components-ha-button` for `components/ha-button`.
- `ha-gallery.ts` renders that element with `dynamicElement()` based on the current page id.
- `gallery/src/ha-gallery.ts` renders that element with `dynamicElement()` based on the current page ID.
## Sidebar
Use `sidebar.js` when a page needs a visible section, section header, or deterministic ordering.
Use `gallery/sidebar.js` when a page needs a visible section, section header, or deterministic ordering.
- `category` must match the first directory name under `src/pages/`.
- `category` must match the first directory name under `gallery/src/pages/`.
- `header` is the section label shown in the drawer.
- `pages` is optional. When present, listed pages keep that exact order.
- Pages in a category that are not listed are appended alphabetically after the listed pages.
@@ -83,20 +88,20 @@ Use markdown pages for explanations, design guidance, API notes, and copy standa
- Use fenced code blocks with a language tag for copyable examples.
- Keep examples short and focused on the behavior being documented.
- Prefer real component names and attributes over prose-only descriptions.
- Use Home Assistant terminology from the root `AGENTS.md`.
- For remove/delete and add/create wording, follow `src/pages/misc/remove-delete-add-create.markdown`.
- Use Home Assistant terminology from `ha-frontend-user-facing-text`.
- For remove/delete and add/create wording, follow `gallery/src/pages/misc/remove-delete-add-create.markdown`.
Gallery markdown is documentation content and is not localized with `localize`. If demo code creates production UI strings, keep those strings aligned with the root localization and copy guidance.
Gallery markdown is documentation content and is not localized with `localize`. If demo code creates production UI strings, follow the localization and copy guidance in `ha-frontend-user-facing-text`.
## Demo Components
Use TypeScript demo pages for interactive or stateful examples.
- Import production components from `../../../src/...` or the correct relative path from the demo file.
- Import production components from `src/` using the correct relative path from the demo file.
- Import reusable gallery helpers from `gallery/src/components/` when they already model the pattern.
- Use `demo-card` and `demo-cards` for Lovelace card examples that render YAML card configs.
- Use `demo-more-info` and `demo-more-infos` for more-info dialog examples.
- Use shared mock data from `src/data/` instead of repeating large fake state objects inline.
- Use shared mock data from `gallery/src/data/` instead of repeating large fake state objects inline.
- Show meaningful states, such as loading, unavailable, empty, error, active, inactive, and disabled when relevant.
- Check responsive behavior and the gallery RTL toggle when layout or direction-sensitive UI changes.
- Keep unavoidable casts or loose demo parsing local to the demo helper or demo page.
@@ -105,7 +110,7 @@ The gallery ESLint config allows `console` for gallery diagnostics. Do not copy
## Content Standards
The root copy standards still apply: use American English, sentence case, active voice, inclusive language, direct user-focused wording, and consistent Home Assistant terminology.
Follow the detailed copy standards in `ha-frontend-user-facing-text`: use American English, sentence case, active voice, inclusive language, direct user-focused wording, and consistent Home Assistant terminology.
- Use `Home Assistant` in full, not `HA` or `HASS`.
- Use `integration` instead of `component` for product concepts.
+25 -4
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,26 @@ 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. App builds and development servers keep exclusive ownership of `hass_frontend/` for their lifetime.
Managed app, demo, gallery, and E2E app workflows share one lifetime lock, so only one build or development server can run at a time.
## 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 +62,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]`. `yarn dev`, `yarn dev:serve`, `yarn dev:demo`, and `yarn dev:gallery` also support `--fetch-translations`; this runs translation fetching, including first-time GitHub device authentication, under the workflow lock before starting the watcher. It works in foreground and background modes. 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
@@ -59,15 +80,15 @@ The custom development wrappers use `/__ha_dev_status` to identify and manage th
Local runs against a watched development server do not always match CI's clean build artifacts, environment, sharding, or worker configuration. Use background servers for the fast iteration loop, but confirm the relevant CI jobs complete successfully before considering E2E changes verified.
Use `-g "<title>" --project=chromium` to narrow a run. `yarn test:e2e` runs all three suites in parallel when every managed server is available, otherwise it runs them sequentially to prevent cold builds racing over shared generated assets. Run suites directly; piping through output truncation hides progress and failures.
Use `-g "<title>" --project=chromium` to narrow a run. `yarn test:e2e` runs suites sequentially when managed servers are unavailable to prevent cold builds racing over shared generated assets. Run suites directly; piping through output truncation hides progress and failures.
The app suite uses a stripped-down harness for e2e. Demo and gallery use their normal dev servers.
## Benchmarks
For chart data transforms such as history, statistics, energy, and downsampling, follow `test/benchmarks/README.md`.
For chart data transforms such as history, statistics, energy, and downsampling, read and follow the complete workflow in `test/benchmarks/README.md` before making benchmark or optimization changes.
Use seeded fixtures, characterization snapshot tests, and `yarn test:bench` before and after optimization. Optimizations must keep output bit-identical.
That workflow owns the baseline, noise analysis, guardrails, acceptance thresholds, and reporting requirements. In particular, optimizations must keep output bit-identical; never update snapshots or modify fixtures to make an optimization pass.
## Verification Selection
@@ -16,7 +16,7 @@ Use this skill for all user-facing text, translations, labels, buttons, dialog c
- Give translators enough context through key naming and placeholders.
```ts
this.hass.localize("ui.panel.config.updates.updates_refreshed", {
this._localize("ui.panel.config.updates.updates_refreshed", {
count: 5,
});
```
+1 -1
View File
@@ -32,7 +32,7 @@ jobs:
ACTIONLINT_VERSION: 1.7.12
steps:
- name: Check out files from GitHub
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Run actionlint
+1 -1
View File
@@ -21,7 +21,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Check out workflow scripts
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
sparse-checkout: .github/scripts
+2 -2
View File
@@ -24,7 +24,7 @@ jobs:
url: ${{ steps.deploy.outputs.netlify_url }}
steps:
- name: Check out files from GitHub
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: dev
persist-credentials: false
@@ -56,7 +56,7 @@ jobs:
url: ${{ steps.deploy.outputs.netlify_url }}
steps:
- name: Check out files from GitHub
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: master
persist-credentials: false
+4 -4
View File
@@ -27,7 +27,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Check out files from GitHub
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Prepare dependencies
@@ -39,7 +39,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Check out files from GitHub
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Setup Node with shared dependencies
@@ -82,7 +82,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Check out files from GitHub
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Setup Node with shared dependencies
@@ -104,7 +104,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Check out files from GitHub
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Setup Node with shared dependencies
+3 -3
View File
@@ -27,17 +27,17 @@ jobs:
steps:
- name: Check out code from GitHub
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Initialize CodeQL
uses: github/codeql-action/init@99df26d4f13ea111d4ec1a7dddef6063f76b97e9 # v4.37.0
uses: github/codeql-action/init@7188fc363630916deb702c7fdcf4e481b751f97a # v4.37.1
with:
languages: javascript-typescript
build-mode: none
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@99df26d4f13ea111d4ec1a7dddef6063f76b97e9 # v4.37.0
uses: github/codeql-action/analyze@7188fc363630916deb702c7fdcf4e481b751f97a # v4.37.1
with:
category: "/language:javascript-typescript"
+34
View File
@@ -0,0 +1,34 @@
name: Copilot Setup Steps
on:
workflow_dispatch:
push:
paths:
- .github/workflows/copilot-setup-steps.yml
pull_request:
paths:
- .github/workflows/copilot-setup-steps.yml
env:
NODE_OPTIONS: --max_old_space_size=6144
jobs:
copilot-setup-steps:
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- name: Check out files from GitHub
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Setup Node and install
uses: ./.github/actions/setup
with:
node-modules-cache: true
- name: Skip fetching nightly translations
run: echo "SKIP_FETCH_NIGHTLY_TRANSLATIONS=1" >> "$GITHUB_ENV"
- name: Build resources
run: ./node_modules/.bin/gulp gen-icons-json build-translations build-locale-data gather-gallery-pages
+2 -2
View File
@@ -25,7 +25,7 @@ jobs:
url: ${{ steps.deploy.outputs.unique_deploy_url }}
steps:
- name: Check out files from GitHub
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: dev
persist-credentials: false
@@ -56,7 +56,7 @@ jobs:
url: ${{ steps.deploy.outputs.netlify_url }}
steps:
- name: Check out files from GitHub
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: master
persist-credentials: false
+1 -1
View File
@@ -19,7 +19,7 @@ jobs:
url: ${{ steps.deploy.outputs.netlify_url }}
steps:
- name: Check out files from GitHub
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
+1 -1
View File
@@ -24,7 +24,7 @@ jobs:
if: github.repository == 'home-assistant/frontend' && contains(github.event.pull_request.labels.*.name, 'needs design preview')
steps:
- name: Check out files from GitHub
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
+13 -13
View File
@@ -27,7 +27,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Check out files from GitHub
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
@@ -38,14 +38,14 @@ jobs:
name: Prepare container dependencies
runs-on: ubuntu-latest
container:
image: mcr.microsoft.com/playwright:v1.61.1-noble
image: mcr.microsoft.com/playwright:v1.62.0-noble
options: --user 1001
defaults:
run:
shell: bash
steps:
- name: Check out files from GitHub
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
@@ -61,7 +61,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Check out files from GitHub
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
@@ -92,7 +92,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Check out files from GitHub
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
@@ -123,7 +123,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Check out files from GitHub
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
@@ -154,7 +154,7 @@ jobs:
- prepare-container-dependencies
runs-on: ubuntu-latest
container:
image: mcr.microsoft.com/playwright:v1.61.1-noble
image: mcr.microsoft.com/playwright:v1.62.0-noble
options: --user 1001 --ipc=host
defaults:
run:
@@ -170,7 +170,7 @@ jobs:
- 2
steps:
- name: Check out files from GitHub
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
@@ -206,7 +206,7 @@ jobs:
- prepare-container-dependencies
runs-on: ubuntu-latest
container:
image: mcr.microsoft.com/playwright:v1.61.1-noble
image: mcr.microsoft.com/playwright:v1.62.0-noble
options: --user 1001 --ipc=host
defaults:
run:
@@ -224,7 +224,7 @@ jobs:
- 4
steps:
- name: Check out files from GitHub
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
@@ -260,7 +260,7 @@ jobs:
- prepare-container-dependencies
runs-on: ubuntu-latest
container:
image: mcr.microsoft.com/playwright:v1.61.1-noble
image: mcr.microsoft.com/playwright:v1.62.0-noble
options: --user 1001 --ipc=host
defaults:
run:
@@ -278,7 +278,7 @@ jobs:
- 4
steps:
- name: Check out files from GitHub
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
@@ -320,7 +320,7 @@ jobs:
pull-requests: write
steps:
- name: Check out files from GitHub
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
+1 -1
View File
@@ -10,6 +10,6 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Apply labels
uses: actions/labeler@b8dd2d9be0f68b860e7dae5dae7d772984eacd6d # v6.2.0
uses: actions/labeler@bf12e9b00b37c5c0ca2b87b79b2daf7891dbda13 # v7.0.0
with:
sync-labels: true
+2 -2
View File
@@ -20,12 +20,12 @@ jobs:
contents: write
steps:
- name: Checkout the repository
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Set up Python ${{ env.PYTHON_VERSION }}
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version: ${{ env.PYTHON_VERSION }}
@@ -24,7 +24,7 @@ jobs:
pull-requests: write # To label and comment on pull requests
steps:
- name: Check out workflow scripts
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
sparse-checkout: .github/scripts
+1 -1
View File
@@ -18,6 +18,6 @@ jobs:
pull-requests: read
runs-on: ubuntu-latest
steps:
- uses: release-drafter/release-drafter@4d75298e00d9e34c483e5ff8c68d0ea1c1940c1e # v7.5.1
- uses: release-drafter/release-drafter@eada3c96a64734dd381cfbda23511034e328ddb0 # v7.6.0
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+5 -5
View File
@@ -26,17 +26,17 @@ jobs:
if: github.repository_owner == 'home-assistant'
steps:
- name: Checkout the repository
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Set up Python ${{ env.PYTHON_VERSION }}
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version: ${{ env.PYTHON_VERSION }}
- name: Verify version
uses: home-assistant/actions/helpers/verify-version@f4ca6f671bd429efb108c0f2fa0ae8af0215986c # master
uses: home-assistant/actions/helpers/verify-version@e3fb68ebda13d88a0d695082f471ba2c83d025fb # master
- name: Setup Node and install
uses: ./.github/actions/setup
@@ -56,7 +56,7 @@ jobs:
script/release
- name: Publish to PyPI
uses: pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b # v1.14.0
uses: pypa/gh-action-pypi-publish@ba38be9e461d3875417946c167d0b5f3d385a247 # v1.14.1
with:
skip-existing: true
@@ -111,7 +111,7 @@ jobs:
contents: write # Required to upload release assets
steps:
- name: Checkout the repository
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Setup Node and install
@@ -42,7 +42,7 @@ jobs:
if: github.event.issue.type.name == 'Task'
steps:
- name: Check out workflow scripts
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
sparse-checkout: .github/scripts
@@ -21,7 +21,7 @@ jobs:
pull-requests: write
steps:
- name: Checkout the repository
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
+1 -1
View File
@@ -18,7 +18,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout the repository
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
+1 -1
View File
@@ -1 +1 @@
24.18.0
24.18.1
+2 -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 gallery-specific documentation, demos, page structure, and examples, read `gallery/AGENTS.md` when working under `gallery/`.
## Essential Commands
```bash
@@ -46,6 +44,8 @@ Detailed guidance lives in project skills under `.agents/skills/`. Load the matc
- `ha-frontend-testing`: lint, typecheck, Vitest, Playwright e2e dev servers, and benchmarks.
- `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
+311
View File
@@ -0,0 +1,311 @@
// 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,
offerToStopProcessRecord,
processStartTime,
readProcessRecord,
releaseProcessRecord,
runCli,
spawnDetachedToLog,
spawnForeground,
terminateDetachedProcess,
terminateProcess,
waitFor,
writeProcessRecord,
} from "./managed-process.mjs";
import {
buildCacheDir,
describeOutputOwner,
workflowLockEnv,
workflowLockFile,
} from "./output-lock.mjs";
const repoRoot = path.resolve(
path.dirname(fileURLToPath(import.meta.url)),
".."
);
const gulpBin = path.join(repoRoot, "node_modules", ".bin", "gulp");
const stateDir = path.join(buildCacheDir, "ha-build");
const logFile = path.join(stateDir, "build.log");
const lockFile = workflowLockFile;
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 devCommand = (suite) => {
switch (suite) {
case "app-serve":
return "dev:serve";
case "demo":
return "dev:demo";
case "gallery":
return "dev:gallery";
case "e2e-app":
return "test:e2e:app:dev";
default:
return "dev";
}
};
const readBuild = () => readProcessRecord(lockFile);
const releaseBuild = (token) => releaseProcessRecord(lockFile, token);
const stopCommandFor = (owner) =>
owner?.kind === "build"
? "yarn build --stop"
: owner?.kind === "dev"
? `yarn ${devCommand(owner.suite)} --stop`
: undefined;
const acquireBuild = async (modern, foreground) => {
const token = `${process.pid}-${Date.now()}-${Math.random()}`;
const record = {
pid: process.pid,
startTime: processStartTime(process.pid),
processGroup: false,
foreground,
kind: "build",
modern,
starting: true,
token,
};
const result = acquireProcessRecord(lockFile, record);
if (result.acquired) {
return { token };
}
reportExisting(result.existing);
return (await offerToStopProcessRecord({
file: lockFile,
owner: result.existing,
ownerDescription: describeOutputOwner(result.existing),
stopCommand: stopCommandFor(result.existing),
}))
? acquireBuild(modern, foreground)
: { 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 === "output") {
process.stdout.write(
`${describeOutputOwner(existing)} already owns the build and development workflow` +
`${existing.pid ? ` (pid ${existing.pid})` : ""}.\n`
);
return;
}
if (existing?.kind === "dev") {
const command = devCommand(existing.suite);
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 = await acquireBuild(modern, true);
if (!lock.token) {
return 1;
}
try {
return await spawnForeground({
cmd: gulpBin,
args: [taskFor(modern)],
cwd: repoRoot,
env: workflowLockEnv(lock.token),
processGroup: true,
onSpawn: (child) => updateBuild(lock.token, child, true),
});
} finally {
releaseBuild(lock.token);
}
};
const runBackground = async (modern) => {
const lock = await acquireBuild(modern, false);
if (!lock.token) {
return 1;
}
let child;
try {
child = await spawnDetachedToLog({
cmd: gulpBin,
args: [taskFor(modern)],
cwd: repoRoot,
env: workflowLockEnv(lock.token),
logFile,
});
updateBuild(lock.token, child, true);
process.stdout.write(
`Started ${modern ? "modern " : ""}frontend build (pid ${child.pid})\n` +
hints()
);
return 0;
} catch (err) {
if (child) {
await terminateDetachedProcess(child);
}
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")) {
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
}
}
File diff suppressed because it is too large Load Diff
+27
View File
@@ -1,5 +1,6 @@
import gulp from "gulp";
import env from "../env.cjs";
import { createWorkflowLockTask } from "../output-lock.mjs";
import "./clean.js";
import "./compress.js";
import "./entry-html.js";
@@ -17,6 +18,7 @@ gulp.task(
async function setEnv() {
process.env.NODE_ENV = "development";
},
createWorkflowLockTask("develop-app"),
"clean",
gulp.parallel(
"gen-service-worker-app-dev",
@@ -36,6 +38,7 @@ gulp.task(
async function setEnv() {
process.env.NODE_ENV = "production";
},
createWorkflowLockTask("build-app"),
"clean",
gulp.parallel(
"gen-icons-json",
@@ -51,6 +54,30 @@ gulp.task(
)
);
gulp.task(
"build-app-modern",
gulp.series(
async function setEnv() {
process.env.NODE_ENV = "production";
},
createWorkflowLockTask("build-app-modern"),
"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(
+3
View File
@@ -1,4 +1,5 @@
import gulp from "gulp";
import { createWorkflowLockTask } from "../output-lock.mjs";
import "./clean.js";
import "./entry-html.js";
import "./gather-static.js";
@@ -13,6 +14,7 @@ gulp.task(
async function setEnv() {
process.env.NODE_ENV = "development";
},
createWorkflowLockTask("develop-demo"),
"clean-demo",
"translations-enable-merge-backend",
gulp.parallel(
@@ -32,6 +34,7 @@ gulp.task(
async function setEnv() {
process.env.NODE_ENV = "production";
},
createWorkflowLockTask("build-demo"),
"clean-demo",
// Cast needs to be backwards compatible and older HA has no translations
"translations-enable-merge-backend",
+3
View File
@@ -1,4 +1,5 @@
import gulp from "gulp";
import { createWorkflowLockTask } from "../output-lock.mjs";
import "./clean.js";
import "./entry-html.js";
import "./gather-static.js";
@@ -12,6 +13,7 @@ gulp.task(
async function setEnv() {
process.env.NODE_ENV = "development";
},
createWorkflowLockTask("develop-e2e-test-app"),
"clean-e2e-test-app",
"translations-enable-merge-backend",
gulp.parallel(
@@ -31,6 +33,7 @@ gulp.task(
async function setEnv() {
process.env.NODE_ENV = "production";
},
createWorkflowLockTask("build-e2e-test-app"),
"clean-e2e-test-app",
"translations-enable-merge-backend",
gulp.parallel("gen-icons-json", "build-translations", "build-locale-data"),
+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"],
@@ -52,6 +52,23 @@ gulp.task("fetch-nightly-translations", async function () {
currentArtifact = null;
}
try {
await fetchTranslations(currentArtifact);
} catch (err) {
// Local builds should work offline or without valid GitHub credentials,
// so fall back to English only. CI must fail instead of silently
// building without translations.
if (process.env.CI) {
throw err;
}
console.warn(
"Failed to fetch nightly translations, continuing with English only:",
err?.message || err
);
}
});
async function fetchTranslations(currentArtifact) {
// To store file writing promises
const createExtractDir = mkdir(EXTRACT_DIR, { recursive: true });
const writings = [];
@@ -130,11 +147,6 @@ gulp.task("fetch-nightly-translations", async function () {
if (!latestArtifact) {
throw Error("Latest nightly workflow run has no translations artifact");
}
writings.push(
createExtractDir.then(
writeFile(ARTIFACT_FILE, JSON.stringify(latestArtifact, null, 2))
)
);
// Remove the current translations
const deleteCurrent = Promise.all(writings).then(
@@ -160,7 +172,12 @@ gulp.task("fetch-nightly-translations", async function () {
await new Promise((resolve, reject) => {
extractStream.on("close", resolve).on("error", reject);
});
});
// Record the artifact only after successful extraction, so a failed fetch
// is retried by the next build instead of being considered current.
await createExtractDir;
await writeFile(ARTIFACT_FILE, JSON.stringify(latestArtifact, null, 2));
}
gulp.task(
"setup-and-fetch-nightly-translations",
+3
View File
@@ -4,6 +4,7 @@ import gulp from "gulp";
import { load as loadYaml } from "js-yaml";
import { marked } from "marked";
import path from "path";
import { createWorkflowLockTask } from "../output-lock.mjs";
import paths from "../paths.cjs";
import "./clean.js";
import "./entry-html.js";
@@ -164,6 +165,7 @@ gulp.task(
async function setEnv() {
process.env.NODE_ENV = "development";
},
createWorkflowLockTask("develop-gallery"),
"clean-gallery",
"translations-enable-merge-backend",
gulp.parallel(
@@ -195,6 +197,7 @@ gulp.task(
async function setEnv() {
process.env.NODE_ENV = "production";
},
createWorkflowLockTask("build-gallery"),
"clean-gallery",
"translations-enable-merge-backend",
gulp.parallel(
+26 -7
View File
@@ -108,7 +108,7 @@ const runDevServer = async ({
}
};
const doneHandler = (done) => (err, stats) => {
const doneHandler = () => (err, stats) => {
if (err) {
log.error(err.stack || err);
if (err.details) {
@@ -122,18 +122,26 @@ const doneHandler = (done) => (err, stats) => {
}
log(`Build done @ ${new Date().toLocaleTimeString()}`);
if (done) {
done();
}
};
const prodBuild = (conf) =>
new Promise((resolve) => {
new Promise((resolve, reject) => {
rspack(
conf,
// Resolve promise when done. Because we pass a callback, rspack closes itself
doneHandler(resolve)
(err, stats) => {
if (err) {
reject(err);
} else if (stats.hasErrors()) {
reject(Error(stats.toString("errors-only")));
} else {
if (stats.hasWarnings()) {
console.log(stats.toString("minimal"));
}
log(`Build done @ ${new Date().toLocaleTimeString()}`);
resolve();
}
}
);
});
@@ -160,6 +168,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"]])
);
-2
View File
@@ -1,5 +1,3 @@
/* eslint-disable max-classes-per-file */
import { deleteAsync } from "del";
import { glob } from "glob";
import gulp from "gulp";
+552
View File
@@ -0,0 +1,552 @@
import { spawn, execFileSync } from "node:child_process";
import fs from "node:fs";
import path from "node:path";
import { createInterface } from "node:readline/promises";
export const LIFECYCLE_MODE_FLAGS = new Map([
["--background", "background"],
["--status", "status"],
["--stop", "stop"],
["--logs", "logs"],
]);
const AGENT_PROVIDERS = [
{
id: "opencode",
env: [
"OPENCODE",
"OPENCODE_BIN_PATH",
"OPENCODE_SERVER",
"OPENCODE_APP_INFO",
"OPENCODE_MODES",
],
processes: ["opencode"],
},
{
id: "claude-code",
env: ["CLAUDECODE"],
processes: ["claude"],
},
{
id: "cursor",
env: ["CURSOR_TRACE_ID"],
processes: [],
},
{
id: "github-copilot",
matchesEnv: (env) =>
env.TERM_PROGRAM === "vscode" && env.GIT_PAGER === "cat",
processes: [],
},
{
id: "generic",
env: ["AGENT", "AI_AGENT"],
processes: [],
},
];
const MAX_ANCESTRY_HOPS = 24;
const readProcessParent = (pid) => {
try {
const stat = fs.readFileSync(`/proc/${pid}/stat`, "utf8");
const commandEnd = stat.lastIndexOf(")");
const commandStart = stat.indexOf("(");
if (commandStart === -1 || commandEnd === -1) {
return undefined;
}
const parentPid = Number.parseInt(
stat.slice(commandEnd + 2).split(" ")[1] ?? "",
10
);
return Number.isFinite(parentPid)
? {
command: stat.slice(commandStart + 1, commandEnd).toLowerCase(),
parentPid,
}
: undefined;
} catch {
return undefined;
}
};
export const detectCodingAgent = (env = process.env, pid = process.pid) => {
if (env.HA_CODING_AGENT === "0") {
return undefined;
}
const envMatch = AGENT_PROVIDERS.find(
(provider) =>
provider.matchesEnv?.(env) || provider.env?.some((name) => env[name])
);
if (envMatch) {
return envMatch.id;
}
if (env.HA_CODING_AGENT === "1") {
return "unknown";
}
let currentPid = pid;
for (let hop = 0; hop < MAX_ANCESTRY_HOPS; hop++) {
const processInfo = readProcessParent(currentPid);
if (!processInfo) {
return undefined;
}
const processMatch = AGENT_PROVIDERS.find((provider) =>
provider.processes.some((name) => processInfo.command.includes(name))
);
if (processMatch) {
return processMatch.id;
}
if (processInfo.parentPid <= 1) {
return undefined;
}
currentPid = processInfo.parentPid;
}
return undefined;
};
const canPromptForConflict = () =>
Boolean(process.stdin.isTTY && process.stderr.isTTY && !detectCodingAgent());
const formatCommand = (command) =>
!("NO_COLOR" in process.env)
? `\u001b[1;36m${command}\u001b[0m`
: `\`${command}\``;
const confirmStopConflict = async (ownerDescription, stopCommand) => {
const readline = createInterface({
input: process.stdin,
output: process.stderr,
});
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 30_000);
try {
process.stderr.write(
`${ownerDescription} can be stopped with ${formatCommand(stopCommand)}.\n`
);
const answer = await readline.question("Stop it and continue? [y/N] ", {
signal: controller.signal,
});
return ["y", "yes"].includes(answer.trim().toLowerCase());
} catch (err) {
if (err?.name !== "AbortError") {
throw err;
}
process.stderr.write("\n");
return false;
} finally {
clearTimeout(timeout);
readline.close();
}
};
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,
env,
processGroup = false,
onSpawn,
}) =>
new Promise((resolve, reject) => {
const child = spawn(cmd, args, {
cwd,
detached: processGroup,
env,
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, env, 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,
env,
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();
};
const sameProcessRecord = (current, expected) =>
Boolean(expected?.token) && current?.token === expected.token;
const stopProcessRecord = async (file, owner) => {
let current = readProcessRecord(file);
if (!current) {
return true;
}
if (!sameProcessRecord(current, owner)) {
return false;
}
if (!isProcessRecordAlive(current)) {
releaseProcessRecord(file, current.token);
return true;
}
const stopped = await terminateProcess({
pid: current.pid,
processGroup: current.processGroup ?? false,
isStopped: () => {
const latest = readProcessRecord(file);
return (
!sameProcessRecord(latest, owner) ||
!latest ||
!isProcessRecordAlive(latest)
);
},
});
current = readProcessRecord(file);
if (current && !sameProcessRecord(current, owner)) {
return false;
}
if (!stopped && current) {
return false;
}
releaseProcessRecord(file, owner.token);
return true;
};
export const offerToStopProcessRecord = async ({
file,
owner,
ownerDescription,
stopCommand,
}) => {
if (
!owner ||
!stopCommand ||
!canPromptForConflict() ||
!(await confirmStopConflict(ownerDescription, stopCommand))
) {
return false;
}
return stopProcessRecord(file, owner);
};
export const terminateDetachedProcess = (child) =>
terminateProcess({
pid: child.pid,
processGroup: true,
isStopped: () => !isProcessAlive(child.pid),
});
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) => {
+127
View File
@@ -0,0 +1,127 @@
import path from "node:path";
import { fileURLToPath } from "node:url";
import {
acquireProcessRecord,
processStartTime,
readProcessRecord,
releaseProcessRecord,
} from "./managed-process.mjs";
const repoRoot = path.resolve(
path.dirname(fileURLToPath(import.meta.url)),
".."
);
export const buildCacheDir =
process.env.HA_BUILD_CACHE_DIR ??
path.join(repoRoot, "node_modules", ".cache");
const WORKFLOW_LOCK_TOKEN_ENV = "HA_WORKFLOW_LOCK_TOKEN";
const signalCleanups = new Set();
const cleanupSignals = ["SIGINT", "SIGTERM", "SIGHUP"];
const handleSignal = (signal) => {
for (const cleanup of signalCleanups) {
cleanup();
}
for (const cleanupSignal of cleanupSignals) {
process.off(cleanupSignal, handleSignal);
}
process.kill(process.pid, signal);
};
const registerSignalCleanup = (cleanup) => {
if (signalCleanups.size === 0) {
for (const signal of cleanupSignals) {
process.on(signal, handleSignal);
}
}
signalCleanups.add(cleanup);
};
const unregisterSignalCleanup = (cleanup) => {
signalCleanups.delete(cleanup);
if (signalCleanups.size === 0) {
for (const signal of cleanupSignals) {
process.off(signal, handleSignal);
}
}
};
export const workflowLockFile = path.join(buildCacheDir, "ha-workflow.lock");
export const workflowLockEnv = (token) => ({
...process.env,
[WORKFLOW_LOCK_TOKEN_ENV]: token,
});
export const describeOutputOwner = (owner) => {
if (owner?.kind === "build") {
return `frontend ${owner.modern ? "modern " : ""}build`;
}
if (owner?.kind === "dev") {
return `dev server (${owner.suite ?? "app"})`;
}
return owner?.target ? `Gulp task ${owner.target}` : "another process";
};
const createLockTask = ({ file, inheritedTokenEnv, kind, label, target }) => {
let exitToken;
const cleanup = () => {
if (!exitToken) {
return;
}
releaseProcessRecord(file, exitToken);
exitToken = undefined;
process.off("exit", cleanup);
unregisterSignalCleanup(cleanup);
};
const acquire = async () => {
const inheritedToken = process.env[inheritedTokenEnv];
if (inheritedToken) {
if (readProcessRecord(file)?.token !== inheritedToken) {
throw Error(
`${label} lock ownership was lost before ${target} started.`
);
}
exitToken = inheritedToken;
process.once("exit", cleanup);
registerSignalCleanup(cleanup);
return;
}
const token = `${process.pid}-${Date.now()}-${Math.random()}`;
const record = {
pid: process.pid,
startTime: processStartTime(process.pid),
processGroup: false,
kind,
target,
token,
};
const result = acquireProcessRecord(file, record);
if (!result.acquired) {
const pid = result.existing?.pid;
throw Error(
`Cannot run ${target}: ${describeOutputOwner(result.existing)} ` +
`already owns ${label}${pid ? ` (pid ${pid})` : ""}.`
);
}
exitToken = token;
process.once("exit", cleanup);
registerSignalCleanup(cleanup);
};
acquire.displayName = `lock-${label}:${target}`;
return acquire;
};
export const createWorkflowLockTask = (target) =>
createLockTask({
file: workflowLockFile,
inheritedTokenEnv: WORKFLOW_LOCK_TOKEN_ENV,
kind: "output",
label: "build and development workflow",
target,
});
+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(
+36 -13
View File
@@ -1,6 +1,9 @@
import { navigate } from "../../../src/common/navigate";
import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
import type { Lovelace } from "../../../src/panels/lovelace/types";
import { setDemoAreas } from "../stubs/area_registry";
import { energyEntities } from "../stubs/entities";
import { setDemoFloors } from "../stubs/floor_registry";
import { getDemoTheme } from "../stubs/frontend";
import type { DemoConfig, DemoTheme } from "./types";
@@ -12,33 +15,53 @@ export const applyDemoTheme = (hass: MockHomeAssistant, theme: DemoTheme) => {
hass.mockTheme(null, getDemoTheme(theme));
};
export const demoConfigs: (() => Promise<DemoConfig>)[] = [
() => import("./sections").then((mod) => mod.demoSections),
() => import("./arsaboo").then((mod) => mod.demoArsaboo),
() => import("./teachingbirds").then((mod) => mod.demoTeachingbirds),
() => import("./kernehed").then((mod) => mod.demoKernehed),
() => import("./jimpower").then((mod) => mod.demoJimpower),
];
export const demoConfigs: Record<string, () => Promise<DemoConfig>> = {
sections: () => import("./sections").then((mod) => mod.demoSections),
home: () => import("./home").then((mod) => mod.demoHome),
arsaboo: () => import("./arsaboo").then((mod) => mod.demoArsaboo),
teachingbirds: () =>
import("./teachingbirds").then((mod) => mod.demoTeachingbirds),
kernehed: () => import("./kernehed").then((mod) => mod.demoKernehed),
jimpower: () => import("./jimpower").then((mod) => mod.demoJimpower),
};
export const demos = Object.keys(demoConfigs);
const initialDemo = () => {
const slug = new URLSearchParams(window.location.search).get("demo");
return slug && demos.includes(slug) ? slug : demos[0];
};
// eslint-disable-next-line import-x/no-mutable-exports
export let selectedDemoConfigIndex = 0;
export let selectedDemo = initialDemo();
// eslint-disable-next-line import-x/no-mutable-exports
export let selectedDemoConfig: Promise<DemoConfig> =
demoConfigs[selectedDemoConfigIndex]();
demoConfigs[selectedDemo]();
export const setDemoConfig = async (
hass: MockHomeAssistant,
lovelace: Lovelace,
index: number
demo: string
) => {
const confProm = demoConfigs[index]();
const confProm = demoConfigs[demo]();
const config = await confProm;
selectedDemoConfigIndex = index;
selectedDemo = demo;
selectedDemoConfig = confProm;
setDemoFloors(hass, config.floors);
setDemoAreas(hass, config.areas);
hass.addEntities(config.entities(hass.localize), true);
hass.addEntities(energyEntities());
lovelace.saveConfig(config.lovelace(hass.localize));
// Let the new registries and entities reach the dashboard before saving the
// config, so dashboard strategies generate against them
await new Promise((resolve) => {
setTimeout(resolve, 0);
});
await lovelace.saveConfig(config.lovelace(hass.localize));
// The view of the previous demo might not exist in the new one
navigate(`/${hass.panelUrl}?demo=${demo}`, { replace: true });
applyDemoTheme(hass, config.theme);
};
+63
View File
@@ -0,0 +1,63 @@
import type { DemoArea } from "../../stubs/area_registry";
import type { DemoFloor } from "../../stubs/floor_registry";
export const demoFloorsHome: DemoFloor[] = [
{
floor_id: "ground",
name: "Ground floor",
level: 0,
},
{
floor_id: "upstairs",
name: "Upstairs",
level: 1,
},
];
export const demoAreasHome: DemoArea[] = [
{
area_id: "living_room",
name: "Living room",
floor_id: "ground",
icon: "mdi:sofa",
temperature_entity_id: "sensor.living_room_temperature",
humidity_entity_id: "sensor.living_room_humidity",
},
{
area_id: "kitchen",
name: "Kitchen",
floor_id: "ground",
icon: "mdi:fridge",
temperature_entity_id: "sensor.kitchen_temperature",
humidity_entity_id: "sensor.kitchen_humidity",
},
{
area_id: "entrance",
name: "Entrance",
floor_id: "ground",
icon: "mdi:door",
},
{
area_id: "bedroom",
name: "Bedroom",
floor_id: "upstairs",
icon: "mdi:bed",
temperature_entity_id: "sensor.bedroom_temperature",
humidity_entity_id: "sensor.bedroom_humidity",
},
{
area_id: "office",
name: "Office",
floor_id: "upstairs",
icon: "mdi:desk",
temperature_entity_id: "sensor.office_temperature",
humidity_entity_id: "sensor.office_humidity",
},
{
area_id: "garden",
name: "Garden",
icon: "mdi:tree",
temperature_entity_id: "sensor.garden_temperature",
humidity_entity_id: "sensor.garden_humidity",
},
];
+465
View File
@@ -0,0 +1,465 @@
import type { DemoConfig } from "../types";
export const demoEntitiesHome: DemoConfig["entities"] = () => [
// The devices tile on the overview uses zone.home, which always exists in
// a real installation.
{
entity_id: "zone.home",
state: "1",
attributes: {
latitude: 52.3731339,
longitude: 4.8903147,
radius: 100,
friendly_name: "Home",
icon: "mdi:home",
},
},
{
entity_id: "light.living_room_floor_lamp",
state: "on",
area_id: "living_room",
attributes: {
min_color_temp_kelvin: 2000,
max_color_temp_kelvin: 6535,
supported_color_modes: ["color_temp", "xy"],
color_mode: "color_temp",
brightness: 178,
color_temp_kelvin: 2583,
icon: "mdi:floor-lamp",
friendly_name: "Floor lamp",
supported_features: 44,
},
},
{
entity_id: "light.living_room_spotlights",
state: "on",
area_id: "living_room",
attributes: {
supported_color_modes: ["brightness"],
color_mode: "brightness",
brightness: 126,
icon: "mdi:ceiling-light-multiple",
friendly_name: "Spotlights",
supported_features: 32,
},
},
{
entity_id: "climate.living_room",
state: "heat",
area_id: "living_room",
attributes: {
hvac_modes: ["auto", "heat", "off"],
min_temp: 7,
max_temp: 35,
current_temperature: 21.5,
temperature: 21,
friendly_name: "Thermostat",
supported_features: 385,
},
},
{
entity_id: "cover.living_room_blinds",
state: "open",
area_id: "living_room",
attributes: {
current_position: 100,
device_class: "blind",
friendly_name: "Blinds",
supported_features: 15,
},
},
{
entity_id: "binary_sensor.living_room_window",
state: "off",
area_id: "living_room",
attributes: {
device_class: "window",
friendly_name: "Window",
},
},
{
entity_id: "media_player.living_room_speaker",
state: "playing",
area_id: "living_room",
attributes: {
device_class: "speaker",
volume_level: 0.28,
is_volume_muted: false,
media_content_type: "music",
media_duration: 300,
media_position: 0,
media_position_updated_at: new Date(
new Date().getTime() - 23000
).toISOString(),
media_title: "I Wasn't Born To Follow",
media_artist: "The Byrds",
media_album_name: "The Notorious Byrd Brothers",
shuffle: false,
friendly_name: "Living room speaker",
supported_features: 64063,
},
},
{
entity_id: "sensor.living_room_temperature",
state: "21.5",
area_id: "living_room",
attributes: {
state_class: "measurement",
unit_of_measurement: "°C",
device_class: "temperature",
friendly_name: "Temperature",
},
},
{
entity_id: "sensor.living_room_humidity",
state: "45",
area_id: "living_room",
attributes: {
state_class: "measurement",
unit_of_measurement: "%",
device_class: "humidity",
friendly_name: "Humidity",
},
},
{
entity_id: "light.kitchen_spotlights",
state: "on",
area_id: "kitchen",
attributes: {
supported_color_modes: ["brightness"],
color_mode: "brightness",
brightness: 255,
icon: "mdi:ceiling-light-multiple",
friendly_name: "Spotlights",
supported_features: 32,
},
},
{
entity_id: "light.kitchen_worktop",
state: "off",
area_id: "kitchen",
attributes: {
supported_color_modes: ["brightness"],
color_mode: null,
brightness: null,
icon: "mdi:light-flood-down",
friendly_name: "Worktop",
supported_features: 32,
},
},
{
entity_id: "switch.coffee_machine",
state: "on",
area_id: "kitchen",
attributes: {
icon: "mdi:coffee-maker",
friendly_name: "Coffee machine",
},
},
{
entity_id: "binary_sensor.kitchen_motion",
state: "off",
area_id: "kitchen",
attributes: {
device_class: "motion",
friendly_name: "Motion",
},
},
{
entity_id: "media_player.kitchen_speaker",
state: "idle",
area_id: "kitchen",
attributes: {
device_class: "speaker",
volume_level: 0.18,
is_volume_muted: false,
friendly_name: "Kitchen speaker",
supported_features: 64063,
},
},
{
entity_id: "sensor.kitchen_temperature",
state: "22.1",
area_id: "kitchen",
attributes: {
state_class: "measurement",
unit_of_measurement: "°C",
device_class: "temperature",
friendly_name: "Temperature",
},
},
{
entity_id: "sensor.kitchen_humidity",
state: "48",
area_id: "kitchen",
attributes: {
state_class: "measurement",
unit_of_measurement: "%",
device_class: "humidity",
friendly_name: "Humidity",
},
},
{
entity_id: "lock.front_door",
state: "locked",
area_id: "entrance",
attributes: {
friendly_name: "Front door",
supported_features: 1,
},
},
{
entity_id: "binary_sensor.front_door",
state: "off",
area_id: "entrance",
attributes: {
device_class: "door",
friendly_name: "Front door",
},
},
{
entity_id: "alarm_control_panel.home_alarm",
state: "disarmed",
area_id: "entrance",
attributes: {
changed_by: null,
code_arm_required: false,
friendly_name: "Home alarm",
supported_features: 3,
},
},
{
entity_id: "light.entrance_ceiling",
state: "off",
area_id: "entrance",
attributes: {
supported_color_modes: ["brightness"],
color_mode: null,
brightness: null,
friendly_name: "Ceiling light",
supported_features: 32,
},
},
{
entity_id: "sensor.front_door_lock_battery",
state: "88",
area_id: "entrance",
attributes: {
state_class: "measurement",
unit_of_measurement: "%",
device_class: "battery",
friendly_name: "Front door lock battery",
},
},
{
entity_id: "light.bedroom_ceiling",
state: "off",
area_id: "bedroom",
attributes: {
supported_color_modes: ["color_temp"],
color_mode: null,
brightness: null,
friendly_name: "Ceiling light",
supported_features: 44,
},
},
{
entity_id: "light.bedside_lamp",
state: "off",
area_id: "bedroom",
attributes: {
supported_color_modes: ["brightness"],
color_mode: null,
brightness: null,
icon: "mdi:lamp",
friendly_name: "Bedside lamp",
supported_features: 32,
},
},
{
entity_id: "cover.bedroom_shutter",
state: "open",
area_id: "bedroom",
attributes: {
current_position: 100,
device_class: "shutter",
friendly_name: "Shutter",
supported_features: 15,
},
},
{
entity_id: "media_player.bedroom_speaker",
state: "off",
area_id: "bedroom",
attributes: {
device_class: "speaker",
friendly_name: "Bedroom speaker",
supported_features: 64063,
},
},
{
entity_id: "binary_sensor.bedroom_motion",
state: "off",
area_id: "bedroom",
attributes: {
device_class: "motion",
friendly_name: "Motion",
},
},
{
entity_id: "sensor.bedroom_motion_battery",
state: "15",
area_id: "bedroom",
attributes: {
state_class: "measurement",
unit_of_measurement: "%",
device_class: "battery",
friendly_name: "Motion sensor battery",
},
},
{
entity_id: "sensor.bedroom_temperature",
state: "19.6",
area_id: "bedroom",
attributes: {
state_class: "measurement",
unit_of_measurement: "°C",
device_class: "temperature",
friendly_name: "Temperature",
},
},
{
entity_id: "sensor.bedroom_humidity",
state: "52",
area_id: "bedroom",
attributes: {
state_class: "measurement",
unit_of_measurement: "%",
device_class: "humidity",
friendly_name: "Humidity",
},
},
{
entity_id: "light.office_desk_lamp",
state: "on",
area_id: "office",
attributes: {
supported_color_modes: ["brightness"],
color_mode: "brightness",
brightness: 200,
icon: "mdi:desk-lamp",
friendly_name: "Desk lamp",
supported_features: 32,
},
},
{
entity_id: "fan.office_ceiling_fan",
state: "off",
area_id: "office",
attributes: {
percentage: 0,
percentage_step: 25,
friendly_name: "Ceiling fan",
supported_features: 1,
},
},
{
entity_id: "binary_sensor.office_window",
state: "off",
area_id: "office",
attributes: {
device_class: "window",
friendly_name: "Window",
},
},
{
entity_id: "sensor.office_temperature",
state: "21.8",
area_id: "office",
attributes: {
state_class: "measurement",
unit_of_measurement: "°C",
device_class: "temperature",
friendly_name: "Temperature",
},
},
{
entity_id: "sensor.office_humidity",
state: "50",
area_id: "office",
attributes: {
state_class: "measurement",
unit_of_measurement: "%",
device_class: "humidity",
friendly_name: "Humidity",
},
},
{
entity_id: "switch.garden_sprinkler",
state: "off",
area_id: "garden",
attributes: {
icon: "mdi:sprinkler-variant",
friendly_name: "Sprinkler",
},
},
{
entity_id: "light.garden_path",
state: "off",
area_id: "garden",
attributes: {
supported_color_modes: ["brightness"],
color_mode: null,
brightness: null,
icon: "mdi:outdoor-lamp",
friendly_name: "Path lights",
supported_features: 32,
},
},
{
entity_id: "binary_sensor.garden_gate",
state: "off",
area_id: "garden",
attributes: {
device_class: "opening",
friendly_name: "Gate",
},
},
{
entity_id: "sensor.garden_temperature",
state: "14.2",
area_id: "garden",
attributes: {
state_class: "measurement",
unit_of_measurement: "°C",
device_class: "temperature",
friendly_name: "Temperature",
},
},
{
entity_id: "sensor.garden_humidity",
state: "68",
area_id: "garden",
attributes: {
state_class: "measurement",
unit_of_measurement: "%",
device_class: "humidity",
friendly_name: "Humidity",
},
},
{
entity_id: "weather.home",
state: "partlycloudy",
area_id: "garden",
attributes: {
temperature: 14.2,
temperature_unit: "°C",
humidity: 68,
pressure: 1012,
pressure_unit: "hPa",
wind_speed: 11.2,
wind_speed_unit: "km/h",
friendly_name: "Home",
},
},
];
+17
View File
@@ -0,0 +1,17 @@
import type { DemoConfig } from "../types";
import { demoAreasHome, demoFloorsHome } from "./areas";
import { demoEntitiesHome } from "./entities";
import { demoLovelaceHome } from "./lovelace";
export const demoHome: DemoConfig = {
authorName: "Home Assistant",
authorUrl: "https://www.home-assistant.io",
name: "Home page",
description:
"The page you land on when you open Home Assistant, automatically built from the areas in your home and the devices in them.",
lovelace: demoLovelaceHome,
entities: demoEntitiesHome,
floors: demoFloorsHome,
areas: demoAreasHome,
theme: { theme: "default", dark: false },
};
+9
View File
@@ -0,0 +1,9 @@
import "./strategies";
import type { DemoConfig } from "../types";
export const demoLovelaceHome: DemoConfig["lovelace"] = () => ({
strategy: {
type: "custom:demo-home",
favorite_entities: ["lock.front_door", "switch.garden_sprinkler"],
},
});
+109
View File
@@ -0,0 +1,109 @@
import { ReactiveElement } from "lit";
import { isStrategySection } from "../../../../src/data/lovelace/config/section";
import type { LovelaceConfig } from "../../../../src/data/lovelace/config/types";
import type { LovelaceViewConfig } from "../../../../src/data/lovelace/config/view";
import { isStrategyView } from "../../../../src/data/lovelace/config/view";
import type { HomeDashboardStrategyConfig } from "../../../../src/panels/lovelace/strategies/home/home-dashboard-strategy";
import { HomeDashboardStrategy } from "../../../../src/panels/lovelace/strategies/home/home-dashboard-strategy";
import type { HomeOverviewViewStrategyConfig } from "../../../../src/panels/lovelace/strategies/home/home-overview-view-strategy";
import { HomeOverviewViewStrategy } from "../../../../src/panels/lovelace/strategies/home/home-overview-view-strategy";
import { generateLovelaceSectionStrategy } from "../../../../src/panels/lovelace/strategies/get-strategy";
import type { HomeAssistant } from "../../../../src/types";
export interface DemoHomeDashboardStrategyConfig extends Omit<
HomeDashboardStrategyConfig,
"type"
> {
type: "custom:demo-home";
}
interface DemoHomeOverviewViewStrategyConfig extends Omit<
HomeOverviewViewStrategyConfig,
"type"
> {
type: "custom:demo-home-overview";
}
class DemoHomeDashboardStrategy extends ReactiveElement {
static registryDependencies = HomeDashboardStrategy.registryDependencies;
static async generate(
config: DemoHomeDashboardStrategyConfig,
hass: HomeAssistant
): Promise<LovelaceConfig> {
const generated = await HomeDashboardStrategy.generate(
{ ...config, type: "home" },
hass
);
// Swap the overview view for the demo version, which adds the demo card.
return {
...generated,
views: generated.views.map((view) =>
isStrategyView(view) && view.strategy.type === "home-overview"
? {
...view,
strategy: { ...view.strategy, type: "custom:demo-home-overview" },
}
: view
),
};
}
}
class DemoHomeOverviewViewStrategy extends ReactiveElement {
static registryDependencies = HomeOverviewViewStrategy.registryDependencies;
static async generate(
config: DemoHomeOverviewViewStrategyConfig,
hass: HomeAssistant
): Promise<LovelaceViewConfig> {
const view = await HomeOverviewViewStrategy.generate(
{ ...config, type: "home-overview" },
hass
);
// Expand the favorites section so the demo card can be added to it
const sections = await Promise.all(
(view.sections || []).map(async (section) => {
if (
!isStrategySection(section) ||
section.strategy.type !== "common-controls"
) {
return section;
}
// The demo card takes up the space of two tiles
const limit = (section.strategy.limit as number | undefined) ?? 8;
const favorites = await generateLovelaceSectionStrategy(
{ ...section, strategy: { ...section.strategy, limit: limit - 2 } },
hass
);
const [heading, ...cards] = favorites.cards || [];
return {
...favorites,
// Place the demo card first so the tiles fill the rows next to it
cards: [heading, { type: "custom:ha-demo-next-card" }, ...cards],
};
})
);
return {
...view,
sections: sections,
};
}
}
customElements.define(
"ll-strategy-dashboard-demo-home",
DemoHomeDashboardStrategy
);
customElements.define(
"ll-strategy-view-demo-home-overview",
DemoHomeOverviewViewStrategy
);
declare global {
interface HTMLElementTagNameMap {
"ll-strategy-dashboard-demo-home": DemoHomeDashboardStrategy;
"ll-strategy-view-demo-home-overview": DemoHomeOverviewViewStrategy;
}
}
+6 -2
View File
@@ -1,8 +1,10 @@
import type { TemplateResult } from "lit";
import type { LocalizeFunc } from "../../../src/common/translations/localize";
import type { LovelaceConfig } from "../../../src/data/lovelace/config/types";
import type { LovelaceRawConfig } from "../../../src/data/lovelace/config/types";
import type { EntityInput } from "../../../src/fake_data/entities/types";
import type { ThemeSettings } from "../../../src/types";
import type { DemoArea } from "../stubs/area_registry";
import type { DemoFloor } from "../stubs/floor_registry";
export type DemoTheme = ThemeSettings | (() => Record<string, string> | null);
@@ -13,7 +15,9 @@ export interface DemoConfig {
authorUrl: string;
description?:
string | ((localize: LocalizeFunc) => string | TemplateResult<1>);
lovelace: (localize: LocalizeFunc) => LovelaceConfig;
lovelace: (localize: LocalizeFunc) => LovelaceRawConfig;
entities: (localize: LocalizeFunc) => EntityInput[];
floors?: DemoFloor[];
areas?: DemoArea[];
theme: DemoTheme;
}
+23 -18
View File
@@ -41,25 +41,30 @@ class CastDemoRow extends LitElement implements LovelaceRow {
protected firstUpdated(changedProps: PropertyValues<this>) {
super.firstUpdated(changedProps);
import("../../../src/cast/cast_manager").then(({ getCastManager }) =>
getCastManager().then((mgr) => {
this._castManager = mgr;
mgr.addEventListener("state-changed", () => {
this.requestUpdate();
});
mgr.castContext.addEventListener(
cast.framework.CastContextEventType.SESSION_STATE_CHANGED,
(ev) => {
// On Android, opening a new session always results in SESSION_RESUMED.
// So treat both as the same.
if (
ev.sessionState === "SESSION_STARTED" ||
ev.sessionState === "SESSION_RESUMED"
) {
castSendShowDemo(mgr);
getCastManager().then(
(mgr) => {
this._castManager = mgr;
mgr.addEventListener("state-changed", () => {
this.requestUpdate();
});
mgr.castContext.addEventListener(
cast.framework.CastContextEventType.SESSION_STATE_CHANGED,
(ev) => {
// On Android, opening a new session always results in SESSION_RESUMED.
// So treat both as the same.
if (
ev.sessionState === "SESSION_STARTED" ||
ev.sessionState === "SESSION_RESUMED"
) {
castSendShowDemo(mgr);
}
}
}
);
})
);
},
() => {
this._castManager = null;
}
)
);
}
+5 -9
View File
@@ -13,9 +13,9 @@ import type {
LovelaceCard,
} from "../../../src/panels/lovelace/types";
import {
demoConfigs,
demos,
selectedDemo,
selectedDemoConfig,
selectedDemoConfigIndex,
} from "../configs/demo-configs";
@customElement("ha-demo-card")
@@ -112,16 +112,12 @@ export class HADemoCard extends LitElement implements LovelaceCard {
}
private _nextConfig() {
this._updateConfig(
selectedDemoConfigIndex < demoConfigs.length - 1
? selectedDemoConfigIndex + 1
: 0
);
this._updateConfig(demos[(demos.indexOf(selectedDemo) + 1) % demos.length]);
}
private async _updateConfig(index: number) {
private async _updateConfig(demo: string) {
this._switching = true;
fireEvent(this, "set-demo-config" as any, { index });
fireEvent(this, "set-demo-config" as any, { demo });
}
static get styles(): CSSResultGroup {
+103
View File
@@ -0,0 +1,103 @@
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { until } from "lit/directives/until";
import { fireEvent } from "../../../src/common/dom/fire_event";
import "../../../src/components/ha-card";
import "../../../src/components/ha-control-button";
import "../../../src/components/ha-control-button-group";
import "../../../src/components/tile/ha-tile-container";
import "../../../src/components/tile/ha-tile-icon";
import "../../../src/components/tile/ha-tile-info";
import type { LovelaceCardConfig } from "../../../src/data/lovelace/config/card";
import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
import { tileCardStyle } from "../../../src/panels/lovelace/cards/tile/tile-card-style";
import type {
LovelaceCard,
LovelaceGridOptions,
} from "../../../src/panels/lovelace/types";
import {
demos,
selectedDemo,
selectedDemoConfig,
} from "../configs/demo-configs";
@customElement("ha-demo-next-card")
export class HADemoNextCard extends LitElement implements LovelaceCard {
@property({ attribute: false }) public hass!: MockHomeAssistant;
@state() private _switching = false;
public getCardSize() {
return 2;
}
public getGridOptions(): LovelaceGridOptions {
return {
columns: 6,
rows: 2,
min_columns: 6,
min_rows: 2,
};
}
// eslint-disable-next-line @typescript-eslint/no-empty-function
public setConfig(_config: LovelaceCardConfig) {}
protected render() {
return html`
<ha-card>
<ha-tile-container>
<ha-tile-icon slot="icon" icon="mdi:home-assistant"></ha-tile-icon>
<ha-tile-info slot="info">
<span slot="primary">
${until(
selectedDemoConfig.then((conf) => conf.name),
nothing
)}
</span>
<span slot="secondary">
${this.hass.localize(
"ui.panel.page-demo.cards.demo.interactive_demo"
)}
</span>
</ha-tile-info>
<ha-control-button-group slot="features">
<ha-control-button
.disabled=${this._switching}
@click=${this._nextConfig}
>
${this.hass.localize("ui.panel.page-demo.cards.demo.next_demo")}
</ha-control-button>
</ha-control-button-group>
</ha-tile-container>
</ha-card>
`;
}
private _nextConfig() {
this._switching = true;
fireEvent(this, "set-demo-config" as any, {
demo: demos[(demos.indexOf(selectedDemo) + 1) % demos.length],
});
}
static styles = [
tileCardStyle,
css`
:host {
--tile-color: var(--primary-color);
}
ha-control-button-group {
--control-button-group-spacing: 0;
--control-button-group-thickness: var(--feature-height, 42px);
padding: 0 var(--ha-space-3) var(--ha-space-3);
}
`,
];
}
declare global {
interface HTMLElementTagNameMap {
"ha-demo-next-card": HADemoNextCard;
}
}
+20 -7
View File
@@ -6,7 +6,7 @@ import { provideHass } from "../../src/fake_data/provide_hass";
import { HomeAssistantAppEl } from "../../src/layouts/home-assistant";
import type { HomeAssistant } from "../../src/types";
import { applyDemoTheme, selectedDemoConfig } from "./configs/demo-configs";
import { mockAreaRegistry } from "./stubs/area_registry";
import { mockAreaRegistry, setDemoAreas } from "./stubs/area_registry";
import { mockAuth } from "./stubs/auth";
import { demoDevices } from "./stubs/devices";
import { mockDeviceRegistry } from "./stubs/device_registry";
@@ -14,8 +14,10 @@ import { mockEnergy } from "./stubs/energy";
import { energyEntities } from "./stubs/entities";
import { mockEntityRegistry } from "./stubs/entity_registry";
import { mockEvents } from "./stubs/events";
import { mockFloorRegistry } from "./stubs/floor_registry";
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";
@@ -29,6 +31,7 @@ import { mockSystemLog } from "./stubs/system_log";
import { mockTemplate } from "./stubs/template";
import { mockTodo } from "./stubs/todo";
import { mockTranslations } from "./stubs/translations";
import { mockUsagePrediction } from "./stubs/usage_prediction";
import "./cloud/cloud-demo-controls";
// WS command / REST path prefixes whose mocks live in the lazily imported
@@ -72,13 +75,18 @@ 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.
hass.updateHass({
config: {
...hass.config,
components: [...(hass.config?.components ?? []), "backup", "webhook"],
components: [
...(hass.config?.components ?? []),
"backup",
"webhook",
"usage_prediction",
"assist_pipeline",
"hassio",
"hardware",
],
},
});
@@ -105,6 +113,8 @@ export class HaDemo extends HomeAssistantAppEl {
mockEvents(hass);
mockMediaPlayer(hass);
mockFrontend(hass);
mockHardware(hass);
mockHassioSupervisor(hass);
mockIcons(hass);
mockEnergy(hass);
mockPersistentNotification(hass);
@@ -122,6 +132,7 @@ export class HaDemo extends HomeAssistantAppEl {
mockDeviceRegistry(hass, demoDevices);
mockFloorRegistry(hass);
mockLabelRegistry(hass);
mockUsagePrediction(hass);
mockEntityRegistry(hass, [
{
config_entry_id: "co2signal",
@@ -169,9 +180,11 @@ export class HaDemo extends HomeAssistantAppEl {
hass.addEntities(energyEntities());
// Once config is loaded AND localize, set entities and apply theme.
// Once config is loaded AND localize, set registries, entities and theme.
Promise.all([selectedDemoConfig, localizePromise]).then(
([conf, localize]) => {
setDemoFloors(hass, conf.floors);
setDemoAreas(hass, conf.areas);
hass.addEntities(conf.entities(localize));
applyDemoTheme(hass, conf.theme);
}
+59 -10
View File
@@ -56,15 +56,40 @@
padding: 0;
}
#ha-launch-screen {
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 svg {
width: 112px;
#ha-launch-screen.removing {
opacity: 0;
}
@keyframes launch-lockup-in {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
#ha-launch-screen .ha-lockup {
display: flex;
align-items: center;
flex-shrink: 0;
animation: launch-lockup-in var(--ha-animation-duration-normal, 250ms) ease-out both;
}
#ha-launch-screen-info-box > *,
#ha-launch-screen .ohf-logo {
animation: launch-lockup-in var(--ha-animation-duration-normal, 250ms) ease-out both;
}
#ha-launch-screen .ha-logo {
width: 96px;
height: 96px;
}
#ha-launch-screen .ha-launch-screen-spacer-top {
flex: 1;
@@ -73,18 +98,39 @@
}
#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;
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
opacity: .66;
}
@media (prefers-color-scheme: dark) {
.ohf-logo span {
font-size: 12px;
line-height: 12px;
text-transform: uppercase;
}
.ohf-logo picture {
display: flex;
}
.ohf-logo img {
width: 237px;
aspect-ratio: 237 / 24;
height: auto;
}
@media (max-height: 560px) {
#ha-launch-screen .ha-launch-screen-spacer-top {
margin-top: 24px;
padding-top: 24px;
}
#ha-launch-screen .ha-launch-screen-spacer-bottom {
padding-top: 16px;
}
.ohf-logo {
filter: invert(1);
margin-block: 24px;
}
}
</style>
@@ -92,13 +138,16 @@
<body>
<div id="ha-launch-screen">
<div class="ha-launch-screen-spacer-top"></div>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 240 240">
<path fill="#18BCF2" d="M240 224.762a15 15 0 0 1-15 15H15a15 15 0 0 1-15-15v-90c0-8.25 4.77-19.769 10.61-25.609l98.78-98.7805c5.83-5.83 15.38-5.83 21.21 0l98.79 98.7895c5.83 5.83 10.61 17.36 10.61 25.61v90-.01Z"/>
<path fill="#F2F4F9" d="m107.27 239.762-40.63-40.63c-2.09.72-4.32 1.13-6.64 1.13-11.3 0-20.5-9.2-20.5-20.5s9.2-20.5 20.5-20.5 20.5 9.2 20.5 20.5c0 2.33-.41 4.56-1.13 6.65l31.63 31.63v-115.88c-6.8-3.3395-11.5-10.3195-11.5-18.3895 0-11.3 9.2-20.5 20.5-20.5s20.5 9.2 20.5 20.5c0 8.07-4.7 15.05-11.5 18.3895v81.27l31.46-31.46c-.62-1.96-.96-4.04-.96-6.2 0-11.3 9.2-20.5 20.5-20.5s20.5 9.2 20.5 20.5-9.2 20.5-20.5 20.5c-2.5 0-4.88-.47-7.09-1.29L129 208.892v30.88z"/>
</svg>
<div class="ha-lockup">
<img class="ha-logo" src="/static/images/home-assistant-logo-loading.svg" alt="Home Assistant">
</div>
<div id="ha-launch-screen-info-box" class="ha-launch-screen-spacer-bottom"></div>
<div class="ohf-logo">
<img src="/static/images/ohf-badge.svg" alt="Home Assistant is a project by the Open Home Foundation" height="46">
<span id="ha-launch-screen-attribution">A project from the</span>
<picture>
<source media="(prefers-color-scheme: dark)" srcset="/static/images/open-home-foundation-on-dark.svg">
<img src="/static/images/open-home-foundation-on-light.svg" alt="Open Home Foundation" width="237" height="24">
</picture>
</div>
</div>
<ha-demo></ha-demo>
+39 -7
View File
@@ -1,14 +1,46 @@
import type { AreaRegistryEntry } from "../../../src/data/area/area_registry";
import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
export interface DemoArea {
area_id: string;
name: string;
floor_id?: string;
icon?: string;
temperature_entity_id?: string;
humidity_entity_id?: string;
}
let areas: AreaRegistryEntry[] = [];
export const mockAreaRegistry = (
hass: MockHomeAssistant,
data: AreaRegistryEntry[] = []
data: DemoArea[] = []
) => {
hass.mockWS("config/area_registry/list", () => data);
const areas = {};
data.forEach((area) => {
areas[area.area_id] = area;
});
hass.updateHass({ areas });
hass.mockWS("config/area_registry/list", () => areas);
setDemoAreas(hass, data);
};
/** Set the areas of the currently loaded demo config. */
export const setDemoAreas = (
hass: MockHomeAssistant,
demoAreas: DemoArea[] = []
) => {
areas = demoAreas.map((area) => ({
area_id: area.area_id,
name: area.name,
floor_id: area.floor_id ?? null,
icon: area.icon ?? null,
temperature_entity_id: area.temperature_entity_id ?? null,
humidity_entity_id: area.humidity_entity_id ?? null,
aliases: [],
labels: [],
picture: null,
created_at: 0,
modified_at: 0,
}));
const areasById: Record<string, AreaRegistryEntry> = {};
areas.forEach((area) => {
areasById[area.area_id] = area;
});
hass.updateHass({ areas: areasById });
};
+18 -4
View File
@@ -19,17 +19,31 @@ const pipelines: AssistPipeline[] = [
{
id: "01local",
name: "Local",
language: "en",
language: "nl",
conversation_engine: "conversation.home_assistant",
conversation_language: "en",
conversation_language: "nl",
stt_engine: "stt.faster_whisper",
stt_language: "en",
stt_language: "nl",
tts_engine: "tts.piper",
tts_language: "en",
tts_language: "nl",
tts_voice: null,
wake_word_entity: null,
wake_word_id: null,
},
{
id: "01chatgpt",
name: "ChatGPT",
language: "es",
conversation_engine: "conversation.chatgpt",
conversation_language: "es",
stt_engine: "cloud",
stt_language: "es-ES",
tts_engine: "cloud",
tts_language: "es-ES",
tts_voice: "ElviraNeural",
wake_word_entity: null,
wake_word_id: null,
},
];
export const mockAssist = (hass: MockHomeAssistant) => {
+1 -1
View File
@@ -85,7 +85,7 @@ export const energyEntities = () =>
},
},
"sensor.energy_consumption_tarif_1": {
entity_id: "sensor.energy_consumption_tarif_1 ",
entity_id: "sensor.energy_consumption_tarif_1",
state: "88.6",
attributes: {
last_reset: "1970-01-01T00:00:00:00+00",
+35 -2
View File
@@ -1,7 +1,40 @@
import type { FloorRegistryEntry } from "../../../src/data/floor_registry";
import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
export interface DemoFloor {
floor_id: string;
name: string;
level?: number;
icon?: string;
}
let floors: FloorRegistryEntry[] = [];
export const mockFloorRegistry = (
hass: MockHomeAssistant,
data: FloorRegistryEntry[] = []
) => hass.mockWS("config/floor_registry/list", () => data);
data: DemoFloor[] = []
) => {
hass.mockWS("config/floor_registry/list", () => floors);
setDemoFloors(hass, data);
};
/** Set the floors of the currently loaded demo config. */
export const setDemoFloors = (
hass: MockHomeAssistant,
demoFloors: DemoFloor[] = []
) => {
floors = demoFloors.map((floor) => ({
floor_id: floor.floor_id,
name: floor.name,
level: floor.level ?? null,
icon: floor.icon ?? null,
aliases: [],
created_at: 0,
modified_at: 0,
}));
const floorsById: Record<string, FloorRegistryEntry> = {};
floors.forEach((floor) => {
floorsById[floor.floor_id] = floor;
});
hass.updateHass({ floors: floorsById });
};
+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" },
});
});
};
+5 -4
View File
@@ -2,12 +2,13 @@ import type { LocalizeFunc } from "../../../src/common/translations/localize";
import type { LovelaceInfo } from "../../../src/data/lovelace/resource";
import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
import {
selectedDemo,
selectedDemoConfig,
selectedDemoConfigIndex,
setDemoConfig,
} from "../configs/demo-configs";
import "../custom-cards/cast-demo-row";
import "../custom-cards/ha-demo-card";
import "../custom-cards/ha-demo-next-card";
import { mapEntities } from "./entities";
export const mockLovelace = (
@@ -45,11 +46,11 @@ customElements.whenDefined("hui-root").then(() => {
HUIRoot.prototype.firstUpdated = function (changedProperties) {
oldFirstUpdated.call(this, changedProperties);
this.addEventListener("set-demo-config", async (ev) => {
const index = (ev as CustomEvent).detail.index;
const demo = (ev as CustomEvent).detail.demo;
try {
await setDemoConfig(this.hass, this.lovelace!, index);
await setDemoConfig(this.hass, this.lovelace!, demo);
} catch (_err: any) {
setDemoConfig(this.hass, this.lovelace!, selectedDemoConfigIndex);
setDemoConfig(this.hass, this.lovelace!, selectedDemo);
alert("Failed to switch config :-(");
}
});
+19
View File
@@ -0,0 +1,19 @@
import type { CommonControlsResult } from "../../../src/data/usage_prediction";
import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
export const mockUsagePrediction = (hass: MockHomeAssistant) => {
// Entities that don't exist in the currently loaded demo config are
// filtered out by the common-controls section strategy.
hass.mockWS("usage_prediction/common_control", (): CommonControlsResult => ({
entities: [
"light.living_room_floor_lamp",
"climate.living_room",
"cover.living_room_blinds",
"media_player.living_room_speaker",
"light.kitchen_spotlights",
"switch.coffee_machine",
"light.bedside_lamp",
"alarm_control_panel.home_alarm",
],
}));
};
+5 -28
View File
@@ -2,7 +2,10 @@
import type { TemplateResult } from "lit";
import { html, LitElement } from "lit";
import { customElement, state } from "lit/decorators";
import { mockAreaRegistry } from "../../../../demo/src/stubs/area_registry";
import {
mockAreaRegistry,
type DemoArea,
} from "../../../../demo/src/stubs/area_registry";
import { mockConfigEntries } from "../../../../demo/src/stubs/config_entries";
import { mockDeviceRegistry } from "../../../../demo/src/stubs/device_registry";
import { mockEntityRegistry } from "../../../../demo/src/stubs/entity_registry";
@@ -10,7 +13,6 @@ import { mockHassioSupervisor } from "../../../../demo/src/stubs/hassio_supervis
import { computeInitialHaFormData } from "../../../../src/components/ha-form/compute-initial-ha-form-data";
import "../../../../src/components/ha-form/ha-form";
import type { HaFormSchema } from "../../../../src/components/ha-form/types";
import type { AreaRegistryEntry } from "../../../../src/data/area/area_registry";
import type { DeviceRegistryEntry } from "../../../../src/data/device/device_registry";
import { provideHass } from "../../../../src/fake_data/provide_hass";
import type { HomeAssistant } from "../../../../src/types";
@@ -136,45 +138,20 @@ const DEVICES: DeviceRegistryEntry[] = [
},
];
const AREAS: AreaRegistryEntry[] = [
const AREAS: DemoArea[] = [
{
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,
},
{
area_id: "livingroom",
floor_id: null,
name: "Livingroom",
icon: "mdi:sofa",
picture: null,
aliases: [],
labels: [],
temperature_entity_id: null,
humidity_entity_id: null,
created_at: 0,
modified_at: 0,
},
];
@@ -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;
}
}
+10 -38
View File
@@ -1,21 +1,25 @@
import type { TemplateResult } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, state } from "lit/decorators";
import { mockAreaRegistry } from "../../../../demo/src/stubs/area_registry";
import {
mockAreaRegistry,
type DemoArea,
} from "../../../../demo/src/stubs/area_registry";
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 { mockFloorRegistry } from "../../../../demo/src/stubs/floor_registry";
import {
mockFloorRegistry,
type DemoFloor,
} from "../../../../demo/src/stubs/floor_registry";
import { mockHassioSupervisor } from "../../../../demo/src/stubs/hassio_supervisor";
import { mockLabelRegistry } from "../../../../demo/src/stubs/label_registry";
import type { HASSDomEvent } from "../../../../src/common/dom/fire_event";
import "../../../../src/components/ha-formfield";
import "../../../../src/components/ha-selector/ha-selector";
import "../../../../src/components/ha-settings-row";
import type { AreaRegistryEntry } from "../../../../src/data/area/area_registry";
import type { BlueprintInput } from "../../../../src/data/blueprint";
import type { DeviceRegistryEntry } from "../../../../src/data/device/device_registry";
import type { FloorRegistryEntry } from "../../../../src/data/floor_registry";
import type { LabelRegistryEntry } from "../../../../src/data/label/label_registry";
import {
showDialog,
@@ -147,75 +151,43 @@ const DEVICES: DeviceRegistryEntry[] = [
},
];
const AREAS: AreaRegistryEntry[] = [
const AREAS: DemoArea[] = [
{
area_id: "backyard",
floor_id: "ground",
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: "first",
name: "Bedroom",
icon: "mdi:bed",
picture: null,
aliases: [],
labels: [],
temperature_entity_id: null,
humidity_entity_id: null,
created_at: 0,
modified_at: 0,
},
{
area_id: "livingroom",
floor_id: "ground",
name: "Livingroom",
icon: "mdi:sofa",
picture: null,
aliases: [],
labels: [],
temperature_entity_id: null,
humidity_entity_id: null,
created_at: 0,
modified_at: 0,
},
];
const FLOORS: FloorRegistryEntry[] = [
const FLOORS: DemoFloor[] = [
{
floor_id: "ground",
name: "Ground floor",
level: 0,
icon: null,
aliases: [],
created_at: 0,
modified_at: 0,
},
{
floor_id: "first",
name: "First floor",
level: 1,
icon: "mdi:numeric-1",
aliases: [],
created_at: 0,
modified_at: 0,
},
{
floor_id: "second",
name: "Second floor",
level: 2,
icon: "mdi:numeric-2",
aliases: [],
created_at: 0,
modified_at: 0,
},
];
+18 -11
View File
@@ -182,12 +182,10 @@ class HaLandingPage extends LandingPageBaseElement {
this._networkInfoError = false;
this._coreStatusChecked = false;
} catch (err: any) {
if (!this._coreStatusChecked) {
// wait before show errors, because we assume that core is starting
this._coreCheckActive = true;
this._scheduleTurnOffCoreCheck();
if (await this._checkCoreAvailability()) {
// core is available, page reload in progress -> don't show an error
return;
}
await this._checkCoreAvailability();
// assume supervisor update if ping fails -> don't show an error
if (!this._coreCheckActive && err.message !== "ping-failed") {
@@ -217,7 +215,10 @@ class HaLandingPage extends LandingPageBaseElement {
this._progress = -1;
}
} catch (err: any) {
await this._checkCoreAvailability();
if (await this._checkCoreAvailability()) {
// core is available, page reload in progress -> stop polling
return;
}
if (!this._coreCheckActive) {
this._progress = -1;
@@ -229,16 +230,22 @@ class HaLandingPage extends LandingPageBaseElement {
this._scheduleFetchSupervisorJobsInfo();
}
private async _checkCoreAvailability() {
private async _checkCoreAvailability(): Promise<boolean> {
try {
const response = await fetch("/manifest.json");
if (response.ok) {
location.reload();
} else {
if (!response.ok) {
throw new Error("Failed to fetch manifest");
}
location.reload();
return true;
} catch (_err) {
this._coreStatusChecked = true;
if (!this._coreStatusChecked) {
// wait before showing errors, because we assume that core is starting
this._coreStatusChecked = true;
this._coreCheckActive = true;
this._scheduleTurnOffCoreCheck();
}
return false;
}
}
+20 -21
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",
@@ -49,7 +49,7 @@
"@codemirror/lint": "6.9.7",
"@codemirror/search": "6.7.1",
"@codemirror/state": "6.7.1",
"@codemirror/view": "6.43.6",
"@codemirror/view": "6.43.7",
"@date-fns/tz": "1.5.0",
"@egjs/hammerjs": "2.0.17",
"@formatjs/intl-datetimeformat": "7.5.2",
@@ -91,7 +91,7 @@
"@webcomponents/webcomponentsjs": "2.8.0",
"barcode-detector": "3.2.1",
"cally": "0.9.2",
"color-name": "2.1.0",
"color-name": "2.1.1",
"comlink": "4.4.2",
"core-js": "3.49.0",
"cropperjs": "1.6.2",
@@ -108,14 +108,14 @@
"home-assistant-js-websocket": "9.6.0",
"idb-keyval": "6.3.0",
"intl-messageformat": "11.2.12",
"js-yaml": "5.2.1",
"js-yaml": "5.2.2",
"leaflet": "1.9.4",
"leaflet-draw": "patch:leaflet-draw@npm%3A1.0.4#./.yarn/patches/leaflet-draw-npm-1.0.4-0ca0ebcf65.patch",
"leaflet.markercluster": "1.5.3",
"lit": "3.3.3",
"lit-html": "3.3.3",
"luxon": "3.7.2",
"marked": "18.0.6",
"marked": "18.0.7",
"memoize-one": "6.0.0",
"node-vibrant": "4.0.4",
"object-hash": "3.0.0",
@@ -150,9 +150,9 @@
"@octokit/auth-oauth-device": "8.0.3",
"@octokit/plugin-retry": "8.1.0",
"@octokit/rest": "22.0.1",
"@playwright/test": "1.61.1",
"@rsdoctor/rspack-plugin": "1.6.0",
"@rspack/core": "2.1.4",
"@playwright/test": "1.62.0",
"@rsdoctor/rspack-plugin": "1.6.1",
"@rspack/core": "2.1.6",
"@rspack/dev-server": "2.1.0",
"@types/babel__plugin-transform-runtime": "7.9.5",
"@types/chromecast-caf-receiver": "6.0.26",
@@ -174,7 +174,7 @@
"babel-plugin-polyfill-corejs3": "1.0.0",
"browserslist-useragent-regexp": "4.1.4",
"del": "8.0.1",
"eslint": "10.7.0",
"eslint": "10.8.0",
"eslint-config-prettier": "10.1.8",
"eslint-import-resolver-webpack": "0.13.11",
"eslint-plugin-import-x": "4.17.1",
@@ -183,35 +183,35 @@
"eslint-plugin-unused-imports": "4.4.1",
"eslint-plugin-wc": "3.1.0",
"fancy-log": "2.0.0",
"fs-extra": "11.3.6",
"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.1.0",
"lint-staged": "17.2.0",
"lit-analyzer": "2.0.3",
"lodash.merge": "4.6.2",
"lodash.template": "4.18.1",
"map-stream": "0.0.7",
"minify-literals": "2.1.0",
"pinst": "3.0.0",
"prettier": "3.9.5",
"prettier": "3.9.6",
"rspack-manifest-plugin": "5.2.2",
"serve": "14.2.6",
"sinon": "22.0.0",
"tar": "7.5.20",
"sinon": "22.1.0",
"tar": "7.5.22",
"terser-webpack-plugin": "5.6.1",
"ts-lit-plugin": "2.0.2",
"typescript": "6.0.3",
"typescript-eslint": "8.64.0",
"typescript-eslint": "8.65.0",
"vite-tsconfig-paths": "6.1.1",
"vitest": "4.1.10",
"webpack-stats-plugin": "1.1.3",
@@ -224,13 +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",
"glob@^10.2.2": "^10.5.0"
"@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"
}
}
@@ -0,0 +1,36 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 120 120" role="presentation">
<style>
.dot-left { animation: pulse-left 1300ms 350ms linear infinite; }
.dot-right { animation: pulse-right 1300ms 350ms linear infinite; }
.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.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.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.3); animation-timing-function: cubic-bezier(.39, .575, .565, 1); }
69.230769%, 100% { transform: translate(70px, 37.6348px) scale(1); }
}
</style>
<g transform="matrix(.938408 0 0 .93841 -5.688557 -13.572944)">
<path fill="#18bcf2" d="M73.5367 13.0937 106.463 46.0524v.0033C108.41 48.0043 110 51.848 110 54.6007v30.0292c0 2.7527-2.25 5.0049-5 5.0049l-70-.0034c-2.75 0-5-2.2522-5-5.0048V54.5974c0-2.7527 1.5933-6.5998 3.5367-8.545l32.93-32.9587c1.9433-1.9452 5.1266-1.9452 7.07 0Z" transform="matrix(1.598452 0 0 1.598452 -41.89164 -.937304)"/>
<g mask="url(#logo-mask)" transform="matrix(1.598452 0 0 1.598452 -41.89164 -.937311)">
<path d="m70 89.6348-20-20M70 89.6348v-52M90 58.1348l-20 20" fill="none" stroke="#f2f4f9" stroke-linecap="round" stroke-width="6"/>
<circle class="dot-left" r="7" fill="#f2f4f9" transform="translate(50 69.634804)"/>
<circle class="dot-right" r="7" fill="#f2f4f9" transform="translate(90 58.634798)"/>
<circle class="dot-top" r="7" fill="#f2f4f9" transform="translate(70 37.6348)"/>
</g>
<mask id="logo-mask" x="-150%" y="-150%" width="400%" height="400%" mask-type="luminance">
<path fill="#f2f4f9" d="M73.5367 13.0937 106.463 46.0524v.0033C108.41 48.0043 110 51.848 110 54.6007v30.0292c0 2.7527-2.25 5.0049-5 5.0049l-70-.0034c-2.75 0-5-2.2522-5-5.0048V54.5974c0-2.7527 1.5933-6.5998 3.5367-8.545l32.93-32.9587c1.9433-1.9452 5.1266-1.9452 7.07 0Z"/>
</mask>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.7 KiB

@@ -0,0 +1,22 @@
<svg width="237" height="24" viewBox="0 0 237 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M8.54181 0.297615C8.14709 -0.0992051 7.5012 -0.0992051 7.1082 0.297615L0.717655 6.72232C0.322945 7.11914 0 7.90248 0 8.46249V14.2894C0 14.8494 0.456224 15.3098 1.01497 15.3098H14.635C15.1921 15.3098 15.65 14.8511 15.65 14.2894V8.46249C15.65 7.90248 15.3271 7.11914 14.9324 6.72232L8.54181 0.297615Z" fill="#F7F6F2"/>
<path d="M14.8672 17.918C15.2993 17.918 15.6502 18.2682 15.6504 18.7002V22.417C15.6504 22.8492 15.2994 23.2002 14.8672 23.2002H13.8896C13.4575 23.2002 13.1064 22.8492 13.1064 22.417V20.4609H2.54297V22.417C2.54297 22.8491 2.19285 23.2001 1.76074 23.2002H0.782227C0.350189 23.2 0 22.8491 0 22.417V18.7002C0.000147852 18.2682 0.350281 17.9181 0.782227 17.918H14.8672Z" fill="#F7F6F2"/>
<path d="M28.0439 5.24023C28.9236 5.24023 29.7386 5.39857 30.4891 5.71734C31.2396 6.03402 31.8754 6.47362 32.3965 7.03406C32.9177 7.5945 33.3242 8.26536 33.616 9.05081C33.9058 9.83625 34.0517 10.6842 34.0517 11.5967C34.0621 12.4989 33.9204 13.3447 33.6285 14.1385C33.3346 14.9323 32.9239 15.6094 32.3965 16.1719C31.8691 16.7344 31.227 17.1761 30.4724 17.4949C29.7178 17.8157 28.9027 17.9699 28.0272 17.9595C26.8702 17.9762 25.83 17.7095 24.9086 17.1616C23.9872 16.6136 23.2764 15.8532 22.7761 14.8802C22.2757 13.9073 22.0339 12.8176 22.0506 11.6134C22.0402 10.7113 22.1819 9.86542 22.4738 9.07164C22.7677 8.27786 23.1763 7.60075 23.7016 7.03406C24.2269 6.46945 24.8648 6.02568 25.6174 5.70692C26.3699 5.38816 27.1787 5.2319 28.0439 5.24232V5.24023ZM28.0584 15.524C29.0966 15.524 29.9179 15.1719 30.5245 14.4698C31.1312 13.7656 31.4334 12.8093 31.4334 11.5967C31.4334 10.3842 31.1312 9.41332 30.5287 8.71121C29.9262 8.01118 29.1028 7.66117 28.0584 7.66117C27.0141 7.66117 26.1906 8.01118 25.5882 8.71121C24.9857 9.41124 24.6835 10.3738 24.6835 11.5967C24.6835 12.8197 24.9857 13.7802 25.5882 14.4781C26.1906 15.1761 27.0141 15.5261 28.0584 15.5261V15.524Z" fill="#F7F6F2"/>
<path d="M44.9854 9.52582C44.9854 10.7592 44.6123 11.7509 43.8681 12.503C43.1239 13.2551 42.1108 13.6322 40.8267 13.6322H38.3898V17.7553H35.9028V5.45899H40.86C42.1483 5.45899 43.1572 5.82151 43.8889 6.54654C44.6206 7.27157 44.9854 8.26536 44.9854 9.52374V9.52582ZM42.3588 9.47791C42.3588 8.95705 42.19 8.52578 41.8502 8.18619C41.5104 7.84659 41.0205 7.67783 40.3785 7.67783H38.3898V11.4676H40.3785C41.0247 11.4676 41.5167 11.2842 41.8544 10.9175C42.1921 10.5509 42.3588 10.0717 42.3588 9.47999V9.47791Z" fill="#F7F6F2"/>
<path d="M54.4829 17.7553H46.7386V5.45899H54.4829V7.82159H49.2234V10.4446H53.895V12.6447H49.2234V15.4156H54.4829V17.7553Z" fill="#F7F6F2"/>
<path d="M67.397 5.46108V17.7574H64.9268L59.5381 9.42999V17.7574H57.0678V5.46108H59.5381L64.9268 13.8052V5.46108H67.397Z" fill="#F7F6F2"/>
<path d="M74.4409 5.46108H76.9279V10.4321L81.8037 10.4488V5.46108H84.3074V17.7574H81.8037V12.6655L76.9279 12.6322V17.7574H74.4409V5.46108Z" fill="#F7F6F2"/>
<path d="M92.133 5.24023C93.0127 5.24023 93.8278 5.39857 94.5782 5.71734C95.3287 6.03402 95.9645 6.47362 96.4856 7.03406C97.0068 7.5945 97.4133 8.26536 97.7051 9.05081C97.9949 9.83625 98.1408 10.6842 98.1408 11.5967C98.1512 12.4989 98.0095 13.3447 97.7176 14.1385C97.4237 14.9323 97.013 15.6094 96.4856 16.1719C95.9582 16.7344 95.3162 17.1761 94.5615 17.4949C93.8069 17.8157 92.9918 17.9699 92.1163 17.9595C90.9593 17.9762 89.9191 17.7095 88.9977 17.1616C88.0763 16.6136 87.3655 15.8532 86.8652 14.8802C86.3649 13.9073 86.1231 12.8176 86.1397 11.6134C86.1293 10.7113 86.2711 9.86542 86.5629 9.07164C86.8568 8.27786 87.2654 7.60075 87.7907 7.03406C88.3161 6.46945 88.954 6.02568 89.7065 5.70692C90.459 5.38816 91.2679 5.2319 92.133 5.24232V5.24023ZM92.1476 15.524C93.1857 15.524 94.007 15.1719 94.6137 14.4698C95.2203 13.7656 95.5225 12.8093 95.5225 11.5967C95.5225 10.3842 95.2203 9.41332 94.6178 8.71121C94.0154 8.01118 93.192 7.66117 92.1476 7.66117C91.1032 7.66117 90.2798 8.01118 89.6773 8.71121C89.0749 9.41124 88.7726 10.3738 88.7726 11.5967C88.7726 12.8197 89.0749 13.7802 89.6773 14.4781C90.2798 15.1761 91.1032 15.5261 92.1476 15.5261V15.524Z" fill="#F7F6F2"/>
<path d="M112.408 5.46108V17.7574H109.954V11.0342L107.321 17.7574H104.966L102.364 11.0842V17.7574H99.9919V5.46108H102.364L106.148 14.8552L109.954 5.46108H112.408Z" fill="#F7F6F2"/>
<path d="M122.762 17.7553H115.018V5.45899H122.762V7.82159H117.503V10.4446H122.174V12.6447H117.503V15.4156H122.762V17.7553Z" fill="#F7F6F2"/>
<path d="M131.526 7.21579V10.9075H136.254V12.5936H131.526V17.7408H129.65V5.44079H137.146V7.21579H131.526Z" fill="#F7F6F2"/>
<path d="M144.303 5.24079C145.156 5.22968 145.956 5.38523 146.699 5.70468C147.441 6.02412 148.074 6.46579 148.597 7.02968C149.117 7.59357 149.525 8.27135 149.82 9.05746C150.114 9.84635 150.256 10.688 150.245 11.5852C150.256 12.488 150.114 13.338 149.82 14.1297C149.525 14.9241 149.117 15.5991 148.597 16.163C148.077 16.7241 147.443 17.1658 146.699 17.4852C145.954 17.8047 145.156 17.9602 144.303 17.9491C143.449 17.9602 142.652 17.8047 141.91 17.4852C141.168 17.1658 140.537 16.7241 140.017 16.1602C139.497 15.5963 139.089 14.9186 138.797 14.1325C138.505 13.3463 138.363 12.5019 138.374 11.6047C138.363 10.7075 138.505 9.86301 138.797 9.07412C139.089 8.28523 139.494 7.60746 140.017 7.04079C140.537 6.4769 141.168 6.03246 141.91 5.71023C142.652 5.38801 143.449 5.23246 144.303 5.24357V5.24079ZM141.44 14.8602C142.165 15.6825 143.124 16.0963 144.319 16.0963C145.515 16.0963 146.473 15.6852 147.196 14.8602C147.919 14.038 148.28 12.9463 148.28 11.5852C148.28 10.2241 147.919 9.12412 147.196 8.3019C146.473 7.47968 145.515 7.06579 144.319 7.06579C143.124 7.06579 142.163 7.47968 141.44 8.30468C140.715 9.12968 140.353 10.2241 140.353 11.5852C140.353 12.9463 140.715 14.038 141.44 14.8602Z" fill="#F7F6F2"/>
<path d="M154.097 5.43801V13.3019C154.103 14.238 154.378 14.9463 154.923 15.4241C155.467 15.9047 156.184 16.1436 157.071 16.1436C157.958 16.1436 158.636 15.8908 159.203 15.388C159.77 14.8852 160.053 14.188 160.053 13.3019V5.43801H161.929V13.3769C161.929 14.0991 161.799 14.7519 161.54 15.338C161.282 15.9241 160.929 16.4075 160.487 16.7825C160.042 17.1602 159.531 17.4491 158.944 17.6547C158.358 17.8602 157.735 17.9602 157.068 17.9602C156.401 17.9602 155.804 17.8602 155.226 17.663C154.647 17.4658 154.131 17.1797 153.68 16.8075C153.227 16.4352 152.871 15.9547 152.61 15.363C152.349 14.7713 152.218 14.1075 152.218 13.3769V5.43801H154.097Z" fill="#F7F6F2"/>
<path d="M174.473 5.43801V17.738H172.597L166.529 8.46857V17.738H164.653V5.43801H166.529L172.597 14.7241V5.43801H174.473Z" fill="#F7F6F2"/>
<path d="M187.583 11.6019C187.588 12.4825 187.436 13.3102 187.124 14.0797C186.813 14.8519 186.382 15.5075 185.835 16.0463C185.287 16.5852 184.628 17.0075 183.859 17.3102C183.089 17.613 182.269 17.7575 181.391 17.7408H177.291V5.44079H181.391C182.547 5.42412 183.603 5.67968 184.553 6.21023C185.504 6.73801 186.249 7.4769 186.788 8.42412C187.327 9.37135 187.591 10.4325 187.583 11.6047V11.6019ZM185.59 11.6019C185.59 10.3019 185.204 9.24635 184.428 8.43523C183.653 7.62412 182.636 7.21579 181.374 7.21579H179.184V15.9963H181.374C182.647 15.9963 183.667 15.5936 184.437 14.7852C185.207 13.9797 185.59 12.9158 185.59 11.6019Z" fill="#F7F6F2"/>
<path d="M195.821 14.7825H190.91L189.843 17.7408H187.916L192.363 5.44079H194.381L198.853 17.7408H196.869L195.818 14.7825H195.821ZM195.276 13.188L193.383 7.8269L191.474 13.188H195.276Z" fill="#F7F6F2"/>
<path d="M207.278 7.21579H203.706V17.7408H201.813V7.21579H198.242V5.44079H207.275V7.21579H207.278Z" fill="#F7F6F2"/>
<path d="M208.973 17.738V5.43801H210.866V17.738H208.973Z" fill="#F7F6F2"/>
<path d="M218.873 5.24079C219.726 5.22968 220.527 5.38523 221.269 5.70468C222.014 6.02412 222.645 6.46579 223.167 7.02968C223.687 7.59357 224.095 8.27135 224.39 9.05746C224.685 9.84635 224.826 10.688 224.815 11.5852C224.826 12.488 224.685 13.338 224.39 14.1297C224.095 14.9241 223.687 15.5991 223.167 16.163C222.647 16.7241 222.014 17.1658 221.269 17.4852C220.524 17.8047 219.726 17.9602 218.873 17.9491C218.02 17.9602 217.222 17.8047 216.48 17.4852C215.738 17.1658 215.107 16.7241 214.587 16.1602C214.067 15.5963 213.659 14.9186 213.367 14.1325C213.075 13.3463 212.933 12.5019 212.945 11.6047C212.933 10.7075 213.075 9.86301 213.367 9.07412C213.659 8.28523 214.065 7.60746 214.587 7.04079C215.107 6.4769 215.738 6.03246 216.48 5.71023C217.222 5.38801 218.02 5.23246 218.873 5.24357V5.24079ZM216.01 14.8602C216.736 15.6825 217.695 16.0963 218.89 16.0963C220.085 16.0963 221.044 15.6852 221.766 14.8602C222.489 14.038 222.85 12.9463 222.85 11.5852C222.85 10.2241 222.489 9.12412 221.766 8.3019C221.044 7.47968 220.085 7.06579 218.89 7.06579C217.695 7.06579 216.733 7.47968 216.01 8.30468C215.285 9.12968 214.924 10.2241 214.924 11.5852C214.924 12.9463 215.285 14.038 216.01 14.8602Z" fill="#F7F6F2"/>
<path d="M236.728 5.43801V17.738H234.852L228.784 8.46857V17.738H226.908V5.43801H228.784L234.852 14.7241V5.43801H236.728Z" fill="#F7F6F2"/>
</svg>

After

Width:  |  Height:  |  Size: 8.7 KiB

@@ -0,0 +1,22 @@
<svg width="237" height="24" viewBox="0 0 237 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M8.54181 0.297615C8.14709 -0.0992051 7.5012 -0.0992051 7.1082 0.297615L0.717655 6.72232C0.322945 7.11914 0 7.90248 0 8.46249V14.2894C0 14.8494 0.456224 15.3098 1.01497 15.3098H14.635C15.1921 15.3098 15.65 14.8511 15.65 14.2894V8.46249C15.65 7.90248 15.3271 7.11914 14.9324 6.72232L8.54181 0.297615Z" fill="#09202E"/>
<path d="M14.8672 17.9182C15.2993 17.9182 15.6502 18.2684 15.6504 18.7004V22.4172C15.6504 22.8494 15.2994 23.2004 14.8672 23.2004H13.8896C13.4575 23.2004 13.1064 22.8494 13.1064 22.4172V20.4612H2.54297V22.4172C2.54297 22.8494 2.19285 23.2004 1.76074 23.2004H0.782227C0.350189 23.2003 0 22.8493 0 22.4172V18.7004C0.000147852 18.2685 0.350281 17.9184 0.782227 17.9182H14.8672Z" fill="#09202E"/>
<path d="M28.0439 5.23999C28.9236 5.23999 29.7386 5.39833 30.4891 5.71709C31.2396 6.03377 31.8754 6.47337 32.3965 7.03381C32.9177 7.59425 33.3242 8.26511 33.616 9.05056C33.9058 9.83601 34.0517 10.684 34.0517 11.5965C34.0621 12.4986 33.9204 13.3445 33.6285 14.1383C33.3346 14.932 32.9239 15.6092 32.3965 16.1717C31.8691 16.7342 31.227 17.1759 30.4724 17.4947C29.7178 17.8155 28.9027 17.9697 28.0272 17.9593C26.8702 17.9759 25.83 17.7092 24.9086 17.1613C23.9872 16.6134 23.2764 15.8529 22.7761 14.88C22.2757 13.907 22.0339 12.8174 22.0506 11.6132C22.0402 10.711 22.1819 9.86518 22.4738 9.0714C22.7677 8.27761 23.1763 7.6005 23.7016 7.03381C24.2269 6.46921 24.8648 6.02544 25.6174 5.70668C26.3699 5.38791 27.1787 5.23166 28.0439 5.24207V5.23999ZM28.0584 15.5237C29.0966 15.5237 29.9179 15.1716 30.5245 14.4695C31.1312 13.7653 31.4334 12.809 31.4334 11.5965C31.4334 10.3839 31.1312 9.41308 30.5287 8.71096C29.9263 8.01094 29.1028 7.66092 28.0584 7.66092C27.0141 7.66092 26.1906 8.01094 25.5882 8.71096C24.9857 9.41099 24.6835 10.3735 24.6835 11.5965C24.6835 12.8195 24.9857 13.7799 25.5882 14.4779C26.1906 15.1758 27.0141 15.5258 28.0584 15.5258V15.5237Z" fill="#09202E"/>
<path d="M44.9854 9.52558C44.9854 10.759 44.6123 11.7507 43.8681 12.5028C43.1239 13.2549 42.1108 13.632 40.8267 13.632H38.3898V17.7551H35.9028V5.45875H40.86C42.1483 5.45875 43.1572 5.82126 43.8889 6.54629C44.6206 7.27132 44.9854 8.26511 44.9854 9.5235V9.52558ZM42.3588 9.47766C42.3588 8.95681 42.19 8.52554 41.8502 8.18594C41.5104 7.84635 41.0205 7.67759 40.3785 7.67759H38.3898V11.4673H40.3785C41.0247 11.4673 41.5167 11.284 41.8544 10.9173C42.1921 10.5506 42.3588 10.0714 42.3588 9.47975V9.47766Z" fill="#09202E"/>
<path d="M54.4829 17.7551H46.7386V5.45875H54.4829V7.82134H49.2234V10.4444H53.895V12.6445H49.2234V15.4154H54.4829V17.7551Z" fill="#09202E"/>
<path d="M67.397 5.46083V17.7572H64.9268L59.5381 9.42974V17.7572H57.0678V5.46083H59.5381L64.9268 13.8049V5.46083H67.397Z" fill="#09202E"/>
<path d="M74.4409 5.46083H76.9279V10.4319L81.8037 10.4485V5.46083H84.3074V17.7572H81.8037V12.6653L76.9279 12.632V17.7572H74.4409V5.46083Z" fill="#09202E"/>
<path d="M92.133 5.23999C93.0127 5.23999 93.8278 5.39833 94.5782 5.71709C95.3287 6.03377 95.9645 6.47337 96.4856 7.03381C97.0068 7.59425 97.4133 8.26511 97.7051 9.05056C97.9949 9.83601 98.1408 10.684 98.1408 11.5965C98.1512 12.4986 98.0095 13.3445 97.7176 14.1383C97.4237 14.932 97.013 15.6092 96.4856 16.1717C95.9582 16.7342 95.3162 17.1759 94.5615 17.4947C93.8069 17.8155 92.9918 17.9697 92.1163 17.9593C90.9593 17.9759 89.9191 17.7092 88.9977 17.1613C88.0763 16.6134 87.3655 15.8529 86.8652 14.88C86.3649 13.907 86.1231 12.8174 86.1397 11.6132C86.1293 10.711 86.2711 9.86518 86.5629 9.0714C86.8568 8.27761 87.2654 7.6005 87.7907 7.03381C88.3161 6.46921 88.954 6.02544 89.7065 5.70668C90.459 5.38791 91.2679 5.23166 92.133 5.24207V5.23999ZM92.1476 15.5237C93.1857 15.5237 94.007 15.1716 94.6137 14.4695C95.2203 13.7653 95.5225 12.809 95.5225 11.5965C95.5225 10.3839 95.2203 9.41308 94.6178 8.71096C94.0154 8.01094 93.192 7.66092 92.1476 7.66092C91.1032 7.66092 90.2798 8.01094 89.6773 8.71096C89.0749 9.41099 88.7726 10.3735 88.7726 11.5965C88.7726 12.8195 89.0749 13.7799 89.6773 14.4779C90.2798 15.1758 91.1032 15.5258 92.1476 15.5258V15.5237Z" fill="#09202E"/>
<path d="M112.408 5.46083V17.7572H109.954V11.034L107.321 17.7572H104.966L102.364 11.084V17.7572H99.9919V5.46083H102.364L106.148 14.855L109.954 5.46083H112.408Z" fill="#09202E"/>
<path d="M122.762 17.7551H115.018V5.45875H122.762V7.82134H117.503V10.4444H122.174V12.6445H117.503V15.4154H122.762V17.7551Z" fill="#09202E"/>
<path d="M131.526 7.21555V10.9072H136.254V12.5933H131.526V17.7405H129.65V5.44054H137.146V7.21555H131.526Z" fill="#09202E"/>
<path d="M144.303 5.24055C145.156 5.22943 145.956 5.38499 146.699 5.70443C147.441 6.02388 148.074 6.46555 148.597 7.02943C149.117 7.59332 149.525 8.2711 149.82 9.05721C150.114 9.8461 150.256 10.6878 150.245 11.585C150.256 12.4878 150.114 13.3378 149.82 14.1294C149.525 14.9239 149.117 15.5989 148.597 16.1628C148.077 16.7239 147.443 17.1655 146.699 17.485C145.954 17.8044 145.156 17.96 144.303 17.9489C143.449 17.96 142.652 17.8044 141.91 17.485C141.168 17.1655 140.537 16.7239 140.017 16.16C139.497 15.5961 139.089 14.9183 138.797 14.1322C138.505 13.3461 138.363 12.5017 138.374 11.6044C138.363 10.7072 138.505 9.86277 138.797 9.07388C139.089 8.28499 139.494 7.60721 140.017 7.04055C140.537 6.47666 141.168 6.03221 141.91 5.70999C142.652 5.38777 143.449 5.23221 144.303 5.24332V5.24055ZM141.44 14.86C142.165 15.6822 143.124 16.0961 144.319 16.0961C145.515 16.0961 146.473 15.685 147.196 14.86C147.919 14.0378 148.28 12.9461 148.28 11.585C148.28 10.2239 147.919 9.12388 147.196 8.30166C146.473 7.47943 145.515 7.06555 144.319 7.06555C143.124 7.06555 142.163 7.47943 141.44 8.30443C140.715 9.12943 140.353 10.2239 140.353 11.585C140.353 12.9461 140.715 14.0378 141.44 14.86Z" fill="#09202E"/>
<path d="M154.097 5.43777V13.3017C154.103 14.2378 154.378 14.9461 154.923 15.4239C155.467 15.9044 156.184 16.1433 157.071 16.1433C157.958 16.1433 158.636 15.8905 159.203 15.3878C159.77 14.885 160.053 14.1878 160.053 13.3017V5.43777H161.929V13.3767C161.929 14.0989 161.799 14.7517 161.54 15.3378C161.282 15.9239 160.929 16.4072 160.487 16.7822C160.042 17.16 159.531 17.4489 158.944 17.6544C158.358 17.86 157.735 17.96 157.068 17.96C156.401 17.96 155.804 17.86 155.226 17.6628C154.647 17.4655 154.131 17.1794 153.68 16.8072C153.227 16.435 152.871 15.9544 152.61 15.3628C152.349 14.7711 152.218 14.1072 152.218 13.3767V5.43777H154.097Z" fill="#09202E"/>
<path d="M174.473 5.43777V17.7378H172.597L166.529 8.46832V17.7378H164.653V5.43777H166.529L172.597 14.7239V5.43777H174.473Z" fill="#09202E"/>
<path d="M187.583 11.6017C187.588 12.4822 187.436 13.31 187.124 14.0794C186.813 14.8517 186.382 15.5072 185.835 16.0461C185.287 16.585 184.628 17.0072 183.859 17.31C183.089 17.6128 182.269 17.7572 181.391 17.7405H177.291V5.44054H181.391C182.547 5.42388 183.603 5.67943 184.553 6.20999C185.504 6.73777 186.249 7.47666 186.788 8.42388C187.327 9.3711 187.591 10.4322 187.583 11.6044V11.6017ZM185.59 11.6017C185.59 10.3017 185.204 9.2461 184.428 8.43499C183.653 7.62388 182.636 7.21555 181.374 7.21555H179.184V15.9961H181.374C182.647 15.9961 183.667 15.5933 184.437 14.785C185.207 13.9794 185.59 12.9155 185.59 11.6017Z" fill="#09202E"/>
<path d="M195.821 14.7822H190.91L189.843 17.7405H187.916L192.363 5.44054H194.381L198.853 17.7405H196.869L195.818 14.7822H195.821ZM195.276 13.1878L193.383 7.82666L191.474 13.1878H195.276Z" fill="#09202E"/>
<path d="M207.278 7.21555H203.706V17.7405H201.813V7.21555H198.242V5.44054H207.275V7.21555H207.278Z" fill="#09202E"/>
<path d="M208.973 17.7378V5.43777H210.866V17.7378H208.973Z" fill="#09202E"/>
<path d="M218.873 5.24055C219.726 5.22943 220.527 5.38499 221.269 5.70443C222.014 6.02388 222.645 6.46555 223.167 7.02943C223.687 7.59332 224.095 8.2711 224.39 9.05721C224.685 9.8461 224.826 10.6878 224.815 11.585C224.826 12.4878 224.685 13.3378 224.39 14.1294C224.095 14.9239 223.687 15.5989 223.167 16.1628C222.647 16.7239 222.014 17.1655 221.269 17.485C220.524 17.8044 219.726 17.96 218.873 17.9489C218.02 17.96 217.222 17.8044 216.48 17.485C215.738 17.1655 215.107 16.7239 214.587 16.16C214.067 15.5961 213.659 14.9183 213.367 14.1322C213.075 13.3461 212.933 12.5017 212.945 11.6044C212.933 10.7072 213.075 9.86277 213.367 9.07388C213.659 8.28499 214.065 7.60721 214.587 7.04055C215.107 6.47666 215.738 6.03221 216.48 5.70999C217.222 5.38777 218.02 5.23221 218.873 5.24332V5.24055ZM216.01 14.86C216.736 15.6822 217.695 16.0961 218.89 16.0961C220.085 16.0961 221.044 15.685 221.766 14.86C222.489 14.0378 222.85 12.9461 222.85 11.585C222.85 10.2239 222.489 9.12388 221.766 8.30166C221.044 7.47943 220.085 7.06555 218.89 7.06555C217.695 7.06555 216.733 7.47943 216.01 8.30443C215.285 9.12943 214.924 10.2239 214.924 11.585C214.924 12.9461 215.285 14.0378 216.01 14.86Z" fill="#09202E"/>
<path d="M236.728 5.43777V17.7378H234.852L228.784 8.46832V17.7378H226.908V5.43777H228.784L234.852 14.7239V5.43777H236.728Z" fill="#09202E"/>
</svg>

After

Width:  |  Height:  |  Size: 8.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"
+2 -1
View File
@@ -106,7 +106,8 @@
{
"description": "Group Playwright package and CI container updates",
"groupName": "Playwright",
"matchPackageNames": ["@playwright/test", "mcr.microsoft.com/playwright"]
"matchPackageNames": ["@playwright/test", "mcr.microsoft.com/playwright"],
"minimumGroupSize": 5
}
]
}
+37 -3
View File
@@ -61,10 +61,44 @@ fi
echo Core is used from ${coreUrl}
# build the frontend so it connects to the passed core
HASS_URL="$coreUrl" ./script/develop &
HASS_URL="$coreUrl" ./node_modules/.bin/gulp develop-app &
develop_pid=$!
# serve the frontend
./node_modules/.bin/serve -p $frontendPort --single --no-port-switching --config ../script/serve-config.json ./hass_frontend &
serve_pid=$!
# keep the script running while serving
wait
stop_children() {
trap - EXIT INT TERM HUP
kill "$develop_pid" "$serve_pid" 2>/dev/null || true
wait "$develop_pid" 2>/dev/null || true
wait "$serve_pid" 2>/dev/null || true
}
trap stop_children EXIT
trap 'stop_children; exit 130' INT
trap 'stop_children; exit 143' TERM
trap 'stop_children; exit 129' HUP
while kill -0 "$develop_pid" 2>/dev/null && kill -0 "$serve_pid" 2>/dev/null; do
sleep 1
done
develop_status=
serve_status=
if ! kill -0 "$develop_pid" 2>/dev/null; then
if wait "$develop_pid"; then develop_status=0; else develop_status=$?; fi
fi
if ! kill -0 "$serve_pid" 2>/dev/null; then
if wait "$serve_pid"; then serve_status=0; else serve_status=$?; fi
fi
if [ -n "$develop_status" ] && [ "$develop_status" -ne 0 ]; then
status=$develop_status
elif [ -n "$serve_status" ]; then
status=$serve_status
else
status=$develop_status
fi
exit "$status"
+11 -10
View File
@@ -9,16 +9,17 @@ export const castApiAvailable = () => {
loadedPromise = new Promise((resolve) => {
(window as any).__onGCastApiAvailable = resolve;
});
// Any element with a specific ID will get set as a JS variable on window
// This will override the cast SDK if the iconset is loaded afterwards.
// Conflicting IDs will no longer mess with window, so we'll just append one.
const el = document.createElement("div");
el.id = "cast";
document.body.append(el);
loadJS(
"https://www.gstatic.com/cv/js/sender/v1/cast_sender.js?loadCastFramework=1"
);
// Any element with a specific ID will get set as a JS variable on window
// This will override the cast SDK if the iconset is loaded afterwards.
// Conflicting IDs will no longer mess with window, so we'll just append one.
const el = document.createElement("div");
el.id = "cast";
document.body.append(el);
loadJS(
"https://www.gstatic.com/cv/js/sender/v1/cast_sender.js?loadCastFramework=1"
).catch(() => resolve(false));
});
return loadedPromise;
};
@@ -0,0 +1,120 @@
import type {
ReactiveController,
ReactiveControllerHost,
} from "@lit/reactive-element/reactive-controller";
import type { LitElement } from "lit";
import type { Ref } from "lit/directives/ref";
const scrollParent = (element: Element): HTMLElement | undefined => {
let node = element.parentElement;
while (node) {
const { overflowY } = getComputedStyle(node);
if (overflowY === "auto" || overflowY === "scroll") {
return node;
}
node = node.parentElement;
}
return undefined;
};
/**
* Does what CSS scroll anchoring does in Chrome and Firefox but not in Safari.
* Point the ref at the element that grows, not at the scroller. Turns native
* anchoring off on that scroller, so growth elsewhere in it is no longer
* compensated either.
*/
export class PreserveScrollPositionController implements ReactiveController {
private _target: Ref<HTMLElement>;
private _element?: HTMLElement;
private _scroller?: HTMLElement;
private _observer?: ResizeObserver;
private _height = 0;
constructor(
host: ReactiveControllerHost & LitElement,
target: Ref<HTMLElement>
) {
this._target = target;
host.addController(this);
}
hostConnected() {
this._sync();
}
hostUpdated() {
this._sync();
}
hostDisconnected() {
this._detach();
}
private _sync() {
const element = this._target.value;
if (element === this._element) {
return;
}
this._detach();
this._element = element;
if (element) {
this._height = element.getBoundingClientRect().height;
this._observer = new ResizeObserver((entries) =>
this._compensate(entries)
);
this._observer.observe(element);
}
}
private _detach() {
this._observer?.disconnect();
this._observer = undefined;
this._element = undefined;
if (this._scroller) {
this._scroller.style.removeProperty("overflow-anchor");
this._scroller = undefined;
}
}
private _resolveScroller(): HTMLElement | undefined {
if (!this._scroller && this._element) {
this._scroller = scrollParent(this._element);
if (this._scroller) {
// Chrome and Firefox would otherwise anchor on top of this controller
// and correct twice.
this._scroller.style.overflowAnchor = "none";
}
}
return this._scroller;
}
private _compensate(entries: ResizeObserverEntry[]) {
const element = this._element;
if (!element) {
return;
}
const height =
entries[0]?.borderBoxSize?.[0]?.blockSize ?? element.offsetHeight;
const delta = height - this._height;
this._height = height;
if (!delta) {
return;
}
const scroller = this._resolveScroller();
if (
scroller &&
element.getBoundingClientRect().top < scroller.getBoundingClientRect().top
) {
scroller.scrollTop += delta;
}
}
}
@@ -1,85 +0,0 @@
import { stripBoundaryLabel } from "../string/strip_boundary_label";
export interface AreaForNameMatch {
area_id: string;
name: string;
aliases?: string[];
}
export interface DeviceAreaSuggestion {
name: string;
area?: string;
}
const areaLabels = (area: AreaForNameMatch): string[] =>
[area.name, ...(area.aliases ?? [])].filter(Boolean);
// Longest matching label (name or alias) of a single area, and the device name
// once that label is stripped off. `null` when the area does not match.
const matchArea = (
name: string,
area: AreaForNameMatch
): { strippedName: string; labelLength: number } | null => {
let best: { strippedName: string; labelLength: number } | null = null;
for (const label of areaLabels(area)) {
const strippedName = stripBoundaryLabel(name, label);
if (strippedName === null) {
continue;
}
if (!best || label.length > best.labelLength) {
best = { strippedName, labelLength: label.length };
}
}
return best;
};
// The area with the longest matching label, and the stripped device name.
const bestAreaMatch = (
name: string,
areas: AreaForNameMatch[]
): { areaId: string; strippedName: string; labelLength: number } | null => {
let best: {
areaId: string;
strippedName: string;
labelLength: number;
} | null = null;
for (const area of areas) {
const match = matchArea(name, area);
if (match && (!best || match.labelLength > best.labelLength)) {
best = { areaId: area.area_id, ...match };
}
}
return best;
};
/**
* Suggests a cleaned device name (and an area) when an area name or alias is a
* prefix or suffix of the device name.
*
* - When the device already has an area, only match against that area, so an
* assigned area is never overridden just the name is cleaned.
* - Otherwise pick the area with the longest matching name/alias and suggest it.
* - The longest match wins with no fallback: if the name is exactly the area,
* nothing is left to keep and the result is a no-op (`null`).
*/
export const computeDeviceAreaSuggestion = (
deviceName: string | null | undefined,
currentAreaId: string | null | undefined,
areas: AreaForNameMatch[]
): DeviceAreaSuggestion | null => {
const name = deviceName?.trim();
if (!name) {
return null;
}
if (currentAreaId) {
const area = areas.find((a) => a.area_id === currentAreaId);
const match = area ? matchArea(name, area) : null;
return match?.strippedName ? { name: match.strippedName } : null;
}
const match = bestAreaMatch(name, areas);
return match?.strippedName
? { name: match.strippedName, area: match.areaId }
: null;
};
@@ -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;
};
+11 -18
View File
@@ -64,22 +64,15 @@ export const navigate = async (path: string, options?: NavigateOptions) => {
const replace = options?.replace || false;
if (__DEMO__) {
if (path.includes("#")) {
if (replace) {
mainWindow.history.replaceState(
mainWindow.history.state?.root
? { root: true }
: (options?.data ?? null),
"",
path
);
} else {
mainWindow.history.pushState(options?.data ?? null, "", path);
}
fireEvent(mainWindow, "location-changed", {
replace,
});
return true;
if (!path.includes("#")) {
// The demo routes with the hash instead of the pathname. Resolve the
// path like the browser would do for pushState, and keep the query
// parameters in the URL query instead of inside the hash.
const url = new URL(
path,
`${mainWindow.location.origin}${mainWindow.location.hash.substring(1)}`
);
path = `${mainWindow.location.pathname}${url.search}#${url.pathname}`;
}
if (replace) {
mainWindow.history.replaceState(
@@ -87,10 +80,10 @@ export const navigate = async (path: string, options?: NavigateOptions) => {
? { root: true }
: (options?.data ?? null),
"",
`${mainWindow.location.pathname}#${path}`
path
);
} else {
mainWindow.location.hash = path;
mainWindow.history.pushState(options?.data ?? null, "", path);
}
} else if (replace) {
mainWindow.history.replaceState(
-42
View File
@@ -1,42 +0,0 @@
const SEPARATORS = "\\s\\-_.";
const LEADING_SEPARATORS = new RegExp(`^[${SEPARATORS}]+`);
const TRAILING_SEPARATORS = new RegExp(`[${SEPARATORS}]+$`);
const STARTS_WITH_SEPARATOR = new RegExp(`^[${SEPARATORS}]`);
const ENDS_WITH_SEPARATOR = new RegExp(`[${SEPARATORS}]$`);
/**
* Strips `label` from the start or end of `text` on a word boundary,
* case-insensitively, and trims the surrounding separators.
*
* Returns:
* - `""` when `text` equals `label`,
* - the trimmed remainder for a prefix or suffix match,
* - `null` when `label` is not a word-boundary prefix or suffix of `text`.
*/
export const stripBoundaryLabel = (
text: string,
label: string
): string | null => {
const lowerText = text.toLowerCase();
const lowerLabel = label.toLowerCase();
if (lowerText === lowerLabel) {
return "";
}
if (lowerText.startsWith(lowerLabel)) {
const rest = text.slice(label.length);
if (STARTS_WITH_SEPARATOR.test(rest)) {
return rest.replace(LEADING_SEPARATORS, "").trim();
}
}
if (lowerText.endsWith(lowerLabel)) {
const rest = text.slice(0, text.length - label.length);
if (ENDS_WITH_SEPARATOR.test(rest)) {
return rest.replace(TRAILING_SEPARATORS, "").trim();
}
}
return null;
};
+52
View File
@@ -0,0 +1,52 @@
import { sanitizeNavigationPath } from "./sanitize-navigation-path";
const HOME_ASSISTANT_SCHEME = "homeassistant://";
/**
* Returns the URL if it is safe to use as a link target, `undefined` otherwise.
* Only absolute `http:` and `https:` URLs pass, the same check
* `ha-attribute-value` already applies to attribute links.
*
* Use for every URL that reaches the frontend as data from an integration
* manifest, an add-on, a config flow or an entity attribute so that a
* `javascript:` URI can never become a clickable link.
*/
export const sanitizeHttpUrl = (
url: string | null | undefined
): string | undefined => {
if (!url) {
return undefined;
}
try {
const { protocol } = new URL(url);
return protocol === "http:" || protocol === "https:" ? url : undefined;
} catch (_err) {
return undefined;
}
};
/** Whether the URL is a `homeassistant://` deep link into the frontend. */
export const isHomeAssistantUrl = (url: string | null | undefined): boolean =>
!!url?.startsWith(HOME_ASSISTANT_SCHEME);
/**
* Turns a `homeassistant://` deep link into an in-app path, or returns
* `undefined` when it does not point inside the frontend. Rewriting the scheme
* on its own is not enough: `homeassistant:///example.com` would become
* `//example.com`, which resolves to another origin.
*/
export const homeAssistantUrlToPath = (
url: string | null | undefined
): string | undefined =>
isHomeAssistantUrl(url)
? sanitizeNavigationPath(`/${url!.slice(HOME_ASSISTANT_SCHEME.length)}`)
: undefined;
/**
* Sanitizes a URL that may be either an external link or a `homeassistant://`
* deep link, returning something safe to bind to an `href`.
*/
export const sanitizeLinkUrl = (
url: string | null | undefined
): string | undefined =>
isHomeAssistantUrl(url) ? homeAssistantUrlToPath(url) : sanitizeHttpUrl(url);
@@ -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);
}
`;
+4
View File
@@ -36,6 +36,10 @@ export function downSampleLineData<
const min = minX ?? getPointData(data[0]!)[0];
const max = maxX ?? getPointData(data[data.length - 1]!)[0];
const step = Math.ceil((max - min) / Math.floor(maxDetails));
if (!Number.isFinite(step) || step <= 0) {
// a degenerate frame size would put every point in a single frame
return data;
}
if (useMean) {
// Group points into frames, accumulating sums in insertion order.
+4 -1
View File
@@ -51,6 +51,7 @@ export const MIN_TIME_BETWEEN_UPDATES = 60 * 5 * 1000;
const LEGEND_OVERFLOW_LIMIT = 10;
const LEGEND_OVERFLOW_LIMIT_MOBILE = 6;
const DOUBLE_TAP_TIME = 300;
const DEFAULT_CHART_WIDTH = 500;
type RawSeriesOption = Exclude<
NonNullable<ECOption["series"]>,
@@ -1048,7 +1049,9 @@ export class HaChartBase extends LitElement {
sampling: undefined,
data: downSampleLineData(
data as LineSeriesOption["data"],
this.clientWidth * window.devicePixelRatio,
// 0 while inside a hidden container, e.g. a section with a visibility condition
(this.clientWidth || DEFAULT_CHART_WIDTH) *
window.devicePixelRatio,
minX,
maxX
),
+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;
@@ -141,7 +141,7 @@ export class HaChooseSelector extends LitElement {
}
private _value(choice?: string): any {
if (!this.value) {
if (this.value === null || this.value === undefined) {
return undefined;
}
return typeof this.value === "object"
+23 -23
View File
@@ -488,6 +488,11 @@ export class HaServiceControl extends LitElement {
)) ||
serviceData?.description;
const documentationLink =
this._manifest?.is_built_in && this._value?.action
? documentationUrl(this.hass, `/actions/${this._value.action}`)
: this._manifest?.documentation;
const targetSelector =
serviceData && "target" in serviceData
? this._targetSelector(
@@ -514,16 +519,9 @@ export class HaServiceControl extends LitElement {
<div class="description">
${description ? html`<p>${description}</p>` : ""}
${
this._manifest
documentationLink
? html` <a
href=${
this._manifest.is_built_in && this._value?.action
? documentationUrl(
this.hass,
`/actions/${this._value.action}`
)
: this._manifest.documentation
}
href=${documentationLink}
title=${this.hass.localize(
"ui.components.service-control.integration_doc"
)}
@@ -542,20 +540,14 @@ export class HaServiceControl extends LitElement {
}
${
serviceData && "target" in serviceData
? html`<ha-settings-row
.narrow=${this.narrow || isFullWidthSelector(targetSelector)}
>
<span slot="heading"
>${this.hass.localize("ui.components.service-control.target")}</span
>
<ha-selector
.hass=${this.hass}
.selector=${targetSelector}
.disabled=${this.disabled}
@value-changed=${this._targetChanged}
.value=${this._value?.target}
></ha-selector
></ha-settings-row>`
? html`<ha-selector
class="target-selector"
.hass=${this.hass}
.selector=${targetSelector}
.disabled=${this.disabled}
@value-changed=${this._targetChanged}
.value=${this._value?.target}
></ha-selector>`
: entityId
? html`<ha-entity-picker
.disabled=${this.disabled}
@@ -1059,6 +1051,14 @@ export class HaServiceControl extends LitElement {
display: block;
margin: var(--service-control-padding, 0 var(--ha-space-4));
}
ha-selector.target-selector {
display: block;
padding: var(--ha-space-2) var(--ha-space-4);
border-top: var(
--service-control-items-border-top,
1px solid var(--divider-color)
);
}
ha-yaml-editor {
padding: var(--ha-space-4) 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;
}
}

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