mirror of
https://github.com/home-assistant/frontend.git
synced 2026-07-25 13:06:27 +00:00
Compare commits
96 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7ea7038fc0 | |||
| 9fc2bfeeed | |||
| e39a09c5ca | |||
| 9d96d0b4aa | |||
| d900cc70c5 | |||
| 837baff26c | |||
| 43b4e247af | |||
| 17def3c5ee | |||
| ba15879662 | |||
| 0046f72641 | |||
| f66ba11705 | |||
| bded4341df | |||
| 7a6091b5e2 | |||
| 6d12672c3e | |||
| 495562d748 | |||
| 5cf7fb3a3d | |||
| b6813ce060 | |||
| 3a060feb82 | |||
| ab39c4dd4a | |||
| 82167a81f7 | |||
| 1f14f8689c | |||
| 56966ee04b | |||
| 598a987ba5 | |||
| 8775d3f4c5 | |||
| 2771a565b9 | |||
| d3505cece0 | |||
| 8579f77c12 | |||
| 6249e037ed | |||
| 8beb4ee36e | |||
| 58ded99773 | |||
| 0d2b99b3a5 | |||
| 4be7a32148 | |||
| e47cebcf6e | |||
| e8bb029d7e | |||
| 4373f016c9 | |||
| 9820428cd3 | |||
| 4fa09b0085 | |||
| 5d63fad772 | |||
| 7f27c9a948 | |||
| c07264fc27 | |||
| 978ad69c1b | |||
| 047ebd4524 | |||
| a25aa656a3 | |||
| 88f244b9df | |||
| ad3389fd4d | |||
| 5e6840ed9c | |||
| ba287779d4 | |||
| 312fc8d2fb | |||
| 1109ae25cb | |||
| fd3b25d13f | |||
| 0f31fb50f8 | |||
| f38a6cf44b | |||
| 8eab8a5505 | |||
| 17fa5dbabf | |||
| a1a15ae674 | |||
| b421a96eb0 | |||
| 7dbfb2f9d2 | |||
| c68cd67cbd | |||
| 6de9841b6e | |||
| 35193447bf | |||
| 650444e92d | |||
| da9a78f752 | |||
| 9077290b39 | |||
| e4c6ea29b3 | |||
| aa57c27739 | |||
| 420137478e | |||
| 25bc81cf1f | |||
| 6b0f543fdb | |||
| bbd26ce5a9 | |||
| d3c23dd704 | |||
| 7d4e5dc03f | |||
| db22be5a30 | |||
| b896b20409 | |||
| cb3b528ba3 | |||
| 2ed8e0bd0d | |||
| adc3393422 | |||
| 06b9b08809 | |||
| ece84d42dc | |||
| e031e59b31 | |||
| 5bdccd2e4a | |||
| be27615851 | |||
| 48f3d45a2e | |||
| f2a049b7c6 | |||
| 1e691f0b64 | |||
| 23d7f4772a | |||
| bd23592e77 | |||
| 0fe80ef5f9 | |||
| 4327871c30 | |||
| 88381e0b11 | |||
| f44590a2aa | |||
| 1c03daac41 | |||
| a2216c6bf5 | |||
| 4a755e00ac | |||
| 36a9da37f8 | |||
| cee5361525 | |||
| 5f46594ee2 |
@@ -22,11 +22,11 @@ fireEvent(this, "show-dialog", {
|
||||
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 `DialogMixin`, which implements `HassDialogNext<T>`, for new dialogs. See `src/dialogs/dialog-mixin.ts`.
|
||||
- Read dialog parameters from the mixin's `params` property and render the dialog open. Return `nothing` while required parameters are absent.
|
||||
- Call the mixin's `closeDialog()` to close a new dialog. The mixin handles the `closed` event, fires `dialog-closed`, and removes the host element.
|
||||
- Existing dialogs may implement the legacy `HassDialog<T>` interface from `src/dialogs/make-dialog-manager.ts`.
|
||||
- Preserve the existing `showDialog()`, open-state, and close-event lifecycle when maintaining a legacy dialog; do not copy that lifecycle into a `DialogMixin` dialog.
|
||||
- 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.
|
||||
@@ -63,7 +63,7 @@ Use `computeLabel`, `computeError`, and `computeHelper` for translated labels, v
|
||||
.data=${this._data}
|
||||
.schema=${this._schema}
|
||||
.error=${this._errors}
|
||||
.computeLabel=${(schema) => this.hass.localize(`ui.panel.${schema.name}`)}
|
||||
.computeLabel=${(schema) => this._localize(`ui.panel.${schema.name}`)}
|
||||
@value-changed=${this._valueChanged}
|
||||
></ha-form>
|
||||
```
|
||||
@@ -77,10 +77,16 @@ Use `ha-alert` for user-visible status messaging.
|
||||
- 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>
|
||||
```ts
|
||||
html`
|
||||
<ha-alert alert-type="error">${this._localize("ui.example.error")}</ha-alert>
|
||||
<ha-alert alert-type="warning" .title=${this._localize("ui.example.warning")}>
|
||||
${this._localize("ui.example.description")}
|
||||
</ha-alert>
|
||||
<ha-alert alert-type="success" dismissable>
|
||||
${this._localize("ui.example.success")}
|
||||
</ha-alert>
|
||||
`;
|
||||
```
|
||||
|
||||
## Shortcuts And Tooltips
|
||||
|
||||
@@ -23,27 +23,27 @@ Container components may keep `hass` when they own it and feed providers. Leaf c
|
||||
|
||||
## 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 |
|
||||
| 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.debugConnection`, `hass.hassUrl` |
|
||||
| `apiContext` | `hass.callService`, `hass.callApi`, `hass.callApiRaw`, `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`.
|
||||
Lazy contexts subscribe on first consumer and tear down after the last consumer: `labelsContext`, `fullEntitiesContext`, `configEntriesContext`, `manifestsContext`, `triggerDescriptionsContext`, and `conditionDescriptionsContext`.
|
||||
|
||||
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:
|
||||
Use entity-scoped helpers when the component watches an entity ID held on the host:
|
||||
|
||||
```ts
|
||||
@state() @consumeEntityState({ entityIdPath: ["_config", "entity"] })
|
||||
@@ -56,6 +56,13 @@ private _entity?: EntityRegistryDisplayEntry;
|
||||
private _localize!: LocalizeFunc;
|
||||
```
|
||||
|
||||
Use `consumeEntityStates` when the host property contains one or more entity IDs. It filters missing entities and preserves the previous record when none of the selected entities changed.
|
||||
|
||||
```ts
|
||||
@state() @consumeEntityStates({ entityIdPath: ["_config", "entities"] })
|
||||
private _stateObjs?: Record<string, HassEntity>;
|
||||
```
|
||||
|
||||
For a single field from a grouped context, pair `@consume` with `@transform`:
|
||||
|
||||
```ts
|
||||
@@ -65,7 +72,7 @@ For a single field from a grouped context, pair `@consume` with `@transform`:
|
||||
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.
|
||||
Use `@transform` with `watch` when the transformer depends on a host property, such as a computed entity ID. `consumeEntityState` and `consumeEntityStates` only watch 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.
|
||||
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
# Gallery Agent Instructions
|
||||
---
|
||||
name: ha-frontend-gallery
|
||||
description: Home Assistant frontend gallery structure, pages, demos, content, and verification. Use when changing files under gallery/, including gallery markdown, TypeScript demos, sidebar entries, mock data, page generation, or gallery builds.
|
||||
---
|
||||
|
||||
This file applies to all files under `gallery/`. Follow the root `AGENTS.md` for repository-wide Home Assistant frontend, TypeScript, Lit, accessibility, and copy standards. This file adds gallery-specific structure, page, demo, and verification guidance.
|
||||
# HA Frontend Gallery
|
||||
|
||||
Use this skill for all work under `gallery/`. Follow the persistent repository guidance in `AGENTS.md` and load the matching specialist skills alongside this gallery-specific guidance.
|
||||
|
||||
## Quick Reference
|
||||
|
||||
@@ -13,7 +18,7 @@ yarn lint # ESLint, Prettier, TypeScript, and Lit checks
|
||||
yarn lint:types # TypeScript compiler, without file arguments
|
||||
```
|
||||
|
||||
Never run `yarn lint:types` or `tsc` with file arguments. See the root `AGENTS.md` for the generated `.js` file risk.
|
||||
Never run `yarn lint:types` or `tsc` with file arguments. File arguments make `tsc` ignore `tsconfig.json` and can emit `.js` files into `src/`.
|
||||
|
||||
## Purpose
|
||||
|
||||
@@ -26,36 +31,36 @@ The gallery is a developer and designer reference for Home Assistant frontend UI
|
||||
|
||||
## Structure
|
||||
|
||||
- `sidebar.js`: Defines gallery sections, headers, and explicit page ordering.
|
||||
- `script/develop_gallery`: Wrapper for the `develop-gallery` gulp task.
|
||||
- `script/build_gallery`: Wrapper for the `build-gallery` gulp task.
|
||||
- `src/entrypoint.js`: Creates the `<ha-gallery>` shell.
|
||||
- `src/ha-gallery.ts`: Renders the drawer, page routing, markdown descriptions, demos, edit links, and RTL toggle.
|
||||
- `src/html/index.html.template`: HTML template used by the gallery build.
|
||||
- `src/pages/<category>/<page>.markdown`: Optional page description and frontmatter.
|
||||
- `src/pages/<category>/<page>.ts`: Optional live demo module for the same page id.
|
||||
- `src/components/`: Gallery-only demo wrappers like `demo-card`, `demo-cards`, `demo-more-info`, and `page-description`.
|
||||
- `src/data/`: Fake `hass`, demo states, mock traces, and reusable sample data.
|
||||
- `public/`: Static assets copied into the gallery output.
|
||||
- `gallery/sidebar.js`: Defines gallery sections, headers, and explicit page ordering.
|
||||
- `gallery/script/develop_gallery`: Wrapper for the `develop-gallery` gulp task.
|
||||
- `gallery/script/build_gallery`: Wrapper for the `build-gallery` gulp task.
|
||||
- `gallery/src/entrypoint.js`: Creates the `<ha-gallery>` shell.
|
||||
- `gallery/src/ha-gallery.ts`: Renders the drawer, page routing, markdown descriptions, demos, edit links, and RTL toggle.
|
||||
- `gallery/src/html/index.html.template`: HTML template used by the gallery build.
|
||||
- `gallery/src/pages/<category>/<page>.markdown`: Optional page description and frontmatter.
|
||||
- `gallery/src/pages/<category>/<page>.ts`: Optional live demo module for the same page ID.
|
||||
- `gallery/src/components/`: Gallery-only demo wrappers like `demo-card`, `demo-cards`, `demo-more-info`, and `page-description`.
|
||||
- `gallery/src/data/`: Fake `hass`, demo states, mock traces, and reusable sample data.
|
||||
- `gallery/public/`: Static assets copied into the gallery output.
|
||||
|
||||
## Page Model
|
||||
|
||||
Gallery pages are generated by `gather-gallery-pages` in `build-scripts/gulp/gallery.js`.
|
||||
|
||||
- A page id is the path under `src/pages/` without the extension, like `components/ha-button`.
|
||||
- A `.markdown` file and a `.ts` file with the same page id become one gallery page.
|
||||
- A page ID is the path under `gallery/src/pages/` without the extension, like `components/ha-button`.
|
||||
- A `.markdown` file and a `.ts` file with the same page ID become one gallery page.
|
||||
- A page may have only markdown, only a TypeScript demo, or both.
|
||||
- Markdown can contain YAML frontmatter with `title` and optional `subtitle`.
|
||||
- Markdown that contains only frontmatter contributes metadata without rendering a description block.
|
||||
- TypeScript demo modules are dynamically imported for side effects when the page is opened.
|
||||
- A demo module must define a custom element named `demo-${category}-${page}` with slashes replaced by hyphens, like `demo-components-ha-button` for `components/ha-button`.
|
||||
- `ha-gallery.ts` renders that element with `dynamicElement()` based on the current page id.
|
||||
- `gallery/src/ha-gallery.ts` renders that element with `dynamicElement()` based on the current page ID.
|
||||
|
||||
## Sidebar
|
||||
|
||||
Use `sidebar.js` when a page needs a visible section, section header, or deterministic ordering.
|
||||
Use `gallery/sidebar.js` when a page needs a visible section, section header, or deterministic ordering.
|
||||
|
||||
- `category` must match the first directory name under `src/pages/`.
|
||||
- `category` must match the first directory name under `gallery/src/pages/`.
|
||||
- `header` is the section label shown in the drawer.
|
||||
- `pages` is optional. When present, listed pages keep that exact order.
|
||||
- Pages in a category that are not listed are appended alphabetically after the listed pages.
|
||||
@@ -83,20 +88,20 @@ Use markdown pages for explanations, design guidance, API notes, and copy standa
|
||||
- Use fenced code blocks with a language tag for copyable examples.
|
||||
- Keep examples short and focused on the behavior being documented.
|
||||
- Prefer real component names and attributes over prose-only descriptions.
|
||||
- Use Home Assistant terminology from the root `AGENTS.md`.
|
||||
- For remove/delete and add/create wording, follow `src/pages/misc/remove-delete-add-create.markdown`.
|
||||
- Use Home Assistant terminology from `ha-frontend-user-facing-text`.
|
||||
- For remove/delete and add/create wording, follow `gallery/src/pages/misc/remove-delete-add-create.markdown`.
|
||||
|
||||
Gallery markdown is documentation content and is not localized with `localize`. If demo code creates production UI strings, keep those strings aligned with the root localization and copy guidance.
|
||||
Gallery markdown is documentation content and is not localized with `localize`. If demo code creates production UI strings, follow the localization and copy guidance in `ha-frontend-user-facing-text`.
|
||||
|
||||
## Demo Components
|
||||
|
||||
Use TypeScript demo pages for interactive or stateful examples.
|
||||
|
||||
- Import production components from `../../../src/...` or the correct relative path from the demo file.
|
||||
- Import production components from `src/` using the correct relative path from the demo file.
|
||||
- Import reusable gallery helpers from `gallery/src/components/` when they already model the pattern.
|
||||
- Use `demo-card` and `demo-cards` for Lovelace card examples that render YAML card configs.
|
||||
- Use `demo-more-info` and `demo-more-infos` for more-info dialog examples.
|
||||
- Use shared mock data from `src/data/` instead of repeating large fake state objects inline.
|
||||
- Use shared mock data from `gallery/src/data/` instead of repeating large fake state objects inline.
|
||||
- Show meaningful states, such as loading, unavailable, empty, error, active, inactive, and disabled when relevant.
|
||||
- Check responsive behavior and the gallery RTL toggle when layout or direction-sensitive UI changes.
|
||||
- Keep unavoidable casts or loose demo parsing local to the demo helper or demo page.
|
||||
@@ -105,7 +110,7 @@ The gallery ESLint config allows `console` for gallery diagnostics. Do not copy
|
||||
|
||||
## Content Standards
|
||||
|
||||
The root copy standards still apply: use American English, sentence case, active voice, inclusive language, direct user-focused wording, and consistent Home Assistant terminology.
|
||||
Follow the detailed copy standards in `ha-frontend-user-facing-text`: use American English, sentence case, active voice, inclusive language, direct user-focused wording, and consistent Home Assistant terminology.
|
||||
|
||||
- Use `Home Assistant` in full, not `HA` or `HASS`.
|
||||
- Use `integration` instead of `component` for product concepts.
|
||||
@@ -1,12 +1,18 @@
|
||||
---
|
||||
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.
|
||||
description: Home Assistant frontend testing and validation workflow. Use when adding or updating tests, 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.
|
||||
|
||||
## Test Helpers
|
||||
|
||||
- Before adding or changing tests, inspect the relevant suite's existing helpers and fixtures. Reuse them instead of duplicating setup, test data, navigation, interactions, waits, or assertions.
|
||||
- When the same test flow appears more than once, move it into the closest suite-local helper with a focused interface.
|
||||
- Keep one-off test behaviour in the test unless a helper makes the intent materially clearer. Do not hide the behaviour under test behind broad, configurable abstractions.
|
||||
|
||||
## Core Commands
|
||||
|
||||
```bash
|
||||
@@ -33,33 +39,35 @@ For focused type feedback on one file, use editor diagnostics instead of a file-
|
||||
|
||||
`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.
|
||||
`yarn dev:serve` also serves locally and supports `-c` for the core URL and `-p` for the port. The default is 8124, or 8123 in a devcontainer.
|
||||
|
||||
Dev server commands support `--background`, `--status`, `--stop`, and `--logs [--follow]`.
|
||||
Dev server commands support `--background`, `--status`, `--stop`, and `--logs [--follow]`. Prefer managed background mode while iterating so the watcher stays available across test runs without occupying the terminal.
|
||||
|
||||
## 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.
|
||||
Each suite has its own dev server port. Playwright reuses an existing server locally when its configured URL responds; otherwise it performs a slow full build. When a development watcher is being reused, rspack recompiles on save and 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` |
|
||||
| Suite | Background server | Test command |
|
||||
| ------- | -------------------------------------------- | ----------------------- |
|
||||
| App | `yarn test:e2e:app:dev --background` on 8095 | `yarn test:e2e:app` |
|
||||
| Demo | `yarn dev:demo --background` on 8090 | `yarn test:e2e:demo` |
|
||||
| Gallery | `yarn dev:gallery --background` on 8100 | `yarn test:e2e:gallery` |
|
||||
|
||||
Server reuse and `--stop` use the `/__ha_dev_status` health check, so starting or stopping twice is harmless.
|
||||
The custom development wrappers use `/__ha_dev_status` to identify and manage their own suites. Playwright server reuse checks the configured URL instead. Wrapper start and stop operations are idempotent for a matching suite and reject an unrelated process occupying the port.
|
||||
|
||||
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.
|
||||
Local runs against a watched development server do not always match CI's clean build artifacts, environment, sharding, or worker configuration. Use background servers for the fast iteration loop, but confirm the relevant CI jobs complete successfully before considering E2E changes verified.
|
||||
|
||||
Use `-g "<title>" --project=chromium` to narrow a run. `yarn test:e2e` runs all three suites in parallel when every managed server is available, otherwise it runs them sequentially to prevent cold builds racing over shared generated assets. 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`.
|
||||
For chart data transforms such as history, statistics, energy, and downsampling, read and follow the complete workflow in `test/benchmarks/README.md` before making benchmark or optimization changes.
|
||||
|
||||
Use seeded fixtures, characterization snapshot tests, and `yarn test:bench` before and after optimization. Optimizations must keep output bit-identical.
|
||||
That workflow owns the baseline, noise analysis, guardrails, acceptance thresholds, and reporting requirements. In particular, optimizations must keep output bit-identical; never update snapshots or modify fixtures to make an optimization pass.
|
||||
|
||||
## Verification Selection
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ Use this skill for all user-facing text, translations, labels, buttons, dialog c
|
||||
- Give translators enough context through key naming and placeholders.
|
||||
|
||||
```ts
|
||||
this.hass.localize("ui.panel.config.updates.update_available", {
|
||||
this._localize("ui.panel.config.updates.updates_refreshed", {
|
||||
count: 5,
|
||||
});
|
||||
```
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
name: Prepare dependencies
|
||||
description: Install and cache the complete dependency tree
|
||||
|
||||
inputs:
|
||||
node-modules-cache-key:
|
||||
description: Prefix for the shared node_modules cache key
|
||||
default: node-modules-v1
|
||||
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
- name: Check for complete dependency tree
|
||||
id: dependencies
|
||||
continue-on-error: true
|
||||
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
with:
|
||||
path: |
|
||||
node_modules
|
||||
.yarn/install-state.gz
|
||||
key: >-
|
||||
${{ inputs.node-modules-cache-key }}-${{ runner.os }}-${{ runner.arch }}-${{
|
||||
hashFiles('.nvmrc', 'package.json', 'yarn.lock', '.yarnrc.yml', '.yarn/releases/**', '.yarn/patches/**') }}
|
||||
lookup-only: true
|
||||
|
||||
- name: Setup Node and install
|
||||
if: steps.dependencies.outputs.cache-hit != 'true'
|
||||
uses: ./.github/actions/setup
|
||||
with:
|
||||
cache: false
|
||||
|
||||
- name: Save complete dependency tree
|
||||
if: steps.dependencies.outputs.cache-hit != 'true'
|
||||
continue-on-error: true
|
||||
uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
with:
|
||||
path: |
|
||||
node_modules
|
||||
.yarn/install-state.gz
|
||||
key: >-
|
||||
${{ inputs.node-modules-cache-key }}-${{ runner.os }}-${{ runner.arch }}-${{
|
||||
hashFiles('.nvmrc', 'package.json', 'yarn.lock', '.yarnrc.yml', '.yarn/releases/**', '.yarn/patches/**') }}
|
||||
@@ -8,16 +8,40 @@ inputs:
|
||||
cache:
|
||||
description: Enable the yarn cache in setup-node
|
||||
default: "true"
|
||||
node-modules-cache:
|
||||
description: Restore the exact shared node_modules cache before installing
|
||||
default: "false"
|
||||
node-modules-cache-key:
|
||||
description: Prefix for the shared node_modules cache key
|
||||
default: node-modules-v1
|
||||
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
- name: Restore complete dependency tree
|
||||
id: dependency-cache
|
||||
if: inputs.node-modules-cache == 'true'
|
||||
continue-on-error: true
|
||||
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
with:
|
||||
path: |
|
||||
node_modules
|
||||
.yarn/install-state.gz
|
||||
key: >-
|
||||
${{ inputs.node-modules-cache-key }}-${{ runner.os }}-${{ runner.arch }}-${{
|
||||
hashFiles('.nvmrc', 'package.json', 'yarn.lock', '.yarnrc.yml', '.yarn/releases/**', '.yarn/patches/**') }}
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
with:
|
||||
node-version-file: ".nvmrc"
|
||||
cache: ${{ inputs.cache == 'true' && 'yarn' || '' }}
|
||||
cache: ${{ inputs.cache == 'true' && (inputs.node-modules-cache != 'true' || steps.dependency-cache.outputs.cache-hit != 'true') && 'yarn' || '' }}
|
||||
|
||||
- name: Enable Corepack
|
||||
shell: bash
|
||||
run: corepack enable
|
||||
|
||||
- name: Install dependencies
|
||||
if: inputs.node-modules-cache != 'true' || steps.dependency-cache.outputs.cache-hit != 'true'
|
||||
shell: bash
|
||||
run: yarn install ${{ inputs.immutable == 'true' && '--immutable' || '' }}
|
||||
|
||||
@@ -15,6 +15,10 @@ updates:
|
||||
cooldown:
|
||||
default-days: 7
|
||||
open-pull-requests-limit: 10
|
||||
groups:
|
||||
codeql-action:
|
||||
patterns:
|
||||
- "github/codeql-action/*"
|
||||
labels:
|
||||
- Dependencies
|
||||
- GitHub Actions
|
||||
|
||||
@@ -1,3 +1,21 @@
|
||||
Agents:
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- "**/AGENTS.md"
|
||||
- "**/CLAUDE.md"
|
||||
- "**/GEMINI.md"
|
||||
- .agents/**
|
||||
- .claude/**
|
||||
- .github/agents/**
|
||||
- .github/copilot-instructions.md
|
||||
- .github/hooks/**
|
||||
- .github/instructions/**
|
||||
- .github/plugin.json
|
||||
- .github/plugin/**
|
||||
- .github/prompts/**
|
||||
- .github/skills/**
|
||||
- .github/workflows/copilot-setup-steps.yml
|
||||
|
||||
Build:
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
|
||||
@@ -1,126 +0,0 @@
|
||||
name: CI accelerator benchmark
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
branches:
|
||||
- workflows-global-build-image
|
||||
|
||||
env:
|
||||
NODE_OPTIONS: --max_old_space_size=6144
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
baseline:
|
||||
name: Baseline / ${{ matrix.workload }}
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
workload:
|
||||
- lint
|
||||
- test
|
||||
steps:
|
||||
- name: Check out files from GitHub
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Setup Node and install
|
||||
uses: ./.github/actions/setup
|
||||
- name: Check for duplicate dependencies
|
||||
if: matrix.workload == 'lint'
|
||||
run: yarn dedupe --check
|
||||
- name: Build resources
|
||||
id: build_resources
|
||||
run: >-
|
||||
./node_modules/.bin/gulp gen-icons-json build-translations build-locale-data
|
||||
${{ matrix.workload == 'lint' && 'gather-gallery-pages' || '' }}
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
- name: Setup lint cache
|
||||
if: matrix.workload == 'lint'
|
||||
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
with:
|
||||
path: |
|
||||
node_modules/.cache/prettier
|
||||
node_modules/.cache/eslint
|
||||
node_modules/.cache/typescript
|
||||
key: lint-${{ github.sha }}
|
||||
restore-keys: lint-
|
||||
- name: Run eslint
|
||||
if: matrix.workload == 'lint'
|
||||
run: yarn run lint:eslint --quiet
|
||||
- name: Run tsc
|
||||
if: matrix.workload == 'lint' && !cancelled() && steps.build_resources.outcome == 'success'
|
||||
run: yarn run lint:types
|
||||
- name: Run lit-analyzer
|
||||
if: matrix.workload == 'lint' && !cancelled() && steps.build_resources.outcome == 'success'
|
||||
run: yarn run lint:lit --quiet
|
||||
- name: Run prettier
|
||||
if: matrix.workload == 'lint' && !cancelled() && steps.build_resources.outcome == 'success'
|
||||
run: yarn run lint:prettier
|
||||
- name: Check dependency licenses
|
||||
if: matrix.workload == 'lint' && !cancelled() && steps.build_resources.outcome == 'success'
|
||||
run: yarn run lint:licenses
|
||||
- name: Run tests
|
||||
if: matrix.workload == 'test'
|
||||
run: yarn run test
|
||||
|
||||
image:
|
||||
name: Image / ${{ matrix.workload }}
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: ghcr.io/timmo001/home-assistant-frontend-ci-images/frontend-workflow@sha256:ffa4d621b4495bf456b7ab3c632f550e329fe78c2d571167aa0899a52c4932c3
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
workload:
|
||||
- lint
|
||||
- test
|
||||
steps:
|
||||
- name: Check out files from GitHub
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Install dependencies
|
||||
run: frontend-install
|
||||
- name: Check for duplicate dependencies
|
||||
if: matrix.workload == 'lint'
|
||||
run: yarn dedupe --check
|
||||
- name: Build resources
|
||||
id: build_resources
|
||||
run: >-
|
||||
./node_modules/.bin/gulp gen-icons-json build-translations build-locale-data
|
||||
${{ matrix.workload == 'lint' && 'gather-gallery-pages' || '' }}
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
- name: Setup lint cache
|
||||
if: matrix.workload == 'lint'
|
||||
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
with:
|
||||
path: |
|
||||
node_modules/.cache/prettier
|
||||
node_modules/.cache/eslint
|
||||
node_modules/.cache/typescript
|
||||
key: lint-${{ github.sha }}
|
||||
restore-keys: lint-
|
||||
- name: Run eslint
|
||||
if: matrix.workload == 'lint'
|
||||
run: yarn run lint:eslint --quiet
|
||||
- name: Run tsc
|
||||
if: matrix.workload == 'lint' && !cancelled() && steps.build_resources.outcome == 'success'
|
||||
run: yarn run lint:types
|
||||
- name: Run lit-analyzer
|
||||
if: matrix.workload == 'lint' && !cancelled() && steps.build_resources.outcome == 'success'
|
||||
run: yarn run lint:lit --quiet
|
||||
- name: Run prettier
|
||||
if: matrix.workload == 'lint' && !cancelled() && steps.build_resources.outcome == 'success'
|
||||
run: yarn run lint:prettier
|
||||
- name: Check dependency licenses
|
||||
if: matrix.workload == 'lint' && !cancelled() && steps.build_resources.outcome == 'success'
|
||||
run: yarn run lint:licenses
|
||||
- name: Run tests
|
||||
if: matrix.workload == 'test'
|
||||
run: yarn run test
|
||||
@@ -1,6 +1,7 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
branches:
|
||||
- dev
|
||||
@@ -21,16 +22,30 @@ permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
lint:
|
||||
name: Lint and check format
|
||||
prepare-dependencies:
|
||||
name: Prepare dependencies
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out files from GitHub
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Setup Node and install
|
||||
- name: Prepare dependencies
|
||||
uses: ./.github/actions/prepare-dependencies
|
||||
|
||||
lint:
|
||||
name: Lint and check format
|
||||
needs: prepare-dependencies
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out files from GitHub
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Setup Node with shared dependencies
|
||||
uses: ./.github/actions/setup
|
||||
with:
|
||||
node-modules-cache: true
|
||||
- name: Check for duplicate dependencies
|
||||
run: yarn dedupe --check
|
||||
- name: Build resources
|
||||
@@ -63,14 +78,17 @@ jobs:
|
||||
run: yarn run lint:licenses
|
||||
test:
|
||||
name: Run tests
|
||||
needs: prepare-dependencies
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out files from GitHub
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Setup Node and install
|
||||
- name: Setup Node with shared dependencies
|
||||
uses: ./.github/actions/setup
|
||||
with:
|
||||
node-modules-cache: true
|
||||
- name: Build resources
|
||||
run: ./node_modules/.bin/gulp gen-icons-json build-translations build-locale-data
|
||||
env:
|
||||
@@ -80,6 +98,7 @@ jobs:
|
||||
build:
|
||||
name: Build frontend
|
||||
needs:
|
||||
- prepare-dependencies
|
||||
- lint
|
||||
- test
|
||||
runs-on: ubuntu-latest
|
||||
@@ -88,8 +107,10 @@ jobs:
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Setup Node and install
|
||||
- name: Setup Node with shared dependencies
|
||||
uses: ./.github/actions/setup
|
||||
with:
|
||||
node-modules-cache: true
|
||||
- name: Build Application
|
||||
uses: ./.github/actions/build
|
||||
with:
|
||||
|
||||
@@ -32,12 +32,12 @@ jobs:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@54f647b7e1bb85c95cddabcd46b0c578ec92bc1a # v4.36.3
|
||||
uses: github/codeql-action/init@99df26d4f13ea111d4ec1a7dddef6063f76b97e9 # v4.37.0
|
||||
with:
|
||||
languages: javascript-typescript
|
||||
build-mode: none
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@54f647b7e1bb85c95cddabcd46b0c578ec92bc1a # v4.36.3
|
||||
uses: github/codeql-action/analyze@99df26d4f13ea111d4ec1a7dddef6063f76b97e9 # v4.37.0
|
||||
with:
|
||||
category: "/language:javascript-typescript"
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
name: Copilot Setup Steps
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
paths:
|
||||
- .github/workflows/copilot-setup-steps.yml
|
||||
pull_request:
|
||||
paths:
|
||||
- .github/workflows/copilot-setup-steps.yml
|
||||
env:
|
||||
NODE_OPTIONS: --max_old_space_size=6144
|
||||
|
||||
jobs:
|
||||
copilot-setup-steps:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- name: Check out files from GitHub
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup Node and install
|
||||
uses: ./.github/actions/setup
|
||||
with:
|
||||
node-modules-cache: true
|
||||
|
||||
- name: Skip fetching nightly translations
|
||||
run: echo "SKIP_FETCH_NIGHTLY_TRANSLATIONS=1" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Build resources
|
||||
run: ./node_modules/.bin/gulp gen-icons-json build-translations build-locale-data gather-gallery-pages
|
||||
@@ -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
|
||||
|
||||
+223
-47
@@ -22,9 +22,8 @@ permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
# ── Build the demo once and share it across test jobs via artifact ──────────
|
||||
build-demo:
|
||||
name: Build demo
|
||||
prepare-dependencies:
|
||||
name: Prepare dependencies
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out files from GitHub
|
||||
@@ -32,14 +31,51 @@ jobs:
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup Node and install
|
||||
- name: Prepare dependencies
|
||||
uses: ./.github/actions/prepare-dependencies
|
||||
|
||||
prepare-container-dependencies:
|
||||
name: Prepare container dependencies
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: mcr.microsoft.com/playwright:v1.61.1-noble
|
||||
options: --user 1001
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
steps:
|
||||
- name: Check out files from GitHub
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Prepare dependencies
|
||||
uses: ./.github/actions/prepare-dependencies
|
||||
with:
|
||||
node-modules-cache-key: node-modules-container-v1
|
||||
|
||||
# ── Build the demo once and share it across test jobs via artifact ──────────
|
||||
build-demo:
|
||||
name: Build demo
|
||||
needs: prepare-dependencies
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out files from GitHub
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup Node with shared dependencies
|
||||
uses: ./.github/actions/setup
|
||||
with:
|
||||
node-modules-cache: true
|
||||
|
||||
- name: Build demo
|
||||
uses: ./.github/actions/build
|
||||
with:
|
||||
target: build-demo
|
||||
target: build-demo-e2e
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
is-test: true
|
||||
|
||||
- name: Upload demo build
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
@@ -52,6 +88,7 @@ jobs:
|
||||
# ── Build the e2e test app and share it via artifact ────────────────────────
|
||||
build-e2e-test-app:
|
||||
name: Build e2e test app
|
||||
needs: prepare-dependencies
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out files from GitHub
|
||||
@@ -59,14 +96,17 @@ jobs:
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup Node and install
|
||||
- name: Setup Node with shared dependencies
|
||||
uses: ./.github/actions/setup
|
||||
with:
|
||||
node-modules-cache: true
|
||||
|
||||
- name: Build e2e test app
|
||||
uses: ./.github/actions/build
|
||||
with:
|
||||
target: build-e2e-test-app
|
||||
target: build-e2e-test-app-e2e
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
is-test: true
|
||||
|
||||
- name: Upload e2e test app build
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
@@ -79,6 +119,7 @@ jobs:
|
||||
# ── Build the gallery and share it via artifact ─────────────────────────────
|
||||
build-gallery:
|
||||
name: Build gallery
|
||||
needs: prepare-dependencies
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out files from GitHub
|
||||
@@ -86,8 +127,10 @@ jobs:
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup Node and install
|
||||
- name: Setup Node with shared dependencies
|
||||
uses: ./.github/actions/setup
|
||||
with:
|
||||
node-modules-cache: true
|
||||
|
||||
- name: Build gallery
|
||||
uses: ./.github/actions/build
|
||||
@@ -103,41 +146,39 @@ jobs:
|
||||
if-no-files-found: error
|
||||
retention-days: 3
|
||||
|
||||
# ── Run Playwright tests locally against Chromium ──────────────────────────
|
||||
e2e-local:
|
||||
name: E2E (local Chromium)
|
||||
needs: [build-demo, build-e2e-test-app, build-gallery]
|
||||
# ── Run Playwright tests against Chromium ──────────────────────────────────
|
||||
e2e-demo:
|
||||
name: E2E demo (${{ matrix.shardIndex }}/${{ matrix.shardTotal }})
|
||||
needs:
|
||||
- build-demo
|
||||
- prepare-container-dependencies
|
||||
runs-on: ubuntu-latest
|
||||
# Fail fast if anything hangs. The whole suite should take < 15 minutes on
|
||||
# Chromium; anything longer is almost certainly an install or webServer
|
||||
# hang.
|
||||
timeout-minutes: 30
|
||||
container:
|
||||
image: mcr.microsoft.com/playwright:v1.61.1-noble
|
||||
options: --user 1001 --ipc=host
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
timeout-minutes: 20
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
shardIndex:
|
||||
- 1
|
||||
- 2
|
||||
shardTotal:
|
||||
- 2
|
||||
steps:
|
||||
- name: Check out files from GitHub
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup Node and install
|
||||
- name: Setup Node with shared dependencies
|
||||
uses: ./.github/actions/setup
|
||||
|
||||
# Resolve the installed Playwright version so the browser cache tracks
|
||||
# Playwright itself, not every unrelated dependency bump.
|
||||
- name: Resolve Playwright version
|
||||
id: playwright-version
|
||||
run: echo "version=$(node -p 'require("@playwright/test/package.json").version')" >> "$GITHUB_OUTPUT"
|
||||
|
||||
# Cache the downloaded browser build keyed on the installed Playwright
|
||||
# version, so re-runs skip the ~170 MB download unless Playwright changes.
|
||||
- name: Cache Playwright browsers
|
||||
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
with:
|
||||
path: ~/.cache/ms-playwright
|
||||
key: ${{ runner.os }}-playwright-${{ steps.playwright-version.outputs.version }}
|
||||
|
||||
- name: Install Playwright browsers
|
||||
run: yarn playwright install --with-deps chromium
|
||||
timeout-minutes: 10
|
||||
node-modules-cache: true
|
||||
node-modules-cache-key: node-modules-container-v1
|
||||
|
||||
- name: Download demo build
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
@@ -145,36 +186,135 @@ jobs:
|
||||
name: demo-dist
|
||||
path: demo/dist/
|
||||
|
||||
- name: Run Playwright demo tests
|
||||
run: yarn test:e2e:demo --shard=${{ matrix.shardIndex }}/${{ matrix.shardTotal }}
|
||||
timeout-minutes: 15
|
||||
|
||||
- name: Upload demo blob report
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
if: always()
|
||||
with:
|
||||
name: blob-report-demo-${{ matrix.shardIndex }}
|
||||
path: test/e2e/reports/demo/
|
||||
if-no-files-found: warn
|
||||
retention-days: 3
|
||||
|
||||
e2e-app:
|
||||
name: E2E app (${{ matrix.shardIndex }}/${{ matrix.shardTotal }})
|
||||
needs:
|
||||
- build-e2e-test-app
|
||||
- prepare-container-dependencies
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: mcr.microsoft.com/playwright:v1.61.1-noble
|
||||
options: --user 1001 --ipc=host
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
timeout-minutes: 20
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
shardIndex:
|
||||
- 1
|
||||
- 2
|
||||
- 3
|
||||
- 4
|
||||
shardTotal:
|
||||
- 4
|
||||
steps:
|
||||
- name: Check out files from GitHub
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup Node with shared dependencies
|
||||
uses: ./.github/actions/setup
|
||||
with:
|
||||
node-modules-cache: true
|
||||
node-modules-cache-key: node-modules-container-v1
|
||||
|
||||
- name: Download e2e test app build
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
with:
|
||||
name: e2e-test-app-dist
|
||||
path: test/e2e/app/dist/
|
||||
|
||||
- name: Run Playwright app tests
|
||||
run: yarn test:e2e:app --shard=${{ matrix.shardIndex }}/${{ matrix.shardTotal }}
|
||||
timeout-minutes: 15
|
||||
|
||||
- name: Upload app blob report
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
if: always()
|
||||
with:
|
||||
name: blob-report-app-${{ matrix.shardIndex }}
|
||||
path: test/e2e/reports/app/
|
||||
if-no-files-found: warn
|
||||
retention-days: 3
|
||||
|
||||
e2e-gallery:
|
||||
name: E2E gallery (${{ matrix.shardIndex }}/${{ matrix.shardTotal }})
|
||||
needs:
|
||||
- build-gallery
|
||||
- prepare-container-dependencies
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: mcr.microsoft.com/playwright:v1.61.1-noble
|
||||
options: --user 1001 --ipc=host
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
timeout-minutes: 20
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
shardIndex:
|
||||
- 1
|
||||
- 2
|
||||
- 3
|
||||
- 4
|
||||
shardTotal:
|
||||
- 4
|
||||
steps:
|
||||
- name: Check out files from GitHub
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup Node with shared dependencies
|
||||
uses: ./.github/actions/setup
|
||||
with:
|
||||
node-modules-cache: true
|
||||
node-modules-cache-key: node-modules-container-v1
|
||||
|
||||
- name: Download gallery build
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
with:
|
||||
name: gallery-dist
|
||||
path: gallery/dist/
|
||||
|
||||
- name: Run Playwright tests (local)
|
||||
run: yarn test:e2e
|
||||
- name: Run Playwright gallery tests
|
||||
run: yarn test:e2e:gallery --shard=${{ matrix.shardIndex }}/${{ matrix.shardTotal }}
|
||||
timeout-minutes: 15
|
||||
|
||||
- name: Upload blob report
|
||||
- name: Upload gallery blob report
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
if: always()
|
||||
with:
|
||||
name: blob-report-local
|
||||
path: test/e2e/reports/
|
||||
name: blob-report-gallery-${{ matrix.shardIndex }}
|
||||
path: test/e2e/reports/gallery/
|
||||
if-no-files-found: warn
|
||||
retention-days: 3
|
||||
|
||||
# ── Merge local blob reports and post PR comment ───────────────────────────
|
||||
report:
|
||||
name: Report
|
||||
needs: [e2e-local]
|
||||
needs:
|
||||
- e2e-demo
|
||||
- e2e-app
|
||||
- e2e-gallery
|
||||
runs-on: ubuntu-latest
|
||||
if: ${{ !cancelled() }}
|
||||
if: ${{ always() }}
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
@@ -184,15 +324,31 @@ jobs:
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup Node and install
|
||||
- name: Setup Node with shared dependencies
|
||||
uses: ./.github/actions/setup
|
||||
with:
|
||||
node-modules-cache: true
|
||||
|
||||
- name: Download blob report (local)
|
||||
- name: Download demo blob reports
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
continue-on-error: true
|
||||
with:
|
||||
name: blob-report-local
|
||||
path: test/e2e/reports/
|
||||
pattern: blob-report-demo-*
|
||||
path: test/e2e/reports/demo/
|
||||
|
||||
- name: Download app blob reports
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
continue-on-error: true
|
||||
with:
|
||||
pattern: blob-report-app-*
|
||||
path: test/e2e/reports/app/
|
||||
|
||||
- name: Download gallery blob reports
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
continue-on-error: true
|
||||
with:
|
||||
pattern: blob-report-gallery-*
|
||||
path: test/e2e/reports/gallery/
|
||||
|
||||
- name: Stage blobs for merge
|
||||
run: node test/e2e/collect-blob-reports.mjs
|
||||
@@ -209,7 +365,11 @@ jobs:
|
||||
retention-days: 14
|
||||
|
||||
- name: Post report to PR
|
||||
if: github.event_name == 'pull_request' && needs.e2e-local.result == 'failure'
|
||||
if: >-
|
||||
github.event_name == 'pull_request' &&
|
||||
(needs.e2e-demo.result == 'failure' ||
|
||||
needs.e2e-app.result == 'failure' ||
|
||||
needs.e2e-gallery.result == 'failure')
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
with:
|
||||
script: |
|
||||
@@ -217,3 +377,19 @@ jobs:
|
||||
`${process.env.GITHUB_WORKSPACE}/test/e2e/post-report-comment.mjs`
|
||||
);
|
||||
await postReportComment({ github, context, core });
|
||||
|
||||
- name: Check suite results
|
||||
run: |
|
||||
failed=0
|
||||
for suite in \
|
||||
"demo:${{ needs.e2e-demo.result }}" \
|
||||
"app:${{ needs.e2e-app.result }}" \
|
||||
"gallery:${{ needs.e2e-gallery.result }}"; do
|
||||
name="${suite%%:*}"
|
||||
result="${suite#*:}"
|
||||
echo "E2E ${name}: ${result}"
|
||||
if [ "$result" != "success" ]; then
|
||||
failed=1
|
||||
fi
|
||||
done
|
||||
exit "$failed"
|
||||
|
||||
@@ -10,6 +10,6 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Apply labels
|
||||
uses: actions/labeler@f27b608878404679385c85cfa523b85ccb86e213 # v6.1.0
|
||||
uses: actions/labeler@b8dd2d9be0f68b860e7dae5dae7d772984eacd6d # v6.2.0
|
||||
with:
|
||||
sync-labels: true
|
||||
|
||||
@@ -15,7 +15,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: 90 days stale policy
|
||||
uses: actions/stale@eb5cf3af3ac0a1aa4c9c45633dd1ae542a27a899 # v10.3.0
|
||||
uses: actions/stale@1e223db275d687790206a7acac4d1a11bd6fe629 # v10.4.0
|
||||
with:
|
||||
repo-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
days-before-stale: 90
|
||||
|
||||
@@ -2,8 +2,6 @@
|
||||
|
||||
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
|
||||
@@ -46,7 +44,16 @@ Detailed guidance lives in project skills under `.agents/skills/`. Load the matc
|
||||
- `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.
|
||||
- `ha-frontend-gallery`: gallery pages, demos, sidebar structure, content, and verification.
|
||||
|
||||
## 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.
|
||||
|
||||
## AI policy
|
||||
|
||||
This project follows the [Open Home Foundation AI Policy](AI_POLICY.md).
|
||||
Autonomous contributions are not accepted: a human must review, understand,
|
||||
and be able to explain every change before it is submitted. Do not open
|
||||
issues or pull requests autonomously, and do not post comments on behalf of
|
||||
a user without their review.
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
# Open Home Foundation - AI Policy
|
||||
|
||||
We support using AI (i.e., LLMs) as tools when contributing to Open Home Foundation projects. However, you are responsible for any contributions you submit, and we are responsible for any contributions we merge and release. We hold a high bar for all contributions to our projects.
|
||||
|
||||
Our maintainers dedicate their time and expertise to reviewing contributions. Submitting AI-generated content that you have not personally reviewed and understood wastes that time and will not be accepted.
|
||||
|
||||
## Autonomous agents
|
||||
|
||||
**We do not allow autonomous agents to be used for contributing to our projects.** We will close any pull requests or issues that we believe were created autonomously, and may mark automated comments as spam. This includes contributions that bypass the provided issue or pull request templates.
|
||||
|
||||
## Communication on issues, pull requests, and code reviews
|
||||
|
||||
We don't mind if you use AI tools to help you write. However, do not have tools post unreviewed content on your behalf. Keep responses to the minimum needed to communicate your intent. We may hide any comments that we believe are unreviewed AI output.
|
||||
|
||||
If you are opening a pull request, we expect you to be able to explain the proposed changes in your own words. This includes the pull request description and responses to questions. If you use AI to help generate the pull request summary, you must review it for technical accuracy.
|
||||
|
||||
**Do not use AI to generate answers to questions from maintainers.** You should understand and be able to explain your own work. Using AI to improve grammar or clarity is fine, but the substance of your responses must be your own.
|
||||
|
||||
If you wish to include context from an interaction with AI in your comments, it must be in a quote block (e.g., using `>`) and disclosed as such. It must be accompanied by your own commentary explaining the relevance and implications of the context. Do not share long snippets.
|
||||
|
||||
## Non-native English speakers
|
||||
|
||||
We understand that AI is useful when communicating as a non-native English speaker. Using AI to improve the grammar or clarity of text you have written yourself is fine. If you are using AI to translate your comments, please ensure the translation accurately reflects your intent. Including your original text in a details block shows the effort behind your contribution, helps maintainers verify the translation if needed, and keeps the conversation readable.
|
||||
|
||||
## Code and documentation contributions
|
||||
|
||||
AI can be a helpful tool for writing code and documentation. However, due to the foundational open source nature of our projects, we require a human in the loop who understands the work produced by AI.
|
||||
|
||||
All contributions must be reviewed and understood by the contributor before submission. You should be able to explain every change in a pull request you submit. Pull requests that appear to be unreviewed AI output will be closed without review.
|
||||
|
||||
## Our use of AI
|
||||
|
||||
Some of our projects use AI tools to assist with code reviews, issue triaging, reporting, and other project management tasks. These tools may leave comments on pull requests or issues. As with any automated tooling, these comments are not always correct.
|
||||
|
||||
If an AI tool leaves a comment on your contribution, treat it as you would any other review comment. If you believe it is incorrect, say so; a brief explanation is sufficient. Maintainers always have the final say. If in doubt, ask a maintainer.
|
||||
|
||||
## Enforcement
|
||||
|
||||
Contributions that do not follow this policy will be closed. Repeated violations may result in being blocked from contributing to OHF projects. If you believe your contribution was closed in error, you are welcome to reach out to a maintainer to discuss.
|
||||
|
||||
---
|
||||
|
||||
The canonical version of this policy is published at
|
||||
<https://developers.home-assistant.io/docs/ai_policy>. In case of differences,
|
||||
the published version applies.
|
||||
@@ -232,7 +232,7 @@ module.exports.config = {
|
||||
};
|
||||
},
|
||||
|
||||
demo({ isProdBuild, latestBuild, isStatsBuild }) {
|
||||
demo({ isProdBuild, latestBuild, isStatsBuild, isTestBuild }) {
|
||||
return {
|
||||
name: "demo" + nameSuffix(latestBuild),
|
||||
entry: {
|
||||
@@ -247,6 +247,7 @@ module.exports.config = {
|
||||
isProdBuild,
|
||||
latestBuild,
|
||||
isStatsBuild,
|
||||
isTestBuild,
|
||||
};
|
||||
},
|
||||
|
||||
@@ -306,7 +307,7 @@ module.exports.config = {
|
||||
};
|
||||
},
|
||||
|
||||
e2eTestApp({ isProdBuild, latestBuild, isStatsBuild }) {
|
||||
e2eTestApp({ isProdBuild, latestBuild, isStatsBuild, isTestBuild }) {
|
||||
return {
|
||||
name: "e2e-test-app" + nameSuffix(latestBuild),
|
||||
entry: {
|
||||
@@ -321,6 +322,7 @@ module.exports.config = {
|
||||
isProdBuild,
|
||||
latestBuild,
|
||||
isStatsBuild,
|
||||
isTestBuild,
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
@@ -42,6 +42,22 @@ gulp.task(
|
||||
)
|
||||
);
|
||||
|
||||
gulp.task(
|
||||
"build-demo-e2e",
|
||||
gulp.series(
|
||||
async function setEnv() {
|
||||
process.env.NODE_ENV = "production";
|
||||
},
|
||||
"clean-demo",
|
||||
// Cast needs to be backwards compatible and older HA has no translations
|
||||
"translations-enable-merge-backend",
|
||||
gulp.parallel("gen-icons-json", "build-translations", "build-locale-data"),
|
||||
"copy-static-demo",
|
||||
"rspack-prod-demo-e2e",
|
||||
"gen-pages-demo-prod-e2e"
|
||||
)
|
||||
);
|
||||
|
||||
gulp.task(
|
||||
"analyze-demo",
|
||||
gulp.series(
|
||||
|
||||
@@ -39,3 +39,18 @@ gulp.task(
|
||||
"gen-pages-e2e-test-app-prod"
|
||||
)
|
||||
);
|
||||
|
||||
gulp.task(
|
||||
"build-e2e-test-app-e2e",
|
||||
gulp.series(
|
||||
async function setEnv() {
|
||||
process.env.NODE_ENV = "production";
|
||||
},
|
||||
"clean-e2e-test-app",
|
||||
"translations-enable-merge-backend",
|
||||
gulp.parallel("gen-icons-json", "build-translations", "build-locale-data"),
|
||||
"copy-static-e2e-test-app",
|
||||
"rspack-prod-e2e-test-app-e2e",
|
||||
"gen-pages-e2e-test-app-prod"
|
||||
)
|
||||
);
|
||||
|
||||
@@ -225,6 +225,16 @@ gulp.task(
|
||||
)
|
||||
);
|
||||
|
||||
gulp.task(
|
||||
"gen-pages-demo-prod-e2e",
|
||||
genPagesProdTask(
|
||||
DEMO_PAGE_ENTRIES,
|
||||
paths.demo_dir,
|
||||
paths.demo_output_root,
|
||||
paths.demo_output_latest
|
||||
)
|
||||
);
|
||||
|
||||
const GALLERY_PAGE_ENTRIES = { "index.html": ["entrypoint"] };
|
||||
|
||||
gulp.task(
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
];
|
||||
|
||||
@@ -177,6 +177,18 @@ gulp.task("rspack-prod-demo", () =>
|
||||
bothBuilds(createDemoConfig, {
|
||||
isProdBuild: true,
|
||||
isStatsBuild: env.isStatsBuild(),
|
||||
isTestBuild: env.isTestBuild(),
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
gulp.task("rspack-prod-demo-e2e", () =>
|
||||
prodBuild(
|
||||
createDemoConfig({
|
||||
isProdBuild: true,
|
||||
latestBuild: true,
|
||||
isStatsBuild: env.isStatsBuild(),
|
||||
isTestBuild: env.isTestBuild(),
|
||||
})
|
||||
)
|
||||
);
|
||||
@@ -269,6 +281,18 @@ gulp.task("rspack-prod-e2e-test-app", () =>
|
||||
bothBuilds(createE2eTestAppConfig, {
|
||||
isProdBuild: true,
|
||||
isStatsBuild: env.isStatsBuild(),
|
||||
isTestBuild: env.isTestBuild(),
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
gulp.task("rspack-prod-e2e-test-app-e2e", () =>
|
||||
prodBuild(
|
||||
createE2eTestAppConfig({
|
||||
isProdBuild: true,
|
||||
latestBuild: true,
|
||||
isStatsBuild: env.isStatsBuild(),
|
||||
isTestBuild: env.isTestBuild(),
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
@@ -387,9 +387,14 @@ const createAppConfig = ({
|
||||
bundle.config.app({ isProdBuild, latestBuild, isStatsBuild, isTestBuild })
|
||||
);
|
||||
|
||||
const createDemoConfig = ({ isProdBuild, latestBuild, isStatsBuild }) =>
|
||||
const createDemoConfig = ({
|
||||
isProdBuild,
|
||||
latestBuild,
|
||||
isStatsBuild,
|
||||
isTestBuild,
|
||||
}) =>
|
||||
createRspackConfig(
|
||||
bundle.config.demo({ isProdBuild, latestBuild, isStatsBuild })
|
||||
bundle.config.demo({ isProdBuild, latestBuild, isStatsBuild, isTestBuild })
|
||||
);
|
||||
|
||||
const createCastConfig = ({ isProdBuild, latestBuild }) =>
|
||||
@@ -401,9 +406,19 @@ const createGalleryConfig = ({ isProdBuild, latestBuild }) =>
|
||||
const createLandingPageConfig = ({ isProdBuild, latestBuild }) =>
|
||||
createRspackConfig(bundle.config.landingPage({ isProdBuild, latestBuild }));
|
||||
|
||||
const createE2eTestAppConfig = ({ isProdBuild, latestBuild, isStatsBuild }) =>
|
||||
const createE2eTestAppConfig = ({
|
||||
isProdBuild,
|
||||
latestBuild,
|
||||
isStatsBuild,
|
||||
isTestBuild,
|
||||
}) =>
|
||||
createRspackConfig(
|
||||
bundle.config.e2eTestApp({ isProdBuild, latestBuild, isStatsBuild })
|
||||
bundle.config.e2eTestApp({
|
||||
isProdBuild,
|
||||
latestBuild,
|
||||
isStatsBuild,
|
||||
isTestBuild,
|
||||
})
|
||||
);
|
||||
|
||||
module.exports = {
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
@@ -8,5 +8,5 @@ export const demoSections: DemoConfig = {
|
||||
name: "Home Demo",
|
||||
lovelace: demoLovelaceSections,
|
||||
entities: demoEntitiesSections,
|
||||
theme: () => ({}),
|
||||
theme: { theme: "default", dark: false },
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -41,25 +41,30 @@ class CastDemoRow extends LitElement implements LovelaceRow {
|
||||
protected firstUpdated(changedProps: PropertyValues<this>) {
|
||||
super.firstUpdated(changedProps);
|
||||
import("../../../src/cast/cast_manager").then(({ getCastManager }) =>
|
||||
getCastManager().then((mgr) => {
|
||||
this._castManager = mgr;
|
||||
mgr.addEventListener("state-changed", () => {
|
||||
this.requestUpdate();
|
||||
});
|
||||
mgr.castContext.addEventListener(
|
||||
cast.framework.CastContextEventType.SESSION_STATE_CHANGED,
|
||||
(ev) => {
|
||||
// On Android, opening a new session always results in SESSION_RESUMED.
|
||||
// So treat both as the same.
|
||||
if (
|
||||
ev.sessionState === "SESSION_STARTED" ||
|
||||
ev.sessionState === "SESSION_RESUMED"
|
||||
) {
|
||||
castSendShowDemo(mgr);
|
||||
getCastManager().then(
|
||||
(mgr) => {
|
||||
this._castManager = mgr;
|
||||
mgr.addEventListener("state-changed", () => {
|
||||
this.requestUpdate();
|
||||
});
|
||||
mgr.castContext.addEventListener(
|
||||
cast.framework.CastContextEventType.SESSION_STATE_CHANGED,
|
||||
(ev) => {
|
||||
// On Android, opening a new session always results in SESSION_RESUMED.
|
||||
// So treat both as the same.
|
||||
if (
|
||||
ev.sessionState === "SESSION_STARTED" ||
|
||||
ev.sessionState === "SESSION_RESUMED"
|
||||
) {
|
||||
castSendShowDemo(mgr);
|
||||
}
|
||||
}
|
||||
}
|
||||
);
|
||||
})
|
||||
);
|
||||
},
|
||||
() => {
|
||||
this._castManager = null;
|
||||
}
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
+2
-4
@@ -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";
|
||||
@@ -173,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);
|
||||
}
|
||||
);
|
||||
|
||||
|
||||
@@ -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 () => {};
|
||||
|
||||
+27
-26
@@ -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",
|
||||
@@ -52,15 +52,15 @@
|
||||
"@codemirror/view": "6.43.6",
|
||||
"@date-fns/tz": "1.5.0",
|
||||
"@egjs/hammerjs": "2.0.17",
|
||||
"@formatjs/intl-datetimeformat": "7.4.10",
|
||||
"@formatjs/intl-displaynames": "7.3.11",
|
||||
"@formatjs/intl-durationformat": "0.10.16",
|
||||
"@formatjs/intl-getcanonicallocales": "3.2.10",
|
||||
"@formatjs/intl-listformat": "8.3.11",
|
||||
"@formatjs/intl-locale": "5.3.9",
|
||||
"@formatjs/intl-numberformat": "9.3.12",
|
||||
"@formatjs/intl-pluralrules": "6.3.11",
|
||||
"@formatjs/intl-relativetimeformat": "12.3.11",
|
||||
"@formatjs/intl-datetimeformat": "7.5.2",
|
||||
"@formatjs/intl-displaynames": "7.3.13",
|
||||
"@formatjs/intl-durationformat": "0.10.18",
|
||||
"@formatjs/intl-getcanonicallocales": "3.2.11",
|
||||
"@formatjs/intl-listformat": "8.3.13",
|
||||
"@formatjs/intl-locale": "5.3.10",
|
||||
"@formatjs/intl-numberformat": "9.3.14",
|
||||
"@formatjs/intl-pluralrules": "6.3.13",
|
||||
"@formatjs/intl-relativetimeformat": "12.3.13",
|
||||
"@fullcalendar/core": "6.1.21",
|
||||
"@fullcalendar/daygrid": "6.1.21",
|
||||
"@fullcalendar/interaction": "6.1.21",
|
||||
@@ -77,19 +77,19 @@
|
||||
"@lit/task": "1.0.3",
|
||||
"@material/mwc-formfield": "patch:@material/mwc-formfield@npm%3A0.27.0#~/.yarn/patches/@material-mwc-formfield-npm-0.27.0-9528cb60f6.patch",
|
||||
"@material/mwc-list": "patch:@material/mwc-list@npm%3A0.27.0#~/.yarn/patches/@material-mwc-list-npm-0.27.0-5344fc9de4.patch",
|
||||
"@material/web": "2.4.1",
|
||||
"@material/web": "2.5.0",
|
||||
"@mdi/js": "7.4.47",
|
||||
"@mdi/svg": "7.4.47",
|
||||
"@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.3.0",
|
||||
"intl-messageformat": "11.2.10",
|
||||
"intl-messageformat": "11.2.12",
|
||||
"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.6",
|
||||
"marked": "18.0.7",
|
||||
"memoize-one": "6.0.0",
|
||||
"node-vibrant": "4.0.4",
|
||||
"object-hash": "3.0.0",
|
||||
@@ -146,13 +146,13 @@
|
||||
"@bundle-stats/plugin-webpack-filter": "4.22.2",
|
||||
"@eslint/js": "10.0.1",
|
||||
"@html-eslint/eslint-plugin": "0.64.0",
|
||||
"@lokalise/node-api": "16.0.0",
|
||||
"@lokalise/node-api": "16.3.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.18",
|
||||
"@rspack/core": "2.1.3",
|
||||
"@rsdoctor/rspack-plugin": "1.6.1",
|
||||
"@rspack/core": "2.1.5",
|
||||
"@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",
|
||||
"@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",
|
||||
@@ -195,22 +196,22 @@
|
||||
"jsdom": "29.1.1",
|
||||
"jszip": "3.10.1",
|
||||
"license-checker-rseidelsohn": "5.0.1",
|
||||
"lint-staged": "17.0.8",
|
||||
"lint-staged": "17.1.0",
|
||||
"lit-analyzer": "2.0.3",
|
||||
"lodash.merge": "4.6.2",
|
||||
"lodash.template": "4.18.1",
|
||||
"map-stream": "0.0.7",
|
||||
"minify-literals": "2.1.0",
|
||||
"pinst": "3.0.0",
|
||||
"prettier": "3.9.5",
|
||||
"prettier": "3.9.6",
|
||||
"rspack-manifest-plugin": "5.2.2",
|
||||
"serve": "14.2.6",
|
||||
"sinon": "22.0.0",
|
||||
"tar": "7.5.19",
|
||||
"sinon": "22.1.0",
|
||||
"tar": "7.5.20",
|
||||
"terser-webpack-plugin": "5.6.1",
|
||||
"ts-lit-plugin": "2.0.2",
|
||||
"typescript": "6.0.3",
|
||||
"typescript-eslint": "8.63.0",
|
||||
"typescript-eslint": "8.65.0",
|
||||
"vite-tsconfig-paths": "6.1.1",
|
||||
"vitest": "4.1.10",
|
||||
"webpack-stats-plugin": "1.1.3",
|
||||
|
||||
@@ -58,6 +58,17 @@
|
||||
"depNameTemplate": "rhysd/actionlint",
|
||||
"datasourceTemplate": "github-releases",
|
||||
"extractVersionTemplate": "^v(?<version>.+)$"
|
||||
},
|
||||
{
|
||||
"description": "Keep Playwright CI container image up to date",
|
||||
"customType": "regex",
|
||||
"managerFilePatterns": ["/^\\.github/workflows/e2e\\.yaml$/"],
|
||||
"matchStrings": [
|
||||
"mcr\\.microsoft\\.com/playwright:(?<currentValue>v\\d+\\.\\d+\\.\\d+-noble)"
|
||||
],
|
||||
"depNameTemplate": "mcr.microsoft.com/playwright",
|
||||
"datasourceTemplate": "docker",
|
||||
"versioningTemplate": "regex:^v(?<major>\\d+)\\.(?<minor>\\d+)\\.(?<patch>\\d+)-(?<compatibility>noble)$"
|
||||
}
|
||||
],
|
||||
"packageRules": [
|
||||
@@ -86,6 +97,16 @@
|
||||
"description": "Group date-fns with dependent timezone package",
|
||||
"groupName": "date-fns",
|
||||
"matchPackageNames": ["date-fns", "date-fns-tz"]
|
||||
},
|
||||
{
|
||||
"description": "Group formatjs monorepo package",
|
||||
"groupName": "formatjs",
|
||||
"matchPackageNames": ["@formatjs/**"]
|
||||
},
|
||||
{
|
||||
"description": "Group Playwright package and CI container updates",
|
||||
"groupName": "Playwright",
|
||||
"matchPackageNames": ["@playwright/test", "mcr.microsoft.com/playwright"]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
+11
-10
@@ -9,16 +9,17 @@ export const castApiAvailable = () => {
|
||||
|
||||
loadedPromise = new Promise((resolve) => {
|
||||
(window as any).__onGCastApiAvailable = resolve;
|
||||
});
|
||||
// Any element with a specific ID will get set as a JS variable on window
|
||||
// This will override the cast SDK if the iconset is loaded afterwards.
|
||||
// Conflicting IDs will no longer mess with window, so we'll just append one.
|
||||
const el = document.createElement("div");
|
||||
el.id = "cast";
|
||||
document.body.append(el);
|
||||
|
||||
loadJS(
|
||||
"https://www.gstatic.com/cv/js/sender/v1/cast_sender.js?loadCastFramework=1"
|
||||
);
|
||||
// Any element with a specific ID will get set as a JS variable on window
|
||||
// This will override the cast SDK if the iconset is loaded afterwards.
|
||||
// Conflicting IDs will no longer mess with window, so we'll just append one.
|
||||
const el = document.createElement("div");
|
||||
el.id = "cast";
|
||||
document.body.append(el);
|
||||
|
||||
loadJS(
|
||||
"https://www.gstatic.com/cv/js/sender/v1/cast_sender.js?loadCastFramework=1"
|
||||
).catch(() => resolve(false));
|
||||
});
|
||||
return loadedPromise;
|
||||
};
|
||||
|
||||
+8
-1
@@ -91,7 +91,14 @@ export const STATES_OFF = ["closed", "locked", "off"];
|
||||
export const BINARY_STATE_ON = "on";
|
||||
export const BINARY_STATE_OFF = "off";
|
||||
|
||||
/** Domains where we allow toggle in Lovelace. */
|
||||
/** Domains where we allow toggle in Lovelace.
|
||||
* This is not strictly a list of what is possible to toggle, but the list of
|
||||
* domains where toggle is considered the primary default behavior.
|
||||
* Entities card uses this to determine which entities are controlled by the
|
||||
* header toggle.
|
||||
* Some cards use this to decide the default tap action.
|
||||
* Use canToggleDomain/canToggleState for determining if toggling is possible.
|
||||
*/
|
||||
export const DOMAINS_TOGGLE = new Set([
|
||||
"fan",
|
||||
"input_boolean",
|
||||
|
||||
@@ -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];
|
||||
}
|
||||
|
||||
@@ -1,16 +1,12 @@
|
||||
import type { HomeAssistant } from "../../types";
|
||||
import { getToggleAction } from "./get_toggle_action";
|
||||
|
||||
export const canToggleDomain = (hass: HomeAssistant, domain: string) => {
|
||||
const services = hass.services[domain];
|
||||
if (!services) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (domain === "lock") {
|
||||
return "lock" in services;
|
||||
}
|
||||
if (domain === "cover") {
|
||||
return "open_cover" in services;
|
||||
}
|
||||
return "turn_on" in services;
|
||||
const actionOn = getToggleAction(domain, true);
|
||||
const actionOff = getToggleAction(domain, false);
|
||||
return actionOn in services && actionOff in services;
|
||||
};
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { HomeAssistant } from "../../types";
|
||||
import { canToggleDomain } from "./can_toggle_domain";
|
||||
import { computeStateDomain } from "./compute_state_domain";
|
||||
import { supportsFeature } from "./supports-feature";
|
||||
import { SPECIAL_TOGGLE_ACTIONS } from "./get_toggle_action";
|
||||
|
||||
export const canToggleState = (hass: HomeAssistant, stateObj: HassEntity) => {
|
||||
const domain = computeStateDomain(stateObj);
|
||||
@@ -25,8 +26,13 @@ export const canToggleState = (hass: HomeAssistant, stateObj: HassEntity) => {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (domain === "climate") {
|
||||
return supportsFeature(stateObj, 4096);
|
||||
if (
|
||||
domain in SPECIAL_TOGGLE_ACTIONS &&
|
||||
SPECIAL_TOGGLE_ACTIONS[domain].feature
|
||||
) {
|
||||
return SPECIAL_TOGGLE_ACTIONS[domain].feature.every((f) =>
|
||||
supportsFeature(stateObj, f)
|
||||
);
|
||||
}
|
||||
|
||||
return canToggleDomain(hass, domain);
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import type { EntityIdPart } from "../../data/entity_id_format";
|
||||
import { slugify } from "../string/slugify";
|
||||
|
||||
export const computeEntityIdFormatExample = (
|
||||
format: EntityIdPart[],
|
||||
examples: Record<EntityIdPart, string>
|
||||
): string => {
|
||||
const parts = format
|
||||
.map((item) => examples[item])
|
||||
.filter(Boolean)
|
||||
.map((part) => slugify(part, "_"))
|
||||
.filter(Boolean);
|
||||
|
||||
return parts.join("_") || "unknown";
|
||||
};
|
||||
@@ -2,19 +2,19 @@ import { AITaskEntityFeature } from "../../data/ai_task";
|
||||
import { AlarmControlPanelEntityFeature } from "../../data/alarm_control_panel";
|
||||
import { AssistSatelliteEntityFeature } from "../../data/assist_satellite";
|
||||
import { CalendarEntityFeature } from "../../data/calendar";
|
||||
import { CameraEntityFeature } from "../../data/camera";
|
||||
import { ClimateEntityFeature } from "../../data/climate";
|
||||
import { CameraEntityFeature } from "../../data/feature/camera_entity_feature";
|
||||
import { ClimateEntityFeature } from "../../data/feature/climate_entity_feature";
|
||||
import { ConversationEntityFeature } from "../../data/conversation";
|
||||
import { CoverEntityFeature } from "../../data/cover";
|
||||
import { CoverEntityFeature } from "../../data/feature/cover_entity_feature";
|
||||
import { FanEntityFeature } from "../../data/fan";
|
||||
import { HumidifierEntityFeature } from "../../data/humidifier";
|
||||
import { LawnMowerEntityFeature } from "../../data/lawn_mower";
|
||||
import { LightEntityFeature } from "../../data/light";
|
||||
import { LockEntityFeature } from "../../data/lock";
|
||||
import { MediaPlayerEntityFeature } from "../../data/media-player";
|
||||
import { MediaPlayerEntityFeature } from "../../data/feature/media-player_entity_feature";
|
||||
import { NotifyEntityFeature } from "../../data/notify";
|
||||
import { RemoteEntityFeature } from "../../data/remote";
|
||||
import { SirenEntityFeature } from "../../data/siren";
|
||||
import { SirenEntityFeature } from "../../data/feature/siren_entity_feature";
|
||||
import { TodoListEntityFeature } from "../../data/todo";
|
||||
import { UpdateEntityFeature } from "../../data/update";
|
||||
import { VacuumEntityFeature } from "../../data/vacuum";
|
||||
|
||||
@@ -89,7 +89,7 @@ const FIXED_DOMAIN_ATTRIBUTE_STATES = {
|
||||
device_class: [
|
||||
"battery",
|
||||
"battery_charging",
|
||||
"co",
|
||||
"carbon_monoxide",
|
||||
"cold",
|
||||
"connectivity",
|
||||
"door",
|
||||
@@ -227,7 +227,12 @@ const FIXED_DOMAIN_ATTRIBUTE_STATES = {
|
||||
"voltage",
|
||||
"volume_flow_rate",
|
||||
],
|
||||
state_class: ["measurement", "total", "total_increasing"],
|
||||
state_class: [
|
||||
"measurement",
|
||||
"measurement_angle",
|
||||
"total",
|
||||
"total_increasing",
|
||||
],
|
||||
},
|
||||
switch: {
|
||||
device_class: ["outlet", "switch"],
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
import { CameraEntityFeature } from "../../data/feature/camera_entity_feature";
|
||||
import { ClimateEntityFeature } from "../../data/feature/climate_entity_feature";
|
||||
import { CoverEntityFeature } from "../../data/feature/cover_entity_feature";
|
||||
import { MediaPlayerEntityFeature } from "../../data/feature/media-player_entity_feature";
|
||||
import { SirenEntityFeature } from "../../data/feature/siren_entity_feature";
|
||||
|
||||
// These are domains which have nonstandard 'toggle' behavior.
|
||||
// Otherwise, any domain with a turn_on and turn_off service may be toggled.
|
||||
// If features are provided, all features must be supported.
|
||||
interface SpecialToggleAction {
|
||||
on: string;
|
||||
off?: string;
|
||||
feature?: number[];
|
||||
}
|
||||
|
||||
export const SPECIAL_TOGGLE_ACTIONS: Record<string, SpecialToggleAction> = {
|
||||
button: {
|
||||
on: "press",
|
||||
},
|
||||
camera: {
|
||||
on: "turn_on",
|
||||
off: "turn_off",
|
||||
feature: [CameraEntityFeature.ON_OFF],
|
||||
},
|
||||
climate: {
|
||||
on: "turn_on",
|
||||
off: "turn_off",
|
||||
feature: [ClimateEntityFeature.TURN_ON, ClimateEntityFeature.TURN_OFF],
|
||||
},
|
||||
cover: {
|
||||
on: "open_cover",
|
||||
off: "close_cover",
|
||||
feature: [CoverEntityFeature.OPEN, CoverEntityFeature.CLOSE],
|
||||
},
|
||||
input_button: {
|
||||
on: "press",
|
||||
},
|
||||
lock: {
|
||||
on: "unlock",
|
||||
off: "lock",
|
||||
},
|
||||
media_player: {
|
||||
on: "turn_on",
|
||||
off: "turn_off",
|
||||
feature: [
|
||||
MediaPlayerEntityFeature.TURN_ON,
|
||||
MediaPlayerEntityFeature.TURN_OFF,
|
||||
],
|
||||
},
|
||||
scene: {
|
||||
on: "turn_on",
|
||||
},
|
||||
siren: {
|
||||
on: "turn_on",
|
||||
off: "turn_off",
|
||||
feature: [SirenEntityFeature.TURN_ON, SirenEntityFeature.TURN_OFF],
|
||||
},
|
||||
valve: {
|
||||
on: "open_valve",
|
||||
off: "close_valve",
|
||||
},
|
||||
};
|
||||
|
||||
// This function assumes that the passed domain can toggle, it may otherwise
|
||||
// return a service that does not exist.
|
||||
export const getToggleAction = (domain: string, onOff: boolean): string => {
|
||||
return (
|
||||
SPECIAL_TOGGLE_ACTIONS[domain]?.[onOff ? "on" : "off"] ||
|
||||
SPECIAL_TOGGLE_ACTIONS[domain]?.["on"] ||
|
||||
(onOff ? "turn_on" : "turn_off")
|
||||
);
|
||||
};
|
||||
@@ -2,6 +2,7 @@ import type { HassEntity } from "home-assistant-js-websocket";
|
||||
import { UNAVAILABLE, UNKNOWN } from "../../data/entity/entity";
|
||||
import type { HomeAssistant } from "../../types";
|
||||
import { computeStateDomain } from "./compute_state_domain";
|
||||
import { getToggleAction } from "./get_toggle_action";
|
||||
|
||||
export const computeGroupEntitiesState = (states: HassEntity[]): string => {
|
||||
if (!states.length) {
|
||||
@@ -57,14 +58,11 @@ export const toggleGroupEntities = (
|
||||
|
||||
const isOn = state === "on" || state === "open";
|
||||
|
||||
let service = isOn ? "turn_off" : "turn_on";
|
||||
let service = getToggleAction(domain, !isOn);
|
||||
if (domain === "cover") {
|
||||
if (state === "opening" || state === "closing") {
|
||||
// If the cover is opening or closing, we toggle it to stop it
|
||||
service = "stop_cover";
|
||||
} else {
|
||||
// For covers, we use the open/close service
|
||||
service = isOn ? "close_cover" : "open_cover";
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -126,7 +126,7 @@ export class HaProgressButton extends LitElement {
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
:host([appearance="brand"]) ha-svg-icon {
|
||||
.progress ha-svg-icon {
|
||||
color: var(--white-color);
|
||||
}
|
||||
`;
|
||||
|
||||
@@ -1,12 +1,27 @@
|
||||
// A range smaller than this fraction of the axis magnitude is floating-point
|
||||
// noise (e.g. from summed statistics), not real precision.
|
||||
const NEGLIGIBLE_RANGE_RATIO = 1e-10;
|
||||
|
||||
// Derive the number of decimal digits to use for Y-axis labels from the
|
||||
// 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;
|
||||
export function computeYAxisFractionDigits(
|
||||
min: number,
|
||||
max: number,
|
||||
// Bar axes render from 0, so union the extent with 0 to match.
|
||||
includeZero = false
|
||||
): number {
|
||||
const lo = includeZero ? Math.min(min, 0) : min;
|
||||
const hi = includeZero ? Math.max(max, 0) : max;
|
||||
const range = hi - lo;
|
||||
if (!Number.isFinite(range) || range <= 0) return 1;
|
||||
// A near-zero range is fp noise; deriving digits from it would pad the labels
|
||||
// with a tail of zeros (e.g. "0.20000000000000"), so treat it as flat.
|
||||
const magnitude = Math.max(Math.abs(lo), Math.abs(hi));
|
||||
if (range <= magnitude * NEGLIGIBLE_RANGE_RATIO) return 1;
|
||||
const rawInterval = range / 5;
|
||||
const exponent = Math.floor(Math.log10(rawInterval));
|
||||
const mantissa = rawInterval / 10 ** exponent; // in [1, 10)
|
||||
@@ -38,9 +53,7 @@ const resolveYAxisBound = (
|
||||
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.
|
||||
// Set for bar axes anchored at 0, so precision reflects the 0-based range.
|
||||
includeZero?: boolean;
|
||||
onFractionDigits: (digits: number) => void;
|
||||
}): {
|
||||
@@ -52,13 +65,11 @@ export function createYAxisPrecisionBounds(options: {
|
||||
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));
|
||||
const extentMin = resolvedMin ?? values.min;
|
||||
const extentMax = resolvedMax ?? values.max;
|
||||
onFractionDigits(
|
||||
computeYAxisFractionDigits(extentMin, extentMax, includeZero)
|
||||
);
|
||||
return resolvedMin;
|
||||
},
|
||||
max: (values) => resolveYAxisBound(max, values),
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { AssistChip } from "@material/web/chips/internal/assist-chip";
|
||||
import { styles } from "@material/web/chips/internal/assist-styles";
|
||||
import { styles } from "@material/web/chips/internal/assist-styles.cssresult.js";
|
||||
|
||||
import { styles as sharedStyles } from "@material/web/chips/internal/shared-styles";
|
||||
import { styles as elevatedStyles } from "@material/web/chips/internal/elevated-styles";
|
||||
import { styles as sharedStyles } from "@material/web/chips/internal/shared-styles.cssresult.js";
|
||||
import { styles as elevatedStyles } from "@material/web/chips/internal/elevated-styles.cssresult.js";
|
||||
|
||||
import { css, html } from "lit";
|
||||
import { customElement, property } from "lit/decorators";
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { styles as elevatedStyles } from "@material/web/chips/internal/elevated-styles";
|
||||
import { styles as elevatedStyles } from "@material/web/chips/internal/elevated-styles.cssresult.js";
|
||||
import { FilterChip } from "@material/web/chips/internal/filter-chip";
|
||||
import { styles } from "@material/web/chips/internal/filter-styles";
|
||||
import { styles as selectableStyles } from "@material/web/chips/internal/selectable-styles";
|
||||
import { styles as sharedStyles } from "@material/web/chips/internal/shared-styles";
|
||||
import { styles as trailingIconStyles } from "@material/web/chips/internal/trailing-icon-styles";
|
||||
import { styles } from "@material/web/chips/internal/filter-styles.cssresult.js";
|
||||
import { styles as selectableStyles } from "@material/web/chips/internal/selectable-styles.cssresult.js";
|
||||
import { styles as sharedStyles } from "@material/web/chips/internal/shared-styles.cssresult.js";
|
||||
import { styles as trailingIconStyles } from "@material/web/chips/internal/trailing-icon-styles.cssresult.js";
|
||||
import { css, html } from "lit";
|
||||
import { customElement, property } from "lit/decorators";
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { InputChip } from "@material/web/chips/internal/input-chip";
|
||||
import { styles } from "@material/web/chips/internal/input-styles";
|
||||
import { styles as selectableStyles } from "@material/web/chips/internal/selectable-styles";
|
||||
import { styles as sharedStyles } from "@material/web/chips/internal/shared-styles";
|
||||
import { styles as trailingIconStyles } from "@material/web/chips/internal/trailing-icon-styles";
|
||||
import { styles } from "@material/web/chips/internal/input-styles.cssresult.js";
|
||||
import { styles as selectableStyles } from "@material/web/chips/internal/selectable-styles.cssresult.js";
|
||||
import { styles as sharedStyles } from "@material/web/chips/internal/shared-styles.cssresult.js";
|
||||
import { styles as trailingIconStyles } from "@material/web/chips/internal/trailing-icon-styles.cssresult.js";
|
||||
import { css } from "lit";
|
||||
import { customElement } from "lit/decorators";
|
||||
|
||||
@@ -27,6 +27,7 @@ export class HaInputChip extends InputChip {
|
||||
);
|
||||
--ha-input-chip-selected-container-opacity: 1;
|
||||
--md-input-chip-label-text-font: Roboto, sans-serif;
|
||||
--md-input-chip-label-text-weight: 400;
|
||||
}
|
||||
/** Set the size of mdc icons **/
|
||||
::slotted([slot="icon"]) {
|
||||
|
||||
@@ -33,7 +33,6 @@ const HIDDEN_ATTRIBUTES = [
|
||||
"battery_level",
|
||||
"code_arm_required",
|
||||
"code_format",
|
||||
"color_modes",
|
||||
"device_class",
|
||||
"editable",
|
||||
"effect_list",
|
||||
|
||||
@@ -13,6 +13,7 @@ import { forwardHaptic } from "../../data/haptics";
|
||||
import "../ha-formfield";
|
||||
import "../ha-icon-button";
|
||||
import "../ha-switch";
|
||||
import { getToggleAction } from "../../common/entity/get_toggle_action";
|
||||
|
||||
const isOn = (stateObj?: HassEntity) =>
|
||||
stateObj !== undefined &&
|
||||
@@ -124,25 +125,10 @@ export class HaEntityToggle extends LitElement {
|
||||
}
|
||||
forwardHaptic(this, "light");
|
||||
const stateDomain = computeStateDomain(this.stateObj);
|
||||
let serviceDomain;
|
||||
let service;
|
||||
|
||||
if (stateDomain === "lock") {
|
||||
serviceDomain = "lock";
|
||||
service = turnOn ? "unlock" : "lock";
|
||||
} else if (stateDomain === "cover") {
|
||||
serviceDomain = "cover";
|
||||
service = turnOn ? "open_cover" : "close_cover";
|
||||
} else if (stateDomain === "valve") {
|
||||
serviceDomain = "valve";
|
||||
service = turnOn ? "open_valve" : "close_valve";
|
||||
} else if (stateDomain === "group") {
|
||||
serviceDomain = "homeassistant";
|
||||
service = turnOn ? "turn_on" : "turn_off";
|
||||
} else {
|
||||
serviceDomain = stateDomain;
|
||||
service = turnOn ? "turn_on" : "turn_off";
|
||||
}
|
||||
const serviceDomain =
|
||||
stateDomain === "group" ? "homeassistant" : stateDomain;
|
||||
const service = getToggleAction(stateDomain, turnOn);
|
||||
|
||||
const currentState = this.stateObj;
|
||||
|
||||
|
||||
@@ -6,8 +6,10 @@ import { LitElement, css, html, nothing } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import { ifDefined } from "lit/directives/if-defined";
|
||||
import { styleMap } from "lit/directives/style-map";
|
||||
import { computeCssColor } from "../../common/color/compute-color";
|
||||
import { computeDomain } from "../../common/entity/compute_domain";
|
||||
import { computeStateDomain } from "../../common/entity/compute_state_domain";
|
||||
import { stateActive } from "../../common/entity/state_active";
|
||||
import {
|
||||
stateColorBrightness,
|
||||
stateColorCss,
|
||||
@@ -27,10 +29,15 @@ export class StateBadge extends LitElement {
|
||||
|
||||
@property({ attribute: false }) public overrideImage?: string;
|
||||
|
||||
// Cannot be a boolean attribute because undefined is treated different than
|
||||
// false. When it is undefined, state is still colored for light entities.
|
||||
/**
|
||||
* Cannot be a boolean attribute because undefined is treated different than
|
||||
* false. When it is undefined, state is still colored for light entities.
|
||||
* @deprecated use `color` instead
|
||||
*/
|
||||
@property({ attribute: false }) public stateColor?: boolean;
|
||||
|
||||
// "state", "none" or a color (theme color name or CSS color). Custom colors
|
||||
// apply when the entity is active. Takes precedence over stateColor.
|
||||
@property() public color?: string;
|
||||
|
||||
// @todo Consider reworking to eliminate need for attribute since it is manipulated internally
|
||||
@@ -67,11 +74,17 @@ export class StateBadge extends LitElement {
|
||||
}
|
||||
}
|
||||
|
||||
private get _stateColor() {
|
||||
private get _color(): string | undefined {
|
||||
if (this.color) {
|
||||
return this.color;
|
||||
}
|
||||
if (this.stateColor !== undefined) {
|
||||
return this.stateColor ? "state" : "none";
|
||||
}
|
||||
const domain = this.stateObj
|
||||
? computeStateDomain(this.stateObj)
|
||||
: undefined;
|
||||
return this.stateColor ?? domain === "light";
|
||||
return domain === "light" ? "state" : undefined;
|
||||
}
|
||||
|
||||
protected render() {
|
||||
@@ -131,6 +144,7 @@ export class StateBadge extends LitElement {
|
||||
|
||||
if (stateObj) {
|
||||
const domain = computeDomain(stateObj.entity_id);
|
||||
const color = this._color;
|
||||
if (this.overrideImage === undefined) {
|
||||
// hide icon if we have entity picture
|
||||
if (
|
||||
@@ -147,13 +161,10 @@ export class StateBadge extends LitElement {
|
||||
}
|
||||
backgroundImage = `url(${imageUrl})`;
|
||||
this.icon = false;
|
||||
} else if (this.color) {
|
||||
// Externally provided overriding color wins over state color
|
||||
iconStyle.color = this.color;
|
||||
} else if (this._stateColor) {
|
||||
const color = stateColorCss(stateObj);
|
||||
if (color) {
|
||||
iconStyle.color = color;
|
||||
} else if (color === "state") {
|
||||
const stateColor = stateColorCss(stateObj);
|
||||
if (stateColor) {
|
||||
iconStyle.color = stateColor;
|
||||
}
|
||||
if (stateObj.attributes.rgb_color) {
|
||||
iconStyle.color = `rgb(${stateObj.attributes.rgb_color.join(",")})`;
|
||||
@@ -180,6 +191,8 @@ export class StateBadge extends LitElement {
|
||||
delete iconStyle.color;
|
||||
}
|
||||
}
|
||||
} else if (color && color !== "none" && stateActive(stateObj)) {
|
||||
iconStyle.color = computeCssColor(color);
|
||||
}
|
||||
} else if (this.overrideImage) {
|
||||
backgroundImage = `url(${this._resolveImageUrl(this.overrideImage)})`;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { HassEntity } from "home-assistant-js-websocket";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property } from "lit/decorators";
|
||||
import { styleMap } from "lit/directives/style-map";
|
||||
import type { HomeAssistant } from "../../types";
|
||||
import "../ha-relative-time";
|
||||
import "../ha-tooltip";
|
||||
@@ -23,10 +24,11 @@ class StateInfo extends LitElement {
|
||||
|
||||
const name = this.hass.formatEntityName(this.stateObj, { type: "entity" });
|
||||
|
||||
// Inline style because the state-badge color API only colors active entities
|
||||
return html`<state-badge
|
||||
.stateObj=${this.stateObj}
|
||||
.stateColor=${true}
|
||||
.color=${this.color}
|
||||
.stateColor=${!this.color}
|
||||
style=${styleMap({ color: this.color })}
|
||||
></state-badge>
|
||||
<div class="info">
|
||||
<div class="name ${this.inDialog ? "in-dialog" : ""}" .title=${name}>
|
||||
|
||||
@@ -57,6 +57,16 @@ interface AssistMessage {
|
||||
error?: boolean;
|
||||
}
|
||||
|
||||
export const initialPromptToSubmit = (
|
||||
prompt: string | undefined,
|
||||
submit: boolean
|
||||
): string | undefined => (submit ? prompt?.trim() || undefined : undefined);
|
||||
|
||||
export const assistPipelineChanged = (
|
||||
previous: AssistPipeline | undefined,
|
||||
current: AssistPipeline | undefined
|
||||
): boolean => previous?.id !== current?.id;
|
||||
|
||||
@customElement("ha-assist-chat")
|
||||
export class HaAssistChat extends LitElement {
|
||||
@property({ attribute: false }) public pipeline?: AssistPipeline;
|
||||
@@ -67,6 +77,12 @@ export class HaAssistChat extends LitElement {
|
||||
@property({ attribute: false })
|
||||
public startListening?: boolean;
|
||||
|
||||
@property({ attribute: false })
|
||||
public initialPrompt?: string;
|
||||
|
||||
@property({ attribute: false })
|
||||
public submitInitialPrompt = false;
|
||||
|
||||
@query("#message-input") private _messageInput!: HaInput;
|
||||
|
||||
@query(".message:last-child")
|
||||
@@ -99,6 +115,8 @@ export class HaAssistChat extends LitElement {
|
||||
|
||||
private _conversationId: string | null = null;
|
||||
|
||||
private _initialPromptSubmitted = false;
|
||||
|
||||
private _audioRecorder?: AudioRecorder;
|
||||
|
||||
private _audioBuffer?: Int16Array[];
|
||||
@@ -108,7 +126,11 @@ export class HaAssistChat extends LitElement {
|
||||
private _stt_binary_handler_id?: number | null;
|
||||
|
||||
protected willUpdate(changedProperties: PropertyValues<this>): void {
|
||||
if (!this.hasUpdated || changedProperties.has("pipeline")) {
|
||||
if (
|
||||
!this.hasUpdated ||
|
||||
(changedProperties.has("pipeline") &&
|
||||
assistPipelineChanged(changedProperties.get("pipeline"), this.pipeline))
|
||||
) {
|
||||
this._conversation = [
|
||||
{
|
||||
who: "hass",
|
||||
@@ -138,6 +160,20 @@ export class HaAssistChat extends LitElement {
|
||||
if (changedProps.has("_conversation")) {
|
||||
this._scrollMessagesBottom();
|
||||
}
|
||||
if (
|
||||
!this._initialPromptSubmitted &&
|
||||
(changedProps.has("initialPrompt") ||
|
||||
changedProps.has("submitInitialPrompt"))
|
||||
) {
|
||||
const prompt = initialPromptToSubmit(
|
||||
this.initialPrompt,
|
||||
this.submitInitialPrompt
|
||||
);
|
||||
if (prompt) {
|
||||
this._initialPromptSubmitted = true;
|
||||
this._processText(prompt);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public disconnectedCallback() {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
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 isFieldVisible = (
|
||||
schema: HaFormSchema,
|
||||
data: HaFormDataContainer | undefined
|
||||
): boolean => {
|
||||
const { visible } = schema as HaFormBaseSchema;
|
||||
if (visible === undefined || visible === true) {
|
||||
return true;
|
||||
}
|
||||
if (visible === false) {
|
||||
return false;
|
||||
}
|
||||
const conditions = Array.isArray(visible) ? visible : [visible];
|
||||
return conditions.every((condition) => evaluateCondition(condition, data));
|
||||
};
|
||||
|
||||
// Hiding a field drops its value, which can flip another field's condition, so
|
||||
// resolve the set to a fixpoint.
|
||||
export const getHiddenFields = (
|
||||
schema: readonly HaFormSchema[],
|
||||
data: HaFormDataContainer | undefined
|
||||
): Set<string> => {
|
||||
const hidden = new Set<string>();
|
||||
const evalData: HaFormDataContainer = { ...(data ?? {}) };
|
||||
let changed = true;
|
||||
while (changed) {
|
||||
changed = false;
|
||||
for (const field of schema) {
|
||||
if (hidden.has(field.name) || isFieldVisible(field, evalData)) {
|
||||
continue;
|
||||
}
|
||||
hidden.add(field.name);
|
||||
delete evalData[field.name];
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
return hidden;
|
||||
};
|
||||
@@ -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 { getHiddenFields } from "./conditions";
|
||||
import "./ha-form";
|
||||
import type { HaForm } from "./ha-form";
|
||||
import type {
|
||||
@@ -67,20 +68,24 @@ export class HaFormGrid extends LitElement implements HaFormElement {
|
||||
}
|
||||
|
||||
protected render(): TemplateResult {
|
||||
const hiddenFields = getHiddenFields(this.schema.schema, this.data);
|
||||
|
||||
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) => !hiddenFields.has(item.name))
|
||||
.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>
|
||||
`
|
||||
)}
|
||||
`;
|
||||
}
|
||||
|
||||
|
||||
@@ -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 { getHiddenFields } from "./conditions";
|
||||
import type { HaFormDataContainer, HaFormElement, HaFormSchema } from "./types";
|
||||
|
||||
const LOAD_ELEMENTS = {
|
||||
@@ -98,7 +99,12 @@ export class HaForm extends LitElement implements HaFormElement {
|
||||
let isValid = true;
|
||||
let firstInvalidElement: HTMLElement | undefined;
|
||||
|
||||
this.schema.forEach((item, index) => {
|
||||
const hiddenFields = getHiddenFields(this.schema, this.data);
|
||||
const visibleSchema = this.schema.filter(
|
||||
(item) => !hiddenFields.has(item.name)
|
||||
);
|
||||
|
||||
visibleSchema.forEach((item, index) => {
|
||||
const element = elements[index];
|
||||
if (!element) {
|
||||
return;
|
||||
@@ -152,6 +158,8 @@ export class HaForm extends LitElement implements HaFormElement {
|
||||
}
|
||||
|
||||
protected render(): TemplateResult {
|
||||
const renderHiddenFields = getHiddenFields(this.schema, this.data);
|
||||
|
||||
return html`
|
||||
<div class="root" part="root">
|
||||
${
|
||||
@@ -164,6 +172,10 @@ export class HaForm extends LitElement implements HaFormElement {
|
||||
: ""
|
||||
}
|
||||
${this.schema.map((item) => {
|
||||
if (renderHiddenFields.has(item.name)) {
|
||||
return nothing;
|
||||
}
|
||||
|
||||
const error = getError(this.error, item);
|
||||
const warning = getWarning(this.warning, item);
|
||||
|
||||
|
||||
@@ -22,6 +22,9 @@ export interface HaFormBaseSchema {
|
||||
default?: HaFormData;
|
||||
required?: boolean;
|
||||
disabled?: boolean;
|
||||
// Field is visible while the condition holds (visible by default).
|
||||
// Serializable so it can be shared with the backend and other renderers.
|
||||
visible?: 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;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { ListItemEl } from "@material/web/list/internal/listitem/list-item";
|
||||
import { styles } from "@material/web/list/internal/listitem/list-item-styles";
|
||||
import { styles } from "@material/web/list/internal/listitem/list-item-styles.cssresult.js";
|
||||
import { css, html, nothing, type TemplateResult } from "lit";
|
||||
import { customElement } from "lit/decorators";
|
||||
import "./ha-ripple";
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { List } from "@material/web/list/internal/list";
|
||||
import { styles } from "@material/web/list/internal/list-styles";
|
||||
import { styles } from "@material/web/list/internal/list-styles.cssresult.js";
|
||||
import { css } from "lit";
|
||||
import { customElement } from "lit/decorators";
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { OutlinedButton } from "@material/web/button/internal/outlined-button";
|
||||
import { styles as sharedStyles } from "@material/web/button/internal/shared-styles";
|
||||
import { styles } from "@material/web/button/internal/outlined-styles";
|
||||
import { styles as sharedStyles } from "@material/web/button/internal/shared-styles.cssresult.js";
|
||||
import { styles } from "@material/web/button/internal/outlined-styles.cssresult.js";
|
||||
import { css } from "lit";
|
||||
import { customElement } from "lit/decorators";
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { OutlinedField } from "@material/web/field/internal/outlined-field";
|
||||
import { styles } from "@material/web/field/internal/outlined-styles";
|
||||
import { styles as sharedStyles } from "@material/web/field/internal/shared-styles";
|
||||
import { styles } from "@material/web/field/internal/outlined-styles.cssresult.js";
|
||||
import { styles as sharedStyles } from "@material/web/field/internal/shared-styles.cssresult.js";
|
||||
import { css } from "lit";
|
||||
import { customElement } from "lit/decorators";
|
||||
import { literal } from "lit/static-html";
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { IconButton } from "@material/web/iconbutton/internal/icon-button";
|
||||
import { styles } from "@material/web/iconbutton/internal/outlined-styles";
|
||||
import { styles as sharedStyles } from "@material/web/iconbutton/internal/shared-styles";
|
||||
import { styles } from "@material/web/iconbutton/internal/outlined-styles.cssresult.js";
|
||||
import { styles as sharedStyles } from "@material/web/iconbutton/internal/shared-styles.cssresult.js";
|
||||
import { css } from "lit";
|
||||
import { customElement } from "lit/decorators";
|
||||
|
||||
|
||||
@@ -4,6 +4,23 @@ import { customElement, property, query, state } from "lit/decorators";
|
||||
import QRCode from "qrcode";
|
||||
import "./ha-alert";
|
||||
import { rgb2hex } from "../common/color/convert-color";
|
||||
import {
|
||||
getContrastedColorHex,
|
||||
getRGBContrastRatio,
|
||||
} from "../common/color/rgb";
|
||||
|
||||
// Minimum contrast ratio (WCAG non-text minimum) below which we substitute a
|
||||
// legible foreground so the QR code never renders unscannable.
|
||||
const MIN_CONTRAST_RATIO = 3;
|
||||
|
||||
const parseRgbVariable = (
|
||||
value: string
|
||||
): [number, number, number] | undefined => {
|
||||
const parts = value.split(",").map((part) => parseInt(part, 10));
|
||||
return parts.length === 3 && parts.every((part) => !Number.isNaN(part))
|
||||
? (parts as [number, number, number])
|
||||
: undefined;
|
||||
};
|
||||
|
||||
@customElement("ha-qr-code")
|
||||
export class HaQrCode extends LitElement {
|
||||
@@ -60,27 +77,27 @@ export class HaQrCode extends LitElement {
|
||||
changedProperties.has("centerImage"))
|
||||
) {
|
||||
const computedStyles = getComputedStyle(this);
|
||||
const textRgb = computedStyles.getPropertyValue(
|
||||
"--rgb-primary-text-color"
|
||||
const textRgb = parseRgbVariable(
|
||||
computedStyles.getPropertyValue("--rgb-primary-text-color")
|
||||
);
|
||||
const backgroundRgb = computedStyles.getPropertyValue(
|
||||
"--rgb-card-background-color"
|
||||
);
|
||||
const textHex = rgb2hex(
|
||||
textRgb.split(",").map((a) => parseInt(a, 10)) as [
|
||||
number,
|
||||
number,
|
||||
number,
|
||||
]
|
||||
);
|
||||
const backgroundHex = rgb2hex(
|
||||
backgroundRgb.split(",").map((a) => parseInt(a, 10)) as [
|
||||
number,
|
||||
number,
|
||||
number,
|
||||
]
|
||||
const backgroundRgb = parseRgbVariable(
|
||||
computedStyles.getPropertyValue("--rgb-card-background-color")
|
||||
);
|
||||
|
||||
const backgroundHex = backgroundRgb ? rgb2hex(backgroundRgb) : "#ffffff";
|
||||
let textHex = textRgb ? rgb2hex(textRgb) : "#000000";
|
||||
|
||||
// If the theme colors are missing/invalid or don't contrast enough, the
|
||||
// QR code would be unscannable (e.g. white-on-white). Fall back to a
|
||||
// foreground guaranteed to contrast the resolved background.
|
||||
if (
|
||||
!textRgb ||
|
||||
!backgroundRgb ||
|
||||
getRGBContrastRatio(textRgb, backgroundRgb) < MIN_CONTRAST_RATIO
|
||||
) {
|
||||
textHex = getContrastedColorHex(backgroundHex);
|
||||
}
|
||||
|
||||
QRCode.toCanvas(canvas, this.data, {
|
||||
errorCorrectionLevel:
|
||||
this.errorCorrectionLevel || (this.centerImage ? "Q" : "M"),
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { AttachableController } from "@material/web/internal/controller/attachable-controller";
|
||||
import { Ripple } from "@material/web/ripple/internal/ripple";
|
||||
import { styles } from "@material/web/ripple/internal/ripple-styles";
|
||||
import { styles } from "@material/web/ripple/internal/ripple-styles.cssresult.js";
|
||||
import { css } from "lit";
|
||||
import { customElement } from "lit/decorators";
|
||||
|
||||
|
||||
@@ -133,7 +133,13 @@ export class HaAreaSelector extends LitElement {
|
||||
}
|
||||
|
||||
return ensureArray(this.selector.area.entity).some((filter) =>
|
||||
filterSelectorEntities(filter, entity, this._entitySources)
|
||||
filterSelectorEntities(
|
||||
filter,
|
||||
entity,
|
||||
this._entitySources,
|
||||
this.hass.entities,
|
||||
this.hass.devices
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -147,7 +147,13 @@ export class HaDeviceSelector extends LitElement {
|
||||
|
||||
private _filterEntities = (entity: HassEntity): boolean =>
|
||||
ensureArray(this.selector.device!.entity).some((filter) =>
|
||||
filterSelectorEntities(filter, entity, this._entitySources)
|
||||
filterSelectorEntities(
|
||||
filter,
|
||||
entity,
|
||||
this._entitySources,
|
||||
this.hass.entities,
|
||||
this.hass.devices
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -2,8 +2,12 @@ import type { HassEntity } from "home-assistant-js-websocket";
|
||||
import type { PropertyValues } from "lit";
|
||||
import { html, LitElement, nothing } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import memoizeOne from "memoize-one";
|
||||
import { ensureArray } from "../../common/array/ensure-array";
|
||||
import { fireEvent } from "../../common/dom/fire_event";
|
||||
import type { ConfigEntry } from "../../data/config_entries";
|
||||
import { getConfigEntries } from "../../data/config_entries";
|
||||
import { getDeviceIntegrationLookup } from "../../data/device/device_registry";
|
||||
import type { EntitySources } from "../../data/entity/entity_sources";
|
||||
import { fetchEntitySourcesWithCache } from "../../data/entity/entity_sources";
|
||||
import type { EntitySelector } from "../../data/selector";
|
||||
@@ -23,6 +27,8 @@ export class HaEntitySelector extends LitElement {
|
||||
|
||||
@state() private _entitySources?: EntitySources;
|
||||
|
||||
@state() private _configEntries?: ConfigEntry[];
|
||||
|
||||
@property() public value?: any;
|
||||
|
||||
@property() public label?: string;
|
||||
@@ -37,12 +43,38 @@ export class HaEntitySelector extends LitElement {
|
||||
|
||||
@state() private _createDomains: string[] | undefined;
|
||||
|
||||
private _hasIntegration(selector: EntitySelector) {
|
||||
return (
|
||||
selector.entity?.filter &&
|
||||
ensureArray(selector.entity.filter).some((filter) => filter.integration)
|
||||
);
|
||||
}
|
||||
private _deviceIntegrationLookup = memoizeOne(
|
||||
(
|
||||
entitySources: EntitySources,
|
||||
entities: HomeAssistant["entities"],
|
||||
devices: HomeAssistant["devices"],
|
||||
configEntries?: ConfigEntry[]
|
||||
) =>
|
||||
getDeviceIntegrationLookup(
|
||||
entitySources,
|
||||
Object.values(entities),
|
||||
Object.values(devices),
|
||||
configEntries
|
||||
)
|
||||
);
|
||||
|
||||
// Which async data the current filter needs to be evaluated: a top-level or
|
||||
// device `integration` filter needs entity sources, and a `device.integration`
|
||||
// filter additionally needs config entries (the device integration lookup is
|
||||
// built from both).
|
||||
private _dataNeeds = memoizeOne((selector: EntitySelector) => {
|
||||
const filters = selector.entity?.filter
|
||||
? ensureArray(selector.entity.filter)
|
||||
: [];
|
||||
return {
|
||||
entitySources: filters.some(
|
||||
(f) => f.integration || f.device?.integration
|
||||
),
|
||||
configEntries: filters.some((f) => f.device?.integration),
|
||||
};
|
||||
});
|
||||
|
||||
private _fetchedConfigEntries = false;
|
||||
|
||||
protected willUpdate(changedProperties: PropertyValues<this>): void {
|
||||
if (changedProperties.get("selector") && this.value !== undefined) {
|
||||
@@ -57,7 +89,11 @@ export class HaEntitySelector extends LitElement {
|
||||
}
|
||||
|
||||
protected render() {
|
||||
if (this._hasIntegration(this.selector) && !this._entitySources) {
|
||||
const needs = this._dataNeeds(this.selector);
|
||||
if (
|
||||
(needs.entitySources && !this._entitySources) ||
|
||||
(needs.configEntries && !this._configEntries)
|
||||
) {
|
||||
return nothing;
|
||||
}
|
||||
|
||||
@@ -96,15 +132,37 @@ export class HaEntitySelector extends LitElement {
|
||||
|
||||
protected updated(changedProps: PropertyValues<this>): void {
|
||||
super.updated(changedProps);
|
||||
if (
|
||||
changedProps.has("selector") &&
|
||||
this._hasIntegration(this.selector) &&
|
||||
!this._entitySources
|
||||
) {
|
||||
|
||||
// The connection changed (e.g. reconnect); refetch config entries.
|
||||
const oldHass = changedProps.get("hass");
|
||||
if (oldHass && oldHass.connection !== this.hass.connection) {
|
||||
this._fetchedConfigEntries = false;
|
||||
this._configEntries = undefined;
|
||||
}
|
||||
|
||||
const needs = this._dataNeeds(this.selector);
|
||||
|
||||
if (needs.entitySources && !this._entitySources) {
|
||||
fetchEntitySourcesWithCache(this.hass).then((sources) => {
|
||||
this._entitySources = sources;
|
||||
});
|
||||
}
|
||||
|
||||
if (needs.configEntries && !this._fetchedConfigEntries) {
|
||||
this._fetchedConfigEntries = true;
|
||||
getConfigEntries(this.hass)
|
||||
.then((entries) => {
|
||||
this._configEntries = entries;
|
||||
})
|
||||
.catch(() => {
|
||||
// Fall back to no entries so the picker still renders. We keep
|
||||
// `_fetchedConfigEntries` set so the failed fetch is not retried on
|
||||
// every re-render; the connection-change handler above retries on
|
||||
// reconnect.
|
||||
this._configEntries = [];
|
||||
});
|
||||
}
|
||||
|
||||
if (changedProps.has("selector")) {
|
||||
this._createDomains = computeCreateDomains(this.selector);
|
||||
}
|
||||
@@ -114,8 +172,25 @@ export class HaEntitySelector extends LitElement {
|
||||
if (!this.selector?.entity?.filter) {
|
||||
return true;
|
||||
}
|
||||
const deviceIntegrationLookup =
|
||||
this._entitySources && this._dataNeeds(this.selector).configEntries
|
||||
? this._deviceIntegrationLookup(
|
||||
this._entitySources,
|
||||
this.hass.entities,
|
||||
this.hass.devices,
|
||||
this._configEntries
|
||||
)
|
||||
: undefined;
|
||||
|
||||
return ensureArray(this.selector.entity.filter).some((filter) =>
|
||||
filterSelectorEntities(filter, entity, this._entitySources)
|
||||
filterSelectorEntities(
|
||||
filter,
|
||||
entity,
|
||||
this._entitySources,
|
||||
this.hass.entities,
|
||||
this.hass.devices,
|
||||
deviceIntegrationLookup
|
||||
)
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
@@ -133,7 +133,13 @@ export class HaFloorSelector extends LitElement {
|
||||
}
|
||||
|
||||
return ensureArray(this.selector.floor.entity).some((filter) =>
|
||||
filterSelectorEntities(filter, entity, this._entitySources)
|
||||
filterSelectorEntities(
|
||||
filter,
|
||||
entity,
|
||||
this._entitySources,
|
||||
this.hass.entities,
|
||||
this.hass.devices
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,19 +1,23 @@
|
||||
import { consume, type ContextType } from "@lit/context";
|
||||
import { initialState } from "@lit/task";
|
||||
import { html, LitElement, nothing } from "lit";
|
||||
import { customElement, property } from "lit/decorators";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import type { HassEntity } from "home-assistant-js-websocket";
|
||||
import { AsyncValueTask } from "../../common/controllers/async-value-task";
|
||||
import { consumeEntityState } from "../../common/decorators/consume-context-entry";
|
||||
import { fireEvent } from "../../common/dom/fire_event";
|
||||
import {
|
||||
configContext,
|
||||
connectionContext,
|
||||
entitiesContext,
|
||||
} from "../../data/context";
|
||||
import { entityIcon } from "../../data/icons";
|
||||
import type { IconSelector } from "../../data/selector";
|
||||
import type { HomeAssistant } from "../../types";
|
||||
import "../ha-icon-picker";
|
||||
import "../ha-state-icon";
|
||||
|
||||
@customElement("ha-selector-icon")
|
||||
export class HaIconSelector extends LitElement {
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
|
||||
@property({ attribute: false }) public selector!: IconSelector;
|
||||
|
||||
@property() public value?: string;
|
||||
@@ -30,10 +34,21 @@ export class HaIconSelector extends LitElement {
|
||||
icon_entity?: string;
|
||||
};
|
||||
|
||||
private get _stateObj(): HassEntity | undefined {
|
||||
const iconEntity = this.context?.icon_entity;
|
||||
return iconEntity ? this.hass.states[iconEntity] : undefined;
|
||||
}
|
||||
@state()
|
||||
@consumeEntityState({ entityIdPath: ["context", "icon_entity"] })
|
||||
private _stateObj?: HassEntity;
|
||||
|
||||
@state()
|
||||
@consume({ context: entitiesContext, subscribe: true })
|
||||
private _entities?: ContextType<typeof entitiesContext>;
|
||||
|
||||
@state()
|
||||
@consume({ context: configContext, subscribe: true })
|
||||
private _config?: ContextType<typeof configContext>;
|
||||
|
||||
@state()
|
||||
@consume({ context: connectionContext, subscribe: true })
|
||||
private _connection?: ContextType<typeof connectionContext>;
|
||||
|
||||
private _placeholderTask = new AsyncValueTask(this, {
|
||||
task: ([
|
||||
@@ -44,19 +59,31 @@ export class HaIconSelector extends LitElement {
|
||||
connection,
|
||||
stateObj,
|
||||
]) => {
|
||||
if (placeholder || attributeIcon || !stateObj) {
|
||||
if (
|
||||
placeholder ||
|
||||
attributeIcon ||
|
||||
!entities ||
|
||||
!config ||
|
||||
!connection ||
|
||||
!stateObj
|
||||
) {
|
||||
return initialState;
|
||||
}
|
||||
return entityIcon(entities, config, connection, stateObj);
|
||||
return entityIcon(
|
||||
entities,
|
||||
config.config,
|
||||
connection.connection,
|
||||
stateObj
|
||||
);
|
||||
},
|
||||
args: () => {
|
||||
const stateObj = this._stateObj;
|
||||
return [
|
||||
this.selector.icon?.placeholder,
|
||||
stateObj?.attributes.icon,
|
||||
this.hass.entities,
|
||||
this.hass.config,
|
||||
this.hass.connection,
|
||||
this._entities,
|
||||
this._config,
|
||||
this._connection,
|
||||
stateObj,
|
||||
] as const;
|
||||
},
|
||||
@@ -72,7 +99,6 @@ export class HaIconSelector extends LitElement {
|
||||
|
||||
return html`
|
||||
<ha-icon-picker
|
||||
.hass=${this.hass}
|
||||
.label=${this.label}
|
||||
.value=${this.value}
|
||||
.required=${this.required}
|
||||
|
||||
@@ -10,6 +10,7 @@ import type {
|
||||
} from "../../common/translations/localize";
|
||||
import type { HomeAssistant } from "../../types";
|
||||
import "../ha-form/ha-form";
|
||||
import { unitOfMeasurementOptions } from "../../data/number";
|
||||
|
||||
const SELECTOR_DEFAULTS = {
|
||||
number: {
|
||||
@@ -112,6 +113,16 @@ const SELECTOR_SCHEMAS = {
|
||||
name: "step",
|
||||
selector: { number: { mode: "box", step: "any" } },
|
||||
},
|
||||
{
|
||||
name: "unit_of_measurement",
|
||||
selector: {
|
||||
select: {
|
||||
custom_value: true,
|
||||
sort: true,
|
||||
options: unitOfMeasurementOptions,
|
||||
},
|
||||
},
|
||||
},
|
||||
] as const,
|
||||
object: [] as const,
|
||||
color_rgb: [] as const,
|
||||
@@ -181,49 +192,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;
|
||||
|
||||
@@ -93,7 +93,13 @@ export class HaTargetSelector extends LitElement {
|
||||
}
|
||||
|
||||
return ensureArray(this.selector.target.entity).some((filter) =>
|
||||
filterSelectorEntities(filter, entity, this._entitySources)
|
||||
filterSelectorEntities(
|
||||
filter,
|
||||
entity,
|
||||
this._entitySources,
|
||||
this.hass.entities,
|
||||
this.hass.devices
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -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 || ""}
|
||||
|
||||
@@ -1,13 +1,10 @@
|
||||
import { css, html, LitElement } from "lit";
|
||||
import { customElement, property } from "lit/decorators";
|
||||
import type { TimezoneSelector } from "../../data/selector";
|
||||
import type { HomeAssistant } from "../../types";
|
||||
import "../ha-timezone-picker";
|
||||
|
||||
@customElement("ha-selector-timezone")
|
||||
export class HaTimezoneSelector extends LitElement {
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
|
||||
@property({ attribute: false }) public selector!: TimezoneSelector;
|
||||
|
||||
@property() public value?: string;
|
||||
@@ -23,7 +20,6 @@ export class HaTimezoneSelector extends LitElement {
|
||||
protected render() {
|
||||
return html`
|
||||
<ha-timezone-picker
|
||||
.hass=${this.hass}
|
||||
.value=${this.value}
|
||||
.label=${this.label}
|
||||
.helper=${this.helper}
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -903,7 +895,13 @@ export class HaServiceControl extends LitElement {
|
||||
}
|
||||
if (targetEntities.length) {
|
||||
targetEntities = targetEntities.filter((entity) =>
|
||||
entityMeetsTargetSelector(this.hass.states[entity], targetSelector)
|
||||
entityMeetsTargetSelector(
|
||||
this.hass.states[entity],
|
||||
targetSelector,
|
||||
undefined,
|
||||
this.hass.entities,
|
||||
this.hass.devices
|
||||
)
|
||||
);
|
||||
}
|
||||
target = {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { SubMenu } from "@material/web/menu/internal/submenu/sub-menu";
|
||||
import { styles } from "@material/web/menu/internal/submenu/sub-menu-styles";
|
||||
import { styles } from "@material/web/menu/internal/submenu/sub-menu-styles.cssresult.js";
|
||||
import { css } from "lit";
|
||||
import { customElement } from "lit/decorators";
|
||||
|
||||
|
||||
@@ -2,8 +2,10 @@ import { getTimeZones, timeZonesNames } from "@vvo/tzdb";
|
||||
import { css, html, LitElement } from "lit";
|
||||
import { customElement, property } from "lit/decorators";
|
||||
import memoizeOne from "memoize-one";
|
||||
import { consumeLocalize } from "../common/decorators/consume-context-entry";
|
||||
import { fireEvent } from "../common/dom/fire_event";
|
||||
import type { HomeAssistant, ValueChangedEvent } from "../types";
|
||||
import type { LocalizeFunc } from "../common/translations/localize";
|
||||
import type { ValueChangedEvent } from "../types";
|
||||
import "./ha-generic-picker";
|
||||
|
||||
import type { PickerComboBoxItem } from "./ha-picker-combo-box";
|
||||
@@ -52,7 +54,8 @@ export const getTimezoneOptions = (): PickerComboBoxItem[] => {
|
||||
|
||||
@customElement("ha-timezone-picker")
|
||||
export class HaTimeZonePicker extends LitElement {
|
||||
@property({ attribute: false }) public hass?: HomeAssistant;
|
||||
@consumeLocalize()
|
||||
private _localize!: LocalizeFunc;
|
||||
|
||||
@property() public value?: string;
|
||||
|
||||
@@ -82,15 +85,14 @@ export class HaTimeZonePicker extends LitElement {
|
||||
protected render() {
|
||||
const label =
|
||||
this.label ??
|
||||
(this.hass?.localize("ui.components.timezone-picker.time_zone") ||
|
||||
(this._localize("ui.components.timezone-picker.time_zone") ||
|
||||
"Time zone");
|
||||
|
||||
return html`
|
||||
<ha-generic-picker
|
||||
.hass=${this.hass}
|
||||
.notFoundLabel=${this._notFoundLabel}
|
||||
.emptyLabel=${
|
||||
this.hass?.localize("ui.components.timezone-picker.no_timezones") ||
|
||||
this._localize("ui.components.timezone-picker.no_timezones") ||
|
||||
"No time zones available"
|
||||
}
|
||||
.label=${label}
|
||||
@@ -124,9 +126,10 @@ export class HaTimeZonePicker extends LitElement {
|
||||
|
||||
private _notFoundLabel = (search: string) => {
|
||||
const term = html`<b>'${search}'</b>`;
|
||||
return this.hass
|
||||
? this.hass.localize("ui.components.timezone-picker.no_match", { term })
|
||||
: html`No time zones found for ${term}`;
|
||||
return (
|
||||
this._localize("ui.components.timezone-picker.no_match", { term }) ||
|
||||
html`No time zones found for ${term}`
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
+1
-2
@@ -1,4 +1,3 @@
|
||||
import { memoize } from "@fullcalendar/core/internal";
|
||||
import { setHours, setMinutes } from "date-fns";
|
||||
import type { HassConfig } from "home-assistant-js-websocket";
|
||||
import memoizeOne from "memoize-one";
|
||||
@@ -411,7 +410,7 @@ export type BackupType = "automatic" | "manual" | "app_update";
|
||||
|
||||
const BACKUP_TYPE_ORDER: BackupType[] = ["automatic", "app_update", "manual"];
|
||||
|
||||
export const getBackupTypes = memoize((isHassio: boolean) =>
|
||||
export const getBackupTypes = memoizeOne((isHassio: boolean) =>
|
||||
isHassio
|
||||
? BACKUP_TYPE_ORDER
|
||||
: BACKUP_TYPE_ORDER.filter((type) => type !== "app_update")
|
||||
|
||||
+1
-4
@@ -13,10 +13,7 @@ export const STREAM_TYPE_WEB_RTC = "web_rtc";
|
||||
|
||||
export type StreamType = typeof STREAM_TYPE_HLS | typeof STREAM_TYPE_WEB_RTC;
|
||||
|
||||
export enum CameraEntityFeature {
|
||||
ON_OFF = 1,
|
||||
STREAM = 2,
|
||||
}
|
||||
export { CameraEntityFeature } from "./feature/camera_entity_feature";
|
||||
|
||||
interface CameraEntityAttributes extends HassEntityAttributeBase {
|
||||
model_name: string;
|
||||
|
||||
+1
-12
@@ -68,18 +68,7 @@ export type ClimateEntity = HassEntityBase & {
|
||||
};
|
||||
};
|
||||
|
||||
export enum ClimateEntityFeature {
|
||||
TARGET_TEMPERATURE = 1,
|
||||
TARGET_TEMPERATURE_RANGE = 2,
|
||||
TARGET_HUMIDITY = 4,
|
||||
FAN_MODE = 8,
|
||||
PRESET_MODE = 16,
|
||||
SWING_MODE = 32,
|
||||
AUX_HEAT = 64,
|
||||
TURN_OFF = 128,
|
||||
TURN_ON = 256,
|
||||
SWING_HORIZONTAL_MODE = 512,
|
||||
}
|
||||
export { ClimateEntityFeature } from "./feature/climate_entity_feature";
|
||||
|
||||
const hvacModeOrdering = HVAC_MODES.reduce(
|
||||
(order, mode, index) => {
|
||||
|
||||
+2
-10
@@ -6,17 +6,9 @@ import { stateActive } from "../common/entity/state_active";
|
||||
import { supportsFeature } from "../common/entity/supports-feature";
|
||||
import type { HomeAssistantFormatters } from "../types";
|
||||
import { UNAVAILABLE } from "./entity/entity";
|
||||
import { CoverEntityFeature } from "./feature/cover_entity_feature";
|
||||
|
||||
export enum CoverEntityFeature {
|
||||
OPEN = 1,
|
||||
CLOSE = 2,
|
||||
SET_POSITION = 4,
|
||||
STOP = 8,
|
||||
OPEN_TILT = 16,
|
||||
CLOSE_TILT = 32,
|
||||
STOP_TILT = 64,
|
||||
SET_TILT_POSITION = 128,
|
||||
}
|
||||
export { CoverEntityFeature };
|
||||
|
||||
export const DEFAULT_COVER_FAVORITE_POSITIONS = [0, 25, 75, 100];
|
||||
|
||||
|
||||
@@ -127,7 +127,6 @@ export const NON_NUMERIC_ATTRIBUTES = [
|
||||
"away_mode",
|
||||
"changed_by",
|
||||
"code_format",
|
||||
"color_modes",
|
||||
"current_activity",
|
||||
"device_class",
|
||||
"editable",
|
||||
@@ -177,6 +176,7 @@ export const NON_NUMERIC_ATTRIBUTES = [
|
||||
"source_type",
|
||||
"source",
|
||||
"state_class",
|
||||
"supported_color_modes",
|
||||
"supported_features",
|
||||
"swing_mode",
|
||||
"swing_mode",
|
||||
@@ -190,7 +190,6 @@ export const NON_NUMERIC_ATTRIBUTES = [
|
||||
export const STATE_CONDITION_HIDDEN_ATTRIBUTES = [
|
||||
"access_token",
|
||||
"available_modes",
|
||||
"color_modes",
|
||||
"editable",
|
||||
"effect_list",
|
||||
"entity_picture",
|
||||
@@ -207,6 +206,7 @@ export const STATE_CONDITION_HIDDEN_ATTRIBUTES = [
|
||||
"sound_mode_list",
|
||||
"source_list",
|
||||
"state_class",
|
||||
"supported_color_modes",
|
||||
"swing_modes",
|
||||
"token",
|
||||
];
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import type { HomeAssistantApi } from "../../types";
|
||||
import type { EntityIdFormat } from "../entity_id_format";
|
||||
|
||||
export interface EntityRegistrySettings {
|
||||
entity_id_parts: EntityIdFormat | null;
|
||||
}
|
||||
|
||||
export const fetchEntityRegistrySettings = (
|
||||
api: HomeAssistantApi
|
||||
): Promise<EntityRegistrySettings> =>
|
||||
api.callWS<EntityRegistrySettings>({
|
||||
type: "config/entity_registry/settings/get",
|
||||
});
|
||||
|
||||
export const updateEntityRegistrySettings = (
|
||||
api: HomeAssistantApi,
|
||||
updates: Partial<EntityRegistrySettings>
|
||||
): Promise<EntityRegistrySettings> =>
|
||||
api.callWS<EntityRegistrySettings>({
|
||||
type: "config/entity_registry/settings/update",
|
||||
...updates,
|
||||
});
|
||||
@@ -0,0 +1,12 @@
|
||||
export type EntityIdPart = "area" | "device" | "entity" | "floor";
|
||||
|
||||
export type EntityIdFormat = EntityIdPart[];
|
||||
|
||||
export const DEFAULT_ENTITY_ID_FORMAT: EntityIdFormat = [
|
||||
"area",
|
||||
"device",
|
||||
"entity",
|
||||
];
|
||||
|
||||
export const isDefaultEntityIdFormat = (format: EntityIdFormat): boolean =>
|
||||
JSON.stringify(format) === JSON.stringify(DEFAULT_ENTITY_ID_FORMAT);
|
||||
@@ -0,0 +1,4 @@
|
||||
export enum CameraEntityFeature {
|
||||
ON_OFF = 1,
|
||||
STREAM = 2,
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
export enum ClimateEntityFeature {
|
||||
TARGET_TEMPERATURE = 1,
|
||||
TARGET_TEMPERATURE_RANGE = 2,
|
||||
TARGET_HUMIDITY = 4,
|
||||
FAN_MODE = 8,
|
||||
PRESET_MODE = 16,
|
||||
SWING_MODE = 32,
|
||||
AUX_HEAT = 64,
|
||||
TURN_OFF = 128,
|
||||
TURN_ON = 256,
|
||||
SWING_HORIZONTAL_MODE = 512,
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
export enum CoverEntityFeature {
|
||||
OPEN = 1,
|
||||
CLOSE = 2,
|
||||
SET_POSITION = 4,
|
||||
STOP = 8,
|
||||
OPEN_TILT = 16,
|
||||
CLOSE_TILT = 32,
|
||||
STOP_TILT = 64,
|
||||
SET_TILT_POSITION = 128,
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
export enum MediaPlayerEntityFeature {
|
||||
PAUSE = 1,
|
||||
SEEK = 2,
|
||||
VOLUME_SET = 4,
|
||||
VOLUME_MUTE = 8,
|
||||
PREVIOUS_TRACK = 16,
|
||||
NEXT_TRACK = 32,
|
||||
|
||||
TURN_ON = 128,
|
||||
TURN_OFF = 256,
|
||||
PLAY_MEDIA = 512,
|
||||
VOLUME_STEP = 1024,
|
||||
SELECT_SOURCE = 2048,
|
||||
STOP = 4096,
|
||||
CLEAR_PLAYLIST = 8192,
|
||||
PLAY = 16384,
|
||||
SHUFFLE_SET = 32768,
|
||||
SELECT_SOUND_MODE = 65536,
|
||||
BROWSE_MEDIA = 131072,
|
||||
REPEAT_SET = 262144,
|
||||
GROUPING = 524288,
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
export enum SirenEntityFeature {
|
||||
TURN_ON = 1,
|
||||
TURN_OFF = 2,
|
||||
TONES = 4,
|
||||
VOLUME_SET = 8,
|
||||
DURATION = 16,
|
||||
}
|
||||
@@ -1,6 +1,11 @@
|
||||
import type { Connection } from "home-assistant-js-websocket";
|
||||
import type { ShortcutItem } from "./home_shortcuts";
|
||||
|
||||
export interface SurveyInteraction {
|
||||
date: string;
|
||||
action: "opened" | "dismissed";
|
||||
}
|
||||
|
||||
export interface CoreFrontendUserData {
|
||||
showEntityIdPicker?: boolean;
|
||||
default_panel?: string;
|
||||
@@ -16,6 +21,9 @@ export interface CoreFrontendSystemData {
|
||||
default_panel?: string;
|
||||
onboarded_version?: string;
|
||||
onboarded_date?: string;
|
||||
surveys?: {
|
||||
onboarding?: SurveyInteraction;
|
||||
};
|
||||
}
|
||||
|
||||
export interface HomeFrontendSystemData {
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
};
|
||||
|
||||
@@ -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
@@ -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",
|
||||
});
|
||||
};
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user