Compare commits

..

181 Commits

Author SHA1 Message Date
Paul Bottein 97aab24d8a Fix tests 2026-06-19 18:15:28 +02:00
Paul Bottein ee52069939 Fix rendering of negative monetary values 2026-06-19 18:12:26 +02:00
Franck Nijhof 21d8fda76d Mask password values in object selector previews (#52748) 2026-06-19 14:30:57 +02:00
Paul Bottein 49716f4151 Replace until() in icon components with a shared async controller (#52746) 2026-06-19 14:15:54 +02:00
Aidan Timson 657bef6a75 Change dialog enter code to adaptive dialog (#52747) 2026-06-19 14:45:58 +03:00
Franck Nijhof 9edd330728 Fix inverted vertical sliders in RTL languages (#52750)
The control slider flipped its value mapping whenever the document
direction was right-to-left, including for vertical sliders. RTL only
mirrors the horizontal axis, so a vertical slider ended up upside down:
the light brightness and color temperature sliders in the more info
dialog reported the opposite of what they showed (1% gave the brightest
output, 100% the dimmest).

Only mirror for right-to-left when the slider is horizontal.
2026-06-19 14:39:53 +03:00
Petar Petrov 09e83b6450 Omit empty select fields from AI metadata suggestion task (#52749) 2026-06-19 12:40:28 +02:00
Franck Nijhof 9c3f3ed05d Stop icon components leaking memory on every state update (#52743) 2026-06-19 10:36:36 +02:00
Aidan Timson aec6c8c1e4 Effective dirty state, apply to card/badge editor (#52727)
* Effective dirty state

* Effective normalise function for those with defaults not undefined
2026-06-19 11:00:12 +03:00
Franck Nijhof 82f4ae1f08 Return to the device page from the Z-Wave node config view (#52735) 2026-06-19 10:56:21 +03:00
Franck Nijhof 2809091b44 Accept backup uploads by .tar extension, not just MIME type (#52744) 2026-06-19 07:52:05 +00:00
Simon Lamon b2dda0f739 Translate exceptions in hass api calls (#52718) 2026-06-19 07:44:06 +01:00
Franck Nijhof d64845f206 Support a list of entities in the zone trigger editor (#52738) 2026-06-19 07:36:05 +01:00
Franck Nijhof 44d929bf56 Label time trigger and condition days as days of the week (#52737) 2026-06-19 07:33:24 +01:00
Franck Nijhof 56cfff6922 Show real repeat iteration number in trace details (#52736) 2026-06-19 07:32:03 +01:00
Franck Nijhof be8782d928 Include diagnostic battery binary sensors in maintenance view (#52734) 2026-06-19 07:28:41 +01:00
renovate[bot] 2eba8425a7 Update typescript-eslint monorepo to v8.61.1 (#52740)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-06-19 07:23:14 +01:00
renovate[bot] 5ddc26df7a Update formatjs monorepo to v0.10.15 (#52739)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-06-19 07:17:11 +02:00
renovate[bot] 97516f5625 Lock file maintenance (#52741)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-06-19 07:16:44 +02:00
Aidan Timson e8c06b4220 Limit cover/valve card feature width to prevent overflow (#52730)
* Limit cover/valve card feature width to prevent overflow

* Remove comments and unnecessary getter
2026-06-18 17:05:27 +02:00
Paul Bottein 4fd976dc8c List main entities first on the device page (#52728)
* List main entities first on the device page

* Update src/panels/config/devices/device-detail/ha-device-entities-card.ts

Co-authored-by: Aidan Timson <aidan@timmo.dev>

---------

Co-authored-by: Aidan Timson <aidan@timmo.dev>
2026-06-18 14:38:37 +00:00
karwosts f8d8dc4eaa Fix entities timestamp editor (#52729) 2026-06-18 14:14:27 +01:00
Petar Petrov 8ccda740ee Fix date/datetime selectors on the design gallery and align datetime fields (#52726)
* Provide i18n and config contexts to gallery ha-selector demo

* Align date and time fields in datetime selector
2026-06-18 13:25:07 +02:00
Aidan Timson 8528dd8a15 Migrate more info person, sun, weather controls to lazy context (#52706) 2026-06-18 12:26:05 +03:00
Paulus Schoutsen ac2f8ebce3 Add radio frequency panel (#52464)
* Add rf panel

* Tweaks

* Align canShowPage with dev PageNavigation type

* Restore page filter check in canShowPage

* Add transmitters list to radio frequency panel

Show transmitter status and a devices data table (name, type, last used)
with links to the device info page, and use the radio tower domain icon.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* Update src/panels/config/integrations/integration-panels/radio_frequency/radio-frequency-transmitters.ts

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

* adjust comments

* Rename radio frequency transmitters page to devices page

Mirror the infrared panel: rename the transmitters page to a devices
page, source the type column label from the integration's
entity_component name, fetch transmitters in the router and pass them to
both pages, and align user-facing copy to "devices".

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* Handle radio frequency transmitter load errors

Wrap the transmitter fetch in try/catch and surface failures via an
alert dialog instead of leaving an unhandled rejection.

Also drop the duplicate PageNavigation.filter declaration introduced by
the dev merge (it already exists on dev).

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: Petar Petrov <MindFreeze@users.noreply.github.com>
2026-06-18 09:25:38 +00:00
renovate[bot] 1462f65f5a Update yarn monorepo to v4.17.0 (#52725)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-06-18 12:22:58 +03:00
renovate[bot] 3e9d3d90a1 Update vitest monorepo to v4.1.9 (#52724)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-06-18 09:21:56 +00:00
Aidan Timson f28898551b Reword advanced controls to more controls in siren (#52700)
Reword advanced to more in siren
2026-06-18 08:25:34 +03:00
Aidan Timson eabbcf3a95 Migrate more info lock / alarm to lazy context (#52703)
Migrate more info lock / alarm
2026-06-18 08:24:44 +03:00
Aidan Timson 01255cebc6 Remove "advanced" from security options in zwave search (#52702) 2026-06-18 08:24:17 +03:00
Aidan Timson d20e062de9 Migrate more info vaccum / lawn mower controls to lazy context (#52704) 2026-06-18 08:23:28 +03:00
Aidan Timson 835c0fa35c Migrate more info fan and light controls to lazy context (#52705) 2026-06-18 08:22:48 +03:00
Aidan Timson e308272d89 Migrate more info media controls to lazy context (#52707)
* Migrate more info media controls to lazy context

* Remove
2026-06-18 08:21:36 +03:00
renovate[bot] d2ae376058 Update Node.js to v24.17.0 (#52721)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-06-18 05:21:21 +00:00
Paulus Schoutsen fdd57645ee Add infrared panel (#52465)
* Add IR panel

* Tweaks

* Redesign infrared panel: device dashboard + table

Show a Bluetooth-style status card with the count of online IR devices,
linking to a separate data-table page that lists devices grouped from
their proxy entities. Devices expose a type (Emitter, Receiver, or
"Receiver, Emitter") and a "Last used" column derived from the most
recent entity state timestamp.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* Pass localize instead of hass into _data memoizeOne

* Fix Prettier formatting in infrared-devices-page.ts

* Derive infrared devices from registries instead of infrared/list

Drop the infrared/list WebSocket call and compute the device dataset
from the entity/device registries in the dashboard router, passing it
down to the dashboard and devices pages.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* Use infrared entity_component device class names for type labels

* Remove fallback strings from localize calls in infrared devices page

* Fix device class translation

* Cleanup

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-18 07:20:16 +02:00
karwosts 1ef1655a4c Add time_format selector to entities card entity-row-editor (#52715) 2026-06-18 08:18:31 +03:00
Paul Bottein aa1108fc41 Translate list attributes and device class in entity details (#52716) 2026-06-18 08:17:34 +03:00
karwosts e3c6a57080 Add short / long timestamp styles (#52719) 2026-06-18 08:16:34 +03:00
karwosts 1e22649ef8 Unify timestamp state domain lists (#52717) 2026-06-18 07:11:45 +02:00
karwosts 5abd04d09a Convert remaining EntityFeatures to enums (#52720) 2026-06-18 07:10:00 +02:00
karwosts a5bf35690b Add time format to entity badge (#52713) 2026-06-17 20:24:03 +02:00
karwosts d98eb47490 Decode supported features in more-info-details (#52712)
* Decode supported features in more-info-details

* Remove 'Supported features' translation entry
2026-06-17 18:33:18 +02:00
karwosts 738e92d27d Add time_format to tile card (#52450)
* Add time_format to tile card

* Updates

* incorrect type

* Apply suggestions from code review

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

* code review feedback

* handle timestamp=0

---------

Co-authored-by: Paul Bottein <paul.bottein@gmail.com>
2026-06-17 16:04:40 +02:00
Aidan Timson ade2e9272b Reword advanced settings to more options in helpers (#52701) 2026-06-17 15:47:24 +02:00
Simon Lamon d8ce60dfb6 Add icons to live condition test (#52458)
Add icons to live condition
2026-06-17 16:41:15 +03:00
Aidan Timson db9374925e Migrate more info climate (+ related) to lazy context (#52694)
* Migrate more info climate (+ related) to lazy context

* Remove hass
2026-06-17 13:19:46 +00:00
Aidan Timson 1bcd1293c0 Reword "advanced concept" in event trigger/action descriptions (#52699) 2026-06-17 16:07:57 +03:00
Aidan Timson b8cf061ebb Migrate more info datetime (+ related) to lazy context (#52696) 2026-06-17 16:01:01 +03:00
karwosts 6585da9a73 Fix continue_on_timeout toggle defaults in wait script actions (#52691)
Fix continue_on_timeout toggle in wait script actions
2026-06-17 13:26:21 +02:00
Paul Bottein 368df82e97 Redesign the Activity (logbook) as a timeline with entity context (#52498)
* Redesign the Activity (logbook) as a timeline with entity context

* Update color

* Refine logbook timeline layout and entry rendering

- Three layout modes in ha-logbook-entry: wide (entity → state inline),
  compact (entity/state + context/time), inline (state + cause icon + time)
- Entity name bold in wide and compact modes, consistent with tile card
- Cause icon shown inline next to the time in inline (single-entity) mode
- Unavailable state rendered as an empty circle dot
- Flash icon for entity-triggered causes
- "Show more" chevron link in logbook card, device page, and area page
- Extract _renderWide / _renderCompact / _renderInline from render()
- Scope entity-name flex layout to .line1 > .entity-name (compact only)

Co-Authored-By: Paul Bottein <paul.bottein@gmail.com>

* Show cause icon in inline logbook entries

- Show cause icon (user avatar, trigger type, integration brand) next to
  the time in single-entity inline mode
- Use ha-trigger-icon for trigger-platform causes
- Use ha-domain-icon with brand-fallback for integration causes when
  context_domain is available, falling back to mdiPuzzle
- Tooltip with cause name on hover
- Icon size 18px, user avatar 18x18px

Co-Authored-By: Paul Bottein <paul.bottein@gmail.com>

* Adjust cause icon sizes: 18px standalone, 16px inline with text

Co-Authored-By: Paul Bottein <paul.bottein@gmail.com>

* Fix somes issues

* Refine logbook timeline rendering

* Fix logbook dot alignment, header link, and graph colors

* Use deterministic colors for select/input_select in logbook timeline

Assign colors by options list index instead of encounter order so
logbook dots always match the history chart colors, regardless of JS
chunk boundaries.

* Add relative time to logbook entries

Show short relative time alongside absolute in all layouts.
Cause moves to its own third line in compact when icon mode is active.

* Replace dual time display with click-to-toggle in logbook

Clicking any time value toggles between absolute (default) and relative
short format. State lives in the renderer and propagates via Lit
re-render when shouldUpdate allows it.

Date headers now show "Today · June 15" and "Yesterday · June 14"
for recent dates via Intl.RelativeTimeFormat.

* Fix time toggle not updating entries in virtualizer mode

Use @queryAll to directly update showRelative on all visible entries
after toggling, covering the virtualizer case where Lit re-render
alone does not propagate prop changes to already-rendered items.

Also remove the !item guard in _renderRow to fix the RenderItemFunction<T>
type mismatch.

* Refine logbook compact/wide layout and cause display

- Move time column to the right in wide layout
- Right-align time in compact cause row by wrapping cause+trace in meta-main
- Hide cause icon/label for automation and script entries in compact/inline mode (only show trace link)
- Make automation/script entity name always clickable (opens more-info)

* Refactor logbook cause into typed kinds with text phrases

Replace the untyped `iconPath`/`triggerPlatform` fields on `LogbookCause`
with a `kind` discriminator (`user`, `automation`, `script`, `state`,
`scheduled`, `homeassistant`, `integration`).

In timeline layout, causes now render as readable text phrases
("By Paul", "By automation: Mode nuit", "By state change: Porte entrée",
"Scheduled", "Via HomeKit") with a `·` separator before "View trace".
Entity names in those phrases are clickable when an entity id is available.

In list/inline layout, the icon badge uses the kind to pick the right
icon (avatar, robot, script, brand domain, puzzle) — no trigger-type
icon component needed anymore.

* Add show-cause mode to logbook list layout

Add a `show-cause` boolean prop to `ha-logbook-entry`, `ha-logbook-renderer`,
and `ha-logbook` that switches list mode from a compact icon badge to a full
cause phrase on a third line.

The third line uses a fixed-width prefix span and a flex-1 truncatable entity
button so long automation/script/entity names ellipsize cleanly. The trace
link always stays right-aligned on the same line.

Enable the mode in `ha-panel-logbook` so the main activity feed shows full
cause context for every entry.

* Rename logbook model identifiers to match HA conventions and clean up

- Rename resolve*/build* → compute*, kind → type, LogbookWhat → LogbookValue,
  model.what → model.value across model, renderer, and tests
- Merge EntryRenderCtx into LogbookRenderItem (extends LogbookItem) so layout
  methods receive one flat object instead of ctx.model.xxx
- Inline _causeUser, drop dead possibleEntity branch in message formatter
- Remove unused .cause and .cause-name CSS classes; fix padding-block
  inconsistency on timeline content

* Use ha-relative-time in logbook for auto-updating relative times

Replace the static relativeTime() string with <ha-relative-time> so the
displayed time updates every 60 s without a full re-render. Add a format
prop (Intl.RelativeTimeFormatStyle) to ha-relative-time to support the
short style needed by the logbook. Fix text-overflow ellipsis in the time
column by restructuring .time to use align-items: stretch with an inner
.time-content block that owns overflow/ellipsis, and display: contents on
ha-relative-time so its text participates in the parent's inline flow.

Also rename computeLogbookItem's internal param from item to entry to
avoid shadowing the outer item variable.

* Fix automation run value detection and timeline arrow display

User-triggered automation runs had context_user_id set but no source or
context_event_type, so isAutomationRun was false and the raw backend
message "triggered" (lowercase) was shown instead of the localized
"Triggered". Add context_user_id to the isAutomationRun check so all
automation runs get the proper localized value.

Restore the state arrow (→) in the timeline for all value.type === "state"
entries, including automation runs.

* Fix ha-relative-time interval and use textContent

The 60-second auto-update interval was never started when datetime is set
via Lit property binding, because connectedCallback runs before Lit sets
properties. Move the interval start/stop logic into update() watching the
datetime property change instead.

Also replace innerHTML with textContent since the relative time string is
always plain text.

* Remove comments

* Feedback
2026-06-17 13:23:08 +02:00
Aidan Timson 1d99a5dff9 Migrate more info actions to lazy context (#52693)
* Migrate more info actions to lazy context

* Restore file while hass is still needed down the deep chain
2026-06-17 12:23:52 +03:00
Aidan Timson 0ca72b763a Migrate more info toggles to lazy context (#52692) 2026-06-17 08:33:38 +00:00
Aidan Timson 31848a1efd Migrate more info cover + valve to lazy context (#52695) 2026-06-17 11:18:43 +03:00
Aidan Timson c6f79c2093 Add a pull request standards workflow (#52555)
Co-authored-by: Bram Kragten <mail@bramkragten.nl>
2026-06-17 08:38:24 +01:00
chli1 1a5ab1903a Add editable duration to timer more-info dialog (#52682)
Lets you set or change a timer's countdown directly from the more-info dialog via timer.start, including durations beyond the configured maximum.
2026-06-17 08:23:40 +03:00
Paulus Schoutsen a410a53524 Update app layout page (#52689) 2026-06-17 07:12:30 +02:00
karwosts 012889e51d Harden helpers table against bad labels, fix registry editor (#52516)
* Harden helpers table against bad labels, fix registry editor

* Revert "Harden helpers table against bad labels, fix registry editor"

This reverts commit cf15e1da33.

* Don't attempt to render unknown labels
2026-06-17 08:03:51 +03:00
karwosts 3b3788b722 Pin helper buttons to bottom of dialog (#52690) 2026-06-17 07:55:18 +03:00
Aidan Timson 9414bbc6ab Migrate more info update to lazy context (#52686) 2026-06-16 18:52:45 +02:00
Aidan Timson 287aabc9a3 Replace advanced with custom on share folder description (#52684) 2026-06-16 18:49:54 +02:00
Aidan Timson 2d505048c5 Less intimidating secondary text for dev tools (#52685) 2026-06-16 18:48:59 +02:00
Aidan Timson e07cbb9164 Rename Advanced options to More options on restart prompt (#52683) 2026-06-16 18:48:31 +02:00
Petar Petrov 9c56ce6386 Optimize energy devices-detail graph card data generation (#52651) 2026-06-16 12:20:38 +02:00
Petar Petrov 30fd803506 Optimize power sources graph card data generation (#52652)
Co-authored-by: Bram Kragten <mail@bramkragten.nl>
2026-06-16 12:19:45 +02:00
Przemysław Szypowicz 3ed9b7df8d Align scene editor entity names with the entity picker (#52517)
Co-authored-by: Przemysław Szypowicz <2733699+pszypowicz@users.noreply.github.com>
2026-06-16 12:57:57 +03:00
Paul Bottein 1c38d80ab2 Fix flash of unformatted entity states on first load (#52663) 2026-06-16 11:42:39 +03:00
Paul Bottein 1c579e207f Add responsive column layout to device and area config pages (#52643) 2026-06-16 10:30:57 +03:00
dependabot[bot] 1b27445485 Bump vite from 8.0.13 to 8.0.16 (#52662)
* Bump vite from 8.0.13 to 8.0.16

Bumps [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite) from 8.0.13 to 8.0.16.
- [Release notes](https://github.com/vitejs/vite/releases)
- [Changelog](https://github.com/vitejs/vite/blob/main/packages/vite/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite/commits/v8.0.16/packages/vite)

---
updated-dependencies:
- dependency-name: vite
  dependency-version: 8.0.16
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>

* Run yarn dedupe to fix tinyglobby deduplication check

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
2026-06-16 05:34:06 +00:00
pcan08 e977d4a9ec Align integration dashboard grid with design tokens (#52608)
* Align integration dashboard grid with design tokens

Replace hardcoded values with design tokens
Add mobile-safe min() in minmax, and add margin/margin-bottom to match
the apps page container spacing.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* Remove useless margin bottom

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-16 08:27:03 +03:00
Paulus Schoutsen 001a842d2f Add the config panel to the demo (#52666)
* Add config panel with cloud to demo

Enable the config panel in the demo and add a logged-in Home Assistant
Cloud account mock so the cloud panel renders with realistic data
(subscription, remote access, text-to-speech, and webhooks).

https://claude.ai/code/session_01LuAsCpbhKpSKufH9FAHNxh

* Add rich mock data for all config panels in demo

Mock the WebSocket commands behind the remaining config panels so they
render with realistic data instead of erroring: integrations, devices,
entities, helpers, automations, scripts, blueprints, voice assistants,
zones, people, logs, backup, about, network, tags, and application
credentials. Adds coherent demo config entries, devices, and integration
manifests so the integrations and devices dashboards are populated.

* Demo: load brand images from CDN and fix more config panels

- Map brand/hardware images to the public brands.home-assistant.io CDN in
  demo mode, since there is no backend to serve the token-gated brands API.
- Honor the config entries domain filter so the Bluetooth card is no longer
  shown (and Bluetooth, which can't be mocked, stays out of the demo).
- Mock automation/script config and the trigger/condition platform
  subscriptions so the automation editor opens.
- Mock search/related so device, entity, and area pages stop erroring.

https://claude.ai/code/session_01LuAsCpbhKpSKufH9FAHNxh

* Demo: mock remaining config WS commands found by crawling

Crawled deep into every config panel (lists, detail pages, editors, and
dialogs) and mocked the WebSocket/REST commands that were still missing:

- auth/sign_path (log download)
- frontend/get_system_data
- config/entity_registry/get_entries (voice assistant expose)
- device_automation trigger/condition/action list + capabilities (device pages)
- validate_config (wired the existing config stub)
- cloud/alexa/entities and cloud/google_assistant/entities (expose)
- config/scene/config REST endpoint (scene editor)

https://claude.ai/code/session_01LuAsCpbhKpSKufH9FAHNxh

* Demo: code-split config panel mocks into a lazy chunk

The config panel mock data is no longer bundled into the demo's main entry
chunk. A loader is registered eagerly at startup and dynamically imports the
config mocks the first time a config-only WS/REST command is requested (i.e.
when the config panel is opened).

- Add mockLazyLoad(shouldLoad, loader) to the mock connection. On an unmocked
  command/path matching the predicate it awaits the loader (once) and retries,
  so there is no race between panel data fetches and mock registration.
- Move the config-only mocks behind stubs/config-panel.ts, imported lazily.
- Keep manifest/list eager since it is consumed app-wide via the manifests
  context and would otherwise pull in the chunk on the regular dashboard.

https://claude.ai/code/session_01LuAsCpbhKpSKufH9FAHNxh

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-06-16 08:23:51 +03:00
Paulus Schoutsen 473be7f8c8 Allow color config on state-label-badge (#52669)
Forward the color option from StateLabelBadgeConfig through to the
underlying entity badge so legacy state-label-badges can pick a color
and pick up state-based icon coloring like hui-entity-badge.

Co-authored-by: Claude <noreply@anthropic.com>
2026-06-16 08:18:53 +03:00
Tom Carpenter 0d9b257d4e Reverse import/export direction on grid neutrality gauge card (#52658)
Reverse import/export on grid neutrality gauge

Swap so that export is on the left, and import is on the right. This matches the orientation of the grid energy balance card, and means export is the negative side which visually makes more sense.
2026-06-16 08:15:15 +03:00
dependabot[bot] 22786df070 Bump launch-editor from 2.13.2 to 2.14.1 (#52661)
Bumps [launch-editor](https://github.com/vitejs/launch-editor) from 2.13.2 to 2.14.1.
- [Commits](https://github.com/vitejs/launch-editor/compare/v2.13.2...v2.14.1)

---
updated-dependencies:
- dependency-name: launch-editor
  dependency-version: 2.14.1
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-15 21:18:48 +02:00
renovate[bot] e5c849359b Update eslint monorepo to v10.5.0 (#52659)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-06-15 20:09:16 +02:00
Abílio Costa 4bfa4f2816 Add legend filter to energy usage graph card (#52485) 2026-06-15 19:42:49 +02:00
Bram Kragten afd86975d6 Fix date dedupe in statistics chart (#52656) 2026-06-15 18:10:31 +02:00
Petar Petrov 7b1eff9eef Optimize energy gas graph card data generation (#52654) 2026-06-15 16:50:17 +02:00
Petar Petrov 4f0c228756 Optimize energy solar graph card data generation (#52653) 2026-06-15 16:49:55 +02:00
Petar Petrov c86101ac6e Optimize energy data processing (#52648) 2026-06-15 16:42:25 +02:00
Petar Petrov 29fa351b16 Optimize history data processing (#52646) 2026-06-15 16:37:25 +02:00
Petar Petrov 7c67633146 Optimize energy chart line gap filling (#52645) 2026-06-15 16:36:13 +02:00
Petar Petrov 180e23ad9b Optimize statistics chart data generation (#52644) 2026-06-15 16:35:34 +02:00
Franck Nijhof 9e7ddb3e5e Preserve unchanged device, area, and floor registry entries (#52655) 2026-06-15 16:04:40 +02:00
Aidan Timson 4a0e46dc2c Subsections for gallery sidebar (#52640)
Implement sections for gallery sidebar
2026-06-15 16:38:00 +03:00
Franck Nijhof 6af0040e73 Preserve unchanged entity display entries across registry updates (#52641)
* Preserve unchanged entity display entries across registry updates

* Compare all display fields (integration reload can change source-defined ones)

* Use a generic preserveUnchangedRecord helper with deepEqual
2026-06-15 13:35:50 +00:00
Aidan Timson ba58ef6dc2 Update gallery home page content (#52642) 2026-06-15 15:30:34 +03:00
Aidan Timson fafbd7a674 Migrate last set of dialogs to dirty state provider and dialog behavior (#52639) 2026-06-15 15:26:43 +03:00
Aidan Timson 07290a5d7e Migrate 6 dialogs to dirty state provider and dialog behavior (#52637)
Migrate more dialogs to dirty state provider and dialog behavior
2026-06-15 15:23:34 +03:00
Aidan Timson 06141043a7 Migrate registry dialogs to dirty state provider and dialog behavior (#52636) 2026-06-15 15:19:18 +03:00
Aidan Timson 03e4f968b4 Migrate calendar, todo, helper dialogs to dirty state provider and dialog behavior (#52634) 2026-06-15 15:16:25 +03:00
Aidan Timson 17d4f67f69 Migrate matter,zwave,zha dialogs to dirty state provider and dialog behavior (#52633) 2026-06-15 15:12:02 +03:00
Petar Petrov 133a9171bc Add chart data processing optimization harness (#52550)
* Add deterministic fixtures and characterization tests for chart data processing

* Extract statistics chart data processing into a pure function

* Extract state history line chart data processing into a pure function

* Add benchmark suite for chart data processing

* Add chart data optimization playbook

* Point agent instructions at the chart optimization playbook
2026-06-15 12:53:01 +02:00
Franck Nijhof c2adc2b84a Use numeric timestamps instead of Date objects in history line chart data (#52631) 2026-06-15 11:22:57 +03:00
Franck Nijhof 2e3cbf6aab Index tooltip points by series in history line chart (#52630) 2026-06-15 11:16:11 +03:00
Franck Nijhof 82b2a60f32 Index chart legend datasets by id and name for O(1) lookup (#52632) 2026-06-15 11:03:36 +03:00
Franck Nijhof 2eb1811524 Don't mutate shared registry objects in the area page (#52611)
The area page resolved device and entity display names by writing them
back onto the registry entries returned by the (memoized) memberships,
which are the shared objects from hass.devices and the entity registry.
That overwrote each entry's raw name during render and leaked app-wide: an
unnamed device would get name set to the localized "Unnamed device", which
then short-circuits the entity-derived fallback elsewhere, and a device or
entity with no user name would have its name field corrupted in the
settings dialog.

Compute the display names on shallow copies and sort/render those instead,
leaving the shared registry objects untouched. The area page renders the
same names and order as before.
2026-06-15 11:00:31 +03:00
TheJulianJES 04b284159a Fix serial port selector integration domain in options flows (#52626)
* Pass the integration domain to the flow form context

In options flows the flow handler is the config entry id, not the
integration domain. Expose the resolved domain (falling back to the
handler for config flows) on the flow form context so selectors can rely
on the actual domain regardless of the flow type.

* Use the flow domain for serial port recommendations

The serial port selector marked a port as recommended by comparing
matching integrations against `context.handler`. In options flows the
handler is the config entry id, so an integration's own ports were
classified as "not recommended" and labelled "Used by <integration>".
Prefer `context.domain` (with a fallback to `handler`) so the
integration's own ports stay recommended in options flows too.

* Drop the handler fallback for the serial port selector domain

`step-flow-form` is the only context producer that sets `handler`, and it
now always sets `domain` alongside it, so the fallback never resolves to a
different value. In options flows `handler` is the config entry id anyway,
which would be the wrong value to fall back to.
2026-06-15 10:58:38 +03:00
renovate[bot] ddce581fdb Update formatjs monorepo to v7.4.9 (#52625)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-06-15 09:31:35 +02:00
Franck Nijhof 668a7df5cd Use American English spelling for badges behavior label (#52627) 2026-06-15 09:31:08 +02:00
Franck Nijhof d7cad1becd Add rel="noreferrer" to links opening in a new tab (#52628) 2026-06-15 09:30:22 +02:00
Franck Nijhof 1e412ad035 Replace O(n²) entity lookups with Map lookups in history and energy graph (#52629)
Replace O(n²) entity lookups with Map lookups in history merge and energy devices graph
2026-06-15 09:28:59 +02:00
Franck Nijhof 11611cd597 Hide redundant device name in group member list (#52593)
The member entity list in a group entity's more-info dialog always
prefixed each tile with the device name, even when every member belongs to
the same device (for example WLED segments under a single device), where it
adds no information.

Mirror the existing area_name handling: omit the device name when all
members share the same device, and fall back to the entity name so each
tile still has a label.
2026-06-15 09:23:44 +02:00
pcan08 0d545d744b Remove dead diagnostics code from integration card (#52606)
supportsDiagnostics and _diagnosticHandlers were no longer used in the
card template.
2026-06-14 22:03:19 +02:00
Petar Petrov f39dab2de5 Remove misleading "Total exported" line from energy usage tooltip (#52605) 2026-06-14 22:02:54 +02:00
Arsène Reymond 1527117015 fix: font-family for breadcrumb & select-anchor (#52612) 2026-06-14 21:55:32 +02:00
Franck Nijhof 26794560ac Remove unused emptyImageBase64 helper (#52614)
The emptyImageBase64 constant in src/common/empty_image_base64.ts has no
references anywhere in the codebase.
2026-06-14 21:54:45 +02:00
Franck Nijhof 976f9de8ad Remove unused timezone-datalist component (#52615)
The createTimezoneListEl helper in src/components/timezone-datalist.ts has
no references anywhere in the codebase. The google-timezones-json
dependency it used is still imported by other modules, so it is kept.
2026-06-14 21:54:23 +02:00
Franck Nijhof 6810bc5412 Remove unused scrollToTarget helper (#52616)
The default-exported scrollToTarget function in
src/common/dom/scroll-to-target.ts (a legacy copy from
paper-scroll-header-panel) has no references anywhere in the codebase.
2026-06-14 21:53:43 +02:00
Franck Nijhof a4ca54b80b Remove unused loadImg helper (#52617)
The loadImg export in load_resource.ts is not referenced anywhere; drop it
and narrow the internal _load tag type to the used values.
2026-06-14 21:52:59 +02:00
Franck Nijhof 07f0ef0ded Remove unused light helpers (#52619)
lightIsInColorMode and formatTempColor in data/light.ts are not referenced
anywhere in the codebase.
2026-06-14 21:52:25 +02:00
Franck Nijhof cf89bb32ab Remove unused replaceTileLayer helper (#52618)
Remove unused replaceTileLayer and LeafletDrawModuleType

Neither the replaceTileLayer helper nor the LeafletDrawModuleType type in
setup-leaflet-map.ts is referenced anywhere in the codebase.
2026-06-14 21:51:54 +02:00
Franck Nijhof ec5cbd16d8 Add accessible labels to entity ID copy/restore buttons (#52620)
The copy and restore icon buttons next to the entity ID field in the
entity settings dialog had no accessible name. Add descriptive labels
using two new translation keys.
2026-06-14 21:51:05 +02:00
Franck Nijhof 926abd7fc5 Replace Latin "e.g." with plain English in translations (#52621) 2026-06-14 21:50:27 +02:00
Franck Nijhof e227bbe9a2 Add tests for isTimestamp string utility (#52622) 2026-06-14 21:49:46 +02:00
Franck Nijhof f82b0b61e5 Add accessible labels to automation editor icon buttons (#52613)
Icon-only ha-icon-buttons have no accessible name, so screen readers
announce nothing. Add labels (using existing translation keys) to the
conversation trigger's add/remove sentence buttons and the integration
documentation buttons in the trigger and condition platform editors.
2026-06-14 15:21:24 +02:00
renovate[bot] a62c89ee00 Update dependency @rsdoctor/rspack-plugin to v1.5.13 (#52607)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-06-14 11:27:49 +02:00
Arjan 4b6d07134c Developer tools - State tab: allow to hide Device and Area columns (#52552)
* Allow to hide Device and Area columns

* Fix prettier error
2026-06-14 08:16:38 +02:00
dependabot[bot] 35829c301e Bump home-assistant/actions from 868e6cb4607727d764341a158d98872cd63fa658 to e91ad1948e57189485b9c1ad608af0c303946f89 (#52602)
Bump home-assistant/actions

Bumps [home-assistant/actions](https://github.com/home-assistant/actions) from 868e6cb4607727d764341a158d98872cd63fa658 to e91ad1948e57189485b9c1ad608af0c303946f89.
- [Release notes](https://github.com/home-assistant/actions/releases)
- [Commits](https://github.com/home-assistant/actions/compare/868e6cb4607727d764341a158d98872cd63fa658...e91ad1948e57189485b9c1ad608af0c303946f89)

---
updated-dependencies:
- dependency-name: home-assistant/actions
  dependency-version: e91ad1948e57189485b9c1ad608af0c303946f89
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-14 08:13:53 +02:00
dependabot[bot] a73f587591 Bump actions/checkout from 6.0.2 to 6.0.3 (#52603)
Bumps [actions/checkout](https://github.com/actions/checkout) from 6.0.2 to 6.0.3.
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/checkout/compare/de0fac2e4500dabe0009e67214ff5f5447ce83dd...df4cb1c069e1874edd31b4311f1884172cec0e10)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-14 06:10:53 +00:00
dependabot[bot] 2e5f776af7 Bump home-assistant/wheels from 2025.12.0 to 2026.06.0 (#52604)
Bumps [home-assistant/wheels](https://github.com/home-assistant/wheels) from 2025.12.0 to 2026.06.0.
- [Release notes](https://github.com/home-assistant/wheels/releases)
- [Commits](https://github.com/home-assistant/wheels/compare/e5742a69d69f0e274e2689c998900c7d19652c21...34957438948e0b3dcde73c77750643dadae594f5)

---
updated-dependencies:
- dependency-name: home-assistant/wheels
  dependency-version: 2026.06.0
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-14 06:10:38 +00:00
dependabot[bot] e91cffe27c Bump github/codeql-action from 4.36.0 to 4.36.2 (#52601)
Bumps [github/codeql-action](https://github.com/github/codeql-action) from 4.36.0 to 4.36.2.
- [Release notes](https://github.com/github/codeql-action/releases)
- [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md)
- [Commits](https://github.com/github/codeql-action/compare/7211b7c8077ea37d8641b6271f6a365a22a5fbfa...8aad20d150bbac5944a9f9d289da16a4b0d87c1e)

---
updated-dependencies:
- dependency-name: github/codeql-action
  dependency-version: 4.36.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-14 06:10:18 +00:00
Sören Beye adbca5145c Fix energy graph stacking order (#52541) 2026-06-14 08:07:54 +02:00
Franck Nijhof 59d5ded6a5 Use Maps for registry lookups in automations picker (#52591)
The automations picker data builder resolved each automation's entity
registry entry by scanning the whole entity registry with find, and each
label by scanning the label registry, making both lookups scale with the
registry size for every row.

Build entity_id and label_id lookup Maps once and use them, so each row's
lookups are O(1). Behavior is unchanged.
2026-06-14 09:06:44 +03:00
Franck Nijhof daba5dd8be Retain option value type in select form field (#52594)
A config flow field guarded by vol.In([1, 5, 6]) sends numeric option
values, but the underlying select returns the chosen value as a string, and
ha-form-select forwarded that string unchanged. On submit the backend
validated "1" against [1, 5, 6] and rejected it as an invalid selection.

Map the selected value back to its original option in ha-form-select so the
source type is retained. String options are unaffected. The mapping is
extracted into matchSelectOptionValue and covered by unit tests.
2026-06-14 09:05:06 +03:00
Franck Nijhof 1e3e43ba46 Reduce per-entity work when building entity picker items (#52588)
getEntities builds the item list over every entity each time an entity
picker, the quick bar, or a target picker opens. On a large installation
that list construction did avoidable per-entity work:

- the include/exclude entity and domain filters used Array.includes,
  a linear scan per entity (O(entities x filter));
- computeRTL was recomputed for every entity although it only depends on
  the language and translation metadata;
- domainToName (a localize lookup) was called for every entity even though
  domains repeat heavily across entities.

Use Sets for the filters, hoist computeRTL out of the map, and cache the
domain name per domain. This speeds up opening pickers on large installs.
Behavior is unchanged. Add tests for getEntities, which had none.
2026-06-14 08:04:29 +02:00
Franck Nijhof e4cc1eaad2 Avoid mutating the input array in computeCssVariable (#52584)
computeCssVariable built its nested var() fallback chain with
props.reverse(), which reverses the caller's array in place. Every current
caller passes a freshly built array so there is no visible corruption
today, but mutating an input parameter is a latent trap: a shared,
memoized, or frozen array would produce wrong output or throw.

Use reduceRight to build the same chain from last to first without
mutating or copying the array. Output is unchanged. Add tests for
computeCssVariable (including a no-mutation guarantee) and computeCssValue,
which previously had no coverage.
2026-06-14 08:03:11 +02:00
Franck Nijhof 9aa687577f Use a Map for device label lookup in devices dashboard (#52590)
The devices dashboard data builder looked up each device label by scanning
the label registry with find, making it O(devices x labels x labelReg).

Build a label_id to entry Map once and use it for the lookups. Behavior is
unchanged.
2026-06-14 08:02:15 +02:00
Franck Nijhof 2556707370 Use a Set for entity-source lookup in helpers panel (#52587)
While the helpers configuration table is open, willUpdate recomputes the
helper entity list on every state change by filtering all states. The
membership test used Array.includes against the entity-source keys, a
linear scan run for every state, making the filter O(states x sources)
per state update.

Build a Set of the entity-source ids once and use Set.has for O(1)
lookups, dropping the filter to O(states + sources). Behavior is
unchanged.
2026-06-14 08:01:36 +02:00
Franck Nijhof 854c57c0e0 Prevent error logger from crashing on unparseable stack traces (#52575)
When createLogMessage received an error whose stack stacktrace-js could
not parse (for example a DOMException such as "AbortError: Transition was
skipped"), fromError threw "Cannot parse given Error object". That throw
escaped createLogMessage, so the global unhandledrejection handler logged
"Failure writing unhandled promise rejection to system log" and the
original error was never recorded.

Wrap the stacktrace extraction in a try/catch and fall back to the raw
error stack (or the provided stack fallback) so the logger stays robust
and still records the original error.
2026-06-14 09:01:31 +03:00
Franck Nijhof 055076c45e Use singular verb in zone condition summary (#52598)
The zone condition summary joins multiple entities with "or", so "If A or B
are in zone X" is grammatically wrong in English; "or" takes singular
agreement: "If A or B is in zone X".

Make both plural branches render "is" while keeping the numberOfEntities
placeholder, so it stays visible in the source language and translators can
keep a real plural where their grammar needs it.
2026-06-14 07:39:30 +02:00
Franck Nijhof d4ec72006d Use singular verb in numeric state trigger summary (#52592)
The numeric state trigger summary joins multiple entities with "or" but
switched the English verb to plural, reading "If A or B are above X". With
"or" the trigger fires when any one entity crosses the threshold, so English
uses singular agreement: "If A or B is above X".

Make both plural branches render "is" in the three English numeric_state
trigger description strings. The numberOfEntities placeholder is kept (not
replaced by a static "is") so it stays visible in the source language and
translators can keep using plural agreement where their grammar needs it.
The numeric_state condition is unchanged: it joins entities with "and" and
requires all to match.
2026-06-13 21:12:16 +02:00
renovate[bot] 393d6a8a0a Update rspack monorepo to v2.0.8 (#52595)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-06-13 20:42:51 +02:00
Franck Nijhof 4a030884f5 Skip entities table list rebuild on plain state changes (#52586)
While the entities configuration table is open, willUpdate rebuilt the
list of entities without a unique id on every hass update. Because each
state update produces a new states object, the oldHass.states !==
this.hass.states guard was always true, so on every state tick the panel
allocated a Set over all registry entities, iterated every state, and built
StateEntity objects, then discarded the result unless a non-registry entity
had actually been added.

Detect a newly added entity up front and only enter the rebuild when the
set of entity ids could have changed (or a registry, entity-sources, or
exposed-entities dependency changed). A plain state value change on an
existing entity can no longer trigger the rebuild. Behavior is unchanged:
the inner assignment already only ran when an entity was added.
2026-06-13 17:13:09 +02:00
pcan08 f65596cad8 Align apps page search bar style with integrations page (#52581)
Align apps installed search bar style with integrations dashboard

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-13 17:09:53 +02:00
Franck Nijhof 1449c17fd1 Hoist per-call allocations out of computeStateDisplay (#52585)
computeStateToPartsFromEntityAttributes runs for essentially every entity
state that is rendered, but it rebuilt several constants on every call: a
3-element domain array, a 15-element timestamp-domain array (both only used
for an includes() check), and the monetary part-type map.

Move these to module-level Set/object constants (matching the existing
STATE_COLORED_DOMAIN pattern in state_color.ts) and use Set.has(). Behavior
is unchanged; the domain lists are now named and documented.
2026-06-13 17:01:41 +02:00
Franck Nijhof ce0e6a7665 Cache Intl.NumberFormat instances in formatNumber (#52583)
formatNumberToParts, the engine behind formatNumber, built a new
Intl.NumberFormat on every call. It is invoked for essentially every
numeric state render, and constructing an Intl.NumberFormat is
comparatively expensive.

Cache the formatters in a Map keyed by (locale, options). Unlike the
single-entry memoizeOne used for the date formatters, the number format
options are derived per value, so a multi-entry cache is needed to avoid
thrashing. The number of distinct combinations is small and bounded in
practice. Output is unchanged.
2026-06-13 17:01:33 +02:00
Franck Nijhof 460dace974 Speed up bulk selection in data table (#52589)
select() looked up each id with a find over all filtered rows and checked
membership with includes on the growing checked-rows array, making a large
batch selection O(rows x ids) plus O(ids squared).

Build a row lookup Map once and track membership with a Set, dropping the
batch selection to O(rows + ids). Behavior is unchanged.
2026-06-13 16:58:37 +02:00
Franck Nijhof 7111d8a8a8 Auto-select first voice in required TTS voice picker (#52576)
When a voice was required and no value was set, the picker displayed the
first voice in the dropdown but kept its own value undefined and never
fired a value-changed event. As a result, the parent (for example the TTS
test card in the media browser) never learned the voice: the selected
voice id footer stayed hidden and no voice was sent on synthesis. This was
most noticeable for languages with a single available voice, where the
selection could not be changed to force an event.

Auto-select and emit the first voice when one is required and the current
value is missing or no longer valid for the loaded voices, so the value
matches what the dropdown shows. Non-required usages keep clearing the
value as before.
2026-06-13 16:51:57 +02:00
Franck Nijhof b96d1f2809 Center the to-do list reorder drag handle (#52582)
The reorder handle lives in the mwc list item meta slot, which is a fixed
24px tall box. The handle's vertical padding of 16px made the icon 56px
tall, overflowing that box and pushing the drag icon visually below the
row center.

Remove the vertical padding so the 24px icon fits the 24px meta slot and
stays centered. The horizontal padding is kept unchanged.
2026-06-13 16:51:18 +02:00
Franck Nijhof 26bdff9a16 Fix clipboard fallback throwing when active element is in the light DOM (#52578)
When the async Clipboard API is unavailable or rejects, copyToClipboard
falls back to a hidden textarea and execCommand. It appended that textarea
to deepActiveElement()?.getRootNode(). When the deepest active element
lives in the light DOM, getRootNode() returns the Document node, and
appending an element to a Document throws HierarchyRequestError (only the
single documentElement is allowed), so the fallback copy silently failed.

Append to document.body when the resolved root is a Document. Shadow roots
and elements keep holding the textarea directly, preserving execCommand
behavior inside dialogs that trap focus.
2026-06-13 11:14:57 +02:00
TheJulianJES 16ac66c1f8 Fix update entity progress text not showing when version is newer (#52579)
* Show installing status for update entities regardless of state

An install can be in progress while the update entity state is "off"
(e.g. downgrading firmware, where installed_version is newer than
latest_version). The installing check was nested inside the state ===
"on" branch, so these entities incorrectly showed "Up-to-date" instead
of "Installing (xx%)". Hoist the in-progress check above the state
branching so it always takes precedence.

* Show installing icon for update entities regardless of state

When an install is in progress (e.g. downgrading firmware), the entity
state can be "off" while installing. Show the installing icon
(mdi:package-down) whenever an install is in progress, before the
state-based icon selection.

Note: for entities with a brand entity_picture (e.g. Zigbee/ZHA
firmware updates), state-badge hides the icon in favour of the picture,
so this only affects update entities without a brand image.
2026-06-13 09:14:12 +00:00
Aidan Timson 8533dd586b Migrate home panel and profile dialogs to dirty state provider and dialog behaviour (#52559)
Migrate home panel and profile dialogs to dirty state provider and dialog behavior
2026-06-12 21:13:54 +02:00
Jan-Philipp Benecke 2cfb947c9b Migrate ha-config-info to use ha-list-base (#52574) 2026-06-12 19:13:08 +00:00
Aidan Timson 466cf2dfb2 Migrate automation dialogs to dirty state provider and dialog behaviour (#52558)
Migrate automation dialogs to dirty state provider and dialog behavior
2026-06-12 21:10:23 +02:00
Jan-Philipp Benecke 193bcad917 Adjust scrolling container of ha-top-app-bar-fixed pages (#52571)
* Adjust scrolling container of ha-top-app-bar-fixed pages

* Adjust scrolling behavior in ha-drawer

* Revert ha-drawer
2026-06-12 20:56:50 +02:00
Stefan Agner 52d32aec42 Add built-in Matter logo icon (#52568)
Add the Matter symbol as a bundled custom icon, available as
mdi:matter. Like the existing ESPHome and Music Assistant logos, the
SVG path is lazy-loaded and rendered with the foreground color.
2026-06-12 20:53:59 +02:00
renovate[bot] 9adb7215ce Update html-eslint monorepo to v0.62.0 (#52573)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-06-12 20:52:00 +02:00
renovate[bot] 273967fe70 Update dependency prettier to v3.8.4 (#52569)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-06-12 19:20:28 +03:00
renovate[bot] 382e07379b Update CodeMirror (#52567)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-06-12 13:24:05 +02:00
Andreas Schneider 01a8b8d3ef Pass network_only: false when commissioning Matter devices (#52456)
The backend defaults network_only to True, which causes BLE
commissioning to be skipped even when BLE proxies are connected. Passing
false lets the Matter server use BLE when proxies are available while
falling back to network commissioning when they are not.

It is only set to false for the following path:

Settings -> Matter -> Options -> Add manually
2026-06-12 09:33:42 +03:00
renovate[bot] 3bbce5607e Update typescript-eslint monorepo to v8.61.0 (#52562)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-06-11 21:58:53 +02:00
Petar Petrov 7ce052e2a8 Reduce allocations in downSampleLineData (#52551)
Replace the per-point grouping (a Map of arrays of {point, x, y} wrapper
objects, plus a second pass that re-walks each frame and, in mean mode,
two reduce() passes) with a single fixed accumulator per frame:

- mean mode keeps running sumX/sumY/count in arrival order, so the
  floating-point summation order is unchanged
- min/max mode tracks the min and max point incrementally with the same
  strict comparisons (first occurrence wins on ties) and the same
  min-before-max emit ordering

Each point is touched once; ~100k transient objects per large payload are
eliminated. Output is bit-identical. Speedups range from ~1.2x (mid-size)
to ~1.5-1.8x (100k-point) payloads, with substantially lower run-to-run
variance from reduced GC pressure. Payloads below maxDetails still
early-return unchanged.
2026-06-11 19:57:31 +02:00
Petar Petrov e929558a9a Fix clipped descenders in chart legend labels (#52554) 2026-06-11 19:47:16 +02:00
Aidan Timson 9cd4a6937f Migrate backup dialogs to dirty state provider and dialog behavior (#52549) 2026-06-11 17:12:57 +03:00
karwosts af617695b8 Make 'Add Card' more robust to bad yaml (#52556) 2026-06-11 17:08:05 +03:00
Aidan Timson 740ad9eb6b Migrate to dirty state provider for 5 dialogs (#52546)
* Add DirtyStateProviderMixin to dialogs (areas, backup, energy, helpers, person, voice assistant)

Migrate dialogs to DirtyStateProviderMixin for dirty state tracking via
Lit context. These dialogs do not use PreventUnsavedMixin and are fully
independent of the PreventUnsavedMixin contract change.

* Add disabled state

* Fix disable state

* Add check
2026-06-11 17:03:37 +03:00
Aidan Timson caeedc41e3 Migrate second set of dashboard dialogs to dirty state provider (#52538)
* Migrate second set of dashboard dialogs to dirty state provider

* Fix create dialog

* Add DirtyStateProviderMixin to Lovelace raw config editor

* Fix yaml mode check from review

* Fix
2026-06-11 16:46:24 +03:00
Bram Kragten fbb76a8ba0 Filter expired camera/image proxy requests in service worker (#52534)
Pre-validate the credential on camera_proxy, camera_proxy_stream and
image_proxy URLs before letting them hit core. Requests with a missing
or "undefined" token, or with an authSig JWT whose exp has passed, are
short-circuited to a synthetic 401 and never reach the server.

This silences spurious "Login attempt or request with invalid
authentication" warnings from homeassistant.components.http.ban that
fire when the browser replays a stale <img src> after BFCache restore,
tab resume, or a network change. The signed-path TTL is short (30s by
default) and image elements happily hold onto the URL long after that.

Limitations: service workers only run on secure contexts, so this does
not help users on plain http LAN access. A core-side fix to ban.py
that distinguishes expired-but-validly-signed paths from real login
attempts remains the principled fix and covers all clients.
2026-06-11 16:35:32 +03:00
Aidan Timson 3340637ff3 Migrate to dirty state provider for 4 areas (#52545)
* Add DirtyStateProviderMixin to dialogs (config entry, credentials, supervisor apps, voice assistant, zones)

Migrate dialogs to DirtyStateProviderMixin for dirty state tracking via
Lit context. These dialogs do not use PreventUnsavedMixin and are fully
independent of the PreventUnsavedMixin contract change.

* Fix disabled state

* Fix disabled state

* Drop voice assistant pipeline dialog changes (handled in dialogs-b)
2026-06-11 16:21:01 +03:00
Petar Petrov 534bea231c Open more-info from energy pie chart legend, enlarge legend toggle on touch (#52506) 2026-06-11 15:18:46 +02:00
Aidan Timson 8635951394 Migrate user dialogs to use dirty state provider (#52537)
* Migrate user dialogs to use dirty state provider

* Restore original dialog setups
2026-06-11 15:14:26 +03:00
Aidan Timson c46f286cb8 Gate more info "Add to" button to admins (#52547) 2026-06-11 13:12:39 +02:00
Petar Petrov cc6b51d53f Show a single toast with the update name when Update all fails (#52530) 2026-06-11 12:06:47 +01:00
karwosts 6915ca8fdd Remove dead code in hui-timestamp-display (#52544) 2026-06-11 08:40:55 +03:00
Jan-Philipp Benecke 677e53f685 Migrate maintenance panel topbar to ha-top-app-bar-fixed (#52540) 2026-06-11 08:40:08 +03:00
Aidan Timson 46b6ae8d7b Remove isDirty from PreventUnsavedMixin, migrate automation editors to DirtyStateProviderMixin (#52515)
* Remove isDirty from PreventUnsavedMixin, migrate to DirtyStateProviderMixin

Drop the legacy isDirty getter from PreventUnsavedMixin and switch to
isDirtyState (provided by DirtyStateProviderMixin). All consumers that
previously overrode isDirty are migrated to use DirtyStateProviderMixin
with proper dirty tracking:

- AutomationScriptEditorMixin: deep config comparison
- ha-scene-editor: revision counter with shallow comparison
- Blueprint editors + manual editor mixin: consume dirty context

This is the atomic core change — all isDirty overriders are migrated in
the same commit so no compatibility layer is needed.

* Fix dirty state not updated when YAML editor has invalid content

- ha-scene-editor: call _updateDirtyState on invalid YAML to increment
  the revision counter, marking the editor dirty
- ha-script-editor, ha-automation-editor: override isDirtyState to also
  return true when yamlErrors is set, ensuring the PreventUnsavedMixin
  navigation guard fires even when the user has never produced a valid
  intermediate config change

* FIx/migrate editor-toast

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
2026-06-11 08:36:19 +03:00
Aidan Timson 09fda1ca1e Migrate energy dialogs to use dirty state provider (#52535)
* Migrate energy dialogs to use dirty state provider

* Migrate energy grid settings dialog

* Fix baseline for add mode

* Review
2026-06-11 08:28:39 +03:00
Jan-Philipp Benecke 7c1522b975 Migrate hass-error-screen to ha-top-app-bar-fixed (#52543) 2026-06-11 07:06:36 +02:00
Jan-Philipp Benecke d26ad7b354 Migrate hass-loading-screen to ha-top-app-bar-fixed (#52542) 2026-06-11 07:06:26 +02:00
Bram Kragten 66235a4c99 Don't try to load brand images without a token (#52532) 2026-06-10 18:09:35 +02:00
dependabot[bot] 6c02864334 Bump shell-quote from 1.8.3 to 1.8.4 (#52533)
Bumps [shell-quote](https://github.com/ljharb/shell-quote) from 1.8.3 to 1.8.4.
- [Changelog](https://github.com/ljharb/shell-quote/blob/main/CHANGELOG.md)
- [Commits](https://github.com/ljharb/shell-quote/compare/v1.8.3...v1.8.4)

---
updated-dependencies:
- dependency-name: shell-quote
  dependency-version: 1.8.4
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-10 15:15:35 +03:00
Aidan Timson 3471cd103a Add a blocking labels workflow (#52531) 2026-06-10 13:53:51 +02:00
Jan-Philipp Benecke 9ae25d96f2 Move default menu/back button to ha-top-app-bar-fixed (#52444) 2026-06-10 12:11:09 +02:00
Marcin Bauer 02361f2517 Show condition row icon on mobile in visibility editor (#52527)
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 10:30:37 +02:00
Aidan Timson 38055b9244 Migrate vaccum segment mapping to dirty state provider (#52510)
* Migrate vaccum segment mapping to dirty state provider

* Use typed value-changed event in vacuum mapping view

* Use consumer key for vaccum segment mapping instead of new provider

* Completeness

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
2026-06-10 09:49:01 +03:00
Aidan Timson d064127f18 Migrate lovelace editors to dirty state provider (#52509)
* Migrate lovelace editors to dirty state provider

* Type in signature

* Type in signature

* Keep saving
2026-06-10 08:45:11 +03:00
Jan-Philipp Benecke cb2d8db91b Migrate security panel topbar to ha-top-app-bar-fixed (#52519) 2026-06-10 08:33:28 +03:00
Joakim Plate 861d7757cc If redirect url has a trailing question mark our redirect is malformed (#52526) 2026-06-10 08:18:22 +03:00
Bram Kragten 1331ec9e2d Add condition live testing to action conditions too (#52511)
* Add condition live testing to action conditions too

* Update src/panels/config/automation/action/ha-automation-action-row.ts

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

* Apply prettier formatting

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

---------

Co-authored-by: Petar Petrov <MindFreeze@users.noreply.github.com>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-10 08:14:30 +03:00
Bram Kragten 0f81311c76 Fix camera/image proxy URLs sent with token=undefined (#52514) 2026-06-09 15:34:49 +01:00
Aidan Timson 8a85d1cf31 Use typed query param handling in todo and refactor handler typing (#52505)
* Use typed query param handlers for todo

* Refactor to query param config obj

* Remove type casts

* Use main window

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

* Fix import

---------

Co-authored-by: Petar Petrov <MindFreeze@users.noreply.github.com>
2026-06-09 11:40:45 +00:00
527 changed files with 69318 additions and 8354 deletions
+1
View File
@@ -289,6 +289,7 @@ For browser support, API details, and current specifications, refer to these aut
- **Test with Vitest**: Use the established test framework
- **Mock appropriately**: Mock WebSocket connections and API calls
- **Test accessibility**: Ensure components are accessible
- **Optimizing chart data processing**: When optimizing chart data transforms (history, statistics, energy, downsampling), follow the playbook in [`test/benchmarks/README.md`](test/benchmarks/README.md) — it has seeded fixtures, characterization (snapshot) tests that pin current output, and `vitest bench` benchmarks (`yarn test:bench`) for before/after comparison. Optimizations must keep output bit-identical.
## Component Library
+50
View File
@@ -0,0 +1,50 @@
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 for blocking labels
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with:
script: |
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();
}
+2 -2
View File
@@ -24,7 +24,7 @@ jobs:
url: ${{ steps.deploy.outputs.NETLIFY_LIVE_URL || steps.deploy.outputs.NETLIFY_URL }}
steps:
- name: Check out files from GitHub
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
ref: dev
persist-credentials: false
@@ -60,7 +60,7 @@ jobs:
url: ${{ steps.deploy.outputs.NETLIFY_LIVE_URL || steps.deploy.outputs.NETLIFY_URL }}
steps:
- name: Check out files from GitHub
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
ref: master
persist-credentials: false
+3 -3
View File
@@ -27,7 +27,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Check out files from GitHub
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
persist-credentials: false
- name: Setup Node
@@ -65,7 +65,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Check out files from GitHub
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
persist-credentials: false
- name: Setup Node
@@ -85,7 +85,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Check out files from GitHub
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
persist-credentials: false
- name: Setup Node
+4 -4
View File
@@ -27,7 +27,7 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
# We must fetch at least the immediate parents so that if this is
# a pull request then we can checkout the head.
@@ -41,14 +41,14 @@ jobs:
# Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL
uses: github/codeql-action/init@7211b7c8077ea37d8641b6271f6a365a22a5fbfa # v4.36.0
uses: github/codeql-action/init@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2
with:
languages: ${{ matrix.language }}
# Autobuild attempts to build any compiled languages (C/C++, C#, or Java).
# If this step fails, then you should remove it and run the build manually (see below)
- name: Autobuild
uses: github/codeql-action/autobuild@7211b7c8077ea37d8641b6271f6a365a22a5fbfa # v4.36.0
uses: github/codeql-action/autobuild@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2
# ️ Command-line programs to run using the OS shell.
# 📚 https://git.io/JvXDl
@@ -62,4 +62,4 @@ jobs:
# make release
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@7211b7c8077ea37d8641b6271f6a365a22a5fbfa # v4.36.0
uses: github/codeql-action/analyze@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2
+2 -2
View File
@@ -25,7 +25,7 @@ jobs:
url: ${{ steps.deploy.outputs.NETLIFY_LIVE_URL || steps.deploy.outputs.NETLIFY_URL }}
steps:
- name: Check out files from GitHub
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
ref: dev
persist-credentials: false
@@ -61,7 +61,7 @@ jobs:
url: ${{ steps.deploy.outputs.NETLIFY_LIVE_URL || steps.deploy.outputs.NETLIFY_URL }}
steps:
- name: Check out files from GitHub
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
ref: master
persist-credentials: false
+1 -1
View File
@@ -19,7 +19,7 @@ jobs:
url: ${{ steps.deploy.outputs.NETLIFY_LIVE_URL || steps.deploy.outputs.NETLIFY_URL }}
steps:
- name: Check out files from GitHub
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
persist-credentials: false
+1 -1
View File
@@ -24,7 +24,7 @@ jobs:
if: github.repository == 'home-assistant/frontend' && contains(github.event.pull_request.labels.*.name, 'needs design preview')
steps:
- name: Check out files from GitHub
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
persist-credentials: false
+1 -1
View File
@@ -20,7 +20,7 @@ jobs:
contents: write
steps:
- name: Checkout the repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
persist-credentials: false
@@ -0,0 +1,190 @@
name: Pull request standards
on:
pull_request_target: # zizmor: ignore[dangerous-triggers] -- safe: reads PR metadata from event payload only, no PR code checkout
types:
- opened
- edited
- reopened
- ready_for_review
branches:
- dev
permissions: {}
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number }}
cancel-in-progress: true
jobs:
check:
name: Check pull request follows contribution standards
runs-on: ubuntu-latest
permissions:
pull-requests: write # To label and comment on pull requests
steps:
- name: Check pull request standards
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with:
script: |
const pr = context.payload.pull_request;
// Exempt bots (Copilot agent, dependabot), drafts, and maintainers.
if (pr.user.type === "Bot") {
core.info(`Skipping bot author: ${pr.user.login}`);
return;
}
if (pr.draft) {
core.info("Skipping draft pull request");
return;
}
try {
await github.rest.orgs.checkMembershipForUser({
org: "home-assistant",
username: pr.user.login,
});
core.info(`Skipping organization member: ${pr.user.login}`);
return;
} catch (error) {
core.info(`${pr.user.login} is not an organization member, checking standards`);
}
const label = "Needs Template";
const marker = "<!-- pr-standards-check -->";
const { owner, repo } = context.repo;
const issue_number = pr.number;
const body = (pr.body || "").replace(/<!--[\s\S]*?-->/g, "");
const normalized = body.toLowerCase();
// Ignore 404s from mutations that race manual edits or cancelled runs.
const ignoreMissing = async (fn) => {
try {
await fn();
} catch (error) {
if (error.status === 404) {
core.info("Target already removed, nothing to do");
return;
}
throw error;
}
};
// Hide/restore our comment via GraphQL (REST cannot minimize).
const setMinimized = async (subjectId, minimized) => {
const mutation = minimized
? `mutation($id: ID!) {
minimizeComment(input: { subjectId: $id, classifier: RESOLVED }) {
clientMutationId
}
}`
: `mutation($id: ID!) {
unminimizeComment(input: { subjectId: $id }) {
clientMutationId
}
}`;
try {
await github.graphql(mutation, { id: subjectId });
} catch (error) {
core.info(
`Could not ${minimized ? "minimize" : "restore"} comment: ${error.message}`
);
}
};
// Content of a "## <name>" section, or null when the heading is absent.
const section = (name) => {
const match = body.match(
new RegExp(`##\\s${name}([\\s\\S]*?)(?=\\n##\\s|$)`, "i")
);
return match ? match[1] : null;
};
const problems = [];
const requiredHeadings = [
"## proposed change",
"## type of change",
"## checklist",
];
if (requiredHeadings.some((h) => !normalized.includes(h))) {
problems.push(
"Use the pull request template without removing its sections."
);
}
const typeOfChange = section("type of change");
if (typeOfChange !== null) {
const ticked = (typeOfChange.match(/-\s*\[[xX]\]/g) || []).length;
if (ticked !== 1) {
problems.push(
'Select exactly one option under "Type of change".'
);
}
}
const proposedChange = section("proposed change");
if (proposedChange !== null && proposedChange.trim().length === 0) {
problems.push('Describe your changes under "Proposed change".');
}
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 hasLabel = pr.labels.some((l) => l.name === label);
if (isValid) {
core.info("Pull request standards met");
if (hasLabel) {
await ignoreMissing(() =>
github.rest.issues.removeLabel({
owner, repo, issue_number, name: label,
})
);
}
if (existing) {
await setMinimized(existing.node_id, true);
}
return;
}
core.info(`Pull request standards not met:\n- ${problems.join("\n- ")}`);
if (!hasLabel) {
await github.rest.issues.addLabels({
owner, repo, issue_number, labels: [label],
});
}
const message =
`${marker}\n` +
`Hey @${pr.user.login}!\n\n` +
`Thank you for your contribution! To help reviewers, please update ` +
`this pull request to follow our pull request standards:\n\n` +
problems.map((p) => `- ${p}`).join("\n") +
`\n\n` +
`Please complete the ` +
`[PR template](https://github.com/home-assistant/frontend/blob/dev/.github/PULL_REQUEST_TEMPLATE.md?plain=1) ` +
`and see the [developer docs](https://developers.home-assistant.io/docs/review-process) ` +
`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 setMinimized(existing.node_id, false);
} else {
await github.rest.issues.createComment({
owner, repo, issue_number, body: message,
});
}
// Fail this check so it can block the PR from being merged
core.setFailed(
`Pull request standards not met:\n- ${problems.join("\n- ")}`
);
+4 -4
View File
@@ -26,7 +26,7 @@ jobs:
if: github.repository_owner == 'home-assistant'
steps:
- name: Checkout the repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
persist-credentials: false
@@ -36,7 +36,7 @@ jobs:
python-version: ${{ env.PYTHON_VERSION }}
- name: Verify version
uses: home-assistant/actions/helpers/verify-version@868e6cb4607727d764341a158d98872cd63fa658 # master
uses: home-assistant/actions/helpers/verify-version@e91ad1948e57189485b9c1ad608af0c303946f89 # master
- name: Setup Node
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
@@ -97,7 +97,7 @@ jobs:
# home-assistant/wheels doesn't support SHA pinning
- name: Build wheels
uses: home-assistant/wheels@e5742a69d69f0e274e2689c998900c7d19652c21 # 2025.12.0
uses: home-assistant/wheels@34957438948e0b3dcde73c77750643dadae594f5 # 2026.06.0
with:
abi: cp314
tag: musllinux_1_2
@@ -113,7 +113,7 @@ jobs:
contents: write # Required to upload release assets
steps:
- name: Checkout the repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
persist-credentials: false
- name: Setup Node
@@ -0,0 +1,51 @@
name: Sync numeric device classes
# Mirrors Home Assistant Core's numeric `SensorDeviceClass` list into the
# build-time default in src/data/sensor_numeric_device_classes.ts and opens a PR
# when it drifts. Reads homeassistant/generated/sensor.json from core.
on:
workflow_dispatch:
schedule:
- cron: "0 4 * * *" # Daily, 04:00 UTC
permissions:
contents: read
jobs:
sync:
name: Sync
runs-on: ubuntu-latest
permissions:
contents: write
pull-requests: write
steps:
- name: Checkout the repository
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
- name: Set up Node
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version-file: ".nvmrc"
cache: yarn
- name: Install dependencies
run: yarn install --immutable
- name: Regenerate numeric device classes
run: ./script/gen_numeric_device_classes
- name: Format
run: yarn prettier --write src/data/sensor_numeric_device_classes.ts
- name: Create pull request
uses: peter-evans/create-pull-request@5f6978faf089d4d20b00c7766989d076bb2fc7f1 # v8.1.1
with:
branch: chore/sync-numeric-device-classes
commit-message: Update numeric sensor device classes
title: Update numeric sensor device classes
body: |
Regenerated `SENSOR_NUMERIC_DEVICE_CLASSES` from Home Assistant Core's
`SensorDeviceClass`.
Automated by `.github/workflows/sync-numeric-device-classes.yaml`.
+1 -1
View File
@@ -17,7 +17,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout the repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
persist-credentials: false
+1
View File
@@ -58,3 +58,4 @@ test/coverage/
.claude
.cursor
.opencode
test/benchmarks/results/
+1 -1
View File
@@ -1 +1 @@
24.16.0
24.17.0
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -13,4 +13,4 @@ nodeLinker: node-modules
npmMinimalAgeGate: 3d
yarnPath: .yarn/releases/yarn-4.16.0.cjs
yarnPath: .yarn/releases/yarn-4.17.0.cjs
+18 -1
View File
@@ -103,12 +103,29 @@ gulp.task("gather-gallery-pages", async function gatherPages() {
if (!toProcess) {
console.error("Unknown category", group.category);
if (!group.pages) {
if (!group.subsections && !group.pages) {
group.pages = [];
}
continue;
}
if (group.subsections) {
// Listed pages keep their per-subsection order.
for (const subsection of group.subsections) {
for (const page of subsection.pages) {
if (!toProcess.delete(page)) {
console.error("Found unreferenced demo", page);
}
}
}
// Any remaining pages land in a trailing "Other" subsection.
const leftover = Array.from(toProcess).sort();
if (leftover.length) {
group.subsections.push({ header: "Other", pages: leftover });
}
continue;
}
// Any pre-defined groups will not be sorted.
if (group.pages) {
for (const page of group.pages) {
@@ -0,0 +1,40 @@
import { writeFile } from "node:fs/promises";
import { join } from "node:path";
import process from "node:process";
import gulp from "gulp";
import paths from "../paths.cjs";
const SOURCE_URL =
process.env.SENSOR_METADATA_URL ||
"https://raw.githubusercontent.com/home-assistant/core/dev/homeassistant/generated/sensor.json";
const TARGET = join(
paths.root_dir,
"src",
"data",
"sensor_numeric_device_classes.ts"
);
gulp.task("gen-numeric-device-classes", async () => {
const response = await fetch(SOURCE_URL);
if (!response.ok) {
throw new Error(`Failed to fetch ${SOURCE_URL}: ${response.status}`);
}
const data = await response.json();
const classes = [...(data.numeric_device_classes ?? [])].sort();
if (!classes.length) {
throw new Error(`No numeric_device_classes found in ${SOURCE_URL}`);
}
const content = `// This file is auto-generated from Home Assistant Core's \`SensorDeviceClass\`
// (all values minus \`NON_NUMERIC_DEVICE_CLASSES\`). Do not edit by hand.
// Regenerate with \`script/gen_numeric_device_classes\`.
export const SENSOR_NUMERIC_DEVICE_CLASSES: string[] = [
${classes.map((deviceClass) => ` "${deviceClass}",`).join("\n")}
];
`;
await writeFile(TARGET, content);
});
+1
View File
@@ -9,6 +9,7 @@ import "./fetch-nightly-translations.js";
import "./gallery.js";
import "./gather-static.js";
import "./gen-icons-json.js";
import "./gen-numeric-device-classes.js";
import "./landing-page.js";
import "./locale-data.js";
import "./rspack.js";
+38 -3
View File
@@ -8,7 +8,7 @@ import type { HomeAssistant } from "../../src/types";
import { selectedDemoConfig } from "./configs/demo-configs";
import { mockAreaRegistry } from "./stubs/area_registry";
import { mockAuth } from "./stubs/auth";
import { mockConfigEntries } from "./stubs/config_entries";
import { demoDevices } from "./stubs/devices";
import { mockDeviceRegistry } from "./stubs/device_registry";
import { mockEnergy } from "./stubs/energy";
import { energyEntities } from "./stubs/entities";
@@ -16,6 +16,7 @@ import { mockEntityRegistry } from "./stubs/entity_registry";
import { mockEvents } from "./stubs/events";
import { mockFloorRegistry } from "./stubs/floor_registry";
import { mockFrontend } from "./stubs/frontend";
import { mockIntegration } from "./stubs/integration";
import { mockLabelRegistry } from "./stubs/label_registry";
import { mockIcons } from "./stubs/icons";
import { mockHistory } from "./stubs/history";
@@ -29,6 +30,31 @@ import { mockTemplate } from "./stubs/template";
import { mockTodo } from "./stubs/todo";
import { mockTranslations } from "./stubs/translations";
// WS command / REST path prefixes whose mocks live in the lazily imported
// config-panel chunk (see ./stubs/config-panel). Must stay in sync with it.
const CONFIG_PANEL_COMMANDS = [
"cloud/",
"validate_config",
"config_entries/",
"device_automation/",
"entity/source",
"blueprint/",
"homeassistant/expose",
"zone/list",
"person/list",
"network/url",
"application_credentials/",
"system_health/",
"backup/",
"automation/config",
"script/config",
"config/automation/config",
"config/script/config",
"config/scene/config",
"search/related",
"tag/list",
];
@customElement("ha-demo")
export class HaDemo extends HomeAssistantAppEl {
protected async _initializeHass() {
@@ -61,9 +87,18 @@ export class HaDemo extends HomeAssistantAppEl {
mockIcons(hass);
mockEnergy(hass);
mockPersistentNotification(hass);
mockConfigEntries(hass);
// Consumed app-wide via the lazy manifests context, so register eagerly.
mockIntegration(hass);
// Config panel mocks are code-split: the loader runs (and the chunk is
// dynamically imported) the first time one of these config-only WS/REST
// commands is requested, i.e. when the config panel is opened.
hass.mockLazyLoad(
(command) => CONFIG_PANEL_COMMANDS.some((p) => command.startsWith(p)),
() =>
import("./stubs/config-panel").then((mod) => mod.mockConfigPanel(hass))
);
mockAreaRegistry(hass);
mockDeviceRegistry(hass);
mockDeviceRegistry(hass, demoDevices);
mockFloorRegistry(hass);
mockLabelRegistry(hass);
mockEntityRegistry(hass, [
+19
View File
@@ -0,0 +1,19 @@
import type { ApplicationCredential } from "../../../src/data/application_credential";
import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
const credentials: ApplicationCredential[] = [
{
id: "mock-credential",
domain: "spotify",
client_id: "demo-client-id",
client_secret: "demo-client-secret",
name: "Spotify",
},
];
export const mockApplicationCredentials = (hass: MockHomeAssistant) => {
hass.mockWS("application_credentials/list", () => credentials);
hass.mockWS("application_credentials/config", () => ({
integrations: { spotify: { description_placeholders: {} } },
}));
};
+3
View File
@@ -3,4 +3,7 @@ import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
export const mockAuth = (hass: MockHomeAssistant) => {
hass.mockWS("config/auth/list", () => []);
hass.mockWS("auth/refresh_tokens", () => []);
hass.mockWS("auth/sign_path", (msg: { path: string }) => ({
path: msg.path,
}));
};
+69
View File
@@ -0,0 +1,69 @@
import type { AutomationConfig } from "../../../src/data/automation";
import type { ScriptConfig } from "../../../src/data/script";
import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
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 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",
});
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;
}
);
hass.mockWS(
"condition_platforms/subscribe",
(
_msg,
_hass,
onChange?: (descriptions: Record<string, unknown>) => void
) => {
onChange?.({});
return () => undefined;
}
);
};
+83
View File
@@ -0,0 +1,83 @@
import type {
BackupAgentsInfo,
BackupConfig,
BackupContent,
BackupInfo,
} from "../../../src/data/backup";
import { BackupScheduleRecurrence } from "../../../src/data/backup";
import type { ManagerStateEvent } from "../../../src/data/backup_manager";
import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
const lastBackupDate = new Date(Date.now() - 86400000).toISOString();
const nextBackupDate = new Date(Date.now() + 86400000).toISOString();
const backups: BackupContent[] = [
{
backup_id: "demo-backup-1",
name: "Automatic backup DEMO",
date: lastBackupDate,
with_automatic_settings: true,
agents: {
"backup.local": { size: 1024 * 1024 * 512, protected: true },
"cloud.cloud": { size: 1024 * 1024 * 512, protected: true },
},
},
];
const backupInfo: BackupInfo = {
backups,
agent_errors: {},
last_attempted_automatic_backup: lastBackupDate,
last_completed_automatic_backup: lastBackupDate,
last_action_event: { manager_state: "idle" },
next_automatic_backup: nextBackupDate,
next_automatic_backup_additional: false,
state: "idle",
};
const backupConfig: BackupConfig = {
automatic_backups_configured: true,
last_attempted_automatic_backup: lastBackupDate,
last_completed_automatic_backup: lastBackupDate,
next_automatic_backup: nextBackupDate,
next_automatic_backup_additional: false,
create_backup: {
agent_ids: ["backup.local", "cloud.cloud"],
include_addons: [],
include_all_addons: true,
include_database: true,
include_folders: [],
name: null,
password: null,
},
retention: { copies: 3, days: null },
schedule: {
recurrence: BackupScheduleRecurrence.DAILY,
time: null,
days: [],
},
agents: {
"backup.local": { protected: true, retention: null },
"cloud.cloud": { protected: true, retention: null },
},
};
const agentsInfo: BackupAgentsInfo = {
agents: [
{ agent_id: "backup.local", name: "This device" },
{ agent_id: "cloud.cloud", name: "Home Assistant Cloud" },
],
};
export const mockBackup = (hass: MockHomeAssistant) => {
hass.mockWS("backup/info", () => backupInfo);
hass.mockWS("backup/config/info", () => ({ config: backupConfig }));
hass.mockWS("backup/agents/info", () => agentsInfo);
hass.mockWS(
"backup/subscribe_events",
(_msg, _hass, onChange?: (event: ManagerStateEvent) => void) => {
onChange?.({ manager_state: "idle" });
return () => undefined;
}
);
};
+45
View File
@@ -0,0 +1,45 @@
import type { BlueprintDomain, Blueprints } from "../../../src/data/blueprint";
import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
const automationBlueprints: Blueprints = {
"homeassistant/motion_light.yaml": {
metadata: {
domain: "automation",
name: "Motion-activated Light",
description: "Turn on a light when motion is detected.",
author: "Home Assistant",
source_url:
"https://github.com/home-assistant/core/blob/dev/homeassistant/components/automation/blueprints/motion_light.yaml",
input: {
motion_entity: { name: "Motion Sensor" },
light_target: { name: "Light" },
},
},
},
"homeassistant/notify_leaving_zone.yaml": {
metadata: {
domain: "automation",
name: "Send notification when leaving a zone",
description: "Get a notification when a person leaves a zone.",
author: "Home Assistant",
},
},
};
const scriptBlueprints: Blueprints = {
"homeassistant/confirmable_notification.yaml": {
metadata: {
domain: "script",
name: "Confirmable Notification",
description:
"A script that sends an actionable notification with a confirmation.",
author: "Home Assistant",
},
},
};
export const mockBlueprint = (hass: MockHomeAssistant) => {
hass.mockWS("blueprint/list", (msg: { domain: BlueprintDomain }) =>
msg.domain === "script" ? scriptBlueprints : automationBlueprints
);
};
+118
View File
@@ -0,0 +1,118 @@
import type {
CloudStatusLoggedIn,
SubscriptionInfo,
} from "../../../src/data/cloud";
import type { CloudTTSInfo } from "../../../src/data/cloud/tts";
import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
const emptyFilter = () => ({
include_domains: [],
include_entities: [],
exclude_domains: [],
exclude_entities: [],
});
// A single mutable status object so that preference changes made in the demo
// are reflected back in the UI.
const cloudStatus: CloudStatusLoggedIn = {
logged_in: true,
cloud: "connected",
cloud_last_disconnect_reason: null,
email: "demo@home-assistant.io",
google_registered: true,
google_entities: emptyFilter(),
google_domains: ["light", "switch", "climate", "cover"],
alexa_registered: true,
alexa_entities: emptyFilter(),
remote_domain: "demo-instance.ui.nabu.casa",
remote_connected: true,
remote_certificate: {
common_name: "demo-instance.ui.nabu.casa",
expire_date: "2099-01-01T00:00:00+00:00",
fingerprint: "demodemodemodemodemodemodemodemodemodemodemodemodemo",
alternative_names: ["demo-instance.ui.nabu.casa"],
},
remote_certificate_status: "ready",
http_use_ssl: false,
active_subscription: true,
prefs: {
google_enabled: true,
alexa_enabled: true,
remote_enabled: true,
remote_allow_remote_enable: true,
strict_connection: "disabled",
google_secure_devices_pin: undefined,
cloudhooks: {},
alexa_report_state: true,
google_report_state: true,
tts_default_voice: ["en-US", "JennyNeural"],
cloud_ice_servers_enabled: true,
},
};
const subscription: SubscriptionInfo = {
human_description: "Demo subscription, renews automatically",
provider: "Nabu Casa, Inc.",
plan_renewal_date: 4102444800,
};
const ttsInfo: CloudTTSInfo = {
languages: [
["en-US", "JennyNeural", "Jenny"],
["en-US", "GuyNeural", "Guy"],
["en-GB", "LibbyNeural", "Libby"],
["nl-NL", "ColetteNeural", "Colette"],
["de-DE", "KatjaNeural", "Katja"],
],
};
export const mockCloud = (hass: MockHomeAssistant) => {
hass.mockWS("cloud/status", () => cloudStatus);
hass.mockWS("cloud/subscription", () => subscription);
hass.mockWS("cloud/tts/info", () => ttsInfo);
hass.mockWS("cloud/update_prefs", (msg) => {
const { type, ...prefs } = msg;
cloudStatus.prefs = { ...cloudStatus.prefs, ...prefs };
return { success: true };
});
hass.mockWS("cloud/cloudhook/create", (msg) => {
const webhook = {
webhook_id: msg.webhook_id,
cloudhook_id: "demo-cloudhook-id",
cloudhook_url: `https://hooks.nabu.casa/demo-${msg.webhook_id}`,
managed: false,
};
cloudStatus.prefs.cloudhooks = {
...cloudStatus.prefs.cloudhooks,
[msg.webhook_id]: webhook,
};
return webhook;
});
hass.mockWS("cloud/cloudhook/delete", (msg) => {
const cloudhooks = { ...cloudStatus.prefs.cloudhooks };
delete cloudhooks[msg.webhook_id];
cloudStatus.prefs.cloudhooks = cloudhooks;
return null;
});
hass.mockWS("cloud/remote/connect", () => {
cloudStatus.remote_connected = true;
return null;
});
hass.mockWS("cloud/remote/disconnect", () => {
cloudStatus.remote_connected = false;
return null;
});
hass.mockWS("cloud/remove_data", () => null);
hass.mockWS("cloud/google_assistant/entities/update", () => null);
hass.mockWS("cloud/alexa/entities", () => []);
hass.mockWS("cloud/google_assistant/entities", () => []);
hass.mockAPI("cloud/logout", () => ({}));
hass.mockAPI("cloud/google_actions/sync", () => ({}));
hass.mockAPI("cloud/support_package", () => "Demo support package");
};
+40
View File
@@ -0,0 +1,40 @@
import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
import { mockApplicationCredentials } from "./application_credentials";
import { mockAutomation } from "./automation";
import { mockBackup } from "./backup";
import { mockBlueprint } from "./blueprint";
import { mockCloud } from "./cloud";
import { mockConfig } from "./config";
import { mockConfigEntries } from "./config_entries";
import { mockDeviceAutomation } from "./device_automation";
import { mockEntitySources } from "./entity_sources";
import { mockExpose } from "./expose";
import { mockNetwork } from "./network";
import { mockPerson } from "./person";
import { mockScene } from "./scene";
import { mockSearch } from "./search";
import { mockSystemHealth } from "./system_health";
import { mockTags } from "./tags";
import { mockZone } from "./zone";
// Registers every mock that is only needed once the config panel is opened.
// This module is dynamically imported so its data stays out of the main bundle.
export const mockConfigPanel = (hass: MockHomeAssistant) => {
mockCloud(hass);
mockConfig(hass);
mockConfigEntries(hass);
mockDeviceAutomation(hass);
mockEntitySources(hass);
mockBlueprint(hass);
mockExpose(hass);
mockZone(hass);
mockPerson(hass);
mockNetwork(hass);
mockApplicationCredentials(hass);
mockSystemHealth(hass);
mockBackup(hass);
mockAutomation(hass);
mockScene(hass);
mockSearch(hass);
mockTags(hass);
};
+120 -20
View File
@@ -1,26 +1,126 @@
import type { getConfigEntries } from "../../../src/data/config_entries";
import type {
ConfigEntry,
ConfigEntryUpdate,
} from "../../../src/data/config_entries";
import type { ConfigFlowInProgressMessage } from "../../../src/data/config_flow";
import type { IntegrationType } from "../../../src/data/integration";
import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
export const mockConfigEntries = (hass: MockHomeAssistant) => {
hass.mockWS<typeof getConfigEntries>("config_entries/get", () => [
{
entry_id: "mock-entry-co2signal",
const baseEntry = {
source: "user",
state: "loaded" as const,
supports_options: false,
supports_remove_device: false,
supports_unload: true,
supports_reconfigure: true,
supported_subentry_types: {},
num_subentries: 0,
pref_disable_new_entities: false,
pref_disable_polling: false,
disabled_by: null,
reason: null,
error_reason_translation_key: null,
error_reason_translation_placeholders: null,
};
// Each entry is tagged with its integration type so we can honor the
// `type_filter` that the integrations and helpers panels subscribe with.
export const demoConfigEntries: {
entry: ConfigEntry;
type: IntegrationType;
}[] = [
{
type: "service",
entry: {
...baseEntry,
entry_id: "co2signal",
domain: "co2signal",
title: "Electricity Maps",
source: "user",
state: "loaded",
supports_options: false,
supports_remove_device: false,
supports_unload: true,
supports_reconfigure: true,
supported_subentry_types: {},
pref_disable_new_entities: false,
pref_disable_polling: false,
disabled_by: null,
reason: null,
num_subentries: 0,
error_reason_translation_key: null,
error_reason_translation_placeholders: null,
},
]);
},
{
type: "hub",
entry: {
...baseEntry,
entry_id: "mock-hue",
domain: "hue",
title: "Philips Hue",
source: "zeroconf",
supports_options: true,
supports_remove_device: true,
},
},
{
type: "hub",
entry: {
...baseEntry,
entry_id: "mock-sonos",
domain: "sonos",
title: "Sonos",
source: "zeroconf",
supports_options: true,
},
},
{
type: "service",
entry: {
...baseEntry,
entry_id: "mock-met",
domain: "met",
title: "Forecast.Home",
},
},
{
type: "helper",
entry: {
...baseEntry,
entry_id: "mock-template-helper",
domain: "template",
title: "Comfort level",
},
},
];
const filterEntries = (filters?: {
type_filter?: IntegrationType[];
domain?: string;
}): ConfigEntry[] =>
demoConfigEntries
.filter(
(e) =>
(!filters?.type_filter || filters.type_filter.includes(e.type)) &&
(!filters?.domain || filters.domain === e.entry.domain)
)
.map((e) => e.entry);
export const mockConfigEntries = (hass: MockHomeAssistant) => {
hass.mockWS(
"config_entries/get",
(msg: { type_filter?: IntegrationType[]; domain?: string }) =>
filterEntries(msg)
);
hass.mockWS(
"config_entries/subscribe",
(
msg: { type_filter?: IntegrationType[]; domain?: string },
_hass,
onChange?: (updates: ConfigEntryUpdate[]) => void
) => {
onChange?.(filterEntries(msg).map((entry) => ({ type: null, entry })));
return () => undefined;
}
);
hass.mockWS(
"config_entries/flow/subscribe",
(
_msg,
_hass,
onChange?: (updates: ConfigFlowInProgressMessage[]) => void
) => {
onChange?.([]);
return () => undefined;
}
);
};
+18
View File
@@ -0,0 +1,18 @@
import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
// The demo's devices don't expose device-specific automations, so report empty
// lists and no extra capability fields for the device automation pickers.
export const mockDeviceAutomation = (hass: MockHomeAssistant) => {
hass.mockWS("device_automation/trigger/list", () => []);
hass.mockWS("device_automation/condition/list", () => []);
hass.mockWS("device_automation/action/list", () => []);
hass.mockWS("device_automation/trigger/capabilities", () => ({
extra_fields: [],
}));
hass.mockWS("device_automation/condition/capabilities", () => ({
extra_fields: [],
}));
hass.mockWS("device_automation/action/capabilities", () => ({
extra_fields: [],
}));
};
+53
View File
@@ -0,0 +1,53 @@
import type { DeviceRegistryEntry } from "../../../src/data/device/device_registry";
const baseDevice = {
config_entries_subentries: {},
connections: [] as [string, string][],
identifiers: [] as [string, string][],
model_id: null,
labels: [] as string[],
sw_version: null,
hw_version: null,
serial_number: null,
via_device_id: null,
area_id: null,
name_by_user: null,
disabled_by: null,
configuration_url: null,
created_at: 0,
modified_at: 0,
};
export const demoDevices: DeviceRegistryEntry[] = [
{
...baseDevice,
id: "co2signal",
name: "Electricity Maps",
manufacturer: "Electricity Maps",
model: "CO2 Signal",
config_entries: ["co2signal"],
primary_config_entry: "co2signal",
entry_type: "service",
},
{
...baseDevice,
id: "hue-bridge",
name: "Philips Hue Bridge",
manufacturer: "Signify",
model: "Hue Bridge (BSB002)",
sw_version: "1.50.0",
config_entries: ["mock-hue"],
primary_config_entry: "mock-hue",
entry_type: null,
},
{
...baseDevice,
id: "sonos-living",
name: "Living Room",
manufacturer: "Sonos",
model: "One",
config_entries: ["mock-sonos"],
primary_config_entry: "mock-sonos",
entry_type: null,
},
];
+17 -1
View File
@@ -1,4 +1,7 @@
import type { EntityRegistryEntry } from "../../../src/data/entity/entity_registry";
import type {
EntityRegistryEntry,
ExtEntityRegistryEntry,
} from "../../../src/data/entity/entity_registry";
import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
export const mockEntityRegistry = (
@@ -6,4 +9,17 @@ export const mockEntityRegistry = (
data: EntityRegistryEntry[] = []
) => {
hass.mockWS("config/entity_registry/list", () => data);
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);
if (entry) {
result[entityId] = { ...entry, capabilities: {}, aliases: [] };
}
}
return result;
}
);
};
+12
View File
@@ -0,0 +1,12 @@
import type { EntitySources } from "../../../src/data/entity/entity_sources";
import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
export const mockEntitySources = (hass: MockHomeAssistant) => {
hass.mockWS(
"entity/source",
(): EntitySources => ({
"sensor.co2_intensity": { domain: "co2signal" },
"sensor.grid_fossil_fuel_percentage": { domain: "co2signal" },
})
);
};
+39
View File
@@ -0,0 +1,39 @@
import type { ExposeEntitySettings } from "../../../src/data/expose";
import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
const exposedEntities: Record<string, ExposeEntitySettings> = {
"light.bed_light": {
conversation: true,
"cloud.alexa": true,
"cloud.google_assistant": true,
},
"light.ceiling_lights": {
conversation: true,
"cloud.alexa": true,
"cloud.google_assistant": false,
},
"switch.decorative_lights": {
conversation: true,
"cloud.alexa": false,
"cloud.google_assistant": true,
},
"climate.ecobee": {
conversation: true,
"cloud.alexa": true,
"cloud.google_assistant": true,
},
};
export const mockExpose = (hass: MockHomeAssistant) => {
hass.mockWS("homeassistant/expose_entity/list", () => ({
exposed_entities: exposedEntities,
}));
hass.mockWS(
"homeassistant/expose_new_entities/get",
(msg: { assistant: string }) => ({
expose_new: msg.assistant !== "cloud.google_assistant",
})
);
hass.mockWS("homeassistant/expose_entity", () => null);
hass.mockWS("homeassistant/expose_new_entities/set", () => null);
};
+1
View File
@@ -42,6 +42,7 @@ export const mockFrontend = (hass: MockHomeAssistant) => {
// eslint-disable-next-line @typescript-eslint/no-empty-function
return () => {};
});
hass.mockWS("frontend/get_system_data", () => ({ value: null }));
hass.mockWS("repairs/list_issues", () => ({ issues: [] }));
hass.mockWS("frontend/get_themes", (_msg, currentHass) => currentHass.themes);
};
+72
View File
@@ -0,0 +1,72 @@
import type { IntegrationManifest } from "../../../src/data/integration";
import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
const manifest = (
domain: string,
name: string,
overrides: Partial<IntegrationManifest> = {}
): IntegrationManifest => ({
is_built_in: true,
domain,
name,
config_flow: true,
documentation: `https://www.home-assistant.io/integrations/${domain}/`,
iot_class: "local_push",
...overrides,
});
const manifests: IntegrationManifest[] = [
manifest("co2signal", "Electricity Maps", { iot_class: "cloud_polling" }),
manifest("hue", "Philips Hue"),
manifest("sonos", "Sonos"),
manifest("met", "Met.no", { iot_class: "cloud_polling" }),
// Helpers
manifest("template", "Template", { integration_type: "helper" }),
manifest("input_boolean", "Toggle", {
config_flow: false,
integration_type: "helper",
iot_class: "local_polling",
}),
manifest("input_number", "Number", {
config_flow: false,
integration_type: "helper",
iot_class: "local_polling",
}),
manifest("input_select", "Dropdown", {
config_flow: false,
integration_type: "helper",
iot_class: "local_polling",
}),
manifest("input_text", "Text", {
config_flow: false,
integration_type: "helper",
iot_class: "local_polling",
}),
manifest("input_datetime", "Date and/or time", {
config_flow: false,
integration_type: "helper",
iot_class: "local_polling",
}),
manifest("counter", "Counter", {
config_flow: false,
integration_type: "helper",
iot_class: "local_polling",
}),
manifest("timer", "Timer", {
config_flow: false,
integration_type: "helper",
iot_class: "local_polling",
}),
manifest("schedule", "Schedule", {
config_flow: false,
integration_type: "helper",
iot_class: "local_polling",
}),
];
export const mockIntegration = (hass: MockHomeAssistant) => {
hass.mockWS("manifest/list", () => manifests);
hass.mockWS("manifest/get", (msg: { integration: string }) =>
manifests.find((m) => m.domain === msg.integration)
);
};
+13
View File
@@ -0,0 +1,13 @@
import type { NetworkUrls } from "../../../src/data/network";
import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
export const mockNetwork = (hass: MockHomeAssistant) => {
hass.mockWS(
"network/url",
(): NetworkUrls => ({
internal: "http://homeassistant.local:8123",
external: "https://demo-instance.ui.nabu.casa",
cloud: "https://demo-instance.ui.nabu.casa",
})
);
};
+20
View File
@@ -0,0 +1,20 @@
import type { Person } from "../../../src/data/person";
import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
const storage: Person[] = [
{
id: "demo_user",
name: "Demo User",
user_id: "abcd",
device_trackers: [],
},
{
id: "anne_therese",
name: "Anne Therese",
device_trackers: [],
},
];
export const mockPerson = (hass: MockHomeAssistant) => {
hass.mockWS("person/list", () => ({ storage, config: [] as Person[] }));
};
+18
View File
@@ -0,0 +1,18 @@
import type { SceneConfig } from "../../../src/data/scene";
import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
const demoSceneConfig = (id: string): SceneConfig => ({
id,
name: "Demo scene",
entities: {
"light.bed_light": { state: "on" },
},
});
export const mockScene = (hass: MockHomeAssistant) => {
hass.mockAPI(/config\/scene\/config\/.+/, (_hass, method, path) => {
const id = path.split("/").pop()!;
// GET returns the config; POST/DELETE just acknowledge.
return method === "GET" ? demoSceneConfig(id) : {};
});
};
+7
View File
@@ -0,0 +1,7 @@
import type { RelatedResult } from "../../../src/data/search";
import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
export const mockSearch = (hass: MockHomeAssistant) => {
// The demo has no relationship graph, so report no related items.
hass.mockWS("search/related", (): RelatedResult => ({}));
};
+37
View File
@@ -0,0 +1,37 @@
import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
export const mockSystemHealth = (hass: MockHomeAssistant) => {
hass.mockWS(
"system_health/info",
(_msg, _hass, onChange?: (event: any) => void) => {
// Defer so the consumer's unsubscribe handle is initialized first
// (real WS events arrive asynchronously).
setTimeout(() => {
onChange?.({
type: "initial",
data: {
homeassistant: {
info: {
version: "DEMO",
installation_type: "Home Assistant OS",
dev: false,
hassio: true,
docker: true,
container_arch: "aarch64",
user: "root",
virtualenv: false,
python_version: "3.13.0",
os_name: "Linux",
os_version: "6.6.0",
arch: "aarch64",
timezone: "America/Los_Angeles",
config_dir: "/config",
},
},
},
});
});
return () => undefined;
}
);
};
+28
View File
@@ -1,5 +1,33 @@
import type { LoggedError } from "../../../src/data/system_log";
import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
const now = Date.now() / 1000;
const logs: LoggedError[] = [
{
name: "homeassistant.components.demo",
message: ["Demo integration failed to update sensor data"],
level: "warning",
source: ["components/demo/sensor.py", 142],
exception: "",
count: 2,
timestamp: now - 120,
first_occurred: now - 3600,
},
{
name: "homeassistant.config_entries",
message: ["Config entry for met.no could not be set up"],
level: "error",
source: ["config_entries.py", 512],
exception:
'Traceback (most recent call last):\n File "config_entries.py", line 512',
count: 1,
timestamp: now - 600,
first_occurred: now - 600,
},
];
export const mockSystemLog = (hass: MockHomeAssistant) => {
hass.mockAPI("error/all", () => []);
hass.mockWS("system_log/list", () => logs);
};
+27
View File
@@ -0,0 +1,27 @@
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,
},
];
export const mockZone = (hass: MockHomeAssistant) => {
hass.mockWS("zone/list", () => zones);
};
+11
View File
@@ -62,6 +62,17 @@ Use `sidebar.js` when a page needs a visible section, section header, or determi
- New categories without a sidebar entry are appended by the generator with their category name as the header.
- If a listed page does not exist, the generator logs an error during `gather-gallery-pages`.
### Subsections
A section can group its pages under named subsections instead of one flat list. Use this for large categories where related pages should sit together.
- `subsections` is an array of `{ header, pages }`. It is mutually exclusive with a flat `pages` array on the same group.
- Each subsection `header` is a non-collapsible label rendered inside the section's expansion panel; the section stays the only collapsible level.
- Listed pages keep their per-subsection order.
- Any pages found in the category but not listed in a subsection are collected into a generated `Other` subsection, appended alphabetically. The `Other` subsection is omitted when there are no leftovers.
- A listed page that does not exist still logs an error during `gather-gallery-pages`.
- Use sentence case for subsection headers and follow the content standards below.
## Markdown Pages
Use markdown pages for explanations, design guidance, API notes, and copy standards.
+164 -9
View File
@@ -10,6 +10,10 @@ import {
mdiViewDashboard,
} from "@mdi/js";
// A group may list its pages flat in `pages`, or group them under named
// `subsections`. The two are mutually exclusive. Listed pages keep their order;
// any pages found in the category but not listed are appended alphabetically
// (to a generated "Other" subsection when the group uses subsections).
export default [
{
// This section has no header and so all page links are shown directly in the sidebar
@@ -27,31 +31,162 @@ export default [
category: "components",
icon: mdiPuzzle,
header: "Components",
subsections: [
{
header: "Form and selectors",
pages: [
"ha-form",
"ha-selector",
"ha-select-box",
"ha-input",
"ha-textarea",
],
},
{
header: "Controls and sliders",
pages: [
"ha-button",
"ha-control-button",
"ha-progress-button",
"ha-switch",
"ha-control-switch",
"ha-slider",
"ha-control-slider",
"ha-control-circular-slider",
"ha-control-number-buttons",
"ha-control-select",
"ha-control-select-menu",
"ha-hs-color-picker",
],
},
{
header: "Overlays",
pages: [
"ha-dialog",
"ha-dialogs",
"ha-adaptive-dialog",
"ha-adaptive-popover",
"ha-dropdown",
"ha-tooltip",
],
},
{
header: "Lists and disclosure",
pages: ["ha-list", "ha-expansion-panel", "ha-faded"],
},
{
header: "Feedback and status",
pages: ["ha-alert", "ha-spinner", "ha-tip", "ha-bar", "ha-gauge"],
},
{
header: "Labels and text",
pages: ["ha-badge", "ha-label-badge", "ha-chips", "ha-marquee-text"],
},
],
},
{
category: "lovelace",
icon: mdiViewDashboard,
// Label for in the sidebar
header: "Dashboards",
// Specify order of pages. Any pages in the category folder but not listed here will
// automatically be added after the pages listed here.
pages: ["introduction"],
subsections: [
{
header: "Introduction",
pages: ["introduction"],
},
{
header: "Entity cards",
pages: [
"entities-card",
"entity-button-card",
"entity-filter-card",
"glance-card",
"tile-card",
"area-card",
],
},
{
header: "Picture cards",
pages: [
"picture-card",
"picture-elements-card",
"picture-entity-card",
"picture-glance-card",
],
},
{
header: "Domain cards",
pages: [
"light-card",
"thermostat-card",
"alarm-panel-card",
"gauge-card",
"plant-card",
"map-card",
"media-control-card",
"media-player-row",
],
},
{
header: "Layout and utility",
pages: [
"grid-and-stack-card",
"conditional-card",
"iframe-card",
"markdown-card",
"todo-list-card",
],
},
],
},
{
category: "more-info",
icon: mdiInformationOutline,
header: "More Info dialogs",
subsections: [
{
header: "Climate and water",
pages: ["climate", "humidifier", "water-heater", "fan"],
},
{
header: "Covers and access",
pages: ["cover", "lock", "lawn-mower", "vacuum"],
},
{
header: "Lighting",
pages: ["light", "scene"],
},
{
header: "Media",
pages: ["media-player"],
},
{
header: "Inputs and values",
pages: ["input-number", "input-text", "number", "timer"],
},
{
header: "System",
pages: ["update"],
},
],
},
{
category: "automation",
icon: mdiRobot,
header: "Automation",
pages: [
"editor-trigger",
"editor-condition",
"editor-action",
"trace",
"trace-timeline",
subsections: [
{
header: "Editors",
pages: ["editor-trigger", "editor-condition", "editor-action"],
},
{
header: "Descriptions",
pages: ["describe-trigger", "describe-condition", "describe-action"],
},
{
header: "Traces",
pages: ["trace", "trace-timeline"],
},
],
},
{
@@ -64,6 +199,26 @@ export default [
category: "date-time",
icon: mdiCalendarClock,
header: "Date and Time",
subsections: [
{
header: "Date",
pages: ["date"],
},
{
header: "Time",
pages: ["time", "time-seconds", "time-weekday"],
},
{
header: "Combined",
pages: [
"date-time",
"date-time-numeric",
"date-time-seconds",
"date-time-short",
"date-time-short-year",
],
},
],
},
{
category: "misc",
+60 -20
View File
@@ -40,15 +40,26 @@ interface GalleryPage {
demo?: unknown;
}
interface GallerySidebarSubsection {
header: string;
pages: string[];
}
interface GallerySidebarGroup {
category: string;
header?: string;
icon?: string;
pages: string[];
pages?: string[];
subsections?: GallerySidebarSubsection[];
}
const groupPages = (group: GallerySidebarGroup): string[] =>
group.subsections
? group.subsections.flatMap((subsection) => subsection.pages)
: (group.pages ?? []);
const GALLERY_SIDEBAR = SIDEBAR as GallerySidebarGroup[];
const DEFAULT_PAGE = `${GALLERY_SIDEBAR[0].category}/${GALLERY_SIDEBAR[0].pages[0]}`;
const DEFAULT_PAGE = `${GALLERY_SIDEBAR[0].category}/${groupPages(GALLERY_SIDEBAR[0])[0]}`;
const mql = matchMedia("(prefers-color-scheme: dark)");
@@ -284,26 +295,15 @@ class HaGallery extends LitElement {
const sidebar: unknown[] = [];
for (const group of GALLERY_SIDEBAR) {
const links: unknown[] = [];
const expanded = group.pages.some(
const expanded = groupPages(group).some(
(page) => this._page === `${group.category}/${page}`
);
for (const page of group.pages) {
const key = `${group.category}/${page}`;
if (!(key in PAGES)) {
console.error("Undefined page referenced in sidebar.js:", key);
continue;
}
links.push(
this._renderPageLink(
key,
PAGES[key].metadata.title || page,
group.header ? undefined : "main-navigation",
group.header ? undefined : group.icon
const content = group.subsections
? group.subsections.map((subsection) =>
this._renderSidebarSubsection(group, subsection)
)
);
}
: this._renderPageLinks(group, group.pages ?? []);
sidebar.push(
group.header
@@ -321,16 +321,46 @@ class HaGallery extends LitElement {
.path=${group.icon}
></ha-svg-icon>`
: nothing}
${links}
${content}
</ha-expansion-panel>
`
: links
: content
);
}
return sidebar;
}
private _renderSidebarSubsection(
group: GallerySidebarGroup,
subsection: GallerySidebarSubsection
) {
return html`
<div class="gallery-sidebar-subheader">${subsection.header}</div>
${this._renderPageLinks(group, subsection.pages)}
`;
}
private _renderPageLinks(group: GallerySidebarGroup, pages: string[]) {
const links: unknown[] = [];
for (const page of pages) {
const key = `${group.category}/${page}`;
if (!(key in PAGES)) {
console.error("Undefined page referenced in sidebar.js:", key);
continue;
}
links.push(
this._renderPageLink(
key,
PAGES[key].metadata.title || page,
group.header ? undefined : "main-navigation",
group.header ? undefined : group.icon
)
);
}
return links;
}
private _renderPageLink(
page: string,
title: string,
@@ -585,6 +615,16 @@ class HaGallery extends LitElement {
width: var(--ha-sidebar-expanded-section-item-width, 248px);
}
.gallery-sidebar-subheader {
margin: var(--ha-space-2) var(--ha-space-4) var(--ha-space-1);
color: var(--secondary-text-color);
font-size: var(--ha-font-size-s);
font-weight: var(--ha-font-weight-medium);
line-height: var(--ha-line-height-condensed);
text-transform: uppercase;
letter-spacing: 0.04em;
}
.gallery-sidebar-icon,
.gallery-nav-item ha-svg-icon[slot="start"] {
color: var(--sidebar-icon-color);
+32 -1
View File
@@ -1,4 +1,5 @@
import type { TemplateResult } from "lit";
import { ContextProvider } from "@lit/context";
import type { PropertyValues, TemplateResult } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, state } from "lit/decorators";
import { mockAreaRegistry } from "../../../../demo/src/stubs/area_registry";
@@ -14,6 +15,11 @@ import "../../../../src/components/ha-selector/ha-selector";
import "../../../../src/components/ha-settings-row";
import type { AreaRegistryEntry } from "../../../../src/data/area/area_registry";
import type { BlueprintInput } from "../../../../src/data/blueprint";
import {
configContext,
internationalizationContext,
} from "../../../../src/data/context";
import { updateHassGroups } from "../../../../src/data/context/updateContext";
import type { DeviceRegistryEntry } from "../../../../src/data/device/device_registry";
import type { FloorRegistryEntry } from "../../../../src/data/floor_registry";
import type { LabelRegistryEntry } from "../../../../src/data/label/label_registry";
@@ -496,6 +502,10 @@ const SCHEMAS: {
},
},
},
password: {
label: "Password",
selector: { text: { type: "password" } },
},
},
},
},
@@ -518,6 +528,17 @@ class DemoHaSelector extends LitElement implements ProvideHassElement {
private data = SCHEMAS.map(() => ({}));
// The date/datetime selectors and the date-picker dialog consume these
// contexts (provided by the root element in the real app). Provide them here
// so they work in the gallery.
private _i18nProvider = new ContextProvider(this, {
context: internationalizationContext,
});
private _configProvider = new ContextProvider(this, {
context: configContext,
});
constructor() {
super();
const hass = provideHass(this);
@@ -539,6 +560,16 @@ class DemoHaSelector extends LitElement implements ProvideHassElement {
el.hass = this.hass;
}
protected willUpdate(changedProps: PropertyValues): void {
super.willUpdate(changedProps);
if (changedProps.has("hass") && this.hass) {
this._i18nProvider.setValue(
updateHassGroups.internationalization(this.hass)
);
this._configProvider.setValue(updateHassGroups.config(this.hass));
}
}
public connectedCallback() {
super.connectedCallback();
this.addEventListener("show-dialog", this._dialogManager);
+16 -7
View File
@@ -4,21 +4,30 @@ title: Home
# Welcome to Home Assistant Design
This portal aims to aid designers and developers on improving the Home Assistant interface. It consists of working code, resources and guidelines.
This is the design gallery for the Home Assistant frontend: a living reference of working components, dashboard cards, and brand and copy guidance. Every page runs outside a Home Assistant instance, so you can explore the interface, try components in isolation, and review changes against a consistent baseline.
## Home Assistant interface
## Browse the gallery
The Home Assistant frontend allows users to browse and control the state of their home, manage their automations and configure integrations. The frontend is designed as a mobile-first experience. It is a progressive web application and offers an app-like experience to our users. The Home Assistant frontend needs to be fast. But it also needs to work on a wide range of old devices.
- [Brand](#brand/logo): the logo, personality, and the story behind the Open Home.
- [Components](#components/ha-button): the `ha-*` component library with live demos and API notes.
- [Dashboards](#lovelace/introduction): Lovelace cards rendered from real card configuration.
- [More Info dialogs](#more-info/light): the more-info experience for each entity type.
- [Automation](#automation/editor-trigger): trigger, condition, and action editors, plus trace views.
- [Users](#user-test/user-types): the audiences we design for.
- [Date and time](#date-time/date): date and time formatting examples.
- [Miscellaneous](#misc/entity-state): smaller utilities and patterns, plus how to edit this gallery.
### Material Design
## Testing and playground
The Home Assistant interface is based on Material Design. It's a design system created by Google to quickly build high-quality digital experiences. Components and guidelines that are custom made for Home Assistant are documented on this portal. For all other components check <a href="https://material.io" rel="noopener noreferrer" target="_blank">material.io</a>.
Every page runs against fake state, so you can interact with components safely and reproducibly. Treat the demo pages as a playground: change a value, resize the window, or switch the layout to right-to-left to check spacing and direction. Use the gallery to reproduce a UI state in isolation before debugging it in a full Home Assistant setup.
Open **Settings** from the gear icon in the sidebar to switch between light and dark themes or preview the interface in right-to-left.
## Designers
We want to make it as easy for designers to contribute as it is for developers. Theres a lot a designer can contribute to:
We want to make it as easy for designers to contribute as it is for developers. There's a lot a designer can contribute to:
- Meet us at <a href="https://www.home-assistant.io/join-chat-design" rel="noopener noreferrer" target="_blank">Discord #designers channel</a>. If you can't see the channel, make sure you set the correct role in Channels & Roles.
- Meet us in the <a href="https://www.home-assistant.io/join-chat-design" rel="noopener noreferrer" target="_blank">Discord #designers channel</a>. If you can't see the channel, make sure you set the correct role in Channels & Roles.
- Start designing with our <a href="https://www.figma.com/design/2WGI8IDGyxINjSV6NRvPur/Home-Assistant-Design-Kit" rel="noopener noreferrer" target="_blank">Figma DesignKit</a>.
- Find the latest UX <a href="https://github.com/home-assistant/frontend/discussions?discussions_q=label%3Aux" rel="noopener noreferrer" target="_blank">discussions</a> and <a href="https://github.com/home-assistant/frontend/labels/ux" rel="noopener noreferrer" target="_blank">issues</a> on GitHub. Everyone can start a new issue or discussion!
-1
View File
@@ -372,7 +372,6 @@ export class DemoEntityState extends LitElement {
hass.localize,
entry.stateObj,
hass.locale,
[], // numericDeviceClasses
hass.config,
hass.entities
)}`,
+16 -14
View File
@@ -21,6 +21,7 @@
"prepack": "pinst --disable",
"postpack": "pinst --enable",
"test": "vitest run --config test/vitest.config.ts",
"test:bench": "vitest bench --run --config test/vitest.bench.config.ts",
"test:coverage": "vitest run --config test/vitest.config.ts --coverage"
},
"author": "Paulus Schoutsen <Paulus@PaulusSchoutsen.nl> (http://paulusschoutsen.nl)",
@@ -34,15 +35,15 @@
"@codemirror/lang-jinja": "6.0.1",
"@codemirror/lang-yaml": "6.1.3",
"@codemirror/language": "6.12.3",
"@codemirror/lint": "6.9.6",
"@codemirror/lint": "6.9.7",
"@codemirror/search": "6.7.0",
"@codemirror/state": "6.6.0",
"@codemirror/view": "6.43.0",
"@codemirror/view": "6.43.1",
"@date-fns/tz": "1.5.0",
"@egjs/hammerjs": "2.0.17",
"@formatjs/intl-datetimeformat": "7.4.8",
"@formatjs/intl-datetimeformat": "7.4.9",
"@formatjs/intl-displaynames": "7.3.10",
"@formatjs/intl-durationformat": "0.10.14",
"@formatjs/intl-durationformat": "0.10.15",
"@formatjs/intl-getcanonicallocales": "3.2.10",
"@formatjs/intl-listformat": "8.3.10",
"@formatjs/intl-locale": "5.3.9",
@@ -62,6 +63,7 @@
"@lit-labs/virtualizer": "2.1.1",
"@lit/context": "1.1.6",
"@lit/reactive-element": "2.1.2",
"@lit/task": "1.0.3",
"@material/mwc-formfield": "patch:@material/mwc-formfield@npm%3A0.27.0#~/.yarn/patches/@material-mwc-formfield-npm-0.27.0-9528cb60f6.patch",
"@material/mwc-list": "patch:@material/mwc-list@npm%3A0.27.0#~/.yarn/patches/@material-mwc-list-npm-0.27.0-5344fc9de4.patch",
"@material/web": "2.4.1",
@@ -131,13 +133,13 @@
"@babel/preset-env": "7.29.7",
"@bundle-stats/plugin-webpack-filter": "4.22.2",
"@eslint/js": "10.0.1",
"@html-eslint/eslint-plugin": "0.61.0",
"@html-eslint/eslint-plugin": "0.62.0",
"@lokalise/node-api": "16.0.0",
"@octokit/auth-oauth-device": "8.0.3",
"@octokit/plugin-retry": "8.1.0",
"@octokit/rest": "22.0.1",
"@rsdoctor/rspack-plugin": "1.5.12",
"@rspack/core": "2.0.6",
"@rsdoctor/rspack-plugin": "1.5.13",
"@rspack/core": "2.0.8",
"@rspack/dev-server": "2.0.3",
"@types/chromecast-caf-receiver": "6.0.26",
"@types/chromecast-caf-sender": "1.0.11",
@@ -153,12 +155,12 @@
"@types/qrcode": "1.5.6",
"@types/sortablejs": "1.15.9",
"@types/tar": "7.0.87",
"@vitest/coverage-v8": "4.1.8",
"@vitest/coverage-v8": "4.1.9",
"babel-loader": "10.1.1",
"babel-plugin-template-html-minifier": "4.1.0",
"browserslist-useragent-regexp": "4.1.4",
"del": "8.0.1",
"eslint": "10.4.1",
"eslint": "10.5.0",
"eslint-config-prettier": "10.1.8",
"eslint-import-resolver-webpack": "0.13.11",
"eslint-plugin-import-x": "4.16.2",
@@ -186,7 +188,7 @@
"lodash.template": "4.18.1",
"map-stream": "0.0.7",
"pinst": "3.0.0",
"prettier": "3.8.3",
"prettier": "3.8.4",
"rspack-manifest-plugin": "5.2.2",
"serve": "14.2.6",
"sinon": "22.0.0",
@@ -194,9 +196,9 @@
"terser-webpack-plugin": "5.6.1",
"ts-lit-plugin": "2.0.2",
"typescript": "6.0.3",
"typescript-eslint": "8.60.1",
"typescript-eslint": "8.61.1",
"vite-tsconfig-paths": "6.1.1",
"vitest": "4.1.8",
"vitest": "4.1.9",
"webpack-stats-plugin": "1.1.3",
"webpackbar": "7.0.0",
"workbox-build": "patch:workbox-build@npm%3A7.4.1#~/.yarn/patches/workbox-build-npm-7.4.1-c84561662c.patch"
@@ -212,8 +214,8 @@
"@material/mwc-list@^0.27.0": "patch:@material/mwc-list@npm%3A0.27.0#~/.yarn/patches/@material-mwc-list-npm-0.27.0-5344fc9de4.patch",
"glob@^10.2.2": "^10.5.0"
},
"packageManager": "yarn@4.16.0",
"packageManager": "yarn@4.17.0",
"volta": {
"node": "24.16.0"
"node": "24.17.0"
}
}
+11
View File
@@ -0,0 +1,11 @@
#!/usr/bin/env bash
# Safe bash settings
# -e Exit on command fail
# -u Exit on unset variable
# -o pipefail Exit if piped command has error code
set -eu -o pipefail
cd "$(dirname "$0")/.."
./node_modules/.bin/gulp gen-numeric-device-classes
+2 -1
View File
@@ -4,7 +4,8 @@ import { ensureArray } from "../array/ensure-array";
import { isComponentLoaded } from "./is_component_loaded";
export const canShowPage = (hass: HomeAssistant, page: PageNavigation) =>
isCore(page) || isLoadedIntegration(hass, page);
(isCore(page) || isLoadedIntegration(hass, page)) &&
(!page.filter || page.filter(hass));
export const isLoadedIntegration = (
hass: HomeAssistant,
+19
View File
@@ -110,6 +110,25 @@ export const DOMAINS_WITH_DYNAMIC_PICTURE = new Set([
"media_player",
]);
/** Domains that use a timestamp for state. */
export const TIMESTAMP_STATE_DOMAINS = new Set([
"ai_task",
"button",
"conversation",
"event",
"image",
"infrared",
"input_button",
"notify",
"radio_frequency",
"scene",
"stt",
"tag",
"tts",
"wake_word",
"datetime",
]);
/** Temperature units. */
export const UNIT_C = "°C";
export const UNIT_F = "°F";
@@ -0,0 +1,29 @@
import { Task, type TaskConfig } from "@lit/task";
import type { ReactiveControllerHost } from "lit";
/**
* A `@lit/task` Task with a sticky `resolved` flag: false until the task has
* completed once, then true. Lets callers tell "still loading" apart from
* "resolved with an empty value" without a null sentinel, while keeping the
* previous value during a re-run.
*/
export class AsyncValueTask<T extends readonly unknown[], R> extends Task<
T,
R
> {
private _resolved = false;
constructor(host: ReactiveControllerHost, config: TaskConfig<T, R>) {
super(host, {
...config,
onComplete: (value) => {
this._resolved = true;
config.onComplete?.(value);
},
});
}
public get resolved(): boolean {
return this._resolved;
}
}
+6 -5
View File
@@ -3,23 +3,24 @@ import type { FrontendLocaleData } from "../../data/translation";
import { selectUnit } from "../util/select-unit";
const formatRelTimeMem = memoizeOne(
(locale: FrontendLocaleData) =>
new Intl.RelativeTimeFormat(locale.language, { numeric: "auto" })
(locale: FrontendLocaleData, style: Intl.RelativeTimeFormatStyle) =>
new Intl.RelativeTimeFormat(locale.language, { numeric: "auto", style })
);
export const relativeTime = (
from: Date,
locale: FrontendLocaleData,
to?: Date,
includeTense = true
includeTense = true,
style: Intl.RelativeTimeFormatStyle = "long"
): string => {
const diff = selectUnit(from, to, locale);
if (includeTense) {
return formatRelTimeMem(locale).format(diff.value, diff.unit);
return formatRelTimeMem(locale, style).format(diff.value, diff.unit);
}
return Intl.NumberFormat(locale.language, {
style: "unit",
unit: diff.unit,
unitDisplay: "long",
unitDisplay: style,
}).format(Math.abs(diff.value));
};
+1 -2
View File
@@ -1,7 +1,7 @@
// Load a resource and get a promise when loading done.
// From: https://davidwalsh.name/javascript-loader
const _load = (tag: "link" | "script" | "img", url: string, type?: "module") =>
const _load = (tag: "link" | "script", url: string, type?: "module") =>
// This promise will be used by Promise.all to determine success or failure
new Promise((resolve, reject) => {
const element = document.createElement(tag);
@@ -33,5 +33,4 @@ const _load = (tag: "link" | "script" | "img", url: string, type?: "module") =>
});
export const loadCSS = (url: string) => _load("link", url);
export const loadJS = (url: string) => _load("script", url);
export const loadImg = (url: string) => _load("img", url);
export const loadModule = (url: string) => _load("script", url, "module");
-41
View File
@@ -1,41 +0,0 @@
/**
* Scroll to a specific y coordinate.
*
* Copied from paper-scroll-header-panel.
*
* @method scroll
* @param {number} top The coordinate to scroll to, along the y-axis.
* @param {boolean} smooth true if the scroll position should be smoothly adjusted.
*/
export default function scrollToTarget(element, target) {
// the scroll event will trigger _updateScrollState directly,
// However, _updateScrollState relies on the previous `scrollTop` to update the states.
// Calling _updateScrollState will ensure that the states are synced correctly.
const top = 0;
const scroller = target;
const easingFn = function easeOutQuad(t, b, c, d) {
t /= d;
return -c * t * (t - 2) + b;
};
const animationId = Math.random();
const duration = 200;
const startTime = Date.now();
const currentScrollTop = scroller.scrollTop;
const deltaScrollTop = top - currentScrollTop;
element._currentAnimationId = animationId;
(function updateFrame() {
const now = Date.now();
const elapsedTime = now - startTime;
if (elapsedTime > duration) {
scroller.scrollTop = top;
} else if (element._currentAnimationId === animationId) {
scroller.scrollTop = easingFn(
elapsedTime,
currentScrollTop,
deltaScrollTop,
duration
);
requestAnimationFrame(updateFrame.bind(element));
}
}).call(element);
}
-13
View File
@@ -3,8 +3,6 @@ import type { Map, TileLayer } from "leaflet";
// Sets up a Leaflet map on the provided DOM element
// eslint-disable-next-line @typescript-eslint/consistent-type-imports
export type LeafletModuleType = typeof import("leaflet");
// eslint-disable-next-line @typescript-eslint/consistent-type-imports
export type LeafletDrawModuleType = typeof import("leaflet-draw");
export const setupLeafletMap = async (
mapElement: HTMLElement,
@@ -45,17 +43,6 @@ export const setupLeafletMap = async (
return [map, Leaflet, tileLayer];
};
export const replaceTileLayer = (
leaflet: LeafletModuleType,
map: Map,
tileLayer: TileLayer
): TileLayer => {
map.removeLayer(tileLayer);
tileLayer = createTileLayer(leaflet);
tileLayer.addTo(map);
return tileLayer;
};
const createTileLayer = (leaflet: LeafletModuleType): TileLayer =>
leaflet.tileLayer(
`https://basemaps.cartocdn.com/rastertiles/voyager/{z}/{x}/{y}${
-3
View File
@@ -1,3 +0,0 @@
/** An empty image which can be set as src of an img element. */
export const emptyImageBase64 =
"data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7";
@@ -60,6 +60,17 @@ export const computeAttributeValueToParts = (
return [{ type: "value", value: localize("state.default.unknown") }];
}
// Device class attribute, return the integration's translated name
if (attribute === "device_class" && typeof attributeValue === "string") {
const domain = computeStateDomain(stateObj);
const deviceClassName = localize(
`component.${domain}.entity_component.${attributeValue}.name`
);
if (deviceClassName) {
return [{ type: "value", value: deviceClassName }];
}
}
// Number value, return formatted number
if (typeof attributeValue === "number") {
const domain = computeStateDomain(stateObj);
@@ -1,6 +1,7 @@
import type { HassEntity } from "home-assistant-js-websocket";
import { UNAVAILABLE, UNKNOWN } from "../../data/entity/entity";
import type { HomeAssistant } from "../../types";
import { unitFromParts } from "./value_parts";
interface EntityUnitStubConfig {
entity: string;
@@ -40,5 +41,5 @@ export const computeEntityUnitDisplay = (
? hass.formatEntityAttributeValueToParts(stateObj, config.attribute)
: hass.formatEntityStateToParts(stateObj);
return parts.find((part) => part.type === "unit")?.value ?? "";
return unitFromParts(parts);
};
+35 -47
View File
@@ -17,13 +17,33 @@ import {
import { blankBeforeUnit } from "../translations/blank_before_unit";
import type { LocalizeFunc } from "../translations/localize";
import { computeDomain } from "./compute_domain";
import { SENSOR_TIMESTAMP_DEVICE_CLASSES } from "../../data/sensor";
import {
isNumericSensorDeviceClass,
SENSOR_TIMESTAMP_DEVICE_CLASSES,
} from "../../data/sensor";
import { TIMESTAMP_STATE_DOMAINS } from "../const";
// Domains whose state is a timezone-agnostic date and/or time string.
const DATE_TIME_DOMAINS = new Set(["date", "input_datetime", "time"]);
// Maps Intl.NumberFormat part types to ValuePart types for monetary states.
const MONETARY_TYPE_MAP: Record<string, ValuePart["type"]> = {
integer: "value",
group: "value",
decimal: "value",
fraction: "value",
minusSign: "value",
plusSign: "value",
literal: "literal",
currency: "unit",
};
const NUMERICAL_DOMAINS = ["counter", "input_number", "number"];
export const computeStateDisplay = (
localize: LocalizeFunc,
stateObj: HassEntity,
locale: FrontendLocaleData,
sensorNumericDeviceClasses: string[],
config: HassConfig,
entities: HomeAssistant["entities"],
state?: string
@@ -34,7 +54,6 @@ export const computeStateDisplay = (
return computeStateDisplayFromEntityAttributes(
localize,
locale,
sensorNumericDeviceClasses,
config,
entity,
stateObj.entity_id,
@@ -46,7 +65,6 @@ export const computeStateDisplay = (
export const computeStateDisplayFromEntityAttributes = (
localize: LocalizeFunc,
locale: FrontendLocaleData,
sensorNumericDeviceClasses: string[],
config: HassConfig,
entity: EntityRegistryDisplayEntry | undefined,
entityId: string,
@@ -56,7 +74,6 @@ export const computeStateDisplayFromEntityAttributes = (
const parts = computeStateToPartsFromEntityAttributes(
localize,
locale,
sensorNumericDeviceClasses,
config,
entity,
entityId,
@@ -69,7 +86,6 @@ export const computeStateDisplayFromEntityAttributes = (
const computeStateToPartsFromEntityAttributes = (
localize: LocalizeFunc,
locale: FrontendLocaleData,
sensorNumericDeviceClasses: string[],
config: HassConfig,
entity: EntityRegistryDisplayEntry | undefined,
entityId: string,
@@ -86,15 +102,15 @@ const computeStateToPartsFromEntityAttributes = (
}
const domain = computeDomain(entityId);
const is_number_domain =
domain === "counter" || domain === "number" || domain === "input_number";
// Entities with a `unit_of_measurement` or `state_class` are numeric values and should use `formatNumber`
const isNumberDomain = NUMERICAL_DOMAINS.includes(domain);
const isSensorDomain = domain === "sensor";
// Numeric values (by attributes, number domain,
// or numeric sensor device class) use formatNumber.
if (
isNumericFromAttributes(
attributes,
domain === "sensor" ? sensorNumericDeviceClasses : []
) ||
is_number_domain
isNumericFromAttributes(attributes) ||
isNumberDomain ||
(isSensorDomain && isNumericSensorDeviceClass(attributes.device_class))
) {
// state is duration
if (
@@ -138,24 +154,14 @@ const computeStateToPartsFromEntityAttributes = (
}
if (parts.length) {
const TYPE_MAP: Record<string, ValuePart["type"]> = {
integer: "value",
group: "value",
decimal: "value",
fraction: "value",
minusSign: "value",
plusSign: "value",
literal: "literal",
currency: "unit",
};
const valueParts: ValuePart[] = [];
for (const part of parts) {
const type = TYPE_MAP[part.type];
const type = MONETARY_TYPE_MAP[part.type];
if (!type) continue;
const last = valueParts[valueParts.length - 1];
// Merge consecutive value parts (e.g. "-" + "12" + "." + "00" → "-12.00")
// Merge consecutive value parts so the number stays a single part
// (e.g. "-" + "12" + "." + "00" → "-12.00")
if (type === "value" && last?.type === "value") {
last.value += part.value;
} else {
@@ -191,7 +197,7 @@ const computeStateToPartsFromEntityAttributes = (
return [{ type: "value", value: value }];
}
if (["date", "input_datetime", "time"].includes(domain)) {
if (DATE_TIME_DOMAINS.has(domain)) {
// If trying to display an explicit state, need to parse the explicit state to `Date` then format.
// Attributes aren't available, we have to use `state`.
@@ -250,23 +256,7 @@ const computeStateToPartsFromEntityAttributes = (
// state is a timestamp
if (
[
"ai_task",
"button",
"conversation",
"event",
"image",
"infrared",
"input_button",
"notify",
"radio_frequency",
"scene",
"stt",
"tag",
"tts",
"wake_word",
"datetime",
].includes(domain) ||
TIMESTAMP_STATE_DOMAINS.has(domain) ||
(domain === "sensor" &&
SENSOR_TIMESTAMP_DEVICE_CLASSES.includes(attributes.device_class))
) {
@@ -307,7 +297,6 @@ export const computeStateToParts = (
localize: LocalizeFunc,
stateObj: HassEntity,
locale: FrontendLocaleData,
sensorNumericDeviceClasses: string[],
config: HassConfig,
entities: HomeAssistant["entities"],
state?: string
@@ -318,7 +307,6 @@ export const computeStateToParts = (
return computeStateToPartsFromEntityAttributes(
localize,
locale,
sensorNumericDeviceClasses,
config,
entity,
stateObj.entity_id,
-23
View File
@@ -1,23 +0,0 @@
import type { HassEntity } from "home-assistant-js-websocket";
import { supportsFeature } from "./supports-feature";
export type FeatureClassNames<T extends number = number> = Partial<
Record<T, string>
>;
// Expects classNames to be an object mapping feature-bit -> className
export const featureClassNames = (
stateObj: HassEntity,
classNames: FeatureClassNames
) => {
if (!stateObj || !stateObj.attributes.supported_features) {
return "";
}
return Object.keys(classNames)
.map((feature) =>
supportsFeature(stateObj, Number(feature)) ? classNames[feature] : ""
)
.filter((attr) => attr !== "")
.join(" ");
};
+56
View File
@@ -0,0 +1,56 @@
import { AITaskEntityFeature } from "../../data/ai_task";
import { AlarmControlPanelEntityFeature } from "../../data/alarm_control_panel";
import { AssistSatelliteEntityFeature } from "../../data/assist_satellite";
import { CalendarEntityFeature } from "../../data/calendar";
import { CameraEntityFeature } from "../../data/camera";
import { ClimateEntityFeature } from "../../data/climate";
import { ConversationEntityFeature } from "../../data/conversation";
import { CoverEntityFeature } from "../../data/cover";
import { FanEntityFeature } from "../../data/fan";
import { HumidifierEntityFeature } from "../../data/humidifier";
import { LawnMowerEntityFeature } from "../../data/lawn_mower";
import { LightEntityFeature } from "../../data/light";
import { LockEntityFeature } from "../../data/lock";
import { MediaPlayerEntityFeature } from "../../data/media-player";
import { NotifyEntityFeature } from "../../data/notify";
import { RemoteEntityFeature } from "../../data/remote";
import { SirenEntityFeature } from "../../data/siren";
import { TodoListEntityFeature } from "../../data/todo";
import { UpdateEntityFeature } from "../../data/update";
import { VacuumEntityFeature } from "../../data/vacuum";
import { ValveEntityFeature } from "../../data/valve";
import { WaterHeaterEntityFeature } from "../../data/water_heater";
import { WeatherEntityFeature } from "../../data/weather";
export type FeatureEnum = Record<string | number, string | number>;
const DOMAIN_ENUMS = {
ai_task: AITaskEntityFeature,
alarm_control_panel: AlarmControlPanelEntityFeature,
assist_satellite: AssistSatelliteEntityFeature,
calendar: CalendarEntityFeature,
camera: CameraEntityFeature,
climate: ClimateEntityFeature,
conversation: ConversationEntityFeature,
cover: CoverEntityFeature,
fan: FanEntityFeature,
humidifier: HumidifierEntityFeature,
lawn_mower: LawnMowerEntityFeature,
light: LightEntityFeature,
lock: LockEntityFeature,
media_player: MediaPlayerEntityFeature,
notify: NotifyEntityFeature,
remote: RemoteEntityFeature,
siren: SirenEntityFeature,
todo: TodoListEntityFeature,
update: UpdateEntityFeature,
vacuum: VacuumEntityFeature,
valve: ValveEntityFeature,
water_heater: WaterHeaterEntityFeature,
weather: WeatherEntityFeature,
};
export function getFeatures(domain: string): FeatureEnum | undefined {
const enumObj = DOMAIN_ENUMS[domain] as FeatureEnum;
return enumObj;
}
+84 -76
View File
@@ -22,16 +22,13 @@ export const FIXED_DOMAIN_STATES = {
assist_satellite: ["idle", "listening", "responding", "processing"],
automation: ["on", "off"],
binary_sensor: ["on", "off"],
button: [],
calendar: ["on", "off"],
camera: ["idle", "recording", "streaming"],
cover: ["closed", "closing", "open", "opening"],
device_tracker: ["home", "not_home"],
fan: ["on", "off"],
humidifier: ["on", "off"],
infrared: [],
input_boolean: ["on", "off"],
input_button: [],
lawn_mower: ["error", "paused", "mowing", "returning", "docked"],
light: ["on", "off"],
lock: [
@@ -56,7 +53,6 @@ export const FIXED_DOMAIN_STATES = {
plant: ["ok", "problem"],
radio_frequency: [],
remote: ["on", "off"],
scene: [],
schedule: ["on", "off"],
script: ["on", "off"],
siren: ["on", "off"],
@@ -290,6 +286,81 @@ export const getStatesDomain = (
return result;
};
// Maps a value attribute (or the main state, keyed `_`) to the attribute listing
// its options. Naming is irregular per domain, so it's mapped explicitly.
export const DOMAIN_OPTIONS_ATTRIBUTES: Record<
string,
Record<string, string>
> = {
climate: {
_: "hvac_modes",
fan_mode: "fan_modes",
preset_mode: "preset_modes",
swing_mode: "swing_modes",
swing_horizontal_mode: "swing_horizontal_modes",
},
event: {
event_type: "event_types",
},
fan: {
preset_mode: "preset_modes",
},
humidifier: {
mode: "available_modes",
},
input_select: {
_: "options",
},
select: {
_: "options",
},
light: {
effect: "effect_list",
color_mode: "supported_color_modes",
},
media_player: {
sound_mode: "sound_mode_list",
source: "source_list",
},
remote: {
current_activity: "activity_list",
},
sensor: {
_: "options",
},
vacuum: {
fan_speed: "fan_speed_list",
},
water_heater: {
_: "operation_list",
operation_mode: "operation_list",
},
};
const DOMAIN_VALUE_ATTRIBUTES: Record<
string,
Record<string, string>
> = Object.fromEntries(
Object.entries(DOMAIN_OPTIONS_ATTRIBUTES).map(([domain, mapping]) => [
domain,
Object.fromEntries(
Object.entries(mapping).map(([value, list]) => [list, value])
),
])
);
// value attribute (or main state) → its options-list attribute
export const getOptionsAttribute = (
domain: string,
attribute?: string
): string | undefined => DOMAIN_OPTIONS_ATTRIBUTES[domain]?.[attribute ?? "_"];
// options-list attribute → its value attribute (`_` = main state)
export const getValueAttribute = (
domain: string,
optionsAttribute: string
): string | undefined => DOMAIN_VALUE_ATTRIBUTES[domain]?.[optionsAttribute];
export const getStates = (
hass: HomeAssistant,
state: HassEntity,
@@ -302,78 +373,15 @@ export const getStates = (
result.push(...getStatesDomain(hass, domain, attribute));
// Dynamic values based on the entities
switch (domain) {
case "climate":
if (!attribute) {
result.push(...state.attributes.hvac_modes);
} else if (attribute === "fan_mode") {
result.push(...state.attributes.fan_modes);
} else if (attribute === "preset_mode") {
result.push(...state.attributes.preset_modes);
} else if (attribute === "swing_mode") {
result.push(...state.attributes.swing_modes);
} else if (attribute === "swing_horizontal_mode") {
result.push(...state.attributes.swing_horizontal_modes);
}
break;
case "event":
if (attribute === "event_type") {
result.push(...state.attributes.event_types);
}
break;
case "fan":
if (attribute === "preset_mode") {
result.push(...state.attributes.preset_modes);
}
break;
case "humidifier":
if (attribute === "mode") {
result.push(...state.attributes.available_modes);
}
break;
case "input_select":
case "select":
if (!attribute) {
result.push(...state.attributes.options);
}
break;
case "light":
if (attribute === "effect" && state.attributes.effect_list) {
result.push(...state.attributes.effect_list);
} else if (
attribute === "color_mode" &&
state.attributes.supported_color_modes
) {
result.push(...state.attributes.supported_color_modes);
}
break;
case "media_player":
if (attribute === "sound_mode") {
result.push(...state.attributes.sound_mode_list);
} else if (attribute === "source") {
result.push(...state.attributes.source_list);
}
break;
case "remote":
if (attribute === "current_activity") {
result.push(...state.attributes.activity_list);
}
break;
case "sensor":
if (!attribute && state.attributes.device_class === "enum") {
result.push(...state.attributes.options);
}
break;
case "vacuum":
if (attribute === "fan_speed") {
result.push(...state.attributes.fan_speed_list);
}
break;
case "water_heater":
if (!attribute || attribute === "operation_mode") {
result.push(...state.attributes.operation_list);
}
break;
const optionsAttribute = getOptionsAttribute(domain, attribute);
if (optionsAttribute) {
const options = state.attributes[optionsAttribute];
// Sensors only expose their options when their device class is `enum`.
const enumSensor =
domain !== "sensor" || state.attributes.device_class === "enum";
if (enumSensor && Array.isArray(options)) {
result.push(...options);
}
}
return [...new Set(result)];
+2 -10
View File
@@ -1,21 +1,13 @@
import type { HassEntity } from "home-assistant-js-websocket";
import { OFF, UNAVAILABLE, UNKNOWN } from "../../data/entity/entity";
import { computeDomain } from "./compute_domain";
import { TIMESTAMP_STATE_DOMAINS } from "../const";
export function stateActive(stateObj: HassEntity, state?: string): boolean {
const domain = computeDomain(stateObj.entity_id);
const compareState = state !== undefined ? state : stateObj?.state;
if (
[
"button",
"event",
"infrared",
"input_button",
"radio_frequency",
"scene",
].includes(domain)
) {
if (TIMESTAMP_STATE_DOMAINS.has(domain)) {
return compareState !== UNAVAILABLE;
}
+6 -5
View File
@@ -4,9 +4,10 @@ import { updateIsInstalling } from "../../data/update";
export const updateIcon = (stateObj: HassEntity, state?: string) => {
const compareState = state ?? stateObj.state;
return compareState === "on"
? updateIsInstalling(stateObj as UpdateEntity)
? "mdi:package-down"
: "mdi:package-up"
: "mdi:package";
// An install can be in progress even when the state is "off", e.g. when
// downgrading firmware. Show the installing icon regardless of state.
if (updateIsInstalling(stateObj as UpdateEntity)) {
return "mdi:package-down";
}
return compareState === "on" ? "mdi:package-up" : "mdi:package";
};
+29
View File
@@ -0,0 +1,29 @@
import type { ValuePart } from "../../types";
// Joins every part except the unit, keeping native order so the sign and
// grouping stay with the value (e.g. "-2,548.14").
export const valueFromParts = (parts: ValuePart[]): string =>
parts
.filter((part) => part.type !== "unit")
.map((part) => part.value)
.join("")
.trim();
export const unitFromParts = (parts: ValuePart[]): string =>
parts.find((part) => part.type === "unit")?.value ?? "";
export type UnitPosition = "before" | "after";
// Whether the unit sits before or after the value in the locale's native order
// (e.g. "$5" / "€ 5" → "before", "5 €" / "5 %" → "after").
export const unitPosition = (parts: ValuePart[]): UnitPosition => {
const unitIndex = parts.findIndex((part) => part.type === "unit");
if (unitIndex === -1) {
return "after";
}
const lastValueIndex = parts.reduceRight(
(acc, part, i) => (acc === -1 && part.type === "value" ? i : acc),
-1
);
return unitIndex < lastValueIndex ? "before" : "after";
};
+23 -8
View File
@@ -14,12 +14,8 @@ export const isNumericState = (stateObj: HassEntity): boolean =>
isNumericFromAttributes(stateObj.attributes);
export const isNumericFromAttributes = (
attributes: HassEntityAttributeBase,
numericDeviceClasses?: string[]
): boolean =>
!!attributes.unit_of_measurement ||
!!attributes.state_class ||
(numericDeviceClasses || []).includes(attributes.device_class || "");
attributes: HassEntityAttributeBase
): boolean => !!attributes.unit_of_measurement || !!attributes.state_class;
export const numberFormatToLocale = (
localeOptions: FrontendLocaleData
@@ -40,6 +36,25 @@ export const numberFormatToLocale = (
}
};
// Constructing an Intl.NumberFormat is comparatively expensive, and these
// formatters are created on every numeric state render. The number of distinct
// (locale, options) combinations is small and bounded in practice, so cache the
// instances instead of rebuilding them on every call.
const numberFormatCache = new Map<string, Intl.NumberFormat>();
const getNumberFormatter = (
locale: string | string[] | undefined,
options: Intl.NumberFormatOptions
): Intl.NumberFormat => {
const key = JSON.stringify([locale, options]);
let formatter = numberFormatCache.get(key);
if (!formatter) {
formatter = new Intl.NumberFormat(locale, options);
numberFormatCache.set(key, formatter);
}
return formatter;
};
/**
* Formats a number based on the user's preference with thousands separator(s) and decimal character for better legibility.
*
@@ -75,7 +90,7 @@ export const formatNumberToParts = (
localeOptions?.number_format !== NumberFormat.none &&
!Number.isNaN(Number(num))
) {
return new Intl.NumberFormat(
return getNumberFormatter(
locale,
getDefaultFormatOptions(num, options)
).formatToParts(Number(num));
@@ -87,7 +102,7 @@ export const formatNumberToParts = (
localeOptions?.number_format === NumberFormat.none
) {
// If NumberFormat is none, use en-US format without grouping.
return new Intl.NumberFormat(
return getNumberFormatter(
"en-US",
getDefaultFormatOptions(num, {
...options,
+3 -20
View File
@@ -46,8 +46,7 @@ export const computeFormatFunctions = async (
entities: HomeAssistant["entities"],
devices: HomeAssistant["devices"],
areas: HomeAssistant["areas"],
floors: HomeAssistant["floors"],
sensorNumericDeviceClasses: string[]
floors: HomeAssistant["floors"]
): Promise<{
formatEntityState: FormatEntityStateFunc;
formatEntityStateToParts: FormatEntityStateToPartsFunc;
@@ -66,25 +65,9 @@ export const computeFormatFunctions = async (
return {
formatEntityState: (stateObj, state) =>
computeStateDisplay(
localize,
stateObj,
locale,
sensorNumericDeviceClasses,
config,
entities,
state
),
computeStateDisplay(localize, stateObj, locale, config, entities, state),
formatEntityStateToParts: (stateObj, state) =>
computeStateToParts(
localize,
stateObj,
locale,
sensorNumericDeviceClasses,
config,
entities,
state
),
computeStateToParts(localize, stateObj, locale, config, entities, state),
formatEntityAttributeValue: (stateObj, attribute, value) =>
computeAttributeValueDisplay(
localize,
-2
View File
@@ -17,8 +17,6 @@ export type LocalizeKeys =
| `ui.common.${string}`
| `ui.components.calendar.event.rrule.${string}`
| `ui.components.selectors.file.${string}`
| `ui.components.logbook.messages.detected_device_classes.${string}`
| `ui.components.logbook.messages.cleared_device_classes.${string}`
| `ui.dialogs.entity_registry.editor.${string}`
| `ui.dialogs.more_info_control.lawn_mower.${string}`
| `ui.dialogs.more_info_control.vacuum.${string}`
+6 -21
View File
@@ -16,32 +16,17 @@ export type HistoryLogbookTargetParamKey =
| "area_id"
| "device_id";
export type HistoryLogbookDateParamKey = "start_date" | "end_date";
export type HistoryLogbookBooleanParamKey = "back";
export type HistoryLogbookQueryParams = QueryParamValues<
HistoryLogbookTargetParamKey,
HistoryLogbookDateParamKey,
HistoryLogbookBooleanParamKey
>;
export const historyLogbookTargetParamKeys: HistoryLogbookTargetParamKey[] = [
"entity_id",
"label_id",
"floor_id",
"area_id",
"device_id",
];
export const historyLogbookTargetParamKeys: readonly HistoryLogbookTargetParamKey[] =
["entity_id", "label_id", "floor_id", "area_id", "device_id"];
export const historyLogbookQueryParamConfig = {
list: historyLogbookTargetParamKeys,
date: ["start_date", "end_date"],
boolean: [{ key: "back", trueValue: "1" }],
} satisfies QueryParamConfig<
HistoryLogbookTargetParamKey,
HistoryLogbookDateParamKey,
HistoryLogbookBooleanParamKey
} as const satisfies QueryParamConfig;
export type HistoryLogbookQueryParams = QueryParamValues<
typeof historyLogbookQueryParamConfig
>;
export const decodeHistoryLogbookQueryParams = (
+73 -41
View File
@@ -6,29 +6,49 @@ export type SearchParamsSource =
| Record<string, string>
| string;
export interface QueryParamConfig<
ListKey extends string,
DateKey extends string,
BooleanKey extends string,
> {
list?: readonly ListKey[];
date?: readonly DateKey[];
export interface QueryParamConfig {
list?: readonly string[];
date?: readonly string[];
boolean?: readonly {
key: BooleanKey;
key: string;
trueValue: string;
}[];
string?: readonly string[];
}
export type QueryParamValues<
ListKey extends string,
DateKey extends string,
BooleanKey extends string,
> = Partial<
Record<ListKey, string[]> &
Record<DateKey, Date> &
Record<BooleanKey, boolean>
type ListKeyOf<C extends QueryParamConfig> = C extends {
list: readonly (infer K extends string)[];
}
? K
: never;
type DateKeyOf<C extends QueryParamConfig> = C extends {
date: readonly (infer K extends string)[];
}
? K
: never;
type BooleanKeyOf<C extends QueryParamConfig> = C extends {
boolean: readonly { key: infer K extends string }[];
}
? K
: never;
type StringKeyOf<C extends QueryParamConfig> = C extends {
string: readonly (infer K extends string)[];
}
? K
: never;
export type QueryParamValues<C extends QueryParamConfig> = Partial<
Record<ListKeyOf<C>, string[]> &
Record<DateKeyOf<C>, Date> &
Record<BooleanKeyOf<C>, boolean> &
Record<StringKeyOf<C>, string>
>;
type QueryParamValue = string[] | Date | boolean | string;
export type ServiceTargetQueryParams<
Key extends keyof HassServiceTarget & string,
> = Partial<Record<Key, string[]>>;
@@ -46,53 +66,59 @@ const getSearchParam = (
return searchParams[key] ?? null;
};
export const decodeQueryParams = <
ListKey extends string,
DateKey extends string,
BooleanKey extends string,
>(
export function decodeQueryParams<C extends QueryParamConfig>(
searchParams: SearchParamsSource,
config: QueryParamConfig<ListKey, DateKey, BooleanKey>
): QueryParamValues<ListKey, DateKey, BooleanKey> => {
const params: QueryParamValues<ListKey, DateKey, BooleanKey> = {};
config: C
): QueryParamValues<C>;
export function decodeQueryParams(
searchParams: SearchParamsSource,
config: QueryParamConfig
): Record<string, QueryParamValue | undefined> {
const params: Record<string, QueryParamValue> = {};
for (const key of config.list ?? []) {
const value = getSearchParam(searchParams, key);
if (value) {
params[key] = value.split(",") as (typeof params)[typeof key];
params[key] = value.split(",");
}
}
for (const key of config.date ?? []) {
const value = getSearchParam(searchParams, key);
if (value) {
params[key] = new Date(value) as (typeof params)[typeof key];
params[key] = new Date(value);
}
}
for (const { key, trueValue } of config.boolean ?? []) {
if (getSearchParam(searchParams, key) === trueValue) {
params[key] = true as (typeof params)[typeof key];
params[key] = true;
}
}
for (const key of config.string ?? []) {
const value = getSearchParam(searchParams, key);
if (value) {
params[key] = value;
}
}
return params;
};
}
export const createQueryString = <
ListKey extends string,
DateKey extends string,
BooleanKey extends string,
>(
values: QueryParamValues<ListKey, DateKey, BooleanKey>,
config: QueryParamConfig<ListKey, DateKey, BooleanKey>
): string => {
export function createQueryString<C extends QueryParamConfig>(
values: QueryParamValues<NoInfer<C>>,
config: C
): string;
export function createQueryString(
values: Record<string, QueryParamValue | undefined>,
config: QueryParamConfig
): string {
const searchParams = new URLSearchParams();
for (const key of config.list ?? []) {
const value = values[key] as string[] | undefined;
if (value?.length) {
const value = values[key];
if (Array.isArray(value) && value.length) {
searchParams.append(key, value.join(","));
}
}
for (const key of config.date ?? []) {
const value = values[key] as Date | undefined;
if (value) {
const value = values[key];
if (value instanceof Date) {
searchParams.append(key, value.toISOString());
}
}
@@ -101,8 +127,14 @@ export const createQueryString = <
searchParams.append(key, trueValue);
}
}
for (const key of config.string ?? []) {
const value = values[key];
if (typeof value === "string" && value) {
searchParams.append(key, value);
}
}
return searchParams.toString();
};
}
export const serviceTargetFromQueryParams = <
Key extends keyof HassServiceTarget & string,
+21
View File
@@ -0,0 +1,21 @@
import {
createQueryString,
decodeQueryParams,
type QueryParamConfig,
type QueryParamValues,
type SearchParamsSource,
} from "./query-params";
export const todoQueryParamConfig = {
string: ["entity_id"],
boolean: [{ key: "add_item", trueValue: "true" }],
} as const satisfies QueryParamConfig;
export type TodoQueryParams = QueryParamValues<typeof todoQueryParamConfig>;
export const decodeTodoQueryParams = (
searchParams: SearchParamsSource
): TodoQueryParams => decodeQueryParams(searchParams, todoQueryParamConfig);
export const createTodoQueryString = (values: TodoQueryParams): string =>
createQueryString(values, todoQueryParamConfig);
+8 -2
View File
@@ -11,6 +11,12 @@ export const copyToClipboard = async (str, rootEl?: HTMLElement) => {
}
const root = rootEl || deepActiveElement()?.getRootNode() || document.body;
// A document node cannot have a textarea appended directly (only the single
// documentElement is allowed), so fall back to its body. Shadow roots and
// elements can hold the textarea directly, which keeps execCommand working
// inside dialogs that trap focus.
const container: Node =
root.nodeType === Node.DOCUMENT_NODE ? document.body : root;
const el = document.createElement("textarea");
el.value = str;
@@ -19,8 +25,8 @@ export const copyToClipboard = async (str, rootEl?: HTMLElement) => {
el.style.top = "0";
el.style.left = "0";
el.style.opacity = "0";
root.appendChild(el);
container.appendChild(el);
el.select();
document.execCommand("copy");
root.removeChild(el);
container.removeChild(el);
};
@@ -0,0 +1,29 @@
/**
* Records like the entity, device, area and floor registries are re-fetched and
* rebuilt in full on every registry-updated event, producing brand-new objects
* for every item even when nothing relevant changed. That gives every item a new
* reference, so all consumers needlessly re-render.
*
* Returns `next` with each item replaced by the equal `previous` item, so
* unchanged items keep their object identity, and returns the `previous` record
* untouched when nothing changed at all (so the update can be skipped entirely).
*/
export const preserveUnchangedRecord = <T>(
previous: Record<string, T> | undefined,
next: Record<string, T>,
equal: (a: T, b: T) => boolean
): Record<string, T> => {
if (!previous) {
return next;
}
let changed = Object.keys(previous).length !== Object.keys(next).length;
for (const key of Object.keys(next)) {
const previousItem = previous[key];
if (previousItem !== undefined && equal(previousItem, next[key])) {
next[key] = previousItem;
} else {
changed = true;
}
}
return changed ? next : previous;
};
+30
View File
@@ -0,0 +1,30 @@
import { ResizeController } from "@lit-labs/observers/resize-controller";
import type { ReactiveControllerHost } from "lit";
import { clamp } from "../number/clamp";
// Count columns from the container's real width (not the viewport) so a
// docked sidebar is accounted for, like the dashboard sections view.
const MIN_COLUMN_WIDTH = 320;
const DEFAULT_COLUMN_GAP = 16;
const parsePx = (value: string) => parseInt(value, 10) || 0;
export const createColumnsController = (
host: ReactiveControllerHost & Element,
maxColumns: number
) =>
new ResizeController<number>(host, {
target: null,
skipInitial: true,
callback: (entries) => {
const entry = entries[0];
if (!entry) {
return maxColumns;
}
const width = entry.contentRect.width;
const gap =
parsePx(getComputedStyle(entry.target).columnGap) || DEFAULT_COLUMN_GAP;
const columns = Math.floor((width + gap) / (MIN_COLUMN_WIDTH + gap));
return clamp(columns, 1, maxColumns);
},
});
+26
View File
@@ -0,0 +1,26 @@
/**
* Return a shallow copy of an object with every key removed whose value is
* `undefined` or equals that key's default, so a key left at its default
* (whether absent or explicit) does not count as a difference. A key's default
* comes from `defaults` when present, otherwise `false`.
*
* Non-plain-object values are returned unchanged; only top-level keys are
* compared.
*/
export const stripDefaults = <T>(
value: T,
defaults?: Record<string, unknown>
): T => {
if (value === null || typeof value !== "object" || Array.isArray(value)) {
return value;
}
const result: Record<string, unknown> = {};
for (const [key, val] of Object.entries(value)) {
const defaultValue = defaults && key in defaults ? defaults[key] : false;
if (val === undefined || val === defaultValue) {
continue;
}
result[key] = val;
}
return result as T;
};
@@ -4,10 +4,10 @@ type ResultCache<T> = Record<string, Promise<T> | undefined>;
/**
* Call a function with result caching per entity.
* @param cacheKey key to store the cache on hass object
* @param cacheKey key to namespace the cache
* @param cacheTime time to cache the results
* @param func function to fetch the data
* @param hass Home Assistant object
* @param hass Home Assistant object (or slice) the cache is keyed on
* @param entityId entity to fetch data for
* @param args extra arguments to pass to the function to fetch the data
* @returns
@@ -15,8 +15,12 @@ type ResultCache<T> = Record<string, Promise<T> | undefined>;
export const timeCacheEntityPromiseFunc = async <T>(
cacheKey: string,
cacheTime: number,
func: (hass: HomeAssistant, entityId: string, ...args: any[]) => Promise<T>,
hass: HomeAssistant,
func: (
hass: Pick<HomeAssistant, "callWS" | "hassUrl">,
entityId: string,
...args: any[]
) => Promise<T>,
hass: Pick<HomeAssistant, "callWS" | "hassUrl">,
entityId: string,
...args: any[]
): Promise<T> => {
@@ -39,11 +43,11 @@ export const timeCacheEntityPromiseFunc = async <T>(
// When successful, set timer to clear cache
() =>
setTimeout(() => {
cache![entityId] = undefined;
cache[entityId] = undefined;
}, cacheTime),
// On failure, clear cache right away
() => {
cache![entityId] = undefined;
cache[entityId] = undefined;
}
);
@@ -16,14 +16,12 @@ interface CacheResult<T> {
* @param args extra arguments to pass to the function to fetch the data
* @returns
*/
export const timeCachePromiseFunc = async <T>(
export const timeCachePromiseFunc = async <T, H = HomeAssistant>(
cacheKey: string,
cacheTime: number,
func: (hass: HomeAssistant, ...args: any[]) => Promise<T>,
generateCacheKey:
| ((hass: HomeAssistant, lastResult: T) => unknown)
| undefined,
hass: HomeAssistant,
func: (hass: H, ...args: any[]) => Promise<T>,
generateCacheKey: ((hass: H, lastResult: T) => unknown) | undefined,
hass: H,
...args: any[]
): Promise<T> => {
const anyHass = hass as any;
@@ -0,0 +1,144 @@
import type { UnsubscribeFunc } from "home-assistant-js-websocket";
import type { PropertyValues } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { debounce } from "../../common/util/debounce";
import type { Condition } from "../../data/automation";
import { subscribeCondition } from "../../data/automation";
import type { HomeAssistant } from "../../types";
import "../ha-tooltip";
import "./ha-automation-row-live-test";
import type { LiveTestState } from "./ha-automation-row-live-test";
@customElement("ha-automation-condition-live-test")
export class HaAutomationConditionLiveTest extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false }) public condition!: Condition;
@state() private _liveTestResult: {
state: LiveTestState;
message?: string;
} = { state: "unknown" };
private _conditionUnsub?: Promise<UnsubscribeFunc>;
public connectedCallback(): void {
super.connectedCallback();
this._subscribeCondition();
}
protected override updated(changedProps: PropertyValues<this>): void {
super.updated(changedProps);
if (
changedProps.has("condition") &&
changedProps.get("condition") !== undefined
) {
this._resetSubscription();
this._debounceSubscribeCondition();
}
}
public disconnectedCallback() {
super.disconnectedCallback();
this._debounceSubscribeCondition.cancel();
this._resetSubscription();
}
protected render() {
return html`
<div id="indicator">
<slot></slot>
<ha-automation-row-live-test
.state=${this._liveTestResult.state}
.label=${this.hass.localize(
`ui.panel.config.automation.editor.conditions.live_test_state.${this._liveTestResult.state}`
)}
></ha-automation-row-live-test>
</div>
${this._liveTestResult.message
? html`<ha-tooltip for="indicator"
>${this._liveTestResult.message}</ha-tooltip
>`
: nothing}
`;
}
private _resetSubscription() {
this._liveTestResult = {
state: "unknown",
message: this.hass.localize(
"ui.panel.config.automation.editor.conditions.live_test_state.unknown"
),
};
if (this._conditionUnsub) {
this._conditionUnsub.then((unsub) => unsub());
this._conditionUnsub = undefined;
}
}
private _debounceSubscribeCondition = debounce(
() => this._subscribeCondition(),
500
);
private async _subscribeCondition() {
this._resetSubscription();
if (!this.condition) {
return;
}
const conditionUnsub = subscribeCondition(
this.hass.connection,
(result) => {
if (result.error) {
this._handleLiveTestError(result.error);
} else {
this._liveTestResult = {
state: result.result ? "pass" : "fail",
message: this.hass.localize(
`ui.panel.config.automation.editor.conditions.testing_${result.result ? "pass" : "error"}`
),
};
}
},
this.condition
);
conditionUnsub.catch((err: any) => {
this._handleLiveTestError(err);
if (this._conditionUnsub === conditionUnsub) {
this._conditionUnsub = undefined;
}
});
this._conditionUnsub = conditionUnsub;
}
private _handleLiveTestError(error: any) {
const invalid =
typeof error !== "string" && error.code === "invalid_format";
this._liveTestResult = {
state: invalid ? "invalid" : "unknown",
message: this.hass.localize(
`ui.panel.config.automation.editor.conditions.${invalid ? "invalid_condition" : "live_test_state.unknown"}`
),
};
}
static styles = css`
:host {
display: inline-flex;
position: relative;
}
#indicator {
display: inline-flex;
position: relative;
}
`;
}
declare global {
interface HTMLElementTagNameMap {
"ha-automation-condition-live-test": HaAutomationConditionLiveTest;
}
}
@@ -1,5 +1,12 @@
import { LitElement, css, html } from "lit";
import { customElement, property } from "lit/decorators";
import "../ha-svg-icon";
import {
mdiAlertCircle,
mdiCheckCircle,
mdiCloseCircle,
mdiHelpCircle,
} from "@mdi/js";
export type LiveTestState = "pass" | "fail" | "invalid" | "unknown";
@@ -19,46 +26,59 @@ export class HaAutomationRowLiveTest extends LitElement {
@property() public label = "";
private get _iconPath() {
switch (this.state) {
case "pass":
return mdiCheckCircle;
case "fail":
return mdiCloseCircle;
case "invalid":
return mdiAlertCircle;
default:
return mdiHelpCircle;
}
}
protected render() {
return html`
<div
id="indicator"
role="status"
tabindex="0"
aria-label=${this.label}
></div>
<div id="indicator" role="status" tabindex="0" aria-label=${this.label}>
<ha-svg-icon .path=${this._iconPath}></ha-svg-icon>
</div>
`;
}
static styles = css`
:host {
position: absolute;
top: -5px;
inset-inline-end: -6px;
top: -8px;
inset-inline-end: -8px;
display: inline-block;
}
#indicator {
width: 10px;
height: 10px;
width: 16px;
height: 16px;
display: grid;
place-items: center;
border-radius: var(--ha-border-radius-circle);
border: var(--ha-border-width-md) solid;
box-sizing: border-box;
background-color: var(--card-background-color);
box-shadow: 0 0 0 2px var(--card-background-color);
transition: all var(--ha-animation-duration-normal) ease-in-out;
}
#indicator ha-svg-icon {
width: 16px;
height: 16px;
--mdc-icon-size: 16px;
}
:host([state="pass"]) #indicator {
background-color: var(--ha-color-green-60);
border-color: var(--ha-color-green-60);
color: var(--ha-color-green-60);
}
:host([state="fail"]) #indicator {
border-color: var(--ha-color-orange-60);
color: var(--ha-color-orange-60);
}
:host([state="invalid"]) #indicator {
border-color: var(--ha-color-red-60);
color: var(--ha-color-red-60);
}
:host([state="unknown"]) #indicator {
border-color: var(--ha-color-neutral-60);
color: var(--ha-color-neutral-60);
}
`;
}
@@ -161,6 +161,8 @@ export class HaAutomationRow extends LitElement {
}
.leading-icon-wrapper {
padding-top: var(--ha-space-3);
position: relative;
z-index: 1;
}
::slotted([slot="leading-icon"]) {
color: var(--ha-color-on-neutral-quiet);
+86 -46
View File
@@ -1,5 +1,23 @@
import type { LineSeriesOption } from "echarts";
type Point = NonNullable<LineSeriesOption["data"]>[number];
interface MeanFrame {
sumX: number;
sumY: number;
count: number;
isArray: boolean;
}
interface MinMaxFrame {
minPoint: Point;
minX: number;
minY: number;
maxPoint: Point;
maxX: number;
maxY: number;
}
export function downSampleLineData<
T extends [number, number] | NonNullable<LineSeriesOption["data"]>[number],
>(
@@ -19,11 +37,47 @@ export function downSampleLineData<
const max = maxX ?? getPointData(data[data.length - 1]!)[0];
const step = Math.ceil((max - min) / Math.floor(maxDetails));
// Group points into frames
const frames = new Map<
number,
{ point: (typeof data)[number]; x: number; y: number }[]
>();
if (useMean) {
// Group points into frames, accumulating sums in insertion order.
const frames = new Map<number, MeanFrame>();
for (const point of data) {
const pointData = getPointData(point);
if (!Array.isArray(pointData)) continue;
const x = Number(pointData[0]);
const y = Number(pointData[1]);
if (isNaN(x) || isNaN(y)) continue;
const frameIndex = Math.floor((x - min) / step);
const frame = frames.get(frameIndex);
if (!frame) {
frames.set(frameIndex, {
sumX: x,
sumY: y,
count: 1,
isArray: Array.isArray(pointData),
});
} else {
frame.sumX += x;
frame.sumY += y;
frame.count += 1;
}
}
const result: T[] = [];
for (const frame of frames.values()) {
const meanX = frame.sumX / frame.count;
const meanY = frame.sumY / frame.count;
const meanPoint = (
frame.isArray ? [meanX, meanY] : { value: [meanX, meanY] }
) as T;
result.push(meanPoint);
}
return result;
}
// Min/max mode: track the min and max point per frame in insertion order.
const frames = new Map<number, MinMaxFrame>();
for (const point of data) {
const pointData = getPointData(point);
@@ -35,53 +89,39 @@ export function downSampleLineData<
const frameIndex = Math.floor((x - min) / step);
const frame = frames.get(frameIndex);
if (!frame) {
frames.set(frameIndex, [{ point, x, y }]);
frames.set(frameIndex, {
minPoint: point,
minX: x,
minY: y,
maxPoint: point,
maxX: x,
maxY: y,
});
} else {
frame.push({ point, x, y });
// Match the original strict-less / strict-greater comparisons so the
// first occurrence wins on ties.
if (y < frame.minY) {
frame.minPoint = point;
frame.minX = x;
frame.minY = y;
}
if (y > frame.maxY) {
frame.maxPoint = point;
frame.maxX = x;
frame.maxY = y;
}
}
}
// Convert frames back to points
const result: T[] = [];
if (useMean) {
// Use mean values for each frame
for (const [_i, framePoints] of frames) {
const sumY = framePoints.reduce((acc, p) => acc + p.y, 0);
const meanY = sumY / framePoints.length;
const sumX = framePoints.reduce((acc, p) => acc + p.x, 0);
const meanX = sumX / framePoints.length;
const firstPoint = framePoints[0].point;
const pointData = getPointData(firstPoint);
const meanPoint = (
Array.isArray(pointData) ? [meanX, meanY] : { value: [meanX, meanY] }
) as T;
result.push(meanPoint);
for (const frame of frames.values()) {
// The order of the data must be preserved so max may be before min
if (frame.minX > frame.maxX) {
result.push(frame.maxPoint as T);
}
} else {
// Use min/max values for each frame
for (const [_i, framePoints] of frames) {
let minPoint = framePoints[0];
let maxPoint = framePoints[0];
for (const p of framePoints) {
if (p.y < minPoint.y) {
minPoint = p;
}
if (p.y > maxPoint.y) {
maxPoint = p;
}
}
// The order of the data must be preserved so max may be before min
if (minPoint.x > maxPoint.x) {
result.push(maxPoint.point);
}
result.push(minPoint.point);
if (minPoint.x < maxPoint.x) {
result.push(maxPoint.point);
}
result.push(frame.minPoint as T);
if (frame.minX < frame.maxX) {
result.push(frame.maxPoint as T);
}
}
+36 -5
View File
@@ -394,6 +394,18 @@ export class HaChartBase extends LitElement {
return nothing;
}
const datasets = ensureArray(this.data!);
// Index datasets by id and name so each legend item is an O(1) lookup
// instead of scanning every dataset twice. Charts can have many series.
const datasetById = new Map<unknown, (typeof datasets)[number]>();
const datasetByName = new Map<unknown, (typeof datasets)[number]>();
for (const dataset of datasets) {
if (dataset.id !== undefined && !datasetById.has(dataset.id)) {
datasetById.set(dataset.id, dataset);
}
if (dataset.name !== undefined && !datasetByName.has(dataset.name)) {
datasetByName.set(dataset.name, dataset);
}
}
const isMobile = window.matchMedia(
"all and (max-width: 450px), all and (max-height: 500px)"
@@ -413,10 +425,10 @@ export class HaChartBase extends LitElement {
return nothing;
}
let itemStyle: Record<string, any> = {};
let id = "";
let value = "";
let noLabelClick = false;
const name = typeof item === "string" ? item : (item.name ?? "");
let id: string;
if (typeof item === "string") {
id = item;
} else {
@@ -426,9 +438,7 @@ export class HaChartBase extends LitElement {
noLabelClick = item.noLabelClick ?? false;
}
const labelClickable = this.clickLabelForMoreInfo && !noLabelClick;
const dataset =
datasets.find((d) => d.id === id) ??
datasets.find((d) => d.name === id);
const dataset = datasetById.get(id) ?? datasetByName.get(id);
itemStyle = {
color: dataset?.color as string,
...(dataset?.itemStyle as { borderColor?: string }),
@@ -1520,7 +1530,9 @@ export class HaChartBase extends LitElement {
text-overflow: ellipsis;
white-space: nowrap;
overflow: hidden;
line-height: 1;
/* overflow: hidden clips descenders (e.g. "g", parentheses) with a tight
line-height, so give the line box room to contain them */
line-height: var(--ha-line-height-condensed);
}
@media (hover: hover) {
.chart-legend .label.clickable:hover {
@@ -1558,6 +1570,25 @@ export class HaChartBase extends LitElement {
.chart-legend .legend-toggle ha-svg-icon {
--mdc-icon-size: 18px;
}
/* On touch devices, enlarge the toggle tap target via taller rows and
leading padding (which also separates it from the previous item), while
keeping the icon tight to its own label so the pairing stays clear.
Drop the now-pointless row gap and li padding. */
@media (pointer: coarse) {
.chart-legend ul {
row-gap: 0;
}
/* Only grow the toggle rows, not the expand/collapse chip's row. */
.chart-legend li:has(.legend-toggle) {
height: 40px;
padding: 0;
}
.chart-legend .legend-toggle {
padding: 11px;
padding-inline-end: 4px;
margin: 0;
}
}
ha-assist-chip {
height: 100%;
--_label-text-weight: 500;
@@ -0,0 +1,481 @@
import type { LineSeriesOption } from "echarts/charts";
import type { VisualMapComponentOption } from "echarts/components";
import { getGraphColorByIndex } from "../../common/color/colors";
import { CLIMATE_HVAC_ACTION_TO_MODE } from "../../data/climate";
import type { LineChartEntity, LineChartState } from "../../data/history";
import type { HomeAssistant } from "../../types";
import { computeYAxisFractionDigits } from "./y-axis-fraction-digits";
const safeParseFloat = (value) => {
const parsed = parseFloat(value);
return isFinite(parsed) ? parsed : null;
};
export const CLIMATE_MODE_CONFIGS = [
{ mode: "heat", action: "heating", cssVar: "--state-climate-heat-color" },
{ mode: "cool", action: "cooling", cssVar: "--state-climate-cool-color" },
{ mode: "dry", action: "drying", cssVar: "--state-climate-dry-color" },
{ mode: "fan_only", action: "fan", cssVar: "--state-climate-fan_only-color" },
] as const;
export interface StateHistoryChartLineDataParams {
hass: HomeAssistant;
data: LineChartEntity[];
endTime: Date;
names?: Record<string, string>;
colors?: Record<string, string | undefined>;
showNames: boolean;
computedStyles: CSSStyleDeclaration;
now: Date;
}
export interface StateHistoryChartLineData {
datasets: LineSeriesOption[];
entityIds: string[];
datasetToDataIndex: number[];
visualMap?: VisualMapComponentOption[];
yAxisFractionDigits: number;
}
/**
* Transforms processed history (`LineChartEntity[]`) into ECharts series for
* `state-history-chart-line`. Pure data processing: all environment inputs
* (current time, theme style, hass) are injected so the transform is
* deterministic and benchmarkable.
*/
export function generateStateHistoryChartLineData(
params: StateHistoryChartLineDataParams
): StateHistoryChartLineData | undefined {
const { hass, computedStyles, endTime } = params;
// Work with numeric epoch timestamps (ms) instead of Date objects below.
// Charts can hold a huge number of points, and allocating a Date per point
// is needless GC pressure; the "time" axis consumes numbers natively.
const endTimeMs = endTime.getTime();
let colorIndex = 0;
const entityStates = params.data;
const datasets: LineSeriesOption[] = [];
const entityIds: string[] = [];
const datasetToDataIndex: number[] = [];
let yMin = Infinity;
let yMax = -Infinity;
const trackY = (v: number | null | undefined) => {
if (typeof v === "number" && Number.isFinite(v)) {
if (v < yMin) yMin = v;
if (v > yMax) yMax = v;
}
};
if (entityStates.length === 0) {
return undefined;
}
const names = params.names || {};
const colors = params.colors || {};
entityStates.forEach((states, dataIdx) => {
const domain = states.domain;
const name = names[states.entity_id] || states.name;
const color = colors[states.entity_id];
// array containing [value1, value2, etc]
let prevValues: any[] | null = null;
const data: LineSeriesOption[] = [];
const pushData = (timestamp: number, datavalues: any[] | null) => {
if (!datavalues) return;
if (timestamp > endTimeMs) {
// Drop data points that are after the requested endTime. This could happen if
// endTime is "now" and client time is not in sync with server time.
return;
}
data.forEach((d, i) => {
if (datavalues[i] === null && prevValues && prevValues[i] !== null) {
// null data values show up as gaps in the chart.
// If the current value for the dataset is null and the previous
// value of the data set is not null, then add an 'end' point
// to the chart for the previous value. Otherwise the gap will
// be too big. It will go from the start of the previous data
// value until the start of the next data value.
d.data!.push([timestamp, prevValues[i]]);
}
d.data!.push([timestamp, datavalues[i]]);
trackY(datavalues[i]);
});
prevValues = datavalues;
};
const addDataSet = (
id: string,
nameY: string,
clr?: string,
fill = false
) => {
if (!clr) {
clr = getGraphColorByIndex(colorIndex, computedStyles);
colorIndex++;
}
data.push({
id,
data: [],
type: "line",
cursor: "default",
name: nameY,
color: clr,
symbol: "circle",
symbolSize: 1,
step: "end",
sampling: "minmax",
animationDurationUpdate: 0,
lineStyle: {
width: fill ? 0 : 1.5,
},
areaStyle: fill
? {
color: clr + "7F",
}
: undefined,
tooltip: {
show: !fill,
},
});
entityIds.push(states.entity_id);
datasetToDataIndex.push(dataIdx);
};
if (
domain === "thermostat" ||
domain === "climate" ||
domain === "water_heater"
) {
const hasHvacAction = states.states.some(
(entityState) => entityState.attributes?.hvac_action
);
const activeModes = CLIMATE_MODE_CONFIGS.map(
({ mode, action, cssVar }) => {
const isActive =
domain === "climate" && hasHvacAction
? (entityState: LineChartState) =>
CLIMATE_HVAC_ACTION_TO_MODE[
entityState.attributes?.hvac_action
] === mode
: (entityState: LineChartState) => entityState.state === mode;
return { action, cssVar, isActive };
}
).filter(({ isActive }) => states.states.some(isActive));
// We differentiate between thermostats that have a target temperature
// range versus ones that have just a target temperature
// Using step chart by step-before so manually interpolation not needed.
const hasTargetRange = states.states.some(
(entityState) =>
entityState.attributes &&
entityState.attributes.target_temp_high !==
entityState.attributes.target_temp_low
);
addDataSet(
states.entity_id + "-current_temperature",
params.showNames
? hass.localize("ui.card.climate.current_temperature", {
name: name,
})
: hass.localize(
"component.climate.entity_component._.state_attributes.current_temperature.name"
)
);
for (const { action, cssVar } of activeModes) {
addDataSet(
`${states.entity_id}-${action}`,
params.showNames
? hass.localize(`ui.card.climate.${action}`, {
name: name,
})
: hass.localize(
`component.climate.entity_component._.state_attributes.hvac_action.state.${action}`
),
computedStyles.getPropertyValue(cssVar),
true
);
}
if (hasTargetRange) {
addDataSet(
states.entity_id + "-target_temperature_mode",
params.showNames
? hass.localize("ui.card.climate.target_temperature_mode", {
name: name,
mode: hass.localize("ui.card.climate.high"),
})
: hass.localize(
"component.climate.entity_component._.state_attributes.target_temp_high.name"
)
);
addDataSet(
states.entity_id + "-target_temperature_mode_low",
params.showNames
? hass.localize("ui.card.climate.target_temperature_mode", {
name: name,
mode: hass.localize("ui.card.climate.low"),
})
: hass.localize(
"component.climate.entity_component._.state_attributes.target_temp_low.name"
)
);
} else {
addDataSet(
states.entity_id + "-target_temperature",
params.showNames
? hass.localize("ui.card.climate.target_temperature_entity", {
name: name,
})
: hass.localize(
"component.climate.entity_component._.state_attributes.temperature.name"
)
);
}
states.states.forEach((entityState) => {
if (!entityState.attributes) return;
const curTemp = safeParseFloat(
entityState.attributes.current_temperature
);
const series = [curTemp];
for (const { isActive } of activeModes) {
series.push(isActive(entityState) ? curTemp : null);
}
if (hasTargetRange) {
const targetHigh = safeParseFloat(
entityState.attributes.target_temp_high
);
const targetLow = safeParseFloat(
entityState.attributes.target_temp_low
);
series.push(targetHigh, targetLow);
pushData(entityState.last_changed, series);
} else {
const target = safeParseFloat(entityState.attributes.temperature);
series.push(target);
pushData(entityState.last_changed, series);
}
});
} else if (domain === "humidifier") {
const hasAction = states.states.some(
(entityState) => entityState.attributes?.action
);
const hasCurrent = states.states.some(
(entityState) => entityState.attributes?.current_humidity
);
const hasHumidifying =
hasAction &&
states.states.some(
(entityState: LineChartState) =>
entityState.attributes?.action === "humidifying"
);
const hasDrying =
hasAction &&
states.states.some(
(entityState: LineChartState) =>
entityState.attributes?.action === "drying"
);
addDataSet(
states.entity_id + "-target_humidity",
params.showNames
? hass.localize("ui.card.humidifier.target_humidity_entity", {
name: name,
})
: hass.localize(
"component.humidifier.entity_component._.state_attributes.humidity.name"
)
);
if (hasCurrent) {
addDataSet(
states.entity_id + "-current_humidity",
params.showNames
? hass.localize("ui.card.humidifier.current_humidity_entity", {
name: name,
})
: hass.localize(
"component.humidifier.entity_component._.state_attributes.current_humidity.name"
)
);
}
// If action attribute is available, we used it to shade the area below the humidity.
// If action attribute is not available, we shade the area when the device is on
if (hasHumidifying) {
addDataSet(
states.entity_id + "-humidifying",
params.showNames
? hass.localize("ui.card.humidifier.humidifying", {
name: name,
})
: hass.localize(
"component.humidifier.entity_component._.state_attributes.action.state.humidifying"
),
computedStyles.getPropertyValue("--state-humidifier-on-color"),
true
);
} else if (hasDrying) {
addDataSet(
states.entity_id + "-drying",
params.showNames
? hass.localize("ui.card.humidifier.drying", {
name: name,
})
: hass.localize(
"component.humidifier.entity_component._.state_attributes.action.state.drying"
),
computedStyles.getPropertyValue("--state-humidifier-on-color"),
true
);
} else {
addDataSet(
states.entity_id + "-on",
params.showNames
? hass.localize("ui.card.humidifier.on_entity", {
name: name,
})
: hass.localize("component.humidifier.entity_component._.state.on"),
undefined,
true
);
}
states.states.forEach((entityState) => {
if (!entityState.attributes) return;
const target = safeParseFloat(entityState.attributes.humidity);
// If the current humidity is not available, then we fill up to the target humidity
const current = hasCurrent
? safeParseFloat(entityState.attributes?.current_humidity)
: target;
const series = [target];
if (hasCurrent) {
series.push(current);
}
if (hasHumidifying) {
series.push(
entityState.attributes?.action === "humidifying" ? current : null
);
} else if (hasDrying) {
series.push(
entityState.attributes?.action === "drying" ? current : null
);
} else {
series.push(entityState.state === "on" ? current : null);
}
pushData(entityState.last_changed, series);
});
} else {
addDataSet(states.entity_id, name, color);
let lastValue: number;
let lastDate: number;
let lastNullDate: number | null = null;
// Process chart data.
// When state is `unknown`, calculate the value and break the line.
const processData = (entityState: LineChartState) => {
const value = safeParseFloat(entityState.state);
const date = entityState.last_changed;
if (value !== null && lastNullDate) {
const tmpValue =
(value - lastValue) *
((lastNullDate - lastDate) / (date - lastDate)) +
lastValue;
pushData(lastNullDate, [tmpValue]);
pushData(lastNullDate + 1, [null]);
pushData(date, [value]);
lastDate = date;
lastValue = value;
lastNullDate = null;
} else if (value !== null && lastNullDate === null) {
pushData(date, [value]);
lastDate = date;
lastValue = value;
} else if (
value === null &&
lastNullDate === null &&
lastValue !== undefined
) {
lastNullDate = date;
}
};
if (states.statistics) {
const stopTime =
!states.states || states.states.length === 0
? 0
: states.states[0].last_changed;
for (const statistic of states.statistics) {
if (stopTime && statistic.last_changed >= stopTime) {
break;
}
processData(statistic);
}
}
states.states.forEach((entityState) => {
processData(entityState);
});
if (lastNullDate !== null) {
pushData(lastNullDate, [null]);
}
}
// Add an entry for final values
pushData(endTimeMs, prevValues);
// For sensors, append current state if viewing recent data
const nowMs = params.now.getTime();
// allow 1s of leeway for "now"
const isUpToNow = nowMs - endTimeMs <= 1000;
if (domain === "sensor" && isUpToNow && data.length === 1) {
const stateObj = hass.states[states.entity_id];
const currentValue = stateObj ? safeParseFloat(stateObj.state) : null;
if (currentValue !== null) {
data[0].data!.push([nowMs, currentValue]);
trackY(currentValue);
}
}
// Concat two arrays
Array.prototype.push.apply(datasets, data);
});
const visualMap: VisualMapComponentOption[] = [];
datasets.forEach((_, seriesIndex) => {
const dataIndex = datasetToDataIndex[seriesIndex];
const data = entityStates[dataIndex];
if (!data.statistics || data.statistics.length === 0) {
return;
}
// render stat data with a slightly transparent line
const firstStateTS = data.states[0]?.last_changed ?? endTime.getTime();
visualMap.push({
show: false,
seriesIndex,
dimension: 0,
pieces: [
{
max: firstStateTS - 0.01,
colorAlpha: 0.5,
},
{
min: firstStateTS,
colorAlpha: 1,
},
],
});
});
return {
datasets,
entityIds,
datasetToDataIndex,
visualMap: visualMap.length > 0 ? visualMap : undefined,
yAxisFractionDigits: computeYAxisFractionDigits(yMin, yMax),
};
}
+33 -451
View File
@@ -5,15 +5,17 @@ import type { VisualMapComponentOption } from "echarts/components";
import type { LineSeriesOption } from "echarts/charts";
import type { YAXisOption } from "echarts/types/dist/shared";
import { styleMap } from "lit/directives/style-map";
import { getGraphColorByIndex } from "../../common/color/colors";
import { computeRTL } from "../../common/util/compute_rtl";
import type { LineChartEntity, LineChartState } from "../../data/history";
import type { LineChartEntity } from "../../data/history";
import type { HomeAssistant } from "../../types";
import { MIN_TIME_BETWEEN_UPDATES } from "./ha-chart-base";
import { sideTooltipPosition } from "./chart-tooltip-position";
import "./ha-chart-tooltip-marker";
import { computeYAxisFractionDigits } from "./y-axis-fraction-digits";
import {
CLIMATE_MODE_CONFIGS,
generateStateHistoryChartLineData,
} from "./state-history-chart-line-data";
import type { HaECOption } from "../../resources/echarts/echarts";
import { formatDateTimeWithSeconds } from "../../common/datetime/format_date_time";
import {
@@ -23,22 +25,9 @@ import {
import { measureTextWidth } from "../../util/text";
import type { HASSDomEvent } from "../../common/dom/fire_event";
import { fireEvent } from "../../common/dom/fire_event";
import { CLIMATE_HVAC_ACTION_TO_MODE } from "../../data/climate";
import { blankBeforeUnit } from "../../common/translations/blank_before_unit";
import { computeAttributeValueDisplay } from "../../common/entity/compute_attribute_display";
const safeParseFloat = (value) => {
const parsed = parseFloat(value);
return isFinite(parsed) ? parsed : null;
};
const CLIMATE_MODE_CONFIGS = [
{ mode: "heat", action: "heating", cssVar: "--state-climate-heat-color" },
{ mode: "cool", action: "cooling", cssVar: "--state-climate-cool-color" },
{ mode: "dry", action: "drying", cssVar: "--state-climate-dry-color" },
{ mode: "fan_only", action: "fan", cssVar: "--state-climate-fan_only-color" },
] as const;
// Used to recover the underlying entity_id from a legend dataset id.
// Kept in sync with the suffixes appended at dataset construction below
// for climate / water_heater / humidifier multi-attribute charts.
@@ -147,6 +136,14 @@ export class StateHistoryChartLine extends LitElement {
this.hass.config
);
const datapoints: Record<string, any>[] = [];
// Index the hovered points by series so the per-dataset lookup below is
// O(1) instead of scanning `params` for every dataset on each mouse move.
const paramsBySeriesIndex = new Map<number, Record<string, any>>();
for (const p of params) {
if (!paramsBySeriesIndex.has(p.seriesIndex)) {
paramsBySeriesIndex.set(p.seriesIndex, p);
}
}
this._chartData.forEach((dataset, index) => {
if (
dataset.tooltip?.show === false ||
@@ -154,9 +151,7 @@ export class StateHistoryChartLine extends LitElement {
) {
return;
}
const param = params.find(
(p: Record<string, any>) => p.seriesIndex === index
);
const param = paramsBySeriesIndex.get(index);
if (param) {
datapoints.push(param);
return;
@@ -420,445 +415,32 @@ export class StateHistoryChartLine extends LitElement {
}
private _generateData() {
let colorIndex = 0;
const computedStyles = getComputedStyle(this);
const entityStates = this.data;
const datasets: LineSeriesOption[] = [];
const entityIds: string[] = [];
const datasetToDataIndex: number[] = [];
let yMin = Infinity;
let yMax = -Infinity;
const trackY = (v: number | null | undefined) => {
if (typeof v === "number" && Number.isFinite(v)) {
if (v < yMin) yMin = v;
if (v > yMax) yMax = v;
}
};
if (entityStates.length === 0) {
if (this.data.length === 0) {
return;
}
this._chartTime = new Date();
const endTime = this.endTime;
const names = this.names || {};
const colors = this.colors || {};
entityStates.forEach((states, dataIdx) => {
const domain = states.domain;
const name = names[states.entity_id] || states.name;
const color = colors[states.entity_id];
// array containing [value1, value2, etc]
let prevValues: any[] | null = null;
const data: LineSeriesOption[] = [];
const pushData = (timestamp: Date, datavalues: any[] | null) => {
if (!datavalues) return;
if (timestamp > endTime) {
// Drop data points that are after the requested endTime. This could happen if
// endTime is "now" and client time is not in sync with server time.
return;
}
data.forEach((d, i) => {
if (datavalues[i] === null && prevValues && prevValues[i] !== null) {
// null data values show up as gaps in the chart.
// If the current value for the dataset is null and the previous
// value of the data set is not null, then add an 'end' point
// to the chart for the previous value. Otherwise the gap will
// be too big. It will go from the start of the previous data
// value until the start of the next data value.
d.data!.push([timestamp, prevValues[i]]);
}
d.data!.push([timestamp, datavalues[i]]);
trackY(datavalues[i]);
});
prevValues = datavalues;
};
const addDataSet = (
id: string,
nameY: string,
clr?: string,
fill = false
) => {
if (!clr) {
clr = getGraphColorByIndex(colorIndex, computedStyles);
colorIndex++;
}
data.push({
id,
data: [],
type: "line",
cursor: "default",
name: nameY,
color: clr,
symbol: "circle",
symbolSize: 1,
step: "end",
sampling: "minmax",
animationDurationUpdate: 0,
lineStyle: {
width: fill ? 0 : 1.5,
},
areaStyle: fill
? {
color: clr + "7F",
}
: undefined,
tooltip: {
show: !fill,
},
});
entityIds.push(states.entity_id);
datasetToDataIndex.push(dataIdx);
};
if (
domain === "thermostat" ||
domain === "climate" ||
domain === "water_heater"
) {
const hasHvacAction = states.states.some(
(entityState) => entityState.attributes?.hvac_action
);
const activeModes = CLIMATE_MODE_CONFIGS.map(
({ mode, action, cssVar }) => {
const isActive =
domain === "climate" && hasHvacAction
? (entityState: LineChartState) =>
CLIMATE_HVAC_ACTION_TO_MODE[
entityState.attributes?.hvac_action
] === mode
: (entityState: LineChartState) => entityState.state === mode;
return { action, cssVar, isActive };
}
).filter(({ isActive }) => states.states.some(isActive));
// We differentiate between thermostats that have a target temperature
// range versus ones that have just a target temperature
// Using step chart by step-before so manually interpolation not needed.
const hasTargetRange = states.states.some(
(entityState) =>
entityState.attributes &&
entityState.attributes.target_temp_high !==
entityState.attributes.target_temp_low
);
addDataSet(
states.entity_id + "-current_temperature",
this.showNames
? this.hass.localize("ui.card.climate.current_temperature", {
name: name,
})
: this.hass.localize(
"component.climate.entity_component._.state_attributes.current_temperature.name"
)
);
for (const { action, cssVar } of activeModes) {
addDataSet(
`${states.entity_id}-${action}`,
this.showNames
? this.hass.localize(`ui.card.climate.${action}`, {
name: name,
})
: this.hass.localize(
`component.climate.entity_component._.state_attributes.hvac_action.state.${action}`
),
computedStyles.getPropertyValue(cssVar),
true
);
}
if (hasTargetRange) {
addDataSet(
states.entity_id + "-target_temperature_mode",
this.showNames
? this.hass.localize("ui.card.climate.target_temperature_mode", {
name: name,
mode: this.hass.localize("ui.card.climate.high"),
})
: this.hass.localize(
"component.climate.entity_component._.state_attributes.target_temp_high.name"
)
);
addDataSet(
states.entity_id + "-target_temperature_mode_low",
this.showNames
? this.hass.localize("ui.card.climate.target_temperature_mode", {
name: name,
mode: this.hass.localize("ui.card.climate.low"),
})
: this.hass.localize(
"component.climate.entity_component._.state_attributes.target_temp_low.name"
)
);
} else {
addDataSet(
states.entity_id + "-target_temperature",
this.showNames
? this.hass.localize(
"ui.card.climate.target_temperature_entity",
{
name: name,
}
)
: this.hass.localize(
"component.climate.entity_component._.state_attributes.temperature.name"
)
);
}
states.states.forEach((entityState) => {
if (!entityState.attributes) return;
const curTemp = safeParseFloat(
entityState.attributes.current_temperature
);
const series = [curTemp];
for (const { isActive } of activeModes) {
series.push(isActive(entityState) ? curTemp : null);
}
if (hasTargetRange) {
const targetHigh = safeParseFloat(
entityState.attributes.target_temp_high
);
const targetLow = safeParseFloat(
entityState.attributes.target_temp_low
);
series.push(targetHigh, targetLow);
pushData(new Date(entityState.last_changed), series);
} else {
const target = safeParseFloat(entityState.attributes.temperature);
series.push(target);
pushData(new Date(entityState.last_changed), series);
}
});
} else if (domain === "humidifier") {
const hasAction = states.states.some(
(entityState) => entityState.attributes?.action
);
const hasCurrent = states.states.some(
(entityState) => entityState.attributes?.current_humidity
);
const hasHumidifying =
hasAction &&
states.states.some(
(entityState: LineChartState) =>
entityState.attributes?.action === "humidifying"
);
const hasDrying =
hasAction &&
states.states.some(
(entityState: LineChartState) =>
entityState.attributes?.action === "drying"
);
addDataSet(
states.entity_id + "-target_humidity",
this.showNames
? this.hass.localize("ui.card.humidifier.target_humidity_entity", {
name: name,
})
: this.hass.localize(
"component.humidifier.entity_component._.state_attributes.humidity.name"
)
);
if (hasCurrent) {
addDataSet(
states.entity_id + "-current_humidity",
this.showNames
? this.hass.localize(
"ui.card.humidifier.current_humidity_entity",
{
name: name,
}
)
: this.hass.localize(
"component.humidifier.entity_component._.state_attributes.current_humidity.name"
)
);
}
// If action attribute is available, we used it to shade the area below the humidity.
// If action attribute is not available, we shade the area when the device is on
if (hasHumidifying) {
addDataSet(
states.entity_id + "-humidifying",
this.showNames
? this.hass.localize("ui.card.humidifier.humidifying", {
name: name,
})
: this.hass.localize(
"component.humidifier.entity_component._.state_attributes.action.state.humidifying"
),
computedStyles.getPropertyValue("--state-humidifier-on-color"),
true
);
} else if (hasDrying) {
addDataSet(
states.entity_id + "-drying",
this.showNames
? this.hass.localize("ui.card.humidifier.drying", {
name: name,
})
: this.hass.localize(
"component.humidifier.entity_component._.state_attributes.action.state.drying"
),
computedStyles.getPropertyValue("--state-humidifier-on-color"),
true
);
} else {
addDataSet(
states.entity_id + "-on",
this.showNames
? this.hass.localize("ui.card.humidifier.on_entity", {
name: name,
})
: this.hass.localize(
"component.humidifier.entity_component._.state.on"
),
undefined,
true
);
}
states.states.forEach((entityState) => {
if (!entityState.attributes) return;
const target = safeParseFloat(entityState.attributes.humidity);
// If the current humidity is not available, then we fill up to the target humidity
const current = hasCurrent
? safeParseFloat(entityState.attributes?.current_humidity)
: target;
const series = [target];
if (hasCurrent) {
series.push(current);
}
if (hasHumidifying) {
series.push(
entityState.attributes?.action === "humidifying" ? current : null
);
} else if (hasDrying) {
series.push(
entityState.attributes?.action === "drying" ? current : null
);
} else {
series.push(entityState.state === "on" ? current : null);
}
pushData(new Date(entityState.last_changed), series);
});
} else {
addDataSet(states.entity_id, name, color);
let lastValue: number;
let lastDate: Date;
let lastNullDate: Date | null = null;
// Process chart data.
// When state is `unknown`, calculate the value and break the line.
const processData = (entityState: LineChartState) => {
const value = safeParseFloat(entityState.state);
const date = new Date(entityState.last_changed);
if (value !== null && lastNullDate) {
const dateTime = date.getTime();
const lastNullDateTime = lastNullDate.getTime();
const lastDateTime = lastDate?.getTime();
const tmpValue =
(value - lastValue) *
((lastNullDateTime - lastDateTime) /
(dateTime - lastDateTime)) +
lastValue;
pushData(lastNullDate, [tmpValue]);
pushData(new Date(lastNullDateTime + 1), [null]);
pushData(date, [value]);
lastDate = date;
lastValue = value;
lastNullDate = null;
} else if (value !== null && lastNullDate === null) {
pushData(date, [value]);
lastDate = date;
lastValue = value;
} else if (
value === null &&
lastNullDate === null &&
lastValue !== undefined
) {
lastNullDate = date;
}
};
if (states.statistics) {
const stopTime =
!states.states || states.states.length === 0
? 0
: states.states[0].last_changed;
for (const statistic of states.statistics) {
if (stopTime && statistic.last_changed >= stopTime) {
break;
}
processData(statistic);
}
}
states.states.forEach((entityState) => {
processData(entityState);
});
if (lastNullDate !== null) {
pushData(lastNullDate, [null]);
}
}
// Add an entry for final values
pushData(endTime, prevValues);
// For sensors, append current state if viewing recent data
const now = new Date();
// allow 1s of leeway for "now"
const isUpToNow = now.getTime() - endTime.getTime() <= 1000;
if (domain === "sensor" && isUpToNow && data.length === 1) {
const stateObj = this.hass.states[states.entity_id];
const currentValue = stateObj ? safeParseFloat(stateObj.state) : null;
if (currentValue !== null) {
data[0].data!.push([now, currentValue]);
trackY(currentValue);
}
}
// Concat two arrays
Array.prototype.push.apply(datasets, data);
const data = generateStateHistoryChartLineData({
hass: this.hass,
data: this.data,
endTime: this.endTime,
names: this.names,
colors: this.colors,
showNames: this.showNames,
computedStyles: getComputedStyle(this),
now: new Date(),
});
this._yAxisFractionDigits = computeYAxisFractionDigits(yMin, yMax);
this._chartData = datasets;
this._entityIds = entityIds;
this._datasetToDataIndex = datasetToDataIndex;
const visualMap: VisualMapComponentOption[] = [];
this._chartData.forEach((_, seriesIndex) => {
const dataIndex = this._datasetToDataIndex[seriesIndex];
const data = this.data[dataIndex];
if (!data.statistics || data.statistics.length === 0) {
return;
}
// render stat data with a slightly transparent line
const firstStateTS =
data.states[0]?.last_changed ?? this.endTime.getTime();
visualMap.push({
show: false,
seriesIndex,
dimension: 0,
pieces: [
{
max: firstStateTS - 0.01,
colorAlpha: 0.5,
},
{
min: firstStateTS,
colorAlpha: 1,
},
],
});
});
this._visualMap = visualMap.length > 0 ? visualMap : undefined;
if (!data) {
return;
}
this._yAxisFractionDigits = data.yAxisFractionDigits;
this._chartData = data.datasets;
this._entityIds = data.entityIds;
this._datasetToDataIndex = data.datasetToDataIndex;
this._visualMap = data.visualMap;
}
private _formatYAxisLabel = (value: number) => {
@@ -0,0 +1,465 @@
import type {
BarSeriesOption,
LineSeriesOption,
ZRColor,
} from "echarts/types/dist/shared";
import { getGraphColorByIndex } from "../../common/color/colors";
import type {
Statistics,
StatisticsMetaData,
StatisticType,
} from "../../data/recorder";
import {
getDisplayUnit,
getStatisticLabel,
isExternalStatistic,
statisticsHaveType,
} from "../../data/recorder";
import type { HomeAssistant } from "../../types";
import { fillDataGapsAndRoundCaps } from "./round-caps";
import { computeYAxisFractionDigits } from "./y-axis-fraction-digits";
export interface StatisticsChartLegendItem {
id: string;
name: string;
color?: ZRColor;
borderColor?: ZRColor;
noLabelClick?: boolean;
}
export interface StatisticsChartDataParams {
hass: HomeAssistant;
statisticsData: Statistics;
statisticsMetaData: Record<string, StatisticsMetaData>;
names?: Record<string, string>;
colors?: Record<string, string | undefined>;
unit?: string;
endTime?: Date;
statTypes: StatisticType[];
chartType: "line" | "line-stack" | "bar" | "bar-stack";
period?: string;
hideLegend: boolean;
hiddenStats: ReadonlySet<string>;
computedStyle: CSSStyleDeclaration;
now: Date;
}
export interface StatisticsChartData {
datasets: (LineSeriesOption | BarSeriesOption)[];
legendData: StatisticsChartLegendItem[];
statisticIds: string[];
/** Chart unit, inferred from statistics metadata when not set explicitly */
unit?: string;
yAxisFractionDigits: number;
}
/**
* Transforms raw statistics into ECharts series for `statistics-chart`.
* Pure data processing: all environment inputs (current time, theme style,
* hass) are injected so the transform is deterministic and benchmarkable.
*/
export function generateStatisticsChartData(
params: StatisticsChartDataParams
): StatisticsChartData | undefined {
const { hass, statisticsMetaData, computedStyle, now, hiddenStats } = params;
let colorIndex = 0;
const chartType = params.chartType.startsWith("line") ? "line" : "bar";
const chartStacked = params.chartType.endsWith("stack");
const statisticsData = Object.entries(params.statisticsData);
const totalDataSets: (LineSeriesOption | BarSeriesOption)[] = [];
let yMin = Infinity;
let yMax = -Infinity;
const trackY = (v: number | null | undefined) => {
if (typeof v === "number" && Number.isFinite(v)) {
if (v < yMin) yMin = v;
if (v > yMax) yMax = v;
}
};
const legendData: StatisticsChartLegendItem[] = [];
const statisticIds: string[] = [];
let endTime: Date;
if (statisticsData.length === 0) {
return undefined;
}
endTime =
params.endTime ||
// Get the highest date from the last date of each statistic
new Date(
Math.max(
...statisticsData.map(([_, stats]) =>
new Date(stats[stats.length - 1].start).getTime()
)
)
);
if (endTime > now) {
endTime = now;
}
// Check if we need to display most recent data. Allow 10m of leeway for "now",
// because stats are 5 minute aggregated.
// Use same now point for all statistics even if processing time means the
// state value is actually from a slightly later time. Otherwise the points
// end up separated slightly and disappear from the tooltips.
const displayCurrentState = now.getTime() - endTime.getTime() <= 600000;
// Try to determine chart unit if it has not already been set explicitly
let unit = params.unit;
if (!unit) {
let inferredUnit: string | undefined | null;
statisticsData.forEach(([statistic_id, _stats]) => {
const meta = statisticsMetaData?.[statistic_id];
const statisticUnit = getDisplayUnit(hass, statistic_id, meta);
if (inferredUnit === undefined) {
inferredUnit = statisticUnit;
} else if (inferredUnit !== null && inferredUnit !== statisticUnit) {
// Clear unit if not all statistics have same unit
inferredUnit = null;
}
});
if (inferredUnit) {
unit = inferredUnit;
}
}
const names = params.names || {};
const colors = params.colors || {};
statisticsData.forEach(([statistic_id, stats]) => {
const meta = statisticsMetaData?.[statistic_id];
let name = names[statistic_id];
if (name === undefined) {
name = getStatisticLabel(hass, statistic_id, meta);
}
// array containing [value1, value2, etc]
let prevValues: (number | null)[][] | null = null;
let prevEndTime: Date | undefined;
// The datasets for the current statistic
const statDataSets: (LineSeriesOption | BarSeriesOption)[] = [];
const statLegendData: StatisticsChartLegendItem[] = [];
// Place bars at centre of their specified time range if this is a bar chart
// and the period is 5minute or hour.
const centerBars =
chartType === "bar" &&
(params.period === "5minute" || params.period === "hour");
const pushData = (
start: Date, // Data point start time
end: Date, // Data point end time
limit: Date, // Limit for end time (e.g. now)
dataValues: (number | null)[][]
) => {
if (!dataValues.length) return;
// Limit for time range is lesser of overall limit and data point end
limit = end.getTime() < limit.getTime() ? end : limit;
if (start.getTime() > limit.getTime()) {
// Drop data points that are after the requested endTime. This could happen if
// endTime is "now" and client time is not in sync with server time.
return;
}
const isLineChart = chartType === "line";
// For bar charts, optionally center the bar within its time range. The
// centered time is shared by every series of this data point.
const barTime =
!isLineChart && centerBars
? new Date((start.getTime() + end.getTime()) / 2)
: start;
// Whether a gap needs to be drawn before this data point (line charts).
const drawGap =
isLineChart &&
!!prevEndTime &&
!!prevValues &&
prevEndTime.getTime() !== start.getTime();
for (let i = 0; i < statDataSets.length; i++) {
const d = statDataSets[i];
const dataValue = dataValues[i];
if (isLineChart) {
if (drawGap) {
// if the end of the previous data doesn't match the start of the current data,
// we have to draw a gap so add a value at the end time, and then an empty value.
d.data!.push([prevEndTime!, ...prevValues![i]!]);
d.data!.push([prevEndTime!, null]);
}
d.data!.push([start, ...dataValue!]);
// For band-top rows dataValues[i] is [diff, top]; the actual Y is
// the last element. For regular rows it's [value]. Same call works.
trackY(dataValue[dataValue.length - 1]);
} else {
// Data value should always be a scalar for bar charts. Pass in
// real start time as extra value to allow formatting tooltip.
d.data!.push([barTime, dataValue[0]!, start, end]);
trackY(dataValue[0]);
}
}
prevValues = dataValues;
prevEndTime = limit;
};
let color = colors[statistic_id];
if (color === undefined) {
color = getGraphColorByIndex(colorIndex, computedStyle);
colorIndex++;
}
const statTypes: StatisticType[] = [];
const hasMean =
params.statTypes.includes("mean") && statisticsHaveType(stats, "mean");
const hasMax =
params.statTypes.includes("max") && statisticsHaveType(stats, "max");
const hasMin =
params.statTypes.includes("min") && statisticsHaveType(stats, "min");
const drawBands =
!chartStacked && [hasMean, hasMax, hasMin].filter(Boolean).length > 1;
const hasState = params.statTypes.includes("state");
const bandTop = hasMax ? "max" : "mean";
const bandBottom = hasMin ? "min" : "mean";
const sortedTypes = drawBands
? [...params.statTypes].sort((a, b) => {
if (a === "min" || b === "max") {
return -1;
}
if (a === "max" || b === "min") {
return +1;
}
return 0;
})
: params.statTypes;
let displayedLegend = false;
sortedTypes.forEach((type) => {
if (statisticsHaveType(stats, type)) {
const band = drawBands && (type === bandTop || type === bandBottom);
statTypes.push(type);
const borderColor =
(band && hasMin && hasMax && hasMean) ||
(hasState && ["change", "sum"].includes(type))
? color + (params.hideLegend ? "00" : "7F")
: color;
const backgroundColor = band ? color + "3F" : color + "7F";
const series: LineSeriesOption | BarSeriesOption = {
id: `${statistic_id}-${type}`,
type: chartType,
smooth: chartType === "line" ? 0.4 : false,
cursor: "default",
data: [],
name: name
? `${name} (${hass.localize(
`ui.components.statistics_charts.statistic_types.${type}`
)})`
: hass.localize(
`ui.components.statistics_charts.statistic_types.${type}`
),
symbol: "none",
// minmax sampling operates independently per series, breaking stacking alignment
// https://github.com/apache/echarts/issues/11879
sampling: band && drawBands ? "lttb" : "minmax",
animationDurationUpdate: 0,
lineStyle: {
width: 1.5,
},
itemStyle:
chartType === "bar"
? {
borderColor,
borderWidth: 1.5,
}
: undefined,
color: chartType === "bar" ? backgroundColor : borderColor,
};
if (chartStacked) {
series.stack = `band-stacked`;
series.stackStrategy = "samesign";
if (chartType === "line") {
(series as LineSeriesOption).areaStyle = {
color: color + "3F",
};
}
} else if (band && chartType === "line") {
series.stack = `band-${statistic_id}`;
series.stackStrategy = "all";
if (hiddenStats.has(`${statistic_id}-${bandBottom}`)) {
// changing the stackOrder forces echarts to render the stacked series that are not hidden #28472
series.stackOrder = "seriesDesc";
(series as LineSeriesOption).areaStyle = undefined;
} else {
series.stackOrder = "seriesAsc";
if (type === bandTop) {
(series as LineSeriesOption).areaStyle = {
color: color + "3F",
};
}
}
}
if (!params.hideLegend) {
const showLegend = hasMean
? type === "mean"
: displayedLegend === false;
if (showLegend) {
statLegendData.push({
id: statistic_id,
name,
color: series.color as ZRColor,
borderColor: series.itemStyle?.borderColor,
noLabelClick: isExternalStatistic(statistic_id),
});
}
displayedLegend = displayedLegend || showLegend;
}
statDataSets.push(series);
statisticIds.push(statistic_id);
}
});
let prevStart: number | null = null;
// Process chart data.
let firstSum: number | null | undefined = null;
// The per-type branch decisions in the inner loop are invariant across all
// stats of this statistic, so classify each type once up front.
// kind: 0 = sum (cumulative diff), 1 = band-top ([diff, top]), 2 = plain.
const SUM_KIND = 0;
const BAND_KIND = 1;
const PLAIN_KIND = 2;
const bandBottomHidden = hiddenStats.has(`${statistic_id}-${bandBottom}`);
const isLine = chartType === "line";
const typeKinds = statTypes.map((type) => {
if (type === "sum") {
return SUM_KIND;
}
if (type === bandTop && isLine && drawBands && !bandBottomHidden) {
return BAND_KIND;
}
return PLAIN_KIND;
});
const numTypes = statTypes.length;
const statHidden = hiddenStats.has(statistic_id);
for (const stat of stats) {
// Skip consecutive stats that share the same start time. Compare the raw
// numeric start so the dedup actually fires (a `Date` reference compare
// never would) and so we skip allocating a `Date` on the dropped path.
if (prevStart === stat.start) {
continue;
}
prevStart = stat.start;
const startDate = new Date(stat.start);
const endDate = new Date(stat.end);
const dataValues: (number | null)[][] = [];
for (let t = 0; t < numTypes; t++) {
const type = statTypes[t];
const val: (number | null)[] = [];
switch (typeKinds[t]) {
case SUM_KIND:
if (firstSum === null || firstSum === undefined) {
val.push(0);
firstSum = stat.sum;
} else {
val.push((stat.sum || 0) - firstSum);
}
break;
case BAND_KIND: {
const top = stat[bandTop] || 0;
val.push(Math.abs(top - (stat[bandBottom] || 0)));
val.push(top);
break;
}
default:
val.push(stat[type] ?? null);
}
dataValues.push(val);
}
if (!statHidden) {
pushData(startDate, endDate, endTime, dataValues);
}
}
// For line charts, close out the last stat segment at prevEndTime
const lastEndTime = prevEndTime;
const lastValues = prevValues;
if (chartType === "line" && lastEndTime && lastValues) {
statDataSets.forEach((d, i) => {
d.data!.push([lastEndTime, ...lastValues[i]!]);
});
}
// Show current state if required, and units match (or are unknown)
const statisticUnit = getDisplayUnit(hass, statistic_id, meta);
if (
displayCurrentState &&
!chartStacked &&
(!unit || !statisticUnit || unit === statisticUnit)
) {
// Skip external statistics
if (!isExternalStatistic(statistic_id)) {
const stateObj = hass.states[statistic_id];
if (stateObj) {
const currentValue = parseFloat(stateObj.state);
if (isFinite(currentValue) && !hiddenStats.has(statistic_id)) {
// Then push the current state at now
statTypes.forEach((type, i) => {
if (type === "sum" || type === "change") {
// Skip cumulative types - need special calculation.
return;
}
const val: (number | null)[] = [];
if (
type === bandTop &&
chartType === "line" &&
drawBands &&
!hiddenStats.has(`${statistic_id}-${bandBottom}`)
) {
// For band chart, current value is both min and max, so diff is 0
val.push(0);
val.push(currentValue);
} else {
val.push(currentValue);
}
statDataSets[i].data!.push([now, ...val]);
trackY(val[val.length - 1]);
});
}
}
}
}
// Concat two arrays
Array.prototype.push.apply(totalDataSets, statDataSets);
Array.prototype.push.apply(legendData, statLegendData);
});
if (chartType === "bar") {
fillDataGapsAndRoundCaps(totalDataSets as BarSeriesOption[], chartStacked);
}
legendData.forEach(({ id, name, color, borderColor }) => {
// Add an empty series for the legend
totalDataSets.push({
id: id,
name: name,
color,
itemStyle: {
borderColor,
},
type: chartType,
data: [],
xAxisIndex: 1,
});
});
return {
datasets: totalDataSets,
legendData,
statisticIds,
unit,
yAxisFractionDigits: computeYAxisFractionDigits(yMin, yMax),
};
}
+26 -391
View File
@@ -1,14 +1,12 @@
import type {
BarSeriesOption,
LineSeriesOption,
ZRColor,
} from "echarts/types/dist/shared";
import type { PropertyValues, TemplateResult } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { styleMap } from "lit/directives/style-map";
import memoizeOne from "memoize-one";
import { getGraphColorByIndex } from "../../common/color/colors";
import { isComponentLoaded } from "../../common/config/is_component_loaded";
import type { HASSDomEvent } from "../../common/dom/fire_event";
import { fireEvent } from "../../common/dom/fire_event";
@@ -27,13 +25,7 @@ import type {
StatisticsMetaData,
StatisticType,
} from "../../data/recorder";
import {
getDisplayUnit,
getStatisticLabel,
getStatisticMetadata,
isExternalStatistic,
statisticsHaveType,
} from "../../data/recorder";
import { getStatisticMetadata, isExternalStatistic } from "../../data/recorder";
import type { HaECOption } from "../../resources/echarts/echarts";
import type { HomeAssistant } from "../../types";
import { getPeriodicAxisLabelConfig } from "./axis-label";
@@ -41,8 +33,7 @@ import type { CustomLegendOption } from "./ha-chart-base";
import "./ha-chart-base";
import { sideTooltipPosition } from "./chart-tooltip-position";
import "./ha-chart-tooltip-marker";
import { fillDataGapsAndRoundCaps } from "./round-caps";
import { computeYAxisFractionDigits } from "./y-axis-fraction-digits";
import { generateStatisticsChartData } from "./statistics-chart-data";
export const supportedStatTypeMap: Record<StatisticType, StatisticType> = {
mean: "mean",
@@ -503,391 +494,35 @@ export class StatisticsChart extends LitElement {
this.metadata ||
(await this._getStatisticsMetaData(Object.keys(this.statisticsData)));
let colorIndex = 0;
const chartType = this.chartType.startsWith("line") ? "line" : "bar";
const chartStacked = this.chartType.endsWith("stack");
const statisticsData = Object.entries(this.statisticsData);
const totalDataSets: typeof this._chartData = [];
let yMin = Infinity;
let yMax = -Infinity;
const trackY = (v: number | null | undefined) => {
if (typeof v === "number" && Number.isFinite(v)) {
if (v < yMin) yMin = v;
if (v > yMax) yMax = v;
}
};
const legendData: {
id: string;
name: string;
color?: ZRColor;
borderColor?: ZRColor;
noLabelClick?: boolean;
}[] = [];
const statisticIds: string[] = [];
let endTime: Date;
const data = generateStatisticsChartData({
hass: this.hass,
statisticsData: this.statisticsData,
statisticsMetaData,
names: this.names,
colors: this.colors,
unit: this.unit,
endTime: this.endTime,
statTypes: this.statTypes,
chartType: this.chartType,
period: this.period,
hideLegend: this.hideLegend,
hiddenStats: this._hiddenStats,
computedStyle: this._computedStyle || getComputedStyle(this),
now: new Date(),
});
if (statisticsData.length === 0) {
if (!data) {
return;
}
endTime =
this.endTime ||
// Get the highest date from the last date of each statistic
new Date(
Math.max(
...statisticsData.map(([_, stats]) =>
new Date(stats[stats.length - 1].start).getTime()
)
)
);
if (endTime > new Date()) {
endTime = new Date();
}
// Check if we need to display most recent data. Allow 10m of leeway for "now",
// because stats are 5 minute aggregated.
// Use same now point for all statistics even if processing time means the
// state value is actually from a slightly later time. Otherwise the points
// end up separated slightly and disappear from the tooltips.
const now = new Date();
const displayCurrentState = now.getTime() - endTime.getTime() <= 600000;
// Try to determine chart unit if it has not already been set explicitly
if (!this.unit) {
let unit: string | undefined | null;
statisticsData.forEach(([statistic_id, _stats]) => {
const meta = statisticsMetaData?.[statistic_id];
const statisticUnit = getDisplayUnit(this.hass, statistic_id, meta);
if (unit === undefined) {
unit = statisticUnit;
} else if (unit !== null && unit !== statisticUnit) {
// Clear unit if not all statistics have same unit
unit = null;
}
});
if (unit) {
this.unit = unit;
}
}
const names = this.names || {};
const colors = this.colors || {};
statisticsData.forEach(([statistic_id, stats]) => {
const meta = statisticsMetaData?.[statistic_id];
let name = names[statistic_id];
if (name === undefined) {
name = getStatisticLabel(this.hass, statistic_id, meta);
}
// array containing [value1, value2, etc]
let prevValues: (number | null)[][] | null = null;
let prevEndTime: Date | undefined;
// The datasets for the current statistic
const statDataSets: (LineSeriesOption | BarSeriesOption)[] = [];
const statLegendData: typeof legendData = [];
// Place bars at centre of their specified time range if this is a bar chart
// and the period is 5minute or hour.
const centerBars =
chartType === "bar" &&
(this.period === "5minute" || this.period === "hour");
const pushData = (
start: Date, // Data point start time
end: Date, // Data point end time
limit: Date, // Limit for end time (e.g. now)
dataValues: (number | null)[][]
) => {
if (!dataValues.length) return;
// Limit for time range is lesser of overall limit and data point end
limit = end.getTime() < limit.getTime() ? end : limit;
if (start.getTime() > limit.getTime()) {
// Drop data points that are after the requested endTime. This could happen if
// endTime is "now" and client time is not in sync with server time.
return;
}
statDataSets.forEach((d, i) => {
if (chartType === "line") {
if (
prevEndTime &&
prevValues &&
prevEndTime.getTime() !== start.getTime()
) {
// if the end of the previous data doesn't match the start of the current data,
// we have to draw a gap so add a value at the end time, and then an empty value.
d.data!.push([prevEndTime, ...prevValues[i]!]);
d.data!.push([prevEndTime, null]);
}
d.data!.push([start, ...dataValues[i]!]);
// For band-top rows dataValues[i] is [diff, top]; the actual Y is
// the last element. For regular rows it's [value]. Same call works.
trackY(dataValues[i][dataValues[i].length - 1]);
} else {
let time = start;
if (centerBars) {
// If centering bars, set the time to the midpoint between start and end instead
// of the start time.
time = new Date((start.getTime() + end.getTime()) / 2);
}
// Data value should always be a scalar for bar charts. Pass in
// real start time as extra value to allow formatting tooltip.
d.data!.push([time, dataValues[i][0]!, start, end]);
trackY(dataValues[i][0]);
}
});
prevValues = dataValues;
prevEndTime = limit;
};
let color = colors[statistic_id];
if (color === undefined) {
color = getGraphColorByIndex(
colorIndex,
this._computedStyle || getComputedStyle(this)
);
colorIndex++;
}
const statTypes: this["statTypes"] = [];
const hasMean =
this.statTypes.includes("mean") && statisticsHaveType(stats, "mean");
const hasMax =
this.statTypes.includes("max") && statisticsHaveType(stats, "max");
const hasMin =
this.statTypes.includes("min") && statisticsHaveType(stats, "min");
const drawBands =
!chartStacked && [hasMean, hasMax, hasMin].filter(Boolean).length > 1;
const hasState = this.statTypes.includes("state");
const bandTop = hasMax ? "max" : "mean";
const bandBottom = hasMin ? "min" : "mean";
const sortedTypes = drawBands
? [...this.statTypes].sort((a, b) => {
if (a === "min" || b === "max") {
return -1;
}
if (a === "max" || b === "min") {
return +1;
}
return 0;
})
: this.statTypes;
let displayedLegend = false;
sortedTypes.forEach((type) => {
if (statisticsHaveType(stats, type)) {
const band = drawBands && (type === bandTop || type === bandBottom);
statTypes.push(type);
const borderColor =
(band && hasMin && hasMax && hasMean) ||
(hasState && ["change", "sum"].includes(type))
? color + (this.hideLegend ? "00" : "7F")
: color;
const backgroundColor = band ? color + "3F" : color + "7F";
const series: LineSeriesOption | BarSeriesOption = {
id: `${statistic_id}-${type}`,
type: chartType,
smooth: chartType === "line" ? 0.4 : false,
cursor: "default",
data: [],
name: name
? `${name} (${this.hass.localize(
`ui.components.statistics_charts.statistic_types.${type}`
)})`
: this.hass.localize(
`ui.components.statistics_charts.statistic_types.${type}`
),
symbol: "none",
// minmax sampling operates independently per series, breaking stacking alignment
// https://github.com/apache/echarts/issues/11879
sampling: band && drawBands ? "lttb" : "minmax",
animationDurationUpdate: 0,
lineStyle: {
width: 1.5,
},
itemStyle:
chartType === "bar"
? {
borderColor,
borderWidth: 1.5,
}
: undefined,
color: chartType === "bar" ? backgroundColor : borderColor,
};
if (chartStacked) {
series.stack = `band-stacked`;
series.stackStrategy = "samesign";
if (chartType === "line") {
(series as LineSeriesOption).areaStyle = {
color: color + "3F",
};
}
} else if (band && chartType === "line") {
series.stack = `band-${statistic_id}`;
series.stackStrategy = "all";
if (this._hiddenStats.has(`${statistic_id}-${bandBottom}`)) {
// changing the stackOrder forces echarts to render the stacked series that are not hidden #28472
series.stackOrder = "seriesDesc";
(series as LineSeriesOption).areaStyle = undefined;
} else {
series.stackOrder = "seriesAsc";
if (type === bandTop) {
(series as LineSeriesOption).areaStyle = {
color: color + "3F",
};
}
}
}
if (!this.hideLegend) {
const showLegend = hasMean
? type === "mean"
: displayedLegend === false;
if (showLegend) {
statLegendData.push({
id: statistic_id,
name,
color: series.color as ZRColor,
borderColor: series.itemStyle?.borderColor,
noLabelClick: isExternalStatistic(statistic_id),
});
}
displayedLegend = displayedLegend || showLegend;
}
statDataSets.push(series);
statisticIds.push(statistic_id);
}
});
let prevDate: Date | null = null;
// Process chart data.
let firstSum: number | null | undefined = null;
stats.forEach((stat) => {
const startDate = new Date(stat.start);
const endDate = new Date(stat.end);
if (prevDate === startDate) {
return;
}
prevDate = startDate;
const dataValues: (number | null)[][] = [];
statTypes.forEach((type) => {
const val: (number | null)[] = [];
if (type === "sum") {
if (firstSum === null || firstSum === undefined) {
val.push(0);
firstSum = stat.sum;
} else {
val.push((stat.sum || 0) - firstSum);
}
} else if (
type === bandTop &&
chartType === "line" &&
drawBands &&
!this._hiddenStats.has(`${statistic_id}-${bandBottom}`)
) {
const top = stat[bandTop] || 0;
val.push(Math.abs(top - (stat[bandBottom] || 0)));
val.push(top);
} else {
val.push(stat[type] ?? null);
}
dataValues.push(val);
});
if (!this._hiddenStats.has(statistic_id)) {
pushData(startDate, endDate, endTime, dataValues);
}
});
// For line charts, close out the last stat segment at prevEndTime
const lastEndTime = prevEndTime;
const lastValues = prevValues;
if (chartType === "line" && lastEndTime && lastValues) {
statDataSets.forEach((d, i) => {
d.data!.push([lastEndTime, ...lastValues[i]!]);
});
}
// Show current state if required, and units match (or are unknown)
const statisticUnit = getDisplayUnit(this.hass, statistic_id, meta);
if (
displayCurrentState &&
!chartStacked &&
(!this.unit || !statisticUnit || this.unit === statisticUnit)
) {
// Skip external statistics
if (!isExternalStatistic(statistic_id)) {
const stateObj = this.hass.states[statistic_id];
if (stateObj) {
const currentValue = parseFloat(stateObj.state);
if (
isFinite(currentValue) &&
!this._hiddenStats.has(statistic_id)
) {
// Then push the current state at now
statTypes.forEach((type, i) => {
if (type === "sum" || type === "change") {
// Skip cumulative types - need special calculation.
return;
}
const val: (number | null)[] = [];
if (
type === bandTop &&
chartType === "line" &&
drawBands &&
!this._hiddenStats.has(`${statistic_id}-${bandBottom}`)
) {
// For band chart, current value is both min and max, so diff is 0
val.push(0);
val.push(currentValue);
} else {
val.push(currentValue);
}
statDataSets[i].data!.push([now, ...val]);
trackY(val[val.length - 1]);
});
}
}
}
}
// Concat two arrays
Array.prototype.push.apply(totalDataSets, statDataSets);
Array.prototype.push.apply(legendData, statLegendData);
});
if (chartType === "bar") {
fillDataGapsAndRoundCaps(
totalDataSets as BarSeriesOption[],
chartStacked
);
}
legendData.forEach(({ id, name, color, borderColor }) => {
// Add an empty series for the legend
totalDataSets.push({
id: id,
name: name,
color,
itemStyle: {
borderColor,
},
type: chartType,
data: [],
xAxisIndex: 1,
});
});
this._yAxisFractionDigits = computeYAxisFractionDigits(yMin, yMax);
this._chartData = totalDataSets;
if (legendData.length !== this._legendData?.length) {
this.unit = data.unit;
this._yAxisFractionDigits = data.yAxisFractionDigits;
this._chartData = data.datasets;
if (data.legendData.length !== this._legendData?.length) {
// only update the legend if it has changed or it will trigger options update
this._legendData =
legendData.length > 1
? legendData.map(({ id, name, noLabelClick }) => ({
data.legendData.length > 1
? data.legendData.map(({ id, name, noLabelClick }) => ({
id,
name,
noLabelClick,
@@ -895,7 +530,7 @@ export class StatisticsChart extends LitElement {
: // if there is only one entity, let the base chart handle the legend
undefined;
}
this._statisticIds = statisticIds;
this._statisticIds = data.statisticIds;
}
private _clampYAxis(value?: number | ((values: any) => number)) {
+5 -3
View File
@@ -79,9 +79,11 @@ function computeTimelineEnumColor(
const domain = computeStateDomain(stateObj);
const states =
FIXED_DOMAIN_STATES[domain] ||
(domain === "sensor" &&
stateObj.attributes.device_class === "enum" &&
stateObj.attributes.options) ||
((domain === "sensor" && stateObj.attributes.device_class === "enum") ||
domain === "select" ||
domain === "input_select"
? stateObj.attributes.options
: undefined) ||
[];
const idx = states.indexOf(state);
if (idx === -1) {
+8 -2
View File
@@ -215,10 +215,16 @@ export class HaDataTable extends LitElement {
if (clear) {
this._checkedRows = [];
}
// Map + Set keep a large selection O(rows + ids) instead of O(rows × ids).
const rowLookup = new Map(
(this._filteredData || []).map((data) => [data[this.id], data])
);
const checkedRows = new Set(this._checkedRows);
ids.forEach((id) => {
const row = this._filteredData?.find((data) => data[this.id] === id);
if (row?.selectable !== false && !this._checkedRows.includes(id)) {
const row = rowLookup.get(id);
if (row?.selectable !== false && !checkedRows.has(id)) {
this._checkedRows.push(id);
checkedRows.add(id);
}
});
this._lastSelectedRowId = null;
+8 -11
View File
@@ -8,6 +8,7 @@ import { arrayLiteralIncludes } from "../../common/array/literal-includes";
import secondsToDuration from "../../common/datetime/seconds_to_duration";
import { computeStateDomain } from "../../common/entity/compute_state_domain";
import { computeStateName } from "../../common/entity/compute_state_name";
import { unitFromParts, valueFromParts } from "../../common/entity/value_parts";
import { FIXED_DOMAIN_STATES } from "../../common/entity/get_states";
import { UNAVAILABLE, UNKNOWN } from "../../data/entity/entity";
import type { EntityRegistryDisplayEntry } from "../../data/entity/entity_registry";
@@ -163,20 +164,18 @@ export class HaStateLabelBadge extends LitElement {
case "sun":
case "timer":
return null;
// @ts-expect-error we don't break and go to default
case "sensor":
if (entry?.platform === "moon") {
return null;
}
// eslint-disable-next-line: disable=no-fallthrough
break;
default:
return entityState.state === UNAVAILABLE ||
entityState.state === UNKNOWN
? "—"
: this.hass!.formatEntityStateToParts(entityState).find(
(part) => part.type === "value"
)?.value;
break;
}
if (entityState.state === UNAVAILABLE || entityState.state === UNKNOWN) {
return "—";
}
return valueFromParts(this.hass!.formatEntityStateToParts(entityState));
}
private _computeShowIcon(
@@ -225,9 +224,7 @@ export class HaStateLabelBadge extends LitElement {
return secondsToDuration(_timerTimeRemaining);
}
return (
this.hass!.formatEntityStateToParts(entityState).find(
(part) => part.type === "unit"
)?.value || null
unitFromParts(this.hass!.formatEntityStateToParts(entityState)) || null
);
}
+46 -20
View File
@@ -1,3 +1,4 @@
import { consume } from "@lit/context";
import {
mdiAlertCircle,
mdiChevronDown,
@@ -10,7 +11,9 @@ import type { CSSResultGroup, PropertyValues, TemplateResult } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import { consumeLocalize } from "../common/decorators/consume-context-entry";
import { supportsFeature } from "../common/entity/supports-feature";
import type { LocalizeFunc } from "../common/translations/localize";
import {
runAssistPipeline,
type AssistPipeline,
@@ -18,10 +21,19 @@ import {
type ConversationChatLogToolResultDelta,
type PipelineRunEvent,
} from "../data/assist_pipeline";
import {
configContext,
connectionContext,
statesContext,
} from "../data/context";
import { ConversationEntityFeature } from "../data/conversation";
import { showAlertDialog } from "../dialogs/generic/show-dialog-box";
import { haStyleScrollbar } from "../resources/styles";
import type { HomeAssistant } from "../types";
import type {
HomeAssistant,
HomeAssistantConfig,
HomeAssistantConnection,
} from "../types";
import { AudioRecorder } from "../util/audio-recorder";
import { documentationUrl } from "../util/documentation-url";
import "./ha-alert";
@@ -47,8 +59,6 @@ interface AssistMessage {
@customElement("ha-assist-chat")
export class HaAssistChat extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false }) public pipeline?: AssistPipeline;
@property({ type: Boolean, attribute: "disable-speech" })
@@ -71,6 +81,22 @@ export class HaAssistChat extends LitElement {
@state() private _processing = false;
@state()
@consumeLocalize()
private _localize!: LocalizeFunc;
@state()
@consume({ context: statesContext, subscribe: true })
private _states!: HomeAssistant["states"];
@state()
@consume({ context: configContext, subscribe: true })
private _config!: HomeAssistantConfig;
@state()
@consume({ context: connectionContext, subscribe: true })
private _connection!: HomeAssistantConnection;
private _conversationId: string | null = null;
private _audioRecorder?: AudioRecorder;
@@ -86,7 +112,7 @@ export class HaAssistChat extends LitElement {
this._conversation = [
{
who: "hass",
text: this.hass.localize("ui.dialogs.voice_command.how_can_i_help"),
text: this._localize("ui.dialogs.voice_command.how_can_i_help"),
thinking: "",
tool_calls: {},
},
@@ -124,9 +150,9 @@ export class HaAssistChat extends LitElement {
const controlHA = !this.pipeline
? false
: this.pipeline.prefer_local_intents ||
(this.hass.states[this.pipeline.conversation_engine]
(this._states[this.pipeline.conversation_engine]
? supportsFeature(
this.hass.states[this.pipeline.conversation_engine],
this._states[this.pipeline.conversation_engine],
ConversationEntityFeature.CONTROL
)
: true);
@@ -139,7 +165,7 @@ export class HaAssistChat extends LitElement {
? nothing
: html`
<ha-alert>
${this.hass.localize(
${this._localize(
"ui.dialogs.voice_command.conversation_no_control"
)}
</ha-alert>
@@ -180,7 +206,7 @@ export class HaAssistChat extends LitElement {
.path=${mdiCommentProcessingOutline}
></ha-svg-icon>
<span class="thinking-label">
${this.hass.localize(
${this._localize(
"ui.dialogs.voice_command.show_details"
)}
</span>
@@ -251,7 +277,7 @@ ${JSON.stringify(toolCall.result, null, 2)}</pre
id="message-input"
@keyup=${this._handleKeyUp}
@input=${this._handleInput}
.label=${this.hass.localize(`ui.dialogs.voice_command.input_label`)}
.label=${this._localize(`ui.dialogs.voice_command.input_label`)}
>
<div slot="end">
${this._showSendButton || !supportsSTT
@@ -261,7 +287,7 @@ ${JSON.stringify(toolCall.result, null, 2)}</pre
.path=${mdiSend}
@click=${this._handleSendMessage}
.disabled=${this._processing}
.label=${this.hass.localize(
.label=${this._localize(
"ui.dialogs.voice_command.send_text"
)}
>
@@ -282,7 +308,7 @@ ${JSON.stringify(toolCall.result, null, 2)}</pre
.path=${mdiMicrophone}
@click=${this._handleListeningButton}
.disabled=${this._processing}
.label=${this.hass.localize(
.label=${this._localize(
"ui.dialogs.voice_command.start_listening"
)}
>
@@ -391,21 +417,21 @@ ${JSON.stringify(toolCall.result, null, 2)}</pre
text:
// New lines matter for messages
// prettier-ignore
html`${this.hass.localize(
html`${this._localize(
"ui.dialogs.voice_command.not_supported_microphone_browser"
)}
${this.hass.localize(
${this._localize(
"ui.dialogs.voice_command.not_supported_microphone_documentation",
{
documentation_link: html`<a
target="_blank"
rel="noopener noreferrer"
href=${documentationUrl(
this.hass,
this._config,
"/docs/configuration/securing/#remote-access"
)}
>${this.hass.localize(
>${this._localize(
"ui.dialogs.voice_command.not_supported_microphone_documentation_link"
)}</a>`,
}
@@ -443,7 +469,7 @@ ${JSON.stringify(toolCall.result, null, 2)}</pre
try {
const unsub = await runAssistPipeline(
this.hass,
this._connection,
(event: PipelineRunEvent) => {
if (event.type === "run-start") {
this._stt_binary_handler_id =
@@ -539,7 +565,7 @@ ${JSON.stringify(toolCall.result, null, 2)}</pre
}
private _sendAudioChunk(chunk: Int16Array) {
this.hass.connection.socket!.binaryType = "arraybuffer";
this._connection.connection.socket!.binaryType = "arraybuffer";
// eslint-disable-next-line eqeqeq
if (this._stt_binary_handler_id == undefined) {
@@ -550,7 +576,7 @@ ${JSON.stringify(toolCall.result, null, 2)}</pre
data[0] = this._stt_binary_handler_id;
data.set(new Uint8Array(chunk.buffer), 1);
this.hass.connection.socket!.send(data);
this._connection.connection.socket!.send(data);
}
private _unloadAudio = () => {
@@ -570,7 +596,7 @@ ${JSON.stringify(toolCall.result, null, 2)}</pre
hassMessageProcesser.addMessage();
try {
const unsub = await runAssistPipeline(
this.hass,
this._connection,
(event) => {
if (event.type.startsWith("intent-")) {
hassMessageProcesser.processEvent(event);
@@ -593,7 +619,7 @@ ${JSON.stringify(toolCall.result, null, 2)}</pre
);
} catch {
hassMessageProcesser.setError(
this.hass.localize("ui.dialogs.voice_command.error")
this._localize("ui.dialogs.voice_command.error")
);
} finally {
this._processing = false;
+67 -19
View File
@@ -1,16 +1,21 @@
import { consume } from "@lit/context";
import type { ContextType } from "@lit/context";
import { initialState } from "@lit/task";
import type { HassEntity } from "home-assistant-js-websocket";
import { html, LitElement, nothing } from "lit";
import { customElement, property } from "lit/decorators";
import { until } from "lit/directives/until";
import { customElement, property, state } from "lit/decorators";
import { AsyncValueTask } from "../common/controllers/async-value-task";
import {
configContext,
connectionContext,
entitiesContext,
} from "../data/context";
import { attributeIcon } from "../data/icons";
import type { HomeAssistant } from "../types";
import "./ha-icon";
import "./ha-svg-icon";
@customElement("ha-attribute-icon")
export class HaAttributeIcon extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false }) public stateObj?: HassEntity;
@property() public attribute?: string;
@@ -19,6 +24,59 @@ export class HaAttributeIcon extends LitElement {
@property() public icon?: string;
@state()
@consume({ context: configContext, subscribe: true })
private _config?: ContextType<typeof configContext>;
@state()
@consume({ context: connectionContext, subscribe: true })
private _connection?: ContextType<typeof connectionContext>;
@state()
@consume({ context: entitiesContext, subscribe: true })
private _entities?: ContextType<typeof entitiesContext>;
private _iconTask = new AsyncValueTask(this, {
task: ([
icon,
config,
connection,
entities,
stateObj,
attribute,
attributeValue,
]) => {
if (
icon ||
!config ||
!connection ||
!entities ||
!stateObj ||
!attribute
) {
return initialState;
}
return attributeIcon(
config.config,
connection.connection,
entities,
stateObj,
attribute,
attributeValue
);
},
args: () =>
[
this.icon,
this._config,
this._connection,
this._entities,
this.stateObj,
this.attribute,
this.attributeValue,
] as const,
});
protected render() {
if (this.icon) {
return html`<ha-icon .icon=${this.icon}></ha-icon>`;
@@ -28,23 +86,13 @@ export class HaAttributeIcon extends LitElement {
return nothing;
}
if (!this.hass) {
if (!this._config || !this._connection || !this._entities) {
return nothing;
}
const icon = attributeIcon(
this.hass,
this.stateObj,
this.attribute,
this.attributeValue
).then((icn) => {
if (icn) {
return html`<ha-icon .icon=${icn}></ha-icon>`;
}
return nothing;
});
return html`${until(icon)}`;
return this._iconTask.value
? html`<ha-icon .icon=${this._iconTask.value}></ha-icon>`
: nothing;
}
}
+43 -12
View File
@@ -1,11 +1,19 @@
import { consume } from "@lit/context";
import type { ContextType } from "@lit/context";
import { initialState } from "@lit/task";
import type { HassEntity } from "home-assistant-js-websocket";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { until } from "lit/directives/until";
import { AsyncValueTask } from "../common/controllers/async-value-task";
import { computeStateDomain } from "../common/entity/compute_state_domain";
import { getValueAttribute } from "../common/entity/get_states";
import { valueFromParts } from "../common/entity/value_parts";
import { formattersContext } from "../data/context";
const isObjectValue = (value: unknown): boolean =>
(Array.isArray(value) && value.some((val) => val instanceof Object)) ||
(!Array.isArray(value) && value instanceof Object);
@customElement("ha-attribute-value")
class HaAttributeValue extends LitElement {
@state()
@@ -18,6 +26,17 @@ class HaAttributeValue extends LitElement {
@property({ type: Boolean, attribute: "hide-unit" }) public hideUnit = false;
private _yamlTask = new AsyncValueTask(this, {
task: async ([attributeValue]) => {
if (!isObjectValue(attributeValue)) {
return initialState;
}
const { dump } = await import("js-yaml");
return dump(attributeValue);
},
args: () => [this.stateObj?.attributes[this.attribute]] as const,
});
protected render() {
if (!this.stateObj) {
return nothing;
@@ -47,13 +66,28 @@ class HaAttributeValue extends LitElement {
}
}
if (
(Array.isArray(attributeValue) &&
attributeValue.some((val) => val instanceof Object)) ||
(!Array.isArray(attributeValue) && attributeValue instanceof Object)
) {
const yaml = import("js-yaml").then(({ dump }) => dump(attributeValue));
return html`<pre>${until(yaml, "")}</pre>`;
if (isObjectValue(attributeValue)) {
return html`<pre>${this._yamlTask.value ?? ""}</pre>`;
}
// Options-list attributes (effect_list, preset_modes, …) translated through
// their value attribute, or the main state for lists like hvac_modes.
if (Array.isArray(attributeValue)) {
const domain = computeStateDomain(this.stateObj);
const valueAttribute = getValueAttribute(domain, this.attribute);
if (valueAttribute) {
return attributeValue
.map((item) =>
valueAttribute === "_"
? this._formatters!.formatEntityState(this.stateObj!, item)
: this._formatters!.formatEntityAttributeValue(
this.stateObj!,
valueAttribute,
item
)
)
.join(", ");
}
}
if (this.hideUnit) {
@@ -61,10 +95,7 @@ class HaAttributeValue extends LitElement {
this.stateObj!,
this.attribute
);
return parts
.filter((part) => part.type === "value")
.map((part) => part.value)
.join("");
return valueFromParts(parts);
}
return this._formatters!.formatEntityAttributeValue(
+11 -12
View File
@@ -1,10 +1,12 @@
import { consume } from "@lit/context";
import type { ContextType } from "@lit/context";
import type { CSSResultGroup } from "lit";
import { LitElement, css, html } from "lit";
import { customElement, property } from "lit/decorators";
import { customElement, property, state } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import { formatNumber } from "../common/number/format_number";
import { blankBeforeUnit } from "../common/translations/blank_before_unit";
import type { HomeAssistant } from "../types";
import { internationalizationContext } from "../data/context";
@customElement("ha-big-number")
export class HaBigNumber extends LitElement {
@@ -15,17 +17,16 @@ export class HaBigNumber extends LitElement {
@property({ attribute: "unit-position" })
public unitPosition: "top" | "bottom" = "top";
@property({ attribute: false }) public hass?: HomeAssistant;
@property({ attribute: false })
public formatOptions: Intl.NumberFormatOptions = {};
@state()
@consume({ context: internationalizationContext, subscribe: true })
private _i18n?: ContextType<typeof internationalizationContext>;
protected render() {
const formatted = formatNumber(
this.value,
this.hass?.locale,
this.formatOptions
);
const locale = this._i18n!.locale;
const formatted = formatNumber(this.value, locale, this.formatOptions);
const [integer] = formatted.includes(".")
? formatted.split(".")
: formatted.split(",");
@@ -33,9 +34,7 @@ export class HaBigNumber extends LitElement {
const temperatureDecimal = formatted.replace(integer, "");
const formattedValue = `${this.value}${
this.unit
? `${blankBeforeUnit(this.unit, this.hass?.locale)}${this.unit}`
: ""
this.unit ? `${blankBeforeUnit(this.unit, locale)}${this.unit}` : ""
}`;
const unitBottom = this.unitPosition === "bottom";
+45 -20
View File
@@ -1,3 +1,4 @@
import { consume, type ContextType } from "@lit/context";
import { css, html, LitElement, nothing, type PropertyValues } from "lit";
import { customElement, property, state } from "lit/decorators";
import { repeat } from "lit/directives/repeat";
@@ -7,7 +8,7 @@ import memoizeOne from "memoize-one";
import { computeStateName } from "../common/entity/compute_state_name";
import { supportsFeature } from "../common/entity/supports-feature";
import {
CAMERA_SUPPORT_STREAM,
CameraEntityFeature,
type CameraCapabilities,
type CameraEntity,
computeMJPEGStreamUrl,
@@ -17,7 +18,7 @@ import {
STREAM_TYPE_WEB_RTC,
type StreamType,
} from "../data/camera";
import type { HomeAssistant } from "../types";
import { apiContext, configContext, connectionContext } from "../data/context";
import "./ha-hls-player";
import "./ha-web-rtc-player";
@@ -30,7 +31,17 @@ interface Stream {
@customElement("ha-camera-stream")
export class HaCameraStream extends LitElement {
@property({ attribute: false }) public hass?: HomeAssistant;
@state()
@consume({ context: configContext, subscribe: true })
private _config!: ContextType<typeof configContext>;
@state()
@consume({ context: apiContext, subscribe: true })
private _api!: ContextType<typeof apiContext>;
@state()
@consume({ context: connectionContext, subscribe: true })
private _connection!: ContextType<typeof connectionContext>;
@property({ attribute: false }) public stateObj?: CameraEntity;
@@ -58,21 +69,33 @@ export class HaCameraStream extends LitElement {
@state() private _webRtcStreams?: { hasAudio: boolean; hasVideo: boolean };
public willUpdate(changedProps: PropertyValues<this>): void {
private _thumbnailApi = memoizeOne(
(
api: ContextType<typeof apiContext>,
connection: ContextType<typeof connectionContext>
) => ({
callWS: api.callWS,
hassUrl: connection.hassUrl,
})
);
public willUpdate(changedProps: PropertyValues): void {
const entityChanged =
changedProps.has("stateObj") &&
this.stateObj &&
(changedProps.get("stateObj") as CameraEntity | undefined)?.entity_id !==
this.stateObj.entity_id;
const oldHass = changedProps.get("hass") as HomeAssistant | undefined;
const oldConfig = changedProps.get("_config") as
| ContextType<typeof configContext>
| undefined;
const backendStarted =
changedProps.has("hass") &&
this.hass &&
changedProps.has("_config") &&
this._config &&
this.stateObj &&
oldHass &&
this.hass.config.state === STATE_RUNNING &&
oldHass.config?.state !== STATE_RUNNING;
oldConfig &&
this._config.config.state === STATE_RUNNING &&
oldConfig.config?.state !== STATE_RUNNING;
if (entityChanged || backendStarted) {
this._getCapabilities();
@@ -112,12 +135,16 @@ export class HaCameraStream extends LitElement {
return nothing;
}
if (stream.type === MJPEG_STREAM) {
const streamUrl = __DEMO__
? this.stateObj.attributes.entity_picture
: this._connected
? computeMJPEGStreamUrl(this.stateObj)
: this._posterUrl;
if (!streamUrl) {
return nothing;
}
return html`<img
.src=${__DEMO__
? this.stateObj.attributes.entity_picture!
: this._connected
? computeMJPEGStreamUrl(this.stateObj)
: this._posterUrl || ""}
.src=${streamUrl}
style=${styleMap({
aspectRatio: this.aspectRatio,
objectFit: this.fitMode,
@@ -133,7 +160,6 @@ export class HaCameraStream extends LitElement {
.allowExoPlayer=${this.allowExoPlayer}
.muted=${this.muted}
.controls=${this.controls}
.hass=${this.hass}
.entityid=${this.stateObj.entity_id}
.posterUrl=${this._posterUrl}
@streams=${this._handleHlsStreams}
@@ -149,7 +175,6 @@ export class HaCameraStream extends LitElement {
playsinline
.muted=${this.muted}
.controls=${this.controls}
.hass=${this.hass}
.entityid=${this.stateObj.entity_id}
.posterUrl=${this._posterUrl}
@streams=${this._handleWebRtcStreams}
@@ -166,12 +191,12 @@ export class HaCameraStream extends LitElement {
this._capabilities = undefined;
this._hlsStreams = undefined;
this._webRtcStreams = undefined;
if (!supportsFeature(this.stateObj!, CAMERA_SUPPORT_STREAM)) {
if (!supportsFeature(this.stateObj!, CameraEntityFeature.STREAM)) {
this._capabilities = { frontend_stream_types: [] };
return;
}
this._capabilities = await fetchCameraCapabilities(
this.hass!,
this._api,
this.stateObj!.entity_id
);
}
@@ -179,7 +204,7 @@ export class HaCameraStream extends LitElement {
private async _getPosterUrl(): Promise<void> {
try {
this._posterUrl = await fetchThumbnailUrlWithCache(
this.hass!,
this._thumbnailApi(this._api, this._connection),
this.stateObj!.entity_id,
this.clientWidth,
this.clientHeight
+19 -13
View File
@@ -12,10 +12,11 @@ import {
mdiWeatherSunny,
} from "@mdi/js";
import { consume } from "@lit/context";
import { initialState } from "@lit/task";
import { html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { until } from "lit/directives/until";
import type { HassConfig, Connection } from "home-assistant-js-websocket";
import { AsyncValueTask } from "../common/controllers/async-value-task";
import { computeDomain } from "../common/entity/compute_domain";
import { transform } from "../common/decorators/transform";
import { configContext, connectionContext } from "../data/context";
@@ -57,6 +58,17 @@ export class HaConditionIcon extends LitElement {
})
private _connection?: Connection;
private _iconTask = new AsyncValueTask(this, {
task: ([icon, connection, config, condition]) => {
if (icon || !connection || !config || !condition) {
return initialState;
}
return conditionIcon(connection, config, condition);
},
args: () =>
[this.icon, this._connection, this._config, this.condition] as const,
});
protected render() {
if (this.icon) {
return html`<ha-icon .icon=${this.icon}></ha-icon>`;
@@ -70,18 +82,12 @@ export class HaConditionIcon extends LitElement {
return this._renderFallback();
}
const icon = conditionIcon(
this._connection,
this._config,
this.condition
).then((icn) => {
if (icn) {
return html`<ha-icon .icon=${icn}></ha-icon>`;
}
return this._renderFallback();
});
return html`${until(icon)}`;
if (!this._iconTask.resolved) {
return nothing;
}
return this._iconTask.value
? html`<ha-icon .icon=${this._iconTask.value}></ha-icon>`
: this._renderFallback();
}
private _renderFallback() {
+1
View File
@@ -183,6 +183,7 @@ export class HaControlSelectMenu extends LitElement {
gap: 10px;
width: 100%;
user-select: none;
font-family: var(--ha-font-family-body, inherit);
font-style: normal;
font-weight: var(--ha-font-weight-normal);
letter-spacing: 0.25px;
+4 -1
View File
@@ -388,7 +388,10 @@ export class HaControlSlider extends LitElement {
private _isVisuallyInverted() {
let inverted = this.inverted;
if (mainWindow.document.dir === "rtl") {
// RTL only mirrors the horizontal axis. A vertical slider always fills
// bottom-to-top regardless of text direction, so it must not be flipped,
// otherwise its value mapping ends up upside down in RTL languages.
if (!this.vertical && mainWindow.document.dir === "rtl") {
inverted = !inverted;
}

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