Compare commits

..

76 Commits

Author SHA1 Message Date
Aidan Timson 69959f73a3 Keep dependency cache fallbacks fast 2026-07-13 15:45:43 +01:00
Aidan Timson 06b7836794 Fall back when dependency cache is unavailable 2026-07-13 15:39:13 +01:00
Aidan Timson 55ea661e31 Allow manual CI runs 2026-07-13 14:53:29 +01:00
Aidan Timson 23b9e97ee5 Use one dependency cache key 2026-07-13 14:24:02 +01:00
Aidan Timson f807806bed Use shared dependencies in CI 2026-07-13 14:06:57 +01:00
Aidan Timson 450a132fc7 Benchmark build with shared dependencies 2026-07-13 12:55:03 +01:00
Aidan Timson c2fdde7ad9 Avoid restoring warm producer cache 2026-07-13 12:37:36 +01:00
Aidan Timson 2b659d27b1 Benchmark shared dependency cache 2026-07-13 12:25:54 +01:00
renovate[bot] 5f46594ee2 Update tsparticles to v4.3.2 (#53112)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-07-13 13:15:36 +02:00
renovate[bot] 58bcf1e865 Update dependency @html-eslint/eslint-plugin to v0.64.0 (#53111) 2026-07-13 10:05:02 +01:00
Simon Lamon 196d71c4f7 Update demo deployment to output unique url again (#53091) 2026-07-13 09:52:33 +01:00
Logan Rosen 7e60fbb66e Allow dismissing clean data entry flows (#53109)
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

Copilot-Session: 67dc41de-ac39-4e22-8adb-c9d35b2a5672
2026-07-13 08:34:40 +03:00
renovate[bot] cea86f0aa0 Update dependency prettier to v3.9.5 (#53107)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-07-13 08:13:10 +03:00
renovate[bot] 85eb2c9f6e Update dependency marked to v18.0.6 (#53106)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-07-12 21:31:01 +02:00
renovate[bot] b401cd1500 Update dependency @rsdoctor/rspack-plugin to v1.5.18 (#53105)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-07-12 21:30:39 +02:00
Raphael Hehl 598e8d4a45 Route picture-elements taps to the nearest icon or label (#52928)
* Route picture-elements taps to the nearest icon or label

* Scope picture-elements double-tap per element and add gesture tests

* Drive routed taps through the shared action handler via a container resolver

* Clear the resolved gesture when a press becomes a multi-touch

* Route conditional-nested picture-elements taps to nearest target

Mark routed element types as delegated in createStyledHuiElement so elements built inside hui-conditional-element get the same nearest-target routing and hit boxes, and collect seeds from the full rendered subtree instead of only top-level elements.
2026-07-12 11:49:58 +02:00
renovate[bot] 030d777f26 Update formatjs monorepo (#53103)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-07-12 09:30:44 +00:00
Jan-Philipp Benecke 194b7edf3a Remove custom app header text color in zha group page (#53101) 2026-07-12 10:17:07 +02:00
dependabot[bot] 97ed3afe4b Bump github/codeql-action/init from 4.36.2 to 4.36.3 (#53097)
* Bump github/codeql-action/init from 4.36.2 to 4.36.3

Bumps [github/codeql-action/init](https://github.com/github/codeql-action) from 4.36.2 to 4.36.3.
- [Release notes](https://github.com/github/codeql-action/releases)
- [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md)
- [Commits](https://github.com/github/codeql-action/compare/8aad20d150bbac5944a9f9d289da16a4b0d87c1e...54f647b7e1bb85c95cddabcd46b0c578ec92bc1a)

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

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

* Update CodeQL action version to v4.36.3

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Simon Lamon <32477463+silamon@users.noreply.github.com>
2026-07-12 08:49:26 +02:00
renovate[bot] 77cc3cfe32 Update dependency @formatjs/intl-durationformat to v0.10.16 (#53098)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-07-12 08:31:06 +02:00
Simon Lamon 1350fd3674 Use ChevronRight instead of OpenInNew icon in ZHA group device list (#53087)
Update icon
2026-07-12 08:22:04 +03:00
Simon Lamon 9a283aba78 Pass in area in ZHA initialization area picker (#53086)
* Pass in area in ZHA initialization area picker

* Fixup
2026-07-12 08:21:04 +03:00
renovate[bot] 7f04b0884b Update dependency idb-keyval to v6.3.0 (#53090)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-07-11 22:31:18 +02:00
renovate[bot] a4c07e5fc2 Update Yarn to v4.17.1 (#53093)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-07-11 22:29:15 +02:00
renovate[bot] 42e0a1e4b5 Update dependency @rspack/core to v2.1.3 (#53081)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-07-10 13:09:57 +02:00
renovate[bot] 438e2e5173 Update dependency typescript-eslint to v8.63.0 (#53075)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-07-10 08:40:54 +03:00
Petar Petrov 0548f99057 Restore per-second updating of ha-relative-time (#53070)
Restore per-second ticking for ha-relative-time
2026-07-09 22:06:47 +02:00
elcaptain ea75339392 Update ohf.svg (#53071)
replaced depreciated logo version with correct one. svg will adapt to light/dark mode.
2026-07-09 22:05:28 +02:00
AlCalzone 7e244a706b Z-Wave: create users with credentials in a single API call (#52339)
* Z-Wave: use add_user service to add users with credentials

* refactor to match backend changes
2026-07-09 06:27:25 -04:00
renovate[bot] 7911aef6c0 Update vitest monorepo to v4.1.10 (#53067)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-07-09 11:44:12 +03:00
renovate[bot] ed00d4ded1 Update dependency @codemirror/view to v6.43.6 (#53066)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-07-09 11:43:36 +03:00
Petar Petrov be55a2197e Wrap device automation picker option labels instead of truncating (#53065) 2026-07-09 10:30:28 +02:00
Arsène Reymond a73c659840 fix: weather card more info on simple click (#53064) 2026-07-09 08:23:36 +03:00
Petar Petrov 207025d9a4 Recompute Y-axis tick precision when zooming charts (#52970) 2026-07-09 07:07:41 +02:00
renovate[bot] e9431105b0 Update dependency @codemirror/state to v6.7.1 (#53061)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-07-09 07:04:46 +02:00
Petar Petrov 1e5560fd42 Fix search icon overlapping label in dev tools states on Safari (#52924)
* Fix search icon overlapping label in ha-input on Safari

* Remove redundant connectedCallback re-observe in ha-input
2026-07-09 06:59:20 +02:00
Petar Petrov 369e446149 Fix energy graph axis limits and bar width with sparse statistics (#52946)
* Zero-fill energy chart buckets so sparse data keeps correct axis and bar width

* Anchor fill grid on compare series when main has no data
2026-07-09 06:53:06 +02:00
Aidan Timson 318dac3bf1 Split agents.md into skills (#53048)
* Split agents into skills, keeping architecture and commands in main file with routing for agents which dont follow standards

* Rename `copy` skill to `user-facing-text`
2026-07-09 06:51:15 +02:00
Petar Petrov 8ee9c95454 Fix cloning when drag-sorting a section to the last position (#53050) 2026-07-08 12:01:52 +01:00
Aidan Timson 8ea65987a7 Clarify E2E report failures (#53052)
* Clarify E2E report retry failures

* Add failing E2E report test

* Format E2E report project names

* Remove failing E2E report test
2026-07-08 14:01:26 +03:00
Yosi Levy ada5be6af5 Various RTL fixes (#53049) 2026-07-08 12:00:56 +01:00
Aidan Timson 5bcb6628b0 Add panel route e2e coverage (#53045)
* Add panel route e2e coverage

* Fix

* Ignore playwright mcp files

* Fix

* Remove bad mock

* Fix URL constructor

* Add panel url normalization tests

* Stabilise Lovelace URL normalization tests

* Fail Lovelace URL tests faster
2026-07-08 13:59:28 +03:00
Krisjanis Lejejs 24fd2241df Fix cloud account page onboarding width (#53053) 2026-07-08 11:59:14 +01:00
Aidan Timson db0813904c Add Settings panel e2e route coverage (#53044) 2026-07-08 10:53:32 +03:00
karwosts b30668995d Suggest picture card for image entities (#53046) 2026-07-08 06:00:58 +02:00
Petar Petrov 9ba003f86a Only re-render context consumers when the selected value changes (#52885)
The consume* decorators paired @lit/context's @consume({ subscribe: true })
with @transform to narrow a context down to a single value. ContextConsumer
calls host.requestUpdate() on every provider notification, so every consumer
ran an (often empty) render cycle on every unrelated statesContext change.

Replace the built-in consumer with a small reactive controller that subscribes
the same way but leaves update scheduling to the property setter, which already
gates on hasChanged. A render is now requested only when the selected value
actually changes.
2026-07-07 18:31:34 +02:00
Krisjanis Lejejs 0536e2cd2a Sync cloud page onboarding_postponed key, update styles (#53043) 2026-07-07 14:37:38 +01:00
Ryan Mounce 0c25f061ef Fix date range picker presets rendering blank when translations load late (#53040)
The preset ranges in ha-date-range-picker were computed once in
connectedCallback(). If the component connected before the translation
chunk containing the range labels had loaded, localize() returned an
empty string for every key, collapsing all presets into a single
empty-keyed entry — the preset list rendered blank and never recovered.

Compute _ranges in willUpdate() instead, recomputing whenever _i18n,
_hassConfig, or extendedPresets changes. This also fixes the presets
not updating when the locale or timezone changes while the page is open.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 13:14:05 +00:00
Petar Petrov 62e5c9dee9 Fix target picker "Add target" popover opening off to the right (#53041) 2026-07-07 14:12:11 +01:00
Krisjanis Lejejs 36fa14ee26 Redesign cloud page (#53025)
* Redesign clould page

* Fix Prettier after upgrade

* Fix formatting after merge

* Further prettier fixes
2026-07-07 16:10:04 +03:00
Petar Petrov 14294998e7 Show date picker in energy-date-selection card without energy config (#53038) 2026-07-07 13:45:30 +01:00
Aidan Timson 60549695b0 Add auto-labels for tests (#53034) 2026-07-07 14:52:25 +03:00
dependabot[bot] 41068657bd Bump home-assistant/wheels from 2026.06.0 to 2026.07.0 (#53039)
Bumps [home-assistant/wheels](https://github.com/home-assistant/wheels) from 2026.06.0 to 2026.07.0.
- [Release notes](https://github.com/home-assistant/wheels/releases)
- [Commits](https://github.com/home-assistant/wheels/compare/34957438948e0b3dcde73c77750643dadae594f5...9e17ab1ed5c4c79d8b61e29fa63de25ca2710716)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-07 11:35:50 +00:00
renovate[bot] 7d11f283d2 Update dependency @codemirror/view to v6.43.5 (#53037)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-07-07 13:28:54 +02:00
Petar Petrov 0927a55a0c Merge Port 8123 feature branch to dev (#53022)
* Add HTTP server settings to the network panel (#51981)

* Fix focus loss in ha-input-multi when items change

* Add HTTP server settings to the network panel

* Surface fetch errors and validate before saving in HTTP config form

* Update src/panels/config/network/ha-config-http-form.ts

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

* Group HTTP form fields into collapsible sections

* Add bottom margin to ha-input-multi add button

* Only apply add-button margin when helper text is present

* Update HTTP config form to new WebSocket API

---------

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

* HTTP config: confirm or revert pending changes after restart (#52452)

* HTTP config: prompt admin to confirm or revert pending changes after restart

After saving HTTP server settings, core now writes the new config as pending,
restarts Home Assistant, and keeps the previous stable config as a recovery
fallback. The frontend now:

- Reads {stable, pending} from http/config and adds a promoteHttpConfig call.
- Asks the admin to confirm before saving (since saving now auto-restarts).
- After reconnect, shows a non-dismissable popup for admins with the changed
  fields, where Confirm promotes pending -> stable and Revert clears pending
  (which triggers another restart).
- Renders an info banner above the form whenever pending is set.

* Drop pending banner and pending handling from HTTP form

The popup blocks any other interaction while pending exists, so the form
is only reachable when pending is null. Stop fetching/displaying the
pending config and the unconfirmed-config banner.

* Use HTTP settings in dialog title

* Show auto-revert countdown in HTTP pending-config dialog (#52799)

* Show auto-revert countdown in HTTP pending-config dialog

Surface the new revert_at deadline (core PR #174428) in the popup that
opens after saving HTTP server settings. While pending, the dialog shows
a ticking 'Settings will revert in M:SS.' line; when the deadline passes
it switches into a reverted state with an info alert and a Close button.
Confirm/Revert remain available until the deadline.

* Add bottom margin to ha-alert in pending-config dialog

Without it the changes list sits flush against the reverted-state info
alert.

* text tweak

* Render under-a-minute revert countdown as '45 seconds'

secondsToDuration returns a bare '45' for under-a-minute durations, so
the line read 'Settings will revert in 45.'. formatNumericDuration uses
Intl unit formatting in that range, giving 'Settings will revert in
45 seconds.', and keeps the M:SS format above one minute.

* Fix Prettier formatting in dialog-http-pending-config.ts and ha-config-http-form.ts

---------

Co-authored-by: Paul Bottein <paul.bottein@gmail.com>
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
2026-07-07 13:27:54 +02:00
Aidan Timson 9e75f9282e Extract shared workflow steps into composite actions (#52982) 2026-07-07 12:25:08 +01:00
Petar Petrov 56af189032 Hide external-link icon on internal repairs "Learn more" links (#53035) 2026-07-07 12:16:02 +01:00
Petar Petrov 02c99dd61d Only show solar/battery legend in energy usage graph when configured (#53030) 2026-07-07 12:13:02 +01:00
Petar Petrov f00342d522 Fix number/input_number slider clipped in entities card at certain zooms (#53017) 2026-07-07 12:12:00 +01:00
Petar Petrov 5538be0d0a Fix escape key discarding unsaved changes in dashboard editor (#53029) 2026-07-07 12:03:12 +01:00
Norbert Rittel 96b5c2910e Use more user-friendly 'Fit mode' options in Picture entity card (#53024)
More user-friendly 'Fit mode' options
2026-07-07 12:02:50 +03:00
Yosi Levy 30c44403de Fix RTL in selection in date time picker (#53020) 2026-07-06 16:27:14 +03:00
Petar Petrov 43c16f0c77 Fix tile trend graph rendering blank when shown by a visibility condition (#53016) 2026-07-06 11:46:55 +01:00
Petar Petrov 42fe3c6f97 Fix history/logbook entity selection leaking across browser tabs (#53014)
Keep history and logbook entity selection independent per tab
2026-07-06 13:11:51 +03:00
Petar Petrov 903f954947 Round fan speed slider display to whole percentages (#53004) 2026-07-06 11:08:54 +01:00
Petar Petrov 50e6691c30 Fix My redirect for AI tasks to /config/ai-tasks (#53015) 2026-07-06 11:08:41 +01:00
Simon Lamon b0e8d45003 Add entity update tracking again in Automation and Script editor (#53002) 2026-07-06 08:33:56 +01:00
Simon Lamon 14e7f434e0 No need to show rename when alias is already provided (#53003)
* No need to show rename when alias is already provided

* Update src/panels/config/script/ha-script-editor.ts

* Apply suggestion from @MindFreeze

---------

Co-authored-by: Petar Petrov <MindFreeze@users.noreply.github.com>
2026-07-06 05:47:35 +00:00
Raman Gupta 535b1b03f2 Fix Z-Wave JS config parameter value type (#53011)
* Fix Z-Wave JS config parameter value type

Type ZWaveJSNodeConfigParam.value as number | null instead of any,
matching what the backend sends. This surfaced two comparison bugs:

- The enumerated picker no-op check compared the stored number against
  the picker's string value, so it never matched and re-selecting the
  current option re-sent the command to the device.
- The numeric input guard coerced null (unknown value) to 0, so
  entering 0 for a parameter with an unknown value was ignored.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Treat empty numeric input as invalid instead of 0

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 08:22:10 +03:00
dependabot[bot] 14db6c99fe Bump actions/cache from 5.0.5 to 6.1.0 (#52999)
Bumps [actions/cache](https://github.com/actions/cache) from 5.0.5 to 6.1.0.
- [Release notes](https://github.com/actions/cache/releases)
- [Changelog](https://github.com/actions/cache/blob/main/RELEASES.md)
- [Commits](https://github.com/actions/cache/compare/27d5ce7f107fe9357f9df03efb73ab90386fccae...55cc8345863c7cc4c66a329aec7e433d2d1c52a9)

---
updated-dependencies:
- dependency-name: actions/cache
  dependency-version: 6.1.0
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-05 11:06:13 +03:00
dependabot[bot] 97226acab9 Bump release-drafter/release-drafter from 7.4.0 to 7.5.1 (#52998)
Bumps [release-drafter/release-drafter](https://github.com/release-drafter/release-drafter) from 7.4.0 to 7.5.1.
- [Release notes](https://github.com/release-drafter/release-drafter/releases)
- [Commits](https://github.com/release-drafter/release-drafter/compare/ed4bc48ec97379be2258e7b7ac2624a3e26ab809...4d75298e00d9e34c483e5ff8c68d0ea1c1940c1e)

---
updated-dependencies:
- dependency-name: release-drafter/release-drafter
  dependency-version: 7.5.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-05 11:05:42 +03:00
dependabot[bot] df0d1a945f Bump actions/setup-python from 6.2.0 to 6.3.0 (#52997)
Bumps [actions/setup-python](https://github.com/actions/setup-python) from 6.2.0 to 6.3.0.
- [Release notes](https://github.com/actions/setup-python/releases)
- [Commits](https://github.com/actions/setup-python/compare/a309ff8b426b58ec0e2a45f0f869d46889d02405...ece7cb06caefa5fff74198d8649806c4678c61a1)

---
updated-dependencies:
- dependency-name: actions/setup-python
  dependency-version: 6.3.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-05 11:05:24 +03:00
renovate[bot] dccd798c26 Update dependency js-yaml to v5.2.1 (#52996)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-07-05 11:05:06 +03:00
renovate[bot] 757c7a3e7f Update tsparticles to v4.3.1 (#52992)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-07-04 20:06:40 +02:00
Petar Petrov 7ccd1371bf Match Statistic card line height to Entity card (#52981) 2026-07-03 17:42:30 +02:00
Paulus Schoutsen fe9017fbdf Clean up energy dashboard card titles and labels (#28778)
* Remove usage labels from energy cards and rename Sources to Costs

- Remove redundant "usage" text from energy card titles and labels
- Simplify Sources table labels by removing "total" text
- Rename Sources table title to Costs

* Apply suggestion from @mindfreeze

* Apply suggestion from @balloob

* Keep 'total' in source type summary labels

Addresses review feedback from @karwosts — in the detail view,
dropping "total" from labels like "Gas total" makes the summary
row ambiguous when listed alongside individual source names.

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

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: Petar Petrov <MindFreeze@users.noreply.github.com>
2026-07-03 14:33:37 +00:00
164 changed files with 14176 additions and 5027 deletions
@@ -0,0 +1,98 @@
---
name: ha-frontend-components
description: Home Assistant frontend component patterns. Use when implementing or reviewing dialogs, ha-form, ha-alert, keyboard shortcuts, tooltips, panels, Lovelace cards, or ha-button usage.
---
# HA Frontend Components
Use this skill when creating or reviewing Home Assistant UI components and common interaction patterns.
## Dialogs
Open dialogs through the fire-event pattern:
```ts
fireEvent(this, "show-dialog", {
dialogTag: "dialog-example",
dialogImport: () => import("./dialog-example"),
dialogParams: { title: "Example", data: someData },
});
```
Dialog implementation requirements:
- Use `ha-dialog`.
- Implement `HassDialog<T>`.
- Use `@state() private _open = false` to control visibility.
- Set `_open = true` in `showDialog()` and `_open = false` in `closeDialog()`.
- Return `nothing` while required params are absent.
- Fire `dialog-closed` in the close handler.
- Use `header-title` and `header-subtitle` for simple header text.
- Use slots when standard header attributes are not enough.
- Use `ha-dialog-footer` with `primaryAction` and `secondaryAction` slots.
- Add `autofocus` to the first focusable element, such as `<ha-form autofocus>`, and forward it internally if needed.
Use standard dialog widths: `small`, `medium`, `large`, or `full`. Avoid custom dialog sizing unless there is a clear product need.
## Buttons
`ha-button` wraps the Web Awesome button in `src/components/ha-button.ts`.
Axes:
- `variant`: `brand`, `neutral`, `danger`, `warning`, `success`.
- `appearance`: `accent`, `filled`, `outlined`, `plain`.
- `size`: `xs`, `s`, `m`, `l`, `xl`.
Common usage:
- Use `appearance="filled"` for primary emphasis when needed.
- Use `appearance="plain"` for cancel and dismiss actions.
- Use `variant="danger"` for destructive actions.
- Place primary actions in `slot="primaryAction"` and secondary actions in `slot="secondaryAction"`.
## Forms
`ha-form` is schema-driven with `HaFormSchema[]` and supports common selectors for entities, devices, areas, targets, numbers, booleans, time, actions, text, objects, selects, icons, media, and location.
Use `computeLabel`, `computeError`, and `computeHelper` for translated labels, validation, and helper text.
```ts
<ha-form
.hass=${this.hass}
.data=${this._data}
.schema=${this._schema}
.error=${this._errors}
.computeLabel=${(schema) => this.hass.localize(`ui.panel.${schema.name}`)}
@value-changed=${this._valueChanged}
></ha-form>
```
## Alerts
Use `ha-alert` for user-visible status messaging.
- Alert types: `error`, `warning`, `info`, `success`.
- Useful properties: `title`, `alert-type`, `dismissable`, `narrow`.
- Slots: `icon` for custom leading icon, `action` for custom action content.
- Content is announced by screen readers when dynamically displayed.
```html
<ha-alert alert-type="error">Error message</ha-alert>
<ha-alert alert-type="warning" title="Warning">Description</ha-alert>
<ha-alert alert-type="success" dismissable>Success message</ha-alert>
```
## Shortcuts And Tooltips
Use `ShortcutManager` from `src/common/keyboard/shortcuts.ts` for keyboard shortcuts. It blocks shortcuts in input fields, can prevent shortcuts during text selection, and supports character and KeyCode shortcuts for non-latin keyboards. See `src/state/quick-bar-mixin.ts` for global shortcut examples.
Use `ha-tooltip` from `src/components/ha-tooltip.ts` for contextual hover help. See `src/components/ha-label.ts` for an example.
## Panels And Lovelace Cards
Panels commonly extend `SubscribeMixin(LitElement)` and receive route and narrow-layout properties.
Lovelace cards should implement `LovelaceCard`, validate config in `setConfig()`, handle loading, error, unavailable, and missing-entity states, and add a configuration editor when needed.
Cards are user-story surfaces. Support different households, entity types, responsive layouts, and accessible interaction states.
@@ -0,0 +1,78 @@
---
name: ha-frontend-contexts
description: Home Assistant frontend Lit context and hass migration guidance. Use when adding or changing component state access, replacing hass reads, consuming entity or registry contexts, or reviewing rerender behavior.
---
# HA Frontend Contexts
Use this skill when a component reads Home Assistant state, registries, localization, services, config, UI data, connection state, or API helpers.
## Goal
Move leaf components away from the broad `hass: HomeAssistant` object. Broad `hass` access rerenders components for unrelated changes, hides the data a component depends on, and makes tests harder to mock.
Container components may keep `hass` when they own it and feed providers. Leaf components should consume the narrowest context that covers their reads.
## Core Files
- Context definitions: `src/data/context/index.ts`
- Entity-scoped consume helpers: `src/common/decorators/consume-context-entry.ts`
- Transform decorator: `src/common/decorators/transform.ts`
- Canonical migration example: `src/panels/lovelace/cards/hui-button-card.ts`
- Providers are wired by `contextMixin` on `HassBaseEl`; consumers do not wire providers manually.
## Context Selection
| Context | Replaces |
| -------------------------------------------------------------------- | -------------------------------------------------------------------------------------- |
| `statesContext` | `hass.states` |
| `entitiesContext`, `devicesContext`, `areasContext`, `floorsContext` | `hass.entities`, `hass.devices`, `hass.areas`, `hass.floors` |
| `registriesContext` | all four registries together |
| `servicesContext` | `hass.services` |
| `internationalizationContext` | `hass.localize`, `hass.locale`, `hass.language` |
| `formattersContext` | entity and attribute formatters |
| `configContext` | `hass.config`, `hass.user`, `hass.auth`, `hass.userData` |
| `connectionContext` | `hass.connection`, `hass.connected`, `hass.hassUrl` |
| `apiContext` | `hass.callService`, `hass.callApi`, `hass.callWS`, `hass.sendWS`, `hass.fetchWithAuth` |
| `uiContext` | themes, selected theme, panels, sidebar, and UI state |
| `narrowViewportContext` | narrow-layout boolean |
Lazy contexts subscribe on first consumer and tear down after the last consumer: `labelsContext`, `fullEntitiesContext`, `configEntriesContext`, and `manifestsContext`.
The single-field contexts such as `localizeContext`, `themesContext`, and `userContext` are deprecated. Use grouped contexts instead.
## Consumption Patterns
Use entity-scoped helpers when the component watches an entity id held on the host:
```ts
@state() @consumeEntityState({ entityIdPath: ["_config", "entity"] })
private _stateObj?: HassEntity;
@state() @consumeEntityRegistryEntry({ entityIdPath: ["_config", "entity"] })
private _entity?: EntityRegistryDisplayEntry;
@state() @consumeLocalize()
private _localize!: LocalizeFunc;
```
For a single field from a grouped context, pair `@consume` with `@transform`:
```ts
@state()
@consume({ context: uiContext, subscribe: true })
@transform<HomeAssistantUI, Themes>({ transformer: ({ themes }) => themes })
private _themes!: Themes;
```
Use `@transform` with `watch` when the transformer depends on a host property, such as a computed entity id. `consumeEntityState` only watches the first path segment.
To consume a whole group untransformed, omit `@transform` and type the field as `ContextType<typeof statesContext>` or the matching context type.
## Review Checklist
- The component consumes the narrowest context needed for the data it reads.
- A broad `hass` property is kept only when the component is a container or external API requires it.
- Entity-scoped reads use the consume helpers rather than ad hoc context transforms.
- Context fields are marked `@state()` so updates trigger rendering.
- Tests and mocks only provide the data the component actually consumes.
@@ -0,0 +1,73 @@
---
name: ha-frontend-review
description: Home Assistant frontend PR and review guidance. Use when reviewing frontend changes, preparing a PR, checking recurring review issues, or applying the PR template.
---
# HA Frontend Review
Use this skill when reviewing Home Assistant frontend changes or preparing a pull request.
## Pull Request Body
When creating a pull request, use `.github/PULL_REQUEST_TEMPLATE.md` as the body.
- Do not omit, reorder, or rewrite template sections.
- Check the appropriate "Type of change" box based on the actual change.
- Do not check checklist items on behalf of the user.
- If the PR includes UI changes, remind the user to add screenshots or a short video.
- Explain what the change does for users, not only implementation details.
- Use Markdown.
## Pre-Submission Checklist
- `yarn lint` passes when practical for the scope.
- `yarn test` or focused relevant tests are green when practical for the scope.
- Tests are added or updated for new data processing and utilities where applicable.
- User-facing text is localized and follows `ha-frontend-user-facing-text` guidance.
- Components handle loading, error, unavailable, and missing-entity states.
- Entity existence is checked before property access.
- Event listeners and subscriptions are cleaned up.
- UI is accessible to screen readers and keyboard users.
## Recurring Review Issues
User experience and accessibility:
- Forms need proper labels, helper text, and validation feedback.
- Form markup should not cause password managers to identify fields incorrectly.
- Clickable areas should be large enough for touch interaction.
- Hover, active, disabled, loading, and focus states should be clear.
Dialog and modal patterns:
- Multi-step operations should show progress.
- Dialog state should survive background operations correctly.
- Cancel and close buttons should behave consistently.
- Defaults should be helpful without blocking user override.
Component design patterns:
- Terminology should be consistent. Use words like "Join" or "Apply" instead of "Group" when that better matches the user action.
- Visual hierarchy should use appropriate font sizes, weights, and spacing ratios.
- Components should align to the design grid.
- Badges and indicators should be placed consistently.
Code quality:
- Null and undefined paths should be handled explicitly.
- Potentially undefined array and object access should be guarded.
- Event handlers, timers, observers, and subscriptions should be cleaned up.
Configuration and props:
- Make configuration fields optional when sensible.
- Provide reasonable defaults.
- Keep APIs extensible without adding speculative abstractions.
- Validate configuration before applying changes.
## Review Flow
- Identify behavioral regressions, bugs, accessibility issues, and missing tests first.
- Keep style-only comments secondary unless they affect maintainability or user experience.
- Prefer small, direct fixes over large refactors during review follow-up.
- Cross-load `ha-frontend-contexts`, `ha-frontend-components`, `ha-frontend-styling`, `ha-frontend-testing`, or `ha-frontend-user-facing-text` when a finding falls in that area.
@@ -0,0 +1,78 @@
---
name: ha-frontend-styling
description: Home Assistant frontend styling, theming, spacing, responsive layout, RTL, and View Transitions guidance. Use when editing CSS, layout, motion, or visual component structure.
---
# HA Frontend Styling
Use this skill when editing CSS, layout, visual hierarchy, theme integration, responsive behavior, RTL support, or view transitions.
## Theme And Layout Basics
- Use Home Assistant CSS custom properties instead of hardcoded colors.
- Use `--ha-space-*` spacing tokens instead of hardcoded spacing where possible.
- Keep components mobile-first and enhance for desktop.
- Keep layouts RTL-safe. Prefer logical properties when they fit.
- Prefer `ha-*` components and current Web Awesome wrappers.
- Avoid adding new legacy Material Web Components (`mwc-*`).
- Scope styles to the component. Do not rely on global styles for component internals.
Spacing tokens are defined in `src/resources/theme/core.globals.ts`. The scale runs from `--ha-space-1` at 4px through `--ha-space-20` at 80px in 4px increments. Common values are `--ha-space-2` at 8px, `--ha-space-4` at 16px, and `--ha-space-8` at 32px.
```ts
static get styles() {
return css`
:host {
padding: var(--ha-space-4);
color: var(--primary-text-color);
background-color: var(--card-background-color);
}
.content {
gap: var(--ha-space-2);
}
@media (max-width: 600px) {
:host {
padding: var(--ha-space-2);
}
}
`;
}
```
## Interaction States
- Make touch targets large enough for mobile.
- Provide clear hover, active, focus, disabled, loading, error, and unavailable states.
- Preserve keyboard navigation and visible focus indicators.
- Maintain WCAG AA contrast for text and essential UI affordances.
## View Transitions
Use the View Transitions API only for meaningful continuity between DOM states.
Core resources:
- Utility wrapper: `src/common/util/view-transition.ts`, `withViewTransition()`.
- Launch-screen fade example: `src/util/launch-screen.ts`.
- Animation keyframes: `src/resources/theme/animations.globals.ts`.
- Animation duration tokens: `src/resources/theme/core.globals.ts`.
Implementation rules:
- Use `withViewTransition()` for fallback behavior.
- Keep transitions simple. Subtle fades and crossfades usually work best.
- Use `--ha-animation-duration-fast`, `--ha-animation-duration-normal`, or `--ha-animation-duration-slow` for timing.
- Ensure each `view-transition-name` is unique at any given time.
- Remember only one view transition can run at a time.
- View transitions operate at document level and do not work inside Shadow DOM style isolation. For web components, set `view-transition-name` on `:host` or use document-level transitions.
- The root gets `view-transition-name: root` by default. Target `::view-transition-group(root)` to customize the default page transition.
## Review Checklist
- Styling uses theme variables and spacing tokens where practical.
- Layout is mobile-first and RTL-safe.
- Component styles are scoped.
- Interactive states are clear and accessible.
- Motion is subtle, tokenized, and respects reduced-motion behavior through existing globals.
@@ -0,0 +1,70 @@
---
name: ha-frontend-testing
description: Home Assistant frontend validation workflow. Use when running lint, TypeScript checks, Vitest, Playwright e2e suites, dev servers, or chart-data benchmarks.
---
# HA Frontend Testing
Use this skill when choosing or running validation for frontend changes.
## Core Commands
```bash
yarn lint # ESLint + Prettier + TypeScript + Lit
yarn format # Auto-fix ESLint + Prettier
yarn lint:types # TypeScript compiler, run without file arguments
yarn test # Vitest
yarn dev # App dev server
yarn dev:serve # Local serving dev server
```
Never run `tsc` or `yarn lint:types` with file arguments. File arguments make `tsc` ignore `tsconfig.json` and can emit `.js` files into `src/`.
For focused type feedback on one file, use editor diagnostics instead of a file-scoped `tsc` command.
## Unit And Utility Tests
- Add or update Vitest tests for data processing, utility code, and behavior that can be tested without a browser.
- Mock WebSocket connections and API calls at boundaries.
- Cover loading, error, unavailable, and missing-entity states where relevant.
- Test accessibility-sensitive behavior when it can be asserted without brittle DOM internals.
## Dev Servers
`yarn dev` builds and watches the app, served by a running Home Assistant core configured through `development_repo`.
`yarn dev:serve` also serves locally and supports `-c` for the core URL and `-p` for the port. Default local serving port is 8124.
Dev server commands support `--background`, `--status`, `--stop`, and `--logs [--follow]`.
## Playwright E2E
Each suite has its own dev server port. Playwright reuses an existing server locally when the port is already running; otherwise it performs a slow full build. The rspack watcher recompiles on save, so reruns should not need a restart.
Start the relevant suite server, then run that suite:
| Suite | Server | Test command |
| ------- | ------------------------------- | ----------------------- |
| App | `yarn test:e2e:app:dev` on 8095 | `yarn test:e2e:app` |
| Demo | `yarn dev:demo` on 8090 | `yarn test:e2e:demo` |
| Gallery | `yarn dev:gallery` on 8100 | `yarn test:e2e:gallery` |
Server reuse and `--stop` use the `/__ha_dev_status` health check, so starting or stopping twice is harmless.
Use `-g "<title>" --project=chromium` to narrow a run. `yarn test:e2e` runs all three suites. Run suites directly; piping through output truncation hides progress and failures.
The app suite uses a stripped-down harness for e2e. Demo and gallery use their normal dev servers.
## Benchmarks
For chart data transforms such as history, statistics, energy, and downsampling, follow `test/benchmarks/README.md`.
Use seeded fixtures, characterization snapshot tests, and `yarn test:bench` before and after optimization. Optimizations must keep output bit-identical.
## Verification Selection
- Documentation-only change: no code test required unless examples or commands changed.
- Type-only or utility change: run focused Vitest if available, then `yarn lint:types` if practical.
- Lit component change: run relevant tests plus lint or typecheck depending on scope.
- E2E-sensitive flow: start the relevant e2e dev server and run the narrow Playwright suite.
- Broad refactor: run `yarn lint` and relevant test suites when practical.
@@ -0,0 +1,98 @@
---
name: ha-frontend-user-facing-text
description: Home Assistant frontend copy, localization, terminology, and user-facing text guidance. Use when adding or reviewing labels, buttons, dialogs, errors, translations, or UI strings.
---
# HA Frontend User-Facing Text
Use this skill for all user-facing text, translations, labels, buttons, dialog copy, errors, helper text, and review comments about wording.
## Localization
- All user-facing text must be translatable.
- Add translation keys to `src/translations/en.json` when introducing new strings.
- Use the localization system instead of inline user-visible strings.
- Prefer complete localized strings with placeholders over concatenating translated fragments.
- Give translators enough context through key naming and placeholders.
```ts
this.hass.localize("ui.panel.config.updates.update_available", {
count: 5,
});
```
## Voice And Style
- Use American English.
- Use a friendly, informational tone.
- Address users directly with "you" and "your" when appropriate.
- Be inclusive, objective, and non-discriminatory.
- Be concise and clear.
- Use active voice.
- Avoid jargon where a familiar home automation term works.
- Always write "Home Assistant" in full. Do not use "HA" or "HASS" in user-facing copy.
- Spell out terms when possible.
- Use sentence case for titles, headings, buttons, labels, and UI elements.
- Use the Oxford comma in lists.
- Prefer "like" over "e.g." and "for example" over "i.e.".
- Avoid all caps for emphasis. Use wording, bold, or italics instead.
- Write for both technical and non-technical users.
Sentence case examples:
- Use: "Create new automation"
- Avoid: "Create New Automation"
- Use: "Device settings"
- Avoid: "Device Settings"
## Terminology
Use "integration" instead of "component" for user-facing product language unless referring to a frontend component in developer context.
Technical product terms are lowercase in prose: automation, entity, device, service.
## Delete, Remove, Create, Add
Use "Remove" for actions that can be restored or reapplied:
- Removing a user's permission.
- Removing a user from a group.
- Removing links between items.
- Removing a widget from a dashboard.
- Removing an item from a cart.
Use "Delete" for permanent, non-recoverable actions:
- Deleting a field.
- Deleting a value in a field.
- Deleting a task.
- Deleting a group.
- Deleting a permission.
- Deleting a calendar event.
Use "Add" for already-existing items:
- Adding a permission to a user.
- Adding a user to a group.
- Adding links between items.
- Adding a widget to a dashboard.
- Adding an item to a cart.
Use "Create" for something made from scratch:
- Creating a new field.
- Creating a new task.
- Creating a new group.
- Creating a new permission.
- Creating a new calendar event.
Create pairs with Delete. Add pairs with Remove.
## Review Checklist
- Text is localized.
- Copy uses sentence case.
- "Home Assistant" is written in full.
- Delete/Remove and Create/Add match recoverability and object lifecycle.
- Placeholders are used instead of string concatenation.
- The wording is concise and understandable without implementation knowledge.
+1
View File
@@ -0,0 +1 @@
../.agents/skills
+23
View File
@@ -0,0 +1,23 @@
name: Build frontend target
description: Run a gulp build target
inputs:
target:
description: gulp target to run
required: true
github-token:
description: GitHub token for fetching nightly translations; omit to build English-only
default: ""
is-test:
description: Set IS_TEST for the build (skips source maps and compression)
default: "false"
runs:
using: composite
steps:
- name: Build ${{ inputs.target }}
shell: bash
run: ./node_modules/.bin/gulp ${{ inputs.target }}
env:
GITHUB_TOKEN: ${{ inputs.github-token }}
IS_TEST: ${{ inputs.is-test }}
+52
View File
@@ -0,0 +1,52 @@
name: Deploy to Netlify
description: Deploy a directory to Netlify (production when alias is empty, otherwise to the alias)
inputs:
dir:
description: Directory to deploy
required: true
alias:
description: Deploy alias; leave empty to deploy to production
default: ""
auth-token:
description: NETLIFY_AUTH_TOKEN
required: true
site-id:
description: NETLIFY_SITE_ID
required: true
outputs:
netlify_url:
description: The deployed URL
value: ${{ steps.deploy.outputs.netlify_url }}
unique_deploy_url:
description: The unique hash-based deployment URL for this specific build
value: ${{ steps.deploy.outputs.unique_deploy_url }}
runs:
using: composite
steps:
- name: Deploy to Netlify
id: deploy
shell: bash
env:
DIR: ${{ inputs.dir }}
ALIAS: ${{ inputs.alias }}
NETLIFY_AUTH_TOKEN: ${{ inputs.auth-token }}
NETLIFY_SITE_ID: ${{ inputs.site-id }}
run: |
# Execute the deployment
if [ -n "$ALIAS" ]; then
npx -y netlify-cli deploy --dir="$DIR" --alias "$ALIAS" --json > deploy_output.json
else
npx -y netlify-cli deploy --dir="$DIR" --prod --json > deploy_output.json
fi
# Collect the urls from the deployment output
NETLIFY_URL=$(jq -r '.url // .deploy_url' deploy_output.json)
SITE_NAME=$(jq -r '.site_name' deploy_output.json)
DEPLOY_ID=$(jq -r '.deploy_id' deploy_output.json)
UNIQUE_URL="https://${DEPLOY_ID}--${SITE_NAME}.netlify.app"
echo "netlify_url=$NETLIFY_URL" >> "$GITHUB_OUTPUT"
echo "unique_deploy_url=$UNIQUE_URL" >> "$GITHUB_OUTPUT"
+40
View File
@@ -0,0 +1,40 @@
name: Setup Node and install
description: Set up Node from .nvmrc and install yarn dependencies
inputs:
immutable:
description: Pass --immutable to yarn install
default: "true"
cache:
description: Enable the yarn cache in setup-node
default: "true"
node-modules-cache:
description: Restore the exact shared node_modules cache before installing
default: "false"
runs:
using: composite
steps:
- name: Restore complete dependency tree
id: dependency-cache
if: inputs.node-modules-cache == 'true'
continue-on-error: true
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: |
node_modules
.yarn/install-state.gz
key: >-
node-modules-v1-${{ runner.os }}-${{ runner.arch }}-${{
hashFiles('.nvmrc', 'package.json', 'yarn.lock', '.yarnrc.yml', '.yarn/releases/**', '.yarn/patches/**') }}
- name: Setup Node
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version-file: ".nvmrc"
cache: ${{ inputs.cache == 'true' && (inputs.node-modules-cache != 'true' || steps.dependency-cache.outputs.cache-hit != 'true') && 'yarn' || '' }}
- name: Install dependencies
if: inputs.node-modules-cache != 'true' || steps.dependency-cache.outputs.cache-hit != 'true'
shell: bash
run: yarn install ${{ inputs.immutable == 'true' && '--immutable' || '' }}
-669
View File
@@ -1,669 +0,0 @@
# GitHub Copilot & Claude Code Instructions
You are an assistant helping with development of the Home Assistant frontend. The frontend is built using Lit-based Web Components and TypeScript, providing a responsive and performant interface for home automation control.
**Note**: This file contains high-level guidelines and references to implementation patterns. For gallery-specific documentation, demos, page structure, and usage examples, see [`gallery/AGENTS.md`](gallery/AGENTS.md).
## Table of Contents
- [Quick Reference](#quick-reference)
- [Core Architecture](#core-architecture)
- [State Access: Contexts Instead of `hass`](#state-access-contexts-instead-of-hass)
- [Development Standards](#development-standards)
- [Component Library](#component-library)
- [Common Patterns](#common-patterns)
- [Text and Copy Guidelines](#text-and-copy-guidelines)
- [Development Workflow](#development-workflow)
- [Review Guidelines](#review-guidelines)
## Quick Reference
### Essential Commands
```bash
yarn lint # ESLint + Prettier + TypeScript + Lit
yarn format # Auto-fix ESLint + Prettier
yarn lint:types # TypeScript compiler (run WITHOUT file arguments)
yarn test # Vitest
yarn dev # Dev server (app; --background/--status/--stop/--logs)
yarn dev:serve # Dev server with serve (-c core URL, -p port; --background/--status/--stop/--logs)
```
> **WARNING:** Never run `tsc` or `yarn lint:types` with file arguments (e.g., `yarn lint:types src/file.ts`). When `tsc` receives file arguments, it ignores `tsconfig.json` and emits `.js` files into `src/`, polluting the codebase. Always run `yarn lint:types` without arguments. For individual file type checking, rely on IDE diagnostics. If `.js` files are accidentally generated, clean up with `git clean -fd src/`.
### Component Prefixes
- `ha-` - Home Assistant components
- `hui-` - Lovelace UI components
- `dialog-` - Dialog components
### Import Patterns
```typescript
import type { HomeAssistant } from "../types";
import { fireEvent } from "../common/dom/fire_event";
import { showAlertDialog } from "../dialogs/generic/show-dialog-box";
```
## Core Architecture
The Home Assistant frontend is a modern web application that:
- Uses Web Components (custom elements) built with Lit framework
- Is written entirely in TypeScript with strict type checking
- Communicates with the backend via WebSocket API
- Provides comprehensive theming and internationalization
## State Access: Contexts Instead of `hass`
Every component used to take the whole `hass: HomeAssistant` object — a god-object that re-renders on any unrelated `hass` change, forces tests to mock everything, and hides what a component actually reads. We're moving leaf components to **fine-grained [Lit context](https://lit.dev/docs/data/context/)**: consume only the slice you need and re-render only when it changes.
For new code, consume the matching context instead of adding a `hass` property. `hass` stays for container components that own it and feed the providers; the canonical migration is [`hui-button-card.ts`](src/panels/lovelace/cards/hui-button-card.ts). Infrastructure: contexts in [`src/data/context/index.ts`](src/data/context/index.ts), the `consume…` helpers in [`src/common/decorators/consume-context-entry.ts`](src/common/decorators/consume-context-entry.ts), and `@transform` in [`src/common/decorators/transform.ts`](src/common/decorators/transform.ts). Providers are wired automatically by `contextMixin` on `HassBaseEl` — you only consume.
### Contexts
Consume the narrowest context that covers your reads:
| Context | Replaces |
| ----------------------------------------------------------------------- | ----------------------------------------------------------------------------------------- |
| `statesContext` | `hass.states` |
| `entitiesContext` / `devicesContext` / `areasContext` / `floorsContext` | `hass.entities` / `.devices` / `.areas` / `.floors` (or `registriesContext` for all four) |
| `servicesContext` | `hass.services` |
| `internationalizationContext` | `hass.localize`, `hass.locale`, `hass.language` |
| `formattersContext` | `hass.formatEntityName`, `hass.formatEntityState`, `hass.formatEntityAttributeName`, … |
| `configContext` | `hass.config`, `hass.user`, `hass.auth`, `hass.userData` |
| `connectionContext` | `hass.connection`, `hass.connected`, `hass.hassUrl` |
| `apiContext` | `hass.callService`, `hass.callApi`, `hass.callWS`, `hass.sendWS`, `hass.fetchWithAuth` |
| `uiContext` | `hass.themes`, `hass.selectedTheme`, `hass.panels`, `hass.dockedSidebar`, … |
| `narrowViewportContext` | narrow-layout boolean |
Lazy contexts (subscribe on first consumer, tear down after the last): `labelsContext`, `fullEntitiesContext`, `configEntriesContext`, `manifestsContext`. The single-field contexts (`localizeContext`, `themesContext`, `userContext`, …) are **deprecated** — use the grouped ones above.
### Consuming
Use the `consume…` helpers for entity-scoped and `localize` reads. `entityIdPath` is resolved against `this`, so these watch `this._config.entity`:
```ts
@state() @consumeEntityState({ entityIdPath: ["_config", "entity"] })
private _stateObj?: HassEntity; // consumeEntityStates(...) for a record of several
@state() @consumeEntityRegistryEntry({ entityIdPath: ["_config", "entity"] })
private _entity?: EntityRegistryDisplayEntry;
@state() @consumeLocalize()
private _localize!: LocalizeFunc;
```
For any other single field, pair `@consume` with `@transform`:
```ts
@state()
@consume({ context: uiContext, subscribe: true })
@transform<HomeAssistantUI, Themes>({ transformer: ({ themes }) => themes })
private _themes!: Themes;
```
`@transform`'s `watch` option re-runs the transformer when a host prop changes — needed when an entity id is computed, since `consumeEntityState` only watches the first path segment. To consume a whole group untransformed, drop `@transform` and type it `ContextType<typeof statesContext>`.
## Development Standards
### Code Quality Requirements
**Linting and Formatting (Enforced by Tools)**
- ESLint config (flat config) extends TypeScript strict, Lit, Web Components, Accessibility (lit-a11y), and import-x
- Prettier with ES5 trailing commas enforced
- No console statements (`no-console: "error"`) - use proper logging
- Import organization: No unused imports, consistent type imports
**Naming Conventions**
- PascalCase for types and classes
- camelCase for variables, methods
- Private methods require leading underscore
- Public methods forbid leading underscore
### TypeScript Usage
- **Always use strict TypeScript**: Enable all strict flags, avoid `any` types
- **Proper type imports**: Use `import type` for type-only imports
- **Define interfaces**: Create proper interfaces for data structures
- **Type component properties**: All Lit properties must be properly typed
- **No unused variables**: Prefix with `_` if intentionally unused
- **Consistent imports**: Use `@typescript-eslint/consistent-type-imports`
```typescript
// Good
import type { HomeAssistant } from "../types";
interface EntityConfig {
entity: string;
name?: string;
}
@property({ type: Object })
hass!: HomeAssistant;
// Bad
@property()
hass: any;
```
### Web Components with Lit
- **Use Lit 3.x patterns**: Follow modern Lit practices
- **Extend appropriate base classes**: Use `LitElement`, `SubscribeMixin`, or other mixins as needed
- **Define custom element names**: Use `ha-` prefix for components
```typescript
@customElement("ha-my-component")
export class HaMyComponent extends LitElement {
@property({ attribute: false })
hass!: HomeAssistant;
@state()
private _config?: MyComponentConfig;
static get styles() {
return css`
:host {
display: block;
}
`;
}
render() {
return html`<div>Content</div>`;
}
}
```
### Component Guidelines
- **Use composition**: Prefer composition over inheritance
- **Lazy load panels**: Heavy panels should be dynamically imported
- **Optimize renders**: Use `@state()` for internal state, `@property()` for public API
- **Handle loading states**: Always show appropriate loading indicators
- **Support themes**: Use CSS custom properties from theme
### Data Management
- **Use WebSocket API**: All backend communication via home-assistant-js-websocket
- **Prefer contexts over `hass`**: For state reads, consume the relevant Lit context instead of taking the whole `hass` object — see [State Access: Contexts Instead of `hass`](#state-access-contexts-instead-of-hass)
- **Cache appropriately**: Use collections and caching for frequently accessed data
- **Handle errors gracefully**: All API calls should have error handling
- **Update real-time**: Subscribe to state changes for live updates
```typescript
// Good
try {
const result = await fetchEntityRegistry(this.hass.connection);
this._processResult(result);
} catch (err) {
showAlertDialog(this, {
text: `Failed to load: ${err.message}`,
});
}
```
### Styling Guidelines
- **Use CSS custom properties**: Leverage the theme system
- **Use spacing tokens**: Prefer `--ha-space-*` tokens over hardcoded values for consistent spacing
- Spacing scale: `--ha-space-1` (4px) through `--ha-space-20` (80px) in 4px increments
- Defined in `src/resources/theme/core.globals.ts`
- Common values: `--ha-space-2` (8px), `--ha-space-4` (16px), `--ha-space-8` (32px)
- **Mobile-first responsive**: Design for mobile, enhance for desktop
- **Prefer `ha-*` components**: Build on the Home Assistant component library (many now wrap Web Awesome components); avoid new use of legacy Material Web Components (`mwc-*`), which are being phased out
- **Support RTL**: Ensure all layouts work in RTL languages
```typescript
static get styles() {
return css`
:host {
padding: var(--ha-space-4);
color: var(--primary-text-color);
background-color: var(--card-background-color);
}
.content {
gap: var(--ha-space-2);
}
@media (max-width: 600px) {
:host {
padding: var(--ha-space-2);
}
}
`;
}
```
### View Transitions
The View Transitions API creates smooth animations between DOM state changes. When implementing view transitions:
**Core Resources:**
- **Utility wrapper**: `src/common/util/view-transition.ts` - `withViewTransition()` function with graceful fallback
- **Real-world example**: `src/util/launch-screen.ts` - Launch screen fade pattern with browser support detection
- **Animation keyframes**: `src/resources/theme/animations.globals.ts` - Global `fade-in`, `fade-out`, `scale` animations
- **Animation duration**: `src/resources/theme/core.globals.ts` - `--ha-animation-duration-fast` (150ms), `--ha-animation-duration-normal` (250ms), `--ha-animation-duration-slow` (350ms) (all respect `prefers-reduced-motion`)
**Implementation Guidelines:**
1. Always use `withViewTransition()` wrapper for automatic fallback
2. Keep transitions simple (subtle crossfades and fades work best)
3. Use `--ha-animation-duration-*` CSS variables for consistent timing (`fast`, `normal`, `slow`)
4. Assign unique `view-transition-name` to elements (must be unique at any given time)
5. For Lit components: Override `performUpdate()` or use `::part()` for internal elements
**Default Root Transition:**
By default, `:root` receives `view-transition-name: root`, creating a full-page crossfade. Target with [`::view-transition-group(root)`](https://developer.mozilla.org/en-US/docs/Web/CSS/::view-transition-group) to customize the default page transition.
**Important Constraints:**
- Each `view-transition-name` must be unique at any given time
- Only one view transition can run at a time
- **Shadow DOM incompatibility**: View transitions operate at document level and do not work within Shadow DOM due to style isolation ([spec discussion](https://github.com/w3c/csswg-drafts/issues/10303)). For web components, set `view-transition-name` on the `:host` element or use document-level transitions
**Specification & Documentation:**
For browser support, API details, and current specifications, refer to these authoritative sources (note: check publication dates as specs evolve):
- [MDN: View Transition API](https://developer.mozilla.org/en-US/docs/Web/API/View_Transition_API) - Comprehensive API reference
- [Chrome for Developers: View Transitions](https://developer.chrome.com/docs/web-platform/view-transitions) - Implementation guide and examples
- [W3C Draft Specification](https://drafts.csswg.org/css-view-transitions/) - Official specification (evolving)
### Performance Best Practices
- **Code split**: Split code at the panel/dialog level
- **Lazy load**: Use dynamic imports for heavy components
- **Optimize bundle**: Keep initial bundle size minimal
- **Use virtual scrolling**: For long lists, implement virtual scrolling
- **Memoize computations**: Cache expensive calculations
### Testing Requirements
- **Write tests**: Add tests for data processing and utilities
- **Test with Vitest**: Use the established test framework
- **Mock appropriately**: Mock WebSocket connections and API calls
- **Test accessibility**: Ensure components are accessible
- **Optimizing chart data processing**: When optimizing chart data transforms (history, statistics, energy, downsampling), follow the playbook in [`test/benchmarks/README.md`](test/benchmarks/README.md) — it has seeded fixtures, characterization (snapshot) tests that pin current output, and `vitest bench` benchmarks (`yarn test:bench`) for before/after comparison. Optimizations must keep output bit-identical.
## Component Library
### Dialog Component
**Opening Dialogs (Fire Event Pattern - Recommended):**
```typescript
fireEvent(this, "show-dialog", {
dialogTag: "dialog-example",
dialogImport: () => import("./dialog-example"),
dialogParams: { title: "Example", data: someData },
});
```
**Dialog Implementation Requirements:**
- Use `ha-dialog` component
- Implement `HassDialog<T>` interface
- Use `@state() private _open = false` to control dialog visibility
- Set `_open = true` in `showDialog()`, `_open = false` in `closeDialog()`
- Return `nothing` when no params (loading state)
- Fire `dialog-closed` event in `_dialogClosed()` handler
- Use `header-title` attribute for simple titles
- Use `header-subtitle` attribute for simple subtitles
- Use slots for custom content where the standard attributes are not enough
- Use `ha-dialog-footer` with `primaryAction`/`secondaryAction` slots for footer content
- Add `autofocus` to first focusable element (e.g., `<ha-form autofocus>`). The component may need to forward this attribute internally.
**Dialog Sizing:**
- Use `width` attribute with predefined sizes: `"small"` (320px), `"medium"` (580px - default), `"large"` (1024px), or `"full"`
- Custom sizing is NOT recommended - use the standard width presets
**Button Appearance Guidelines:**
`ha-button` (wraps the Web Awesome button — see `src/components/ha-button.ts`) has two independent axes plus size:
- **`variant`** (color): `"brand"` (default), `"neutral"`, `"danger"`, `"warning"`, `"success"`
- **`appearance`** (fill style): `"accent"`, `"filled"`, `"outlined"`, `"plain"`
- **`size`**: `"xs"` (extra small, 40px), `"s"` (small, 32px), `"m"` (medium, 40px - default), `"l"` (large, 48px), `"xl"` (extra large, 40px)
Common patterns:
- **Primary action**: `appearance="filled"` for emphasis (or the default appearance for a lighter look)
- **Secondary action**: `appearance="plain"` for cancel/dismiss actions
- **Destructive actions**: `variant="danger"` for delete/remove operations (the generic confirmation dialog uses `variant="danger"` for its confirm button — see `src/dialogs/generic/dialog-box.ts`)
- Always place primary action in `slot="primaryAction"` and secondary in `slot="secondaryAction"` within `ha-dialog-footer`
### Form Component (ha-form)
- Schema-driven using `HaFormSchema[]`
- Supports entity, device, area, target, number, boolean, time, action, text, object, select, icon, media, location selectors
- Built-in validation with error display
- Use `computeLabel`, `computeError`, `computeHelper` for translations
```typescript
<ha-form
.hass=${this.hass}
.data=${this._data}
.schema=${this._schema}
.error=${this._errors}
.computeLabel=${(schema) => this.hass.localize(`ui.panel.${schema.name}`)}
@value-changed=${this._valueChanged}
></ha-form>
```
### Alert Component (ha-alert)
- Types: `error`, `warning`, `info`, `success`
- Properties: `title`, `alert-type`, `dismissable`, `narrow`
- Slots: `icon` (override the leading icon), `action` (custom action content)
- Content announced by screen readers when dynamically displayed
```html
<ha-alert alert-type="error">Error message</ha-alert>
<ha-alert alert-type="warning" title="Warning">Description</ha-alert>
<ha-alert alert-type="success" dismissable>Success message</ha-alert>
```
### Keyboard Shortcuts (ShortcutManager)
The `ShortcutManager` class provides a unified way to register keyboard shortcuts with automatic input field protection.
**Key Features:**
- Automatically blocks shortcuts when input fields are focused
- Prevents shortcuts during text selection (configurable via `allowWhenTextSelected`)
- Supports both character-based and KeyCode-based shortcuts (for non-latin keyboards)
**Implementation:**
- **Class definition**: `src/common/keyboard/shortcuts.ts`
- **Real-world example**: `src/state/quick-bar-mixin.ts` - Global shortcuts (e, c, d, m, a, Shift+?) with non-latin keyboard fallbacks
### Tooltip Component (ha-tooltip)
The `ha-tooltip` component wraps Web Awesome tooltip with Home Assistant theming. Use for providing contextual help text on hover.
**Implementation:**
- **Component definition**: `src/components/ha-tooltip.ts`
- **Usage example**: `src/components/ha-label.ts`
## Common Patterns
### Creating a Panel
```typescript
@customElement("ha-panel-myfeature")
export class HaPanelMyFeature extends SubscribeMixin(LitElement) {
@property({ attribute: false })
hass!: HomeAssistant;
@property({ type: Boolean, reflect: true })
narrow!: boolean;
@property()
route!: Route;
hassSubscribe() {
return [
subscribeEntityRegistry(this.hass.connection, (entities) => {
this._entities = entities;
}),
];
}
}
```
#### Creating a Lovelace Card
**Purpose**: Cards allow users to tell different stories about their house.
```typescript
@customElement("hui-my-card")
export class HuiMyCard extends LitElement implements LovelaceCard {
@property({ attribute: false })
hass!: HomeAssistant;
@state()
private _config?: MyCardConfig;
public setConfig(config: MyCardConfig): void {
if (!config.entity) {
throw new Error("Entity required");
}
this._config = config;
}
public getCardSize(): number {
return 3; // Height in grid units
}
// Optional: Editor for card configuration
public static getConfigElement(): LovelaceCardEditor {
return document.createElement("hui-my-card-editor");
}
// Optional: Stub config for card picker
public static getStubConfig(): object {
return { entity: "" };
}
}
```
**Card Guidelines:**
- Cards are highly customizable for different households
- Implement `LovelaceCard` interface with `setConfig()` and `getCardSize()`
- Use proper error handling in `setConfig()`
- Consider all possible states (loading, error, unavailable)
- Support different entity types and states
- Follow responsive design principles
- Add configuration editor when needed
### Internationalization
- **Use localize**: Always use the localization system
- **Add translation keys**: Add keys to src/translations/en.json
- **Support placeholders**: Use proper placeholder syntax
```typescript
this.hass.localize("ui.panel.config.updates.update_available", {
count: 5,
});
```
### Accessibility
- **ARIA labels**: Add appropriate ARIA labels
- **Keyboard navigation**: Ensure all interactions work with keyboard
- **Screen reader support**: Test with screen readers
- **Color contrast**: Meet WCAG AA standards
## Development Workflow
### Setup and Commands
1. **Setup**: `script/setup` - Install dependencies
2. **Develop**: `script/develop` - Development server
3. **Lint**: `yarn lint` - Run all linting before committing
4. **Test**: `yarn test` - Add and run tests
5. **Build**: `script/build_frontend` - Test production build
### Dev servers
`yarn dev` builds and watches the app, served by a running Home Assistant core (`development_repo` setting). `yarn dev:serve` also serves it locally (`-c` core URL, `-p` port, default 8124).
These and the e2e dev servers below take `--background`, `--status`, `--stop`, and `--logs [--follow]`.
### End-to-end (e2e) tests
Each Playwright suite has a dev server on its own port. Playwright reuses a server already on the port (`reuseExistingServer` locally); otherwise it does a slow full build. The rspack watcher recompiles on save, so re-runs need no restart.
Start the suite's dev server, then run the suite:
- **App** (8095): `yarn test:e2e:app:dev`, then `yarn test:e2e:app`
- **Demo** (8090): `yarn dev:demo`, then `yarn test:e2e:demo`
- **Gallery** (8100): `yarn dev:gallery`, then `yarn test:e2e:gallery`
Server reuse and `--stop` key off a `/__ha_dev_status` health check, so starting or stopping twice is harmless. The app suite uses a stripped-down harness built only for e2e; demo and gallery use their normal dev servers.
Add `-g "<title>" --project=chromium` to narrow a run; `yarn test:e2e` runs all three. Run the suite directly, since piping through `tail`/`head` hides progress and truncates results.
### Gallery
For Gallery-specific structure, page/demo naming, sidebar behavior, content standards, and commands, see [`gallery/AGENTS.md`](gallery/AGENTS.md).
### Common Pitfalls to Avoid
- Don't manually query the DOM with `querySelector` - use the `@query`/`@queryAll` decorators or component properties
- Don't manipulate DOM directly - Let Lit handle rendering
- Don't use global styles - Scope styles to components
- Don't block the main thread - Use web workers for heavy computation
- Don't ignore TypeScript errors - Fix all type issues
### Security Best Practices
- Sanitize HTML - Never use `unsafeHTML` with user content
- Validate inputs - Always validate user inputs
- Use HTTPS - All external resources must use HTTPS
- CSP compliance - Ensure code works with Content Security Policy
### Pull Requests
When creating a pull request, you **must** use the PR template located at `.github/PULL_REQUEST_TEMPLATE.md`. Read the template file and use its full content as the PR body, filling in each section appropriately.
- Do not omit, reorder, or rewrite the template sections
- Check the appropriate "Type of change" box based on the changes
- Do not check the checklist items on behalf of the user — those are the user's responsibility to review and check
- If the PR includes UI changes, remind the user to add screenshots or a short video to the PR after creating it
- Be simple and user friendly — explain what the change does, not implementation details
- Use markdown so the user can copy it
### Text and Copy Guidelines
#### Terminology Standards
**Delete vs Remove**
- **Use "Remove"** for actions that can be restored or reapplied:
- Removing a user's permission
- Removing a user from a group
- Removing links between items
- Removing a widget from dashboard
- Removing an item from a cart
- **Use "Delete"** for permanent, non-recoverable actions:
- Deleting a field
- Deleting a value in a field
- Deleting a task
- Deleting a group
- Deleting a permission
- Deleting a calendar event
**Create vs Add** (Create pairs with Delete, Add pairs with Remove)
- **Use "Add"** for already-existing items:
- Adding a permission to a user
- Adding a user to a group
- Adding links between items
- Adding a widget to dashboard
- Adding an item to a cart
- **Use "Create"** for something made from scratch:
- Creating a new field
- Creating a new task
- Creating a new group
- Creating a new permission
- Creating a new calendar event
#### Writing Style (Consistent with Home Assistant Documentation)
- **Use American English**: Standard spelling and terminology
- **Friendly, informational tone**: Be inspiring, personal, comforting, engaging
- **Address users directly**: Use "you" and "your"
- **Be inclusive**: Objective, non-discriminatory language
- **Be concise**: Use clear, direct language
- **Be consistent**: Follow established terminology patterns
- **Use active voice**: "Delete the automation" not "The automation should be deleted"
- **Avoid jargon**: Use terms familiar to home automation users
#### Language Standards
- **Always use "Home Assistant"** in full, never "HA" or "HASS"
- **Avoid abbreviations**: Spell out terms when possible
- **Use sentence case everywhere**: Titles, headings, buttons, labels, UI elements
- ✅ "Create new automation"
- ❌ "Create New Automation"
- ✅ "Device settings"
- ❌ "Device Settings"
- **Oxford comma**: Use in lists (item 1, item 2, and item 3)
- **Replace Latin terms**: Use "like" instead of "e.g.", "for example" instead of "i.e."
- **Avoid CAPS for emphasis**: Use bold or italics instead
- **Write for all skill levels**: Both technical and non-technical users
#### Key Terminology
- **"integration"** (preferred over "component")
- **Technical terms**: Use lowercase (automation, entity, device, service)
#### Translation Considerations
All user-facing text must be translatable — see the **Internationalization** section (under Common Patterns) for the `localize` API and placeholder usage. From a copy perspective:
- **Keep context**: Provide enough context for translators
- **Avoid concatenation**: Prefer full localized strings with placeholders over stitching translated fragments together
### Common Review Issues (From PR Analysis)
Recurring, easy-to-miss problems surfaced in real PR reviews. These complement the standards above rather than repeating them — items already covered earlier (loading states, error handling, mobile layout, theming, import hygiene) are intentionally not duplicated here.
#### User Experience and Accessibility
- **Form validation**: Always provide proper field labels and validation feedback
- **Form accessibility**: Prevent password managers from incorrectly identifying fields
- **Hit targets**: Make clickable areas large enough for touch interaction
- **Visual feedback**: Provide clear indication of interactive states (hover, active, focus)
#### Dialog and Modal Patterns
- **Interview progress**: Show clear progress for multi-step operations
- **State persistence**: Handle dialog state properly during background operations
- **Cancel behavior**: Ensure cancel/close buttons work consistently
- **Form prefilling**: Use smart defaults but allow user override
#### Component Design Patterns
- **Terminology consistency**: Use "Join"/"Apply" instead of "Group" when appropriate
- **Visual hierarchy**: Ensure proper font sizes and spacing ratios
- **Grid alignment**: Components should align to the design grid system
- **Badge placement**: Position badges and indicators consistently
#### Code Quality Issues
- **Null checking**: Always check if entities exist before accessing properties
- **TypeScript safety**: Handle potentially undefined array/object access
- **Event handling and cleanup**: Subscribe/unsubscribe correctly and remove listeners to avoid memory leaks
#### Configuration and Props
- **Optional parameters**: Make configuration fields optional when sensible
- **Smart defaults**: Provide reasonable default values
- **Future extensibility**: Design APIs that can be extended later
- **Validation**: Validate configuration before applying changes
## Review Guidelines
Final pre-submission checklist. Linting and formatting are enforced by tooling, so this focuses on what tools can't catch rather than restating every rule above.
- [ ] `yarn lint` passes (TypeScript, ESLint, Prettier, Lit analyzer) and `yarn test` is green
- [ ] Tests added for new data processing/utilities (where applicable)
- [ ] All user-facing text is localized and follows the Text and Copy guidelines (sentence case, "Home Assistant" in full, Delete/Remove + Create/Add)
- [ ] Components handle all states (loading, error, unavailable)
- [ ] Entity existence checked before property access
- [ ] Event/subscription listeners cleaned up (no memory leaks)
- [ ] Accessible to screen readers and keyboard
+1
View File
@@ -0,0 +1 @@
../AGENTS.md
+8 -1
View File
@@ -1,7 +1,14 @@
version: 2
updates:
- package-ecosystem: "github-actions"
directory: "/"
# Dependabot only scans .github/workflows by default; composite actions
# under .github/actions must be listed explicitly to stay updated.
# https://github.com/dependabot/dependabot-core/issues/6704
directories:
- "/"
- "/.github/actions/setup"
- "/.github/actions/build"
- "/.github/actions/netlify-deploy"
schedule:
interval: weekly
time: "06:00"
+31
View File
@@ -42,5 +42,36 @@ Dependencies:
GitHub Actions:
- changed-files:
- any-glob-to-any-file:
- .github/actions/**
- .github/workflows/**
- .github/*.yml
"Tests: E2E":
- changed-files:
- any-glob-to-any-file:
- test/e2e/**
"Tests: App":
- changed-files:
- any-glob-to-any-file:
- test/e2e/app.spec.ts
- test/e2e/app/**
- test/e2e/playwright.app.config.ts
"Tests: Demo":
- changed-files:
- any-glob-to-any-file:
- test/e2e/demo.spec.ts
- test/e2e/playwright.demo.config.ts
"Tests: Design":
- changed-files:
- any-glob-to-any-file:
- test/e2e/gallery.spec.ts
- test/e2e/playwright.gallery.config.ts
"Tests: Unit":
- changed-files:
- any-glob-to-any-file:
- test/*.ts
- "test/!(e2e)/**"
+2
View File
@@ -6,12 +6,14 @@ on:
- dev
- master
paths:
- ".github/actions/**"
- ".github/workflows/**"
pull_request:
branches:
- dev
- master
paths:
- ".github/actions/**"
- ".github/workflows/**"
concurrency:
+23 -34
View File
@@ -29,28 +29,23 @@ jobs:
ref: dev
persist-credentials: false
- name: Setup Node
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version-file: ".nvmrc"
cache: yarn
- name: Install dependencies
run: yarn install --immutable
- name: Setup Node and install
uses: ./.github/actions/setup
- name: Build Cast
run: ./node_modules/.bin/gulp build-cast
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
uses: ./.github/actions/build
with:
target: build-cast
github-token: ${{ secrets.GITHUB_TOKEN }}
- name: Deploy to Netlify
id: deploy
run: |
npx -y netlify-cli deploy --dir=cast/dist --alias dev --json > deploy_output.json
echo "netlify_url=$(jq -r '.url // .deploy_url' deploy_output.json)" >> "$GITHUB_OUTPUT"
env:
NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }}
NETLIFY_SITE_ID: ${{ secrets.NETLIFY_CAST_SITE_ID }}
uses: ./.github/actions/netlify-deploy
with:
dir: cast/dist
alias: dev
auth-token: ${{ secrets.NETLIFY_AUTH_TOKEN }}
site-id: ${{ secrets.NETLIFY_CAST_SITE_ID }}
deploy_master:
runs-on: ubuntu-latest
@@ -66,25 +61,19 @@ jobs:
ref: master
persist-credentials: false
- name: Setup Node
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version-file: ".nvmrc"
cache: yarn
- name: Install dependencies
run: yarn install --immutable
- name: Setup Node and install
uses: ./.github/actions/setup
- name: Build Cast
run: ./node_modules/.bin/gulp build-cast
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
uses: ./.github/actions/build
with:
target: build-cast
github-token: ${{ secrets.GITHUB_TOKEN }}
- name: Deploy to Netlify
id: deploy
run: |
npx -y netlify-cli deploy --dir=cast/dist --prod --json > deploy_output.json
echo "netlify_url=$(jq -r '.url // .deploy_url' deploy_output.json)" >> "$GITHUB_OUTPUT"
env:
NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }}
NETLIFY_SITE_ID: ${{ secrets.NETLIFY_CAST_SITE_ID }}
uses: ./.github/actions/netlify-deploy
with:
dir: cast/dist
auth-token: ${{ secrets.NETLIFY_AUTH_TOKEN }}
site-id: ${{ secrets.NETLIFY_CAST_SITE_ID }}
+58 -25
View File
@@ -1,6 +1,7 @@
name: CI
on:
workflow_dispatch:
push:
branches:
- dev
@@ -21,21 +22,56 @@ permissions:
contents: read
jobs:
lint:
name: Lint and check format
prepare-dependencies:
name: Prepare dependencies
runs-on: ubuntu-latest
steps:
- name: Check out files from GitHub
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
- name: Setup Node
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
- name: Check for complete dependency tree
id: dependencies
continue-on-error: true
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
node-version-file: ".nvmrc"
cache: yarn
- name: Install dependencies
run: yarn install --immutable
path: |
node_modules
.yarn/install-state.gz
key: >-
node-modules-v1-${{ runner.os }}-${{ runner.arch }}-${{
hashFiles('.nvmrc', 'package.json', 'yarn.lock', '.yarnrc.yml', '.yarn/releases/**', '.yarn/patches/**') }}
lookup-only: true
- name: Setup Node and install
if: steps.dependencies.outputs.cache-hit != 'true'
uses: ./.github/actions/setup
with:
cache: false
- name: Save complete dependency tree
if: steps.dependencies.outputs.cache-hit != 'true'
continue-on-error: true
uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: |
node_modules
.yarn/install-state.gz
key: >-
node-modules-v1-${{ runner.os }}-${{ runner.arch }}-${{
hashFiles('.nvmrc', 'package.json', 'yarn.lock', '.yarnrc.yml', '.yarn/releases/**', '.yarn/patches/**') }}
lint:
name: Lint and check format
needs: prepare-dependencies
runs-on: ubuntu-latest
steps:
- name: Check out files from GitHub
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
- name: Setup Node with shared dependencies
uses: ./.github/actions/setup
with:
node-modules-cache: true
- name: Check for duplicate dependencies
run: yarn dedupe --check
- name: Build resources
@@ -44,7 +80,7 @@ jobs:
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Setup lint cache
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: |
node_modules/.cache/prettier
@@ -68,19 +104,17 @@ jobs:
run: yarn run lint:licenses
test:
name: Run tests
needs: prepare-dependencies
runs-on: ubuntu-latest
steps:
- name: Check out files from GitHub
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
- name: Setup Node
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
- name: Setup Node with shared dependencies
uses: ./.github/actions/setup
with:
node-version-file: ".nvmrc"
cache: yarn
- name: Install dependencies
run: yarn install --immutable
node-modules-cache: true
- name: Build resources
run: ./node_modules/.bin/gulp gen-icons-json build-translations build-locale-data
env:
@@ -90,6 +124,7 @@ jobs:
build:
name: Build frontend
needs:
- prepare-dependencies
- lint
- test
runs-on: ubuntu-latest
@@ -98,18 +133,16 @@ jobs:
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
- name: Setup Node
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
- name: Setup Node with shared dependencies
uses: ./.github/actions/setup
with:
node-version-file: ".nvmrc"
cache: yarn
- name: Install dependencies
run: yarn install --immutable
node-modules-cache: true
- name: Build Application
run: ./node_modules/.bin/gulp build-app
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
IS_TEST: "true"
uses: ./.github/actions/build
with:
target: build-app
github-token: ${{ secrets.GITHUB_TOKEN }}
is-test: true
- name: Upload bundle stats
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
+2 -2
View File
@@ -32,12 +32,12 @@ jobs:
persist-credentials: false
- name: Initialize CodeQL
uses: github/codeql-action/init@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2
uses: github/codeql-action/init@54f647b7e1bb85c95cddabcd46b0c578ec92bc1a # v4.36.3
with:
languages: javascript-typescript
build-mode: none
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2
uses: github/codeql-action/analyze@54f647b7e1bb85c95cddabcd46b0c578ec92bc1a # v4.36.3
with:
category: "/language:javascript-typescript"
+22 -34
View File
@@ -30,28 +30,22 @@ jobs:
ref: dev
persist-credentials: false
- name: Setup Node
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version-file: ".nvmrc"
cache: yarn
- name: Install dependencies
run: yarn install --immutable
- name: Setup Node and install
uses: ./.github/actions/setup
- name: Build Demo
run: ./node_modules/.bin/gulp build-demo
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
uses: ./.github/actions/build
with:
target: build-demo
github-token: ${{ secrets.GITHUB_TOKEN }}
- name: Deploy to Netlify
id: deploy
run: |
npx -y netlify-cli deploy --dir=demo/dist --prod --json > deploy_output.json
echo "netlify_url=$(jq -r '.url // .deploy_url' deploy_output.json)" >> "$GITHUB_OUTPUT"
env:
NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }}
NETLIFY_SITE_ID: ${{ secrets.NETLIFY_DEMO_DEV_SITE_ID }}
uses: ./.github/actions/netlify-deploy
with:
dir: demo/dist
auth-token: ${{ secrets.NETLIFY_AUTH_TOKEN }}
site-id: ${{ secrets.NETLIFY_DEMO_DEV_SITE_ID }}
deploy_master:
runs-on: ubuntu-latest
@@ -67,25 +61,19 @@ jobs:
ref: master
persist-credentials: false
- name: Setup Node
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version-file: ".nvmrc"
cache: yarn
- name: Install dependencies
run: yarn install --immutable
- name: Setup Node and install
uses: ./.github/actions/setup
- name: Build Demo
run: ./node_modules/.bin/gulp build-demo
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
uses: ./.github/actions/build
with:
target: build-demo
github-token: ${{ secrets.GITHUB_TOKEN }}
- name: Deploy to Netlify
id: deploy
run: |
npx -y netlify-cli deploy --dir=demo/dist --prod --json > deploy_output.json
echo "netlify_url=$(jq -r '.url // .deploy_url' deploy_output.json)" >> "$GITHUB_OUTPUT"
env:
NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }}
NETLIFY_SITE_ID: ${{ secrets.NETLIFY_DEMO_SITE_ID }}
uses: ./.github/actions/netlify-deploy
with:
dir: demo/dist
auth-token: ${{ secrets.NETLIFY_AUTH_TOKEN }}
site-id: ${{ secrets.NETLIFY_DEMO_SITE_ID }}
+11 -17
View File
@@ -23,25 +23,19 @@ jobs:
with:
persist-credentials: false
- name: Setup Node
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version-file: ".nvmrc"
cache: yarn
- name: Install dependencies
run: yarn install --immutable
- name: Setup Node and install
uses: ./.github/actions/setup
- name: Build Gallery
run: ./node_modules/.bin/gulp build-gallery
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
uses: ./.github/actions/build
with:
target: build-gallery
github-token: ${{ secrets.GITHUB_TOKEN }}
- name: Deploy to Netlify
id: deploy
run: |
npx -y netlify-cli deploy --dir=gallery/dist --prod --json > deploy_output.json
echo "netlify_url=$(jq -r '.url // .deploy_url' deploy_output.json)" >> "$GITHUB_OUTPUT"
env:
NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }}
NETLIFY_SITE_ID: ${{ secrets.NETLIFY_GALLERY_SITE_ID }}
uses: ./.github/actions/netlify-deploy
with:
dir: gallery/dist
auth-token: ${{ secrets.NETLIFY_AUTH_TOKEN }}
site-id: ${{ secrets.NETLIFY_GALLERY_SITE_ID }}
+12 -18
View File
@@ -28,29 +28,23 @@ jobs:
with:
persist-credentials: false
- name: Setup Node
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version-file: ".nvmrc"
cache: yarn
- name: Install dependencies
run: yarn install --immutable
- name: Setup Node and install
uses: ./.github/actions/setup
- name: Build Gallery
run: ./node_modules/.bin/gulp build-gallery
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
uses: ./.github/actions/build
with:
target: build-gallery
github-token: ${{ secrets.GITHUB_TOKEN }}
- name: Deploy preview to Netlify
id: deploy
run: |
npx -y netlify-cli deploy --dir=gallery/dist --alias "deploy-preview-${{ github.event.number }}" \
--json > deploy_output.json
echo "netlify_url=$(jq -r '.url // .deploy_url' deploy_output.json)" >> "$GITHUB_OUTPUT"
env:
NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }}
NETLIFY_SITE_ID: ${{ secrets.NETLIFY_GALLERY_SITE_ID }}
uses: ./.github/actions/netlify-deploy
with:
dir: gallery/dist
alias: deploy-preview-${{ github.event.number }}
auth-token: ${{ secrets.NETLIFY_AUTH_TOKEN }}
site-id: ${{ secrets.NETLIFY_GALLERY_SITE_ID }}
- name: Generate summary
run: echo "${{ steps.deploy.outputs.netlify_url }}" >> "$GITHUB_STEP_SUMMARY"
+23 -50
View File
@@ -32,19 +32,14 @@ jobs:
with:
persist-credentials: false
- name: Setup Node
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version-file: ".nvmrc"
cache: yarn
- name: Install dependencies
run: yarn install --immutable
- name: Setup Node and install
uses: ./.github/actions/setup
- name: Build demo
run: ./node_modules/.bin/gulp build-demo
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
uses: ./.github/actions/build
with:
target: build-demo
github-token: ${{ secrets.GITHUB_TOKEN }}
- name: Upload demo build
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
@@ -64,19 +59,14 @@ jobs:
with:
persist-credentials: false
- name: Setup Node
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version-file: ".nvmrc"
cache: yarn
- name: Install dependencies
run: yarn install --immutable
- name: Setup Node and install
uses: ./.github/actions/setup
- name: Build e2e test app
run: ./node_modules/.bin/gulp build-e2e-test-app
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
uses: ./.github/actions/build
with:
target: build-e2e-test-app
github-token: ${{ secrets.GITHUB_TOKEN }}
- name: Upload e2e test app build
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
@@ -96,19 +86,14 @@ jobs:
with:
persist-credentials: false
- name: Setup Node
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version-file: ".nvmrc"
cache: yarn
- name: Install dependencies
run: yarn install --immutable
- name: Setup Node and install
uses: ./.github/actions/setup
- name: Build gallery
run: ./node_modules/.bin/gulp build-gallery
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
uses: ./.github/actions/build
with:
target: build-gallery
github-token: ${{ secrets.GITHUB_TOKEN }}
- name: Upload gallery build
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
@@ -133,14 +118,8 @@ jobs:
with:
persist-credentials: false
- name: Setup Node
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version-file: ".nvmrc"
cache: yarn
- name: Install dependencies
run: yarn install --immutable
- name: Setup Node and install
uses: ./.github/actions/setup
# Resolve the installed Playwright version so the browser cache tracks
# Playwright itself, not every unrelated dependency bump.
@@ -151,7 +130,7 @@ jobs:
# Cache the downloaded browser build keyed on the installed Playwright
# version, so re-runs skip the ~170 MB download unless Playwright changes.
- name: Cache Playwright browsers
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: ~/.cache/ms-playwright
key: ${{ runner.os }}-playwright-${{ steps.playwright-version.outputs.version }}
@@ -205,14 +184,8 @@ jobs:
with:
persist-credentials: false
- name: Setup Node
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version-file: ".nvmrc"
cache: yarn
- name: Install dependencies
run: yarn install --immutable
- name: Setup Node and install
uses: ./.github/actions/setup
- name: Download blob report (local)
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
+4 -8
View File
@@ -25,18 +25,14 @@ jobs:
persist-credentials: false
- name: Set up Python ${{ env.PYTHON_VERSION }}
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6
with:
python-version: ${{ env.PYTHON_VERSION }}
- name: Setup Node
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
- name: Setup Node and install
uses: ./.github/actions/setup
with:
node-version-file: ".nvmrc"
cache: yarn
- name: Install dependencies
run: yarn install
immutable: false
- name: Download translations
run: ./script/translations_download
+1 -1
View File
@@ -18,6 +18,6 @@ jobs:
pull-requests: read
runs-on: ubuntu-latest
steps:
- uses: release-drafter/release-drafter@ed4bc48ec97379be2258e7b7ac2624a3e26ab809 # v7.4.0
- uses: release-drafter/release-drafter@4d75298e00d9e34c483e5ff8c68d0ea1c1940c1e # v7.5.1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+10 -13
View File
@@ -31,20 +31,18 @@ jobs:
persist-credentials: false
- name: Set up Python ${{ env.PYTHON_VERSION }}
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0
with:
python-version: ${{ env.PYTHON_VERSION }}
- name: Verify version
uses: home-assistant/actions/helpers/verify-version@f4ca6f671bd429efb108c0f2fa0ae8af0215986c # master
- name: Setup Node
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
- name: Setup Node and install
uses: ./.github/actions/setup
with:
node-version-file: ".nvmrc"
- name: Install dependencies
run: yarn install
immutable: false
cache: false
- name: Download Translations
run: ./script/translations_download
@@ -97,7 +95,7 @@ jobs:
# home-assistant/wheels doesn't support SHA pinning
- name: Build wheels
uses: home-assistant/wheels@34957438948e0b3dcde73c77750643dadae594f5 # 2026.06.0
uses: home-assistant/wheels@9e17ab1ed5c4c79d8b61e29fa63de25ca2710716 # 2026.07.0
with:
abi: cp314
tag: musllinux_1_2
@@ -116,12 +114,11 @@ jobs:
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
- name: Setup Node
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
- name: Setup Node and install
uses: ./.github/actions/setup
with:
node-version-file: ".nvmrc"
- name: Install dependencies
run: yarn install
immutable: false
cache: false
- name: Download Translations
run: ./script/translations_download
env:
@@ -25,14 +25,8 @@ jobs:
with:
persist-credentials: false
- name: Set up Node
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version-file: ".nvmrc"
cache: yarn
- name: Install dependencies
run: yarn install --immutable
- name: Setup Node and install
uses: ./.github/actions/setup
- name: Regenerate numeric device classes
run: ./script/gen_numeric_device_classes
+6 -1
View File
@@ -6,6 +6,8 @@ build/
dist/
/hass_frontend/
/translations/
# Composite action source, not build output
!/.github/actions/build/
# yarn
.yarn/*
@@ -59,9 +61,12 @@ test/e2e/reports/
test/e2e/test-results/
# E2E test app build output
test/e2e/app/dist/
# MCP server
.playwright-mcp/
# AI tooling
.claude
.claude/*
!.claude/skills
.cursor
.opencode
.serena
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -13,4 +13,4 @@ nodeLinker: node-modules
npmMinimalAgeGate: 3d
yarnPath: .yarn/releases/yarn-4.17.0.cjs
yarnPath: .yarn/releases/yarn-4.17.1.cjs
-1
View File
@@ -1 +0,0 @@
.github/copilot-instructions.md
+52
View File
@@ -0,0 +1,52 @@
# Home Assistant Frontend Agent Guide
You are helping develop the Home Assistant frontend. This repository is a TypeScript application built from Lit-based Web Components for the Home Assistant web UI.
For gallery-specific documentation, demos, page structure, and examples, read `gallery/AGENTS.md` when working under `gallery/`.
## Essential Commands
```bash
yarn lint # ESLint + Prettier + TypeScript + Lit
yarn format # Auto-fix ESLint + Prettier
yarn lint:types # TypeScript compiler, run without file arguments
yarn test # Vitest
yarn dev # App dev server, supports --background/--status/--stop/--logs
yarn dev:serve # Local serving dev server, supports -c core URL, -p port, and dev flags
```
Never run `tsc` or `yarn lint:types` with file arguments. When `tsc` receives file arguments, it ignores `tsconfig.json` and can emit `.js` files into `src/`. Always run `yarn lint:types` without arguments. For individual file type checking, rely on editor diagnostics.
## Architecture
- The frontend uses custom elements built with Lit and TypeScript strict mode.
- Components communicate with the backend through the Home Assistant WebSocket API.
- Use `ha-` for Home Assistant components, `hui-` for Lovelace UI components, and `dialog-` for dialogs.
- Prefer `ha-*` components and current Web Awesome wrappers. Avoid adding new legacy `mwc-*` usage.
- Leaf components should consume narrow Lit contexts instead of taking the broad `hass` object unless they are containers that own and provide `hass`.
## Development Standards
- Use strict TypeScript, proper interfaces, and `import type` for type-only imports.
- Avoid `any`; model data with existing Home Assistant types or narrow new types.
- Keep imports organized and remove unused imports.
- Do not use `console`; use existing logging or user-visible error patterns.
- Use `@state()` for internal Lit state and `@property()` for public API.
- Do not query or manipulate DOM manually when Lit decorators, component refs, or render state are appropriate.
- Scope styles to components, use theme custom properties, and keep layouts mobile-first and RTL-safe.
- All user-facing text must be localized through the translation system.
## Project Skills
Detailed guidance lives in project skills under `.agents/skills/`. Load the matching skill before detailed implementation or review:
- `ha-frontend-contexts`: Lit contexts, `hass` migration, and rerender-sensitive state access.
- `ha-frontend-components`: dialogs, forms, alerts, shortcuts, tooltips, panels, and Lovelace cards.
- `ha-frontend-styling`: theme variables, spacing tokens, responsive layout, RTL, and view transitions.
- `ha-frontend-testing`: lint, typecheck, Vitest, Playwright e2e dev servers, and benchmarks.
- `ha-frontend-user-facing-text`: localization, terminology, sentence case, and Home Assistant text style.
- `ha-frontend-review`: PR template use, review checklist, and recurring review issues.
## Pull Requests
When creating a pull request, use `.github/PULL_REQUEST_TEMPLATE.md` as the PR body. Preserve template sections, check only the appropriate type-of-change boxes, and do not check checklist items on behalf of the user. If the PR includes UI changes, remind the user to add screenshots or a short video.
+1 -1
View File
@@ -1 +1 @@
.github/copilot-instructions.md
AGENTS.md
+304
View File
@@ -0,0 +1,304 @@
import { mdiClose, mdiFlaskOutline } from "@mdi/js";
import { css, html, LitElement, nothing } from "lit";
import { customElement, state } from "lit/decorators";
import { fireEvent } from "../../../src/common/dom/fire_event";
import { mainWindow } from "../../../src/common/dom/get_main_window";
import { navigate } from "../../../src/common/navigate";
import "../../../src/components/ha-button";
import "../../../src/components/ha-card";
import "../../../src/components/ha-icon-button";
import "../../../src/components/ha-svg-icon";
import "../../../src/components/ha-switch";
import type { HaSwitch } from "../../../src/components/ha-switch";
import type { CloudDemoScenario } from "../stubs/cloud-demo-state";
import {
getCloudDemoScenario,
setCloudDemoScenario,
subscribeCloudDemoScenario,
} from "../stubs/cloud-demo-state";
// Walk the DOM, descending into shadow roots, to find the first matching
// element. Used to reach <ha-panel-config> (which owns the cloud status) so we
// can ask it to re-fetch after a scenario change.
const deepQuery = (
selector: string,
root: Document | ShadowRoot = document
): Element | null => {
const direct = root.querySelector(selector);
if (direct) {
return direct;
}
const elements = root.querySelectorAll("*");
for (const element of elements) {
const shadow = element.shadowRoot;
if (shadow) {
const found = deepQuery(selector, shadow);
if (found) {
return found;
}
}
}
return null;
};
/**
* Demo-only floating panel that flips the mocked Home Assistant Cloud state so
* reviewers can preview every UI state of the cloud account page. It writes to
* the shared {@link CloudDemoScenario} (which the cloud/backup mocks read) and
* then nudges the page to re-read it. Lives entirely under demo/.
*/
@customElement("cloud-demo-controls")
export class CloudDemoControls extends LitElement {
@state() private _open = true;
@state() private _visible = false;
@state() private _scenario: CloudDemoScenario = getCloudDemoScenario();
private _unsub?: () => void;
// The demo uses hash-based routing (navigate() sets location.hash), so the
// active route lives in the hash, not the pathname.
private get _currentPath(): string {
const hash = mainWindow.location.hash;
return hash.startsWith("#/") ? hash.slice(1) : mainWindow.location.pathname;
}
private _locationChanged = () => {
this._visible = this._currentPath.startsWith("/config/cloud");
};
public connectedCallback(): void {
super.connectedCallback();
this._locationChanged();
mainWindow.addEventListener("location-changed", this._locationChanged);
mainWindow.addEventListener("popstate", this._locationChanged);
mainWindow.addEventListener("hashchange", this._locationChanged);
this._unsub = subscribeCloudDemoScenario((scenario) => {
this._scenario = { ...scenario };
});
}
public disconnectedCallback(): void {
super.disconnectedCallback();
mainWindow.removeEventListener("location-changed", this._locationChanged);
mainWindow.removeEventListener("popstate", this._locationChanged);
mainWindow.removeEventListener("hashchange", this._locationChanged);
this._unsub?.();
}
protected render() {
if (!this._visible) {
return nothing;
}
if (!this._open) {
return html`
<ha-icon-button
class="fab"
label="Cloud demo controls"
.path=${mdiFlaskOutline}
@click=${this._toggleOpen}
></ha-icon-button>
`;
}
return html`
<ha-card>
<div class="header">
<ha-svg-icon .path=${mdiFlaskOutline}></ha-svg-icon>
<span class="title">Cloud demo controls</span>
<ha-icon-button
label="Close"
.path=${mdiClose}
@click=${this._toggleOpen}
></ha-icon-button>
</div>
<p class="note">
Demo only. Flips the mocked cloud state shown on this page.
</p>
<div class="controls">
${this._segment("Subscription", "account", [
["active", "Active"],
["trialing", "Trialing"],
["canceled", "Canceled"],
["expired", "Expired"],
["unknown", "Unknown"],
])}
${this._toggle("Onboarded", "onboarded")}
${this._toggle("Onboarding postponed", "postponed")}
${this._toggle("Remote access", "remote")}
${this._segment("Remote status", "remoteStatus", [
["ready", "Ready"],
["generating", "Preparing"],
["loading", "Loading"],
["loaded", "Loaded"],
["error", "Error"],
])}
${this._segment("Backups", "backup", [
["fresh", "Recent"],
["stale", "Old"],
["failed", "Failed"],
["local", "Local only"],
["none", "None"],
])}
${this._toggle("Alexa linked", "alexa")}
${this._toggle("Google linked", "google")}
${this._toggle("Cameras (WebRTC)", "webrtc")}
${this._toggle("Has webhooks", "webhooks")}
</div>
</ha-card>
`;
}
private _segment(
label: string,
field: keyof CloudDemoScenario,
options: [string, string][]
) {
return html`
<div class="row">
<span>${label}</span>
<div class="segment">
${options.map(
([value, text]) => html`
<ha-button
size="s"
appearance=${
this._scenario[field] === value ? "filled" : "plain"
}
data-field=${field}
data-value=${value}
@click=${this._segmentClick}
>
${text}
</ha-button>
`
)}
</div>
</div>
`;
}
private _toggle(label: string, field: keyof CloudDemoScenario) {
return html`
<div class="row">
<span>${label}</span>
<ha-switch
.checked=${this._scenario[field] as boolean}
data-field=${field}
@change=${this._toggleChange}
></ha-switch>
</div>
`;
}
private _toggleOpen() {
this._open = !this._open;
}
private _segmentClick(ev: Event) {
const target = ev.currentTarget as HTMLElement;
this._set(
target.dataset.field as keyof CloudDemoScenario,
target.dataset.value!
);
}
private _toggleChange(ev: Event) {
const target = ev.target as HaSwitch;
this._set(target.dataset.field as keyof CloudDemoScenario, target.checked);
}
private _set(field: keyof CloudDemoScenario, value: string | boolean) {
setCloudDemoScenario({ [field]: value } as Partial<CloudDemoScenario>);
this._refresh();
}
private _refresh() {
// Refresh the shared cloud status so login-state changes (signed out) and
// status-derived fields update.
const panel = deepQuery("ha-panel-config");
if (panel) {
fireEvent(panel as HTMLElement, "ha-refresh-cloud-status");
}
// cloud-account fetches its subscription/backup/webhook data once on mount
// and is not cached by the router, so bounce through a sibling cloud route
// to force a clean remount that re-reads the updated mocks.
const path = this._currentPath;
if (path.startsWith("/config/cloud") && path !== "/config/cloud/login") {
const sibling =
path === "/config/cloud/remote"
? "/config/cloud/account"
: "/config/cloud/remote";
navigate(sibling, { replace: true });
window.setTimeout(() => navigate(path, { replace: true }), 0);
}
}
static styles = css`
:host {
position: fixed;
right: 16px;
bottom: 16px;
z-index: 9999;
}
.fab {
--mdc-icon-button-size: 48px;
--mdc-icon-size: 24px;
background-color: var(--primary-color);
color: var(--text-primary-color, #fff);
border-radius: 50%;
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.3);
}
ha-card {
display: block;
width: 320px;
max-height: 80vh;
overflow: auto;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.3);
}
.header {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 8px 8px 16px;
border-bottom: 1px solid var(--divider-color);
}
.header .title {
flex: 1;
font-weight: var(--ha-font-weight-medium, 500);
}
.header ha-svg-icon {
color: var(--secondary-text-color);
}
.note {
margin: 8px 16px;
color: var(--secondary-text-color);
font-size: var(--ha-font-size-s, 0.875rem);
}
.controls {
display: flex;
flex-direction: column;
gap: 8px;
padding: 0 16px 16px;
}
.row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
min-height: 36px;
}
.segment {
display: flex;
gap: 4px;
flex-wrap: wrap;
justify-content: flex-end;
}
`;
}
declare global {
interface HTMLElementTagNameMap {
"cloud-demo-controls": CloudDemoControls;
}
}
+18
View File
@@ -29,11 +29,13 @@ import { mockSystemLog } from "./stubs/system_log";
import { mockTemplate } from "./stubs/template";
import { mockTodo } from "./stubs/todo";
import { mockTranslations } from "./stubs/translations";
import "./cloud/cloud-demo-controls";
// WS command / REST path prefixes whose mocks live in the lazily imported
// config-panel chunk (see ./stubs/config-panel). Must stay in sync with it.
const CONFIG_PANEL_COMMANDS = [
"cloud/",
"webhook/list",
"validate_config",
"config_entries/",
"device_automation/",
@@ -69,6 +71,22 @@ export class HaDemo extends HomeAssistantAppEl {
// `false` for contexts: HomeAssistantAppEl already provides them via
// `contextMixin`, so let provideHass skip them to avoid duplicate providers.
const hass = provideHass(this, initial, true, false);
// The cloud account page only fetches backup config and the webhook count
// when those integrations are loaded. Enable them here (demo only) so the
// mocked backup/config/info and webhook/list are queried.
hass.updateHass({
config: {
...hass.config,
components: [...(hass.config?.components ?? []), "backup", "webhook"],
},
});
// Demo-only floating panel to flip the mocked cloud state. Mounted once at
// the document level; it shows itself only on the cloud panel.
if (!document.querySelector("cloud-demo-controls")) {
document.body.appendChild(document.createElement("cloud-demo-controls"));
}
const localizePromise =
// @ts-ignore
this._loadFragmentTranslations(hass.language, "page-demo").then(
+139 -25
View File
@@ -7,42 +7,34 @@ import type {
import { BackupScheduleRecurrence } from "../../../src/data/backup";
import type { ManagerStateEvent } from "../../../src/data/backup_manager";
import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
import type { DemoCloudBackup } from "./cloud-demo-state";
import {
getCloudDemoScenario,
setCloudDemoScenario,
subscribeCloudDemoScenario,
} from "./cloud-demo-state";
const lastBackupDate = new Date(Date.now() - 86400000).toISOString();
const nextBackupDate = new Date(Date.now() + 86400000).toISOString();
const backups: BackupContent[] = [
{
backup_id: "demo-backup-1",
name: "Automatic backup DEMO",
date: lastBackupDate,
with_automatic_settings: true,
agents: {
"backup.local": { size: 1024 * 1024 * 512, protected: true },
"cloud.cloud": { size: 1024 * 1024 * 512, protected: true },
},
},
];
const CLOUD_AGENT = "cloud.cloud";
const backupInfo: BackupInfo = {
backups,
backups: [],
agent_errors: {},
last_attempted_automatic_backup: lastBackupDate,
last_completed_automatic_backup: lastBackupDate,
last_attempted_automatic_backup: null,
last_completed_automatic_backup: null,
last_action_event: { manager_state: "idle" },
next_automatic_backup: nextBackupDate,
next_automatic_backup: null,
next_automatic_backup_additional: false,
state: "idle",
};
const backupConfig: BackupConfig = {
automatic_backups_configured: true,
last_attempted_automatic_backup: lastBackupDate,
last_completed_automatic_backup: lastBackupDate,
next_automatic_backup: nextBackupDate,
last_attempted_automatic_backup: null,
last_completed_automatic_backup: null,
next_automatic_backup: null,
next_automatic_backup_additional: false,
create_backup: {
agent_ids: ["backup.local", "cloud.cloud"],
agent_ids: ["backup.local", CLOUD_AGENT],
include_addons: [],
include_all_addons: true,
include_database: true,
@@ -69,10 +61,132 @@ const agentsInfo: BackupAgentsInfo = {
],
};
// Map the demo "Backups" scenario onto the mutable backup config/info, so the
// cloud overview status line and the backup sub-page reflect the chosen state.
const applyScenario = () => {
const kind = getCloudDemoScenario().backup;
const now = Date.now();
const recent = new Date(now - 12 * 3600 * 1000).toISOString();
const old = new Date(now - 5 * 86400000).toISOString();
const future = new Date(now + 86400000).toISOString();
// Comfortably past BACKUP_OVERDUE_MARGIN_HOURS (3h) so the "stale" scenario
// actually reads as overdue rather than slipping under the margin.
const overdue = new Date(now - 6 * 3600 * 1000).toISOString();
// The cloud agent is a backup target for the cloud-backed states only. For
// "local" a backup exists but is stored locally (no cloud copy), and for
// "none" there are no automatic backups at all.
const cloudEnabled =
kind === "fresh" || kind === "stale" || kind === "failed";
backupConfig.create_backup.agent_ids = cloudEnabled
? ["backup.local", CLOUD_AGENT]
: ["backup.local"];
switch (kind) {
case "fresh":
backupConfig.automatic_backups_configured = true;
backupConfig.last_completed_automatic_backup = recent;
backupConfig.last_attempted_automatic_backup = recent;
backupConfig.next_automatic_backup = future;
break;
case "local":
// Automatic backups run, but only to the local agent.
backupConfig.automatic_backups_configured = true;
backupConfig.last_completed_automatic_backup = recent;
backupConfig.last_attempted_automatic_backup = recent;
backupConfig.next_automatic_backup = future;
break;
case "stale":
backupConfig.automatic_backups_configured = true;
backupConfig.last_completed_automatic_backup = old;
backupConfig.last_attempted_automatic_backup = old;
// Next scheduled backup is in the past, so it reads as overdue.
backupConfig.next_automatic_backup = overdue;
break;
case "failed":
backupConfig.automatic_backups_configured = true;
backupConfig.last_completed_automatic_backup = old;
// Most recent attempt is newer than the last success, so it failed.
backupConfig.last_attempted_automatic_backup = recent;
backupConfig.next_automatic_backup = future;
break;
case "none":
backupConfig.automatic_backups_configured = false;
backupConfig.last_completed_automatic_backup = null;
backupConfig.last_attempted_automatic_backup = null;
backupConfig.next_automatic_backup = null;
break;
}
backupInfo.last_completed_automatic_backup =
backupConfig.last_completed_automatic_backup;
backupInfo.last_attempted_automatic_backup =
backupConfig.last_attempted_automatic_backup;
backupInfo.next_automatic_backup = backupConfig.next_automatic_backup;
backupInfo.backups =
cloudEnabled && backupConfig.last_completed_automatic_backup
? [
{
backup_id: "demo-backup-1",
name: "Automatic backup DEMO",
date: backupConfig.last_completed_automatic_backup,
with_automatic_settings: true,
agents: {
"backup.local": { size: 1024 * 1024 * 512, protected: true },
"cloud.cloud": { size: 1024 * 1024 * 512, protected: true },
},
} as BackupContent,
]
: [];
};
applyScenario();
subscribeCloudDemoScenario(applyScenario);
export const mockBackup = (hass: MockHomeAssistant) => {
hass.mockWS("backup/info", () => backupInfo);
hass.mockWS("backup/config/info", () => ({ config: backupConfig }));
// Fresh objects each fetch so re-reading after a mutation actually re-renders
// (Lit change detection is identity-based; the real WS API returns new
// objects too).
hass.mockWS("backup/info", () => ({ ...backupInfo }));
hass.mockWS("backup/config/info", () => ({ config: { ...backupConfig } }));
hass.mockWS("backup/agents/info", () => agentsInfo);
hass.mockWS("backup/config/update", (msg) => {
const { type, ...update } = msg;
if (update.create_backup) {
backupConfig.create_backup = {
...backupConfig.create_backup,
...update.create_backup,
};
}
if (update.automatic_backups_configured !== undefined) {
backupConfig.automatic_backups_configured =
update.automatic_backups_configured;
}
if (update.schedule) {
backupConfig.schedule = { ...backupConfig.schedule, ...update.schedule };
}
if (update.retention) {
backupConfig.retention = update.retention;
}
if (update.agents) {
backupConfig.agents = { ...backupConfig.agents, ...update.agents };
}
// Reflect the UI-driven backup change into the demo scenario so the demo
// controls panel stays in sync with the mocked state.
const cloudNow = backupConfig.create_backup.agent_ids.includes(CLOUD_AGENT);
const current = getCloudDemoScenario().backup;
const next: DemoCloudBackup = !backupConfig.automatic_backups_configured
? "none"
: cloudNow
? current === "fresh" || current === "stale" || current === "failed"
? current
: "fresh"
: "local";
if (next !== current) {
setCloudDemoScenario({ backup: next });
}
return null;
});
hass.mockWS(
"backup/subscribe_events",
(_msg, _hass, onChange?: (event: ManagerStateEvent) => void) => {
+89
View File
@@ -0,0 +1,89 @@
// Demo-only switchable Home Assistant Cloud scenario.
//
// The redesigned cloud account page (src/panels/config/cloud/account) renders
// purely from real WS data. To let reviewers preview every UI state without a
// real cloud account, this module holds a mutable "scenario" that the cloud and
// backup mocks read from, plus the floating <cloud-demo-controls> panel writes
// to. It is persisted to localStorage so the choice survives the data the page
// fetches once per visit (subscription, backup config, webhooks).
//
// This lives entirely under demo/ — no production code imports it.
import type { RemoteCertificateStatus } from "../../../src/data/cloud";
// The five PaymentSubscriptionState values.
export type DemoCloudAccount =
"active" | "trialing" | "canceled" | "expired" | "unknown";
// "local": automatic backups are configured, but not to the cloud agent
// (a backup exists, just no cloud copy). "none": no automatic backups at all.
export type DemoCloudBackup = "fresh" | "stale" | "failed" | "local" | "none";
export interface CloudDemoScenario {
account: DemoCloudAccount;
onboarded: boolean;
// Onboarding postponed server-side (maps to onboarding_postponed); hides
// the onboarding UI without marking it completed.
postponed: boolean;
remote: boolean;
remoteStatus: RemoteCertificateStatus;
backup: DemoCloudBackup;
alexa: boolean;
google: boolean;
webrtc: boolean;
webhooks: boolean;
}
export const DEFAULT_CLOUD_DEMO_SCENARIO: CloudDemoScenario = {
account: "active",
onboarded: true,
postponed: false,
remote: true,
remoteStatus: "ready",
backup: "fresh",
alexa: true,
google: true,
webrtc: true,
webhooks: true,
};
const STORAGE_KEY = "cloudDemoScenario";
const readScenario = (): CloudDemoScenario => {
try {
const raw = window.localStorage.getItem(STORAGE_KEY);
if (raw) {
return { ...DEFAULT_CLOUD_DEMO_SCENARIO, ...JSON.parse(raw) };
}
} catch (_err) {
// Ignore malformed or unavailable storage and fall back to the default.
}
return { ...DEFAULT_CLOUD_DEMO_SCENARIO };
};
let scenario: CloudDemoScenario = readScenario();
const listeners = new Set<(scenario: CloudDemoScenario) => void>();
export const getCloudDemoScenario = (): CloudDemoScenario => scenario;
export const subscribeCloudDemoScenario = (
listener: (scenario: CloudDemoScenario) => void
): (() => void) => {
listeners.add(listener);
return () => {
listeners.delete(listener);
};
};
export const setCloudDemoScenario = (
partial: Partial<CloudDemoScenario>
): void => {
scenario = { ...scenario, ...partial };
try {
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(scenario));
} catch (_err) {
// Ignore storage failures (e.g. private mode); state still applies in-memory.
}
listeners.forEach((listener) => listener(scenario));
};
+157 -2
View File
@@ -2,8 +2,15 @@ import type {
CloudStatusLoggedIn,
SubscriptionInfo,
} from "../../../src/data/cloud";
import { ONBOARDING_ITEMS } from "../../../src/data/cloud";
import type { CloudTTSInfo } from "../../../src/data/cloud/tts";
import type { Webhook } from "../../../src/data/webhook";
import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
import {
getCloudDemoScenario,
setCloudDemoScenario,
subscribeCloudDemoScenario,
} from "./cloud-demo-state";
const emptyFilter = () => ({
include_domains: [],
@@ -12,8 +19,23 @@ const emptyFilter = () => ({
exclude_entities: [],
});
const demoWebhooks: Webhook[] = [
{
webhook_id: "demo_front_door",
domain: "automation",
name: "Front door motion",
local_only: false,
},
{
webhook_id: "demo_companion_app",
domain: "mobile_app",
name: "Companion app",
local_only: false,
},
];
// A single mutable status object so that preference changes made in the demo
// are reflected back in the UI.
// (both via the real UI and the demo scenario controls) are reflected back.
const cloudStatus: CloudStatusLoggedIn = {
logged_in: true,
cloud: "connected",
@@ -35,6 +57,8 @@ const cloudStatus: CloudStatusLoggedIn = {
remote_certificate_status: "ready",
http_use_ssl: false,
active_subscription: true,
onboarding_postponed: false,
onboarding_completed: true,
prefs: {
google_enabled: true,
alexa_enabled: true,
@@ -47,6 +71,8 @@ const cloudStatus: CloudStatusLoggedIn = {
google_report_state: true,
tts_default_voice: ["en-US", "JennyNeural"],
cloud_ice_servers_enabled: true,
onboarded_items: [...ONBOARDING_ITEMS],
onboarding_postponed_until: null,
},
};
@@ -54,6 +80,7 @@ const subscription: SubscriptionInfo = {
human_description: "Demo subscription, renews automatically",
provider: "Nabu Casa, Inc.",
plan_renewal_date: 4102444800,
subscription: { status: "active" },
};
const ttsInfo: CloudTTSInfo = {
@@ -66,17 +93,140 @@ const ttsInfo: CloudTTSInfo = {
],
};
// Map the high-level demo scenario onto the mutable cloud status / subscription.
const applyScenario = () => {
const scenario = getCloudDemoScenario();
switch (scenario.account) {
case "trialing":
cloudStatus.active_subscription = true;
subscription.subscription = { status: "trialing" };
break;
case "canceled":
cloudStatus.active_subscription = false;
subscription.subscription = { status: "canceled" };
break;
case "expired":
cloudStatus.active_subscription = false;
subscription.subscription = { status: "expired" };
break;
case "unknown":
cloudStatus.active_subscription = true;
subscription.subscription = { status: "unknown" };
break;
default:
// "active"
cloudStatus.active_subscription = true;
subscription.subscription = { status: "active" };
}
cloudStatus.prefs.onboarded_items = scenario.onboarded
? [...ONBOARDING_ITEMS]
: [];
cloudStatus.onboarding_completed = scenario.onboarded;
cloudStatus.onboarding_postponed = scenario.postponed;
cloudStatus.prefs.onboarding_postponed_until = scenario.postponed
? new Date(Date.now() + 24 * 3600 * 1000).toISOString()
: null;
cloudStatus.prefs.remote_enabled = scenario.remote;
cloudStatus.remote_connected = scenario.remote;
cloudStatus.remote_certificate_status = scenario.remoteStatus;
cloudStatus.alexa_registered = scenario.alexa;
cloudStatus.google_registered = scenario.google;
cloudStatus.prefs.cloud_ice_servers_enabled = scenario.webrtc;
const hasCloudhooks = Object.keys(cloudStatus.prefs.cloudhooks).length > 0;
if (scenario.webhooks && !hasCloudhooks) {
cloudStatus.prefs.cloudhooks = Object.fromEntries(
demoWebhooks.map((webhook) => [
webhook.webhook_id,
{
webhook_id: webhook.webhook_id,
cloudhook_id: `demo-${webhook.webhook_id}`,
cloudhook_url: `https://hooks.nabu.casa/demo-${webhook.webhook_id}`,
managed: false,
},
])
);
} else if (!scenario.webhooks && hasCloudhooks) {
cloudStatus.prefs.cloudhooks = {};
}
};
applyScenario();
subscribeCloudDemoScenario(applyScenario);
// Reflect UI-driven changes (onboarding toggles, remote connect/disconnect)
// back into the demo scenario so the demo controls panel stays in sync with the
// mocked state. Only writes when a value actually changed, to avoid needless
// re-projection. `applyScenario` re-applies the (now matching) scenario, so
// this stays idempotent and does not fight the direct mutation above.
const syncScenarioFromStatus = () => {
const scenario = getCloudDemoScenario();
const next = {
onboarded: cloudStatus.onboarding_completed,
postponed: cloudStatus.onboarding_postponed,
remote: cloudStatus.prefs.remote_enabled,
webrtc: cloudStatus.prefs.cloud_ice_servers_enabled,
webhooks: Object.keys(cloudStatus.prefs.cloudhooks).length > 0,
};
if (
scenario.onboarded !== next.onboarded ||
scenario.postponed !== next.postponed ||
scenario.remote !== next.remote ||
scenario.webrtc !== next.webrtc ||
scenario.webhooks !== next.webhooks
) {
setCloudDemoScenario(next);
}
};
export const mockCloud = (hass: MockHomeAssistant) => {
hass.mockWS("cloud/status", () => cloudStatus);
hass.mockWS("cloud/status", () => ({
...cloudStatus,
prefs: { ...cloudStatus.prefs },
}));
hass.mockWS("cloud/subscription", () => subscription);
hass.mockWS("cloud/tts/info", () => ttsInfo);
hass.mockWS("webhook/list", () => demoWebhooks);
hass.mockWS("cloud/update_prefs", (msg) => {
const { type, ...prefs } = msg;
cloudStatus.prefs = { ...cloudStatus.prefs, ...prefs };
syncScenarioFromStatus();
return { success: true };
});
hass.mockWS("cloud/onboarding/postpone", () => {
cloudStatus.prefs.onboarding_postponed_until = new Date(
Date.now() + 24 * 3600 * 1000
).toISOString();
cloudStatus.onboarding_postponed = true;
syncScenarioFromStatus();
// Backend returns the full logged-in status object.
return { ...cloudStatus, prefs: { ...cloudStatus.prefs } };
});
hass.mockWS("cloud/onboarding/complete", (msg) => {
const items: string[] = msg.items ?? [];
const missing = items.filter(
(item) => !cloudStatus.prefs.onboarded_items.includes(item)
);
if (missing.length) {
cloudStatus.prefs.onboarded_items = [
...cloudStatus.prefs.onboarded_items,
...missing,
];
}
cloudStatus.onboarding_completed = ONBOARDING_ITEMS.every(
(onboardingItem) =>
cloudStatus.prefs.onboarded_items.includes(onboardingItem)
);
syncScenarioFromStatus();
// Backend returns the full logged-in status object.
return { ...cloudStatus, prefs: { ...cloudStatus.prefs } };
});
hass.mockWS("cloud/cloudhook/create", (msg) => {
const webhook = {
webhook_id: msg.webhook_id,
@@ -95,15 +245,20 @@ export const mockCloud = (hass: MockHomeAssistant) => {
const cloudhooks = { ...cloudStatus.prefs.cloudhooks };
delete cloudhooks[msg.webhook_id];
cloudStatus.prefs.cloudhooks = cloudhooks;
syncScenarioFromStatus();
return null;
});
hass.mockWS("cloud/remote/connect", () => {
cloudStatus.remote_connected = true;
cloudStatus.prefs.remote_enabled = true;
syncScenarioFromStatus();
return null;
});
hass.mockWS("cloud/remote/disconnect", () => {
cloudStatus.remote_connected = false;
cloudStatus.prefs.remote_enabled = false;
syncScenarioFromStatus();
return null;
});
+23 -23
View File
@@ -48,19 +48,19 @@
"@codemirror/language": "6.12.4",
"@codemirror/lint": "6.9.7",
"@codemirror/search": "6.7.1",
"@codemirror/state": "6.7.0",
"@codemirror/view": "6.43.4",
"@codemirror/state": "6.7.1",
"@codemirror/view": "6.43.6",
"@date-fns/tz": "1.5.0",
"@egjs/hammerjs": "2.0.17",
"@formatjs/intl-datetimeformat": "7.4.9",
"@formatjs/intl-displaynames": "7.3.10",
"@formatjs/intl-durationformat": "0.10.15",
"@formatjs/intl-datetimeformat": "7.4.10",
"@formatjs/intl-displaynames": "7.3.11",
"@formatjs/intl-durationformat": "0.10.16",
"@formatjs/intl-getcanonicallocales": "3.2.10",
"@formatjs/intl-listformat": "8.3.10",
"@formatjs/intl-listformat": "8.3.11",
"@formatjs/intl-locale": "5.3.9",
"@formatjs/intl-numberformat": "9.3.11",
"@formatjs/intl-pluralrules": "6.3.10",
"@formatjs/intl-relativetimeformat": "12.3.10",
"@formatjs/intl-numberformat": "9.3.12",
"@formatjs/intl-pluralrules": "6.3.11",
"@formatjs/intl-relativetimeformat": "12.3.11",
"@fullcalendar/core": "6.1.21",
"@fullcalendar/daygrid": "6.1.21",
"@fullcalendar/interaction": "6.1.21",
@@ -83,8 +83,8 @@
"@replit/codemirror-indentation-markers": "6.5.3",
"@swc/helpers": "0.5.23",
"@thomasloven/round-slider": "0.6.0",
"@tsparticles/engine": "4.3.0",
"@tsparticles/preset-links": "4.3.0",
"@tsparticles/engine": "4.3.2",
"@tsparticles/preset-links": "4.3.2",
"@vibrant/color": "4.0.4",
"@vvo/tzdb": "6.198.0",
"@webcomponents/scoped-custom-element-registry": "0.0.10",
@@ -106,16 +106,16 @@
"gulp-zopfli-green": "7.0.0",
"hls.js": "1.6.16",
"home-assistant-js-websocket": "9.6.0",
"idb-keyval": "6.2.6",
"intl-messageformat": "11.2.9",
"js-yaml": "5.2.0",
"idb-keyval": "6.3.0",
"intl-messageformat": "11.2.10",
"js-yaml": "5.2.1",
"leaflet": "1.9.4",
"leaflet-draw": "patch:leaflet-draw@npm%3A1.0.4#./.yarn/patches/leaflet-draw-npm-1.0.4-0ca0ebcf65.patch",
"leaflet.markercluster": "1.5.3",
"lit": "3.3.3",
"lit-html": "3.3.3",
"luxon": "3.7.2",
"marked": "18.0.5",
"marked": "18.0.6",
"memoize-one": "6.0.0",
"node-vibrant": "4.0.4",
"object-hash": "3.0.0",
@@ -145,14 +145,14 @@
"@babel/preset-env": "8.0.2",
"@bundle-stats/plugin-webpack-filter": "4.22.2",
"@eslint/js": "10.0.1",
"@html-eslint/eslint-plugin": "0.63.0",
"@html-eslint/eslint-plugin": "0.64.0",
"@lokalise/node-api": "16.0.0",
"@octokit/auth-oauth-device": "8.0.3",
"@octokit/plugin-retry": "8.1.0",
"@octokit/rest": "22.0.1",
"@playwright/test": "1.61.1",
"@rsdoctor/rspack-plugin": "1.5.17",
"@rspack/core": "2.1.2",
"@rsdoctor/rspack-plugin": "1.5.18",
"@rspack/core": "2.1.3",
"@rspack/dev-server": "2.1.0",
"@types/babel__plugin-transform-runtime": "7.9.5",
"@types/chromecast-caf-receiver": "6.0.26",
@@ -168,7 +168,7 @@
"@types/qrcode": "1.5.6",
"@types/sortablejs": "1.15.9",
"@types/tar": "7.0.87",
"@vitest/coverage-v8": "4.1.9",
"@vitest/coverage-v8": "4.1.10",
"babel-loader": "10.1.1",
"babel-plugin-polyfill-corejs3": "1.0.0",
"browserslist-useragent-regexp": "4.1.4",
@@ -202,7 +202,7 @@
"map-stream": "0.0.7",
"minify-literals": "2.1.0",
"pinst": "3.0.0",
"prettier": "3.9.4",
"prettier": "3.9.5",
"rspack-manifest-plugin": "5.2.2",
"serve": "14.2.6",
"sinon": "22.0.0",
@@ -210,9 +210,9 @@
"terser-webpack-plugin": "5.6.1",
"ts-lit-plugin": "2.0.2",
"typescript": "6.0.3",
"typescript-eslint": "8.62.1",
"typescript-eslint": "8.63.0",
"vite-tsconfig-paths": "6.1.1",
"vitest": "4.1.9",
"vitest": "4.1.10",
"webpack-stats-plugin": "1.1.3",
"webpackbar": "7.0.0",
"workbox-build": "patch:workbox-build@npm%3A7.4.1#~/.yarn/patches/workbox-build-npm-7.4.1-c84561662c.patch"
@@ -228,7 +228,7 @@
"@material/mwc-list@^0.27.0": "patch:@material/mwc-list@npm%3A0.27.0#~/.yarn/patches/@material-mwc-list-npm-0.27.0-5344fc9de4.patch",
"glob@^10.2.2": "^10.5.0"
},
"packageManager": "yarn@4.17.0",
"packageManager": "yarn@4.17.1",
"volta": {
"node": "24.18.0"
}
File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 5.2 KiB

After

Width:  |  Height:  |  Size: 8.8 KiB

+85 -8
View File
@@ -1,5 +1,7 @@
import { consume } from "@lit/context";
import { ContextEvent } from "@lit/context";
import type { Context, ContextCallback } from "@lit/context";
import type { HassEntities, HassEntity } from "home-assistant-js-websocket";
import type { ReactiveController, ReactiveElement } from "lit";
import type {
HomeAssistant,
HomeAssistantInternationalization,
@@ -49,8 +51,86 @@ export const preserveUnchangedEntityStatesRecord = <
return previous;
};
/**
* Reactive controller that subscribes to a Lit context and assigns each
* delivered value to a host property — WITHOUT forcing a host update on every
* delivery.
*
* `@lit/context`'s built-in `ContextConsumer` calls `host.requestUpdate()`
* unconditionally on every provider notification. For a hot context such as
* `statesContext` (replaced on every entity state change) that means every
* consumer runs an (often empty) update/render cycle on every unrelated state
* change, even when the value it actually reads is unchanged.
*
* This controller instead leaves update scheduling to the property's own
* setter. Combined with {@link transform}, that setter only requests an update
* when the *selected* value changes (Lit gates `requestUpdate(key, oldValue)`
* with `hasChanged`), so unrelated context churn no longer triggers renders.
*/
class ContextSubscriptionController<ValueType> implements ReactiveController {
private _unsubscribe?: () => void;
constructor(
private readonly _host: ReactiveElement,
private readonly _context: Context<unknown, ValueType>,
private readonly _assign: (value: ValueType) => void
) {
this._host.addController(this);
}
public hostConnected(): void {
this._host.dispatchEvent(
new ContextEvent(this._context, this._host, this._callback, true)
);
}
public hostDisconnected(): void {
this._unsubscribe?.();
this._unsubscribe = undefined;
}
// Class field arrow function so the identity is stable per instance, which the
// provider's subscription bookkeeping and `ContextRoot` deduping rely on.
private readonly _callback: ContextCallback<ValueType> = (
value,
unsubscribe
) => {
// A different provider answered (e.g. re-parenting); drop the stale one.
if (this._unsubscribe && this._unsubscribe !== unsubscribe) {
this._unsubscribe();
}
this._unsubscribe = unsubscribe;
// Assign through the property setter, which decides — via `hasChanged` —
// whether an update is actually needed. We intentionally never call
// `host.requestUpdate()` here.
this._assign(value);
};
}
/**
* Like `@consume({ subscribe: true })` from `@lit/context`, but does not force a
* host update on every provider notification — see
* {@link ContextSubscriptionController}. Pair with {@link transform} so an
* update is scheduled only when the derived value actually changes.
*/
const subscribeContext =
<ValueType>(context: Context<unknown, ValueType>) =>
(proto: object, propertyKey: string): void => {
(proto.constructor as unknown as typeof ReactiveElement).addInitializer(
(host) => {
new ContextSubscriptionController<ValueType>(
host as ReactiveElement,
context,
(value) => {
(host as unknown as Record<string, unknown>)[propertyKey] = value;
}
);
}
);
};
const composeDecorator = <T, V>(
context: Parameters<typeof consume>[0]["context"],
context: Context<unknown, T>,
watchKey: string | undefined,
select: (this: unknown, value: T) => V | undefined
) => {
@@ -60,7 +140,7 @@ const composeDecorator = <T, V>(
},
watch: watchKey ? [watchKey] : [],
});
const consumeDec = consume<any>({ context, subscribe: true });
const consumeDec = subscribeContext<T>(context);
return (proto: any, propertyKey: string) => {
transformDec(proto, propertyKey);
consumeDec(proto, propertyKey);
@@ -124,10 +204,7 @@ export const consumeEntityStates = (config: ConsumeEntryConfig) => {
},
watch: watchKey ? [watchKey] : [],
});
const consumeDec = consume<any>({
context: statesContext,
subscribe: true,
});
const consumeDec = subscribeContext<HassEntities>(statesContext);
transformDec(proto as never, propertyKey);
consumeDec(proto as never, propertyKey);
};
@@ -151,7 +228,7 @@ export const consumeEntityRegistryEntry = (config: ConsumeEntryConfig) =>
/**
* Consumes `internationalizationContext` and narrows it to the `localize`
* function. No host watching is needed — the decorated property updates
* whenever the i18n context changes.
* whenever `localize` changes.
*/
export const consumeLocalize = () =>
composeDecorator<HomeAssistantInternationalization, LocalizeFunc>(
+17
View File
@@ -64,6 +64,23 @@ export const navigate = async (path: string, options?: NavigateOptions) => {
const replace = options?.replace || false;
if (__DEMO__) {
if (path.includes("#")) {
if (replace) {
mainWindow.history.replaceState(
mainWindow.history.state?.root
? { root: true }
: (options?.data ?? null),
"",
path
);
} else {
mainWindow.history.pushState(options?.data ?? null, "", path);
}
fireEvent(mainWindow, "location-changed", {
replace,
});
return true;
}
if (replace) {
mainWindow.history.replaceState(
mainWindow.history.state?.root
+2 -1
View File
@@ -1,5 +1,6 @@
export const constructUrlCurrentPath = (searchParams: string): string => {
const base = window.location.pathname;
const hash = __DEMO__ ? window.location.hash : "";
// Prevent trailing "?" if no parameters exist
return searchParams ? base + "?" + searchParams : base;
return `${searchParams ? `${base}?${searchParams}` : base}${hash}`;
};
+21 -3
View File
@@ -1,8 +1,16 @@
import type { BarSeriesOption } from "echarts/types/dist/shared";
/**
* `extraBuckets` (only used when `stacked`) seeds the bucket union with the
* expected statistics grid so sparse datasets get zero-filled across the whole
* range, including past their last real point. Without it, buckets are only
* derived from the data and trailing buckets are never filled (legacy
* behavior, kept for callers that don't pass a grid).
*/
export function fillDataGapsAndRoundCaps(
datasets: BarSeriesOption[],
stacked = true
stacked = true,
extraBuckets?: number[]
) {
if (!stacked) {
// For non-stacked charts, we can simply apply an overall border to each stack
@@ -44,6 +52,7 @@ export function fillDataGapsAndRoundCaps(
dataset.data!.map((datapoint) => Number(datapoint![0]))
)
.flat()
.concat(extraBuckets ?? [])
)
).sort((a, b) => a - b);
@@ -61,9 +70,18 @@ export function fillDataGapsAndRoundCaps(
const x = item.value?.[0];
const stack = datasets[i].stack ?? "";
if (x === undefined) {
continue;
// Past the end of this dataset's data. Only append trailing buckets
// when an explicit grid was provided; originally-empty datasets
// (e.g. compare placeholders) stay empty either way.
if (
dataPoint !== undefined ||
extraBuckets === undefined ||
!datasets[i].data!.length
) {
continue;
}
}
if (Number(x) !== bucket) {
if (x === undefined || Number(x) !== bucket) {
datasets[i].data?.splice(index, 0, {
value: [bucket, 0],
itemStyle: {
@@ -16,6 +16,7 @@ import {
CLIMATE_MODE_CONFIGS,
generateStateHistoryChartLineData,
} from "./state-history-chart-line-data";
import { createYAxisPrecisionBounds } from "./y-axis-fraction-digits";
import type { HaECOption } from "../../resources/echarts/echarts";
import { formatDateTimeWithSeconds } from "../../common/datetime/format_date_time";
import {
@@ -28,6 +29,10 @@ import { fireEvent } from "../../common/dom/fire_event";
import { blankBeforeUnit } from "../../common/translations/blank_before_unit";
import { computeAttributeValueDisplay } from "../../common/entity/compute_attribute_display";
// Minimum width reserved for the Y-axis labels; also the value _yWidth is
// re-measured up from whenever the tick precision changes on zoom.
const MIN_Y_AXIS_WIDTH = 25;
// Used to recover the underlying entity_id from a legend dataset id.
// Kept in sync with the suffixes appended at dataset construction below
// for climate / water_heater / humidifier multi-attribute charts.
@@ -101,7 +106,7 @@ export class StateHistoryChartLine extends LitElement {
private _hiddenStats = new Set<string>();
@state() private _yWidth = 25;
@state() private _yWidth = MIN_Y_AXIS_WIDTH;
@state() private _visualMap?: VisualMapComponentOption[];
@@ -318,8 +323,18 @@ export class StateHistoryChartLine extends LitElement {
yAxis: {
type: this.logarithmicScale ? "log" : "value",
name: this.unit,
min: this._clampYAxis(minYAxis),
max: this._clampYAxis(maxYAxis),
...createYAxisPrecisionBounds({
min: this._clampYAxis(minYAxis),
max: this._clampYAxis(maxYAxis),
onFractionDigits: (digits) => {
if (digits !== this._yAxisFractionDigits) {
this._yAxisFractionDigits = digits;
// Re-measure the gutter for the new precision so it can shrink
// again when zooming back out (_yWidth otherwise only grows).
this._yWidth = 0;
}
},
}),
position: rtl ? "right" : "left",
scale: true,
nameGap: 2,
@@ -448,7 +463,7 @@ export class StateHistoryChartLine extends LitElement {
minimumFractionDigits: value === 0 ? 0 : this._yAxisFractionDigits,
maximumFractionDigits: this._yAxisFractionDigits,
});
const width = measureTextWidth(label, 12) + 5;
const width = Math.max(measureTextWidth(label, 12) + 5, MIN_Y_AXIS_WIDTH);
if (width > this._yWidth) {
this._yWidth = width;
fireEvent(this, "y-width-changed", {
+17 -7
View File
@@ -34,6 +34,7 @@ import "./ha-chart-base";
import { sideTooltipPosition } from "./chart-tooltip-position";
import "./ha-chart-tooltip-marker";
import { generateStatisticsChartData } from "./statistics-chart-data";
import { createYAxisPrecisionBounds } from "./y-axis-fraction-digits";
export const supportedStatTypeMap: Record<StatisticType, StatisticType> = {
mean: "mean",
@@ -391,6 +392,11 @@ export class StatisticsChart extends LitElement {
}
}
const yAxisScale =
this.chartType.startsWith("line") ||
this.logarithmicScale ||
minYAxis !== undefined ||
maxYAxis !== undefined;
this._chartOptions = {
xAxis: [
{
@@ -434,13 +440,17 @@ export class StatisticsChart extends LitElement {
)
? "right"
: "left",
scale:
this.chartType.startsWith("line") ||
this.logarithmicScale ||
minYAxis !== undefined ||
maxYAxis !== undefined,
min: this._clampYAxis(minYAxis),
max: this._clampYAxis(maxYAxis),
scale: yAxisScale,
...createYAxisPrecisionBounds({
min: this._clampYAxis(minYAxis),
max: this._clampYAxis(maxYAxis),
// Bar charts stay anchored at 0, so precision must reflect the
// 0-based range that is actually rendered.
includeZero: !yAxisScale,
onFractionDigits: (digits) => {
this._yAxisFractionDigits = digits;
},
}),
splitLine: {
show: true,
},
+61 -4
View File
@@ -1,9 +1,66 @@
// Derive the number of decimal digits to use for Y-axis labels from the
// observed data range. We estimate the tick interval as `range / 10` (twice
// ECharts' default splitNumber of 5, as a safety margin against finer "nice"
// intervals), then derive `ceil(-log10(interval))`.
// observed data range. We mirror how ECharts sizes its ticks: it splits the
// range into ~5 intervals (its default `splitNumber`) and rounds that raw
// interval to a "nice" 1/2/3/5×10ⁿ value, then reports the decimals that nice
// interval needs. This matches the precision ECharts actually renders, so
// labels are neither truncated to identical values nor padded with extra zeros.
export function computeYAxisFractionDigits(min: number, max: number): number {
const range = max - min;
if (!Number.isFinite(range) || range <= 0) return 1;
return Math.max(0, Math.ceil(-Math.log10(range / 10)));
const rawInterval = range / 5;
const exponent = Math.floor(Math.log10(rawInterval));
const mantissa = rawInterval / 10 ** exponent; // in [1, 10)
// Rounding the mantissa to a nice value only ever carries to the next power
// of ten (mantissa ≥ 7 → 10), which needs one fewer decimal.
const niceExponent = mantissa >= 7 ? exponent + 1 : exponent;
return Math.max(0, -niceExponent);
}
interface YAxisExtentValues {
min: number;
max: number;
}
type YAxisBound =
number | ((values: YAxisExtentValues) => number | undefined) | undefined;
const resolveYAxisBound = (
bound: YAxisBound,
values: YAxisExtentValues
): number | undefined => (typeof bound === "function" ? bound(values) : bound);
// Wrap the Y-axis `min`/`max` options in callbacks so the tick-label precision
// tracks the currently visible axis extent. ECharts re-invokes these callbacks
// with the extent of the visible (zoom-filtered) data on every dataZoom, and
// always before the label formatter runs, so recomputing the fraction digits
// here keeps zoomed-in labels distinct. The callbacks return the original
// bounds unchanged, so auto-scaling still applies when a bound is not set.
export function createYAxisPrecisionBounds(options: {
min?: YAxisBound;
max?: YAxisBound;
// Axes without `scale: true` (e.g. bar charts) stay anchored at 0, so the
// rendered ticks span from 0 even when the data does not. Union the extent
// with 0 to match the labels ECharts actually draws.
includeZero?: boolean;
onFractionDigits: (digits: number) => void;
}): {
min: (values: YAxisExtentValues) => number | undefined;
max: (values: YAxisExtentValues) => number | undefined;
} {
const { min, max, includeZero, onFractionDigits } = options;
return {
min: (values) => {
const resolvedMin = resolveYAxisBound(min, values);
const resolvedMax = resolveYAxisBound(max, values);
let extentMin = resolvedMin ?? values.min;
let extentMax = resolvedMax ?? values.max;
if (includeZero) {
extentMin = Math.min(extentMin, 0);
extentMax = Math.max(extentMax, 0);
}
onFractionDigits(computeYAxisFractionDigits(extentMin, extentMax));
return resolvedMin;
},
max: (values) => resolveYAxisBound(max, values),
};
}
@@ -176,7 +176,7 @@ export class DateRangePicker extends MobileAwareMixin(LitElement) {
tabindex="-1"
slot="next"
></ha-icon-button-next>
<calendar-month></calendar-month>
<calendar-month dir=${this.dir}></calendar-month>
</calendar-range>
${
this.timePicker
@@ -4,7 +4,7 @@ import { mdiCalendar } from "@mdi/js";
import "cally";
import { isThisYear } from "date-fns";
import type { HassConfig } from "home-assistant-js-websocket/dist/types";
import type { TemplateResult } from "lit";
import type { PropertyValues, TemplateResult } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import { tinykeys } from "tinykeys";
@@ -26,6 +26,8 @@ import "../ha-icon-button-prev";
import "../ha-textarea";
import type { HaTextArea } from "../ha-textarea";
import "./date-range-picker";
import { computeRTL, emitRTLDirection } from "../../common/util/compute_rtl";
import { translationMetadata } from "../../resources/translations-metadata";
export type DateRangePickerRanges = Record<string, [Date, Date]>;
@@ -110,17 +112,28 @@ export class HaDateRangePicker extends LitElement {
this._handleResize();
window.addEventListener("resize", this._handleResize);
}
const rangeKeys = this.extendedPresets
? [...RANGE_KEYS, ...EXTENDED_RANGE_KEYS]
: RANGE_KEYS;
protected willUpdate(changedProps: PropertyValues): void {
super.willUpdate(changedProps);
this._ranges = {};
rangeKeys.forEach((key) => {
this._ranges![
this._i18n.localize(`ui.components.date-range-picker.ranges.${key}`)
] = calcDateRange(this._i18n.locale, this._hassConfig, key);
});
if (
changedProps.has("_i18n") ||
changedProps.has("_hassConfig") ||
changedProps.has("extendedPresets")
) {
const rangeKeys = this.extendedPresets
? [...RANGE_KEYS, ...EXTENDED_RANGE_KEYS]
: RANGE_KEYS;
const ranges: DateRangePickerRanges = {};
rangeKeys.forEach((key) => {
ranges[
this._i18n.localize(`ui.components.date-range-picker.ranges.${key}`)
] = calcDateRange(this._i18n.locale, this._hassConfig, key);
});
this._ranges = ranges;
}
}
public open(): void {
@@ -239,8 +252,13 @@ export class HaDateRangePicker extends LitElement {
if (!this._opened) {
return nothing;
}
const dir = emitRTLDirection(
computeRTL(this._i18n.locale.language, translationMetadata.translations)
);
return html`
<date-range-picker
dir=${dir}
.ranges=${this.ranges === false ? false : this.ranges || this._ranges}
.startDate=${this.startDate}
.endDate=${this.endDate}
+6 -4
View File
@@ -103,13 +103,15 @@ export const dateRangePickerStyles = css`
}
calendar-month::part(range-start),
calendar-month::part(range-start):hover {
border-top-left-radius: var(--ha-border-radius-circle);
border-bottom-left-radius: var(--ha-border-radius-circle);
/* logical: rounds the inline-start corners (works in LTR and RTL) */
border-start-start-radius: var(--ha-border-radius-circle);
border-end-start-radius: var(--ha-border-radius-circle);
}
calendar-month::part(range-end),
calendar-month::part(range-end):hover {
border-top-right-radius: var(--ha-border-radius-circle);
border-bottom-right-radius: var(--ha-border-radius-circle);
/* logical: rounds the inline-end corners (works in LTR and RTL) */
border-start-end-radius: var(--ha-border-radius-circle);
border-end-end-radius: var(--ha-border-radius-circle);
}
calendar-month::part(range-start):hover,
calendar-month::part(range-end):hover,
@@ -1,3 +1,4 @@
import type { RenderItemFunction } from "@lit-labs/virtualizer/virtualize";
import { consume } from "@lit/context";
import type { HassEntities } from "home-assistant-js-websocket";
import type { PropertyValues } from "lit";
@@ -15,7 +16,12 @@ import {
} from "../../data/device/device_automation";
import type { EntityRegistryEntry } from "../../data/entity/entity_registry";
import type { CallWS, HomeAssistant, ValueChangedEvent } from "../../types";
import "../ha-combo-box-item";
import "../ha-generic-picker";
import {
DEFAULT_ROW_RENDERER_CONTENT,
type PickerComboBoxItem,
} from "../ha-picker-combo-box";
import type { PickerValueRenderer } from "../ha-picker-field";
const NO_AUTOMATION_KEY = "NO_AUTOMATION";
@@ -105,6 +111,7 @@ export abstract class HaDeviceAutomationPicker<
.disabled=${!this._automations || this._automations.length === 0}
.getItems=${this._getItems(value, this._automations)}
@value-changed=${this._automationChanged}
.rowRenderer=${this._rowRenderer}
.valueRenderer=${this._valueRenderer}
.unknownItemText=${this.hass.localize(
"ui.panel.config.devices.automation.actions.unknown_action"
@@ -160,6 +167,13 @@ export abstract class HaDeviceAutomationPicker<
}
);
// Device automation labels (entity name + subtype) are often longer than the
// field, so let the option wrap onto multiple lines instead of truncating.
private _rowRenderer: RenderItemFunction<PickerComboBoxItem> = (item) =>
html`<ha-combo-box-item type="button" compact multiline>
${DEFAULT_ROW_RENDERER_CONTENT(item)}
</ha-combo-box-item>`;
private _valueRenderer: PickerValueRenderer = (value: string) => {
const automation = this._automations?.find(
(a, idx) => value === `${a.device_id}_${idx}`
+9
View File
@@ -7,6 +7,11 @@ export class HaComboBoxItem extends HaMdListItem {
@property({ type: Boolean, reflect: true, attribute: "border-top" })
public borderTop = false;
// Allow the headline/supporting text to wrap onto multiple lines instead of
// truncating with an ellipsis. Off by default to preserve single-line rows.
@property({ type: Boolean, reflect: true })
public multiline = false;
static override styles = [
...haMdListStyles,
css`
@@ -41,6 +46,10 @@ export class HaComboBoxItem extends HaMdListItem {
font-size: var(--ha-font-size-s);
white-space: nowrap;
}
:host([multiline]) [slot="headline"],
:host([multiline]) [slot="supporting-text"] {
white-space: normal;
}
::slotted(state-badge),
::slotted(img) {
width: 32px;
+20 -4
View File
@@ -71,6 +71,17 @@ export class HaControlSlider extends LitElement {
@property({ type: Number })
public step = 1;
/**
* Round the value shown in the tooltip and announced to assistive
* technologies to the nearest integer. The handle still snaps to `step`, so
* the number of steps is unchanged. Useful when `step` is fractional but the
* value is conceptually a whole number — e.g. a fan whose `percentage_step`
* is 100 / speed_count (like ~1.0989 for 91 speeds), which would otherwise
* display fractional percentages such as "28.57%".
*/
@property({ type: Boolean, attribute: "round-value" })
public roundValue = false;
@property({ type: Number })
public min = 0;
@@ -107,6 +118,11 @@ export class HaControlSlider extends LitElement {
return Math.round(value / this.step) * this.step;
}
private _displayedValue(value: number) {
const stepped = this.steppedValue(value);
return this.roundValue ? Math.round(stepped) : stepped;
}
boundedValue(value: number) {
return Math.min(Math.max(value, this.min), this.max);
}
@@ -118,8 +134,8 @@ export class HaControlSlider extends LitElement {
protected updated(changedProps: PropertyValues<this>) {
super.updated(changedProps);
if (changedProps.has("value")) {
const valuenow = this.steppedValue(this.value ?? 0);
if (changedProps.has("value") || changedProps.has("roundValue")) {
const valuenow = this._displayedValue(this.value ?? 0);
this.setAttribute("aria-valuenow", valuenow.toString());
this.setAttribute("aria-valuetext", this._formatValue(valuenow));
}
@@ -312,7 +328,7 @@ export class HaControlSlider extends LitElement {
this.tooltipMode === "always" ||
(this.tooltipVisible && this.tooltipMode === "interaction");
const value = this.steppedValue(this.value ?? 0);
const value = this._displayedValue(this.value ?? 0);
return html`
<span
@@ -330,7 +346,7 @@ export class HaControlSlider extends LitElement {
}
protected render(): TemplateResult {
const valuenow = this.steppedValue(this.value ?? 0);
const valuenow = this._displayedValue(this.value ?? 0);
return html`
<div
class="container${classMap({
+15 -8
View File
@@ -135,8 +135,6 @@ export class HaDialog extends ScrollableFadeMixin(LitElement) {
@state()
private _bodyScrolled = false;
private _escapePressed = false;
public connectedCallback(): void {
super.connectedCallback();
this.addEventListener(
@@ -306,7 +304,6 @@ export class HaDialog extends ScrollableFadeMixin(LitElement) {
private _handleKeyDown(ev: KeyboardEvent) {
if (ev.key === "Escape") {
this._escapePressed = true;
if (this.preventScrimClose) {
ev.preventDefault();
}
@@ -316,13 +313,23 @@ export class HaDialog extends ScrollableFadeMixin(LitElement) {
}
private _handleHide(ev: DialogHideEvent) {
const sourceIsDialog = ev.detail?.source === (ev.target as WaDialog).dialog;
if (this.preventScrimClose && this._escapePressed && sourceIsDialog) {
ev.preventDefault();
// Ignore wa-hide events bubbling up from nested overlays (pickers,
// popovers, bottom sheets, nested dialogs); only handle this dialog's own
// hide, like the sibling wa-* handlers above.
if (ev.eventPhase !== Event.AT_TARGET) {
return;
}
this._escapePressed = false;
const sourceIsDialog = ev.detail?.source === (ev.target as WaDialog).dialog;
// A dialog-sourced hide (Escape, native cancel, or scrim) while the host
// still wants the dialog open is a user dismissal, not a programmatic
// close. Block it when closing must be guarded, regardless of where focus
// currently is — e.g. after a picker overlay took focus and dropped it
// outside the dialog, the Escape keydown never reaches this element.
if (this.preventScrimClose && sourceIsDialog && this.open) {
ev.preventDefault();
}
}
static get styles() {
+35 -39
View File
@@ -20,18 +20,16 @@ class HaRelativeTime extends ReactiveElement {
@consume({ context: internationalizationContext, subscribe: true })
private _i18n?: HomeAssistantInternationalization;
private _interval?: number;
private _timeout?: number;
public disconnectedCallback(): void {
super.disconnectedCallback();
this._clearInterval();
this._clearTimeout();
}
public connectedCallback(): void {
super.connectedCallback();
if (this.datetime) {
this._startInterval();
}
this._updateRelative();
}
protected createRenderRoot() {
@@ -40,31 +38,19 @@ class HaRelativeTime extends ReactiveElement {
protected update(changedProps: PropertyValues<this>) {
super.update(changedProps);
if (changedProps.has("datetime")) {
if (this.datetime) {
this._startInterval();
} else {
this._clearInterval();
}
}
this._updateRelative();
}
private _clearInterval(): void {
if (this._interval) {
window.clearInterval(this._interval);
this._interval = undefined;
private _clearTimeout(): void {
if (this._timeout) {
window.clearTimeout(this._timeout);
this._timeout = undefined;
}
}
private _startInterval(): void {
this._clearInterval();
// update every 60 seconds
this._interval = window.setInterval(() => this._updateRelative(), 60000);
}
private _updateRelative(): void {
this._clearTimeout();
if (!this._i18n) {
return;
}
@@ -73,23 +59,33 @@ class HaRelativeTime extends ReactiveElement {
this.textContent = this._i18n.localize(
"ui.components.relative_time.never"
);
} else {
const date =
typeof this.datetime === "string"
? parseISO(this.datetime)
: this.datetime;
const relTime = relativeTime(
date,
this._i18n.locale,
undefined,
true,
this.format
);
this.textContent = this.capitalize
? capitalizeFirstLetter(relTime)
: relTime;
return;
}
const date =
typeof this.datetime === "string"
? parseISO(this.datetime)
: this.datetime;
const relTime = relativeTime(
date,
this._i18n.locale,
undefined,
true,
this.format
);
this.textContent = this.capitalize
? capitalizeFirstLetter(relTime)
: relTime;
// Keep the relative time counting up on its own. Refresh every second
// while the difference is still measured in seconds, otherwise every
// minute.
const secondsDiff = Math.abs(Date.now() - date.getTime()) / 1000;
this._timeout = window.setTimeout(
() => this._updateRelative(),
secondsDiff < 60 ? 1000 : 60000
);
}
}
+1
View File
@@ -479,6 +479,7 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
<div class="add-target-wrapper">
<ha-generic-picker
.hass=${this.hass}
popover-placement="bottom-start"
.disabled=${this.disabled}
.autofocus=${this.autofocus}
.helper=${this.helper}
+4 -1
View File
@@ -156,7 +156,7 @@ class HaInputMulti extends LitElement {
)}
</div>
</ha-sortable>
<div class="layout horizontal">
<div class="layout horizontal add-row">
<ha-button
size="s"
appearance="filled"
@@ -258,6 +258,9 @@ class HaInputMulti extends LitElement {
margin-bottom: 8px;
--ha-input-padding-bottom: 0;
}
.add-row:has(+ ha-input-helper-text) {
margin-bottom: var(--ha-space-1);
}
ha-icon-button {
display: block;
}
+27
View File
@@ -127,6 +127,8 @@ export class HaInput extends WaInputMixin(LitElement) {
@query("wa-input")
private _input?: WaInput;
private _startSlotResizeObserver?: ResizeObserver;
@state()
@consume({ context: internationalizationContext, subscribe: true })
protected i18n?: ContextType<typeof internationalizationContext>;
@@ -167,9 +169,15 @@ export class HaInput extends WaInputMixin(LitElement) {
// Wait for wa-input to finish its first render
await this._input?.updateComplete;
this._syncStartSlotWidth();
this._observeStartSlot();
}
}
public override disconnectedCallback(): void {
super.disconnectedCallback();
this._startSlotResizeObserver?.disconnect();
}
protected render() {
const hasLabelSlot = this.label
? false
@@ -291,6 +299,25 @@ export class HaInput extends WaInputMixin(LitElement) {
return nothing;
}
// Safari can report the start-slot width as 0 during the first render, which
// leaves the floating label overlapping the start icon (e.g. the magnify icon
// in ha-input-search). Re-sync whenever the wrapper's size changes
// (0 -> icon width, or hidden -> shown) so the label padding stays correct.
private _observeStartSlot() {
if (typeof ResizeObserver === "undefined") {
return;
}
const startEl = this._input?.shadowRoot?.querySelector('[part~="start"]');
if (!startEl) {
return;
}
this._startSlotResizeObserver?.disconnect();
this._startSlotResizeObserver = new ResizeObserver(() =>
this._syncStartSlotWidth()
);
this._startSlotResizeObserver.observe(startEl);
}
private _syncStartSlotWidth = () => {
const startEl = this._input?.shadowRoot?.querySelector(
'[part~="start"]'
+54
View File
@@ -181,6 +181,8 @@ export interface RestoreBackupParams {
restore_homeassistant?: boolean;
}
export type CloudBackupHealth = "success" | "failed" | "old" | "none";
export const fetchBackupConfig = (hass: Pick<HomeAssistant, "callWS">) =>
hass.callWS<{ config: BackupConfig }>({ type: "backup/config/info" });
@@ -314,9 +316,61 @@ export const CORE_LOCAL_AGENT = "backup.local";
export const HASSIO_LOCAL_AGENT = "hassio.local";
export const CLOUD_AGENT = "cloud.cloud";
// How many hours a scheduled automatic backup may be behind before it reads as
// overdue, so a few hours of scheduler lag (or a daylight saving shift) doesn't
// show a warning.
export const BACKUP_OVERDUE_MARGIN_HOURS = 3;
export const isLocalAgent = (agentId: string) =>
[CORE_LOCAL_AGENT, HASSIO_LOCAL_AGENT].includes(agentId);
export const getLastCloudBackup = (
backups?: BackupContent[]
): BackupContent | undefined =>
backups
?.filter((backup) => CLOUD_AGENT in backup.agents)
.sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime())[0];
export const cloudBackupEnabled = (backupConfig?: BackupConfig): boolean =>
!!backupConfig?.automatic_backups_configured &&
backupConfig.create_backup.agent_ids.includes(CLOUD_AGENT);
const BACKUP_OVERDUE_MARGIN_MS = BACKUP_OVERDUE_MARGIN_HOURS * 60 * 60 * 1000;
export const cloudBackupHealth = (
backupConfig?: BackupConfig
): CloudBackupHealth => {
if (!cloudBackupEnabled(backupConfig)) {
return "none";
}
const completed = backupConfig?.last_completed_automatic_backup
? new Date(backupConfig.last_completed_automatic_backup).getTime()
: 0;
const attempted = backupConfig?.last_attempted_automatic_backup
? new Date(backupConfig.last_attempted_automatic_backup).getTime()
: 0;
if (!completed && !attempted) {
return "none";
}
if (attempted > completed) {
return "failed";
}
const next =
backupConfig?.next_automatic_backup &&
new Date(backupConfig.next_automatic_backup).getTime();
if (next && next < Date.now() - BACKUP_OVERDUE_MARGIN_MS) {
return "old";
}
return "success";
};
export const isNetworkMountAgent = (agentId: string) => {
const [domain, name] = agentId.split(".");
return domain === "hassio" && name !== "local";
+39 -2
View File
@@ -28,8 +28,13 @@ export interface CloudPreferences {
google_report_state: boolean;
tts_default_voice: [string, string];
cloud_ice_servers_enabled: boolean;
onboarded_items: string[];
onboarding_postponed_until: string | null;
}
export type RemoteCertificateStatus =
"error" | "generating" | "loading" | "loaded" | "ready";
export interface CloudStatusLoggedIn {
logged_in: true;
cloud: "disconnected" | "connecting" | "connected";
@@ -44,18 +49,36 @@ export interface CloudStatusLoggedIn {
remote_domain: string | undefined;
remote_connected: boolean;
remote_certificate: undefined | CertificateInformation;
remote_certificate_status:
null | "error" | "generating" | "loaded" | "loading" | "ready";
remote_certificate_status: RemoteCertificateStatus | null;
http_use_ssl: boolean;
active_subscription: boolean;
onboarding_postponed: boolean;
onboarding_completed: boolean;
}
export type CloudStatus = CloudStatusNotLoggedIn | CloudStatusLoggedIn;
// Onboarding items the backend tracks. Mirrors ONBOARDING_ITEMS in the cloud
// integration; onboarding is complete once every item has been onboarded.
export const ONBOARDING_ITEMS = [
"remote",
"backup",
"voice",
"streaming",
] as const;
export type CloudOnboardingItem = (typeof ONBOARDING_ITEMS)[number];
type SubscriptionStatus =
"active" | "canceled" | "expired" | "trialing" | "unknown";
export interface SubscriptionInfo {
human_description: string;
provider: string;
plan_renewal_date?: number;
subscription?: {
status?: SubscriptionStatus;
};
}
export interface CloudWebhook {
@@ -159,6 +182,20 @@ export const updateCloudPref = (
...prefs,
});
export const postponeCloudOnboarding = (hass: HomeAssistant) =>
hass.callWS<CloudStatusLoggedIn>({
type: "cloud/onboarding/postpone",
});
export const completeCloudOnboarding = (
hass: HomeAssistant,
items: CloudOnboardingItem[]
) =>
hass.callWS<CloudStatusLoggedIn>({
type: "cloud/onboarding/complete",
items,
});
export const removeCloudData = (hass: HomeAssistant) =>
hass.callWS({
type: "cloud/remove_data",
+2
View File
@@ -842,6 +842,8 @@ export const getEnergyDataCollection = (
if (err.code === "not_found") {
return {
prefs: EMPTY_PREFERENCES,
start: collection.start,
end: collection.end,
} as EnergyData;
}
throw err;
+41
View File
@@ -0,0 +1,41 @@
import type { HomeAssistant } from "../types";
export interface HttpConfig {
server_host?: string[];
server_port?: number;
ssl_certificate?: string;
ssl_peer_certificate?: string;
ssl_key?: string;
cors_allowed_origins?: string[];
use_x_forwarded_for?: boolean;
trusted_proxies?: string[];
use_x_frame_options?: boolean;
ip_ban_enabled?: boolean;
login_attempts_threshold?: number;
ssl_profile?: "modern" | "intermediate";
}
export interface HttpConfigState {
stable: HttpConfig;
pending: HttpConfig | null;
revert_at: string | null;
}
export interface SaveHttpConfigResult {
restart: boolean;
}
export const fetchHttpConfig = (hass: HomeAssistant) =>
hass.callWS<HttpConfigState>({ type: "http/config" });
export const saveHttpConfig = (
hass: HomeAssistant,
config: HttpConfig | null
) =>
hass.callWS<SaveHttpConfigResult>({
type: "http/config/configure",
config,
});
export const promoteHttpConfig = (hass: HomeAssistant) =>
hass.callWS<undefined>({ type: "http/config/promote" });
+17
View File
@@ -5,6 +5,23 @@ export interface ActionHandlerOptions {
hasHold?: boolean;
hasDoubleClick?: boolean;
disabled?: boolean;
/**
* Only listen for keyboard activation; pointer gestures are handled
* elsewhere (a container binding with `resolve` owns them).
*/
keyboardOnly?: boolean;
/**
* Container binding: resolve a pointer gesture at viewport coordinates
* (x, y) to the element that should receive the action. Returning null
* leaves the event alone. The resolver runs at both the start and the end
* of a gesture; a release resolving to a different target aborts it.
*/
resolve?: (x: number, y: number, ev: Event) => ActionHandlerResolution | null;
}
export interface ActionHandlerResolution {
target: HTMLElement;
options: ActionHandlerOptions;
}
export interface ActionHandlerDetail {
+4
View File
@@ -15,3 +15,7 @@ export const fetchWebhooks = (hass: HomeAssistant): Promise<Webhook[]> =>
hass.callWS({
type: "webhook/list",
});
export const isActiveCloudWebhook = (hook: Webhook): boolean =>
!hook.local_only &&
(hook.domain !== "mobile_app" || hook.name !== "Deleted Webhook");
+4
View File
@@ -117,10 +117,14 @@ export interface SetZwaveUserParams {
user_type?: string;
credential_rule?: string;
active?: boolean;
credential_type?: ZwaveCredentialType;
credential_slot?: number;
credential_data?: string;
}
export interface SetZwaveUserResult {
user_id: number;
credential_slot?: number | null;
}
export interface SetZwaveCredentialParams {
+1 -1
View File
@@ -272,7 +272,7 @@ export interface ZWaveJSNodeConfigParam {
property: number;
property_key: number | null;
endpoint: number;
value: any;
value: number | null;
configuration_value_type: string;
metadata: ZWaveJSNodeConfigParamMetadata;
}
@@ -17,6 +17,7 @@ import {
subscribeDataEntryFlowProgressed,
} from "../../data/data_entry_flow";
import type { DeviceRegistryEntry } from "../../data/device/device_registry";
import { DirtyStateProviderMixin } from "../../mixins/dirty-state-provider-mixin";
import { haStyleDialog } from "../../resources/styles";
import type { HomeAssistant } from "../../types";
import { documentationUrl } from "../../util/documentation-url";
@@ -74,7 +75,10 @@ declare global {
}
@customElement("dialog-data-entry-flow")
class DataEntryFlowDialog extends LitElement {
class DataEntryFlowDialog extends DirtyStateProviderMixin<
Record<string, unknown>,
"form"
>()(LitElement) {
@property({ attribute: false }) public hass!: HomeAssistant;
@state() private _params?: DataEntryFlowDialogParams;
@@ -99,6 +103,8 @@ class DataEntryFlowDialog extends LitElement {
@state() private _createEntryHasPendingUpdates = false;
@state() private _flowHasProgressed = false;
private _formStepRef = createRef<FormStepElement>();
private _abortStepRef = createRef<AbortStepElement>();
@@ -108,6 +114,8 @@ class DataEntryFlowDialog extends LitElement {
private _unsubDataEntryFlowProgress?: UnsubscribeFunc;
public async showDialog(params: DataEntryFlowDialogParams): Promise<void> {
this._initDirtyTracking({ type: "deep" });
this._flowHasProgressed = Boolean(params.continueFlowId);
this._params = params;
this._instance = instance++;
this._open = true;
@@ -175,7 +183,7 @@ class DataEntryFlowDialog extends LitElement {
return;
}
this._processStep(step);
this._processStep(step, this._flowHasProgressed);
this._loading = undefined;
}
@@ -213,6 +221,7 @@ class DataEntryFlowDialog extends LitElement {
this._loading = undefined;
this._step = undefined;
this._flowHasProgressed = false;
this._params = undefined;
this._handler = undefined;
if (this._unsubDataEntryFlowProgress) {
@@ -334,7 +343,7 @@ class DataEntryFlowDialog extends LitElement {
return html`
<ha-dialog
.open=${this._open}
prevent-scrim-close
.preventScrimClose=${this._preventScrimClose}
@after-show=${this._focusFormStep}
@closed=${this._dialogClosed}
>
@@ -484,6 +493,18 @@ class DataEntryFlowDialog extends LitElement {
`;
}
private get _preventScrimClose(): boolean {
return (
this.isDirtyState ||
this._flowHasProgressed ||
this._loading !== undefined ||
this._formStepLoading ||
this._createEntryHasPendingUpdates ||
this._step?.type === "external" ||
this._step?.type === "progress"
);
}
private _renderFooter() {
if (!this._step || this._loading) {
return nothing;
@@ -588,13 +609,15 @@ class DataEntryFlowDialog extends LitElement {
}
private async _processStep(
step: DataEntryFlowStep | undefined | Promise<DataEntryFlowStep>
step: DataEntryFlowStep | undefined | Promise<DataEntryFlowStep>,
flowHasProgressed = true
): Promise<void> {
if (step === undefined) {
this.closeDialog();
return;
}
this._flowHasProgressed ||= flowHasProgressed;
const delayedLoading = setTimeout(() => {
// only show loading for slow steps to avoid flickering
this._loading = "loading_step";
@@ -615,11 +638,11 @@ class DataEntryFlowDialog extends LitElement {
clearTimeout(delayedLoading);
this._loading = undefined;
}
this._step = undefined;
this._formStepLoading = false;
this._createEntryHasPendingUpdates = false;
await this.updateComplete;
this._initDirtyTracking({ type: "deep" });
this._step = _step;
if (
(_step.type === "create_entry" || _step.type === "abort") &&
+11
View File
@@ -1,3 +1,4 @@
import { consume } from "@lit/context";
import type { CSSResultGroup, PropertyValues, TemplateResult } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
@@ -16,6 +17,10 @@ import type {
import "../../components/ha-markdown";
import "../../components/ha-spinner";
import { autocompleteLoginFields } from "../../data/auth";
import {
dirtyStateContext,
type DirtyStateContext,
} from "../../data/context/dirty-state";
import type { DataEntryFlowStepForm } from "../../data/data_entry_flow";
import { previewModule } from "../../data/preview";
import { haStyle } from "../../resources/styles";
@@ -49,6 +54,10 @@ class StepFlowForm extends LitElement {
@state() private _errorMsg?: string;
@consume({ context: dirtyStateContext, subscribe: true })
@state()
private _dirtyState?: DirtyStateContext<Record<string, unknown>, "form">;
private _errors?: Record<string, string>;
private _formRef = createRef<HTMLElementTagNameMap["ha-form"]>();
@@ -150,6 +159,7 @@ class StepFlowForm extends LitElement {
protected firstUpdated(changedProps: PropertyValues<this>) {
super.firstUpdated(changedProps);
this.addEventListener("keydown", this._handleKeyDown);
this._dirtyState?.setState(this._stepDataProcessed, "form");
}
protected updated(changedProps: PropertyValues): void {
@@ -303,6 +313,7 @@ class StepFlowForm extends LitElement {
ev: ValueChangedEvent<Record<string, unknown>>
): void {
this._stepData = ev.detail.value;
this._dirtyState?.setState(this._stepData, "form");
}
private _labelCallback = (field: HaFormSchema, _data, options): string =>
@@ -0,0 +1,343 @@
import { ERR_CONNECTION_LOST } from "home-assistant-js-websocket";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { formatNumericDuration } from "../../common/datetime/format_duration";
import { fireEvent } from "../../common/dom/fire_event";
import "../../components/ha-alert";
import "../../components/ha-button";
import "../../components/ha-dialog-footer";
import "../../components/ha-dialog";
import type { HttpConfig } from "../../data/http";
import { promoteHttpConfig, saveHttpConfig } from "../../data/http";
import { haStyleDialog } from "../../resources/styles";
import type { HomeAssistant } from "../../types";
import type { HassDialog } from "../make-dialog-manager";
import type { HttpPendingConfigDialogParams } from "./show-dialog-http-pending-config";
const HTTP_FIELDS: (keyof HttpConfig)[] = [
"server_port",
"server_host",
"ssl_certificate",
"ssl_key",
"ssl_peer_certificate",
"ssl_profile",
"cors_allowed_origins",
"use_x_forwarded_for",
"trusted_proxies",
"use_x_frame_options",
"ip_ban_enabled",
"login_attempts_threshold",
];
@customElement("dialog-http-pending-config")
export class DialogHttpPendingConfig
extends LitElement
implements HassDialog<HttpPendingConfigDialogParams>
{
@property({ attribute: false }) public hass!: HomeAssistant;
@state() private _params?: HttpPendingConfigDialogParams;
@state() private _open = false;
@state() private _busy: "confirm" | "revert" | undefined;
@state() private _error?: string;
@state() private _secondsRemaining?: number;
@state() private _reverted = false;
private _interval?: number;
public showDialog(params: HttpPendingConfigDialogParams): void {
this._params = params;
this._open = true;
this._busy = undefined;
this._error = undefined;
this._reverted = false;
this._startCountdown();
}
public closeDialog(): boolean {
this._open = false;
this._stopCountdown();
return true;
}
public disconnectedCallback(): void {
super.disconnectedCallback();
this._stopCountdown();
}
private _dialogClosed(): void {
this._params = undefined;
this._busy = undefined;
this._error = undefined;
this._reverted = false;
this._secondsRemaining = undefined;
this._stopCountdown();
fireEvent(this, "dialog-closed", { dialog: this.localName });
}
private _startCountdown(): void {
this._stopCountdown();
const revertAt = this._params?.state.revert_at;
if (!revertAt) {
this._secondsRemaining = undefined;
return;
}
const target = new Date(revertAt).getTime();
const tick = () => {
const remaining = Math.max(0, Math.ceil((target - Date.now()) / 1000));
this._secondsRemaining = remaining;
if (remaining === 0) {
this._stopCountdown();
this._reverted = true;
}
};
tick();
if (this._secondsRemaining && this._secondsRemaining > 0) {
this._interval = window.setInterval(tick, 1000);
}
}
private _stopCountdown(): void {
if (this._interval) {
window.clearInterval(this._interval);
this._interval = undefined;
}
}
private get _changedFields(): (keyof HttpConfig)[] {
if (!this._params?.state.pending) {
return [];
}
const { stable, pending } = this._params.state;
return HTTP_FIELDS.filter(
(key) => JSON.stringify(stable[key]) !== JSON.stringify(pending[key])
);
}
protected render() {
if (!this._params) {
return nothing;
}
const changes = this._changedFields;
return html`
<ha-dialog
.open=${this._open}
.headerTitle=${this.hass.localize(
"ui.dialogs.http_pending_config.title"
)}
prevent-scrim-close
width="medium"
@closed=${this._dialogClosed}
>
<span slot="headerNavigationIcon"></span>
<div class="content">
${
this._reverted
? html`
<ha-alert alert-type="info">
${this.hass.localize(
"ui.dialogs.http_pending_config.reverted"
)}
</ha-alert>
`
: html`
<p>
${this.hass.localize(
"ui.dialogs.http_pending_config.description"
)}
</p>
${
this._secondsRemaining !== undefined
? html`
<p class="countdown">
${this.hass.localize(
"ui.dialogs.http_pending_config.auto_revert",
{
time:
formatNumericDuration(this.hass.locale, {
minutes: Math.floor(
this._secondsRemaining / 60
),
seconds: this._secondsRemaining % 60,
}) ?? "0",
}
)}
</p>
`
: nothing
}
`
}
${
changes.length
? html`
<p class="changes-label">
${this.hass.localize(
"ui.dialogs.http_pending_config.changes_label"
)}
</p>
<ul>
${changes.map(
(key) => html`
<li>
${this.hass.localize(
`ui.panel.config.network.http.fields.${key}` as any
)}
</li>
`
)}
</ul>
`
: nothing
}
${
this._error
? html`<ha-alert alert-type="error">${this._error}</ha-alert>`
: nothing
}
</div>
<ha-dialog-footer slot="footer">
${
this._reverted
? html`
<ha-button slot="primaryAction" @click=${this._close}>
${this.hass.localize("ui.dialogs.http_pending_config.close")}
</ha-button>
`
: html`
<ha-button
slot="secondaryAction"
appearance="plain"
.loading=${this._busy === "revert"}
.disabled=${this._busy === "confirm"}
@click=${this._revert}
>
${this.hass.localize("ui.dialogs.http_pending_config.revert")}
</ha-button>
<ha-button
slot="primaryAction"
.loading=${this._busy === "confirm"}
.disabled=${this._busy === "revert"}
@click=${this._confirm}
>
${this.hass.localize(
"ui.dialogs.http_pending_config.confirm"
)}
</ha-button>
`
}
</ha-dialog-footer>
</ha-dialog>
`;
}
private async _confirm(): Promise<void> {
if (this._busy) {
return;
}
this._busy = "confirm";
this._error = undefined;
try {
await promoteHttpConfig(this.hass);
this._notifyResolved();
this._open = false;
} catch (err: any) {
// A confirm fired right as the backend auto-reverts may race the
// restart and surface as a connection-lost error. Treat it as resolved;
// the disconnected overlay takes over.
if (err?.error?.code === ERR_CONNECTION_LOST) {
this._notifyResolved();
this._open = false;
return;
}
this._error = this.hass.localize(
"ui.dialogs.http_pending_config.confirm_error",
{ error: err.message ?? "" }
);
this._busy = undefined;
}
}
private _close(): void {
this._notifyResolved();
this._open = false;
}
private async _revert(): Promise<void> {
if (this._busy || !this._params) {
return;
}
this._busy = "revert";
this._error = undefined;
try {
await saveHttpConfig(this.hass, null);
this._notifyResolved();
this._open = false;
} catch (err: any) {
// The restart triggered by clearing pending may cut the WS connection
// before we get a reply. The disconnected overlay takes over from here.
if (err?.error?.code === ERR_CONNECTION_LOST) {
this._notifyResolved();
this._open = false;
return;
}
this._error = this.hass.localize(
"ui.dialogs.http_pending_config.revert_error",
{ error: err.message ?? "" }
);
this._busy = undefined;
}
}
private _notifyResolved(): void {
this._params?.onResolved?.();
// The form on Settings > System > Network may be mounted and showing
// stale state; let it know to refetch.
window.dispatchEvent(new Event("http-config-resolved"));
}
static styles = [
haStyleDialog,
css`
.content {
line-height: var(--ha-line-height-normal);
}
p {
margin: 0 0 var(--ha-space-4) 0;
}
.countdown {
color: var(--secondary-text-color);
}
.changes-label {
font-weight: var(--ha-font-weight-medium);
margin-bottom: var(--ha-space-2);
}
ul {
margin: 0 0 var(--ha-space-4) 0;
padding-left: var(--ha-space-6);
color: var(--secondary-text-color);
}
li {
margin-bottom: var(--ha-space-1);
}
ha-alert {
display: block;
margin-top: var(--ha-space-4);
margin-bottom: var(--ha-space-4);
}
`,
];
}
declare global {
interface HTMLElementTagNameMap {
"dialog-http-pending-config": DialogHttpPendingConfig;
}
}
@@ -0,0 +1,22 @@
import { fireEvent } from "../../common/dom/fire_event";
import type { HttpConfigState } from "../../data/http";
export interface HttpPendingConfigDialogParams {
state: HttpConfigState;
onResolved?: () => void;
}
export const loadHttpPendingConfigDialog = () =>
import("./dialog-http-pending-config");
export const showHttpPendingConfigDialog = (
element: HTMLElement,
dialogParams: HttpPendingConfigDialogParams
): void => {
fireEvent(element, "show-dialog", {
dialogTag: "dialog-http-pending-config",
dialogImport: loadHttpPendingConfigDialog,
dialogParams,
addHistory: false,
});
};
@@ -257,10 +257,6 @@ class HaMoreInfoDetails extends LitElement {
font-weight: var(--ha-font-weight-medium);
}
ha-card {
direction: ltr;
}
.card-content {
padding: var(--ha-space-2) var(--ha-space-4);
}
@@ -249,6 +249,7 @@ class DialogShortcuts extends DialogMixin(LitElement) {
static styles = [
css`
.shortcut {
direction: ltr;
display: flex;
flex-direction: row;
align-items: center;
+42 -1
View File
@@ -5,9 +5,12 @@ import { customElement, state } from "lit/decorators";
import { storage } from "../common/decorators/storage";
import { isNavigationClick } from "../common/dom/is-navigation-click";
import { navigate } from "../common/navigate";
import { fetchHttpConfig } from "../data/http";
import type { HttpConfigState } from "../data/http";
import type { WindowWithPreloads } from "../data/preloads";
import type { RecorderInfo } from "../data/recorder";
import { getRecorderInfo } from "../data/recorder";
import { showHttpPendingConfigDialog } from "../dialogs/http-pending-config/show-dialog-http-pending-config";
import "../resources/custom-card-support";
import { HassElement } from "../state/hass-element";
import QuickBarMixin from "../state/quick-bar-mixin";
@@ -54,6 +57,8 @@ export class HomeAssistantAppEl extends QuickBarMixin(HassElement) {
@state() private _databaseMigration?: boolean;
private _httpPendingDialogOpen = false;
private _panelUrl: string;
@storage({ key: "ha-version", state: false, subscribe: false })
@@ -85,14 +90,24 @@ export class HomeAssistantAppEl extends QuickBarMixin(HassElement) {
protected willUpdate(changedProps: PropertyValues<this>) {
super.willUpdate(changedProps);
const oldHass = changedProps.get("hass") as HomeAssistant | undefined;
if (
this._databaseMigration === undefined &&
changedProps.has("hass") &&
this.hass?.config &&
changedProps.get("hass")?.config !== this.hass?.config
oldHass?.config !== this.hass.config
) {
this.checkDataBaseMigration();
}
// Wait for `hass.user` to populate so the admin guard can run; it arrives
// asynchronously after `hass.config`.
if (
changedProps.has("hass") &&
this.hass?.user &&
oldHass?.user !== this.hass.user
) {
this.checkHttpPendingConfig();
}
}
protected update(changedProps: PropertyValues<this>) {
@@ -220,6 +235,32 @@ export class HomeAssistantAppEl extends QuickBarMixin(HassElement) {
}
}
protected async checkHttpPendingConfig() {
if (__DEMO__ || this._httpPendingDialogOpen) {
return;
}
if (!this.hass?.user?.is_admin) {
return;
}
let httpConfig: HttpConfigState;
try {
httpConfig = await fetchHttpConfig(this.hass);
} catch (_err) {
// The check re-runs on the next reconnect; ignore transient failures.
return;
}
if (!httpConfig.pending || this._httpPendingDialogOpen) {
return;
}
this._httpPendingDialogOpen = true;
showHttpPendingConfigDialog(this, {
state: httpConfig,
onResolved: () => {
this._httpPendingDialogOpen = false;
},
});
}
protected async checkDataBaseMigration() {
if (__DEMO__) {
this._databaseMigration = false;
@@ -2686,6 +2686,7 @@ class DialogAddAutomationElement
}
.shortcut {
direction: ltr;
--mdc-icon-size: var(--ha-space-3);
display: inline-flex;
flex-direction: row;
@@ -688,8 +688,17 @@ export class HaAutomationEditor extends AutomationScriptEditorMixin<AutomationCo
...baseConfig,
...(initData ? normalizeAutomationConfig(initData) : initData),
} as AutomationConfig;
this._initDirtyTracking({ type: "deep" }, baseConfig as AutomationConfig);
this._updateDirtyState(this.config);
this._initDirtyTracking(
{ type: "deep" },
{
config: baseConfig as AutomationConfig,
entityRegistryUpdate: this.entityRegistryUpdate,
}
);
this._updateDirtyState({
config: this.config,
entityRegistryUpdate: this.entityRegistryUpdate,
});
this.currentEntityId = undefined;
this.readOnly = false;
}
@@ -697,7 +706,13 @@ export class HaAutomationEditor extends AutomationScriptEditorMixin<AutomationCo
if (changedProps.has("entityId") && this.entityId) {
getAutomationStateConfig(this.hass, this.entityId).then((c) => {
this.config = normalizeAutomationConfig(c.config);
this._initDirtyTracking({ type: "deep" }, this.config);
this._initDirtyTracking(
{ type: "deep" },
{
config: this.config,
entityRegistryUpdate: this.entityRegistryUpdate,
}
);
this._checkValidation();
});
this.currentEntityId = this.entityId;
@@ -763,7 +778,10 @@ export class HaAutomationEditor extends AutomationScriptEditorMixin<AutomationCo
if (this.readOnly) {
return;
}
this._updateDirtyState(this.config);
this._updateDirtyState({
config: this.config,
entityRegistryUpdate: this.entityRegistryUpdate,
});
this.errors = undefined;
}
@@ -844,7 +862,10 @@ export class HaAutomationEditor extends AutomationScriptEditorMixin<AutomationCo
id: this.config?.id,
...normalizeAutomationConfig(ev.detail.value),
};
this._updateDirtyState(this.config!);
this._updateDirtyState({
config: this.config!,
entityRegistryUpdate: this.entityRegistryUpdate,
});
this.errors = undefined;
}
@@ -860,7 +881,10 @@ export class HaAutomationEditor extends AutomationScriptEditorMixin<AutomationCo
updateConfig: async (config, entityRegistryUpdate) => {
this.config = config;
this.entityRegistryUpdate = entityRegistryUpdate;
this._updateDirtyState(this.config);
this._updateDirtyState({
config: this.config,
entityRegistryUpdate: this.entityRegistryUpdate,
});
this.requestUpdate();
const id = this.automationId || String(Date.now());
@@ -974,7 +998,10 @@ export class HaAutomationEditor extends AutomationScriptEditorMixin<AutomationCo
updateConfig: async (config, entityRegistryUpdate) => {
this.config = config;
this.entityRegistryUpdate = entityRegistryUpdate;
this._updateDirtyState(this.config);
this._updateDirtyState({
config: this.config,
entityRegistryUpdate: this.entityRegistryUpdate,
});
this.requestUpdate();
resolve(true);
},
@@ -991,7 +1018,10 @@ export class HaAutomationEditor extends AutomationScriptEditorMixin<AutomationCo
config: this.config!,
updateConfig: (config) => {
this.config = config;
this._updateDirtyState(config);
this._updateDirtyState({
config,
entityRegistryUpdate: this.entityRegistryUpdate,
});
this.requestUpdate();
resolve();
},
@@ -1011,7 +1041,7 @@ export class HaAutomationEditor extends AutomationScriptEditorMixin<AutomationCo
this._manualEditor?.resetPastedConfig();
const id = this.automationId || String(Date.now());
if (!this.automationId) {
if (!this.automationId && !this.config?.alias) {
const saved = await this._promptAutomationAlias();
if (!saved) {
return;
@@ -1142,7 +1172,10 @@ export class HaAutomationEditor extends AutomationScriptEditorMixin<AutomationCo
private _applyUndoRedo(config: AutomationConfig) {
this._manualEditor?.triggerCloseSidebar();
this.config = config;
this._updateDirtyState(this.config);
this._updateDirtyState({
config: this.config,
entityRegistryUpdate: this.entityRegistryUpdate,
});
}
private _undo() {
@@ -71,6 +71,7 @@ export const automationScriptEditorStyles: CSSResult = css`
width: 12px;
}
ha-tooltip .shortcut {
direction: ltr;
display: inline-flex;
flex-direction: row;
align-items: center;
@@ -85,12 +86,17 @@ export interface EditorDomainHooks<TConfig> {
domain: "automation" | "script";
}
interface AutomationEditorConfig<TConfig> {
config: TConfig;
entityRegistryUpdate?: EntityRegistryUpdate;
}
export const AutomationScriptEditorMixin = <TConfig extends BaseEditorConfig>(
superClass: Constructor<LitElement>
) => {
class AutomationScriptEditorClass extends DirtyStateProviderMixin<TConfig>()(
superClass
) {
class AutomationScriptEditorClass extends DirtyStateProviderMixin<
AutomationEditorConfig<TConfig>
>()(superClass) {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: "is-wide", type: Boolean }) public isWide = false;
@@ -220,8 +226,17 @@ export const AutomationScriptEditorMixin = <TConfig extends BaseEditorConfig>(
protected takeControlSave() {
this.readOnly = false;
// Force dirty: set baseline to null so current config always differs
this._initDirtyTracking({ type: "deep" }, null as unknown as TConfig);
this._updateDirtyState(this.config!);
this._initDirtyTracking(
{ type: "deep" },
{
config: null as unknown as TConfig,
entityRegistryUpdate: this.entityRegistryUpdate,
}
);
this._updateDirtyState({
config: this.config!,
entityRegistryUpdate: this.entityRegistryUpdate,
});
this.blueprintConfig = undefined;
}
@@ -266,7 +281,13 @@ export const AutomationScriptEditorMixin = <TConfig extends BaseEditorConfig>(
// looks dirty. Surface an alert offering to save when deprecated
// options were migrated.
this.deprecatedConfigMigrated = report.deprecated;
this._initDirtyTracking({ type: "deep" }, this.config);
this._initDirtyTracking(
{ type: "deep" },
{
config: this.config,
entityRegistryUpdate: this.entityRegistryUpdate,
}
);
hooks.checkValidation();
} catch (err: any) {
if (err.status_code !== 404) {
+1
View File
@@ -269,6 +269,7 @@ export const overflowStyles = css`
white-space: nowrap;
}
.overflow-label .shortcut {
direction: ltr;
--mdc-icon-size: 12px;
display: inline-flex;
flex-direction: row;
@@ -18,6 +18,7 @@ import "../../../../../components/item/ha-list-item-base";
import "../../../../../components/list/ha-list-base";
import type { BackupConfig, BackupContent } from "../../../../../data/backup";
import {
BACKUP_OVERDUE_MARGIN_HOURS,
BackupScheduleRecurrence,
getFormattedBackupTime,
} from "../../../../../data/backup";
@@ -26,8 +27,6 @@ import type { HomeAssistant } from "../../../../../types";
import { showAlertDialog } from "../../../../lovelace/custom-card-helpers";
import "../ha-backup-summary-card";
const OVERDUE_MARGIN_HOURS = 3;
@customElement("ha-backup-overview-summary")
class HaBackupOverviewBackups extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@@ -74,26 +73,33 @@ class HaBackupOverviewBackups extends LitElement {
</ha-list-item-base>
${
description || description === null
? html`<ha-list-item-base>
<ha-svg-icon slot="start" .path=${mdiCalendar}></ha-svg-icon>
<span
slot="headline"
class=${description === null ? "skeleton" : ""}
>${description}</span
>
? html`
<ha-list-item-base>
<ha-svg-icon
slot="start"
.path=${mdiCalendar}
></ha-svg-icon>
<span
slot="headline"
class=${description === null ? "skeleton" : ""}
>${description}</span
>
${
lastCompletedDate
? html` <ha-icon-button
slot="end"
@click=${this._createAdditionalBackupDescription(
lastCompletedDate
)}
.path=${mdiInformationOutline}
></ha-icon-button>`
: nothing
}
</ha-list-item-base>`
${
lastCompletedDate
? html`
<ha-icon-button
slot="end"
@click=${this._createAdditionalBackupDescription(
lastCompletedDate
)}
.path=${mdiInformationOutline}
></ha-icon-button>
`
: nothing
}
</ha-list-item-base>
`
: nothing
}
</ha-list-base>
@@ -293,7 +299,7 @@ class HaBackupOverviewBackups extends LitElement {
const numberOfDays = differenceInDays(
// Subtract a few hours to avoid showing as overdue if it's just a few hours (e.g. daylight saving)
addHours(now, -OVERDUE_MARGIN_HOURS),
addHours(now, -BACKUP_OVERDUE_MARGIN_HOURS),
lastBackupDate
);
@@ -74,6 +74,8 @@ class HaConfigBackupOverview extends LitElement {
@state() private _config?: BackupConfig;
private _searchParms = new URLSearchParams(window.location.search);
protected willUpdate(changedProperties: PropertyValues<this>): void {
super.willUpdate(changedProperties);
if (changedProperties.has("config") && !this._config) {
@@ -204,7 +206,9 @@ class HaConfigBackupOverview extends LitElement {
return html`
<hass-subpage
back-path="/config/system"
.backPath=${
this._searchParms.has("historyBack") ? undefined : "/config/system"
}
.hass=${this.hass}
.narrow=${this.narrow}
.header=${this.hass.localize("ui.panel.config.backup.overview.header")}
@@ -229,24 +233,27 @@ class HaConfigBackupOverview extends LitElement {
<div class="content">
${
this.info && Object.keys(this.info.agent_errors).length
? html`${Object.entries(this.info.agent_errors).map(
([agentId, error]) =>
html`<ha-alert
alert-type="error"
.title=${this.hass.localize(
"ui.panel.config.backup.overview.agent_error",
{
name: computeBackupAgentName(
this.hass.localize,
agentId,
this.agents
),
}
)}
>
${error}
</ha-alert>`
)}`
? html`
${Object.entries(this.info.agent_errors).map(
([agentId, error]) => html`
<ha-alert
alert-type="error"
.title=${this.hass.localize(
"ui.panel.config.backup.overview.agent_error",
{
name: computeBackupAgentName(
this.hass.localize,
agentId,
this.agents
),
}
)}
>
${error}
</ha-alert>
`
)}
`
: nothing
}
${
@@ -0,0 +1,263 @@
import {
mdiBackupRestore,
mdiCheckCircle,
mdiEarth,
mdiMicrophoneMessage,
mdiVideo,
} from "@mdi/js";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property } from "lit/decorators";
import type { TemplateResult } from "lit";
import { fireEvent } from "../../../../common/dom/fire_event";
import "../../../../components/ha-button";
import "../../../../components/ha-card";
import "../../../../components/ha-svg-icon";
import type { BackupConfig } from "../../../../data/backup";
import type {
CloudOnboardingItem,
CloudStatusLoggedIn,
} from "../../../../data/cloud";
import { postponeCloudOnboarding } from "../../../../data/cloud";
import { haStyle } from "../../../../resources/styles";
import type { HomeAssistant } from "../../../../types";
import { showToast } from "../../../../util/toast";
import { onboardingPanelCompleted } from "./cloud-account-status";
@customElement("cloud-account-onboarding")
export class CloudAccountOnboarding extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false }) public cloudStatus!: CloudStatusLoggedIn;
@property({ attribute: false }) public backupConfig?: BackupConfig;
protected render() {
return html`
<ha-card outlined>
<div class="card-content ready-card">
<div class="ready-left">
<h2>
${this.hass.localize(
"ui.panel.config.cloud.account.onboarding.ready_title"
)}
</h2>
<p class="muted">
${this.hass.localize(
"ui.panel.config.cloud.account.onboarding.ready_text"
)}
</p>
<div class="ready-actions">
<ha-button appearance="filled" @click=${this._startSetup}>
${this.hass.localize(
"ui.panel.config.cloud.account.onboarding.start_setup"
)}
</ha-button>
<ha-button appearance="plain" @click=${this._onboardingPostpone}>
${this.hass.localize(
"ui.panel.config.cloud.account.onboarding.review_later"
)}
</ha-button>
</div>
</div>
<div class="ready-grid">
${this._readyChip(
mdiEarth,
this.hass.localize(
"ui.panel.config.cloud.account.onboarding.chip_remote"
),
"remote"
)}
${this._readyChip(
mdiBackupRestore,
this.hass.localize(
"ui.panel.config.cloud.account.onboarding.chip_backups"
),
"backup"
)}
${this._readyChip(
mdiMicrophoneMessage,
this.hass.localize(
"ui.panel.config.cloud.account.onboarding.chip_voice"
),
"voice"
)}
${this._readyChip(
mdiVideo,
this.hass.localize(
"ui.panel.config.cloud.account.onboarding.chip_streaming"
),
"streaming"
)}
</div>
</div>
</ha-card>
`;
}
private _readyChip(
icon: string,
label: string,
key: CloudOnboardingItem
): TemplateResult {
return html`
<div class="ready-chip ${key}">
<div class="ready-chip-icon">
<ha-svg-icon .path=${icon}></ha-svg-icon>
${
this._panelCompleted(key)
? html`
<ha-svg-icon
class="chip-badge on"
.path=${mdiCheckCircle}
></ha-svg-icon>
`
: nothing
}
</div>
<span>${label}</span>
</div>
`;
}
private _panelCompleted(key: CloudOnboardingItem): boolean {
return onboardingPanelCompleted(key, this.cloudStatus, this.backupConfig);
}
// Delegate opening to the parent (cloud-account), which stays mounted while
// the dialog is open — so completing the last step can't tear the dialog down
// and the dialog can refresh the page through a stable element.
private _startSetup() {
fireEvent(this, "cloud-open-onboarding");
}
private async _onboardingPostpone() {
try {
await postponeCloudOnboarding(this.hass);
fireEvent(this, "ha-refresh-cloud-status");
} catch (err: any) {
showToast(this, { message: err.message });
}
}
static get styles() {
return [
haStyle,
css`
ha-card {
display: block;
width: 100%;
max-width: 600px;
margin-inline: auto;
container-type: inline-size;
}
.ready-card {
display: flex;
flex-direction: column;
gap: var(--ha-space-6);
}
.ready-left {
display: flex;
flex-direction: column;
min-width: 0;
}
.ready-left h2 {
font-size: var(--ha-font-size-2xl);
font-weight: var(--ha-font-weight-normal);
margin: 0 0 var(--ha-space-2);
white-space: normal;
overflow: visible;
text-overflow: clip;
}
.ready-actions {
display: flex;
align-items: center;
gap: var(--ha-space-2);
margin-top: var(--ha-space-4);
}
.ready-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: var(--ha-space-4);
}
@container (max-width: 450px) {
.ready-grid {
grid-template-columns: 1fr;
}
}
.ready-chip {
display: flex;
align-items: center;
gap: var(--ha-space-3);
}
.ready-chip-icon {
position: relative;
display: inline-flex;
align-items: center;
justify-content: center;
width: 40px;
height: 40px;
border-radius: 50%;
flex-shrink: 0;
}
.chip-badge {
position: absolute;
right: -2px;
bottom: -2px;
--mdc-icon-size: 16px;
border-radius: 50%;
background-color: var(--card-background-color);
}
.chip-badge.on {
color: var(--success-color);
}
.ready-chip.remote .ready-chip-icon {
color: var(--blue-color);
background-color: color-mix(
in srgb,
var(--blue-color) 15%,
transparent
);
}
.ready-chip.backup .ready-chip-icon {
color: var(--green-color);
background-color: color-mix(
in srgb,
var(--green-color) 15%,
transparent
);
}
.ready-chip.voice .ready-chip-icon {
color: var(--purple-color);
background-color: color-mix(
in srgb,
var(--purple-color) 15%,
transparent
);
}
.ready-chip.streaming .ready-chip-icon {
color: var(--cyan-color);
background-color: color-mix(
in srgb,
var(--cyan-color) 15%,
transparent
);
}
.muted {
color: var(--secondary-text-color);
}
p.muted {
margin: var(--ha-space-2) 0 0;
}
`,
];
}
}
declare global {
interface HTMLElementTagNameMap {
"cloud-account-onboarding": CloudAccountOnboarding;
}
interface HASSDomEvents {
"cloud-open-onboarding": undefined;
}
}
@@ -0,0 +1,763 @@
import {
mdiBackupRestore,
mdiCellphone,
mdiEarth,
mdiEye,
mdiEyeOff,
mdiMicrophone,
mdiMicrophoneMessage,
mdiVideo,
mdiWebhook,
} from "@mdi/js";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import type { TemplateResult } from "lit";
import { formatDate } from "../../../../common/datetime/format_date";
import { relativeTime } from "../../../../common/datetime/relative_time";
import { fireEvent } from "../../../../common/dom/fire_event";
import "../../../../components/ha-alert";
import "../../../../components/ha-button";
import "../../../../components/ha-card";
import "../../../../components/ha-icon-button";
import "../../../../components/ha-icon-next";
import "../../../../components/ha-md-list";
import "../../../../components/ha-md-list-item";
import "../../../../components/ha-svg-icon";
import type { BackupConfig } from "../../../../data/backup";
import { cloudBackupHealth, cloudBackupEnabled } from "../../../../data/backup";
import type {
CloudStatusLoggedIn,
SubscriptionInfo,
} from "../../../../data/cloud";
import type { Webhook } from "../../../../data/webhook";
import { isActiveCloudWebhook } from "../../../../data/webhook";
import { haStyle } from "../../../../resources/styles";
import type { HomeAssistant } from "../../../../types";
@customElement("cloud-account-overview")
export class CloudAccountOverview extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false }) public cloudStatus!: CloudStatusLoggedIn;
@property({ attribute: false }) public subscription?: SubscriptionInfo;
@property({ attribute: false }) public backupConfig?: BackupConfig;
@property({ attribute: false }) public webhooks?: Webhook[];
@state() private _emailRevealed = false;
protected render(): TemplateResult {
return html`
${this._renderTopCard()} ${this._renderFeaturesCard()}
${this._renderAccountCard()}
`;
}
private _renderTopCard(): TemplateResult {
return html`
<ha-card outlined>
<div class="card-content">
<div
class="thank-you-header"
role="heading"
aria-level="1"
aria-label=${this.hass.localize(
"ui.panel.config.cloud.account.thank_you_title"
)}
>
<svg
width="163"
height="30"
viewBox="0 0 163 30"
fill="none"
xmlns="http://www.w3.org/2000/svg"
aria-hidden="true"
>
<path
d="M156.478 16.0647C156.254 16.0686 156.052 15.9841 155.873 15.8112C155.71 15.622 155.624 15.2795 155.615 14.7836C155.609 14.4316 155.664 13.8786 155.778 13.1245C155.893 12.3703 156.054 11.5034 156.261 10.5236C156.484 9.52761 156.738 8.49102 157.023 7.41388C157.308 6.32074 157.617 5.25118 157.951 4.20519C158.285 3.14321 158.628 2.18507 158.981 1.33078C159.15 0.911768 159.416 0.595068 159.781 0.380677C160.161 0.15001 160.575 0.0307665 161.023 0.0229478C161.487 0.0148499 161.826 0.224953 162.042 0.653257C162.257 1.08156 162.369 1.55167 162.378 2.0636C162.382 2.25557 162.292 2.61719 162.109 3.14846C161.943 3.67945 161.714 4.32354 161.423 5.08073C161.132 5.83792 160.81 6.65966 160.458 7.54595C160.121 8.41596 159.776 9.30211 159.424 10.2044C159.087 11.0904 158.774 11.936 158.484 12.7412C158.194 13.5464 157.959 14.2546 157.777 14.8658C157.657 15.3 157.486 15.607 157.265 15.7869C157.06 15.9665 156.798 16.0591 156.478 16.0647ZM155.347 22.7815C155.155 22.7848 154.93 22.7407 154.673 22.6492C154.415 22.5577 154.187 22.3296 153.988 21.9651C153.806 21.6162 153.708 21.0498 153.695 20.2659C153.684 19.658 153.868 19.1907 154.246 18.8641C154.625 18.5374 155.134 18.3685 155.774 18.3573C156.318 18.3479 156.736 18.4926 157.03 18.7915C157.339 19.0901 157.498 19.5194 157.508 20.0793C157.517 20.5913 157.397 21.0494 157.148 21.4538C156.915 21.858 156.625 22.1751 156.276 22.4052C155.945 22.651 155.635 22.7764 155.347 22.7815Z"
fill="currentColor"
/>
<path
d="M148.022 22.7654C147.654 22.7718 147.315 22.6257 147.006 22.327C146.713 22.0441 146.56 21.5587 146.548 20.8708C146.539 20.3589 146.567 19.6463 146.631 18.733C146.711 17.8035 146.866 16.6086 147.097 15.1484C146.557 16.31 145.991 17.344 145.398 18.2505C144.806 19.1569 144.218 19.8713 143.636 20.3936C143.068 20.8995 142.521 21.1571 141.993 21.1664C141.145 21.1812 140.469 20.9369 139.964 20.4337C139.459 19.9144 139.193 18.9349 139.168 17.4951C139.156 16.8072 139.199 16.0303 139.296 15.1645C139.408 14.2984 139.553 13.4317 139.73 12.5645C139.907 11.6973 140.086 10.9261 140.266 10.2508C140.462 9.5593 140.637 9.04417 140.791 8.70543C140.914 8.41524 141.07 8.18848 141.259 8.02515C141.464 7.86155 141.727 7.77695 142.047 7.77137C142.559 7.76243 142.954 7.92356 143.232 8.25477C143.509 8.58597 143.651 8.92754 143.658 9.27949C143.662 9.55145 143.605 9.9445 143.486 10.4587C143.383 10.9725 143.249 11.543 143.084 12.1699C142.935 12.7966 142.778 13.4395 142.613 14.0984C142.449 14.7574 142.308 15.376 142.19 15.9541C142.088 16.532 142.04 17.0209 142.047 17.4208C142.056 17.9168 142.252 18.1614 142.636 18.1547C142.86 18.1508 143.144 17.9298 143.489 17.4917C143.849 17.0533 144.223 16.4707 144.611 15.7438C145.014 15.0007 145.416 14.1936 145.816 13.3224C146.233 12.435 146.609 11.5403 146.946 10.6383C147.298 9.72002 147.595 8.87471 147.838 8.10235C147.989 7.61964 148.199 7.27992 148.468 7.0832C148.737 6.88648 149.047 6.78505 149.399 6.77891C149.943 6.76941 150.361 6.91413 150.655 7.21306C150.964 7.51171 151.123 7.94099 151.133 8.5009C151.138 8.75686 151.105 9.1895 151.036 9.7988C150.966 10.4081 150.875 11.1218 150.761 11.9399C150.663 12.7577 150.542 13.624 150.398 14.5386C150.254 15.4533 150.11 16.352 149.965 17.2346C149.837 18.117 149.707 18.9274 149.576 19.6658C149.444 20.3882 149.334 20.9662 149.246 21.3998C149.097 22.0425 148.912 22.4298 148.69 22.5617C148.468 22.6936 148.246 22.7615 148.022 22.7654Z"
fill="currentColor"
/>
<path
d="M128.996 22.8094C127.86 22.8292 126.942 22.4452 126.24 21.6573C125.538 20.8534 125.175 19.7716 125.151 18.4118C125.143 17.9799 125.192 17.539 125.296 17.0891C125.416 16.6389 125.561 16.2203 125.73 15.8333C125.632 15.691 125.548 15.5084 125.48 15.2856C125.428 15.0465 125.399 14.7669 125.394 14.447C125.376 13.4071 125.543 12.4521 125.896 11.5818C126.248 10.6955 126.731 9.92695 127.344 9.27616C127.957 8.62537 128.636 8.11744 129.382 7.75236C130.127 7.38729 130.884 7.19806 131.652 7.18465C132.964 7.16175 134.043 7.58298 134.891 8.44833C135.754 9.31339 136.202 10.6738 136.234 12.5295C136.25 13.4574 136.123 14.4037 135.851 15.3686C135.58 16.3335 135.204 17.2602 134.724 18.1487C134.259 19.021 133.713 19.8066 133.085 20.5057C132.473 21.2045 131.81 21.7601 131.098 22.1726C130.401 22.5848 129.7 22.7971 128.996 22.8094ZM128.471 14.7053C128.554 14.8799 128.597 15.0872 128.602 15.3271C128.606 15.5671 128.562 15.8159 128.471 16.0735C128.379 16.3312 128.289 16.6768 128.201 17.1104C128.112 17.528 128.074 18.1288 128.088 18.9126C128.092 19.1206 128.119 19.2962 128.169 19.4393C128.236 19.5662 128.373 19.6278 128.581 19.6242C129.125 19.6147 129.633 19.4297 130.107 19.0694C130.597 18.7088 131.029 18.2372 131.403 17.6546C131.793 17.0717 132.117 16.4259 132.377 15.7173C132.652 14.9924 132.856 14.2607 132.987 13.5223C133.134 12.7676 133.202 12.0704 133.19 11.4305C133.183 11.0145 133.098 10.728 132.935 10.5708C132.773 10.4136 132.491 10.3385 132.091 10.3455C131.547 10.355 130.99 10.5407 130.421 10.9027C129.867 11.2484 129.403 11.7206 129.029 12.3192C128.655 12.9018 128.475 13.5611 128.488 14.297C128.49 14.4409 128.485 14.5771 128.471 14.7053Z"
fill="currentColor"
/>
<path
d="M113.297 28.8921C112.401 28.9078 111.584 28.866 110.846 28.7669C110.109 28.6837 109.386 28.5363 108.678 28.3247C107.971 28.113 107.206 27.8303 106.383 27.4766C106.045 27.3225 105.762 27.1594 105.535 26.9873C105.308 26.8153 105.191 26.5533 105.185 26.2013C105.176 25.6894 105.329 25.2947 105.644 25.0171C105.944 24.7558 106.365 24.6205 106.909 24.611C107.197 24.6059 107.567 24.6955 108.018 24.8797C108.47 25.0798 108.986 25.3028 109.566 25.5487C110.163 25.8104 110.823 26.0309 111.546 26.2103C112.269 26.4057 113.047 26.4961 113.879 26.4816C114.727 26.4668 115.513 26.101 116.236 25.3843C116.96 24.6676 117.534 23.6654 117.96 22.3778C118.402 21.1059 118.608 19.606 118.578 17.8783C118.571 17.4784 118.555 17.0466 118.531 16.5829C118.507 16.1033 118.482 15.5996 118.457 15.072C117.9 16.1699 117.294 17.1966 116.638 18.1522C115.983 19.1078 115.3 19.8878 114.591 20.4923C113.897 21.0805 113.19 21.3809 112.47 21.3934C111.59 21.4088 110.947 21.212 110.539 20.803C110.148 20.3778 109.944 19.7013 109.928 18.7734C109.914 17.9895 109.955 17.1327 110.051 16.2029C110.163 15.2568 110.314 14.302 110.505 13.3385C110.696 12.375 110.92 11.459 111.177 10.5903C111.45 9.70545 111.741 8.94026 112.049 8.29478C112.281 7.82666 112.645 7.58827 113.141 7.57962C113.461 7.57403 113.784 7.72042 114.109 8.01879C114.45 8.31688 114.625 8.72189 114.634 9.23381C114.639 9.52176 114.574 9.93896 114.44 10.4854C114.305 11.0318 114.14 11.6508 113.944 12.3423C113.764 13.0176 113.577 13.733 113.382 14.4885C113.187 15.228 113.023 15.9589 112.892 16.6814C112.76 17.3878 112.699 18.0289 112.709 18.6048C112.711 18.6688 112.759 18.7 112.855 18.6983C113.047 18.6949 113.3 18.5305 113.615 18.205C113.945 17.8632 114.305 17.4248 114.696 16.8899C115.102 16.3387 115.508 15.7396 115.913 15.0924C116.333 14.429 116.738 13.7738 117.126 13.1269C117.515 12.48 117.865 11.8898 118.176 11.3563C118.123 11.1012 118.087 10.8778 118.068 10.6861C118.064 10.4781 118.061 10.3022 118.059 10.1582C118.047 9.50228 118.15 8.95641 118.366 8.52057C118.599 8.08444 119.059 7.86038 119.747 7.84837C120.195 7.84055 120.542 8.00252 120.787 8.33428C121.033 8.65005 121.161 9.10388 121.171 9.69579C121.176 9.95175 121.141 10.2484 121.067 10.5858C120.992 10.9071 120.841 11.4218 120.614 12.1299L120.865 11.3814C121.034 12.8187 121.176 14.0964 121.292 15.2145C121.423 16.3324 121.498 17.4433 121.518 18.5471C121.554 20.6268 121.234 22.4326 120.556 23.9647C119.895 25.5125 118.94 26.7133 117.691 27.5673C116.457 28.4209 114.993 28.8625 113.297 28.8921Z"
fill="currentColor"
/>
<path
d="M91.1422 24.4303C90.0544 24.4493 89.0425 24.2349 88.1065 23.7872C87.1709 23.3554 86.3127 22.7783 85.5319 22.0558C84.7509 21.3174 84.0488 20.5135 83.4255 19.6442C82.818 18.7587 82.2987 17.8876 81.8677 17.031C81.6911 17.9142 81.5282 18.6692 81.3791 19.2959C81.2461 19.9223 81.1351 20.4443 81.0464 20.8619C80.972 21.1833 80.8895 21.4968 80.7988 21.8024C80.7241 22.1078 80.6005 22.3579 80.4278 22.553C80.2552 22.748 80.0089 22.8483 79.689 22.8539C79.0011 22.8659 78.5571 22.6417 78.357 22.1811C78.1567 21.7045 78.0501 21.0983 78.0372 20.3624C78.0258 19.7065 78.1147 18.8408 78.304 17.7653C78.5092 16.6896 78.7842 15.4846 79.129 14.1504C79.4738 12.8162 79.8738 11.441 80.3292 10.0248C80.8002 8.59236 81.304 7.19936 81.8405 5.84579C82.3767 4.47622 82.9151 3.23464 83.4557 2.12103C83.7959 1.44299 84.1005 1.01361 84.3694 0.832891C84.638 0.636173 84.9562 0.534603 85.3242 0.52818C85.6921 0.521758 85.9738 0.612857 86.1691 0.801477C86.3644 0.990096 86.4649 1.24438 86.4705 1.56433C86.4741 1.7723 86.4297 1.98111 86.3374 2.19075C86.245 2.40039 86.1455 2.65817 86.0388 2.96408C85.6851 3.78638 85.3178 4.74493 84.9368 5.83975C84.5556 6.91857 84.1911 8.0371 83.8432 9.19535C83.4951 10.3376 83.186 11.4232 82.9159 12.452C83.6917 11.9744 84.5558 11.5113 85.5081 11.0626C86.4601 10.5979 87.421 10.1811 88.3907 9.81208C89.3761 9.42682 90.2989 9.12267 91.1592 8.89962C92.0194 8.67657 92.7455 8.55988 93.3374 8.54955C93.8013 8.54146 94.1557 8.67129 94.4004 8.93906C94.6451 9.20683 94.7702 9.50069 94.7758 9.82064C94.78 10.0606 94.7122 10.3018 94.5724 10.5443C94.4326 10.7868 94.1323 11.0001 93.6714 11.1841C92.1446 11.7229 90.6259 12.2694 89.1154 12.8239C87.6208 13.3781 85.96 14.0311 84.1328 14.7831C84.5299 15.5283 84.9913 16.2964 85.5172 17.0873C86.0428 17.8623 86.6076 18.5885 87.2115 19.2661C87.8151 19.9277 88.4327 20.469 89.0641 20.89C89.6953 21.2951 90.3148 21.4923 90.9227 21.4817C91.8186 21.466 92.6794 21.275 93.5051 20.9085C94.3306 20.526 95.0908 20.0727 95.7857 19.5485C96.4804 19.0083 97.0874 18.4856 97.6067 17.9805C98.1419 17.4751 98.5591 17.0837 98.8584 16.8065C99.0313 16.6274 99.197 16.4965 99.3556 16.4137C99.5302 16.3307 99.7454 16.2869 100.001 16.2824C100.305 16.2771 100.539 16.3691 100.702 16.5582C100.881 16.7311 100.965 16.9617 100.954 17.2499C100.943 17.5222 100.812 17.7965 100.561 18.0729C100.357 18.3005 100.035 18.6662 99.5957 19.1699C99.1722 19.6574 98.6536 20.2025 98.04 20.8053C97.4422 21.3919 96.772 21.9637 96.0296 22.5207C95.303 23.0615 94.5266 23.5071 93.7006 23.8575C92.8749 24.224 92.0221 24.4149 91.1422 24.4303Z"
fill="currentColor"
/>
<path
d="M60.3456 23.2154C59.9616 23.2221 59.5754 23.1008 59.187 22.8516C58.8143 22.586 58.5037 22.2074 58.2551 21.7156C58.0062 21.2079 57.8754 20.5941 57.8629 19.8742C57.8461 18.9144 57.9562 17.8883 58.1932 16.796C58.4462 15.7034 58.7551 14.6098 59.1201 13.5153C59.485 12.4208 59.8431 11.3904 60.1943 10.4241C60.3622 9.95708 60.5727 9.64135 60.8259 9.47691C61.0787 9.29647 61.4132 9.20262 61.8291 9.19536C62.773 9.17888 63.2531 9.64258 63.2696 10.5864C63.2724 10.7464 63.2058 11.0596 63.07 11.5261C62.9338 11.9765 62.7515 12.5318 62.523 13.1919C62.3104 13.8517 62.0829 14.5677 61.8403 15.3401C61.6135 16.0962 61.4029 16.868 61.2087 17.6555C61.0301 18.4267 60.8908 19.1573 60.7908 19.8471C61.149 19.2808 61.5448 18.5778 61.9782 17.7381C62.4273 16.8821 62.8998 15.9857 63.3955 15.0489C63.9072 14.1118 64.4201 13.2388 64.934 12.4297C65.4477 11.6046 65.956 10.9316 66.459 10.4107C66.962 9.88988 67.4455 9.62541 67.9094 9.61731C68.3093 9.61033 68.6867 9.68375 69.0414 9.83759C69.4119 9.97514 69.7368 10.2575 70.0163 10.6847C70.2956 11.0959 70.514 11.6922 70.6717 12.4735C70.8453 13.2546 70.9433 14.2851 70.9657 15.5649C70.9768 16.2048 71.0437 16.8277 71.1663 17.4337C71.3046 18.0233 71.5212 18.5156 71.8162 18.9105C72.1108 19.2895 72.5221 19.4743 73.05 19.4651C73.9619 19.4492 74.7748 19.267 75.4889 18.9184C76.2029 18.5699 76.828 18.167 77.3641 17.7095C77.9002 17.2521 78.3492 16.8522 78.7113 16.5098C78.9314 16.282 79.1203 16.1026 79.278 15.9719C79.4518 15.8408 79.5946 15.7743 79.7066 15.7724C80.0426 15.7665 80.2841 15.8503 80.4311 16.0238C80.5942 16.1969 80.6783 16.4355 80.6836 16.7395C80.6864 16.8994 80.5556 17.1978 80.2912 17.6344C80.0268 18.0711 79.6515 18.5737 79.1653 19.1423C78.6949 19.6946 78.1522 20.2322 77.5372 20.755C76.9223 21.2778 76.2578 21.7135 75.5437 22.062C74.8297 22.4105 74.0968 22.5913 73.3449 22.6044C72.0811 22.6265 71.0293 22.4208 70.1896 21.9874C69.3657 21.5377 68.7443 20.7804 68.3257 19.7156C67.9068 18.6347 67.681 17.1584 67.6483 15.2867C67.6475 15.2387 67.6461 15.1587 67.6441 15.0468C67.6355 14.5508 67.6198 14.111 67.5971 13.7274C67.5904 13.3434 67.5609 13.0319 67.5087 12.7928C67.2454 13.2935 66.9278 13.8911 66.5559 14.5857C66.2 15.28 65.8129 16.0229 65.3946 16.8143C64.9761 17.5897 64.5414 18.3574 64.0906 19.1174C63.6395 19.8614 63.1873 20.5414 62.734 21.1574C62.2967 21.7731 61.8733 22.2686 61.4638 22.6438C61.0542 23.019 60.6815 23.2095 60.3456 23.2154Z"
fill="currentColor"
/>
<path
d="M41.481 24.457C40.9691 24.4659 40.5027 24.33 40.0817 24.0494C39.661 23.7847 39.3268 23.4304 39.079 22.9867C38.831 22.5269 38.7023 22.0331 38.6931 21.5052C38.6758 20.5134 38.8749 19.5417 39.2903 18.5903C39.7218 17.6387 40.29 16.7326 40.9951 15.8722C41.7002 15.0117 42.4706 14.2222 43.3062 13.5035C44.1575 12.7685 45.0026 12.1377 45.8415 11.6109C46.6964 11.0839 47.4733 10.6703 48.1722 10.3701C48.8871 10.0695 49.4445 9.91579 49.8444 9.90881C50.3083 9.90072 50.6861 9.99814 50.9777 10.2011C51.285 10.3877 51.4423 10.689 51.4495 11.105C51.4518 11.233 51.4216 11.3375 51.359 11.4186C51.5678 11.463 51.7379 11.58 51.8692 11.7698C52.0005 11.9595 52.0695 12.2463 52.0763 12.6303C52.079 12.7903 52.0463 13.2069 51.978 13.8802C51.9098 14.5535 51.836 15.3709 51.7568 16.3324C51.6936 17.2937 51.671 18.2942 51.6892 19.3341C51.692 19.494 51.7185 19.6376 51.7687 19.7647C51.8187 19.8759 51.9236 19.9301 52.0836 19.9273C52.7235 19.9161 53.3692 19.7768 54.0206 19.5094C54.6878 19.2257 55.3299 18.8864 55.9471 18.4916C56.5801 18.0805 57.1491 17.6785 57.6543 17.2856C58.1753 16.8765 58.6095 16.5409 58.957 16.2787C59.3202 16.0004 59.5578 15.8602 59.6697 15.8582C60.2137 15.8488 60.4904 16.116 60.4999 16.6599C60.5024 16.8039 60.3317 17.1109 59.9879 17.581C59.6597 18.0348 59.2049 18.5628 58.6233 19.165C58.0414 19.7513 57.3876 20.3388 56.6618 20.9275C55.9516 21.5 55.2079 21.977 54.4304 22.3587C53.6689 22.74 52.9283 22.937 52.2084 22.9495C51.3285 22.9649 50.653 22.7687 50.1818 22.3608C49.7266 21.9527 49.3969 21.3984 49.1926 20.6978C49.0041 19.981 48.894 19.1748 48.8623 18.2792C48.0669 19.4613 47.2371 20.5159 46.3732 21.4432C45.525 22.3541 44.6815 23.0809 43.8429 23.6236C43.0042 24.1664 42.2169 24.4441 41.481 24.457ZM49.7659 12.2865C48.2113 13.0658 46.8186 13.9542 45.5878 14.9519C44.3567 15.9335 43.3825 16.9587 42.665 18.0273C41.9635 19.0958 41.6213 20.1179 41.6384 21.0937C41.6431 21.3657 41.7735 21.4994 42.0294 21.495C42.3334 21.4897 42.7303 21.3067 43.22 20.9461C43.7258 20.5852 44.2696 20.1117 44.8515 19.5254C45.4331 18.9232 46.0135 18.2569 46.5929 17.5267C47.1719 16.7805 47.7107 16.019 48.2092 15.2422C48.7078 14.4653 49.1029 13.7223 49.3945 13.0131C49.502 12.7552 49.6258 12.513 49.7659 12.2865Z"
fill="currentColor"
/>
<path
d="M20.2332 25.788C19.4974 25.8008 18.9658 25.6021 18.6386 25.1917C18.3114 24.7814 18.1408 24.1763 18.1268 23.3764C18.114 22.6405 18.2332 21.6783 18.4845 20.4897C18.7515 19.2849 19.1042 17.9425 19.5424 16.4626C19.9963 14.9665 20.5056 13.4294 21.0701 11.8513C21.6344 10.2572 22.2232 8.69467 22.8366 7.16373C23.4497 5.6168 24.049 4.19812 24.6346 2.9077C24.9128 2.34276 25.21 1.95352 25.5264 1.73996C25.8427 1.52641 26.1928 1.41628 26.5767 1.40958C26.9447 1.40316 27.2585 1.5017 27.5181 1.7052C27.7774 1.8927 27.9108 2.20242 27.9183 2.63435C27.92 2.73034 27.7984 3.10052 27.5537 3.74489C27.3086 4.37326 26.9869 5.195 26.5885 6.21011C26.1899 7.20922 25.7535 8.337 25.2794 9.59347C24.805 10.8339 24.3315 12.1304 23.859 13.4829C23.3863 14.8193 22.9613 16.1389 22.5839 17.4417C22.2066 18.7445 21.9076 19.9499 21.6869 21.0579C22.2337 20.2963 22.8191 19.4539 23.4431 18.5309C24.0828 17.5916 24.7306 16.6601 25.3866 15.7365C26.0583 14.7967 26.7154 13.9371 27.3579 13.1577C28.0164 12.3781 28.6296 11.7513 29.1974 11.2773C29.7652 10.8034 30.2651 10.5626 30.697 10.5551C31.4969 10.5411 32.1407 10.7539 32.6284 11.1935C33.1319 11.6167 33.393 12.3643 33.4117 13.4361C33.4203 13.9321 33.3348 14.5336 33.1551 15.2409C32.9752 15.9321 32.7559 16.6641 32.4974 17.4367C32.2545 18.193 32.0433 18.9248 31.8636 19.6321C31.6839 20.3393 31.5987 20.9569 31.6079 21.4848C31.6129 21.7728 31.7514 21.9144 32.0234 21.9096C32.8072 21.896 33.5962 21.7142 34.3902 21.3643C35.2002 21.0141 35.9688 20.5846 36.6961 20.0758C37.439 19.5508 38.1101 19.035 38.7094 18.5284C39.3243 18.0056 39.8209 17.5729 40.1989 17.2303C40.4664 16.9695 40.7115 16.7972 40.934 16.7133C41.1563 16.6134 41.3634 16.5618 41.5554 16.5585C42.2113 16.547 42.545 16.8693 42.5564 17.5252C42.5587 17.6531 42.5375 17.8135 42.4928 18.0063C42.4479 18.1831 42.2998 18.4098 42.0486 18.6862C41.7974 18.9626 41.4202 19.3533 40.9169 19.8581C40.4136 20.363 39.8229 20.9014 39.1448 21.4733C38.4827 22.0449 37.7562 22.5937 36.9652 23.1196C36.19 23.6292 35.3892 24.0512 34.563 24.3857C33.7367 24.7202 32.9316 24.8943 32.1477 24.9079C30.9639 24.9286 30.0462 24.6086 29.3945 23.9478C28.7432 23.3031 28.4076 22.4128 28.3878 21.277C28.3741 20.4931 28.5169 19.5065 28.8162 18.3171C29.1152 17.1117 29.4538 15.8816 29.8319 14.6268C29.2815 15.1805 28.7089 15.8386 28.1141 16.6011C27.519 17.3476 26.9168 18.1422 26.3074 18.985C25.6978 19.8117 25.096 20.6304 24.502 21.4408C23.9238 22.2351 23.3685 22.9649 22.836 23.6303C22.3032 24.2796 21.8162 24.8002 21.375 25.192C20.9338 25.5837 20.5532 25.7824 20.2332 25.788Z"
fill="currentColor"
/>
<path
d="M2.61566 9.60494C1.84777 9.61835 1.2786 9.55627 0.908146 9.41872C0.553407 9.26488 0.318231 9.08496 0.202618 8.87895C0.102724 8.65666 0.0512403 8.45753 0.0481686 8.28155C0.0442593 8.05759 0.167772 7.79939 0.418707 7.50697C0.669362 7.19855 1.16138 6.96592 1.89475 6.8091C2.62785 6.63628 3.4972 6.47708 4.50281 6.33151C5.50815 6.16994 6.57789 6.03125 7.71204 5.91544C8.84592 5.78363 9.99606 5.66754 11.1625 5.56717C12.3289 5.46679 13.4398 5.3914 14.495 5.34097C15.5501 5.27455 16.4855 5.23422 17.3014 5.21998C18.1492 5.20518 18.806 5.24172 19.2716 5.32961C19.7372 5.4175 20.0672 5.53175 20.2617 5.67238C20.4722 5.81273 20.5948 5.96261 20.6296 6.12203C20.6804 6.28117 20.707 6.43272 20.7096 6.5767C20.7101 6.6087 20.7105 6.63269 20.7108 6.64869C20.7142 6.84066 20.6698 7.04947 20.5777 7.27511C20.5017 7.50047 20.321 7.69565 20.0359 7.86065C19.7504 8.00966 19.3197 8.08919 18.7438 8.09924C18.5359 8.10287 18.0725 8.14296 17.3537 8.21952C16.6349 8.29608 15.7565 8.39142 14.7183 8.50556C13.6801 8.61969 12.578 8.73495 11.4118 8.85132C11.2484 9.57428 11.0631 10.4176 10.8559 11.3814C10.6647 12.3449 10.4663 13.3565 10.2608 14.4163C10.0713 15.4757 9.88943 16.5191 9.71533 17.5462C9.54095 18.5574 9.39731 19.4961 9.28441 20.3622C9.17123 21.2123 9.09539 21.9097 9.05689 22.4545C9.00575 23.1915 8.79886 23.7152 8.43622 24.0256C8.0893 24.3197 7.73986 24.4698 7.38792 24.4759C7.13196 24.4804 6.87502 24.4289 6.6171 24.3214C6.35947 24.2298 6.14773 24.0175 5.98189 23.6843C5.81577 23.3352 5.72671 22.8167 5.7147 22.1288C5.70241 21.4249 5.75132 20.5599 5.86143 19.5338C5.97126 18.4917 6.11983 17.377 6.30714 16.1895C6.51044 15.0018 6.7376 13.8056 6.98861 12.6011C7.25563 11.3962 7.53203 10.2712 7.81784 9.2261C6.7474 9.32479 5.7568 9.41409 4.84606 9.494C3.93504 9.55791 3.19157 9.59489 2.61566 9.60494Z"
fill="currentColor"
/>
</svg>
</div>
<p class="muted">
${this.hass.localize(
"ui.panel.config.cloud.account.thank_you_note"
)}
</p>
<p class="muted">
${this.hass.localize("ui.panel.config.cloud.account.funding_note")}
</p>
${this._renderSubscriptionState()}
</div>
</ha-card>
`;
}
private _renderSubscriptionState(): TemplateResult | typeof nothing {
switch (this._accountState) {
case "trial":
return html`
<ha-alert
alert-type="warning"
.title=${this.hass.localize(
"ui.panel.config.cloud.account.overview.trial_title"
)}
>
${this.hass.localize(
"ui.panel.config.cloud.account.overview.trial_text"
)}
<ha-button
slot="action"
size="s"
href="https://account.nabucasa.com"
target="_blank"
rel="noreferrer"
>
${this.hass.localize(
"ui.panel.config.cloud.account.overview.add_payment"
)}
</ha-button>
</ha-alert>
`;
case "canceled":
return html`
<ha-alert
alert-type="warning"
.title=${this.hass.localize(
"ui.panel.config.cloud.account.overview.canceled_title"
)}
>
${this.hass.localize(
"ui.panel.config.cloud.account.overview.canceled_text",
{ date: this._renewalDate }
)}
<ha-button
slot="action"
size="s"
href="https://account.nabucasa.com"
target="_blank"
rel="noreferrer"
>
${this.hass.localize(
"ui.panel.config.cloud.account.overview.resubscribe"
)}
</ha-button>
</ha-alert>
`;
case "expired":
return html`
<ha-alert
alert-type="error"
.title=${this.hass.localize(
"ui.panel.config.cloud.account.overview.expired_title"
)}
>
${this.hass.localize(
"ui.panel.config.cloud.account.overview.expired_text"
)}
<ha-button
slot="action"
size="s"
href="https://account.nabucasa.com"
target="_blank"
rel="noreferrer"
>
${this.hass.localize(
"ui.panel.config.cloud.account.overview.renew"
)}
</ha-button>
</ha-alert>
`;
default:
// "subscribed" and "unknown" show no alert.
return nothing;
}
}
private _subscriptionDetail(): string {
switch (this._accountState) {
case "unknown":
return this.hass.localize(
"ui.panel.config.cloud.account.status_unknown"
);
case "expired":
return this.hass.localize(
"ui.panel.config.cloud.account.expired_label"
);
case "trial":
return this.hass.localize("ui.panel.config.cloud.account.trial_ends", {
date: this._renewalDate,
});
case "canceled":
return this.hass.localize("ui.panel.config.cloud.account.access_ends", {
date: this._renewalDate,
});
default:
// "subscribed"
return this.hass.localize("ui.panel.config.cloud.account.renews", {
date: this._renewalDate,
});
}
}
private _renderAccountCard(): TemplateResult {
const { email } = this.cloudStatus;
const at = email.indexOf("@");
const maskedEmail = at > 0 ? `${"•".repeat(at)}${email.slice(at)}` : email;
return html`
<ha-card outlined>
<div class="account-header">
<span class="card-title"
>${this.hass.localize(
"ui.panel.config.cloud.account.nabu_casa_account"
)}</span
>
<img
class="nc-logo"
alt="Nabu Casa"
src="https://brands.home-assistant.io/_/cloud/${
this.hass.themes?.darkMode ? "dark_" : ""
}icon.png"
crossorigin="anonymous"
referrerpolicy="no-referrer"
/>
</div>
<div class="card-content">
<ha-md-list>
<ha-md-list-item>
<span slot="headline"
>${this.hass.localize(
"ui.panel.config.cloud.account.email"
)}</span
>
<span slot="supporting-text" class="email-line">
<span>${this._emailRevealed ? email : maskedEmail}</span>
<ha-icon-button
.path=${this._emailRevealed ? mdiEyeOff : mdiEye}
.label=${
this._emailRevealed
? this.hass.localize(
"ui.panel.config.cloud.account.hide_email"
)
: this.hass.localize(
"ui.panel.config.cloud.account.show_email"
)
}
@click=${this._toggleEmail}
></ha-icon-button>
</span>
</ha-md-list-item>
<ha-md-list-item>
<span slot="headline"
>${this.hass.localize(
"ui.panel.config.cloud.account.subscription"
)}</span
>
<span slot="supporting-text">${this._subscriptionDetail()}</span>
</ha-md-list-item>
</ha-md-list>
</div>
<div class="card-actions split">
<ha-button
appearance="filled"
href="https://account.nabucasa.com"
target="_blank"
rel="noreferrer"
>
${this.hass.localize(
"ui.panel.config.cloud.account.manage_account"
)}
</ha-button>
<ha-button
@click=${this._signOut}
variant="danger"
appearance="plain"
>
${this.hass.localize("ui.panel.config.cloud.account.sign_out")}
</ha-button>
</div>
</ha-card>
`;
}
private _renderFeaturesCard(): TemplateResult {
const cloudhooks = this.cloudStatus.prefs.cloudhooks || {};
// Distinguish "not loaded yet / unavailable" (undefined) from "zero active"
// so we don't claim 0 active webhooks before the fetch resolves.
const webhookStatus =
this.webhooks === undefined
? ""
: this.hass.localize(
"ui.panel.config.cloud.account.overview.webhooks_active",
{
count: this.webhooks.filter(
(hook) =>
isActiveCloudWebhook(hook) && cloudhooks[hook.webhook_id]
).length,
}
);
return html`
<ha-card outlined>
<div class="extras-header">
<h1>
${this.hass.localize(
"ui.panel.config.cloud.account.overview.features_title"
)}
</h1>
</div>
<div class="extras-content">
<p>
${this.hass.localize(
"ui.panel.config.cloud.account.overview.features_intro"
)}
</p>
<ha-md-list>
${this._featureRow(
mdiEarth,
this.hass.localize(
"ui.panel.config.cloud.account.overview.feature_remote"
),
this._remoteStatus(),
"/config/cloud/remote"
)}
${this._featureRow(
mdiBackupRestore,
this.hass.localize(
"ui.panel.config.cloud.account.overview.feature_backups"
),
this._renderBackupStatus(),
"/config/cloud/backup"
)}
${this._featureRow(
mdiMicrophone,
this.hass.localize(
"ui.panel.config.cloud.account.overview.feature_voice"
),
this.hass.localize(
"ui.panel.config.cloud.account.overview.feature_voice_sub"
),
"/config/cloud/voice-assistants"
)}
${this._featureRow(
mdiCellphone,
this.hass.localize(
"ui.panel.config.cloud.account.overview.feature_companion"
),
this.hass.localize(
"ui.panel.config.cloud.account.overview.feature_companion_sub"
),
"/config/cloud/companion"
)}
${this._featureRow(
mdiMicrophoneMessage,
this.hass.localize(
"ui.panel.config.cloud.account.overview.feature_alexa_google"
),
this._alexaGoogleStatus(),
"/config/voice-assistants/assistants?historyBack=1"
)}
${this._featureRow(
mdiVideo,
this.hass.localize(
"ui.panel.config.cloud.account.overview.feature_cameras"
),
this._onOffStatus(
this.cloudStatus.prefs.cloud_ice_servers_enabled
),
"/config/cloud/webrtc"
)}
${this._featureRow(
mdiWebhook,
this.hass.localize(
"ui.panel.config.cloud.account.overview.feature_webhooks"
),
webhookStatus,
"/config/cloud/webhooks"
)}
</ha-md-list>
</div>
</ha-card>
`;
}
private _featureRow(
icon: string,
title: string,
supporting: string | TemplateResult,
href: string
): TemplateResult {
return html`
<ha-md-list-item type="link" href=${href}>
<ha-svg-icon slot="start" .path=${icon}></ha-svg-icon>
<span slot="headline">${title}</span>
<span slot="supporting-text">${supporting}</span>
<ha-icon-next slot="end"></ha-icon-next>
</ha-md-list-item>
`;
}
private get _subscriptionActive(): boolean {
return this.cloudStatus.active_subscription;
}
private _statusLine(
dotClass: "enabled" | "warning" | "disabled",
text: string
): TemplateResult {
return html`
<span class="status-line">
<span class="status-dot ${dotClass}"></span>
${text}
</span>
`;
}
private _onOffStatus(enabled: boolean): TemplateResult {
// An enabled feature only works with an active subscription; otherwise it
// is configured on but inactive, so show it as such instead of green "On".
if (enabled && !this._subscriptionActive) {
return this._statusLine(
"disabled",
this.hass.localize("ui.panel.config.cloud.account.overview.inactive")
);
}
return this._statusLine(
enabled ? "enabled" : "disabled",
enabled
? this.hass.localize("ui.panel.config.cloud.account.overview.on")
: this.hass.localize("ui.panel.config.cloud.account.overview.off")
);
}
private _remoteStatus(): TemplateResult {
if (!this.cloudStatus.prefs.remote_enabled) {
return this._statusLine(
"disabled",
this.hass.localize("ui.panel.config.cloud.account.overview.off")
);
}
if (!this._subscriptionActive) {
return this._statusLine(
"disabled",
this.hass.localize("ui.panel.config.cloud.account.overview.inactive")
);
}
// Remote access only actually works once the certificate is ready — reflect
// the in-progress and error states instead of a misleading green "On".
switch (this.cloudStatus.remote_certificate_status) {
case "ready":
return this._statusLine(
"enabled",
this.hass.localize("ui.panel.config.cloud.account.overview.on")
);
case "error":
return this._statusLine(
"disabled",
this.hass.localize("ui.panel.config.cloud.account.overview.error")
);
default:
return this._statusLine(
"warning",
this.hass.localize("ui.panel.config.cloud.account.overview.preparing")
);
}
}
private _alexaGoogleStatus(): string {
const { alexa_registered, google_registered } = this.cloudStatus;
if (alexa_registered && google_registered) {
return this.hass.localize(
"ui.panel.config.cloud.account.overview.alexa_google_both"
);
}
if (alexa_registered) {
return this.hass.localize(
"ui.panel.config.cloud.account.overview.alexa_google_alexa"
);
}
if (google_registered) {
return this.hass.localize(
"ui.panel.config.cloud.account.overview.alexa_google_google"
);
}
return this.hass.localize(
"ui.panel.config.cloud.account.overview.alexa_google_none"
);
}
private _renderBackupStatus(): TemplateResult | string {
// No config yet (still loading, fetch failed, or backup integration not
// loaded) is "unknown", not "no cloud backup" — leave it blank rather than
// asserting a state we don't know.
if (!this.backupConfig) {
return "";
}
if (!cloudBackupEnabled(this.backupConfig)) {
return this.hass.localize(
"ui.panel.config.cloud.account.overview.no_cloud_backup"
);
}
// Cloud backup is configured, but without an active subscription future
// backups won't run — surface that instead of a stale green "last backup".
if (!this._subscriptionActive) {
return this._statusLine(
"disabled",
this.hass.localize("ui.panel.config.cloud.account.overview.inactive")
);
}
const health = cloudBackupHealth(this.backupConfig);
if (health === "none") {
return this.hass.localize(
"ui.panel.config.cloud.account.overview.no_cloud_backup"
);
}
const dot =
health === "success"
? "enabled"
: health === "failed"
? "disabled"
: "warning";
const last = this.backupConfig?.last_completed_automatic_backup;
const lastBackupRelative = last
? relativeTime(new Date(last), this.hass.locale, new Date(), true)
: undefined;
const text =
health === "failed"
? this.hass.localize(
"ui.panel.config.cloud.account.overview.backup_failed"
)
: this.hass.localize(
"ui.panel.config.cloud.account.overview.last_backup",
{ relative: lastBackupRelative ?? "" }
);
return this._statusLine(dot, text);
}
private get _accountState():
"subscribed" | "trial" | "canceled" | "expired" | "unknown" {
const active = this.cloudStatus.active_subscription;
switch (this.subscription?.subscription?.status) {
case "trialing":
return active ? "trial" : "expired";
case "active":
return active ? "subscribed" : "expired";
case "canceled":
return "canceled";
case "expired":
return "expired";
case "unknown":
return active ? "unknown" : "expired";
default:
return active ? "subscribed" : "expired";
}
}
private get _renewalDate(): string {
return this.subscription?.plan_renewal_date
? formatDate(
new Date(this.subscription.plan_renewal_date * 1000),
this.hass.locale,
this.hass.config
)
: this.hass.localize(
"ui.panel.config.cloud.account.getting_renewal_date"
);
}
private _toggleEmail() {
this._emailRevealed = !this._emailRevealed;
}
private _signOut() {
fireEvent(this, "cloud-sign-out");
}
static get styles() {
return [
haStyle,
css`
:host {
display: flex;
flex-direction: column;
gap: var(--ha-space-6);
}
ha-card {
display: block;
width: 100%;
max-width: 600px;
margin-inline: auto;
}
.card-title {
font-size: var(--ha-font-size-xl);
font-weight: var(--ha-font-weight-normal);
line-height: var(--ha-line-height-condensed);
}
.thank-you-header {
display: flex;
align-items: center;
gap: var(--ha-space-2);
}
.thank-you-header svg {
height: 28px;
width: auto;
color: var(--primary-text-color);
}
ha-alert {
display: block;
margin-top: var(--ha-space-3);
}
ha-alert ha-button[slot="action"] {
width: max-content;
white-space: nowrap;
}
.account-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--ha-space-4) var(--ha-space-4) 0;
}
.nc-logo {
width: 36px;
height: auto;
}
ha-md-list-item {
--md-item-overflow: visible;
}
.muted {
color: var(--secondary-text-color);
}
p.muted {
margin: var(--ha-space-2) 0 0;
}
.status-line {
display: flex;
align-items: center;
gap: var(--ha-space-1);
font-size: var(--ha-font-size-s);
color: var(--secondary-text-color);
}
.status-dot {
flex-shrink: 0;
width: 8px;
height: 8px;
border-radius: var(--ha-border-radius-circle);
}
.status-dot.enabled {
background-color: var(--success-color);
}
.status-dot.warning {
background-color: var(--warning-color);
}
.status-dot.disabled {
background-color: var(--error-color);
}
ha-md-list {
padding: 0;
--md-list-item-leading-space: 0;
--md-list-item-trailing-space: 0;
}
.extras-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--ha-space-3) var(--ha-space-4);
}
.extras-header h1 {
font-size: var(--ha-font-size-2xl);
font-weight: var(--ha-font-weight-normal);
line-height: var(--ha-line-height-expanded);
letter-spacing: -0.012em;
margin: 0;
}
.extras-content {
padding: 0;
overflow: hidden;
border-radius: 0 0 var(--ha-card-border-radius, 12px)
var(--ha-card-border-radius, 12px);
}
.extras-content p {
color: var(--secondary-text-color);
padding-inline: var(--ha-space-4);
margin-top: 0;
}
.extras-content ha-md-list {
padding-top: 0;
--md-list-item-leading-space: var(--ha-space-4);
--md-list-item-trailing-space: var(--ha-space-4);
}
.extras-content ha-md-list-item {
--md-list-item-top-space: var(--ha-space-2);
--md-list-item-bottom-space: var(--ha-space-2);
}
.extras-content ha-svg-icon[slot="start"],
.extras-content ha-icon-next {
color: var(--secondary-text-color);
}
.card-actions {
display: flex;
justify-content: flex-end;
gap: var(--ha-space-2);
padding: var(--ha-space-2) var(--ha-space-3);
flex-wrap: wrap;
}
.card-actions.split {
flex-direction: row-reverse;
justify-content: space-between;
}
.email-line {
display: inline-flex;
align-items: center;
gap: var(--ha-space-1);
}
.email-line ha-icon-button {
--ha-icon-button-size: 32px;
--mdc-icon-size: 18px;
color: var(--secondary-text-color);
}
`,
];
}
}
declare global {
interface HTMLElementTagNameMap {
"cloud-account-overview": CloudAccountOverview;
}
interface HASSDomEvents {
"cloud-sign-out": undefined;
}
}
@@ -0,0 +1,38 @@
import type { BackupConfig } from "../../../../data/backup";
import { cloudBackupHealth } from "../../../../data/backup";
import type {
CloudOnboardingItem,
CloudStatusLoggedIn,
} from "../../../../data/cloud";
import { ONBOARDING_ITEMS } from "../../../../data/cloud";
// Whether a single onboarding step is already set up. Shared so the onboarding
// UI and the "is onboarding complete" check can't drift apart.
export const onboardingPanelCompleted = (
panel: CloudOnboardingItem,
cloudStatus: CloudStatusLoggedIn,
backupConfig?: BackupConfig
): boolean => {
if (panel === "remote") {
return cloudStatus.prefs.remote_enabled;
}
if (panel === "backup") {
const health = cloudBackupHealth(backupConfig);
return health === "success" || health === "old";
}
if (panel === "voice") {
return cloudStatus.alexa_registered || cloudStatus.google_registered;
}
return cloudStatus.prefs.cloud_ice_servers_enabled;
};
export const onboardingComplete = (
cloudStatus: CloudStatusLoggedIn,
backupConfig?: BackupConfig
): boolean =>
ONBOARDING_ITEMS.every((panel) =>
onboardingPanelCompleted(panel, cloudStatus, backupConfig)
);
+160 -199
View File
@@ -1,26 +1,30 @@
import { mdiDeleteForever, mdiDotsVertical, mdiDownload } from "@mdi/js";
import { css, html, LitElement } from "lit";
import { css, html, LitElement, nothing } from "lit";
import type { PropertyValues } from "lit";
import { customElement, property, state } from "lit/decorators";
import { formatDateTime } from "../../../../common/datetime/format_date_time";
import { isComponentLoaded } from "../../../../common/config/is_component_loaded";
import { fireEvent } from "../../../../common/dom/fire_event";
import { debounce } from "../../../../common/util/debounce";
import "../../../../components/ha-alert";
import "../../../../components/ha-button";
import "../../../../components/ha-card";
import "../../../../components/ha-dropdown";
import "../../../../components/ha-dropdown-item";
import "../../../../components/ha-list-item";
import "../../../../components/ha-icon-button";
import "../../../../components/ha-svg-icon";
import "../../../../components/ha-tip";
import type { HaDropdownSelectEvent } from "../../../../components/ha-dropdown";
import type { BackupConfig } from "../../../../data/backup";
import { fetchBackupConfig } from "../../../../data/backup";
import type {
CloudStatusLoggedIn,
SubscriptionInfo,
} from "../../../../data/cloud";
import {
cloudLogout,
completeCloudOnboarding,
fetchCloudSubscriptionInfo,
ONBOARDING_ITEMS,
removeCloudData,
} from "../../../../data/cloud";
import type { Webhook } from "../../../../data/webhook";
import { fetchWebhooks } from "../../../../data/webhook";
import {
showAlertDialog,
showConfirmationDialog,
@@ -29,13 +33,14 @@ import "../../../../layouts/hass-subpage";
import { SubscribeMixin } from "../../../../mixins/subscribe-mixin";
import { haStyle } from "../../../../resources/styles";
import type { HomeAssistant } from "../../../../types";
import "../../ha-config-section";
import "./cloud-ice-servers-pref";
import "./cloud-remote-pref";
import "./cloud-tts-pref";
import "./cloud-webhooks";
import "./cloud-account-onboarding";
import "./cloud-account-overview";
import {
onboardingComplete,
onboardingPanelCompleted,
} from "./cloud-account-status";
import { showCloudOnboardingDialog } from "./show-dialog-cloud-onboarding";
import { showSupportPackageDialog } from "./show-dialog-cloud-support-package";
import type { HaDropdownSelectEvent } from "../../../../components/ha-dropdown";
@customElement("cloud-account")
export class CloudAccount extends SubscribeMixin(LitElement) {
@@ -49,6 +54,15 @@ export class CloudAccount extends SubscribeMixin(LitElement) {
@state() private _subscription?: SubscriptionInfo;
@state() private _backupConfig?: BackupConfig;
@state() private _webhooks?: Webhook[];
// Whether the async backup config fetch has finished (regardless of outcome).
// Used to avoid flashing the onboarding card in and out before we know the
// backup state — see _showOnboarding.
@state() private _backupConfigChecked = false;
protected render() {
return html`
<hass-subpage
@@ -77,174 +91,112 @@ export class CloudAccount extends SubscribeMixin(LitElement) {
</ha-dropdown-item>
</ha-dropdown>
<div class="content">
<ha-config-section .isWide=${this.isWide}>
<span slot="header">Home Assistant Cloud</span>
<div slot="introduction">
<p>
${this.hass.localize(
"ui.panel.config.cloud.account.thank_you_note"
)}
</p>
</div>
<ha-card
outlined
.header=${this.hass.localize(
"ui.panel.config.cloud.account.nabu_casa_account"
)}
>
<div class="account-row">
<ha-list-item noninteractive twoline>
${this.cloudStatus.email.replace(
/(\w{3})[\w.-]+@([\w.]+\w)/,
"$1***@$2"
)}
<span slot="secondary" class="wrap">
${
this._subscription
? this._subscription.human_description.replace(
"{periodEnd}",
this._subscription.plan_renewal_date
? formatDateTime(
new Date(
this._subscription.plan_renewal_date * 1000
),
this.hass.locale,
this.hass.config
)
: ""
)
: this.hass.localize(
"ui.panel.config.cloud.account.fetching_subscription"
)
}
</span>
</ha-list-item>
</div>
${
this.cloudStatus.cloud === "connecting" &&
this.cloudStatus.cloud_last_disconnect_reason
? html`
<ha-alert
alert-type="warning"
.title=${
this.cloudStatus.cloud_last_disconnect_reason.reason
}
></ha-alert>
`
: ""
}
<div class="account-row">
<ha-list-item noninteractive>
${this.hass.localize(
"ui.panel.config.cloud.account.connection_status"
)}:
${
this.cloudStatus.cloud === "connected"
? this.hass.localize(
"ui.panel.config.cloud.account.connected"
)
: this.cloudStatus.cloud === "disconnected"
? this.hass.localize(
"ui.panel.config.cloud.account.not_connected"
)
: this.hass.localize(
"ui.panel.config.cloud.account.connecting"
)
}
</ha-list-item>
</div>
<div class="card-actions">
<ha-button
appearance="filled"
href="https://account.nabucasa.com"
target="_blank"
rel="noreferrer"
>
${this.hass.localize(
"ui.panel.config.cloud.account.manage_account"
)}
</ha-button>
<ha-button
@click=${this._signOut}
variant="danger"
appearance="plain"
>
${this.hass.localize(
"ui.panel.config.cloud.account.sign_out"
)}
</ha-button>
</div>
</ha-card>
</ha-config-section>
<ha-config-section .isWide=${this.isWide}>
<span slot="header"
>${this.hass.localize(
"ui.panel.config.cloud.account.integrations"
)}</span
>
<div slot="introduction">
<p>
${this.hass.localize(
"ui.panel.config.cloud.account.integrations_introduction"
)}
</p>
<p>
${this.hass.localize(
"ui.panel.config.cloud.account.integrations_introduction2"
)}
<a
href="https://www.nabucasa.com"
target="_blank"
rel="noreferrer"
>
${this.hass.localize(
"ui.panel.config.cloud.account.integrations_link_all_features"
)}</a
>.
</p>
</div>
<cloud-remote-pref
.hass=${this.hass}
.cloudStatus=${this.cloudStatus}
></cloud-remote-pref>
<cloud-tts-pref
.hass=${this.hass}
.narrow=${this.narrow}
.cloudStatus=${this.cloudStatus}
></cloud-tts-pref>
<cloud-ice-servers-pref
.hass=${this.hass}
.cloudStatus=${this.cloudStatus}
></cloud-ice-servers-pref>
<ha-tip>
<a href="/config/voice-assistants">
${this.hass.localize(
"ui.panel.config.cloud.account.tip_moved_voice_assistants"
)}
</a>
</ha-tip>
<cloud-webhooks
.hass=${this.hass}
.cloudStatus=${this.cloudStatus}
></cloud-webhooks>
</ha-config-section>
${
this._showOnboarding
? html`
<cloud-account-onboarding
.hass=${this.hass}
.cloudStatus=${this.cloudStatus}
.backupConfig=${this._backupConfig}
@cloud-open-onboarding=${this._openOnboardingDialog}
></cloud-account-onboarding>
`
: nothing
}
<cloud-account-overview
.hass=${this.hass}
.cloudStatus=${this.cloudStatus}
.subscription=${this._subscription}
.backupConfig=${this._backupConfig}
.webhooks=${this._webhooks}
@cloud-sign-out=${this._signOut}
></cloud-account-overview>
</div>
</hass-subpage>
`;
}
private get _onboarded(): boolean {
return (
this.cloudStatus.onboarding_completed ||
this.cloudStatus.onboarding_postponed
);
}
private get _showOnboarding(): boolean {
if (!this.cloudStatus.active_subscription) {
return false;
}
if (this._onboarded) {
return false;
}
if (!this._backupConfigChecked && this._nonBackupStepsComplete) {
return false;
}
// Hide once every step is set up (see _syncOnboardedItems, which records the
// completed items on the backend). The setup dialog lives at the document
// level (see _openOnboardingDialog), so it survives this card unmounting.
return !onboardingComplete(this.cloudStatus, this._backupConfig);
}
private _openOnboardingDialog() {
showCloudOnboardingDialog(this, {
cloudStatus: this.cloudStatus,
backupConfig: this._backupConfig,
// The dialog keeps its own copy live; this refreshes the page behind it.
onChanged: () => {
fireEvent(this, "ha-refresh-cloud-status");
this._fetchBackupConfig();
},
});
}
private get _nonBackupStepsComplete(): boolean {
return (
onboardingPanelCompleted("remote", this.cloudStatus) &&
onboardingPanelCompleted("voice", this.cloudStatus) &&
onboardingPanelCompleted("streaming", this.cloudStatus)
);
}
firstUpdated() {
this._fetchSubscriptionInfo();
this._fetchBackupConfig();
this._fetchWebhooks();
}
protected updated(changedProps: PropertyValues) {
super.updated(changedProps);
if (changedProps.has("cloudStatus") || changedProps.has("_backupConfig")) {
this._syncOnboardedItems();
}
}
private async _syncOnboardedItems() {
if (!this.cloudStatus.active_subscription) {
return;
}
const onboarded = this.cloudStatus.prefs.onboarded_items;
const toComplete = ONBOARDING_ITEMS.filter(
(item) =>
!onboarded.includes(item) &&
onboardingPanelCompleted(item, this.cloudStatus, this._backupConfig)
);
if (toComplete.length === 0) {
return;
}
try {
await completeCloudOnboarding(this.hass, toComplete);
fireEvent(this, "ha-refresh-cloud-status");
} catch {
// Best effort; retried on the next status refresh.
}
}
protected override hassSubscribe() {
@@ -289,6 +241,32 @@ export class CloudAccount extends SubscribeMixin(LitElement) {
}
}
private async _fetchBackupConfig() {
if (!isComponentLoaded(this.hass.config, "backup")) {
this._backupConfigChecked = true;
return;
}
try {
const result = await fetchBackupConfig(this.hass);
this._backupConfig = result.config;
} catch {
// Best effort; leave the backup status unknown.
} finally {
this._backupConfigChecked = true;
}
}
private async _fetchWebhooks() {
if (!isComponentLoaded(this.hass.config, "webhook")) {
return;
}
try {
this._webhooks = await fetchWebhooks(this.hass);
} catch {
// Best effort; leave the webhook count at zero.
}
}
private async _signOut() {
showConfirmationDialog(this, {
text: this.hass.localize(
@@ -355,33 +333,16 @@ export class CloudAccount extends SubscribeMixin(LitElement) {
return [
haStyle,
css`
[slot="introduction"] {
margin: -1em 0;
}
[slot="introduction"] a {
color: var(--primary-color);
}
.content {
padding-bottom: 24px;
}
.account-row {
padding: var(--ha-space-7) var(--ha-space-5) 0;
padding-bottom: calc(
var(--safe-area-inset-bottom) + var(--ha-space-6)
);
max-width: 860px;
margin: 0 auto;
gap: var(--ha-space-6);
display: flex;
padding: 0 16px;
}
.card-actions {
display: flex;
flex-direction: row-reverse;
justify-content: space-between;
}
ha-button {
align-self: center;
}
.wrap {
white-space: normal;
}
.status {
text-transform: capitalize;
padding: 16px;
flex-direction: column;
}
`,
];
@@ -0,0 +1,337 @@
import {
mdiBackupRestore,
mdiCalendar,
mdiHarddisk,
mdiShieldLock,
} from "@mdi/js";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { isComponentLoaded } from "../../../../common/config/is_component_loaded";
import { relativeTime } from "../../../../common/datetime/relative_time";
import "../../../../components/ha-button";
import "../../../../components/ha-card";
import "../../../../components/ha-spinner";
import "../../../../components/ha-md-list";
import "../../../../components/ha-md-list-item";
import "../../../../components/ha-svg-icon";
import type { BackupConfig, BackupInfo } from "../../../../data/backup";
import {
CLOUD_AGENT,
cloudBackupEnabled,
fetchBackupConfig,
fetchBackupInfo,
getLastCloudBackup,
} from "../../../../data/backup";
import "../../../../layouts/hass-subpage";
import { haStyle } from "../../../../resources/styles";
import type { HomeAssistant } from "../../../../types";
import { bytesToString } from "../../../../util/bytes-to-string";
import { cloudSubpageStyle } from "./cloud-subpage-style";
@customElement("cloud-backup-pref")
export class CloudBackupPref extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ type: Boolean }) public narrow = false;
@state() private _backupConfig?: BackupConfig;
@state() private _backupInfo?: BackupInfo;
@state() private _loaded = false;
protected render() {
const isConfigured = cloudBackupEnabled(this._backupConfig);
return html`
<hass-subpage
.hass=${this.hass}
.narrow=${this.narrow}
.header=${this.hass.localize(
"ui.panel.config.cloud.account.backup.title"
)}
back-path="/config/cloud/account"
>
<div class="content">
${
this._loaded
? isConfigured
? this._renderStatusCard()
: this._renderSetupCard()
: html`
<ha-card outlined>
<div class="spinner">
<ha-spinner></ha-spinner>
</div>
</ha-card>
`
}
${this._renderInfoCard()}
</div>
</hass-subpage>
`;
}
private _renderInfoCard() {
return html`
<ha-card outlined>
<div class="card-content">
<h3 class="info-header">
${this.hass.localize(
"ui.panel.config.cloud.account.backup.card_title"
)}
</h3>
<p>
${this.hass.localize(
"ui.panel.config.cloud.account.backup.cloud_info"
)}
</p>
<ul>
<li>
${this.hass.localize(
"ui.panel.config.cloud.account.backup.cloud_info_one_backup"
)}
</li>
<li>
${this.hass.localize(
"ui.panel.config.cloud.account.backup.cloud_info_size_limit"
)}
</li>
<li>
${this.hass.localize(
"ui.panel.config.cloud.account.backup.cloud_info_encrypted"
)}
</li>
<li>
${this.hass.localize(
"ui.panel.config.cloud.account.backup.cloud_info_no_access"
)}
</li>
</ul>
</div>
<div class="card-actions">
<ha-button
appearance="plain"
href="https://support.nabucasa.com/hc/en-us/sections/26353804834973"
target="_blank"
rel="noreferrer"
>
${this.hass.localize(
"ui.panel.config.cloud.account.backup.link_learn_more"
)}
</ha-button>
</div>
</ha-card>
`;
}
private _renderStatusCard() {
const cloudBackup = getLastCloudBackup(this._backupInfo?.backups);
const cloudAgent = cloudBackup?.agents[CLOUD_AGENT];
return html`
<ha-card
outlined
.header=${this.hass.localize(
"ui.panel.config.cloud.account.backup.status_card_title"
)}
>
<div class="card-content">
<ha-md-list>
<ha-md-list-item>
<ha-svg-icon slot="start" .path=${mdiBackupRestore}></ha-svg-icon>
<span slot="headline">
${
cloudBackup
? this.hass.localize(
"ui.panel.config.cloud.account.backup.last_cloud_backup",
{
relative_time: relativeTime(
new Date(cloudBackup.date),
this.hass.locale,
new Date(),
true
),
}
)
: this.hass.localize(
"ui.panel.config.cloud.account.backup.no_cloud_backups"
)
}
</span>
</ha-md-list-item>
${
cloudAgent
? html`
<ha-md-list-item>
<ha-svg-icon
slot="start"
.path=${mdiHarddisk}
></ha-svg-icon>
<span slot="headline">
${bytesToString(cloudAgent.size)}
</span>
</ha-md-list-item>
<ha-md-list-item>
<ha-svg-icon
slot="start"
.path=${mdiShieldLock}
></ha-svg-icon>
<span slot="headline">
${
cloudAgent.protected
? this.hass.localize(
"ui.panel.config.cloud.account.backup.backup_encrypted"
)
: this.hass.localize(
"ui.panel.config.cloud.account.backup.backup_not_encrypted"
)
}
</span>
</ha-md-list-item>
`
: nothing
}
<ha-md-list-item>
<ha-svg-icon slot="start" .path=${mdiCalendar}></ha-svg-icon>
<span slot="headline">
${
this._backupConfig?.next_automatic_backup
? this.hass.localize(
"ui.panel.config.cloud.account.backup.next_backup",
{
relative_time: relativeTime(
new Date(this._backupConfig.next_automatic_backup),
this.hass.locale,
new Date(),
true
),
}
)
: this.hass.localize(
"ui.panel.config.cloud.account.backup.no_next_backup"
)
}
</span>
</ha-md-list-item>
</ha-md-list>
</div>
<div class="card-actions">
<ha-button appearance="filled" href="/config/backup?historyBack=1">
${this.hass.localize(
"ui.panel.config.cloud.account.backup.configure"
)}
</ha-button>
</div>
</ha-card>
`;
}
private _renderSetupCard() {
return html`
<ha-card
outlined
.header=${this.hass.localize(
"ui.panel.config.cloud.account.backup.setup_card_title"
)}
>
<div class="card-content">
<p>
${this.hass.localize(
"ui.panel.config.cloud.account.backup.setup_info"
)}
</p>
</div>
<div class="card-actions">
<ha-button appearance="filled" href="/config/backup?historyBack=1">
${this.hass.localize("ui.panel.config.cloud.account.backup.set_up")}
</ha-button>
</div>
</ha-card>
`;
}
protected willUpdate(changedProps) {
super.willUpdate(changedProps);
if (!this.hasUpdated) {
this._fetchData();
}
}
private async _fetchData() {
if (!isComponentLoaded(this.hass.config, "backup")) {
// Backup integration isn't loaded, so there's nothing to fetch; fall back
// to the setup card rather than spinning forever.
this._loaded = true;
return;
}
try {
const [configResult, info] = await Promise.all([
fetchBackupConfig(this.hass),
fetchBackupInfo(this.hass),
]);
this._backupConfig = configResult.config;
this._backupInfo = info;
} catch {
// Best effort; leave the backup status unknown.
} finally {
this._loaded = true;
}
}
static styles = [
haStyle,
cloudSubpageStyle,
css`
.info-header {
font-size: var(--ha-font-size-l);
font-weight: var(--ha-font-weight-medium);
line-height: var(--ha-line-height-condensed);
margin: 0 0 var(--ha-space-2) 0;
}
.card-content p,
.card-content li {
color: var(--secondary-text-color);
}
.card-content a {
color: var(--primary-color);
}
ul {
padding-left: var(--ha-space-6);
padding-inline-start: var(--ha-space-6);
padding-inline-end: initial;
}
li {
margin-bottom: var(--ha-space-2);
}
.card-actions {
display: flex;
justify-content: flex-end;
}
ha-md-list {
background: none;
--md-list-item-leading-space: 0;
--md-list-item-trailing-space: 0;
}
ha-md-list-item {
--md-list-item-top-space: var(--ha-space-2);
--md-list-item-bottom-space: var(--ha-space-2);
--md-list-item-one-line-container-height: 40px;
}
ha-svg-icon[slot="start"] {
color: var(--secondary-text-color);
}
.spinner {
display: flex;
justify-content: center;
padding: var(--ha-space-8) 0;
}
`,
];
}
declare global {
interface HTMLElementTagNameMap {
"cloud-backup-pref": CloudBackupPref;
}
}
@@ -0,0 +1,134 @@
import { css, html, LitElement } from "lit";
import { customElement, property } from "lit/decorators";
import "../../../../components/ha-button";
import "../../../../components/ha-card";
import "../../../../layouts/hass-subpage";
import { haStyle } from "../../../../resources/styles";
import type { HomeAssistant } from "../../../../types";
import { cloudSubpageStyle } from "./cloud-subpage-style";
@customElement("cloud-companion-pref")
export class CloudCompanionPref extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ type: Boolean }) public narrow = false;
protected render() {
return html`
<hass-subpage
.hass=${this.hass}
.narrow=${this.narrow}
.header=${this.hass.localize(
"ui.panel.config.cloud.account.companion.title"
)}
back-path="/config/cloud/account"
>
<div class="content">
<ha-card
outlined
.header=${this.hass.localize(
"ui.panel.config.cloud.account.companion.connected_title"
)}
>
<div class="card-content">
<p>
${this.hass.localize(
"ui.panel.config.cloud.account.companion.connected_intro"
)}
</p>
<ul>
<li>
${this.hass.localize(
"ui.panel.config.cloud.account.companion.bullet_sensors"
)}
</li>
<li>
${this.hass.localize(
"ui.panel.config.cloud.account.companion.bullet_presence"
)}
</li>
<li>
${this.hass.localize(
"ui.panel.config.cloud.account.companion.bullet_alerts"
)}
</li>
<li>
${this.hass.localize(
"ui.panel.config.cloud.account.companion.bullet_secure"
)}
</li>
<li>
${this.hass.localize(
"ui.panel.config.cloud.account.companion.bullet_push"
)}
</li>
</ul>
</div>
<div class="card-actions">
<ha-button
appearance="plain"
href="https://companion.home-assistant.io/"
target="_blank"
rel="noreferrer"
>
${this.hass.localize(
"ui.panel.config.cloud.account.companion.learn_more"
)}
</ha-button>
</div>
</ha-card>
<ha-card
outlined
.header=${this.hass.localize(
"ui.panel.config.cloud.account.companion.webhook_title"
)}
>
<div class="card-content">
<p>
${this.hass.localize(
"ui.panel.config.cloud.account.companion.webhook_info_one"
)}
</p>
<p>
${this.hass.localize(
"ui.panel.config.cloud.account.companion.webhook_info_two"
)}
</p>
</div>
</ha-card>
</div>
</hass-subpage>
`;
}
static styles = [
haStyle,
cloudSubpageStyle,
css`
.card-content p,
.card-content li {
color: var(--secondary-text-color);
}
ul {
padding-left: var(--ha-space-6);
padding-inline-start: var(--ha-space-6);
padding-inline-end: initial;
margin: 0;
}
li {
margin-bottom: var(--ha-space-2);
}
.card-actions {
display: flex;
justify-content: flex-end;
}
`,
];
}
declare global {
interface HTMLElementTagNameMap {
"cloud-companion-pref": CloudCompanionPref;
}
}
@@ -1,15 +1,17 @@
import { mdiHelpCircleOutline } from "@mdi/js";
import { LitElement, css, html, nothing } from "lit";
import { customElement, property } from "lit/decorators";
import { fireEvent } from "../../../../common/dom/fire_event";
import "../../../../components/ha-button";
import "../../../../components/ha-card";
import "../../../../components/ha-icon-button";
import "../../../../components/ha-switch";
import type { HaSwitch } from "../../../../components/ha-switch";
import type { CloudStatusLoggedIn } from "../../../../data/cloud";
import { updateCloudPref } from "../../../../data/cloud";
import "../../../../layouts/hass-subpage";
import { haStyle } from "../../../../resources/styles";
import type { HomeAssistant } from "../../../../types";
import { showToast } from "../../../../util/toast";
import { cloudSubpageStyle } from "./cloud-subpage-style";
@customElement("cloud-ice-servers-pref")
export class CloudICEServersPref extends LitElement {
@@ -17,6 +19,8 @@ export class CloudICEServersPref extends LitElement {
@property({ attribute: false }) public cloudStatus?: CloudStatusLoggedIn;
@property({ type: Boolean }) public narrow = false;
protected render() {
if (!this.cloudStatus) {
return nothing;
@@ -26,37 +30,50 @@ export class CloudICEServersPref extends LitElement {
this.cloudStatus.prefs;
return html`
<ha-card
outlined
header=${this.hass.localize(
<hass-subpage
.hass=${this.hass}
.narrow=${this.narrow}
.header=${this.hass.localize(
"ui.panel.config.cloud.account.ice_servers.title"
)}
back-path="/config/cloud/account"
>
<div class="header-actions">
<ha-icon-button
.label=${this.hass.localize(
"ui.panel.config.cloud.account.ice_servers.link_learn_how_it_works"
<div class="content">
<ha-card
outlined
header=${this.hass.localize(
"ui.panel.config.cloud.account.ice_servers.title"
)}
.path=${mdiHelpCircleOutline}
href="https://www.nabucasa.com/config/webrtc/"
target="_blank"
rel="noreferrer"
class="icon-link"
></ha-icon-button>
<ha-switch
.checked=${cloudICEServersEnabled}
@change=${this._toggleCloudICEServersEnabledChanged}
></ha-switch>
</div>
>
<div class="header-actions">
<ha-switch
.checked=${cloudICEServersEnabled}
@change=${this._toggleCloudICEServersEnabledChanged}
></ha-switch>
</div>
<div class="card-content">
<p>
${this.hass.localize(
"ui.panel.config.cloud.account.ice_servers.info"
)}
</p>
<div class="card-content">
<p>
${this.hass.localize(
"ui.panel.config.cloud.account.ice_servers.info"
)}
</p>
</div>
<div class="card-actions">
<ha-button
appearance="plain"
href="https://www.nabucasa.com/config/webrtc/"
target="_blank"
rel="noreferrer"
>
${this.hass.localize(
"ui.panel.config.cloud.account.ice_servers.link_learn_more"
)}
</ha-button>
</div>
</ha-card>
</div>
</ha-card>
</hass-subpage>
`;
}
@@ -74,25 +91,28 @@ export class CloudICEServersPref extends LitElement {
}
}
static styles = css`
.header-actions {
position: absolute;
right: 16px;
inset-inline-end: 16px;
inset-inline-start: initial;
top: 24px;
display: flex;
flex-direction: row;
}
.header-actions .icon-link {
margin-top: -16px;
margin-right: 8px;
margin-inline-end: 8px;
margin-inline-start: initial;
direction: var(--direction);
color: var(--secondary-text-color);
}
`;
static styles = [
haStyle,
cloudSubpageStyle,
css`
.header-actions {
position: absolute;
right: var(--ha-space-4);
inset-inline-end: var(--ha-space-4);
inset-inline-start: initial;
top: var(--ha-space-6);
display: flex;
flex-direction: row;
}
.card-content p {
color: var(--secondary-text-color);
}
.card-actions {
display: flex;
justify-content: flex-end;
}
`,
];
}
declare global {
@@ -1,13 +1,13 @@
import { mdiHelpCircleOutline } from "@mdi/js";
import { LitElement, css, html, nothing } from "lit";
import { customElement, property } from "lit/decorators";
import { fireEvent } from "../../../../common/dom/fire_event";
import "../../../../components/ha-alert";
import "../../../../components/ha-button";
import "../../../../components/ha-card";
import "../../../../components/ha-expansion-panel";
import "../../../../components/ha-md-list";
import "../../../../components/ha-md-list-item";
import "../../../../components/ha-switch";
import "../../../../components/item/ha-row-item";
import "../../../../components/ha-tip";
import { formatDate } from "../../../../common/datetime/format_date";
import type { HaSwitch } from "../../../../components/ha-switch";
@@ -18,15 +18,20 @@ import {
disconnectCloudRemote,
updateCloudPref,
} from "../../../../data/cloud";
import "../../../../layouts/hass-subpage";
import { haStyle } from "../../../../resources/styles";
import type { HomeAssistant } from "../../../../types";
import { showToast } from "../../../../util/toast";
import { obfuscateUrl } from "../../../../util/url";
import { showCloudCertificateDialog } from "../dialog-cloud-certificate/show-dialog-cloud-certificate";
import { cloudSubpageStyle } from "./cloud-subpage-style";
@customElement("cloud-remote-pref")
export class CloudRemotePref extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ type: Boolean }) public narrow = false;
@property({ attribute: false }) public cloudStatus?: CloudStatusLoggedIn;
protected render() {
@@ -38,174 +43,216 @@ export class CloudRemotePref extends LitElement {
this.cloudStatus.prefs;
const {
cloud,
cloud_last_disconnect_reason,
remote_connected,
remote_domain,
remote_certificate,
remote_certificate_status,
} = this.cloudStatus;
if (!remote_certificate || remote_certificate_status !== "ready") {
return html`
<ha-card
outlined
header=${this.hass.localize(
"ui.panel.config.cloud.account.remote.title"
)}
>
<div class="preparing">
${
remote_certificate_status === "error"
? this.hass.localize(
"ui.panel.config.cloud.account.remote.cerificate_error"
)
: remote_certificate_status === "loading"
? this.hass.localize(
"ui.panel.config.cloud.account.remote.cerificate_loading"
)
: remote_certificate_status === "loaded"
? this.hass.localize(
"ui.panel.config.cloud.account.remote.cerificate_loaded"
)
: this.hass.localize(
"ui.panel.config.cloud.account.remote.access_is_being_prepared"
)
}
</div>
</ha-card>
`;
}
return html`
<ha-card
outlined
header=${this.hass.localize(
<hass-subpage
.hass=${this.hass}
.narrow=${this.narrow}
.header=${this.hass.localize(
"ui.panel.config.cloud.account.remote.title"
)}
back-path="/config/cloud/account"
>
<div class="header-actions">
<ha-icon-button
.label=${this.hass.localize(
"ui.panel.config.cloud.account.remote.link_learn_how_it_works"
)}
.path=${mdiHelpCircleOutline}
href="https://www.nabucasa.com/config/remote/"
target="_blank"
rel="noreferrer"
class="icon-link"
></ha-icon-button>
<ha-switch
.checked=${remote_enabled}
@change=${this._toggleChanged}
></ha-switch>
</div>
<div class="card-content">
<div class="content">
${
!remote_connected && remote_enabled
!remote_certificate || remote_certificate_status !== "ready"
? html`
<ha-alert
.title=${this.hass.localize(
`ui.panel.config.cloud.account.remote.reconnecting`
)}
></ha-alert>
<ha-card outlined>
<div class="preparing">
${
remote_certificate_status === "error"
? this.hass.localize(
"ui.panel.config.cloud.account.remote.cerificate_error"
)
: remote_certificate_status === "loading"
? this.hass.localize(
"ui.panel.config.cloud.account.remote.cerificate_loading"
)
: remote_certificate_status === "loaded"
? this.hass.localize(
"ui.panel.config.cloud.account.remote.cerificate_loaded"
)
: this.hass.localize(
"ui.panel.config.cloud.account.remote.access_is_being_prepared"
)
}
</div>
</ha-card>
`
: strict_connection === "drop_connection"
? html`<ha-alert
alert-type="warning"
.title=${this.hass.localize(
`ui.panel.config.cloud.account.remote.drop_connection_warning_title`
)}
>${this.hass.localize(
`ui.panel.config.cloud.account.remote.drop_connection_warning`
)}</ha-alert
>`
: nothing
}
<p>
${this.hass.localize("ui.panel.config.cloud.account.remote.info")}
</p>
${
remote_connected
? nothing
: html`
<p>
${this.hass.localize(
"ui.panel.config.cloud.account.remote.info_instance_will_be_available"
<ha-card
outlined
header=${this.hass.localize(
"ui.panel.config.cloud.account.remote.card_title"
)}
</p>
>
<div class="header-actions">
<ha-switch
.checked=${remote_enabled}
@change=${this._toggleChanged}
></ha-switch>
</div>
<div class="card-content">
${
cloud === "connecting" && cloud_last_disconnect_reason
? html`
<ha-alert
alert-type="warning"
.title=${cloud_last_disconnect_reason.reason}
></ha-alert>
`
: nothing
}
${
!remote_connected && remote_enabled
? html`
<ha-alert
.title=${this.hass.localize(
`ui.panel.config.cloud.account.remote.reconnecting`
)}
></ha-alert>
`
: strict_connection === "drop_connection"
? html`<ha-alert
alert-type="warning"
.title=${this.hass.localize(
`ui.panel.config.cloud.account.remote.drop_connection_warning_title`
)}
>${this.hass.localize(
`ui.panel.config.cloud.account.remote.drop_connection_warning`
)}</ha-alert
>`
: nothing
}
<p>
${this.hass.localize(
"ui.panel.config.cloud.account.remote.info"
)}
</p>
${
remote_connected
? nothing
: html`
<p>
${this.hass.localize(
"ui.panel.config.cloud.account.remote.info_instance_will_be_available"
)}
</p>
`
}
<ha-input-copy
readonly
.value=${`https://${remote_domain}`}
.maskedValue=${obfuscateUrl(`https://${remote_domain}`)}
.label=${this.hass!.localize(
"ui.panel.config.common.copy_link"
)}
></ha-input-copy>
</div>
<div class="card-actions">
<ha-button
appearance="plain"
href="https://www.nabucasa.com/config/remote/"
target="_blank"
rel="noreferrer"
>
${this.hass.localize(
"ui.panel.config.cloud.account.remote.link_learn_how_it_works"
)}
</ha-button>
</div>
</ha-card>
<ha-card
outlined
.header=${this.hass.localize(
"ui.panel.config.cloud.account.remote.security_options"
)}
>
<div class="card-content">
<ha-md-list>
<ha-md-list-item>
<span slot="headline"
>${this.hass.localize(
"ui.panel.config.cloud.account.remote.external_activation"
)}</span
>
<span slot="supporting-text"
>${this.hass.localize(
"ui.panel.config.cloud.account.remote.external_activation_secondary"
)}</span
>
<ha-switch
slot="end"
.checked=${remote_allow_remote_enable}
@change=${this._toggleAllowRemoteEnabledChanged}
></ha-switch>
</ha-md-list-item>
<ha-md-list-item>
<span slot="headline"
>${this.hass.localize(
"ui.panel.config.cloud.account.remote.certificate_info"
)}</span
>
<span slot="supporting-text"
>${
this.cloudStatus!.remote_certificate
? this.hass.localize(
"ui.panel.config.cloud.account.remote.certificate_expire",
{
date: formatDate(
new Date(
this.cloudStatus.remote_certificate
.expire_date
),
this.hass.locale,
this.hass.config
),
}
)
: nothing
}</span
>
<ha-button
slot="end"
appearance="plain"
size="s"
@click=${this._openCertInfo}
>
${this.hass.localize(
"ui.panel.config.cloud.account.remote.more_info"
)}
</ha-button>
</ha-md-list-item>
</ha-md-list>
</div>
</ha-card>
<ha-tip .hass=${this.hass}>
${this.hass.localize(
"ui.panel.config.cloud.account.remote.tip_custom_domain"
)}
<a
href="https://support.nabucasa.com/hc/en-us/articles/26497540527517"
target="_blank"
rel="noreferrer"
>${this.hass.localize(
"ui.panel.config.cloud.account.remote.tip_custom_domain_link"
)}</a
>
</ha-tip>
`
}
<ha-input-copy
readonly
.value=${`https://${remote_domain}`}
.maskedValue=${obfuscateUrl(`https://${remote_domain}`)}
.label=${this.hass!.localize("ui.panel.config.common.copy_link")}
></ha-input-copy>
<ha-expansion-panel
outlined
.header=${this.hass.localize(
"ui.panel.config.cloud.account.remote.security_options"
)}
>
<ha-row-item>
<span slot="headline"
>${this.hass.localize(
"ui.panel.config.cloud.account.remote.external_activation"
)}</span
>
<span slot="supporting-text"
>${this.hass.localize(
"ui.panel.config.cloud.account.remote.external_activation_secondary"
)}</span
>
<ha-switch
slot="end"
.checked=${remote_allow_remote_enable}
@change=${this._toggleAllowRemoteEnabledChanged}
>
</ha-switch>
</ha-row-item>
<hr />
<ha-row-item>
<span slot="headline"
>${this.hass.localize(
"ui.panel.config.cloud.account.remote.certificate_info"
)}</span
>
<span slot="supporting-text"
>${
this.cloudStatus!.remote_certificate
? this.hass.localize(
"ui.panel.config.cloud.account.remote.certificate_expire",
{
date: formatDate(
new Date(
this.cloudStatus.remote_certificate.expire_date
),
this.hass.locale,
this.hass.config
),
}
)
: nothing
}</span
>
<ha-button
slot="end"
appearance="plain"
size="s"
@click=${this._openCertInfo}
>
${this.hass.localize(
"ui.panel.config.cloud.account.remote.more_info"
)}
</ha-button>
</ha-row-item>
</ha-expansion-panel>
</div>
</ha-card>
</hass-subpage>
`;
}
@@ -245,72 +292,50 @@ export class CloudRemotePref extends LitElement {
}
}
static styles = css`
.preparing {
padding: 0 16px 16px;
}
.header-actions {
position: absolute;
right: 16px;
inset-inline-end: 16px;
inset-inline-start: initial;
top: 24px;
display: flex;
flex-direction: row;
}
.header-actions .icon-link {
margin-top: -16px;
margin-right: 8px;
margin-inline-end: 8px;
margin-inline-start: initial;
direction: var(--direction);
color: var(--secondary-text-color);
}
.warning {
font-weight: var(--ha-font-weight-bold);
margin-bottom: 1em;
}
.break-word {
overflow-wrap: break-word;
}
.connection-status {
position: absolute;
right: 24px;
top: 24px;
inset-inline-end: 24px;
inset-inline-start: initial;
}
.card-actions {
display: flex;
}
.card-actions a {
text-decoration: none;
}
ha-expansion-panel {
margin-top: 16px;
}
ha-row-item {
--ha-row-item-padding-inline: 0;
}
ha-row-item::part(headline),
ha-row-item::part(supporting-text) {
white-space: wrap;
}
ha-expansion-panel {
--expansion-panel-content-padding: 0 16px;
--expansion-panel-summary-padding: 0 16px;
}
ha-alert {
display: block;
margin-bottom: 16px;
}
hr {
border: none;
height: 1px;
background-color: var(--divider-color);
margin: 8px 0;
}
`;
static styles = [
haStyle,
cloudSubpageStyle,
css`
.preparing {
padding: var(--ha-space-4);
}
a {
color: var(--primary-color);
}
.card-content p {
color: var(--secondary-text-color);
}
.header-actions {
position: absolute;
right: var(--ha-space-4);
inset-inline-end: var(--ha-space-4);
inset-inline-start: initial;
top: var(--ha-space-6);
display: flex;
flex-direction: row;
}
.card-actions {
display: flex;
justify-content: flex-end;
}
ha-md-list {
background: none;
--md-list-item-leading-space: 0;
--md-list-item-trailing-space: 0;
}
ha-md-list-item {
--md-item-overflow: visible;
}
ha-tip {
max-width: 600px;
margin: 0 auto;
}
ha-alert {
display: block;
margin-bottom: var(--ha-space-4);
}
`,
];
}
declare global {
@@ -0,0 +1,15 @@
import { css } from "lit";
export const cloudSubpageStyle = css`
.content {
padding: var(--ha-space-7) var(--ha-space-5) 0;
max-width: 1040px;
margin: 0 auto;
}
ha-card {
display: block;
max-width: 600px;
margin: 0 auto;
margin-bottom: var(--ha-space-6);
}
`;
+431 -127
View File
@@ -1,17 +1,31 @@
import { mdiContentCopy } from "@mdi/js";
import { mdiContentCopy, mdiPlayCircleOutline, mdiRobot } from "@mdi/js";
import type { PropertyValues } from "lit";
import { css, html, LitElement, nothing } 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 { fireEvent } from "../../../../common/dom/fire_event";
import { copyToClipboard } from "../../../../common/util/copy-clipboard";
import { computeStateDomain } from "../../../../common/entity/compute_state_domain";
import { computeStateName } from "../../../../common/entity/compute_state_name";
import { supportsFeature } from "../../../../common/entity/supports-feature";
import "../../../../components/ha-button";
import "../../../../components/ha-card";
import "../../../../components/ha-icon-button";
import "../../../../components/ha-language-picker";
import "../../../../components/ha-md-list";
import "../../../../components/ha-md-list-item";
import "../../../../components/ha-select";
import type { HaSelectSelectEvent } from "../../../../components/ha-select";
import "../../../../components/ha-svg-icon";
import "../../../../components/ha-switch";
import "../../../../components/ha-textarea";
import "../../../../components/ha-tip";
import "../../../../components/voice-assistant-brand-icon";
import type {
HaSelectOption,
HaSelectSelectEvent,
} from "../../../../components/ha-select";
import type { HaTextArea } from "../../../../components/ha-textarea";
import { showAutomationEditor } from "../../../../data/automation";
import type { CloudStatusLoggedIn } from "../../../../data/cloud";
import { updateCloudPref } from "../../../../data/cloud";
import type { CloudTTSInfo } from "../../../../data/cloud/tts";
@@ -19,10 +33,14 @@ import {
getCloudTTSInfo,
getCloudTtsLanguages,
} from "../../../../data/cloud/tts";
import { MediaPlayerEntityFeature } from "../../../../data/media-player";
import { convertTextToSpeech } from "../../../../data/tts";
import { showAlertDialog } from "../../../../dialogs/generic/show-dialog-box";
import "../../../../layouts/hass-subpage";
import { haStyle } from "../../../../resources/styles";
import type { HomeAssistant } from "../../../../types";
import { showToast } from "../../../../util/toast";
import { showTryTtsDialog } from "./show-dialog-cloud-tts-try";
import { cloudSubpageStyle } from "./cloud-subpage-style";
export const getCloudTtsSupportedVoices = (
language: string,
@@ -55,6 +73,16 @@ export class CloudTTSPref extends LitElement {
@state() private ttsInfo?: CloudTTSInfo;
@state() private _loadingExample = false;
@query("#message") private _messageInput?: HaTextArea;
@storage({ key: "cloudTtsTryMessage", state: false, subscribe: false })
private _message!: string;
@storage({ key: "cloudTtsTryTarget", state: false, subscribe: false })
private _target!: string;
protected render() {
if (!this.cloudStatus || !this.ttsInfo) {
return nothing;
@@ -64,74 +92,231 @@ export class CloudTTSPref extends LitElement {
const defaultVoice = this.cloudStatus.prefs.tts_default_voice;
const voices = this.getSupportedVoices(defaultVoice[0], this.ttsInfo);
return html`
<ha-card
outlined
header=${this.hass.localize("ui.panel.config.cloud.account.tts.title")}
>
<div class="card-content">
${this.hass.localize(
"ui.panel.config.cloud.account.tts.description",
{
service: '"tts.cloud_say"',
}
)}
<br /><br />
<div class="row">
<ha-language-picker
.hass=${this.hass}
.label=${this.hass.localize(
"ui.panel.config.cloud.account.tts.default_language"
)}
.disabled=${this.savingPreferences}
.value=${defaultVoice[0]}
.languages=${languages}
@value-changed=${this._handleLanguageChange}
>
</ha-language-picker>
const target = this._target || "browser";
const targetOptions = this._getTargetOptions(
this.hass.states,
this.hass.localize(
"ui.panel.config.cloud.account.tts.dialog.target_browser"
)
);
<ha-select
.label=${this.hass.localize(
"ui.panel.config.cloud.account.tts.default_voice"
)}
.disabled=${this.savingPreferences}
.value=${defaultVoice[1]}
@selected=${this._handleVoiceChange}
.options=${voices.map((voice) => ({
value: voice.voiceId,
label: voice.voiceName,
}))}
>
</ha-select>
</div>
</div>
<div class="card-actions">
<div class="voice-id" @click=${this._copyVoiceId}>
<div class="label">
return html`
<hass-subpage
.hass=${this.hass}
.narrow=${this.narrow}
.header=${this.hass.localize("ui.panel.config.cloud.account.tts.title")}
back-path="/config/cloud/account"
>
<div class="content">
<ha-card outlined>
<div class="card-header">
<voice-assistant-brand-icon
.hass=${this.hass}
.voiceAssistantId=${"conversation"}
></voice-assistant-brand-icon>
${this.hass.localize(
"ui.components.media-browser.tts.selected_voice_id"
"ui.panel.config.cloud.account.assist.card_title"
)}
</div>
<code>${defaultVoice[1]}</code>
${
this.narrow
? nothing
: html`
<ha-icon-button
.path=${mdiContentCopy}
title=${this.hass.localize(
"ui.components.media-browser.tts.copy_voice_id"
)}
></ha-icon-button>
`
}
</div>
<div class="flex"></div>
<ha-button appearance="plain" @click=${this._openTryDialog}>
${this.hass.localize("ui.panel.config.cloud.account.tts.try")}
</ha-button>
<div class="card-content">
<p>
${this.hass.localize(
"ui.panel.config.cloud.account.assist.description"
)}
</p>
</div>
<div class="card-actions">
<ha-button
appearance="plain"
href="https://www.home-assistant.io/voice_control/"
target="_blank"
rel="noreferrer"
>
${this.hass.localize(
"ui.panel.config.cloud.account.assist.link_learn_more"
)}
</ha-button>
<ha-button
appearance="filled"
href="/config/voice-assistants/assistants"
>
${this.hass.localize(
"ui.panel.config.cloud.account.assist.configure"
)}
</ha-button>
</div>
</ha-card>
<ha-card
outlined
header=${this.hass.localize(
"ui.panel.config.cloud.account.tts.card_title"
)}
>
<div class="card-content">
<ha-md-list>
<ha-md-list-item>
<span slot="headline">
${this.hass.localize(
"ui.panel.config.cloud.account.tts.default_language"
)}
</span>
<span slot="supporting-text">
${this.hass.localize(
"ui.panel.config.cloud.account.tts.default_language_description"
)}
</span>
<ha-language-picker
slot="end"
.hass=${this.hass}
.label=${""}
.disabled=${this.savingPreferences}
.value=${defaultVoice[0]}
.languages=${languages}
noClearButton
@value-changed=${this._handleLanguageChange}
>
</ha-language-picker>
</ha-md-list-item>
<ha-md-list-item>
<span slot="headline">
${this.hass.localize(
"ui.panel.config.cloud.account.tts.default_voice"
)}
</span>
<span slot="supporting-text">
${this.hass.localize(
"ui.panel.config.cloud.account.tts.default_voice_description"
)}
</span>
<ha-select
slot="end"
.disabled=${this.savingPreferences}
.value=${defaultVoice[1]}
@selected=${this._handleVoiceChange}
.options=${voices.map((voice) => ({
value: voice.voiceId,
label: voice.voiceName,
}))}
>
</ha-select>
</ha-md-list-item>
<ha-md-list-item>
<span slot="headline">
${this.hass.localize(
"ui.components.media-browser.tts.selected_voice_id"
)}
</span>
<code slot="supporting-text">${defaultVoice[1]}</code>
<ha-icon-button
slot="end"
.path=${mdiContentCopy}
.label=${this.hass.localize(
"ui.components.media-browser.tts.copy_voice_id"
)}
@click=${this._copyVoiceId}
></ha-icon-button>
</ha-md-list-item>
</ha-md-list>
<div class="try-tts">
<span class="try-heading">
${this.hass.localize(
"ui.panel.config.cloud.account.tts.dialog.header"
)}
</span>
<ha-textarea
id="message"
resize="auto"
.label=${this.hass.localize(
"ui.panel.config.cloud.account.tts.dialog.message"
)}
.value=${
this._message ||
this.hass.localize(
"ui.panel.config.cloud.account.tts.dialog.example_message",
{ name: this.hass.user!.name }
)
}
></ha-textarea>
<ha-select
id="target"
.label=${this.hass.localize(
"ui.panel.config.cloud.account.tts.dialog.target"
)}
.value=${target}
@selected=${this._handleTargetChanged}
.options=${targetOptions}
></ha-select>
<div class="try-actions">
<ha-button
appearance="plain"
.disabled=${target === "browser"}
@click=${this._createAutomation}
>
<ha-svg-icon slot="start" .path=${mdiRobot}></ha-svg-icon>
${this.hass.localize(
"ui.panel.config.cloud.account.tts.dialog.create_automation"
)}
</ha-button>
<ha-button
appearance="filled"
@click=${this._playExample}
.disabled=${this._loadingExample}
>
<ha-svg-icon
slot="start"
.path=${mdiPlayCircleOutline}
></ha-svg-icon>
${this.hass.localize(
"ui.panel.config.cloud.account.tts.dialog.play"
)}
</ha-button>
</div>
</div>
</div>
<div class="card-actions">
<ha-button
appearance="plain"
href="https://support.nabucasa.com/hc/en-us/articles/25619386304541"
target="_blank"
rel="noreferrer"
>
${this.hass.localize(
"ui.panel.config.cloud.account.tts.link_learn_more"
)}
</ha-button>
</div>
</ha-card>
<ha-card
outlined
header=${this.hass.localize(
"ui.panel.config.cloud.account.stt.card_title"
)}
>
<div class="card-content">
<p>
${this.hass.localize(
"ui.panel.config.cloud.account.stt.description"
)}
</p>
</div>
<div class="card-actions">
<ha-button
appearance="plain"
href="https://support.nabucasa.com/hc/en-us/articles/29718084245149"
target="_blank"
rel="noreferrer"
>
${this.hass.localize(
"ui.panel.config.cloud.account.stt.link_learn_more"
)}
</ha-button>
</div>
</ha-card>
<ha-tip .hass=${this.hass}>
${this.hass.localize("ui.panel.config.cloud.account.tts.tip")}
</ha-tip>
</div>
</ha-card>
</hass-subpage>
`;
}
@@ -151,9 +336,113 @@ export class CloudTTSPref extends LitElement {
private getSupportedVoices = memoizeOne(getCloudTtsSupportedVoices);
private _openTryDialog() {
showTryTtsDialog(this, {
defaultVoice: this.cloudStatus!.prefs.tts_default_voice,
// Memoized on hass.states identity: rebuilt when states change (so a media
// player rename is picked up) and reused across self-triggered re-renders
// (target selection, saving, loading example) where states is unchanged.
private _getTargetOptions = memoizeOne(
(
states: HomeAssistant["states"],
browserLabel: string
): HaSelectOption[] => [
{ value: "browser", label: browserLabel },
...Object.values(states)
.filter(
(entity) =>
computeStateDomain(entity) === "media_player" &&
supportsFeature(entity, MediaPlayerEntityFeature.PLAY_MEDIA)
)
.map((entity) => ({
value: entity.entity_id,
label: computeStateName(entity),
})),
]
);
private async _copyVoiceId(ev: Event) {
ev.preventDefault();
await copyToClipboard(this.cloudStatus!.prefs.tts_default_voice[1]);
showToast(this, {
message: this.hass.localize("ui.common.copied_clipboard"),
});
}
private _handleTargetChanged(ev: HaSelectSelectEvent) {
this._target = ev.detail.value;
this.requestUpdate("_target");
}
private async _playExample() {
const message = this._messageInput?.value;
if (!message) {
return;
}
this._message = message;
const target = this._target || "browser";
if (target === "browser") {
// The audio element must be created + played from a user action (iOS).
const audio = new Audio();
audio.play();
this._playBrowser(message, audio);
} else {
this.hass.callService("tts", "cloud_say", {
entity_id: target,
message,
});
}
}
private _createAutomation() {
const message = this._messageInput!.value!;
this._message = message;
showAutomationEditor({
action: [
{
service: "tts.cloud_say",
data: { entity_id: this._target, message },
},
],
});
}
private async _playBrowser(message: string, audio: HTMLAudioElement) {
this._loadingExample = true;
const [language, voice] = this.cloudStatus!.prefs.tts_default_voice;
let url: string;
try {
const result = await convertTextToSpeech(this.hass, {
platform: "cloud",
message,
language,
options: { voice },
});
url = result.path;
} catch (err: any) {
this._loadingExample = false;
showAlertDialog(this, {
text: this.hass.localize(
"ui.panel.config.cloud.account.tts.unable_load_example",
{ error: err.error || err.body || err }
),
warning: true,
});
return;
}
audio.src = url;
audio.addEventListener("canplaythrough", () => {
audio.play();
});
audio.addEventListener("playing", () => {
this._loadingExample = false;
});
audio.addEventListener("error", () => {
showAlertDialog(this, {
title: this.hass.localize(
"ui.panel.config.cloud.account.tts.error_playing_audio"
),
});
this._loadingExample = false;
});
}
@@ -210,62 +499,77 @@ export class CloudTTSPref extends LitElement {
}
}
private async _copyVoiceId(ev) {
ev.preventDefault();
await copyToClipboard(this.cloudStatus!.prefs.tts_default_voice[1]);
showToast(this, {
message: this.hass.localize("ui.common.copied_clipboard"),
});
}
static styles = css`
.example {
position: absolute;
right: 16px;
inset-inline-end: 16px;
inset-inline-start: initial;
top: 16px;
}
.row {
display: flex;
gap: var(--ha-space-2);
}
.row > * {
flex: 1;
width: 0;
}
.card-actions {
display: flex;
align-items: center;
}
code {
margin-left: 6px;
font-weight: var(--ha-font-weight-bold);
}
.voice-id {
display: flex;
align-items: center;
font-size: var(--ha-font-size-s);
color: var(--secondary-text-color);
--mdc-icon-size: 14px;
--ha-icon-button-size: 24px;
}
:host([narrow]) .voice-id {
flex-direction: column;
font-size: var(--ha-font-size-xs);
align-items: start;
align-items: left;
}
:host([narrow]) .label {
text-transform: uppercase;
}
:host([narrow]) code {
margin-left: 0;
}
.flex {
flex: 1;
}
`;
static styles = [
haStyle,
cloudSubpageStyle,
css`
a {
color: var(--primary-color);
}
.card-header {
display: flex;
align-items: center;
gap: var(--ha-space-3);
}
.card-content {
padding-left: 0;
padding-right: 0;
}
.card-content p {
color: var(--secondary-text-color);
padding-inline: var(--ha-space-4);
margin: 0;
}
.try-tts {
padding-inline: var(--ha-space-4);
margin-top: var(--ha-space-3);
}
.try-heading {
display: block;
font-weight: var(--ha-font-weight-medium);
margin-bottom: var(--ha-space-2);
}
.try-tts ha-textarea,
.try-tts ha-select {
display: block;
width: 100%;
margin-bottom: var(--ha-space-2);
}
.try-actions {
display: flex;
justify-content: flex-end;
gap: var(--ha-space-2);
flex-wrap: wrap;
}
ha-md-list {
background: none;
--md-list-item-leading-space: var(--ha-space-4);
--md-list-item-trailing-space: var(--ha-space-4);
}
ha-md-list-item {
--md-item-overflow: visible;
}
ha-language-picker,
ha-select {
min-width: 210px;
}
@media all and (max-width: 450px) {
ha-language-picker,
ha-select {
min-width: 160px;
width: 160px;
}
}
.card-actions {
display: flex;
justify-content: space-between;
}
ha-tip {
max-width: 600px;
margin: 0 auto;
}
`,
];
}
declare global {
+252 -126
View File
@@ -1,7 +1,8 @@
import type { CSSResultGroup, PropertyValues } from "lit";
import { css, html, LitElement } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { isComponentLoaded } from "../../../../common/config/is_component_loaded";
import { fireEvent } from "../../../../common/dom/fire_event";
import "../../../../components/ha-button";
import "../../../../components/ha-card";
import "../../../../components/ha-spinner";
@@ -10,20 +11,26 @@ import "../../../../components/item/ha-row-item";
import type { CloudStatusLoggedIn, CloudWebhook } from "../../../../data/cloud";
import { createCloudhook, deleteCloudhook } from "../../../../data/cloud";
import type { Webhook, WebhookError } from "../../../../data/webhook";
import { fetchWebhooks } from "../../../../data/webhook";
import { fetchWebhooks, isActiveCloudWebhook } from "../../../../data/webhook";
import "../../../../layouts/hass-subpage";
import { haStyle } from "../../../../resources/styles";
import type { HomeAssistant } from "../../../../types";
import { showManageCloudhookDialog } from "../dialog-manage-cloudhook/show-dialog-manage-cloudhook";
import { cloudSubpageStyle } from "./cloud-subpage-style";
@customElement("cloud-webhooks")
export class CloudWebhooks extends LitElement {
@property({ attribute: false }) public hass?: HomeAssistant;
@property({ type: Boolean }) public narrow = false;
@property({ attribute: false }) public cloudStatus?: CloudStatusLoggedIn;
@state() private _cloudHooks?: Record<string, CloudWebhook>;
@state() private _localHooks?: Webhook[];
@state() private _customHooks?: Webhook[];
@state() private _mobileHooks?: Webhook[];
@state() private _progress: string[] = [];
@@ -34,102 +41,152 @@ export class CloudWebhooks extends LitElement {
protected render() {
return html`
<ha-card
outlined
header=${this.hass!.localize(
<hass-subpage
.hass=${this.hass}
.narrow=${this.narrow}
.header=${this.hass!.localize(
"ui.panel.config.cloud.account.webhooks.title"
)}
back-path="/config/cloud/account"
>
<div class="card-content">
${this.hass!.localize("ui.panel.config.cloud.account.webhooks.info")}
${
!this.cloudStatus ||
!this._localHooks ||
!this._cloudHooks ||
!this.hass
? html`
<div class="body-text">
${this.hass!.localize(
"ui.panel.config.cloud.account.webhooks.loading"
)}
</div>
`
: this._localHooks.length === 0
? html`
<div class="body-text">
${this.hass.localize(
"ui.panel.config.cloud.account.webhooks.no_hooks_yet"
)}
<a href="/config/integrations"
>${this.hass.localize(
"ui.panel.config.cloud.account.webhooks.no_hooks_yet_link_integration"
<div class="content">
<ha-card
outlined
header=${this.hass!.localize(
"ui.panel.config.cloud.account.webhooks.title"
)}
>
<div class="card-content">
<p>
${this.hass!.localize(
"ui.panel.config.cloud.account.webhooks.info"
)}
</p>
${
!this.cloudStatus ||
!this._customHooks ||
!this._mobileHooks ||
!this._cloudHooks ||
!this.hass
? html`
<div class="body-text">
${this.hass!.localize(
"ui.panel.config.cloud.account.webhooks.loading"
)}
</a>
${this.hass.localize(
"ui.panel.config.cloud.account.webhooks.no_hooks_yet2"
)}
<a href="/config/automation/edit/new"
>${this.hass.localize(
"ui.panel.config.cloud.account.webhooks.no_hooks_yet_link_automation"
)}</a
>.
</div>
`
: this._localHooks.map(
(entry) => html`
<ha-row-item .entry=${entry}>
<span slot="headline"
>${entry.name}
${
entry.domain !== entry.name.toLowerCase()
? ` (${entry.domain})`
: ""
}</span
>
<span slot="supporting-text">${entry.webhook_id}</span>
${
this._progress.includes(entry.webhook_id)
? html`
<div class="progress" slot="end">
<ha-spinner></ha-spinner>
</div>
`
: this._cloudHooks![entry.webhook_id]
? html`
<ha-button
slot="end"
appearance="plain"
size="s"
@click=${this._handleManageButton}
>
${this.hass!.localize(
"ui.panel.config.cloud.account.webhooks.manage"
)}
</ha-button>
`
: html`<ha-switch
slot="end"
@click=${this._enableWebhook}
>
</ha-switch>`
}
</ha-row-item>
</div>
`
)
: this._customHooks.length === 0 && !this._mobileHooks.length
? html`
<div class="body-text">
${this.hass.localize(
"ui.panel.config.cloud.account.webhooks.no_hooks_yet"
)}
<a href="/config/integrations"
>${this.hass.localize(
"ui.panel.config.cloud.account.webhooks.no_hooks_yet_link_integration"
)}
</a>
${this.hass.localize(
"ui.panel.config.cloud.account.webhooks.no_hooks_yet2"
)}
<a href="/config/automation/edit/new"
>${this.hass.localize(
"ui.panel.config.cloud.account.webhooks.no_hooks_yet_link_automation"
)}</a
>.
</div>
`
: this._customHooks.map((entry) =>
this._renderHookRow(entry)
)
}
</div>
<div class="card-actions">
<ha-button
appearance="plain"
href="https://www.nabucasa.com/config/webhooks"
target="_blank"
rel="noreferrer"
>
${this.hass!.localize(
"ui.panel.config.cloud.account.webhooks.link_learn_more"
)}
</ha-button>
</div>
</ha-card>
${
this._mobileHooks && this._mobileHooks.length
? html`
<ha-card
outlined
header=${this.hass!.localize(
"ui.panel.config.cloud.account.webhooks.companion_title"
)}
>
<div class="card-content">
<p>
${this.hass!.localize(
"ui.panel.config.cloud.account.webhooks.companion_info"
)}
</p>
${this._mobileHooks.map((entry) =>
this._renderHookRow(entry)
)}
</div>
</ha-card>
`
: nothing
}
<div class="footer">
<a
href="https://www.nabucasa.com/config/webhooks"
target="_blank"
rel="noreferrer"
>
${this.hass!.localize(
"ui.panel.config.cloud.account.webhooks.link_learn_more"
)}
</a>
</div>
<ha-card
outlined
header=${this.hass!.localize(
"ui.panel.config.cloud.account.webhooks.about_title"
)}
>
<div class="card-content">
<p>
${this.hass!.localize(
"ui.panel.config.cloud.account.webhooks.about_info"
)}
</p>
<ul class="examples">
<li>
${this.hass!.localize(
"ui.panel.config.cloud.account.webhooks.example_camera"
)}
</li>
<li>
${this.hass!.localize(
"ui.panel.config.cloud.account.webhooks.example_location"
)}
</li>
<li>
${this.hass!.localize(
"ui.panel.config.cloud.account.webhooks.example_ifttt"
)}
</li>
<li>
${this.hass!.localize(
"ui.panel.config.cloud.account.webhooks.example_nfc"
)}
</li>
<li>
${this.hass!.localize(
"ui.panel.config.cloud.account.webhooks.example_sms"
)}
</li>
</ul>
<p class="note">
${this.hass!.localize(
"ui.panel.config.cloud.account.webhooks.about_note"
)}
</p>
</div>
</ha-card>
</div>
</ha-card>
</hass-subpage>
`;
}
@@ -140,10 +197,59 @@ export class CloudWebhooks extends LitElement {
}
}
private _renderHookRow(entry: Webhook) {
return html`
<ha-row-item>
<span slot="headline"
>${entry.name}
${
entry.domain !== entry.name.toLowerCase()
? ` (${entry.domain})`
: ""
}</span
>
<span slot="supporting-text">${entry.webhook_id}</span>
${
this._progress.includes(entry.webhook_id)
? html`
<div class="progress" slot="end">
<ha-spinner></ha-spinner>
</div>
`
: this._cloudHooks?.[entry.webhook_id]
? html`
<ha-button
slot="end"
appearance="plain"
size="s"
data-webhook-id=${entry.webhook_id}
@click=${this._handleManageButton}
>
${this.hass!.localize(
"ui.panel.config.cloud.account.webhooks.manage"
)}
</ha-button>
`
: html`
<ha-switch
slot="end"
data-webhook-id=${entry.webhook_id}
@click=${this._enableWebhook}
></ha-switch>
`
}
</ha-row-item>
`;
}
private _showDialog(webhookId: string) {
const webhook = this._localHooks!.find(
(ent) => ent.webhook_id === webhookId
)!;
const webhook = [
...(this._customHooks ?? []),
...(this._mobileHooks ?? []),
].find((ent) => ent.webhook_id === webhookId);
if (!webhook) {
return;
}
const cloudhook = this._cloudHooks![webhookId];
showManageCloudhookDialog(this, {
webhook,
@@ -152,33 +258,40 @@ export class CloudWebhooks extends LitElement {
});
}
private _handleManageButton(ev: MouseEvent) {
const entry = (ev.currentTarget as any).parentElement.entry as Webhook;
this._showDialog(entry.webhook_id);
private _handleManageButton(ev: Event) {
const webhookId = (ev.currentTarget as HTMLElement).dataset.webhookId!;
this._showDialog(webhookId);
}
private async _enableWebhook(ev: MouseEvent) {
const entry = (ev.currentTarget as any).parentElement!.entry as Webhook;
this._progress = [...this._progress, entry.webhook_id];
private async _enableWebhook(ev: Event) {
const webhookId = (ev.currentTarget as HTMLElement).dataset.webhookId!;
if (this._progress.includes(webhookId)) {
return;
}
this._progress = [...this._progress, webhookId];
let updatedWebhook;
try {
updatedWebhook = await createCloudhook(this.hass!, entry.webhook_id);
updatedWebhook = await createCloudhook(this.hass!, webhookId);
} catch (err: any) {
alert((err as WebhookError).message);
return;
} finally {
this._progress = this._progress.filter((wid) => wid !== entry.webhook_id);
this._progress = this._progress.filter((wid) => wid !== webhookId);
}
this._cloudHooks = {
...this._cloudHooks,
[entry.webhook_id]: updatedWebhook,
[webhookId]: updatedWebhook,
};
// Refresh the shared cloud status so the account overview's active-webhook
// count reflects the new cloudhook (it reads cloudStatus.prefs.cloudhooks).
fireEvent(this, "ha-refresh-cloud-status");
// Only open dialog if we're not also enabling others, otherwise it's confusing
if (this._progress.length === 0) {
this._showDialog(entry.webhook_id);
this._showDialog(webhookId);
}
}
@@ -200,48 +313,61 @@ export class CloudWebhooks extends LitElement {
// Remove cloud related parts from entry.
const { [webhookId]: disabledHook, ...newHooks } = this._cloudHooks!;
this._cloudHooks = newHooks;
// Keep the shared cloud status (and the overview's active-webhook count) in
// sync now that this cloudhook is gone.
fireEvent(this, "ha-refresh-cloud-status");
}
private async _fetchData() {
if (!isComponentLoaded(this.hass!.config, "webhook")) {
this._localHooks = [];
this._customHooks = [];
this._mobileHooks = [];
return;
}
const hooks = await fetchWebhooks(this.hass!);
this._localHooks = hooks.filter(
(hook) =>
// Only hooks that are not limited to local requests are relevant
!hook.local_only &&
// Deleted webhooks -> nobody cares :)
(hook.domain !== "mobile_app" || hook.name !== "Deleted Webhook")
);
const relevant = hooks.filter(isActiveCloudWebhook);
// Mobile app webhooks are created automatically and shown in their own card.
this._customHooks = relevant.filter((hook) => hook.domain !== "mobile_app");
this._mobileHooks = relevant.filter((hook) => hook.domain === "mobile_app");
}
static get styles(): CSSResultGroup {
return [
haStyle,
cloudSubpageStyle,
css`
.body-text {
padding: 8px 0;
.card-content p {
color: var(--secondary-text-color);
}
.webhook {
display: flex;
padding: 4px 0;
.body-text {
padding: var(--ha-space-2) 0;
}
.body-text a {
color: var(--primary-color);
}
.examples {
color: var(--secondary-text-color);
padding-inline-start: var(--ha-space-5);
}
.examples li {
margin-bottom: var(--ha-space-1);
}
.note {
font-size: var(--ha-font-size-s);
}
.progress {
margin-right: 16px;
margin-inline-end: 16px;
margin-right: var(--ha-space-4);
margin-inline-end: var(--ha-space-4);
margin-inline-start: initial;
display: flex;
flex-direction: column;
justify-content: center;
}
.footer {
padding-top: 16px;
}
.body-text a,
.footer a {
color: var(--primary-color);
.card-actions {
display: flex;
justify-content: flex-end;
}
ha-row-item {
--ha-row-item-padding-inline: 0;
@@ -0,0 +1,770 @@
import {
mdiBackupRestore,
mdiCheck,
mdiCheckCircle,
mdiEarth,
mdiGoogleAssistant,
mdiMicrophone,
mdiMicrophoneMessage,
mdiVideo,
} from "@mdi/js";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import type { TemplateResult } from "lit";
import { isComponentLoaded } from "../../../../common/config/is_component_loaded";
import { fireEvent } from "../../../../common/dom/fire_event";
import { navigate } from "../../../../common/navigate";
import "../../../../components/ha-button";
import "../../../../components/ha-dialog";
import "../../../../components/ha-dialog-footer";
import "../../../../components/ha-expansion-panel";
import "../../../../components/ha-logo-svg";
import "../../../../components/ha-svg-icon";
import type { BackupConfig } from "../../../../data/backup";
import {
CLOUD_AGENT,
cloudBackupEnabled,
fetchBackupConfig,
updateBackupConfig,
} from "../../../../data/backup";
import type { CloudOnboardingItem } from "../../../../data/cloud";
import {
connectCloudRemote,
disconnectCloudRemote,
fetchCloudStatus,
ONBOARDING_ITEMS,
updateCloudPref,
} from "../../../../data/cloud";
import { haStyle, haStyleDialog } from "../../../../resources/styles";
import type { HomeAssistant } from "../../../../types";
import { showToast } from "../../../../util/toast";
import { onboardingPanelCompleted } from "./cloud-account-status";
import type { CloudOnboardingDialogParams } from "./show-dialog-cloud-onboarding";
@customElement("dialog-cloud-onboarding")
export class DialogCloudOnboarding extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@state() private _params?: CloudOnboardingDialogParams;
@state() private _open = false;
@state() private _openPanel?: string;
// Kept in sync locally (re-fetched after each toggle) so the panel labels and
// completion ticks stay live while the dialog is open, independent of the
// page behind it.
@state() private _cloudStatus?: CloudOnboardingDialogParams["cloudStatus"];
@state() private _backupConfig?: BackupConfig;
public showDialog(params: CloudOnboardingDialogParams): void {
this._params = params;
this._cloudStatus = params.cloudStatus;
this._backupConfig = params.backupConfig;
this._openPanel = this._firstIncompletePanel();
this._open = true;
}
public closeDialog(): boolean {
this._open = false;
return true;
}
private _dialogClosed() {
this._open = false;
this._params = undefined;
this._cloudStatus = undefined;
this._backupConfig = undefined;
this._openPanel = undefined;
fireEvent(this, "dialog-closed", { dialog: this.localName });
}
protected render() {
if (!this._params || !this._cloudStatus) {
return nothing;
}
const cloudStatus = this._cloudStatus;
return html`
<ha-dialog
.open=${this._open}
header-title=${this.hass.localize(
"ui.panel.config.cloud.account.onboarding.dialog_title"
)}
@closed=${this._dialogClosed}
>
<p class="muted setup-intro">
${this.hass.localize(
"ui.panel.config.cloud.account.onboarding.dialog_intro"
)}
</p>
<div class="setup-dialog">
<ha-expansion-panel
outlined
data-panel="remote"
.expanded=${this._openPanel === "remote"}
@expanded-changed=${this._panelExpanded}
>
${this._panelHeader(
"remote",
mdiEarth,
this.hass.localize(
"ui.panel.config.cloud.account.onboarding.remote_title"
),
this.hass.localize(
"ui.panel.config.cloud.account.onboarding.remote_helper"
)
)}
<div class="panel-body">
<p class="muted">
${this.hass.localize(
"ui.panel.config.cloud.account.onboarding.remote_body"
)}
</p>
<ha-button appearance="plain" @click=${this._toggleRemote}>
${
cloudStatus.prefs.remote_enabled
? this.hass.localize(
"ui.panel.config.cloud.account.onboarding.turn_off"
)
: this.hass.localize(
"ui.panel.config.cloud.account.onboarding.turn_on"
)
}
</ha-button>
</div>
</ha-expansion-panel>
<ha-expansion-panel
outlined
data-panel="backup"
.expanded=${this._openPanel === "backup"}
@expanded-changed=${this._panelExpanded}
>
${this._panelHeader(
"backup",
mdiBackupRestore,
this.hass.localize(
"ui.panel.config.cloud.account.onboarding.backup_title"
),
this.hass.localize(
"ui.panel.config.cloud.account.onboarding.backup_helper"
)
)}
<div class="panel-body">
${
!this._backupConfig?.automatic_backups_configured
? html`
<p class="muted">
${this.hass.localize(
"ui.panel.config.cloud.account.onboarding.backup_none_body"
)}
</p>
<ha-button
appearance="plain"
href="/config/backup?historyBack=1"
@click=${this.closeDialog}
>
${this.hass.localize(
"ui.panel.config.cloud.account.onboarding.backup_set_up"
)}
</ha-button>
`
: html`
<p class="muted">
${this.hass.localize(
"ui.panel.config.cloud.account.onboarding.backup_body"
)}
</p>
<ha-button
appearance="plain"
@click=${this._toggleCloudBackup}
>
${
this._cloudBackupEnabled
? this.hass.localize(
"ui.panel.config.cloud.account.onboarding.turn_off"
)
: this.hass.localize(
"ui.panel.config.cloud.account.onboarding.turn_on"
)
}
</ha-button>
`
}
</div>
</ha-expansion-panel>
<ha-expansion-panel
outlined
data-panel="voice"
.expanded=${this._openPanel === "voice"}
@expanded-changed=${this._panelExpanded}
>
${this._panelHeader(
"voice",
mdiMicrophoneMessage,
this.hass.localize(
"ui.panel.config.cloud.account.onboarding.voice_title"
),
this.hass.localize(
"ui.panel.config.cloud.account.onboarding.voice_helper"
)
)}
<div class="panel-body">
<p class="muted">
${this.hass.localize(
"ui.panel.config.cloud.account.onboarding.voice_body"
)}
</p>
${this._voiceCloudCard()}
${this._assistantCard(
this.hass.localize(
"ui.panel.config.cloud.account.onboarding.alexa_name"
),
mdiMicrophone,
"alexa",
this.hass.localize(
"ui.panel.config.cloud.account.onboarding.alexa_tagline"
),
[
this.hass.localize(
"ui.panel.config.cloud.account.onboarding.alexa_bullet1"
),
this.hass.localize(
"ui.panel.config.cloud.account.onboarding.alexa_bullet2"
),
],
cloudStatus.alexa_registered
)}
${this._assistantCard(
this.hass.localize(
"ui.panel.config.cloud.account.onboarding.google_name"
),
mdiGoogleAssistant,
"google",
this.hass.localize(
"ui.panel.config.cloud.account.onboarding.google_tagline"
),
[
this.hass.localize(
"ui.panel.config.cloud.account.onboarding.google_bullet1"
),
this.hass.localize(
"ui.panel.config.cloud.account.onboarding.google_bullet2"
),
],
cloudStatus.google_registered
)}
</div>
</ha-expansion-panel>
<ha-expansion-panel
outlined
data-panel="streaming"
.expanded=${this._openPanel === "streaming"}
@expanded-changed=${this._panelExpanded}
>
${this._panelHeader(
"streaming",
mdiVideo,
this.hass.localize(
"ui.panel.config.cloud.account.onboarding.streaming_title"
),
this.hass.localize(
"ui.panel.config.cloud.account.onboarding.streaming_helper"
)
)}
<div class="panel-body">
<p class="muted">
${this.hass.localize(
"ui.panel.config.cloud.account.onboarding.streaming_body"
)}
</p>
<ha-button appearance="plain" @click=${this._toggleWebrtc}>
${
cloudStatus.prefs.cloud_ice_servers_enabled
? this.hass.localize(
"ui.panel.config.cloud.account.onboarding.turn_off"
)
: this.hass.localize(
"ui.panel.config.cloud.account.onboarding.turn_on"
)
}
</ha-button>
</div>
</ha-expansion-panel>
</div>
<ha-dialog-footer slot="footer">
<ha-button slot="primaryAction" @click=${this.closeDialog}>
${this.hass.localize(
"ui.panel.config.cloud.account.onboarding.done"
)}
</ha-button>
</ha-dialog-footer>
</ha-dialog>
`;
}
private _panelHeader(
key: CloudOnboardingItem,
icon: string,
title: string,
helper: string
): TemplateResult {
return html`
<div slot="leading-icon" class="panel-icon ${key}">
<ha-svg-icon .path=${icon}></ha-svg-icon>
</div>
<div slot="header" class="panel-header">
<div class="panel-heading">
<span class="panel-title">${title}</span>
<span class="panel-sub">${helper}</span>
</div>
${this._statusTick(this._panelCompleted(key))}
</div>
`;
}
private _panelCompleted(key: CloudOnboardingItem): boolean {
return this._cloudStatus
? onboardingPanelCompleted(key, this._cloudStatus, this._backupConfig)
: false;
}
private _statusTick(completed: boolean) {
if (!completed) {
return nothing;
}
return html`
<ha-svg-icon class="status-tick on" .path=${mdiCheckCircle}></ha-svg-icon>
`;
}
private _panelExpanded(ev: CustomEvent<{ expanded: boolean }>) {
const key = (ev.currentTarget as HTMLElement).getAttribute("data-panel");
if (ev.detail.expanded) {
this._openPanel = key ?? undefined;
} else if (this._openPanel === key) {
this._openPanel = undefined;
}
}
private _voiceCloudCard(): TemplateResult {
return html`
<div class="option-card">
<div class="option-head">
<div class="option-icon cloud">
<ha-logo-svg></ha-logo-svg>
</div>
<div class="option-heading">
<span class="option-title"
>${this.hass.localize(
"ui.panel.config.cloud.account.onboarding.ha_cloud_title"
)}</span
>
<span class="option-sub">
${this.hass.localize(
"ui.panel.config.cloud.account.onboarding.ha_cloud_sub"
)}
</span>
</div>
<span class="pill active"
>${this.hass.localize(
"ui.panel.config.cloud.account.onboarding.included"
)}</span
>
</div>
<div class="option-actions">
<ha-button
appearance="plain"
href="https://www.home-assistant.io/voice_control/"
target="_blank"
rel="noreferrer"
>
${this.hass.localize(
"ui.panel.config.cloud.account.onboarding.learn_more"
)}
</ha-button>
</div>
</div>
`;
}
private _assistantCard(
name: string,
icon: string,
colorKey: string,
tagline: string,
bullets: string[],
linked: boolean
): TemplateResult {
return html`
<div class="option-card">
<div class="option-head">
<div class="option-icon ${colorKey}">
<ha-svg-icon .path=${icon}></ha-svg-icon>
</div>
<div class="option-heading">
<span class="option-title">${name}</span>
<span class="option-sub">${tagline}</span>
</div>
${
linked
? html`
<span class="pill active"
>${this.hass.localize(
"ui.panel.config.cloud.account.onboarding.active"
)}</span
>
`
: nothing
}
</div>
${
linked
? html`
<div class="option-actions">
<ha-button
appearance="plain"
href="/config/voice-assistants/assistants?historyBack=1"
@click=${this.closeDialog}
>
${this.hass.localize(
"ui.panel.config.cloud.account.onboarding.manage"
)}
</ha-button>
</div>
`
: html`
<ul class="option-bullets">
${bullets.map(
(bullet) => html`
<li>
<ha-svg-icon .path=${mdiCheck}></ha-svg-icon>${bullet}
</li>
`
)}
</ul>
<div class="option-actions">
<ha-button
href="/config/voice-assistants/assistants?historyBack=1"
@click=${this.closeDialog}
>
${this.hass.localize(
"ui.panel.config.cloud.account.onboarding.set_up",
{ name }
)}
</ha-button>
</div>
`
}
</div>
`;
}
private get _cloudBackupEnabled(): boolean {
return cloudBackupEnabled(this._backupConfig);
}
private _firstIncompletePanel(): CloudOnboardingItem | undefined {
return ONBOARDING_ITEMS.find((key) => !this._panelCompleted(key));
}
private async _refreshStatus() {
const status = await fetchCloudStatus(this.hass);
if (status.logged_in) {
this._cloudStatus = status;
}
}
private async _refreshBackupConfig() {
if (!isComponentLoaded(this.hass.config, "backup")) {
return;
}
try {
const result = await fetchBackupConfig(this.hass);
this._backupConfig = result.config;
} catch {
// Best effort; keep the last known config.
}
}
private async _toggleRemote() {
if (!this._cloudStatus) {
return;
}
const enable = !this._cloudStatus.prefs.remote_enabled;
try {
if (enable) {
await connectCloudRemote(this.hass);
} else {
await disconnectCloudRemote(this.hass);
}
await this._refreshStatus();
this._params?.onChanged?.();
} catch (err: any) {
showToast(this, { message: err.message });
}
}
private async _toggleWebrtc() {
if (!this._cloudStatus) {
return;
}
const enable = !this._cloudStatus.prefs.cloud_ice_servers_enabled;
try {
await updateCloudPref(this.hass, {
cloud_ice_servers_enabled: enable,
});
await this._refreshStatus();
this._params?.onChanged?.();
} catch (err: any) {
showToast(this, { message: err.message });
}
}
private async _toggleCloudBackup() {
const config = this._backupConfig;
if (!config) {
return;
}
if (!config.automatic_backups_configured) {
this.closeDialog();
navigate("/config/backup?historyBack=1");
return;
}
const agentIds = this._cloudBackupEnabled
? config.create_backup.agent_ids.filter((id) => id !== CLOUD_AGENT)
: [...config.create_backup.agent_ids, CLOUD_AGENT];
try {
await updateBackupConfig(this.hass, {
create_backup: { agent_ids: agentIds },
});
await this._refreshBackupConfig();
this._params?.onChanged?.();
} catch (err: any) {
showToast(this, { message: err.message });
}
}
static get styles() {
return [
haStyle,
haStyleDialog,
css`
.muted {
color: var(--secondary-text-color);
}
p.muted {
margin: var(--ha-space-2) 0 0;
}
.setup-intro.muted {
margin-bottom: var(--ha-space-6);
}
.setup-dialog {
display: flex;
flex-direction: column;
gap: var(--ha-space-3);
}
.setup-dialog ha-expansion-panel {
--expansion-panel-summary-padding: var(--ha-space-3) var(--ha-space-4);
}
.panel-header {
flex: 1;
display: flex;
align-items: center;
gap: var(--ha-space-3);
min-width: 0;
}
.panel-heading {
flex: 1;
display: flex;
flex-direction: column;
min-width: 0;
}
.panel-title {
font-size: var(--ha-font-size-m);
font-weight: var(--ha-font-weight-medium);
}
.panel-sub {
color: var(--secondary-text-color);
font-size: var(--ha-font-size-s);
font-weight: var(--ha-font-weight-normal);
}
.status-tick {
flex-shrink: 0;
}
.status-tick.on {
color: var(--success-color);
}
.panel-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 40px;
height: 40px;
border-radius: 50%;
--mdc-icon-size: 22px;
flex-shrink: 0;
}
.panel-icon.remote {
color: var(--blue-color);
background-color: color-mix(
in srgb,
var(--blue-color) 15%,
transparent
);
}
.panel-icon.backup {
color: var(--green-color);
background-color: color-mix(
in srgb,
var(--green-color) 15%,
transparent
);
}
.panel-icon.voice {
color: var(--purple-color);
background-color: color-mix(
in srgb,
var(--purple-color) 15%,
transparent
);
}
.panel-icon.streaming {
color: var(--cyan-color);
background-color: color-mix(
in srgb,
var(--cyan-color) 15%,
transparent
);
}
.panel-body {
padding: var(--ha-space-2) var(--ha-space-5) var(--ha-space-5)
var(--ha-space-14);
display: flex;
flex-direction: column;
gap: var(--ha-space-3);
}
.panel-body p {
margin: 0;
}
.panel-body > ha-button {
align-self: flex-start;
--ha-button-padding-inline: 0;
}
.option-card {
border: 1px solid var(--divider-color);
border-radius: var(--ha-border-radius-lg, 12px);
padding: var(--ha-space-4);
display: flex;
flex-direction: column;
gap: var(--ha-space-3);
}
.option-head {
display: flex;
align-items: center;
gap: var(--ha-space-3);
}
.option-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 40px;
height: 40px;
border-radius: 50%;
--mdc-icon-size: 22px;
flex-shrink: 0;
}
.option-icon.cloud {
color: var(--primary-color);
background-color: color-mix(
in srgb,
var(--primary-color) 15%,
transparent
);
}
.option-icon.alexa {
color: var(--cyan-color);
background-color: color-mix(
in srgb,
var(--cyan-color) 15%,
transparent
);
}
.option-icon.google {
color: var(--blue-color);
background-color: color-mix(
in srgb,
var(--blue-color) 15%,
transparent
);
}
.option-heading {
flex: 1;
display: flex;
flex-direction: column;
min-width: 0;
}
.option-title {
font-weight: var(--ha-font-weight-medium);
}
.option-sub {
color: var(--secondary-text-color);
font-size: var(--ha-font-size-s);
}
.pill {
flex-shrink: 0;
align-self: flex-start;
padding: 2px var(--ha-space-2);
border-radius: 999px;
font-size: var(--ha-font-size-s);
white-space: nowrap;
}
.pill.active {
background-color: color-mix(
in srgb,
var(--primary-text-color) 8%,
transparent
);
color: var(--secondary-text-color);
}
.option-bullets {
margin: 0;
padding: 0;
list-style: none;
display: flex;
flex-direction: column;
gap: var(--ha-space-1);
color: var(--secondary-text-color);
font-size: var(--ha-font-size-s);
}
.option-bullets li {
display: flex;
align-items: center;
gap: var(--ha-space-2);
}
.option-bullets ha-svg-icon {
color: var(--success-color);
--mdc-icon-size: 18px;
flex-shrink: 0;
}
.option-actions {
display: flex;
align-items: center;
gap: var(--ha-space-2);
}
`,
];
}
}
declare global {
interface HTMLElementTagNameMap {
"dialog-cloud-onboarding": DialogCloudOnboarding;
}
}
@@ -1,258 +0,0 @@
import { mdiPlayCircleOutline, mdiRobot } from "@mdi/js";
import type { CSSResultGroup } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import { storage } from "../../../../common/decorators/storage";
import { fireEvent } from "../../../../common/dom/fire_event";
import { computeStateDomain } from "../../../../common/entity/compute_state_domain";
import { computeStateName } from "../../../../common/entity/compute_state_name";
import { supportsFeature } from "../../../../common/entity/supports-feature";
import "../../../../components/ha-button";
import "../../../../components/ha-dialog-footer";
import "../../../../components/ha-select";
import type {
HaSelectOption,
HaSelectSelectEvent,
} from "../../../../components/ha-select";
import "../../../../components/ha-textarea";
import type { HaTextArea } from "../../../../components/ha-textarea";
import "../../../../components/ha-dialog";
import { showAutomationEditor } from "../../../../data/automation";
import { MediaPlayerEntityFeature } from "../../../../data/media-player";
import { convertTextToSpeech } from "../../../../data/tts";
import { showAlertDialog } from "../../../../dialogs/generic/show-dialog-box";
import { haStyleDialog } from "../../../../resources/styles";
import type { HomeAssistant } from "../../../../types";
import type { TryTtsDialogParams } from "./show-dialog-cloud-tts-try";
@customElement("dialog-cloud-try-tts")
export class DialogTryTts extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@state() private _loadingExample = false;
@state() private _open = false;
@state() private _params?: TryTtsDialogParams;
@query("#message") private _messageInput?: HaTextArea;
@storage({
key: "cloudTtsTryMessage",
state: false,
subscribe: false,
})
private _message!: string;
@storage({
key: "cloudTtsTryTarget",
state: false,
subscribe: false,
})
private _target!: string;
public showDialog(params: TryTtsDialogParams) {
this._params = params;
this._open = true;
}
public closeDialog() {
this._open = false;
}
private _dialogClosed() {
this._open = false;
this._params = undefined;
fireEvent(this, "dialog-closed", { dialog: this.localName });
}
protected render() {
if (!this._params) {
return nothing;
}
const target = this._target || "browser";
const targetOptions: HaSelectOption[] = Object.values(this.hass.states)
.filter(
(entity) =>
computeStateDomain(entity) === "media_player" &&
supportsFeature(entity, MediaPlayerEntityFeature.PLAY_MEDIA)
)
.map((entity) => ({
value: entity.entity_id,
label: computeStateName(entity),
}));
targetOptions.unshift({
value: "browser",
label: this.hass.localize(
"ui.panel.config.cloud.account.tts.dialog.target_browser"
),
});
return html`
<ha-dialog
.open=${this._open}
header-title=${this.hass.localize(
"ui.panel.config.cloud.account.tts.dialog.header"
)}
@closed=${this._dialogClosed}
>
<div>
<ha-textarea
resize="auto"
id="message"
autofocus
.label=${this.hass.localize(
"ui.panel.config.cloud.account.tts.dialog.message"
)}
.value=${
this._message ||
this.hass.localize(
"ui.panel.config.cloud.account.tts.dialog.example_message",
{ name: this.hass.user!.name }
)
}
>
</ha-textarea>
<ha-select
.label=${this.hass.localize(
"ui.panel.config.cloud.account.tts.dialog.target"
)}
id="target"
.value=${target}
@selected=${this._handleTargetChanged}
.options=${targetOptions}
>
</ha-select>
</div>
<ha-dialog-footer slot="footer">
<ha-button
appearance="plain"
slot="secondaryAction"
.disabled=${target === "browser"}
@click=${this._createAutomation}
>
<ha-svg-icon slot="start" .path=${mdiRobot}></ha-svg-icon>
${this.hass.localize(
"ui.panel.config.cloud.account.tts.dialog.create_automation"
)}
</ha-button>
<ha-button
slot="primaryAction"
@click=${this._playExample}
.disabled=${this._loadingExample}
>
<ha-svg-icon
slot="start"
.path=${mdiPlayCircleOutline}
></ha-svg-icon>
${this.hass.localize(
"ui.panel.config.cloud.account.tts.dialog.play"
)}
</ha-button>
</ha-dialog-footer>
</ha-dialog>
`;
}
private _handleTargetChanged(ev: HaSelectSelectEvent) {
this._target = ev.detail.value;
this.requestUpdate("_target");
}
private async _playExample() {
const message = this._messageInput?.value;
if (!message) {
return;
}
this._message = message;
const target = this._target || "browser";
if (target === "browser") {
// We create the audio element here + do a play, because iOS requires it to be done by user action
const audio = new Audio();
audio.play();
this._playBrowser(message, audio);
} else {
this.hass.callService("tts", "cloud_say", {
entity_id: target,
message,
});
}
}
private _createAutomation() {
const message = this._messageInput!.value!;
this._message = message;
showAutomationEditor({
action: [
{
service: "tts.cloud_say",
data: {
entity_id: this._target,
message: message,
},
},
],
});
this.closeDialog();
}
private async _playBrowser(message: string, audio: HTMLAudioElement) {
this._loadingExample = true;
const language = this._params!.defaultVoice[0];
const voice = this._params!.defaultVoice[1];
let url;
try {
const result = await convertTextToSpeech(this.hass, {
platform: "cloud",
message,
language,
options: { voice },
});
url = result.path;
} catch (err: any) {
this._loadingExample = false;
showAlertDialog(this, {
text: `Unable to load example. ${err.error || err.body || err}`,
warning: true,
});
return;
}
audio.src = url;
audio.addEventListener("canplaythrough", () => {
audio.play();
});
audio.addEventListener("playing", () => {
this._loadingExample = false;
});
audio.addEventListener("error", () => {
showAlertDialog(this, { title: "Error playing audio." });
this._loadingExample = false;
});
}
static get styles(): CSSResultGroup {
return [
haStyleDialog,
css`
ha-textarea,
ha-select {
display: block;
margin-top: 8px;
width: 100%;
}
`,
];
}
}
declare global {
interface HTMLElementTagNameMap {
"dialog-cloud-try-tts": DialogTryTts;
}
}
@@ -0,0 +1,25 @@
import { fireEvent } from "../../../../common/dom/fire_event";
import type { BackupConfig } from "../../../../data/backup";
import type { CloudStatusLoggedIn } from "../../../../data/cloud";
export interface CloudOnboardingDialogParams {
cloudStatus: CloudStatusLoggedIn;
backupConfig?: BackupConfig;
// Called after each successful change so the opener can refresh the page
// behind the dialog (the dialog keeps its own copy live independently).
onChanged?: () => void;
}
export const loadCloudOnboardingDialog = () =>
import("./dialog-cloud-onboarding");
export const showCloudOnboardingDialog = (
element: HTMLElement,
dialogParams: CloudOnboardingDialogParams
): void => {
fireEvent(element, "show-dialog", {
dialogTag: "dialog-cloud-onboarding",
dialogImport: loadCloudOnboardingDialog,
dialogParams,
});
};
@@ -1,18 +0,0 @@
import { fireEvent } from "../../../../common/dom/fire_event";
export interface TryTtsDialogParams {
defaultVoice: [string, string];
}
export const loadTryTtsDialog = () => import("./dialog-cloud-tts-try");
export const showTryTtsDialog = (
element: HTMLElement,
dialogParams: TryTtsDialogParams
): void => {
fireEvent(element, "show-dialog", {
dialogTag: "dialog-cloud-try-tts",
dialogImport: loadTryTtsDialog,
dialogParams,
});
};
+42 -6
View File
@@ -8,8 +8,20 @@ import type { ValueChangedEvent, HomeAssistant, Route } from "../../../types";
import "./account/cloud-account";
import "./login/cloud-login-panel";
const LOGGED_IN_URLS = ["account", "google-assistant", "alexa"];
const NOT_LOGGED_IN_URLS = ["login", "register", "forgot-password"];
const LOGGED_IN_URLS = [
"account",
"remote",
"backup",
"voice-assistants",
"companion",
"webrtc",
"webhooks",
] as const;
const NOT_LOGGED_IN_URLS = ["login", "register", "forgot-password"] as const;
type CloudPage =
(typeof LOGGED_IN_URLS)[number] | (typeof NOT_LOGGED_IN_URLS)[number];
@customElement("ha-config-cloud")
class HaConfigCloud extends HassRouterPage {
@@ -30,10 +42,10 @@ class HaConfigCloud extends HassRouterPage {
// Guard the different pages based on if we're logged in.
beforeRender: (page: string) => {
if (this.cloudStatus.logged_in) {
if (!LOGGED_IN_URLS.includes(page)) {
if (!LOGGED_IN_URLS.some((url) => url === page)) {
return "account";
}
} else if (!NOT_LOGGED_IN_URLS.includes(page)) {
} else if (!NOT_LOGGED_IN_URLS.some((url) => url === page)) {
return "login";
}
return undefined;
@@ -53,7 +65,31 @@ class HaConfigCloud extends HassRouterPage {
account: {
tag: "cloud-account",
},
},
remote: {
tag: "cloud-remote-pref",
load: () => import("./account/cloud-remote-pref"),
},
backup: {
tag: "cloud-backup-pref",
load: () => import("./account/cloud-backup-pref"),
},
"voice-assistants": {
tag: "cloud-tts-pref",
load: () => import("./account/cloud-tts-pref"),
},
companion: {
tag: "cloud-companion-pref",
load: () => import("./account/cloud-companion-pref"),
},
webrtc: {
tag: "cloud-ice-servers-pref",
load: () => import("./account/cloud-ice-servers-pref"),
},
webhooks: {
tag: "cloud-webhooks",
load: () => import("./account/cloud-webhooks"),
},
} satisfies Record<CloudPage, RouterOptions["routes"][string]>,
};
@state() private _flashMessage = "";
@@ -105,7 +141,7 @@ class HaConfigCloud extends HassRouterPage {
if (
this.cloudStatus &&
!this.cloudStatus.logged_in &&
LOGGED_IN_URLS.includes(this._currentPage)
LOGGED_IN_URLS.some((url) => url === this._currentPage)
) {
return;
}
@@ -107,6 +107,7 @@ class ZHADeviceCard extends SubscribeMixin(LitElement) {
></ha-input>
<ha-area-picker
.device=${this.device.device_reg_id}
.value=${this.device.area_id ?? undefined}
@value-changed=${this._areaPicked}
></ha-area-picker>
</div>
@@ -1,5 +1,5 @@
import { consume, type ContextType } from "@lit/context";
import { mdiOpenInNew } from "@mdi/js";
import { mdiChevronRight } from "@mdi/js";
import type { CSSResultGroup, TemplateResult } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, query, state } from "lit/decorators";
@@ -183,7 +183,7 @@ export class ZHADeviceEndpointList extends LitElement {
? html`
<ha-icon-button
slot="end"
.path=${mdiOpenInNew}
.path=${mdiChevronRight}
.href=${`/config/devices/device/${deviceEndpoint.dev_id}`}
.label=${this._i18n.localize(
"ui.panel.config.zha.groups.open_device"
@@ -211,7 +211,7 @@ export class ZHADeviceEndpointList extends LitElement {
? html`
<ha-icon-button
slot="end"
.path=${mdiOpenInNew}
.path=${mdiChevronRight}
.href=${`/config/devices/device/${deviceEndpoint.dev_id}`}
.label=${this._i18n.localize(
"ui.panel.config.zha.groups.open_device"
@@ -227,10 +227,6 @@ export class ZHAGroupPage extends LitElement {
static get styles(): CSSResultGroup {
return [
css`
hass-subpage {
--app-header-text-color: var(--sidebar-icon-color);
}
.container {
box-sizing: border-box;
max-width: 720px;
@@ -19,7 +19,6 @@ import {
DEFAULT_CREDENTIAL_MIN_LENGTH,
ENTERABLE_ZWAVE_CREDENTIAL_TYPES,
deleteZwaveCredential,
deleteZwaveUser,
enterableCredentialTypes,
getCredentialError,
compatibleUserTypes,
@@ -539,27 +538,16 @@ class DialogZwaveCredentialUserEdit extends DirtyStateProviderMixin<CredentialFo
credentialType: ZwaveCredentialType
): Promise<void> {
const params = this._params!;
const { user_id } = await setZwaveUser(this.hass, params.entity_id, {
user_name: this._supportsUserNames ? this._userName.trim() : undefined,
user_type: this._userType,
active: true,
});
try {
await setZwaveCredential(this.hass, params.entity_id, {
user_id,
await setZwaveUser(this.hass, params.entity_id, {
// Omit user_id to create a new user with the given credential.
user_name: this._supportsUserNames ? this._userName.trim() : undefined,
user_type: this._userType,
active: true,
credential_type: credentialType,
credential_data: this._credentialData,
});
} catch (err: unknown) {
// Roll back the user so the lock returns to its prior state. We
// ignore rollback errors — the credential error is the actionable
// one to surface; a stranded user will reappear on next refresh.
try {
await deleteZwaveUser(this.hass, params.entity_id, user_id);
} catch {
// Ignore.
}
this._error = this.hass.localize(
"ui.panel.config.zwave_js.credentials.errors.add_user_failed",
{
@@ -372,7 +372,7 @@ class ZWaveJSNodeConfig extends LitElement {
return html`${labelAndDescription}
<ha-input
type="number"
.value=${item.value}
.value=${item.value?.toString()}
.min=${item.metadata.min}
.max=${item.metadata.max}
.property=${item.property}
@@ -493,7 +493,7 @@ class ZWaveJSNodeConfig extends LitElement {
if (ev.target === undefined || this._config![ev.target.key] === undefined) {
return;
}
if (this._config![ev.target.key].value === value) {
if (this._config![ev.target.key].value === Number(value)) {
return;
}
this._setResult(ev.target.key, undefined);
@@ -505,8 +505,12 @@ class ZWaveJSNodeConfig extends LitElement {
if (ev.target === undefined || this._config![ev.target.key] === undefined) {
return;
}
const value = Number(ev.target.value);
if (Number(this._config![ev.target.key].value) === value) {
// An empty input must not be coerced to 0 by Number()
const value =
ev.target.value === undefined || ev.target.value === ""
? NaN
: Number(ev.target.value);
if (this._config![ev.target.key].value === value) {
return;
}
if (isNaN(value)) {
@@ -0,0 +1,369 @@
import { ERR_CONNECTION_LOST } from "home-assistant-js-websocket";
import type { CSSResultGroup, PropertyValues } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import type { LocalizeFunc } from "../../../common/translations/localize";
import "../../../components/ha-alert";
import "../../../components/ha-button";
import "../../../components/ha-card";
import "../../../components/ha-form/ha-form";
import type { HaForm } from "../../../components/ha-form/ha-form";
import type { SchemaUnion } from "../../../components/ha-form/types";
import { fetchHttpConfig, saveHttpConfig } from "../../../data/http";
import type { HttpConfig } from "../../../data/http";
import { showConfirmationDialog } from "../../../dialogs/generic/show-dialog-box";
import { haStyle } from "../../../resources/styles";
import type { HomeAssistant } from "../../../types";
const SCHEMA = memoizeOne(
(localize: LocalizeFunc) =>
[
{
name: "server_port",
required: true,
selector: { number: { min: 1, max: 65535, mode: "box" } },
},
{
name: "server_host",
selector: { text: { multiple: true } },
},
{
name: "ssl",
type: "expandable",
flatten: true,
title: localize("ui.panel.config.network.http.sections.ssl"),
schema: [
{
name: "ssl_certificate",
selector: { text: {} },
},
{
name: "ssl_key",
selector: { text: {} },
},
{
name: "ssl_peer_certificate",
selector: { text: {} },
},
{
name: "ssl_profile",
selector: {
select: {
options: [
{
value: "modern",
label: localize(
"ui.panel.config.network.http.ssl_profile_modern"
),
},
{
value: "intermediate",
label: localize(
"ui.panel.config.network.http.ssl_profile_intermediate"
),
},
],
},
},
},
],
},
{
name: "reverse_proxy",
type: "expandable",
flatten: true,
title: localize("ui.panel.config.network.http.sections.reverse_proxy"),
schema: [
{
name: "use_x_forwarded_for",
selector: { boolean: {} },
},
{
name: "trusted_proxies",
selector: { text: { multiple: true } },
},
],
},
{
name: "ip_banning",
type: "expandable",
flatten: true,
title: localize("ui.panel.config.network.http.sections.ip_banning"),
schema: [
{
name: "ip_ban_enabled",
selector: { boolean: {} },
},
{
name: "login_attempts_threshold",
required: true,
selector: { number: { min: -1, max: 1000, mode: "box" } },
},
],
},
{
name: "advanced",
type: "expandable",
flatten: true,
title: localize("ui.panel.config.network.http.sections.advanced"),
schema: [
{
name: "cors_allowed_origins",
selector: { text: { multiple: true } },
},
{
name: "use_x_frame_options",
selector: { boolean: {} },
},
],
},
] as const
);
@customElement("ha-config-http-form")
class HaConfigHttpForm extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@state() private _stable?: HttpConfig;
@state() private _config?: HttpConfig;
@state() private _error?: string;
@state() private _fieldErrors: Record<string, string> = {};
@state() private _saving = false;
@state() private _showNoChanges = false;
@query("ha-form") private _form?: HaForm;
@query("ha-alert") private _firstAlert?: HTMLElement;
private _onConfigResolved = () => this._fetchConfig();
protected override firstUpdated(changedProps: PropertyValues<this>) {
super.firstUpdated(changedProps);
this._fetchConfig();
}
public override connectedCallback() {
super.connectedCallback();
window.addEventListener("http-config-resolved", this._onConfigResolved);
}
public override disconnectedCallback() {
super.disconnectedCallback();
window.removeEventListener("http-config-resolved", this._onConfigResolved);
}
protected render() {
if (!this._stable && !this._error) {
return nothing;
}
const schema = SCHEMA(this.hass.localize);
return html`
<ha-card
outlined
.header=${this.hass.localize("ui.panel.config.network.http.caption")}
>
<div class="card-content">
<p class="description">
${this.hass.localize("ui.panel.config.network.http.description")}
</p>
${
this._error
? html`<ha-alert alert-type="error">${this._error}</ha-alert>`
: nothing
}
${
this._showNoChanges
? html`
<ha-alert alert-type="success">
${this.hass.localize(
"ui.panel.config.network.http.save_no_changes"
)}
</ha-alert>
`
: nothing
}
${
this._config
? html`
<ha-form
.hass=${this.hass}
.data=${this._config}
.schema=${schema}
.error=${this._fieldErrors}
.disabled=${this._saving}
.computeLabel=${this._computeLabel}
.computeHelper=${this._computeHelper}
@value-changed=${this._valueChanged}
></ha-form>
`
: nothing
}
</div>
${
this._config
? html`
<div class="card-actions">
<ha-button
@click=${this._save}
.disabled=${this._saving}
.loading=${this._saving}
>
${this.hass.localize("ui.panel.config.network.http.save")}
</ha-button>
</div>
`
: nothing
}
</ha-card>
`;
}
private async _fetchConfig(): Promise<void> {
try {
// Pending is exclusively handled by the global confirm/revert dialog, so
// the form only ever displays stable.
const { stable } = await fetchHttpConfig(this.hass);
this._stable = stable;
this._config = { ...stable };
} catch (err: any) {
this._error = err.message;
}
}
private _computeLabel = (
schema: SchemaUnion<ReturnType<typeof SCHEMA>>
): string => {
if ("type" in schema && schema.type === "expandable") {
// Expandable sections render their own title; never label them.
return "";
}
return this.hass.localize(
`ui.panel.config.network.http.fields.${schema.name}` as any
);
};
private _computeHelper = (
schema: SchemaUnion<ReturnType<typeof SCHEMA>>
): string => {
if ("type" in schema && schema.type === "expandable") {
return "";
}
return (
this.hass.localize(
`ui.panel.config.network.http.helpers.${schema.name}` as any
) || ""
);
};
private _valueChanged(ev: CustomEvent): void {
this._config = ev.detail.value;
this._error = undefined;
this._fieldErrors = {};
this._showNoChanges = false;
}
private async _save(): Promise<void> {
if (!this._config || !this._stable) {
return;
}
if (this._form && !this._form.reportValidity()) {
return;
}
if (JSON.stringify(this._stable) === JSON.stringify(this._config)) {
this._showNoChanges = true;
return;
}
const confirmed = await showConfirmationDialog(this, {
title: this.hass.localize(
"ui.panel.config.network.http.save_confirm.title"
),
text: this.hass.localize(
"ui.panel.config.network.http.save_confirm.text"
),
confirmText: this.hass.localize(
"ui.panel.config.network.http.save_confirm.confirm"
),
});
if (!confirmed) {
return;
}
this._saving = true;
this._error = undefined;
this._fieldErrors = {};
this._showNoChanges = false;
try {
const result = await saveHttpConfig(this.hass, this._config);
if (!result.restart) {
this._showNoChanges = true;
}
// restart === true: a restart is in flight. The reply usually races with
// the connection drop; if we do reach this branch, the disconnected
// overlay will appear in moments. Leave the form as is.
} catch (err: any) {
// The restart kills the WS connection before the ack — that's expected.
if (
err?.error?.code === ERR_CONNECTION_LOST ||
err === ERR_CONNECTION_LOST
) {
return;
}
// voluptuous formats errors as "<message> @ data['<field>']".
// If a field is identified, mark it inline; otherwise show a card-level
// alert.
const fieldMatch = err.message?.match(/\bdata\['([^']+)'\]/);
if (fieldMatch) {
this._fieldErrors = { [fieldMatch[1]]: err.message };
} else {
this._error = err.message;
}
} finally {
this._saving = false;
}
await this.updateComplete;
await this._form?.updateComplete;
// Inline field errors render inside ha-form's shadow root, so fall back to
// it when no top-level alert is present.
const target =
this._firstAlert ??
this._form?.shadowRoot?.querySelector<HTMLElement>("ha-alert");
target?.scrollIntoView({ behavior: "smooth", block: "center" });
}
static get styles(): CSSResultGroup {
return [
haStyle,
css`
.description {
margin-top: 0;
color: var(--secondary-text-color);
}
ha-alert {
display: block;
margin-bottom: var(--ha-space-4);
}
.card-actions {
display: flex;
gap: var(--ha-space-2);
justify-content: flex-end;
}
`,
];
}
}
declare global {
interface HTMLElementTagNameMap {
"ha-config-http-form": HaConfigHttpForm;
}
}

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