Compare commits

...

83 Commits

Author SHA1 Message Date
Aidan Timson ef22539175 Remove hass from clock card editor 2026-07-17 14:22:42 +01:00
Aidan Timson 7346e9408a Use conditional visibility in clock card editor 2026-07-17 14:18:09 +01:00
Paul Bottein 6b0f543fdb Add conditional field visibility to ha-form (#53165) 2026-07-17 14:06:04 +01:00
Bram Kragten bbd26ce5a9 Improve messaging and error handling http config (#53126)
Co-authored-by: Stefan Agner <stefan@agner.ch>
2026-07-17 14:38:25 +02:00
pcan08 d3c23dd704 Limit clock card date picker to one value per group (#53160) 2026-07-17 08:53:28 +01:00
Paulus Schoutsen 7d4e5dc03f Use /store/reload instead of legacy /addons/reload alias when checking for add-on updates (#53162)
Use /store/reload instead of legacy /addons/reload alias

Supervisor's /addons/reload is a backwards-compat alias for
/store/reload. Core now refreshes the add-on update entities after a
/store/reload call proxied through the supervisor/api websocket command,
so use the canonical endpoint.


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

Co-authored-by: Claude <noreply@anthropic.com>
2026-07-17 03:45:51 -04:00
renovate[bot] db22be5a30 Update dependency typescript-eslint to v8.64.0 (#53164)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-07-17 09:44:24 +02:00
Simon Lamon b896b20409 Output different url for demo deployment (#53158)
Output different url
2026-07-17 09:38:28 +02:00
renovate[bot] cb3b528ba3 Update dependency fuse.js to v7.5.0 (#53157) 2026-07-17 08:37:35 +01:00
karwosts 2ed8e0bd0d Add activity-panel CSV download (#53154) 2026-07-17 08:31:55 +01:00
renovate[bot] adc3393422 Update dependency @lokalise/node-api to v16.1.0 (#53156)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-07-16 18:11:34 +00:00
TowyTowy 06b9b08809 Skip custom rows in entities/glance card format migration (#53074)
* Skip custom rows in entities/glance card format migration

The 2026.7.0 `format` -> `time_format` migration in the entities and
glance cards was applied to every row, including `type: custom:...` rows.
Custom rows own their config schema and some use `format` with unrelated
semantics (e.g. custom:multiple-entity-row uses `format: precision1`), so
rewriting `format` to `time_format` silently drops the option and breaks
the row's rendering.

Skip rows whose type starts with `custom:` so their config is left
untouched, matching how the frontend treats custom card/row configs as
opaque elsewhere.

Fixes #52935

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

* Extract entities/glance format migration into a testable module

Move `migrateEntitiesCardConfig` and `migrateGlanceCardConfig` out of the
card element modules into a standalone `migrate-card-config.ts` so they can
be imported by unit tests without pulling in the element modules'
build-time globals. Both card modules re-export the functions, so existing
importers (the card editors) are unaffected.

Add vitest coverage for the migration: native `format` -> `time_format`
migration, custom rows left untouched, unchanged configs returned by
identity, string rows passed through, and a pre-existing `time_format`
taking precedence over the legacy `format`.

Drop the custom-row guard from the glance migration. The glance card
renders each entity directly (name/icon/state) and never instantiates
custom rows via createRowElement, so a `custom:` type on a glance entity is
inert and the guard was dead code. The entities card does render custom
rows, so its guard stays.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-16 13:48:44 +00:00
Bruno Pantaleão Gonçalves ece84d42dc Send frontend/loaded event to companion apps when launch screen is removed (#53150) 2026-07-16 15:22:38 +02:00
renovate[bot] e031e59b31 Update dependency barcode-detector to v3.2.1 (#53144)
Co-authored-by: Aidan Timson <aidan@timmo.dev>
2026-07-16 09:48:13 +01:00
Paul Bottein 5bdccd2e4a Extract repeated indexed lookups into local variables (#53143) 2026-07-16 08:40:04 +01:00
Aidan Timson be27615851 Fix E2E theme scenario preferences (#53142)
* Fix E2E theme scenario preferences

* Clarify mock theme selection
2026-07-15 18:02:52 +02:00
Paul Bottein 48f3d45a2e Fix history graph editor crash on unavailable entity (#53141) 2026-07-15 15:48:02 +01:00
Aidan Timson f2a049b7c6 Install TypeScript 7 alongside TypeScript 6 API for tooling (#53122)
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
2026-07-15 15:22:31 +01:00
Aidan Timson 1e691f0b64 Add date to clock card (#28866)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-07-15 15:22:21 +01:00
Aidan Timson 23d7f4772a Migrate demo theme settings storage (#53139) 2026-07-15 15:47:16 +02:00
Paul Bottein bd23592e77 Fix save button not enabling on whitespace-only entity name changes (#53138) 2026-07-15 14:34:21 +01:00
Stefan Agner 0fe80ef5f9 Remove legacy Supervisor REST API fallbacks from data layer (#53131)
* Remove legacy Supervisor REST fallbacks from data layer

The WebSocket proxy command (supervisor/api) was added in Core 2021.2.4
and the REST fallbacks only existed because the Supervisor panel used to
ship with Supervisor itself and could run against older Core versions.
Since the panel moved into the main frontend, the frontend is
version-locked with Core and these fallback paths are unreachable.

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

* Update create session test for WebSocket-only path

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

* Fix ts-ignore placement in create session test

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

* Use Pick<HomeAssistant, "callWS"> for ingress session functions

Narrows the hass parameter so test mocks typecheck without ts-ignore.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-15 13:23:32 +02:00
Aidan Timson 4327871c30 Add clear/reset action to Activity panel (#53137) 2026-07-15 11:56:24 +01:00
Bruno Pantaleão Gonçalves 88381e0b11 Revert "Add App settings entry to dashboard more menu" (#53135) 2026-07-15 08:11:33 +00:00
Bruno Pantaleão Gonçalves f44590a2aa Add App settings entry to dashboard more menu (#53130) 2026-07-15 08:39:52 +01:00
Bram Kragten 1c03daac41 Add safe area handling and opt-out to custom panels and apps (#53127) 2026-07-15 08:35:54 +01:00
Yosi Levy a2216c6bf5 Minor RTL fix (#53132) 2026-07-15 08:33:08 +01:00
renovate[bot] 4a755e00ac Update dependency tar to v7.5.20 (#53133) 2026-07-15 08:28:09 +01:00
Aidan Timson 36a9da37f8 Update FormatJS dependencies (#53121) 2026-07-14 12:31:09 +02:00
renovate[bot] cee5361525 Update dependency eslint to v10.7.0 (#53116)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-07-14 07:12:03 +02: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
211 changed files with 17605 additions and 4242 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"
+23
View File
@@ -0,0 +1,23 @@
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"
runs:
using: composite
steps:
- name: Setup Node
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version-file: ".nvmrc"
cache: ${{ inputs.cache == 'true' && 'yarn' || '' }}
- name: Install dependencies
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 }}
+11 -25
View File
@@ -29,13 +29,8 @@ jobs:
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
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: Check for duplicate dependencies
run: yarn dedupe --check
- name: Build resources
@@ -74,13 +69,8 @@ jobs:
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
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 resources
run: ./node_modules/.bin/gulp gen-icons-json build-translations build-locale-data
env:
@@ -98,18 +88,14 @@ jobs:
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
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 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"
+23 -35
View File
@@ -22,7 +22,7 @@ jobs:
if: github.event_name != 'push' || github.ref_name != 'master'
environment:
name: Demo Development
url: ${{ steps.deploy.outputs.netlify_url }}
url: ${{ steps.deploy.outputs.unique_deploy_url }}
steps:
- name: Check out files from GitHub
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
@@ -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"
+22 -49
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.
@@ -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
+3 -7
View File
@@ -29,14 +29,10 @@ jobs:
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
+9 -12
View File
@@ -38,13 +38,11 @@ jobs:
- 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
+1 -1
View File
@@ -29,7 +29,7 @@ const LICENSE_OVERRIDES = [
// type-fest ships two license files (MIT for code, CC0 for types).
// We use the MIT license since that covers the bundled code.
packageName: "type-fest",
version: "5.7.0",
version: "5.8.0",
licenseFile: "license-mit",
},
];
+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;
}
}
+11 -2
View File
@@ -1,7 +1,16 @@
import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
import type { Lovelace } from "../../../src/panels/lovelace/types";
import { energyEntities } from "../stubs/entities";
import type { DemoConfig } from "./types";
import { getDemoTheme } from "../stubs/frontend";
import type { DemoConfig, DemoTheme } from "./types";
export const applyDemoTheme = (hass: MockHomeAssistant, theme: DemoTheme) => {
if (typeof theme === "function") {
hass.mockTheme(theme());
return;
}
hass.mockTheme(null, getDemoTheme(theme));
};
export const demoConfigs: (() => Promise<DemoConfig>)[] = [
() => import("./sections").then((mod) => mod.demoSections),
@@ -31,5 +40,5 @@ export const setDemoConfig = async (
hass.addEntities(config.entities(hass.localize), true);
hass.addEntities(energyEntities());
lovelace.saveConfig(config.lovelace(hass.localize));
hass.mockTheme(config.theme());
applyDemoTheme(hass, config.theme);
};
+1 -1
View File
@@ -8,5 +8,5 @@ export const demoSections: DemoConfig = {
name: "Home Demo",
lovelace: demoLovelaceSections,
entities: demoEntitiesSections,
theme: () => ({}),
theme: { theme: "default", dark: false },
};
+4 -1
View File
@@ -2,6 +2,9 @@ import type { TemplateResult } from "lit";
import type { LocalizeFunc } from "../../../src/common/translations/localize";
import type { LovelaceConfig } from "../../../src/data/lovelace/config/types";
import type { EntityInput } from "../../../src/fake_data/entities/types";
import type { ThemeSettings } from "../../../src/types";
export type DemoTheme = ThemeSettings | (() => Record<string, string> | null);
export interface DemoConfig {
index?: number;
@@ -12,5 +15,5 @@ export interface DemoConfig {
string | ((localize: LocalizeFunc) => string | TemplateResult<1>);
lovelace: (localize: LocalizeFunc) => LovelaceConfig;
entities: (localize: LocalizeFunc) => EntityInput[];
theme: () => Record<string, string> | null;
theme: DemoTheme;
}
+20 -4
View File
@@ -5,7 +5,7 @@ import type { MockHomeAssistant } from "../../src/fake_data/provide_hass";
import { provideHass } from "../../src/fake_data/provide_hass";
import { HomeAssistantAppEl } from "../../src/layouts/home-assistant";
import type { HomeAssistant } from "../../src/types";
import { selectedDemoConfig } from "./configs/demo-configs";
import { applyDemoTheme, selectedDemoConfig } from "./configs/demo-configs";
import { mockAreaRegistry } from "./stubs/area_registry";
import { mockAuth } from "./stubs/auth";
import { demoDevices } from "./stubs/devices";
@@ -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(
@@ -155,9 +173,7 @@ export class HaDemo extends HomeAssistantAppEl {
Promise.all([selectedDemoConfig, localizePromise]).then(
([conf, localize]) => {
hass.addEntities(conf.entities(localize));
if (conf.theme) {
hass.mockTheme(conf.theme());
}
applyDemoTheme(hass, conf.theme);
}
);
+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;
});
+38 -3
View File
@@ -1,10 +1,37 @@
import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
import type { ThemeSettings } from "../../../src/types";
let sidebarChangeCallback;
let sidebarChangeCallback: ((data: { value: unknown }) => void) | undefined;
let themeChangeCallback: ((data: { value: ThemeSettings }) => void) | undefined;
const THEME_STORAGE_KEY = "demo_theme";
const DEFAULT_THEME: ThemeSettings = { theme: "default", dark: false };
export const getDemoTheme = (
fallback: ThemeSettings = DEFAULT_THEME
): ThemeSettings => {
const storedTheme = localStorage.getItem(THEME_STORAGE_KEY);
if (!storedTheme) {
return fallback;
}
try {
return JSON.parse(storedTheme) as ThemeSettings;
} catch {
localStorage.removeItem(THEME_STORAGE_KEY);
return fallback;
}
};
export const mockFrontend = (hass: MockHomeAssistant) => {
hass.mockWS("frontend/get_user_data", () => ({ value: null }));
hass.mockWS("frontend/get_user_data", ({ key }) => ({
value: key === "theme" ? getDemoTheme() : null,
}));
hass.mockWS("frontend/set_user_data", ({ key, value }) => {
if (key === "theme") {
localStorage.setItem(THEME_STORAGE_KEY, JSON.stringify(value));
themeChangeCallback?.({ value });
localStorage.removeItem("selectedTheme");
}
if (key === "sidebar") {
sidebarChangeCallback?.({
value: {
@@ -14,10 +41,18 @@ export const mockFrontend = (hass: MockHomeAssistant) => {
});
}
});
hass.mockWS("frontend/subscribe_user_data", (msg, _hass, onChange) => {
hass.mockWS("frontend/subscribe_user_data", (msg, currentHass, onChange) => {
if (msg.key === "sidebar") {
sidebarChangeCallback = onChange;
}
if (msg.key === "theme") {
themeChangeCallback = onChange;
onChange?.({
value: getDemoTheme(currentHass.selectedTheme ?? undefined),
});
// eslint-disable-next-line @typescript-eslint/no-empty-function
return () => {};
}
onChange?.({ value: null });
// eslint-disable-next-line @typescript-eslint/no-empty-function
return () => {};
+31 -30
View File
@@ -12,7 +12,7 @@
"format:eslint": "eslint \"**/src/**/*.{js,ts,html}\" --cache --cache-strategy=content --cache-location=node_modules/.cache/eslint/.eslintcache --ignore-pattern=.gitignore --fix",
"lint:prettier": "prettier . --cache --check",
"format:prettier": "prettier . --cache --write",
"lint:types": "tsc",
"lint:types": "node ./node_modules/@typescript/native/bin/tsc",
"lint:lit": "lit-analyzer \"{.,*}/src/**/*.ts\"",
"lint:licenses": "node --no-deprecation script/check-licenses",
"lint": "yarn run lint:eslint && yarn run lint:prettier && yarn run lint:types && yarn run lint:lit",
@@ -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-getcanonicallocales": "3.2.10",
"@formatjs/intl-listformat": "8.3.10",
"@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-datetimeformat": "7.5.0",
"@formatjs/intl-displaynames": "7.3.12",
"@formatjs/intl-durationformat": "0.10.17",
"@formatjs/intl-getcanonicallocales": "3.2.11",
"@formatjs/intl-listformat": "8.3.12",
"@formatjs/intl-locale": "5.3.10",
"@formatjs/intl-numberformat": "9.3.13",
"@formatjs/intl-pluralrules": "6.3.12",
"@formatjs/intl-relativetimeformat": "12.3.12",
"@fullcalendar/core": "6.1.21",
"@fullcalendar/daygrid": "6.1.21",
"@fullcalendar/interaction": "6.1.21",
@@ -83,13 +83,13 @@
"@replit/codemirror-indentation-markers": "6.5.3",
"@swc/helpers": "0.5.23",
"@thomasloven/round-slider": "0.6.0",
"@tsparticles/engine": "4.3.1",
"@tsparticles/preset-links": "4.3.1",
"@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",
"@webcomponents/webcomponentsjs": "2.8.0",
"barcode-detector": "3.2.0",
"barcode-detector": "3.2.1",
"cally": "0.9.2",
"color-name": "2.1.0",
"comlink": "4.4.2",
@@ -102,12 +102,12 @@
"dialog-polyfill": "0.5.6",
"echarts": "6.1.0",
"element-internals-polyfill": "3.0.2",
"fuse.js": "7.4.2",
"fuse.js": "7.5.0",
"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",
"idb-keyval": "6.3.0",
"intl-messageformat": "11.2.11",
"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",
@@ -115,7 +115,7 @@
"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",
"@lokalise/node-api": "16.0.0",
"@html-eslint/eslint-plugin": "0.64.0",
"@lokalise/node-api": "16.1.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,12 +168,13 @@
"@types/qrcode": "1.5.6",
"@types/sortablejs": "1.15.9",
"@types/tar": "7.0.87",
"@vitest/coverage-v8": "4.1.9",
"@typescript/native": "npm:typescript@7.0.2",
"@vitest/coverage-v8": "4.1.10",
"babel-loader": "10.1.1",
"babel-plugin-polyfill-corejs3": "1.0.0",
"browserslist-useragent-regexp": "4.1.4",
"del": "8.0.1",
"eslint": "10.6.0",
"eslint": "10.7.0",
"eslint-config-prettier": "10.1.8",
"eslint-import-resolver-webpack": "0.13.11",
"eslint-plugin-import-x": "4.17.1",
@@ -202,17 +203,17 @@
"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",
"tar": "7.5.19",
"tar": "7.5.20",
"terser-webpack-plugin": "5.6.1",
"ts-lit-plugin": "2.0.2",
"typescript": "6.0.3",
"typescript-eslint": "8.62.1",
"typescript-eslint": "8.64.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 +229,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>(
+3 -2
View File
@@ -51,8 +51,9 @@ class StorageClass {
storageKey: string,
callback: Callback
): UnsubscribeFunc {
if (this._listeners[storageKey]) {
this._listeners[storageKey].push(callback);
const listeners = this._listeners[storageKey];
if (listeners) {
listeners.push(callback);
} else {
this._listeners[storageKey] = [callback];
}
+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
+52 -3
View File
@@ -1,4 +1,53 @@
const regexp =
/^(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$/;
// Unanchored regex fragments, shared as the single source of truth for both
// the boolean validators below and the HTML `pattern` attribute (the browser
// anchors a pattern as `^(?:…)$`).
const IPV4 =
"(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)";
export const isIPAddress = (input: string): boolean => regexp.test(input);
const IPV6 =
"(?:([0-9a-fA-F]{1,4}:){7}[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,7}:|([0-9a-fA-F]{1,4}:){1,6}:[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,5}(:[0-9a-fA-F]{1,4}){1,2}|([0-9a-fA-F]{1,4}:){1,4}(:[0-9a-fA-F]{1,4}){1,3}|([0-9a-fA-F]{1,4}:){1,3}(:[0-9a-fA-F]{1,4}){1,4}|([0-9a-fA-F]{1,4}:){1,2}(:[0-9a-fA-F]{1,4}){1,5}|[0-9a-fA-F]{1,4}:((:[0-9a-fA-F]{1,4}){1,6})|:((:[0-9a-fA-F]{1,4}){1,7}|:)|::(ffff(:0{1,4})?:)?((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\\.){3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])|([0-9a-fA-F]{1,4}:){1,4}:((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\\.){3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9]))";
// CIDR prefix lengths: 0-32 for IPv4, 0-128 for IPv6.
const IPV4_PREFIX = "(?:3[0-2]|[12]?[0-9])";
const IPV6_PREFIX = "(?:12[0-8]|1[01][0-9]|[1-9]?[0-9])";
// IPv4 or IPv6 address.
export const IP_ADDRESS_PATTERN = `${IPV4}|${IPV6}`;
// IPv4/IPv6 address, optionally with a CIDR prefix (network).
export const IP_ADDRESS_OR_NETWORK_PATTERN = `${IPV4}(?:/${IPV4_PREFIX})?|${IPV6}(?:/${IPV6_PREFIX})?`;
const anchored = (pattern: string): RegExp => new RegExp(`^(?:${pattern})$`);
const ipv4Regexp = anchored(IPV4);
const ipv6Regexp = anchored(IPV6);
// IPv4 address, e.g. 192.168.1.10
export const isIPAddress = (input: string): boolean => ipv4Regexp.test(input);
// IPv6 address, e.g. fe80::85d:e82c:9446:7995
export const isIPv6Address = (input: string): boolean => ipv6Regexp.test(input);
// IPv4 or IPv6 address
export const isIPAddressV4OrV6 = (input: string): boolean =>
isIPAddress(input) || isIPv6Address(input);
// IP network in CIDR notation, e.g. 192.168.1.0/24 or fd00::/8
export const isIPNetwork = (input: string): boolean => {
const parts = input.split("/");
if (parts.length !== 2) {
return false;
}
const [address, prefix] = parts;
if (!/^\d{1,3}$/.test(prefix)) {
return false;
}
const prefixLength = Number(prefix);
if (isIPAddress(address)) {
return prefixLength >= 0 && prefixLength <= 32;
}
if (isIPv6Address(address)) {
return prefixLength >= 0 && prefixLength <= 128;
}
return false;
};
+4 -5
View File
@@ -33,13 +33,12 @@ const extractVarFromBase = (
varName: string,
baseVars: Record<string, string>
): string | undefined => {
if (baseVars[varName] && baseVars[varName].startsWith("var(")) {
const baseVarName = baseVars[varName]
.substring(6, baseVars[varName].length - 1)
.trim();
const value = baseVars[varName];
if (value && value.startsWith("var(")) {
const baseVarName = value.substring(6, value.length - 1).trim();
return extractVarFromBase(baseVarName, baseVars);
}
return baseVars[varName];
return value;
};
/**
+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),
};
}
@@ -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";
@@ -112,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 {
@@ -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}`
@@ -0,0 +1,579 @@
import { consume, type ContextType } from "@lit/context";
import { mdiDragHorizontalVariant, mdiPlus } from "@mdi/js";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import { repeat } from "lit/directives/repeat";
import memoizeOne from "memoize-one";
import { ensureArray } from "../common/array/ensure-array";
import { resolveTimeZone } from "../common/datetime/resolve-time-zone";
import { fireEvent } from "../common/dom/fire_event";
import { configContext, internationalizationContext } from "../data/context";
import {
CLOCK_CARD_DATE_PARTS,
formatClockCardDate,
} from "../panels/lovelace/cards/clock/clock-date-format";
import type { ClockCardDatePart } from "../panels/lovelace/cards/types";
import type { HomeAssistant, ValueChangedEvent } from "../types";
import "./chips/ha-assist-chip";
import "./chips/ha-chip-set";
import "./chips/ha-input-chip";
import "./ha-generic-picker";
import type { HaGenericPicker } from "./ha-generic-picker";
import "./ha-input-helper-text";
import type { PickerComboBoxItem } from "./ha-picker-combo-box";
import "./ha-sortable";
type ClockDatePartSection = "weekday" | "day" | "month" | "year" | "separator";
type ClockDateSeparatorPart = Extract<
ClockCardDatePart,
"separator-dash" | "separator-slash" | "separator-dot" | "separator-new-line"
>;
const CLOCK_DATE_PART_SECTION_ORDER: readonly ClockDatePartSection[] = [
"day",
"month",
"year",
"weekday",
"separator",
];
const CLOCK_DATE_SEPARATOR_VALUES: Record<ClockDateSeparatorPart, string> = {
"separator-dash": "-",
"separator-slash": "/",
"separator-dot": ".",
"separator-new-line": "",
};
const getClockDatePartSection = (
part: ClockCardDatePart
): ClockDatePartSection => {
if (part.startsWith("weekday-")) {
return "weekday";
}
if (part.startsWith("day-")) {
return "day";
}
if (part.startsWith("month-")) {
return "month";
}
if (part.startsWith("year-")) {
return "year";
}
return "separator";
};
interface ClockDatePartSectionData {
id: ClockDatePartSection;
title: string;
items: PickerComboBoxItem[];
}
/**
* Filters out sections whose group already has a value in `value`, so only
* one non-separator value per group can be selected in the picker. The whole
* group of the item at `excludeIndex` (the one being edited) stays
* selectable, even if that group has more than one value in `value` (e.g. a
* pre-existing config authored outside the picker via YAML).
* The separator group is always kept.
*/
export const getAvailableClockDatePartSections = (
sections: ClockDatePartSectionData[],
value: string[],
excludeIndex?: number
): ClockDatePartSectionData[] => {
const editedItem = excludeIndex != null ? value[excludeIndex] : undefined;
const editedSection = editedItem
? getClockDatePartSection(editedItem as ClockCardDatePart)
: undefined;
const usedSections = new Set<ClockDatePartSection>();
value.forEach((item) => {
const section = getClockDatePartSection(item as ClockCardDatePart);
if (section !== "separator" && section !== editedSection) {
usedSections.add(section);
}
});
return sections.filter(
(sectionData) =>
sectionData.id === "separator" || !usedSections.has(sectionData.id)
);
};
interface ClockDatePartValueItem {
key: string;
item: string;
idx: number;
}
@customElement("ha-clock-date-format-picker")
export class HaClockDateFormatPicker extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ type: Boolean, reflect: true }) public disabled = false;
@property({ type: Boolean }) public required = false;
@property() public label?: string;
@property() public value?: string[] | string;
@property() public helper?: string;
@state()
@consume({ context: internationalizationContext, subscribe: true })
private _i18n!: ContextType<typeof internationalizationContext>;
@state()
@consume({ context: configContext, subscribe: true })
private _hassConfig!: ContextType<typeof configContext>;
@query("ha-generic-picker", true) private _picker?: HaGenericPicker;
@state() private _editIndex?: number;
protected render() {
const value = this._value;
const valueItems = this._getValueItems(value);
const sections = this._buildSections();
const pickerSections = getAvailableClockDatePartSections(
sections,
value,
this._editIndex
);
return html`
${this.label ? html`<label>${this.label}</label>` : nothing}
<ha-generic-picker
.hass=${this.hass}
.disabled=${this.disabled}
.required=${this.required && !value.length}
.value=${this._getPickerValue()}
.sections=${this._getSectionHeaders(pickerSections)}
.getItems=${this._getItems(pickerSections)}
@value-changed=${this._pickerValueChanged}
>
<div slot="field" class="container">
<ha-sortable
no-style
@item-moved=${this._moveItem}
.disabled=${this.disabled}
handle-selector="button.primary.action"
filter=".add"
>
<ha-chip-set>
${repeat(
valueItems,
(entry: ClockDatePartValueItem) => entry.key,
({ item, idx }) => this._renderValueChip(item, idx, sections)
)}
${
this.disabled
? nothing
: html`
<ha-assist-chip
@click=${this._addItem}
.disabled=${this.disabled}
label=${this._i18n.localize("ui.common.add")}
class="add"
>
<ha-svg-icon slot="icon" .path=${mdiPlus}></ha-svg-icon>
</ha-assist-chip>
`
}
</ha-chip-set>
</ha-sortable>
</div>
</ha-generic-picker>
${this._renderHelper()}
`;
}
private _renderHelper() {
return this.helper
? html`
<ha-input-helper-text .disabled=${this.disabled}>
${this.helper}
</ha-input-helper-text>
`
: nothing;
}
private _getValueItems = memoizeOne(
(value: string[]): ClockDatePartValueItem[] => {
const occurrences = new Map<string, number>();
return value.map((item, idx) => {
const occurrence = occurrences.get(item) ?? 0;
occurrences.set(item, occurrence + 1);
return {
key: `${item}:${occurrence}`,
item,
idx,
};
});
}
);
private _renderValueChip(
item: string,
idx: number,
sections: ClockDatePartSectionData[]
) {
const label = this._getItemLabel(item, sections);
const isValid = !!label;
return html`
<ha-input-chip
data-idx=${idx}
@remove=${this._removeItem}
@click=${this._editItem}
.label=${label ?? item}
.selected=${!this.disabled}
.disabled=${this.disabled}
class=${!isValid ? "invalid" : ""}
>
<ha-svg-icon
slot="icon"
.path=${mdiDragHorizontalVariant}
></ha-svg-icon>
</ha-input-chip>
`;
}
private async _addItem(ev: Event) {
ev.stopPropagation();
if (this.disabled) {
return;
}
this._editIndex = undefined;
await this.updateComplete;
await this._picker?.open();
}
private async _editItem(ev: Event) {
ev.stopPropagation();
if (this.disabled) {
return;
}
const idx = parseInt(
(ev.currentTarget as HTMLElement).dataset.idx ?? "",
10
);
this._editIndex = idx;
await this.updateComplete;
await this._picker?.open();
}
private get _value() {
return !this.value ? [] : ensureArray(this.value);
}
private _toValue = memoizeOne((value: string[]): string[] | undefined =>
value.length === 0 ? undefined : value
);
private _buildSections(): ClockDatePartSectionData[] {
const itemsBySection: Record<ClockDatePartSection, PickerComboBoxItem[]> = {
weekday: [],
day: [],
month: [],
year: [],
separator: [],
};
const previewDate = new Date();
const previewTimeZone = resolveTimeZone(
this._i18n.locale.time_zone,
this._hassConfig.config.time_zone
);
CLOCK_CARD_DATE_PARTS.forEach((part) => {
const section = getClockDatePartSection(part);
const label =
this._i18n.localize(
`ui.panel.lovelace.editor.card.clock.date.parts.${part}`
) ?? part;
const secondary =
section === "separator"
? CLOCK_DATE_SEPARATOR_VALUES[part as ClockDateSeparatorPart]
: formatClockCardDate(
previewDate,
{ parts: [part] },
this._i18n.locale.language,
previewTimeZone
);
itemsBySection[section].push({
id: part,
primary: label,
secondary,
sorting_label: label,
});
});
return CLOCK_DATE_PART_SECTION_ORDER.map((section) => ({
id: section,
title:
this._i18n.localize(
`ui.panel.lovelace.editor.card.clock.date.sections.${section}`
) ?? section,
items: itemsBySection[section],
})).filter((section) => section.items.length > 0);
}
private _getSectionHeaders(
sections: ClockDatePartSectionData[]
): { id: string; label: string }[] {
return sections.map((section) => ({
id: section.id,
label: section.title,
}));
}
private _getItems = memoizeOne(
(sections: ClockDatePartSectionData[]) =>
(
searchString?: string,
section?: string
): (PickerComboBoxItem | string)[] => {
const normalizedSearch = searchString?.trim().toLowerCase();
const filteredSections = sections
.map((sectionData) => {
if (!normalizedSearch) {
return sectionData;
}
return {
...sectionData,
items: sectionData.items.filter(
(item) =>
item.primary.toLowerCase().includes(normalizedSearch) ||
item.secondary?.toLowerCase().includes(normalizedSearch) ||
item.id.toLowerCase().includes(normalizedSearch)
),
};
})
.filter((sectionData) => sectionData.items.length > 0);
if (section) {
return (
filteredSections.find((candidate) => candidate.id === section)
?.items || []
);
}
const groupedItems: (PickerComboBoxItem | string)[] = [];
filteredSections.forEach((sectionData) => {
groupedItems.push(sectionData.title, ...sectionData.items);
});
return groupedItems;
}
);
private _getItemLabel(
value: string,
sections: ClockDatePartSectionData[]
): string | undefined {
for (const section of sections) {
const item = section.items.find((candidate) => candidate.id === value);
if (item) {
if (section.id === "separator") {
if (value === "separator-new-line") {
return item.primary;
}
return item.secondary ?? item.primary;
}
return `${item.secondary} [${item.primary} ${section.title}]`;
}
}
return undefined;
}
private _getPickerValue(): string | undefined {
if (this._editIndex != null) {
return this._value[this._editIndex];
}
return undefined;
}
private async _moveItem(ev: CustomEvent) {
ev.stopPropagation();
const { oldIndex, newIndex } = ev.detail;
const value = this._value;
const newValue = value.concat();
const element = newValue.splice(oldIndex, 1)[0];
newValue.splice(newIndex, 0, element);
this._setValue(newValue);
}
private async _removeItem(ev: Event) {
ev.preventDefault();
ev.stopPropagation();
const idx = parseInt(
(ev.currentTarget as HTMLElement).dataset.idx ?? "",
10
);
if (Number.isNaN(idx)) {
return;
}
const value = [...this._value];
value.splice(idx, 1);
if (this._editIndex !== undefined) {
if (this._editIndex === idx) {
this._editIndex = undefined;
} else if (this._editIndex > idx) {
this._editIndex -= 1;
}
}
this._setValue(value);
}
private _pickerValueChanged(ev: ValueChangedEvent<string>): void {
ev.stopPropagation();
const value = ev.detail.value;
if (this.disabled || !value) {
return;
}
const newValue = [...this._value];
if (this._editIndex != null) {
newValue[this._editIndex] = value;
this._editIndex = undefined;
} else {
newValue.push(value);
}
this._setValue(newValue);
if (this._picker) {
this._picker.value = undefined;
}
}
private _setValue(value: string[]) {
const newValue = this._toValue(value);
this.value = newValue;
fireEvent(this, "value-changed", {
value: newValue,
});
}
static styles = css`
:host {
position: relative;
width: 100%;
}
.container {
position: relative;
background-color: var(--mdc-text-field-fill-color, whitesmoke);
border-radius: var(--ha-border-radius-sm);
border-end-end-radius: var(--ha-border-radius-square);
border-end-start-radius: var(--ha-border-radius-square);
}
.container:after {
display: block;
content: "";
position: absolute;
pointer-events: none;
bottom: 0;
left: 0;
right: 0;
height: 1px;
width: 100%;
background-color: var(
--mdc-text-field-idle-line-color,
rgba(0, 0, 0, 0.42)
);
transition:
height 180ms ease-in-out,
background-color 180ms ease-in-out;
}
:host([disabled]) .container:after {
background-color: var(
--mdc-text-field-disabled-line-color,
rgba(0, 0, 0, 0.42)
);
}
.container:focus-within:after {
height: 2px;
background-color: var(--mdc-theme-primary);
}
label {
display: block;
margin: 0 0 var(--ha-space-2);
}
.add {
order: 1;
}
ha-chip-set {
padding: var(--ha-space-2);
}
.invalid {
text-decoration: line-through;
}
.sortable-fallback {
display: none;
opacity: 0;
}
.sortable-ghost {
opacity: 0.4;
}
.sortable-drag {
cursor: grabbing;
}
ha-input-helper-text {
display: block;
margin: var(--ha-space-2) 0 0;
}
`;
}
declare global {
interface HTMLElementTagNameMap {
"ha-clock-date-format-picker": HaClockDateFormatPicker;
}
}
+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;
+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() {
+73
View File
@@ -0,0 +1,73 @@
import type {
HaFormBaseSchema,
HaFormCondition,
HaFormDataContainer,
HaFormFieldCondition,
HaFormSchema,
} from "./types";
const isEmpty = (value: unknown): boolean =>
value === undefined || value === null || value === "";
const matchFieldCondition = (
condition: HaFormFieldCondition,
data: HaFormDataContainer | undefined
): boolean => {
const actual = data?.[condition.field];
switch (condition.operator ?? "eq") {
case "eq":
return actual === condition.value;
case "not_eq":
return actual !== condition.value;
case "in":
return (
Array.isArray(condition.value) &&
condition.value.includes(actual as any)
);
case "not_in":
return (
Array.isArray(condition.value) &&
!condition.value.includes(actual as any)
);
case "exists":
return !isEmpty(actual);
case "not_exists":
return isEmpty(actual);
default:
return false;
}
};
export const evaluateCondition = (
condition: HaFormCondition,
data: HaFormDataContainer | undefined
): boolean => {
if ("condition" in condition) {
switch (condition.condition) {
case "and":
return condition.conditions.every((c) => evaluateCondition(c, data));
case "or":
return condition.conditions.some((c) => evaluateCondition(c, data));
case "not":
return !condition.conditions.some((c) => evaluateCondition(c, data));
default:
return false;
}
}
return matchFieldCondition(condition, data);
};
export const isFieldHidden = (
schema: HaFormSchema,
data: HaFormDataContainer | undefined
): boolean => {
const { hidden } = schema as HaFormBaseSchema;
if (!hidden) {
return false;
}
if (hidden === true) {
return true;
}
const conditions = Array.isArray(hidden) ? hidden : [hidden];
return conditions.every((condition) => evaluateCondition(condition, data));
};
+16 -13
View File
@@ -2,6 +2,7 @@ import type { PropertyValues, TemplateResult } from "lit";
import { css, html, LitElement } from "lit";
import { customElement, property, queryAll } from "lit/decorators";
import type { HomeAssistant } from "../../types";
import { isFieldHidden } from "./conditions";
import "./ha-form";
import type { HaForm } from "./ha-form";
import type {
@@ -68,19 +69,21 @@ export class HaFormGrid extends LitElement implements HaFormElement {
protected render(): TemplateResult {
return html`
${this.schema.schema.map(
(item) => html`
<ha-form
.hass=${this.hass}
.data=${this.data}
.schema=${[item]}
.disabled=${this.disabled}
.computeLabel=${this.computeLabel}
.computeHelper=${this.computeHelper}
.localizeValue=${this.localizeValue}
></ha-form>
`
)}
${this.schema.schema
.filter((item) => !isFieldHidden(item, this.data))
.map(
(item) => html`
<ha-form
.hass=${this.hass}
.data=${this.data}
.schema=${[item]}
.disabled=${this.disabled}
.computeLabel=${this.computeLabel}
.computeHelper=${this.computeHelper}
.localizeValue=${this.localizeValue}
></ha-form>
`
)}
`;
}
+11 -2
View File
@@ -1,11 +1,12 @@
import type { PropertyValues, TemplateResult } from "lit";
import { css, html, LitElement } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, query } from "lit/decorators";
import { dynamicElement } from "../../common/dom/dynamic-element-directive";
import { fireEvent } from "../../common/dom/fire_event";
import type { HomeAssistant } from "../../types";
import "../ha-alert";
import "../ha-selector/ha-selector";
import { isFieldHidden } from "./conditions";
import type { HaFormDataContainer, HaFormElement, HaFormSchema } from "./types";
const LOAD_ELEMENTS = {
@@ -98,7 +99,11 @@ export class HaForm extends LitElement implements HaFormElement {
let isValid = true;
let firstInvalidElement: HTMLElement | undefined;
this.schema.forEach((item, index) => {
const visibleSchema = this.schema.filter(
(item) => !isFieldHidden(item, this.data)
);
visibleSchema.forEach((item, index) => {
const element = elements[index];
if (!element) {
return;
@@ -164,6 +169,10 @@ export class HaForm extends LitElement implements HaFormElement {
: ""
}
${this.schema.map((item) => {
if (isFieldHidden(item, this.data)) {
return nothing;
}
const error = getError(this.error, item);
const warning = getWarning(this.warning, item);
+33
View File
@@ -22,6 +22,9 @@ export interface HaFormBaseSchema {
default?: HaFormData;
required?: boolean;
disabled?: boolean;
// Field is hidden while the condition holds. Serializable so it can be
// shared with the backend and other renderers.
hidden?: boolean | HaFormCondition | HaFormCondition[];
description?: {
suffix?: string;
// This value will be set initially when form is loaded
@@ -30,6 +33,36 @@ export interface HaFormBaseSchema {
context?: Record<string, string>;
}
export type HaFormConditionOperator =
"eq" | "not_eq" | "in" | "not_in" | "exists" | "not_exists";
export interface HaFormFieldCondition {
field: string;
operator?: HaFormConditionOperator;
value?: HaFormData | readonly HaFormData[];
}
export interface HaFormAndCondition {
condition: "and";
conditions: readonly HaFormCondition[];
}
export interface HaFormOrCondition {
condition: "or";
conditions: readonly HaFormCondition[];
}
export interface HaFormNotCondition {
condition: "not";
conditions: readonly HaFormCondition[];
}
export type HaFormCondition =
| HaFormFieldCondition
| HaFormAndCondition
| HaFormOrCondition
| HaFormNotCondition;
export interface HaFormGridSchema extends HaFormBaseSchema {
type: "grid";
flatten?: boolean;
+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
);
}
}
@@ -181,49 +181,49 @@ export class HaSelectorSelector extends LitElement {
return true;
}
private _schema = memoizeOne(
(choice: string, localize: LocalizeFunc) =>
[
{
name: "type",
required: true,
selector: {
select: {
mode: "dropdown",
options: Object.keys(SELECTOR_SCHEMAS)
.concat("manual")
.map((key) => ({
label:
localize(
`ui.components.selectors.selector.types.${key}` as LocalizeKeys
) || key,
value: key,
})),
},
private _schema = memoizeOne((choice: string, localize: LocalizeFunc) => {
const schemas = SELECTOR_SCHEMAS[choice];
return [
{
name: "type",
required: true,
selector: {
select: {
mode: "dropdown",
options: Object.keys(SELECTOR_SCHEMAS)
.concat("manual")
.map((key) => ({
label:
localize(
`ui.components.selectors.selector.types.${key}` as LocalizeKeys
) || key,
value: key,
})),
},
},
...(choice === "manual"
? ([
},
...(choice === "manual"
? ([
{
name: "manual",
selector: { object: {} },
},
] as const)
: []),
...(schemas
? schemas.length > 1
? [
{
name: "manual",
selector: { object: {} },
name: "",
type: "expandable",
title: localize("ui.components.selectors.selector.options"),
schema: schemas,
},
] as const)
: []),
...(SELECTOR_SCHEMAS[choice]
? SELECTOR_SCHEMAS[choice].length > 1
? [
{
name: "",
type: "expandable",
title: localize("ui.components.selectors.selector.options"),
schema: SELECTOR_SCHEMAS[choice],
},
]
: SELECTOR_SCHEMAS[choice]
: []),
] as const
);
]
: schemas
: []),
] as const;
});
protected render() {
let data;
+10 -1
View File
@@ -28,6 +28,10 @@ export class HaTextSelector extends LitElement {
@query("ha-input, ha-textarea") private _input?: HTMLInputElement;
@query("ha-input-multi") private _inputMulti?: {
reportValidity: () => boolean;
};
public async focus() {
await this.updateComplete;
this._input?.focus();
@@ -35,7 +39,7 @@ export class HaTextSelector extends LitElement {
public reportValidity(): boolean {
if (this.selector.text?.multiple) {
return true;
return this._inputMulti?.reportValidity() ?? true;
}
return this._input?.reportValidity() ?? true;
}
@@ -52,6 +56,8 @@ export class HaTextSelector extends LitElement {
.inputPrefix=${this.selector.text?.prefix}
.helper=${this.helper}
.autocomplete=${this.selector.text?.autocomplete}
.pattern=${this.selector.text?.pattern}
.validationMessage=${this.selector.text?.validation_message}
@value-changed=${this._handleChange}
>
</ha-input-multi>
@@ -80,6 +86,9 @@ export class HaTextSelector extends LitElement {
.hint=${this.helper}
.disabled=${this.disabled}
.type=${this.selector.text?.type}
.pattern=${this.selector.text?.pattern}
.validationMessage=${this.selector.text?.validation_message}
.autoValidate=${this.selector.text?.pattern !== undefined}
@input=${this._handleChange}
@change=${this._handleChange}
.label=${this.label || ""}
@@ -0,0 +1,41 @@
import { html, LitElement } from "lit";
import { customElement, property } from "lit/decorators";
import type { UiClockDateFormatSelector } from "../../data/selector";
import type { HomeAssistant } from "../../types";
import "../ha-clock-date-format-picker";
@customElement("ha-selector-ui_clock_date_format")
export class HaSelectorUiClockDateFormat extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false }) public selector!: UiClockDateFormatSelector;
@property() public value?: string | string[];
@property() public label?: string;
@property() public helper?: string;
@property({ type: Boolean }) public disabled = false;
@property({ type: Boolean }) public required = true;
protected render() {
return html`
<ha-clock-date-format-picker
.hass=${this.hass}
.value=${this.value}
.label=${this.label}
.helper=${this.helper}
.disabled=${this.disabled}
.required=${this.required}
></ha-clock-date-format-picker>
`;
}
}
declare global {
interface HTMLElementTagNameMap {
"ha-selector-ui_clock_date_format": HaSelectorUiClockDateFormat;
}
}
@@ -65,6 +65,7 @@ const LOAD_ELEMENTS = {
location: () => import("./ha-selector-location"),
color_temp: () => import("./ha-selector-color-temp"),
ui_action: () => import("./ha-selector-ui-action"),
ui_clock_date_format: () => import("./ha-selector-ui-clock-date-format"),
ui_color: () => import("./ha-selector-ui-color"),
ui_state_content: () => import("./ha-selector-ui-state-content"),
ui_time_format: () => import("./ha-selector-ui-time-format"),
+7 -15
View File
@@ -305,6 +305,10 @@ export class HaServiceControl extends LitElement {
) {
return null;
}
const isPrimaryEntity = (entityId: string) => {
const entity = this.hass.entities[entityId];
return !entity?.entity_category && !entity?.hidden;
};
const targetEntities =
ensureArray(
value?.target?.entity_id || value?.data?.entity_id
@@ -333,11 +337,7 @@ export class HaServiceControl extends LitElement {
targetSelector
);
targetDevices.push(...expanded.devices);
const primaryEntities = expanded.entities.filter(
(entityId) =>
!this.hass.entities[entityId]?.entity_category &&
!this.hass.entities[entityId]?.hidden
);
const primaryEntities = expanded.entities.filter(isPrimaryEntity);
targetEntities.push(primaryEntities);
targetAreas.push(...expanded.areas);
});
@@ -362,11 +362,7 @@ export class HaServiceControl extends LitElement {
this.hass.entities,
targetSelector
);
const primaryEntities = expanded.entities.filter(
(entityId) =>
!this.hass.entities[entityId]?.entity_category &&
!this.hass.entities[entityId]?.hidden
);
const primaryEntities = expanded.entities.filter(isPrimaryEntity);
targetEntities.push(...primaryEntities);
targetDevices.push(...expanded.devices);
});
@@ -379,11 +375,7 @@ export class HaServiceControl extends LitElement {
this.hass.entities,
targetSelector
);
const primaryEntities = expanded.entities.filter(
(entityId) =>
!this.hass.entities[entityId]?.entity_category &&
!this.hass.entities[entityId]?.hidden
);
const primaryEntities = expanded.entities.filter(isPrimaryEntity);
targetEntities.push(...primaryEntities);
});
}
+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}
+33 -2
View File
@@ -2,7 +2,13 @@ import { consume, type ContextType } from "@lit/context";
import { mdiDeleteOutline, mdiDragHorizontalVariant, mdiPlus } from "@mdi/js";
import type { CSSResultGroup, PropertyValues } from "lit";
import { LitElement, css, html, nothing } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import {
customElement,
property,
query,
queryAll,
state,
} from "lit/decorators";
import { repeat } from "lit/directives/repeat";
import { fireEvent } from "../../common/dom/fire_event";
import { uid } from "../../common/util/uid";
@@ -50,6 +56,13 @@ class HaInputMulti extends LitElement {
@property() public autocomplete?: string;
/** Regular expression each entry is validated against (HTML `pattern`). */
@property() public pattern?: string;
/** Message shown on an entry when it fails `pattern` validation. */
@property({ attribute: "validation-message" })
public validationMessage?: string;
@property({ attribute: "add-label" }) public addLabel?: string;
@property({ attribute: "remove-label" }) public removeLabel?: string;
@@ -70,6 +83,8 @@ class HaInputMulti extends LitElement {
@query("ha-input[data-last]") private _lastInput?: HaInput;
@queryAll("ha-input") private _inputs?: NodeListOf<HaInput>;
// Stable key per row, kept in sync with `value`. Because items are plain
// strings we cannot use a WeakMap (as the object-based sortable lists do),
// so we track keys in a parallel array. Keys stay fixed while a row is
@@ -89,6 +104,16 @@ class HaInputMulti extends LitElement {
}
}
public reportValidity(): boolean {
let valid = true;
this._inputs?.forEach((input) => {
if (!input.reportValidity()) {
valid = false;
}
});
return valid;
}
protected render() {
return html`
<ha-sortable
@@ -109,6 +134,9 @@ class HaInputMulti extends LitElement {
.type=${this.inputType}
.autocomplete=${this.autocomplete}
.disabled=${this.disabled}
.pattern=${this.pattern}
.validationMessage=${this.validationMessage}
.autoValidate=${this.pattern !== undefined}
dialogInitialFocus=${index}
.index=${index}
class="flex-auto"
@@ -156,7 +184,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 +286,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;
+11 -10
View File
@@ -213,12 +213,14 @@ export const findBatteryEntity = <T extends { entity_id: string }>(
entities: T[]
): T | undefined => {
const batteryEntities = entities
.filter(
(entity) =>
states[entity.entity_id] &&
states[entity.entity_id].attributes.device_class === "battery" &&
.filter((entity) => {
const state = states[entity.entity_id];
return (
state &&
state.attributes.device_class === "battery" &&
batteryPriorities.includes(computeDomain(entity.entity_id))
)
);
})
.sort(
(a, b) =>
batteryPriorities.indexOf(computeDomain(a.entity_id)) -
@@ -235,11 +237,10 @@ export const findBatteryChargingEntity = <T extends { entity_id: string }>(
states: HomeAssistant["states"],
entities: T[]
): T | undefined =>
entities.find(
(entity) =>
states[entity.entity_id] &&
states[entity.entity_id].attributes.device_class === "battery_charging"
);
entities.find((entity) => {
const state = states[entity.entity_id];
return state && state.attributes.device_class === "battery_charging";
});
export const computeEntityRegistryName = (
hass: HomeAssistant,
+1 -1
View File
@@ -140,7 +140,7 @@ export interface HassioAddonSetOptionParams {
export const reloadHassioAddons = async (hass: HomeAssistant) => {
await hass.callWS({
type: "supervisor/api",
endpoint: "/addons/reload",
endpoint: "/store/reload",
method: "post",
});
};
-3
View File
@@ -17,9 +17,6 @@ export interface HassioStats {
network_tx: number;
}
export const hassioApiResultExtractor = <T>(response: HassioResponse<T>) =>
response.data;
export const extractApiErrorMessage = (error: any): string =>
typeof error === "object"
? typeof error.body === "object"
+17 -47
View File
@@ -1,7 +1,4 @@
import { atLeastVersion } from "../../common/config/version";
import type { HomeAssistant } from "../../types";
import type { HassioResponse } from "./common";
import { hassioApiResultExtractor } from "./common";
type HassioDockerRegistries = Record<
string,
@@ -10,59 +7,32 @@ type HassioDockerRegistries = Record<
export const fetchHassioDockerRegistries = async (
hass: HomeAssistant
): Promise<HassioDockerRegistries> => {
if (atLeastVersion(hass.config.version, 2021, 2, 4)) {
return hass.callWS({
type: "supervisor/api",
endpoint: `/docker/registries`,
method: "get",
});
}
return hassioApiResultExtractor(
await hass.callApi<HassioResponse<HassioDockerRegistries>>(
"GET",
"hassio/docker/registries"
)
);
};
): Promise<HassioDockerRegistries> =>
hass.callWS({
type: "supervisor/api",
endpoint: `/docker/registries`,
method: "get",
});
export const addHassioDockerRegistry = async (
hass: HomeAssistant,
data: HassioDockerRegistries
) => {
if (atLeastVersion(hass.config.version, 2021, 2, 4)) {
await hass.callWS({
type: "supervisor/api",
endpoint: `/docker/registries`,
method: "post",
data,
});
return;
}
await hass.callApi<HassioResponse<HassioDockerRegistries>>(
"POST",
"hassio/docker/registries",
data
);
await hass.callWS({
type: "supervisor/api",
endpoint: `/docker/registries`,
method: "post",
data,
});
};
export const removeHassioDockerRegistry = async (
hass: HomeAssistant,
registry: string
) => {
if (atLeastVersion(hass.config.version, 2021, 2, 4)) {
await hass.callWS({
type: "supervisor/api",
endpoint: `/docker/registries/${registry}`,
method: "delete",
});
return;
}
await hass.callApi<HassioResponse<void>>(
"DELETE",
`hassio/docker/registries/${registry}`
);
await hass.callWS({
type: "supervisor/api",
endpoint: `/docker/registries/${registry}`,
method: "delete",
});
};
+12 -35
View File
@@ -1,7 +1,4 @@
import { atLeastVersion } from "../../common/config/version";
import type { HomeAssistant } from "../../types";
import type { HassioResponse } from "./common";
import { hassioApiResultExtractor } from "./common";
export interface HassioHardwareAudioDevice {
device?: string | null;
@@ -30,38 +27,18 @@ export interface HassioHardwareInfo {
export const fetchHassioHardwareAudio = async (
hass: HomeAssistant
): Promise<HassioHardwareAudioList> => {
if (atLeastVersion(hass.config.version, 2021, 2, 4)) {
return hass.callWS({
type: "supervisor/api",
endpoint: `/hardware/audio`,
method: "get",
});
}
return hassioApiResultExtractor(
await hass.callApi<HassioResponse<HassioHardwareAudioList>>(
"GET",
"hassio/hardware/audio"
)
);
};
): Promise<HassioHardwareAudioList> =>
hass.callWS({
type: "supervisor/api",
endpoint: `/hardware/audio`,
method: "get",
});
export const fetchHassioHardwareInfo = async (
hass: HomeAssistant
): Promise<HassioHardwareInfo> => {
if (atLeastVersion(hass.config.version, 2021, 2, 4)) {
return hass.callWS({
type: "supervisor/api",
endpoint: `/hardware/info`,
method: "get",
});
}
return hassioApiResultExtractor(
await hass.callApi<HassioResponse<HassioHardwareInfo>>(
"GET",
"hassio/hardware/info"
)
);
};
): Promise<HassioHardwareInfo> =>
hass.callWS({
type: "supervisor/api",
endpoint: `/hardware/info`,
method: "get",
});
+70 -143
View File
@@ -1,7 +1,4 @@
import { atLeastVersion } from "../../common/config/version";
import type { HomeAssistant } from "../../types";
import type { HassioResponse } from "./common";
import { hassioApiResultExtractor } from "./common";
export interface HassioHostInfo {
agent_version: string;
@@ -54,156 +51,86 @@ export interface HostDisksUsage {
export const fetchHassioHostInfo = async (
hass: HomeAssistant
): Promise<HassioHostInfo> => {
if (atLeastVersion(hass.config.version, 2021, 2, 4)) {
return hass.callWS({
type: "supervisor/api",
endpoint: "/host/info",
method: "get",
});
}
const response = await hass.callApi<HassioResponse<HassioHostInfo>>(
"GET",
"hassio/host/info"
);
return hassioApiResultExtractor(response);
};
): Promise<HassioHostInfo> =>
hass.callWS({
type: "supervisor/api",
endpoint: "/host/info",
method: "get",
});
export const fetchHassioHassOsInfo = async (
hass: HomeAssistant
): Promise<HassioHassOSInfo> => {
if (atLeastVersion(hass.config.version, 2021, 2, 4)) {
return hass.callWS({
type: "supervisor/api",
endpoint: "/os/info",
method: "get",
});
}
): Promise<HassioHassOSInfo> =>
hass.callWS({
type: "supervisor/api",
endpoint: "/os/info",
method: "get",
});
return hassioApiResultExtractor(
await hass.callApi<HassioResponse<HassioHassOSInfo>>(
"GET",
"hassio/os/info"
)
);
};
export const rebootHost = async (hass: HomeAssistant) =>
hass.callWS({
type: "supervisor/api",
endpoint: "/host/reboot",
method: "post",
timeout: null,
});
export const rebootHost = async (hass: HomeAssistant) => {
if (atLeastVersion(hass.config.version, 2021, 2, 4)) {
return hass.callWS({
type: "supervisor/api",
endpoint: "/host/reboot",
method: "post",
timeout: null,
});
}
export const shutdownHost = async (hass: HomeAssistant) =>
hass.callWS({
type: "supervisor/api",
endpoint: "/host/shutdown",
method: "post",
timeout: null,
});
return hass.callApi<HassioResponse<void>>("POST", "hassio/host/reboot");
};
export const updateOS = async (hass: HomeAssistant) =>
hass.callWS({
type: "supervisor/api",
endpoint: "/os/update",
method: "post",
timeout: null,
});
export const shutdownHost = async (hass: HomeAssistant) => {
if (atLeastVersion(hass.config.version, 2021, 2, 4)) {
return hass.callWS({
type: "supervisor/api",
endpoint: "/host/shutdown",
method: "post",
timeout: null,
});
}
export const configSyncOS = async (hass: HomeAssistant) =>
hass.callWS({
type: "supervisor/api",
endpoint: "/os/config/sync",
method: "post",
timeout: null,
});
return hass.callApi<HassioResponse<void>>("POST", "hassio/host/shutdown");
};
export const changeHostOptions = async (hass: HomeAssistant, options: any) =>
hass.callWS({
type: "supervisor/api",
endpoint: "/host/options",
method: "post",
data: options,
});
export const updateOS = async (hass: HomeAssistant) => {
if (atLeastVersion(hass.config.version, 2021, 2, 4)) {
return hass.callWS({
type: "supervisor/api",
endpoint: "/os/update",
method: "post",
timeout: null,
});
}
return hass.callApi<HassioResponse<void>>("POST", "hassio/os/update");
};
export const configSyncOS = async (hass: HomeAssistant) => {
if (atLeastVersion(hass.config.version, 2021, 2, 4)) {
return hass.callWS({
type: "supervisor/api",
endpoint: "/os/config/sync",
method: "post",
timeout: null,
});
}
return hass.callApi<HassioResponse<void>>("POST", "hassio/os/config/sync");
};
export const changeHostOptions = async (hass: HomeAssistant, options: any) => {
if (atLeastVersion(hass.config.version, 2021, 2, 4)) {
return hass.callWS({
type: "supervisor/api",
endpoint: "/host/options",
method: "post",
data: options,
});
}
return hass.callApi<HassioResponse<void>>(
"POST",
"hassio/host/options",
options
);
};
export const moveDatadisk = async (hass: HomeAssistant, device: string) => {
if (atLeastVersion(hass.config.version, 2021, 2, 4)) {
return hass.callWS({
type: "supervisor/api",
endpoint: "/os/datadisk/move",
method: "post",
timeout: null,
data: { device },
});
}
return hass.callApi<HassioResponse<void>>("POST", "hassio/os/datadisk/move");
};
export const moveDatadisk = async (hass: HomeAssistant, device: string) =>
hass.callWS({
type: "supervisor/api",
endpoint: "/os/datadisk/move",
method: "post",
timeout: null,
data: { device },
});
export const listDatadisks = async (
hass: HomeAssistant
): Promise<DatadiskList> => {
if (atLeastVersion(hass.config.version, 2021, 2, 4)) {
return hass.callWS<DatadiskList>({
type: "supervisor/api",
endpoint: "/os/datadisk/list",
method: "get",
timeout: null,
});
}
): Promise<DatadiskList> =>
hass.callWS<DatadiskList>({
type: "supervisor/api",
endpoint: "/os/datadisk/list",
method: "get",
timeout: null,
});
return hassioApiResultExtractor(
await hass.callApi<HassioResponse<DatadiskList>>("GET", "/os/datadisk/list")
);
};
export const fetchHostDisksUsage = async (hass: HomeAssistant) => {
if (atLeastVersion(hass.config.version, 2021, 2, 4)) {
return hass.callWS<HostDisksUsage>({
type: "supervisor/api",
endpoint: "/host/disks/default/usage",
method: "get",
timeout: 3600, // seconds. This can take a while
params: { max_depth: 3 },
});
}
return hassioApiResultExtractor(
await hass.callApi<HassioResponse<HostDisksUsage>>(
"GET",
"hassio/host/disks/default/usage"
)
);
};
export const fetchHostDisksUsage = async (hass: HomeAssistant) =>
hass.callWS<HostDisksUsage>({
type: "supervisor/api",
endpoint: "/host/disks/default/usage",
method: "get",
timeout: 3600, // seconds. This can take a while
params: { max_depth: 3 },
});
+14 -33
View File
@@ -1,9 +1,6 @@
import { getCollection, type Connection } from "home-assistant-js-websocket";
import { atLeastVersion } from "../../common/config/version";
import type { HomeAssistant } from "../../types";
import { supervisorApiWsRequest } from "../supervisor/supervisor";
import type { HassioResponse } from "./common";
import type { CreateSessionResponse } from "./supervisor";
function setIngressCookie(session: string): string {
document.cookie = `ingress_session=${session};path=/api/hassio_ingress/;SameSite=Strict${
@@ -13,21 +10,14 @@ function setIngressCookie(session: string): string {
}
export const createHassioSession = async (
hass: HomeAssistant
hass: Pick<HomeAssistant, "callWS">
): Promise<string> => {
if (atLeastVersion(hass.config.version, 2021, 2, 4)) {
const wsResponse: { session: string } = await hass.callWS({
type: "supervisor/api",
endpoint: "/ingress/session",
method: "post",
});
return setIngressCookie(wsResponse.session);
}
const restResponse: { data: { session: string } } = await hass.callApi<
HassioResponse<CreateSessionResponse>
>("POST", "hassio/ingress/session");
return setIngressCookie(restResponse.data.session);
const wsResponse: { session: string } = await hass.callWS({
type: "supervisor/api",
endpoint: "/ingress/session",
method: "post",
});
return setIngressCookie(wsResponse.session);
};
export interface IngressPanelInfo {
@@ -50,22 +40,13 @@ export const getIngressPanelInfoCollection = (conn: Connection) =>
);
export const validateHassioSession = async (
hass: HomeAssistant,
hass: Pick<HomeAssistant, "callWS">,
session: string
): Promise<void> => {
if (atLeastVersion(hass.config.version, 2021, 2, 4)) {
await hass.callWS({
type: "supervisor/api",
endpoint: "/ingress/validate_session",
method: "post",
data: { session },
});
return;
}
await hass.callApi<HassioResponse<void>>(
"POST",
"hassio/ingress/validate_session",
{ session }
);
await hass.callWS({
type: "supervisor/api",
endpoint: "/ingress/validate_session",
method: "post",
data: { session },
});
};
+20 -52
View File
@@ -1,7 +1,4 @@
import { atLeastVersion } from "../../common/config/version";
import type { HomeAssistant } from "../../types";
import type { HassioResponse } from "./common";
import { hassioApiResultExtractor } from "./common";
interface IpConfiguration {
address: string[];
@@ -55,66 +52,37 @@ export interface NetworkInfo {
export const fetchNetworkInfo = async (
hass: HomeAssistant
): Promise<NetworkInfo> => {
if (atLeastVersion(hass.config.version, 2021, 2, 4)) {
return hass.callWS({
type: "supervisor/api",
endpoint: "/network/info",
method: "get",
});
}
return hassioApiResultExtractor(
await hass.callApi<HassioResponse<NetworkInfo>>(
"GET",
"hassio/network/info"
)
);
};
): Promise<NetworkInfo> =>
hass.callWS({
type: "supervisor/api",
endpoint: "/network/info",
method: "get",
});
export const updateNetworkInterface = async (
hass: HomeAssistant,
network_interface: string,
options: Partial<NetworkInterface>
) => {
if (atLeastVersion(hass.config.version, 2021, 2, 4)) {
await hass.callWS({
type: "supervisor/api",
endpoint: `/network/interface/${network_interface}/update`,
method: "post",
data: options,
timeout: null,
});
return;
}
await hass.callApi<HassioResponse<NetworkInfo>>(
"POST",
`hassio/network/interface/${network_interface}/update`,
options
);
await hass.callWS({
type: "supervisor/api",
endpoint: `/network/interface/${network_interface}/update`,
method: "post",
data: options,
timeout: null,
});
};
export const accesspointScan = async (
hass: HomeAssistant,
network_interface: string
): Promise<AccessPoints> => {
if (atLeastVersion(hass.config.version, 2021, 2, 4)) {
return hass.callWS({
type: "supervisor/api",
endpoint: `/network/interface/${network_interface}/accesspoints`,
method: "get",
timeout: null,
});
}
return hassioApiResultExtractor(
await hass.callApi<HassioResponse<AccessPoints>>(
"GET",
`hassio/network/interface/${network_interface}/accesspoints`
)
);
};
): Promise<AccessPoints> =>
hass.callWS({
type: "supervisor/api",
endpoint: `/network/interface/${network_interface}/accesspoints`,
method: "get",
timeout: null,
});
export const parseAddress = (address: string) => {
const [ip, cidr] = address.split("/");
+6 -19
View File
@@ -1,7 +1,4 @@
import { atLeastVersion } from "../../common/config/version";
import type { HomeAssistant, TranslationDict } from "../../types";
import type { HassioResponse } from "./common";
import { hassioApiResultExtractor } from "./common";
export interface HassioResolution {
unsupported: (keyof TranslationDict["ui"]["dialogs"]["unsupported"]["reasons"])[];
@@ -12,19 +9,9 @@ export interface HassioResolution {
export const fetchHassioResolution = async (
hass: HomeAssistant
): Promise<HassioResolution> => {
if (atLeastVersion(hass.config.version, 2021, 2, 4)) {
return hass.callWS({
type: "supervisor/api",
endpoint: "/resolution/info",
method: "get",
});
}
return hassioApiResultExtractor(
await hass.callApi<HassioResponse<HassioResolution>>(
"GET",
"hassio/resolution/info"
)
);
};
): Promise<HassioResolution> =>
hass.callWS({
type: "supervisor/api",
endpoint: "/resolution/info",
method: "get",
});
+41 -98
View File
@@ -1,8 +1,6 @@
import { atLeastVersion } from "../../common/config/version";
import type { HomeAssistant, PanelInfo } from "../../types";
import type { SupervisorArch } from "../supervisor/supervisor";
import type { HassioResponse } from "./common";
import { hassioApiResultExtractor } from "./common";
export interface HassioHomeAssistantInfo {
arch: SupervisorArch;
@@ -77,10 +75,6 @@ export type HassioPanelInfo = PanelInfo<
}
>;
export interface CreateSessionResponse {
session: string;
}
export interface SupervisorOptions {
channel?: "beta" | "dev" | "stable";
diagnostics?: boolean;
@@ -88,99 +82,57 @@ export interface SupervisorOptions {
}
export const reloadSupervisor = async (hass: HomeAssistant) => {
if (atLeastVersion(hass.config.version, 2021, 2, 4)) {
await hass.callWS({
type: "supervisor/api",
endpoint: "/supervisor/reload",
method: "post",
});
return;
}
await hass.callApi<HassioResponse<void>>("POST", `hassio/supervisor/reload`);
await hass.callWS({
type: "supervisor/api",
endpoint: "/supervisor/reload",
method: "post",
});
};
export const restartSupervisor = async (hass: HomeAssistant) => {
if (atLeastVersion(hass.config.version, 2021, 2, 4)) {
await hass.callWS({
type: "supervisor/api",
endpoint: "/supervisor/restart",
method: "post",
timeout: null,
});
return;
}
await hass.callApi<HassioResponse<void>>("POST", `hassio/supervisor/restart`);
await hass.callWS({
type: "supervisor/api",
endpoint: "/supervisor/restart",
method: "post",
timeout: null,
});
};
export const updateSupervisor = async (hass: HomeAssistant) => {
if (atLeastVersion(hass.config.version, 2021, 2, 4)) {
await hass.callWS({
type: "supervisor/api",
endpoint: "/supervisor/update",
method: "post",
timeout: null,
});
return;
}
await hass.callApi<HassioResponse<void>>("POST", `hassio/supervisor/update`);
await hass.callWS({
type: "supervisor/api",
endpoint: "/supervisor/update",
method: "post",
timeout: null,
});
};
export const fetchHassioHomeAssistantInfo = async (
hass: HomeAssistant
): Promise<HassioHomeAssistantInfo> => {
if (atLeastVersion(hass.config.version, 2021, 2, 4)) {
return hass.callWS({
type: "supervisor/api",
endpoint: "/core/info",
method: "get",
});
}
return hassioApiResultExtractor(
await hass.callApi<HassioResponse<HassioHomeAssistantInfo>>(
"GET",
"hassio/core/info"
)
);
};
): Promise<HassioHomeAssistantInfo> =>
hass.callWS({
type: "supervisor/api",
endpoint: "/core/info",
method: "get",
});
export const fetchHassioSupervisorInfo = async (
hass: HomeAssistant
): Promise<HassioSupervisorInfo> => {
if (atLeastVersion(hass.config.version, 2021, 2, 4)) {
return hass.callWS({
type: "supervisor/api",
endpoint: "/supervisor/info",
method: "get",
});
}
return hassioApiResultExtractor(
await hass.callApi<HassioResponse<HassioSupervisorInfo>>(
"GET",
"hassio/supervisor/info"
)
);
};
): Promise<HassioSupervisorInfo> =>
hass.callWS({
type: "supervisor/api",
endpoint: "/supervisor/info",
method: "get",
});
export const fetchHassioInfo = async (
hass: HomeAssistant
): Promise<HassioInfo> => {
if (atLeastVersion(hass.config.version, 2021, 2, 4)) {
return hass.callWS({
type: "supervisor/api",
endpoint: "/info",
method: "get",
});
}
return hassioApiResultExtractor(
await hass.callApi<HassioResponse<HassioInfo>>("GET", "hassio/info")
);
};
): Promise<HassioInfo> =>
hass.callWS({
type: "supervisor/api",
endpoint: "/info",
method: "get",
});
export const fetchHassioBoots = async (hass: HomeAssistant) =>
hass.callApi<HassioResponse<HassioBoots>>("GET", `hassio/host/logs/boots`);
@@ -263,21 +215,12 @@ export const setSupervisorOption = async (
hass: HomeAssistant,
data: SupervisorOptions
) => {
if (atLeastVersion(hass.config.version, 2021, 2, 4)) {
await hass.callWS({
type: "supervisor/api",
endpoint: "/supervisor/options",
method: "post",
data,
});
return;
}
await hass.callApi<HassioResponse<void>>(
"POST",
"hassio/supervisor/options",
data
);
await hass.callWS({
type: "supervisor/api",
endpoint: "/supervisor/options",
method: "post",
data,
});
};
export const coreLatestLogsUrl = "/api/hassio/core/logs/latest";
+56
View File
@@ -0,0 +1,56 @@
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 const HTTP_CONFIG_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",
];
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" });
+14 -15
View File
@@ -325,35 +325,34 @@ export const getCategoryIcons = async <
domain?: string,
force = false
): Promise<CategoryType[T] | Record<string, CategoryType[T]> | undefined> => {
const categoryResources = resources[category];
if (!domain) {
if (!force && resources[category].all) {
return resources[category].all as Promise<
Record<string, CategoryType[T]>
>;
if (!force && categoryResources.all) {
return categoryResources.all as Promise<Record<string, CategoryType[T]>>;
}
resources[category].all = getHassIcons(connection, category).then((res) => {
resources[category].domains = res.resources as any;
categoryResources.all = getHassIcons(connection, category).then((res) => {
categoryResources.domains = res.resources as any;
return res?.resources as Record<string, CategoryType[T]>;
}) as any;
return resources[category].all as Promise<Record<string, CategoryType[T]>>;
return categoryResources.all as Promise<Record<string, CategoryType[T]>>;
}
if (!force && domain in resources[category].domains) {
return resources[category].domains[domain] as Promise<CategoryType[T]>;
if (!force && domain in categoryResources.domains) {
return categoryResources.domains[domain] as Promise<CategoryType[T]>;
}
if (resources[category].all && !force) {
await resources[category].all;
if (domain in resources[category].domains) {
return resources[category].domains[domain] as Promise<CategoryType[T]>;
if (categoryResources.all && !force) {
await categoryResources.all;
if (domain in categoryResources.domains) {
return categoryResources.domains[domain] as Promise<CategoryType[T]>;
}
}
if (!isComponentLoaded(hassConfig, domain)) {
return undefined;
}
const result = getHassIcons(connection, category, domain);
resources[category].domains[domain] = result.then(
categoryResources.domains[domain] = result.then(
(res) => res?.resources[domain]
) as any;
return resources[category].domains[domain] as Promise<CategoryType[T]>;
return categoryResources.domains[domain] as Promise<CategoryType[T]>;
};
export const getServiceIcons = async (
+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 {
+5
View File
@@ -7,6 +7,11 @@ export interface CustomPanelConfig {
js_url?: string;
module_url?: string;
html_url?: string;
// When true, the panel takes care of the safe-area insets itself (e.g. it
// consumes the `--safe-area-inset-*` variables or draws into the safe area on
// purpose). Home Assistant then skips adding its own safe-area padding around
// the panel to avoid doubling the insets.
handle_safe_area?: boolean;
}
export type CustomPanelInfo<T = Record<string, unknown>> = PanelInfo<
+9
View File
@@ -80,6 +80,7 @@ export type Selector =
| TTSVoiceSelector
| SerialPortSelector
| UiActionSelector
| UiClockDateFormatSelector
| UiColorSelector
| UiStateContentSelector
| UiTimeFormatSelector
@@ -530,6 +531,10 @@ export interface StringSelector {
placeholder?: string;
autocomplete?: string;
multiple?: true;
// Regular expression the value must match (HTML `pattern`); with `multiple`
// every entry is validated. `validation_message` is shown when it fails.
pattern?: string;
validation_message?: string;
} | null;
}
@@ -577,6 +582,10 @@ export interface UiActionSelector {
} | null;
}
export interface UiClockDateFormatSelector {
ui_clock_date_format: {} | null;
}
export interface UiColorExtraOption {
value: string;
label: string;
+8 -23
View File
@@ -1,7 +1,4 @@
import { atLeastVersion } from "../../common/config/version";
import type { HomeAssistant } from "../../types";
import type { HassioResponse } from "../hassio/common";
import { hassioApiResultExtractor } from "../hassio/common";
export interface SupervisorApiCallOptions {
method?: "get" | "post" | "delete";
@@ -13,23 +10,11 @@ export const supervisorApiCall = async <T>(
hass: HomeAssistant,
endpoint: string,
options?: SupervisorApiCallOptions
): Promise<T> => {
if (atLeastVersion(hass.config.version, 2021, 2, 4)) {
// Websockets was added in 2021.2.4
return hass.callWS<T>({
type: "supervisor/api",
endpoint,
method: options?.method || "get",
timeout: options?.timeout ?? null,
data: options?.data,
});
}
return hassioApiResultExtractor(
await hass.callApi<HassioResponse<T>>(
// @ts-ignore
(options.method || "get").toUpperCase(),
`hassio${endpoint}`,
options?.data
)
);
};
): Promise<T> =>
hass.callWS<T>({
type: "supervisor/api",
endpoint,
method: options?.method || "get",
timeout: options?.timeout ?? null,
data: options?.data,
});
+3 -23
View File
@@ -1,32 +1,12 @@
import { atLeastVersion } from "../../common/config/version";
import type { HomeAssistant } from "../../types";
import type { HassioResponse } from "../hassio/common";
export const restartCore = async (hass: HomeAssistant) => {
await hass.callService("homeassistant", "restart");
};
export const updateCore = async (hass: HomeAssistant, backup: boolean) => {
if (atLeastVersion(hass.config.version, 2025, 2, 0)) {
await hass.callWS({
type: "hassio/update/core",
backup: backup,
});
return;
}
if (atLeastVersion(hass.config.version, 2021, 2, 4)) {
await hass.callWS({
type: "supervisor/api",
endpoint: "/core/update",
method: "post",
timeout: null,
data: { backup },
});
return;
}
await hass.callApi<HassioResponse<void>>("POST", "hassio/core/update", {
backup,
await hass.callWS({
type: "hassio/update/core",
backup: backup,
});
};
+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 {
@@ -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 =>
+9 -10
View File
@@ -80,27 +80,26 @@ class StepFlowMenu extends LitElement {
return html`
${description ? html`<div class="content">${description}</div>` : nothing}
<div class="options">
${options.map(
(option) => html`
${options.map((option) => {
const optionDescription = optionDescriptions[option];
return html`
<ha-list-item
hasMeta
.step=${option}
@click=${this._handleStep}
?twoline=${optionDescriptions[option]}
?multiline-secondary=${optionDescriptions[option]}
?twoline=${optionDescription}
?multiline-secondary=${optionDescription}
>
<span>${translations[option]}</span>
${
optionDescriptions[option]
? html`<span slot="secondary">
${optionDescriptions[option]}
</span>`
optionDescription
? html`<span slot="secondary"> ${optionDescription} </span>`
: nothing
}
<ha-icon-next slot="meta"></ha-icon-next>
</ha-list-item>
`
)}
`;
})}
</div>
`;
}
@@ -0,0 +1,407 @@
import { mdiArrowRight } from "@mdi/js";
import { ERR_CONNECTION_LOST } from "home-assistant-js-websocket";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { styleMap } from "lit/directives/style-map";
import { formatNumericDuration } from "../../common/datetime/format_duration";
import { fireEvent } from "../../common/dom/fire_event";
import { computeRTL } from "../../common/util/compute_rtl";
import "../../components/ha-alert";
import "../../components/ha-button";
import "../../components/ha-dialog-footer";
import "../../components/ha-dialog";
import "../../components/ha-svg-icon";
import type { HttpConfig } from "../../data/http";
import {
HTTP_CONFIG_FIELDS,
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";
@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();
// The field labels live in the config panel fragment, which is not loaded
// yet when this dialog pops up on startup. Load it so the changed-field
// names resolve; the dialog re-renders once hass updates.
this.hass.loadFragmentTranslation("config");
}
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_CONFIG_FIELDS.filter(
(key) => JSON.stringify(stable[key]) !== JSON.stringify(pending[key])
);
}
private _formatValue(key: keyof HttpConfig, value: unknown): string {
if (value === undefined || value === null || value === "") {
return this.hass.localize("ui.dialogs.http_pending_config.not_set");
}
if (typeof value === "boolean") {
return this.hass.localize(value ? "ui.common.yes" : "ui.common.no");
}
if (Array.isArray(value)) {
return value.length
? value.join(", ")
: this.hass.localize("ui.dialogs.http_pending_config.not_set");
}
if (key === "ssl_profile") {
return (
this.hass.localize(
`ui.panel.config.network.http.ssl_profile_${value}` as any
) || String(value)
);
}
return String(value);
}
protected render() {
if (!this._params) {
return nothing;
}
const changes = this._changedFields;
const { stable, pending } = this._params.state;
const rtl = computeRTL(
this.hass.language,
this.hass.translationMetadata.translations
);
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>
<span class="field">
${this.hass.localize(
`ui.panel.config.network.http.fields.${key}` as any
)}
</span>
<span class="values">
<span class="old"
>${this._formatValue(key, stable[key])}</span
>
<ha-svg-icon
.path=${mdiArrowRight}
style=${styleMap({
transform: rtl ? "scaleX(-1)" : "",
})}
></ha-svg-icon>
<span class="new"
>${this._formatValue(key, pending![key])}</span
>
</span>
</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 {
list-style: none;
margin: 0 0 var(--ha-space-4) 0;
padding: 0;
}
li {
padding: var(--ha-space-2) 0;
border-bottom: 1px solid var(--divider-color);
}
li:last-child {
border-bottom: none;
}
.field {
display: block;
font-weight: var(--ha-font-weight-medium);
margin-bottom: var(--ha-space-1);
}
.values {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: var(--ha-space-2);
color: var(--secondary-text-color);
word-break: break-word;
}
.values .new {
color: var(--primary-text-color);
}
.values ha-svg-icon {
--mdc-icon-size: 18px;
flex-shrink: 0;
}
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;

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