Compare commits

...
Author SHA1 Message Date
Paul Bottein 630d7ff82e Limit the section preview to the view width and reset a cleared width 2026-10-15 17:06:20 +02:00
Paul Bottein 6874781340 Use early returns in the section editor 2026-10-15 15:24:12 +02:00
Paul Bottein be214acfd1 Add a preview and a clearer width setting to the section editor 2026-10-15 15:13:36 +02:00
Aidan TimsonandClaude Opus 5.5 7c74f45e5e Explain when HTTPS or the microphone blocks a feature (#54948)
* Fix Assist microphone not supported message showing [object Object]

The not supported microphone message was built as a Lit TemplateResult,
but chat messages are rendered through ha-markdown which expects a
string, so it was displayed as "[object Object]". Build the message as
markdown instead and narrow the message text type to string.

Fixes #29769

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

* Load Assist voice responses from Home Assistant's URL

* Explain when HTTPS or the microphone blocks a feature

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-15 14:42:26 +02:00
Ruslan Sayfutdinov 05848f0df4 Add gap between backup upload percentage and spinner (#54951) 2026-10-15 14:35:57 +03:00
Marcin BauerandClaude Opus 5.5 bfa14296d7 Show a toast instead of a dialog after reloading an integration entry (#54954)
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-15 14:28:36 +03:00
renovate[bot] ba3722b983 Update vitest monorepo to v5.0.4 (#54953)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-10-15 14:27:46 +03:00
Lucas Deutschland cbc3b9dbaf Fix tile zone icons for zones with duplicate names (#54909)
* Fix tile zone icons for zones with duplicate names

* Match tile zone icons to the current tracker state

* Simplify person tile zone lookup
2026-10-15 14:11:06 +03:00
Lucas Deutschland 76f5280b0a Fix map activity for zones with duplicate names (#54908)
* Fix map activity for zones with duplicate names

* Preserve legacy zone icons in person activity

* Simplify map activity zone handling

* Fix map demo history zone membership
2026-10-15 14:10:57 +03:00
Petar Petrov 7123790fed Fix number selector validation on blur (#54944) 2026-10-15 11:03:10 +02:00
Petar Petrov ebfc19eff1 Fix validate-on-blur in the change password card and enter code dialog (#54945)
Fix auto-validate on the change password and enter code inputs
2026-10-15 11:02:21 +02:00
Petar Petrov 80b515fb77 Use contexts in small standalone components (#54943) 2026-10-15 10:59:52 +02:00
Aidan Timson 73200e7dc6 Use image select boxes for buttons or slider feature styles (#54947) 2026-10-15 10:59:19 +02:00
Petar Petrov 067aa09222 Use contexts in the add-on, config entry and service pickers (#54942) 2026-10-15 09:21:00 +02:00
Paulus SchoutsenandClaude Opus 5.5 c24f4d29a9 Add dialog to delete statistics in a time range (#54875)
* Add dialog to delete statistics in a time range

A bad measurement ruins the graphs of a statistic. Mean statistics
cannot be adjusted, so the only fix was to delete the complete
statistic. A new action in the statistics table opens a dialog to pick
a time range. The dialog previews the statistics in the range and
deletes them with the recorder/clear_statistics_range command.

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

* Use recorder/clear_statistics with a time range

The backend adds the optional time range to recorder/clear_statistics
instead of a separate command.

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

* Preview the full first period when clearing a statistics range

The recorder also deletes the statistics period in which the range
starts. The preview started at the exact start time, so it did not show
that period. Start the preview at the beginning of the period.

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

* Fall back to hourly statistics in the clear range preview

The recorder purges the 5-minute statistics before the hourly
statistics. For an older short range, the preview showed "No statistics
found" and disabled Delete, although hourly statistics existed. Show the
hourly statistics when no 5-minute statistics are found.

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

---------

Co-authored-by: Claude <[email protected]>
2026-10-15 09:17:31 +02:00
Petar Petrov 7597b5ce77 Reject invalid static prices in the energy source dialogs (#54940)
Block saving an invalid static energy price
2026-10-15 08:48:14 +02:00
Aidan TimsonandAlexander Schieweck a491966068 Add buttons or slider style to target temperature card feature (#54923)
* Add buttons or slider style to target temperature card feature

* Always disable the target temperature fallback slider

---------

Co-authored-by: Alexander Schieweck <[email protected]>
2026-10-15 09:06:08 +03:00
Balloob Bot ff76e4e1a0 Add default decision entity preferences to AI Task settings (#54728) 2026-10-15 07:23:54 +02:00
Elias Lecomte 2ad2c38a41 Prevent stale Energy refreshes from changing the selected period (#54741)
* Prevent stale Energy refreshes from overwriting the selected period

* Preserve Energy data when reapplying the current selection

* Handle Energy subscription refresh failures

* Refresh invalidated Energy selections on resubscribe

* Use checked Energy collection construction and a typed cache

* Restore Energy collection access on the connection

* Retain Energy preferences when the selected period changes
2026-10-15 08:16:29 +03:00
Paul Bottein 4277060d6c Hide divider above the first section row of an entities card (#54929) 2026-10-15 07:00:30 +02:00
renovate[bot] 284691592e Update dependency @vvo/tzdb to v6.201.1 (#54938)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-10-15 06:56:08 +02:00
renovate[bot] 736d1f4dec Update dependency maplibre-gl to v6.14.0 (#54928)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-10-14 15:52:01 +00:00
Petar Petrov 6a0a5fb2ef Reduce stale period for pull requests to 60 days (#54926)
* Reduce stale period to 60 days

* Only reduce stale period for pull requests
2026-10-14 17:39:11 +02:00
Marco HillgerandSeoFood a2ad5c4b14 Guard script field descriptions against non-string YAML values (#54924)
A field description of 123 saved through the config API or YAML reaches the editor as a number, and description?.trim() threw while rendering the field row and its sidebar. Reuse getRowNote, like the automation row notes.

Co-authored-by: SeoFood <[email protected]>
2026-10-14 18:01:02 +03:00
Aidan TimsonandCopilot Autofix powered by AI 95afebf538 Explain debug logging banner and make disable the main action (#54922)
* Explain debug logging banner and make disable the main action

* Improve debug logging description clarity

Updated the description for debug logging to clarify log file download availability.

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

---------

Co-authored-by: Copilot Autofix powered by AI <[email protected]>
2026-10-14 17:56:47 +03:00
Aidan TimsonandClaude Opus 5.5 07f7bd91de Keep data table search, sorting and grouping in the URL, starting with the Marketplace (#54912)
* Add opt-in URL sync for data table search, sorting and grouping

Adds a shared query params controller and lets hass-tabs-subpage-data-table
keep its search, sorting and grouping in the URL (`search`, `sort`,
`direction`, `group`) when a page sets `sync-url`. Writes replace the current
history entry, search writes are debounced, and empty or default values are
left out. Params in the URL win over stored preferences; a link without table
params keeps what was picked before. Columns can give a short public name
(`urlName`) for the URL, grouping is only written for columns that have one.

No page opts in yet, so behaviour is unchanged.

Part of #54788

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

* Keep links and searches intact in data table URL sync

- Read the URL once the table is opted in and has its columns, and only
  write after that, so a link isn't overwritten when sync is turned on or
  columns arrive after the first render
- Write a pending search as soon as the search field loses focus, so a
  dialog opened right after typing doesn't pick up the write and Back
  doesn't bring an older search back
- Keep other query params exactly as they are written instead of
  re-encoding them
- Note that a column's URL name must not match another sortable column id

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

* Move the Marketplace tables onto the shared URL sync

* Shorten the urlName comment to one line

* Keep statuses picked on another Marketplace tab when applying a link

* Drop the rendering check from the Marketplace link test

* Cancel pending URL writes on navigation

* Write data table URL after update and reset typed search

* Track the host's first update in the URL params controller

* Add tests for the URL params controller

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-14 17:54:41 +03:00
Marco HillgerandSeoFood 9ca8f2e98b Use light color scheme for themes with a light mode (#54898)
* Use light color scheme for themes with an explicit light mode

* Only force the light scheme when a theme defines a light mode

---------

Co-authored-by: SeoFood <[email protected]>
2026-10-14 16:11:32 +02:00
Wendelin 04f46f037d Use ha-section-title for the picker sticky title and share its logic (#54916)
* Refactor section title handling in picker components and add utility function

- Removed unused sectionTitleFunction properties from ha-generic-picker and ha-picker-combo-box.
- Implemented findSectionTitle utility function to determine section titles based on visible items.
- Updated ha-picker-combo-box to use findSectionTitle for section title management.
- Cleaned up related code in ha-target-picker and ha-automation-add-search to enhance readability and maintainability.
- Added tests for findSectionTitle to ensure correct functionality.

* review
2026-10-14 14:41:54 +01:00
a9b25a3180 Guard automation row notes against non-string YAML values (#54911)
* Guard automation row notes against non-string YAML values

A note set in YAML is not guaranteed to be a string (e.g. `note: 123`),
so calling `note?.trim()` in the trigger, condition, action and option
rows and sidebars could throw while rendering. Add a `getRowNote` helper
that only trims string notes and use it everywhere notes are shown.

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

* Remove getRowNote comment

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

---------

Co-authored-by: Claude <[email protected]>
Co-authored-by: copilot-swe-agent[bot] <[email protected]>
2026-10-14 13:27:10 +00:00
4b18f1233e Use rounded squares for avatars and entity pictures (#54902)
* Use rounded squares for avatars everywhere

The map draws person and device avatars as rounded squares, while the
user and person badges in the sidebar, people list, profile, pickers
and editors, the picture previews and the badge card's entity picture
were still circles. They now share one --ha-avatar-border-radius
token, defaulting to the large radius the map already used, so themes
have a single knob and the map markers follow the same token. The
large picture previews take the extra-large radius to keep the same
proportion.

* Use shared picture radius across entity images

Apply the theme radius to state badges, picture previews, and the crop overlay while keeping state-badge-specific overrides.

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

* Keep the small radius for media artwork

Media player and camera badges share the has-media-image class; the 8% fallback was replaced by the picture radius, contradicting the PR description.

* Give camera pictures the shared picture radius

Cameras were grouped with media players and kept the small artwork radius; only media artwork keeps it.

* Use a small picture radius for related item thumbnails

The shared 12px picture radius collapses to a circle on the 20px
related item pictures, so add a themeable small variant that keeps
visible straight edges at that size.

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

---------

Co-authored-by: Copilot <[email protected]>
Co-authored-by: Claude Fable 5.1 <[email protected]>
2026-10-14 14:26:54 +01:00
Joakim PlateandClaude Opus 5.5 424068c4ca Fix media control card cut off for off players without turn on (#54904)
Players that are off with an assumed state but no TURN_ON support are
treated as unavailable, so the controls are not rendered. The controls
were still computed though, which dropped the no-controls class and
removed the bottom padding, clipping the card.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-14 15:15:00 +02:00
Erwin DounaandLucas Deutschland a6f4ab4a7a Add live log dialog to integration page (#54809)
* Add live log dialog to integration page

* Move live log button into the debug banner

* Limit retained integration live log lines to 5000

* Add filters and pause to the live log dialog

* Make integration live log retention configurable

* Align live log line limit input with toolbar controls

* Resubscribe live log after reconnect and use DialogMixin

* Keep reading position while searching live log

* Only show live log unsupported for unknown command

* Simplify the live log dialog

* Handle live log review feedback

* Apply live log review suggestions

---------

Co-authored-by: Lucas Deutschland <[email protected]>
2026-10-14 15:06:17 +02:00
Maarten Lakerveld cfde8cd14b Stop the user condition editor from duplicating selected users (#54921)
When the users finish loading, the list picks up the pre-selected items and
fires a selected event for them. The handler appended that user again on
every open of the editor, so a single user condition grew one duplicate id
per edit. Ignore selection events that do not change the configured users.

Fixes #54894
2026-10-14 15:02:38 +02:00
Paul Bottein 921ac4998a Show the color feature for light group members in color mode (#54918)
* Show the color feature for light group members in color mode

* Show hue and saturation for light group members

* Show only hue for light group members
2026-10-14 15:00:30 +02:00
Petar Petrov a0c7c36403 Follow up on choose option YAML editing (#54900)
* Follow up on choose option YAML editing

Make action and trigger migration safe for malformed YAML, drop the
option-specific sequence migration, type the option sidebar save value
as unknown and wire option YAML mode through the sidebar like the
other sidebars.

* Guard malformed trigger options during migration
2026-10-14 14:55:24 +02:00
WendelinandClaude Opus 5.5 21670e5ae6 Prevent iOS Safari text inflation on landscape rotation (#54914)
* Prevent iOS Safari text inflation on landscape rotation

Fixes #53060

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

* also add the fix for gallery and demo

---------

Co-authored-by: Claude <[email protected]>
2026-10-14 15:53:15 +03:00
Paul Bottein 76e353b36b Fix hue scrubber zoom for features sharing the card width (#54917) 2026-10-14 15:49:44 +03:00
Aidan TimsonandClaude Opus 5.5 ddb33f7d12 Add accessible names to icon-only badges and interactive tile icons (#54907)
* Add accessible names to icon-only badges and interactive tile icons

ha-badge and ha-tile-icon could render a focusable button with no
accessible name. Add an accessibleLabel property to both, set as
aria-label on the button element (for ha-badge only in icon-only mode,
so visible text still names the button), and pass the entity or
shortcut name from the entity badge, shortcut badge, tile card and
toggle group card.

Fixes #54890

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

* Name shortcut badges without an action label and document accessibleLabel

Fall back to the localized shortcut badge name when the shortcut has no
action-derived label, such as hold-only or double-tap-only badges, and
add accessibleLabel to the ha-badge gallery API table.

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

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-14 14:49:41 +02:00
Maarten LakerveldandClaude Fable 5.1 85ca758060 Draw the onboarding home marker like the home zone (#54903)
* Show a home icon for the location marker in onboarding

The onboarding location step passed no icon for its marker. On Leaflet that
fell back to the stock pin; since the MapLibre engine it is a bare dot, the
same element that marks a zone's draggable center. Use the home icon for the
editable marker, both before a search and for the highlighted search result,
so the step shows what the marker stands for.

* Draw the onboarding home marker like the home zone on a map

The location step showed a bare home icon, while the map card draws the
home zone as a colored circle with the icon. Let a point marker in the
locations editor opt into that zone marker, and use it in onboarding
with the home zone's color for the editable marker and the highlighted
search result, so the step shows the same marker the user will see later.

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

* Make the home marker color a memoization input

The memoized locations read the primary color implicitly, so a theme change left a stale radius color. Resolve the color in render and pass it in.

---------

Co-authored-by: Claude Fable 5.1 <[email protected]>
2026-10-14 14:42:55 +02:00
Paul Bottein 8401541c87 Fade light color features when the light is off (#54913)
* Fade light color features when the light is off

* Keep light color features faded while focused

* Show a neutral saturation gradient when the hue is unknown
2026-10-14 14:30:13 +02:00
JonathanandXyolyp 2b1b58d482 Link each run in the activity details to its own trace (#54592)
* Link each run in the activity details to its own trace

An automation and the scripts it calls share one context, and the trace contexts the logbook loads keep only one run per context, so every "View trace" link of such a chain opened the same trace. Core now names the run of a run row in its logbook entry: link that run, and keep the link by context for entries recorded before.

* Only link runs with trace contexts

Trace contexts are only loaded for admins and only hold stored traces.
Keep them as the condition for the link, so non-admins get no link to a
trace page they cannot open.

* Use getTraceUrl for the run trace link

---------

Co-authored-by: Xyolyp <[email protected]>
2026-10-14 12:01:42 +02:00
Paul Bottein 374864fcc1 Expand badge shorthand and drop empty entries when loading a dashboard (#54651)
* Expand badge shorthand and drop empty entries when loading a dashboard

* Normalize the generated config when saving a strategy dashboard
2026-10-14 11:58:19 +03:00
oneseventhreeandWendelin 1bcda9fb80 Add Edit in YAML for choose options in the automation editor (#53327)
* Add Edit in YAML for choose options in the automation editor

* Refactor automation sidebar option handling and validation logic

* copilot review

* fix copilot and regression

* Enhance note display logic in automation sidebar option

* copilot fix

* Add sidebarKey property to ha-automation-sidebar-option and update rendering logic

---------

Co-authored-by: Wendelin <[email protected]>
2026-10-14 11:50:04 +03:00
Jan Bouwhuis dca32d7065 Use unit of measurement selector and add device class to input number helper (#54785) 2026-10-14 08:43:55 +00:00
Paul Bottein cfc5add6cf Use the entity name formatter for all entity names (#54891)
* Use the entity name formatter for all entity names

* Guard entity completions until contexts are loaded
2026-10-14 11:36:43 +03:00
Aidan Timson ae6241859f Run tile card icon hold and double tap actions when icon tap is none (#54889) 2026-10-14 10:16:58 +02:00
Muhammad UmairandWendelin 4c190f806d Lower description search weight in automation add-element search (#53751)
* Lower description search weight in automation add-element search

* remove comment

---------

Co-authored-by: Wendelin <[email protected]>
2026-10-14 09:00:18 +03:00
charishma2k06andWendelin 6e698f8c7c Fix todo item input clearing (#54318)
* Fix todo item input clearing

* review

---------

Co-authored-by: Wendelin <[email protected]>
2026-10-14 08:59:33 +03:00
Paul Bottein 63708e9cba Add settings button to AI task entity pickers (#54886)
* Add settings button to AI task entity pickers

* Fix clearing AI task pickers and label settings buttons
2026-10-13 21:23:33 -04:00
Eric Lorenzo Benjamin Jr. f1763068e0 Treat contenteditable elements as text input for keyboard shortcuts (#53491)
canOverrideAlphanumericInput exempts textarea and the text-entry input types,
but has no branch for contenteditable, so an element the user is actively
typing into is still treated as fair game for global shortcuts. For an admin
that means a, c, d, e and m fire in the middle of a word.

The rest of the codebase already treats contenteditable as a typing surface
when it makes this decision, in ha-list-base and in the integrations
dashboard, and the HA-CODE-EDITOR tag name check in this same function is
that omission handled narrowly for CodeMirror, which is contenteditable
itself. Add the general check.

The HA-CODE-EDITOR entry is left in place. It is redundant in the common case
but it matches on the whole composed path rather than the event target, and
narrowing that belongs in its own change.

Adds tests for this function, which had none.
2026-10-13 14:10:59 +00:00
Aidan Timson 2c5970304a Use contexts in state-display (#54883)
* Use contexts in state-display

* Rename computeUpdateStateDisplay hass argument to options
2026-10-13 14:51:57 +01:00
WendelinandClaude Opus 5.5 eef2ad91cd Keep pending target temperature when entity state updates (#54854)
* Keep pending target temperature when entity state updates

Thermostat and water heater +/- taps were lost when the entity updated
during the 1 second debounce: willUpdate copied the entity's setpoint
over the value the user was still adjusting, so the debounced
set_temperature call sent the old value. This commonly happens when the
state update confirming the previous call arrives right after a new tap.

Add PendingValueSync, which skips copying entity state while a change is
scheduled or its service call is in flight, and resyncs from the latest
state once nothing is pending if an update was skipped or the call
failed. Use it in the target temperature card feature and the climate
and water heater more-info controls.

The single-setpoint buttons in the card feature now also display the
pending value instead of the entity attribute.

Fixes #54832

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

* Keep pending target humidity when entity state updates

Apply PendingValueSync to the climate and humidifier humidity controls
and the target humidity card feature, which had the same race as the
temperature controls. The card feature calls the service on every tap,
so state updates from an earlier tap could reset the value before the
next one. It now also displays the pending value.

Always sync from state when the control switches to another entity, so
a pending value is never shown for or sent to a different entity. This
matters because more-info and card features reuse the control element.

When the slider commits a value, cancel any queued button call so it
does not clear the pending state too early.

Don't rethrow failed service calls: callService already shows a toast,
and the control resyncs from the entity state.

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

* Implement cancel method in PendingValueSync and update entity state handling in climate controls

* review

* reduce helpers

---------

Co-authored-by: Claude <[email protected]>
2026-10-13 16:26:44 +03:00
f6a9b8e8a0 Location visibility condition: match zones by target - entity, label (#54679)
* Match zones by label in the location visibility condition

Add `target` (zones, or zones with a label) and `away` options to the
location visibility condition. `target` is matched against the person's
`in_zones` attribute, so overlapping zones match too. Existing `locations`
configs keep working and can be converted from the editor.

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

* Make resolveLocationZones a private helper

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

* Cache resolved location zones, validate target, add tests

- Cache resolved zones per target and refresh them only when the entity,
  device or area registry changes, instead of scanning on every update.
- Validate `target` with the shared targetStruct.
- Match the existing helper text wording.
- Add tests for validation, area targets, cache refresh, and the editor's
  value handling and conversion.

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

* Change button size from 'small' to 's'

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

* Fix button size attribute in ha-card-condition-location

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

* Validate location target and away shape, keep mixed fields in editor

- Reject a `target` that isn't a mapping and a non-boolean `away` in the
  location validator; evaluation ignores them instead of throwing.
- Keep `target` and `away` when editing a legacy condition, and merge
  into them when converting, so hand-written mixed configs aren't lost.

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

* Migrate legacy location conditions when the editor opens

Replace the Convert button with automatic migration, following the
existing pattern for deprecated options. A warning alert explains that
the condition now matches any zone the person is in. Zone names that
match no zone are kept and shown as not found instead of being dropped.

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

* Show legacy location conditions migrated, keep same-named zones

Migrate for display only, like toCoreEditorCondition: the new format is
written once the condition is edited, so opening a card doesn't create
unsaved changes. Zone names aren't unique, so migrate a name to every
zone that has it, as the old condition matched all of them. Mention the
in_zones attribute in the alert.

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

* Don't reuse an existing zone ID for a missing zone name

A renamed zone keeps its old ID, so slugifying a name that matches no
zone could select a zone the condition never matched. Add _2, _3 and so
on until the ID is unused, like the script editor and core do for new
entity IDs.

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

* Keep missing location names that slugify the same apart

Two names that match no zone but slugify to the same ID got the same
generated ID and collapsed into one. Reserve generated IDs while
migrating, and skip repeated names so they don't get a second one.

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

* Validate location target IDs, not just that target is a mapping

A target like {entity_id: 5} passed validation and threw while
resolving zones. The entity filter card, entity filter badge and map
card evaluate conditions without catching errors. Check target keys and
ID types the way targetStruct does.

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

* Keep other keys when editing a location condition

Spread the existing condition like the other condition editors, so
keys such as alias aren't dropped. Migrate legacy conditions when the
condition changes instead of on every state update.

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

* Describe Away as the person being away, including passive zones

away matches the not_home state, which passive zones don't change, so
a person only in passive zones is away even though in_zones lists them.

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

* Pick up zone changes in the location editor, clarify texts

Redo the legacy migration when states change, so a zone renamed while
the editor is open isn't saved under a stale ID. Keep the same data
object when nothing changed. Drop backticks from the alert, which is
plain text, and mention areas and floors in the Zones helper.

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

* Potential fix for pull request finding 'Add the required Oxford comma'

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

* Keep zones named home or not_home when migrating

A zone can be named home or not_home, and the old condition matched a
person in it as well, so collect zones by name for those values too.
Describe away as the not_home state in code comments.

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

* Handle zones without a state in location resolving and migration

Resolving skips entities without a state, so a labeled zone evaluated
while zones reload was cached as missing until a registry change.
Resolve again on state changes while a registered zone has no state.
When migrating, treat registered zone IDs as taken even without a
state, so a missing name can't take over a reloading zone's ID.

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

* Only redo location migration when zones change

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

* Export the location migration and test it directly

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

* Watch registered zones without a state in the location editor

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

* Add match_mode so converted location conditions keep matching the same way

Converted conditions get match_mode: active_zone, which matches the zone
shown as the person state like locations did. any_zone stays the default.
The editor warns when active_zone has passive zones selected.

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

* Match the active zone by ID, localize the mixed-config error

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

* Skip location zones without a state on each check instead of in the cache

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

* Match the person state when in_zones is empty, for legacy device trackers

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

* Treat not_home as away in the location fallback, not as a zone name

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

* Cache location zones on the registries only

Resolve registered zones without a state through a placeholder, so one
missing zone no longer makes every location condition resolve again on
each state change.

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

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Copilot Autofix powered by AI <[email protected]>
2026-10-13 16:08:41 +03:00
Paul Bottein 25d8c3cadb Use the snap bottom sheet for the automation sidebar (#54664)
* Use the snap bottom sheet for the automation sidebar

* Use a fixed collapsed height for the automation sidebar sheet

* Make room for the half sheet before scrolling to an opened row

* Close the automation sidebar sheet by dragging it below the collapsed state

* Fall back to vh where dvh is not supported in the automation sidebar sheet
2026-10-13 14:49:39 +02:00
renovate[bot] 2034395784 Update dependency prettier to v3.9.10 (#54879)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-10-13 12:12:20 +02:00
Petar Petrov 2ad11a808d Use i18n contexts in the language, country, currency and trace pickers (#54877) 2026-10-13 11:19:45 +02:00
Paulus SchoutsenandClaude Opus 5.5 14866713ba Fix values and time picker in the adjust statistic dialog (#54876)
* Show exact values in the adjust statistic dialog

The dialog rounded each change to the display precision and showed the
raw JavaScript number. This caused three problems:

- The adjustment was calculated from the rounded value. Setting a
  change of 1.2345 to 0 left 0.0045 in the database.
- Changes smaller than the precision showed as 0. They could not be
  found or set to 0, because the new value equaled the shown value.
- The numbers did not use the number format of the user's locale.

The adjustment is now calculated from the stored value. Small changes
get more digits, and the values are formatted with the user's locale.

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

* Wrap the time field below the date field on narrow screens

The date and time fields of the datetime selector are too wide for one
row on a phone. The time field overflowed the dialog and cut off the
AM/PM field in the adjust statistic dialog.

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

* Redraw the adjust statistic dialog when the locale changes

The values use the number format of the user's locale. The dialog only
redrew on hass changes when the translations changed, so a new number
format did not show until another change.

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

* Show a missing change as zero in the adjust statistic dialog

A statistic row without a sum has a null change. The digit count
treated it as a very small value and showed 20 decimals.

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

---------

Co-authored-by: Claude <[email protected]>
2026-10-13 08:32:47 +03:00
Aidan TimsonandClaude Opus 5.5 d1365b02da Add sticky group headers to data tables (#54848)
* Add sticky group headers to data table

lit-virtualizer positions rows absolutely, so CSS sticky positioning
can't be used. Render an overlay copy of the current group header at the
top of the scroller, pushed up by the next group header, and allow
collapsing the group from it.

With the group header now always visible, leave the grouped column out
of the narrow secondary line (except for auto-height tables, which have
no sticky header).

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

* Keep sticky group header aligned and clear of focused rows

Shift the sticky group header's cell with the scroller's horizontal
scroll, so it lines up with the group header rows it covers, including
in RTL.

Add scroll padding to grouped tables, so a focused control in a row is
not left hidden under the sticky group header. Collapsing a group from
the sticky header scrolls a rendered header to the top directly, as
scrollIntoView would now leave room for the padding.

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

* Keep the sticky group header fixed instead of pushing it

Browsers scroll off the main thread, so the push offset set from scroll
events lagged behind the rows. Scrolling fast showed one header taking
over the other late, and scrolling up on mobile left a gap above the
next group header where rows showed through.

The sticky group header now stays at the top of the scroller, and the
next group header slides under it, so it no longer needs to move in
step with the scroll.

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

* Resize the sticky group header with the data table

The sticky group header's width was only measured on scroll, so after
resizing the window it kept its old width until the next scroll, letting
rows show through beside it. Observe the table's size to update it.

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

* Count a sticky group header within a pixel of the top as reached

The virtualizer can place rows at fractional positions, so a group
header scrolled to the top could sit a fraction of a pixel below it.
The sticky header then kept showing the previous group over it, for
example after collapsing a group from the sticky header.

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

* Refresh the sticky group after renders and make its whole row clickable

Filtering, sorting or collapsing can replace the virtualizer's items
without changing the visible range, so no visibility event fires and the
sticky group header kept showing the previous group until the next
scroll. Recompute it after each render.

The sticky header's cell shifts with horizontal scrolling, which left a
strip at the end of the header that didn't respond to clicks. Handle
clicks on the whole sticky row instead.

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

* Explain the sticky group scroll back for unrendered headers

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

* Memoize the data table row renderer

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

---------

Co-authored-by: Claude <[email protected]>
2026-10-13 08:31:08 +03:00
Erwin Douna 707dc96fb1 Localize live indicator on logs page (#54858) 2026-10-13 08:13:38 +03:00
Paulus SchoutsenandClaude db1a628b57 Keep hidden columns when reopening data table settings (#54874)
Keep column settings for tables without fixed last columns

The data table settings dialog dropped the stored column order when the
table had no lastFixed columns. With no column order, the dialog showed
every column as visible. Reopening the dialog on the statistics table
then showed hidden columns as enabled again.


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

Co-authored-by: Claude <[email protected]>
2026-10-13 07:55:48 +03:00
renovate[bot] 21c059e9fc Update dependency @types/node to v24.19.2 (#54868)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-10-13 06:28:23 +02:00
renovate[bot] 33c734ea8b Update pnpm to v12.11.2 (#54869)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-10-13 06:28:05 +02:00
Paulus SchoutsenandClaude d8751e5b89 Show Z-Wave device config link for controller node (#54859)
Claude-Session: https://claude.ai/code/session_01Q6GDGNkP4RZT3wg17BEhsD

Co-authored-by: Claude <[email protected]>
2026-10-12 19:18:05 +00:00
renovate[bot]andMaarten Lakerveld edb7a42a7e Update dependency @versatiles/style to v6.4.0 (#54856)
Co-authored-by: Maarten Lakerveld <[email protected]>
2026-10-12 14:33:35 +00:00
WendelinandClaude 65a1f0722f Fix image cropper race conditions (#54850)
* Migrate image cropper dialog to DialogMixin and wait for cropper ready

Closing the cropper dialog with the header close button or Escape did not
run closeDialog(), so the Cropper instance stayed bound to the removed
<img>. The next open called replace() on that stale instance: the new
image never got a source or crop overlay, and clicking Crop before the
hidden reload finished threw because getCroppedCanvas() returned null.

Using DialogMixin gives every open a fresh element, and the cropper is
destroyed on disconnect. The Crop button is now disabled until the
cropper is ready, and _cropImage bails out if no canvas is available.

Refs #54793

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

* Add file handling improvements to upload components and cropper dialog

- Reset native input value when cleared externally in ha-file-upload
- Introduce _pickedFile state in ha-picture-upload for better file management
- Ensure cancelCallback is invoked if cropping is not completed in image-cropper-dialog
- Update HaImageCropperDialogParams to include optional cancelCallback

* Handle unsupported file formats in picture upload and ensure dialog connection during blob creation

* Add handling for dialog closing state in image cropper dialog

---------

Co-authored-by: Claude <[email protected]>
2026-10-12 17:32:31 +03:00
Petar Petrov c1981b16f2 Disable Lit dev mode in the legacy build under pnpm (#54855)
Compile Lit without dev mode in the legacy build again
2026-10-12 16:17:28 +02:00
renovate[bot] dfc1971ab4 Update pnpm to v12.11.1 (#54851)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-10-12 17:08:11 +03:00
768d61e223 Add query strings and My links to the Marketplace (#54787)
* Open the Marketplace with a search from the link

* Add My links for the Marketplace tabs and custom repositories

* Sort ascending when a Marketplace link gives no direction

* Keep the Marketplace link in step with the table

* Apply Installed deep-link settings behind Marketplace warning

---------

Co-authored-by: Claude <[email protected]>
Co-authored-by: copilot-swe-agent[bot] <[email protected]>
2026-10-12 17:07:40 +03:00
Claudio Wunder 04af517a22 Show Thread network information on its own page (#54818)
* Show Thread network information on its own page

The Thread panel showed a network's details behind an unlabeled info
icon that opened a plain-text dialog. Zigbee shows the same kind of
details through a "Network information" row that opens its own page,
so move Thread to that pattern.

Each network card with a stored dataset now ends with a "Network
information" row that opens /config/thread/network-info/<dataset_id>.
A new thread-config-router serves the panel and the page, like the
Zigbee and Bluetooth routers, and the border router lookup moves to
findOTBRInfoForDataset so both match border routers the same way.

Assisted-by: Claude Opus 5.5 <[email protected]>

* Align the Thread network delete button with the network name

The 48px icon button was top-aligned with the shorter header text, so it
sat about 10px low. Center it on the name, and pull its block margins in
so the header keeps the same height as cards without the button.

Assisted-by: Claude Opus 5.5 <[email protected]>

* Show an error when Thread network info fails to load

If thread/list_datasets rejected, the page never finished loading and
stayed empty, with an unhandled rejection. Catch the failure, always
finish loading, and show the error message on the error screen.

Assisted-by: Claude Opus 5.5 <[email protected]>

* Show the Thread dataset before border router details load

otbr/info asks every OpenThread border router over REST, with a 10 s
timeout per call, so the network information page stayed empty while
an unreachable border router timed out. Show the dataset as soon as it
loads, without waiting for otbr/info, and show a Loading row in place
of the border router details until it answers.

Assisted-by: Claude Opus 5.5 <[email protected]>

* Rename the Thread config router to thread-config-dashboard-router

thread-config-router read like a Thread border router. Match
zha-config-dashboard-router and the other config dashboard routers.

Assisted-by: Claude Opus 5.5 <[email protected]>

* Say what the Thread network information page is loading

Use "Loading border router details…" for the row shown while otbr/info
answers, instead of a bare "Loading".

Assisted-by: Claude Opus 5.5 <[email protected]>

* Name each Thread network information landmark after its network

Every network card had a navigation landmark named "Network
information", so screen reader landmark navigation couldn't tell them
apart. Include the network name, or its extended PAN ID when it has none.

Assisted-by: Claude Opus 5.5 <[email protected]>
2026-10-12 17:06:40 +03:00
Petar Petrov 0d74036677 Remove dead .hass bindings (#54853) 2026-10-12 15:46:46 +02:00
Petar Petrov 7a69aac65a Remove unused hass properties from selectors and pickers (#54852) 2026-10-12 15:45:26 +02:00
Josef Zweck 4407404206 Show translated backup agent errors (#54847)
* Show translated backup agent errors

* Add tests for localizing backup agent errors
2026-10-12 16:14:05 +03:00
Markus TuominenandCopilot Autofix powered by AI d6ed305a8b Offer versioned SSL profiles in the HTTP server settings (#54559)
Co-authored-by: Copilot Autofix powered by AI <[email protected]>
2026-10-12 12:39:42 +00:00
Aidan TimsonandClaude 13ea2643ee Hide last updated time on narrow marketplace dashboard rows (#54846)
Hide Marketplace last updated on narrow rows

A bare relative time like "4 months ago" has no context in the narrow
secondary line. The repository overview already shows it with a label.


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

Co-authored-by: Claude <[email protected]>
2026-10-12 12:25:20 +00:00
Aidan TimsonandClaude cbae291079 Match the tab badge colors to the Apps update tag (#54845)
Match the Marketplace update badge to the Apps update tag

The update badge on the Installed tab and its narrow dot used the
warning color, while Apps marks an available update with a light
primary tag. Use the same primary fill and text colors so both read
the same way.


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

Co-authored-by: Claude <[email protected]>
2026-10-12 15:16:59 +03:00
renovate[bot] 71c061b68b Update dependency @rsdoctor/rspack-plugin to v1.6.5 (#54844)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-10-12 15:02:04 +03:00
Aidan Timson 26dcbfccfc Fix Marketplace update badge and row numbers on mobile (#54843) 2026-10-12 13:47:42 +02:00
Paul Bottein 3bb04f417c Remove name prefix stripping from generated dashboards (#54727)
* Remove name prefix stripping from generated dashboards

* Keep an empty entity name in generated card rows
2026-10-12 13:45:53 +03:00
Paul Bottein 03e5b8148b Simplify entity name helpers (#54726) 2026-10-12 13:45:53 +03:00
Paul Bottein 49e6a744e2 Finalize the entity naming migration (#54725)
* Finalize the entity naming migration

* Open the name picker in custom mode for a text default name
2026-10-12 13:45:53 +03:00
Petar Petrov 23c75da8f5 Number energy grid colors per direction so export-only connections are not white (#54808) 2026-10-12 10:53:15 +02:00
Petar Petrov fdae33a5ec Keep History and Activity filters usable with many targets (#54786) 2026-10-12 09:06:45 +01:00
Bascht74 ae7ce92a79 Keep adjust statistic dialog open after adjusting and refresh the list (#54628)
* Keep adjust statistics dialog open and refresh list after adjusting

* Guard adjustment against dialog close and handle refresh errors

* Ignore results of outdated statistics fetches

* Reset dirty state on close and guard failed adjustment
2026-10-12 08:04:29 +00:00
renovate[bot] c3ed2635e4 Update pnpm to v12.11.0 (#54842)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-10-12 11:02:11 +03:00
Daan Vervacke 1f2d5af99f Add selection mode with bulk delete to the tags page (#54819) 2026-10-12 09:46:46 +02:00
Claudio Wunder 1b4ff42821 Show empty state on MQTT panel when MQTT is not set up (#54817)
The MQTT panel rendered nothing when there is no active MQTT config
entry, leaving a blank page. That happens when another integration
loads MQTT as a dependency, so the Connectivity page still links to
the panel. Show an empty state with a button that starts the MQTT
config flow instead.

Assisted-by: Claude Opus 5.5 <[email protected]>
2026-10-12 09:24:01 +03:00
Paulus SchoutsenandClaude Opus 5.5 eec2ff9fa5 Treat unknown media player state as available in media control card (#54835)
* Set no-controls on media control card when entity is unavailable

An entity in the unknown state with assumed_state and turn on/off
support gets control buttons from computeMediaControls. The card hides
the controls for unknown and unavailable entities, but still left out the
no-controls class. That removed the bottom padding and made the card too
short.

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

* Treat unknown media player state as available in media control card

A media player in the unknown state is available. The card now shows
its title and controls, such as the turn on and turn off buttons of an
assumed state player, instead of the unavailable layout.

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

---------

Co-authored-by: Claude <[email protected]>
2026-10-12 08:58:33 +03:00
6a9501fa8a Unsubscribe from system health info only once (#54838)
The info page and the system log detail dialog unsubscribe from the
system health callback. The callback runs again for each update that
arrives before the unsubscribe completes, and each run unsubscribes
again. The finish event can unsubscribe once more. The backend rejects
every extra unsubscribe with "Subscription not found.", which ends up
as an unhandled promise rejection.

Make the unsubscribe function from subscribeSystemHealthInfo run only
once, which fixes all callers.

Co-authored-by: Paulus Schoutsen <[email protected]>
Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-12 08:18:24 +03:00
bb7308a024 Fix Z-Wave node config page for parameters without an endpoint (#54840)
The backend sends endpoint as null for configuration values on the
root endpoint, because zwave-js-server-python types Value.endpoint as
int | None. The frontend typed it as number and grouped parameters
with item.endpoint.toString(), so the page threw on every render and
stayed on the loading spinner.

Type endpoint as number | null. Treat null as endpoint 0 when grouping
parameters and when setting a parameter, because the backend set
command only accepts an integer endpoint.

Co-authored-by: Paulus Schoutsen <[email protected]>
Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-12 08:16:08 +03:00
renovate[bot] 1c151c65f2 Update dependency del to v9 (#54831)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-10-12 07:04:32 +02:00
439d61966d Fix media control card reading state before config is set (#54834)
The card can update with hass before setConfig is called. The progress
controller reads the state object on every host update, and the
_stateObj getter read _config.entity without a check. Each card then
threw a TypeError as an unhandled promise rejection.

The frontend logger maps the stack of every unhandled rejection with
source maps on the main thread. On a dashboard with 40 media control
cards, the 80 rejections blocked the main thread for about 3 seconds
on load.

Return undefined from _stateObj until the card has a config.

Co-authored-by: Paulus Schoutsen <[email protected]>
Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-12 06:44:53 +02:00
renovate[bot] 6dea194706 Update relative-ci/agent-action action to v3.2.4 (#54836)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-10-12 06:44:35 +02:00
renovate[bot] 31b76346d2 Update dependency vite to v8.3.4 (#54829)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-10-12 06:44:28 +02:00
renovate[bot] 87c079829a Update release-drafter/release-drafter action to v7.9.0 (#54837)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-10-12 06:42:58 +02:00
Petar Petrov 158c14b6ab Show device area in energy device graph tooltips (#54826) 2026-10-11 17:27:19 +02:00
Paulus SchoutsenandClaude de82c36acb Add bottom padding to AI and entity ID format settings pages (#54828)
The last card on these pages touched the bottom edge when scrolled to
the end. Use the same 24px bottom spacing as the other settings pages.


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

Co-authored-by: Claude <[email protected]>
2026-10-11 17:26:16 +02:00
Patrick DecatandClaude Opus 5.5 32801130ba Close the previous ZHA permit subscription on search again (#54811)
* Close the previous ZHA permit subscription on search again

"Search again" called _subscribe() while the subscription from the first
search was still open, since _deactivate() only stops the UI at the end of
the permit window. Each zha/devices/permit subscription keeps ZHA debug
logging enabled in the backend until it is closed, so the leaked one kept
the backend in debug mode until the websocket connection closed.

Close any previous subscription before opening a new one.

Co-authored-by: Claude Opus 5.5 <[email protected]>

* Wait for the previous ZHA permit subscription to close before resubscribing

_unsubscribe() only called the previous unsubscriber from a promise
callback, so _subscribe() sent the new zha/devices/permit subscription
before the old one was closed, and the backend started the next debug
logging session while the previous one was still running.

Chain the new subscription onto the previous unsubscribe instead.
_subscribed always holds the latest chain, so a quick second search waits
for the first one, and leaving the page unsubscribes whatever that chain
ends up subscribing.

Co-authored-by: Claude Opus 5.5 <[email protected]>

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-11 09:28:35 +03:00
renovate[bot] 0f09f11ac4 Update CodeMirror (#54820)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-10-11 09:27:24 +03:00
MichaelandClaude Opus 5.5 6f3c4b427b Fix Matter add device button not opening the add device dialog (#54823)
The add device button on the Matter dashboard linked to
/config/matter/add, which navigated to the devices dashboard and opened
the Matter add device dialog without awaiting the navigation. The
navigation closes open dialogs before it completes, so the dialog was
closed again right away and the user ended up on the devices dashboard.

Open the dialog directly from the Matter dashboard, like the Z-Wave
dashboard does, and make the /config/matter/add route (used by the
add_matter_device my link) await the navigation before showing the
dialog from the app root, as its own element is detached by then.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-11 09:26:58 +03:00
renovate[bot] 809ebc5095 Update Playwright (#54824)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-10-11 09:26:06 +03:00
renovate[bot] fbd1de0ec7 Update dependency cropperjs to v1.7.0 (#54810)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-10-10 17:16:56 +02:00
renovate[bot] 9f50d399a6 Update babel monorepo to v8.0.7 (#54813)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-10-10 17:16:19 +02:00
renovate[bot] da632926bb Update dependency @versatiles/style to v6.3.1 (#54814)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-10-10 17:15:37 +02:00
renovate[bot] 0e9b62e7d0 Update dependency @versatiles/style to v6.3.0 (#54804)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-10-10 05:42:05 +00:00
Elias Lecomte 2f09fb9e5b Improve demo water consumption and cost statistics (#54648)
* Add demo water statistics and long energy source names

* Align demo water statistics scaling and consumption costs

* Preserve non-admin permissions when the test app refreshes its user

* Restore original demo energy meter friendly names

* Remove unrelated authentication scenario fix from water statistics PR

* Derive demo costs generically and scale water by bucket duration
2026-10-10 08:39:06 +03:00
karwosts 1a922fc067 Fix time_format field appearing when it shouldn't in tile editor (#54799)
Fix time_format field appearing when it shouldnt in tile editor.
2026-10-10 08:35:10 +03:00
renovate[bot] 54e15575d2 Update pnpm to v12.10.1 (#54803)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-10-10 08:32:26 +03:00
renovate[bot] e9bfc29729 Update dependency maplibre-gl to v6.13.0 (#54806)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-10-10 08:31:33 +03:00
Jan-Philipp Benecke 743755a59f Fix script graph crash on template shorthand conditions (#54798)
* Fix script graph crash on template shorthand conditions

* Move string to ConditionWithShorthand
2026-10-09 16:17:44 -07:00
Aidan TimsonandClaude ea1d3fef73 Add E2E tests for creating an automation (#54791)
Cover creating an automation from the picker through the save dialog,
the save dialog refusing an empty name, and discarding a new automation
when leaving the editor.

The app harness now registers the demo's display registry mock, which
answers the refetch the app makes when a saved automation fires
entity_registry_updated.


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

Co-authored-by: Claude <[email protected]>
2026-10-09 19:02:28 +03:00
karwosts 8a83331e94 Fix autocomplete trying to fill username in weird places (#54789) 2026-10-09 16:59:22 +02:00
renovate[bot] b207a486dd Update dependency @versatiles/style to v6.2.0 (#54777)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-10-09 15:39:04 +02:00
Petar Petrov 00b3dfd43a Link Ethernet Matter nodes to Home Assistant in the network map (#54784) 2026-10-09 14:00:12 +02:00
Aidan Timson 8f38778180 Only offer installed statuses on the Marketplace installed tab (#54662) 2026-10-09 13:51:22 +02:00
Petar Petrovandcopilot-swe-agent[bot] e9ed4c5c66 Add local disk mounts to storage settings (#53643)
Co-authored-by: copilot-swe-agent[bot] <[email protected]>
2026-10-09 13:04:57 +02:00
Bruno Pantaleão Gonçalves 3ae69b041b Let the tab padding give way before the tab label (#54670) 2026-10-09 13:03:42 +02:00
Paul Bottein f7804a92f4 Use the entity name formatter in voice assistant settings (#54775) 2026-10-09 12:56:39 +02:00
Maarten Lakerveld fb0dcf29cb Map overview fixes (#54774) 2026-10-09 12:55:19 +02:00
tzagim bf564163bf Fix RTL bidi corruption of temperature in weather entity row (#54776) 2026-10-09 12:38:11 +02:00
Bram KragtenandClaude Opus 5 93b04625ac Drop ha-formfield in favour of the switch's own label (#54200)
Co-authored-by: Claude Opus 5 <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-09 12:26:20 +02:00
Bram KragtenandClaude Opus 5 13c3155dab Remove dead --mdc-typography-* custom properties (#54201)
Co-authored-by: Claude Opus 5 <[email protected]>
2026-10-09 12:18:50 +02:00
WendelinandClaude 9435c5494d Remove @material/web (#54580)
Co-authored-by: Claude <[email protected]>
2026-10-09 12:00:20 +02:00
Paul Bottein 4fbd34ac81 Use the entity name formatter in the entity filter, related items and logbook (#54773) 2026-10-09 12:50:02 +03:00
Paul Bottein 70390d7e43 Simplify how data tables reach the screen edges (#54714) 2026-10-09 11:32:26 +02:00
Bram KragtenandClaude Opus 5.5 43395ab59b Remove invalid ::part() selector that broke CSS minification (#54772)
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-09 09:27:11 +00:00
Jan-Philipp Benecke 4247a1439e Fix crash on null trigger entries in the automation editor (#54771) 2026-10-09 09:15:00 +00:00
JonathanandXyolyp 0ecf3a0bb3 Link trace steps to the script or automation run they started (#54518)
* Link trace steps to the script or automation run they started

A step that runs a script or triggers an automation already records the started run as child_id in its trace, but the step details only dumped it as raw YAML. Show a link to that run's trace instead, named after the script or automation. A link to another run of the same item, like a script that starts itself, only changes the query string, so the trace pages now pick up a changed run_id themselves. The logbook's trace link and the new link share one URL helper, which encodes the item id.

* Keep the shown trace run in the URL

A link to another run of the same script or automation adds a history entry, but the trace page only followed in-app navigation, so browser back and forward left the URL and the shown run apart. Write the shown run into the current history entry, follow popstate within the same trace page, and let only the latest trace list request update the page. Entries without a run_id are ignored, since closing a dialog with back lands on one with the same URL.

* Let only the latest trace request update the trace page

Going back to the shown run while a linked run was still loading left the older request in charge: it then showed the linked run and wrote it into the history entry the user went back to. Remember which run the latest trace list request is for and drop it when the URL returns to the shown run. Loading the same run twice could also let the older response win, so trace requests are numbered as well.

* Let a URL change replace the first trace list request

While a trace page was loading its first trace list, a link or browser history step to another run of the same script or automation was ignored, so that first request won and wrote its run into the newer history entry. Handle such a change right away, so it replaces the pending request.

* Let a run picked on the trace page win over a pending link

While a link to another run of the same script or automation was loading, a run picked with the trace picker or the arrows was overwritten once the link's trace list arrived, and Refresh dropped the linked run while the URL kept it. A picked run now cancels the pending link, and Refresh keeps the run the link asked for.

* Move the trace run navigation into a shared controller

Both trace pages carried the same logic to follow the URL, number their requests and write the shown run back into the URL. Move it into a TraceRunController with unit tests, build the trace page path in one place, and split the child trace name from its link. No change in behaviour.

* Link trace timeline steps to the run they started

The step details link a step that ran a script or triggered an automation to the run it started. Show the same link under that step in the trace timeline, and share the name lookup and the link between both views.

* Compare trace paths decoded

The route decodes the item id, so a trace page can also be opened by a
link that encodes it differently, like the logbook's links, which do not
encode it. Compare the decoded paths, so that the URL follows the shown
run there too.

---------

Co-authored-by: Xyolyp <[email protected]>
2026-10-09 11:56:00 +03:00
WendelinandClaude aea94f557b Migrate ha-outlined-icon-button to ha-icon-button (#54571)
Co-authored-by: Claude <[email protected]>
2026-10-09 10:55:32 +02:00
Franck Nijhof cd6bdc0344 Don't count the characters of a string target (#54769) 2026-10-09 08:52:30 +00:00
Franck Nijhof 844d7bc0e5 Keep custom logbook messages for automations and scripts (#54739)
* Keep custom logbook messages for automations and scripts

* Only match the domain's own run message
2026-10-09 11:48:14 +03:00
Franck Nijhof b4c6035a75 Convert CSS color names to hex when saving a label (#54738) 2026-10-09 08:45:01 +00:00
Franck Nijhof f77295fcfa Keep dropdown menus clear of safe areas (#54762) 2026-10-09 10:41:39 +02:00
Paul Bottein 951ca9e9f1 Use the entity name formatter in automation descriptions (#54767) 2026-10-09 10:41:23 +02:00
Claudio Wunder e7f3a02d73 Fix thin line flashing under empty sliders while dialogs open (#54716) 2026-10-09 08:40:44 +00:00
Franck Nijhof 9555f6f4d7 Give dialogs an accessible name (#54758) 2026-10-09 10:40:23 +02:00
Franck Nijhof d7f39d3804 Hide hidden cards in grid sections without :has() (#54764) 2026-10-09 11:39:41 +03:00
Franck Nijhof 854f28fc26 Translate attribute names every entity can have (#54768)
* Translate attribute names every entity can have

* Translate the supported features attribute name
2026-10-09 11:38:31 +03:00
Franck Nijhof ef587c6b77 Release streaming images when hui-image is detached (#54760) 2026-10-09 10:36:18 +02:00
Franck Nijhof 70a96e47ca Show the speech-to-text duration without VAD (#54759) 2026-10-09 10:35:26 +02:00
Franck Nijhof 28e7cb39fb Show the ZHA config entry state when it isn't loaded (#54756) 2026-10-09 10:32:10 +02:00
Maarten Lakerveld 49d01d20eb Center the map on a single entity instead of fitting it (#54718)
The vector map's globe projection (new in 2026.10) cannot solve a camera
for a zero-area bound, so fitting a single-entity map warned "Map cannot
fit within canvas" and left the view on the default location. Center a
single point directly with easeTo/flyTo rather than routing it through
fitBounds, carrying the fit's padding as a transient offset and honouring
its fly request.
2026-10-09 11:30:35 +03:00
Franck Nijhof 941b167c82 Keep icon button labels off the ha-button host (#54761) 2026-10-09 11:28:13 +03:00
Franck Nijhof e908ac15d3 Greet in the interface language when it is a variant of the pipeline language (#54766) 2026-10-09 11:27:33 +03:00
Aidan TimsonandPetar Petrov 20cd4683b0 Add area labels to the pull request labeler (#54697)
* Add area labels to the pull request labeler

* Label Labs pull requests from the Labs API as well as paths

* Rename the Landingpage label to Landing Page

* Label Supervisor pull requests that change src/data/hassio

* Label pull requests from area code as well as paths, and only add labels

* Remove stale labels again, owning code-matched ones in the script

* Warn instead of failing the labeler, and summarise changes

* Move the run summary into a shared helper

* Split the Activity label from History

* Add labels for the remaining areas and split broad ones

* Apply the Home Assistant Link label from the content script

* Catch more companion app usage in the content labels

* Add Notifications, Search and Sidebar labels and widen some rules

* Add the AI label

* Label trace changes as Automations

* Match content labels on whole calls against the current labels

* Run the labeler one at a time per pull request

* Label dashboard creation, quick bar and theme loading changes

* Compare Labs and companion app calls with their arguments

* Match full companion app calls and imports in content labels

* Label updates list changes as System

* Match companion app property reads and renamed files in content labels

* Remove

Co-authored-by: Petar Petrov <[email protected]>

* Add ingress panel

Co-authored-by: Petar Petrov <[email protected]>

---------

Co-authored-by: Petar Petrov <[email protected]>
2026-10-09 11:25:29 +03:00
Franck Nijhof c6c85e862d Expose the real invalid state of inputs to screen readers (#54757) 2026-10-09 10:25:05 +02:00
Franck Nijhof c74b9831af Fit the add integration list on short screens (#54755) 2026-10-09 10:18:10 +02:00
Petar Petrov eb4b1e8b3d Fix negative upstream device untracked energy in devices detail graph (#54753) 2026-10-09 10:16:56 +02:00
Petar Petrov 3cd56c4b9c Follow the active row in virtual-focus lists after a removal (#54765) 2026-10-09 10:01:02 +02:00
WendelinandClaude Opus 5.5 e11cdc16ec Migrate remaining ha-md-list usages and remove ha-md-list (#54569)
Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Claude <[email protected]>
2026-10-09 09:10:04 +02:00
Petar Petrov a42ba01ca2 Hide Zigbee from the ESPHome device setup wizard (#54751) 2026-10-09 08:59:02 +02:00
Franck Nijhof 941dbb5697 Fall back to local voice setup when the cloud status fails (#54754) 2026-10-09 09:51:54 +03:00
renovate[bot] 3d6061e6e0 Update pnpm to v12.10.0 (#54752)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-10-09 09:33:57 +03:00
Claudio Wunder 21b07240ab Speed up building history line chart data (#54671)
* Allocate less per point when building history line chart data

For a single dataset, a plain numeric value went through
pushData(date, [value]), which allocates a one element array and a
forEach callback for every point. Push [timestamp, value] directly and
reuse one array for prevValues, which is only ever read for its latest
entry. The output is unchanged.

pnpm test:bench state-history-chart-line-data, median of 8 alternating
runs: mixed large (100k states) 8.62 -> 5.83 ms, mixed medium (10k
states) 0.80 -> 0.58 ms. In a production browser build the function took
about 101 ms before and after on 1.34 million points.

Assisted-by: Claude Sonnet 5.5 <[email protected]>

* Tighten the per-point loop in history line chart data

Profiling generateStateHistoryChartLineData on a large history put most
of the remaining time in the per-point path. Iterate the states with an
indexed loop instead of forEach, read the series array once, and track
the y range inline instead of through trackY, since the value is always
finite there. The output is unchanged.

These only help together. On their own, the hoisted array and the inline
min/max measured no change, and for...of loses most of the gain, so
@typescript-eslint/prefer-for-of is disabled for this one loop.

Cold first call in a fresh headless Brave, median of 7 runs, on 1.34
million states: 87.9 -> 77.2 ms on top of the previous commit (98.7 ms
on dev). At 100k states: 5.2 -> 4.1 ms.

Assisted-by: Claude Opus 5.5 <[email protected]>

* Add a test for history line chart states past the end time

The characterization tests never had a state after endTime, so the path
that drops those points was not covered. That path is where the faster
pushValue skips updating the reused prevValues array, and the end point
of the line depends on it.

Add a numeric sensor with a value and a gap that only closes after
endTime. The snapshot was generated on dev before being run against this
branch, so it pins the previous behavior. Updating the reused array
before the endTime check makes only this test fail.

Assisted-by: Claude Opus 5.5 <[email protected]>

* Drop the indexed loop from history line chart data

The previous commit gives nearly all of the gain, so the indexed loop,
the hoisted series array and the inline y range aren't worth disabling
@typescript-eslint/prefer-for-of and keeping a second copy of trackY.

This reverts commit 952022b91.

Assisted-by: Claude Opus 5.5 <[email protected]>
2026-10-09 09:24:52 +03:00
4fa8cf6dc7 Show energy gas in the unit of its sources (#54677)
* Show therm gas in therms on the energy dashboard

Gas sources with an energy unit class were always converted to and shown
in kWh. Now that therm is a native energy unit, show gas in therms when
every gas source reports therms, and request those statistics in therms
for both the current and the compare period. Any other energy unit, or a
mix of units, keeps showing kWh as before.

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

* Keep kWh when a therm gas statistic is also a device

The device charts read the same statistics map and label values as kWh.
If a therm gas statistic is also configured as an individual device, the
therm values would be shown as kWh there, so keep kWh for gas in that case.

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

* Use the unit of energy gas sources for any energy unit

Volume gas and water already use the display unit of their sources when
they all share one. Do the same for energy gas instead of always showing
kWh, by adding an ENERGY_UNITS list next to VOLUME_UNITS and requesting
the gas statistics in that unit. Different units, or a gas statistic that
is also an individual device, still fall back to kWh.

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

* Refactor energy statistics handling in energy.ts

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

* Test gas statistics shared with grid, solar and battery

Cover the fallback to kWh when a therm gas statistic is also used as a
grid, solar or battery statistic, and update the comment to match.

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

---------

Co-authored-by: Claude Sonnet 5.5 <[email protected]>
Co-authored-by: Copilot Autofix powered by AI <[email protected]>
2026-10-09 06:18:46 +00:00
Franck Nijhof c0ed2e1b59 Keep the label of color inputs raised (#54737) 2026-10-09 08:52:39 +03:00
Simon Lamon 5a96e1829f Restore compatibillity with older chromium devices in ScrollFadeController (#54742)
Restore compatibillity with older chromium devices
2026-10-09 08:49:33 +03:00
renovate[bot] 10c91bb9a3 Update dependency vite to v8.3.3 (#54748)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-10-09 05:08:01 +00:00
renovate[bot] d74804b44f Update dependency zxing-wasm to v3.1.5 (#54747)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-10-09 06:56:27 +02:00
renovate[bot] fbe209cd0b Update dependency nanoid to v6.0.2 (#54746)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-10-09 06:24:23 +02:00
renovate[bot] a66e13b0b9 Update dependency js-yaml to v5.4.3 (#54745)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-10-09 06:24:04 +02:00
Franck Nijhof dca4a8e0a9 Load Marketplace icons through the Marketplace brands view (#54740) 2026-10-08 15:27:54 -04:00
480182c2b3 Make the Marketplace warning a friendlier modal over the Marketplace (#54586)
* Make the Marketplace warning a friendlier modal over the Marketplace

Replace the full-page first-run warning with a calmer dialog shown over
the Marketplace, which stays visible but can't be used until the notice
is accepted. Continue is enabled once the checkbox is ticked, without
the 30 second countdown. Go back, or Escape, leaves the Marketplace.

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

* Update the Marketplace warning to the new design

Lead with what installing can do, in a highlighted list, followed by
numbered steps to stay safe, and agree to the risks explicitly.

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

* Update the Marketplace warning tests to the modal

Test the warning's accept, failure and go back logic instead of the
countdown, test that the Marketplace is inert behind the warning and
starts over once it is accepted, and drop the countdown strings.

Focus the checkbox when the warning opens, and drop the close handler:
neither Escape nor the scrim close the warning, Go back leaves it.

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

* Keep links from acting behind the Marketplace warning

Until the warning is accepted, the Marketplace behind it shows the
catalog whatever the route is, so a link can't add a repository or open
a dialog over the warning. It starts over on the route once accepted.

Use the animation duration token for the agree row, which respects
reduced motion.

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

* Add role attribute to marketplace warning list

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

* Enhance ha-dialog-header and ha-marketplace-warning components with improved structure and styling

* Refactor marketplace panel tests to improve clarity and structure

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Copilot Autofix powered by AI <[email protected]>
Co-authored-by: Wendelin <[email protected]>
2026-10-08 20:30:54 +02:00
Petar Petrov 2d11eba37f Show only serial integrations when setting up an ESPHome serial port (#54668)
* Show only serial integrations when setting up an ESPHome serial port

The add integration dialog gets an integrationFilter param with a list of
domains and trims the integration descriptions to them. The ESPHome serial
Set up button fetches usb/list_serial_integrations and passes it. If that
fails, it opens the unfiltered dialog as before.

* Check for a newer add integration load after loading translations
2026-10-08 20:23:20 +02:00
Maarten Lakerveld 5821cc6e53 Drop the Mapbox RTL text plugin now that MapLibre shapes RTL text itself (#54706)
Since MapLibre 6.9.0 Arabic and Hebrew labels are shaped and reordered by
MapLibre, and setRTLTextPlugin is deprecated. We were still loading the
0.4.0 plugin, which only replaced that built-in implementation with the
same result. Version 1.0.0 of the plugin drops the UMD bundle we copied
into static assets and no longer works as a plugin at all, so bumping it
breaks the build. Remove the dependency, the static copy and the plugin
registration instead.
2026-10-08 20:21:45 +02:00
renovate[bot] ade5d15fee Update dependency @formatjs/intl-datetimeformat to v7.8.1 (#54735)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-10-08 18:15:34 +00:00
Paul Bottein b8ea4d12b9 Replace map overview more info button with a header icon (#54704)
* Replace map overview more info button with a header icon

* Reuse the show more information label

* Show a settings button for admins in the map overview

* Hide the map overview settings button in the demo
2026-10-08 20:14:37 +02:00
Paul Bottein fcb7efdec2 Use the entity name formatter in dashboards (#54724)
* Use the entity name formatter in dashboards

* Refresh map trail names when the entity name formatter changes

* Show the device only once in the card editor entity search

* Make the name optional in the bootstrap entity name formatter
2026-10-08 20:08:21 +02:00
renovate[bot] 8dfcbe7a74 Update dependency intl-messageformat to v12.1.3 (#54734)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-10-08 18:04:13 +00:00
JonathanandXyolyp 5509c405ad Encode the automation id in activity trace links (#54733)
Encode the item id in the activity trace link

Automation ids are free-form, so an id with a question mark, a hash or a
percent sign broke the "View trace" link of the activity details. Encode
it like the automation editor and the automation list do.

Co-authored-by: Xyolyp <[email protected]>
2026-10-08 19:59:02 +02:00
Paul Bottein bdd67e89e2 Add side padding to the statistics search field (#54717) 2026-10-08 19:56:05 +02:00
renovate[bot] a9f1a24860 Update dependency typescript-eslint to v8.71.1 (#54732)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-10-08 19:53:44 +02:00
renovate[bot] 20062433ec Update dependency marked to v18.1.0 (#54730)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-10-08 19:38:39 +02:00
Franck Nijhof d3ed92978f Only cache the frontend's own static files in the service worker (#54707) 2026-10-08 18:09:28 +02:00
Franck Nijhof 2ae987504a Show the right page title for more settings pages (#54708) 2026-10-08 17:54:56 +02:00
Franck Nijhof 8662da2e8a Respect custom icons for built-in panels in the sidebar (#54709) 2026-10-08 18:24:05 +03:00
Paul Botteinandcopilot-swe-agent[bot] f2c2b9e472 Add a debug tool to simulate safe areas (#54719)
Co-authored-by: copilot-swe-agent[bot] <[email protected]>
2026-10-08 15:01:14 +00:00
Petar Petrov 47289adbab Drop hass from ha-network-graph and add a reduced motion context (#54713) 2026-10-08 16:52:15 +02:00
Claudio Wunder 6315f4a9e2 Coalesce live updates on large history charts (#54669)
* Add a delay calculation for live history updates

Rebuilding the History charts for a large history takes tens of
milliseconds, and it grows with the number of states, so applying every
live stream message to one makes the page hitch.

Add countHistoryStates and computeHistoryUpdateDelay. A history under
100,000 states is never delayed. For a larger one the delay is the time
one chart pixel covers (assuming 1000 pixels), between one second and a
maximum the caller passes in.

Assisted-by: Claude Sonnet 5.5 <[email protected]>

* Coalesce live updates on large history charts

When a History range ends in the future, Core keeps streaming states and
every message rebuilds the whole history: computeHistory, the line chart
data and the downsampling all run again over every point. With 1.34
million states (30 days of a sensor updating about every 2 seconds)
that is a 150 to 200 ms long frame per message.

Apply the first message right away, then coalesce later ones with
computeHistoryUpdateDelay, using MIN_TIME_BETWEEN_UPDATES as the maximum.
Every message carries the full history, so the latest one wins. The
timer is cleared when the panel unsubscribes.

A replaced subscription can still deliver until its unsubscribe is
acknowledged. Ignore those messages, so a coalesced one can't land
minutes later on top of the new range.

Over 25 s on a production demo build with a mocked live stream (median
of 5 runs): rebuilds 21 -> 0, long frames 22 -> 0, main thread blocked
2057 -> 0 ms, heap after GC 500 -> 288 MB.

Assisted-by: Claude Sonnet 5.5 <[email protected]>

* Use the measured chart width for the live update delay

The delay was the time one pixel covers on a chart assumed to be 1000
pixels wide. Measure the charts the same way the timeline downsamples
them, so wider or high density charts update as often as a pixel
changes.

Assisted-by: Claude Opus 5.5 <[email protected]>

* Keep the history legends current while live updates wait

The line chart legends show the current state of each entity, but the
charts skip updates where only hass changed. While a live update waited,
the legends could lag behind by up to five minutes. Request an update of
the charts when an update is held back, so they follow the current
states.

Assisted-by: Claude Opus 5.5 <[email protected]>
2026-10-08 17:34:30 +03:00
Artur Pragacz 6269d09b0d Fix translation key for external config flow step description (#54722) 2026-10-08 14:19:51 +00:00
Petar Petrov 98cb371d86 Drop hass from the distribution card (#54711)
* Drop hass from the distribution card

The card consumes the states of its own entities, the formatters,
localize and the core config. It no longer re-renders when an
unrelated entity changes.

* Re-render the distribution card when the theme changes

The card resolves its graph colors from the theme when it renders, so
it has to render again when the theme changes.
2026-10-08 16:07:39 +02:00
Petar Petrov 308b03e432 Pass localize and config to createEntityNotFoundWarning (#54710)
The helper took a hass-shaped object and an unused entity ID. It now
takes the two values it reads. The dead .hass bindings on hui-warning
are removed too; the element has had no hass property since #52926.
2026-10-08 15:54:14 +02:00
Claudio Wunder 24e15e76d9 Avoid recomputing chart series when only options change (#54666)
* Avoid recomputing chart series when only options change

The history line chart downsamples every point of a series before it is
handed to ECharts. ha-chart-base did that again on every options change:
_updateHiddenStatsFromOptions requested an update for _hiddenDatasets
each time it ran, even when no dataset had been hidden, and
_applyChartUpdate rebuilds and downsamples all series whenever
_hiddenDatasets is flagged.

Only the history line chart samples its data, and its x axis bounds
change together with its data, so rebuilding on data or hidden dataset
changes covers every input of _getSeries.

On a 30 day range of a sensor with 1.34 million states, loading the chart
called _getSeries 4 times, and now calls it once. Median of 7 runs on a
production demo build: _getSeries 85 -> 29 ms, time from data delivery to
the first frame 273 -> 209 ms.

Assisted-by: Claude Sonnet 5.5 <[email protected]>

* Drop the chart screen reader connection when the options change

Chart2Music is built with the axis names and value labels of the
current options. It used to be dropped on every options change because
_hiddenDatasets was always flagged, which now only happens when a
dataset gets hidden. Drop it whenever the options change, so the next
keyboard focus rebuilds it with the current ones.

Assisted-by: Claude Opus 5.5 <[email protected]>
2026-10-08 13:30:11 +00:00
XanderandClaude Opus 5.5 77d80bdec4 Add copy, cut and paste for dashboard sections (#54652)
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-08 10:46:01 +00:00
Franck Nijhof 8d0d8160f5 Handle map labels without a name (#54689) 2026-10-08 12:16:49 +02:00
Bram KragtenandClaude Opus 5.5 463bda3e7c Add realistic automations to the demo (#54647)
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-08 12:05:54 +02:00
Marcin BauerandClaude Opus 5.5 dfdaeee75b Allow running and testing disabled actions and conditions (#54649)
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-08 12:01:37 +02:00
Franck Nijhof f0a67727a2 Fix ZHA reconfigure dialog errors after closing and on reconnect (#54684)
* Fix ZHA reconfigure dialog errors after closing and on reconnect

* Only handle reconfigure messages of the current run
2026-10-08 11:54:09 +02:00
Paul Bottein e47a7c3a45 Allow sections to contain badges and sections (#54624) 2026-10-08 09:30:26 +01:00
WendelinandClaude Opus 5.5 da70ab7268 Refactor UI support checks into static methods and add validation helpers (#54493)
Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Claude <[email protected]>
2026-10-08 10:27:10 +02:00
Franck Nijhof 2f15c7640f Keep boolean fields inline in the narrow action editor (#54686) 2026-10-08 09:18:40 +01:00
Bram KragtenandClaude Opus 5.5 1c8da1329e Add focus indicator to entity and sensor cards (#54644)
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-08 09:16:21 +01:00
Franck Nijhof a9c449f502 Load the HLS source once instead of on every media attach (#54683) 2026-10-08 08:55:09 +01:00
Franck Nijhof 568d842245 Let long dropdown submenus scroll (#54692) 2026-10-08 07:48:26 +00:00
Petar Petrov 796795818d Show the newest history states when the browser clock runs behind (#54688) 2026-10-08 08:44:19 +01:00
Franck Nijhof 33c1a3acfd Keep a cleared input value when focusing it again (#54690) 2026-10-08 09:40:27 +02:00
Franck Nijhof 9e0f0931ae Keep the key order when duplicating a script (#54687) 2026-10-08 09:29:30 +02:00
b71c47f2e8 Add unit of measurement selector (#54391)
* WIP

* Implement unit of measurement picker and selector

* Disable units for device classes that do not support units

* Fix no unit

* Follow sensor entity unit rules in unit of measurement selector

Non-numeric device classes only allow no unit, and a device class or
state class without a unit table (such as monetary or measurement)
no longer restricts the units.

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

* Follow up on comments

* Lint error

* Add context info to config

* Only clear the unit when the context filters change

ha-form, the blueprint editor and the gallery create a new context object
on every render, so editing an unrelated field counted as a context
change and could clear a saved unit. Compare the filter values instead.

A filter can be a list of device classes or state classes, so compare
them by value rather than by reference.

Co-authored-by: Petar Petrov <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Petar Petrov <[email protected]>
2026-10-08 09:25:14 +03:00
karwosts 6afbc6322d Fix unhandled undefined label registry access (#54659) 2026-10-08 09:13:19 +03:00
bd286f2519 Add power values to energy dashboard (#54627)
* Bugfixes

* Remove useless updates

* Fix updating issue

* Remove useless check

* Rename test case for clarity

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

---------

Co-authored-by: Petar Petrov <[email protected]>
Co-authored-by: Copilot Autofix powered by AI <[email protected]>
2026-10-08 05:49:43 +00:00
Jump-333 fb16219c5b Add legend values in the devices details graph (#54631)
* Add legend values in the electricity page

* Fix localization issue and tests

* Add tests and fix localization issues

* Remove energy usage graph legend
2026-10-08 08:25:34 +03:00
Aidan Timson 4852d3c609 Add repository source filter and grouping to Marketplace (#54654)
* Add a source filter and group by source to the marketplace

* Plural form

* Test that marketplace browse links keep the source

* Drop the Marketplace filter pane rendering test
2026-10-08 08:22:14 +03:00
Jump-333 26c332d922 Remove duplicate condition from the automation editor code (#54674)
Remove duplicate condition
2026-10-08 08:13:04 +03:00
renovate[bot]andPetar Petrov b829ba9eca Update dependency jsdom to v30.1.2 (#54642)
* Update dependency jsdom to v30.1.2

* Focus shadow-rendered buttons in dialog-form tests

jsdom 30.1.2 no longer focuses light-DOM children of a shadow host
without a slot. The ha-form mock has no slot, so the opener buttons
were never rendered. Put them in the form's shadow root, where real
ha-form controls live.

---------

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Petar Petrov <[email protected]>
2026-10-08 06:40:44 +02:00
Franck Nijhof 46c65973a0 Wrap descriptions in voice assistant settings (#54678) 2026-10-08 06:39:03 +02:00
karwosts 4bb7908f60 Fix create floor memoization issue (#54675) 2026-10-08 06:38:34 +02:00
Paul Bottein bc21992178 Snap the map overview bottom sheet to fixed positions (#54663)
* Snap the map overview bottom sheet to fixed positions

* Include the safe area in the collapsed stop and follow reduced motion
2026-10-07 17:50:09 +02:00
Bram KragtenandClaude Opus 5.5 28f53d9f5a Show the next demo card on the home page demo again (#54646)
* Show the next demo card on the home page demo again

The home overview strategy now generates the favorites section itself
instead of returning a common-controls section strategy, so the demo
strategy no longer found the section to add its card to. Find the
favorites section by its heading instead.

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

* Keep at least six favorites next to the demo card

The section strategy used to get a limit of two less, so seven tiles
became six. Only drop as many tiles as needed to keep six.

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

* Keep the current user when the demo user subscription refreshes

The auth/current_user mock answered with the user from startup, so the
user subscription reset a changed user, like the non-admin scenario of
the e2e tests, to the initial admin user.

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

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-07 17:33:31 +02:00
Aidan Timson d2fa820035 Add group by to Marketplace tables (#54653)
* Add group by status and type to the marketplace

* Update the marketplace dashboard test for grouping

* Group the marketplace by status by default

* Type the Marketplace grouping events and drop the panel test
2026-10-07 17:05:45 +02:00
Petar Petrov 0e34aa06c1 Align history timeline X axis with line charts (#54657) 2026-10-07 17:53:03 +03:00
Marcin BauerandClaude Opus 5.5 bb168601b8 Restore left-column timeline names on the history-graph card (#54661)
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-07 15:47:15 +01:00
Aidan Timson 1823e783ee E2E test fix: Keep the current user in the demo auth stub (#54658) 2026-10-07 15:56:00 +02:00
Marcin BauerandClaude Opus 5.5 03a815bb2e Fix long entity names overflowing the entities picker (#54656)
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-07 13:13:50 +00:00
Marcin BauerandClaude Opus 5.5 adde734800 Make disabled automation rows easier to read (#54650)
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-07 14:58:02 +02:00
Petar Petrov 8598a35c88 Add reset zoom button to network graphs (#54510) 2026-10-07 14:30:10 +02:00
WendelinandClaude Opus 5.5 7008f2ee38 Keep dropdown menus within the viewport and wrap long items (#54643)
Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Claude <[email protected]>
2026-10-07 14:23:54 +02:00
Paul Bottein 0a8a71049b Use generic paths to address Lovelace dashboard items (#54207)
* Use generic paths to address Lovelace dashboard items

* Return node target for container paths

* Remove dropped card node when creating a section from a drop

* Refuse to write through a missing list item

* Only build card and badge paths in edit mode

* Use optional badge config in edit badge dialog params

* Append cards in a single write in addCards

* Append badges in a single write in addBadges

* Pass a save callback to the badge edit dialog

* Handle ll-create-card from custom layouts that don't bubble it
2026-10-07 14:20:51 +02:00
Bram KragtenandClaude Opus 5.5 79488d0c09 Mock editing the own profile in the demo (#54645)
The demo did not answer person/update_own_profile, so saving the profile
dialog showed an error. Also answer auth/current_user, so the user
subscription picks up the new name.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-07 15:00:39 +03:00
WendelinandClaude d0c09951e6 Dismiss reboot/shutdown toast when frontend reconnects (#54640)
The "Rebooting system" toast was shown without an id and with no
timeout, so nothing could ever close it and it stayed on screen after
the host came back. Give it an id, dismiss it when the connection is
re-established, and also when the action fails with an error dialog.

Fixes #54630


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

Co-authored-by: Claude <[email protected]>
2026-10-07 10:37:54 +02:00
Aidan Timsonandcopilot-swe-agent[bot] 3d57d1b949 Remove the blocking-label workflow (#54064)
Co-authored-by: copilot-swe-agent[bot] <[email protected]>
2026-10-07 10:33:37 +02:00
Maarten Lakerveld d02b605916 Zone editor: fit without passive zones, hide covered radius handles, name tooltips, and start new zones at the map center (#54597)
* Leave passive zones out of the zones map fit

Opening the zones page fitted the map around every zone, so a few remote
passive zones pushed the view out to the whole globe and adding a zone took
a lot of zooming. Passive zones are still drawn and editable, but no longer
count toward the fit unless they are the only zones there are. Editable
locations gained a fit flag for this.

Fixes #54595

* Hide a zone's radius handle while the circle is under its icon

Zoomed out on the zones page, every zone's radius handle collapsed onto the
zone icon and covered it. The handle now hides while the radius on screen is
shorter than the icon's half width, so it returns as soon as the circle's
edge shows past the icon, and it stays while the radius is being dragged.

* Show a zone's name in a tooltip when hovering its marker on the zones map

The marker only had a native title, which appears late and in the
browser's own style. Every named editable location now gets an ha-tooltip
anchored to its element, so hovering a zone icon on the zones page names
it promptly; the native title is dropped so the two do not stack.

* Start a new zone at the center of the zones map

Create zone placed the new zone at a random spot up to 500 meters from
home, so it landed somewhere unexpected and often off-screen. When the zones
page has its map open, a new zone now starts at the center of the current
view, so panning to the right place first works as expected. A location
handed in, as from a person's Create zone action, still wins; without a map
the random offset around home remains.
2026-10-07 10:12:08 +02:00
Maarten Lakerveld a9c1531e37 Map: float every marker, keep the selection in the URL, pan-first focus, and fix attribution and zone radius display (#54596)
* Auto-hide compact map attribution and restyle it for dark mode

MapLibre decides compact mode from the map canvas width alone (640px), so
shrinking the window flipped the attribution from compact back to inline
when the sidebar became a drawer and the map got wider. The engine now adds
the control itself and forces compact on a narrow viewport (870px, the app's
own narrow layout), swapping it when the breakpoint changes.

MapLibre only collapsed the opened compact attribution on a drag; it now
also collapses by itself after five seconds, unless the user opened it from
the info button.

MapLibre's stylesheet hardcodes a white attribution with black text, which
Home Assistant never overrode, so it stood out on a dark map. The dark mode
rules now match the zoom control styling, and the inline text color is set
explicitly instead of inherited from a page that may be in the other mode.

* Add show_zone_radius option to the map card

In panel layout the map hides zone radii except on the Zones tab, for the
selected zone, and while editing. A user asked to always see them, so the
new option draws the radius of every visible zone on all tabs. Other layouts
already show radii and are unaffected.

* Regroup map clusters up front for every fit

Clicking a bubble already regrouped its members at the zoom the fit would
land on, so they parted as the animation started. Any other way of zooming
in, such as picking a person in the map overview, waited for the move to end
before the bubble split. The fit itself now does the regrouping, for all
markers, so every animated fit shows the bubbles as they will end up.

* Return the map to its previous view from the overview's back button

Leaving a selected person or zone cleared the selection but left the map
zoomed in on it. The card now remembers where the map was when the overview
drilled in: the exact camera, or the previous entity's fit when one selection
followed another, and the back button eases back there. A camera that was
still auto-fitting resumes auto-fit instead of freezing. Clicking the map to
deselect stays put, since that is the user looking around.

Engines gain getView, and setView can animate.

* Float the selected map marker above a dot at its location

Selecting a person or device marked it with a ring only, and inside a zone
it stayed hidden in the zone's bubble. The selected marker is now excluded
from clustering, drawn a quarter larger, and hangs above a small dot at the
exact coordinate with a tail pointing down to it. The accuracy circle around
the dot carries the entity color, so the ring is dropped; an entity that
reports no accuracy gets a nominal 5 meter circle so the color still shows.
A fix of 10 meters or better draws that circle as a soft disc without an
outline, since a hard edge would claim more precision than is known; map
circles gained an outline option for this. The engine's marker anchor puts
the dot on the coordinate; both engines support it. Bubble avatars keep
their size.

* Open more info from a button in the map overview detail

The selected person, device or zone's name was the only way to the more-info
dialog, which made the title read as a link. The name is plain text now and
a filled More info button sits below the activity, where the user expects an
action. Focus still moves to the name when the detail view opens.

* Always show the selected marker's accuracy circle

The circle only appeared for a GPS fix looser than 100 meters, so most
phones never showed one. The selected marker now floats above a dot at its
coordinate, so even a small circle around that dot reads clearly; it is
never hidden for the selected entity. Focusing still fits the circle into
view only above 100 meters, since a tighter circle fits at the focus zoom.

* Describe every toggle in the map card editor

Only Show all and Show zone radius explained themselves; Scale ruler, Auto
fit, Fit zones and Cluster markers now have helper text too.

* Float every map marker above its location

Only the selected marker hung above a dot with a tail; the rest sat
centered on their coordinate, so a marker's frame always hid the spot it
marked. Every entity marker is now a pin whose tail points just above its
coordinate. Selecting one scales its frame up and shows the dot at the
exact location. Cluster bubble avatars keep their plain frames.

* Keep the map overview selection in browser history

The card remembered a single camera to restore from the overview's back
button, so after selecting one entity and then another the original view
was lost, and back from the second left the map zoomed on the first while
the overview showed the list. The selected entity now lives in the URL as
an entity_id query parameter; selecting pushes a history entry, and so
does deselecting, whether from the overview's back arrow or a click on
the map, so the browser's back and forward step through every selection.
The overview's arrow itself always returns to the list. Selecting focuses
the entity; deselecting leaves the map where it is. Only selectable
domains are accepted from the URL; anything else is dropped from it. The
saved camera, the animated setView and the autoFit view flag had no
other users.

* Pan to a selected map entity instead of zooming in on it

Focusing a person jumped to zoom 19 and a zone fitted its circle, which
often zoomed much further in than the user had been. Selecting now keeps
the current zoom and pans the entity into the free part of the view. It
only zooms in when another marker sits within 80 screen pixels, and then
just far enough to clear it, capped at zoom 17. Markers at the very same
position cannot be separated by zooming and are left alone. A zone is
fitted as its whole circle with a little room around it under that same
zoom cap, so a zone that fits the view keeps the zoom and a bigger one
zooms out just enough. A marker already on screen eases over; one off
screen flies there in an arc. Fits gain a fly option for this, and every
other fit now eases straight to its bounds on both engines, where
MapLibre used to fly. Map-engine gains pixelDistance, the Web Mercator
screen distance at a zoom level, with a test for its scale.

* Float every cluster bubble above its location

Only a bubble attached to a zone had a tail and hung above the zone's
circle; a bubble of nearby markers sat centered on the cluster, hiding the
spot it stood for. Every bubble now has the tail and floats above its
location with the same lift as a single marker's pin, so the two read as
one style. A zone's bubble keeps floating above the zone circle.
2026-10-07 10:09:07 +02:00
Matthias AlphartandClaude 87166c98dc Sort selected weekdays in time trigger and condition (#54623)
Co-authored-by: Claude <[email protected]>
2026-10-07 09:16:10 +02:00
Artur Pragacz 89c333894d Consider next name part when unprefixing entity name (#54633)
* Consider next name part when unprefixing entity name

* Adjust
2026-10-07 08:39:45 +03:00
karwosts d994db64cb Fix script duplication (#54637) 2026-10-07 08:20:57 +03:00
renovate[bot] d6a3c6efcc Update dependency @versatiles/style to v6.1.1 (#54629)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-10-07 05:01:37 +00:00
Paul Botteinandcopilot-swe-agent[bot] ce58026788 Fix section editor crash while typing a strategy in YAML (#54622)
* Fix section editor crash while typing a strategy in YAML

* Reject array-valued section strategies in YAML

---------

Co-authored-by: copilot-swe-agent[bot] <[email protected]>
2026-10-07 04:59:26 +00:00
2e09d1a43b Correct system and diagnostics API types in the frontend (#54290)
* Correct system and diagnostics API types in the frontend

Compare the system health, log, repairs and related data modules with
the handlers that produce them, and fix the mismatches.

The system health failure detail was read from a key that does not
exist, so a failed check rendered blank. Repairs issues carry several
always-present nullable fields and no active flag. Saving the core
config and ignoring a repair return nothing, and the icon types were
missing a nesting level.

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

* Surface the HTTP fallback port and the failure reason

Warn separately when the server fell back to the previous default
port, which the saved configuration warning did not cover.

Show the diagnostic message a failed configuration carries, and fall
back to the error code when there is none.

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

---------

Co-authored-by: Claude <[email protected]>
Co-authored-by: copilot-swe-agent[bot] <[email protected]>
2026-10-07 06:52:57 +02:00
WendelinandClaude Opus 5.5 45e2392eeb Fix leaked WebRTC peer connections in ha-web-rtc-player (#54557)
* Abandon superseded WebRTC starts to avoid leaking peer connections

If ha-web-rtc-player was disconnected (or restarted) while awaiting the
WebRTC client configuration, the start continued afterwards and created
an RTCPeerConnection that nothing ever closed.

Track a start id that is bumped on every clean up and bail out when a
start has been superseded. Negotiation now also checks that it still
belongs to the current peer connection.

Fixes #54482

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

* Bind WebRTC signaling callbacks to their own peer connection

Events of an offer subscription that belongs to a superseded start could
still reach the current stream before the unsubscribe completed, sending
candidates to the old session or applying the old answer to the new
offer. Ignore events whose peer connection is no longer the current one,
and don't report errors from operations rejected because a superseded
connection was closed.

Also show an error instead of leaving an unhandled rejection when
fetching the WebRTC client configuration fails.

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

* Localize WebRTC player errors and handle rejected offer subscriptions

Error messages of ha-web-rtc-player were hardcoded English strings. They
are now stored as an error type plus the backend message and localized
when rendering.

The try/catch around webRtcOffer() never caught anything because the
subscription returns a promise, so a rejected offer left an unhandled
rejection and no error. Handle the rejection, and don't let clean up
re-raise it.

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

* Rename WebRTC start id to clean up count

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

* Don't start a WebRTC stream on a disconnected player

Lit still runs a pending update after the element was disconnected, so
a player added and removed in the same task (or given a new entityid
after removal) started a stream after its last clean up, which nothing
closed.

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

---------

Co-authored-by: Claude <[email protected]>
2026-10-07 06:52:10 +02:00
renovate[bot] b0c5ea6296 Update pnpm to v12.9.1 (#54635)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-10-07 06:49:20 +02:00
renovate[bot] c0204eeb25 Update dependency maplibre-gl to v6.12.0 (#54636)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-10-07 06:49:03 +02:00
59444f72bc Add a gallery page for ha-picker-combo-box (#54588)
* Add a gallery page for ha-picker-combo-box

The picker combo box is only ever seen inside a popover or dialog, which
makes its two render paths and two row highlights hard to inspect. This
page renders it directly in four states: a plain list under the
MAX_PLAIN_LIST_ITEMS threshold, a virtualized list of 200 items, an empty
list, and one with a current value pinned into view.

The component takes no hass, so the page provides internationalizationContext
directly rather than going through a fake hass object.

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

* WIP - combobox gallery

* Tighten the picker combo box gallery text

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

* Improve documentation for ha-picker-combo-box

Clarified rendering behavior and filtering logic in the documentation. Updated keyboard shortcuts and added information about selected sections.

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

* Fix Prettier formatting in picker combo box gallery page

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

---------

Co-authored-by: Claude Opus 5 <[email protected]>
Co-authored-by: Copilot Autofix powered by AI <[email protected]>
2026-10-06 13:52:15 +00:00
Petar Petrov 6856449bd3 Drop hass from the home summary card (#54606)
* Drop hass from the home summary card

The card consumes the contexts it reads and computes its summary text
from statesContext, so it only re-renders when the text changes. It
subscribes to the energy collection through EnergyCollectionController,
which gains a beforeSubscribe hook to set today's period, and fires
hass-action like the button card. isSecurityAlertActive and the battery
filters take the values they need.

* Test isSecurityAlertActive against every alert domain
2026-10-06 15:42:24 +02:00
Petar Petrov 9fa168e22c Pass registries to generateEntityFilter as separate values (#54605)
generateEntityFilter takes states, entities, devices, areas and floors
instead of a hass-shaped object, so callers without hass can use it.
2026-10-06 15:24:18 +02:00
Marcin BauerandClaude Opus 5.5 6d6aaecb39 Remove no-op refresh button from activity panel (#54620)
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-06 15:20:22 +02:00
Jump-333 d9f2baeeb4 Add badge for the wait for a template row (#54529)
* Add badge for the wait for a template row

* Removed literal text filter
2026-10-06 15:35:55 +03:00
Aidan Timson 234eb800bb Fetch nightly translations artifact directly with retry backoff (#54574)
* Fetch nightly translations artifact directly with backoff

* Type nightly translations fetch and use shared retry helper

* Retry GitHub API calls in workflow scripts
2026-10-06 15:27:36 +03:00
Wendelin 40c76b3993 Upgrade webawesome to version 3.14 (#54616)
* Upgrade @home-assistant/webawesome to version 3.14.0-ha.0 in package.json and pnpm-lock.yaml

* Update bundle size budget for frontend-modern and frontend-legacy
2026-10-06 15:11:29 +03:00
WendelinandClaude 7ca12940d9 Increase touch area of ha-checkbox (#54617)
Co-authored-by: Claude <[email protected]>
2026-10-06 13:11:20 +01:00
Aidan Timson 19630745d8 Follow webawesome fork releases in Renovate (#54619) 2026-10-06 12:08:17 +00:00
Marcin BauerandClaude Opus 5.5 69f83e0ddc Move dashboard edit mode help button to the overflow menu (#54608)
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-06 14:34:55 +03:00
Marcin BauerandClaude Opus 5.5 afac5d6e56 Add undo action to cut toast in automation editor (#54612)
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-06 13:19:41 +02:00
Aidan Timson c40846ada7 Skip release delay for first-party packages (#54614) 2026-10-06 13:18:24 +02:00
Bram KragtenandClaude 388c4f856b Simplify CI workflow: make build depend on lint and test (#54609)
Co-authored-by: Claude <[email protected]>
2026-10-06 12:09:47 +01:00
Aidan Timson 7051fd27f7 Add a custom repositories page to the Marketplace (#54604)
Manage custom Marketplace repositories on their own page
2026-10-06 12:27:42 +03:00
Jonathan 6fca7a89b6 Show the requested run when a trace page switches item (#54591) 2026-10-06 11:26:46 +02:00
Wendelin 121b3dade7 Fix HaChipBase border radius (#54603)
Add border-radius to HaChipBase styles for improved appearance
2026-10-06 12:19:56 +03:00
Marcin BauerandClaude Opus 5.5 5a5611df6b Load History and Activity results only after closing the sources sheet on mobile (#54602)
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-06 11:05:39 +02:00
Paul Bottein a7c435e1a0 Show referenced triggers instead of generated IDs in automation and trace descriptions (#54587) 2026-10-06 10:52:45 +02:00
Petar Petrov 8bc7ab0e74 Drop hass from the energy total badges and the statistics graph card (#54590)
The badges and the card subscribe to the energy collection through
EnergyCollectionController and consume contexts for everything else.
statistics-chart moves to contexts with them and regenerates on a theme
change. The controller unsubscribes when config() returns undefined, so
turning energy_date_selection off stops the subscription.
2026-10-06 09:40:38 +01:00
Bram KragtenandClaude Opus 5.5 5ca2ac68a3 Use standard Apache 2.0 license text so GitHub detects it (#54599)
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-06 10:35:17 +02:00
Petar Petrov eb43b1eab8 Migrate energy, power, gas and water cards to context instead of hass (#54560)
* Migrate energy, power, gas and water cards to context instead of hass

The cards consume only the context slices they read. A new
EnergyCollectionController replaces SubscribeMixin for the energy
collection subscription. ha-chart-base, ha-sankey-chart and
hui-energy-period-selector move to contexts too, and the energy and
recorder helpers take Pick<HomeAssistant, ...> slices.

* Inline the energy collection Pick type

* Pass values instead of hass-shaped objects to energy and recorder helpers

The helpers take states, formatEntityName, callWS, locale or panelUrl
directly. getEnergyDataCollection takes the connection plus an options
object, since it needs several inputs.

* Use consumeContext in EnergyCollectionController

The controller extends ContextController and declares its consumed
values as decorated fields instead of wiring each subscription by hand.

* Consume the internationalization context once in energy cards and charts

ha-chart-base, the period selector and the energy cards read both
localize and locale, so they consume the whole context as _i18n
instead of subscribing to it twice.

* Drop the connected-host guard from EnergyCollectionController

ContextController now defers its setup on connected hosts, so the
controller no longer needs to guard against running before its options
are set.
2026-10-06 10:27:06 +02:00
Petar Petrov 96684da7a7 Add a consumeContext decorator for reactive controllers (#54575)
* Add a consumeContext decorator for reactive controllers

Controllers extend ContextController and decorate their fields with
consumeContext({ context, subscribe, transform }). The base class keeps
each field current and calls contextUpdated() only when the transformed
value changes. ServiceInfoController uses it instead of its own
ContextSubscriptionController wiring.

* Defer context setup on connected hosts and check consumeContext fields

A controller created on a connected host no longer runs subclass code
from super(): Lit calls hostConnected() from addController(), so the
subscriptions are set up after construction instead. consumeContext
also rejects public fields that cannot hold the stored value, like
@consume does.
2026-10-06 10:08:11 +02:00
WendelinandClaude Sonnet 5.5 13e9127bdb Fix config entry state styling (#54584)
* Restore needs attention color on config entry rows

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

* Restore config entry row state styling

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

* Highlight config entry state with border color instead of tint

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

* Add quiet fill background to config entry states

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

* Use semantic colors for config entry state borders

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

* Drop state border colors and use semantic text colors

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

* Use semantic color for config entry link icon

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

* Add divider between discovered and attention flow rows

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

* Use semantic border and fill colors for discovered and attention lists

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

* Use warning colors for discovered list too

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

* Use standard divider color for flow list borders

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

* Use border width token for flow list borders

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

* Restore warning icon color for failed unload state

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

* review

---------

Co-authored-by: Claude <[email protected]>
2026-10-06 09:59:54 +02:00
885 changed files with 39351 additions and 13394 deletions

No files matched your search

+5 -2
View File
@@ -16,7 +16,7 @@ Container components may keep `hass` when they own it and feed providers. Leaf c
## Core Files
- Context definitions: `src/data/context/index.ts`
- `consume` decorator and `ContextSubscriptionController`: `src/common/decorators/consume.ts` (use instead of `@lit/context`'s `consume` and `ContextConsumer`, enforced by ESLint)
- `consume` decorator, `ContextSubscriptionController`, and `consumeContext` / `ContextController` for reactive controllers: `src/common/decorators/consume.ts` (use instead of `@lit/context`'s `consume` and `ContextConsumer`, enforced by ESLint)
- Entity-scoped consume helpers: `src/common/decorators/consume-context-entry.ts`
- Transform decorator: `src/common/decorators/transform.ts`
- Canonical migration example: `src/panels/lovelace/cards/hui-button-card.ts`
@@ -37,6 +37,7 @@ Container components may keep `hass` when they own it and feed providers. Leaf c
| `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 |
| `reducedMotionContext` | `prefers-reduced-motion` media query |
Lazy contexts subscribe on first consumer and tear down after the last consumer: `labelsContext`, `fullEntitiesContext`, `configEntriesContext`, `manifestsContext`, `triggerDescriptionsContext`, and `conditionDescriptionsContext`.
@@ -50,7 +51,9 @@ Import `consume` from `src/common/decorators/consume`, not from `@lit/context`.
- `@state()` plus `@consume` plus `@transform`: rerenders only when the transformed value changes.
- `@consume` alone: the field stays current but never triggers a render. Use this for values only read in event handlers or callbacks, such as `apiContext`, or for data read on demand, such as picker item callbacks.
In controllers or for lazily created subscriptions, use `ContextSubscriptionController` from the same file instead of `@lit/context`'s `ContextConsumer`, which also forces a host update. Its callback must store the value in a reactive field or call `host.requestUpdate()` itself when the host needs to rerender.
In reactive controllers, extend `ContextController` and decorate fields with `consumeContext({ context, subscribe, transform })` from the same file. `transform` stores one part of the context value, and `contextUpdated()` runs only when that part changes. `@state()` and `@transform` do not work on controller fields; call `host.requestUpdate()` when the host needs to rerender. Example: `src/data/service-info-controller.ts`.
For lazily created subscriptions, use `ContextSubscriptionController` from the same file instead of `@lit/context`'s `ContextConsumer`, which also forces a host update. Its callback must store the value in a reactive field or call `host.requestUpdate()` itself when the host needs to rerender.
Use entity-scoped helpers when the component watches an entity ID held on the host:
+4
View File
@@ -54,6 +54,10 @@ http://localhost:8090/?demo=<second slug>#/energy/water
- `demo/src/stubs/`: Mocked WebSocket and REST APIs.
- `demo/script/develop_demo`, `demo/script/build_demo`: Development server and static build wrappers.
## Core Data
The service descriptions in `src/fake_data/demo_services.ts` and the trigger and condition descriptions and icons in `demo/src/stubs/automation_platforms_*.ts` are generated from Home Assistant Core. Do not edit them by hand. Run `script/gen_demo_core_data` to regenerate them from the core `dev` branch, and add a domain to the lists in `build-scripts/gulp/gen-demo-core-data.js` when the demo needs its services, triggers, or conditions. Their translations come from the backend translations merged into the demo build.
## 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.
+226
View File
@@ -1,3 +1,23 @@
# Path rules for .github/workflows/labeler.yaml, which runs actions/labeler on
# every pull request. A label is added when a pull request changes a matching
# file, and removed again when it no longer does, including one added by hand.
#
# Do not add these labels here. .github/scripts/label-content.mts applies and
# removes them from paths and code. A rule here would remove them whenever its
# paths don't match, even when the script's code check does:
#
# - Companion App
# - Home Assistant Link
# - Labs
# - Supervisor
Activity:
- changed-files:
- any-glob-to-any-file:
- src/panels/logbook/**
- src/panels/lovelace/**/*logbook*
- src/data/logbook.ts
Agents:
- changed-files:
- any-glob-to-any-file:
@@ -16,6 +36,36 @@ Agents:
- .github/skills/**
- .github/workflows/copilot-setup-steps.yml
AI:
- changed-files:
- any-glob-to-any-file:
- "src/panels/config/core/{ha-config-section-ai,ai-task-pref,mcp-pref}.ts"
- src/components/ha-suggest-with-ai-button.ts
- "src/data/{ai_task,llm}.ts"
Areas:
- changed-files:
- any-glob-to-any-file:
- src/panels/config/areas/**
- src/data/area/**
- "src/data/{area_floor_picker,floor_registry,ws-area_registry,ws-floor_registry}.ts"
Automations:
- changed-files:
- any-glob-to-any-file:
- "src/panels/config/{automation,script,scene,blueprint}/**"
- src/components/automation/**
- src/components/trace/**
- src/data/trace*.ts
- "src/data/{automation,automation_i18n,script,script_i18n,scene,blueprint,trigger,condition,action}.ts"
Backups:
- changed-files:
- any-glob-to-any-file:
- src/panels/config/backup/**
- src/data/backup*.ts
- src/onboarding/onboarding-restore-backup.ts
Build:
- changed-files:
- any-glob-to-any-file:
@@ -23,12 +73,37 @@ Build:
- .browserslistrc
- gulpfile.js
Calendar:
- changed-files:
- any-glob-to-any-file:
- src/panels/calendar/**
- src/panels/lovelace/**/*calendar*
- src/data/calendar.ts
Cast:
- changed-files:
- any-glob-to-any-file:
- cast/src/**
- src/cast/**
Connectivity:
- changed-files:
- any-glob-to-any-file:
- src/panels/config/connectivity/**
- src/panels/config/tags/**
- "src/panels/config/integrations/integration-panels/{bluetooth,infrared,matter,modbus,mqtt,radio_frequency,serial,thread,zha,zwave_js}/**"
- "src/data/{bluetooth,infrared,matter,matter-lock,modbus,mqtt,otbr,radio_frequency,tag,thread,usb,zha,zwave_js,zwave_js-credentials}.ts"
Dashboards:
- changed-files:
- any-glob-to-any-file:
- src/panels/lovelace/**
- "src/panels/{home,climate,light,security,maintenance,map}/**"
- src/panels/config/lovelace/**
- "src/panels/config/dashboard/{dashboard-card,dialog-new-dashboard,show-dialog-new-dashboard}.ts"
- src/data/lovelace/**
- src/data/lovelace*.ts
Demo:
- changed-files:
- any-glob-to-any-file:
@@ -57,6 +132,29 @@ Dependencies:
- "^renovate/"
- "^dependabot/"
Devices:
- changed-files:
- any-glob-to-any-file:
- src/panels/config/devices/**
- src/data/device/device_registry.ts
- src/data/ws-device_registry.ts
Energy:
- changed-files:
- any-glob-to-any-file:
- src/panels/energy/**
- src/panels/config/energy/**
- src/panels/lovelace/cards/energy/**
- src/panels/lovelace/**/*energy*
- src/data/energy*.ts
Entities:
- changed-files:
- any-glob-to-any-file:
- src/panels/config/entities/**
- src/data/entity/entity_registry*.ts
- src/data/ws-entity_registry_display.ts
GitHub Actions:
- changed-files:
- any-glob-to-any-file:
@@ -64,6 +162,100 @@ GitHub Actions:
- .github/workflows/**
- .github/*.yml
Helpers:
- changed-files:
- any-glob-to-any-file:
- src/panels/config/helpers/**
- src/data/helpers_crud.ts
History:
- changed-files:
- any-glob-to-any-file:
- src/panels/history/**
- src/panels/lovelace/**/*history*
- src/data/history.ts
Integrations:
- changed-files:
- any-glob-to-any-file:
- src/panels/config/integrations/**
- src/panels/config/application_credentials/**
- src/dialogs/config-flow/**
- src/data/application_credential.ts
- src/data/config_entries.ts
- "src/data/{config_flow,data_entry_flow,options_flow,sub_config_flow}.ts"
Labels:
- changed-files:
- any-glob-to-any-file:
- src/panels/config/labels/**
- src/data/label/**
Landing Page:
- changed-files:
- any-glob-to-any-file:
- landing-page/**
Marketplace:
- changed-files:
- any-glob-to-any-file:
- src/panels/marketplace/**
- src/data/marketplace/**
- src/panels/config/integrations/offer-marketplace-uninstall.ts
Media:
- changed-files:
- any-glob-to-any-file:
- src/panels/media-browser/**
- src/components/media-player/**
- src/data/media_source.ts
Notifications:
- changed-files:
- any-glob-to-any-file:
- src/dialogs/notifications/**
- src/data/persistent_notification.ts
Onboarding:
- changed-files:
- any-glob-to-any-file:
- src/onboarding/**
- src/auth/**
- src/data/onboarding.ts
- src/data/auth.ts
People:
- changed-files:
- any-glob-to-any-file:
- "src/panels/config/{person,users}/**"
- "src/data/{person,user,ws-user}.ts"
Profile:
- changed-files:
- any-glob-to-any-file:
- src/panels/profile/**
Search:
- changed-files:
- any-glob-to-any-file:
- src/dialogs/quick-bar/**
- src/state/quick-bar-mixin.ts
- "src/data/{quick_bar,search}.ts"
Sidebar:
- changed-files:
- any-glob-to-any-file:
- src/components/ha-sidebar.ts
- src/dialogs/sidebar/**
System:
- changed-files:
- any-glob-to-any-file:
- "src/panels/config/{core,hardware,info,logs,network,repairs,storage}/**"
- src/panels/config/dashboard/ha-config-updates.ts
- "src/dialogs/{repairs-flow,restart}/**"
- "src/data/{analytics,entity_id_format,error_log,hardware,network,repairs,system_health,system_log,system_log_report,update}.ts"
"Tests: E2E":
- changed-files:
- any-glob-to-any-file:
@@ -93,3 +285,37 @@ GitHub Actions:
- any-glob-to-any-file:
- test/*.ts
- "test/!(e2e)/**"
Theming:
- changed-files:
- any-glob-to-any-file:
- src/resources/theme/**
- src/state/themes-mixin.ts
- src/common/dom/apply_themes_on_element.ts
- "src/data/{theme,ws-themes}.ts"
To-do:
- changed-files:
- any-glob-to-any-file:
- src/panels/todo/**
- src/panels/lovelace/**/*todo*
- src/data/todo.ts
Tools:
- changed-files:
- any-glob-to-any-file:
- src/panels/config/tools/**
Voice:
- changed-files:
- any-glob-to-any-file:
- src/panels/config/voice-assistants/**
- "src/dialogs/{voice-assistant-setup,voice-command-dialog}/**"
- src/components/ha-assist-chat.ts
- "src/data/{assist_pipeline,assist_satellite,conversation,stt,tts,wake_word}.ts"
Zones:
- changed-files:
- any-glob-to-any-file:
- src/panels/config/zone/**
- src/data/zone.ts
-46
View File
@@ -1,46 +0,0 @@
#!/usr/bin/env node
// Fails the check when a pull request carries a label that blocks merging, and
// writes the outcome to the job summary. Invoked from the `check` job in
// .github/workflows/blocking-labels.yaml via actions/github-script:
//
// const { default: checkBlockingLabels } =
// await import(`${process.env.GITHUB_WORKSPACE}/.github/scripts/check-blocking-labels.mts`);
// await checkBlockingLabels({ github, context, core });
import type {
GitHubScriptArgs,
PullRequestPayload,
} from "./github-script.d.ts";
export default async function checkBlockingLabels({
context,
core,
}: GitHubScriptArgs<PullRequestPayload>) {
const blockingLabels = [
"wait for backend",
"Needs UX",
"Do Not Review",
"Blocked",
"has-parent",
];
const prLabels = context.payload.pull_request.labels.map((l) => l.name);
const found = blockingLabels.filter((bl) => prLabels.includes(bl));
if (found.length > 0) {
const message = `This Pull Request is blocked by label${found.length > 1 ? "s" : ""}: ${found.join(", ")}`;
await core.summary
.addHeading(":no_entry_sign: Pull Request is blocked", 2)
.addRaw(message)
.write();
core.setFailed(message);
} else {
await core.summary
.addHeading(
":white_check_mark: Pull Request is clear to merge after review",
2
)
.addRaw(
"This Pull Request is not blocked by any labels which prevent it from being merged."
)
.write();
}
}
+9 -6
View File
@@ -17,6 +17,7 @@ import type {
GitHubScriptArgs,
PullRequestPayload,
} from "./github-script.d.ts";
import { withRetry } from "./github-retry.mts";
// Scheduled and manual runs have no pull request
type Payload = Partial<PullRequestPayload>;
@@ -90,12 +91,14 @@ const readPackageJson = async (
context: Context<Payload>,
ref: string
): Promise<PackageJson> => {
const { data } = await github.rest.repos.getContent({
...context.repo,
path: "package.json",
ref,
mediaType: { format: "raw" },
});
const { data } = await withRetry(`package.json lookup at ${ref}`, () =>
github.rest.repos.getContent({
...context.repo,
path: "package.json",
ref,
mediaType: { format: "raw" },
})
);
// The raw media type returns the file contents as a string, but Octokit
// types the response as the JSON form
@@ -13,6 +13,7 @@ import type {
GitHubScriptArgs,
PullRequestPayload,
} from "./github-script.d.ts";
import { withRetry } from "./github-retry.mts";
export default async function checkPullRequestStandards({
github,
@@ -31,10 +32,12 @@ export default async function checkPullRequestStandards({
return;
}
try {
await github.rest.orgs.checkMembershipForUser({
org: "home-assistant",
username: pr.user.login,
});
await withRetry("organization membership check", () =>
github.rest.orgs.checkMembershipForUser({
org: "home-assistant",
username: pr.user.login,
})
);
core.info(`Skipping organization member: ${pr.user.login}`);
return;
} catch (_error) {
@@ -88,7 +91,9 @@ export default async function checkPullRequestStandards({
}
}`;
try {
await github.graphql(mutation, { id: subjectId });
await withRetry(`comment ${minimized ? "minimize" : "restore"}`, () =>
github.graphql(mutation, { id: subjectId })
);
} catch (error) {
core.info(
`Could not ${minimized ? "minimize" : "restore"} comment: ${error instanceof Error ? error.message : error}`
@@ -132,12 +137,14 @@ export default async function checkPullRequestStandards({
const isValid = problems.length === 0;
const comments = await github.paginate(github.rest.issues.listComments, {
owner,
repo,
issue_number,
per_page: 100,
});
const comments = await withRetry("comment list", () =>
github.paginate(github.rest.issues.listComments, {
owner,
repo,
issue_number,
per_page: 100,
})
);
const existing = comments.find((c) => c.body?.includes(marker));
const hasLabel = pr.labels.some((l) => l.name === label);
@@ -147,12 +154,14 @@ export default async function checkPullRequestStandards({
if (hasLabel) {
await ignoreMissing(() =>
github.rest.issues.removeLabel({
owner,
repo,
issue_number,
name: label,
})
withRetry("label removal", () =>
github.rest.issues.removeLabel({
owner,
repo,
issue_number,
name: label,
})
)
);
}
if (existing) {
@@ -164,12 +173,14 @@ export default async function checkPullRequestStandards({
core.info(`Pull request standards not met:\n- ${problems.join("\n- ")}`);
if (!hasLabel) {
await github.rest.issues.addLabels({
owner,
repo,
issue_number,
labels: [label],
});
await withRetry("label addition", () =>
github.rest.issues.addLabels({
owner,
repo,
issue_number,
labels: [label],
})
);
}
const message =
@@ -185,14 +196,17 @@ export default async function checkPullRequestStandards({
`for more on creating a great pull request (see point 6).`;
if (existing) {
await github.rest.issues.updateComment({
owner,
repo,
comment_id: existing.id,
body: message,
});
await withRetry("comment update", () =>
github.rest.issues.updateComment({
owner,
repo,
comment_id: existing.id,
body: message,
})
);
await setMinimized(existing.node_id, false);
} else {
// Not retried, as a retry after GitHub created the comment would post it twice
await github.rest.issues.createComment({
owner,
repo,
+25 -17
View File
@@ -9,6 +9,7 @@
// await checkTaskAuthorization({ github, context, core });
import type { GitHubScriptArgs, IssuePayload } from "./github-script.d.ts";
import { withRetry } from "./github-retry.mts";
export default async function checkTaskAuthorization({
github,
@@ -19,17 +20,20 @@ export default async function checkTaskAuthorization({
// Check if user is an organization member
try {
await github.rest.orgs.checkMembershipForUser({
org: "home-assistant",
username: issueAuthor,
});
await withRetry("organization membership check", () =>
github.rest.orgs.checkMembershipForUser({
org: "home-assistant",
username: issueAuthor,
})
);
core.info(`✅ ${issueAuthor} is an organization member`);
return; // Authorized
} catch (_error) {
core.info(`❌ ${issueAuthor} is not authorized to create Task issues`);
}
// Close the issue with a comment
// Close the issue with a comment. Not retried, as a retry after GitHub
// created the comment would post it twice.
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
@@ -43,18 +47,22 @@ export default async function checkTaskAuthorization({
`If you believe you should have access to create Task issues, please contact the maintainers.`,
});
await github.rest.issues.update({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
state: "closed",
});
await withRetry("issue close", () =>
github.rest.issues.update({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
state: "closed",
})
);
// Add a label to indicate this was auto-closed
await github.rest.issues.addLabels({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
labels: ["auto-closed"],
});
await withRetry("auto-closed label", () =>
github.rest.issues.addLabels({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
labels: ["auto-closed"],
})
);
}
+69
View File
@@ -0,0 +1,69 @@
// Retries GitHub API requests with jittered exponential backoff. Shared by the
// workflow scripts in this directory, test/e2e/post-report-comment.mjs and the
// fetch-nightly-translations gulp task. Only wrap requests that are safe to
// repeat: a create that fails after GitHub has applied it would run twice.
//
// This replaces @octokit/plugin-retry, which only retries HTTP error statuses
// on a fixed 1s, 4s, 9s delay with no jitter or logging. GitHub can also
// answer 200 with briefly stale data, such as the nightly translations lookup
// returning an old run with no artifact, and the plugin cannot retry that.
// github-script also leaves its built-in retries off by default.
const RETRIES = 3;
const BASE_DELAY = 1000; // ms
const MAX_DELAY = 10000; // ms
const TRANSIENT_STATUSES = new Set([408, 429, 500, 502, 503, 504]);
// Throw from a request to retry a response GitHub may answer differently on
// the next attempt, such as briefly stale data
export class RetryableError extends Error {}
// Octokit reports network failures as status 500
const isRetryable = (err: unknown) => {
if (err instanceof RetryableError) {
return true;
}
if (!(err instanceof Error) || !("status" in err)) {
return false;
}
const status = Number(err.status);
return (
TRANSIENT_STATUSES.has(status) ||
(status === 403 && /rate limit/i.test(err.message))
);
};
export async function withRetry<T>(
label: string,
request: () => Promise<T>
): Promise<T> {
for (let attempt = 1; ; attempt++) {
try {
// eslint-disable-next-line no-await-in-loop
return await request();
} catch (err) {
if (attempt > RETRIES || !isRetryable(err)) {
throw err;
}
const delay =
Math.min(BASE_DELAY * 2 ** (attempt - 1), MAX_DELAY) *
(0.8 + Math.random() * 0.4);
// eslint-disable-next-line no-console
console.log(
`Retrying ${label} after ${(delay / 1000).toFixed(1)}s (attempt ${attempt}/${RETRIES}): ${err instanceof Error ? err.message : err}`
);
// eslint-disable-next-line no-await-in-loop
await new Promise((resolve) => {
setTimeout(resolve, delay);
});
}
}
}
+56
View File
@@ -0,0 +1,56 @@
/**
* Builds a section of a workflow run's summary: a heading, a table of rows and
* the warnings raised along the way.
*
* Warnings are also logged as annotations, so a script can report problems
* without failing its step.
*/
import type { AnnotationProperties, Core } from "./github-script.d.ts";
interface SummaryOptions {
heading: string;
columns: string[];
// Shown instead of the table when no rows were added
empty: string;
}
export const createSummary = (
core: Core,
{ heading, columns, empty }: SummaryOptions
) => {
const rows: string[][] = [];
const warnings: string[] = [];
return {
addRow: (...cells: string[]) => {
rows.push(cells);
},
warn: (message: string, properties?: AnnotationProperties) => {
core.warning(message, properties);
warnings.push(message);
},
write: async () => {
core.summary.addHeading(heading, 2);
if (rows.length > 0) {
core.summary.addTable([
columns.map((data) => ({ data, header: true })),
...rows,
]);
} else {
core.summary.addRaw(`${empty}\n`);
}
if (warnings.length > 0) {
core.summary
.addHeading("Warnings", 3)
.addRaw(`${warnings.map((warning) => `- ${warning}`).join("\n")}\n`);
}
await core.summary.write();
},
};
};
+324
View File
@@ -0,0 +1,324 @@
#!/usr/bin/env node
/**
* Applies the Companion App, Home Assistant Link, Labs and Supervisor labels
* to pull requests.
*
* These areas are often changed from code outside their folders, which
* actions/labeler can't see, so this script owns them and .github/labeler.yml
* leaves them out.
*
* A label matches when the pull request:
* - changes a file in one of the area's paths, or
* - adds or removes a line that uses the area's API, such as gating a feature
* behind Labs or only showing it on Supervisor installs.
*
* Like actions/labeler, a label is removed once the pull request no longer
* matches, including one added by hand.
*
* Invoked from the `triage` job in .github/workflows/labeler.yaml.
*/
import type {
GitHubScriptArgs,
PullRequestPayload,
} from "./github-script.d.ts";
import { withRetry } from "./github-retry.mts";
import { createSummary } from "./github-summary.mts";
const RULES: { label: string; paths: RegExp[]; pattern: RegExp }[] = [
{
label: "Companion App",
paths: [
/^src\/external_app\//,
/^src\/data\/external\.ts$/,
/^src\/util\/is_ios\.ts$/,
],
// Reads or messages the companion app the frontend is running in, or
// imports its modules. Matches imports of data/external rather than
// isExternal, which is also a common local name.
pattern:
/\bauth\??\.external\b(?:[!?]*\.\w+)*(?:\([^)]*)?|\bfireExternalBusMessage\([^)]*|(?:\bimport\s[^;]*?from\s*|\bimport\(\s*)["'][^"']*\/(?:external_app\/[^"']*|data\/external)["']/g,
},
{
label: "Home Assistant Link",
paths: [
/^src\/panels\/config\/cloud\//,
/^src\/data\/cloud\//,
/^src\/data\/cloud\.ts$/,
],
// Only runs when Home Assistant Link is set up
pattern: /isComponentLoaded\([^)]*["']cloud["']/g,
},
{
label: "Labs",
paths: [/^src\/panels\/config\/labs\//, /^src\/data\/labs\.ts$/],
// Gates a feature behind, or toggles, a Labs preview feature
pattern:
/\b(?:subscribeLabFeatures?|fetchLabFeatures|labsUpdatePreviewFeature)\([^)]*/g,
},
{
label: "Supervisor",
paths: [
/^src\/panels\/config\/apps\//,
/^src\/panels\/app\//,
/^src\/data\/hassio\//,
/^src\/data\/supervisor\//,
],
// Only runs on installs with the Supervisor
pattern: /isComponentLoaded\([^)]*["']hassio["']/g,
},
];
interface HunkSide {
lines: string[];
changed: boolean[];
}
interface CodeChange {
code: string;
filename: string;
}
const parseHunks = (patch: string) => {
const hunks: { before: HunkSide; after: HunkSide }[] = [];
for (const line of patch.split("\n")) {
if (line.startsWith("@@")) {
hunks.push({
before: { lines: [], changed: [] },
after: { lines: [], changed: [] },
});
continue;
}
const hunk = hunks.at(-1);
if (!hunk || line.startsWith("\\")) {
continue;
}
const text = line.slice(1);
if (!line.startsWith("+")) {
hunk.before.lines.push(text);
hunk.before.changed.push(line.startsWith("-"));
}
if (!line.startsWith("-")) {
hunk.after.lines.push(text);
hunk.after.changed.push(line.startsWith("+"));
}
}
return hunks;
};
// Ignores whitespace and trailing commas outside strings, as Prettier changes
// them when wrapping
const normaliseCode = (code: string) => {
const parts = code.split(
/("(?:[^"\\\n]|\\.)*"|'(?:[^'\\\n]|\\.)*'|`(?:[^`\\]|\\.)*`)/
);
return parts
.map((part, i) => {
if (i % 2 === 1) {
return part;
}
const stripped = part.replace(/\s+/g, "").replace(/,(?=[}\])])/g, "");
return i === parts.length - 1 ? stripped.replace(/,$/, "") : stripped;
})
.join("");
};
// Matches whole calls, which can span lines, that touch a changed line
const changedMatches = (side: HunkSide, pattern: RegExp) => {
const text = side.lines.join("\n");
const matches: string[] = [];
for (const match of text.matchAll(pattern)) {
const first = text.slice(0, match.index).split("\n").length - 1;
const last = first + match[0].split("\n").length - 1;
if (side.changed.slice(first, last + 1).some(Boolean)) {
matches.push(normaliseCode(match[0]));
}
}
return matches;
};
// Matches that were only moved, reindented or reformatted cancel out in pairs,
// returning a file with a match that doesn't
const unmatchedChange = (added: CodeChange[], removed: CodeChange[]) => {
const remaining = [...removed];
for (const change of added) {
const index = remaining.findIndex(({ code }) => code === change.code);
if (index === -1) {
return change.filename;
}
remaining.splice(index, 1);
}
return remaining[0]?.filename;
};
const describeError = (cause: unknown) =>
cause instanceof Error ? cause.message : String(cause);
const isNotFound = (cause: unknown) =>
cause instanceof Error && "status" in cause && cause.status === 404;
export default async function labelContent({
github,
context,
core,
}: GitHubScriptArgs<PullRequestPayload>) {
const pr = context.payload.pull_request;
const summary = createSummary(core, {
heading: "Content labels",
columns: ["Label", "Change", "Reason"],
empty: "No content labels changed.",
});
if (process.env.LABELER_OUTCOME === "failure") {
summary.warn(
"Applying labels from .github/labeler.yml failed, see its step"
);
}
// Bot pull requests, such as Prettier bumps, rewrite code they don't change
const checkCode = pr.user.type !== "Bot";
const { owner, repo } = context.repo;
let files;
let existing: Set<string>;
try {
// Read the current labels, as the event's snapshot can be stale by now
const [fileList, labels] = await Promise.all([
withRetry("pull request files", () =>
github.paginate(github.rest.pulls.listFiles, {
owner,
repo,
pull_number: pr.number,
per_page: 100,
})
),
withRetry("pull request labels", () =>
github.paginate(github.rest.issues.listLabelsOnIssue, {
owner,
repo,
issue_number: pr.number,
per_page: 100,
})
),
]);
files = fileList;
existing = new Set(labels.map((l) => l.name));
} catch (error) {
summary.warn(
`Could not read the pull request's files and labels: ${describeError(error)}`
);
await summary.write();
return;
}
const add: { label: string; reason: string }[] = [];
const remove: string[] = [];
for (const rule of RULES) {
const added: CodeChange[] = [];
const removed: CodeChange[] = [];
if (checkCode) {
for (const { filename, patch } of files) {
for (const { before, after } of parseHunks(patch ?? "")) {
for (const code of changedMatches(after, rule.pattern)) {
added.push({ code, filename });
}
for (const code of changedMatches(before, rule.pattern)) {
removed.push({ code, filename });
}
}
}
}
// Renames count for both locations, as moving a file out of an area changes it
const match =
files.find(({ filename, previous_filename }) =>
rule.paths.some(
(path) =>
path.test(filename) ||
(previous_filename !== undefined && path.test(previous_filename))
)
)?.filename ?? unmatchedChange(added, removed);
if (match && !existing.has(rule.label)) {
core.info(`Adding ${rule.label} for ${match}`);
add.push({ label: rule.label, reason: match });
} else if (!match && existing.has(rule.label)) {
core.info(`Removing ${rule.label}, no longer matched`);
remove.push(rule.label);
}
}
const removals = await Promise.allSettled(
remove.map((name) =>
withRetry("label removal", () =>
github.rest.issues.removeLabel({
owner,
repo,
issue_number: pr.number,
name,
})
)
)
);
removals.forEach((removal, i) => {
const name = remove[i];
// Already removed, such as by hand or a run that raced this one
if (removal.status === "fulfilled" || isNotFound(removal.reason)) {
summary.addRow(name, "Removed", "No longer matched");
} else {
summary.warn(
`Could not remove ${name}: ${describeError(removal.reason)}`
);
}
});
if (add.length > 0) {
try {
await withRetry("label addition", () =>
github.rest.issues.addLabels({
owner,
repo,
issue_number: pr.number,
labels: add.map(({ label }) => label),
})
);
for (const { label, reason } of add) {
summary.addRow(label, "Added", reason);
}
} catch (error) {
summary.warn(
`Could not add ${add.map(({ label }) => label).join(", ")}: ${describeError(error)}`
);
}
}
await summary.write();
}
-35
View File
@@ -1,35 +0,0 @@
name: Blocking labels
on:
pull_request:
types:
- opened
- synchronize
- reopened
- labeled
- unlabeled
branches:
- dev
- master
permissions:
contents: read
jobs:
check:
name: Check for labels which block the Pull Request from being merged
runs-on: ubuntu-latest
steps:
- name: Check out workflow scripts
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
sparse-checkout: .github/scripts
- name: Check for blocking labels
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with:
script: |
const { default: checkBlockingLabels } = await import(
`${process.env.GITHUB_WORKSPACE}/.github/scripts/check-blocking-labels.mts`
);
await checkBlockingLabels({ github, context, core });
+3 -57
View File
@@ -100,12 +100,9 @@ jobs:
run: pnpm run test
build:
name: Build frontend
# Runs alongside lint and test rather than after them: the build only needs
# the dependency tree, and with the rspack cache it is no longer expensive
# enough to be worth serialising behind the other checks. The
# cancel-on-failure job below stops the run as soon as a check fails, so a
# broken pull request does not finish building.
needs: prepare-dependencies
needs:
- lint
- test
runs-on: ubuntu-latest
steps:
- name: Check out files from GitHub
@@ -151,54 +148,3 @@ jobs:
path: hass_frontend/
if-no-files-found: error
retention-days: 7
# Now that the checks run in parallel, a failing lint or test no longer stops
# the build from finishing on its own, so this watches them and cancels the
# whole run on the first failure.
#
# It is a separate job on purpose. Cancelling needs `actions: write`, and the
# other jobs check out the pull request and run its build scripts — handing
# them that scope would give PR-controlled code (or a compromised dependency)
# write access to Actions. This job never checks out the repository, so the
# elevated token stays away from PR code. It also cannot be a job that
# `needs` the checks: that would only start once they have all finished, which
# is exactly too late to cancel anything.
cancel-on-failure:
name: Cancel run on failure
needs: prepare-dependencies
runs-on: ubuntu-latest
permissions:
actions: write
timeout-minutes: 30
steps:
- name: Cancel the run when a check fails
env:
GH_TOKEN: ${{ github.token }}
REPO: ${{ github.repository }}
RUN_ID: ${{ github.run_id }}
run: |
watched='^(Lint and check format|Run tests|Build frontend)$'
while :; do
jobs=$(gh api "repos/$REPO/actions/runs/$RUN_ID/jobs?per_page=100" \
--paginate --jq '.jobs[] | [.name, .status, (.conclusion // "")] | @tsv' \
2>/dev/null || true)
failed=$(printf '%s\n' "$jobs" | awk -F'\t' -v w="$watched" \
'$1 ~ w && ($3 == "failure" || $3 == "timed_out") { print $1 }')
if [ -n "$failed" ]; then
echo "Cancelling the run, these checks failed:"
printf '%s\n' "$failed"
gh run cancel "$RUN_ID" --repo "$REPO" || true
exit 0
fi
found=$(printf '%s\n' "$jobs" | awk -F'\t' -v w="$watched" '$1 ~ w' | wc -l)
running=$(printf '%s\n' "$jobs" | awk -F'\t' -v w="$watched" \
'$1 ~ w && $2 != "completed" { print $1 }')
if [ "$found" -ge 3 ] && [ -z "$running" ]; then
echo "All checks finished without failure"
exit 0
fi
sleep 15
done
+4 -4
View File
@@ -38,7 +38,7 @@ jobs:
name: Prepare container dependencies
runs-on: ubuntu-latest
container:
image: mcr.microsoft.com/playwright:v1.63.0-noble
image: mcr.microsoft.com/playwright:v1.64.0-noble
options: --user 1001
defaults:
run:
@@ -155,7 +155,7 @@ jobs:
- prepare-container-dependencies
runs-on: ubuntu-latest
container:
image: mcr.microsoft.com/playwright:v1.63.0-noble
image: mcr.microsoft.com/playwright:v1.64.0-noble
options: --user 1001 --ipc=host
defaults:
run:
@@ -207,7 +207,7 @@ jobs:
- prepare-container-dependencies
runs-on: ubuntu-latest
container:
image: mcr.microsoft.com/playwright:v1.63.0-noble
image: mcr.microsoft.com/playwright:v1.64.0-noble
options: --user 1001 --ipc=host
defaults:
run:
@@ -261,7 +261,7 @@ jobs:
- prepare-container-dependencies
runs-on: ubuntu-latest
container:
image: mcr.microsoft.com/playwright:v1.63.0-noble
image: mcr.microsoft.com/playwright:v1.64.0-noble
options: --user 1001 --ipc=host
defaults:
run:
+24 -1
View File
@@ -1,6 +1,11 @@
name: "Pull Request Labeler"
on: pull_request_target # zizmor: ignore[dangerous-triggers] -- safe: only runs actions/labeler, no PR code checkout
on: pull_request_target # zizmor: ignore[dangerous-triggers] -- safe: checks out base repo scripts only and reads PR files through the API, never PR head code
# Runs one at a time per pull request, so an older run can't undo a newer one
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number }}
cancel-in-progress: true
jobs:
triage:
@@ -10,6 +15,24 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Apply labels
id: labeler
continue-on-error: true
uses: actions/labeler@bf12e9b00b37c5c0ca2b87b79b2daf7891dbda13 # v7.0.0
with:
sync-labels: true
- name: Check out workflow scripts
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
sparse-checkout: .github/scripts
- name: Apply content labels
continue-on-error: true
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
env:
LABELER_OUTCOME: ${{ steps.labeler.outcome }}
with:
script: |
const { default: labelContent } = await import(
`${process.env.GITHUB_WORKSPACE}/.github/scripts/label-content.mts`
);
await labelContent({ github, context, core });
+2 -2
View File
@@ -18,7 +18,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Send bundle stats and build information to RelativeCI
uses: relative-ci/agent-action@fcf45416581928e8dd62eded78ce98c78e5149f8 # v3.2.3
uses: relative-ci/agent-action@711f3a8fa1b928e6afde280011110c9b0a224a60 # v3.2.4
with:
key: ${{ secrets.RELATIVE_CI_KEY_frontend_modern }}
token: ${{ github.token }}
@@ -31,7 +31,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Send bundle stats and build information to RelativeCI
uses: relative-ci/agent-action@fcf45416581928e8dd62eded78ce98c78e5149f8 # v3.2.3
uses: relative-ci/agent-action@711f3a8fa1b928e6afde280011110c9b0a224a60 # v3.2.4
with:
key: ${{ secrets.RELATIVE_CI_KEY_frontend_legacy }}
token: ${{ github.token }}
+1 -1
View File
@@ -19,6 +19,6 @@ jobs:
pull-requests: read
runs-on: ubuntu-latest
steps:
- uses: release-drafter/release-drafter@34d80673e067bdc0c24568d3af899c216adcfaa9 # v7.7.0
- uses: release-drafter/release-drafter@72967cdc98ddd3160f1fc3dff619de365e137dd0 # v7.9.0
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+17 -6
View File
@@ -15,22 +15,33 @@ jobs:
name: Add no-stale label
runs-on: ubuntu-latest
permissions:
contents: read # To check out workflow scripts
issues: write # To add labels to issues
if: >-
github.event.issue.type.name == 'Task'
|| github.event.issue.type.name == 'Epic'
|| github.event.issue.type.name == 'Opportunity'
steps:
- name: Check out workflow scripts
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
sparse-checkout: .github/scripts
- name: Add no-stale label
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with:
script: |
await github.rest.issues.addLabels({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
labels: ['no-stale']
});
const { withRetry } = await import(
`${process.env.GITHUB_WORKSPACE}/.github/scripts/github-retry.mts`
);
await withRetry("no-stale label", () =>
github.rest.issues.addLabels({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
labels: ['no-stale']
})
);
check-authorization:
name: Check authorization
+3 -2
View File
@@ -15,11 +15,12 @@ jobs:
if: github.repository == 'home-assistant/frontend'
runs-on: ubuntu-latest
steps:
- name: 90 days stale policy
- name: Stale policy
uses: actions/stale@4391f3da665fdf50b6810c1a66712fb9ba21aa93 # v11.0.0
with:
repo-token: ${{ secrets.GITHUB_TOKEN }}
days-before-stale: 90
days-before-issue-stale: 90
days-before-pr-stale: 60
days-before-close: 7
operations-per-run: 25
remove-stale-when-updated: true
+169 -162
View File
@@ -1,194 +1,201 @@
Apache License
==============
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
_Version 2.0, January 2004_
_&lt;<http://www.apache.org/licenses/>&gt;_
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
### Terms and Conditions for use, reproduction, and distribution
1. Definitions.
#### 1. Definitions
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
“License” shall mean the terms and conditions for use, reproduction, and
distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
“Licensor” shall mean the copyright owner or entity authorized by the copyright
owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
“Legal Entity” shall mean the union of the acting entity and all other entities
that control, are controlled by, or are under common control with that entity.
For the purposes of this definition, “control” means **(i)** the power, direct or
indirect, to cause the direction or management of such entity, whether by
contract or otherwise, or **(ii)** ownership of fifty percent (50%) or more of the
outstanding shares, or **(iii)** beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
“You” (or “Your”) shall mean an individual or Legal Entity exercising
permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
“Source” form shall mean the preferred form for making modifications, including
but not limited to software source code, documentation source, and configuration
files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
“Object” form shall mean any form resulting from mechanical transformation or
translation of a Source form, including but not limited to compiled object code,
generated documentation, and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
“Work” shall mean the work of authorship, whether in Source or Object form, made
available under the License, as indicated by a copyright notice that is included
in or attached to the work (an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
“Derivative Works” shall mean any work, whether in Source or Object form, that
is based on (or derived from) the Work and for which the editorial revisions,
annotations, elaborations, or other modifications represent, as a whole, an
original work of authorship. For the purposes of this License, Derivative Works
shall not include works that remain separable from, or merely link (or bind by
name) to the interfaces of, the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
“Contribution” shall mean any work of authorship, including the original version
of the Work and any modifications or additions to that Work or Derivative Works
thereof, that is intentionally submitted to Licensor for inclusion in the Work
by the copyright owner or by an individual or Legal Entity authorized to submit
on behalf of the copyright owner. For the purposes of this definition,
“submitted” means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems, and
issue tracking systems that are managed by, or on behalf of, the Licensor for
the purpose of discussing and improving the Work, but excluding communication
that is conspicuously marked or otherwise designated in writing by the copyright
owner as “Not a Contribution.”
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
“Contributor” shall mean Licensor and any individual or Legal Entity on behalf
of whom a Contribution has been received by Licensor and subsequently
incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
#### 2. Grant of Copyright License
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
Subject to the terms and conditions of this License, each Contributor hereby
grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free,
irrevocable copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the Work and such
Derivative Works in Source or Object form.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
#### 3. Grant of Patent License
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
Subject to the terms and conditions of this License, each Contributor hereby
grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free,
irrevocable (except as stated in this section) patent license to make, have
made, use, offer to sell, sell, import, and otherwise transfer the Work, where
such license applies only to those patent claims licensable by such Contributor
that are necessarily infringed by their Contribution(s) alone or by combination
of their Contribution(s) with the Work to which such Contribution(s) was
submitted. If You institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work or a
Contribution incorporated within the Work constitutes direct or contributory
patent infringement, then any patent licenses granted to You under this License
for that Work shall terminate as of the date such litigation is filed.
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
#### 4. Redistribution
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
You may reproduce and distribute copies of the Work or Derivative Works thereof
in any medium, with or without modifications, and in Source or Object form,
provided that You meet the following conditions:
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
* **(a)** You must give any other recipients of the Work or Derivative Works a copy of
this License; and
* **(b)** You must cause any modified files to carry prominent notices stating that You
changed the files; and
* **(c)** You must retain, in the Source form of any Derivative Works that You distribute,
all copyright, patent, trademark, and attribution notices from the Source form
of the Work, excluding those notices that do not pertain to any part of the
Derivative Works; and
* **(d)** If the Work includes a “NOTICE” text file as part of its distribution, then any
Derivative Works that You distribute must include a readable copy of the
attribution notices contained within such NOTICE file, excluding those notices
that do not pertain to any part of the Derivative Works, in at least one of the
following places: within a NOTICE text file distributed as part of the
Derivative Works; within the Source form or documentation, if provided along
with the Derivative Works; or, within a display generated by the Derivative
Works, if and wherever such third-party notices normally appear. The contents of
the NOTICE file are for informational purposes only and do not modify the
License. You may add Your own attribution notices within Derivative Works that
You distribute, alongside or as an addendum to the NOTICE text from the Work,
provided that such additional attribution notices cannot be construed as
modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
You may add Your own copyright statement to Your modifications and may provide
additional or different license terms and conditions for use, reproduction, or
distribution of Your modifications, or for any such Derivative Works as a whole,
provided Your use, reproduction, and distribution of the Work otherwise complies
with the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
#### 5. Submission of Contributions
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
Unless You explicitly state otherwise, any Contribution intentionally submitted
for inclusion in the Work by You to the Licensor shall be under the terms and
conditions of this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify the terms of
any separate license agreement you may have executed with Licensor regarding
such Contributions.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
#### 6. Trademarks
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
This License does not grant permission to use the trade names, trademarks,
service marks, or product names of the Licensor, except as required for
reasonable and customary use in describing the origin of the Work and
reproducing the content of the NOTICE file.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
#### 7. Disclaimer of Warranty
END OF TERMS AND CONDITIONS
Unless required by applicable law or agreed to in writing, Licensor provides the
Work (and each Contributor provides its Contributions) on an “AS IS” BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied,
including, without limitation, any warranties or conditions of TITLE,
NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are
solely responsible for determining the appropriateness of using or
redistributing the Work and assume any risks associated with Your exercise of
permissions under this License.
APPENDIX: How to apply the Apache License to your work.
#### 8. Limitation of Liability
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
In no event and under no legal theory, whether in tort (including negligence),
contract, or otherwise, unless required by applicable law (such as deliberate
and grossly negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special, incidental,
or consequential damages of any character arising as a result of this License or
out of the use or inability to use the Work (including but not limited to
damages for loss of goodwill, work stoppage, computer failure or malfunction, or
any and all other commercial damages or losses), even if such Contributor has
been advised of the possibility of such damages.
Copyright [yyyy] [name of copyright owner]
#### 9. Accepting Warranty or Additional Liability
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
While redistributing the Work or Derivative Works thereof, You may choose to
offer, and charge a fee for, acceptance of support, warranty, indemnity, or
other liability obligations and/or rights consistent with this License. However,
in accepting such obligations, You may act only on Your own behalf and on Your
sole responsibility, not on behalf of any other Contributor, and only if You
agree to indemnify, defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason of your
accepting any such warranty or additional liability.
http://www.apache.org/licenses/LICENSE-2.0
_END OF TERMS AND CONDITIONS_
### APPENDIX: How to apply the Apache License to your work
To apply the Apache License to your work, attach the following boilerplate
notice, with the fields enclosed by brackets `[]` replaced with your own
identifying information. (Don't include the brackets!) The text should be
enclosed in the appropriate comment syntax for the file format. We also
recommend that a file or class name and description of purpose be included on
the same “printed page” as the copyright notice for easier identification within
third-party archives.
Copyright [yyyy] [name of copyright owner]
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
+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": 593517,
"core": 54884,
"authorize": 557490,
"onboarding": 676131
"app": 624240,
"core": 55144,
"authorize": 594400,
"onboarding": 705499
},
"frontend-legacy": {
"app": 738388,
"core": 188119,
"authorize": 716748,
"onboarding": 840680
"app": 776696,
"core": 188309,
"authorize": 763316,
"onboarding": 879807
}
}
+29 -8
View File
@@ -5,6 +5,7 @@ import mapStream from "map-stream";
import transform from "gulp-json-transform";
import { LokaliseApi } from "@lokalise/node-api";
import JSZip from "jszip";
import { SERVICE_DOMAINS } from "./gen-demo-core-data.js";
const inDir = "translations";
const inDirFrontend = `${inDir}/frontend`;
@@ -51,15 +52,34 @@ function convertBackendTranslations(data, _file) {
if (!data.component) {
return output;
}
// Keep the entity domains and the domains with automation triggers or
// conditions, so the demo can show states and describe automations. Only
// keep the services of the domains whose services the demo mocks.
Object.keys(data.component).forEach((domain) => {
if (!("entity_component" in data.component[domain])) {
const {
entity_component,
services,
triggers,
conditions,
selector,
title,
} = data.component[domain];
if (
!entity_component &&
!triggers &&
!conditions &&
!SERVICE_DOMAINS.includes(domain)
) {
return;
}
output.component[domain] = { entity_component: {} };
Object.keys(data.component[domain].entity_component).forEach((key) => {
output.component[domain].entity_component[key] =
data.component[domain].entity_component[key];
});
output.component[domain] = {
entity_component,
services: SERVICE_DOMAINS.includes(domain) ? services : undefined,
triggers,
conditions,
selector,
title,
};
});
return output;
}
@@ -155,8 +175,9 @@ gulp.task("fetch-lokalise", async function () {
fs.mkdir(inDirBackend, { recursive: true }),
]);
// The backend project only provides entity_component translations, which are
// merged into the demo, gallery, cast and e2e builds. The shipped app fetches
// The backend project only provides the translations the mocked backend
// needs (see convertBackendTranslations), which are merged into the demo,
// gallery, cast and e2e builds. The shipped app fetches
// them live from core, so builds that only produce the app (release, release
// landing-page) can skip this second, whole-project export to save time.
const projects = Object.entries(lokaliseProjects).filter(
@@ -1,7 +1,6 @@
// Task to download the latest Lokalise translations from the nightly workflow artifacts
import { createOAuthDeviceAuth } from "@octokit/auth-oauth-device";
import { retry } from "@octokit/plugin-retry";
import { Octokit } from "@octokit/rest";
import { deleteAsync } from "del";
import { mkdir, readFile, writeFile } from "fs/promises";
@@ -10,11 +9,20 @@ import jszip from "jszip";
import path from "path";
import process from "process";
import { extract } from "tar";
import {
RetryableError,
withRetry,
} from "../../.github/scripts/github-retry.mts";
type Artifact = Awaited<
ReturnType<Octokit["rest"]["actions"]["listArtifactsForRepo"]>
>["data"]["artifacts"][number];
const MAX_AGE = 24; // hours
const OWNER = "home-assistant";
const REPO = "frontend";
const WORKFLOW_NAME = "nightly.yaml";
const BRANCH = "dev";
const ARTIFACT_NAME = "translations";
const CLIENT_ID = "Iv1.3914e28cb27834d1";
const EXTRACT_DIR = "translations";
@@ -36,11 +44,16 @@ gulp.task("fetch-nightly-translations", async function () {
// Read current translations artifact info if it exists,
// and stop if they are not old enough
let currentArtifact;
let currentArtifact: Artifact | null;
try {
currentArtifact = JSON.parse(await readFile(ARTIFACT_FILE, "utf-8"));
const artifact: Artifact = JSON.parse(
await readFile(ARTIFACT_FILE, "utf-8")
);
currentArtifact = artifact;
const currentAge =
(Date.now() - Date.parse(currentArtifact.created_at)) / 3600000;
(Date.now() - Date.parse(artifact.created_at ?? "")) / 3600000;
if (currentAge < MAX_AGE) {
console.log(
"Keeping current translations (only %s hours old)",
@@ -63,19 +76,19 @@ gulp.task("fetch-nightly-translations", async function () {
}
console.warn(
"Failed to fetch nightly translations, continuing with English only:",
err?.message || err
err instanceof Error ? err.message : err
);
}
});
async function fetchTranslations(currentArtifact) {
async function fetchTranslations(currentArtifact: Artifact | null) {
// To store file writing promises
const createExtractDir = mkdir(EXTRACT_DIR, { recursive: true });
const writings = [];
const writings: Promise<void>[] = [];
// Authenticate to GitHub using GitHub action token if it exists,
// otherwise look for a saved user token or generate a new one if none
let tokenAuth;
let tokenAuth: { token: string };
if (process.env.GITHUB_TOKEN) {
tokenAuth = { token: process.env.GITHUB_TOKEN };
} else {
@@ -104,69 +117,86 @@ async function fetchTranslations(currentArtifact) {
});
tokenAuth = await auth({ type: "oauth" });
writings.push(
createExtractDir.then(
createExtractDir.then(() =>
writeFile(TOKEN_FILE, JSON.stringify(tokenAuth, null, 2))
)
);
}
}
// Authenticate with token and request workflow runs from GitHub
console.log("Fetching new translations...");
const octokit = new (Octokit.plugin(retry))({
// Authenticate with token and find the newest translations artifact
const octokit = new Octokit({
userAgent: "Fetch Nightly Translations",
auth: tokenAuth.token,
});
const workflowRunsResponse = await octokit.rest.actions.listWorkflowRuns({
owner: OWNER,
repo: REPO,
workflow_id: WORKFLOW_NAME,
status: "success",
event: "schedule",
per_page: 1,
exclude_pull_requests: true,
});
if (workflowRunsResponse.data.total_count === 0) {
throw Error("No successful nightly workflow runs found");
}
const latestNightlyRun = workflowRunsResponse.data.workflow_runs[0];
// Only the nightly workflow uploads this artifact. Requiring a run from this
// repository's own branch excludes artifacts uploaded by fork pull requests.
const latestArtifact = await withRetry(
"translations artifact lookup",
async () => {
const { data } = await octokit.rest.actions.listArtifactsForRepo({
owner: OWNER,
repo: REPO,
name: ARTIFACT_NAME,
per_page: 10,
});
// Stop if current is already the latest, otherwise Find the translations artifact
if (currentArtifact?.workflow_run.id === latestNightlyRun.id) {
const artifact = data.artifacts.find(
({ expired, workflow_run: run }) =>
!expired &&
run?.head_branch === BRANCH &&
run.head_repository_id === run.repository_id
);
if (!artifact) {
throw new RetryableError(
`No ${ARTIFACT_NAME} artifact found from ${BRANCH}`
);
}
return artifact;
}
);
console.log(
"Latest translations artifact is %s from workflow run %s (%s)",
latestArtifact.id,
latestArtifact.workflow_run?.id,
latestArtifact.created_at
);
// Stop if current is already the latest
if (currentArtifact?.id === latestArtifact.id) {
console.log("Stopping because current translations are still the latest");
return;
}
const latestArtifact = (
await octokit.actions.listWorkflowRunArtifacts({
owner: OWNER,
repo: REPO,
run_id: latestNightlyRun.id,
})
).data.artifacts.find((artifact) => artifact.name === ARTIFACT_NAME);
if (!latestArtifact) {
throw Error("Latest nightly workflow run has no translations artifact");
}
// Remove the current translations
const deleteCurrent = Promise.all(writings).then(
const deleteCurrent = Promise.all(writings).then(() =>
deleteAsync([`${EXTRACT_DIR}/*`, `!${ARTIFACT_FILE}`, `!${TOKEN_FILE}`])
);
// Get the download URL and follow the redirect to download (stored as ArrayBuffer)
const downloadResponse = await octokit.actions.downloadArtifact({
owner: OWNER,
repo: REPO,
artifact_id: latestArtifact.id,
archive_format: "zip",
});
if (downloadResponse.status !== 200) {
const downloadResponse = await withRetry("translations download", () =>
octokit.rest.actions.downloadArtifact({
owner: OWNER,
repo: REPO,
artifact_id: latestArtifact.id,
archive_format: "zip",
})
);
// Octokit types the redirect, but fetch follows it to the archive
if ((downloadResponse.status as number) !== 200) {
throw Error("Failure downloading translations artifact");
}
// Artifact is a tarball, but GitHub adds it to a zip file
console.log("Unpacking downloaded translations...");
const zip = await jszip.loadAsync(downloadResponse.data);
const zip = await jszip.loadAsync(downloadResponse.data as ArrayBuffer);
await deleteCurrent;
const extractStream = zip.file(/.*/)[0].nodeStream().pipe(extract());
await new Promise((resolve, reject) => {
-4
View File
@@ -77,10 +77,6 @@ async function copyMapPanel(staticDir) {
// Style, glyphs and sprites for the vector base map
await ensureMapAssets();
fs.copySync(mapAssetsDir, staticPath("map/"));
copyFileDir(
npmPath("@mapbox/mapbox-gl-rtl-text/dist/mapbox-gl-rtl-text.js"),
staticPath("map/")
);
// Controls and popups of the native MapLibre engine
copyFileDir(npmPath("maplibre-gl/dist/maplibre-gl.css"), staticPath("map/"));
}
+422
View File
@@ -0,0 +1,422 @@
// Generates the service, trigger and condition descriptions the demo and
// gallery serve instead of a backend, from the files in Home Assistant Core.
import gulp from "gulp";
import { CORE_SCHEMA, load, mergeTag } from "js-yaml";
import { writeFile } from "node:fs/promises";
import { join } from "node:path";
import process from "node:process";
import prettier from "prettier";
import paths from "../paths.cjs";
const CORE_URL =
process.env.CORE_COMPONENTS_URL ||
"https://raw.githubusercontent.com/home-assistant/core/refs/heads/dev/homeassistant/components";
// Integrations whose services the mocked backend provides
export const SERVICE_DOMAINS = [
"alarm_control_panel",
"automation",
"button",
"calendar",
"camera",
"climate",
"conversation",
"counter",
"cover",
"device_tracker",
"fan",
"frontend",
"group",
"homeassistant",
"humidifier",
"image_processing",
"input_boolean",
"input_button",
"input_datetime",
"input_number",
"input_select",
"input_text",
"lawn_mower",
"light",
"lock",
"logger",
"media_player",
"notify",
"number",
"persistent_notification",
"person",
"recorder",
"remote",
"scene",
"schedule",
"script",
"select",
"siren",
"switch",
"system_log",
"text",
"timer",
"todo",
"tts",
"update",
"vacuum",
"valve",
"water_heater",
"weather",
"zone",
];
// Services that return data. Core sets this when it registers a service, it is
// not part of services.yaml. The value is whether the response is optional.
const RESPONSE_SERVICES = {
"calendar.get_events": false,
"conversation.process": true,
"media_player.browse_media": false,
"media_player.search_media": false,
"recorder.get_statistics": false,
"schedule.get_schedule": false,
"todo.get_items": false,
"weather.get_forecasts": false,
};
// Services that are still in services.yaml, but that core no longer registers
const UNREGISTERED_SERVICES = ["weather.get_forecast"];
// Integrations that provide the new style triggers and conditions
const AUTOMATION_PLATFORM_DOMAINS = [
"air_quality",
"alarm_control_panel",
"assist_satellite",
"battery",
"button",
"calendar",
"climate",
"counter",
"cover",
"door",
"doorbell",
"event",
"fan",
"garage_door",
"gate",
"humidifier",
"humidity",
"illuminance",
"lawn_mower",
"light",
"lock",
"media_player",
"moisture",
"moon",
"motion",
"occupancy",
"power",
"remote",
"scene",
"schedule",
"select",
"siren",
"sun",
"switch",
"temperature",
"text",
"timer",
"todo",
"update",
"vacuum",
"valve",
"vibration",
"water_heater",
"window",
"zone",
];
// YAML 1.2 with merge keys, which matches how core reads these files
const YAML_SCHEMA = CORE_SCHEMA.withTags([mergeTag]);
const HEADER = `// This file is auto-generated from Home Assistant Core. Do not edit by hand.
// Regenerate with \`script/gen_demo_core_data\`.`;
const fetchCoreFile = async (domain, file) => {
const url = `${CORE_URL}/${domain}/${file}`;
const response = await fetch(url);
if (response.status === 404) {
return undefined;
}
if (!response.ok) {
throw new Error(`Failed to fetch ${url}: ${response.status}`);
}
return response.text();
};
// Fetches a file of every domain, keyed by domain
const fetchCoreFiles = async (domains, file) =>
Object.fromEntries(
await Promise.all(
domains.map(async (domain) => [domain, await fetchCoreFile(domain, file)])
)
);
const parseYaml = (content) => load(content, { schema: YAML_SCHEMA }) ?? {};
// Enum references look like "cover.CoverEntityFeature.OPEN"
const ENUM_REFERENCE = /^([a-z_]+)\.[A-Z]\w*\.[A-Z0-9_]+$/;
const enumDomains = (value, domains = new Set()) => {
if (typeof value === "string") {
const match = value.match(ENUM_REFERENCE);
if (match) {
domains.add(match[1]);
}
} else if (value && typeof value === "object") {
Object.values(value).forEach((val) => enumDomains(val, domains));
}
return domains;
};
// Resolves "<domain>.<Enum>.<MEMBER>" to the value of the member, like core
const enumResolver = async (yamls) => {
const domains = [...enumDomains(yamls)];
const [consts, inits] = await Promise.all([
fetchCoreFiles(domains, "const.py"),
fetchCoreFiles(domains, "__init__.py"),
]);
return (reference) => {
const [domain, enumName, member] = reference.split(".");
for (const source of [consts[domain], inits[domain]]) {
const match = source
?.split(`class ${enumName}(`)[1]
?.match(new RegExp(`\\n\\s+${member} = ("[^"]*"|\\d+)`));
if (match) {
return JSON.parse(match[1]);
}
}
throw new Error(`Unknown enum ${reference}`);
};
};
const createResolvers = (enumValue) => {
// In entity filters, every item of the supported features, a feature or a
// list of features, becomes one bit mask
const featureMask = (features) =>
[].concat(features).reduce(
// eslint-disable-next-line no-bitwise
(mask, feature) => mask | enumValue(feature),
0
);
const resolveSelector = (value) => {
if (Array.isArray(value)) {
return value.map(resolveSelector);
}
if (!value || typeof value !== "object") {
return value;
}
return Object.fromEntries(
Object.entries(value).map(([key, val]) => [
key,
key === "supported_features"
? [].concat(val).map(featureMask)
: resolveSelector(val),
])
);
};
// Like core, an empty target allows any target
const resolveTarget = (target) => resolveSelector(target ?? {});
// Field filters keep their lists, with every enum resolved on its own
const resolveField = (field) => {
const result = { ...field };
if (field.selector) {
result.selector = resolveSelector(field.selector);
}
if (field.filter?.supported_features) {
result.filter = {
...result.filter,
supported_features: field.filter.supported_features.map(enumValue),
};
}
if (field.filter?.attribute) {
result.filter = {
...result.filter,
attribute: Object.fromEntries(
Object.entries(field.filter.attribute).map(([name, options]) => [
name,
options.map(enumValue),
])
),
};
}
return result;
};
// Fields can be grouped in sections, which hold fields themselves
const resolveFields = (fields) =>
Object.fromEntries(
Object.entries(fields ?? {}).map(([key, field]) => [
key,
field && "fields" in field && !("selector" in field)
? { ...field, fields: resolveFields(field.fields) }
: resolveField(field ?? {}),
])
);
return { resolveFields, resolveTarget };
};
// Mirrors how core builds the response of get_services
const serviceDescriptions = (yamls, { resolveFields, resolveTarget }) =>
Object.fromEntries(
SERVICE_DOMAINS.map((domain) => {
if (!yamls[domain]) {
throw new Error(`No services.yaml found for ${domain}`);
}
const services = Object.entries(yamls[domain])
.filter(
([service]) =>
!service.startsWith(".") &&
!UNREGISTERED_SERVICES.includes(`${domain}.${service}`)
)
.map(([service, value]) => {
const description = { fields: resolveFields(value?.fields) };
for (const item of ["description", "name"]) {
if (value && item in value) {
description[item] = value[item];
}
}
if (value && "target" in value) {
description.target = resolveTarget(value.target);
}
const key = `${domain}.${service}`;
if (key in RESPONSE_SERVICES) {
description.response = { optional: RESPONSE_SERVICES[key] };
}
return [service, description];
});
return [domain, Object.fromEntries(services)];
})
);
// Mirrors how core builds the trigger and condition platform descriptions
const platformDescriptions = (yamls, { resolveFields, resolveTarget }) =>
Object.fromEntries(
AUTOMATION_PLATFORM_DOMAINS.flatMap((domain) =>
Object.entries(yamls[domain] ?? {})
// Keys starting with a dot only hold YAML anchors
.filter(([key, value]) => !key.startsWith(".") && value)
.map(([key, value]) => {
const absoluteKey = !key.startsWith("_")
? `${domain}.${key}`
: key.slice(1) || domain;
const description = { fields: resolveFields(value.fields) };
if ("target" in value) {
description.target = resolveTarget(value.target);
}
return [absoluteKey, description];
})
)
);
// The trigger or condition icons of every domain that has them
const platformIcons = (icons, category) =>
Object.fromEntries(
Object.entries(icons)
.map(([domain, content]) => [
domain,
content ? JSON.parse(content)[category] : undefined,
])
.filter(([, categoryIcons]) => categoryIcons)
);
const parseYamls = (files) =>
Object.fromEntries(
Object.entries(files)
.filter(([, content]) => content !== undefined)
.map(([domain, content]) => [domain, parseYaml(content)])
);
const writeTs = async (path, content) => {
const file = join(paths.root_dir, path);
const options = await prettier.resolveConfig(file);
await writeFile(
file,
await prettier.format(`${HEADER}\n${content}`, {
...options,
filepath: file,
})
);
};
gulp.task("gen-demo-core-data", async () => {
const [servicesFiles, triggersFiles, conditionsFiles, iconsFiles] =
await Promise.all([
fetchCoreFiles(SERVICE_DOMAINS, "services.yaml"),
fetchCoreFiles(AUTOMATION_PLATFORM_DOMAINS, "triggers.yaml"),
fetchCoreFiles(AUTOMATION_PLATFORM_DOMAINS, "conditions.yaml"),
fetchCoreFiles(AUTOMATION_PLATFORM_DOMAINS, "icons.json"),
]);
const servicesYamls = parseYamls(servicesFiles);
const triggersYamls = parseYamls(triggersFiles);
const conditionsYamls = parseYamls(conditionsFiles);
const resolvers = createResolvers(
await enumResolver([servicesYamls, triggersYamls, conditionsYamls])
);
for (const key of UNREGISTERED_SERVICES) {
const [domain, service] = key.split(".");
if (!(service in (servicesYamls[domain] ?? {}))) {
throw new Error(`Remove ${key} from UNREGISTERED_SERVICES, it is gone`);
}
}
const services = serviceDescriptions(servicesYamls, resolvers);
for (const key of Object.keys(RESPONSE_SERVICES)) {
const [domain, service] = key.split(".");
if (!services[domain]?.[service]) {
throw new Error(`Service ${key} that returns a response does not exist`);
}
}
const triggers = platformDescriptions(triggersYamls, resolvers);
const conditions = platformDescriptions(conditionsYamls, resolvers);
const icons = {
triggers: platformIcons(iconsFiles, "triggers"),
conditions: platformIcons(iconsFiles, "conditions"),
};
await Promise.all([
writeTs(
"src/fake_data/demo_services.ts",
`import type { HassServices } from "home-assistant-js-websocket";
// Core sends more than the HassServices type covers, like sections and list
// examples.
export const demoServices = ${JSON.stringify(services)} as unknown as HassServices;
`
),
writeTs(
"demo/src/stubs/automation_platform_domains.ts",
`
// Integrations that provide the new style triggers and conditions. Their
// translations are part of the demo build.
export const automationPlatformDomains = ${JSON.stringify(AUTOMATION_PLATFORM_DOMAINS)};
`
),
writeTs(
"demo/src/stubs/automation_platforms_data.ts",
`import type { ConditionDescriptions } from "../../../src/data/condition";
import type { TriggerDescriptions } from "../../../src/data/trigger";
export const triggerDescriptions: TriggerDescriptions = ${JSON.stringify(triggers)};
export const conditionDescriptions: ConditionDescriptions = ${JSON.stringify(conditions)};
`
),
writeTs(
"demo/src/stubs/automation_platforms_icons.ts",
`
export const triggerIcons = ${JSON.stringify(icons.triggers)};
export const conditionIcons = ${JSON.stringify(icons.conditions)};
`
),
]);
});
@@ -69,7 +69,7 @@ export const SENSOR_STATE_CLASSES: string[] = [
${stateClasses.map((stateClass) => ` "${stateClass}",`).join("\n")}
];
export const SENSOR_STATE_CLASS_UNITS: Record<string, string[]> = {
export const SENSOR_STATE_CLASS_UNITS: Record<string, (string | null)[]> = {
${Object.entries(stateClassUnits)
.map(
([stateClass, units]) =>
+2 -1
View File
@@ -6,9 +6,10 @@ import "./demo.js";
import "./download-translations.js";
import "./e2e-test-app.js";
import "./entry-html.js";
import "./fetch-nightly-translations.js";
import "./fetch-nightly-translations.mts";
import "./gallery.js";
import "./gather-static.js";
import "./gen-demo-core-data.js";
import "./gen-device-classes.js";
import "./gen-icons-json.js";
import "./gen-sensor-entity-constants.js";
+1 -1
View File
@@ -10,7 +10,7 @@ import { finished } from "node:stream/promises";
import env from "../env.cjs";
import paths from "../paths.cjs";
import { mergeTranslations } from "./merge-translations.js";
import "./fetch-nightly-translations.js";
import "./fetch-nightly-translations.mts";
const inFrontendDir = "translations/frontend";
const inBackendDir = "translations/backend";
+4 -1
View File
@@ -115,7 +115,10 @@ const createRspackConfig = ({
dontHash = new Set();
}
const ignorePackages = bundle.ignorePackages({ latestBuild });
const litHtmlRoot = path.resolve(__dirname, "../node_modules/lit-html");
// pnpm links node_modules/lit-html into its store; rspack compares real paths
const litHtmlRoot = fs.realpathSync(
path.resolve(__dirname, "../node_modules/lit-html")
);
const litHtmlDevelopmentRoot = path.join(litHtmlRoot, "development");
const litDisableDevModeLoader = path.join(
__dirname,
+2 -2
View File
@@ -27,6 +27,7 @@ import type {
LovelaceConfig,
LovelaceDashboardStrategyConfig,
} from "../../../../src/data/lovelace/config/types";
import { normalizeLovelaceConfig } from "../../../../src/data/lovelace/config/normalize";
import { isStrategyDashboard } from "../../../../src/data/lovelace/config/types";
import { fetchResources } from "../../../../src/data/lovelace/resource";
import { loadLovelaceResources } from "../../../../src/panels/lovelace/common/load-resources";
@@ -34,7 +35,6 @@ import { HassElement } from "../../../../src/state/hass-element";
import { castContext } from "../cast_context";
import "./hc-launch-screen";
import { getPanelTitleFromUrlPath } from "../../../../src/data/panel";
import { checkLovelaceConfig } from "../../../../src/panels/lovelace/common/check-lovelace-config";
const DEFAULT_CONFIG: LovelaceDashboardStrategyConfig = {
strategy: {
@@ -359,7 +359,7 @@ export class HcMain extends HassElement {
this._urlPath || "lovelace"
);
castContext.setApplicationState(title || "");
this._lovelaceConfig = checkLovelaceConfig(
this._lovelaceConfig = normalizeLovelaceConfig(
lovelaceConfig
) as LovelaceConfig;
}
+96 -2
View File
@@ -1,12 +1,16 @@
import { navigate } from "../../../src/common/navigate";
import { slugify } from "../../../src/common/string/slugify";
import type { LocalizeFunc } from "../../../src/common/translations/localize";
import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
import type { Lovelace } from "../../../src/panels/lovelace/types";
import { setDemoAreas } from "../stubs/area_registry";
import { setPlatformEntityRegistryEntries } from "../stubs/entity_registry";
import { energyEntities } from "../stubs/entities";
import { connectivityEntities } from "../stubs/connectivity/fixtures";
import { setDemoFloors } from "../stubs/floor_registry";
import { getDemoTheme } from "../stubs/frontend";
import type { DemoConfig, DemoTheme } from "./types";
import type { EntityInput } from "../../../src/fake_data/entities/types";
import type { DemoAutomation, DemoConfig, DemoTheme } from "./types";
export const applyDemoTheme = (hass: MockHomeAssistant, theme: DemoTheme) => {
if (typeof theme === "function") {
@@ -16,6 +20,95 @@ export const applyDemoTheme = (hass: MockHomeAssistant, theme: DemoTheme) => {
hass.mockTheme(null, getDemoTheme(theme));
};
export const automationEntity = ({
config: automation,
entityId,
state = "on",
icon,
lastTriggered,
}: DemoAutomation): EntityInput => ({
entity_id: entityId ?? `automation.${slugify(automation.alias)}`,
platform: "automation",
state,
attributes: {
id: automation.id,
friendly_name: automation.alias,
last_triggered:
lastTriggered === undefined
? null
: new Date(Date.now() - lastTriggered * 60000).toISOString(),
mode: automation.mode ?? "single",
current: 0,
// Like core, only the modes that can run more than once have a maximum
...((automation.mode === "queued" || automation.mode === "parallel") && {
max: automation.max ?? 10,
}),
icon,
},
});
// Automations saved in the editor during this session, per demo
// Automations saved in the editor during this session, per demo. A deleted
// automation is null.
const savedAutomations: Record<
string,
Record<string, DemoAutomation | null>
> = {};
export const saveDemoAutomation = (
id: string,
automation: DemoAutomation | null
) => {
savedAutomations[selectedDemo] = {
...savedAutomations[selectedDemo],
[id]: automation,
};
};
// The automations of a demo config, with the ones saved in the editor
export const demoAutomations = (
demo: string,
config: DemoConfig
): DemoAutomation[] => {
const saved = savedAutomations[demo] ?? {};
const configured = config.automations ?? [];
const ids = new Set(configured.map((automation) => automation.config.id));
return [
...configured.map((automation) =>
automation.config.id in saved ? saved[automation.config.id] : automation
),
...Object.values(saved).filter(
(automation) => automation && !ids.has(automation.config.id)
),
].filter((automation): automation is DemoAutomation => automation !== null);
};
// The automation entities are derived from the configs the editor shows, so
// their names and IDs always match.
export const demoConfigEntities = (
demo: string,
config: DemoConfig,
localize: LocalizeFunc
): EntityInput[] => [
...config.entities(localize),
...demoAutomations(demo, config).map(automationEntity),
];
// Like core, the automations of a demo config are in the entity registry
export const registerDemoAutomations = (
hass: MockHomeAssistant,
demo: string,
config: DemoConfig
) =>
setPlatformEntityRegistryEntries(
hass,
"automation",
demoAutomations(demo, config).map((automation) => ({
entity_id: automationEntity(automation).entity_id,
unique_id: automation.config.id,
}))
);
export const demoConfigs: Record<string, () => Promise<DemoConfig>> = {
sections: () => import("./sections").then((mod) => mod.demoSections),
home: () => import("./home").then((mod) => mod.demoHome),
@@ -52,7 +145,8 @@ export const setDemoConfig = async (
setDemoFloors(hass, config.floors);
setDemoAreas(hass, config.areas);
hass.addEntities(config.entities(hass.localize), true);
hass.addEntities(demoConfigEntities(demo, config, hass.localize), true);
registerDemoAutomations(hass, demo, config);
hass.addEntities(energyEntities());
// Replaced the whole state map above, so the entities that do not belong to a
// demo config have to be added back.
+27 -23
View File
@@ -7,7 +7,7 @@ import type { HomeDashboardStrategyConfig } from "../../../../src/panels/lovelac
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 { HeadingCardConfig } from "../../../../src/panels/lovelace/cards/types";
import type { HomeAssistant } from "../../../../src/types";
export interface DemoHomeDashboardStrategyConfig extends Omit<
@@ -61,29 +61,33 @@ class DemoHomeOverviewViewStrategy extends ReactiveElement {
{ ...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],
};
})
// Add the demo card to the favorites section, found by its heading
const favoritesHeading = hass.localize(
"ui.panel.lovelace.strategy.home.favorites"
);
const sections = (view.sections || []).map((section) => {
if (
isStrategySection(section) ||
!section.cards?.some(
(card) =>
card.type === "heading" &&
(card as HeadingCardConfig).heading === favoritesHeading
)
) {
return section;
}
const [heading, ...cards] = section.cards;
return {
...section,
// Place the demo card first so the tiles fill the rows next to it. It
// takes up the space of two tiles, so keep two less, but at least six.
cards: [
heading,
{ type: "custom:ha-demo-next-card" },
...cards.slice(0, Math.max(6, cards.length - 2)),
],
};
});
return {
...view,
+361
View File
@@ -0,0 +1,361 @@
import type { DemoAutomation } from "../types";
export const demoAutomationsSections: DemoAutomation[] = [
{
lastTriggered: 60 * 9,
state: "off",
icon: "mdi:auto-mode",
config: {
id: "1700669321947",
alias: "Home Assistant Auto-update",
description:
"Installs Home Assistant updates at night, so nobody is bothered by the restart.",
triggers: [{ trigger: "time", at: "03:00:00" }],
conditions: [
{
condition: "update.is_available",
target: { entity_id: "update.home_assistant_core_update" },
options: { for: "00:00:00" },
},
],
actions: [
{
action: "update.install",
target: { entity_id: "update.home_assistant_core_update" },
data: { backup: true },
},
],
mode: "queued",
},
},
{
lastTriggered: 25,
config: {
id: "1700669322001",
alias: "Kitchen lights on motion",
description:
"Turns on the kitchen lights when motion is detected and turns them off again once the kitchen has been empty for 2 minutes.",
triggers: [
{
trigger: "motion.detected",
target: { entity_id: "binary_sensor.kitchen_motion" },
options: { for: "00:00:00" },
},
],
conditions: [],
actions: [
{
action: "light.turn_on",
target: {
entity_id: ["light.kitchen_spotlights", "light.worktop_spotlights"],
},
data: { brightness_pct: 80 },
},
{
wait_for_trigger: [
{
trigger: "motion.cleared",
target: { entity_id: "binary_sensor.kitchen_motion" },
options: { for: "00:02:00" },
},
],
},
{
action: "light.turn_off",
target: {
entity_id: ["light.kitchen_spotlights", "light.worktop_spotlights"],
},
},
],
mode: "restart",
},
},
{
lastTriggered: 60 * 3,
config: {
id: "1700669322002",
alias: "Outdoor light from sunset to sunrise",
description:
"Uses trigger IDs to handle both sunset and sunrise in one automation.",
triggers: [
{
trigger: "sun.sunset",
id: "sunset",
options: {
offset: { days: 0, hours: 0, minutes: 15, seconds: 0 },
offset_type: "before",
},
},
{
trigger: "sun.sunrise",
id: "sunrise",
options: {
offset: { days: 0, hours: 0, minutes: 0, seconds: 0 },
offset_type: "before",
},
},
],
conditions: [],
actions: [
{
choose: [
{
conditions: [{ condition: "trigger", id: "sunset" }],
sequence: [
{
action: "light.turn_on",
target: { entity_id: "light.outdoor_light" },
},
],
},
{
conditions: [{ condition: "trigger", id: "sunrise" }],
sequence: [
{
action: "light.turn_off",
target: { entity_id: "light.outdoor_light" },
},
],
},
],
},
],
mode: "single",
},
},
{
lastTriggered: 60 * 14,
config: {
id: "1700669322003",
alias: "Flood light on motion when dark",
description:
"Turns on the flood light for 5 minutes when someone walks by in the dark.",
triggers: [
{
trigger: "motion.detected",
target: { entity_id: "binary_sensor.outdoor_motion_sensor_motion" },
options: { for: "00:00:00" },
},
],
conditions: [
{
condition: "illuminance.is_value",
target: { entity_id: "sensor.outdoor_motion_sensor_illuminance" },
options: {
threshold: {
type: "below",
value: { number: 50, unit_of_measurement: "lx" },
},
for: "00:00:00",
},
},
],
actions: [
{
action: "light.turn_on",
target: { entity_id: "light.flood_light" },
},
{ delay: { hours: 0, minutes: 5, seconds: 0, milliseconds: 0 } },
{
action: "light.turn_off",
target: { entity_id: "light.flood_light" },
},
],
mode: "restart",
},
},
{
lastTriggered: 60 * 26,
config: {
id: "1700669322004",
alias: "Close the shutters when it gets hot",
description:
"Keeps the living room cool by closing the shutters on hot, sunny days.",
triggers: [
{
trigger: "temperature.crossed_threshold",
target: { entity_id: "sensor.outdoor_temperature" },
options: {
threshold: {
type: "above",
value: { number: 25, unit_of_measurement: "°C" },
},
for: "00:10:00",
},
},
],
conditions: [{ condition: "sun.is_up" }],
actions: [
{
action: "cover.close_cover",
target: {
entity_id: [
"cover.living_room_garden_shutter",
"cover.living_room_graveyard_shutter",
"cover.living_room_left_shutter",
"cover.living_room_right_shutter",
],
},
},
],
mode: "single",
},
},
{
lastTriggered: 60 * 50,
config: {
id: "1700669322005",
alias: "Fridge door left open",
description:
"Sends a reminder when the fridge door has been open for 2 minutes.",
triggers: [
{
trigger: "door.opened",
target: { entity_id: "binary_sensor.fridge_door" },
options: { for: "00:02:00" },
},
],
conditions: [],
actions: [
{
action: "notify.notify",
data: {
title: "Fridge door",
message: "The fridge door has been open for 2 minutes.",
},
},
],
mode: "single",
},
},
{
lastTriggered: 90,
config: {
id: "1700669322006",
alias: "Meeting mode",
description:
"Pauses the music in the study and turns on the lights when a meeting starts.",
triggers: [
{
trigger: "switch.turned_on",
target: { entity_id: "switch.in_meeting" },
options: { for: "00:00:00" },
},
],
conditions: [],
actions: [
{
action: "media_player.media_pause",
target: { entity_id: "media_player.study_nest_hub" },
},
{
action: "light.turn_on",
target: { entity_id: "light.study_spotlights" },
data: { brightness_pct: 100, color_temp_kelvin: 4000 },
},
],
mode: "single",
},
},
{
lastTriggered: 60 * 20,
config: {
id: "1700669322009",
alias: "Lights up when the music pauses",
description:
"Turns on the living room spotlights when the music is paused in the evening.",
triggers: [
{
trigger: "media_player.paused_playing",
target: { entity_id: "media_player.living_room_nest_mini" },
options: { for: "00:00:00" },
},
],
conditions: [{ condition: "sun.is_set" }],
actions: [
{
action: "light.turn_on",
target: { entity_id: "light.living_room_spotlights" },
data: { brightness_pct: 40 },
},
],
mode: "single",
},
},
{
lastTriggered: 60 * 7,
config: {
id: "1700669322007",
alias: "Lower heating when the car leaves",
triggers: [
{
trigger: "zone.left",
target: { entity_id: "device_tracker.car" },
options: { zone: "zone.home", for: "00:00:00" },
},
],
conditions: [
{
condition: "time",
weekday: ["mon", "tue", "wed", "thu", "fri"],
},
],
actions: [
{
action: "climate.set_temperature",
target: {
entity_id: ["climate.ground_floor", "climate.first_floor"],
},
data: { temperature: 17 },
},
],
mode: "single",
},
},
{
lastTriggered: 60 * 11,
config: {
id: "1700669322008",
alias: "Good night",
description:
"Turns off all lights and closes the shutters at 23:00 on weeknights.",
triggers: [
{
trigger: "time",
at: "23:00:00",
weekday: ["sun", "mon", "tue", "wed", "thu"],
},
],
conditions: [],
actions: [
{
action: "light.turn_off",
target: {
entity_id: [
"light.floor_lamp",
"light.living_room_spotlights",
"light.bar_lamp",
"light.kitchen_spotlights",
"light.worktop_spotlights",
"light.study_spotlights",
],
},
},
{
action: "cover.close_cover",
target: {
entity_id: [
"cover.living_room_garden_shutter",
"cover.living_room_graveyard_shutter",
"cover.living_room_left_shutter",
"cover.living_room_right_shutter",
"cover.kitchen_shutter",
"cover.study_shutter",
],
},
},
],
mode: "single",
},
},
];
+12 -13
View File
@@ -140,6 +140,18 @@ export const demoEntitiesSections: DemoConfig["entities"] = (localize) =>
friendly_name: "Outdoor humidity",
},
},
// Used by the automation for when the car leaves home
"zone.home": {
entity_id: "zone.home",
state: "0",
attributes: {
latitude: 52.3731339,
longitude: 4.8903147,
radius: 100,
friendly_name: "Home",
icon: "mdi:home",
},
},
"device_tracker.car": {
entity_id: "device_tracker.car",
state: "not_home",
@@ -514,19 +526,6 @@ export const demoEntitiesSections: DemoConfig["entities"] = (localize) =>
friendly_name: "Outdoor motion sensor Illuminance",
},
},
"automation.home_assistant_auto_update": {
entity_id: "automation.home_assistant_auto_update",
state: "off",
attributes: {
id: "1700669321947",
last_triggered: "2024-02-29T18:02:05.343139+00:00",
mode: "queued",
current: 0,
max: 50,
icon: "mdi:auto-mode",
friendly_name: "Home Assistant Auto-update",
},
},
"update.home_assistant_operating_system_update": {
entity_id: "update.home_assistant_operating_system_update",
state: "off",
+2
View File
@@ -1,4 +1,5 @@
import type { DemoConfig } from "../types";
import { demoAutomationsSections } from "./automations";
import { demoEntitiesSections } from "./entities";
import { demoLovelaceSections } from "./lovelace";
@@ -8,5 +9,6 @@ export const demoSections: DemoConfig = {
name: "Home Demo",
lovelace: demoLovelaceSections,
entities: demoEntitiesSections,
automations: demoAutomationsSections,
theme: { theme: "default", dark: false },
};
+12
View File
@@ -1,5 +1,6 @@
import type { TemplateResult } from "lit";
import type { LocalizeFunc } from "../../../src/common/translations/localize";
import type { ManualAutomationConfig } from "../../../src/data/automation";
import type { LovelaceRawConfig } from "../../../src/data/lovelace/config/types";
import type { EntityInput } from "../../../src/fake_data/entities/types";
import type { ThemeSettings } from "../../../src/types";
@@ -8,6 +9,16 @@ import type { DemoFloor } from "../stubs/floor_registry";
export type DemoTheme = ThemeSettings | (() => Record<string, string> | null);
export interface DemoAutomation {
config: ManualAutomationConfig & { id: string; alias: string };
/** Defaults to the alias as slug */
entityId?: string;
state?: "on" | "off";
icon?: string;
/** Minutes ago */
lastTriggered?: number;
}
export interface DemoConfig {
index?: number;
name: string;
@@ -17,6 +28,7 @@ export interface DemoConfig {
string | ((localize: LocalizeFunc) => string | TemplateResult<1>);
lovelace: (localize: LocalizeFunc) => LovelaceRawConfig;
entities: (localize: LocalizeFunc) => EntityInput[];
automations?: DemoAutomation[];
floors?: DemoFloor[];
areas?: DemoArea[];
theme: DemoTheme;
+38 -13
View File
@@ -1,11 +1,18 @@
import { customElement } from "lit/decorators";
import type { LocalizeFunc } from "../../src/common/translations/localize";
import { isNavigationClick } from "../../src/common/dom/is-navigation-click";
import { navigate } from "../../src/common/navigate";
import type { MockHomeAssistant } from "../../src/fake_data/provide_hass";
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 {
applyDemoTheme,
demoConfigEntities,
registerDemoAutomations,
selectedDemo,
selectedDemoConfig,
} from "./configs/demo-configs";
import { mockAreaRegistry, setDemoAreas } from "./stubs/area_registry";
import {
connectivityCommands,
@@ -18,7 +25,10 @@ import { demoDevices } from "./stubs/devices";
import { mockDeviceRegistry } from "./stubs/device_registry";
import { mockEnergy } from "./stubs/energy";
import { energyEntities } from "./stubs/entities";
import { mockEntityRegistry } from "./stubs/entity_registry";
import {
mockEntityRegistry,
mockEntityRegistryDisplay,
} from "./stubs/entity_registry";
import { mockEvents } from "./stubs/events";
import { mockFloorRegistry, setDemoFloors } from "./stubs/floor_registry";
import { mockFrontend } from "./stubs/frontend";
@@ -26,6 +36,7 @@ import { mockHardware } from "./stubs/hardware";
import { mockHassioSupervisor } from "./stubs/hassio_supervisor";
import { mockIntegration } from "./stubs/integration";
import { mockLabelRegistry } from "./stubs/label_registry";
import { automationPlatformDomains } from "./stubs/automation_platform_domains";
import { mockIcons } from "./stubs/icons";
import { mockHistory } from "./stubs/history";
import { mockLovelace } from "./stubs/lovelace";
@@ -58,6 +69,12 @@ const CONFIG_PANEL_COMMANDS = [
"system_health/",
"backup/",
"automation/config",
"trigger_platforms/subscribe",
"condition_platforms/subscribe",
"get_triggers_for_target",
"get_conditions_for_target",
"get_services_for_target",
"extract_from_target",
"script/config",
"config/automation/config",
"config/script/config",
@@ -89,15 +106,19 @@ export class HaDemo extends HomeAssistantAppEl {
config: {
...hass.config,
components: [
...(hass.config?.components ?? []),
"backup",
"webhook",
"usage_prediction",
"assist_pipeline",
"hassio",
"hardware",
"marketplace",
...connectivityComponents,
// Some automation platforms are loaded already
...new Set([
...(hass.config?.components ?? []),
"backup",
"webhook",
"usage_prediction",
"assist_pipeline",
"hassio",
"hardware",
"marketplace",
...connectivityComponents,
...automationPlatformDomains,
]),
],
},
panels: {
@@ -116,7 +137,9 @@ export class HaDemo extends HomeAssistantAppEl {
const localizePromise =
// @ts-ignore
this._loadFragmentTranslations(hass.language, "page-demo").then(
() => this.hass!.localize
// hass is not updated yet when another fragment started loading
// meanwhile, so use the localize with these translations directly
(localize?: LocalizeFunc) => localize ?? this.hass!.localize
);
mockLovelace(hass, localizePromise);
@@ -151,6 +174,7 @@ export class HaDemo extends HomeAssistantAppEl {
mockFloorRegistry(hass);
mockLabelRegistry(hass);
mockUsagePrediction(hass);
mockEntityRegistryDisplay(hass);
mockEntityRegistry(hass, [
{
config_entry_id: "co2signal",
@@ -206,7 +230,8 @@ export class HaDemo extends HomeAssistantAppEl {
([conf, localize]) => {
setDemoFloors(hass, conf.floors);
setDemoAreas(hass, conf.areas);
hass.addEntities(conf.entities(localize));
hass.addEntities(demoConfigEntities(selectedDemo, conf, localize));
registerDemoAutomations(hass, selectedDemo, conf);
applyDemoTheme(hass, conf.theme);
}
);
+3
View File
@@ -39,6 +39,9 @@
html {
background-color: var(--primary-background-color, #fafafa);
color: var(--primary-text-color, #212121);
/* Prevent iOS Safari from inflating text when rotating to landscape */
-webkit-text-size-adjust: 100%;
text-size-adjust: 100%;
}
@media (prefers-color-scheme: dark) {
html {
+19
View File
@@ -1,3 +1,4 @@
import type { OwnProfileMutableParams } from "../../../src/data/person";
import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
export const mockAuth = (hass: MockHomeAssistant) => {
@@ -6,4 +7,22 @@ export const mockAuth = (hass: MockHomeAssistant) => {
hass.mockWS("auth/sign_path", (msg: { path: string }) => ({
path: msg.path,
}));
let name: string | undefined;
// The profile dialog refreshes the user subscription after saving, which
// writes the updated user back to hass.user. Read the current user on each
// call so later changes to it, such as a non-admin user, are kept.
hass.mockWS("auth/current_user", (_msg, currentHass: MockHomeAssistant) =>
name && currentHass.user ? { ...currentHass.user, name } : currentHass.user
);
hass.mockWS(
"person/update_own_profile",
(msg: Partial<OwnProfileMutableParams>, currentHass: MockHomeAssistant) => {
if (msg.name) {
name = msg.name;
}
return { user_name: name ?? currentHass.user?.name, person: null };
}
);
};
+142 -56
View File
@@ -1,69 +1,155 @@
import type { AutomationConfig } from "../../../src/data/automation";
import type { ScriptConfig } from "../../../src/data/script";
import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
import { computeDomain } from "../../../src/common/entity/compute_domain";
import { slugify } from "../../../src/common/string/slugify";
import {
automationEntity,
demoAutomations,
saveDemoAutomation,
selectedDemo,
selectedDemoConfig,
} from "../configs/demo-configs";
import type { DemoAutomation } from "../configs/types";
import { mockAutomationPlatforms } from "./automation_platforms";
import {
addEntityRegistryEntry,
getEntityRegistryEntry,
removeEntityRegistryEntry,
} from "./entity_registry";
const demoAutomationConfig = (entityId: string): AutomationConfig => ({
id: entityId.split(".")[1],
alias: "Demo automation",
description: "An example automation shown in the demo.",
triggers: [
{ trigger: "state", entity_id: "binary_sensor.basement_floor_wet" },
],
conditions: [],
actions: [
{
action: "light.turn_on",
target: { entity_id: "light.bed_light" },
},
],
mode: "single",
});
const automationConfig = async (
id: string | undefined
): Promise<AutomationConfig> => {
const automation = demoAutomations(
selectedDemo,
await selectedDemoConfig
).find((a) => a.config.id === id);
if (!automation) {
throw new Error("Automation not found");
}
return automation.config;
};
const demoScriptConfig = (): ScriptConfig => ({
alias: "Demo script",
description: "An example script shown in the demo.",
sequence: [
{
action: "light.turn_on",
target: { entity_id: "light.bed_light" },
},
],
mode: "single",
});
const scriptConfig = (
currentHass: MockHomeAssistant,
entityId: string
): ScriptConfig => {
const alias =
currentHass.states[entityId]?.attributes.friendly_name ?? entityId;
return {
alias,
sequence: [
{
action: "persistent_notification.create",
data: { message: `${alias} ran` },
},
],
mode: "single",
};
};
export const mockAutomation = (hass: MockHomeAssistant) => {
hass.mockWS("automation/config", (msg: { entity_id: string }) => ({
config: demoAutomationConfig(msg.entity_id),
}));
hass.mockWS("script/config", () => ({ config: demoScriptConfig() }));
hass.mockAPI(/config\/automation\/config\/.+/, () =>
demoAutomationConfig("automation.demo")
);
hass.mockAPI(/config\/script\/config\/.+/, () => demoScriptConfig());
// Trigger/condition type pickers subscribe for integration-provided
// platforms. The demo only uses the built-in ones, so emit empty records.
hass.mockWS(
"trigger_platforms/subscribe",
(
_msg,
_hass,
onChange?: (descriptions: Record<string, unknown>) => void
) => {
onChange?.({});
return () => undefined;
"automation/config",
async (msg: { entity_id: string }, currentHass: MockHomeAssistant) => ({
config: await automationConfig(
currentHass.states[msg.entity_id]?.attributes.id
),
})
);
hass.mockAPI(
/config\/automation\/config\/.+/,
(currentHass, method, path, parameters) => {
const id = decodeURIComponent(path.split("/").pop()!);
const existing = Object.values(currentHass.states).find(
(stateObj) =>
computeDomain(stateObj.entity_id) === "automation" &&
stateObj.attributes.id === id
);
if (method === "DELETE") {
// Like core, remove the automation with its entity
saveDemoAutomation(id, null);
if (existing) {
const { [existing.entity_id]: _state, ...states } =
currentHass.states;
const { [existing.entity_id]: _entity, ...entities } =
currentHass.entities;
currentHass.updateHass({ states, entities });
// Otherwise a service call to it would add its state again
delete currentHass.mockEntities[existing.entity_id];
removeEntityRegistryEntry(currentHass, existing.entity_id);
}
return { result: "ok" };
}
if (method === "POST") {
// Core stores the ID in the config
const config = {
...(parameters as DemoAutomation["config"]),
id,
};
config.alias ||= id;
// Like core, update the automation entity, or add it for a new one
// A new automation gets a free entity ID, like core
const slug = `automation.${slugify(config.alias)}`;
let entityId = existing?.entity_id ?? slug;
for (let i = 2; !existing && entityId in currentHass.states; i++) {
entityId = `${slug}_${i}`;
}
const lastTriggered = existing?.attributes.last_triggered;
const automation: DemoAutomation = {
config,
entityId,
state: existing?.state as DemoAutomation["state"],
icon: existing?.attributes.icon,
lastTriggered: lastTriggered
? (Date.now() - Date.parse(lastTriggered)) / 60000
: undefined,
};
// Kept per demo, so it is still there after switching demos
saveDemoAutomation(id, automation);
// A display entry of a new automation is left over from another demo
const display = existing ? currentHass.entities[entityId] : undefined;
currentHass.addEntities(automationEntity(automation));
// Adding the entity resets its display entry, keep what the registry
// holds
if (display) {
const registryName = getEntityRegistryEntry(entityId)?.name;
currentHass.entities[entityId] = {
...currentHass.entities[entityId],
...(registryName && { name: registryName }),
area_id: display.area_id,
labels: display.labels,
icon: display.icon,
hidden: display.hidden,
};
}
if (!existing) {
addEntityRegistryEntry(currentHass, {
entity_id: entityId,
platform: "automation",
unique_id: id,
});
}
return { result: "ok" };
}
return automationConfig(id);
}
);
// Scripts do not have demo configs yet, show the script under its own name.
hass.mockWS(
"condition_platforms/subscribe",
(
_msg,
_hass,
onChange?: (descriptions: Record<string, unknown>) => void
) => {
onChange?.({});
return () => undefined;
}
"script/config",
(msg: { entity_id: string }, currentHass: MockHomeAssistant) => ({
config: scriptConfig(currentHass, msg.entity_id),
})
);
hass.mockAPI(/config\/script\/config\/.+/, (currentHass, _method, path) =>
scriptConfig(
currentHass,
`script.${decodeURIComponent(path.split("/").pop()!)}`
)
);
mockAutomationPlatforms(hass);
};
@@ -0,0 +1,52 @@
// This file is auto-generated from Home Assistant Core. Do not edit by hand.
// Regenerate with `script/gen_demo_core_data`.
// Integrations that provide the new style triggers and conditions. Their
// translations are part of the demo build.
export const automationPlatformDomains = [
"air_quality",
"alarm_control_panel",
"assist_satellite",
"battery",
"button",
"calendar",
"climate",
"counter",
"cover",
"door",
"doorbell",
"event",
"fan",
"garage_door",
"gate",
"humidifier",
"humidity",
"illuminance",
"lawn_mower",
"light",
"lock",
"media_player",
"moisture",
"moon",
"motion",
"occupancy",
"power",
"remote",
"scene",
"schedule",
"select",
"siren",
"sun",
"switch",
"temperature",
"text",
"timer",
"todo",
"update",
"vacuum",
"valve",
"vibration",
"water_heater",
"window",
"zone",
];
+270
View File
@@ -0,0 +1,270 @@
import type { HassServiceTarget } from "home-assistant-js-websocket";
import { ensureArray } from "../../../src/common/array/ensure-array";
import { computeDomain } from "../../../src/common/entity/compute_domain";
import type { ConditionDescriptions } from "../../../src/data/condition";
import type { ExtractFromTargetResult } from "../../../src/data/target";
import type { TriggerDescriptions } from "../../../src/data/trigger";
import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
import { getLabelIds } from "./label_registry";
import {
conditionDescriptions,
triggerDescriptions,
} from "./automation_platforms_data";
type Descriptions = TriggerDescriptions | ConditionDescriptions;
interface EntityFilter {
integration?: string;
domain?: string | string[];
device_class?: string | string[];
supported_features?: number[];
}
// Expands a target to its areas, devices and entities, like core does
const expandTarget = (
hass: MockHomeAssistant,
target: HassServiceTarget,
primaryEntitiesOnly = true
) => {
const missing = {
floors: [] as string[],
areas: [] as string[],
devices: [] as string[],
labels: [] as string[],
};
const knownLabelIds = getLabelIds();
const floorIds = ensureArray(target.floor_id ?? []);
const labelIds = ensureArray(target.label_id ?? []);
missing.floors.push(...floorIds.filter((id) => !(id in hass.floors)));
// Like core, unknown labels are reported, but still expanded
missing.labels.push(...labelIds.filter((id) => !knownLabelIds.includes(id)));
const hasLabel = (labels?: string[]) =>
labelIds.some((labelId) => labels?.includes(labelId));
// Unknown areas and devices are reported, but still referenced
const areaIds = new Set(ensureArray(target.area_id ?? []));
missing.areas.push(...[...areaIds].filter((id) => !(id in hass.areas)));
for (const area of Object.values(hass.areas)) {
if (
(area.floor_id && floorIds.includes(area.floor_id)) ||
hasLabel(area.labels)
) {
areaIds.add(area.area_id);
}
}
// A targeted or labeled device includes its child devices
const deviceIds = new Set<string>();
const addDevice = (deviceId: string) => {
deviceIds.add(deviceId);
for (const device of Object.values(hass.devices)) {
if (device.parent_device_id === deviceId) {
deviceIds.add(device.id);
}
}
};
for (const deviceId of ensureArray(target.device_id ?? [])) {
if (!(deviceId in hass.devices)) {
missing.devices.push(deviceId);
}
addDevice(deviceId);
}
for (const device of Object.values(hass.devices)) {
if (hasLabel(device.labels)) {
addDevice(device.id);
}
}
// A child device without its own area is in the area of its parent device
const deviceArea = (deviceId: string) => {
const device = hass.devices[deviceId];
return (
device?.area_id ??
(device?.parent_device_id
? hass.devices[device.parent_device_id]?.area_id
: undefined)
);
};
const areaDeviceIds = new Set<string>();
for (const device of Object.values(hass.devices)) {
const areaId = deviceArea(device.id);
if (areaId && areaIds.has(areaId)) {
areaDeviceIds.add(device.id);
}
}
const entityIds = new Set(ensureArray(target.entity_id ?? []));
for (const entity of Object.values(hass.entities)) {
// The display registry keeps the entities of a previous demo, and like
// core, hidden entities are never included indirectly
if (!(entity.entity_id in hass.states) || entity.hidden) {
continue;
}
const primary = !primaryEntitiesOnly || !entity.entity_category;
if (
hasLabel(entity.labels) ||
(primary &&
((entity.device_id && deviceIds.has(entity.device_id)) ||
(entity.area_id && areaIds.has(entity.area_id)) ||
// An entity with its own area is not in the area of its device
(!entity.area_id &&
entity.device_id &&
areaDeviceIds.has(entity.device_id))))
) {
entityIds.add(entity.entity_id);
}
}
return {
areas: [...areaIds],
devices: [...new Set([...deviceIds, ...areaDeviceIds])],
entities: [...entityIds],
missing,
};
};
const matchesFilter = (
hass: MockHomeAssistant,
entityId: string,
filter: EntityFilter
) =>
(!filter.integration ||
hass.entities[entityId]?.platform === filter.integration) &&
(!filter.domain ||
ensureArray(filter.domain).includes(computeDomain(entityId))) &&
(!filter.device_class ||
ensureArray(filter.device_class).includes(
hass.states[entityId]?.attributes.device_class ?? ""
)) &&
// Like core, the entity needs all the features of one of the feature sets
(!filter.supported_features ||
filter.supported_features.some(
(features) =>
// eslint-disable-next-line no-bitwise
((hass.states[entityId]?.attributes.supported_features ?? 0) &
features) ===
features
));
type TargetDescriptions = Record<
string,
{
target?: {
entity?: EntityFilter | EntityFilter[];
primary_entities_only?: boolean;
} | null;
}
>;
// Like core, the components with a target that apply to at least one entity
// of the target. A target without entity filters applies to any entity.
const componentsForTarget = (
hass: MockHomeAssistant,
target: HassServiceTarget,
descriptions: TargetDescriptions
) => {
const entityIds = expandTarget(hass, target, false).entities;
const targetedIds = ensureArray(target.entity_id ?? []);
return Object.entries(descriptions)
.filter(([, description]) => {
if (!description.target) {
return false;
}
const filters = ensureArray(description.target.entity ?? []);
const primaryEntitiesOnly =
description.target.primary_entities_only ?? true;
return entityIds.some(
(entityId) =>
// Entities with a category only count when targeted directly,
// unless the component allows them
(!primaryEntitiesOnly ||
targetedIds.includes(entityId) ||
!hass.entities[entityId]?.entity_category) &&
(!filters.length ||
filters.some((filter) => matchesFilter(hass, entityId, filter)))
);
})
.map(([key]) => key);
};
const mockForTarget = (
hass: MockHomeAssistant,
type: string,
descriptions: Descriptions
) =>
hass.mockWS(
type,
(msg: { target: HassServiceTarget }, currentHass: MockHomeAssistant) =>
componentsForTarget(
currentHass,
msg.target,
descriptions as TargetDescriptions
)
);
const mockPlatformSubscription = (
hass: MockHomeAssistant,
type: string,
descriptions: Descriptions
) =>
hass.mockWS(
type,
(_msg, _hass, onChange?: (descriptions: Descriptions) => void) => {
onChange?.(descriptions);
return () => undefined;
}
);
export const mockAutomationPlatforms = (hass: MockHomeAssistant) => {
mockPlatformSubscription(
hass,
"trigger_platforms/subscribe",
triggerDescriptions
);
mockPlatformSubscription(
hass,
"condition_platforms/subscribe",
conditionDescriptions
);
mockForTarget(hass, "get_triggers_for_target", triggerDescriptions);
mockForTarget(hass, "get_conditions_for_target", conditionDescriptions);
hass.mockWS(
"extract_from_target",
(
msg: { target: HassServiceTarget; primary_entities_only?: boolean },
currentHass: MockHomeAssistant
): ExtractFromTargetResult => {
const { areas, devices, entities, missing } = expandTarget(
currentHass,
msg.target,
msg.primary_entities_only ?? true
);
return {
missing_areas: missing.areas,
missing_devices: missing.devices,
missing_floors: missing.floors,
missing_labels: missing.labels,
referenced_areas: areas,
referenced_devices: devices,
referenced_entities: entities,
};
}
);
hass.mockWS(
"get_services_for_target",
(msg: { target: HassServiceTarget }, currentHass: MockHomeAssistant) =>
componentsForTarget(
currentHass,
msg.target,
// Keyed by <domain>.<service>, like the triggers and conditions
Object.fromEntries(
Object.entries(currentHass.services).flatMap(([domain, services]) =>
Object.entries(services).map(([service, description]) => [
`${domain}.${service}`,
description,
])
)
) as TargetDescriptions
)
);
};
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,495 @@
// This file is auto-generated from Home Assistant Core. Do not edit by hand.
// Regenerate with `script/gen_demo_core_data`.
export const triggerIcons = {
air_quality: {
co2_changed: { trigger: "mdi:molecule-co2" },
co2_crossed_threshold: { trigger: "mdi:molecule-co2" },
co_changed: { trigger: "mdi:molecule-co" },
co_cleared: { trigger: "mdi:check-circle" },
co_crossed_threshold: { trigger: "mdi:molecule-co" },
co_detected: { trigger: "mdi:molecule-co" },
gas_cleared: { trigger: "mdi:check-circle" },
gas_detected: { trigger: "mdi:gas-cylinder" },
n2o_changed: { trigger: "mdi:factory" },
n2o_crossed_threshold: { trigger: "mdi:factory" },
no2_changed: { trigger: "mdi:factory" },
no2_crossed_threshold: { trigger: "mdi:factory" },
no_changed: { trigger: "mdi:factory" },
no_crossed_threshold: { trigger: "mdi:factory" },
ozone_changed: { trigger: "mdi:weather-sunny-alert" },
ozone_crossed_threshold: { trigger: "mdi:weather-sunny-alert" },
pm10_changed: { trigger: "mdi:blur" },
pm10_crossed_threshold: { trigger: "mdi:blur" },
pm1_changed: { trigger: "mdi:blur" },
pm1_crossed_threshold: { trigger: "mdi:blur" },
pm25_changed: { trigger: "mdi:blur" },
pm25_crossed_threshold: { trigger: "mdi:blur" },
pm4_changed: { trigger: "mdi:blur" },
pm4_crossed_threshold: { trigger: "mdi:blur" },
smoke_cleared: { trigger: "mdi:check-circle" },
smoke_detected: { trigger: "mdi:smoke-detector-variant" },
so2_changed: { trigger: "mdi:factory" },
so2_crossed_threshold: { trigger: "mdi:factory" },
voc_changed: { trigger: "mdi:air-filter" },
voc_crossed_threshold: { trigger: "mdi:air-filter" },
voc_ratio_changed: { trigger: "mdi:air-filter" },
voc_ratio_crossed_threshold: { trigger: "mdi:air-filter" },
},
alarm_control_panel: {
armed: { trigger: "mdi:shield" },
armed_away: { trigger: "mdi:shield-lock" },
armed_home: { trigger: "mdi:shield-home" },
armed_night: { trigger: "mdi:shield-moon" },
armed_vacation: { trigger: "mdi:shield-airplane" },
disarmed: { trigger: "mdi:shield-off" },
triggered: { trigger: "mdi:bell-ring" },
},
assist_satellite: {
idle: { trigger: "mdi:chat-sleep" },
listening: { trigger: "mdi:chat-question" },
processing: { trigger: "mdi:chat-processing" },
responding: { trigger: "mdi:chat-alert" },
},
battery: {
became_low: { trigger: "mdi:battery-alert" },
level_changed: { trigger: "mdi:battery-unknown" },
level_crossed_threshold: { trigger: "mdi:battery-alert" },
no_longer_low: { trigger: "mdi:battery" },
started_charging: { trigger: "mdi:battery-charging" },
stopped_charging: { trigger: "mdi:battery" },
},
button: { pressed: { trigger: "mdi:gesture-tap-button" } },
calendar: {
event_ended: { trigger: "mdi:calendar-end" },
event_started: { trigger: "mdi:calendar-start" },
},
climate: {
hvac_mode_changed: { trigger: "mdi:thermostat" },
started_cooling: { trigger: "mdi:snowflake" },
started_drying: { trigger: "mdi:water-percent" },
started_heating: { trigger: "mdi:fire" },
target_humidity_changed: { trigger: "mdi:water-percent" },
target_humidity_crossed_threshold: { trigger: "mdi:water-percent" },
target_temperature_changed: { trigger: "mdi:thermometer" },
target_temperature_crossed_threshold: { trigger: "mdi:thermometer" },
turned_off: { trigger: "mdi:power-off" },
turned_on: { trigger: "mdi:power-on" },
},
counter: {
decremented: { trigger: "mdi:numeric-negative-1" },
incremented: { trigger: "mdi:numeric-positive-1" },
maximum_reached: { trigger: "mdi:sort-numeric-ascending-variant" },
minimum_reached: { trigger: "mdi:sort-numeric-descending-variant" },
reset: { trigger: "mdi:refresh" },
},
cover: {
awning_closed: { trigger: "mdi:storefront-outline" },
awning_opened: { trigger: "mdi:storefront-outline" },
blind_closed: { trigger: "mdi:blinds-horizontal-closed" },
blind_opened: { trigger: "mdi:blinds-horizontal" },
curtain_closed: { trigger: "mdi:curtains-closed" },
curtain_opened: { trigger: "mdi:curtains" },
shade_closed: { trigger: "mdi:roller-shade-closed" },
shade_opened: { trigger: "mdi:roller-shade" },
shutter_closed: { trigger: "mdi:window-shutter" },
shutter_opened: { trigger: "mdi:window-shutter-open" },
},
door: {
closed: { trigger: "mdi:door-closed" },
opened: { trigger: "mdi:door-open" },
},
doorbell: { rang: { trigger: "mdi:doorbell" } },
event: { received: { trigger: "mdi:eye-check" } },
fan: {
turned_off: { trigger: "mdi:fan-off" },
turned_on: { trigger: "mdi:fan" },
},
garage_door: {
closed: { trigger: "mdi:garage" },
opened: { trigger: "mdi:garage-open" },
},
gate: {
closed: { trigger: "mdi:gate" },
opened: { trigger: "mdi:gate-open" },
},
humidifier: {
mode_changed: { trigger: "mdi:air-humidifier" },
started_drying: { trigger: "mdi:arrow-down-bold" },
started_humidifying: { trigger: "mdi:arrow-up-bold" },
turned_off: { trigger: "mdi:air-humidifier-off" },
turned_on: { trigger: "mdi:air-humidifier" },
},
humidity: {
changed: { trigger: "mdi:water-percent" },
crossed_threshold: { trigger: "mdi:water-percent" },
},
illuminance: {
changed: { trigger: "mdi:brightness-6" },
cleared: { trigger: "mdi:brightness-4" },
crossed_threshold: { trigger: "mdi:brightness-6" },
detected: { trigger: "mdi:brightness-7" },
},
lawn_mower: {
became_idle: { trigger: "mdi:stop" },
errored: { trigger: "mdi:alert-circle-outline" },
paused_mowing: { trigger: "mdi:pause" },
returned_to_dock: { trigger: "mdi:home-import-outline" },
started_mowing: { trigger: "mdi:play" },
started_returning: { trigger: "mdi:home-import-outline" },
},
light: {
brightness_changed: { trigger: "mdi:lightbulb-on-50" },
brightness_crossed_threshold: { trigger: "mdi:lightbulb-on-50" },
turned_off: { trigger: "mdi:lightbulb-off" },
turned_on: { trigger: "mdi:lightbulb-on" },
},
lock: {
jammed: { trigger: "mdi:lock-alert" },
locked: { trigger: "mdi:lock" },
opened: { trigger: "mdi:lock-open-variant" },
unlocked: { trigger: "mdi:lock-open-variant" },
},
media_player: {
muted: { trigger: "mdi:volume-mute" },
paused_playing: { trigger: "mdi:pause" },
started_playing: { trigger: "mdi:play" },
stopped_playing: { trigger: "mdi:stop" },
turned_off: { trigger: "mdi:power" },
turned_on: { trigger: "mdi:power" },
unmuted: { trigger: "mdi:volume-high" },
volume_changed: { trigger: "mdi:volume-medium" },
volume_crossed_threshold: { trigger: "mdi:volume-medium" },
},
moisture: {
changed: { trigger: "mdi:water-percent" },
cleared: { trigger: "mdi:water-off" },
crossed_threshold: { trigger: "mdi:water-percent" },
detected: { trigger: "mdi:water" },
},
moon: { phase_changed: { trigger: "mdi:moon-waning-crescent" } },
motion: {
cleared: { trigger: "mdi:motion-sensor-off" },
detected: { trigger: "mdi:motion-sensor" },
},
occupancy: {
cleared: { trigger: "mdi:home-outline" },
detected: { trigger: "mdi:home-account" },
},
power: {
changed: { trigger: "mdi:flash" },
crossed_threshold: { trigger: "mdi:flash" },
},
remote: {
turned_off: { trigger: "mdi:remote-off" },
turned_on: { trigger: "mdi:remote" },
},
scene: { activated: { trigger: "mdi:palette" } },
schedule: {
block_ended: { trigger: "mdi:calendar-blank" },
block_started: { trigger: "mdi:calendar-clock" },
},
select: { selection_changed: { trigger: "mdi:format-list-bulleted" } },
siren: {
turned_off: { trigger: "mdi:bullhorn-outline" },
turned_on: { trigger: "mdi:bullhorn" },
},
sun: {
blue_hour_ended: { trigger: "mdi:weather-sunset" },
blue_hour_started: { trigger: "mdi:weather-sunset" },
dawn: { trigger: "mdi:weather-sunset-up" },
dusk: { trigger: "mdi:weather-sunset-down" },
elevation_changed: { trigger: "mdi:sun-angle" },
elevation_crossed_threshold: { trigger: "mdi:sun-angle" },
golden_hour_ended: { trigger: "mdi:weather-sunset" },
golden_hour_started: { trigger: "mdi:weather-sunset" },
midnight_sun_ended: { trigger: "mdi:weather-sunset" },
midnight_sun_started: { trigger: "mdi:weather-sunny" },
polar_night_ended: { trigger: "mdi:weather-sunset-up" },
polar_night_started: { trigger: "mdi:weather-night" },
solar_midnight: { trigger: "mdi:weather-night" },
solar_noon: { trigger: "mdi:weather-sunny" },
sunrise: { trigger: "mdi:weather-sunset-up" },
sunset: { trigger: "mdi:weather-sunset-down" },
},
switch: {
turned_off: { trigger: "mdi:toggle-switch-variant-off" },
turned_on: { trigger: "mdi:toggle-switch-variant" },
},
temperature: {
changed: { trigger: "mdi:thermometer" },
crossed_threshold: { trigger: "mdi:thermometer" },
},
text: { changed: { trigger: "mdi:form-textbox" } },
timer: {
cancelled: { trigger: "mdi:timer-cancel" },
finished: { trigger: "mdi:timer-check" },
paused: { trigger: "mdi:timer-pause" },
remaining_time_reached: { trigger: "mdi:timer-alert-outline" },
restarted: { trigger: "mdi:timer-refresh" },
started: { trigger: "mdi:timer-play" },
},
todo: {
item_added: { trigger: "mdi:clipboard-plus" },
item_completed: { trigger: "mdi:clipboard-check" },
item_removed: { trigger: "mdi:clipboard-minus" },
},
update: { became_available: { trigger: "mdi:package-up" } },
vacuum: {
errored: { trigger: "mdi:alert-circle-outline" },
paused_cleaning: { trigger: "mdi:pause" },
returned_to_dock: { trigger: "mdi:home-outline" },
started_cleaning: { trigger: "mdi:play" },
started_returning: { trigger: "mdi:home-import-outline" },
},
valve: {
closed: { trigger: "mdi:valve-closed" },
opened: { trigger: "mdi:valve-open" },
},
vibration: {
cleared: { trigger: "mdi:vibrate-off" },
detected: { trigger: "mdi:vibrate" },
},
water_heater: {
operation_mode_changed: { trigger: "mdi:water-boiler" },
target_temperature_changed: { trigger: "mdi:thermometer" },
target_temperature_crossed_threshold: { trigger: "mdi:thermometer" },
turned_off: { trigger: "mdi:water-boiler-off" },
turned_on: { trigger: "mdi:water-boiler" },
},
window: {
closed: { trigger: "mdi:window-closed" },
opened: { trigger: "mdi:window-open" },
},
zone: {
entered: { trigger: "mdi:map-marker-plus" },
left: { trigger: "mdi:map-marker-minus" },
occupancy_cleared: { trigger: "mdi:account-off" },
occupancy_detected: { trigger: "mdi:account-group" },
},
};
export const conditionIcons = {
air_quality: {
is_co2_value: { condition: "mdi:molecule-co2" },
is_co_cleared: { condition: "mdi:check-circle" },
is_co_detected: { condition: "mdi:molecule-co" },
is_co_value: { condition: "mdi:molecule-co" },
is_gas_cleared: { condition: "mdi:check-circle" },
is_gas_detected: { condition: "mdi:gas-cylinder" },
is_n2o_value: { condition: "mdi:factory" },
is_no2_value: { condition: "mdi:factory" },
is_no_value: { condition: "mdi:factory" },
is_ozone_value: { condition: "mdi:weather-sunny-alert" },
is_pm10_value: { condition: "mdi:blur" },
is_pm1_value: { condition: "mdi:blur" },
is_pm25_value: { condition: "mdi:blur" },
is_pm4_value: { condition: "mdi:blur" },
is_smoke_cleared: { condition: "mdi:check-circle" },
is_smoke_detected: { condition: "mdi:smoke-detector-variant" },
is_so2_value: { condition: "mdi:factory" },
is_voc_ratio_value: { condition: "mdi:air-filter" },
is_voc_value: { condition: "mdi:air-filter" },
},
alarm_control_panel: {
is_armed: { condition: "mdi:shield" },
is_armed_away: { condition: "mdi:shield-lock" },
is_armed_home: { condition: "mdi:shield-home" },
is_armed_night: { condition: "mdi:shield-moon" },
is_armed_vacation: { condition: "mdi:shield-airplane" },
is_disarmed: { condition: "mdi:shield-off" },
is_triggered: { condition: "mdi:bell-ring" },
},
assist_satellite: {
is_idle: { condition: "mdi:chat-sleep" },
is_listening: { condition: "mdi:chat-question" },
is_processing: { condition: "mdi:chat-processing" },
is_responding: { condition: "mdi:chat-alert" },
},
battery: {
is_charging: { condition: "mdi:battery-charging" },
is_level: { condition: "mdi:battery-unknown" },
is_low: { condition: "mdi:battery-alert" },
is_not_charging: { condition: "mdi:battery" },
is_not_low: { condition: "mdi:battery" },
},
calendar: { is_event_active: { condition: "mdi:calendar-check" } },
climate: {
is_cooling: { condition: "mdi:snowflake" },
is_drying: { condition: "mdi:water-percent" },
is_heating: { condition: "mdi:fire" },
is_hvac_mode: { condition: "mdi:thermostat" },
is_off: { condition: "mdi:power-off" },
is_on: { condition: "mdi:power-on" },
is_target_humidity: { condition: "mdi:water-percent" },
is_target_temperature: { condition: "mdi:thermometer" },
},
counter: { is_value: { condition: "mdi:counter" } },
cover: {
awning_is_closed: { condition: "mdi:storefront-outline" },
awning_is_open: { condition: "mdi:storefront-outline" },
blind_is_closed: { condition: "mdi:blinds-horizontal-closed" },
blind_is_open: { condition: "mdi:blinds-horizontal" },
curtain_is_closed: { condition: "mdi:curtains-closed" },
curtain_is_open: { condition: "mdi:curtains" },
shade_is_closed: { condition: "mdi:roller-shade-closed" },
shade_is_open: { condition: "mdi:roller-shade" },
shutter_is_closed: { condition: "mdi:window-shutter" },
shutter_is_open: { condition: "mdi:window-shutter-open" },
},
door: {
is_closed: { condition: "mdi:door-closed" },
is_open: { condition: "mdi:door-open" },
},
fan: {
is_off: { condition: "mdi:fan-off" },
is_on: { condition: "mdi:fan" },
},
garage_door: {
is_closed: { condition: "mdi:garage" },
is_open: { condition: "mdi:garage-open" },
},
gate: {
is_closed: { condition: "mdi:gate" },
is_open: { condition: "mdi:gate-open" },
},
humidifier: {
is_drying: { condition: "mdi:arrow-down-bold" },
is_humidifying: { condition: "mdi:arrow-up-bold" },
is_mode: { condition: "mdi:air-humidifier" },
is_off: { condition: "mdi:air-humidifier-off" },
is_on: { condition: "mdi:air-humidifier" },
is_target_humidity: { condition: "mdi:water-percent" },
},
humidity: { is_value: { condition: "mdi:water-percent" } },
illuminance: {
is_detected: { condition: "mdi:brightness-7" },
is_not_detected: { condition: "mdi:brightness-4" },
is_value: { condition: "mdi:brightness-percent" },
},
lawn_mower: {
is_docked: { condition: "mdi:home-import-outline" },
is_encountering_an_error: { condition: "mdi:alert-circle-outline" },
is_idle: { condition: "mdi:stop" },
is_mowing: { condition: "mdi:play" },
is_paused: { condition: "mdi:pause" },
is_returning: { condition: "mdi:home-import-outline" },
},
light: {
is_brightness: { condition: "mdi:lightbulb-on-50" },
is_off: { condition: "mdi:lightbulb-off" },
is_on: { condition: "mdi:lightbulb-on" },
},
lock: {
is_jammed: { condition: "mdi:lock-alert" },
is_locked: { condition: "mdi:lock" },
is_open: { condition: "mdi:lock-open-variant" },
is_unlocked: { condition: "mdi:lock-open-variant" },
},
media_player: {
is_muted: { condition: "mdi:volume-mute" },
is_not_playing: { condition: "mdi:stop" },
is_off: { condition: "mdi:cast-off" },
is_on: { condition: "mdi:cast" },
is_paused: { condition: "mdi:pause" },
is_playing: { condition: "mdi:play" },
is_unmuted: { condition: "mdi:volume-high" },
is_volume: { condition: "mdi:volume-medium" },
},
moisture: {
is_detected: { condition: "mdi:water" },
is_not_detected: { condition: "mdi:water-off" },
is_value: { condition: "mdi:water-percent" },
},
moon: {
is_phase: { condition: "mdi:moon-full" },
is_waning: { condition: "mdi:moon-waning-crescent" },
is_waxing: { condition: "mdi:moon-waxing-crescent" },
},
motion: {
is_detected: { condition: "mdi:motion-sensor" },
is_not_detected: { condition: "mdi:motion-sensor-off" },
},
occupancy: {
is_detected: { condition: "mdi:home-account" },
is_not_detected: { condition: "mdi:home-outline" },
},
power: { is_value: { condition: "mdi:flash" } },
remote: {
is_off: { condition: "mdi:remote-off" },
is_on: { condition: "mdi:remote" },
},
schedule: {
is_off: { condition: "mdi:calendar-blank" },
is_on: { condition: "mdi:calendar-clock" },
},
select: { is_option_selected: { condition: "mdi:format-list-bulleted" } },
siren: {
is_off: { condition: "mdi:bullhorn-outline" },
is_on: { condition: "mdi:bullhorn" },
},
sun: {
elevation: { condition: "mdi:sun-angle" },
is_ascending: { condition: "mdi:weather-sunset-up" },
is_blue_hour: { condition: "mdi:weather-sunset" },
is_descending: { condition: "mdi:weather-sunset-down" },
is_evening_twilight: { condition: "mdi:weather-sunset-down" },
is_golden_hour: { condition: "mdi:weather-sunset" },
is_midnight_sun: { condition: "mdi:weather-sunny" },
is_morning_twilight: { condition: "mdi:weather-sunset-up" },
is_night: { condition: "mdi:weather-night" },
is_polar_night: { condition: "mdi:weather-night" },
is_set: { condition: "mdi:weather-sunny-off" },
is_up: { condition: "mdi:weather-sunny" },
},
switch: {
is_off: { condition: "mdi:toggle-switch-variant-off" },
is_on: { condition: "mdi:toggle-switch-variant" },
},
temperature: { is_value: { condition: "mdi:thermometer" } },
text: { is_equal_to: { condition: "mdi:form-textbox" } },
timer: {
is_active: { condition: "mdi:timer" },
is_idle: { condition: "mdi:timer-off" },
is_paused: { condition: "mdi:timer-pause" },
},
todo: {
all_completed: { condition: "mdi:clipboard-check" },
incomplete: { condition: "mdi:clipboard-alert" },
},
update: {
is_available: { condition: "mdi:package-up" },
is_not_available: { condition: "mdi:package" },
},
vacuum: {
is_cleaning: { condition: "mdi:play" },
is_docked: { condition: "mdi:home-import-outline" },
is_encountering_an_error: { condition: "mdi:alert-circle-outline" },
is_paused: { condition: "mdi:pause" },
is_returning: { condition: "mdi:home-import-outline" },
},
valve: {
is_closed: { condition: "mdi:valve-closed" },
is_open: { condition: "mdi:valve-open" },
},
vibration: {
is_detected: { condition: "mdi:vibrate" },
is_not_detected: { condition: "mdi:vibrate-off" },
},
water_heater: {
is_off: { condition: "mdi:water-boiler-off" },
is_on: { condition: "mdi:water-boiler" },
is_operation_mode: { condition: "mdi:water-boiler" },
is_target_temperature: { condition: "mdi:thermometer" },
},
window: {
is_closed: { condition: "mdi:window-closed" },
is_open: { condition: "mdi:window-open" },
},
zone: {
in_zone: { condition: "mdi:map-marker-check" },
not_in_zone: { condition: "mdi:map-marker-remove" },
occupancy_is_detected: { condition: "mdi:account-group" },
occupancy_is_not_detected: { condition: "mdi:account-off" },
},
};
+1
View File
@@ -39,6 +39,7 @@ const backupInfo: BackupInfo = {
},
],
agent_errors: {},
agent_error_translations: {},
last_attempted_automatic_backup: recent,
last_completed_automatic_backup: recent,
last_action_event: { manager_state: "idle" },
+222 -3
View File
@@ -1,25 +1,244 @@
import type {
EntityRegistryDisplayEntryResponse,
EntityRegistryEntry,
ExtEntityRegistryEntry,
UpdateEntityRegistryEntryResult,
} from "../../../src/data/entity/entity_registry";
import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
let entries: EntityRegistryEntry[] = [];
const newEntry = (
entry: Pick<EntityRegistryEntry, "entity_id" | "platform" | "unique_id">
): EntityRegistryEntry => {
const now = Date.now() / 1000;
return {
config_entry_id: null,
config_subentry_id: null,
device_id: null,
area_id: null,
disabled_by: null,
id: entry.entity_id,
name: null,
icon: null,
labels: [],
categories: {},
hidden_by: null,
entity_category: null,
has_entity_name: false,
options: null,
created_at: now,
modified_at: now,
...entry,
};
};
// The registry settings that the display registry holds. A display entry
// without a registry name shows the name of its state.
const displayFields = (entry: EntityRegistryEntry) => ({
...(entry.name && { name: entry.name }),
icon: entry.icon ?? undefined,
area_id: entry.area_id ?? undefined,
labels: entry.labels,
hidden: entry.hidden_by !== null,
});
const extEntry = (entry: EntityRegistryEntry): ExtEntityRegistryEntry => ({
...entry,
capabilities: {},
aliases: [],
});
export const mockEntityRegistry = (
hass: MockHomeAssistant,
data: EntityRegistryEntry[] = []
) => {
hass.mockWS("config/entity_registry/list", () => data);
entries = [...data];
// A new array, so subscribers see the registry changed after an update
hass.mockWS("config/entity_registry/list", () => [...entries]);
hass.mockWS(
"config/entity_registry/get_entries",
(msg: { entity_ids: string[] }) => {
const result: Record<string, ExtEntityRegistryEntry> = {};
for (const entityId of msg.entity_ids) {
const entry = data.find((e) => e.entity_id === entityId);
const entry = entries.find((e) => e.entity_id === entityId);
if (entry) {
result[entityId] = { ...entry, capabilities: {}, aliases: [] };
result[entityId] = extEntry(entry);
}
}
return result;
}
);
hass.mockWS(
"config/entity_registry/update",
(
msg: Pick<
EntityRegistryEntry,
| "entity_id"
| "name"
| "icon"
| "area_id"
| "labels"
| "categories"
| "hidden_by"
| "disabled_by"
> & { new_entity_id?: string },
currentHass: MockHomeAssistant
): UpdateEntityRegistryEntryResult => {
const {
entity_id: entityId,
new_entity_id: newEntityId,
...updates
} = msg;
// Renaming would have to move the entity in every mock, so refuse it
// instead of reporting a rename that did not happen
if (newEntityId && newEntityId !== entityId) {
throw new Error("Changing the entity ID is not supported in the demo");
}
// The demo entities without an entry get one, like any core entity has
const entry = {
...(entries.find((e) => e.entity_id === entityId) ??
newEntry({
entity_id: entityId,
platform: currentHass.entities[entityId]?.platform ?? "demo",
unique_id: entityId,
})),
...Object.fromEntries(
Object.entries(updates).filter(([key]) => key !== "type")
),
modified_at: Date.now() / 1000,
};
entries = [...entries.filter((e) => e.entity_id !== entityId), entry];
// Core updates the display registry too, only change what was updated
const display = currentHass.entities[entityId];
if (display) {
const fields = displayFields(entry);
currentHass.updateHass({
entities: {
...currentHass.entities,
[entityId]: {
...display,
...("name" in updates && { name: fields.name }),
...("icon" in updates && { icon: fields.icon }),
...("area_id" in updates && { area_id: fields.area_id }),
...("labels" in updates && { labels: fields.labels }),
...("hidden_by" in updates && { hidden: fields.hidden }),
},
},
});
}
currentHass.mockEvent("entity_registry_updated");
return { entity_entry: extEntry(entry) };
}
);
};
// Entries of a platform that were replaced, so their settings are restored
// when the entity comes back after switching demos
const retiredEntries = new Map<string, EntityRegistryEntry>();
// Replaces the entries of a platform, like core does for the entities of a
// demo config
export const setPlatformEntityRegistryEntries = (
hass: MockHomeAssistant,
platform: string,
platformEntries: Pick<EntityRegistryEntry, "entity_id" | "unique_id">[]
) => {
for (const entry of entries) {
if (entry.platform === platform) {
retiredEntries.set(`${platform}.${entry.unique_id}`, entry);
}
}
const restored: EntityRegistryEntry[] = [];
const platformRegistryEntries = platformEntries.map((entry) => {
const retired = retiredEntries.get(`${platform}.${entry.unique_id}`);
if (!retired) {
return newEntry({ ...entry, platform });
}
const restoredEntry = { ...retired, entity_id: entry.entity_id };
restored.push(restoredEntry);
return restoredEntry;
});
entries = [
...entries.filter((e) => e.platform !== platform),
...platformRegistryEntries,
];
// The display registry was rebuilt with the entities, restore its settings
if (restored.length) {
const entities = { ...hass.entities };
for (const entry of restored) {
if (entities[entry.entity_id]) {
entities[entry.entity_id] = {
...entities[entry.entity_id],
...displayFields(entry),
};
}
}
hass.updateHass({ entities });
}
hass.mockEvent("entity_registry_updated");
};
export const getEntityRegistryEntry = (entityId: string) =>
entries.find((e) => e.entity_id === entityId);
export const removeEntityRegistryEntry = (
hass: MockHomeAssistant,
entityId: string
) => {
entries = entries.filter((e) => e.entity_id !== entityId);
hass.mockEvent("entity_registry_updated");
};
// Adds an entry like core does when an integration creates an entity
export const addEntityRegistryEntry = (
hass: MockHomeAssistant,
entry: Pick<EntityRegistryEntry, "entity_id" | "platform" | "unique_id">
) => {
entries = [
...entries.filter((e) => e.entity_id !== entry.entity_id),
newEntry(entry),
];
hass.mockEvent("entity_registry_updated");
};
// The app refetches the display registry on entity_registry_updated events.
// Answer with the current display registry, which the demo keeps in hass.
export const mockEntityRegistryDisplay = (hass: MockHomeAssistant) =>
hass.mockWS(
"config/entity_registry/list_for_display",
(
_msg,
currentHass: MockHomeAssistant
): EntityRegistryDisplayEntryResponse => {
const categories = [
...new Set(
Object.values(currentHass.entities)
.map((entity) => entity.entity_category)
.filter((category) => category !== undefined)
),
];
return {
entities: Object.values(currentHass.entities).map((entity) => ({
ei: entity.entity_id,
di: entity.device_id,
ai: entity.area_id,
np: entity.next_name_part,
lb: entity.labels,
ec: entity.entity_category
? categories.indexOf(entity.entity_category)
: undefined,
en: entity.name,
ic: entity.icon,
pl: entity.platform ?? "demo",
tk: entity.translation_key,
hb: entity.hidden,
dp: entity.display_precision,
hn: entity.has_entity_name,
})),
entity_categories: Object.fromEntries(
categories.map((category, index) => [index, category])
),
};
}
);
+11
View File
@@ -12,6 +12,17 @@ export const mockIcons = (hass: MockHomeAssistant) => {
category: IconCategory;
integration?: string;
}) => {
if (category === "triggers" || category === "conditions") {
const { triggerIcons, conditionIcons } =
await import("./automation_platforms_icons");
const icons: Record<string, unknown> =
category === "triggers" ? triggerIcons : conditionIcons;
return {
resources: integration
? { [integration]: icons[integration] ?? {} }
: icons,
};
}
if (integration) {
try {
const response = await fetch(
+8 -1
View File
@@ -1,7 +1,14 @@
import type { LabelRegistryEntry } from "../../../src/data/label/label_registry";
import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
let labels: LabelRegistryEntry[] = [];
export const getLabelIds = () => labels.map((label) => label.label_id);
export const mockLabelRegistry = (
hass: MockHomeAssistant,
data: LabelRegistryEntry[] = []
) => hass.mockWS("config/label_registry/list", () => data);
) => {
labels = data;
hass.mockWS("config/label_registry/list", () => data);
};
+8 -1
View File
@@ -221,7 +221,14 @@ const personEntity = (person: DemoPerson, now: number): EntityInput => {
const personHistory = (person: DemoPerson, now: number): EntityHistoryState[] =>
person.timeline.map(([hoursAgo, state]) => {
const time = (now - hoursAgo * HOUR) / 1000;
return { s: state, a: {}, lu: time, lc: time };
const zoneId =
state === "home" ? "home" : zones.find((zone) => zone.name === state)?.id;
return {
s: state,
a: { in_zones: zoneId ? [`zone.${zoneId}`] : [] },
lu: time,
lc: time,
};
});
export const mockMap = (hass: MockHomeAssistant) => {
+33 -4
View File
@@ -60,7 +60,7 @@ const generateSumStatistics = (
end: Date,
period: "5minute" | "hour" | "day" | "month" = "hour",
initValue: number,
maxDiff: number
maxDiff: number | ((start: Date, end: Date) => number)
): StatisticValue[] => {
const statistics: StatisticValue[] = [];
let currentDate = new Date(start);
@@ -69,7 +69,9 @@ const generateSumStatistics = (
const now = new Date();
while (end > currentDate && currentDate < now) {
const nextDate = getNextDate(currentDate, period);
const add = Math.random() * maxDiff;
const bound =
typeof maxDiff === "function" ? maxDiff(currentDate, nextDate) : maxDiff;
const add = Math.random() * bound;
sum += add;
statistics.push({
start: currentDate.getTime(),
@@ -136,6 +138,15 @@ const statisticsFunctions: Record<
period: "5minute" | "hour" | "day" | "month"
) => StatisticValue[]
> = {
"sensor.energy_water": (_id, start, end, period = "hour") =>
generateSumStatistics(
start,
end,
period,
4000,
(bucketStart, bucketEnd) =>
(20 * (bucketEnd.getTime() - bucketStart.getTime())) / 3600000
),
"sensor.energy_consumption_tarif_1": (
_id: string,
start: Date,
@@ -301,6 +312,11 @@ const statisticsFunctions: Record<
return [...morning, ...production, ...evening, ...rest];
},
};
// Prices are per source unit; water uses litres (4 currency units per m³).
const derivedCosts: Record<string, { source: string; price: number }> = {
"sensor.energy_water_cost": { source: "sensor.energy_water", price: 0.004 },
};
export const mockRecorder = (mockHass: MockHomeAssistant) => {
mockHass.mockWS(
"recorder/get_statistics_metadata",
@@ -317,10 +333,23 @@ export const mockRecorder = (mockHass: MockHomeAssistant) => {
const end = end_time ? new Date(end_time) : new Date();
const statistics: Record<string, StatisticValue[]> = {};
const cache: Record<string, StatisticValue[]> = {};
const getStats = (id: string) => {
cache[id] ??= statisticsFunctions[id](id, start, end, period);
return cache[id];
};
statistic_ids.forEach((id: string) => {
if (id in statisticsFunctions) {
statistics[id] = statisticsFunctions[id](id, start, end, period);
if (id in derivedCosts) {
const { source, price } = derivedCosts[id];
let cost = 0;
statistics[id] = getStats(source).map((statistic) => {
const change = statistic.change! * price;
cost += change;
return { ...statistic, change, state: cost, sum: cost };
});
} else if (id in statisticsFunctions) {
statistics[id] = getStats(id);
} else {
const entityState = hass.states[id];
const state = entityState ? Number(entityState.state) : 1;
+2
View File
@@ -40,6 +40,7 @@ export default [
"ha-select-box",
"ha-input",
"ha-textarea",
"ha-picker-combo-box",
],
},
{
@@ -102,6 +103,7 @@ export default [
"entity-button-card",
"entity-filter-card",
"glance-card",
"sensor-card",
"tile-card",
"area-card",
],
+2 -5
View File
@@ -1,6 +1,5 @@
import { html, css, LitElement } from "lit";
import { customElement, property, state } from "lit/decorators";
import "../../../src/components/ha-formfield";
import "../../../src/components/ha-switch";
import type { HomeAssistant } from "../../../src/types";
import "./demo-card";
@@ -18,9 +17,7 @@ class DemoCards extends LitElement {
render() {
return html`
<ha-demo-options>
<ha-formfield label="Show config">
<ha-switch @change=${this._showConfigToggled}> </ha-switch>
</ha-formfield>
<ha-switch @change=${this._showConfigToggled}>Show config</ha-switch>
</ha-demo-options>
<div id="container">
<div class="cards">
@@ -51,7 +48,7 @@ class DemoCards extends LitElement {
demo-card {
margin: 16px 16px 32px;
}
ha-formfield {
ha-switch {
margin-right: 16px;
}
#container {
+2 -5
View File
@@ -1,6 +1,5 @@
import { LitElement, css, html } from "lit";
import { customElement, property, state } from "lit/decorators";
import "../../../src/components/ha-formfield";
import "../../../src/components/ha-switch";
import type { HomeAssistant } from "../../../src/types";
import "../ha-demo-options";
@@ -17,9 +16,7 @@ class DemoMoreInfos extends LitElement {
render() {
return html`
<ha-demo-options>
<ha-formfield label="Show config">
<ha-switch @change=${this._showConfigToggled}> </ha-switch>
</ha-formfield>
<ha-switch @change=${this._showConfigToggled}>Show config</ha-switch>
</ha-demo-options>
<div id="container">
<div class="cards">
@@ -49,7 +46,7 @@ class DemoMoreInfos extends LitElement {
demo-more-info {
margin: var(--ha-space-4) var(--ha-space-4) var(--ha-space-8);
}
ha-formfield {
ha-switch {
margin-right: var(--ha-space-4);
}
`;
+1 -4
View File
@@ -6,12 +6,10 @@ import "../../../src/components/ha-settings-row";
import "../../../src/components/ha-switch";
import type { HaSwitch } from "../../../src/components/ha-switch";
import "../../../src/components/ha-theme-settings";
import type { HomeAssistant, ThemeSettings } from "../../../src/types";
import type { ThemeSettings } from "../../../src/types";
@customElement("gallery-settings")
class GallerySettings extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false }) public themeSettings!: ThemeSettings;
@property({ type: Boolean }) public narrow = false;
@@ -26,7 +24,6 @@ class GallerySettings extends LitElement {
Configure how the gallery renders component previews and examples.
</div>
<ha-theme-settings
.hass=${this.hass}
.selectedTheme=${this.themeSettings}
.narrow=${this.narrow}
.heading=${"Theme"}
+10
View File
@@ -151,6 +151,16 @@ export const createMediaPlayerEntities = () => [
supported_features: 289,
},
},
{
entity_id: "media_player.theater_off_assumed",
state: "off",
attributes: {
friendly_name: "TV Off (assumed state, cannot be switched on)",
assumed_state: true,
// Off + Next + Pause
supported_features: 289,
},
},
{
entity_id: "media_player.theater_on_static",
state: "on",
+15 -1
View File
@@ -9,6 +9,7 @@ import { ifDefined } from "lit/directives/if-defined";
import { applyThemesOnElement } from "../../src/common/dom/apply_themes_on_element";
import { dynamicElement } from "../../src/common/dom/dynamic-element-directive";
import type { HASSDomEvent } from "../../src/common/dom/fire_event";
import { listenMediaQuery } from "../../src/common/dom/media_query";
import { computeEntityNameDisplayWithoutContext } from "../../src/common/entity/compute_entity_name_display";
import { setDirectionStyles } from "../../src/common/util/compute_rtl";
import "../../src/components/ha-button";
@@ -31,6 +32,7 @@ import {
floorsContext,
formattersContext,
internationalizationContext,
reducedMotionContext,
registriesContext,
servicesContext,
statesContext,
@@ -161,6 +163,13 @@ class HaGallery extends LitElement {
floors: new ContextProvider(this, { context: floorsContext }),
};
private _reducedMotionProvider = new ContextProvider(this, {
context: reducedMotionContext,
initialValue: false,
});
private _unsubReducedMotion?: () => void;
protected willUpdate(changedProps: PropertyValues<this>) {
super.willUpdate(changedProps);
// Refresh the fallback contexts before each render so theme/page changes in
@@ -233,7 +242,6 @@ class HaGallery extends LitElement {
${
isSettingsPage
? html`<gallery-settings
.hass=${this._galleryHass}
.themeSettings=${this._themeSettings}
.narrow=${this._narrow}
.rtl=${this._rtl}
@@ -267,6 +275,10 @@ class HaGallery extends LitElement {
connectedCallback() {
super.connectedCallback();
mql.addEventListener("change", this._systemDarkChanged);
this._unsubReducedMotion = listenMediaQuery(
"(prefers-reduced-motion: reduce)",
(matches) => this._reducedMotionProvider.setValue(matches)
);
}
firstUpdated(changedProps: PropertyValues<this>) {
@@ -300,6 +312,8 @@ class HaGallery extends LitElement {
super.disconnectedCallback();
mql.removeEventListener("change", this._systemDarkChanged);
window.removeEventListener("hashchange", this._hashChanged);
this._unsubReducedMotion?.();
this._unsubReducedMotion = undefined;
}
private _hashChanged = () => {
+5
View File
@@ -12,6 +12,11 @@
<script type="module" src="<%= entry %>"></script>
<% } %>
<style>
html {
/* Prevent iOS Safari from inflating text when rotating to landscape */
-webkit-text-size-adjust: 100%;
text-size-adjust: 100%;
}
body {
font-family: Roboto, Noto, sans-serif;
margin: 0;
@@ -1,7 +1,6 @@
import type { TemplateResult } from "lit";
import { LitElement, css, html } from "lit";
import { customElement, state } from "lit/decorators";
import "../../../../src/components/ha-formfield";
import { provideHass } from "../../../../src/fake_data/provide_hass";
import type { HomeAssistant } from "../../../../src/types";
import "../../components/demo-black-white-row";
@@ -64,13 +63,12 @@ class DemoHaAutomationEditorAction extends LitElement {
protected render(): TemplateResult {
return html`
<div class="options">
<ha-formfield label="Disabled">
<ha-switch
.name=${"disabled"}
.checked=${this._disabled}
@change=${this._handleOptionChange}
></ha-switch>
</ha-formfield>
<ha-switch
.name=${"disabled"}
.checked=${this._disabled}
@change=${this._handleOptionChange}
>Disabled</ha-switch
>
</div>
${SCHEMAS.map(
(info, sampleIdx) => html`
@@ -111,7 +109,7 @@ class DemoHaAutomationEditorAction extends LitElement {
max-width: 800px;
margin: 16px auto;
}
.options ha-formfield {
.options ha-switch {
margin-right: 16px;
}
`;
@@ -5,7 +5,6 @@ import { mockAreaRegistry } from "../../../../demo/src/stubs/area_registry";
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 "../../../../src/components/ha-formfield";
import type { ConditionWithShorthand } from "../../../../src/data/automation";
import { provideHass } from "../../../../src/fake_data/provide_hass";
import "../../../../src/panels/config/automation/condition/ha-automation-condition";
@@ -106,13 +105,12 @@ export class DemoAutomationEditorCondition extends LitElement {
protected render(): TemplateResult {
return html`
<div class="options">
<ha-formfield label="Disabled">
<ha-switch
.name=${"disabled"}
.checked=${this._disabled}
@change=${this._handleOptionChange}
></ha-switch>
</ha-formfield>
<ha-switch
.name=${"disabled"}
.checked=${this._disabled}
@change=${this._handleOptionChange}
>Disabled</ha-switch
>
</div>
${SCHEMAS.map(
(info, sampleIdx) => html`
@@ -153,7 +151,7 @@ export class DemoAutomationEditorCondition extends LitElement {
max-width: 800px;
margin: 16px auto;
}
.options ha-formfield {
.options ha-switch {
margin-right: 16px;
}
`;
@@ -8,7 +8,6 @@ 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 { mockTags } from "../../../../demo/src/stubs/tags";
import "../../../../src/components/ha-formfield";
import type { Trigger } from "../../../../src/data/automation";
import { provideHass } from "../../../../src/fake_data/provide_hass";
import "../../../../src/panels/config/automation/trigger/ha-automation-trigger";
@@ -146,13 +145,12 @@ export class DemoAutomationEditorTrigger extends LitElement {
protected render(): TemplateResult {
return html`
<div class="options">
<ha-formfield label="Disabled">
<ha-switch
.name=${"disabled"}
.checked=${this._disabled}
@change=${this._handleOptionChange}
></ha-switch>
</ha-formfield>
<ha-switch
.name=${"disabled"}
.checked=${this._disabled}
@change=${this._handleOptionChange}
>Disabled</ha-switch
>
</div>
${SCHEMAS.map(
(info, sampleIdx) => html`
@@ -193,7 +191,7 @@ export class DemoAutomationEditorTrigger extends LitElement {
max-width: 800px;
margin: 16px auto;
}
.options ha-formfield {
.options ha-switch {
margin-right: 16px;
}
`;
@@ -9,7 +9,6 @@ import "../../../../src/components/ha-form/ha-form";
import "../../../../src/components/ha-icon-button";
import type { HaFormSchema } from "../../../../src/components/ha-form/types";
import { provideHass } from "../../../../src/fake_data/provide_hass";
import type { HomeAssistant } from "../../../../src/types";
const SCHEMA: HaFormSchema[] = [
{ type: "string", name: "Name", default: "", autofocus: true },
@@ -31,11 +30,8 @@ type DialogType =
export class DemoHaAdaptiveDialog extends LitElement {
@state() private _openDialog: DialogType = false;
@state() private _hass?: HomeAssistant;
protected firstUpdated() {
const hass = provideHass(this);
this._hass = hass;
provideHass(this);
}
protected render() {
@@ -89,7 +85,6 @@ export class DemoHaAdaptiveDialog extends LitElement {
</ha-card>
<ha-adaptive-dialog
.hass=${this._hass}
.open=${this._openDialog === "basic"}
header-title="Basic adaptive dialog"
@closed=${this._handleClosed}
@@ -98,7 +93,6 @@ export class DemoHaAdaptiveDialog extends LitElement {
</ha-adaptive-dialog>
<ha-adaptive-dialog
.hass=${this._hass}
.open=${this._openDialog === "basic-subtitle-below"}
header-title="Adaptive dialog with subtitle"
header-subtitle="This is an adaptive dialog with a subtitle below"
@@ -108,7 +102,6 @@ export class DemoHaAdaptiveDialog extends LitElement {
</ha-adaptive-dialog>
<ha-adaptive-dialog
.hass=${this._hass}
.open=${this._openDialog === "basic-subtitle-above"}
header-title="Adaptive dialog with subtitle above"
header-subtitle="This is an adaptive dialog with a subtitle above"
@@ -119,7 +112,6 @@ export class DemoHaAdaptiveDialog extends LitElement {
</ha-adaptive-dialog>
<ha-adaptive-dialog
.hass=${this._hass}
.open=${this._openDialog === "small"}
width="small"
header-title="Small adaptive dialog"
@@ -129,7 +121,6 @@ export class DemoHaAdaptiveDialog extends LitElement {
</ha-adaptive-dialog>
<ha-adaptive-dialog
.hass=${this._hass}
.open=${this._openDialog === "large"}
width="large"
header-title="Large adaptive dialog"
@@ -139,7 +130,6 @@ export class DemoHaAdaptiveDialog extends LitElement {
</ha-adaptive-dialog>
<ha-adaptive-dialog
.hass=${this._hass}
.open=${this._openDialog === "form"}
header-title="Adaptive dialog with form"
header-subtitle="This is an adaptive dialog with a form"
@@ -163,7 +153,6 @@ export class DemoHaAdaptiveDialog extends LitElement {
</ha-adaptive-dialog>
<ha-adaptive-dialog
.hass=${this._hass}
.allowModeChange=${this._openDialog === "allow-mode-change"}
header-title="Adaptive dialog with allow mode change"
header-subtitle="Resize the window while this dialog is open"
@@ -176,7 +165,6 @@ export class DemoHaAdaptiveDialog extends LitElement {
</ha-adaptive-dialog>
<ha-adaptive-dialog
.hass=${this._hass}
.open=${this._openDialog === "actions"}
header-title="Adaptive dialog with actions"
header-subtitle="This is an adaptive dialog with header actions"
@@ -395,7 +383,6 @@ export class DemoHaAdaptiveDialog extends LitElement {
<h3>Example usage</h3>
<pre><code>&lt;ha-adaptive-dialog
.hass=\${this.hass}
open
header-title="Dialog title"
header-subtitle="Dialog subtitle"
@@ -607,7 +594,7 @@ export class DemoHaAdaptiveDialog extends LitElement {
<p>Example:</p>
<pre><code>&lt;ha-adaptive-dialog .hass=\${this.hass} open&gt;
<pre><code>&lt;ha-adaptive-dialog open&gt;
&lt;ha-form autofocus .schema=\${schema}&gt;&lt;/ha-form&gt;
&lt;/ha-adaptive-dialog&gt;</code></pre>
</div>
@@ -9,7 +9,6 @@ import "../../../../src/components/ha-form/ha-form";
import "../../../../src/components/ha-icon-button";
import type { HASSDomCurrentTargetEvent } from "../../../../src/common/dom/fire_event";
import { provideHass } from "../../../../src/fake_data/provide_hass";
import type { HomeAssistant } from "../../../../src/types";
import type { HaFormSchema } from "../../../../src/components/ha-form/types";
const SCHEMA: HaFormSchema[] = [
@@ -23,12 +22,10 @@ type PopoverType = false | "basic" | "small" | "large" | "form" | "actions";
export class DemoHaAdaptivePopover extends LitElement {
@state() private _openPopover: PopoverType = false;
@state() private _hass?: HomeAssistant;
@state() private _dialogAnchor?: HTMLElement;
protected firstUpdated() {
this._hass = provideHass(this);
provideHass(this);
}
protected render() {
@@ -72,7 +69,6 @@ export class DemoHaAdaptivePopover extends LitElement {
</ha-card>
<ha-adaptive-popover
.hass=${this._hass}
.open=${this._openPopover === "basic"}
.dialogAnchor=${this._dialogAnchor}
header-title="Basic adaptive popover"
@@ -86,7 +82,6 @@ export class DemoHaAdaptivePopover extends LitElement {
</ha-adaptive-popover>
<ha-adaptive-popover
.hass=${this._hass}
.open=${this._openPopover === "small"}
.dialogAnchor=${this._dialogAnchor}
width="small"
@@ -97,7 +92,6 @@ export class DemoHaAdaptivePopover extends LitElement {
</ha-adaptive-popover>
<ha-adaptive-popover
.hass=${this._hass}
.open=${this._openPopover === "large"}
.dialogAnchor=${this._dialogAnchor}
width="large"
@@ -108,7 +102,6 @@ export class DemoHaAdaptivePopover extends LitElement {
</ha-adaptive-popover>
<ha-adaptive-popover
.hass=${this._hass}
.open=${this._openPopover === "form"}
.dialogAnchor=${this._dialogAnchor}
header-title="Adaptive popover with form"
@@ -133,7 +126,6 @@ export class DemoHaAdaptivePopover extends LitElement {
</ha-adaptive-popover>
<ha-adaptive-popover
.hass=${this._hass}
.open=${this._openPopover === "actions"}
.dialogAnchor=${this._dialogAnchor}
header-title="Adaptive popover with actions"
@@ -51,11 +51,12 @@ The badge component is a small component that displays a number or status inform
**Properties/Attributes**
| Name | Type | Default | Description |
| -------- | ----------------------- | ----------- | ------------------------------------------------------------ |
| type | `"badge"` or `"button"` | `"badge"` | If it's button it shows a ripple effect |
| label | string | `undefined` | Text label for the badge, only visible if `iconOnly = false` |
| iconOnly | boolean | `false` | Only show label |
| Name | Type | Default | Description |
| --------------- | ----------------------- | ----------- | ------------------------------------------------------------- |
| type | `"badge"` or `"button"` | `"badge"` | If it's button it shows a ripple effect |
| label | string | `undefined` | Text label for the badge, only visible if `iconOnly = false` |
| iconOnly | boolean | `false` | Only show label |
| accessibleLabel | string | `undefined` | Accessible name of the button, only used if `iconOnly = true` |
**CSS Custom Properties**
@@ -0,0 +1,91 @@
---
title: Picker combo box
---
# Picker combo box `<ha-picker-combo-box>`
The searchable list used inside pickers. `ha-generic-picker` renders it in a
popover or dialog; entity, area, floor, label and device pickers all reach it
that way. It is shown directly here so the list and its keyboard model can be
inspected on their own.
It takes no `hass`. Items come from a `getItems` callback and locale comes from
`internationalizationContext`.
## Two render paths
The item count picks the renderer, and the two behave differently enough that
list changes need checking against both.
| Items | Renderer | Rows in the DOM |
| ------------------------------------------- | ----------------- | --------------------- |
| 12 or fewer rendered rows, no sections | `repeat()` | all of them |
| more than 12 rendered rows, or any sections | `lit-virtualizer` | only the visible ones |
The threshold is `MAX_PLAIN_LIST_ITEMS`, counted against the initial rendered
row set, including additional items and dialog padding. Filtering does not switch renderers.
## Two highlights
The list has two row backgrounds.
- **`selected`** — the row matching the `value` property, rendered onto its
`ha-list-item-option`.
- **`active`** — the keyboard cursor, and the row <kbd>Enter</kbd> picks. The
list runs in `virtual-focus` mode: DOM focus stays in the search field, and
the list owns the active row, skips disabled rows and section titles, and
scrolls it into view.
## Keyboard
| Key | Behavior |
| ------------------------------------- | ----------------------------------------------------- |
| <kbd>↑</kbd> <kbd>↓</kbd> | Move the cursor |
| <kbd>Home</kbd> <kbd>End</kbd> | Jump to the first or last item |
| <kbd>PageUp</kbd> <kbd>PageDown</kbd> | Move the cursor a page |
| <kbd>Enter</kbd> | Pick the row under the cursor |
| <kbd>Ctrl/⌘</kbd> + <kbd>Enter</kbd> | Pick it and report `newTab: true` in `index-selected` |
With no cursor yet, <kbd>↓</kbd> and <kbd>Enter</kbd> start from the `value`
row, or from the top match while searching. With one item in the list,
<kbd>Enter</kbd> picks it whether or not the cursor has moved.
## Implementation
Without `sections`, the component filters the items from `getItems` itself as the
user types, so `getItems` can ignore its search argument. With sections,
`getItems` receives the search string and selected section and must return the
matching rows.
The host is `display: flex` with `flex: 1` and expects a parent with a bounded
height. In the app that bound comes from the popover; embedding it directly
means supplying one.
### Properties/Attributes
| Name | Type | Default | Description |
| ------------------ | ------------------ | --------- | --------------------------------------------------------------------------------------------------------------------------------------- |
| getItems | Function | - | Required. Returns the items, optionally filtered by search string and section. |
| getAdditionalItems | Function | - | Extra items appended to search results, for example "add new". |
| value | String | - | Id of the current item. Marks its row `selected`, and the virtualized list opens with that row in view. |
| label | String | - | Placeholder for the search field. Falls back to a localized "Search". |
| mode | "popover"/"dialog" | "popover" | Adjusts padding for the surface the list sits in. |
| shown | Boolean | true | Whether the surface finished animating. `ha-generic-picker` sets it so the virtualizer does not measure rows mid-animation. |
| sections | Array | - | Filter chips. Section headers are plain strings returned by `getItems`, and the sticky title shows the one above the first visible row. |
| selectedSection | String | - | Section filter selected when the list is initialized. |
| searchKeys | Array | - | Fuse weighted keys for fuzzy search. |
| searchFn | Function | - | Post-processes filtered results. |
| rowRenderer | Function | - | Replaces the default row template. |
| allowCustomValue | Boolean | false | Offers the typed string as an item. |
| customValueLabel | String | - | Label for that custom item. |
| notFoundLabel | String/Function | - | Shown when a search matches nothing. |
| emptyLabel | String | - | Shown when there are no items at all. |
| noSort | Boolean | false | Keeps the order `getItems` returned. |
| clearable | Boolean | false | Adjusts search field padding for a clear affordance. |
### Events
| Event | Detail | Description |
| ---------------- | ------------------------- | -------------------------------------------------------------- |
| `value-changed` | `{ value }` | An item was picked. |
| `index-selected` | `{ index, item, newTab }` | Which row was picked, and whether it should open in a new tab. |
@@ -0,0 +1,273 @@
import { ContextProvider } from "@lit/context";
import type { TemplateResult } from "lit";
import { css, html, LitElement } from "lit";
import { customElement, state } from "lit/decorators";
import "../../../../src/components/ha-card";
import "../../../../src/components/ha-picker-combo-box";
import type { PickerComboBoxItem } from "../../../../src/components/ha-picker-combo-box";
import { internationalizationContext } from "../../../../src/data/context";
import {
DateFormat,
FirstWeekday,
NumberFormat,
TimeFormat,
TimeZone,
} from "../../../../src/data/translation";
import type {
HomeAssistantInternationalization,
ValueChangedEvent,
} from "../../../../src/types";
import { THEME_COMPARISON_PANELS } from "../../components/demo-theme-comparison";
const LOCALIZE_KEYS: Record<string, string> = {
"ui.common.search": "Search",
"ui.components.combo-box.no_match": "No matching items found",
"ui.components.combo-box.no_items": "No items available",
};
const localize = (key: string) => LOCALIZE_KEYS[key] ?? key;
const DEMO_I18N: HomeAssistantInternationalization = {
localize,
language: "en",
selectedLanguage: null,
locale: {
language: "en",
number_format: NumberFormat.language,
time_format: TimeFormat.language,
date_format: DateFormat.language,
first_weekday: FirstWeekday.language,
time_zone: TimeZone.local,
},
translationMetadata: { fragments: [], translations: {} },
loadBackendTranslation: async () => localize,
loadFragmentTranslation: async () => localize,
};
const PLAIN_ITEMS: PickerComboBoxItem[] = [
{ id: "light.desk", primary: "Desk", secondary: "Office" },
{ id: "light.dining_table", primary: "Dining table", secondary: "Kitchen" },
{ id: "light.doorway", primary: "Doorway", secondary: "Hallway" },
{ id: "light.hallway", primary: "Hallway", secondary: "Hallway" },
{
id: "light.kitchen_counter",
primary: "Kitchen counter",
secondary: "Kitchen",
},
{ id: "light.porch", primary: "Porch", secondary: "Outside" },
{ id: "light.reading_lamp", primary: "Reading lamp", secondary: "Bedroom" },
{
id: "light.shed",
primary: "Shed (disabled)",
secondary: "Outside",
disabled: true,
},
{ id: "light.workbench", primary: "Workbench", secondary: "Garage" },
];
const AREAS = [
"Attic",
"Basement",
"Bedroom",
"Dining room",
"Garage",
"Hallway",
"Kitchen",
"Living room",
"Office",
"Outside",
];
const FIXTURES = [
"Ceiling",
"Corner lamp",
"Cupboard",
"Desk lamp",
"Downlight",
"Floor lamp",
"Shelf",
"Spotlight",
"Strip",
"Wall sconce",
];
const VIRTUALIZED_ITEMS: PickerComboBoxItem[] = AREAS.flatMap((area, a) =>
FIXTURES.flatMap((fixture) =>
[1, 2].map((offset) => {
const n = a * 2 + offset;
return {
id: `light.${fixture.toLowerCase().replace(/ /g, "_")}_${n}`,
primary: `${fixture} ${n}`,
secondary: area,
};
})
)
);
@customElement("demo-components-ha-picker-combo-box")
export class DemoHaPickerComboBox extends LitElement {
@state() private _plainValue?: string;
@state() private _virtualizedValue?: string;
@state() private _preselectedValue = "light.spotlight_13";
constructor() {
super();
// Provides internationalizationContext for ha-picker-combo-box and ha-input-search
new ContextProvider(this, {
context: internationalizationContext,
initialValue: DEMO_I18N,
});
}
protected render(): TemplateResult {
return html`
<demo-theme-comparison>
${THEME_COMPARISON_PANELS.map(
({ slot }) => html`
<div slot=${slot} class="panel-content">
<ha-card header="Plain list (9 items)">
<div class="card-content">
<p class="note">
At or below 12 items the list renders with
<code>repeat()</code>, so every row is in the DOM. One item
is disabled, so neither a click nor <kbd>Enter</kbd>
picks it.
</p>
<div class="picker">
<ha-picker-combo-box
.getItems=${this._getPlainItems}
.value=${this._plainValue}
@value-changed=${this._plainValueChanged}
></ha-picker-combo-box>
</div>
<p class="value">
Picked: <code>${this._plainValue ?? "—"}</code>
</p>
</div>
</ha-card>
<ha-card header="Virtualized list (200 items)">
<div class="card-content">
<p class="note">
Above 12 items the list switches to
<code>lit-virtualizer</code> and only the visible rows
exist.
</p>
<div class="picker">
<ha-picker-combo-box
.getItems=${this._getVirtualizedItems}
.value=${this._virtualizedValue}
@value-changed=${this._virtualizedValueChanged}
></ha-picker-combo-box>
</div>
<p class="value">
Picked: <code>${this._virtualizedValue ?? "—"}</code>
</p>
</div>
</ha-card>
<ha-card header="Empty">
<div class="card-content">
<p class="note">
With no items the list shows
<code>emptyLabel</code>, or a localized fallback. Searching
a non-empty list that matches nothing shows
<code>notFoundLabel</code> instead.
</p>
<div class="picker">
<ha-picker-combo-box
.getItems=${this._getNoItems}
></ha-picker-combo-box>
</div>
</div>
</ha-card>
<ha-card header="With a current value">
<div class="card-content">
<p class="note">
A set <code>value</code> marks its row
<code>selected</code>. The keyboard cursor is a separate
highlight, and <kbd>↓</kbd> starts it on this row.
</p>
<div class="picker">
<ha-picker-combo-box
.getItems=${this._getVirtualizedItems}
.value=${this._preselectedValue}
@value-changed=${this._preselectedValueChanged}
></ha-picker-combo-box>
</div>
<p class="value">
Current: <code>${this._preselectedValue}</code>
</p>
</div>
</ha-card>
</div>
`
)}
</demo-theme-comparison>
`;
}
private _getPlainItems = () => PLAIN_ITEMS;
private _getVirtualizedItems = () => VIRTUALIZED_ITEMS;
private _getNoItems = (): PickerComboBoxItem[] => [];
private _plainValueChanged(ev: ValueChangedEvent<string>) {
this._plainValue = ev.detail.value;
}
private _virtualizedValueChanged(ev: ValueChangedEvent<string>) {
this._virtualizedValue = ev.detail.value;
}
private _preselectedValueChanged(ev: ValueChangedEvent<string>) {
this._preselectedValue = ev.detail.value;
}
static styles = css`
:host {
display: block;
}
.panel-content {
display: flex;
flex-direction: column;
gap: var(--ha-space-4);
}
ha-card {
margin: 0;
width: 100%;
}
.card-content {
display: flex;
flex-direction: column;
gap: var(--ha-space-2);
}
.note {
margin: 0;
color: var(--secondary-text-color);
font-size: var(--ha-font-size-s);
}
.picker {
display: flex;
flex-direction: column;
max-height: 320px;
border: 1px solid var(--divider-color);
border-radius: var(--ha-border-radius-md);
overflow: hidden;
}
.value {
margin: 0;
font-size: var(--ha-font-size-s);
}
`;
}
declare global {
interface HTMLElementTagNameMap {
"demo-components-ha-picker-combo-box": DemoHaPickerComboBox;
}
}
+111 -31
View File
@@ -15,13 +15,13 @@ import {
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 { BlueprintInput } from "../../../../src/data/blueprint";
import type { DeviceRegistryEntry } from "../../../../src/data/device/device_registry";
import type { LabelRegistryEntry } from "../../../../src/data/label/label_registry";
import { StatisticMeanType } from "../../../../src/data/recorder";
import { resolveSelectorContext } from "../../../../src/data/selector";
import type { SerialPort } from "../../../../src/data/usb";
import {
showDialog,
@@ -679,6 +679,77 @@ const SCHEMAS: {
},
},
},
{
name: "Unit of measurement",
input: {
sensor_device_class: {
name: "Device class",
selector: { device_class: { domain: "sensor" } },
},
sensor_state_class: {
name: "State class",
selector: { state_class: {} },
},
unit_linked: {
name: "Unit of the device class and state class above",
selector: {
unit_of_measurement: {
context: {
filter_device_class: "sensor_device_class",
filter_state_class: "sensor_state_class",
},
},
},
},
unit: {
name: "Any unit (custom allowed)",
selector: { unit_of_measurement: {} },
},
unit_temperature: {
name: "Temperature",
selector: { unit_of_measurement: { device_classes: ["temperature"] } },
},
unit_battery_humidity: {
name: "Battery or humidity",
selector: {
unit_of_measurement: { device_classes: ["battery", "humidity"] },
},
},
unit_aqi: {
name: "Air quality index (no unit)",
selector: { unit_of_measurement: { device_classes: ["aqi"] } },
},
unit_enum: {
name: "Enum (no unit)",
selector: { unit_of_measurement: { device_classes: ["enum"] } },
},
unit_timestamp: {
name: "Timestamp (no unit)",
selector: { unit_of_measurement: { device_classes: ["timestamp"] } },
},
unit_uptime: {
name: "Uptime (no unit)",
selector: { unit_of_measurement: { device_classes: ["uptime"] } },
},
unit_monetary: {
name: "Monetary (custom unit)",
selector: { unit_of_measurement: { device_classes: ["monetary"] } },
},
unit_angle: {
name: "Measurement angle",
selector: {
unit_of_measurement: { state_classes: ["measurement_angle"] },
},
},
unit_context: {
name: "Temperature or humidity, narrowed by context to humidity",
selector: {
unit_of_measurement: { device_classes: ["temperature", "humidity"] },
},
context: { filter_device_class: "humidity" },
},
},
},
{
name: "Multiples",
input: {
@@ -991,34 +1062,30 @@ class DemoHaSelector extends LitElement implements ProvideHassElement {
protected render(): TemplateResult {
return html`
<div class="options">
<ha-formfield label="Labels">
<ha-switch
.name=${"label"}
.checked=${this._label}
@change=${this._handleOptionChange}
></ha-switch>
</ha-formfield>
<ha-formfield label="Required">
<ha-switch
.name=${"required"}
.checked=${this._required}
@change=${this._handleOptionChange}
></ha-switch>
</ha-formfield>
<ha-formfield label="Disabled">
<ha-switch
.name=${"disabled"}
.checked=${this._disabled}
@change=${this._handleOptionChange}
></ha-switch>
</ha-formfield>
<ha-formfield label="Helper text">
<ha-switch
.name=${"helper"}
.checked=${this._helper}
@change=${this._handleOptionChange}
></ha-switch>
</ha-formfield>
<ha-switch
.name=${"label"}
.checked=${this._label}
@change=${this._handleOptionChange}
>Labels</ha-switch
>
<ha-switch
.name=${"required"}
.checked=${this._required}
@change=${this._handleOptionChange}
>Required</ha-switch
>
<ha-switch
.name=${"disabled"}
.checked=${this._disabled}
@change=${this._handleOptionChange}
>Disabled</ha-switch
>
<ha-switch
.name=${"helper"}
.checked=${this._helper}
@change=${this._handleOptionChange}
>Helper text</ha-switch
>
</div>
${SCHEMAS.map((info, idx) => {
const data = this.data[idx];
@@ -1039,7 +1106,7 @@ class DemoHaSelector extends LitElement implements ProvideHassElement {
<ha-selector
.hass=${this.hass}
.selector=${value!.selector}
.context=${value!.context}
.context=${this._context(value!, data)}
.key=${key}
.label=${this._label ? value!.name : undefined}
.value=${data[key] ?? value!.default}
@@ -1059,6 +1126,19 @@ class DemoHaSelector extends LitElement implements ProvideHassElement {
`;
}
private _context(
input: (typeof SCHEMAS)[number]["input"][string],
data: Record<string, unknown>
): Record<string, unknown> | undefined {
const selectorContext = input?.selector
? resolveSelectorContext(input.selector, data)
: undefined;
if (!selectorContext) {
return input?.context;
}
return { ...input?.context, ...selectorContext };
}
private _handleValueChanged(ev) {
const idx = ev.target.sampleIdx;
this.data[idx] = {
@@ -1077,7 +1157,7 @@ class DemoHaSelector extends LitElement implements ProvideHassElement {
max-width: 800px;
margin: 16px auto;
}
.options ha-formfield {
.options ha-switch {
margin-right: 16px;
}
`;
@@ -102,6 +102,13 @@ const CONFIGS = [
entity: "media_player.theater_off_static",
},
},
{
heading: "Player Off (assumed state, cannot be switched on)",
config: {
type: "media-control",
entity: "media_player.theater_off_assumed",
},
},
{
heading: "Player On (cannot be switched off)",
config: {
@@ -0,0 +1,3 @@
---
title: Sensor card
---
+147
View File
@@ -0,0 +1,147 @@
import type { PropertyValues, TemplateResult } from "lit";
import { html, LitElement } from "lit";
import { customElement, query } from "lit/decorators";
import { mockHistory } from "../../../../demo/src/stubs/history";
import { mockIcons } from "../../../../demo/src/stubs/icons";
import { provideHass } from "../../../../src/fake_data/provide_hass";
import type { SensorCardConfig } from "../../../../src/panels/lovelace/cards/types";
import type { DemoCardConfig } from "../../components/demo-card";
import "../../components/demo-cards";
const ENTITIES = [
{
entity_id: "sensor.outside_temperature",
state: "15.6",
attributes: {
friendly_name: "Outside temperature",
device_class: "temperature",
unit_of_measurement: "°C",
},
},
{
entity_id: "sensor.outside_humidity",
state: "54",
attributes: {
friendly_name: "Outside humidity",
device_class: "humidity",
unit_of_measurement: "%",
},
},
{
entity_id: "sensor.phone_battery",
state: "18",
attributes: {
friendly_name: "Phone battery",
device_class: "battery",
unit_of_measurement: "%",
},
},
{
entity_id: "sensor.not_working",
state: "unavailable",
attributes: {
friendly_name: "Not working",
},
},
];
const CONFIGS = [
{
heading: "Basic example",
config: {
type: "sensor",
entity: "sensor.outside_temperature",
},
},
{
heading: "With line graph",
config: {
type: "sensor",
entity: "sensor.outside_temperature",
graph: "line",
},
},
{
heading: "With graph detail and hours to show",
config: {
type: "sensor",
entity: "sensor.outside_humidity",
graph: "line",
detail: 2,
hours_to_show: 6,
},
},
{
heading: "With state color",
config: {
type: "sensor",
entity: "sensor.phone_battery",
icon: "mdi:battery-20",
graph: "line",
state_color: true,
},
},
{
heading: "With name, icon and unit",
config: {
type: "sensor",
entity: "sensor.outside_humidity",
name: "Humidity",
icon: "mdi:water",
unit: "pct",
},
},
{
heading: "Without tap action (not focusable)",
config: {
type: "sensor",
entity: "sensor.outside_temperature",
tap_action: { action: "none" },
},
},
{
heading: "Unavailable",
config: {
type: "sensor",
entity: "sensor.not_working",
},
},
{
heading: "Invalid entity",
config: {
type: "sensor",
entity: "sensor.invalid_entity",
},
},
] satisfies DemoCardConfig<SensorCardConfig>[];
@customElement("demo-lovelace-sensor-card")
class DemoSensorCard extends LitElement {
@query("#demos") private _demoRoot!: HTMLElement;
protected render(): TemplateResult {
return html`<demo-cards id="demos" .configs=${CONFIGS}></demo-cards>`;
}
protected firstUpdated(changedProperties: PropertyValues<this>) {
super.firstUpdated(changedProperties);
const hass = provideHass(this._demoRoot);
hass.updateTranslations(null, "en");
hass.updateTranslations("lovelace", "en");
hass.updateHass({
config: {
...hass.config,
components: [...hass.config.components, "history"],
},
});
hass.addEntities(ENTITIES);
mockIcons(hass);
mockHistory(hass);
}
}
declare global {
interface HTMLElementTagNameMap {
"demo-lovelace-sensor-card": DemoSensorCard;
}
}
+6 -8
View File
@@ -1,7 +1,6 @@
import { css, html, LitElement, nothing } from "lit";
import { customElement, state } from "lit/decorators";
import type { HASSDomEvent } from "../../../../src/common/dom/fire_event";
import "../../../../src/components/ha-formfield";
import "../../../../src/components/ha-select";
import type { HaSelectSelectEvent } from "../../../../src/components/ha-select";
import "../../../../src/components/ha-switch";
@@ -388,13 +387,12 @@ export class DemoMiscCloudAccount
private _toggle(label: string, field: keyof CloudDemoScenario) {
return html`
<ha-formfield .label=${label}>
<ha-switch
.checked=${this._scenario[field] as boolean}
data-field=${field}
@change=${this._switchChanged}
></ha-switch>
</ha-formfield>
<ha-switch
.checked=${this._scenario[field] as boolean}
data-field=${field}
@change=${this._switchChanged}
>${label}</ha-switch
>
`;
}
+6 -11
View File
@@ -1,6 +1,5 @@
import type { PropertyValues } from "lit";
import { css, html, LitElement, nothing } from "lit";
import "../../../../src/components/ha-formfield";
import "../../../../src/components/ha-switch";
import { customElement, property, state } from "lit/decorators";
@@ -258,12 +257,10 @@ export class DemoIntegrationCard extends LitElement {
return html`
<div class="container">
<div class="filters">
<ha-formfield label="Custom Integration">
<ha-switch @change=${this._toggleCustomIntegration}></ha-switch>
</ha-formfield>
<ha-formfield label="Relies on cloud">
<ha-switch @change=${this._toggleCloud}></ha-switch>
</ha-formfield>
<ha-switch @change=${this._toggleCustomIntegration}
>Custom Integration</ha-switch
>
<ha-switch @change=${this._toggleCloud}>Relies on cloud</ha-switch>
</div>
<ha-ignored-config-entry-card
@@ -293,7 +290,6 @@ export class DemoIntegrationCard extends LitElement {
class=${classMap({
highlight: info.highlight !== undefined,
})}
.hass=${this.hass}
domain="esphome"
.items=${info.items}
.manifest=${createManifest(
@@ -316,7 +312,6 @@ export class DemoIntegrationCard extends LitElement {
<!-- One that is standalone to see how it increases height if height
not defined by other cards. -->
<ha-integration-card
.hass=${this.hass}
domain="esphome"
.items=${[
loadedEntry,
@@ -369,9 +364,9 @@ export class DemoIntegrationCard extends LitElement {
max-width: 500px;
}
ha-formfield {
ha-switch {
margin: 8px 0;
display: block;
display: flex;
}
`;
}
+28 -32
View File
@@ -40,20 +40,20 @@
"license": "Apache-2.0",
"type": "module",
"dependencies": {
"@babel/runtime": "8.0.5",
"@babel/runtime": "8.0.7",
"@braintree/sanitize-url": "7.1.2",
"@codemirror/autocomplete": "6.20.3",
"@codemirror/commands": "6.11.1",
"@codemirror/lang-jinja": "6.0.1",
"@codemirror/lang-yaml": "6.1.3",
"@codemirror/language": "6.12.4",
"@codemirror/language": "6.13.1",
"@codemirror/lint": "6.9.7",
"@codemirror/search": "6.7.2",
"@codemirror/state": "6.7.6",
"@codemirror/view": "6.43.13",
"@codemirror/view": "6.43.14",
"@date-fns/tz": "1.5.0",
"@egjs/hammerjs": "2.0.17",
"@formatjs/intl-datetimeformat": "7.8.0",
"@formatjs/intl-datetimeformat": "7.8.1",
"@formatjs/intl-displaynames": "7.3.15",
"@formatjs/intl-durationformat": "0.11.1",
"@formatjs/intl-getcanonicallocales": "3.2.12",
@@ -68,7 +68,7 @@
"@fullcalendar/list": "6.1.21",
"@fullcalendar/luxon3": "6.1.21",
"@fullcalendar/timegrid": "6.1.21",
"@home-assistant/webawesome": "3.7.0-ha.1",
"@home-assistant/webawesome": "3.14.0-ha.0",
"@lezer/common": "1.5.3",
"@lezer/highlight": "1.2.5",
"@lit-labs/motion": "1.1.0",
@@ -77,27 +77,24 @@
"@lit/context": "1.1.6",
"@lit/reactive-element": "2.1.2",
"@lit/task": "1.0.3",
"@mapbox/mapbox-gl-rtl-text": "0.4.0",
"@maplibre/maplibre-gl-leaflet": "0.1.4",
"@material/mwc-formfield": "0.27.0",
"@material/mwc-list": "0.27.0",
"@material/web": "2.5.0",
"@mdi/js": "7.4.47",
"@mdi/svg": "7.4.47",
"@replit/codemirror-indentation-markers": "6.5.3",
"@swc/helpers": "0.5.23",
"@tsparticles/engine": "4.4.0",
"@tsparticles/preset-links": "4.4.0",
"@versatiles/style": "6.0.3",
"@versatiles/style": "6.4.0",
"@vibrant/color": "4.0.4",
"@vvo/tzdb": "6.198.0",
"@vvo/tzdb": "6.201.1",
"@webcomponents/scoped-custom-element-registry": "0.0.10",
"barcode-detector": "3.2.2",
"cally": "0.9.2",
"color-name": "2.1.1",
"comlink": "4.4.2",
"core-js": "3.50.0",
"cropperjs": "1.6.3",
"cropperjs": "1.7.0",
"culori": "4.0.2",
"date-fns": "4.4.0",
"deep-clone-simple": "1.1.1",
@@ -109,17 +106,17 @@
"hls.js": "1.7.3",
"home-assistant-js-websocket": "9.7.1",
"idb-keyval": "6.3.0",
"intl-messageformat": "12.1.2",
"js-yaml": "5.4.2",
"intl-messageformat": "12.1.3",
"js-yaml": "5.4.3",
"leaflet": "1.9.4",
"leaflet.markercluster": "1.5.3",
"lit": "3.3.3",
"lit-html": "3.3.3",
"luxon": "3.7.2",
"maplibre-gl": "6.11.2",
"marked": "18.0.14",
"maplibre-gl": "6.14.0",
"marked": "18.1.0",
"memoize-one": "6.0.0",
"nanoid": "6.0.1",
"nanoid": "6.0.2",
"node-vibrant": "4.0.4",
"object-hash": "3.0.0",
"punycode": "2.3.1",
@@ -140,25 +137,24 @@
"workbox-strategies": "7.4.1",
"xss": "1.0.15",
"zrender": "6.1.0",
"zxing-wasm": "3.1.4"
"zxing-wasm": "3.1.5"
},
"devDependencies": {
"@ampproject/remapping": "2.3.0",
"@babel/core": "8.0.6",
"@babel/helper-compilation-targets": "8.0.6",
"@babel/core": "8.0.7",
"@babel/helper-compilation-targets": "8.0.7",
"@babel/helper-define-polyfill-provider": "1.0.0",
"@babel/plugin-transform-runtime": "8.0.6",
"@babel/preset-env": "8.0.6",
"@babel/preset-env": "8.0.7",
"@bundle-stats/plugin-webpack-filter": "4.22.3",
"@eslint/js": "10.0.1",
"@gfx/zopfli": "1.0.15",
"@html-eslint/eslint-plugin": "0.66.1",
"@lokalise/node-api": "16.6.0",
"@octokit/auth-oauth-device": "8.0.5",
"@octokit/plugin-retry": "8.1.1",
"@octokit/rest": "22.0.1",
"@playwright/test": "1.63.0",
"@rsdoctor/rspack-plugin": "1.6.4",
"@playwright/test": "1.64.0",
"@rsdoctor/rspack-plugin": "1.6.5",
"@rspack/core": "2.2.8",
"@rspack/dev-server": "2.2.1",
"@types/babel__plugin-transform-runtime": "7.9.5",
@@ -172,18 +168,18 @@
"@types/leaflet.markercluster": "1.5.6",
"@types/lodash.merge": "4.6.9",
"@types/luxon": "3.7.6",
"@types/node": "24.19.1",
"@types/node": "24.19.2",
"@types/qrcode": "1.5.6",
"@types/sortablejs": "1.15.9",
"@types/tar": "7.0.87",
"@typescript/native": "npm:[email protected]",
"@vitest/coverage-v8": "5.0.3",
"@vitest/coverage-v8": "5.0.4",
"babel-loader": "10.1.1",
"babel-plugin-polyfill-corejs3": "1.0.0",
"browserslist": "4.29.3",
"browserslist-useragent-regexp": "4.1.4",
"core-js-compat": "3.50.0",
"del": "8.0.1",
"del": "9.0.1",
"eslint": "10.12.0",
"eslint-config-prettier": "10.1.8",
"eslint-import-resolver-webpack": "0.13.11",
@@ -202,7 +198,7 @@
"gulp-rename": "2.1.0",
"html-minifier-terser": "7.2.0",
"husky": "9.1.7",
"jsdom": "30.1.1",
"jsdom": "30.1.2",
"jszip": "3.10.2",
"license-checker-rseidelsohn": "5.0.1",
"lightningcss": "1.33.0",
@@ -213,7 +209,7 @@
"map-stream": "0.0.7",
"minify-literals": "2.2.0",
"pinst": "3.0.0",
"prettier": "3.9.9",
"prettier": "3.9.10",
"rspack-manifest-plugin": "5.2.2",
"serve": "14.2.6",
"sinon": "22.1.0",
@@ -221,15 +217,15 @@
"terser-webpack-plugin": "5.6.1",
"ts-lit-plugin": "2.0.2",
"typescript": "6.0.3",
"typescript-eslint": "8.71.0",
"vite": "8.3.2",
"typescript-eslint": "8.71.1",
"vite": "8.3.4",
"vite-tsconfig-paths": "6.1.1",
"vitest": "5.0.3",
"vitest": "5.0.4",
"webpack-stats-plugin": "1.1.3",
"webpackbar": "7.0.0",
"workbox-build": "7.4.1"
},
"packageManager": "pnpm@12.9.0",
"packageManager": "pnpm@12.11.2",
"volta": {
"node": "24.21.0"
}
-22
View File
@@ -1,22 +0,0 @@
diff --git a/mwc-formfield-base.js b/mwc-formfield-base.js
index 7b763326d7d51835ad52646bfbc80fe21989abd3..f2baa8224e6d03df1fdb0b9fd03f5c6d77fc8747 100644
--- a/mwc-formfield-base.js
+++ b/mwc-formfield-base.js
@@ -9,7 +9,7 @@ import { BaseElement } from '@material/mwc-base/base-element.js';
import { FormElement } from '@material/mwc-base/form-element.js';
import { observer } from '@material/mwc-base/observer.js';
import { html } from 'lit';
-import { property, query, queryAssignedNodes } from 'lit/decorators.js';
+import { property, query, queryAssignedElements } from 'lit/decorators.js';
import { classMap } from 'lit/directives/class-map.js';
export class FormfieldBase extends BaseElement {
constructor() {
@@ -96,7 +96,7 @@ __decorate([
query('.mdc-form-field')
], FormfieldBase.prototype, "mdcRoot", void 0);
__decorate([
- queryAssignedNodes('', true, '*')
+ queryAssignedElements({ slot: "", flatten: true, selector: "*" })
], FormfieldBase.prototype, "slottedInputs", void 0);
__decorate([
query('label')
+1041 -1015
View File
File diff suppressed because it is too large. Load diff
+2 -2
View File
@@ -4,8 +4,9 @@ autoInstallPeers: false
# Supply-chain protection
minimumReleaseAge: 4320 # 3 days, in minutes
minimumReleaseAgeExclude:
# Our own packages, published from our own CI
# Our own packages, published from our own CI. Keep in sync with renovate.json
- home-assistant-js-websocket
- "@home-assistant/*"
trustPolicy: no-downgrade
trustPolicyExclude:
# Backports published after provenance-signed 7.x releases
@@ -29,7 +30,6 @@ overrides:
tslib: 2.8.1
patchedDependencies:
"@material/[email protected]": patches/@[email protected]
"@material/[email protected]": patches/@[email protected]
[email protected]: patches/[email protected]
[email protected]: patches/[email protected]
@@ -0,0 +1,9 @@
<svg width="94" height="28" viewBox="0 0 94 28" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="94" height="28" rx="8" fill="white"/>
<rect x="0.5" y="0.5" width="93" height="27" rx="7.5" stroke="black" stroke-opacity="0.12"/>
<rect x="8" y="8" width="78" height="12" rx="3" fill="black" fill-opacity="0.12"/>
<rect x="12" y="13.25" width="6" height="1.5" rx="0.75" fill="black" fill-opacity="0.48"/>
<rect x="37" y="12.5" width="20" height="3" rx="1.5" fill="black" fill-opacity="0.32"/>
<rect x="76" y="13.25" width="6" height="1.5" rx="0.75" fill="black" fill-opacity="0.48"/>
<rect x="78.25" y="11" width="1.5" height="6" rx="0.75" fill="black" fill-opacity="0.48"/>
</svg>

After

Width:  |  Height:  |  Size: 691 B

@@ -0,0 +1,9 @@
<svg width="94" height="28" viewBox="0 0 94 28" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="94" height="28" rx="8" fill="#1C1C1C"/>
<rect x="0.5" y="0.5" width="93" height="27" rx="7.5" stroke="white" stroke-opacity="0.24"/>
<rect x="8" y="8" width="78" height="12" rx="3" fill="white" fill-opacity="0.24"/>
<rect x="12" y="13.25" width="6" height="1.5" rx="0.75" fill="white" fill-opacity="0.72"/>
<rect x="37" y="12.5" width="20" height="3" rx="1.5" fill="white" fill-opacity="0.48"/>
<rect x="76" y="13.25" width="6" height="1.5" rx="0.75" fill="white" fill-opacity="0.72"/>
<rect x="78.25" y="11" width="1.5" height="6" rx="0.75" fill="white" fill-opacity="0.72"/>
</svg>

After

Width:  |  Height:  |  Size: 693 B

@@ -0,0 +1,7 @@
<svg width="94" height="28" viewBox="0 0 94 28" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="94" height="28" rx="8" fill="white"/>
<rect x="0.5" y="0.5" width="93" height="27" rx="7.5" stroke="black" stroke-opacity="0.12"/>
<rect x="8" y="8" width="78" height="12" rx="3" fill="black" fill-opacity="0.12"/>
<rect x="8" y="8" width="46" height="12" rx="3" fill="black" fill-opacity="0.32"/>
<rect x="49" y="10.5" width="2" height="7" rx="1" fill="white"/>
</svg>

After

Width:  |  Height:  |  Size: 478 B

@@ -0,0 +1,7 @@
<svg width="94" height="28" viewBox="0 0 94 28" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="94" height="28" rx="8" fill="#1C1C1C"/>
<rect x="0.5" y="0.5" width="93" height="27" rx="7.5" stroke="white" stroke-opacity="0.24"/>
<rect x="8" y="8" width="78" height="12" rx="3" fill="white" fill-opacity="0.24"/>
<rect x="8" y="8" width="46" height="12" rx="3" fill="white" fill-opacity="0.48"/>
<rect x="49" y="10.5" width="2" height="7" rx="1" fill="#1C1C1C"/>
</svg>

After

Width:  |  Height:  |  Size: 482 B

@@ -0,0 +1,4 @@
<svg width="36" height="16" viewBox="0 0 36 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="16" height="16" rx="3" fill="black" fill-opacity="0.32"/>
<rect x="20" width="16" height="16" rx="3" fill="black" fill-opacity="0.12"/>
</svg>

After

Width:  |  Height:  |  Size: 252 B

@@ -0,0 +1,4 @@
<svg width="36" height="16" viewBox="0 0 36 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="16" height="16" rx="3" fill="white" fill-opacity="0.48"/>
<rect x="20" width="16" height="16" rx="3" fill="white" fill-opacity="0.24"/>
</svg>

After

Width:  |  Height:  |  Size: 252 B

@@ -0,0 +1,5 @@
<svg width="56" height="16" viewBox="0 0 56 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="16" height="16" rx="3" fill="black" fill-opacity="0.32"/>
<rect x="20" width="16" height="16" rx="3" fill="black" fill-opacity="0.12"/>
<rect x="40" width="16" height="16" rx="3" fill="black" fill-opacity="0.12"/>
</svg>

After

Width:  |  Height:  |  Size: 330 B

@@ -0,0 +1,5 @@
<svg width="56" height="16" viewBox="0 0 56 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="16" height="16" rx="3" fill="white" fill-opacity="0.48"/>
<rect x="20" width="16" height="16" rx="3" fill="white" fill-opacity="0.24"/>
<rect x="40" width="16" height="16" rx="3" fill="white" fill-opacity="0.24"/>
</svg>

After

Width:  |  Height:  |  Size: 330 B

@@ -0,0 +1,6 @@
<svg width="76" height="16" viewBox="0 0 76 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="16" height="16" rx="3" fill="black" fill-opacity="0.32"/>
<rect x="20" width="16" height="16" rx="3" fill="black" fill-opacity="0.12"/>
<rect x="40" width="16" height="16" rx="3" fill="black" fill-opacity="0.12"/>
<rect x="60" width="16" height="16" rx="3" fill="black" fill-opacity="0.12"/>
</svg>

After

Width:  |  Height:  |  Size: 408 B

@@ -0,0 +1,6 @@
<svg width="76" height="16" viewBox="0 0 76 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="16" height="16" rx="3" fill="white" fill-opacity="0.48"/>
<rect x="20" width="16" height="16" rx="3" fill="white" fill-opacity="0.24"/>
<rect x="40" width="16" height="16" rx="3" fill="white" fill-opacity="0.24"/>
<rect x="60" width="16" height="16" rx="3" fill="white" fill-opacity="0.24"/>
</svg>

After

Width:  |  Height:  |  Size: 408 B

@@ -0,0 +1,3 @@
<svg width="36" height="16" viewBox="0 0 36 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="36" height="16" rx="3" fill="black" fill-opacity="0.32"/>
</svg>

After

Width:  |  Height:  |  Size: 174 B

@@ -0,0 +1,3 @@
<svg width="36" height="16" viewBox="0 0 36 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="36" height="16" rx="3" fill="white" fill-opacity="0.48"/>
</svg>

After

Width:  |  Height:  |  Size: 174 B

@@ -0,0 +1,4 @@
<svg width="56" height="16" viewBox="0 0 56 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="36" height="16" rx="3" fill="black" fill-opacity="0.32"/>
<rect x="40" width="16" height="16" rx="3" fill="black" fill-opacity="0.12"/>
</svg>

After

Width:  |  Height:  |  Size: 252 B

@@ -0,0 +1,4 @@
<svg width="56" height="16" viewBox="0 0 56 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="36" height="16" rx="3" fill="white" fill-opacity="0.48"/>
<rect x="40" width="16" height="16" rx="3" fill="white" fill-opacity="0.24"/>
</svg>

After

Width:  |  Height:  |  Size: 252 B

@@ -0,0 +1,5 @@
<svg width="76" height="16" viewBox="0 0 76 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="36" height="16" rx="3" fill="black" fill-opacity="0.32"/>
<rect x="40" width="16" height="16" rx="3" fill="black" fill-opacity="0.12"/>
<rect x="60" width="16" height="16" rx="3" fill="black" fill-opacity="0.12"/>
</svg>

After

Width:  |  Height:  |  Size: 330 B

@@ -0,0 +1,5 @@
<svg width="76" height="16" viewBox="0 0 76 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="36" height="16" rx="3" fill="white" fill-opacity="0.48"/>
<rect x="40" width="16" height="16" rx="3" fill="white" fill-opacity="0.24"/>
<rect x="60" width="16" height="16" rx="3" fill="white" fill-opacity="0.24"/>
</svg>

After

Width:  |  Height:  |  Size: 330 B

@@ -0,0 +1,3 @@
<svg width="56" height="16" viewBox="0 0 56 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="56" height="16" rx="3" fill="black" fill-opacity="0.32"/>
</svg>

After

Width:  |  Height:  |  Size: 174 B

@@ -0,0 +1,3 @@
<svg width="56" height="16" viewBox="0 0 56 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="56" height="16" rx="3" fill="white" fill-opacity="0.48"/>
</svg>

After

Width:  |  Height:  |  Size: 174 B

@@ -0,0 +1,4 @@
<svg width="76" height="16" viewBox="0 0 76 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="56" height="16" rx="3" fill="black" fill-opacity="0.32"/>
<rect x="60" width="16" height="16" rx="3" fill="black" fill-opacity="0.12"/>
</svg>

After

Width:  |  Height:  |  Size: 252 B

@@ -0,0 +1,4 @@
<svg width="76" height="16" viewBox="0 0 76 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="56" height="16" rx="3" fill="white" fill-opacity="0.48"/>
<rect x="60" width="16" height="16" rx="3" fill="white" fill-opacity="0.24"/>
</svg>

After

Width:  |  Height:  |  Size: 252 B

@@ -0,0 +1,3 @@
<svg width="76" height="16" viewBox="0 0 76 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="76" height="16" rx="3" fill="black" fill-opacity="0.32"/>
</svg>

After

Width:  |  Height:  |  Size: 174 B

@@ -0,0 +1,3 @@
<svg width="76" height="16" viewBox="0 0 76 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="76" height="16" rx="3" fill="white" fill-opacity="0.48"/>
</svg>

After

Width:  |  Height:  |  Size: 174 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 90 KiB

After

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 223 KiB

After

Width:  |  Height:  |  Size: 223 KiB

+15
View File
@@ -144,6 +144,21 @@
"matchPackageNames": ["/^actions\\/cache(?:\\/|$)/"],
"groupName": "actions/cache",
"groupSlug": "actions-cache"
},
{
"description": "First-party packages and actions are reviewed and published by us, so skip the release delay. Keep in sync with minimumReleaseAgeExclude in pnpm-workspace.yaml",
"matchPackageNames": [
"home-assistant-js-websocket",
"@home-assistant/**",
"home-assistant/**"
],
"minimumReleaseAge": null
},
{
"description": "Follow webawesome fork releases (X.Y.Z-ha.N) across upstream versions, and skip the plain upstream syncs",
"matchPackageNames": ["@home-assistant/webawesome"],
"allowedVersions": "/-ha\\.\\d+$/",
"ignoreUnstable": false
}
]
}
+11
View File
@@ -0,0 +1,11 @@
#!/usr/bin/env bash
# Safe bash settings
# -e Exit on command fail
# -u Exit on unset variable
# -o pipefail Exit if piped command has error code
set -eu -o pipefail
cd "$(dirname "$0")/.."
./node_modules/.bin/gulp gen-demo-core-data
Loaded 100 of 885 files, more files were not shown because too many files have changed in this diff. Show more