Compare commits

...

123 Commits

Author SHA1 Message Date
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
317 changed files with 61076 additions and 5124 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
+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/
+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);
+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
)}`,
+10 -9
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,13 +35,13 @@
"@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-getcanonicallocales": "3.2.10",
@@ -131,13 +132,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",
@@ -158,7 +159,7 @@
"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 +187,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,7 +195,7 @@
"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.0",
"vite-tsconfig-paths": "6.1.1",
"vitest": "4.1.8",
"webpack-stats-plugin": "1.1.3",
+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
+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";
+51 -46
View File
@@ -17,13 +17,51 @@ 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";
// Domains whose state is a timezone-agnostic date and/or time string.
const DATE_TIME_DOMAINS = new Set(["date", "input_datetime", "time"]);
// Domains whose state is a timestamp.
const TIMESTAMP_DOMAINS = new Set([
"ai_task",
"button",
"conversation",
"event",
"image",
"infrared",
"input_button",
"notify",
"radio_frequency",
"scene",
"stt",
"tag",
"tts",
"wake_word",
"datetime",
]);
// 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 +72,6 @@ export const computeStateDisplay = (
return computeStateDisplayFromEntityAttributes(
localize,
locale,
sensorNumericDeviceClasses,
config,
entity,
stateObj.entity_id,
@@ -46,7 +83,6 @@ export const computeStateDisplay = (
export const computeStateDisplayFromEntityAttributes = (
localize: LocalizeFunc,
locale: FrontendLocaleData,
sensorNumericDeviceClasses: string[],
config: HassConfig,
entity: EntityRegistryDisplayEntry | undefined,
entityId: string,
@@ -56,7 +92,6 @@ export const computeStateDisplayFromEntityAttributes = (
const parts = computeStateToPartsFromEntityAttributes(
localize,
locale,
sensorNumericDeviceClasses,
config,
entity,
entityId,
@@ -69,7 +104,6 @@ export const computeStateDisplayFromEntityAttributes = (
const computeStateToPartsFromEntityAttributes = (
localize: LocalizeFunc,
locale: FrontendLocaleData,
sensorNumericDeviceClasses: string[],
config: HassConfig,
entity: EntityRegistryDisplayEntry | undefined,
entityId: string,
@@ -86,15 +120,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,21 +172,10 @@ 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")
@@ -191,7 +214,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 +273,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_DOMAINS.has(domain) ||
(domain === "sensor" &&
SENSOR_TIMESTAMP_DEVICE_CLASSES.includes(attributes.device_class))
) {
@@ -307,7 +314,6 @@ export const computeStateToParts = (
localize: LocalizeFunc,
stateObj: HassEntity,
locale: FrontendLocaleData,
sensorNumericDeviceClasses: string[],
config: HassConfig,
entities: HomeAssistant["entities"],
state?: string
@@ -318,7 +324,6 @@ export const computeStateToParts = (
return computeStateToPartsFromEntityAttributes(
localize,
locale,
sensorNumericDeviceClasses,
config,
entity,
stateObj.entity_id,
+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";
};
+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,
+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);
},
});
@@ -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;
}
}
@@ -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)) {
+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;
+9 -5
View File
@@ -112,12 +112,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,
+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;
+21 -5
View File
@@ -12,6 +12,20 @@ import type {
HaFormSelectSchema,
} from "./types";
/**
* The underlying select returns option values as strings. Map a selected value
* back to its original option value so the source type is retained (for example
* a number coming from a backend `vol.In` schema), falling back to the value
* itself when no option matches.
*/
export const matchSelectOptionValue = (
options: HaFormSelectSchema["options"],
value: string
): HaFormSelectData => {
const option = options.find((opt) => String(opt[0]) === String(value));
return option ? option[0] : value;
};
@customElement("ha-form-select")
export class HaFormSelect extends LitElement implements HaFormElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@@ -66,14 +80,16 @@ export class HaFormSelect extends LitElement implements HaFormElement {
private _valueChanged(ev: CustomEvent) {
ev.stopPropagation();
let value: string | undefined = ev.detail.value;
if (value === this.data) {
return;
}
let value: HaFormSelectData | undefined = ev.detail.value;
if (value === "") {
value = undefined;
} else if (value != null) {
value = matchSelectOptionValue(this.schema.options, value);
}
if (value === this.data) {
return;
}
fireEvent(this, "value-changed", {
+2
View File
@@ -41,6 +41,8 @@ const CUSTOM_ICONS: Record<string, () => Promise<string>> = {
),
esphome: () =>
import("../resources/esphome-logo-svg").then((mod) => mod.mdiEsphomeLogo),
matter: () =>
import("../resources/matter-logo-svg").then((mod) => mod.mdiMatterLogo),
};
@customElement("ha-icon")
@@ -354,7 +354,9 @@ export class HaSerialPortSelector extends LitElement {
}
private get _selectorDomain(): string | undefined {
return this.context?.handler;
// `domain` is the integration domain even in options flows, where the flow
// handler is the config entry id instead.
return this.context?.domain;
}
private _memoRecommendedDomains = memoizeOne(
+49 -27
View File
@@ -2,12 +2,19 @@ import type { CSSResultGroup, PropertyValues } from "lit";
import { LitElement, css, html, nothing } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import { goBack } from "../common/navigate";
import { haStyleScrollbar } from "../resources/styles";
import "./ha-icon-button-arrow-prev";
import "./ha-menu-button";
const PASSIVE_EVENT_OPTIONS = { passive: true } as const;
export const haTopAppBarFixedStyles = css`
:host {
display: block;
position: relative;
height: 100vh;
overflow: hidden;
--total-top-app-bar-height: calc(
var(--header-height, 0px) + var(--sub-row-height, 0px)
);
@@ -18,10 +25,11 @@ export const haTopAppBarFixedStyles = css`
box-sizing: border-box;
color: var(--app-header-text-color, #fff);
background-color: var(--app-header-background-color, var(--primary-color));
position: fixed;
position: absolute;
top: 0;
inset-inline-start: 0;
inset-inline-end: 0;
width: var(--ha-top-app-bar-width, 100%);
width: 100%;
z-index: 4;
padding-top: var(--safe-area-inset-top);
padding-right: var(--safe-area-inset-right);
@@ -113,17 +121,17 @@ export const haTopAppBarFixedStyles = css`
}
.top-app-bar-fixed-adjust {
height: calc(
100vh - var(--total-top-app-bar-height, 0px) - var(
--safe-area-inset-top,
0px
) - var(--safe-area-inset-bottom, 0px)
);
padding-top: calc(
box-sizing: border-box;
position: absolute;
top: calc(
var(--total-top-app-bar-height, 0px) + var(--safe-area-inset-top, 0px)
);
bottom: 0;
inset-inline-start: 0;
inset-inline-end: 0;
padding-bottom: var(--safe-area-inset-bottom);
padding-right: var(--safe-area-inset-right);
overflow: auto;
}
:host([narrow]) .top-app-bar-fixed-adjust {
@@ -135,12 +143,16 @@ export const haTopAppBarFixedStyles = css`
export class HaTopAppBarFixed extends LitElement {
@property({ type: Boolean, reflect: true }) public narrow = false;
@property({ attribute: "back-button", type: Boolean }) backButton = false;
@property({ attribute: "center-title", type: Boolean }) centerTitle = false;
@query(".top-app-bar") protected _barElement!: HTMLElement;
@query(".sub-row") protected _subRowElement?: HTMLElement;
@query(".top-app-bar-fixed-adjust") protected _scrollElement?: HTMLElement;
@state() private _hasSubRow = false;
private _scrollTarget?: HTMLElement | Window;
@@ -149,14 +161,13 @@ export class HaTopAppBarFixed extends LitElement {
@property({ attribute: false })
public get scrollTarget(): HTMLElement | Window {
return this._scrollTarget || window;
return this._scrollTarget || this._scrollElement || window;
}
public set scrollTarget(value: HTMLElement | Window) {
const old = this.scrollTarget;
this._unregisterListeners();
this._scrollTarget = value;
this._updateBarPosition();
this.requestUpdate("scrollTarget", old);
if (this.isConnected) {
this._registerListeners();
@@ -178,7 +189,6 @@ export class HaTopAppBarFixed extends LitElement {
if (this.hasUpdated) {
this._observeSubRowHeight();
this._updateSubRowHeight();
this._updateBarPosition();
this._registerListeners();
this._syncScrollState();
}
@@ -200,16 +210,14 @@ export class HaTopAppBarFixed extends LitElement {
<div class="row">
${paneHeader
? html`<section class="section" id="title">
<slot name="navigationIcon"></slot>
${title}
${this._renderNavigationIcon()} ${title}
</section>`
: nothing}
<section class="section" id="navigation">
${paneHeader
? nothing
: html`<slot name="navigationIcon"></slot> ${this.centerTitle
? nothing
: title}`}
: html`${this._renderNavigationIcon()}
${this.centerTitle ? nothing : title}`}
</section>
${!paneHeader && this.centerTitle
? html`<section class="section center">${title}</section>`
@@ -225,8 +233,22 @@ export class HaTopAppBarFixed extends LitElement {
`;
}
private _renderNavigationIcon() {
return html`
<slot name="navigationIcon">
${this.backButton
? html`
<ha-icon-button-arrow-prev
@click=${this._handleBackClick}
></ha-icon-button-arrow-prev>
`
: html`<ha-menu-button></ha-menu-button>`}
</slot>
`;
}
protected _renderContent() {
return html`<div class="top-app-bar-fixed-adjust">
return html`<div class="top-app-bar-fixed-adjust ha-scrollbar">
<slot></slot>
</div>`;
}
@@ -235,7 +257,6 @@ export class HaTopAppBarFixed extends LitElement {
super.firstUpdated(changedProperties);
this._observeSubRowHeight();
this._updateSubRowHeight();
this._updateBarPosition();
this._registerListeners();
this._syncScrollState();
}
@@ -253,13 +274,6 @@ export class HaTopAppBarFixed extends LitElement {
this._unregisterListeners();
}
protected _updateBarPosition() {
if (this._barElement) {
this._barElement.style.position =
this.scrollTarget === window ? "" : "absolute";
}
}
protected _syncScrollState = () => {
const scrollTop =
this.scrollTarget instanceof Window
@@ -268,6 +282,11 @@ export class HaTopAppBarFixed extends LitElement {
this._barElement?.classList.toggle("scrolled", scrollTop > 0);
};
private _handleBackClick(ev: Event) {
ev.stopPropagation();
goBack();
}
protected _registerListeners() {
this.scrollTarget.addEventListener(
"scroll",
@@ -314,7 +333,10 @@ export class HaTopAppBarFixed extends LitElement {
this.style.setProperty("--sub-row-height", `${subRowHeight}px`);
};
static override styles: CSSResultGroup = haTopAppBarFixedStyles;
static override styles: CSSResultGroup = [
haStyleScrollbar,
haTopAppBarFixedStyles,
];
}
declare global {
+16 -6
View File
@@ -85,15 +85,25 @@ export class HaTTSVoicePicker extends LitElement {
await listTTSVoices(this.hass, this.engineId, this.language)
).voices;
if (!this.value) {
const valueIsValid =
this.value &&
this._voices?.some((voice) => voice.voice_id === this.value);
if (valueIsValid) {
return;
}
if (
!this._voices ||
!this._voices.find((voice) => voice.voice_id === this.value)
) {
this.value = undefined;
// The current value is missing or no longer valid for the loaded voices.
// When a voice is required, auto-select the first one (the <ha-select>
// already displays it) so the value is propagated to the parent;
// otherwise clear it.
const newValue =
this.required && this._voices?.length
? this._voices[0].voice_id
: undefined;
if (newValue !== this.value) {
this.value = newValue;
fireEvent(this, "value-changed", { value: this.value });
}
}
@@ -29,6 +29,7 @@ export class HaTwoPaneTopAppBarFixed extends HaTopAppBarFixed {
<div
class=${classMap({
"top-app-bar-fixed-adjust": true,
"ha-scrollbar": true,
"top-app-bar-fixed-adjust--pane": this.pane,
})}
>
@@ -130,12 +131,7 @@ export class HaTwoPaneTopAppBarFixed extends HaTopAppBarFixed {
.top-app-bar-fixed-adjust--pane {
display: flex;
height: calc(
100vh - var(--total-top-app-bar-height, 0px) - var(
--safe-area-inset-top,
0px
) - var(--safe-area-inset-bottom, 0px)
);
overflow: hidden;
}
.pane {
@@ -167,6 +163,7 @@ export class HaTwoPaneTopAppBarFixed extends HaTopAppBarFixed {
position: relative;
flex: 1;
height: 100%;
min-width: 0;
}
.top-app-bar-fixed-adjust--pane .content {
-13
View File
@@ -1,13 +0,0 @@
import timezones from "google-timezones-json";
export const createTimezoneListEl = () => {
const list = document.createElement("datalist");
list.id = "timezones";
Object.keys(timezones).forEach((key) => {
const option = document.createElement("option");
option.value = key;
option.innerText = timezones[key];
list.appendChild(option);
});
return list;
};
+1 -1
View File
@@ -87,7 +87,7 @@ export const redirectWithAuthCode = (
// OAuth 2: 3.1.2 we need to retain query component of a redirect URI
if (!url.includes("?")) {
url += "?";
} else if (!url.endsWith("&")) {
} else if (!url.endsWith("?") && !url.endsWith("&")) {
url += "&";
}
+1 -1
View File
@@ -181,7 +181,7 @@ export interface RestoreBackupParams {
restore_homeassistant?: boolean;
}
export const fetchBackupConfig = (hass: HomeAssistant) =>
export const fetchBackupConfig = (hass: Pick<HomeAssistant, "callWS">) =>
hass.callWS<{ config: BackupConfig }>({ type: "backup/config/info" });
export const updateBackupConfig = (
+7 -3
View File
@@ -17,7 +17,7 @@ export type StreamType = typeof STREAM_TYPE_HLS | typeof STREAM_TYPE_WEB_RTC;
interface CameraEntityAttributes extends HassEntityAttributeBase {
model_name: string;
access_token: string;
access_token?: string;
brand: string;
motion_detection: boolean;
frontend_stream_type: string;
@@ -78,8 +78,12 @@ export const cameraUrlWithWidthHeight = (
height: number
) => `${base_url}&width=${width}&height=${height}`;
export const computeMJPEGStreamUrl = (entity: CameraEntity) =>
`/api/camera_proxy_stream/${entity.entity_id}?token=${entity.attributes.access_token}`;
export const computeMJPEGStreamUrl = (
entity: CameraEntity
): string | undefined =>
entity.attributes.access_token
? `/api/camera_proxy_stream/${entity.entity_id}?token=${entity.attributes.access_token}`
: undefined;
export const fetchThumbnailUrlWithCache = async (
hass: HomeAssistant,
+20 -16
View File
@@ -1121,14 +1121,12 @@ const getSummedDataPartial = (
const timestamps = new Set<number>();
Object.entries(statIds).forEach(([key, subStatIds]) => {
const totalStats: Record<number, number> = {};
const sets: Record<string, Record<number, number>> = {};
let sum = 0;
subStatIds!.forEach((id) => {
const stats = compare ? data.statsCompare[id] : data.stats[id];
if (!stats) {
return;
}
const set = {};
stats.forEach((stat) => {
if (stat.change === null || stat.change === undefined) {
return;
@@ -1139,7 +1137,6 @@ const getSummedDataPartial = (
stat.start in totalStats ? totalStats[stat.start] + val : val;
timestamps.add(stat.start);
});
sets[id] = set;
});
summedData[key] = totalStats;
summedData.total[key] = sum;
@@ -1190,6 +1187,13 @@ const computeConsumptionDataPartial = (
},
};
const fromGrid = data.from_grid;
const toGrid = data.to_grid;
const solarData = data.solar;
const toBattery = data.to_battery;
const fromBattery = data.from_battery;
const total = outData.total;
data.timestamps.forEach((t) => {
const {
grid_to_battery,
@@ -1201,29 +1205,29 @@ const computeConsumptionDataPartial = (
solar_to_battery,
solar_to_grid,
} = computeConsumptionSingle({
from_grid: data.from_grid && (data.from_grid[t] ?? 0),
to_grid: data.to_grid && (data.to_grid[t] ?? 0),
solar: data.solar && (data.solar[t] ?? 0),
to_battery: data.to_battery && (data.to_battery[t] ?? 0),
from_battery: data.from_battery && (data.from_battery[t] ?? 0),
from_grid: fromGrid && (fromGrid[t] ?? 0),
to_grid: toGrid && (toGrid[t] ?? 0),
solar: solarData && (solarData[t] ?? 0),
to_battery: toBattery && (toBattery[t] ?? 0),
from_battery: fromBattery && (fromBattery[t] ?? 0),
});
outData.used_total[t] = used_total;
outData.total.used_total += used_total;
total.used_total += used_total;
outData.grid_to_battery[t] = grid_to_battery;
outData.total.grid_to_battery += grid_to_battery;
total.grid_to_battery += grid_to_battery;
outData.battery_to_grid![t] = battery_to_grid;
outData.total.battery_to_grid += battery_to_grid;
total.battery_to_grid += battery_to_grid;
outData.used_battery![t] = used_battery;
outData.total.used_battery += used_battery;
total.used_battery += used_battery;
outData.used_grid![t] = used_grid;
outData.total.used_grid += used_grid;
total.used_grid += used_grid;
outData.used_solar![t] = used_solar;
outData.total.used_solar += used_solar;
total.used_solar += used_solar;
outData.solar_to_battery[t] = solar_to_battery;
outData.total.solar_to_battery += solar_to_battery;
total.solar_to_battery += solar_to_battery;
outData.solar_to_grid[t] = solar_to_grid;
outData.total.solar_to_grid += solar_to_grid;
total.solar_to_grid += solar_to_grid;
});
return outData;
+24 -10
View File
@@ -73,30 +73,44 @@ export const getEntities = (
let entityIds = Object.keys(hass.states);
// These run over every entity, so use Sets for O(1) membership instead of
// repeated Array.includes scans.
if (includeEntities) {
const includeEntitiesSet = new Set(includeEntities);
entityIds = entityIds.filter((entityId) =>
includeEntities.includes(entityId)
includeEntitiesSet.has(entityId)
);
}
if (excludeEntities) {
const excludeEntitiesSet = new Set(excludeEntities);
entityIds = entityIds.filter(
(entityId) => !excludeEntities.includes(entityId)
(entityId) => !excludeEntitiesSet.has(entityId)
);
}
if (includeDomains) {
const includeDomainsSet = new Set(includeDomains);
entityIds = entityIds.filter((eid) =>
includeDomains.includes(computeDomain(eid))
includeDomainsSet.has(computeDomain(eid))
);
}
if (excludeDomains) {
const excludeDomainsSet = new Set(excludeDomains);
entityIds = entityIds.filter(
(eid) => !excludeDomains.includes(computeDomain(eid))
(eid) => !excludeDomainsSet.has(computeDomain(eid))
);
}
// These values are the same for every entity, so compute them once instead
// of inside the map over (potentially thousands of) entities.
const isRTL = computeRTL(
hass.language,
hass.translationMetadata.translations
);
const domainNames = new Map<string, string>();
items = entityIds.map<EntityComboBoxItem>((entityId) => {
const stateObj = hass.states[entityId];
@@ -110,12 +124,12 @@ export const getEntities = (
hass.floors
);
const domainName = domainToName(hass.localize, computeDomain(entityId));
const isRTL = computeRTL(
hass.language,
hass.translationMetadata.translations
);
const domain = computeDomain(entityId);
let domainName = domainNames.get(domain);
if (domainName === undefined) {
domainName = domainToName(hass.localize, domain);
domainNames.set(domain, domainName);
}
const primary = entityName || deviceName || entityId;
const secondary = [areaName, entityName ? deviceName : undefined]
+4 -3
View File
@@ -7,11 +7,12 @@ interface EntitySource {
export type EntitySources = Record<string, EntitySource>;
const fetchEntitySources = (hass: HomeAssistant): Promise<EntitySources> =>
hass.callWS({ type: "entity/source" });
const fetchEntitySources = (
hass: Pick<HomeAssistant, "callWS">
): Promise<EntitySources> => hass.callWS({ type: "entity/source" });
export const fetchEntitySourcesWithCache = (
hass: HomeAssistant
hass: Pick<HomeAssistant, "callWS" | "states">
): Promise<EntitySources> =>
timeCachePromiseFunc(
"_entitySources",
+70 -70
View File
@@ -10,6 +10,7 @@ import { computeStateDisplayFromEntityAttributes } from "../common/entity/comput
import { computeStateNameFromEntityAttributes } from "../common/entity/compute_state_name";
import type { LocalizeFunc } from "../common/translations/localize";
import type { HomeAssistant } from "../types";
import { isNumericSensorDeviceClass } from "./sensor";
import type { FrontendLocaleData } from "./translation";
import type { Statistics } from "./recorder";
@@ -164,60 +165,70 @@ export class HistoryStream {
? (new Date().getTime() - 60 * 60 * this.hoursToShow * 1000) / 1000
: undefined;
const newHistory: HistoryStates = {};
for (const entityId of Object.keys(this.combinedHistory)) {
newHistory[entityId] = [];
}
for (const entityId of Object.keys(streamMessage.states)) {
newHistory[entityId] = [];
}
for (const entityId of Object.keys(newHistory)) {
if (
entityId in this.combinedHistory &&
entityId in streamMessage.states
) {
// Build the union of entity ids (existing first, then new ones) in a
// single pass and process each entity inline. The per-entity slot is
// always assigned below before being read, so there is no need to
// pre-seed every key with an empty array first.
const streamStates = streamMessage.states;
const processEntity = (entityId: string) => {
const inCombined = entityId in this.combinedHistory;
const inStream = entityId in streamStates;
if (inCombined && inStream) {
const entityCombinedHistory = this.combinedHistory[entityId];
const lastEntityCombinedHistory =
entityCombinedHistory[entityCombinedHistory.length - 1];
newHistory[entityId] = entityCombinedHistory.concat(
streamMessage.states[entityId]
streamStates[entityId]
);
if (
streamMessage.states[entityId][0].lu < lastEntityCombinedHistory.lu
) {
if (streamStates[entityId][0].lu < lastEntityCombinedHistory.lu) {
// If the history is out of order we have to sort it.
newHistory[entityId] = newHistory[entityId].sort(
(a, b) => a.lu - b.lu
);
}
} else if (entityId in this.combinedHistory) {
} else if (inCombined) {
newHistory[entityId] = this.combinedHistory[entityId];
} else {
newHistory[entityId] = streamMessage.states[entityId];
newHistory[entityId] = streamStates[entityId];
return;
}
// Remove old history
if (purgeBeforePythonTime && entityId in this.combinedHistory) {
const expiredStates = newHistory[entityId].filter(
(state) => state.lu < purgeBeforePythonTime
);
if (!expiredStates.length) {
continue;
// Remove old history (only entities present in combinedHistory reach
// here without an early return).
if (purgeBeforePythonTime) {
// Single pass: split into kept (lu >= cutoff, preserving order) and
// track the last expired state (lu < cutoff) without allocating a
// second array.
const states = newHistory[entityId];
const kept: EntityHistoryState[] = [];
let lastExpiredState: EntityHistoryState | undefined;
for (const state of states) {
if (state.lu < purgeBeforePythonTime) {
lastExpiredState = state;
} else {
kept.push(state);
}
}
newHistory[entityId] = newHistory[entityId].filter(
(state) => state.lu >= purgeBeforePythonTime
);
if (
newHistory[entityId].length &&
newHistory[entityId][0].lu === purgeBeforePythonTime
) {
continue;
if (!lastExpiredState) {
return;
}
newHistory[entityId] = kept;
if (kept.length && kept[0].lu === purgeBeforePythonTime) {
return;
}
// Update the first entry to the start time state
// as we need to preserve the start time state and
// only expire the rest of the history as it ages.
const lastExpiredState = expiredStates[expiredStates.length - 1];
lastExpiredState.lu = purgeBeforePythonTime;
delete lastExpiredState.lc;
newHistory[entityId].unshift(lastExpiredState);
kept.unshift(lastExpiredState);
}
};
for (const entityId of Object.keys(this.combinedHistory)) {
processEntity(entityId);
}
for (const entityId of Object.keys(streamStates)) {
if (!(entityId in this.combinedHistory)) {
processEntity(entityId);
}
}
this.combinedHistory = newHistory;
@@ -346,7 +357,6 @@ const processTimelineEntity = (
state_localize: computeStateDisplayFromEntityAttributes(
localize,
locale,
[], // numeric device classes not used for Timeline
config,
entities[entityId],
entityId,
@@ -381,16 +391,18 @@ const processLineChartEntities = (
): LineChartUnit => {
const data: LineChartEntity[] = [];
Object.keys(entities).forEach((entityId) => {
const entityIds = Object.keys(entities);
entityIds.forEach((entityId) => {
const states = entities[entityId];
const first: EntityHistoryState = states[0];
const domain = computeDomain(entityId);
const useLastUpdated = DOMAINS_USE_LAST_UPDATED.includes(domain);
const processedStates: LineChartState[] = [];
for (const state of states) {
let processedState: LineChartState;
if (DOMAINS_USE_LAST_UPDATED.includes(domain)) {
if (useLastUpdated) {
processedState = {
state: state.s,
last_changed: state.lu * 1000,
@@ -412,13 +424,11 @@ const processLineChartEntities = (
};
}
const len = processedStates.length;
if (
processedStates.length > 1 &&
equalState(
processedState,
processedStates[processedStates.length - 1]
) &&
equalState(processedState, processedStates[processedStates.length - 2])
len > 1 &&
equalState(processedState, processedStates[len - 1]) &&
equalState(processedState, processedStates[len - 2])
) {
continue;
}
@@ -444,11 +454,17 @@ const processLineChartEntities = (
return {
unit,
device_class,
identifier: Object.keys(entities).join(""),
identifier: entityIds.join(""),
data,
};
};
const SPECIAL_DOMAIN_CLASSES: Record<string, string | undefined> = {
climate: "temperature",
humidifier: "humidity",
water_heater: "temperature",
};
const NUMERICAL_DOMAINS = ["counter", "input_number", "number"];
const isNumericFromDomain = (domain: string) =>
@@ -457,20 +473,12 @@ const isNumericFromDomain = (domain: string) =>
const isNumericFromAttributes = (attributes: Record<string, any>) =>
"unit_of_measurement" in attributes || "state_class" in attributes;
const isNumericSensorEntity = (
stateObj: HassEntity,
sensorNumericalDeviceClasses: string[]
) =>
stateObj.attributes.device_class != null &&
sensorNumericalDeviceClasses.includes(stateObj.attributes.device_class);
const BLANK_UNIT = " ";
export const convertStatisticsToHistory = (
hass: HomeAssistant,
statistics: Statistics,
statisticIds: string[],
sensorNumericDeviceClasses: string[],
splitDeviceClasses = false
): HistoryResult => {
// Maintain the statistic id ordering
@@ -498,7 +506,6 @@ export const convertStatisticsToHistory = (
statsHistoryStates,
[],
hass.localize,
sensorNumericDeviceClasses,
splitDeviceClasses,
true
);
@@ -528,7 +535,6 @@ export const computeHistory = (
stateHistory: HistoryStates,
entityIds: string[],
localize: LocalizeFunc,
sensorNumericalDeviceClasses: string[],
splitDeviceClasses = false,
forceNumeric = false
): HistoryResult => {
@@ -575,7 +581,6 @@ export const computeHistory = (
domain,
currentState,
numericStateFromHistory,
sensorNumericalDeviceClasses,
forceNumeric
);
@@ -593,14 +598,8 @@ export const computeHistory = (
}[domain];
}
const specialDomainClasses = {
climate: "temperature",
humidifier: "humidity",
water_heater: "temperature",
};
const deviceClass: string | undefined =
specialDomainClasses[domain] ||
SPECIAL_DOMAIN_CLASSES[domain] ||
(currentState?.attributes || numericStateFromHistory?.a)?.device_class;
const key = computeGroupKey(unit, deviceClass, splitDeviceClasses);
@@ -656,7 +655,6 @@ export const isNumericEntity = (
domain: string,
currentState: HassEntity | undefined,
numericStateFromHistory: EntityHistoryState | undefined,
sensorNumericalDeviceClasses: string[],
forceNumeric = false
): boolean =>
forceNumeric ||
@@ -664,7 +662,7 @@ export const isNumericEntity = (
(currentState != null && isNumericFromAttributes(currentState.attributes)) ||
(currentState != null &&
domain === "sensor" &&
isNumericSensorEntity(currentState, sensorNumericalDeviceClasses)) ||
isNumericSensorDeviceClass(currentState.attributes.device_class)) ||
numericStateFromHistory != null;
export const mergeHistoryResults = (
@@ -725,16 +723,18 @@ export const mergeHistoryResults = (
}
const newLineItem: LineChartUnit = { ...historyItem, data: [] };
const historyDataByEntity = new Map(
historyItem.data.map((d) => [d.entity_id, d])
);
const ltsDataByEntity = new Map(ltsItem.data.map((d) => [d.entity_id, d]));
const entities = new Set([
...historyItem.data.map((d) => d.entity_id),
...ltsItem.data.map((d) => d.entity_id),
...historyDataByEntity.keys(),
...ltsDataByEntity.keys(),
]);
for (const entity of entities) {
const historyDataItem = historyItem.data.find(
(d) => d.entity_id === entity
);
const ltsDataItem = ltsItem.data.find((d) => d.entity_id === entity);
const historyDataItem = historyDataByEntity.get(entity);
const ltsDataItem = ltsDataByEntity.get(entity);
if (!historyDataItem || !ltsDataItem) {
newLineItem.data.push(historyDataItem || ltsDataItem!);
+5 -3
View File
@@ -4,12 +4,14 @@ import type {
} from "home-assistant-js-websocket";
interface ImageEntityAttributes extends HassEntityAttributeBase {
access_token: string;
access_token?: string;
}
export interface ImageEntity extends HassEntityBase {
attributes: ImageEntityAttributes;
}
export const computeImageUrl = (entity: ImageEntity): string =>
`/api/image_proxy/${entity.entity_id}?token=${entity.attributes.access_token}&state=${entity.state}`;
export const computeImageUrl = (entity: ImageEntity): string | undefined =>
entity.attributes.access_token
? `/api/image_proxy/${entity.entity_id}?token=${entity.attributes.access_token}&state=${entity.state}`
: undefined;
-7
View File
@@ -43,11 +43,6 @@ export const lightSupportsColorMode = (
mode: LightColorMode
) => entity.attributes.supported_color_modes?.includes(mode) || false;
export const lightIsInColorMode = (entity: LightEntity) =>
(entity.attributes.color_mode &&
modesSupportingColor.includes(entity.attributes.color_mode)) ||
false;
export const lightSupportsColor = (entity: LightEntity) =>
entity.attributes.supported_color_modes?.some((mode) =>
modesSupportingColor.includes(mode)
@@ -159,5 +154,3 @@ export const computeDefaultFavoriteColors = (
return colors;
};
export const formatTempColor = (value: number) => `${value} K`;
+2 -6
View File
@@ -369,14 +369,10 @@ export const localizeStateMessage = (
});
};
export const filterLogbookCompatibleEntities = (
entity,
sensorNumericDeviceClasses: string[] = []
) => {
export const filterLogbookCompatibleEntities = (entity) => {
const domain = computeStateDomain(entity);
const continuous =
CONTINUOUS_DOMAINS.includes(domain) ||
(domain === "sensor" &&
isNumericEntity(domain, entity, undefined, sensorNumericDeviceClasses));
(domain === "sensor" && isNumericEntity(domain, entity, undefined));
return !continuous;
};
+3 -1
View File
@@ -128,11 +128,13 @@ export const addMatterDevice = (hass: HomeAssistant) => {
export const commissionMatterDevice = (
hass: HomeAssistant,
code: string
code: string,
networkOnly: boolean
): Promise<void> =>
hass.callWS({
type: "matter/commission",
code,
network_only: networkOnly,
});
export const acceptSharedMatterDevice = (
+4 -25
View File
@@ -1,3 +1,4 @@
import { SENSOR_NUMERIC_DEVICE_CLASSES } from "./sensor_numeric_device_classes";
import type { HomeAssistant } from "../types";
export const SENSOR_DEVICE_CLASS_BATTERY = "battery";
@@ -11,6 +12,9 @@ export const SENSOR_TIMESTAMP_DEVICE_CLASSES: (string | undefined)[] = [
"uptime",
];
export const isNumericSensorDeviceClass = (deviceClass?: string): boolean =>
deviceClass != null && SENSOR_NUMERIC_DEVICE_CLASSES.includes(deviceClass);
export interface SensorDeviceClassUnits {
units: string[];
}
@@ -23,28 +27,3 @@ export const getSensorDeviceClassConvertibleUnits = (
type: "sensor/device_class_convertible_units",
device_class: deviceClass,
});
export interface SensorNumericDeviceClasses {
numeric_device_classes: string[];
}
let sensorNumericDeviceClassesCache:
| Promise<SensorNumericDeviceClasses>
| undefined;
export const getSensorNumericDeviceClasses = async (
hass: HomeAssistant
): Promise<SensorNumericDeviceClasses> => {
if (sensorNumericDeviceClassesCache) {
return sensorNumericDeviceClassesCache;
}
sensorNumericDeviceClassesCache = hass
.callWS<SensorNumericDeviceClasses>({
type: "sensor/numeric_device_classes",
})
.catch((err: Error) => {
sensorNumericDeviceClassesCache = undefined;
throw err;
});
return sensorNumericDeviceClassesCache!;
};
+63
View File
@@ -0,0 +1,63 @@
// 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[] = [
"absolute_humidity",
"apparent_power",
"aqi",
"area",
"atmospheric_pressure",
"battery",
"blood_glucose_concentration",
"carbon_dioxide",
"carbon_monoxide",
"conductivity",
"current",
"data_rate",
"data_size",
"distance",
"duration",
"energy",
"energy_distance",
"energy_storage",
"frequency",
"gas",
"humidity",
"illuminance",
"irradiance",
"moisture",
"monetary",
"nitrogen_dioxide",
"nitrogen_monoxide",
"nitrous_oxide",
"ozone",
"ph",
"pm1",
"pm10",
"pm25",
"pm4",
"power",
"power_factor",
"precipitation",
"precipitation_intensity",
"pressure",
"reactive_energy",
"reactive_power",
"signal_strength",
"sound_pressure",
"speed",
"sulphur_dioxide",
"temperature",
"temperature_delta",
"volatile_organic_compounds",
"volatile_organic_compounds_parts",
"voltage",
"volume",
"volume_flow_rate",
"volume_storage",
"water",
"weight",
"wind_direction",
"wind_speed",
];
+3 -1
View File
@@ -6,7 +6,9 @@ export interface SupervisorUpdateConfig {
core_backup_before_update: boolean;
}
export const getSupervisorUpdateConfig = async (hass: HomeAssistant) =>
export const getSupervisorUpdateConfig = async (
hass: Pick<HomeAssistant, "callWS">
) =>
hass.callWS<SupervisorUpdateConfig>({
type: "hassio/update/config/info",
});
+36 -22
View File
@@ -77,7 +77,10 @@ export const updateButtonIsDisabled = (entity: UpdateEntity): boolean =>
export const updateIsInstalling = (entity: UpdateEntity): boolean =>
!!entity.attributes.in_progress;
export const updateReleaseNotes = (hass: HomeAssistant, entityId: string) =>
export const updateReleaseNotes = (
hass: Pick<HomeAssistant, "callWS">,
entityId: string
) =>
hass.callWS<string | null>({
type: "update/release_notes",
entity_id: entityId,
@@ -146,10 +149,20 @@ export const filterUpdateEntitiesParameterized = (
return updateCanInstall(entity, showSkipped);
});
export const installUpdates = (hass: HomeAssistant, entityIds: string[]) =>
hass.callService("update", "install", {
entity_id: entityIds,
});
export const installUpdates = (
hass: HomeAssistant,
entityIds: string[],
notifyOnError = true
) =>
hass.callService(
"update",
"install",
{
entity_id: entityIds,
},
undefined,
notifyOnError
);
export const checkForEntityUpdates = async (
element: HTMLElement,
@@ -221,6 +234,24 @@ export const computeUpdateStateDisplay = (
const state = stateObj.state;
const attributes = stateObj.attributes;
// An install can be in progress even when the state is "off", e.g. when
// downgrading firmware (installed_version is newer than latest_version).
// Show the installing status regardless of state in that case.
if (updateIsInstalling(stateObj)) {
const supportsProgress =
supportsFeature(stateObj, UpdateEntityFeature.PROGRESS) &&
attributes.update_percentage !== null;
if (supportsProgress) {
return hass.localize("ui.card.update.installing_with_progress", {
progress: formatNumber(attributes.update_percentage!, hass.locale, {
maximumFractionDigits: attributes.display_precision,
minimumFractionDigits: attributes.display_precision,
}),
});
}
return hass.localize("ui.card.update.installing");
}
if (state === "off") {
const isSkipped =
attributes.latest_version &&
@@ -231,23 +262,6 @@ export const computeUpdateStateDisplay = (
return hass.formatEntityState(stateObj);
}
if (state === "on") {
if (updateIsInstalling(stateObj)) {
const supportsProgress =
supportsFeature(stateObj, UpdateEntityFeature.PROGRESS) &&
attributes.update_percentage !== null;
if (supportsProgress) {
return hass.localize("ui.card.update.installing_with_progress", {
progress: formatNumber(attributes.update_percentage!, hass.locale, {
maximumFractionDigits: attributes.display_precision,
minimumFractionDigits: attributes.display_precision,
}),
});
}
return hass.localize("ui.card.update.installing");
}
}
return hass.formatEntityState(stateObj);
};
@@ -10,13 +10,21 @@ import "../../components/ha-button";
import type { HaSwitch } from "../../components/ha-switch";
import type { ConfigEntryMutableParams } from "../../data/config_entries";
import { updateConfigEntry } from "../../data/config_entries";
import { DirtyStateProviderMixin } from "../../mixins/dirty-state-provider-mixin";
import { haStyleDialog } from "../../resources/styles";
import type { HomeAssistant } from "../../types";
import { showAlertDialog } from "../generic/show-dialog-box";
import type { ConfigEntrySystemOptionsDialogParams } from "./show-dialog-config-entry-system-options";
interface SystemOptionsState {
disableNewEntities: boolean;
disablePolling: boolean;
}
@customElement("dialog-config-entry-system-options")
class DialogConfigEntrySystemOptions extends LitElement {
class DialogConfigEntrySystemOptions extends DirtyStateProviderMixin<SystemOptionsState>()(
LitElement
) {
@property({ attribute: false }) public hass!: HomeAssistant;
@state() private _disableNewEntities!: boolean;
@@ -38,6 +46,13 @@ class DialogConfigEntrySystemOptions extends LitElement {
this._error = undefined;
this._disableNewEntities = params.entry.pref_disable_new_entities;
this._disablePolling = params.entry.pref_disable_polling;
this._initDirtyTracking(
{ type: "shallow" },
{
disableNewEntities: this._disableNewEntities,
disablePolling: this._disablePolling,
}
);
this._open = true;
}
@@ -68,7 +83,7 @@ class DialogConfigEntrySystemOptions extends LitElement {
) || this._params.entry.domain,
}
)}
prevent-scrim-close
.preventScrimClose=${this.isDirtyState}
@closed=${this._dialogClosed}
>
${this._error ? html` <div class="error">${this._error}</div> ` : ""}
@@ -135,7 +150,7 @@ class DialogConfigEntrySystemOptions extends LitElement {
<ha-button
slot="primaryAction"
@click=${this._updateEntry}
.disabled=${this._submitting}
.disabled=${this._submitting || !this.isDirtyState}
>
${this.hass.localize(
"ui.dialogs.config_entry_system_options.update"
@@ -149,11 +164,19 @@ class DialogConfigEntrySystemOptions extends LitElement {
private _disableNewEntitiesChanged(ev: Event): void {
this._error = undefined;
this._disableNewEntities = !(ev.target as HaSwitch).checked;
this._updateDirtyState({
disableNewEntities: this._disableNewEntities,
disablePolling: this._disablePolling,
});
}
private _disablePollingChanged(ev: Event): void {
this._error = undefined;
this._disablePolling = !(ev.target as HaSwitch).checked;
this._updateDirtyState({
disableNewEntities: this._disableNewEntities,
disablePolling: this._disablePolling,
});
}
private async _updateEntry(): Promise<void> {
@@ -403,6 +403,7 @@ class DataEntryFlowDialog extends LitElement {
.flowConfig=${this._params.flowConfig}
.step=${this._step}
.hass=${this.hass}
.domain=${this._params.domain ?? this._step.handler}
@flow-step-footer-state-changed=${this
._handleFooterStateChanged}
></step-flow-form>
@@ -106,7 +106,9 @@ class EntityPreviewRow extends LitElement {
}
`;
private _renderEntityState(stateObj: HassEntity): TemplateResult | string {
private _renderEntityState(
stateObj: HassEntity
): TemplateResult | string | typeof nothing {
const domain = stateObj.entity_id.split(".", 1)[0];
const disabled = stateObj.state === UNAVAILABLE;
const noValue =
@@ -216,7 +218,10 @@ class EntityPreviewRow extends LitElement {
}
if (domain === "image") {
const image: string = computeImageUrl(stateObj as ImageEntity);
const image = computeImageUrl(stateObj as ImageEntity);
if (!image) {
return nothing;
}
return html`
<img
alt=${ifDefined(stateObj?.attributes.friendly_name)}
+5 -1
View File
@@ -35,6 +35,10 @@ class StepFlowForm extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
// The integration domain this flow belongs to. Unlike `step.handler`, this is
// the domain even for options flows (where the handler is the config entry id).
@property({ attribute: false }) public domain?: string;
@state() private _loading = false;
@state() private _stepData?: Record<string, any>;
@@ -108,7 +112,7 @@ class StepFlowForm extends LitElement {
.computeHelper=${this._helperCallback}
.computeError=${this._errorCallback}
.localizeValue=${this._localizeValueCallback}
.context=${{ handler: step.handler }}
.context=${{ handler: step.handler, domain: this.domain }}
></ha-form>`
: nothing}
</div>
+8 -2
View File
@@ -7,6 +7,7 @@ import "../../components/ha-button";
import "../../components/ha-form/ha-form";
import "../../components/ha-dialog-footer";
import "../../components/ha-dialog";
import { DirtyStateProviderMixin } from "../../mixins/dirty-state-provider-mixin";
import { haStyleDialog } from "../../resources/styles";
import type { HomeAssistant } from "../../types";
import type { HassDialog, ShowDialogParams } from "../make-dialog-manager";
@@ -20,7 +21,7 @@ interface StackEntry {
@customElement("dialog-form")
export class DialogForm
extends LitElement
extends DirtyStateProviderMixin<FormDialogData>()(LitElement)
implements HassDialog<FormDialogData>
{
@property({ attribute: false }) public hass?: HomeAssistant;
@@ -39,6 +40,7 @@ export class DialogForm
this._params = params;
this._data = params.data || {};
this._open = true;
this._initDirtyTracking({ type: "deep" }, this._data);
}
public closeDialog(): boolean {
@@ -62,6 +64,7 @@ export class DialogForm
const nested = ev.detail.dialogParams as FormDialogParams;
this._params = nested;
this._data = nested?.data || {};
this._initDirtyTracking({ type: "deep" }, this._data);
};
private _popStack(): string | undefined {
@@ -72,6 +75,7 @@ export class DialogForm
this._stack = this._stack.slice(0, -1);
this._params = prev.params;
this._data = prev.data;
this._initDirtyTracking({ type: "deep" }, this._data);
return prev.nestedField;
}
@@ -115,6 +119,7 @@ export class DialogForm
: data;
this._data = deepClone({ ...this._data, [nestedField]: newValue });
this._updateDirtyState(this._data);
}
private _cancel(): void {
@@ -131,6 +136,7 @@ export class DialogForm
private _valueChanged(ev: CustomEvent): void {
this._data = ev.detail.value;
this._updateDirtyState(this._data);
}
protected render() {
@@ -142,7 +148,7 @@ export class DialogForm
<ha-dialog
.open=${this._open}
header-title=${this._params.title}
prevent-scrim-close
.preventScrimClose=${this.isDirtyState}
@closed=${this._dialogClosed}
>
<ha-form
+17 -2
View File
@@ -13,11 +13,18 @@ import "../../components/ha-textarea";
import type { HaTextArea } from "../../components/ha-textarea";
import "../../components/input/ha-input";
import type { HaInput } from "../../components/input/ha-input";
import { DirtyStateProviderMixin } from "../../mixins/dirty-state-provider-mixin";
import type { HomeAssistant } from "../../types";
import type { DialogBoxParams } from "./show-dialog-box";
interface DialogBoxDirtyState {
value: string;
}
@customElement("dialog-box")
class DialogBox extends LitElement {
class DialogBox extends DirtyStateProviderMixin<DialogBoxDirtyState>()(
LitElement
) {
@property({ attribute: false }) public hass!: HomeAssistant;
@state() private _params?: DialogBoxParams;
@@ -43,6 +50,10 @@ class DialogBox extends LitElement {
this._params = params;
this._validInput = true;
this._open = true;
this._initDirtyTracking(
{ type: "deep" },
{ value: params.defaultValue ?? "" }
);
await this.updateComplete;
this._validateInput();
}
@@ -77,7 +88,7 @@ class DialogBox extends LitElement {
<ha-dialog
.open=${this._open}
type=${confirmPrompt ? "alert" : "standard"}
?prevent-scrim-close=${confirmPrompt}
.preventScrimClose=${!!this._params.confirmation || this.isDirtyState}
@closed=${this._dialogClosed}
aria-labelledby="dialog-box-title"
aria-describedby="dialog-box-description"
@@ -212,6 +223,7 @@ class DialogBox extends LitElement {
if (this._params!.confirm) {
this._params!.confirm(this._textField?.value);
}
this._markDirtyStateClean();
this._closeDialog();
}
@@ -219,6 +231,9 @@ class DialogBox extends LitElement {
this._validInput = this._params?.prompt
? (this._textField?.checkValidity() ?? true)
: true;
if (this._params?.prompt) {
this._updateDirtyState({ value: this._textField?.value ?? "" });
}
}
private _closeDialog() {

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