Compare commits

..
63 Commits
Author SHA1 Message Date
Aidan Timson 63d1d1b9b8 Drop hass from error-log-card (#55062) 2026-10-19 14:13:25 +02:00
Aidan Timsonandcopilot-swe-agent[bot] bab1485473 Hide skipped updates in the Marketplace by default (#55061)
* Hide skipped updates in the Marketplace by default

* Drop the show skipped updates filter

* Narrow Marketplace skipped update subscriptions

* Work out skipped updates after their states are selected

* Keep the Marketplace table when the same updates are skipped

---------

Co-authored-by: copilot-swe-agent[bot] <[email protected]>
2026-10-19 14:12:09 +02:00
0x696f6fandClaude Opus 5.5 363a1861e6 Fix view tab ignored after navigating to a hidden view (#54613)
* Fix view tab ignored after navigating to a hidden view

Web Awesome does not activate disabled tabs, so when the dashboard marks
the disabled tab of a hidden view active, the tab group keeps tracking the
previous tab and ignores clicks or Enter/Space on it. Show the picked tab
ourselves when the group ignored it.

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

* Drive the view tab group through its active property

Set the tab group's active property from the current view instead of only
marking the tabs, so the group never keeps a stale active tab after
navigating to a hidden view. Also show the tab of a hidden view while it is
the current view. This replaces the fallback in ha-tab-group, as suggested
in review.

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

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-19 12:11:37 +00:00
Maarten Lakerveld 38ac2a01ec Do not start timers or keep subscriptions after an element is removed (#55056) 2026-10-19 10:31:17 +01:00
charishma2k06 7edd0f1c38 Fix entity pictures in automation targets (#54925) 2026-10-19 11:26:31 +02:00
CRYPTONIKAV 313ec9440e Gate welcome banner and dismiss save on loaded config (#54825)
* fix(home): gate welcome banner and dismiss save on loaded config

* chore: retrigger status evaluation

* fix: optional config instead of loaded flag, typed fixtures
2026-10-19 09:14:38 +00:00
5f0fb8ab23 Merge final assistant response into streamed chat text instead of replacing it (#54311)
Co-authored-by: Copilot Autofix powered by AI <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-19 11:09:38 +02:00
Lucas DeutschlandandCopilot Autofix powered by AI b9a04ed660 Fix map marker placement for zones with duplicate names (#54895)
* Fix map marker placement for zones with duplicate names

* Ensure entities are spread in ha-map-engine test

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

* Remove unused zone tracker state import

* Fix legacy map zone grouping and simplify tests

* Fix map zone selection when the active zone is hidden

* Update map markers when referenced zones change

* Align map coordinate fallback with Core zone selection

* Simplify map zone grouping and tests

---------

Co-authored-by: Copilot Autofix powered by AI <[email protected]>
2026-10-19 09:51:35 +02:00
renovate[bot]andPetar Petrov 60108e1d74 Update dependency js-yaml to v5.5.0 (#55053)
Co-authored-by: Petar Petrov <[email protected]>
2026-10-19 08:45:52 +01:00
karwosts ca685a5c13 Add group role and aria labelling to thermostat card (#55045) 2026-10-19 08:43:23 +01:00
Petar Petrov f008af2751 Fix overlapping horizontal Sankey labels at narrow widths (#55021) 2026-10-19 08:34:09 +01:00
Daan Vervacke 47a0f8863d Fix inconsistent heading spacing on the integrations dashboard (#55037) 2026-10-19 09:32:14 +02:00
renovate[bot] f2bf89ad69 Update actions/setup-node action to v7.1.0 (#55052)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-10-19 07:29:57 +00:00
Aidan Timson c2955a8745 Add alert badge (#54881)
* Add alert badge

* Use shared alert domains in the alert badge

* Only alert while the alert badge entity is in an alert state

* Default the alert badge color to red and show its colors side by side

* Use consumeLocalize in the alert badge

* Rename the alert badge colors to alert color and clear color
2026-10-19 09:22:42 +02:00
renovate[bot] b9a5d0af0d Update github/codeql-action action to v4.38.3 (#55048)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-10-19 07:43:02 +02:00
renovate[bot] 5cfaf7ed40 Update fullcalendar monorepo to v6.1.22 (#55047)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-10-19 05:31:02 +00:00
karwosts 85a3bcc494 Present hidden select menu label to screen readers (#55032) 2026-10-19 08:11:03 +03:00
karwosts 81f7cbdc22 Apply thermostat accessibility fixes to humidifier and water_heater (#55044)
Apply thermostat localization fixes to humidifier and water_heater
2026-10-19 08:10:30 +03:00
renovate[bot] 8990a42f1b Update actions/upload-artifact action to v7.0.2 (#55046)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-10-19 08:09:08 +03:00
renovate[bot] 330208a98f Update actions/download-artifact action to v8.0.2 (#55043)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-10-19 05:05:46 +00:00
renovate[bot] a8a5d8d73a Update home-assistant/actions digest to cd6f363 (#55042)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-10-19 05:05:27 +00:00
Daan VervackeandCopilot Autofix powered by AI 1801441779 Add selection mode with bulk delete to the labels page (#54987)
* Add selection mode with bulk remove to the labels page

* Seed demo labels and align the registry stub with tags

* Align the label overflow menu with Remove wording

* Trigger event on label removal

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

* Use Delete wording for label destruction

* Clear deleted labels from entity assignments in the demo

* Clear deleted labels from retired entity entries in the demo

* Copy the seed array in the label registry stub

---------

Co-authored-by: Copilot Autofix powered by AI <[email protected]>
2026-10-18 19:49:12 +00:00
renovate[bot] 2cdb57b00f Update vitest monorepo to v5.0.5 (#55033)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-10-18 15:18:33 +00:00
Aidan Timson a6fe937d45 Add visual editor for the alert card and add clear color (#54880)
* Stop alert card pulse when reduced motion is on

* Add visual editor for the alert card

* Suggest the alert card when adding a card by entity

* Share the alert active states with the alert card

* Only alert while the alert card entity is in an alert state

* Show the alert card colors side by side

* Default the alert card color to red

* Only offer alert entities in the security alerts picker

* Rename the alert card colors to alert color and clear color
2026-10-18 17:10:30 +02:00
ChrisandClaude Opus 5.5 ffb78bd21f Show the local apps folder toggle in backup settings (#54983)
* Show the local apps folder toggle in backup settings

Local apps have the slug local_<slug>, never local, so the toggle for
the addons/local folder was never shown.

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

* Tests for this change

* Remove rendering tests for the local apps toggle

The frontend testing guidelines don't allow rendering tests.

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-18 14:49:19 +00:00
Petar Petrov bb436667d5 Set up Music Assistant through its config flow in the ESPHome wizard (#54973)
* Set up Music Assistant through its config flow in the ESPHome wizard

The Music Assistant config flow now installs, starts and onboards the app
itself, so open it instead of installing the app and waiting for its
discovery flow.

* Don't open the Music Assistant flow after the offer was skipped
2026-10-18 16:44:24 +02:00
Daan Vervacke aca2ba2745 Add selection mode with bulk delete to the blueprints page (#54984)
* Add selection mode with bulk delete to the blueprints page

* Tidy bulk delete state handling and add demo delete stub

* Distinguish failed usage checks from in-use blueprints

* Let fresh usage checks decide which blueprints can be deleted
2026-10-18 16:41:44 +02:00
Daan VervackeandCopilot Autofix powered by AI 807b037650 Add selection mode with bulk uninstall to the Marketplace installed tab (#54996)
* Add selection mode with bulk uninstall to the Marketplace installed tab

* Refactor mock imports in dashboard test

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

---------

Co-authored-by: Copilot Autofix powered by AI <[email protected]>
2026-10-18 16:35:12 +02:00
Daan Vervacke 277ae199e5 Add selection mode with bulk delete to the dashboards page (#54993) 2026-10-18 16:30:02 +02:00
renovate[bot] e0aece68aa Update pnpm to v12.14.0 (#55029)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-10-18 16:24:04 +02:00
Petar Petrov d6449e5707 Fix chart legend squeezed when legend items arrive after setup (#55019)
Resize chart when its legend item count changes
2026-10-18 16:06:12 +02:00
Petar Petrov 54e123c469 Fix Sankey label sizes after zooming (#55023)
The label layout callback gets the node rect in zoomed screen pixels,
so only the slack around the node scales with the zoom. Report the new
zoom before the setOption calls and actions that lay the labels out
again, so a reset or double-click doesn't size them for the old zoom.
2026-10-18 15:35:41 +02:00
Petar Petrov 54f674fd47 Fix Sankey labels on thin nodes showing at full size in Safari (#55020) 2026-10-18 15:34:06 +02:00
renovate[bot] 8a3e7e8e62 Update dependency jsdom to v30.1.3 (#55028)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-10-18 15:28:43 +02:00
karwosts b8dbf2e419 Improve climate control accessibility (#55024) 2026-10-18 15:28:25 +02:00
Petar Petrov a23759bf63 Handle null cast device capabilities (#55025)
@types/chromecast-caf-receiver 6.0.27 types getDeviceCapabilities() as
nullable, since it returns null until the cast system is ready. This
broke tsc on dev.
2026-10-18 15:10:45 +02:00
karwosts 6f2f62a602 Localize control-number-button aria label (#55026)
* Localize control-number-button aria label

* keep increment

* fix test
2026-10-18 15:10:05 +02:00
renovate[bot] dd1b392b1f Update dependency @types/chromecast-caf-receiver to v6.0.27 (#55015)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-10-18 10:20:33 +02:00
Elias LecomteandSeoFood b399dac788 Use server timezone for demo statistics calendar buckets (#54893)
* Use server timezone for demo statistics calendar buckets

* Preserve server timezone in derived energy statistic boundaries

* Clamp demo production day boundaries to the requested end

* Use supported array indexing in demo statistics test

* Align demo sub-day statistics with the UTC bucket grid

* Use server timezone for demo statistics calendar buckets

* Preserve server timezone in derived energy statistic boundaries

* Clamp demo production day boundaries to the requested end

* Use supported array indexing in demo statistics test

---------

Co-authored-by: SeoFood <[email protected]>
2026-10-18 08:30:10 +03:00
Paulus Schoutsen b0f498b5d3 Add LoRaWAN connectivity panel (#54998)
* Add LoRaWAN device overview and connection details

* Add LoRaWAN overview, connections, and topology views

* Match Bluetooth connection list navigation

* Simplify LoRaWAN add-device dialog navigation

* Group LoRaWAN devices by connection and simplify mobile rows

* Fix LoRa icon wave paths

* Use floating Add device action on LoRaWAN overview

* Widen LoRaWAN connection picker and simplify demo connections

* Use public console destinations in LoRaWAN demo

* Show two TTN gateways and one ChirpStack gateway in demo

* Simplify LoRaWAN connection picker heading

* Keep LoRaWAN Add device dialog compact on mobile

* Simplify LoRaWAN device details and widen unsupported-device alert

* Use the brand server for LoRaWAN demo icons

* Revert demo loading screen layout change

* Move LoRaWAN topology helper into the panel

* Select LoRaWAN device in subscription callback

* Only show LoRaWAN info for matching device integrations

* Name LoRaWAN pages by their purpose

* Identify LoRaWAN integrations by their dependency

* Fix LoRaWAN table sorting and column updates

* Refresh LoRaWAN details when navigating between devices

* Fix LoRaWAN device search, grouping, and demo entity IDs

* Treat listed LoRaWAN connections as active

* Use sensecap_lorawan domain in LoRaWAN demo
2026-10-17 14:46:23 -04:00
renovate[bot] 2d71337e12 Update Playwright (#55009)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-10-17 19:51:54 +02:00
renovate[bot] c7064d6dd3 Update dependency @html-eslint/eslint-plugin to v0.67.0 (#55010)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-10-17 19:41:24 +02:00
Raman GuptaandClaude Opus 5.5 7daae8a8cb Fix Z-Wave config parameter status always showing as error (#55002)
The zwave_js/set_config_parameter and set_raw_config_parameter commands
return the zwave-js-server CommandStatus string ("accepted" or
"queued"), not the numeric zwave-js SetValueStatus. #54287 retyped the
status as a number and mapped it through computeSetConfigParamStatus,
which turned every successful update into "error".

Type the status as the string union the backend sends and use it
directly again. The raw variant only returns status, so give it its own
result type without value_id.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-17 19:55:27 +03:00
renovate[bot] cf2e49aaad Update Node.js to v24.22.0 (#55001)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-10-17 07:34:23 +02:00
Daan Vervacke 545f518a02 Stop numeric data table headers from jumping on hover (#54999)
* Stop numeric data table headers from jumping on hover

* Match the hover pseudo class order in the numeric header rules
2026-10-16 17:12:45 -04:00
Franck Nijhof 9e98e6cdb9 Add Debug console to the Tools panel (#54937) 2026-10-16 19:32:37 +02:00
renovate[bot] dba7a5f238 Update dependency eslint-plugin-lit-a11y to v5.1.3 (#54997)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-10-16 17:00:17 +00:00
renovate[bot] b453b05e6a Update dependency @rspack/core to v2.2.9 (#54982)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-10-16 18:55:52 +02:00
Franck Nijhof 73d41a7c6a Update links to the new contributors website (#54995) 2026-10-16 18:55:08 +02:00
renovate[bot] 26a796e308 Update pnpm to v12.13.0 (#54992)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-10-16 18:53:41 +02:00
renovate[bot] 52a12f08ce Update dependency @versatiles/style to v6.4.1 (#54994) 2026-10-16 17:26:23 +02:00
karwosts a83511ec2f Fix tabs toolbar-icon slot width (#54991) 2026-10-16 06:43:02 -07:00
Øyvind Matheson Wergeland 03447fb4a1 Use separate translation keys for device and service info headings (#54981)
* Use separate translation keys for device and service info headings

The "{type} info" heading cannot be translated correctly in languages
that form compound words.

* Use new translation key for device info heading
2026-10-16 05:42:05 -07:00
JochenandClaude Opus 5.5 f2210e9081 Fix energy setup wizard skipping the individual devices step (#54977)
The Next button condition was not updated when water was added to the
wizard, so the water step showed Done and the individual devices step
was unreachable. Derive all step checks from one step count.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-16 09:24:07 +03:00
G JohanssonandClaude Opus 5.5 0fdbac3703 Show translated overview labels in ObjectSelector (#54864)
* Show translated overview labels in ObjectSelector

Use the optional `<translation_key>.fields.<field>.overview_label`
translation, with a `{value}` placeholder, to render field values in the
object selector overview. Falls back to the plain value when no
translation exists. Placeholder values are forwarded in config, options
and subentry flows.

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

* Forward placeholder values in repair flow selectors

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

* Forward placeholder values in action, trigger and condition selectors

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

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-16 08:17:49 +03:00
Bascht74 23d622958d Show the back button when the statistics graph card opens the history (#54976) 2026-10-16 08:10:50 +03:00
Ruslan Sayfutdinov d7e9877c4e Only list the backup's own locations in the upload progress (#54970)
* Only list the backup's own locations in the upload progress

* Show a spinner until a location reports upload progress
2026-10-16 06:43:23 +02:00
renovate[bot] 5f13af579d Update dependency typescript-eslint to v8.71.2 (#54979)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-10-16 06:39:43 +02:00
Aidan Timson a83d32f7c9 Explain blocked updates in the update dialog during a backup (#54955)
* Explain blocked updates in the update dialog during a backup

* Pause the updates page group buttons during a backup

* Only pause Supervisor updates during a backup

* Show the backup alert for System-only updates
2026-10-15 18:41:51 +02:00
Petar Petrov 7009352285 Show the punctuation warning when opening a conversation trigger (#54950)
Validate conversation trigger sentences on load
2026-10-15 15:58:48 +01:00
renovate[bot] 1c6135efc3 Update dependency marked to v18.1.1 (#54967)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-10-15 14:32:48 +00:00
renovate[bot] 9b9900ef05 Update dependency eslint-plugin-lit-a11y to v5.1.2 (#54966)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-10-15 14:31:14 +00:00
Ruslan Sayfutdinov e401e69d09 Show upload progress in the backup progress bar (#54956)
* Show upload progress in the backup progress bar

* Leave finished locations out of the upload progress fill

* Narrow upload percentages to numbers
2026-10-15 15:30:59 +01:00
188 changed files with 9174 additions and 1927 deletions

No files matched your search

+1 -1
View File
@@ -58,7 +58,7 @@ Readiness and invalidation:
Repository-owned contracts:
- Consult the public [frontend developer documentation](https://developers.home-assistant.io/docs/frontend/) for documented architecture, data flow, design, and development workflows.
- Consult the public [frontend contributor documentation](https://contributors.home-assistant.io/frontend/) for documented architecture, data flow, design, and development workflows.
- For new leaf components, load `ha-frontend-contexts` and verify they consume narrow contexts instead of introducing a broad `hass` property; containers and external APIs may still require `hass`.
- Verify backend assumptions against the owning Core, Supervisor, or WebSocket implementation, and component assumptions against the exported component contract.
- Prefer canonical repository helpers and test setup over duplicate local implementations.
+1 -1
View File
@@ -105,4 +105,4 @@ To help with the load of incoming pull requests:
Below, some useful links you could explore:
-->
[docs-repository]: https://github.com/home-assistant/home-assistant.io
[perfect-pr]: https://developers.home-assistant.io/docs/review-process/#creating-the-perfect-pr
[perfect-pr]: https://contributors.home-assistant.io/start/pull-requests/#before-you-open-it
+1 -1
View File
@@ -34,7 +34,7 @@ runs:
hashFiles('.nvmrc', 'package.json', 'pnpm-lock.yaml', 'pnpm-workspace.yaml', 'patches/**') }}
- name: Setup Node
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
with:
node-version-file: ".nvmrc"
@@ -192,8 +192,8 @@ export default async function checkPullRequestStandards({
`\n\n` +
`Please complete the ` +
`[PR template](https://github.com/home-assistant/frontend/blob/dev/.github/PULL_REQUEST_TEMPLATE.md?plain=1) ` +
`and see the [developer docs](https://developers.home-assistant.io/docs/review-process) ` +
`for more on creating a great pull request (see point 6).`;
`and see the [contributor docs](https://contributors.home-assistant.io/start/pull-requests/#fill-out-the-template) ` +
`for more on creating a great pull request.`;
if (existing) {
await withRetry("comment update", () =>
+2 -2
View File
@@ -134,7 +134,7 @@ jobs:
is-test: true
rspack-cache: readonly
- name: Upload bundle stats
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: frontend-bundle-stats
path: build/stats/*.json
@@ -142,7 +142,7 @@ jobs:
- name: Check entrypoint bundle size budget
run: pnpm run check-bundlesize
- name: Upload frontend build
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: frontend-build
path: hass_frontend/
+2 -2
View File
@@ -32,12 +32,12 @@ jobs:
persist-credentials: false
- name: Initialize CodeQL
uses: github/codeql-action/init@2892aa5e19bbd11bc0cff5427e3b750a04d9e3c2 # v4.38.2
uses: github/codeql-action/init@24c54180a607b1449ed407dd24f251e4e9147c8d # v4.38.3
with:
languages: javascript-typescript
build-mode: none
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@2892aa5e19bbd11bc0cff5427e3b750a04d9e3c2 # v4.38.2
uses: github/codeql-action/analyze@24c54180a607b1449ed407dd24f251e4e9147c8d # v4.38.3
with:
category: "/language:javascript-typescript"
+17 -17
View File
@@ -38,7 +38,7 @@ jobs:
name: Prepare container dependencies
runs-on: ubuntu-latest
container:
image: mcr.microsoft.com/playwright:v1.64.0-noble
image: mcr.microsoft.com/playwright:v1.64.1-noble
options: --user 1001
defaults:
run:
@@ -78,7 +78,7 @@ jobs:
is-test: true
- name: Upload demo build
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: demo-dist
path: demo/dist/
@@ -109,7 +109,7 @@ jobs:
is-test: true
- name: Upload e2e test app build
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: e2e-test-app-dist
path: test/e2e/app/dist/
@@ -140,7 +140,7 @@ jobs:
is-test: true
- name: Upload gallery build
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: gallery-dist
path: gallery/dist/
@@ -155,7 +155,7 @@ jobs:
- prepare-container-dependencies
runs-on: ubuntu-latest
container:
image: mcr.microsoft.com/playwright:v1.64.0-noble
image: mcr.microsoft.com/playwright:v1.64.1-noble
options: --user 1001 --ipc=host
defaults:
run:
@@ -182,7 +182,7 @@ jobs:
node-modules-cache-key: node-modules-container-v1
- name: Download demo build
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
with:
name: demo-dist
path: demo/dist/
@@ -192,7 +192,7 @@ jobs:
timeout-minutes: 15
- name: Upload demo blob report
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
if: always()
with:
name: blob-report-demo-${{ matrix.shardIndex }}
@@ -207,7 +207,7 @@ jobs:
- prepare-container-dependencies
runs-on: ubuntu-latest
container:
image: mcr.microsoft.com/playwright:v1.64.0-noble
image: mcr.microsoft.com/playwright:v1.64.1-noble
options: --user 1001 --ipc=host
defaults:
run:
@@ -236,7 +236,7 @@ jobs:
node-modules-cache-key: node-modules-container-v1
- name: Download e2e test app build
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
with:
name: e2e-test-app-dist
path: test/e2e/app/dist/
@@ -246,7 +246,7 @@ jobs:
timeout-minutes: 15
- name: Upload app blob report
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
if: always()
with:
name: blob-report-app-${{ matrix.shardIndex }}
@@ -261,7 +261,7 @@ jobs:
- prepare-container-dependencies
runs-on: ubuntu-latest
container:
image: mcr.microsoft.com/playwright:v1.64.0-noble
image: mcr.microsoft.com/playwright:v1.64.1-noble
options: --user 1001 --ipc=host
defaults:
run:
@@ -290,7 +290,7 @@ jobs:
node-modules-cache-key: node-modules-container-v1
- name: Download gallery build
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
with:
name: gallery-dist
path: gallery/dist/
@@ -300,7 +300,7 @@ jobs:
timeout-minutes: 15
- name: Upload gallery blob report
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
if: always()
with:
name: blob-report-gallery-${{ matrix.shardIndex }}
@@ -331,21 +331,21 @@ jobs:
node-modules-cache: true
- name: Download demo blob reports
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
continue-on-error: true
with:
pattern: blob-report-demo-*
path: test/e2e/reports/demo/
- name: Download app blob reports
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
continue-on-error: true
with:
pattern: blob-report-app-*
path: test/e2e/reports/app/
- name: Download gallery blob reports
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
continue-on-error: true
with:
pattern: blob-report-gallery-*
@@ -359,7 +359,7 @@ jobs:
- name: Upload merged HTML report
id: upload-report
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: playwright-report
path: test/e2e/reports/combined/
+2 -2
View File
@@ -104,7 +104,7 @@ jobs:
key: rspack-cache-${{ runner.os }}-${{ github.run_id }}-${{ github.run_attempt }}
- name: Upload build artifacts
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: wheels
path: dist/home_assistant_frontend*.whl
@@ -137,7 +137,7 @@ jobs:
run: tar -czvf translations.tar.gz translations
- name: Upload translations
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: translations
path: translations.tar.gz
+1 -1
View File
@@ -36,7 +36,7 @@ jobs:
python-version: ${{ env.PYTHON_VERSION }}
- name: Verify version
uses: home-assistant/actions/helpers/verify-version@06749dd8c0b54f350bc69c8752456cee498808a3 # master
uses: home-assistant/actions/helpers/verify-version@cd6f3635a740c84d8a14f6121a90068b8ece6bf6 # master
- name: Setup Node and install
uses: ./.github/actions/setup
+1 -1
View File
@@ -1 +1 @@
24.21.0
24.22.0
+1 -1
View File
@@ -41,5 +41,5 @@ Contributions that do not follow this policy will be closed. Repeated violations
---
The canonical version of this policy is published at
<https://developers.home-assistant.io/docs/ai_policy>. In case of differences,
<https://contributors.home-assistant.io/start/ai-policy/>. In case of differences,
the published version applies.
+2 -2
View File
@@ -6,12 +6,12 @@ This is the repository for the official [Home Assistant](https://home-assistant.
- [View demo of Home Assistant](https://demo.home-assistant.io/)
- [More information about Home Assistant](https://home-assistant.io)
- [Frontend development instructions](https://developers.home-assistant.io/docs/frontend/development/)
- [Frontend development instructions](https://contributors.home-assistant.io/frontend/development/)
## Development
- Initial setup: `script/setup`
- Development: [Instructions](https://developers.home-assistant.io/docs/frontend/development/)
- Development: [Instructions](https://contributors.home-assistant.io/frontend/development/)
- Production build: `pnpm build`
- Gallery: `cd gallery && script/develop_gallery`
+1 -1
View File
@@ -4,7 +4,7 @@ import { castContext } from "../cast_context";
export const castDemoLovelace: () => LovelaceConfig = () => {
const touchSupported =
castContext.getDeviceCapabilities().touch_input_supported;
castContext.getDeviceCapabilities()?.touch_input_supported;
return {
views: [
{
+2 -2
View File
@@ -19,7 +19,7 @@ const playerStylesAdded = false;
let controls: HTMLElement | null;
const setTouchControlsVisibility = (visible: boolean) => {
if (!castContext.getDeviceCapabilities().touch_input_supported) {
if (!castContext.getDeviceCapabilities()?.touch_input_supported) {
return;
}
controls =
@@ -51,7 +51,7 @@ const playDummyMedia = (viewTitle?: string) => {
clearTimeout(timeOut);
timeOut = undefined;
}
if (castContext.getDeviceCapabilities().touch_input_supported) {
if (castContext.getDeviceCapabilities()?.touch_input_supported) {
timeOut = window.setTimeout(() => playDummyMedia(viewTitle), 540000); // repeat every 9 minutes to keep it active (gets deactivated after 10 minutes)
}
};
+68 -2
View File
@@ -142,7 +142,45 @@ export class HaDemo extends HomeAssistantAppEl {
(localize?: LocalizeFunc) => localize ?? this.hass!.localize
);
mockLovelace(hass, localizePromise);
mockLovelace(hass, localizePromise, [
{
id: "music",
url_path: "music",
require_admin: false,
show_in_sidebar: true,
icon: "mdi:music",
title: "Music",
mode: "storage",
},
{
id: "cameras",
url_path: "cameras",
require_admin: false,
show_in_sidebar: true,
icon: "mdi:cctv",
title: "Cameras",
mode: "storage",
},
{
id: "garden",
url_path: "garden",
require_admin: false,
show_in_sidebar: true,
icon: "mdi:flower",
title: "Garden",
mode: "storage",
},
{
id: "lovelace-advanced",
url_path: "lovelace-advanced",
require_admin: false,
show_in_sidebar: false,
icon: "mdi:cog",
title: "Advanced",
mode: "yaml",
filename: "lovelace-advanced.yaml",
},
]);
mockAuth(hass);
mockTranslations(hass, localizePromise);
mockHistory(hass);
@@ -172,7 +210,35 @@ export class HaDemo extends HomeAssistantAppEl {
mockAreaRegistry(hass);
mockDeviceRegistry(hass, demoDevices);
mockFloorRegistry(hass);
mockLabelRegistry(hass);
mockLabelRegistry(hass, [
{
label_id: "kitchen",
name: "Kitchen",
icon: null,
color: "red",
description: "Everything in the kitchen",
created_at: 1735689600,
modified_at: 1735689600,
},
{
label_id: "garden",
name: "Garden",
icon: "mdi:flower",
color: "green",
description: null,
created_at: 1735689600,
modified_at: 1735689600,
},
{
label_id: "battery-powered",
name: "Battery powered",
icon: null,
color: "orange",
description: "Runs on batteries",
created_at: 1735689600,
modified_at: 1735689600,
},
]);
mockUsagePrediction(hass);
mockEntityRegistryDisplay(hass);
mockEntityRegistry(hass, [
+8
View File
@@ -42,4 +42,12 @@ export const mockBlueprint = (hass: MockHomeAssistant) => {
hass.mockWS("blueprint/list", (msg: { domain: BlueprintDomain }) =>
msg.domain === "script" ? scriptBlueprints : automationBlueprints
);
hass.mockWS(
"blueprint/delete",
(msg: { domain: BlueprintDomain; path: string }) => {
const blueprints =
msg.domain === "script" ? scriptBlueprints : automationBlueprints;
delete blueprints[msg.path];
}
);
};
+2
View File
@@ -1,3 +1,4 @@
import { lorawanFixtures } from "./lorawan/fixtures";
import { bluetoothFixtures } from "./bluetooth/fixtures";
import { infraredFixtures } from "./infrared/fixtures";
import { matterFixtures } from "./matter/fixtures";
@@ -15,6 +16,7 @@ import { zwaveJsFixtures } from "./zwave_js/fixtures";
// data to mock. Each owns its own fixtures, so they can be added and removed
// one at a time.
const INTEGRATIONS: ConnectivityFixtures[] = [
lorawanFixtures,
bluetoothFixtures,
serialFixtures,
modbusFixtures,
+2
View File
@@ -1,3 +1,4 @@
import { mockLoRaWAN } from "./lorawan/mock";
import type { MockHomeAssistant } from "../../../../src/fake_data/provide_hass";
import { mockBluetooth } from "./bluetooth/mock";
import { mockMatter } from "./matter/mock";
@@ -11,6 +12,7 @@ import { mockZwaveJs } from "./zwave_js/mock";
// The WebSocket mocks, code-split into the config panel chunk.
const MOCKS = [
mockLoRaWAN,
mockBluetooth,
mockSerial,
mockModbus,
@@ -0,0 +1,135 @@
import { manifest } from "../../manifest";
import {
configEntry,
device,
registryEntry,
withRegistryLinks,
} from "../helpers";
import type { ConnectivityFixtures } from "../types";
export const CHIRPSTACK_ENTRY = "mock-lorawan-chirpstack";
export const TTN_ENTRY = "mock-lorawan-ttn";
export const SENSECAP_ENTRY = "mock-lorawan-sensecap";
const REGISTRY_ENTRIES = [
registryEntry(
"sensor.lorawan_greenhouse_temperature",
"lorawan-greenhouse",
SENSECAP_ENTRY,
"sensecap_lorawan",
"Temperature"
),
registryEntry(
"sensor.lorawan_greenhouse_humidity",
"lorawan-greenhouse",
SENSECAP_ENTRY,
"sensecap_lorawan",
"Humidity"
),
registryEntry(
"sensor.lorawan_study_carbon_dioxide",
"lorawan-study",
SENSECAP_ENTRY,
"sensecap_lorawan",
"Carbon dioxide"
),
];
export const lorawanFixtures: ConnectivityFixtures = {
components: [
"lorawan",
"sensecap_lorawan",
"chirpstack",
"thethingsnetwork_lorawan",
],
commands: ["lorawan/"],
manifests: [
manifest("lorawan", "LoRaWAN", { integration_type: "helper" }),
manifest("sensecap_lorawan", "SenseCAP", {
integration_type: "device",
dependencies: ["lorawan"],
}),
manifest("chirpstack", "ChirpStack", { integration_type: "hub" }),
manifest("thethingsnetwork_lorawan", "The Things Network", {
integration_type: "hub",
}),
],
configEntries: [
{
type: "hub",
entry: configEntry(CHIRPSTACK_ENTRY, "chirpstack", "Home ChirpStack"),
},
{
type: "hub",
entry: configEntry(
TTN_ENTRY,
"thethingsnetwork_lorawan",
"The Things Network"
),
},
{
type: "device",
entry: configEntry(SENSECAP_ENTRY, "sensecap_lorawan", "SenseCAP"),
},
],
devices: [
device(
"lorawan-greenhouse",
"Greenhouse",
"Seeed Studio",
"S2101",
SENSECAP_ENTRY,
{
config_entries: [SENSECAP_ENTRY, CHIRPSTACK_ENTRY],
identifiers: [
["sensecap_lorawan", `${CHIRPSTACK_ENTRY}:0000000000000001`],
],
}
),
device(
"lorawan-study",
"Study air quality",
"Seeed Studio",
"S2103",
SENSECAP_ENTRY,
{
config_entries: [SENSECAP_ENTRY, TTN_ENTRY],
identifiers: [["sensecap_lorawan", `${TTN_ENTRY}:0000000000000002`]],
}
),
],
entityRegistryEntries: REGISTRY_ENTRIES,
entities: () =>
withRegistryLinks(REGISTRY_ENTRIES, {
"sensor.lorawan_greenhouse_temperature": {
entity_id: "sensor.lorawan_greenhouse_temperature",
state: "24.3",
attributes: {
friendly_name: "Greenhouse temperature",
device_class: "temperature",
state_class: "measurement",
unit_of_measurement: "°C",
},
},
"sensor.lorawan_greenhouse_humidity": {
entity_id: "sensor.lorawan_greenhouse_humidity",
state: "58",
attributes: {
friendly_name: "Greenhouse humidity",
device_class: "humidity",
state_class: "measurement",
unit_of_measurement: "%",
},
},
"sensor.lorawan_study_carbon_dioxide": {
entity_id: "sensor.lorawan_study_carbon_dioxide",
state: "652",
attributes: {
friendly_name: "Study carbon dioxide",
device_class: "carbon_dioxide",
state_class: "measurement",
unit_of_measurement: "ppm",
},
},
}),
};
+124
View File
@@ -0,0 +1,124 @@
import type { LoRaWANDevice } from "../../../../../src/data/lorawan";
import type { MockHomeAssistant } from "../../../../../src/fake_data/provide_hass";
import { minutesAgo } from "../helpers";
import { emitInitial } from "../subscription";
import { CHIRPSTACK_ENTRY, TTN_ENTRY } from "./fixtures";
const devices = (): LoRaWANDevice[] => {
const base: LoRaWANDevice = {
dev_eui: "0000000000000001",
name: "Greenhouse",
manufacturer: "Seeed Studio",
model: "S2101",
application_id: "home-sensors",
device_id: "lorawan-greenhouse",
connection: {
entry_id: CHIRPSTACK_ENTRY,
domain: "chirpstack",
title: "Home ChirpStack",
configuration_url: "https://www.chirpstack.io/",
},
support: "supported",
last_uplink: minutesAgo(2),
last_join: minutesAgo(2400),
last_status: minutesAgo(64),
receptions: [{ gateway_id: "home-indoor", rssi: -67, snr: 9.5 }],
battery_level: 87,
external_power_source: false,
downlink_margin: 12,
};
return [
base,
{
...base,
dev_eui: "0000000000000002",
name: "Study air quality",
model: "S2103",
device_id: "lorawan-study",
application_id: "demo-home",
connection: {
entry_id: TTN_ENTRY,
domain: "thethingsnetwork_lorawan",
title: "The Things Network",
configuration_url: "https://eu1.cloud.thethings.network/console",
},
receptions: [
{ gateway_id: "brooklyn-rooftop", rssi: -103, snr: -2.5 },
{ gateway_id: "brooklyn-community", rssi: -110, snr: -5.2 },
],
battery_level: 64,
last_uplink: minutesAgo(4),
last_status: minutesAgo(140),
},
{
...base,
dev_eui: "24E124128E123456",
name: "Irrigation controller",
manufacturer: "Milesight",
model: "UC511",
device_id: null,
support: "integration_not_configured",
battery_level: 93,
last_uplink: minutesAgo(7),
receptions: [{ gateway_id: "home-indoor", rssi: -83, snr: 6.8 }],
},
{
...base,
dev_eui: "A84041FFFF123456",
name: "Garage power meter",
manufacturer: "",
model: "",
device_id: null,
support: "missing_identity",
battery_level: null,
external_power_source: true,
last_uplink: minutesAgo(1),
receptions: [{ gateway_id: "home-indoor", rssi: -75, snr: 8.1 }],
},
{
...base,
dev_eui: "2CF7F1C071500199",
name: "New soil sensor",
model: "S2105",
device_id: null,
support: "unsupported_model",
last_uplink: null,
last_join: null,
last_status: null,
receptions: [],
battery_level: null,
external_power_source: null,
downlink_margin: null,
},
{
...base,
dev_eui: "2CF7F1C071500166",
name: "Garden shed",
device_id: null,
support: "integration_not_configured",
battery_level: 18,
last_uplink: minutesAgo(180),
last_status: minutesAgo(600),
receptions: [{ gateway_id: "home-indoor", rssi: -112, snr: -7.5 }],
},
];
};
export const mockLoRaWAN = (hass: MockHomeAssistant) => {
const deviceList = devices();
const snapshot = {
devices: deviceList,
connections: [
...new Map(
deviceList.map((device) => [
device.connection.entry_id,
device.connection,
])
).values(),
],
};
hass.mockWS("lorawan/devices/list", () => snapshot);
hass.mockWS("lorawan/devices/subscribe", (_msg, _hass, onChange) =>
emitInitial(() => onChange?.(snapshot))
);
};
+33
View File
@@ -190,6 +190,39 @@ export const removeEntityRegistryEntry = (
hass.mockEvent("entity_registry_updated");
};
export const removeLabelFromEntityRegistry = (
hass: MockHomeAssistant,
labelId: string
) => {
const withoutLabel = (labels: string[]) =>
labels.filter((id) => id !== labelId);
for (const [key, entry] of retiredEntries) {
if (entry.labels.includes(labelId)) {
retiredEntries.set(key, { ...entry, labels: withoutLabel(entry.labels) });
}
}
if (!entries.some((entry) => entry.labels.includes(labelId))) {
return;
}
entries = entries.map((entry) =>
entry.labels.includes(labelId)
? { ...entry, labels: withoutLabel(entry.labels) }
: entry
);
const entities = { ...hass.entities };
for (const entry of entries) {
const entity = entities[entry.entity_id];
if (entity?.labels.includes(labelId)) {
entities[entry.entity_id] = {
...entity,
labels: withoutLabel(entity.labels),
};
}
}
hass.updateHass({ entities });
hass.mockEvent("entity_registry_updated");
};
// Adds an entry like core does when an integration creates an entity
export const addEntityRegistryEntry = (
hass: MockHomeAssistant,
+19 -2
View File
@@ -1,5 +1,6 @@
import type { LabelRegistryEntry } from "../../../src/data/label/label_registry";
import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
import { removeLabelFromEntityRegistry } from "./entity_registry";
let labels: LabelRegistryEntry[] = [];
@@ -9,6 +10,22 @@ export const mockLabelRegistry = (
hass: MockHomeAssistant,
data: LabelRegistryEntry[] = []
) => {
labels = data;
hass.mockWS("config/label_registry/list", () => data);
labels = [...data];
hass.mockWS("config/label_registry/list", () =>
labels.map((label) => ({ ...label }))
);
hass.mockWS(
"config/label_registry/delete",
(msg: { label_id: string }, currentHass: MockHomeAssistant) => {
const index = labels.findIndex(
(label) => label.label_id === msg.label_id
);
if (index === -1) {
throw new Error(`Label ${msg.label_id} not found`);
}
labels.splice(index, 1);
removeLabelFromEntityRegistry(currentHass, msg.label_id);
hass.mockEvent("label_registry_updated");
}
);
};
+16 -2
View File
@@ -1,4 +1,5 @@
import type { LocalizeFunc } from "../../../src/common/translations/localize";
import type { LovelaceDashboard } from "../../../src/data/lovelace/dashboard";
import type { LovelaceInfo } from "../../../src/data/lovelace/resource";
import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
import {
@@ -11,10 +12,14 @@ import "../custom-cards/ha-demo-card";
import "../custom-cards/ha-demo-next-card";
import { mockMap } from "./map";
let dashboards: LovelaceDashboard[] = [];
export const mockLovelace = (
hass: MockHomeAssistant,
localizePromise: Promise<LocalizeFunc>
localizePromise: Promise<LocalizeFunc>,
data: LovelaceDashboard[] = []
) => {
dashboards = data;
hass.mockWS("lovelace/config", ({ url_path }) => {
if (url_path === "map") {
mockMap(hass);
@@ -34,7 +39,16 @@ export const mockLovelace = (
);
hass.mockWS("lovelace/config/save", () => Promise.resolve());
hass.mockWS("lovelace/resources", () => Promise.resolve([]));
hass.mockWS("lovelace/dashboards/list", () => Promise.resolve([]));
hass.mockWS("lovelace/dashboards/list", () =>
dashboards.map((dashboard) => ({ ...dashboard }))
);
hass.mockWS("lovelace/dashboards/delete", (msg: { dashboard_id: string }) => {
const index = dashboards.findIndex((d) => d.id === msg.dashboard_id);
if (index === -1) {
throw new Error(`Dashboard ${msg.dashboard_id} not found`);
}
dashboards.splice(index, 1);
});
};
customElements.whenDefined("hui-root").then(() => {
+36 -18
View File
@@ -1,8 +1,10 @@
import { TZDate } from "@date-fns/tz";
import {
addDays,
addHours,
addMinutes,
addMonths,
constructFrom,
differenceInHours,
endOfDay,
} from "date-fns";
@@ -13,6 +15,18 @@ import type {
} from "../../../src/data/recorder";
import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
const getFirstDate = (
start: Date,
period: "5minute" | "hour" | "day" | "month"
): Date => {
if (period === "day" || period === "month") {
return constructFrom(start, start);
}
// Core returns UTC-aligned buckets whose start is at or after the request.
const interval = (period === "hour" ? 60 : 5) * 60 * 1000;
return constructFrom(start, Math.ceil(start.getTime() / interval) * interval);
};
const getNextDate = (
currentDate: Date,
period: "5minute" | "hour" | "day" | "month"
@@ -33,8 +47,7 @@ const generateMeanStatistics = (
maxDiff: number
): StatisticValue[] => {
const statistics: StatisticValue[] = [];
let currentDate = new Date(start);
currentDate.setMinutes(0, 0, 0);
let currentDate = getFirstDate(start, period);
const now = new Date();
while (end > currentDate && currentDate < now) {
const delta = Math.random() * maxDiff;
@@ -63,8 +76,7 @@ const generateSumStatistics = (
maxDiff: number | ((start: Date, end: Date) => number)
): StatisticValue[] => {
const statistics: StatisticValue[] = [];
let currentDate = new Date(start);
currentDate.setMinutes(0, 0, 0);
let currentDate = getFirstDate(start, period);
let sum = initValue;
const now = new Date();
while (end > currentDate && currentDate < now) {
@@ -98,8 +110,7 @@ const generateCurvedStatistics = (
metered: boolean
): StatisticValue[] => {
const statistics: StatisticValue[] = [];
let currentDate = new Date(start);
currentDate.setMinutes(0, 0, 0);
let currentDate = getFirstDate(start, period);
let sum = initValue;
const hours = differenceInHours(end, start) - 1;
let i = 0;
@@ -162,9 +173,9 @@ const statisticsFunctions: Record<
period === "day" ? 17 : 504
);
}
const morningEnd = new Date(start.getTime() + 10 * 60 * 60 * 1000);
const morningEnd = addHours(start, 10);
const morningLow = generateSumStatistics(start, morningEnd, period, 0, 0.7);
const eveningStart = new Date(start.getTime() + 20 * 60 * 60 * 1000);
const eveningStart = addHours(start, 20);
const morningFinalVal = morningLow.length
? morningLow[morningLow.length - 1].sum!
: 0;
@@ -199,8 +210,8 @@ const statisticsFunctions: Record<
period === "day" ? 17 : 504
);
}
const morningEnd = new Date(start.getTime() + 9 * 60 * 60 * 1000);
const eveningStart = new Date(start.getTime() + 20 * 60 * 60 * 1000);
const morningEnd = addHours(start, 9);
const eveningStart = addHours(start, 20);
const highTarif = generateSumStatistics(
morningEnd,
eveningStart,
@@ -239,9 +250,12 @@ const statisticsFunctions: Record<
period === "day" ? 17 : 504
);
}
const productionStart = new Date(start.getTime() + 9 * 60 * 60 * 1000);
const productionEnd = new Date(start.getTime() + 21 * 60 * 60 * 1000);
const dayEnd = new Date(endOfDay(productionEnd));
const productionStart = addHours(start, 9);
const productionEnd = addHours(start, 21);
const dayEnd = constructFrom(
end,
Math.min(endOfDay(productionEnd).getTime(), end.getTime())
);
const production = generateCurvedStatistics(
productionStart,
productionEnd,
@@ -280,9 +294,12 @@ const statisticsFunctions: Record<
period === "day" ? 17 : 504
);
}
const productionStart = new Date(start.getTime() + 7 * 60 * 60 * 1000);
const productionEnd = new Date(start.getTime() + 23 * 60 * 60 * 1000);
const dayEnd = new Date(endOfDay(productionEnd));
const productionStart = addHours(start, 7);
const productionEnd = addHours(start, 23);
const dayEnd = constructFrom(
end,
Math.min(endOfDay(productionEnd).getTime(), end.getTime())
);
const production = generateCurvedStatistics(
productionStart,
productionEnd,
@@ -329,8 +346,9 @@ export const mockRecorder = (mockHass: MockHomeAssistant) => {
mockHass.mockWS(
"recorder/statistics_during_period",
({ statistic_ids, start_time, end_time, period }, hass): Statistics => {
const start = new Date(start_time);
const end = end_time ? new Date(end_time) : new Date();
// Calendar buckets follow the server timezone, independent of the browser.
const start = new TZDate(start_time, hass.config.time_zone);
const end = new TZDate(end_time ?? Date.now(), hass.config.time_zone);
const statistics: Record<string, StatisticValue[]> = {};
const cache: Record<string, StatisticValue[]> = {};
+1 -1
View File
@@ -33,4 +33,4 @@ We want to make it as easy for designers to contribute as it is for developers.
## Developers
Everything you need to get started developing can be found in our <a href="https://developers.home-assistant.io" rel="noopener noreferrer" target="_blank">Home Assistant Developer Docs</a>.
Everything you need to get started developing can be found in our <a href="https://contributors.home-assistant.io/frontend/" rel="noopener noreferrer" target="_blank">Home Assistant contributor docs</a>.
+1 -1
View File
@@ -271,7 +271,7 @@ const CONFIGS = [
### ha-alert
You can use our [\`ha-alert\`](https://design.home-assistant.io/#components/ha-alert) component in markdown content rendered in the Home Assistant Frontend.
You can use our [\`ha-alert\`](https://contributors.home-assistant.io/design/components/alert/) component in markdown content rendered in the Home Assistant Frontend.
<ha-alert alert-type="error">This is an error alert — check it out!</ha-alert>
<ha-alert alert-type="warning">This is a warning alert — check it out!</ha-alert>
+1 -1
View File
@@ -32,7 +32,7 @@ It is similar to the core frontend dev.
- `"mounts": ["source=/path/to/hass/frontend,target=/workspaces/frontend,type=bind,consistency=cached"]`
- use the dev container
- start the dev server with following optional env vars:
- `SUPERVISOR_HOST` to have real supervisor data, you can [setup a supervisor remote API access](https://developers.home-assistant.io/docs/supervisor/development/#supervisor-api-access) and set the host of your supervisor. e.g.: `SUPERVISOR_HOST=192.168.0.20:8888`
- `SUPERVISOR_HOST` to have real supervisor data, you can [setup a supervisor remote API access](https://contributors.home-assistant.io/os/supervisor/core-connection/#get-access-to-the-supervisor-api) and set the host of your supervisor. e.g.: `SUPERVISOR_HOST=192.168.0.20:8888`
- `SUPERVISOR_TOKEN` the supervisor api token you get from the Remote API proxy Addon Logs
- `FRONTEND_PATH` the path inside your container should be `/workspaces/frontend`
- example: `SUPERVISOR_TOKEN=abc123 SUPERVISOR_HOST=192.168.0.20:8888 FRONTEND_PATH=/workspaces/frontend go run main.go http.go mdns.go`
+1 -1
View File
@@ -6,7 +6,7 @@ export default {
],
"*.{json,css,md,markdown,html,ya?ml}": "prettier --cache --write",
"translations/*/*.json": (files) =>
'printf "%s\n" "Translation files should not be added or modified here. Instead, make the necessary modifications in src/translations/en.json. Other languages are managed externally. Please see https://developers.home-assistant.io/docs/translations/ for details." ' +
'printf "%s\n" "Translation files should not be added or modified here. Instead, make the necessary modifications in src/translations/en.json. Other languages are managed externally. Please see https://contributors.home-assistant.io/translate/ for details." ' +
files.join(" ") +
" >&2 && exit 1",
"pnpm-lock.yaml": () => "pnpm dedupe",
+21 -20
View File
@@ -45,6 +45,7 @@
"@codemirror/autocomplete": "6.20.3",
"@codemirror/commands": "6.11.1",
"@codemirror/lang-jinja": "6.0.1",
"@codemirror/lang-python": "6.2.1",
"@codemirror/lang-yaml": "6.1.3",
"@codemirror/language": "6.13.1",
"@codemirror/lint": "6.9.7",
@@ -62,12 +63,12 @@
"@formatjs/intl-numberformat": "9.4.3",
"@formatjs/intl-pluralrules": "6.3.15",
"@formatjs/intl-relativetimeformat": "12.3.15",
"@fullcalendar/core": "6.1.21",
"@fullcalendar/daygrid": "6.1.21",
"@fullcalendar/interaction": "6.1.21",
"@fullcalendar/list": "6.1.21",
"@fullcalendar/luxon3": "6.1.21",
"@fullcalendar/timegrid": "6.1.21",
"@fullcalendar/core": "6.1.22",
"@fullcalendar/daygrid": "6.1.22",
"@fullcalendar/interaction": "6.1.22",
"@fullcalendar/list": "6.1.22",
"@fullcalendar/luxon3": "6.1.22",
"@fullcalendar/timegrid": "6.1.22",
"@home-assistant/webawesome": "3.14.0-ha.0",
"@lezer/common": "1.5.3",
"@lezer/highlight": "1.2.5",
@@ -85,7 +86,7 @@
"@swc/helpers": "0.5.23",
"@tsparticles/engine": "4.4.0",
"@tsparticles/preset-links": "4.4.0",
"@versatiles/style": "6.4.0",
"@versatiles/style": "6.4.1",
"@vibrant/color": "4.0.4",
"@vvo/tzdb": "6.201.1",
"@webcomponents/scoped-custom-element-registry": "0.0.10",
@@ -107,14 +108,14 @@
"home-assistant-js-websocket": "9.7.1",
"idb-keyval": "6.3.0",
"intl-messageformat": "12.1.3",
"js-yaml": "5.4.3",
"js-yaml": "5.5.0",
"leaflet": "1.9.4",
"leaflet.markercluster": "1.5.3",
"lit": "3.3.3",
"lit-html": "3.3.3",
"luxon": "3.7.2",
"maplibre-gl": "6.14.0",
"marked": "18.1.0",
"marked": "18.1.1",
"memoize-one": "6.0.0",
"nanoid": "6.0.2",
"node-vibrant": "4.0.4",
@@ -149,16 +150,16 @@
"@bundle-stats/plugin-webpack-filter": "4.22.3",
"@eslint/js": "10.0.1",
"@gfx/zopfli": "1.0.15",
"@html-eslint/eslint-plugin": "0.66.1",
"@html-eslint/eslint-plugin": "0.67.0",
"@lokalise/node-api": "16.6.0",
"@octokit/auth-oauth-device": "8.0.5",
"@octokit/rest": "22.0.1",
"@playwright/test": "1.64.0",
"@playwright/test": "1.64.1",
"@rsdoctor/rspack-plugin": "1.6.5",
"@rspack/core": "2.2.8",
"@rspack/core": "2.2.9",
"@rspack/dev-server": "2.2.1",
"@types/babel__plugin-transform-runtime": "7.9.5",
"@types/chromecast-caf-receiver": "6.0.26",
"@types/chromecast-caf-receiver": "6.0.27",
"@types/chromecast-caf-sender": "1.0.11",
"@types/color-name": "2.0.0",
"@types/culori": "4.0.1",
@@ -173,7 +174,7 @@
"@types/sortablejs": "1.15.9",
"@types/tar": "7.0.87",
"@typescript/native": "npm:[email protected]",
"@vitest/coverage-v8": "5.0.4",
"@vitest/coverage-v8": "5.0.5",
"babel-loader": "10.1.1",
"babel-plugin-polyfill-corejs3": "1.0.0",
"browserslist": "4.29.3",
@@ -185,7 +186,7 @@
"eslint-import-resolver-webpack": "0.13.11",
"eslint-plugin-import-x": "4.17.1",
"eslint-plugin-lit": "2.3.1",
"eslint-plugin-lit-a11y": "5.1.1",
"eslint-plugin-lit-a11y": "5.1.3",
"eslint-plugin-unused-imports": "4.4.1",
"eslint-plugin-wc": "3.1.0",
"fancy-log": "2.0.0",
@@ -198,7 +199,7 @@
"gulp-rename": "2.1.0",
"html-minifier-terser": "7.2.0",
"husky": "9.1.7",
"jsdom": "30.1.2",
"jsdom": "30.1.3",
"jszip": "3.10.2",
"license-checker-rseidelsohn": "5.0.1",
"lightningcss": "1.33.0",
@@ -217,16 +218,16 @@
"terser-webpack-plugin": "5.6.1",
"ts-lit-plugin": "2.0.2",
"typescript": "6.0.3",
"typescript-eslint": "8.71.1",
"typescript-eslint": "8.71.2",
"vite": "8.3.4",
"vite-tsconfig-paths": "6.1.1",
"vitest": "5.0.4",
"vitest": "5.0.5",
"webpack-stats-plugin": "1.1.3",
"webpackbar": "7.0.0",
"workbox-build": "7.4.1"
},
"packageManager": "[email protected]1.2",
"packageManager": "[email protected]4.0",
"volta": {
"node": "24.21.0"
"node": "24.22.0"
}
}
+417 -389
View File
File diff suppressed because it is too large. Load diff
+1 -1
View File
@@ -26,7 +26,7 @@ overrides:
lit-html: 3.3.3
clean-css: 5.3.3
"@lit/reactive-element": 2.1.2
"@fullcalendar/daygrid": 6.1.21
"@fullcalendar/daygrid": 6.1.22
tslib: 2.8.1
patchedDependencies:
@@ -1,4 +0,0 @@
<svg width="36" height="16" viewBox="0 0 36 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="16" height="16" rx="3" fill="black" fill-opacity="0.32"/>
<rect x="20" width="16" height="16" rx="3" fill="black" fill-opacity="0.12"/>
</svg>

Before

Width:  |  Height:  |  Size: 252 B

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

Before

Width:  |  Height:  |  Size: 252 B

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

Before

Width:  |  Height:  |  Size: 330 B

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

Before

Width:  |  Height:  |  Size: 330 B

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

Before

Width:  |  Height:  |  Size: 408 B

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

Before

Width:  |  Height:  |  Size: 408 B

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

Before

Width:  |  Height:  |  Size: 174 B

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

Before

Width:  |  Height:  |  Size: 174 B

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

Before

Width:  |  Height:  |  Size: 252 B

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

Before

Width:  |  Height:  |  Size: 252 B

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

Before

Width:  |  Height:  |  Size: 330 B

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

Before

Width:  |  Height:  |  Size: 330 B

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

Before

Width:  |  Height:  |  Size: 174 B

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

Before

Width:  |  Height:  |  Size: 174 B

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

Before

Width:  |  Height:  |  Size: 252 B

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

Before

Width:  |  Height:  |  Size: 252 B

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

Before

Width:  |  Height:  |  Size: 174 B

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

Before

Width:  |  Height:  |  Size: 174 B

+1 -1
View File
@@ -29,7 +29,7 @@ const ALLOWED_LICENSES = new Set([
"(MIT OR CC0-1.0)",
"(MIT OR Apache-2.0)",
"(MIT AND Zlib)",
"Python-2.0", // argparse - Python Software Foundation License (permissive)
"PSF-2.0", // argparse - Python Software Foundation License (permissive)
"Public Domain",
"W3C-20150513", // wicg-inert - W3C Software and Document License (permissive)
"Unlicense",
+27
View File
@@ -0,0 +1,27 @@
import type { HassEntity } from "home-assistant-js-websocket";
import { computeDomain } from "./compute_domain";
export const ALERT_DOMAINS = [
"alarm_control_panel",
"binary_sensor",
"cover",
"lock",
];
export const DEFAULT_ALERT_COLOR = "red";
export const computeAlertActiveStates = (entityId: string): string[] => {
switch (computeDomain(entityId)) {
case "alarm_control_panel":
return ["triggered"];
case "cover":
return ["open"];
case "lock":
return ["jammed", "unlocked", "open"];
default:
return ["on"];
}
};
export const isAlertActive = (stateObj: HassEntity): boolean =>
computeAlertActiveStates(stateObj.entity_id).includes(stateObj.state);
+9 -3
View File
@@ -5,14 +5,20 @@ export const subscribePollingCollection = (
updateData: (hass: HomeAssistant) => void,
interval: number
) => {
let timeout;
let timeout: number | undefined;
let cancelled = false;
const fetchData = async () => {
try {
await updateData(hass);
} finally {
timeout = setTimeout(() => fetchData(), interval);
if (!cancelled) {
timeout = window.setTimeout(() => fetchData(), interval);
}
}
};
fetchData();
return () => clearTimeout(timeout);
return () => {
cancelled = true;
clearTimeout(timeout);
};
};
@@ -0,0 +1,82 @@
import { parsePlainText } from "zrender/lib/graphic/helper/parseText.js";
const FONT_FAMILY = "Roboto, Noto, sans-serif";
// zrender's overflow "break" only wraps at these characters, see isWordBreakChar
const WORD_BREAK =
/[ ,&?/;\]]|[^\u0020-\u024F\u0370-\u10FF\u1200-\u13FF\u1E00-\u206F]/;
interface FitLabelOptions {
/** Font size the label is measured at, and the largest size returned */
fontSize: number;
width: number;
height: number;
/** Height of the label at `fontSize` before ECharts wraps it */
unwrappedHeight: number;
}
/**
* Largest font size at which an ECharts label with `overflow: "break"`,
* wrapped at `width`, fits in `height` without breaking a word.
*/
export function fitLabelFontSize(
text: string,
{ fontSize: baseFontSize, width, height, unwrappedHeight }: FitLabelOptions
) {
const font = `${baseFontSize}px ${FONT_FAMILY}`;
const measure = (label: string, wrapWidth?: number) =>
parsePlainText(
label,
{ font, width: wrapWidth, overflow: "break" },
undefined,
undefined
);
// ECharts measures at the final font size, leave room for rounding
const fitWidth = Math.max(width - 1, 1);
const unwrappedContentHeight = measure(text).contentHeight;
const words = text.split(WORD_BREAK).filter(Boolean);
// a label at a smaller font wraps like one at baseFontSize in a wider box
const wrapWidthAt = (fontSize: number) =>
(fitWidth * baseFontSize) / fontSize;
const maxFontSize = (height / unwrappedHeight) * baseFontSize;
const fits = (fontSize: number) => {
const wrapWidth = wrapWidthAt(fontSize);
if (words.some((word) => measure(word, wrapWidth).lines.length > 1)) {
return false;
}
const wrappedHeight =
unwrappedHeight +
(measure(text, wrapWidth).contentHeight - unwrappedContentHeight);
// exact at maxFontSize when the label doesn't wrap
return fontSize <= maxFontSize * (unwrappedHeight / wrappedHeight);
};
let low = 0;
let high = Math.min(baseFontSize, maxFontSize);
if (fits(high)) {
low = high;
} else {
for (let i = 0; i < 10; i++) {
const mid = (low + high) / 2;
if (fits(mid)) {
low = mid;
} else {
high = mid;
}
}
}
// zrender moves a space that doesn't fit to the next line, indenting it.
// Shrinking further only widens the wrap, so the label still fits.
const spaceWidth = measure(" ").contentWidth;
const indents = (fontSize: number) =>
text
.split("\n")
.some((paragraph) =>
measure(paragraph, wrapWidthAt(fontSize)).lines.some(
(line, i) => i > 0 && line.startsWith(" ")
)
);
let fontSize = low;
for (let i = 0; i < 3 && indents(fontSize); i++) {
fontSize = (fitWidth * baseFontSize) / (wrapWidthAt(fontSize) + spaceWidth);
}
return fontSize;
}
+29 -47
View File
@@ -280,7 +280,7 @@ export class HaChartBase extends MobileAwareMixin(LitElement) {
private _pendingUpdate?: Set<PropertyKey>;
private _pendingOptions?: HaECOption;
private _legendItemCount?: number;
private _pendingZoom?: [number, number, boolean];
@@ -292,7 +292,6 @@ export class HaChartBase extends MobileAwareMixin(LitElement) {
this._tooltipHiddenWhilePanning = false;
this._pendingSetup = false;
this._pendingUpdate = undefined;
this._pendingOptions = undefined;
this._pendingZoom = undefined;
// The observers are about to be torn down, so nothing would correct a stale
// value if this element is reattached inside a hidden container.
@@ -445,11 +444,6 @@ export class HaChartBase extends MobileAwareMixin(LitElement) {
if (!this._pendingUpdate) {
this._pendingUpdate = new Set();
}
if (prop === "options" && !this._pendingUpdate.has(prop)) {
// The one previous value a replay reads, and it has to stay the options
// the chart currently renders, not those of a later deferred change.
this._pendingOptions = changedProps.get(prop) as HaECOption | undefined;
}
this._pendingUpdate.add(prop);
}
}
@@ -466,10 +460,8 @@ export class HaChartBase extends MobileAwareMixin(LitElement) {
return;
}
const pending = this._pendingUpdate!;
const previousOptions = this._pendingOptions;
this._pendingUpdate = undefined;
this._pendingOptions = undefined;
this._applyChartUpdate(pending, previousOptions);
this._applyChartUpdate(pending);
}
public willUpdate(changedProps: PropertyValues): void {
@@ -485,7 +477,6 @@ export class HaChartBase extends MobileAwareMixin(LitElement) {
if (invisible) {
this._pendingSetup = true;
this._pendingUpdate = undefined;
this._pendingOptions = undefined;
} else {
this._setupChart();
}
@@ -503,16 +494,10 @@ export class HaChartBase extends MobileAwareMixin(LitElement) {
}
return;
}
this._applyChartUpdate(
changedProps,
changedProps.get("options") as HaECOption | undefined
);
this._applyChartUpdate(changedProps);
}
private _applyChartUpdate(
changedProps: { has: (prop: string) => boolean },
previousOptions: HaECOption | undefined
) {
private _applyChartUpdate(changedProps: { has: (prop: string) => boolean }) {
let chartOptions: ECOption = {};
if (changedProps.has("data") || changedProps.has("_hiddenDatasets")) {
chartOptions.series = this._getSeries();
@@ -532,20 +517,28 @@ export class HaChartBase extends MobileAwareMixin(LitElement) {
if (changedProps.has("options")) {
chartOptions = { ...chartOptions, ...this._createOptions() };
this._disposeSonification();
if (this._compareCustomLegendOptions(previousOptions, this.options)) {
// custom legend changes may require a resize to layout properly
this._shouldResizeChart = true;
this._resizeAnimationDuration = 250;
}
} else if (this._isTouchDevice && changedProps.has("_isZoomed")) {
chartOptions.dataZoom = this._getDataZoomConfig();
}
let legendChanged = false;
if (changedProps.has("options") || changedProps.has("data")) {
const legendItemCount = this._getLegendItems()?.length;
legendChanged = legendItemCount !== this._legendItemCount;
this._legendItemCount = legendItemCount;
}
if (Object.keys(chartOptions).length > 0) {
this._setChartOptions(chartOptions);
if (chartOptions.series || changedProps.has("_isZoomed")) {
this._updateSankeyRoam();
}
}
if (legendChanged) {
// Within a fixed height the host does not resize when the legend grows.
// Without animation, setOption has already fired "finished" by now.
this._shouldResizeChart = true;
this._resizeAnimationDuration = 250;
this.updateComplete.then(() => this._resizeChartIfNeeded());
}
}
protected render() {
@@ -832,7 +825,6 @@ export class HaChartBase extends MobileAwareMixin(LitElement) {
this._loading = true;
this._pendingSetup = false;
this._pendingUpdate = undefined;
this._pendingOptions = undefined;
try {
// The connection holds a reference to the chart instance, so it cannot
// outlive it. Focusing the chart again reconnects.
@@ -883,13 +875,14 @@ export class HaChartBase extends MobileAwareMixin(LitElement) {
const sankeySeries = series?.find((s: any) => s.type === "sankey");
const zoomed = Math.abs(sankeySeries.zoom - 1) > 1e-6;
this._isZoomed = zoomed;
// Before setOption, which lays the labels out again
fireEvent(this, "chart-sankeyroam", { zoom: sankeySeries.zoom });
if (!zoomed) {
// Reset center when fully zoomed out
this.chart!.setOption({
series: [{ id: sankeySeries.id, center: null }],
});
}
fireEvent(this, "chart-sankeyroam", { zoom: sankeySeries.zoom });
// Clear cached emphasis states so labels don't revert to pre-zoom sizes
this.chart!.dispatchAction({ type: "downplay" });
});
@@ -1002,6 +995,7 @@ export class HaChartBase extends MobileAwareMixin(LitElement) {
...this._createOptions(),
series: this._getSeries(),
});
this._legendItemCount = this._getLegendItems()?.length;
this._updateSankeyRoam();
if (this._pendingZoom) {
const [start, end, silent] = this._pendingZoom;
@@ -1458,6 +1452,7 @@ export class HaChartBase extends MobileAwareMixin(LitElement) {
if (this._isZoomed) {
this._handleZoomReset();
} else {
fireEvent(this, "chart-sankeyroam", { zoom: 2 });
this.chart.setOption({
series: sankeySeries.map((s: any) => ({
id: s.id,
@@ -1502,15 +1497,19 @@ export class HaChartBase extends MobileAwareMixin(LitElement) {
}
private _handleZoomReset() {
const option = this.chart?.getOption();
const sankeySeries = (option?.series as any[])?.filter(
(s: any) => s.type === "sankey"
);
// Before the dataZoom action and setOption, which lay the labels out again
if (sankeySeries?.length) {
fireEvent(this, "chart-sankeyroam", { zoom: 1 });
}
// A dataZoom action reruns the layout of every series, graphs included
if (this._isZoomed) {
this.chart?.dispatchAction({ type: "dataZoom", start: 0, end: 100 });
}
// Reset sankey roam zoom
const option = this.chart?.getOption();
const sankeySeries = (option?.series as any[])?.filter(
(s: any) => s.type === "sankey"
);
if (sankeySeries?.length) {
this.chart?.setOption({
series: sankeySeries.map((s: any) => ({
@@ -1520,7 +1519,6 @@ export class HaChartBase extends MobileAwareMixin(LitElement) {
})),
});
this._isZoomed = false;
fireEvent(this, "chart-sankeyroam", { zoom: 1 });
}
this._getGraphRoams().forEach(({ series, zoom, x, y }) => {
this.chart!.dispatchAction({
@@ -1872,22 +1870,6 @@ export class HaChartBase extends MobileAwareMixin(LitElement) {
this._resizeAnimationDuration = undefined;
}
private _compareCustomLegendOptions(
oldOptions: HaECOption | undefined,
newOptions: HaECOption | undefined
): boolean {
const oldLegends = ensureArray(
oldOptions?.legend || []
) as LegendComponentOption[];
const newLegends = ensureArray(
newOptions?.legend || []
) as LegendComponentOption[];
return (
oldLegends.some((l) => l.show && l.type === "custom") !==
newLegends.some((l) => l.show && l.type === "custom")
);
}
static styles = css`
:host {
display: block;
+2
View File
@@ -32,6 +32,7 @@ export interface NetworkNode {
borderColor?: string;
borderWidth?: number;
};
label?: { position: "left" | "right" | "top" | "bottom" };
fixed?: boolean;
/**
* Distance from the center, where 0 is the center and 1 is the edge
@@ -315,6 +316,7 @@ export class HaNetworkGraph extends LitElement {
symbolSize: node.symbolSize || 30,
symbol: node.symbol || "circle",
itemStyle: node.itemStyle || {},
label: node.label,
fixed: node.fixed,
x: node.x,
y: node.y,
+16 -9
View File
@@ -9,6 +9,7 @@ import { fireEvent, type HASSDomEvent } from "../../common/dom/fire_event";
import SankeyChart from "../../resources/echarts/components/sankey/install";
import type { HaECOption } from "../../resources/echarts/echarts";
import { measureTextWidth } from "../../util/text";
import { fitLabelFontSize } from "./fit-label-font-size";
import "./ha-chart-base";
import "./ha-chart-tooltip-marker";
import "../ha-alert";
@@ -237,8 +238,10 @@ export class HaSankeyChart extends LitElement {
distance: LABEL_DISTANCE,
minMargin: LABEL_MIN_MARGIN,
overflow: "break",
lineOverflow: this.vertical ? "truncate" : "none",
},
labelLayout: (params) => {
// params.rect is already zoomed, only the gap slack needs scaling
if (this.vertical) {
// reduce the label font size so the longest word fits on one line
const longestWord = params.text
@@ -257,7 +260,7 @@ export class HaSankeyChart extends LitElement {
: longest;
}, "");
const wordWidth = measureTextWidth(longestWord, FONT_SIZE) || 1;
const availableWidth = (params.rect.width + 6) * this._currentZoom;
const availableWidth = params.rect.width + 6 * this._currentZoom;
// minMargin is applied as padding on the label box, so words must
// fit in the inner wrap width or overflow:break splits them.
const wrapWidth = Math.max(availableWidth - LABEL_MIN_MARGIN, 1);
@@ -266,21 +269,25 @@ export class HaSankeyChart extends LitElement {
(wrapWidth / wordWidth) * FONT_SIZE
);
return {
fontSize: fontSize > 1 ? fontSize : 0,
fontSize,
// Safari draws a 0px canvas font at its default size, so hide
// unreadable labels by dropping all of their lines instead.
height: fontSize > 1 ? undefined : 0,
width: availableWidth,
align: "center",
dy: -2, // shift up or the lowest row labels may be cut off
};
}
const availableHeight = (params.rect.height + 8) * this._currentZoom; // account for the margin
const fontSize = Math.min(
(availableHeight / params.labelRect.height) * FONT_SIZE,
FONT_SIZE
);
const availableHeight = params.rect.height + 8 * this._currentZoom; // account for the margin
return {
fontSize,
lineHeight: fontSize,
// labelRect is measured before the width is applied, so unwrapped
fontSize: fitLabelFontSize(params.text, {
fontSize: FONT_SIZE,
width: labelSpace,
height: availableHeight,
unwrappedHeight: params.labelRect.height,
}),
width: labelSpace,
height: params.rect.height,
};
+21 -6
View File
@@ -1762,10 +1762,6 @@ export class HaDataTable extends LitElement {
.mdc-data-table__header-cell--numeric {
text-align: var(--float-end);
}
.mdc-data-table__header-cell--numeric.sortable:hover,
.mdc-data-table__header-cell--numeric.sortable:not(.not-sorted) {
text-align: var(--float-start);
}
/* custom from here */
@@ -1839,8 +1835,8 @@ export class HaDataTable extends LitElement {
.mdc-data-table__header-cell > * {
transition: var(--float-start) 0.2s ease;
}
.mdc-data-table__header-cell--numeric > span {
transition: none;
.mdc-data-table__header-cell--numeric ha-svg-icon {
transition: var(--float-end) 0.2s ease;
}
.mdc-data-table__header-cell ha-svg-icon {
top: -3px;
@@ -1863,6 +1859,25 @@ export class HaDataTable extends LitElement {
inset-inline-start: 12px;
inset-inline-end: initial;
}
.mdc-data-table__header-cell--numeric.not-sorted ha-svg-icon {
right: -20px;
inset-inline-end: -20px;
inset-inline-start: initial;
}
.mdc-data-table__header-cell--numeric.sortable:not(.not-sorted)
ha-svg-icon,
.mdc-data-table__header-cell--numeric.sortable:hover.not-sorted
ha-svg-icon {
right: 12px;
inset-inline-end: 12px;
inset-inline-start: initial;
}
.mdc-data-table__header-cell--numeric.sortable:not(.not-sorted) span,
.mdc-data-table__header-cell--numeric.sortable:hover.not-sorted span {
left: -24px;
inset-inline-start: -24px;
inset-inline-end: initial;
}
.table-header {
border-bottom: 1px solid var(--divider-color);
}
@@ -188,8 +188,8 @@ const sortData = (
: b[column.valueColumn || sortColumn];
if (column.type === "numeric") {
valA = isNaN(valA) ? undefined : Number(valA);
valB = isNaN(valB) ? undefined : Number(valB);
valA = valA == null || isNaN(valA) ? undefined : Number(valA);
valB = valB == null || isNaN(valB) ? undefined : Number(valB);
} else if (column.type === "ip") {
return sort * ipCompare(valA, valB);
} else if (typeof valA === "string" && typeof valB === "string") {
+16 -131
View File
@@ -15,14 +15,16 @@ import { consumeLocalize } from "../common/decorators/consume-context-entry";
import { transform } from "../common/decorators/transform";
import { supportsFeature } from "../common/entity/supports-feature";
import type { LocalizeFunc } from "../common/translations/localize";
import {
createAssistMessageProcessor,
type AssistMessage,
type AssistMessageProcessor,
} from "../data/assist_chat";
import {
runAssistPipeline,
type AssistPipeline,
type ConversationChatLogAssistantDelta,
type ConversationChatLogToolResultDelta,
type PipelineRunEvent,
} from "../data/assist_pipeline";
import type { ChatLogToolResult } from "../data/chat_log";
import {
configContext,
connectionContext,
@@ -52,22 +54,6 @@ import "./ha-markdown";
import "./input/ha-input";
import type { HaInput } from "./input/ha-input";
interface AssistMessage {
who: string;
text: string;
thinking: string;
thinking_expanded?: boolean;
tool_calls: Record<
string,
{
tool_name: string;
tool_args: Record<string, unknown>;
result?: ChatLogToolResult;
}
>;
error?: boolean;
}
export const initialPromptToSubmit = (
prompt: string | undefined,
submit: boolean
@@ -653,6 +639,9 @@ ${JSON.stringify(toolCall.result, null, 2)}</pre>
hassMessageProcesser.addMessage();
} else if (event.type.startsWith("intent-")) {
hassMessageProcesser.processEvent(event);
if (event.type === "intent-end") {
this._conversationId = event.data.intent_output.conversation_id;
}
} else if (event.type === "run-end") {
this._stt_binary_handler_id = undefined;
unsub();
@@ -744,6 +733,9 @@ ${JSON.stringify(toolCall.result, null, 2)}</pre>
(event) => {
if (event.type.startsWith("intent-")) {
hassMessageProcesser.processEvent(event);
if (event.type === "intent-end") {
this._conversationId = event.data.intent_output.conversation_id;
}
}
if (event.type === "intent-end") {
unsub();
@@ -770,118 +762,11 @@ ${JSON.stringify(toolCall.result, null, 2)}</pre>
}
}
private _createAddHassMessageProcessor() {
let currentDeltaRole = "";
const progressToNextMessage = () => {
if (
progress.hassMessage.text === "…" &&
!progress.hassMessage.thinking &&
(!progress.hassMessage.tool_calls ||
Object.keys(progress.hassMessage.tool_calls).length === 0)
) {
return;
}
if (progress.hassMessage.text?.endsWith("…")) {
progress.hassMessage.text = progress.hassMessage.text.slice(0, -1);
}
progress.hassMessage = {
who: "hass",
text: "…",
thinking: "",
tool_calls: {},
error: false,
};
this._addMessage(progress.hassMessage);
};
const isAssistantDelta = (
_delta: any
): _delta is Partial<ConversationChatLogAssistantDelta> =>
currentDeltaRole === "assistant";
const isToolResult = (
_delta: any
): _delta is ConversationChatLogToolResultDelta =>
currentDeltaRole === "tool_result";
const progress = {
continueConversation: false,
hassMessage: {
who: "hass",
text: "…",
thinking: "",
tool_calls: {},
error: false,
},
addMessage: () => {
this._addMessage(progress.hassMessage);
},
setError: (error: string) => {
progressToNextMessage();
progress.hassMessage.text = error;
progress.hassMessage.error = true;
this.requestUpdate("_conversation");
},
processEvent: (event: PipelineRunEvent) => {
if (event.type === "intent-progress" && event.data.chat_log_delta) {
const delta = event.data.chat_log_delta;
// new message
if (delta.role) {
currentDeltaRole = delta.role;
}
if (isAssistantDelta(delta)) {
if (delta.content) {
if (progress.hassMessage.text.endsWith("…")) {
progress.hassMessage.text =
progress.hassMessage.text.substring(
0,
progress.hassMessage.text.length - 1
) +
delta.content +
"…";
} else {
progress.hassMessage.text += delta.content + "…";
}
}
if (delta.thinking_content) {
progress.hassMessage.thinking += delta.thinking_content;
}
if (delta.tool_calls) {
for (const toolCall of delta.tool_calls) {
progress.hassMessage.tool_calls[toolCall.id] = toolCall;
}
}
this.requestUpdate("_conversation");
} else if (isToolResult(delta)) {
if (progress.hassMessage.tool_calls[delta.tool_call_id]) {
progress.hassMessage.tool_calls[delta.tool_call_id].result =
delta.result;
this.requestUpdate("_conversation");
}
}
} else if (event.type === "intent-end") {
this._conversationId = event.data.intent_output.conversation_id;
progress.continueConversation =
event.data.intent_output.continue_conversation;
const response =
event.data.intent_output.response.speech.plain?.speech;
if (!response) {
return;
}
if (event.data.intent_output.response.response_type === "error") {
progress.setError(response);
} else {
progress.hassMessage.text = response;
this.requestUpdate("_conversation");
}
}
},
};
return progress;
private _createAddHassMessageProcessor(): AssistMessageProcessor {
return createAssistMessageProcessor({
addMessage: (message) => this._addMessage(message),
requestUpdate: () => this.requestUpdate("_conversation"),
});
}
static get styles(): CSSResultGroup {
+16 -4
View File
@@ -80,6 +80,9 @@ export class HaControlCircularSlider extends LitElement {
@property({ type: String, attribute: "high-label" })
public highLabel?: string;
@property({ attribute: false })
public formatValue?: (value: number) => string;
@property({ type: Number }) public value?: number;
@property({ type: Number }) public low?: number;
@@ -562,13 +565,22 @@ export class HaControlCircularSlider extends LitElement {
aria-valuemin=${this.min}
aria-valuemax=${this.max}
aria-valuenow=${
this._localValue != null
? this._steppedValue(this._localValue)
: undefined
value != null ? this._steppedValue(value) : undefined
}
aria-valuetext=${ifDefined(
value != null && this.formatValue
? this.formatValue(this._steppedValue(value))
: undefined
)}
aria-disabled=${this.disabled}
aria-readonly=${this.readonly}
aria-label=${ifDefined(this.lowLabel ?? this.label)}
aria-label=${ifDefined(
id === "low"
? (this.lowLabel ?? this.label)
: id === "high"
? (this.highLabel ?? this.label)
: this.label
)}
@keydown=${this._handleKeyDown}
@keyup=${this._handleKeyUp}
/>
+14 -3
View File
@@ -1,13 +1,16 @@
import { ResizeController } from "@lit-labs/observers/resize-controller";
import type { ContextType } from "@lit/context";
import { mdiMinus, mdiPlus } from "@mdi/js";
import type { TemplateResult } from "lit";
import { LitElement, css, html, nothing } from "lit";
import { customElement, property, query } from "lit/decorators";
import { customElement, property, query, state } from "lit/decorators";
import { ifDefined } from "lit/directives/if-defined";
import { consume } from "../common/decorators/consume";
import { fireEvent } from "../common/dom/fire_event";
import { conditionalClamp } from "../common/number/clamp";
import { formatNumber } from "../common/number/format_number";
import { blankBeforeUnit } from "../common/translations/blank_before_unit";
import { internationalizationContext } from "../data/context";
import type { FrontendLocaleData } from "../data/translation";
import "./ha-svg-icon";
@@ -45,6 +48,10 @@ export class HaControlNumberButton extends LitElement {
@query("#input") _input!: HTMLDivElement;
@state()
@consume({ context: internationalizationContext, subscribe: true })
private _i18n?: ContextType<typeof internationalizationContext>;
private _hideUnit = new ResizeController(this, {
callback: (entries) => {
const width = entries[0]?.contentRect.width;
@@ -160,7 +167,9 @@ export class HaControlNumberButton extends LitElement {
class="button minus"
type="button"
tabindex="-1"
aria-label="decrement"
aria-label=${ifDefined(
this._i18n?.localize("ui.components.number-buttons.decrement")
)}
@click=${this._handleMinusButton}
.disabled=${
this.disabled || (this.min != null && this._value <= this.min)
@@ -172,7 +181,9 @@ export class HaControlNumberButton extends LitElement {
class="button plus"
type="button"
tabindex="-1"
aria-label="increment"
aria-label=${ifDefined(
this._i18n?.localize("ui.components.number-buttons.increment")
)}
@click=${this._handlePlusButton}
.disabled=${
this.disabled || (this.max != null && this._value >= this.max)
+17 -5
View File
@@ -71,11 +71,12 @@ export class HaControlSelectMenu extends LitElement {
>
${this._renderIcon()}
<div class="content">
${
this.hideLabel
? nothing
: html`<p id="label" class="label">${this.label}</p>`
}
<p
id="label"
class="label ${classMap({ "visually-hidden": this.hideLabel })}"
>
${this.label}
</p>
${selectedText ? html`<p class="value">${selectedText}</p>` : nothing}
</div>
${this._renderArrow()}
@@ -235,6 +236,17 @@ export class HaControlSelectMenu extends LitElement {
font-size: var(--ha-font-size-m);
}
.content .visually-hidden {
position: absolute;
overflow: hidden;
clip: rect(0 0 0 0);
height: 1px;
width: 1px;
margin: -1px;
padding: 0;
border: 0;
}
.select-anchor:focus-visible {
box-shadow: 0 0 0 2px var(--control-select-menu-focus-color);
}
+2 -1
View File
@@ -106,7 +106,8 @@ export class HaForm extends LitElement implements HaFormElement {
) => string | undefined;
@property({ attribute: false }) public localizeValue?: (
key: string
key: string,
values?: Record<string, string>
) => string;
@property({ attribute: false }) public context?: Record<string, any>;
@@ -43,7 +43,8 @@ export class HaObjectSelector extends LitElement {
@property({ type: Boolean }) public required = true;
@property({ attribute: false }) public localizeValue?: (
key: string
key: string,
values?: Record<string, string>
) => string;
@query("ha-yaml-editor", true) private _yamlEditor?: HaYamlEditor;
@@ -79,37 +80,45 @@ export class HaObjectSelector extends LitElement {
return this.selector.object?.fields?.[schema.name]?.description || "";
};
private _formatField(item: any, field: string): string {
const fields = this.selector.object!.fields!;
const value = formatSelectorValue(
this.hass,
item[field],
fields[field]?.selector
);
const translationKey = this.selector.object!.translation_key;
if (!value || !this.localizeValue || !translationKey) {
return value;
}
return (
this.localizeValue(`${translationKey}.fields.${field}.overview_label`, {
value,
}) || value
);
}
private _renderItem(item: any, index: number) {
const fields = this.selector.object!.fields!;
const preferredLabel = this.selector.object!.label_field;
const hasValidLabelField = preferredLabel && preferredLabel in fields;
const label = hasValidLabelField
? formatSelectorValue(
this.hass,
item[preferredLabel!],
fields[preferredLabel!]?.selector
)
: Object.entries(fields)
.map(([key, field]) =>
formatSelectorValue(this.hass, item[key], field.selector)
)
? this._formatField(item, preferredLabel!)
: Object.keys(fields)
.map((key) => this._formatField(item, key))
.filter(Boolean)
.join(" · ");
let description = "";
const descriptionField = this.selector.object!.description_field;
if (descriptionField && descriptionField in fields) {
const descriptionSelector = fields[descriptionField]?.selector;
description = descriptionSelector
? formatSelectorValue(
this.hass,
item[descriptionField],
descriptionSelector
)
: "";
if (
descriptionField &&
descriptionField in fields &&
fields[descriptionField]?.selector
) {
description = this._formatField(item, descriptionField);
}
const reorderable = this.selector.object!.multiple || false;
+4 -1
View File
@@ -93,7 +93,10 @@ export class HaSelector extends LitElement {
@property() public helper?: string;
@property({ attribute: false })
public localizeValue?: (key: string) => string;
public localizeValue?: (
key: string,
values?: Record<string, string>
) => string;
@property() public placeholder?: any;
+6 -2
View File
@@ -773,12 +773,16 @@ export class HaServiceControl extends LitElement {
filter_entity: targetEntities || undefined,
}));
private _localizeValueCallback = (key: string) => {
private _localizeValueCallback = (
key: string,
values?: Record<string, string>
) => {
if (!this._value?.action) {
return "";
}
return this.hass.localize(
`component.${computeDomain(this._value.action)}.selector.${key}`
`component.${computeDomain(this._value.action)}.selector.${key}`,
values
);
};
+47 -14
View File
@@ -17,7 +17,10 @@ import { UNIT_KM } from "../../common/const";
import { transform } from "../../common/decorators/transform";
import { fireEvent } from "../../common/dom/fire_event";
import { computeStateDomain } from "../../common/entity/compute_state_domain";
import { getEntityLocation } from "../../common/entity/get_entity_location";
import {
findFirstActiveZone,
getEntityLocation,
} from "../../common/entity/get_entity_location";
import { supportsVectorMaps } from "../../common/map/base-layer";
import type {
MapCircleHandle,
@@ -66,6 +69,7 @@ import {
statesContext,
uiContext,
} from "../../data/context";
import { UNAVAILABLE, UNKNOWN } from "../../data/entity/entity";
import type { EntityRegistryEntry } from "../../data/entity/entity_registry";
import { ensureMapTilesToken } from "../../data/map_tiles";
import "../ha-tooltip";
@@ -78,7 +82,6 @@ import type {
ThemeMode,
} from "../../types";
import { FLOATING_LIFT, floatingMarkerFootprint } from "./ha-entity-marker";
import { computeZoneTrackerState } from "../../data/zone";
declare global {
// for fire event
@@ -528,8 +531,19 @@ export class HaMap extends ReactiveElement {
} else if (this._loaded && oldStates && this.entities) {
// Check if any state has changed
for (const entity of this.entities) {
const entityId = getEntityId(entity);
const stateObj = this._states[entityId];
const inZones: string[] | undefined = stateObj?.attributes.in_zones;
if (
oldStates[getEntityId(entity)] !== this._states[getEntityId(entity)]
oldStates[entityId] !== stateObj ||
(stateObj &&
["person", "device_tracker"].includes(
computeStateDomain(stateObj)
) &&
Array.isArray(inZones) &&
inZones.some(
(zoneId) => oldStates[zoneId] !== this._states[zoneId]
))
) {
this._drawEntities();
autoFitRequired = !this._pauseAutoFit;
@@ -1347,8 +1361,6 @@ export class HaMap extends ReactiveElement {
this._watchRegistry();
const computedStyles = getComputedStyle(this);
// A person's state is "home" for the home zone, the zone name otherwise
const zoneByState: Record<string, string> = {};
this._zonePositions = {};
this._zoneRadii = {};
for (const entity of this.entities) {
@@ -1359,7 +1371,6 @@ export class HaMap extends ReactiveElement {
computeStateDomain(stateObj) === "zone" &&
(this.renderPassive || !stateObj.attributes.passive)
) {
zoneByState[computeZoneTrackerState(stateObj)] = stateObj.entity_id;
if (
typeof stateObj.attributes.latitude === "number" &&
typeof stateObj.attributes.longitude === "number"
@@ -1518,6 +1529,22 @@ export class HaMap extends ReactiveElement {
typeof entity !== "string" && (entity.selected ?? false);
entityMarker.floating = true;
let zoneId: string | undefined;
if (["person", "device_tracker"].includes(computeStateDomain(stateObj))) {
const inZones: string[] | undefined = stateObj.attributes.in_zones;
if (Array.isArray(inZones) && inZones.length > 0) {
const activeZone = findFirstActiveZone(inZones, states);
if (activeZone && this._zonePositions[activeZone.entity_id]) {
zoneId = activeZone.entity_id;
}
} else if (
!Array.isArray(inZones) ||
!["not_home", UNKNOWN, UNAVAILABLE].includes(stateObj.state)
) {
zoneId = this._zoneContaining(position, gpsAccuracy);
}
}
const clusterData: ClusterData = {
entityId,
title,
@@ -1527,11 +1554,7 @@ export class HaMap extends ReactiveElement {
unit: entityMarker.entityUnit ?? "",
color: entityColor,
selected: entityMarker.selected,
zoneId: ["person", "device_tracker"].includes(
computeStateDomain(stateObj)
)
? (zoneByState[stateObj.state] ?? this._zoneContaining(position))
: undefined,
zoneId,
};
const accuracy =
@@ -1591,16 +1614,26 @@ export class HaMap extends ReactiveElement {
);
}
private _zoneContaining(position: MapLatLng): string | undefined {
private _zoneContaining(
position: MapLatLng,
gpsAccuracy = 0
): string | undefined {
let found: string | undefined;
let foundRadius = Infinity;
let foundDistance = Infinity;
for (const [zoneId, radius] of Object.entries(this._zoneRadii)) {
if (this._states[zoneId]?.attributes.passive) {
continue;
}
const distance = distanceMeters(position, this._zonePositions[zoneId]);
if (
radius < foundRadius &&
distanceMeters(position, this._zonePositions[zoneId]) <= radius
distance - gpsAccuracy < radius &&
(radius < foundRadius ||
(radius === foundRadius && distance < foundDistance))
) {
found = zoneId;
foundRadius = radius;
foundDistance = distance;
}
}
return found;
+209
View File
@@ -0,0 +1,209 @@
import type {
ConversationChatLogAssistantDelta,
ConversationChatLogToolResultDelta,
PipelineRunEvent,
} from "./assist_pipeline";
import type { ChatLogToolResult } from "./chat_log";
export interface AssistMessage {
who: string;
text: string;
thinking: string;
thinking_expanded?: boolean;
tool_calls: Record<
string,
{
tool_name: string;
tool_args: Record<string, unknown>;
result?: ChatLogToolResult;
}
>;
error?: boolean;
}
export interface AssistMessageProcessor {
readonly continueConversation: boolean;
readonly hassMessage: AssistMessage;
addMessage: () => void;
setError: (error: string) => void;
processEvent: (event: PipelineRunEvent) => void;
}
type ChatLogDelta =
| Partial<ConversationChatLogAssistantDelta>
| ConversationChatLogToolResultDelta;
const STREAMING_ELLIPSIS = "…";
const stripStreamingEllipsis = (text: string): string =>
text.endsWith(STREAMING_ELLIPSIS) ? text.slice(0, -1) : text;
const joinWithBreak = (previous: string, next: string): string => {
if (previous === "" || next === "") {
return previous + next;
}
if (
/\r?\n\r?\n$/.test(previous) ||
/^\r?\n\r?\n/.test(next) ||
(/\r?\n$/.test(previous) && /^\r?\n/.test(next))
) {
return previous + next;
}
return /\r?\n$/.test(previous) || /^\r?\n/.test(next)
? `${previous}\n${next}`
: `${previous}\n\n${next}`;
};
const newAssistantMessage = (): AssistMessage => ({
who: "hass",
text: STREAMING_ELLIPSIS,
thinking: "",
tool_calls: {},
error: false,
});
export const createAssistMessageProcessor = ({
addMessage,
requestUpdate,
}: {
addMessage: (message: AssistMessage) => void;
requestUpdate: () => void;
}): AssistMessageProcessor => {
let currentDeltaRole = "";
// The role and the content of a new message can arrive in separate deltas.
let pendingMessageBoundary = false;
// Once finalized, a trailing ellipsis is part of the reply and is kept.
let streamingMarker = true;
let latestSegment = "";
let continueConversation = false;
let hassMessage: AssistMessage = newAssistantMessage();
const isMessageEmpty = (message: AssistMessage) =>
streamingMarker &&
message.text === STREAMING_ELLIPSIS &&
!message.thinking &&
Object.keys(message.tool_calls).length === 0;
const finalizeStreamingText = () => {
if (streamingMarker) {
hassMessage.text = stripStreamingEllipsis(hassMessage.text);
}
streamingMarker = false;
};
const progressToNextMessage = () => {
if (isMessageEmpty(hassMessage)) {
return;
}
finalizeStreamingText();
hassMessage = newAssistantMessage();
streamingMarker = true;
latestSegment = "";
addMessage(hassMessage);
};
const isAssistantDelta = (
_delta: ChatLogDelta
): _delta is Partial<ConversationChatLogAssistantDelta> =>
currentDeltaRole === "assistant";
const isToolResultDelta = (
_delta: ChatLogDelta
): _delta is ConversationChatLogToolResultDelta =>
currentDeltaRole === "tool_result";
// The response speech only contains the final assistant message, so merge
// it into the streamed text instead of replacing it.
const applyFinalResponse = (response: string) => {
finalizeStreamingText();
const streamed = hassMessage.text;
if (streamed === "") {
hassMessage.text = response;
} else if (latestSegment.trim() !== response.trim()) {
hassMessage.text = joinWithBreak(streamed, response);
}
};
const setError = (error: string) => {
progressToNextMessage();
hassMessage.text = error;
hassMessage.error = true;
streamingMarker = false;
requestUpdate();
};
const processEvent = (event: PipelineRunEvent) => {
if (event.type === "intent-progress" && event.data.chat_log_delta) {
const delta = event.data.chat_log_delta;
// new message
if (delta.role) {
currentDeltaRole = delta.role;
if (delta.role === "assistant") {
pendingMessageBoundary = true;
latestSegment = "";
}
}
if (isAssistantDelta(delta)) {
if (delta.content) {
let text = stripStreamingEllipsis(hassMessage.text);
if (pendingMessageBoundary) {
text = joinWithBreak(text, delta.content);
pendingMessageBoundary = false;
} else {
text += delta.content;
}
latestSegment += delta.content;
hassMessage.text = text + STREAMING_ELLIPSIS;
streamingMarker = true;
}
if (delta.thinking_content) {
hassMessage.thinking += delta.thinking_content;
}
if (delta.tool_calls) {
for (const toolCall of delta.tool_calls) {
hassMessage.tool_calls[toolCall.id] = toolCall;
}
}
requestUpdate();
} else if (isToolResultDelta(delta)) {
if (hassMessage.tool_calls[delta.tool_call_id]) {
hassMessage.tool_calls[delta.tool_call_id].result = delta.result;
requestUpdate();
}
}
} else if (event.type === "intent-end") {
continueConversation = event.data.intent_output.continue_conversation;
const response = event.data.intent_output.response.speech.plain?.speech;
if (
event.data.intent_output.response.response_type === "error" &&
response
) {
setError(response);
return;
}
if (response) {
applyFinalResponse(response);
} else {
finalizeStreamingText();
}
pendingMessageBoundary = false;
requestUpdate();
}
};
return {
get continueConversation() {
return continueConversation;
},
get hassMessage() {
return hassMessage;
},
addMessage: () => {
addMessage(hassMessage);
},
setError,
processEvent,
};
};
+1 -1
View File
@@ -91,7 +91,7 @@ export type ManagerStateEvent =
export type BackupSubscriptionEvent = ManagerStateEvent | UploadBackupEvent;
export const subscribeBackupEvents = (
hass: HomeAssistant,
hass: Pick<HomeAssistant, "connection">,
callback: (event: BackupSubscriptionEvent) => void,
preCheck?: () => boolean | Promise<boolean>
) =>
+255
View File
@@ -0,0 +1,255 @@
import { generateUuidV4 } from "../common/util/uuid";
import type { HomeAssistant } from "../types";
const STORAGE_KEY_SESSION = "tools-debug-console-session";
// Tabs tell each other which sessions they use, over this channel.
const SESSION_CHANNEL = "tools-debug-console-sessions";
// How long a tab waits for another tab to say it uses a session already.
const CLAIM_TIMEOUT = 100;
type SessionMessage =
| { type: "claim"; sessionId: string; ticket: number }
| { type: "taken"; sessionId: string };
// Per user, so accounts taking turns in the same browser tab share nothing.
const sessionIds = new Map<string, Promise<string>>();
// The sessions this tab uses, once claimed.
const ownSessionIds = new Set<string>();
const sessionChannel = (() => {
try {
const channel = new BroadcastChannel(SESSION_CHANNEL);
channel.addEventListener("message", (ev: MessageEvent<SessionMessage>) => {
if (ev.data.type === "claim" && ownSessionIds.has(ev.data.sessionId)) {
channel.postMessage({
type: "taken",
sessionId: ev.data.sessionId,
} satisfies SessionMessage);
}
});
return channel;
} catch (_err: unknown) {
// Without it, a tab opened from another one shares its session.
return undefined;
}
})();
/**
* Return if another open tab uses this session already, or claims it at the
* same time. Then the tab with the lowest ticket keeps it; both see the claim
* of the other, so exactly one of them does.
*/
const isSessionTaken = (sessionId: string): Promise<boolean> => {
const channel = sessionChannel;
if (!channel) {
return Promise.resolve(false);
}
const ticket = Math.random();
return new Promise((resolve) => {
const done = (taken: boolean) => {
window.clearTimeout(timeout);
channel.removeEventListener("message", onMessage);
resolve(taken);
};
const onMessage = (ev: MessageEvent<SessionMessage>) => {
const message = ev.data;
if (message.sessionId !== sessionId) {
return;
}
if (
message.type === "taken" ||
(message.type === "claim" && message.ticket < ticket)
) {
done(true);
}
};
const timeout = window.setTimeout(() => done(false), CLAIM_TIMEOUT);
channel.addEventListener("message", onMessage);
channel.postMessage({
type: "claim",
sessionId,
ticket,
} satisfies SessionMessage);
});
};
const claimSessionId = async (userId: string): Promise<string> => {
const storageKey = `${STORAGE_KEY_SESSION}-${userId}`;
let sessionId: string | undefined;
try {
sessionId = window.sessionStorage.getItem(storageKey) ?? undefined;
} catch (_err: unknown) {
// Without storage, the session lasts until the page reloads.
}
// A tab opened from another one gets a copy of its session storage. When
// that tab is still open, this one starts a session of its own.
if (!sessionId || (await isSessionTaken(sessionId))) {
sessionId = generateUuidV4();
try {
window.sessionStorage.setItem(storageKey, sessionId);
} catch (_err: unknown) {
// Kept for this page load only.
}
}
ownSessionIds.add(sessionId);
return sessionId;
};
/**
* The console session of this browser tab. The backend keeps the session for
* a while after disconnecting, so a reload continues where it left off.
*/
const getSessionId = (userId: string): Promise<string> => {
let sessionId = sessionIds.get(userId);
if (!sessionId) {
sessionId = claimSessionId(userId);
sessionIds.set(userId, sessionId);
}
return sessionId;
};
/** Claim the session of this tab ahead of time, so commands do not wait. */
export const prepareDebugConsoleSession = (userId: string) => {
getSessionId(userId);
};
const callDebugConsole = async <T>(
hass: HomeAssistant,
message: { type: string; [key: string]: unknown }
) =>
hass.callWS<T>({
...message,
session_id: await getSessionId(hass.user!.id),
});
/** Return if the user has to accept the warning before using the console. */
export const getDebugConsoleInfo = (hass: Pick<HomeAssistant, "callWS">) =>
hass.callWS<{ warning_required: boolean }>({ type: "debug_console/info" });
export const acceptDebugConsoleWarning = (
hass: Pick<HomeAssistant, "callWS">
) => hass.callWS({ type: "debug_console/warning/accept" });
export type DebugConsoleValue =
| { type: "none" }
| { type: "boolean"; value: boolean }
| { type: "number"; value: string }
| { type: "string"; value: string; truncated: boolean }
| { type: "text"; value: string; truncated: boolean }
| {
type: "object";
class: string;
preview: string;
/** Unique per backend session, so stale IDs never find another object. */
object_id: string;
length?: number;
};
export interface DebugConsoleExecuteResult {
logs?: DebugConsoleValue[][];
/** Set when the code printed more lines than the console keeps. */
logs_truncated?: boolean;
result?: DebugConsoleValue | null;
error?: string;
}
export type DebugConsoleChild =
{ key: string; value: DebugConsoleValue } | { key: string; getter: true };
/**
* Looks into objects of the console, for components that do not hold the
* hass object.
*/
export interface DebugConsoleObjects {
inspect(objectId: string): Promise<DebugConsoleInspectResult>;
getAttribute(
objectId: string,
attribute: string
): Promise<DebugConsoleGetAttributeResult>;
}
export interface DebugConsoleInspectResult {
children: DebugConsoleChild[];
truncated: boolean;
}
export type DebugConsoleGetAttributeResult =
{ value: DebugConsoleValue } | { error: string };
export const executeDebugConsole = (hass: HomeAssistant, code: string) =>
callDebugConsole<DebugConsoleExecuteResult>(hass, {
type: "debug_console/execute",
code,
});
/** Return if code needs more input before it runs, like in the Python REPL. */
export const checkDebugConsoleInput = (
hass: Pick<HomeAssistant, "callWS">,
code: string
) =>
hass.callWS<{ incomplete: boolean }>({
type: "debug_console/check_input",
code,
});
/** Stop the code this tab is running, which then answers it stopped. */
export const stopDebugConsole = (hass: HomeAssistant) =>
callDebugConsole(hass, { type: "debug_console/stop" });
export const inspectDebugConsole = (hass: HomeAssistant, objectId: string) =>
callDebugConsole<DebugConsoleInspectResult>(hass, {
type: "debug_console/inspect",
object_id: objectId,
});
export const getDebugConsoleAttribute = (
hass: HomeAssistant,
objectId: string,
attribute: string
) =>
callDebugConsole<DebugConsoleGetAttributeResult>(hass, {
type: "debug_console/get_attribute",
object_id: objectId,
attribute,
});
export interface DebugConsoleCompleteResult {
/** The part of the line the matches complete. */
token: string;
matches: string[];
}
/** Complete the code being typed, which is the text up to the cursor. */
export const completeDebugConsole = (hass: HomeAssistant, code: string) =>
callDebugConsole<DebugConsoleCompleteResult>(hass, {
type: "debug_console/complete",
code,
});
export interface DebugConsoleSignature {
name: string;
parameters: string[];
/** Index of the parameter the argument being typed goes to. */
active: number | null;
returns: string | null;
doc: string;
}
/** Return the signature of the call the code up to the cursor is in. */
export const getDebugConsoleSignature = (hass: HomeAssistant, code: string) =>
callDebugConsole<{ signature: DebugConsoleSignature | null }>(hass, {
type: "debug_console/signature",
code,
});
export const clearDebugConsole = (hass: HomeAssistant) =>
callDebugConsole(hass, { type: "debug_console/clear" });
+15 -11
View File
@@ -1,7 +1,11 @@
import type { Connection, UnsubscribeFunc } from "home-assistant-js-websocket";
import { isComponentLoaded } from "../common/config/is_component_loaded";
import { atLeastVersion } from "../common/config/version";
import type { HomeAssistant, LogFileDisabledReason } from "../types";
import type {
HomeAssistantApi,
HomeAssistantConfig,
LogFileDisabledReason,
} from "../types";
import type { HassioAddonInfo } from "./hassio/addon";
import { supervisorUrl } from "./hassio/common";
@@ -11,12 +15,12 @@ export interface LogProvider {
addon?: HassioAddonInfo;
}
const hasSupervisorCoreLogDownload = (hass: HomeAssistant): boolean =>
isComponentLoaded(hass.config, "hassio") &&
atLeastVersion(hass.config.version, 2025, 10);
const hasSupervisorCoreLogDownload = (config: HomeAssistantConfig): boolean =>
isComponentLoaded(config.config, "hassio") &&
atLeastVersion(config.config.version, 2025, 10);
export const fetchErrorLog = (hass: HomeAssistant) =>
hass.callApi<string>("GET", "error_log");
export const fetchErrorLog = (api: HomeAssistantApi) =>
api.callApi<string>("GET", "error_log");
export const subscribeIntegrationLog = (
connection: Connection,
@@ -31,17 +35,17 @@ export const subscribeIntegrationLog = (
{ resubscribe: false }
);
export const getErrorLogDownloadUrl = (hass: HomeAssistant) =>
hasSupervisorCoreLogDownload(hass)
export const getErrorLogDownloadUrl = (config: HomeAssistantConfig) =>
hasSupervisorCoreLogDownload(config)
? supervisorUrl("core/logs/latest")
: "/api/error_log";
export const getCoreLogFileDownloadUnavailableReason = (
hass: HomeAssistant
config: HomeAssistantConfig
): LogFileDisabledReason | undefined => {
if (hasSupervisorCoreLogDownload(hass)) {
if (hasSupervisorCoreLogDownload(config)) {
return undefined;
}
return hass.config.logging?.log_file_disabled_reason ?? undefined;
return config.config.logging?.log_file_disabled_reason ?? undefined;
};
-2
View File
@@ -12,8 +12,6 @@ import type { ESPHomeDeviceCapabilities, ESPHomeSerialProxy } from "./esphome";
import type { ESPHomeFrontendUserData } from "./frontend";
import type { SerialPortUsage } from "./usb";
export const MUSIC_ASSISTANT_ADDON_SLUG = "d5369777_music_assistant";
export const MUSIC_ASSISTANT_DOCS_URL = "https://music-assistant.io/";
export type ESPHomeCapabilityId =
+11 -11
View File
@@ -1,4 +1,4 @@
import type { HomeAssistant, PanelInfo } from "../../types";
import type { HomeAssistant, HomeAssistantApi, PanelInfo } from "../../types";
import type { SupervisorArch } from "../supervisor/supervisor";
import type { AddonState } from "./addon";
import { supervisorUrl, type HassioResponse } from "./common";
@@ -157,25 +157,25 @@ export const fetchHassioInfo = async (
method: "get",
});
export const fetchHassioBoots = async (hass: HomeAssistant) =>
hass.callApi<HassioResponse<HassioBoots>>("GET", `hassio/host/logs/boots`);
export const fetchHassioBoots = async (api: HomeAssistantApi) =>
api.callApi<HassioResponse<HassioBoots>>("GET", `hassio/host/logs/boots`);
export const fetchHassioLogsLegacy = async (
hass: HomeAssistant,
api: HomeAssistantApi,
provider: string
) =>
hass.callApi<string>(
api.callApi<string>(
"GET",
`hassio/${provider.includes("_") ? `addons/${provider}` : provider}/logs`
);
export const fetchHassioLogs = async (
hass: HomeAssistant,
api: HomeAssistantApi,
provider: string,
range?: string,
boot = 0
) =>
hass.callApiRaw(
api.callApiRaw(
"GET",
`hassio/${provider.includes("_") ? `addons/${provider}` : provider}/logs${boot !== 0 ? `/boots/${boot}` : ""}`,
undefined,
@@ -187,13 +187,13 @@ export const fetchHassioLogs = async (
);
export const fetchHassioLogsFollow = async (
hass: HomeAssistant,
api: HomeAssistantApi,
provider: string,
signal: AbortSignal,
lines = 100,
boot = 0
) =>
hass.callApiRaw(
api.callApiRaw(
"GET",
`hassio/${provider.includes("_") ? `addons/${provider}` : provider}/logs${boot !== 0 ? `/boots/${boot}` : ""}/follow?lines=${lines}`,
undefined,
@@ -202,7 +202,7 @@ export const fetchHassioLogsFollow = async (
);
export const fetchHassioLogsFollowSkip = async (
hass: HomeAssistant,
api: HomeAssistantApi,
provider: string,
signal: AbortSignal,
cursor: string,
@@ -210,7 +210,7 @@ export const fetchHassioLogsFollowSkip = async (
lines = 100,
boot = 0
) =>
hass.callApiRaw(
api.callApiRaw(
"GET",
`hassio/${provider.includes("_") ? `addons/${provider}` : provider}/logs${boot !== 0 ? `/boots/${boot}` : ""}/follow`,
undefined,
+63
View File
@@ -0,0 +1,63 @@
import type { Connection } from "home-assistant-js-websocket";
import type { HomeAssistant } from "../types";
export interface LoRaWANReception {
gateway_id: string;
rssi: number | null;
snr: number | null;
}
export interface LoRaWANConnection {
entry_id: string;
domain: string;
title: string;
configuration_url: string | null;
}
export interface LoRaWANDevice {
dev_eui: string;
name: string;
manufacturer: string;
model: string;
application_id: string;
device_id: string | null;
connection: LoRaWANConnection;
support:
| "supported"
| "missing_identity"
| "integration_not_configured"
| "unsupported_model";
last_uplink: string | null;
last_join: string | null;
last_status: string | null;
receptions: LoRaWANReception[];
battery_level: number | null;
external_power_source: boolean | null;
downlink_margin: number | null;
}
export interface LoRaWANDevices {
devices: LoRaWANDevice[];
connections: LoRaWANConnection[];
}
export const fetchLoRaWANDevices = (hass: HomeAssistant) =>
hass.callWS<LoRaWANDevices>({ type: "lorawan/devices/list" });
export const subscribeLoRaWANDevices = (
connection: Connection,
listener: (snapshot: LoRaWANDevices) => void
) =>
connection.subscribeMessage<LoRaWANDevices>(listener, {
type: "lorawan/devices/subscribe",
});
export const strongestLoRaWANReception = (receptions: LoRaWANReception[]) =>
receptions.reduce<LoRaWANReception | undefined>(
(strongest, reception) =>
reception.rssi !== null &&
(strongest?.rssi == null || reception.rssi > strongest.rssi)
? reception
: strongest,
undefined
);
+8 -15
View File
@@ -310,22 +310,15 @@ export interface ZWaveJSSetRawConfigParamData {
value_format: number;
}
export interface ZWaveJSSetConfigParamResult {
value_id: string;
status: number;
export type ZWaveJSSetConfigParamStatus = "accepted" | "queued";
export interface ZWaveJSSetRawConfigParamResult {
status: ZWaveJSSetConfigParamStatus;
}
export type ZWaveJSSetConfigParamStatus = "accepted" | "queued" | "error";
// Maps the numeric SetValueStatus from zwave-js.
export const computeSetConfigParamStatus = (
status: number
): ZWaveJSSetConfigParamStatus => {
if (status === 254 || status === 255) {
return "accepted";
}
return status === 1 ? "queued" : "error";
};
export interface ZWaveJSSetConfigParamResult extends ZWaveJSSetRawConfigParamResult {
value_id: string;
}
export interface ZwaveJSNodeConfigParameterUpdate {
id: string;
@@ -816,7 +809,7 @@ export const setZwaveNodeRawConfigParameter = (
value: number,
value_size: number,
value_format: number
): Promise<ZWaveJSSetConfigParamResult> => {
): Promise<ZWaveJSSetRawConfigParamResult> => {
const data: ZWaveJSSetRawConfigParamData = {
type: "zwave_js/set_raw_config_parameter",
device_id,
@@ -145,8 +145,8 @@ export const showConfigFlowDialog = (
);
},
renderShowFormStepFieldLocalizeValue(hass, step, key) {
return hass.localize(`component.${step.handler}.selector.${key}`);
renderShowFormStepFieldLocalizeValue(hass, step, key, values) {
return hass.localize(`component.${step.handler}.selector.${key}`, values);
},
renderShowFormStepSubmitButton(hass, step) {
@@ -81,7 +81,8 @@ export interface FlowConfig {
renderShowFormStepFieldLocalizeValue(
hass: HomeAssistant,
step: DataEntryFlowStepForm,
key: string
key: string,
values?: Record<string, string>
): string;
renderShowFormStepSubmitButton(
@@ -141,8 +141,11 @@ export const showOptionsFlowDialog = (
);
},
renderShowFormStepFieldLocalizeValue(hass, _step, key) {
return hass.localize(`component.${configEntry.domain}.selector.${key}`);
renderShowFormStepFieldLocalizeValue(hass, _step, key, values) {
return hass.localize(
`component.${configEntry.domain}.selector.${key}`,
values
);
},
renderShowFormStepSubmitButton(hass, step) {
@@ -144,8 +144,11 @@ export const showSubConfigFlowDialog = (
);
},
renderShowFormStepFieldLocalizeValue(hass, _step, key) {
return hass.localize(`component.${configEntry.domain}.selector.${key}`);
renderShowFormStepFieldLocalizeValue(hass, _step, key, values) {
return hass.localize(
`component.${configEntry.domain}.selector.${key}`,
values
);
},
renderShowFormStepSubmitButton(hass, step) {
+6 -2
View File
@@ -348,11 +348,15 @@ class StepFlowForm extends LitElement {
private _errorCallback = (error: string) =>
this.flowConfig.renderShowFormStepFieldError(this.hass, this.step, error);
private _localizeValueCallback = (key: string) =>
private _localizeValueCallback = (
key: string,
values?: Record<string, string>
) =>
this.flowConfig.renderShowFormStepFieldLocalizeValue(
this.hass,
this.step,
key
key,
values
);
static get styles(): CSSResultGroup {
@@ -1,6 +1,5 @@
import type { ContextType } from "@lit/context";
import { mdiCheck, mdiChevronDown, mdiOpenInNew } from "@mdi/js";
import type { UnsubscribeFunc } from "home-assistant-js-websocket";
import type { CSSResultGroup, PropertyValues } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, state } from "lit/decorators";
@@ -20,11 +19,7 @@ import "../../components/ha-spinner";
import "../../components/ha-svg-icon";
import "../../components/item/ha-list-item-button";
import "../../components/list/ha-list-nav";
import {
fetchConfigFlowInProgress,
subscribeConfigFlowInProgress,
type ConfigFlowInProgressMessage,
} from "../../data/config_flow";
import { fetchConfigFlowInProgress } from "../../data/config_flow";
import {
apiContext,
configContext,
@@ -51,19 +46,12 @@ import {
getESPHomeAudioControls,
getESPHomeSetupCapabilityIds,
isESPHomeSerialConfigured,
MUSIC_ASSISTANT_ADDON_SLUG,
MUSIC_ASSISTANT_DOCS_URL,
type ESPHomeAudioControls,
type ESPHomeCapabilityId,
type ESPHomeCapabilityStatus,
type ESPHomeSetupStatus,
} from "../../data/esphome_setup";
import {
fetchHassioAddonsInfo,
installHassioAddon,
startHassioAddon,
} from "../../data/hassio/addon";
import { extractApiErrorMessage } from "../../data/hassio/common";
import {
listSerialIntegrations,
listSerialPortsWithUsage,
@@ -74,9 +62,11 @@ import { haStyle, haStyleDialog } from "../../resources/styles";
import type { HomeAssistant, HomeAssistantUI } from "../../types";
import { documentationUrl } from "../../util/documentation-url";
import { getWsErrorMessage } from "../../util/ws-error";
import { showConfigFlowDialog } from "../config-flow/show-dialog-config-flow";
import {
loadConfigFlowDialog,
showConfigFlowDialog,
} from "../config-flow/show-dialog-config-flow";
import { DialogMixin } from "../dialog-mixin";
import { showAlertDialog } from "../generic/show-dialog-box";
import type { ESPHomeDeviceSetupDialogParams } from "./show-dialog-esphome-device-setup";
type SetupView =
@@ -89,9 +79,6 @@ const SERIAL_PORT_TYPE_LABELS: Record<ESPHomeSerialPortType, LocalizeKeys> = {
USB_SERIAL: "ui.panel.config.devices.esphome.setup_serial_port_usb_serial",
};
/** Add-on discovery on slow hardware can take longer than a few seconds. */
const MUSIC_ASSISTANT_DISCOVERY_TIMEOUT_MS = 60_000;
/** Same delay ha-entity-toggle uses to resync a switch the state did not follow. */
const SWITCH_RESYNC_MS = 2000;
@@ -112,21 +99,6 @@ const CAPABILITY_DESCRIPTION_KEYS: Record<ESPHomeCapabilityId, LocalizeKeys> = {
serial: "ui.panel.config.devices.esphome.setup_capability_serial_description",
};
const hassioMusicAssistantFlow = (
messages: ConfigFlowInProgressMessage[]
): DataEntryFlowProgress | undefined => {
for (const message of messages) {
if (
message.type !== "removed" &&
message.flow.handler === "music_assistant" &&
message.flow.context.source === "hassio"
) {
return message.flow;
}
}
return undefined;
};
@customElement("dialog-esphome-device-setup")
class DialogESPHomeDeviceSetup extends DialogMixin<ESPHomeDeviceSetupDialogParams>(
LitElement
@@ -208,14 +180,9 @@ class DialogESPHomeDeviceSetup extends DialogMixin<ESPHomeDeviceSetupDialogParam
private _loadStarted = false;
private _musicAssistantDiscovery?: {
unsub?: UnsubscribeFunc;
timer?: number;
resolve?: (flow: DataEntryFlowProgress | undefined) => void;
};
private _openingMusicAssistantFlow = false;
public disconnectedCallback() {
this._finishMusicAssistantDiscovery(undefined);
this.params?.dialogClosedCallback?.();
super.disconnectedCallback();
}
@@ -516,7 +483,7 @@ class DialogESPHomeDeviceSetup extends DialogMixin<ESPHomeDeviceSetupDialogParam
? html`
<ha-button
slot="primaryAction"
@click=${this._installMusicAssistant}
@click=${this._setupMusicAssistant}
>
${localize(
"ui.panel.config.devices.esphome.setup_install_music_assistant"
@@ -865,7 +832,6 @@ class DialogESPHomeDeviceSetup extends DialogMixin<ESPHomeDeviceSetupDialogParam
private _showChecklist = () => {
this._audioFlowId += 1;
this._finishMusicAssistantDiscovery(undefined);
this._view = "checklist";
this._installingAudio = false;
this._installStatus = undefined;
@@ -985,143 +951,32 @@ class DialogESPHomeDeviceSetup extends DialogMixin<ESPHomeDeviceSetupDialogParam
}
}
private _installMusicAssistant = async (ev: Event) => {
/** The integration's config flow installs and starts the Music Assistant app. */
private _setupMusicAssistant = async (ev: Event) => {
ev.stopPropagation();
if (!this._api || !this._i18n || !this._connection) {
if (this._openingMusicAssistantFlow) {
return;
}
const flowId = this._audioFlowId;
this._view = "audio-working";
this._installingAudio = true;
this._error = undefined;
this._installStatus = this._i18n.localize(
"ui.panel.config.devices.esphome.setup_installing_music_assistant"
);
// The flow dialog only covers this button once its chunk has loaded
this._openingMusicAssistantFlow = true;
try {
const { addons } = await fetchHassioAddonsInfo(this._api);
if (!this._audioFlowCurrent(flowId)) {
return;
}
const addon = addons.find(
(item) => item.slug === MUSIC_ASSISTANT_ADDON_SLUG
);
if (!addon) {
await installHassioAddon(this._api.callWS, MUSIC_ASSISTANT_ADDON_SLUG);
}
if (!this._audioFlowCurrent(flowId)) {
return;
}
if (!addon || addon.state !== "started") {
this._installStatus = this._i18n.localize(
"ui.panel.config.devices.esphome.setup_starting_music_assistant"
);
await startHassioAddon(this._api.callWS, MUSIC_ASSISTANT_ADDON_SLUG);
}
if (!this._audioFlowCurrent(flowId)) {
return;
}
this._installStatus = this._i18n.localize(
"ui.panel.config.devices.esphome.setup_discovering_music_assistant"
);
await this._openMusicAssistantFlow(flowId);
} catch (err: unknown) {
if (!this._audioFlowCurrent(flowId) || !this._i18n) {
return;
}
this._view = "audio-offer";
await showAlertDialog(this, {
title: this._i18n.localize(
"ui.panel.config.devices.esphome.setup_error_music_assistant"
),
text: extractApiErrorMessage(err),
});
await loadConfigFlowDialog();
} finally {
if (this._audioFlowCurrent(flowId)) {
this._installingAudio = false;
}
this._openingMusicAssistantFlow = false;
}
};
private async _openMusicAssistantFlow(flowId: number) {
if (!this._connection) {
return;
}
const flow = await this._waitForHassioMusicAssistantFlow();
if (!this._audioFlowCurrent(flowId)) {
return;
}
const continueSetup = ({ flowFinished }: { flowFinished: boolean }) => {
if (!this._audioFlowCurrent(flowId)) {
return;
}
if (!flowFinished) {
this._installingAudio = false;
this._installStatus = undefined;
this._view = "audio-offer";
return;
}
void this._continueAfterMusicAssistant(flowId);
};
if (flow) {
showConfigFlowDialog(this, {
continueFlowId: flow.flow_id,
dialogClosedCallback: continueSetup,
});
if (!this._audioFlowCurrent(flowId) || this._view !== "audio-offer") {
return;
}
showConfigFlowDialog(this, {
startFlowHandler: "music_assistant",
dialogClosedCallback: continueSetup,
});
}
private _finishMusicAssistantDiscovery(
flow: DataEntryFlowProgress | undefined
) {
const pending = this._musicAssistantDiscovery;
if (!pending) {
return;
}
this._musicAssistantDiscovery = undefined;
if (pending.timer !== undefined) {
window.clearTimeout(pending.timer);
}
pending.unsub?.();
pending.resolve?.(flow);
}
private _waitForHassioMusicAssistantFlow(): Promise<
DataEntryFlowProgress | undefined
> {
const connection = this._connection;
if (!connection) {
return Promise.resolve(undefined);
}
return new Promise((resolve) => {
const pending: NonNullable<typeof this._musicAssistantDiscovery> = {
resolve,
};
this._musicAssistantDiscovery = pending;
pending.timer = window.setTimeout(
() => this._finishMusicAssistantDiscovery(undefined),
MUSIC_ASSISTANT_DISCOVERY_TIMEOUT_MS
);
void subscribeConfigFlowInProgress(connection, (messages) => {
const flow = hassioMusicAssistantFlow(messages);
if (flow) {
this._finishMusicAssistantDiscovery(flow);
dialogClosedCallback: ({ entryId }) => {
if (entryId) {
void this._continueAfterMusicAssistant(flowId);
}
})
.then((unsub) => {
if (this._musicAssistantDiscovery !== pending) {
unsub();
return;
}
pending.unsub = unsub;
})
.catch(() => this._finishMusicAssistantDiscovery(undefined));
},
});
}
};
private async _refreshZWaveFlows() {
if (!this._connection) {
@@ -42,6 +42,8 @@ import type {
export class HaMoreInfoUpdateBackup extends LitElement {
@property({ attribute: false }) public stateObj?: UpdateEntity;
@property({ type: Boolean }) public disabled = false;
@state()
@consumeLocalize()
private _localize!: LocalizeFunc;
@@ -126,7 +128,7 @@ export class HaMoreInfoUpdateBackup extends LitElement {
slot="end"
.checked=${this._createBackup}
@change=${this._createBackupChanged}
.disabled=${updateIsInstalling(this.stateObj)}
.disabled=${this.disabled || updateIsInstalling(this.stateObj)}
></ha-switch>`
}
</ha-row-item>
@@ -1,18 +1,35 @@
import type { ContextType } from "@lit/context";
import type { HassConfig, UnsubscribeFunc } from "home-assistant-js-websocket";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import { isComponentLoaded } from "../../../common/config/is_component_loaded";
import { consume } from "../../../common/decorators/consume";
import { BINARY_STATE_OFF } from "../../../common/const";
import { consumeLocalize } from "../../../common/decorators/consume-context-entry";
import {
consumeEntityRegistryEntry,
consumeLocalize,
} from "../../../common/decorators/consume-context-entry";
import { transform } from "../../../common/decorators/transform";
import { supportsFeature } from "../../../common/entity/supports-feature";
import type { LocalizeFunc } from "../../../common/translations/localize";
import { sanitizeHttpUrl } from "../../../common/url/sanitize-http-url";
import { closestWithProperty } from "../../../common/dom/ancestors-with-property";
import "../../../components/ha-alert";
import "../../../components/ha-button";
import "../../../components/ha-markdown";
import "../../../components/ha-spinner";
import "../../../components/progress/ha-progress-bar";
import { apiContext, formattersContext } from "../../../data/context";
import type { ManagerState } from "../../../data/backup_manager";
import { subscribeBackupEvents } from "../../../data/backup_manager";
import {
apiContext,
configContext,
connectionContext,
formattersContext,
internationalizationContext,
} from "../../../data/context";
import { UNAVAILABLE, UNKNOWN } from "../../../data/entity/entity";
import type { EntityRegistryDisplayEntry } from "../../../data/entity/entity_registry";
import type { UpdateEntity } from "../../../data/update";
import {
latestVersionIsSkipped,
@@ -21,11 +38,19 @@ import {
updateIsInstalling,
updateReleaseNotes,
} from "../../../data/update";
import type { HomeAssistantApi, HomeAssistantFormatters } from "../../../types";
import type {
HomeAssistantApi,
HomeAssistantConfig,
HomeAssistantFormatters,
} from "../../../types";
import { showAlertDialog } from "../../generic/show-dialog-box";
import { showToast } from "../../../util/toast";
import "../components/update/ha-more-info-update-backup";
import type { HaMoreInfoUpdateBackup } from "../components/update/ha-more-info-update-backup";
// Dialogs that reflect their open state to the `open` attribute.
const DIALOG_ELEMENTS = new Set(["ha-adaptive-dialog", "ha-dialog"]);
@customElement("more-info-update")
class MoreInfoUpdate extends LitElement {
@property({ attribute: false }) public stateObj?: UpdateEntity;
@@ -41,15 +66,102 @@ class MoreInfoUpdate extends LitElement {
@consume({ context: apiContext, subscribe: true })
private _api!: HomeAssistantApi;
@consume({ context: internationalizationContext, subscribe: true })
private _i18n!: ContextType<typeof internationalizationContext>;
@state()
@consumeEntityRegistryEntry({ entityIdPath: ["stateObj", "entity_id"] })
private _entityEntry?: EntityRegistryDisplayEntry;
@consume({ context: connectionContext, subscribe: true })
private _connection!: ContextType<typeof connectionContext>;
@consume({ context: configContext, subscribe: true })
@transform<HomeAssistantConfig, HassConfig>({
transformer: ({ config }) => config,
})
private _config!: HassConfig;
@state() private _releaseNotes?: string | null;
@state() private _error?: string;
@state() private _installError?: string;
@state() private _backupState: ManagerState = "idle";
@state() private _markdownLoading = true;
@query("ha-more-info-update-backup")
private _backupElement?: HaMoreInfoUpdateBackup;
private _backupEventsUnsub?: Promise<UnsubscribeFunc>;
public connectedCallback(): void {
super.connectedCallback();
this._subscribeBackupEvents();
}
public disconnectedCallback(): void {
super.disconnectedCallback();
this._backupEventsUnsub?.then(
(unsub) => unsub(),
() => undefined
);
this._backupEventsUnsub = undefined;
this._backupState = "idle";
}
private _subscribeBackupEvents(): void {
if (
this._backupEventsUnsub ||
!isComponentLoaded(this._config, "backup") ||
!isComponentLoaded(this._config, "hassio")
) {
return;
}
const backupEventsUnsub = subscribeBackupEvents(
this._connection,
(event) => {
this._backupState = event.manager_state;
}
);
backupEventsUnsub.catch(() => {
// The update can still be attempted; a failure is shown inline.
if (this._backupEventsUnsub === backupEventsUnsub) {
this._backupEventsUnsub = undefined;
}
});
this._backupEventsUnsub = backupEventsUnsub;
}
private _backupInProgressMessage(): string | undefined {
// The Supervisor only holds back the updates it manages.
if (this._entityEntry?.platform !== "hassio") {
return undefined;
}
switch (this._backupState) {
case "create_backup":
case "receive_backup":
case "restore_backup":
return this._localize(
`ui.dialogs.more_info_control.update.backup_in_progress.${this._backupState}`,
{
backups_link: html`<a href="/config/backup"
>${this._localize(
"ui.dialogs.more_info_control.update.backup_in_progress.backups_link"
)}</a
>`,
}
);
default:
return undefined;
}
}
protected render() {
if (
!this._localize ||
@@ -61,6 +173,7 @@ class MoreInfoUpdate extends LitElement {
}
const releaseUrl = sanitizeHttpUrl(this.stateObj.attributes.release_url);
const backupInProgressMessage = this._backupInProgressMessage();
return html`
<div class="content">
@@ -160,7 +273,22 @@ class MoreInfoUpdate extends LitElement {
<div class="footer">
<ha-more-info-update-backup
.stateObj=${this.stateObj}
.disabled=${!!backupInProgressMessage}
></ha-more-info-update-backup>
${
backupInProgressMessage
? html`<ha-alert alert-type="info">
${backupInProgressMessage}
</ha-alert>`
: this._installError
? html`<ha-alert alert-type="error">
${this._localize(
"ui.dialogs.more_info_control.update.install_failed",
{ error: this._installError }
)}
</ha-alert>`
: nothing
}
<div class="actions">
${
this.stateObj.state === BINARY_STATE_OFF &&
@@ -195,7 +323,10 @@ class MoreInfoUpdate extends LitElement {
<ha-button
@click=${this._handleInstall}
.loading=${updateIsInstalling(this.stateObj)}
.disabled=${updateButtonIsDisabled(this.stateObj)}
.disabled=${
updateButtonIsDisabled(this.stateObj) ||
!!backupInProgressMessage
}
>
${this._localize(
"ui.dialogs.more_info_control.update.update"
@@ -240,7 +371,7 @@ class MoreInfoUpdate extends LitElement {
}
}
private _handleInstall(): void {
private async _handleInstall(): Promise<void> {
const installData: Record<string, any> = {
entity_id: this.stateObj!.entity_id,
};
@@ -256,7 +387,77 @@ class MoreInfoUpdate extends LitElement {
installData.version = this.stateObj!.attributes.latest_version;
}
this._api.callService("update", "install", installData);
this._installError = undefined;
try {
await this._api.callService(
"update",
"install",
installData,
undefined,
false
);
} catch (err: any) {
const message = await this._localizeInstallError(err);
if (this._isShown()) {
this._installError = message;
return;
}
// The dialog was closed, or is closing, while the update was installing.
const toastHost = document.querySelector("home-assistant");
if (toastHost) {
showToast(toastHost, {
message: this._localize(
"ui.dialogs.more_info_control.update.install_failed",
{ error: message }
),
duration: 10000,
});
}
}
}
private _isShown(): boolean {
if (!this.isConnected) {
return false;
}
// The dialog keeps its content mounted while its close animation runs.
let element = closestWithProperty(this, "open", false);
while (element) {
if (DIALOG_ELEMENTS.has(element.localName)) {
return element.hasAttribute("open");
}
element = closestWithProperty(element, "open", false);
}
return true;
}
private async _localizeInstallError(err: any): Promise<string> {
if (err?.translation_domain && err?.translation_key) {
const localize = await this._i18n.loadBackendTranslation(
"exceptions",
err.translation_domain
);
const localizedMessage = localize(
`component.${err.translation_domain}.exceptions.${err.translation_key}.message`,
err.translation_placeholders
);
if (localizedMessage) {
return localizedMessage;
}
}
return err?.message ?? String(err);
}
private _handleSkip(): void {
@@ -329,6 +530,13 @@ class MoreInfoUpdate extends LitElement {
z-index: 10;
}
.footer ha-alert {
display: block;
width: 100%;
box-sizing: border-box;
padding: var(--ha-space-2) var(--ha-space-4) 0;
}
.actions {
width: 100%;
display: flex;
+1 -6
View File
@@ -811,12 +811,7 @@ export class MoreInfoDialog extends DirtyStateProviderMixin<
}
></ha-svg-icon>
${this.hass.localize(
"ui.dialogs.more_info_control.device_or_service_info",
{
type: this.hass.localize(
`ui.dialogs.more_info_control.device_type.${deviceType}`
),
}
`ui.dialogs.more_info_control.${deviceType}_info`
)}
</ha-dropdown-item>
`
@@ -179,8 +179,11 @@ export const showRepairsFlowDialog = (
);
},
renderShowFormStepFieldLocalizeValue(hass, _step, key) {
return hass.localize(`component.${issue.domain}.selector.${key}`);
renderShowFormStepFieldLocalizeValue(hass, _step, key, values) {
return hass.localize(
`component.${issue.domain}.selector.${key}`,
values
);
},
renderShowFormStepSubmitButton(hass, step) {
+1 -1
View File
@@ -396,7 +396,7 @@ export class HassTabsSubpage extends LitElement {
}
:host(:not([narrow])) #toolbar-icon {
min-width: 40px;
min-width: 48px;
}
ha-menu-button,
+5 -1
View File
@@ -241,6 +241,10 @@ class HaPanelApp extends LitElement {
return;
}
if (this._getAddonSlug() !== addonSlug || !this.isConnected) {
return;
}
if (!addon.version) {
await this._showErrorAndNavigateHome(
addon.name,
@@ -324,7 +328,7 @@ class HaPanelApp extends LitElement {
}
// Check if user navigated away while we were fetching
if (this._getAddonSlug() !== addonSlug) {
if (this._getAddonSlug() !== addonSlug || !this.isConnected) {
return;
}
@@ -38,7 +38,6 @@ class SupervisorAppLogDashboard extends LitElement {
</div>
<div class="content">
<error-log-card
.hass=${this.hass}
.header=${this.addon.name}
.provider=${this.addon.slug}
.filter=${this._filter}
@@ -316,6 +316,12 @@ export class HaAutomationAddItems extends LitElement {
white-space: nowrap;
}
.selected-target state-badge {
width: 24px;
height: 24px;
flex-shrink: 0;
}
.selected-target ha-icon,
.selected-target ha-svg-icon,
.selected-target ha-domain-icon {
@@ -458,12 +458,16 @@ export class HaPlatformCondition extends LitElement {
this.requestUpdate("_checkedKeys");
}
private _localizeValueCallback = (key: string) => {
private _localizeValueCallback = (
key: string,
values?: Record<string, string>
) => {
if (!this.condition?.condition) {
return "";
}
return this.hass.localize(
`component.${getConditionDomain(this.condition.condition)}.selector.${key}`
`component.${getConditionDomain(this.condition.condition)}.selector.${key}`,
values
);
};
@@ -4,6 +4,7 @@ import "../../../../components/ha-domain-icon";
import "../../../../components/ha-floor-icon";
import "../../../../components/ha-icon";
import "../../../../components/ha-state-icon";
import "../../../../components/entity/state-badge";
import "../../../../components/ha-svg-icon";
import type { ConfigEntry } from "../../../../data/config_entries";
import type { LabelRegistryEntry } from "../../../../data/label/label_registry";
@@ -57,8 +58,20 @@ export const getTargetIcon = (
}
if (targetType === "entity" && states[targetId]) {
const stateObj = states[targetId];
const hasEntityPicture =
stateObj.attributes.entity_picture ||
stateObj.attributes.entity_picture_local;
if (hasEntityPicture) {
return html`<state-badge
.stateObj=${stateObj}
.slot=${slot}
></state-badge>`;
}
return html`<ha-state-icon
.stateObj=${states[targetId]}
.stateObj=${stateObj}
.slot=${slot}
></ha-state-icon>`;
}
@@ -30,6 +30,7 @@ import "../../../../components/ha-dropdown";
import type { HaDropdownSelectEvent } from "../../../../components/ha-dropdown";
import "../../../../components/ha-dropdown-item";
import "../../../../components/ha-svg-icon";
import "../../../../components/entity/state-badge";
import { showTargetDetailsDialog } from "../../../../components/target-picker/dialog/show-dialog-target-details";
import type { ConfigEntry } from "../../../../data/config_entries";
import {
@@ -680,6 +681,12 @@ export class HaAutomationRowTargets extends LitElement {
padding: var(--ha-space-1) 0;
}
.target state-badge,
ha-dropdown-item state-badge {
width: 24px;
height: 24px;
flex-shrink: 0;
}
.target ha-floor-icon {
display: flex;
height: 32px;
@@ -689,18 +696,25 @@ export class HaAutomationRowTargets extends LitElement {
:host([size="s"]) {
min-height: 24px;
}
:host([size="s"]) .target {
height: 24px;
}
/* A default 24px icon would fill the whole small chip. */
:host([size="s"]) .target ha-floor-icon {
height: 24px;
}
:host([size="s"]) .target ha-icon,
:host([size="s"]) .target ha-svg-icon,
:host([size="s"]) .target ha-domain-icon,
:host([size="s"]) .target ha-floor-icon {
--mdc-icon-size: 16px;
}
:host([size="s"]) .target ha-floor-icon {
height: 24px;
:host([size="s"]) .target state-badge {
width: 16px;
height: 16px;
--mdc-icon-size: 16px;
}
button.target {
@@ -1,7 +1,8 @@
import "@home-assistant/webawesome/dist/components/divider/divider";
import { mdiClose, mdiPlus } from "@mdi/js";
import type { PropertyValues } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, query } from "lit/decorators";
import { customElement, property, query, queryAll } from "lit/decorators";
import { ensureArray } from "../../../../../common/array/ensure-array";
import { fireEvent } from "../../../../../common/dom/fire_event";
import "../../../../../components/ha-icon-button";
@@ -27,10 +28,25 @@ export class HaConversationTrigger
@query("#option_input", true) private _optionInput?: HaInput;
@queryAll("ha-input.option") private _options!: NodeListOf<HaInput>;
public static get defaultConfig(): ConversationTrigger {
return { trigger: "conversation", command: "" };
}
protected updated(changedProps: PropertyValues<this>) {
super.updated(changedProps);
if (changedProps.has("trigger")) {
this._validateOptions();
}
}
private async _validateOptions() {
const options = [...this._options];
await Promise.all(options.map((option) => option.updateComplete));
options.forEach((option) => option.reportValidity());
}
protected render() {
const { command } = this.trigger;
const commands = command ? ensureArray(command) : [];
@@ -41,14 +57,12 @@ export class HaConversationTrigger
(option, index) => html`
<ha-input
class="option"
iconTrailing
.index=${index}
.value=${option}
.validationMessage=${this.hass.localize(
"ui.panel.config.automation.editor.triggers.type.conversation.no_punctuation"
)}
auto-validate
validateOnInitialRender
pattern=${PATTERN}
@change=${this._updateOption}
>
@@ -448,12 +448,16 @@ export class HaPlatformTrigger extends LitElement {
this.requestUpdate("_checkedKeys");
}
private _localizeValueCallback = (key: string) => {
private _localizeValueCallback = (
key: string,
values?: Record<string, string>
) => {
if (!this.trigger?.trigger) {
return "";
}
return this.hass.localize(
`component.${getTriggerDomain(this.trigger.trigger)}.selector.${key}`
`component.${getTriggerDomain(this.trigger.trigger)}.selector.${key}`,
values
);
};
@@ -131,7 +131,7 @@ class HaBackupConfigData extends LitElement {
}
private _hasLocalAddons(addons: BackupAddonItem[]): boolean {
return addons.some((addon) => addon.slug === "local");
return addons.some((addon) => addon.slug.startsWith("local_"));
}
private _estimateBackupSize = memoizeOne(
@@ -29,6 +29,7 @@ interface ProgressSegment {
label: string;
state: SegmentState;
flex: number;
progress?: number;
}
const HA_STAGES: CreateBackupStage[] = ["home_assistant"];
@@ -139,12 +140,29 @@ export class HaBackupOverviewProgress extends LitElement {
private _computeAgentPercent(agentId: string): number | undefined {
const progress = this.uploadProgress[agentId];
if (!progress || progress.total_bytes === 0) {
// 0 bytes only means the upload started; the agent may never report more.
if (
!progress ||
progress.total_bytes === 0 ||
progress.uploaded_bytes === 0
) {
return undefined;
}
return Math.round((progress.uploaded_bytes / progress.total_bytes) * 100);
}
private _computeUploadPercent(): number | undefined {
// Follow the slowest location still uploading. Finished ones are left out:
// already-written locations report 100% at once, and a location that
// doesn't report progress is only known once it has finished.
const percents = Object.keys(this.uploadProgress)
.map((agentId) => this._computeAgentPercent(agentId))
.filter(
(percent): percent is number => percent !== undefined && percent < 100
);
return percents.length ? Math.min(...percents) : undefined;
}
private _getStageGroupIndex(stage: CreateBackupStage): number {
return STAGE_ORDER.findIndex((group) => group.includes(stage));
}
@@ -169,6 +187,9 @@ export class HaBackupOverviewProgress extends LitElement {
: null;
const currentGroupIndex = stage ? this._getStageGroupIndex(stage) : -1;
const uploadProgress = this._isUploadStage
? this._computeUploadPercent()
: undefined;
const isHassio = isComponentLoaded(this.hass.config, "hassio");
if (isHassio) {
@@ -201,6 +222,7 @@ export class HaBackupOverviewProgress extends LitElement {
),
state: this._getSegmentState(3, currentGroupIndex),
flex: 5,
progress: uploadProgress,
},
{
label: this.hass.localize(
@@ -234,6 +256,7 @@ export class HaBackupOverviewProgress extends LitElement {
),
state: this._getSegmentState(3, currentGroupIndex),
flex: 5,
progress: uploadProgress,
},
{
label: this.hass.localize(
@@ -312,8 +335,18 @@ export class HaBackupOverviewProgress extends LitElement {
active: segment.state === "active",
completed: segment.state === "completed",
pending: segment.state === "pending",
determinate: segment.progress !== undefined,
})}"
></div>
>
${
segment.progress !== undefined
? html`<div
class="segment-fill"
style="width: ${segment.progress}%"
></div>`
: nothing
}
</div>
<span class="segment-label">${segment.label}</span>
</div>
`
@@ -330,9 +363,12 @@ export class HaBackupOverviewProgress extends LitElement {
return nothing;
}
const hasProgress = Object.keys(this.uploadProgress).length > 0;
// Only upload events tell which locations this backup goes to.
const agents = this.agents.filter(
(agent) => agent.agent_id in this.uploadProgress
);
if (!hasProgress) {
if (agents.length === 0) {
return nothing;
}
@@ -344,7 +380,7 @@ export class HaBackupOverviewProgress extends LitElement {
}
>
<ha-list-base class="agent-list">
${this.agents.map((agent) => {
${agents.map((agent) => {
const name = computeBackupAgentName(
this.hass.localize,
agent.agent_id,
@@ -462,6 +498,18 @@ export class HaBackupOverviewProgress extends LitElement {
.segment-bar.completed {
background-color: var(--primary-color);
}
.segment-bar.determinate {
background-color: var(--divider-color);
animation: none;
overflow: hidden;
}
.segment-fill {
height: 100%;
border-radius: inherit;
background-color: var(--primary-color);
transition: width 0.3s ease;
animation: pulse 1.5s ease-in-out infinite;
}
.segment-label {
font-size: var(--ha-font-size-xs);
color: var(--secondary-text-color);
@@ -482,6 +530,10 @@ export class HaBackupOverviewProgress extends LitElement {
.segment-bar.active {
animation: none;
}
.segment-fill {
animation: none;
transition: none;
}
}
.agent-list-wrapper {
display: grid;
@@ -10,7 +10,7 @@ import {
} from "@mdi/js";
import type { CSSResultGroup, PropertyValues, TemplateResult } from "lit";
import { LitElement, html } from "lit";
import { customElement, property, state } from "lit/decorators";
import { customElement, property, query, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import { storage } from "../../../common/decorators/storage";
import type {
@@ -21,9 +21,11 @@ import { fireEvent } from "../../../common/dom/fire_event";
import { navigate } from "../../../common/navigate";
import type { LocalizeFunc } from "../../../common/translations/localize";
import { extractSearchParam } from "../../../common/url/search-params";
import { hasRejectedItems } from "../../../common/util/promise-all-settled-results";
import type {
DataTableColumnContainer,
RowClickedEvent,
SelectionChangedEvent,
SortingChangedEvent,
} from "../../../components/data-table/ha-data-table";
import "../../../components/ha-button";
@@ -49,6 +51,7 @@ import {
showConfirmationDialog,
} from "../../../dialogs/generic/show-dialog-box";
import "../../../layouts/hass-tabs-subpage-data-table";
import type { HaTabsSubpageDataTable } from "../../../layouts/hass-tabs-subpage-data-table";
import { haStyle } from "../../../resources/styles";
import type { HomeAssistant, Route } from "../../../types";
import { documentationUrl } from "../../../util/documentation-url";
@@ -134,16 +137,30 @@ class HaBlueprintOverview extends LitElement {
private _usageCountRequest = 0;
@state() private _usageCountsLoading = false;
@state() private _selected: string[] = [];
@state() private _deletedFullpaths: string[] = [];
@query("hass-tabs-subpage-data-table", true)
private _dataTable!: HaTabsSubpageDataTable;
private _processedBlueprints = memoizeOne(
(
blueprints: Record<string, Blueprints>,
localize: LocalizeFunc,
usageCounts: Record<string, number>
usageCounts: Record<string, number>,
deleted: string[]
): BlueprintMetaDataPath[] => {
const deletedFullpaths = new Set(deleted);
const result: any[] = [];
Object.entries(blueprints).forEach(([type, typeBlueprints]) =>
Object.entries(typeBlueprints).forEach(([path, blueprint]) => {
const fullpath = `${type}/${path}`;
if (deletedFullpaths.has(fullpath)) {
return;
}
if ("error" in blueprint) {
result.push({
name: blueprint.error,
@@ -314,6 +331,13 @@ class HaBlueprintOverview extends LitElement {
}
}
protected willUpdate(changedProps: PropertyValues<this>) {
super.willUpdate(changedProps);
if (changedProps.has("blueprints") && this._deletedFullpaths.length) {
this._deletedFullpaths = [];
}
}
protected updated(changedProps: PropertyValues<this>) {
super.updated(changedProps);
if (changedProps.has("blueprints")) {
@@ -333,7 +357,8 @@ class HaBlueprintOverview extends LitElement {
.data=${this._processedBlueprints(
this.blueprints,
this.hass.localize,
this._usageCounts
this._usageCounts,
this._deletedFullpaths
)}
id="fullpath"
.noDataText=${this.hass.localize(
@@ -342,6 +367,9 @@ class HaBlueprintOverview extends LitElement {
has-fab
clickable
@row-click=${this._handleRowClicked}
selectable
.selected=${this._selected.length}
@selection-changed=${this._handleSelectionChanged}
.appendRow=${html`<div
class="mdc-data-table__cell"
style="width: 100%; text-align: center;"
@@ -378,6 +406,14 @@ class HaBlueprintOverview extends LitElement {
.path=${mdiHelpCircleOutline}
@click=${this._showHelp}
></ha-icon-button>
<ha-assist-chip
slot="selection-bar"
.label=${this.hass.localize(
"ui.panel.config.blueprint.overview.delete_selected"
)}
.disabled=${this._usageCountsLoading || !this._selected.length}
@click=${this._removeSelected}
></ha-assist-chip>
<ha-button slot="fab" size="l" @click=${this._addBlueprintClicked}>
<ha-svg-icon slot="start" .path=${mdiDownload}></ha-svg-icon>
${this.hass.localize(
@@ -432,12 +468,14 @@ class HaBlueprintOverview extends LitElement {
}
const request = ++this._usageCountRequest;
this._usageCountsLoading = true;
const usageCounts: Record<string, number> = {};
const blueprintList = this._processedBlueprints(
this.blueprints,
this.hass.localize,
{}
{},
this._deletedFullpaths
);
await Promise.all(
@@ -463,6 +501,7 @@ class HaBlueprintOverview extends LitElement {
if (request === this._usageCountRequest) {
this._usageCounts = usageCounts;
this._usageCountsLoading = false;
}
}
@@ -470,7 +509,8 @@ class HaBlueprintOverview extends LitElement {
const blueprint = this._processedBlueprints(
this.blueprints,
this.hass.localize,
this._usageCounts
this._usageCounts,
this._deletedFullpaths
).find((b) => b.fullpath === ev.detail.id)!;
if (blueprint.error) {
showAlertDialog(this, {
@@ -495,7 +535,8 @@ class HaBlueprintOverview extends LitElement {
const blueprint = this._processedBlueprints(
this.blueprints,
this.hass.localize,
this._usageCounts
this._usageCounts,
this._deletedFullpaths
).find((item) => item.fullpath === fullpath);
if (!blueprint || blueprint.error) {
return;
@@ -652,6 +693,102 @@ class HaBlueprintOverview extends LitElement {
fireEvent(this, "reload-blueprints");
};
private _handleSelectionChanged = (
ev: HASSDomEvent<SelectionChangedEvent>
): void => {
this._selected = ev.detail.value;
};
private _removeSelected = async () => {
const rows = this._processedBlueprints(
this.blueprints,
this.hass.localize,
this._usageCounts,
this._deletedFullpaths
);
const selectedRows = rows.filter((row) =>
this._selected.includes(row.fullpath)
);
const usageChecks = await Promise.allSettled(
selectedRows.map((row) =>
findRelated(this.hass, `${row.type}_blueprint`, row.path)
)
);
if (usageChecks.some((check) => check.status === "rejected")) {
showAlertDialog(this, {
title: this.hass.localize(
"ui.panel.config.blueprint.overview.delete_check_failed_title"
),
text: this.hass.localize(
"ui.panel.config.blueprint.overview.delete_check_failed"
),
});
return;
}
const deletable = selectedRows.filter((_row, index) => {
const check = usageChecks[index];
return (
check.status === "fulfilled" &&
!check.value.automation?.length &&
!check.value.script?.length
);
});
if (!deletable.length) {
showAlertDialog(this, {
title: this.hass.localize(
"ui.panel.config.blueprint.overview.delete_none_deletable_title"
),
text: this.hass.localize(
"ui.panel.config.blueprint.overview.delete_none_deletable"
),
});
return;
}
const allDeletable = deletable.length === selectedRows.length;
let deleteFailed = false;
showConfirmationDialog(this, {
title: this.hass.localize(
"ui.panel.config.blueprint.overview.confirm_delete_selected_title"
),
text: this.hass.localize(
allDeletable
? "ui.panel.config.blueprint.overview.confirm_delete_selected"
: "ui.panel.config.blueprint.overview.confirm_delete_selected_partly",
allDeletable
? { count: deletable.length }
: { deletable: deletable.length, selected: selectedRows.length }
),
confirmText: this.hass.localize("ui.common.delete"),
dismissText: this.hass.localize("ui.common.cancel"),
destructive: true,
action: async () => {
const results = await Promise.allSettled(
deletable.map((row) => deleteBlueprint(this.hass, row.type, row.path))
);
const deleted = new Set(
deletable
.filter((_row, index) => results[index].status === "fulfilled")
.map((row) => row.fullpath)
);
this._deletedFullpaths = [...this._deletedFullpaths, ...deleted];
deleteFailed = hasRejectedItems(results);
this._dataTable.clearSelection();
fireEvent(this, "reload-blueprints");
},
}).then((confirmed) => {
if (confirmed && deleteFailed) {
showAlertDialog(this, {
title: this.hass.localize(
"ui.panel.config.blueprint.overview.delete_failed_title"
),
text: this.hass.localize(
"ui.panel.config.blueprint.overview.delete_failed"
),
});
}
});
};
private _handleSortingChanged(ev: CustomEvent) {
this._activeSorting = ev.detail;
}
+10
View File
@@ -41,6 +41,7 @@ import {
mdiZWave,
} from "@mdi/js";
import memoizeOne from "memoize-one";
import { haLora } from "../../resources/ha-icons";
import type { PageNavigation } from "../../layouts/hass-tabs-subpage";
import type { HomeAssistant } from "../../types";
import { mdiMqttLogo } from "../../resources/mqtt-logo-svg";
@@ -167,6 +168,15 @@ export const configSections: Record<string, PageNavigation[]> = {
translationKey: "knx",
adminOnly: true,
},
{
path: "/config/lorawan",
iconPath: haLora,
iconViewBox: "0 0 250 166",
iconColor: "#009fe3",
component: "lorawan",
translationKey: "lorawan",
adminOnly: true,
},
{
path: "/config/mqtt",
iconPath: mdiMqttLogo,
@@ -5,7 +5,10 @@ import {
mdiLocationExit,
mdiRefresh,
} from "@mdi/js";
import type { HassEntities } from "home-assistant-js-websocket";
import type {
HassEntities,
UnsubscribeFunc,
} from "home-assistant-js-websocket";
import type { TemplateResult, PropertyValues } from "lit";
import { LitElement, css, html, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
@@ -13,11 +16,16 @@ import memoizeOne from "memoize-one";
import { isComponentLoaded } from "../../../common/config/is_component_loaded";
import { supportsFeature } from "../../../common/entity/supports-feature";
import { caseInsensitiveStringCompare } from "../../../common/string/compare";
import "../../../components/animation/ha-fade-in";
import "../../../components/ha-alert";
import "../../../components/ha-button";
import "../../../components/ha-card";
import "../../../components/ha-dropdown";
import type { HaDropdownSelectEvent } from "../../../components/ha-dropdown";
import "../../../components/ha-dropdown-item";
import "../../../components/ha-spinner";
import type { ManagerState } from "../../../data/backup_manager";
import { subscribeBackupEvents } from "../../../data/backup_manager";
import type { EntitySources } from "../../../data/entity/entity_sources";
import { fetchEntitySourcesWithCache } from "../../../data/entity/entity_sources";
import { extractApiErrorMessage } from "../../../data/hassio/common";
@@ -44,6 +52,7 @@ import {
} from "../../../data/update";
import { showAlertDialog } from "../../../dialogs/generic/show-dialog-box";
import "../../../layouts/hass-subpage";
import { SubscribeMixin } from "../../../mixins/subscribe-mixin";
import type { HomeAssistant } from "../../../types";
import { showToast } from "../../../util/toast";
import "../dashboard/ha-config-updates";
@@ -60,13 +69,15 @@ const SYSTEM_KEY = "__system__";
const APPS_KEY = "__apps__";
@customElement("ha-config-section-updates")
class HaConfigSectionUpdates extends LitElement {
class HaConfigSectionUpdates extends SubscribeMixin(LitElement) {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ type: Boolean }) public narrow = false;
@state() private _showSkipped = false;
@state() private _backupState?: ManagerState;
@state() private _supervisorInfo?: HassioSupervisorInfo;
@state() private _entitySources?: EntitySources;
@@ -129,6 +140,62 @@ class HaConfigSectionUpdates extends LitElement {
this.requestUpdate();
}
protected hassSubscribe(): Promise<UnsubscribeFunc>[] {
if (!this._watchesBackups()) {
return [];
}
return [
subscribeBackupEvents(this.hass, (event) => {
this._backupState = event.manager_state;
}).catch(() => {
// Show the page anyway; a blocked update reports its own error.
this._backupState = "idle";
return () => undefined;
}),
];
}
// The Supervisor only holds back the updates it manages.
private _watchesBackups(): boolean {
return (
isComponentLoaded(this.hass.config, "backup") &&
isComponentLoaded(this.hass.config, "hassio")
);
}
private _backupStateLoading(): boolean {
return this._backupState === undefined && this._watchesBackups();
}
private _backupInProgressAlert() {
switch (this._backupState) {
case "create_backup":
case "receive_backup":
case "restore_backup":
return html`<ha-alert
alert-type="info"
.title=${this.hass.localize(
`ui.panel.config.updates.backup_in_progress.title.${this._backupState}`
)}
>
${this.hass.localize(
`ui.panel.config.updates.backup_in_progress.${this._backupState}`,
{
backups_link: html`<a href="/config/backup"
>${this.hass.localize(
"ui.panel.config.updates.backup_in_progress.backups_link"
)}</a
>`,
}
)}
</ha-alert>`;
default:
return undefined;
}
}
protected render(): TemplateResult {
const installableUpdates = this._filterInstallableUpdateEntities(
this.hass.states,
@@ -153,6 +220,8 @@ class HaConfigSectionUpdates extends LitElement {
this.hass.locale.language
);
const backupInProgressAlert = this._backupInProgressAlert();
return html`
<hass-subpage
back-path="/config/system"
@@ -207,108 +276,143 @@ class HaConfigSectionUpdates extends LitElement {
}
</ha-dropdown>
</div>
<div class="content">
${groups.map(
(group) => html`
<ha-card outlined>
<div class="card-content">
<div class="card-header">
<div class="title" role="heading" aria-level="2">
${group.title}
</div>
${
group.showUpdateButton
? html`
<ha-button
appearance="plain"
size="s"
.group=${group}
.disabled=${group.entities.every((entity) =>
updateIsInstalling(entity)
)}
@click=${this._updateGroup}
>
${
this._backupStateLoading()
? html`
<ha-fade-in class="loading" .delay=${1000}>
<ha-spinner></ha-spinner>
</ha-fade-in>
`
: html`
<div class="content">
${
backupInProgressAlert &&
groups.some(
(group) =>
group.key === APPS_KEY || group.key === SYSTEM_KEY
)
? backupInProgressAlert
: nothing
}
${groups.map(
(group) => html`
<ha-card outlined>
<div class="card-content">
<div class="card-header">
<div class="title" role="heading" aria-level="2">
${group.title}
</div>
${
group.showUpdateButton
? html`
<ha-button
appearance="plain"
size="s"
.group=${group}
.disabled=${
(!!backupInProgressAlert &&
group.key === APPS_KEY) ||
group.entities.every((entity) =>
updateIsInstalling(entity)
)
}
@click=${this._updateGroup}
>
${this.hass.localize(
group.entities.length > 1
? "ui.panel.config.updates.update_all"
: "ui.common.update"
)}
</ha-button>
`
: nothing
}
</div>
<ha-config-updates
.narrow=${this.narrow}
.updateEntities=${group.entities}
showAll
></ha-config-updates>
</div>
</ha-card>
`
)}
${
skippedUpdates.length
? html`
<ha-card outlined>
<div class="card-content">
<div class="card-header">
<div
class="title"
role="heading"
aria-level="2"
>
${this.hass.localize(
"ui.panel.config.updates.title_skipped",
{
count: skippedUpdates.length,
}
)}
</div>
</div>
<ha-config-updates
.narrow=${this.narrow}
.updateEntities=${skippedUpdates}
showAll
></ha-config-updates>
</div>
</ha-card>
`
: nothing
}
${
notInstallableUpdates.length
? html`
<ha-card outlined>
<div class="card-content">
<div class="card-header">
<div
class="title"
role="heading"
aria-level="2"
>
${this.hass.localize(
"ui.panel.config.updates.title_not_installable",
{
count: notInstallableUpdates.length,
}
)}
</div>
</div>
<ha-config-updates
.narrow=${this.narrow}
.updateEntities=${notInstallableUpdates}
showAll
></ha-config-updates>
</div>
</ha-card>
`
: nothing
}
${
groups.length +
notInstallableUpdates.length +
skippedUpdates.length
? nothing
: html`
<ha-card outlined>
<div class="no-updates">
${this.hass.localize(
group.entities.length > 1
? "ui.panel.config.updates.update_all"
: "ui.common.update"
"ui.panel.config.updates.no_updates"
)}
</ha-button>
`
: nothing
}
</div>
<ha-config-updates
.narrow=${this.narrow}
.updateEntities=${group.entities}
showAll
></ha-config-updates>
</div>
</ha-card>
`
}
</div>
</ha-card>
`
)}
${
skippedUpdates.length
? html`
<ha-card outlined>
<div class="card-content">
<div class="card-header">
<div class="title" role="heading" aria-level="2">
${this.hass.localize(
"ui.panel.config.updates.title_skipped",
{
count: skippedUpdates.length,
}
)}
</div>
</div>
<ha-config-updates
.narrow=${this.narrow}
.updateEntities=${skippedUpdates}
showAll
></ha-config-updates>
</div>
</ha-card>
`
: nothing
}
${
notInstallableUpdates.length
? html`
<ha-card outlined>
<div class="card-content">
<div class="card-header">
<div class="title" role="heading" aria-level="2">
${this.hass.localize(
"ui.panel.config.updates.title_not_installable",
{
count: notInstallableUpdates.length,
}
)}
</div>
</div>
<ha-config-updates
.narrow=${this.narrow}
.updateEntities=${notInstallableUpdates}
showAll
></ha-config-updates>
</div>
</ha-card>
`
: nothing
}
${
groups.length + notInstallableUpdates.length + skippedUpdates.length
? nothing
: html`
<ha-card outlined>
<div class="no-updates">
${this.hass.localize("ui.panel.config.updates.no_updates")}
</div>
</ha-card>
`
}
</div>
`
}
</hass-subpage>
`;
}
@@ -516,6 +620,16 @@ class HaConfigSectionUpdates extends LitElement {
ha-config-updates {
margin-bottom: 8px;
}
ha-alert {
display: block;
max-width: 600px;
margin: 0 auto var(--ha-space-6);
}
.loading {
display: flex;
justify-content: center;
padding: var(--ha-space-8) 0;
}
.card-content {
display: flex;
@@ -62,13 +62,9 @@ export class HaDeviceCard extends LitElement {
return html`
<ha-card
outlined
.header=${this.hass.localize("ui.panel.config.devices.device_info", {
type: this.hass.localize(
`ui.panel.config.devices.type.${
this.device.entry_type || "device"
}_heading`
),
})}
.header=${this.hass.localize(
`ui.panel.config.devices.${this.device.entry_type || "device"}_info_heading`
)}
>
<div class="card-content">
${
Loaded 100 of 188 files, more files were not shown because too many files have changed in this diff. Show more