Compare commits

..
Author SHA1 Message Date
Paul Bottein caa9871132 Keep device entities without a state in the device section 2026-10-15 16:26:35 +02:00
Paul Bottein b069a362e4 Use the inherited area of child devices in the device section heading 2026-10-15 15:34:31 +02:00
Paul Bottein 2e5814ad0d Add device section strategy 2026-10-15 09:59:33 +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
karwosts 9a45b6a200 Cleanup multiple unnecessary usages of entitySources (#54556)
* Cleanup multiple unnecessary usages of entitySources

* performance

* don't need entitySources for device integration

* memoize and filter device integration lookups

* Fix race condition fetching config entries

* Gate deviceIntegratonLookup in selector.ts on integration filter
2026-10-06 09:56:57 +03:00
Einar Skreslett a3f5929ddd Fix invisible inherited label tooltip text colour (#54521)
* fix invisible inherited label tooltip text colour

* fix tooltip while keeping theme text colour
2026-10-06 05:51:42 +00:00
Aidan Timson 0fed256025 Use context in Marketplace, prepare shared layouts for future context migrations (#54566)
* Read config and translations from context in shared layouts

* Use context instead of hass in Marketplace pages

* Provide contexts in Marketplace and tabs subpage tests

* Use provideHass for the Marketplace dialog test host

* Pass entities to tab page filters

* Update tab filters when entities change

* Fall back to hass localize in error screen

* Note when to inline error screen localize
2026-10-06 08:35:04 +03:00
ae3b3eefb3 Forward OAuth authorization parameters to the login flow (#54548)
* Forward OAuth authorization request parameters to the login flow

* Defer OAuth resource forwarding to a separate change

* Test preservation of empty PKCE parameters

* Do not forward state to the login flow

Core does not use state. The frontend appends it to the redirect
after the login flow finishes. The createLoginFlow type excludes
state, because Core rejects unknown fields.

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

* Reduce the login flow change to forwarding response_type

Keep the positional createLoginFlow signature, the existing property
and import order, and the existing tests. Forward response_type and
send empty PKCE values instead of dropping them.

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

---------

Co-authored-by: Paulus Schoutsen <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-06 08:33:40 +03:00
renovate[bot] 1e9d0432b3 Update dependency eslint to v10.12.0 (#54593)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-10-06 06:58:30 +02:00
renovate[bot] 60027ef4b1 Update pnpm to v12.9.0 (#54594)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-10-06 06:48:33 +02:00
Paul Bottein 5b25a220df Treat an entity name equal to its device name as no own name (#54585)
* Treat an entity name equal to its device name as no own name

* Use the same device name check when computing entity names

* Only check the user-set name against the device name

* Add tests for the device name check

* Clarify why only the user-set name is matched loosely
2026-10-05 18:54:27 +02:00
Paul Bottein 74c44e7c71 Fix first badges cut off in centered scrolling view header (#54565) 2026-10-05 18:50:36 +02:00
Paul Bottein 7b6561491a Fix option labels overflowing horizontal control selects (#54582) 2026-10-05 15:38:32 +00:00
Paul Bottein 17e0352ecf Show then branch before else in trace graph (#54583) 2026-10-05 17:03:31 +02:00
Maarten Lakerveld 7b379d587c Bump home-assistant-js-websocket to 9.7.1 (#54577)
Exclude our own packages from the 3-day minimumReleaseAge rule so
fixes we publish ourselves can be picked up immediately. The
no-downgrade trust policy still rejects unsigned releases.
2026-10-05 16:54:43 +02:00
Paul Bottein 6b32c87702 Fix map history paths flickering on every update (#54573)
Update map paths and zone circles in place instead of redrawing them
2026-10-05 15:40:34 +03:00
Jan-Philipp Benecke 2482b47d0b Hide dangling trace graph connectors when nothing follows (#54568) 2026-10-05 13:53:55 +02:00
Wendelin e9507ad201 Fix styling for config entry device rows (#54570) 2026-10-05 12:29:17 +01:00
Paul Bottein c26790e54d Add light color card feature with hue scrubber (#54219)
* Add light color card feature with hue scrubber

* Keep only the hue example in the scrubber gallery page

* Fix cancel event, card suggestion and white light hue in light color feature

* Update saturation gradient live while dragging hue

* Send terminal slider-moved after keyboard changes in scrubber

* Fix End key in wrap mode and keep focus when swapping light color controls

* Center collapsed hue preview on current hue and animate scrubber wrap by shortest path

* Hide the light color indicators when the light has no color
2026-10-05 12:16:16 +02:00
Aidan Timson 7eeca385a8 Shorten Marketplace warning countdown in development (#54564) 2026-10-05 11:55:17 +02:00
0149516743 Highlight the picker row that Enter would pick (#54319)
Co-authored-by: Claude Opus 5 <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Wendelin <[email protected]>
2026-10-05 11:54:25 +02:00
karwosts a270426d98 Show entity diagnostic in a readonly editor w/ copy button (#54536) 2026-10-05 12:26:05 +03:00
Wendelin f8d70893a1 config-area-page fix missing list-item import (#54563) 2026-10-05 09:25:46 +00:00
karwosts 2cc6112d6f Fix stale missing entities in target picker (#54544) 2026-10-05 12:10:13 +03:00
Jason Hunter bfe09bd2e7 Preserve OAuth callback state through login and onboarding (#54547)
Preserve OAuth callback state when logging in and resuming onboarding
2026-10-05 09:52:41 +03:00
Jump-333 d1370c8577 Add source names for SoC badges (#54370)
* Add device names for SoC badges

* Fixed reload issues

* Removed useless fallback

* Modified badge name source

* Remove useless check
2026-10-05 08:19:33 +03:00
renovate[bot] 430890f401 Update dependency @types/node to v24.19.1 (#54553)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-10-05 07:05:29 +02:00
renovate[bot] ba536e13e7 Update home-assistant/actions digest to 06749dd (#54552)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-10-05 07:05:10 +02:00
renovate[bot] 4278d16a62 Update github/codeql-action action to v4.38.2 (#54555)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-10-05 07:04:46 +02:00
renovate[bot] 1c28b7fef1 Update dependency globals to v17.13.0 (#54539)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-10-04 21:04:49 +02:00
renovate[bot] f867b6186f Update dependency vite to v8.3.2 (#54541)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-10-04 19:50:00 +02:00
Paulus SchoutsenandClaude Opus 5.5 7432e63c16 Show the Marketplace risks alert full width without an icon (#54540)
* Show the Marketplace risks alert full width without an icon

The risks alert spans the full card width, so it reads as a band in the
card. The text aligns with the card content. The alert icon is hidden
because the heading already shows a large warning icon.

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

* Scope the full-width alert styles to the risks alert

The error alert for a failed acceptance keeps its icon, corners and
padding.

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

* Start-align the Marketplace warning heading and intro

The heading and intro line up with the risks and the checkbox below
them. The warning icon stays centered.

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

---------

Co-authored-by: Claude <[email protected]>
2026-10-04 19:49:40 +02:00
Paulus SchoutsenandClaude 0bda4a13ca Add the Marketplace to the demo (#54546)
The demo registers the Marketplace panel and mocks its read commands.
A list of community repositories shows on the discover, browse and
installed pages, and each repository has its own page. The warning
screen is skipped.

The mocks load with the config panel chunk. A loaded Marketplace config
entry lets the panel start. Commands that change the Marketplace, such
as install and uninstall, are not mocked.


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

Co-authored-by: Claude <[email protected]>
2026-10-04 19:48:24 +02:00
karwosts dd0a0df88f Fix max_columns number selector (#54542) 2026-10-04 15:51:41 +02:00
renovate[bot] 6775b1e2bb Update vitest monorepo to v5.0.3 (#54535)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-10-04 08:56:20 +02:00
renovate[bot] 06df4046a9 Update pnpm to v12.8.2 (#54538)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-10-04 08:56:16 +02:00
renovate[bot] 634b6ddfcd Update dependency browserslist to v4.29.3 (#54531)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-10-03 08:06:47 +02:00
Paulus SchoutsenandClaude 970bb12856 Hide MCP URL hint until the server is enabled (#54532)
When the MCP server is not set up, the card has no URLs to show.
The description told the user to add one of the URLs below. Show
only the documentation link until the server is enabled.


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

Co-authored-by: Claude <[email protected]>
2026-10-03 08:06:41 +02:00
Jan-Philipp Benecke c03750e605 Handle undefined trigger condition IDs in rendering (#54527)
* Handle undefined trigger condition IDs in rendering

* Handle trigger conditions without ID in editor and updates
2026-10-02 16:41:55 +02:00
Maarten Lakerveld 655e11ac1c Keep padded map fits within the poles (#54526)
The MapLibre engine pads a fit by a fraction of its span. A zone whose
circle reaches a pole, or markers far apart, pushed the padded
latitude past 90, and MapLibre throws on such bounds, so the fit never
happened and the console filled with errors. The padded latitude is
now clamped to the poles.

Fixes #54498
2026-10-02 15:03:02 +02:00
Paul Bottein b25cf13401 Add signed and offset modes to the duration selector (#54111)
* Add signed and offset modes to the duration selector

* Remove translation key from the duration selector

* Address review comments on the duration selector

* Expose the duration selector mode in the selector editor

* Remove allow_negative from the selector editor

* Migrate allow_negative to mode when editing a duration selector

* Derive the magnitude of mixed-sign legacy durations from their total

* Test the duration sign and value helpers

* Merge the duration sign helpers into normalizeDuration

* Omit the negative flag for positive signed durations

* Keep the pending offset type in local state instead of writing negative false

* Keep the offset type when the duration is cleared to zero

* Rename the offset type state

* Address remaining Copilot comments on the duration selector

* Simplify the duration sign handling

* Reset the offset type when the duration changes from outside

* Translate the duration selector mode in the selector editor

* Write negative durations as negative components instead of a negative key

* Simplify duration sign handling around a single normalizeDuration

* Keep the offset type as local state and refresh duration fields with live

* Forget the emitted duration after an external change

* Keep the duration sign at zero and use selector units everywhere

* Simplify duration sign handling and compute typed negative values

* Hide the clear button of offset durations
2026-10-02 09:02:45 +00:00
Wendelin 24c3ab6061 Use optional short issue title localization to repairs dialog (#54506)
Add short issue title localization to repairs dialog
2026-10-02 07:52:22 +00:00
Bram KragtenandClaude Opus 5.5 2c73378e9d Cache map tiles per asset class and stop refetching what still works (#54451)
* Cache map tiles per asset class and stop refetching what still works

The service worker kept glyphs, sprites and tiles in one 500-entry LRU, so
panning around evicted the fonts that every label on every map needs. Glyphs
and sprites move to a cache of their own, on the 30 day age core already sends
them with, and the tile ceiling goes up.

The TileJSON was deliberately left on the network because it is the switching
point for the tile source. Every map waits on it before asking for a tile
though, and at a five minute max-age that is a round trip before the first one.
Served stale-while-revalidate instead: out of the cache immediately, refreshed
behind it, so a moved endpoint still arrives.

The raster fallback redrew on every token rotation, refetching every tile on
screen twice an hour whether or not anything had failed. Later tiles pick up a
new token by themselves, so only a layer that actually had one refused needs
asking again - and since a refused tile is the only sign that path gets that
its token is stale, it now asks for a new one instead of waiting for the
refresh interval to come round.

Style requests that are in flight are shared, so a dashboard of maps, or a
theme toggle switching all of them at once, fetches each style once. Only while
in flight: a settled request is dropped, or a style that failed once would
never be fetched again.

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

* Hand the raster fallback the current map tiles token

A rotation is only announced once, so a vector layer that later falls back
handed the raster layer the token it was created with, and the refresh that
follows a refused tile fetches the already-current token and announces
nothing. The vector layer now tracks rotations itself.

Also covers in-flight style sharing, and passes a map style rather than
`false` in the raster recovery tests.

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

---------

Co-authored-by: Claude Opus 5 <[email protected]>
2026-10-02 09:50:33 +02:00
Paul Bottein d8e09dd120 Show entered and left in map zone activity (#54516)
* Show arrived and left in map zone activity

* Use entered instead of arrived in map zone activity
2026-10-02 07:49:26 +00:00
Jan-Philipp Benecke 6cdfafcbf9 Fix misplaced trace graph icons on iOS (#54520) 2026-10-02 09:40:29 +02:00
Elias Lecomte 4819cd6d27 Fix long source name overflow in energy dashboard totals (#54522)
* Fix long source name overflow in energy dashboard totals

* style: format with prettier
2026-10-02 09:39:16 +02:00
karwosts c5f099dab7 Remove spurious color clear button (#54515)
* Improve number selectors in section settings

* Fix sections editor default color
2026-10-02 09:33:42 +02:00
renovate[bot]andSimon Lamon 2da41b6e59 Update dependency zxing-wasm to v3.1.4 (#54480)
* Update dependency zxing-wasm to v3.1.4

* Update type-fest version to 5.10.0

---------

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Simon Lamon <[email protected]>
2026-10-02 09:27:41 +02:00
renovate[bot] b7bb487208 Update pnpm to v12.8.1 (#54523)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-10-02 08:11:53 +03:00
renovate[bot] dfaf2a6f72 Update dependency @types/luxon to v3.7.6 (#54525)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-10-02 08:10:43 +03:00
renovate[bot] 8639ccde06 Update dependency typescript-eslint to v8.71.0 (#54517)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-10-01 20:07:31 +02:00
ee67f6b99f Add dependency review workflow (#54490)
* Add dependency review workflow with npm metadata checks

* Temporarily run dependency review on push

* Remove temporary push trigger from dependency review

* Encode the full package name in npm registry URLs

* Check optional/peer dependencies and fail on npm registry errors

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

* Convert npm metadata check to TypeScript

* Type workflow scripts with Node 24 types instead of DOM

* Detect npm alias target changes as new dependencies

---------

Co-authored-by: copilot-swe-agent[bot] <[email protected]>
2026-10-01 20:02:11 +02:00
renovate[bot] e569664724 Update dependency @rspack/core to v2.2.8 (#54514)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-10-01 19:58:07 +02:00
Maarten Lakerveld c113eb1f9a Fix map overview tabs, activity timeline, cluster counts, zone fit and the new-zone route (#54512)
* Show four avatars in a cluster bubble before counting

A bubble capped at three avatars added a +1 chip for the fourth
member, taking a fourth slot to hide one avatar. Four members now show
as four avatars; from five on, three avatars and a count fill the same
four slots.

* Open the new zone dialog on every visit to /config/zone/new

The route was only handled in firstUpdated. The config panel is kept
alive between visits and the router reuses the zone page when it is
already the current one, so "Create zone from current location" did
nothing once the zone list had been opened earlier in the session. The
route is now handled on every route change, like /edit/ already was.

The dialog also opened before the more-info dialog it was launched
from had finished leaving the history, and that departure closed the
new dialog again. The page now awaits the replace navigation, which
waits for closing dialogs, before opening it.

* Hide the map overview while the dashboard is being edited

In panel view edit mode the map card kept rendering its overview. On
phones that is a bottom sheet fixed over the bottom of the screen,
which covered the card's edit bar and the view's add button, so the
map dashboard could not be edited in portrait. The overview is for
using the map, not configuring it, so it is left out while editing.

* Show only the overview tabs that have something to list

People and Zones were always offered, with an empty message when the
map card's entities held none of them; only Devices was hidden when
empty. All three now appear only with entries, the first remaining tab
takes over when the selected one empties, and the overview tells the
card so its marker filter follows. The empty-tab messages are gone
with the empty tabs.

* List the person of the current user first, as "Me"

The overview's People tab sorted everyone alike. The person linked to
the user looking at the map now comes first and is named "Me" in the
list and the detail header, through a localized string. Their initials
still come from their real name.

* Draw the overview activity as a timeline

The activity list reused logbook rows: the person's name on every row,
the state pushed to the right, dividers between rows. It is now a
timeline of its own. Each row names where the person went, with the
clock time and how long ago underneath, hung off a dot in the zone's
color with a rail to the next row. A zone's rows name who came or
left and where they are. Five rows show at first, with a "Show more"
button for the rest. The overview no longer needs the broad hass
object, which only the logbook row took.

* Fit the map to its zones when they are all it shows

Zones only joined the auto-fit with the fit_zones option. The panel
map does not set it, so on the Zones tab, with no people or devices
left, the fit found nothing and jumped straight to the home
coordinates at the default zoom. Zones now stand in whenever nothing
else is on the map, which goes through the animated fit like the
other tabs. With fit_zones they still join a fit of people and devices.
2026-10-01 17:56:01 +02:00
Franck Nijhof 1f98d6ef6b Add Marketplace panel (#54437)
* Add store panel as a copy of the HACS frontend

Verbatim copy of src/ from hacs/frontend at d97dce0 into src/panels/store. Imports still point at the HACS submodule layout and the iframe shell is still present; this is the baseline for the port.

* Rewrite store panel imports and drop the iframe shell

Every import that pointed at the old `homeassistant-frontend` submodule now
resolves inside this repository's `src/` tree. Five components were renamed or
removed upstream, so they point at their current equivalents: `ha-button-menu`
and `ha-menu` to `ha-dropdown`, `ha-md-menu-item` to `ha-dropdown-item`,
`ha-fab` to `ha-button`, and `ha-circular-progress` to `ha-spinner`. The
`@material/mwc-button` and `@material/mwc-linear-progress` side-effect imports
are gone with them; those packages are no longer dependencies here.

`main.ts` becomes `ha-panel-store.ts` and registers `ha-panel-store`. It now
receives `hass`, `narrow`, `route` and `panel` from the panel resolver, so
everything that only made sense inside an iframe is removed: applying themes to
the parent element, forwarding haptic and keydown events to the main window,
bridging `location-changed`, the document-level navigation click handler, the
dialog manager, the injected body stylesheet, and the redirect to the default
route. `entrypoint.ts`, `extra.ts`, `html/` and `version.ts` are deleted for the
same reason.

* Move store panel translations into the frontend translation tree

The panel's own translation bundle is gone. Its keys now live under
`ui.panel.store` in `src/translations/en.json`, and `panel.store` gives the
sidebar its "Community store" title. Every `hacs.localize("some.key")` call
became `hass.localize("ui.panel.store.some.key")`, which means the `localize`
member on the `Hacs` interface, the `HacsLocalizeKeys` type and the localize
bootstrap in `HacsElement` are no longer needed. `TranslationDict` is derived
straight from `en.json`, so the moved keys type-check without any casts.

`dialog_about.frontend_version` did not come along: its only consumer was the
frontend version constant that went away with the iframe shell.

* Hold store panel state in the panel instead of a HACS mixin

The panel element now owns the repository list and the info payload as
internal state, loads both on first update and refreshes them from the
store/subscribe signals through SubscribeMixin. That drops the
HacsElement base class and its logger, so the hacs object handed to the
child components is plain data. Children that used to patch that object
with an update-hacs event now fire store-refresh instead and let the
panel refetch.

Also moves every WebSocket command over to the store/ prefix and trims
the info payload to what the backend still returns.

* Update the store panel to current frontend components

Rebuilds the panel on the components the frontend ships today: the
dialogs get their heading from ha-dialog itself and their buttons from
ha-dialog-footer, the overflow menus become ha-dropdown with
ha-dropdown-item, the download action becomes a fab-slotted ha-button,
and the progress and spinner indicators move to ha-progress-bar and
ha-spinner. brandsUrl loses the dropped useFallback option.

Also clears the eslint backlog the panel came in with: no arrow
functions in templates, no console calls, alt text on the brand images,
and private members named consistently.

* Register the store panel and route old HACS paths

Add the store panel to the panel resolver so the backend can serve it,
and move every internal navigation path in the panel from /hacs to
/store. The /hacsfiles static prefix stays, the backend serves it.

Repoint the hacs_repository my-redirect at the store panel and add a
store_repository alias next to it. Both keep the same target, so the
owner, repository, and category parameters still reach the panel's
redirect handler. Existing README badges keep working.

* Rename HACS to Community store in the store panel

Bring the ported panel in line with the repository conventions: panel
parts are ha-*, dialogs are dialog-* with a matching show-dialog-*
helper, and the typo in the overflow menu filename is fixed.

Rename the code identifiers to match, so the panel talks about a store
instead of HACS: StoreData, StoreInfo, StoreDispatchEvent, a store
property on the child elements, storeStyles and --store-* custom
properties.

Reword every user-facing string that named HACS. Backend names stay put:
the hacs_dispatch_* signal values, the /hacsfiles static prefix, the
hacs_repository my-redirect key, and the hacs.xyz documentation and
catalog infrastructure.

The stale HACS-REPOSITORY-PANEL nodeName check now matches the element
it was always meant to match, so removing a repository from its own page
navigates back again.

* Show a message when the store panel is opened without the integration

The panel is registered on every install while the integration is only set up on some, so an empty store must not try to talk to a backend that is not there.

* Link the Community store from the Settings dashboard

The store panel is hidden from the sidebar, so reach it from the Settings dashboard instead and give its pages a way back.

* Fit the store pages into the main layout

The repository page still sized itself to the viewport and the router recoloured the header with sidebar colours, both leftovers from running inside an iframe. Next to the sidebar that made the page a sidebar-width too wide, pushing the toolbar menu off-screen and un-centering the card.

* Let the quick bar create My links for store repositories

The repository page listened for the "m" key on the document body and opened a My link itself, which fought the quick bar's own "m" shortcut once the panel stopped living in an iframe. The quick bar now resolves a store repository page to the hacs_repository redirect by looking up the repository name, and the panel's handler is gone. The reload after installing a dashboard resource no longer goes through the main window helper, and unused scrollbar styles are dropped.

* Remove the external links from the store menu

The About dialog, the issue tracker link and the link to the HACS GitHub organisation pointed at a project outside Home Assistant. The store is part of Home Assistant now, so they go. The documentation link stays until the documentation moves.

* Fix runtime bugs in the store panel

* Remove external HACS leftovers from the store panel

* Tidy types and conventions in the store panel

* Clean up the store panel translations

* Give the store redirect error screen its hass and narrow properties

Without them the screen's back and refresh buttons render without labels.

* Use the store signal names and local resource URLs

The backend renames these in lockstep: the dispatcher signals lose their
`hacs_dispatch_` prefix, and downloaded dashboard resources are served
from `/local/community/` with a `v` cache-busting parameter instead of
the store-owned `/hacsfiles/` route and `hacstag`.

* Drop the disabled reasons the store backend no longer reports

Setup failures now surface through the config entry state instead of a disabled store, so only the runtime reasons remain.

* Rename the Community store to Marketplace

The panel, its elements, dialogs, data types, WebSocket commands,
dispatcher signals and translations now use marketplace, matching the
renamed backend domain. The hacs_repository My link keeps its slug.

* Finish the Marketplace panel

* Confirm replacing a built-in integration with the download

* Fix README links, older versions and grouping in the Marketplace panel

* Handle list continuations and placeholder characters in Marketplace READMEs

- Indented lines inside a list item are treated as text, so their links and issue references get rewritten.
- A README that already contains the private placeholder characters is left alone.

* Keep indented code inside list items literal in Marketplace READMEs

* Fix the warning title, icon colors and dead subscriptions in the Marketplace panel

- The warning takes its title from the Marketplace translations, it is not empty on a direct visit.
- Icons keep the colors of the menu or chip they are in.
- Dismissing new repositories is offered whatever the filters show.
- The dialogs no longer subscribe to an error signal the backend never sends.
- The custom repositories list names the category in the language of the user.

* Handle the edges of the Marketplace panel more carefully

- The warning can be continued again when the panel did not take over.
- Only a query with an owner and a repository opens a repository as a My link.
- Links to /hacs inside Home Assistant open the Marketplace.
- A rate limit after the download dialog closed opens nothing.
- A removed entry takes its catalog with it.
- The quick bar says why it could not make a My link for a repository.

* Type the Marketplace panel against the backend contract

- The data types match what the backend sends.
- Errors are caught as unknown and read through one helper.
- Events are typed from their declarations.
- Spacing uses the theme tokens.

* Make the wording of Marketplace actions clearer

* Leave the way back from the Marketplace warning to the header

* Show the download progress in the Marketplace download dialog

* Offer to set up a newly downloaded integration right away

* Make the Marketplace download dialog plainer and move other versions to the menu

- One sentence says what the download or update does, no paths or technical details.
- Warnings span the top of the dialog, like in the more-info dialog.
- The progress shows where the update dialog shows it, and the button shows it is busy.
- A restart is only mentioned for updates of integrations.
- Choosing another version moved to the repository menu, it opens on its own when the newest version is too new.

* Use the consume decorator of the frontend in the Marketplace dialogs

* Save the Marketplace search, and keep search and filters per session

* Lay out the Marketplace repository page like the other Settings detail pages

* Bring the Marketplace list page in line with the other Settings lists

* Use the standard Settings dialogs, styles and strings in the Marketplace

* Name the Marketplace data helpers like the rest of the frontend and drop the global refresh event

* Center the Marketplace warning and say how long accepting it lasts

* Call it installing in the Marketplace, and set up right after installing

* Use the uninstall command of the Marketplace

* Fix the row menu, the failed setup screen and README links in the Marketplace panel

* Guide uninstalling an integration that is in use, and offer it after deleting the last entry

* Fix reinstalling, filters, stale pages and README handling in the Marketplace panel

* Explain why an integration in use can not be uninstalled

* Test that accepting the warning and uninstalling in use really happen

* Point HACS links and icons at the Marketplace, and clarify its copy

* Share the error message and menu rendering of the Marketplace panel

* Split the long renders of the Marketplace panel

* Treat Marketplace repository ids as strings

* Guide the user after installing or uninstalling from the Marketplace

* Count down before the Marketplace warning can be accepted, and show it only once

* Show what to do next and who made it on the Marketplace repository page

* Title the Marketplace repository page Marketplace and keep signals one line

* Say which Home Assistant a Marketplace repository requires

"Works with Home Assistant 2025.1 or newer" read as a promise it had been tried there. It is a minimum the repository asks for, so say it that way.

* Explain adding to the Marketplace from a GitHub link

The dialog was called "Custom repositories" and opened on a list of them. Someone adding their first one saw an empty list and two bare fields.

It now says what it is for, and that what is added this way is made by the community like everything else in the Marketplace. Both fields say what they want. The list of what was added from links comes after that, under its own heading, and only once there is any.

* Give the Marketplace Discover, Browse and Installed tabs

The Marketplace was one long table, grouped by status. Finding what you have installed, or what waits for an update, meant scrolling past everything else.

Discover opens with what is popular and what changed lately. Each section links to Browse, sorted and filtered the way the section picked from it. The sorting and filters are in that link, so it works in a new tab or when shared too. Browse is the full table, without the grouping. Installed lists only what is installed, and its tab says how many updates wait.

On the tables, the icon shows a check for what is installed and an arrow for what waits for an update. The search says how many items it looks through. Adding from a GitHub link also got its own button in the toolbar.

Tabs take an optional badge for the update count.

* Find out what is added from a link in the Marketplace

The dialog asks only for the link. What it holds is found out when it is added, and only when that does not settle it, the dialog asks for the type, from what it might be.

* Fix races and lost answers in the Marketplace panel

The add from link dialog could add another link than the one it looked at, a See all link followed a second time kept the filters of the first, Retry did not retry a My link, and a My link that finished late replaced the repository opened meanwhile. A /hacs link from a dashboard showed the Marketplace without its translations. Uninstalling after deleting the last entry could hang when it failed, and a failed removal from the list closed as if it worked. Code inside a quote in a README got issue links.

* Tidy up small things in the Marketplace panel

The README was prepared again for every change of an entity, a screen reader heard the name of a tab but not its badge, and four exports and a type member were left from before.

* Show Marketplace errors in the language of the user and refresh less

Errors from the backend are translated with their key and placeholders in
the panel and when offering to uninstall after deleting an entry, with the
message of Home Assistant as fallback. The repository page reloads after
updating its information, since the list does not carry everything it
shows. Repository signals share one list request, with one more after it
when anything changed meanwhile, and Discover parses each date once.

* Fix the Marketplace strings that showed placeholders and wrong plurals

The add repository question showed {repository} literally, the quotes
around it escaped the placeholder. Seconds, downloads, stars and issues
now say one instead of ones, the status names match across the list and
the repository page, and the GitHub and log messages read the same as
the rest.

* Handle the review of the Marketplace panel

Links to /hacs reach beforeRender again, a route alias was resolved
before it. A refetch on a signal no longer opens the GitHub dialog by
itself, a failed release list is told once, and a failed first load is
translated without logging to the console. Deleting an entry only offers
the uninstall for custom integrations, after the restart and credential
prompts. The YAML resource address carries the version and is encoded.
Applied links mark their history entry instead of listening on the
window, and the last updated column sorts on a number.

* Rewrite Marketplace README addresses once they are rendered

ha-markdown gets an optional rewriteUrl, which runs over every link and
image of the rendered HTML in an inert template, before anything loads.
The README of a repository uses it, so reference links, entities and
unquoted attributes no longer slip through as addresses on Home
Assistant. The regular expressions that set aside links and addresses
for issue references only start where those can start, a README with a
long word or many unclosed links no longer freezes the page.
2026-10-01 11:19:21 -04:00
renovate[bot] e01f5b830f Update dependency @versatiles/style to v6.0.3 (#54509) 2026-10-01 15:45:05 +01:00
Petar Petrov b85cd00cc8 Detect the ESPHome Z-Wave discovery flow by the device MAC (#54513) 2026-10-01 16:44:38 +02:00
Paul Bottein e63fe4d631 Fix area switch for entities without their own name (#54503) 2026-10-01 15:30:53 +02:00
Aidan Timson d24d9d6051 Replace ha-ripple Material base with own implementation (#54508)
* Replace ha-ripple Material base with own implementation

* Remove ha-ripple origin comment

* Restore ha-ripple control setter and CSS zoom handling

* Defer ha-ripple forced-colors check to event handling
2026-10-01 15:10:44 +02:00
Bram Kragten 6c882f47bb Resolve split devices in the logbook card (#54507) 2026-10-01 14:59:12 +02:00
Petar Petrov 3689442f43 Fix double side padding around more-info history and activity (#54511) 2026-10-01 14:16:18 +02:00
Bram KragtenandClaude Opus 5 d19635b6bd Draw the globe against a sky when the map is zoomed out (#54485)
Co-authored-by: Claude Opus 5 <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-01 14:14:40 +02:00
Bram KragtenandClaude Opus 5 6ef7072e08 Add ha/no-unused-element-import lint rule (#54210)
Co-authored-by: Claude Opus 5 <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-01 13:27:03 +02:00
renovate[bot] 36829e0e05 Update dependency browserslist to v4.29.2 (#54504)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-10-01 11:46:51 +02:00
574c89b5fd Update dependency sortablejs to v1.15.7 (#54476)
* Update dependency sortablejs to v1.15.7

* Move sortablejs patch to 1.15.7 and update lockfile

---------

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Petar Petrov <[email protected]>
Co-authored-by: copilot-swe-agent[bot] <[email protected]>
Co-authored-by: timmo001 <[email protected]>
2026-10-01 12:34:39 +03:00
4384336476 Migrate combo box item to new lists (#54453)
* Move pickers onto the new list components

Pickers, the quick bar, the automation element search and the card
picker entity tree now use ha-list-base and its item family instead of
Material Web list items.

- Add a virtual-focus mode to ha-list-base and ha-list-virtualized:
  focus stays in a search field and the list marks the active row.
- Render picker rows as ha-list-item-option, with the current value as
  the selected option.
- Make ha-combo-box-item a presentational row layout on ha-row-item.
- Render ha-picker-field as a native button with the clear button next
  to it.
- Let held arrow keys repeat in pickers and the automation search.
- Fix list items not unregistering after they leave the DOM, and an
  unhandled rejection when a virtualized list is removed mid-layout.

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

* Fix section titles

* fix e2e

* mage entity tree search like picker and automation

* remove comments

* fix picker field aria label

* fix disabled field style selector

* Update src/components/ha-picker-combo-box.ts

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

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Petar Petrov <[email protected]>
2026-10-01 08:46:56 +00:00
Paul Bottein bc5eb1af2d Give zones their own map colors and prefer white zone icons (#54499)
* Give zones their own map color sequence

* Prefer white zone icons when contrast allows
2026-10-01 10:43:10 +02:00
renovate[bot] 6c81951d88 Update pnpm to v12.8.0 (#54502) 2026-10-01 09:23:18 +01:00
renovate[bot]andPetar Petrov 36b31d2c50 Update dependency tinykeys to v4.0.1 (#54479)
* Update dependency tinykeys to v4.0.1

* Move tinykeys patch to 4.0.1 and update lockfile

---------

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Petar Petrov <[email protected]>
2026-10-01 10:06:16 +02:00
Aidan Timson e98710867f Ignore .pnpm-store directory (#54500) 2026-10-01 07:55:01 +00:00
renovate[bot] b5afb05d7b Update dependency @lezer/highlight to v1.2.5 (#54501) 2026-10-01 07:53:56 +00:00
Aidan Timson 3f882c9a9b Type check workflow scripts (#54494)
* Convert workflow scripts to TypeScript

* Type check workflow scripts in lint:types
2026-10-01 08:39:21 +01:00
renovate[bot] 94c7d6e20a Update dependency @versatiles/style to v6.0.2 (#54497)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-10-01 08:25:05 +03:00
Bram KragtenandClaude Opus 5 8acc456dfd Hash the pnpm patches directory in the rspack cache version (#54478)
The persistent cache version still hashed `.yarn/patches`, which moved to
`patches/` in #54392. The lookup is guarded by `existsSync`, so it failed
silently and patch contents stopped contributing to the version at all.

That hash is the only thing that notices a patch change: a patch alters a
package's files but not its version, which is all rspack's node_modules
snapshot checks. Editing a patch therefore left the cache version
identical and the build reused stale compiled modules.

Co-authored-by: Claude Opus 5 <[email protected]>
2026-10-01 08:22:39 +03:00
Tom Mittelstädtand1358 d68a113f4c Give tentative calendar events a striped background (#54483)
* Give tentative calendar events a striped background

A tentative bar gets a light fill in its calendar color with diagonal stripes in the text color, instead of the outline alone. A tentative dot keeps its color and size and gets stripes in the calendar's contrast color. The stripes set a tentative event apart from past days, which are already drawn faded. They repeat on whole pixels, so every stripe looks the same.

* Strengthen tentative dot stripes and add a forced-colors fallback

The stripes on a tentative dot are its only cue, so they now reach at least 3:1 against the dot. In forced colors the stripes and the dot's fill are dropped, so a tentative bar falls back to a dashed border and a tentative dot to a ring of the same size.

---------

Co-authored-by: 1358 <[email protected]>
2026-10-01 08:21:40 +03:00
karwosts 95a4ca587b Improve number selectors in section settings (#54487) 2026-10-01 08:17:13 +03:00
Jan Čermák f5ac90a1f9 Copy locale data to the landing page build (#54488)
The landing page bundle includes the Intl polyfill. When a browser
needs a polyfill, it loads locale data from /static/locale-data/.
The landing page build never copied that directory, so these requests
returned 404. The language picker then showed raw language codes.

Fixes home-assistant/landingpage#216
2026-10-01 08:14:20 +03:00
renovate[bot] 6b060d3082 Update pnpm to v12 (#54486)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-09-30 21:41:00 +02:00
Bram Kragten 2f8bdf4260 Bump saturation of the default light mode map (#54492)
bump saturation of the default light mode map
2026-09-30 19:28:52 +00:00
Paul Bottein ee1e27772f Show people and zones on the demo map (#54496) 2026-09-30 21:19:22 +02:00
Bram Kragten d7f8d25a1d Bumped version to 20260930.0 2026-09-30 16:22:54 +02:00
Bram Kragten 33615bc5dc Merge branch 'rc' into dev 2026-09-30 16:21:00 +02:00
973 changed files with 56249 additions and 14213 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
-38
View File
@@ -1,38 +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.mjs`);
// await checkBlockingLabels({ github, context, core });
export default async function checkBlockingLabels({ context, core }) {
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();
}
}
+289
View File
@@ -0,0 +1,289 @@
#!/usr/bin/env node
// Checks npm registry metadata for the direct dependencies in package.json:
// deprecation, source repository and provenance. On pull requests only newly
// added packages are checked, and a deprecated one fails the check. Scheduled
// and manual runs report on every direct dependency without failing. Invoked
// from the `npm-metadata` job in .github/workflows/dependency-review.yaml via
// actions/github-script:
//
// const { default: checkNpmMetadata } =
// await import(`${process.env.GITHUB_WORKSPACE}/.github/scripts/check-npm-metadata.mts`);
// await checkNpmMetadata({ github, context, core });
import type { Octokit } from "@octokit/rest";
import type {
Context,
Core,
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>;
type Section =
| "dependencies"
| "devDependencies"
| "optionalDependencies"
| "peerDependencies";
type PackageJson = Partial<Record<Section, Record<string, string>>>;
interface Dependency {
name: string;
// The package npm installs, which differs from name for npm: aliases
registryName: string;
version: string;
section: Section;
}
// The fields read from a version document on registry.npmjs.org
interface NpmManifest {
deprecated?: string;
repository?: string | { url?: string };
dist?: { attestations?: object };
}
interface Result {
name: string;
version: string;
section: Section;
skipped?: string;
deprecated?: string;
repository?: string;
provenance?: boolean;
}
const SECTIONS: Section[] = [
"dependencies",
"devDependencies",
"optionalDependencies",
"peerDependencies",
];
const EXACT_VERSION = /^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/;
const NPM_ALIAS = /^npm:((?:@[^/]+\/)?[^@]+)@(.+)$/;
const CONCURRENCY = 8;
// Returns undefined when the registry reports 404, and throws on any other
// failure so a registry outage fails the job instead of skipping the check.
const fetchJson = async <T,>(url: string): Promise<T | undefined> => {
const response = await fetch(url);
if (response.status === 404) {
return undefined;
}
if (!response.ok) {
throw new Error(
`Request to ${url} failed: ${response.status} ${response.statusText}`
);
}
return JSON.parse(await response.text());
};
const readPackageJson = async (
github: Octokit,
context: Context<Payload>,
ref: string
): Promise<PackageJson> => {
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
if (typeof data !== "string") {
throw new Error("Expected raw package.json contents");
}
return JSON.parse(data);
};
const listDependencies = (pkg: PackageJson): Dependency[] =>
SECTIONS.flatMap((section) =>
Object.entries(pkg[section] ?? {}).map(([name, spec]) => {
// npm: aliases install another package, e.g. npm:[email protected]
const alias = spec.match(NPM_ALIAS);
return {
name,
registryName: alias?.[1] ?? name,
version: alias?.[2] ?? spec,
section,
};
})
);
const repositoryUrl = (repository: NpmManifest["repository"]) => {
const url = typeof repository === "string" ? repository : repository?.url;
return url?.replace(/^git\+/, "").replace(/\.git$/, "");
};
const checkPackage = async ({
name,
registryName,
version,
section,
}: Dependency): Promise<Result> => {
const result = {
name: registryName === name ? name : `${name} (npm:${registryName})`,
version,
section,
};
if (!EXACT_VERSION.test(version)) {
return { ...result, skipped: "not an exact version" };
}
const manifest = await fetchJson<NpmManifest>(
`https://registry.npmjs.org/${encodeURIComponent(registryName)}/${version}`
);
if (!manifest) {
return { ...result, skipped: "not found on the npm registry" };
}
return {
...result,
deprecated: manifest.deprecated,
repository: repositoryUrl(manifest.repository),
provenance: Boolean(manifest.dist?.attestations),
};
};
const checkAll = async (dependencies: Dependency[]) => {
const results: Result[] = [];
for (let i = 0; i < dependencies.length; i += CONCURRENCY) {
results.push(
// eslint-disable-next-line no-await-in-loop
...(await Promise.all(
dependencies.slice(i, i + CONCURRENCY).map(checkPackage)
))
);
}
return results;
};
const writeSummary = async (core: Core, heading: string, results: Result[]) => {
await core.summary
.addHeading(heading, 2)
.addTable([
[
"Package",
"Version",
"Section",
"Deprecated",
"Provenance",
"Repository",
].map((data) => ({ data, header: true })),
...results.map((result) =>
result.skipped
? [
result.name,
result.version,
result.section,
`skipped: ${result.skipped}`,
"-",
"-",
]
: [
result.name,
result.version,
result.section,
result.deprecated ? "yes" : "no",
result.provenance ? "yes" : "no",
result.repository ?? "none",
]
),
])
.write();
};
export default async function checkNpmMetadata({
github,
context,
core,
}: GitHubScriptArgs<Payload>) {
const pr = context.payload.pull_request;
if (!pr) {
const pkg = await readPackageJson(github, context, context.sha);
const results = await checkAll(listDependencies(pkg));
const flagged = (result: Result) =>
Number(
Boolean(result.skipped || result.deprecated || !result.repository)
);
results.sort(
(a, b) => flagged(b) - flagged(a) || a.name.localeCompare(b.name)
);
await writeSummary(core, "Direct dependencies", results);
return;
}
const [base, head] = await Promise.all([
readPackageJson(github, context, pr.base.sha),
readPackageJson(github, context, pr.head.sha),
]);
// Compare the packages npm installs, so pointing an npm: alias at another
// package counts as new, but moving a package between sections does not
const existing = new Set(
listDependencies(base).map(({ registryName }) => registryName)
);
const added = listDependencies(head).filter(
({ registryName }) => !existing.has(registryName)
);
if (added.length === 0) {
core.info("No new dependencies");
return;
}
const results = await checkAll(added);
const annotation = { file: "package.json" };
for (const result of results) {
const label = `${result.name}@${result.version}`;
if (result.skipped) {
core.warning(`${label}: ${result.skipped}, not checked`, annotation);
continue;
}
if (result.deprecated) {
core.error(`${label} is deprecated: ${result.deprecated}`, annotation);
}
if (!result.repository) {
core.warning(`${label} has no source repository`, annotation);
}
if (!result.provenance) {
core.notice(`${label} was published without provenance`, annotation);
}
}
await writeSummary(core, "New dependencies", results);
if (results.some((result) => result.deprecated)) {
core.setFailed("New dependencies must not be deprecated on npm");
}
}
@@ -6,14 +6,20 @@
// via actions/github-script:
//
// const { default: checkStandards } =
// await import(`${process.env.GITHUB_WORKSPACE}/.github/scripts/check-pull-request-standards.mjs`);
// await import(`${process.env.GITHUB_WORKSPACE}/.github/scripts/check-pull-request-standards.mts`);
// await checkStandards({ github, context, core });
import type {
GitHubScriptArgs,
PullRequestPayload,
} from "./github-script.d.ts";
import { withRetry } from "./github-retry.mts";
export default async function checkPullRequestStandards({
github,
context,
core,
}) {
}: GitHubScriptArgs<PullRequestPayload>) {
const pr = context.payload.pull_request;
// Exempt bots (Copilot agent, dependabot), drafts, and maintainers.
@@ -26,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) {
@@ -52,11 +60,16 @@ export default async function checkPullRequestStandards({
const normalized = body.toLowerCase();
// Ignore 404s from mutations that race manual edits or cancelled runs.
const ignoreMissing = async (fn) => {
const ignoreMissing = async <T,>(fn: () => Promise<T>) => {
try {
await fn();
} catch (error) {
if (error.status === 404) {
if (
typeof error === "object" &&
error !== null &&
"status" in error &&
error.status === 404
) {
core.info("Target already removed, nothing to do");
return;
}
@@ -65,7 +78,7 @@ export default async function checkPullRequestStandards({
};
// Hide/restore our comment via GraphQL (REST cannot minimize).
const setMinimized = async (subjectId, minimized) => {
const setMinimized = async (subjectId: string, minimized: boolean) => {
const mutation = minimized
? `mutation($id: ID!) {
minimizeComment(input: { subjectId: $id, classifier: RESOLVED }) {
@@ -78,16 +91,18 @@ 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.message}`
`Could not ${minimized ? "minimize" : "restore"} comment: ${error instanceof Error ? error.message : error}`
);
}
};
// Content of a "## <name>" section, or null when the heading is absent.
const section = (name) => {
const section = (name: string) => {
const match = body.match(
new RegExp(`##\\s${name}([\\s\\S]*?)(?=\\n##\\s|$)`, "i")
);
@@ -122,13 +137,16 @@ 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 existing = comments.find((c) => c.body.includes(marker));
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);
if (isValid) {
@@ -136,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) {
@@ -153,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 =
@@ -174,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,
@@ -5,29 +5,35 @@
// actions/github-script:
//
// const { default: checkTaskAuthorization } =
// await import(`${process.env.GITHUB_WORKSPACE}/.github/scripts/check-task-authorization.mjs`);
// await import(`${process.env.GITHUB_WORKSPACE}/.github/scripts/check-task-authorization.mts`);
// 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,
context,
core,
}) {
}: GitHubScriptArgs<IssuePayload>) {
const issueAuthor = context.payload.issue.user.login;
// 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,
@@ -41,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);
});
}
}
}
+61
View File
@@ -0,0 +1,61 @@
// The arguments actions/github-script passes to the scripts in this directory,
// limited to the parts they use.
import type { Octokit } from "@octokit/rest";
export type SummaryTableCell = string | { data: string; header?: boolean };
export interface Summary {
addHeading(text: string, level?: number): Summary;
addRaw(text: string): Summary;
addTable(rows: SummaryTableCell[][]): Summary;
write(): Promise<Summary>;
}
export interface AnnotationProperties {
file?: string;
}
export interface Core {
info(message: string): void;
notice(message: string, properties?: AnnotationProperties): void;
warning(message: string, properties?: AnnotationProperties): void;
error(message: string, properties?: AnnotationProperties): void;
setFailed(message: string): void;
summary: Summary;
}
export interface User {
login: string;
type: string;
}
export interface PullRequestPayload {
pull_request: {
number: number;
body: string | null;
draft: boolean;
user: User;
labels: { name: string }[];
base: { sha: string };
head: { sha: string };
};
}
export interface IssuePayload {
issue: {
user: User;
};
}
export interface Context<Payload> {
sha: string;
repo: { owner: string; repo: string };
issue: { owner: string; repo: string; number: number };
payload: Payload;
}
export interface GitHubScriptArgs<Payload> {
github: Octokit;
context: Context<Payload>;
core: Core;
}
+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();
}
+19
View File
@@ -0,0 +1,19 @@
{
"compilerOptions": {
"target": "ES2024",
"lib": ["ES2024"],
"module": "NodeNext",
"moduleResolution": "NodeNext",
// actions/github-script imports these directly, relying on Node's type stripping
"erasableSyntaxOnly": true,
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"noEmit": true,
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"skipLibCheck": true,
"types": ["node"]
},
"include": ["*.mts", "*.d.ts"]
}
-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.mjs`
);
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
+2 -2
View File
@@ -32,12 +32,12 @@ jobs:
persist-credentials: false
- name: Initialize CodeQL
uses: github/codeql-action/init@1c5b675653bb5c22dbe9b12b556ec555138e09fd # v4.38.1
uses: github/codeql-action/init@2892aa5e19bbd11bc0cff5427e3b750a04d9e3c2 # v4.38.2
with:
languages: javascript-typescript
build-mode: none
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@1c5b675653bb5c22dbe9b12b556ec555138e09fd # v4.38.1
uses: github/codeql-action/analyze@2892aa5e19bbd11bc0cff5427e3b750a04d9e3c2 # v4.38.2
with:
category: "/language:javascript-typescript"
+55
View File
@@ -0,0 +1,55 @@
name: Dependency review
on:
pull_request:
branches:
- dev
paths:
- package.json
- pnpm-lock.yaml
- pnpm-workspace.yaml
schedule:
- cron: "0 6 1 * *" # Monthly report on all direct dependencies
workflow_dispatch:
permissions: {}
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
jobs:
dependency-review:
name: Review dependency changes
if: github.event_name == 'pull_request'
runs-on: ubuntu-latest
permissions:
contents: read # To compare the dependency graph between base and head
steps:
- name: Dependency review
uses: actions/dependency-review-action@a1d282b36b6f3519aa1f3fc636f609c47dddb294 # v5.0.0
with:
license-check: false # Covered by pnpm lint:licenses
fail-on-severity: high
show-openssf-scorecard: true
warn-on-openssf-scorecard-level: 3
npm-metadata:
name: Check npm metadata
runs-on: ubuntu-latest
permissions:
contents: read # To read package.json
steps:
- name: Check out workflow scripts
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
sparse-checkout: .github/scripts
- name: Check npm metadata
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with:
script: |
const { default: checkNpmMetadata } = await import(
`${process.env.GITHUB_WORKSPACE}/.github/scripts/check-npm-metadata.mts`
);
await checkNpmMetadata({ github, context, core });
+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 });
@@ -33,6 +33,6 @@ jobs:
with:
script: |
const { default: checkStandards } = await import(
`${process.env.GITHUB_WORKSPACE}/.github/scripts/check-pull-request-standards.mjs`
`${process.env.GITHUB_WORKSPACE}/.github/scripts/check-pull-request-standards.mts`
);
await checkStandards({ 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 }}
+1 -1
View File
@@ -36,7 +36,7 @@ jobs:
python-version: ${{ env.PYTHON_VERSION }}
- name: Verify version
uses: home-assistant/actions/helpers/verify-version@58bff37c8947f690ace498be413a9b78d6f30f93 # master
uses: home-assistant/actions/helpers/verify-version@06749dd8c0b54f350bc69c8752456cee498808a3 # master
- name: Setup Node and install
uses: ./.github/actions/setup
+18 -7
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
@@ -51,6 +62,6 @@ jobs:
with:
script: |
const { default: checkTaskAuthorization } = await import(
`${process.env.GITHUB_WORKSPACE}/.github/scripts/check-task-authorization.mjs`
`${process.env.GITHUB_WORKSPACE}/.github/scripts/check-task-authorization.mts`
);
await checkTaskAuthorization({ github, context, core });
+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
+1
View File
@@ -14,6 +14,7 @@ dist/
# Node
node_modules/
npm-debug.log
.pnpm-store/
# Python stuff
*.py[cod]
+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
}
}
+10
View File
@@ -0,0 +1,10 @@
// @ts-check
import { noUnusedElementImport } from "./no-unused-element-import.mjs";
export default {
meta: { name: "eslint-plugin-ha" },
rules: {
"no-unused-element-import": noUnusedElementImport,
},
};
@@ -0,0 +1,188 @@
// @ts-check
import fs from "node:fs";
import path from "node:path";
const TAG_PATTERN =
/@customElement\(\s*["'`]([a-z0-9_-]+)["'`]|customElements\.define\(\s*["'`]([a-z0-9_-]+)["'`]/g;
const EXTENSIONS = /\.(ts|js)$/;
// Entry points register the root element that the served HTML renders.
const ENTRY_POINT = /(^|[\\/])(entrypoints[\\/]|entrypoint\.(ts|js)$)/;
// These resolve a tag from config at runtime, so they register every candidate
// they may be asked for.
const RUNTIME_REGISTRY =
/[\\/]create-element[\\/]|createLovelaceElement\(|getLovelaceElementClass\(/;
/** @type {Map<string, string[]>} */
const tagCache = new Map();
/** Tags the module at `file` registers when imported. */
const registeredTags = (file) => {
let tags = tagCache.get(file);
if (!tags) {
try {
const source = fs.readFileSync(file, "utf8");
tags = [...source.matchAll(TAG_PATTERN)].map((m) => m[1] || m[2]);
} catch {
tags = [];
}
tagCache.set(file, tags);
}
return tags;
};
const isFile = (candidate) => {
try {
return fs.statSync(candidate).isFile();
} catch {
return false;
}
};
const resolveModule = (fromFile, request) => {
const base = path.resolve(path.dirname(fromFile), request);
return [
base,
`${base}.ts`,
`${base}.js`,
base.replace(/\.js$/, ".ts"),
path.join(base, "index.ts"),
path.join(base, "index.js"),
].find((candidate) => EXTENSIONS.test(candidate) && isFile(candidate));
};
// `hui-${type}-card` or "state-card-" + domain never spell the tag out.
const buildsTagAtRuntime = (source, tag) => {
const segments = tag.split("-");
for (let end = 1; end < segments.length; end++) {
const prefix = `${segments.slice(0, end).join("-")}-`;
if (
source.includes(`\`${prefix}\${`) ||
source.includes(`"${prefix}" +`) ||
source.includes(`'${prefix}' +`)
) {
return true;
}
}
return false;
};
// Plain substring matching, so <ha-icon-button> counts as a use of ha-icon.
// That direction is deliberate: it misses dead imports rather than asking for
// a removal that breaks registration.
const usesTag = (body, tag) => body.includes(tag);
// A gallery page registers the elements its sibling markdown demos render, so
// that markdown is part of what the module is used by.
const siblingMarkup = (file) => {
const markdown = file.replace(EXTENSIONS, ".markdown");
try {
return fs.readFileSync(markdown, "utf8");
} catch {
return "";
}
};
/** Blanks out the given ranges so an import path cannot count as a usage. */
const withoutRanges = (source, ranges) => {
let result = source;
for (const [start, end] of ranges) {
result =
result.slice(0, start) + " ".repeat(end - start) + result.slice(end);
}
return result;
};
/**
* Side-effect imports register a custom element. ESLint's unused-import rules
* cannot judge them — there is no binding to be unused — so a registration
* outlives the template that needed it and nothing reports the dead import.
*
* Deliberately not fixable: removing the import is only safe once every file
* that renders the element imports it itself, which this rule cannot see.
*
* Off in eslint.config.mjs while a backlog of those paired cases remains;
* `pnpm lint:element-imports` enables it as a warning to list them. Flip it to
* "error" there once the list is empty.
*
* No disable comments while it is off: ESLint reports them as unused
* directives, which fails lint:eslint. Imports that must stay get theirs in
* the change that turns the rule on.
*/
export const noUnusedElementImport = {
meta: {
type: "suggestion",
docs: {
description:
"Disallow side-effect imports of a custom element the importing file never uses",
},
schema: [],
messages: {
unused:
'"{{request}}" only registers <{{tags}}>, which this file never uses. Remove the import, adding it to whichever file renders the element if that file relies on this registration.',
},
},
create(context) {
const filename = context.filename;
if (!EXTENSIONS.test(filename) || ENTRY_POINT.test(filename)) {
return {};
}
return {
Program(program) {
const imports = program.body.filter(
(node) => node.type === "ImportDeclaration"
);
const sideEffectImports = imports.filter(
(node) =>
node.specifiers.length === 0 &&
typeof node.source.value === "string" &&
node.source.value.startsWith(".")
);
if (!sideEffectImports.length) {
return;
}
const source = context.sourceCode.getText();
if (RUNTIME_REGISTRY.test(filename) || RUNTIME_REGISTRY.test(source)) {
return;
}
// Every import declaration, not just the side-effect ones: a module
// path is never a usage, and `import type { HaCard } from "./ha-card"`
// would otherwise pass off its own path as one.
const body =
withoutRanges(
source,
imports.map((node) => node.range)
) + siblingMarkup(filename);
for (const node of sideEffectImports) {
const request = node.source.value;
const target = resolveModule(filename, request);
if (!target) {
continue;
}
// No tags means no registration to judge: styles, polyfills, mocks.
const tags = registeredTags(target);
if (
!tags.length ||
tags.some(
(tag) => usesTag(body, tag) || buildsTagAtRuntime(body, tag)
)
) {
continue;
}
context.report({
node,
messageId: "unused",
data: { request, tags: tags.join(">, <") },
});
}
},
};
},
};
+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) => {
+1 -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/"));
}
@@ -177,6 +173,7 @@ gulp.task("copy-static-landing-page", async () => {
copyFonts(paths.landingPage_output_static);
copyTranslations(paths.landingPage_output_static);
copyLocaleData(paths.landingPage_output_static);
});
gulp.task("copy-static-e2e-test-app", async () => {
+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
@@ -29,7 +29,7 @@ const LICENSE_OVERRIDES = [
// type-fest ships two license files (MIT for code, CC0 for types).
// We use the MIT license since that covers the bundled code.
packageName: "type-fest",
version: "5.8.0",
version: "5.10.0",
licenseFile: "license-mit",
},
];
+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";
+9 -6
View File
@@ -54,9 +54,9 @@ const CONFIG_FILES = [
path.join(__dirname, "babel-plugins", "inline-constants-plugin.cjs"),
];
// Yarn patches change a package's files but not its version, which is all the
// Patches change a package's files but not its version, which is all the
// node_modules snapshot checks, so their contents go into the version too.
const YARN_PATCHES_DIR = path.join(paths.root_dir, ".yarn", "patches");
const PATCHES_DIR = path.join(paths.root_dir, "patches");
// Content hash of the toolchain versions and our own build files, used as the
// persistent cache `version`. Everything here is path-independent so the cache
@@ -69,12 +69,12 @@ const cacheVersion = () => {
...CONFIG_FILES.map(
(file) => `${path.basename(file)}:${fs.readFileSync(file, "utf8")}`
),
...(existsSync(YARN_PATCHES_DIR) ? fs.readdirSync(YARN_PATCHES_DIR) : [])
...(existsSync(PATCHES_DIR) ? fs.readdirSync(PATCHES_DIR) : [])
.filter((file) => file.endsWith(".patch"))
.sort()
.map(
(file) =>
`${file}:${fs.readFileSync(path.join(YARN_PATCHES_DIR, file), "utf8")}`
`${file}:${fs.readFileSync(path.join(PATCHES_DIR, file), "utf8")}`
),
];
return require("crypto")
@@ -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,
@@ -489,7 +492,7 @@ const createRspackConfig = ({
// `name` is already unique per variant (frontend-modern/-legacy).
name,
// Content-based version (node major + toolchain versions + our own
// build files + Yarn patches). Everything is path-independent, so
// build files + patches). Everything is path-independent, so
// the cache stays valid when reused on another machine/checkout.
// Runtime deps are deliberately absent — rspack's node_modules
// snapshot invalidates their modules per-package, so a single
+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;
+52 -12
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,9 +36,11 @@ 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";
import { zoneRegistryEntries } from "./stubs/map";
import { mockMediaPlayer } from "./stubs/media_player";
import { mockPersistentNotification } from "./stubs/persistent_notification";
import { mockRecorder } from "./stubs/recorder";
@@ -57,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",
@@ -66,6 +84,7 @@ const CONFIG_PANEL_COMMANDS = [
"assist_pipeline/",
"config/entity_registry/settings/",
"slugify",
"marketplace/",
...connectivityCommands,
];
@@ -87,22 +106,40 @@ export class HaDemo extends HomeAssistantAppEl {
config: {
...hass.config,
components: [
...(hass.config?.components ?? []),
"backup",
"webhook",
"usage_prediction",
"assist_pipeline",
"hassio",
"hardware",
...connectivityComponents,
// Some automation platforms are loaded already
...new Set([
...(hass.config?.components ?? []),
"backup",
"webhook",
"usage_prediction",
"assist_pipeline",
"hassio",
"hardware",
"marketplace",
...connectivityComponents,
...automationPlatformDomains,
]),
],
},
panels: {
...hass.panels,
// Opened from the settings page, not listed in the sidebar
marketplace: {
component_name: "marketplace",
icon: null,
title: null,
config: null,
url_path: "marketplace",
},
},
});
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);
@@ -137,6 +174,7 @@ export class HaDemo extends HomeAssistantAppEl {
mockFloorRegistry(hass);
mockLabelRegistry(hass);
mockUsagePrediction(hass);
mockEntityRegistryDisplay(hass);
mockEntityRegistry(hass, [
{
config_entry_id: "co2signal",
@@ -181,6 +219,7 @@ export class HaDemo extends HomeAssistantAppEl {
modified_at: 0,
},
...connectivityEntityRegistryEntries,
...zoneRegistryEntries,
]);
hass.addEntities(energyEntities());
@@ -191,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" },
+2
View File
@@ -12,6 +12,7 @@ import { mockDeviceAutomation } from "./device_automation";
import { mockEntityRegistrySettings } from "./entity_registry_settings";
import { mockEntitySources } from "./entity_sources";
import { mockExpose } from "./expose";
import { mockMarketplace } from "./marketplace";
import { mockNetwork } from "./network";
import { mockPerson } from "./person";
import { mockScene } from "./scene";
@@ -45,6 +46,7 @@ export const mockConfigPanel = (hass: MockHomeAssistant) => {
mockAssist(hass);
mockEntityRegistrySettings(hass);
mockSlugify(hass);
mockMarketplace(hass);
hass.mockWS("llm/api/list", () => ({
apis: [
{ id: "assist", name: "Assist" },
+9
View File
@@ -91,6 +91,15 @@ export const demoConfigEntries: {
supports_options: true,
},
},
{
type: "system",
entry: {
...baseEntry,
entry_id: "mock-marketplace",
domain: "marketplace",
title: "Marketplace",
},
},
...connectivityConfigEntries,
];
-50
View File
@@ -1,53 +1,3 @@
export const mapEntities = () =>
Object.values({
"zone.home": {
entity_id: "zone.home",
state: "zoning",
attributes: {
hidden: true,
latitude: 52.3631339,
longitude: 4.8903147,
radius: 200,
friendly_name: "Home",
icon: "hademo:home",
},
},
"zone.uva": {
entity_id: "zone.buckhead",
state: "zoning",
attributes: {
hidden: true,
radius: 400,
friendly_name: "UvA",
icon: "hademo:school",
latitude: 52.3558182,
longitude: 4.9535376,
},
},
"person.arsaboo": {
entity_id: "person.arsaboo",
state: "not_home",
attributes: {
radius: 50,
friendly_name: "Arsaboo",
latitude: 52.3579946,
longitude: 4.8664597,
entity_picture: "/assets/arsaboo/images/arsaboo.jpg",
},
},
"person.melody": {
entity_id: "person.melody",
state: "not_home",
attributes: {
radius: 50,
friendly_name: "Melody",
latitude: 52.3408927,
longitude: 4.8711073,
entity_picture: "/assets/arsaboo/images/melody.jpg",
},
},
});
export const energyEntities = () =>
Object.values({
"sensor.grid_fossil_fuel_percentage": {
+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);
};
+2 -2
View File
@@ -9,7 +9,7 @@ import {
import "../custom-cards/cast-demo-row";
import "../custom-cards/ha-demo-card";
import "../custom-cards/ha-demo-next-card";
import { mapEntities } from "./entities";
import { mockMap } from "./map";
export const mockLovelace = (
hass: MockHomeAssistant,
@@ -17,7 +17,7 @@ export const mockLovelace = (
) => {
hass.mockWS("lovelace/config", ({ url_path }) => {
if (url_path === "map") {
hass.addEntities(mapEntities());
mockMap(hass);
return {
strategy: {
type: "map",
+243
View File
@@ -0,0 +1,243 @@
import type { EntityRegistryEntry } from "../../../src/data/entity/entity_registry";
import type { EntityHistoryState } from "../../../src/data/history";
import type { Zone } from "../../../src/data/zone";
import { demoConfig } from "../../../src/fake_data/demo_config";
import type { EntityInput } from "../../../src/fake_data/entities/types";
import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
const HOUR = 3600 * 1000;
type ZoneData = Omit<Zone, "id">;
const HOME: ZoneData = {
name: "Home",
icon: "mdi:home",
latitude: demoConfig.latitude,
longitude: demoConfig.longitude,
radius: 100,
passive: false,
};
export const zones: Zone[] = [
{
id: "gym",
name: "Gym",
icon: "mdi:dumbbell",
latitude: 52.358,
longitude: 4.8686,
radius: 150,
passive: false,
},
{
id: "school",
name: "School",
icon: "mdi:school",
latitude: 52.3558182,
longitude: 4.9535376,
radius: 250,
passive: false,
},
{
id: "supermarket",
name: "Supermarket",
icon: "mdi:cart",
latitude: 52.3793,
longitude: 4.8994,
radius: 100,
passive: false,
},
{
id: "work",
name: "Work",
icon: "mdi:briefcase",
latitude: 52.3909184,
longitude: 4.8530821,
radius: 200,
passive: false,
},
];
interface DemoPerson {
id: string;
name: string;
picture: string;
latitude: number;
longitude: number;
// Hours ago and the state entered then, oldest first. The first entry is
// the state before the 24 hour activity window, the last one the current
// state.
timeline: [number, string][];
}
const PEOPLE: DemoPerson[] = [
{
id: "arsaboo",
name: "Arsaboo",
picture: "/assets/arsaboo/images/arsaboo.jpg",
latitude: 52.3732,
longitude: 4.8904,
timeline: [
[30, "home"],
[10, "not_home"],
[9.5, "Work"],
[5, "not_home"],
[4.6, "Gym"],
[3.4, "not_home"],
[3.1, "Supermarket"],
[2.8, "not_home"],
[2.4, "home"],
],
},
{
id: "melody",
name: "Melody",
picture: "/assets/arsaboo/images/melody.jpg",
latitude: 52.373,
longitude: 4.8901,
timeline: [
[30, "home"],
[9, "not_home"],
[8.6, "School"],
[4, "not_home"],
[3.6, "home"],
],
},
{
id: "oscar",
name: "Oscar",
picture: "/assets/kernehed/oscar.jpg",
latitude: 52.391,
longitude: 4.8532,
timeline: [
[30, "home"],
[9, "not_home"],
[8.7, "Work"],
[4.5, "not_home"],
[4.2, "Supermarket"],
[3.8, "not_home"],
[3.5, "Work"],
],
},
{
id: "bella",
name: "Bella",
picture: "/assets/kernehed/bella.jpg",
latitude: 52.365,
longitude: 4.8795,
timeline: [
[30, "home"],
[8, "not_home"],
[7.6, "Work"],
[1.2, "not_home"],
[0.9, "Gym"],
[0.3, "not_home"],
],
},
];
const CAR: EntityInput = {
entity_id: "device_tracker.car",
state: "not_home",
attributes: {
friendly_name: "Car",
icon: "mdi:car",
source_type: "gps",
latitude: 52.3805,
longitude: 4.8718,
gps_accuracy: 10,
},
};
const lastMove = (person: DemoPerson) =>
person.timeline[person.timeline.length - 1];
const zoneEntity = (entityId: string, zone: ZoneData): EntityInput => {
// A person's state is "home" for the home zone, the zone name otherwise
const personState = entityId === "zone.home" ? "home" : zone.name;
const persons = PEOPLE.filter(
(person) => lastMove(person)[1] === personState
).map((person) => `person.${person.id}`);
return {
entity_id: entityId,
state: String(persons.length),
attributes: {
latitude: zone.latitude,
longitude: zone.longitude,
radius: zone.radius,
passive: zone.passive,
persons,
editable: true,
icon: zone.icon,
friendly_name: zone.name,
},
};
};
export const zoneEntities = (): EntityInput[] => [
zoneEntity("zone.home", HOME),
...zones.map((zone) => zoneEntity(`zone.${zone.id}`, zone)),
];
// UI zones are in the entity registry, so the zone editor does not also list
// them as YAML zones
export const zoneRegistryEntries: EntityRegistryEntry[] = zones.map((zone) => ({
config_entry_id: null,
config_subentry_id: null,
device_id: null,
area_id: null,
disabled_by: null,
entity_id: `zone.${zone.id}`,
id: `zone.${zone.id}`,
name: null,
icon: null,
labels: [],
categories: {},
platform: "zone",
hidden_by: null,
entity_category: null,
has_entity_name: false,
unique_id: zone.id,
options: null,
created_at: 0,
modified_at: 0,
}));
const personEntity = (person: DemoPerson, now: number): EntityInput => {
const [hoursAgo, state] = lastMove(person);
return {
entity_id: `person.${person.id}`,
state,
last_changed: new Date(now - hoursAgo * HOUR).toISOString(),
attributes: {
friendly_name: person.name,
entity_picture: person.picture,
latitude: person.latitude,
longitude: person.longitude,
gps_accuracy: 20,
},
};
};
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 };
});
export const mockMap = (hass: MockHomeAssistant) => {
const now = Date.now();
hass.addEntities([
...zoneEntities(),
...PEOPLE.map((person) => personEntity(person, now)),
CAR,
]);
hass.mockWS(
"history/history_during_period",
({ entity_ids }: { entity_ids?: string[] }) =>
Object.fromEntries(
PEOPLE.filter(
(person) => !entity_ids || entity_ids.includes(`person.${person.id}`)
).map((person) => [`person.${person.id}`, personHistory(person, now)])
)
);
};
+209
View File
@@ -0,0 +1,209 @@
import type { MarketplaceInfo } from "../../../src/data/marketplace/marketplace";
import type {
MarketplaceRelease,
RepositoryBase,
RepositoryInfo,
} from "../../../src/data/marketplace/repository";
import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
const baseRepository = {
can_install: true,
config_flow: false,
custom: false,
domain: null,
file_name: "",
hide: false,
homeassistant: null,
installed: false,
installed_version: "",
local_path: "",
new: false,
pending_upgrade: false,
status: "default" as const,
};
const repositories: RepositoryBase[] = [
{
...baseRepository,
id: "1",
category: "integration",
full_name: "AlexxIT/WebRTC",
name: "WebRTC Camera",
description: "Watch camera streams with low latency through WebRTC.",
authors: ["@AlexxIT"],
domain: "webrtc",
config_flow: true,
available_version: "v3.6.1",
downloads: 98000,
stars: 1900,
last_updated: "2026-09-12T08:21:00Z",
topics: ["camera", "webrtc"],
},
{
...baseRepository,
id: "2",
category: "integration",
full_name: "basnijholt/adaptive-lighting",
name: "Adaptive Lighting",
description:
"Change the brightness and color temperature of your lights over the day.",
authors: ["@basnijholt"],
domain: "adaptive_lighting",
config_flow: true,
available_version: "v1.26.0",
installed: true,
installed_version: "v1.26.0",
status: "installed",
downloads: 120000,
stars: 2300,
last_updated: "2026-08-30T17:02:00Z",
topics: ["lights", "circadian-rhythm"],
},
{
...baseRepository,
id: "3",
category: "integration",
full_name: "blakeblackshear/frigate-hass-integration",
name: "Frigate",
description: "Use the cameras and events of a Frigate NVR.",
authors: ["@blakeblackshear", "@dermotduffy", "@NickM-27"],
domain: "frigate",
config_flow: true,
available_version: "v5.9.0",
installed: true,
installed_version: "v5.8.0",
pending_upgrade: true,
status: "pending-upgrade",
downloads: 150000,
stars: 900,
last_updated: "2026-09-25T11:45:00Z",
topics: ["camera", "nvr"],
},
{
...baseRepository,
id: "4",
category: "plugin",
full_name: "piitaya/lovelace-mushroom",
name: "Mushroom",
description: "A collection of cards to build a clean dashboard.",
authors: ["@piitaya"],
file_name: "mushroom.js",
available_version: "v5.1.0",
installed: true,
installed_version: "v5.1.0",
status: "installed",
downloads: 310000,
stars: 4400,
last_updated: "2026-09-02T09:10:00Z",
topics: ["cards", "dashboard"],
},
{
...baseRepository,
id: "5",
category: "plugin",
full_name: "thomasloven/lovelace-card-mod",
name: "card-mod",
description: "Add CSS styles to any card of your dashboard.",
authors: ["@thomasloven"],
file_name: "card-mod.js",
available_version: "v4.0.0",
downloads: 280000,
stars: 1500,
last_updated: "2026-07-19T14:33:00Z",
topics: ["css", "dashboard"],
},
{
...baseRepository,
id: "6",
category: "plugin",
full_name: "custom-cards/button-card",
name: "button-card",
description: "A button card that you can change in almost every way.",
authors: ["@RomRider"],
file_name: "button-card.js",
available_version: "v5.0.0",
new: true,
status: "new",
downloads: 240000,
stars: 2100,
last_updated: "2026-09-28T19:05:00Z",
topics: ["button", "cards"],
},
{
...baseRepository,
id: "7",
category: "theme",
full_name: "catppuccin/home-assistant",
name: "Catppuccin",
description: "Soothing pastel themes for Home Assistant.",
authors: ["@catppuccin"],
file_name: "catppuccin.yaml",
available_version: "v2.1.0",
downloads: 45000,
stars: 300,
last_updated: "2026-06-11T07:48:00Z",
topics: ["theme"],
},
];
const repositoryInfo = (repository: RepositoryBase): RepositoryInfo => ({
...repository,
additional_info: `# ${repository.name}\n\n${repository.description}\n\nThis is a demo. Installing is not available.`,
default_branch: "main",
issues: 12,
releases: [repository.available_version],
ref: repository.available_version,
replaces_built_in: false,
selected_tag: null,
update_entity_id: null,
version_or_commit: "version",
});
const info: MarketplaceInfo = {
categories: ["integration", "plugin", "theme"],
disabled_reason: null,
github_connected: false,
has_pending_tasks: false,
lovelace_mode: "storage",
stage: "running",
startup: false,
version: "2.0.0",
warning_accepted: true,
};
// Commands that change the Marketplace are not mocked, so the data never
// changes and no signal fires.
export const mockMarketplace = (hass: MockHomeAssistant) => {
hass.mockWS("marketplace/info", () => info);
hass.mockWS("marketplace/repositories/list", () => repositories);
hass.mockWS(
"marketplace/repository/info",
({ repository_id }: { repository_id: string }) => {
const repository = repositories.find((repo) => repo.id === repository_id);
return repository
? repositoryInfo(repository)
: Promise.reject({ code: "not_found", message: "Unknown repository" });
}
);
hass.mockWS(
"marketplace/repository/releases",
({ repository_id }: { repository_id: string }): MarketplaceRelease[] => {
const repository = repositories.find((repo) => repo.id === repository_id);
return repository
? [
{
tag: repository.available_version,
name: repository.available_version,
published_at: String(repository.last_updated),
prerelease: false,
},
]
: [];
}
);
hass.mockWS("marketplace/subscribe", () => () => undefined);
};
+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 -22
View File
@@ -1,27 +1,7 @@
import type { Zone } from "../../../src/data/zone";
import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
const zones: Zone[] = [
{
id: "home",
name: "Home",
icon: "mdi:home",
latitude: 52.3731339,
longitude: 4.8903147,
radius: 100,
passive: false,
},
{
id: "work",
name: "Work",
icon: "mdi:briefcase",
latitude: 52.3909184,
longitude: 4.8530821,
radius: 200,
passive: false,
},
];
import { zoneEntities, zones } from "./map";
export const mockZone = (hass: MockHomeAssistant) => {
hass.mockWS("zone/list", () => zones);
hass.addEntities(zoneEntities());
};
+7 -1
View File
@@ -12,6 +12,7 @@ import { configs as wcConfigs } from "eslint-plugin-wc";
import { configs as a11yConfigs } from "eslint-plugin-lit-a11y";
import html from "@html-eslint/eslint-plugin";
import importX from "eslint-plugin-import-x";
import ha from "./build-scripts/eslint-rules/index.mjs";
const rspackConfigPath = fileURLToPath(
new URL("./rspack.config.cjs", import.meta.url)
@@ -33,6 +34,7 @@ export default tseslint.config(
{
plugins: {
"unused-imports": unusedImports,
ha,
},
languageOptions: {
@@ -231,6 +233,10 @@ export default tseslint.config(
],
"unused-imports/no-unused-imports": "error",
// Off because the existing backlog would fail lint:eslint's
// --max-warnings=0; run lint:element-imports to see it. Disable
// comments for it would be reported as unused, so none until it is on.
"ha/no-unused-element-import": "off",
"lit/attribute-names": "error",
"lit/attribute-value-entities": "off",
"lit/no-template-map": "off",
@@ -290,7 +296,7 @@ export default tseslint.config(
},
},
{
files: [".github/scripts/*.mjs"],
files: [".github/scripts/*.mts"],
languageOptions: {
globals: globals.node,
},
+3
View File
@@ -40,6 +40,7 @@ export default [
"ha-select-box",
"ha-input",
"ha-textarea",
"ha-picker-combo-box",
],
},
{
@@ -52,6 +53,7 @@ export default [
"ha-control-switch",
"ha-slider",
"ha-control-slider",
"ha-control-scrubber",
"ha-control-circular-slider",
"ha-control-number-buttons",
"ha-control-select",
@@ -101,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);
}
`;
+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
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
@@ -267,6 +276,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 +313,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,7 @@
---
title: Control scrubber
---
A horizontal control where the value sits under a fixed window in the middle and the background strip moves. Drag the strip to change the value, or tap a point of the strip to bring it under the window.
Set `--control-scrubber-track-width` to make the strip wider than the control, so only a part of the range is visible at a time and each pixel of drag changes the value less. Set `wrap` for cyclic ranges such as hue, where the strip repeats and the value wraps around at the bounds. Set `show-handle` to draw the window, and leave it off when there is no current value to point at, such as a light that is off.
@@ -0,0 +1,96 @@
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-control-scrubber";
@customElement("demo-components-ha-control-scrubber")
export class DemoHaControlScrubber extends LitElement {
@state() private hue = 200;
@state() private position?: number;
handleHueChanged(e: CustomEvent) {
this.hue = e.detail.value as number;
}
handleMoved(e: CustomEvent) {
this.position = e.detail.value as number;
}
protected render(): TemplateResult {
return html`
<ha-card>
<div class="card-content">
<p><b>Scrubber values</b></p>
<table>
<tbody>
<tr>
<td>position</td>
<td>${this.position ?? "-"}</td>
</tr>
<tr>
<td>value</td>
<td>${this.hue}</td>
</tr>
</tbody>
</table>
</div>
</ha-card>
<ha-card>
<div class="card-content">
<label id="scrubber-hue">Hue (wrap)</label>
<pre>Config: {"wrap":true,"min":0,"max":360}</pre>
<ha-control-scrubber
wrap
show-handle
min="0"
max="360"
unit="°"
.value=${this.hue}
@value-changed=${this.handleHueChanged}
@slider-moved=${this.handleMoved}
label="Hue"
>
</ha-control-scrubber>
</div>
</ha-card>
`;
}
static styles = css`
ha-card {
max-width: 600px;
margin: 24px auto;
}
pre {
margin-top: 0;
margin-bottom: 8px;
}
p {
margin: 0;
}
label {
font-weight: var(--ha-font-weight-bold);
}
ha-control-scrubber {
--control-scrubber-track-width: 200%;
--control-scrubber-background: linear-gradient(
to right,
hsl(0 100% 50%),
hsl(60 100% 50%),
hsl(120 100% 50%),
hsl(180 100% 50%),
hsl(240 100% 50%),
hsl(300 100% 50%),
hsl(360 100% 50%)
);
}
`;
}
declare global {
interface HTMLElementTagNameMap {
"demo-components-ha-control-scrubber": DemoHaControlScrubber;
}
}
+18 -4
View File
@@ -17,12 +17,18 @@ A styled container with roving-tabindex keyboard navigation. Host role is
Home/End jump to the first/last enabled item; Enter/Space activates the
focused item.
With `virtual-focus`, DOM focus stays outside the list, for example in the
search field of a picker. The list then ignores key presses. The element that
owns the focus calls `moveActiveItem()`, and the list marks the row it points at
with the `active` attribute and scrolls it into view instead of focusing it.
**Attributes**
| Name | Type | Default | Description |
| ------------ | ------- | ------- | ------------------------------ |
| `wrap-focus` | Boolean | `false` | Arrow keys wrap past the ends. |
| `aria-label` | String | — | Accessible name. |
| Name | Type | Default | Description |
| --------------- | ------- | ------- | ---------------------------------------------------------------- |
| `wrap-focus` | Boolean | `false` | Arrow keys wrap past the ends. |
| `virtual-focus` | Boolean | `false` | Keep DOM focus outside the list and mark the active row instead. |
| `aria-label` | String | — | Accessible name. |
**Events**
@@ -36,6 +42,10 @@ focused item.
- `getActiveItemIndex()` — current active index, or `-1`.
- `setActiveItemIndex(index, focusItem?)` — move the active index without
necessarily focusing.
- `moveActiveItem(target)` — move the active row to `"next"`, `"previous"`,
`"first"`, `"last"`, `"next-page"` or `"previous-page"`, skipping rows that
are not interactive or disabled. Meant for `virtual-focus`.
- `clearActiveItem()` — clear the active row.
- `updateListItems()` — re-discover slotted items (called automatically on
slotchange).
@@ -134,6 +144,8 @@ Non-interactive list row. Host role is `listitem`. Inherits everything from
- `interactive` (Boolean, default `false`) — opt this row into the parent
list's roving tabindex. Useful for sortable rows that need keyboard focus
but no click action. Interactive subclasses set this automatically.
- `active` (Boolean) — set by a parent list with `virtual-focus` on the row
that keyboard navigation points at.
**CSS custom properties**
@@ -143,6 +155,8 @@ Non-interactive list row. Host role is `listitem`. Inherits everything from
the focus-in animation.
- `--ha-list-item-focus-offset` — focus outline offset.
- `--ha-list-item-focus-background` — background color on keyboard focus.
- `--ha-list-item-active-background` — background color of the `active` row.
Defaults to `--ha-list-item-focus-background`.
### `<ha-list-item-button>`
@@ -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;
}
}
+119 -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,
@@ -384,6 +384,14 @@ const SCHEMAS: {
selector: { config_entry: {} },
},
duration: { name: "Duration", selector: { duration: {} } },
signed_duration: {
name: "Signed duration",
selector: { duration: { mode: "signed" } },
},
offset_duration: {
name: "Offset",
selector: { duration: { mode: "offset", enable_day: true } },
},
app: { name: "App", selector: { app: {} } },
serial_port: {
name: "Serial port",
@@ -671,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: {
@@ -983,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];
@@ -1031,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}
@@ -1051,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] = {
@@ -1069,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;
}
}
+18
View File
@@ -32,6 +32,8 @@ const ENTITIES = [
attributes: {
friendly_name: "Bed Light",
supported_color_modes: [LightColorMode.HS, LightColorMode.COLOR_TEMP],
color_mode: LightColorMode.HS,
hs_color: [210, 60],
},
},
{
@@ -275,6 +277,22 @@ const CONFIGS = [
features: [{ type: "light-brightness" }],
},
},
{
heading: "Light color feature",
config: {
type: "tile",
entity: "light.bed_light",
features: [{ type: "light-color" }],
},
},
{
heading: "Light color feature with hue and saturation",
config: {
type: "tile",
entity: "light.bed_light",
features: [{ type: "light-color", controls: "hue_saturation" }],
},
},
{
heading: "Light color temperature feature",
config: {
+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;
}
`;
}
+39 -41
View File
@@ -12,8 +12,9 @@
"format:eslint": "eslint \"**/src/**/*.{js,ts,html}\" --cache --cache-strategy=content --cache-location=node_modules/.cache/eslint/.eslintcache --ignore-pattern=.gitignore --fix",
"lint:prettier": "prettier . --cache --check",
"format:prettier": "prettier . --cache --write",
"lint:types": "node ./node_modules/@typescript/native/bin/tsc",
"lint:types": "node ./node_modules/@typescript/native/bin/tsc && node ./node_modules/@typescript/native/bin/tsc -p .github/scripts",
"lint:lit": "lit-analyzer \"{.,*}/src/**/*.ts\"",
"lint:element-imports": "eslint \"**/src/**/*.{js,ts,html}\" --ignore-pattern=.gitignore --rule '{\"ha/no-unused-element-import\": \"warn\"}'",
"lint:licenses": "node --no-deprecation script/check-licenses",
"lint": "pnpm run lint:eslint && pnpm run lint:prettier && pnpm run lint:types && pnpm run lint:lit",
"format": "pnpm run format:eslint && pnpm run format:prettier",
@@ -39,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",
@@ -67,36 +68,33 @@
"@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.4",
"@lezer/highlight": "1.2.5",
"@lit-labs/motion": "1.1.0",
"@lit-labs/observers": "2.1.0",
"@lit-labs/virtualizer": "2.1.1",
"@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.1",
"@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",
@@ -106,19 +104,19 @@
"element-internals-polyfill": "3.0.2",
"fuse.js": "7.5.0",
"hls.js": "1.7.3",
"home-assistant-js-websocket": "9.7.0",
"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",
@@ -126,10 +124,10 @@
"qrcode": "1.5.4",
"roboto-fontface": "0.10.0",
"rrule": "2.8.1",
"sortablejs": "1.15.6",
"sortablejs": "1.15.7",
"stacktrace-js": "2.0.2",
"superstruct": "2.0.2",
"tinykeys": "4.0.0",
"tinykeys": "4.0.1",
"weekstart": "2.0.0",
"workbox-cacheable-response": "7.4.1",
"workbox-core": "7.4.1",
@@ -139,26 +137,25 @@
"workbox-strategies": "7.4.1",
"xss": "1.0.15",
"zrender": "6.1.0",
"zxing-wasm": "3.1.3"
"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",
"@rspack/core": "2.2.7",
"@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",
"@types/chromecast-caf-receiver": "6.0.26",
@@ -170,19 +167,20 @@
"@types/leaflet": "1.9.22",
"@types/leaflet.markercluster": "1.5.6",
"@types/lodash.merge": "4.6.9",
"@types/luxon": "3.7.5",
"@types/luxon": "3.7.6",
"@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.2",
"@vitest/coverage-v8": "5.0.3",
"babel-loader": "10.1.1",
"babel-plugin-polyfill-corejs3": "1.0.0",
"browserslist": "4.29.1",
"browserslist": "4.29.3",
"browserslist-useragent-regexp": "4.1.4",
"core-js-compat": "3.50.0",
"del": "8.0.1",
"eslint": "10.11.0",
"del": "9.0.1",
"eslint": "10.12.0",
"eslint-config-prettier": "10.1.8",
"eslint-import-resolver-webpack": "0.13.11",
"eslint-plugin-import-x": "4.17.1",
@@ -194,13 +192,13 @@
"fs-extra": "11.4.1",
"generate-license-file": "4.2.5",
"glob": "13.0.6",
"globals": "17.12.0",
"globals": "17.13.0",
"gulp": "5.0.1",
"gulp-json-transform": "0.5.0",
"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",
@@ -211,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",
@@ -219,15 +217,15 @@
"terser-webpack-plugin": "5.6.1",
"ts-lit-plugin": "2.0.2",
"typescript": "6.0.3",
"typescript-eslint": "8.70.1",
"vite": "8.3.1",
"typescript-eslint": "8.71.1",
"vite": "8.3.4",
"vite-tsconfig-paths": "6.1.1",
"vitest": "5.0.2",
"vitest": "5.0.3",
"webpack-stats-plugin": "1.1.3",
"webpackbar": "7.0.0",
"workbox-build": "7.4.1"
},
"packageManager": "[email protected]8.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')
File renamed without changes.
File renamed without changes.
+1289 -1089
View File
File diff suppressed because it is too large. Load diff
+6 -3
View File
@@ -3,6 +3,10 @@ autoInstallPeers: false
# Supply-chain protection
minimumReleaseAge: 4320 # 3 days, in minutes
minimumReleaseAgeExclude:
# 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
@@ -26,9 +30,8 @@ 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].6: patches/[email protected].6.patch
[email protected].0: patches/[email protected].0.patch
[email protected].7: patches/[email protected].7.patch
[email protected].1: patches/[email protected].1.patch
[email protected]: patches/[email protected]
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

+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "home-assistant-frontend"
version = "20260826.0"
version = "20260930.0"
license = "Apache-2.0"
license-files = ["LICENSE*"]
description = "The Home Assistant frontend"
+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
}
]
}
Loaded 100 of 973 files, more files were not shown because too many files have changed in this diff. Show more