mirror of
https://github.com/home-assistant/frontend.git
synced 2026-07-29 09:46:59 +00:00
Compare commits
80 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 60148ea54e | |||
| 52f2b5abed | |||
| e658113ef4 | |||
| c35e316ef7 | |||
| 946c40d8a3 | |||
| 7ca7c08090 | |||
| 73865161e7 | |||
| f9d1059ff6 | |||
| 691e77f948 | |||
| 6667d0e57e | |||
| 70ec53ff43 | |||
| e34c1b9857 | |||
| 06580f3dbc | |||
| fe8f32423f | |||
| b14343b831 | |||
| 972ade22bf | |||
| 0e2a5e414b | |||
| 428a21175e | |||
| 1bcf6aa882 | |||
| b7a1f1d0db | |||
| 2bbd9d37e4 | |||
| f9bddc1228 | |||
| 669eab4d31 | |||
| ea179bd7fe | |||
| ef50cc0332 | |||
| ab0ce2a3b7 | |||
| 23bc2cf1c1 | |||
| 85e94841a9 | |||
| 1de7230d9e | |||
| 7620f21033 | |||
| 425a914d34 | |||
| f413e71d54 | |||
| 28045f6cd6 | |||
| 34f3283850 | |||
| d02c4924c4 | |||
| e71bb867be | |||
| 60163efd56 | |||
| 351859c3d0 | |||
| 5f0f1f4867 | |||
| 18de8b13d3 | |||
| 39e70ff315 | |||
| 14fd9114d6 | |||
| 406a0b2bad | |||
| 4c86785d21 | |||
| 2aeb522eed | |||
| bef1c0def6 | |||
| f7e5bdb2a0 | |||
| de59237cfb | |||
| f778572b89 | |||
| d2c753b589 | |||
| 8fbb38a25d | |||
| 2f6c1388e8 | |||
| ced5ffdd18 | |||
| 5a6a16ed27 | |||
| 3d1ed43edc | |||
| d682830a3e | |||
| 863aad24fc | |||
| 3284633ac9 | |||
| 8dd54e0aa8 | |||
| 90a79d458f | |||
| 34dba7ea5a | |||
| e76e7d1064 | |||
| b6675159ae | |||
| 9d96d0b4aa | |||
| d900cc70c5 | |||
| 837baff26c | |||
| 43b4e247af | |||
| 17def3c5ee | |||
| ba15879662 | |||
| 0046f72641 | |||
| f66ba11705 | |||
| bded4341df | |||
| 7a6091b5e2 | |||
| 6d12672c3e | |||
| 495562d748 | |||
| 5cf7fb3a3d | |||
| b6813ce060 | |||
| 3a060feb82 | |||
| ab39c4dd4a | |||
| 82167a81f7 |
@@ -23,11 +23,10 @@ Dialog implementation requirements:
|
||||
|
||||
- Use `ha-dialog`.
|
||||
- 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`.
|
||||
- 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.
|
||||
- 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.
|
||||
@@ -64,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>
|
||||
```
|
||||
@@ -78,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.
|
||||
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
---
|
||||
name: ha-frontend-demo
|
||||
description: Home Assistant standalone demo structure, configurations, URL navigation, mocked APIs, shared demo stubs used by gallery, and verification. Use when changing files under demo/, changing gallery consumers of demo/src/stubs/, or running and validating the demo.
|
||||
---
|
||||
|
||||
# HA Frontend Demo
|
||||
|
||||
Use this skill for standalone demo work and changes to shared demo stubs consumed by the gallery. Follow the persistent repository guidance in `AGENTS.md` and load matching specialist skills alongside this skill, especially `ha-frontend-testing` when running or validating the demo.
|
||||
|
||||
## Purpose
|
||||
|
||||
The demo is the full Home Assistant frontend running against a mocked backend and is published at <https://demo.home-assistant.io>. It needs no Home Assistant server, which makes it the easiest way to load the real UI in a browser, for example to take screenshots.
|
||||
|
||||
## Running The Demo
|
||||
|
||||
Run commands from the repository root:
|
||||
|
||||
```bash
|
||||
yarn dev:demo # Development server on http://localhost:8090
|
||||
yarn dev:demo --background # Detached; also supports --status/--stop/--logs
|
||||
```
|
||||
|
||||
Use the E2E workflows documented in `ha-frontend-testing` when validating the demo.
|
||||
|
||||
## Opening A Specific Demo
|
||||
|
||||
The demo contains multiple demo configurations. Select one directly with the `demo` query parameter, for example `http://localhost:8090/?demo=<slug>`. The valid slugs are defined in `demoConfigs` in `demo/src/configs/demo-configs.ts`, so "the second demo" means the slug of the second entry in that list. An unknown slug falls back to the default, which is the first entry.
|
||||
|
||||
## Opening A Specific Page
|
||||
|
||||
The demo build uses hash-based routing: the frontend path goes in the URL hash, and the `demo` query parameter goes before the `#`. The URL format is:
|
||||
|
||||
```text
|
||||
http://localhost:8090/?demo=<slug>#/<path>
|
||||
```
|
||||
|
||||
Useful paths:
|
||||
|
||||
- `/lovelace/0`: The selected demo's dashboard, also the default when no hash is given.
|
||||
- `/energy`: The energy dashboard. Its tabs are views: `/energy/overview` (Summary), `/energy/electricity`, `/energy/gas`, `/energy/water`, and `/energy/now`.
|
||||
- `/map`, `/history`, `/todo`, `/config`: Other sidebar panels.
|
||||
|
||||
For example, the water tab of the energy dashboard of the second demo is:
|
||||
|
||||
```text
|
||||
http://localhost:8090/?demo=<second slug>#/energy/water
|
||||
```
|
||||
|
||||
## Structure
|
||||
|
||||
- `demo/src/ha-demo.ts`: Root element that sets up all backend mocks.
|
||||
- `demo/src/configs/<slug>/`: One directory per demo configuration, containing entities, dashboard, and theme data.
|
||||
- `demo/src/configs/demo-configs.ts`: Registry of demo configurations and URL slug handling.
|
||||
- `demo/src/stubs/`: Mocked WebSocket and REST APIs.
|
||||
- `demo/script/develop_demo`, `demo/script/build_demo`: Development server and static build wrappers.
|
||||
|
||||
## Shared Gallery Stubs
|
||||
|
||||
`demo/src/stubs/` is shared, not demo-private: gallery pages import from it directly. Before changing or removing anything a stub does, search for its callers across `demo/src/` and `gallery/src/`, then check the affected gallery pages as well as the demo.
|
||||
|
||||
The two consumers use a stub differently, so demo behavior does not predict gallery behavior. A gallery page calls stubs against the `hass` from `provideHass`, where `hass.config` is the shared `demoConfig` object. A stub that mutates `hass.config` in place is therefore visible to the page. `demo/src/ha-demo.ts` copies `components` into a new array before the stubs run, so the same mutation never reaches the demo. A change can look correct in the demo while quietly breaking a gallery page.
|
||||
|
||||
## Verification
|
||||
|
||||
Load `ha-frontend-testing` and use its demo and gallery workflows. Validate both consumers when changing shared stubs. Documentation-only changes do not require code tests unless examples or commands change.
|
||||
@@ -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.
|
||||
@@ -65,9 +65,9 @@ The app suite uses a stripped-down harness for e2e. Demo and gallery use their n
|
||||
|
||||
## 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.updates_refreshed", {
|
||||
this._localize("ui.panel.config.updates.updates_refreshed", {
|
||||
count: 5,
|
||||
});
|
||||
```
|
||||
|
||||
@@ -32,7 +32,7 @@ jobs:
|
||||
ACTIONLINT_VERSION: 1.7.12
|
||||
steps:
|
||||
- name: Check out files from GitHub
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Run actionlint
|
||||
|
||||
@@ -21,7 +21,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out workflow scripts
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
sparse-checkout: .github/scripts
|
||||
|
||||
@@ -24,7 +24,7 @@ jobs:
|
||||
url: ${{ steps.deploy.outputs.netlify_url }}
|
||||
steps:
|
||||
- name: Check out files from GitHub
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
ref: dev
|
||||
persist-credentials: false
|
||||
@@ -56,7 +56,7 @@ jobs:
|
||||
url: ${{ steps.deploy.outputs.netlify_url }}
|
||||
steps:
|
||||
- name: Check out files from GitHub
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
ref: master
|
||||
persist-credentials: false
|
||||
|
||||
@@ -27,7 +27,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out files from GitHub
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Prepare dependencies
|
||||
@@ -39,7 +39,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out files from GitHub
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Setup Node with shared dependencies
|
||||
@@ -82,7 +82,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out files from GitHub
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Setup Node with shared dependencies
|
||||
@@ -104,7 +104,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out files from GitHub
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Setup Node with shared dependencies
|
||||
|
||||
@@ -27,17 +27,17 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Check out code from GitHub
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@99df26d4f13ea111d4ec1a7dddef6063f76b97e9 # v4.37.0
|
||||
uses: github/codeql-action/init@7188fc363630916deb702c7fdcf4e481b751f97a # v4.37.1
|
||||
with:
|
||||
languages: javascript-typescript
|
||||
build-mode: none
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@99df26d4f13ea111d4ec1a7dddef6063f76b97e9 # v4.37.0
|
||||
uses: github/codeql-action/analyze@7188fc363630916deb702c7fdcf4e481b751f97a # v4.37.1
|
||||
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@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
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
|
||||
@@ -25,7 +25,7 @@ jobs:
|
||||
url: ${{ steps.deploy.outputs.unique_deploy_url }}
|
||||
steps:
|
||||
- name: Check out files from GitHub
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
ref: dev
|
||||
persist-credentials: false
|
||||
@@ -56,7 +56,7 @@ jobs:
|
||||
url: ${{ steps.deploy.outputs.netlify_url }}
|
||||
steps:
|
||||
- name: Check out files from GitHub
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
ref: master
|
||||
persist-credentials: false
|
||||
|
||||
@@ -19,7 +19,7 @@ jobs:
|
||||
url: ${{ steps.deploy.outputs.netlify_url }}
|
||||
steps:
|
||||
- name: Check out files from GitHub
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
|
||||
@@ -24,7 +24,7 @@ jobs:
|
||||
if: github.repository == 'home-assistant/frontend' && contains(github.event.pull_request.labels.*.name, 'needs design preview')
|
||||
steps:
|
||||
- name: Check out files from GitHub
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
|
||||
+13
-13
@@ -27,7 +27,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out files from GitHub
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
@@ -38,14 +38,14 @@ jobs:
|
||||
name: Prepare container dependencies
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: mcr.microsoft.com/playwright:v1.61.1-noble
|
||||
image: mcr.microsoft.com/playwright:v1.62.0-noble
|
||||
options: --user 1001
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
steps:
|
||||
- name: Check out files from GitHub
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
@@ -61,7 +61,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out files from GitHub
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
@@ -92,7 +92,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out files from GitHub
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
@@ -123,7 +123,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out files from GitHub
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
@@ -154,7 +154,7 @@ jobs:
|
||||
- prepare-container-dependencies
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: mcr.microsoft.com/playwright:v1.61.1-noble
|
||||
image: mcr.microsoft.com/playwright:v1.62.0-noble
|
||||
options: --user 1001 --ipc=host
|
||||
defaults:
|
||||
run:
|
||||
@@ -170,7 +170,7 @@ jobs:
|
||||
- 2
|
||||
steps:
|
||||
- name: Check out files from GitHub
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
@@ -206,7 +206,7 @@ jobs:
|
||||
- prepare-container-dependencies
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: mcr.microsoft.com/playwright:v1.61.1-noble
|
||||
image: mcr.microsoft.com/playwright:v1.62.0-noble
|
||||
options: --user 1001 --ipc=host
|
||||
defaults:
|
||||
run:
|
||||
@@ -224,7 +224,7 @@ jobs:
|
||||
- 4
|
||||
steps:
|
||||
- name: Check out files from GitHub
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
@@ -260,7 +260,7 @@ jobs:
|
||||
- prepare-container-dependencies
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: mcr.microsoft.com/playwright:v1.61.1-noble
|
||||
image: mcr.microsoft.com/playwright:v1.62.0-noble
|
||||
options: --user 1001 --ipc=host
|
||||
defaults:
|
||||
run:
|
||||
@@ -278,7 +278,7 @@ jobs:
|
||||
- 4
|
||||
steps:
|
||||
- name: Check out files from GitHub
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
@@ -320,7 +320,7 @@ jobs:
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: Check out files from GitHub
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
|
||||
@@ -10,6 +10,6 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Apply labels
|
||||
uses: actions/labeler@b8dd2d9be0f68b860e7dae5dae7d772984eacd6d # v6.2.0
|
||||
uses: actions/labeler@bf12e9b00b37c5c0ca2b87b79b2daf7891dbda13 # v7.0.0
|
||||
with:
|
||||
sync-labels: true
|
||||
|
||||
@@ -20,12 +20,12 @@ jobs:
|
||||
contents: write
|
||||
steps:
|
||||
- name: Checkout the repository
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Set up Python ${{ env.PYTHON_VERSION }}
|
||||
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6
|
||||
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
|
||||
with:
|
||||
python-version: ${{ env.PYTHON_VERSION }}
|
||||
|
||||
|
||||
@@ -24,7 +24,7 @@ jobs:
|
||||
pull-requests: write # To label and comment on pull requests
|
||||
steps:
|
||||
- name: Check out workflow scripts
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
sparse-checkout: .github/scripts
|
||||
|
||||
@@ -18,6 +18,6 @@ jobs:
|
||||
pull-requests: read
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: release-drafter/release-drafter@4d75298e00d9e34c483e5ff8c68d0ea1c1940c1e # v7.5.1
|
||||
- uses: release-drafter/release-drafter@eada3c96a64734dd381cfbda23511034e328ddb0 # v7.6.0
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
@@ -26,17 +26,17 @@ jobs:
|
||||
if: github.repository_owner == 'home-assistant'
|
||||
steps:
|
||||
- name: Checkout the repository
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Set up Python ${{ env.PYTHON_VERSION }}
|
||||
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0
|
||||
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
|
||||
with:
|
||||
python-version: ${{ env.PYTHON_VERSION }}
|
||||
|
||||
- name: Verify version
|
||||
uses: home-assistant/actions/helpers/verify-version@f4ca6f671bd429efb108c0f2fa0ae8af0215986c # master
|
||||
uses: home-assistant/actions/helpers/verify-version@e3fb68ebda13d88a0d695082f471ba2c83d025fb # master
|
||||
|
||||
- name: Setup Node and install
|
||||
uses: ./.github/actions/setup
|
||||
@@ -56,7 +56,7 @@ jobs:
|
||||
script/release
|
||||
|
||||
- name: Publish to PyPI
|
||||
uses: pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b # v1.14.0
|
||||
uses: pypa/gh-action-pypi-publish@ba38be9e461d3875417946c167d0b5f3d385a247 # v1.14.1
|
||||
with:
|
||||
skip-existing: true
|
||||
|
||||
@@ -111,7 +111,7 @@ jobs:
|
||||
contents: write # Required to upload release assets
|
||||
steps:
|
||||
- name: Checkout the repository
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Setup Node and install
|
||||
|
||||
@@ -42,7 +42,7 @@ jobs:
|
||||
if: github.event.issue.type.name == 'Task'
|
||||
steps:
|
||||
- name: Check out workflow scripts
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
sparse-checkout: .github/scripts
|
||||
|
||||
@@ -21,7 +21,7 @@ jobs:
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: Checkout the repository
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout the repository
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
|
||||
@@ -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,6 +44,8 @@ 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.
|
||||
- `ha-frontend-demo`: standalone demo structure, configurations, navigation, shared stubs, and verification.
|
||||
|
||||
## Pull Requests
|
||||
|
||||
|
||||
@@ -52,6 +52,23 @@ gulp.task("fetch-nightly-translations", async function () {
|
||||
currentArtifact = null;
|
||||
}
|
||||
|
||||
try {
|
||||
await fetchTranslations(currentArtifact);
|
||||
} catch (err) {
|
||||
// Local builds should work offline or without valid GitHub credentials,
|
||||
// so fall back to English only. CI must fail instead of silently
|
||||
// building without translations.
|
||||
if (process.env.CI) {
|
||||
throw err;
|
||||
}
|
||||
console.warn(
|
||||
"Failed to fetch nightly translations, continuing with English only:",
|
||||
err?.message || err
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
async function fetchTranslations(currentArtifact) {
|
||||
// To store file writing promises
|
||||
const createExtractDir = mkdir(EXTRACT_DIR, { recursive: true });
|
||||
const writings = [];
|
||||
@@ -130,11 +147,6 @@ gulp.task("fetch-nightly-translations", async function () {
|
||||
if (!latestArtifact) {
|
||||
throw Error("Latest nightly workflow run has no translations artifact");
|
||||
}
|
||||
writings.push(
|
||||
createExtractDir.then(
|
||||
writeFile(ARTIFACT_FILE, JSON.stringify(latestArtifact, null, 2))
|
||||
)
|
||||
);
|
||||
|
||||
// Remove the current translations
|
||||
const deleteCurrent = Promise.all(writings).then(
|
||||
@@ -160,7 +172,12 @@ gulp.task("fetch-nightly-translations", async function () {
|
||||
await new Promise((resolve, reject) => {
|
||||
extractStream.on("close", resolve).on("error", reject);
|
||||
});
|
||||
});
|
||||
|
||||
// Record the artifact only after successful extraction, so a failed fetch
|
||||
// is retried by the next build instead of being considered current.
|
||||
await createExtractDir;
|
||||
await writeFile(ARTIFACT_FILE, JSON.stringify(latestArtifact, null, 2));
|
||||
}
|
||||
|
||||
gulp.task(
|
||||
"setup-and-fetch-nightly-translations",
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import { navigate } from "../../../src/common/navigate";
|
||||
import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
|
||||
import type { Lovelace } from "../../../src/panels/lovelace/types";
|
||||
import { setDemoAreas } from "../stubs/area_registry";
|
||||
import { energyEntities } from "../stubs/entities";
|
||||
import { setDemoFloors } from "../stubs/floor_registry";
|
||||
import { getDemoTheme } from "../stubs/frontend";
|
||||
import type { DemoConfig, DemoTheme } from "./types";
|
||||
|
||||
@@ -12,33 +15,53 @@ export const applyDemoTheme = (hass: MockHomeAssistant, theme: DemoTheme) => {
|
||||
hass.mockTheme(null, getDemoTheme(theme));
|
||||
};
|
||||
|
||||
export const demoConfigs: (() => Promise<DemoConfig>)[] = [
|
||||
() => import("./sections").then((mod) => mod.demoSections),
|
||||
() => import("./arsaboo").then((mod) => mod.demoArsaboo),
|
||||
() => import("./teachingbirds").then((mod) => mod.demoTeachingbirds),
|
||||
() => import("./kernehed").then((mod) => mod.demoKernehed),
|
||||
() => import("./jimpower").then((mod) => mod.demoJimpower),
|
||||
];
|
||||
export const demoConfigs: Record<string, () => Promise<DemoConfig>> = {
|
||||
sections: () => import("./sections").then((mod) => mod.demoSections),
|
||||
home: () => import("./home").then((mod) => mod.demoHome),
|
||||
arsaboo: () => import("./arsaboo").then((mod) => mod.demoArsaboo),
|
||||
teachingbirds: () =>
|
||||
import("./teachingbirds").then((mod) => mod.demoTeachingbirds),
|
||||
kernehed: () => import("./kernehed").then((mod) => mod.demoKernehed),
|
||||
jimpower: () => import("./jimpower").then((mod) => mod.demoJimpower),
|
||||
};
|
||||
|
||||
export const demos = Object.keys(demoConfigs);
|
||||
|
||||
const initialDemo = () => {
|
||||
const slug = new URLSearchParams(window.location.search).get("demo");
|
||||
return slug && demos.includes(slug) ? slug : demos[0];
|
||||
};
|
||||
|
||||
// eslint-disable-next-line import-x/no-mutable-exports
|
||||
export let selectedDemoConfigIndex = 0;
|
||||
export let selectedDemo = initialDemo();
|
||||
// eslint-disable-next-line import-x/no-mutable-exports
|
||||
export let selectedDemoConfig: Promise<DemoConfig> =
|
||||
demoConfigs[selectedDemoConfigIndex]();
|
||||
demoConfigs[selectedDemo]();
|
||||
|
||||
export const setDemoConfig = async (
|
||||
hass: MockHomeAssistant,
|
||||
lovelace: Lovelace,
|
||||
index: number
|
||||
demo: string
|
||||
) => {
|
||||
const confProm = demoConfigs[index]();
|
||||
const confProm = demoConfigs[demo]();
|
||||
const config = await confProm;
|
||||
|
||||
selectedDemoConfigIndex = index;
|
||||
selectedDemo = demo;
|
||||
selectedDemoConfig = confProm;
|
||||
|
||||
setDemoFloors(hass, config.floors);
|
||||
setDemoAreas(hass, config.areas);
|
||||
hass.addEntities(config.entities(hass.localize), true);
|
||||
hass.addEntities(energyEntities());
|
||||
lovelace.saveConfig(config.lovelace(hass.localize));
|
||||
|
||||
// Let the new registries and entities reach the dashboard before saving the
|
||||
// config, so dashboard strategies generate against them
|
||||
await new Promise((resolve) => {
|
||||
setTimeout(resolve, 0);
|
||||
});
|
||||
|
||||
await lovelace.saveConfig(config.lovelace(hass.localize));
|
||||
// The view of the previous demo might not exist in the new one
|
||||
navigate(`/${hass.panelUrl}?demo=${demo}`, { replace: true });
|
||||
applyDemoTheme(hass, config.theme);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import type { DemoArea } from "../../stubs/area_registry";
|
||||
import type { DemoFloor } from "../../stubs/floor_registry";
|
||||
|
||||
export const demoFloorsHome: DemoFloor[] = [
|
||||
{
|
||||
floor_id: "ground",
|
||||
name: "Ground floor",
|
||||
level: 0,
|
||||
},
|
||||
{
|
||||
floor_id: "upstairs",
|
||||
name: "Upstairs",
|
||||
level: 1,
|
||||
},
|
||||
];
|
||||
|
||||
export const demoAreasHome: DemoArea[] = [
|
||||
{
|
||||
area_id: "living_room",
|
||||
name: "Living room",
|
||||
floor_id: "ground",
|
||||
icon: "mdi:sofa",
|
||||
temperature_entity_id: "sensor.living_room_temperature",
|
||||
humidity_entity_id: "sensor.living_room_humidity",
|
||||
},
|
||||
{
|
||||
area_id: "kitchen",
|
||||
name: "Kitchen",
|
||||
floor_id: "ground",
|
||||
icon: "mdi:fridge",
|
||||
temperature_entity_id: "sensor.kitchen_temperature",
|
||||
humidity_entity_id: "sensor.kitchen_humidity",
|
||||
},
|
||||
{
|
||||
area_id: "entrance",
|
||||
name: "Entrance",
|
||||
floor_id: "ground",
|
||||
icon: "mdi:door",
|
||||
},
|
||||
{
|
||||
area_id: "bedroom",
|
||||
name: "Bedroom",
|
||||
floor_id: "upstairs",
|
||||
icon: "mdi:bed",
|
||||
temperature_entity_id: "sensor.bedroom_temperature",
|
||||
humidity_entity_id: "sensor.bedroom_humidity",
|
||||
},
|
||||
{
|
||||
area_id: "office",
|
||||
name: "Office",
|
||||
floor_id: "upstairs",
|
||||
icon: "mdi:desk",
|
||||
temperature_entity_id: "sensor.office_temperature",
|
||||
humidity_entity_id: "sensor.office_humidity",
|
||||
},
|
||||
{
|
||||
area_id: "garden",
|
||||
name: "Garden",
|
||||
icon: "mdi:tree",
|
||||
temperature_entity_id: "sensor.garden_temperature",
|
||||
humidity_entity_id: "sensor.garden_humidity",
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,465 @@
|
||||
import type { DemoConfig } from "../types";
|
||||
|
||||
export const demoEntitiesHome: DemoConfig["entities"] = () => [
|
||||
// The devices tile on the overview uses zone.home, which always exists in
|
||||
// a real installation.
|
||||
{
|
||||
entity_id: "zone.home",
|
||||
state: "1",
|
||||
attributes: {
|
||||
latitude: 52.3731339,
|
||||
longitude: 4.8903147,
|
||||
radius: 100,
|
||||
friendly_name: "Home",
|
||||
icon: "mdi:home",
|
||||
},
|
||||
},
|
||||
{
|
||||
entity_id: "light.living_room_floor_lamp",
|
||||
state: "on",
|
||||
area_id: "living_room",
|
||||
attributes: {
|
||||
min_color_temp_kelvin: 2000,
|
||||
max_color_temp_kelvin: 6535,
|
||||
supported_color_modes: ["color_temp", "xy"],
|
||||
color_mode: "color_temp",
|
||||
brightness: 178,
|
||||
color_temp_kelvin: 2583,
|
||||
icon: "mdi:floor-lamp",
|
||||
friendly_name: "Floor lamp",
|
||||
supported_features: 44,
|
||||
},
|
||||
},
|
||||
{
|
||||
entity_id: "light.living_room_spotlights",
|
||||
state: "on",
|
||||
area_id: "living_room",
|
||||
attributes: {
|
||||
supported_color_modes: ["brightness"],
|
||||
color_mode: "brightness",
|
||||
brightness: 126,
|
||||
icon: "mdi:ceiling-light-multiple",
|
||||
friendly_name: "Spotlights",
|
||||
supported_features: 32,
|
||||
},
|
||||
},
|
||||
{
|
||||
entity_id: "climate.living_room",
|
||||
state: "heat",
|
||||
area_id: "living_room",
|
||||
attributes: {
|
||||
hvac_modes: ["auto", "heat", "off"],
|
||||
min_temp: 7,
|
||||
max_temp: 35,
|
||||
current_temperature: 21.5,
|
||||
temperature: 21,
|
||||
friendly_name: "Thermostat",
|
||||
supported_features: 385,
|
||||
},
|
||||
},
|
||||
{
|
||||
entity_id: "cover.living_room_blinds",
|
||||
state: "open",
|
||||
area_id: "living_room",
|
||||
attributes: {
|
||||
current_position: 100,
|
||||
device_class: "blind",
|
||||
friendly_name: "Blinds",
|
||||
supported_features: 15,
|
||||
},
|
||||
},
|
||||
{
|
||||
entity_id: "binary_sensor.living_room_window",
|
||||
state: "off",
|
||||
area_id: "living_room",
|
||||
attributes: {
|
||||
device_class: "window",
|
||||
friendly_name: "Window",
|
||||
},
|
||||
},
|
||||
{
|
||||
entity_id: "media_player.living_room_speaker",
|
||||
state: "playing",
|
||||
area_id: "living_room",
|
||||
attributes: {
|
||||
device_class: "speaker",
|
||||
volume_level: 0.28,
|
||||
is_volume_muted: false,
|
||||
media_content_type: "music",
|
||||
media_duration: 300,
|
||||
media_position: 0,
|
||||
media_position_updated_at: new Date(
|
||||
new Date().getTime() - 23000
|
||||
).toISOString(),
|
||||
media_title: "I Wasn't Born To Follow",
|
||||
media_artist: "The Byrds",
|
||||
media_album_name: "The Notorious Byrd Brothers",
|
||||
shuffle: false,
|
||||
friendly_name: "Living room speaker",
|
||||
supported_features: 64063,
|
||||
},
|
||||
},
|
||||
{
|
||||
entity_id: "sensor.living_room_temperature",
|
||||
state: "21.5",
|
||||
area_id: "living_room",
|
||||
attributes: {
|
||||
state_class: "measurement",
|
||||
unit_of_measurement: "°C",
|
||||
device_class: "temperature",
|
||||
friendly_name: "Temperature",
|
||||
},
|
||||
},
|
||||
{
|
||||
entity_id: "sensor.living_room_humidity",
|
||||
state: "45",
|
||||
area_id: "living_room",
|
||||
attributes: {
|
||||
state_class: "measurement",
|
||||
unit_of_measurement: "%",
|
||||
device_class: "humidity",
|
||||
friendly_name: "Humidity",
|
||||
},
|
||||
},
|
||||
{
|
||||
entity_id: "light.kitchen_spotlights",
|
||||
state: "on",
|
||||
area_id: "kitchen",
|
||||
attributes: {
|
||||
supported_color_modes: ["brightness"],
|
||||
color_mode: "brightness",
|
||||
brightness: 255,
|
||||
icon: "mdi:ceiling-light-multiple",
|
||||
friendly_name: "Spotlights",
|
||||
supported_features: 32,
|
||||
},
|
||||
},
|
||||
{
|
||||
entity_id: "light.kitchen_worktop",
|
||||
state: "off",
|
||||
area_id: "kitchen",
|
||||
attributes: {
|
||||
supported_color_modes: ["brightness"],
|
||||
color_mode: null,
|
||||
brightness: null,
|
||||
icon: "mdi:light-flood-down",
|
||||
friendly_name: "Worktop",
|
||||
supported_features: 32,
|
||||
},
|
||||
},
|
||||
{
|
||||
entity_id: "switch.coffee_machine",
|
||||
state: "on",
|
||||
area_id: "kitchen",
|
||||
attributes: {
|
||||
icon: "mdi:coffee-maker",
|
||||
friendly_name: "Coffee machine",
|
||||
},
|
||||
},
|
||||
{
|
||||
entity_id: "binary_sensor.kitchen_motion",
|
||||
state: "off",
|
||||
area_id: "kitchen",
|
||||
attributes: {
|
||||
device_class: "motion",
|
||||
friendly_name: "Motion",
|
||||
},
|
||||
},
|
||||
{
|
||||
entity_id: "media_player.kitchen_speaker",
|
||||
state: "idle",
|
||||
area_id: "kitchen",
|
||||
attributes: {
|
||||
device_class: "speaker",
|
||||
volume_level: 0.18,
|
||||
is_volume_muted: false,
|
||||
friendly_name: "Kitchen speaker",
|
||||
supported_features: 64063,
|
||||
},
|
||||
},
|
||||
{
|
||||
entity_id: "sensor.kitchen_temperature",
|
||||
state: "22.1",
|
||||
area_id: "kitchen",
|
||||
attributes: {
|
||||
state_class: "measurement",
|
||||
unit_of_measurement: "°C",
|
||||
device_class: "temperature",
|
||||
friendly_name: "Temperature",
|
||||
},
|
||||
},
|
||||
{
|
||||
entity_id: "sensor.kitchen_humidity",
|
||||
state: "48",
|
||||
area_id: "kitchen",
|
||||
attributes: {
|
||||
state_class: "measurement",
|
||||
unit_of_measurement: "%",
|
||||
device_class: "humidity",
|
||||
friendly_name: "Humidity",
|
||||
},
|
||||
},
|
||||
{
|
||||
entity_id: "lock.front_door",
|
||||
state: "locked",
|
||||
area_id: "entrance",
|
||||
attributes: {
|
||||
friendly_name: "Front door",
|
||||
supported_features: 1,
|
||||
},
|
||||
},
|
||||
{
|
||||
entity_id: "binary_sensor.front_door",
|
||||
state: "off",
|
||||
area_id: "entrance",
|
||||
attributes: {
|
||||
device_class: "door",
|
||||
friendly_name: "Front door",
|
||||
},
|
||||
},
|
||||
{
|
||||
entity_id: "alarm_control_panel.home_alarm",
|
||||
state: "disarmed",
|
||||
area_id: "entrance",
|
||||
attributes: {
|
||||
changed_by: null,
|
||||
code_arm_required: false,
|
||||
friendly_name: "Home alarm",
|
||||
supported_features: 3,
|
||||
},
|
||||
},
|
||||
{
|
||||
entity_id: "light.entrance_ceiling",
|
||||
state: "off",
|
||||
area_id: "entrance",
|
||||
attributes: {
|
||||
supported_color_modes: ["brightness"],
|
||||
color_mode: null,
|
||||
brightness: null,
|
||||
friendly_name: "Ceiling light",
|
||||
supported_features: 32,
|
||||
},
|
||||
},
|
||||
{
|
||||
entity_id: "sensor.front_door_lock_battery",
|
||||
state: "88",
|
||||
area_id: "entrance",
|
||||
attributes: {
|
||||
state_class: "measurement",
|
||||
unit_of_measurement: "%",
|
||||
device_class: "battery",
|
||||
friendly_name: "Front door lock battery",
|
||||
},
|
||||
},
|
||||
{
|
||||
entity_id: "light.bedroom_ceiling",
|
||||
state: "off",
|
||||
area_id: "bedroom",
|
||||
attributes: {
|
||||
supported_color_modes: ["color_temp"],
|
||||
color_mode: null,
|
||||
brightness: null,
|
||||
friendly_name: "Ceiling light",
|
||||
supported_features: 44,
|
||||
},
|
||||
},
|
||||
{
|
||||
entity_id: "light.bedside_lamp",
|
||||
state: "off",
|
||||
area_id: "bedroom",
|
||||
attributes: {
|
||||
supported_color_modes: ["brightness"],
|
||||
color_mode: null,
|
||||
brightness: null,
|
||||
icon: "mdi:lamp",
|
||||
friendly_name: "Bedside lamp",
|
||||
supported_features: 32,
|
||||
},
|
||||
},
|
||||
{
|
||||
entity_id: "cover.bedroom_shutter",
|
||||
state: "open",
|
||||
area_id: "bedroom",
|
||||
attributes: {
|
||||
current_position: 100,
|
||||
device_class: "shutter",
|
||||
friendly_name: "Shutter",
|
||||
supported_features: 15,
|
||||
},
|
||||
},
|
||||
{
|
||||
entity_id: "media_player.bedroom_speaker",
|
||||
state: "off",
|
||||
area_id: "bedroom",
|
||||
attributes: {
|
||||
device_class: "speaker",
|
||||
friendly_name: "Bedroom speaker",
|
||||
supported_features: 64063,
|
||||
},
|
||||
},
|
||||
{
|
||||
entity_id: "binary_sensor.bedroom_motion",
|
||||
state: "off",
|
||||
area_id: "bedroom",
|
||||
attributes: {
|
||||
device_class: "motion",
|
||||
friendly_name: "Motion",
|
||||
},
|
||||
},
|
||||
{
|
||||
entity_id: "sensor.bedroom_motion_battery",
|
||||
state: "15",
|
||||
area_id: "bedroom",
|
||||
attributes: {
|
||||
state_class: "measurement",
|
||||
unit_of_measurement: "%",
|
||||
device_class: "battery",
|
||||
friendly_name: "Motion sensor battery",
|
||||
},
|
||||
},
|
||||
{
|
||||
entity_id: "sensor.bedroom_temperature",
|
||||
state: "19.6",
|
||||
area_id: "bedroom",
|
||||
attributes: {
|
||||
state_class: "measurement",
|
||||
unit_of_measurement: "°C",
|
||||
device_class: "temperature",
|
||||
friendly_name: "Temperature",
|
||||
},
|
||||
},
|
||||
{
|
||||
entity_id: "sensor.bedroom_humidity",
|
||||
state: "52",
|
||||
area_id: "bedroom",
|
||||
attributes: {
|
||||
state_class: "measurement",
|
||||
unit_of_measurement: "%",
|
||||
device_class: "humidity",
|
||||
friendly_name: "Humidity",
|
||||
},
|
||||
},
|
||||
{
|
||||
entity_id: "light.office_desk_lamp",
|
||||
state: "on",
|
||||
area_id: "office",
|
||||
attributes: {
|
||||
supported_color_modes: ["brightness"],
|
||||
color_mode: "brightness",
|
||||
brightness: 200,
|
||||
icon: "mdi:desk-lamp",
|
||||
friendly_name: "Desk lamp",
|
||||
supported_features: 32,
|
||||
},
|
||||
},
|
||||
{
|
||||
entity_id: "fan.office_ceiling_fan",
|
||||
state: "off",
|
||||
area_id: "office",
|
||||
attributes: {
|
||||
percentage: 0,
|
||||
percentage_step: 25,
|
||||
friendly_name: "Ceiling fan",
|
||||
supported_features: 1,
|
||||
},
|
||||
},
|
||||
{
|
||||
entity_id: "binary_sensor.office_window",
|
||||
state: "off",
|
||||
area_id: "office",
|
||||
attributes: {
|
||||
device_class: "window",
|
||||
friendly_name: "Window",
|
||||
},
|
||||
},
|
||||
{
|
||||
entity_id: "sensor.office_temperature",
|
||||
state: "21.8",
|
||||
area_id: "office",
|
||||
attributes: {
|
||||
state_class: "measurement",
|
||||
unit_of_measurement: "°C",
|
||||
device_class: "temperature",
|
||||
friendly_name: "Temperature",
|
||||
},
|
||||
},
|
||||
{
|
||||
entity_id: "sensor.office_humidity",
|
||||
state: "50",
|
||||
area_id: "office",
|
||||
attributes: {
|
||||
state_class: "measurement",
|
||||
unit_of_measurement: "%",
|
||||
device_class: "humidity",
|
||||
friendly_name: "Humidity",
|
||||
},
|
||||
},
|
||||
{
|
||||
entity_id: "switch.garden_sprinkler",
|
||||
state: "off",
|
||||
area_id: "garden",
|
||||
attributes: {
|
||||
icon: "mdi:sprinkler-variant",
|
||||
friendly_name: "Sprinkler",
|
||||
},
|
||||
},
|
||||
{
|
||||
entity_id: "light.garden_path",
|
||||
state: "off",
|
||||
area_id: "garden",
|
||||
attributes: {
|
||||
supported_color_modes: ["brightness"],
|
||||
color_mode: null,
|
||||
brightness: null,
|
||||
icon: "mdi:outdoor-lamp",
|
||||
friendly_name: "Path lights",
|
||||
supported_features: 32,
|
||||
},
|
||||
},
|
||||
{
|
||||
entity_id: "binary_sensor.garden_gate",
|
||||
state: "off",
|
||||
area_id: "garden",
|
||||
attributes: {
|
||||
device_class: "opening",
|
||||
friendly_name: "Gate",
|
||||
},
|
||||
},
|
||||
{
|
||||
entity_id: "sensor.garden_temperature",
|
||||
state: "14.2",
|
||||
area_id: "garden",
|
||||
attributes: {
|
||||
state_class: "measurement",
|
||||
unit_of_measurement: "°C",
|
||||
device_class: "temperature",
|
||||
friendly_name: "Temperature",
|
||||
},
|
||||
},
|
||||
{
|
||||
entity_id: "sensor.garden_humidity",
|
||||
state: "68",
|
||||
area_id: "garden",
|
||||
attributes: {
|
||||
state_class: "measurement",
|
||||
unit_of_measurement: "%",
|
||||
device_class: "humidity",
|
||||
friendly_name: "Humidity",
|
||||
},
|
||||
},
|
||||
{
|
||||
entity_id: "weather.home",
|
||||
state: "partlycloudy",
|
||||
area_id: "garden",
|
||||
attributes: {
|
||||
temperature: 14.2,
|
||||
temperature_unit: "°C",
|
||||
humidity: 68,
|
||||
pressure: 1012,
|
||||
pressure_unit: "hPa",
|
||||
wind_speed: 11.2,
|
||||
wind_speed_unit: "km/h",
|
||||
friendly_name: "Home",
|
||||
},
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,17 @@
|
||||
import type { DemoConfig } from "../types";
|
||||
import { demoAreasHome, demoFloorsHome } from "./areas";
|
||||
import { demoEntitiesHome } from "./entities";
|
||||
import { demoLovelaceHome } from "./lovelace";
|
||||
|
||||
export const demoHome: DemoConfig = {
|
||||
authorName: "Home Assistant",
|
||||
authorUrl: "https://www.home-assistant.io",
|
||||
name: "Home page",
|
||||
description:
|
||||
"The page you land on when you open Home Assistant, automatically built from the areas in your home and the devices in them.",
|
||||
lovelace: demoLovelaceHome,
|
||||
entities: demoEntitiesHome,
|
||||
floors: demoFloorsHome,
|
||||
areas: demoAreasHome,
|
||||
theme: { theme: "default", dark: false },
|
||||
};
|
||||
@@ -0,0 +1,9 @@
|
||||
import "./strategies";
|
||||
import type { DemoConfig } from "../types";
|
||||
|
||||
export const demoLovelaceHome: DemoConfig["lovelace"] = () => ({
|
||||
strategy: {
|
||||
type: "custom:demo-home",
|
||||
favorite_entities: ["lock.front_door", "switch.garden_sprinkler"],
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,109 @@
|
||||
import { ReactiveElement } from "lit";
|
||||
import { isStrategySection } from "../../../../src/data/lovelace/config/section";
|
||||
import type { LovelaceConfig } from "../../../../src/data/lovelace/config/types";
|
||||
import type { LovelaceViewConfig } from "../../../../src/data/lovelace/config/view";
|
||||
import { isStrategyView } from "../../../../src/data/lovelace/config/view";
|
||||
import type { HomeDashboardStrategyConfig } from "../../../../src/panels/lovelace/strategies/home/home-dashboard-strategy";
|
||||
import { HomeDashboardStrategy } from "../../../../src/panels/lovelace/strategies/home/home-dashboard-strategy";
|
||||
import type { HomeOverviewViewStrategyConfig } from "../../../../src/panels/lovelace/strategies/home/home-overview-view-strategy";
|
||||
import { HomeOverviewViewStrategy } from "../../../../src/panels/lovelace/strategies/home/home-overview-view-strategy";
|
||||
import { generateLovelaceSectionStrategy } from "../../../../src/panels/lovelace/strategies/get-strategy";
|
||||
import type { HomeAssistant } from "../../../../src/types";
|
||||
|
||||
export interface DemoHomeDashboardStrategyConfig extends Omit<
|
||||
HomeDashboardStrategyConfig,
|
||||
"type"
|
||||
> {
|
||||
type: "custom:demo-home";
|
||||
}
|
||||
|
||||
interface DemoHomeOverviewViewStrategyConfig extends Omit<
|
||||
HomeOverviewViewStrategyConfig,
|
||||
"type"
|
||||
> {
|
||||
type: "custom:demo-home-overview";
|
||||
}
|
||||
|
||||
class DemoHomeDashboardStrategy extends ReactiveElement {
|
||||
static registryDependencies = HomeDashboardStrategy.registryDependencies;
|
||||
|
||||
static async generate(
|
||||
config: DemoHomeDashboardStrategyConfig,
|
||||
hass: HomeAssistant
|
||||
): Promise<LovelaceConfig> {
|
||||
const generated = await HomeDashboardStrategy.generate(
|
||||
{ ...config, type: "home" },
|
||||
hass
|
||||
);
|
||||
// Swap the overview view for the demo version, which adds the demo card.
|
||||
return {
|
||||
...generated,
|
||||
views: generated.views.map((view) =>
|
||||
isStrategyView(view) && view.strategy.type === "home-overview"
|
||||
? {
|
||||
...view,
|
||||
strategy: { ...view.strategy, type: "custom:demo-home-overview" },
|
||||
}
|
||||
: view
|
||||
),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
class DemoHomeOverviewViewStrategy extends ReactiveElement {
|
||||
static registryDependencies = HomeOverviewViewStrategy.registryDependencies;
|
||||
|
||||
static async generate(
|
||||
config: DemoHomeOverviewViewStrategyConfig,
|
||||
hass: HomeAssistant
|
||||
): Promise<LovelaceViewConfig> {
|
||||
const view = await HomeOverviewViewStrategy.generate(
|
||||
{ ...config, type: "home-overview" },
|
||||
hass
|
||||
);
|
||||
// Expand the favorites section so the demo card can be added to it
|
||||
const sections = await Promise.all(
|
||||
(view.sections || []).map(async (section) => {
|
||||
if (
|
||||
!isStrategySection(section) ||
|
||||
section.strategy.type !== "common-controls"
|
||||
) {
|
||||
return section;
|
||||
}
|
||||
// The demo card takes up the space of two tiles
|
||||
const limit = (section.strategy.limit as number | undefined) ?? 8;
|
||||
const favorites = await generateLovelaceSectionStrategy(
|
||||
{ ...section, strategy: { ...section.strategy, limit: limit - 2 } },
|
||||
hass
|
||||
);
|
||||
const [heading, ...cards] = favorites.cards || [];
|
||||
return {
|
||||
...favorites,
|
||||
// Place the demo card first so the tiles fill the rows next to it
|
||||
cards: [heading, { type: "custom:ha-demo-next-card" }, ...cards],
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
return {
|
||||
...view,
|
||||
sections: sections,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define(
|
||||
"ll-strategy-dashboard-demo-home",
|
||||
DemoHomeDashboardStrategy
|
||||
);
|
||||
customElements.define(
|
||||
"ll-strategy-view-demo-home-overview",
|
||||
DemoHomeOverviewViewStrategy
|
||||
);
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"ll-strategy-dashboard-demo-home": DemoHomeDashboardStrategy;
|
||||
"ll-strategy-view-demo-home-overview": DemoHomeOverviewViewStrategy;
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,10 @@
|
||||
import type { TemplateResult } from "lit";
|
||||
import type { LocalizeFunc } from "../../../src/common/translations/localize";
|
||||
import type { LovelaceConfig } from "../../../src/data/lovelace/config/types";
|
||||
import type { LovelaceRawConfig } from "../../../src/data/lovelace/config/types";
|
||||
import type { EntityInput } from "../../../src/fake_data/entities/types";
|
||||
import type { ThemeSettings } from "../../../src/types";
|
||||
import type { DemoArea } from "../stubs/area_registry";
|
||||
import type { DemoFloor } from "../stubs/floor_registry";
|
||||
|
||||
export type DemoTheme = ThemeSettings | (() => Record<string, string> | null);
|
||||
|
||||
@@ -13,7 +15,9 @@ export interface DemoConfig {
|
||||
authorUrl: string;
|
||||
description?:
|
||||
string | ((localize: LocalizeFunc) => string | TemplateResult<1>);
|
||||
lovelace: (localize: LocalizeFunc) => LovelaceConfig;
|
||||
lovelace: (localize: LocalizeFunc) => LovelaceRawConfig;
|
||||
entities: (localize: LocalizeFunc) => EntityInput[];
|
||||
floors?: DemoFloor[];
|
||||
areas?: DemoArea[];
|
||||
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;
|
||||
}
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -13,9 +13,9 @@ import type {
|
||||
LovelaceCard,
|
||||
} from "../../../src/panels/lovelace/types";
|
||||
import {
|
||||
demoConfigs,
|
||||
demos,
|
||||
selectedDemo,
|
||||
selectedDemoConfig,
|
||||
selectedDemoConfigIndex,
|
||||
} from "../configs/demo-configs";
|
||||
|
||||
@customElement("ha-demo-card")
|
||||
@@ -112,16 +112,12 @@ export class HADemoCard extends LitElement implements LovelaceCard {
|
||||
}
|
||||
|
||||
private _nextConfig() {
|
||||
this._updateConfig(
|
||||
selectedDemoConfigIndex < demoConfigs.length - 1
|
||||
? selectedDemoConfigIndex + 1
|
||||
: 0
|
||||
);
|
||||
this._updateConfig(demos[(demos.indexOf(selectedDemo) + 1) % demos.length]);
|
||||
}
|
||||
|
||||
private async _updateConfig(index: number) {
|
||||
private async _updateConfig(demo: string) {
|
||||
this._switching = true;
|
||||
fireEvent(this, "set-demo-config" as any, { index });
|
||||
fireEvent(this, "set-demo-config" as any, { demo });
|
||||
}
|
||||
|
||||
static get styles(): CSSResultGroup {
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import { until } from "lit/directives/until";
|
||||
import { fireEvent } from "../../../src/common/dom/fire_event";
|
||||
import "../../../src/components/ha-card";
|
||||
import "../../../src/components/ha-control-button";
|
||||
import "../../../src/components/ha-control-button-group";
|
||||
import "../../../src/components/tile/ha-tile-container";
|
||||
import "../../../src/components/tile/ha-tile-icon";
|
||||
import "../../../src/components/tile/ha-tile-info";
|
||||
import type { LovelaceCardConfig } from "../../../src/data/lovelace/config/card";
|
||||
import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
|
||||
import { tileCardStyle } from "../../../src/panels/lovelace/cards/tile/tile-card-style";
|
||||
import type {
|
||||
LovelaceCard,
|
||||
LovelaceGridOptions,
|
||||
} from "../../../src/panels/lovelace/types";
|
||||
import {
|
||||
demos,
|
||||
selectedDemo,
|
||||
selectedDemoConfig,
|
||||
} from "../configs/demo-configs";
|
||||
|
||||
@customElement("ha-demo-next-card")
|
||||
export class HADemoNextCard extends LitElement implements LovelaceCard {
|
||||
@property({ attribute: false }) public hass!: MockHomeAssistant;
|
||||
|
||||
@state() private _switching = false;
|
||||
|
||||
public getCardSize() {
|
||||
return 2;
|
||||
}
|
||||
|
||||
public getGridOptions(): LovelaceGridOptions {
|
||||
return {
|
||||
columns: 6,
|
||||
rows: 2,
|
||||
min_columns: 6,
|
||||
min_rows: 2,
|
||||
};
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-empty-function
|
||||
public setConfig(_config: LovelaceCardConfig) {}
|
||||
|
||||
protected render() {
|
||||
return html`
|
||||
<ha-card>
|
||||
<ha-tile-container>
|
||||
<ha-tile-icon slot="icon" icon="mdi:home-assistant"></ha-tile-icon>
|
||||
<ha-tile-info slot="info">
|
||||
<span slot="primary">
|
||||
${until(
|
||||
selectedDemoConfig.then((conf) => conf.name),
|
||||
nothing
|
||||
)}
|
||||
</span>
|
||||
<span slot="secondary">
|
||||
${this.hass.localize(
|
||||
"ui.panel.page-demo.cards.demo.interactive_demo"
|
||||
)}
|
||||
</span>
|
||||
</ha-tile-info>
|
||||
<ha-control-button-group slot="features">
|
||||
<ha-control-button
|
||||
.disabled=${this._switching}
|
||||
@click=${this._nextConfig}
|
||||
>
|
||||
${this.hass.localize("ui.panel.page-demo.cards.demo.next_demo")}
|
||||
</ha-control-button>
|
||||
</ha-control-button-group>
|
||||
</ha-tile-container>
|
||||
</ha-card>
|
||||
`;
|
||||
}
|
||||
|
||||
private _nextConfig() {
|
||||
this._switching = true;
|
||||
fireEvent(this, "set-demo-config" as any, {
|
||||
demo: demos[(demos.indexOf(selectedDemo) + 1) % demos.length],
|
||||
});
|
||||
}
|
||||
|
||||
static styles = [
|
||||
tileCardStyle,
|
||||
css`
|
||||
:host {
|
||||
--tile-color: var(--primary-color);
|
||||
}
|
||||
ha-control-button-group {
|
||||
--control-button-group-spacing: 0;
|
||||
--control-button-group-thickness: var(--feature-height, 42px);
|
||||
padding: 0 var(--ha-space-3) var(--ha-space-3);
|
||||
}
|
||||
`,
|
||||
];
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"ha-demo-next-card": HADemoNextCard;
|
||||
}
|
||||
}
|
||||
+20
-7
@@ -6,7 +6,7 @@ import { provideHass } from "../../src/fake_data/provide_hass";
|
||||
import { HomeAssistantAppEl } from "../../src/layouts/home-assistant";
|
||||
import type { HomeAssistant } from "../../src/types";
|
||||
import { applyDemoTheme, selectedDemoConfig } from "./configs/demo-configs";
|
||||
import { mockAreaRegistry } from "./stubs/area_registry";
|
||||
import { mockAreaRegistry, setDemoAreas } from "./stubs/area_registry";
|
||||
import { mockAuth } from "./stubs/auth";
|
||||
import { demoDevices } from "./stubs/devices";
|
||||
import { mockDeviceRegistry } from "./stubs/device_registry";
|
||||
@@ -14,8 +14,10 @@ import { mockEnergy } from "./stubs/energy";
|
||||
import { energyEntities } from "./stubs/entities";
|
||||
import { mockEntityRegistry } from "./stubs/entity_registry";
|
||||
import { mockEvents } from "./stubs/events";
|
||||
import { mockFloorRegistry } from "./stubs/floor_registry";
|
||||
import { mockFloorRegistry, setDemoFloors } from "./stubs/floor_registry";
|
||||
import { mockFrontend } from "./stubs/frontend";
|
||||
import { mockHardware } from "./stubs/hardware";
|
||||
import { mockHassioSupervisor } from "./stubs/hassio_supervisor";
|
||||
import { mockIntegration } from "./stubs/integration";
|
||||
import { mockLabelRegistry } from "./stubs/label_registry";
|
||||
import { mockIcons } from "./stubs/icons";
|
||||
@@ -29,6 +31,7 @@ import { mockSystemLog } from "./stubs/system_log";
|
||||
import { mockTemplate } from "./stubs/template";
|
||||
import { mockTodo } from "./stubs/todo";
|
||||
import { mockTranslations } from "./stubs/translations";
|
||||
import { mockUsagePrediction } from "./stubs/usage_prediction";
|
||||
import "./cloud/cloud-demo-controls";
|
||||
|
||||
// WS command / REST path prefixes whose mocks live in the lazily imported
|
||||
@@ -72,13 +75,18 @@ export class HaDemo extends HomeAssistantAppEl {
|
||||
// `contextMixin`, so let provideHass skip them to avoid duplicate providers.
|
||||
const hass = provideHass(this, initial, true, false);
|
||||
|
||||
// The cloud account page only fetches backup config and the webhook count
|
||||
// when those integrations are loaded. Enable them here (demo only) so the
|
||||
// mocked backup/config/info and webhook/list are queried.
|
||||
hass.updateHass({
|
||||
config: {
|
||||
...hass.config,
|
||||
components: [...(hass.config?.components ?? []), "backup", "webhook"],
|
||||
components: [
|
||||
...(hass.config?.components ?? []),
|
||||
"backup",
|
||||
"webhook",
|
||||
"usage_prediction",
|
||||
"assist_pipeline",
|
||||
"hassio",
|
||||
"hardware",
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
@@ -105,6 +113,8 @@ export class HaDemo extends HomeAssistantAppEl {
|
||||
mockEvents(hass);
|
||||
mockMediaPlayer(hass);
|
||||
mockFrontend(hass);
|
||||
mockHardware(hass);
|
||||
mockHassioSupervisor(hass);
|
||||
mockIcons(hass);
|
||||
mockEnergy(hass);
|
||||
mockPersistentNotification(hass);
|
||||
@@ -122,6 +132,7 @@ export class HaDemo extends HomeAssistantAppEl {
|
||||
mockDeviceRegistry(hass, demoDevices);
|
||||
mockFloorRegistry(hass);
|
||||
mockLabelRegistry(hass);
|
||||
mockUsagePrediction(hass);
|
||||
mockEntityRegistry(hass, [
|
||||
{
|
||||
config_entry_id: "co2signal",
|
||||
@@ -169,9 +180,11 @@ export class HaDemo extends HomeAssistantAppEl {
|
||||
|
||||
hass.addEntities(energyEntities());
|
||||
|
||||
// Once config is loaded AND localize, set entities and apply theme.
|
||||
// Once config is loaded AND localize, set registries, entities and theme.
|
||||
Promise.all([selectedDemoConfig, localizePromise]).then(
|
||||
([conf, localize]) => {
|
||||
setDemoFloors(hass, conf.floors);
|
||||
setDemoAreas(hass, conf.areas);
|
||||
hass.addEntities(conf.entities(localize));
|
||||
applyDemoTheme(hass, conf.theme);
|
||||
}
|
||||
|
||||
@@ -34,8 +34,16 @@
|
||||
content="width=device-width, initial-scale=1, shrink-to-fit=no"
|
||||
/>
|
||||
<meta name="theme-color" content="#03a9f4" />
|
||||
<link rel="preload" href="/static/fonts/roboto/Roboto-Regular.woff2" as="font" type="font/woff2" crossorigin>
|
||||
<%= renderTemplate("_social_meta.html.template") %>
|
||||
<style>
|
||||
@font-face {
|
||||
font-family: "Roboto Launch Screen";
|
||||
font-display: block;
|
||||
src: url("/static/fonts/roboto/Roboto-Regular.woff2") format("woff2");
|
||||
font-weight: 400;
|
||||
font-style: normal;
|
||||
}
|
||||
html {
|
||||
background-color: var(--primary-background-color, #fafafa);
|
||||
color: var(--primary-text-color, #212121);
|
||||
@@ -56,15 +64,38 @@
|
||||
padding: 0;
|
||||
}
|
||||
#ha-launch-screen {
|
||||
font-family: "Roboto Launch Screen", sans-serif;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
transition: opacity var(--ha-animation-duration-normal, 250ms) ease-out;
|
||||
}
|
||||
#ha-launch-screen svg {
|
||||
width: 112px;
|
||||
#ha-launch-screen.removing {
|
||||
opacity: 0;
|
||||
}
|
||||
@keyframes launch-lockup-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
#ha-launch-screen .ha-lockup {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-shrink: 0;
|
||||
animation: launch-lockup-in var(--ha-animation-duration-normal, 250ms) ease-out both;
|
||||
}
|
||||
#ha-launch-screen-info-box > *,
|
||||
#ha-launch-screen .ohf-logo {
|
||||
animation: launch-lockup-in var(--ha-animation-duration-normal, 250ms) ease-out both;
|
||||
}
|
||||
#ha-launch-screen .ha-logo {
|
||||
width: 96px;
|
||||
height: 96px;
|
||||
}
|
||||
#ha-launch-screen .ha-launch-screen-spacer-top {
|
||||
flex: 1;
|
||||
@@ -80,11 +111,32 @@
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
opacity: .66;
|
||||
}
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.ohf-logo span {
|
||||
font-size: 12px;
|
||||
line-height: 12px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.ohf-logo picture {
|
||||
display: flex;
|
||||
}
|
||||
.ohf-logo img {
|
||||
width: 237px;
|
||||
aspect-ratio: 237 / 24;
|
||||
height: auto;
|
||||
}
|
||||
@media (max-height: 560px) {
|
||||
#ha-launch-screen .ha-launch-screen-spacer-top {
|
||||
margin-top: 24px;
|
||||
padding-top: 24px;
|
||||
}
|
||||
#ha-launch-screen .ha-launch-screen-spacer-bottom {
|
||||
padding-top: 16px;
|
||||
}
|
||||
.ohf-logo {
|
||||
filter: invert(1);
|
||||
margin-block: 24px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -92,13 +144,16 @@
|
||||
<body>
|
||||
<div id="ha-launch-screen">
|
||||
<div class="ha-launch-screen-spacer-top"></div>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 240 240">
|
||||
<path fill="#18BCF2" d="M240 224.762a15 15 0 0 1-15 15H15a15 15 0 0 1-15-15v-90c0-8.25 4.77-19.769 10.61-25.609l98.78-98.7805c5.83-5.83 15.38-5.83 21.21 0l98.79 98.7895c5.83 5.83 10.61 17.36 10.61 25.61v90-.01Z"/>
|
||||
<path fill="#F2F4F9" d="m107.27 239.762-40.63-40.63c-2.09.72-4.32 1.13-6.64 1.13-11.3 0-20.5-9.2-20.5-20.5s9.2-20.5 20.5-20.5 20.5 9.2 20.5 20.5c0 2.33-.41 4.56-1.13 6.65l31.63 31.63v-115.88c-6.8-3.3395-11.5-10.3195-11.5-18.3895 0-11.3 9.2-20.5 20.5-20.5s20.5 9.2 20.5 20.5c0 8.07-4.7 15.05-11.5 18.3895v81.27l31.46-31.46c-.62-1.96-.96-4.04-.96-6.2 0-11.3 9.2-20.5 20.5-20.5s20.5 9.2 20.5 20.5-9.2 20.5-20.5 20.5c-2.5 0-4.88-.47-7.09-1.29L129 208.892v30.88z"/>
|
||||
</svg>
|
||||
<div class="ha-lockup">
|
||||
<img class="ha-logo" src="/static/images/home-assistant-logo-loading.svg" alt="Home Assistant">
|
||||
</div>
|
||||
<div id="ha-launch-screen-info-box" class="ha-launch-screen-spacer-bottom"></div>
|
||||
<div class="ohf-logo">
|
||||
<img src="/static/images/ohf-badge.svg" alt="Home Assistant is a project by the Open Home Foundation" height="46">
|
||||
<span id="ha-launch-screen-attribution">A project from the</span>
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="/static/images/open-home-foundation-on-dark.svg">
|
||||
<img src="/static/images/open-home-foundation-on-light.svg" alt="Open Home Foundation" width="237" height="24">
|
||||
</picture>
|
||||
</div>
|
||||
</div>
|
||||
<ha-demo></ha-demo>
|
||||
|
||||
@@ -1,14 +1,46 @@
|
||||
import type { AreaRegistryEntry } from "../../../src/data/area/area_registry";
|
||||
import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
|
||||
|
||||
export interface DemoArea {
|
||||
area_id: string;
|
||||
name: string;
|
||||
floor_id?: string;
|
||||
icon?: string;
|
||||
temperature_entity_id?: string;
|
||||
humidity_entity_id?: string;
|
||||
}
|
||||
|
||||
let areas: AreaRegistryEntry[] = [];
|
||||
|
||||
export const mockAreaRegistry = (
|
||||
hass: MockHomeAssistant,
|
||||
data: AreaRegistryEntry[] = []
|
||||
data: DemoArea[] = []
|
||||
) => {
|
||||
hass.mockWS("config/area_registry/list", () => data);
|
||||
const areas = {};
|
||||
data.forEach((area) => {
|
||||
areas[area.area_id] = area;
|
||||
});
|
||||
hass.updateHass({ areas });
|
||||
hass.mockWS("config/area_registry/list", () => areas);
|
||||
setDemoAreas(hass, data);
|
||||
};
|
||||
|
||||
/** Set the areas of the currently loaded demo config. */
|
||||
export const setDemoAreas = (
|
||||
hass: MockHomeAssistant,
|
||||
demoAreas: DemoArea[] = []
|
||||
) => {
|
||||
areas = demoAreas.map((area) => ({
|
||||
area_id: area.area_id,
|
||||
name: area.name,
|
||||
floor_id: area.floor_id ?? null,
|
||||
icon: area.icon ?? null,
|
||||
temperature_entity_id: area.temperature_entity_id ?? null,
|
||||
humidity_entity_id: area.humidity_entity_id ?? null,
|
||||
aliases: [],
|
||||
labels: [],
|
||||
picture: null,
|
||||
created_at: 0,
|
||||
modified_at: 0,
|
||||
}));
|
||||
const areasById: Record<string, AreaRegistryEntry> = {};
|
||||
areas.forEach((area) => {
|
||||
areasById[area.area_id] = area;
|
||||
});
|
||||
hass.updateHass({ areas: areasById });
|
||||
};
|
||||
|
||||
@@ -19,17 +19,31 @@ const pipelines: AssistPipeline[] = [
|
||||
{
|
||||
id: "01local",
|
||||
name: "Local",
|
||||
language: "en",
|
||||
language: "nl",
|
||||
conversation_engine: "conversation.home_assistant",
|
||||
conversation_language: "en",
|
||||
conversation_language: "nl",
|
||||
stt_engine: "stt.faster_whisper",
|
||||
stt_language: "en",
|
||||
stt_language: "nl",
|
||||
tts_engine: "tts.piper",
|
||||
tts_language: "en",
|
||||
tts_language: "nl",
|
||||
tts_voice: null,
|
||||
wake_word_entity: null,
|
||||
wake_word_id: null,
|
||||
},
|
||||
{
|
||||
id: "01chatgpt",
|
||||
name: "ChatGPT",
|
||||
language: "es",
|
||||
conversation_engine: "conversation.chatgpt",
|
||||
conversation_language: "es",
|
||||
stt_engine: "cloud",
|
||||
stt_language: "es-ES",
|
||||
tts_engine: "cloud",
|
||||
tts_language: "es-ES",
|
||||
tts_voice: "ElviraNeural",
|
||||
wake_word_entity: null,
|
||||
wake_word_id: null,
|
||||
},
|
||||
];
|
||||
|
||||
export const mockAssist = (hass: MockHomeAssistant) => {
|
||||
|
||||
@@ -85,7 +85,7 @@ export const energyEntities = () =>
|
||||
},
|
||||
},
|
||||
"sensor.energy_consumption_tarif_1": {
|
||||
entity_id: "sensor.energy_consumption_tarif_1 ",
|
||||
entity_id: "sensor.energy_consumption_tarif_1",
|
||||
state: "88.6",
|
||||
attributes: {
|
||||
last_reset: "1970-01-01T00:00:00:00+00",
|
||||
|
||||
@@ -1,7 +1,40 @@
|
||||
import type { FloorRegistryEntry } from "../../../src/data/floor_registry";
|
||||
import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
|
||||
|
||||
export interface DemoFloor {
|
||||
floor_id: string;
|
||||
name: string;
|
||||
level?: number;
|
||||
icon?: string;
|
||||
}
|
||||
|
||||
let floors: FloorRegistryEntry[] = [];
|
||||
|
||||
export const mockFloorRegistry = (
|
||||
hass: MockHomeAssistant,
|
||||
data: FloorRegistryEntry[] = []
|
||||
) => hass.mockWS("config/floor_registry/list", () => data);
|
||||
data: DemoFloor[] = []
|
||||
) => {
|
||||
hass.mockWS("config/floor_registry/list", () => floors);
|
||||
setDemoFloors(hass, data);
|
||||
};
|
||||
|
||||
/** Set the floors of the currently loaded demo config. */
|
||||
export const setDemoFloors = (
|
||||
hass: MockHomeAssistant,
|
||||
demoFloors: DemoFloor[] = []
|
||||
) => {
|
||||
floors = demoFloors.map((floor) => ({
|
||||
floor_id: floor.floor_id,
|
||||
name: floor.name,
|
||||
level: floor.level ?? null,
|
||||
icon: floor.icon ?? null,
|
||||
aliases: [],
|
||||
created_at: 0,
|
||||
modified_at: 0,
|
||||
}));
|
||||
const floorsById: Record<string, FloorRegistryEntry> = {};
|
||||
floors.forEach((floor) => {
|
||||
floorsById[floor.floor_id] = floor;
|
||||
});
|
||||
hass.updateHass({ floors: floorsById });
|
||||
};
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import type {
|
||||
HardwareInfo,
|
||||
SystemStatusStreamMessage,
|
||||
} from "../../../src/data/hardware";
|
||||
import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
|
||||
|
||||
// Mirrors what homeassistant_green reports, so the hardware page resolves the
|
||||
// board name and the brands image the same way it does on a real Green.
|
||||
const HARDWARE_INFO: HardwareInfo = {
|
||||
hardware: [
|
||||
{
|
||||
board: {
|
||||
hassio_board_id: "green",
|
||||
manufacturer: "homeassistant",
|
||||
model: "green",
|
||||
},
|
||||
dongle: null,
|
||||
config_entries: [],
|
||||
name: "Home Assistant Green",
|
||||
url: "https://support.nabucasa.com/hc/en-us/categories/24638797677853-Home-Assistant-Green",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
export const mockHardware = (hass: MockHomeAssistant) => {
|
||||
hass.mockWS("hardware/info", () => HARDWARE_INFO);
|
||||
|
||||
hass.mockWS(
|
||||
"hardware/subscribe_system_status",
|
||||
(_msg, _currentHass, onChange) => {
|
||||
// Rounded like the hardware integration rounds psutil's values.
|
||||
const send = () => {
|
||||
const usedMb = 1560 + Math.round(Math.random() * 80);
|
||||
const message: SystemStatusStreamMessage = {
|
||||
cpu_percent: Math.round((8 + Math.random() * 6) * 10) / 10,
|
||||
memory_free_mb: 4096 - usedMb,
|
||||
memory_used_mb: usedMb,
|
||||
memory_used_percent: Math.round((usedMb / 4096) * 1000) / 10,
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
onChange?.(message);
|
||||
};
|
||||
send();
|
||||
const interval = window.setInterval(send, 1000);
|
||||
return () => clearInterval(interval);
|
||||
}
|
||||
);
|
||||
};
|
||||
@@ -1,59 +1,385 @@
|
||||
import type { HassioSupervisorInfo } from "../../../src/data/hassio/supervisor";
|
||||
import type {
|
||||
HassioAddonDetails,
|
||||
HassioAddonInfo,
|
||||
HassioAddonsInfo,
|
||||
} from "../../../src/data/hassio/addon";
|
||||
import type { HassioStats } from "../../../src/data/hassio/common";
|
||||
import type {
|
||||
HassioHassOSInfo,
|
||||
HassioHostInfo,
|
||||
HostDisksUsage,
|
||||
} from "../../../src/data/hassio/host";
|
||||
import type { NetworkInfo } from "../../../src/data/hassio/network";
|
||||
import type {
|
||||
HassioInfo,
|
||||
HassioSupervisorInfo,
|
||||
} from "../../../src/data/hassio/supervisor";
|
||||
import type { SupervisorMounts } from "../../../src/data/supervisor/mounts";
|
||||
import type { SupervisorUpdateConfig } from "../../../src/data/supervisor/update";
|
||||
import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
|
||||
|
||||
// `icon`/`logo` are false on purpose: the panel would otherwise request
|
||||
// /api/hassio/addons/<slug>/icon, which the demo has no backend for.
|
||||
const DEMO_ADDONS: HassioAddonInfo[] = [
|
||||
{
|
||||
name: "Music Assistant",
|
||||
slug: "d5369777_music_assistant",
|
||||
description:
|
||||
"Music library manager for all your media sources and streaming services, with support for a wide range of players",
|
||||
advanced: false,
|
||||
available: true,
|
||||
build: false,
|
||||
detached: false,
|
||||
homeassistant: "2025.7.0",
|
||||
icon: false,
|
||||
installed: true,
|
||||
logo: false,
|
||||
repository: "d5369777",
|
||||
stage: "stable",
|
||||
state: "started",
|
||||
update_available: false,
|
||||
url: "https://github.com/music-assistant/home-assistant-addon",
|
||||
version: "2.6.3",
|
||||
version_latest: "2.6.3",
|
||||
},
|
||||
{
|
||||
name: "ESPHome Device Builder",
|
||||
slug: "5c53de3b_esphome",
|
||||
description:
|
||||
"Manage and program your ESP8266/ESP32 based microcontrollers directly via WiFi and with a simple, yet powerful configuration file syntax",
|
||||
advanced: false,
|
||||
available: true,
|
||||
build: false,
|
||||
detached: false,
|
||||
homeassistant: "2025.7.0",
|
||||
icon: false,
|
||||
installed: true,
|
||||
logo: false,
|
||||
repository: "5c53de3b",
|
||||
stage: "stable",
|
||||
state: "started",
|
||||
update_available: false,
|
||||
url: "https://esphome.io/",
|
||||
version: "2025.7.3",
|
||||
version_latest: "2025.7.3",
|
||||
},
|
||||
];
|
||||
|
||||
const LONG_DESCRIPTIONS: Record<string, string> = {
|
||||
d5369777_music_assistant: `## Music Assistant
|
||||
|
||||
Music Assistant brings all your music sources together in one library and streams
|
||||
them to the players you already own.
|
||||
|
||||
- Combines local files with streaming services into a single searchable library
|
||||
- Plays to Sonos, Chromecast, AirPlay, Squeezebox, DLNA and Home Assistant media players
|
||||
- Group players together for synced multi-room audio
|
||||
- Exposes players and playlists to Home Assistant automations and voice assistants`,
|
||||
"5c53de3b_esphome": `## ESPHome Device Builder
|
||||
|
||||
ESPHome turns an ESP8266 or ESP32 into a Home Assistant device using a short YAML
|
||||
configuration instead of hand-written firmware.
|
||||
|
||||
- Compile and flash firmware straight from the browser, over WiFi after the first flash
|
||||
- Hundreds of supported sensors, displays, lights and switches
|
||||
- Devices are discovered by Home Assistant automatically, with no cloud in between
|
||||
- Configuration lives next to your Home Assistant config, so it is covered by backups`,
|
||||
};
|
||||
|
||||
// Supervisor schema format (converted to selectors by the config tab). Music
|
||||
// Assistant is configured in its own UI, so it has no add-on options.
|
||||
const CONFIG_SCHEMAS: Record<string, HassioAddonDetails["schema"]> = {
|
||||
"5c53de3b_esphome": [
|
||||
{ name: "ssl", type: "boolean", required: true },
|
||||
{ name: "certfile", type: "string", required: true },
|
||||
{ name: "keyfile", type: "string", required: true },
|
||||
{ name: "leave_front_door_open", type: "boolean", required: false },
|
||||
{ name: "status_use_ping", type: "boolean", required: false },
|
||||
],
|
||||
};
|
||||
|
||||
const CONFIG_OPTIONS: Record<string, Record<string, unknown>> = {
|
||||
"5c53de3b_esphome": {
|
||||
ssl: false,
|
||||
certfile: "fullchain.pem",
|
||||
keyfile: "privkey.pem",
|
||||
},
|
||||
};
|
||||
|
||||
const addonDetails = (addon: HassioAddonInfo): HassioAddonDetails => ({
|
||||
...addon,
|
||||
apparmor: "default",
|
||||
arch: ["aarch64", "amd64"],
|
||||
audio_input: null,
|
||||
audio_output: null,
|
||||
audio: false,
|
||||
auth_api: false,
|
||||
auto_uart: false,
|
||||
auto_update: false,
|
||||
boot: "auto",
|
||||
changelog: false,
|
||||
devices: [],
|
||||
devicetree: false,
|
||||
discovery: [],
|
||||
docker_api: false,
|
||||
documentation: false,
|
||||
full_access: false,
|
||||
gpio: false,
|
||||
hassio_api: false,
|
||||
hassio_role: "default",
|
||||
hostname: addon.slug.replace(/_/g, "-"),
|
||||
homeassistant_api: false,
|
||||
host_dbus: false,
|
||||
host_ipc: false,
|
||||
host_network: false,
|
||||
host_pid: false,
|
||||
ingress_entry: null,
|
||||
ingress_panel: false,
|
||||
ingress_url: null,
|
||||
ingress: false,
|
||||
ip_address: "172.30.33.2",
|
||||
kernel_modules: false,
|
||||
long_description: LONG_DESCRIPTIONS[addon.slug],
|
||||
machine: [],
|
||||
network_description: null,
|
||||
network: null,
|
||||
options: CONFIG_OPTIONS[addon.slug] ?? {},
|
||||
privileged: [],
|
||||
protected: true,
|
||||
rating: 6,
|
||||
schema: CONFIG_SCHEMAS[addon.slug] ?? null,
|
||||
services_role: [],
|
||||
signed: false,
|
||||
startup: "application",
|
||||
stdin: false,
|
||||
system_managed: false,
|
||||
system_managed_config_entry: null,
|
||||
translations: {},
|
||||
watchdog: true,
|
||||
webui: null,
|
||||
});
|
||||
|
||||
const LOGS: Record<string, string> = {
|
||||
d5369777_music_assistant: `[server] Starting Music Assistant Server 2.6.3
|
||||
[server] Loaded provider: filesystem_local
|
||||
[server] Loaded provider: spotify
|
||||
[server] Loaded provider: sonos
|
||||
[players] Discovered player: Living Room (Sonos)
|
||||
[players] Discovered player: Kitchen (Chromecast)
|
||||
[server] Music Assistant is ready
|
||||
`,
|
||||
"5c53de3b_esphome": `[esphome] Starting ESPHome Device Builder 2025.7.3
|
||||
[esphome] Dashboard running on port 6052
|
||||
[esphome] Found 3 configurations
|
||||
[esphome] bedroom-sensor is online (2025.7.3)
|
||||
[esphome] garage-door is online (2025.7.3)
|
||||
[esphome] office-display is online (2025.7.3)
|
||||
`,
|
||||
};
|
||||
|
||||
const ADDON_STATS: HassioStats = {
|
||||
blk_read: 12300000,
|
||||
blk_write: 4500000,
|
||||
cpu_percent: 1.4,
|
||||
memory_limit: 3900000000,
|
||||
memory_percent: 4.2,
|
||||
memory_usage: 163000000,
|
||||
network_rx: 8900000,
|
||||
network_tx: 2300000,
|
||||
};
|
||||
|
||||
export const mockHassioSupervisor = (hass: MockHomeAssistant) => {
|
||||
hass.config.components.push("hassio");
|
||||
// Gallery pages rely on this to enable the hassio-gated pickers. The demo
|
||||
// lists hassio in its own components, hence the guard against duplicates.
|
||||
if (!hass.config.components.includes("hassio")) {
|
||||
hass.config.components.push("hassio");
|
||||
}
|
||||
|
||||
hass.mockWS("supervisor/api", (msg) => {
|
||||
if (msg.endpoint === "/supervisor/info") {
|
||||
const data: HassioSupervisorInfo = {
|
||||
version: "2021.10.dev0805",
|
||||
version_latest: "2021.10.dev0806",
|
||||
update_available: true,
|
||||
channel: "dev",
|
||||
version: "2026.07.1",
|
||||
version_latest: "2026.07.1",
|
||||
update_available: false,
|
||||
channel: "stable",
|
||||
arch: "aarch64",
|
||||
supported: true,
|
||||
healthy: true,
|
||||
ip_address: "172.30.32.2",
|
||||
wait_boot: 5,
|
||||
timezone: "America/Los_Angeles",
|
||||
timezone: "Europe/Amsterdam",
|
||||
logging: "info",
|
||||
debug: false,
|
||||
debug_block: false,
|
||||
diagnostics: true,
|
||||
addons: [
|
||||
{
|
||||
name: "Visual Studio Code",
|
||||
slug: "a0d7b954_vscode",
|
||||
description:
|
||||
"Fully featured VSCode experience, to edit your HA config in the browser, including auto-completion!",
|
||||
state: "started",
|
||||
version: "3.6.2",
|
||||
version_latest: "3.6.2",
|
||||
update_available: false,
|
||||
repository: "a0d7b954",
|
||||
icon: false,
|
||||
logo: true,
|
||||
},
|
||||
{
|
||||
name: "Z-Wave JS",
|
||||
slug: "core_zwave_js",
|
||||
description:
|
||||
"Control a ZWave network with Home Assistant Z-Wave JS",
|
||||
state: "started",
|
||||
version: "0.1.45",
|
||||
version_latest: "0.1.45",
|
||||
update_available: false,
|
||||
repository: "core",
|
||||
icon: true,
|
||||
logo: true,
|
||||
},
|
||||
] as any,
|
||||
addons: DEMO_ADDONS as any,
|
||||
addons_repositories: [
|
||||
"https://github.com/hassio-addons/repository",
|
||||
"https://github.com/music-assistant/home-assistant-addon",
|
||||
"https://github.com/esphome/home-assistant-addon",
|
||||
] as any,
|
||||
};
|
||||
return data;
|
||||
}
|
||||
|
||||
if (msg.endpoint === "/addons") {
|
||||
const data: HassioAddonsInfo = {
|
||||
addons: DEMO_ADDONS,
|
||||
repositories: [
|
||||
{
|
||||
slug: "d5369777",
|
||||
name: "Music Assistant",
|
||||
source: "https://github.com/music-assistant/home-assistant-addon",
|
||||
url: "https://github.com/music-assistant/home-assistant-addon",
|
||||
maintainer: "Music Assistant",
|
||||
},
|
||||
{
|
||||
slug: "5c53de3b",
|
||||
name: "ESPHome",
|
||||
source: "https://github.com/esphome/home-assistant-addon",
|
||||
url: "https://esphome.io/",
|
||||
maintainer: "ESPHome",
|
||||
},
|
||||
],
|
||||
};
|
||||
return data;
|
||||
}
|
||||
|
||||
const addonMatch = msg.endpoint.match(/^\/addons\/([^/]+)\/(info|stats)$/);
|
||||
if (addonMatch) {
|
||||
const addon = DEMO_ADDONS.find((item) => item.slug === addonMatch[1]);
|
||||
if (!addon) {
|
||||
return Promise.reject(`Addon ${addonMatch[1]} not found`);
|
||||
}
|
||||
return addonMatch[2] === "stats" ? ADDON_STATS : addonDetails(addon);
|
||||
}
|
||||
|
||||
if (msg.endpoint === "/info") {
|
||||
const data: HassioInfo = {
|
||||
arch: "aarch64",
|
||||
channel: "stable",
|
||||
docker: "27.5.1",
|
||||
features: ["reboot", "shutdown", "network", "hostname", "os_agent"],
|
||||
hassos: null,
|
||||
homeassistant: "2026.7.2",
|
||||
hostname: "homeassistant",
|
||||
logging: "info",
|
||||
machine: "green",
|
||||
state: "running",
|
||||
operating_system: "Home Assistant OS 18.2",
|
||||
supervisor: "2026.07.1",
|
||||
supported: true,
|
||||
supported_arch: ["aarch64", "armv7", "armhf"],
|
||||
timezone: "Europe/Amsterdam",
|
||||
};
|
||||
return data;
|
||||
}
|
||||
|
||||
if (msg.endpoint === "/host/info") {
|
||||
const data: HassioHostInfo = {
|
||||
agent_version: "1.8.0",
|
||||
chassis: "embedded",
|
||||
cpe: "cpe:2.3:o:home-assistant:haos:18.2:*:production:*:*:*:aarch64:*",
|
||||
deployment: "production",
|
||||
disk_life_time: 6,
|
||||
disk_free: 22.3,
|
||||
disk_total: 31.2,
|
||||
disk_used: 8.9,
|
||||
features: ["reboot", "shutdown", "network", "hostname", "os_agent"],
|
||||
hostname: "homeassistant",
|
||||
kernel: "6.12.48-haos",
|
||||
operating_system: "Home Assistant OS 18.2",
|
||||
boot_timestamp: 1751932800000000,
|
||||
startup_time: 12.4,
|
||||
};
|
||||
return data;
|
||||
}
|
||||
|
||||
if (msg.endpoint === "/os/info") {
|
||||
const data: HassioHassOSInfo = {
|
||||
board: "green",
|
||||
boot: "A",
|
||||
update_available: false,
|
||||
version: "18.2",
|
||||
version_latest: "18.2",
|
||||
data_disk: "Home Assistant Green (mmcblk0)",
|
||||
};
|
||||
return data;
|
||||
}
|
||||
|
||||
if (msg.endpoint === "/host/disks/default/usage") {
|
||||
const data: HostDisksUsage = {
|
||||
id: "root",
|
||||
label: "Total",
|
||||
total_bytes: 31200000000,
|
||||
used_bytes: 8900000000,
|
||||
children: [
|
||||
{ id: "media", label: "Media", used_bytes: 4100000000 },
|
||||
{ id: "addons", label: "Apps", used_bytes: 2600000000 },
|
||||
{ id: "backup", label: "Backups", used_bytes: 1400000000 },
|
||||
{ id: "share", label: "Share", used_bytes: 800000000 },
|
||||
],
|
||||
};
|
||||
return data;
|
||||
}
|
||||
|
||||
if (msg.endpoint === "/mounts") {
|
||||
const data: SupervisorMounts = {
|
||||
default_backup_mount: null,
|
||||
mounts: [],
|
||||
};
|
||||
return data;
|
||||
}
|
||||
|
||||
if (msg.endpoint === "/network/info") {
|
||||
const data: NetworkInfo = {
|
||||
interfaces: [
|
||||
{
|
||||
primary: true,
|
||||
privacy: false,
|
||||
interface: "eth0",
|
||||
enabled: true,
|
||||
type: "ethernet",
|
||||
ipv4: {
|
||||
address: ["192.168.1.10/24"],
|
||||
gateway: "192.168.1.1",
|
||||
method: "auto",
|
||||
nameservers: ["192.168.1.1"],
|
||||
},
|
||||
wifi: null,
|
||||
},
|
||||
],
|
||||
docker: {
|
||||
address: "172.30.32.0/23",
|
||||
dns: "172.30.32.3",
|
||||
gateway: "172.30.32.1",
|
||||
interface: "hassio",
|
||||
},
|
||||
};
|
||||
return data;
|
||||
}
|
||||
|
||||
if (msg.endpoint === "/store/reload") {
|
||||
return null;
|
||||
}
|
||||
|
||||
return Promise.reject(`${msg.method} ${msg.endpoint} is not implemented`);
|
||||
});
|
||||
|
||||
hass.mockWS("hassio/update/config/info", (): SupervisorUpdateConfig => ({
|
||||
add_on_backup_before_update: true,
|
||||
add_on_backup_retain_copies: 1,
|
||||
core_backup_before_update: true,
|
||||
}));
|
||||
|
||||
hass.mockAPI(/^hassio\/host\/logs\/boots$/, () => ({
|
||||
data: { boots: { "0": "2026-07-26T09:00:00.000000+00:00" } },
|
||||
}));
|
||||
|
||||
hass.mockAPI(/^hassio\/addons\/[^/]+\/logs/, (_hass, _method, path) => {
|
||||
const slug = path.split("/")[2];
|
||||
// X-First-Cursor tells error-log-card there is nothing older to page to.
|
||||
return new Response(LOGS[slug], {
|
||||
headers: { "X-First-Cursor": "demo" },
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
@@ -2,12 +2,13 @@ import type { LocalizeFunc } from "../../../src/common/translations/localize";
|
||||
import type { LovelaceInfo } from "../../../src/data/lovelace/resource";
|
||||
import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
|
||||
import {
|
||||
selectedDemo,
|
||||
selectedDemoConfig,
|
||||
selectedDemoConfigIndex,
|
||||
setDemoConfig,
|
||||
} from "../configs/demo-configs";
|
||||
import "../custom-cards/cast-demo-row";
|
||||
import "../custom-cards/ha-demo-card";
|
||||
import "../custom-cards/ha-demo-next-card";
|
||||
import { mapEntities } from "./entities";
|
||||
|
||||
export const mockLovelace = (
|
||||
@@ -45,11 +46,11 @@ customElements.whenDefined("hui-root").then(() => {
|
||||
HUIRoot.prototype.firstUpdated = function (changedProperties) {
|
||||
oldFirstUpdated.call(this, changedProperties);
|
||||
this.addEventListener("set-demo-config", async (ev) => {
|
||||
const index = (ev as CustomEvent).detail.index;
|
||||
const demo = (ev as CustomEvent).detail.demo;
|
||||
try {
|
||||
await setDemoConfig(this.hass, this.lovelace!, index);
|
||||
await setDemoConfig(this.hass, this.lovelace!, demo);
|
||||
} catch (_err: any) {
|
||||
setDemoConfig(this.hass, this.lovelace!, selectedDemoConfigIndex);
|
||||
setDemoConfig(this.hass, this.lovelace!, selectedDemo);
|
||||
alert("Failed to switch config :-(");
|
||||
}
|
||||
});
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import type { CommonControlsResult } from "../../../src/data/usage_prediction";
|
||||
import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
|
||||
|
||||
export const mockUsagePrediction = (hass: MockHomeAssistant) => {
|
||||
// Entities that don't exist in the currently loaded demo config are
|
||||
// filtered out by the common-controls section strategy.
|
||||
hass.mockWS("usage_prediction/common_control", (): CommonControlsResult => ({
|
||||
entities: [
|
||||
"light.living_room_floor_lamp",
|
||||
"climate.living_room",
|
||||
"cover.living_room_blinds",
|
||||
"media_player.living_room_speaker",
|
||||
"light.kitchen_spotlights",
|
||||
"switch.coffee_machine",
|
||||
"light.bedside_lamp",
|
||||
"alarm_control_panel.home_alarm",
|
||||
],
|
||||
}));
|
||||
};
|
||||
@@ -2,7 +2,10 @@
|
||||
import type { TemplateResult } from "lit";
|
||||
import { html, LitElement } from "lit";
|
||||
import { customElement, state } from "lit/decorators";
|
||||
import { mockAreaRegistry } from "../../../../demo/src/stubs/area_registry";
|
||||
import {
|
||||
mockAreaRegistry,
|
||||
type DemoArea,
|
||||
} from "../../../../demo/src/stubs/area_registry";
|
||||
import { mockConfigEntries } from "../../../../demo/src/stubs/config_entries";
|
||||
import { mockDeviceRegistry } from "../../../../demo/src/stubs/device_registry";
|
||||
import { mockEntityRegistry } from "../../../../demo/src/stubs/entity_registry";
|
||||
@@ -10,7 +13,6 @@ import { mockHassioSupervisor } from "../../../../demo/src/stubs/hassio_supervis
|
||||
import { computeInitialHaFormData } from "../../../../src/components/ha-form/compute-initial-ha-form-data";
|
||||
import "../../../../src/components/ha-form/ha-form";
|
||||
import type { HaFormSchema } from "../../../../src/components/ha-form/types";
|
||||
import type { AreaRegistryEntry } from "../../../../src/data/area/area_registry";
|
||||
import type { DeviceRegistryEntry } from "../../../../src/data/device/device_registry";
|
||||
import { provideHass } from "../../../../src/fake_data/provide_hass";
|
||||
import type { HomeAssistant } from "../../../../src/types";
|
||||
@@ -136,45 +138,20 @@ const DEVICES: DeviceRegistryEntry[] = [
|
||||
},
|
||||
];
|
||||
|
||||
const AREAS: AreaRegistryEntry[] = [
|
||||
const AREAS: DemoArea[] = [
|
||||
{
|
||||
area_id: "backyard",
|
||||
floor_id: null,
|
||||
name: "Backyard",
|
||||
icon: null,
|
||||
picture: null,
|
||||
aliases: [],
|
||||
labels: [],
|
||||
temperature_entity_id: null,
|
||||
humidity_entity_id: null,
|
||||
created_at: 0,
|
||||
modified_at: 0,
|
||||
},
|
||||
{
|
||||
area_id: "bedroom",
|
||||
floor_id: null,
|
||||
name: "Bedroom",
|
||||
icon: "mdi:bed",
|
||||
picture: null,
|
||||
aliases: [],
|
||||
labels: [],
|
||||
temperature_entity_id: null,
|
||||
humidity_entity_id: null,
|
||||
created_at: 0,
|
||||
modified_at: 0,
|
||||
},
|
||||
{
|
||||
area_id: "livingroom",
|
||||
floor_id: null,
|
||||
name: "Livingroom",
|
||||
icon: "mdi:sofa",
|
||||
picture: null,
|
||||
aliases: [],
|
||||
labels: [],
|
||||
temperature_entity_id: null,
|
||||
humidity_entity_id: null,
|
||||
created_at: 0,
|
||||
modified_at: 0,
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
---
|
||||
title: Replaced device selectors
|
||||
subtitle: How device and target selectors surface devices that were split into separate devices
|
||||
---
|
||||
|
||||
A device that used to belong to multiple config entries is split into one
|
||||
device per config entry. The original composite device is removed from the
|
||||
registry, so existing references to it (targets in automations, device
|
||||
selectors) point at a device that no longer exists.
|
||||
|
||||
When a selector holds such a reference, it shows a **replaced** state instead of
|
||||
a plain "not found", and offers to point the reference at the replacement
|
||||
device(s). The candidate replacements are filtered through the selector's own
|
||||
filters, so in practice usually a single device matches:
|
||||
|
||||
- **Target selector** — the replaced device row offers **Replace**, which adds
|
||||
every replacement device that matches the target filters and removes the old
|
||||
reference.
|
||||
- **Device selector** — when exactly one replacement matches, **Replace** swaps
|
||||
to it in one click; when several match, a dialog lets you pick one.
|
||||
|
||||
All samples below reference the removed composite device `old_composite`, which
|
||||
was split into a light device and a switch device.
|
||||
@@ -0,0 +1,318 @@
|
||||
import type { HassServiceTarget } from "home-assistant-js-websocket";
|
||||
import type { TemplateResult } from "lit";
|
||||
import { css, html, LitElement } from "lit";
|
||||
import { customElement, state } from "lit/decorators";
|
||||
import { mockConfigEntries } from "../../../../demo/src/stubs/config_entries";
|
||||
import { mockDeviceRegistry } from "../../../../demo/src/stubs/device_registry";
|
||||
import { mockEntityRegistry } from "../../../../demo/src/stubs/entity_registry";
|
||||
import { mockHassioSupervisor } from "../../../../demo/src/stubs/hassio_supervisor";
|
||||
import type { HASSDomEvent } from "../../../../src/common/dom/fire_event";
|
||||
import "../../../../src/components/ha-selector/ha-selector";
|
||||
import "../../../../src/components/ha-settings-row";
|
||||
import "../../../../src/components/ha-target-picker";
|
||||
import type { AreaRegistryEntry } from "../../../../src/data/area/area_registry";
|
||||
import type { DeviceRegistryEntry } from "../../../../src/data/device/device_registry";
|
||||
import type { EntityRegistryDisplayEntry } from "../../../../src/data/entity/entity_registry";
|
||||
import type { Selector } from "../../../../src/data/selector";
|
||||
import {
|
||||
showDialog,
|
||||
type ShowDialogParams,
|
||||
} from "../../../../src/dialogs/make-dialog-manager";
|
||||
import { provideHass } from "../../../../src/fake_data/provide_hass";
|
||||
import type { ProvideHassElement } from "../../../../src/mixins/provide-hass-lit-mixin";
|
||||
import type { HomeAssistant } from "../../../../src/types";
|
||||
import "../../components/demo-black-white-row";
|
||||
|
||||
// The composite device "old_composite" is intentionally NOT in the registry:
|
||||
// it was split into "device_light" and "device_switch". References to the old
|
||||
// id (targets, device selectors) should surface a "replaced" state.
|
||||
const DEVICES: DeviceRegistryEntry[] = [
|
||||
{
|
||||
area_id: "bedroom",
|
||||
configuration_url: null,
|
||||
config_entries: ["config_entry_light"],
|
||||
config_entries_subentries: {},
|
||||
connections: [],
|
||||
disabled_by: null,
|
||||
entry_type: null,
|
||||
id: "device_light",
|
||||
identifiers: [["demo", "light"] as [string, string]],
|
||||
manufacturer: null,
|
||||
model: null,
|
||||
model_id: null,
|
||||
name_by_user: null,
|
||||
name: "Living room lamp",
|
||||
sw_version: null,
|
||||
hw_version: null,
|
||||
via_device_id: null,
|
||||
serial_number: null,
|
||||
labels: [],
|
||||
created_at: 0,
|
||||
modified_at: 0,
|
||||
primary_config_entry: null,
|
||||
},
|
||||
{
|
||||
area_id: "backyard",
|
||||
configuration_url: null,
|
||||
config_entries: ["config_entry_switch"],
|
||||
config_entries_subentries: {},
|
||||
connections: [],
|
||||
disabled_by: null,
|
||||
entry_type: null,
|
||||
id: "device_switch",
|
||||
identifiers: [["demo", "switch"] as [string, string]],
|
||||
manufacturer: null,
|
||||
model: null,
|
||||
model_id: null,
|
||||
name_by_user: null,
|
||||
name: "Garden socket",
|
||||
sw_version: null,
|
||||
hw_version: null,
|
||||
via_device_id: null,
|
||||
serial_number: null,
|
||||
labels: [],
|
||||
created_at: 0,
|
||||
modified_at: 0,
|
||||
primary_config_entry: null,
|
||||
},
|
||||
];
|
||||
|
||||
const ENTITIES = [
|
||||
{
|
||||
entity_id: "light.living_room_lamp",
|
||||
state: "on",
|
||||
attributes: { friendly_name: "Living room lamp" },
|
||||
},
|
||||
{
|
||||
entity_id: "switch.garden_socket",
|
||||
state: "off",
|
||||
attributes: { friendly_name: "Garden socket" },
|
||||
},
|
||||
];
|
||||
|
||||
// Registry display entries link the demo entities to the split devices so the
|
||||
// pickers can filter split candidates by domain.
|
||||
const ENTITY_REGISTRY: Record<string, EntityRegistryDisplayEntry> = {
|
||||
"light.living_room_lamp": {
|
||||
entity_id: "light.living_room_lamp",
|
||||
name: "Living room lamp",
|
||||
device_id: "device_light",
|
||||
area_id: "bedroom",
|
||||
platform: "demo",
|
||||
labels: [],
|
||||
},
|
||||
"switch.garden_socket": {
|
||||
entity_id: "switch.garden_socket",
|
||||
name: "Garden socket",
|
||||
device_id: "device_switch",
|
||||
area_id: "backyard",
|
||||
platform: "demo",
|
||||
labels: [],
|
||||
},
|
||||
};
|
||||
|
||||
const AREAS: AreaRegistryEntry[] = [
|
||||
{
|
||||
area_id: "backyard",
|
||||
floor_id: null,
|
||||
name: "Backyard",
|
||||
icon: null,
|
||||
picture: null,
|
||||
aliases: [],
|
||||
labels: [],
|
||||
temperature_entity_id: null,
|
||||
humidity_entity_id: null,
|
||||
created_at: 0,
|
||||
modified_at: 0,
|
||||
},
|
||||
{
|
||||
area_id: "bedroom",
|
||||
floor_id: null,
|
||||
name: "Bedroom",
|
||||
icon: "mdi:bed",
|
||||
picture: null,
|
||||
aliases: [],
|
||||
labels: [],
|
||||
temperature_entity_id: null,
|
||||
humidity_entity_id: null,
|
||||
created_at: 0,
|
||||
modified_at: 0,
|
||||
},
|
||||
];
|
||||
|
||||
// Maps the removed composite device to the devices that replaced it.
|
||||
const COMPOSITE_SPLITS = {
|
||||
old_composite: {
|
||||
split_ids: ["device_light", "device_switch"],
|
||||
primary_id: "device_light",
|
||||
},
|
||||
};
|
||||
|
||||
interface Sample {
|
||||
name: string;
|
||||
description: string;
|
||||
selector: Selector;
|
||||
value: unknown;
|
||||
// Render ha-target-picker directly in compact (chip) mode instead of the
|
||||
// ha-selector, which does not expose the compact option.
|
||||
compact?: boolean;
|
||||
}
|
||||
|
||||
const SAMPLES: Sample[] = [
|
||||
{
|
||||
name: "Target",
|
||||
description:
|
||||
"Migrate adds every replacement device that matches the target filters (here both).",
|
||||
selector: { target: {} },
|
||||
value: { device_id: ["old_composite"] },
|
||||
},
|
||||
{
|
||||
name: "Target (compact)",
|
||||
description:
|
||||
"In compact mode the replaced reference is shown as a warning chip.",
|
||||
selector: { target: {} },
|
||||
value: { device_id: ["old_composite"] },
|
||||
compact: true,
|
||||
},
|
||||
{
|
||||
name: "Device (unfiltered, multiple matches)",
|
||||
description:
|
||||
"Both replacement devices qualify, so Replace opens a dialog to pick one.",
|
||||
selector: { device: {} },
|
||||
value: "old_composite",
|
||||
},
|
||||
{
|
||||
name: "Device (filtered to lights, single match)",
|
||||
description:
|
||||
"Only the light device passes the filter, so Replace swaps to it in one click.",
|
||||
selector: { device: { entity: [{ domain: "light" }] } },
|
||||
value: "old_composite",
|
||||
},
|
||||
{
|
||||
name: "Device (multiple)",
|
||||
description: "Each slot resolves independently to a matching replacement.",
|
||||
selector: { device: { multiple: true } },
|
||||
value: ["old_composite"],
|
||||
},
|
||||
];
|
||||
|
||||
@customElement("demo-components-ha-selector-replaced-device")
|
||||
class DemoHaSelectorReplacedDevice
|
||||
extends LitElement
|
||||
implements ProvideHassElement
|
||||
{
|
||||
@state() public hass!: HomeAssistant;
|
||||
|
||||
private _values = SAMPLES.map((sample) => sample.value);
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
const hass = provideHass(this);
|
||||
hass.updateTranslations(null, "en");
|
||||
hass.updateTranslations("config", "en");
|
||||
hass.addEntities(ENTITIES);
|
||||
mockEntityRegistry(hass);
|
||||
mockDeviceRegistry(hass, DEVICES);
|
||||
mockConfigEntries(hass);
|
||||
mockHassioSupervisor(hass);
|
||||
// Provide the demo areas and link the demo entities to the split devices.
|
||||
// Set them directly via updateHass (typed against the real registry types)
|
||||
// instead of the area stub, whose demo-specific type differs.
|
||||
const areas: Record<string, AreaRegistryEntry> = {};
|
||||
AREAS.forEach((area) => {
|
||||
areas[area.area_id] = area;
|
||||
});
|
||||
hass.updateHass({ areas, entities: ENTITY_REGISTRY });
|
||||
hass.mockWS(
|
||||
"config/device_registry/list_composite_splits",
|
||||
() => COMPOSITE_SPLITS
|
||||
);
|
||||
hass.mockWS("extract_from_target", () => ({
|
||||
referenced_entities: [],
|
||||
referenced_devices: [],
|
||||
referenced_areas: [],
|
||||
}));
|
||||
hass.mockWS("auth/sign_path", (params) => params);
|
||||
}
|
||||
|
||||
public provideHass(el) {
|
||||
el.hass = this.hass;
|
||||
}
|
||||
|
||||
public connectedCallback() {
|
||||
super.connectedCallback();
|
||||
this.addEventListener("show-dialog", this._dialogManager);
|
||||
}
|
||||
|
||||
public disconnectedCallback() {
|
||||
super.disconnectedCallback();
|
||||
this.removeEventListener("show-dialog", this._dialogManager);
|
||||
}
|
||||
|
||||
private _dialogManager = (e: HASSDomEvent<ShowDialogParams<unknown>>) => {
|
||||
const { dialogTag, dialogImport, dialogParams, addHistory, parentElement } =
|
||||
e.detail;
|
||||
showDialog(
|
||||
this,
|
||||
dialogTag,
|
||||
dialogParams,
|
||||
dialogImport,
|
||||
parentElement,
|
||||
addHistory
|
||||
);
|
||||
};
|
||||
|
||||
protected render(): TemplateResult {
|
||||
return html`
|
||||
${SAMPLES.map(
|
||||
(sample, idx) => html`
|
||||
<demo-black-white-row .title=${sample.name}>
|
||||
${["light", "dark"].map(
|
||||
(slot) => html`
|
||||
<ha-settings-row narrow slot=${slot}>
|
||||
<span slot="heading">${sample.name}</span>
|
||||
<span slot="description">${sample.description}</span>
|
||||
${
|
||||
sample.compact
|
||||
? html`<ha-target-picker
|
||||
compact
|
||||
.hass=${this.hass}
|
||||
.value=${this._values[idx] as HassServiceTarget}
|
||||
.sampleIdx=${idx}
|
||||
@value-changed=${this._handleValueChanged}
|
||||
></ha-target-picker>`
|
||||
: html`<ha-selector
|
||||
.hass=${this.hass}
|
||||
.selector=${sample.selector}
|
||||
.value=${this._values[idx]}
|
||||
.sampleIdx=${idx}
|
||||
@value-changed=${this._handleValueChanged}
|
||||
></ha-selector>`
|
||||
}
|
||||
</ha-settings-row>
|
||||
`
|
||||
)}
|
||||
</demo-black-white-row>
|
||||
`
|
||||
)}
|
||||
`;
|
||||
}
|
||||
|
||||
private _handleValueChanged(ev) {
|
||||
const idx = ev.target.sampleIdx;
|
||||
this._values[idx] = ev.detail.value;
|
||||
this.requestUpdate();
|
||||
}
|
||||
|
||||
static styles = css`
|
||||
ha-settings-row {
|
||||
--settings-row-content-width: 100%;
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"demo-components-ha-selector-replaced-device": DemoHaSelectorReplacedDevice;
|
||||
}
|
||||
}
|
||||
@@ -1,21 +1,25 @@
|
||||
import type { TemplateResult } from "lit";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, state } from "lit/decorators";
|
||||
import { mockAreaRegistry } from "../../../../demo/src/stubs/area_registry";
|
||||
import {
|
||||
mockAreaRegistry,
|
||||
type DemoArea,
|
||||
} from "../../../../demo/src/stubs/area_registry";
|
||||
import { mockConfigEntries } from "../../../../demo/src/stubs/config_entries";
|
||||
import { mockDeviceRegistry } from "../../../../demo/src/stubs/device_registry";
|
||||
import { mockEntityRegistry } from "../../../../demo/src/stubs/entity_registry";
|
||||
import { mockFloorRegistry } from "../../../../demo/src/stubs/floor_registry";
|
||||
import {
|
||||
mockFloorRegistry,
|
||||
type DemoFloor,
|
||||
} from "../../../../demo/src/stubs/floor_registry";
|
||||
import { mockHassioSupervisor } from "../../../../demo/src/stubs/hassio_supervisor";
|
||||
import { mockLabelRegistry } from "../../../../demo/src/stubs/label_registry";
|
||||
import type { HASSDomEvent } from "../../../../src/common/dom/fire_event";
|
||||
import "../../../../src/components/ha-formfield";
|
||||
import "../../../../src/components/ha-selector/ha-selector";
|
||||
import "../../../../src/components/ha-settings-row";
|
||||
import type { AreaRegistryEntry } from "../../../../src/data/area/area_registry";
|
||||
import type { BlueprintInput } from "../../../../src/data/blueprint";
|
||||
import type { DeviceRegistryEntry } from "../../../../src/data/device/device_registry";
|
||||
import type { FloorRegistryEntry } from "../../../../src/data/floor_registry";
|
||||
import type { LabelRegistryEntry } from "../../../../src/data/label/label_registry";
|
||||
import {
|
||||
showDialog,
|
||||
@@ -147,75 +151,43 @@ const DEVICES: DeviceRegistryEntry[] = [
|
||||
},
|
||||
];
|
||||
|
||||
const AREAS: AreaRegistryEntry[] = [
|
||||
const AREAS: DemoArea[] = [
|
||||
{
|
||||
area_id: "backyard",
|
||||
floor_id: "ground",
|
||||
name: "Backyard",
|
||||
icon: null,
|
||||
picture: null,
|
||||
aliases: [],
|
||||
labels: [],
|
||||
temperature_entity_id: null,
|
||||
humidity_entity_id: null,
|
||||
created_at: 0,
|
||||
modified_at: 0,
|
||||
},
|
||||
{
|
||||
area_id: "bedroom",
|
||||
floor_id: "first",
|
||||
name: "Bedroom",
|
||||
icon: "mdi:bed",
|
||||
picture: null,
|
||||
aliases: [],
|
||||
labels: [],
|
||||
temperature_entity_id: null,
|
||||
humidity_entity_id: null,
|
||||
created_at: 0,
|
||||
modified_at: 0,
|
||||
},
|
||||
{
|
||||
area_id: "livingroom",
|
||||
floor_id: "ground",
|
||||
name: "Livingroom",
|
||||
icon: "mdi:sofa",
|
||||
picture: null,
|
||||
aliases: [],
|
||||
labels: [],
|
||||
temperature_entity_id: null,
|
||||
humidity_entity_id: null,
|
||||
created_at: 0,
|
||||
modified_at: 0,
|
||||
},
|
||||
];
|
||||
|
||||
const FLOORS: FloorRegistryEntry[] = [
|
||||
const FLOORS: DemoFloor[] = [
|
||||
{
|
||||
floor_id: "ground",
|
||||
name: "Ground floor",
|
||||
level: 0,
|
||||
icon: null,
|
||||
aliases: [],
|
||||
created_at: 0,
|
||||
modified_at: 0,
|
||||
},
|
||||
{
|
||||
floor_id: "first",
|
||||
name: "First floor",
|
||||
level: 1,
|
||||
icon: "mdi:numeric-1",
|
||||
aliases: [],
|
||||
created_at: 0,
|
||||
modified_at: 0,
|
||||
},
|
||||
{
|
||||
floor_id: "second",
|
||||
name: "Second floor",
|
||||
level: 2,
|
||||
icon: "mdi:numeric-2",
|
||||
aliases: [],
|
||||
created_at: 0,
|
||||
modified_at: 0,
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
@@ -182,12 +182,10 @@ class HaLandingPage extends LandingPageBaseElement {
|
||||
this._networkInfoError = false;
|
||||
this._coreStatusChecked = false;
|
||||
} catch (err: any) {
|
||||
if (!this._coreStatusChecked) {
|
||||
// wait before show errors, because we assume that core is starting
|
||||
this._coreCheckActive = true;
|
||||
this._scheduleTurnOffCoreCheck();
|
||||
if (await this._checkCoreAvailability()) {
|
||||
// core is available, page reload in progress -> don't show an error
|
||||
return;
|
||||
}
|
||||
await this._checkCoreAvailability();
|
||||
|
||||
// assume supervisor update if ping fails -> don't show an error
|
||||
if (!this._coreCheckActive && err.message !== "ping-failed") {
|
||||
@@ -217,7 +215,10 @@ class HaLandingPage extends LandingPageBaseElement {
|
||||
this._progress = -1;
|
||||
}
|
||||
} catch (err: any) {
|
||||
await this._checkCoreAvailability();
|
||||
if (await this._checkCoreAvailability()) {
|
||||
// core is available, page reload in progress -> stop polling
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this._coreCheckActive) {
|
||||
this._progress = -1;
|
||||
@@ -229,16 +230,22 @@ class HaLandingPage extends LandingPageBaseElement {
|
||||
this._scheduleFetchSupervisorJobsInfo();
|
||||
}
|
||||
|
||||
private async _checkCoreAvailability() {
|
||||
private async _checkCoreAvailability(): Promise<boolean> {
|
||||
try {
|
||||
const response = await fetch("/manifest.json");
|
||||
if (response.ok) {
|
||||
location.reload();
|
||||
} else {
|
||||
if (!response.ok) {
|
||||
throw new Error("Failed to fetch manifest");
|
||||
}
|
||||
location.reload();
|
||||
return true;
|
||||
} catch (_err) {
|
||||
this._coreStatusChecked = true;
|
||||
if (!this._coreStatusChecked) {
|
||||
// wait before showing errors, because we assume that core is starting
|
||||
this._coreStatusChecked = true;
|
||||
this._coreCheckActive = true;
|
||||
this._scheduleTurnOffCoreCheck();
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+14
-15
@@ -91,7 +91,7 @@
|
||||
"@webcomponents/webcomponentsjs": "2.8.0",
|
||||
"barcode-detector": "3.2.1",
|
||||
"cally": "0.9.2",
|
||||
"color-name": "2.1.0",
|
||||
"color-name": "2.1.1",
|
||||
"comlink": "4.4.2",
|
||||
"core-js": "3.49.0",
|
||||
"cropperjs": "1.6.2",
|
||||
@@ -108,14 +108,14 @@
|
||||
"home-assistant-js-websocket": "9.6.0",
|
||||
"idb-keyval": "6.3.0",
|
||||
"intl-messageformat": "11.2.12",
|
||||
"js-yaml": "5.2.1",
|
||||
"js-yaml": "5.2.2",
|
||||
"leaflet": "1.9.4",
|
||||
"leaflet-draw": "patch:leaflet-draw@npm%3A1.0.4#./.yarn/patches/leaflet-draw-npm-1.0.4-0ca0ebcf65.patch",
|
||||
"leaflet.markercluster": "1.5.3",
|
||||
"lit": "3.3.3",
|
||||
"lit-html": "3.3.3",
|
||||
"luxon": "3.7.2",
|
||||
"marked": "18.0.6",
|
||||
"marked": "18.0.7",
|
||||
"memoize-one": "6.0.0",
|
||||
"node-vibrant": "4.0.4",
|
||||
"object-hash": "3.0.0",
|
||||
@@ -150,9 +150,9 @@
|
||||
"@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.6.0",
|
||||
"@rspack/core": "2.1.4",
|
||||
"@playwright/test": "1.62.0",
|
||||
"@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",
|
||||
@@ -174,7 +174,7 @@
|
||||
"babel-plugin-polyfill-corejs3": "1.0.0",
|
||||
"browserslist-useragent-regexp": "4.1.4",
|
||||
"del": "8.0.1",
|
||||
"eslint": "10.7.0",
|
||||
"eslint": "10.8.0",
|
||||
"eslint-config-prettier": "10.1.8",
|
||||
"eslint-import-resolver-webpack": "0.13.11",
|
||||
"eslint-plugin-import-x": "4.17.1",
|
||||
@@ -183,7 +183,7 @@
|
||||
"eslint-plugin-unused-imports": "4.4.1",
|
||||
"eslint-plugin-wc": "3.1.0",
|
||||
"fancy-log": "2.0.0",
|
||||
"fs-extra": "11.3.6",
|
||||
"fs-extra": "11.4.0",
|
||||
"generate-license-file": "4.2.1",
|
||||
"glob": "13.0.6",
|
||||
"globals": "17.7.0",
|
||||
@@ -196,22 +196,22 @@
|
||||
"jsdom": "29.1.1",
|
||||
"jszip": "3.10.1",
|
||||
"license-checker-rseidelsohn": "5.0.1",
|
||||
"lint-staged": "17.1.0",
|
||||
"lint-staged": "17.2.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.20",
|
||||
"sinon": "22.1.0",
|
||||
"tar": "7.5.22",
|
||||
"terser-webpack-plugin": "5.6.1",
|
||||
"ts-lit-plugin": "2.0.2",
|
||||
"typescript": "6.0.3",
|
||||
"typescript-eslint": "8.64.0",
|
||||
"typescript-eslint": "8.65.0",
|
||||
"vite-tsconfig-paths": "6.1.1",
|
||||
"vitest": "4.1.10",
|
||||
"webpack-stats-plugin": "1.1.3",
|
||||
@@ -226,8 +226,7 @@
|
||||
"@fullcalendar/daygrid": "6.1.21",
|
||||
"globals": "17.7.0",
|
||||
"tslib": "2.8.1",
|
||||
"@material/mwc-list@^0.27.0": "patch:@material/mwc-list@npm%3A0.27.0#~/.yarn/patches/@material-mwc-list-npm-0.27.0-5344fc9de4.patch",
|
||||
"glob@^10.2.2": "^10.5.0"
|
||||
"@material/mwc-list@^0.27.0": "patch:@material/mwc-list@npm%3A0.27.0#~/.yarn/patches/@material-mwc-list-npm-0.27.0-5344fc9de4.patch"
|
||||
},
|
||||
"packageManager": "yarn@4.17.1",
|
||||
"volta": {
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 120 120" role="presentation">
|
||||
<style>
|
||||
.dot-left { animation: pulse-left 1300ms 350ms linear infinite; }
|
||||
.dot-right { animation: pulse-right 1300ms 350ms linear infinite; }
|
||||
.dot-top { animation: pulse-top 1300ms 350ms linear infinite; }
|
||||
@keyframes pulse-left {
|
||||
0% { transform: translate(50px, 69.634804px) scale(1); animation-timing-function: cubic-bezier(.39, .575, .565, 1); }
|
||||
15.384615% { transform: translate(50px, 69.634804px) scale(1.3); animation-timing-function: cubic-bezier(.39, .575, .565, 1); }
|
||||
38.461538%, 100% { transform: translate(50px, 69.634804px) scale(1); }
|
||||
}
|
||||
@keyframes pulse-right {
|
||||
0%, 15.384615% { transform: translate(90px, 58.634798px) scale(1); }
|
||||
15.384615% { animation-timing-function: cubic-bezier(.39, .575, .565, 1); }
|
||||
30.769231% { transform: translate(90px, 58.634798px) scale(1.3); animation-timing-function: cubic-bezier(.39, .575, .565, 1); }
|
||||
53.846154%, 100% { transform: translate(90px, 58.634798px) scale(1); }
|
||||
}
|
||||
@keyframes pulse-top {
|
||||
0%, 30.769231% { transform: translate(70px, 37.6348px) scale(1); }
|
||||
30.769231% { animation-timing-function: cubic-bezier(.39, .575, .565, 1); }
|
||||
46.153846% { transform: translate(70px, 37.6348px) scale(1.3); animation-timing-function: cubic-bezier(.39, .575, .565, 1); }
|
||||
69.230769%, 100% { transform: translate(70px, 37.6348px) scale(1); }
|
||||
}
|
||||
</style>
|
||||
<g transform="matrix(.938408 0 0 .93841 -5.688557 -13.572944)">
|
||||
<path fill="#18bcf2" d="M73.5367 13.0937 106.463 46.0524v.0033C108.41 48.0043 110 51.848 110 54.6007v30.0292c0 2.7527-2.25 5.0049-5 5.0049l-70-.0034c-2.75 0-5-2.2522-5-5.0048V54.5974c0-2.7527 1.5933-6.5998 3.5367-8.545l32.93-32.9587c1.9433-1.9452 5.1266-1.9452 7.07 0Z" transform="matrix(1.598452 0 0 1.598452 -41.89164 -.937304)"/>
|
||||
<g mask="url(#logo-mask)" transform="matrix(1.598452 0 0 1.598452 -41.89164 -.937311)">
|
||||
<path d="m70 89.6348-20-20M70 89.6348v-52M90 58.1348l-20 20" fill="none" stroke="#f2f4f9" stroke-linecap="round" stroke-width="6"/>
|
||||
<circle class="dot-left" r="7" fill="#f2f4f9" transform="translate(50 69.634804)"/>
|
||||
<circle class="dot-right" r="7" fill="#f2f4f9" transform="translate(90 58.634798)"/>
|
||||
<circle class="dot-top" r="7" fill="#f2f4f9" transform="translate(70 37.6348)"/>
|
||||
</g>
|
||||
<mask id="logo-mask" x="-150%" y="-150%" width="400%" height="400%" mask-type="luminance">
|
||||
<path fill="#f2f4f9" d="M73.5367 13.0937 106.463 46.0524v.0033C108.41 48.0043 110 51.848 110 54.6007v30.0292c0 2.7527-2.25 5.0049-5 5.0049l-70-.0034c-2.75 0-5-2.2522-5-5.0048V54.5974c0-2.7527 1.5933-6.5998 3.5367-8.545l32.93-32.9587c1.9433-1.9452 5.1266-1.9452 7.07 0Z"/>
|
||||
</mask>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.7 KiB |
@@ -0,0 +1,22 @@
|
||||
<svg width="237" height="24" viewBox="0 0 237 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M8.54181 0.297615C8.14709 -0.0992051 7.5012 -0.0992051 7.1082 0.297615L0.717655 6.72232C0.322945 7.11914 0 7.90248 0 8.46249V14.2894C0 14.8494 0.456224 15.3098 1.01497 15.3098H14.635C15.1921 15.3098 15.65 14.8511 15.65 14.2894V8.46249C15.65 7.90248 15.3271 7.11914 14.9324 6.72232L8.54181 0.297615Z" fill="#F7F6F2"/>
|
||||
<path d="M14.8672 17.918C15.2993 17.918 15.6502 18.2682 15.6504 18.7002V22.417C15.6504 22.8492 15.2994 23.2002 14.8672 23.2002H13.8896C13.4575 23.2002 13.1064 22.8492 13.1064 22.417V20.4609H2.54297V22.417C2.54297 22.8491 2.19285 23.2001 1.76074 23.2002H0.782227C0.350189 23.2 0 22.8491 0 22.417V18.7002C0.000147852 18.2682 0.350281 17.9181 0.782227 17.918H14.8672Z" fill="#F7F6F2"/>
|
||||
<path d="M28.0439 5.24023C28.9236 5.24023 29.7386 5.39857 30.4891 5.71734C31.2396 6.03402 31.8754 6.47362 32.3965 7.03406C32.9177 7.5945 33.3242 8.26536 33.616 9.05081C33.9058 9.83625 34.0517 10.6842 34.0517 11.5967C34.0621 12.4989 33.9204 13.3447 33.6285 14.1385C33.3346 14.9323 32.9239 15.6094 32.3965 16.1719C31.8691 16.7344 31.227 17.1761 30.4724 17.4949C29.7178 17.8157 28.9027 17.9699 28.0272 17.9595C26.8702 17.9762 25.83 17.7095 24.9086 17.1616C23.9872 16.6136 23.2764 15.8532 22.7761 14.8802C22.2757 13.9073 22.0339 12.8176 22.0506 11.6134C22.0402 10.7113 22.1819 9.86542 22.4738 9.07164C22.7677 8.27786 23.1763 7.60075 23.7016 7.03406C24.2269 6.46945 24.8648 6.02568 25.6174 5.70692C26.3699 5.38816 27.1787 5.2319 28.0439 5.24232V5.24023ZM28.0584 15.524C29.0966 15.524 29.9179 15.1719 30.5245 14.4698C31.1312 13.7656 31.4334 12.8093 31.4334 11.5967C31.4334 10.3842 31.1312 9.41332 30.5287 8.71121C29.9262 8.01118 29.1028 7.66117 28.0584 7.66117C27.0141 7.66117 26.1906 8.01118 25.5882 8.71121C24.9857 9.41124 24.6835 10.3738 24.6835 11.5967C24.6835 12.8197 24.9857 13.7802 25.5882 14.4781C26.1906 15.1761 27.0141 15.5261 28.0584 15.5261V15.524Z" fill="#F7F6F2"/>
|
||||
<path d="M44.9854 9.52582C44.9854 10.7592 44.6123 11.7509 43.8681 12.503C43.1239 13.2551 42.1108 13.6322 40.8267 13.6322H38.3898V17.7553H35.9028V5.45899H40.86C42.1483 5.45899 43.1572 5.82151 43.8889 6.54654C44.6206 7.27157 44.9854 8.26536 44.9854 9.52374V9.52582ZM42.3588 9.47791C42.3588 8.95705 42.19 8.52578 41.8502 8.18619C41.5104 7.84659 41.0205 7.67783 40.3785 7.67783H38.3898V11.4676H40.3785C41.0247 11.4676 41.5167 11.2842 41.8544 10.9175C42.1921 10.5509 42.3588 10.0717 42.3588 9.47999V9.47791Z" fill="#F7F6F2"/>
|
||||
<path d="M54.4829 17.7553H46.7386V5.45899H54.4829V7.82159H49.2234V10.4446H53.895V12.6447H49.2234V15.4156H54.4829V17.7553Z" fill="#F7F6F2"/>
|
||||
<path d="M67.397 5.46108V17.7574H64.9268L59.5381 9.42999V17.7574H57.0678V5.46108H59.5381L64.9268 13.8052V5.46108H67.397Z" fill="#F7F6F2"/>
|
||||
<path d="M74.4409 5.46108H76.9279V10.4321L81.8037 10.4488V5.46108H84.3074V17.7574H81.8037V12.6655L76.9279 12.6322V17.7574H74.4409V5.46108Z" fill="#F7F6F2"/>
|
||||
<path d="M92.133 5.24023C93.0127 5.24023 93.8278 5.39857 94.5782 5.71734C95.3287 6.03402 95.9645 6.47362 96.4856 7.03406C97.0068 7.5945 97.4133 8.26536 97.7051 9.05081C97.9949 9.83625 98.1408 10.6842 98.1408 11.5967C98.1512 12.4989 98.0095 13.3447 97.7176 14.1385C97.4237 14.9323 97.013 15.6094 96.4856 16.1719C95.9582 16.7344 95.3162 17.1761 94.5615 17.4949C93.8069 17.8157 92.9918 17.9699 92.1163 17.9595C90.9593 17.9762 89.9191 17.7095 88.9977 17.1616C88.0763 16.6136 87.3655 15.8532 86.8652 14.8802C86.3649 13.9073 86.1231 12.8176 86.1397 11.6134C86.1293 10.7113 86.2711 9.86542 86.5629 9.07164C86.8568 8.27786 87.2654 7.60075 87.7907 7.03406C88.3161 6.46945 88.954 6.02568 89.7065 5.70692C90.459 5.38816 91.2679 5.2319 92.133 5.24232V5.24023ZM92.1476 15.524C93.1857 15.524 94.007 15.1719 94.6137 14.4698C95.2203 13.7656 95.5225 12.8093 95.5225 11.5967C95.5225 10.3842 95.2203 9.41332 94.6178 8.71121C94.0154 8.01118 93.192 7.66117 92.1476 7.66117C91.1032 7.66117 90.2798 8.01118 89.6773 8.71121C89.0749 9.41124 88.7726 10.3738 88.7726 11.5967C88.7726 12.8197 89.0749 13.7802 89.6773 14.4781C90.2798 15.1761 91.1032 15.5261 92.1476 15.5261V15.524Z" fill="#F7F6F2"/>
|
||||
<path d="M112.408 5.46108V17.7574H109.954V11.0342L107.321 17.7574H104.966L102.364 11.0842V17.7574H99.9919V5.46108H102.364L106.148 14.8552L109.954 5.46108H112.408Z" fill="#F7F6F2"/>
|
||||
<path d="M122.762 17.7553H115.018V5.45899H122.762V7.82159H117.503V10.4446H122.174V12.6447H117.503V15.4156H122.762V17.7553Z" fill="#F7F6F2"/>
|
||||
<path d="M131.526 7.21579V10.9075H136.254V12.5936H131.526V17.7408H129.65V5.44079H137.146V7.21579H131.526Z" fill="#F7F6F2"/>
|
||||
<path d="M144.303 5.24079C145.156 5.22968 145.956 5.38523 146.699 5.70468C147.441 6.02412 148.074 6.46579 148.597 7.02968C149.117 7.59357 149.525 8.27135 149.82 9.05746C150.114 9.84635 150.256 10.688 150.245 11.5852C150.256 12.488 150.114 13.338 149.82 14.1297C149.525 14.9241 149.117 15.5991 148.597 16.163C148.077 16.7241 147.443 17.1658 146.699 17.4852C145.954 17.8047 145.156 17.9602 144.303 17.9491C143.449 17.9602 142.652 17.8047 141.91 17.4852C141.168 17.1658 140.537 16.7241 140.017 16.1602C139.497 15.5963 139.089 14.9186 138.797 14.1325C138.505 13.3463 138.363 12.5019 138.374 11.6047C138.363 10.7075 138.505 9.86301 138.797 9.07412C139.089 8.28523 139.494 7.60746 140.017 7.04079C140.537 6.4769 141.168 6.03246 141.91 5.71023C142.652 5.38801 143.449 5.23246 144.303 5.24357V5.24079ZM141.44 14.8602C142.165 15.6825 143.124 16.0963 144.319 16.0963C145.515 16.0963 146.473 15.6852 147.196 14.8602C147.919 14.038 148.28 12.9463 148.28 11.5852C148.28 10.2241 147.919 9.12412 147.196 8.3019C146.473 7.47968 145.515 7.06579 144.319 7.06579C143.124 7.06579 142.163 7.47968 141.44 8.30468C140.715 9.12968 140.353 10.2241 140.353 11.5852C140.353 12.9463 140.715 14.038 141.44 14.8602Z" fill="#F7F6F2"/>
|
||||
<path d="M154.097 5.43801V13.3019C154.103 14.238 154.378 14.9463 154.923 15.4241C155.467 15.9047 156.184 16.1436 157.071 16.1436C157.958 16.1436 158.636 15.8908 159.203 15.388C159.77 14.8852 160.053 14.188 160.053 13.3019V5.43801H161.929V13.3769C161.929 14.0991 161.799 14.7519 161.54 15.338C161.282 15.9241 160.929 16.4075 160.487 16.7825C160.042 17.1602 159.531 17.4491 158.944 17.6547C158.358 17.8602 157.735 17.9602 157.068 17.9602C156.401 17.9602 155.804 17.8602 155.226 17.663C154.647 17.4658 154.131 17.1797 153.68 16.8075C153.227 16.4352 152.871 15.9547 152.61 15.363C152.349 14.7713 152.218 14.1075 152.218 13.3769V5.43801H154.097Z" fill="#F7F6F2"/>
|
||||
<path d="M174.473 5.43801V17.738H172.597L166.529 8.46857V17.738H164.653V5.43801H166.529L172.597 14.7241V5.43801H174.473Z" fill="#F7F6F2"/>
|
||||
<path d="M187.583 11.6019C187.588 12.4825 187.436 13.3102 187.124 14.0797C186.813 14.8519 186.382 15.5075 185.835 16.0463C185.287 16.5852 184.628 17.0075 183.859 17.3102C183.089 17.613 182.269 17.7575 181.391 17.7408H177.291V5.44079H181.391C182.547 5.42412 183.603 5.67968 184.553 6.21023C185.504 6.73801 186.249 7.4769 186.788 8.42412C187.327 9.37135 187.591 10.4325 187.583 11.6047V11.6019ZM185.59 11.6019C185.59 10.3019 185.204 9.24635 184.428 8.43523C183.653 7.62412 182.636 7.21579 181.374 7.21579H179.184V15.9963H181.374C182.647 15.9963 183.667 15.5936 184.437 14.7852C185.207 13.9797 185.59 12.9158 185.59 11.6019Z" fill="#F7F6F2"/>
|
||||
<path d="M195.821 14.7825H190.91L189.843 17.7408H187.916L192.363 5.44079H194.381L198.853 17.7408H196.869L195.818 14.7825H195.821ZM195.276 13.188L193.383 7.8269L191.474 13.188H195.276Z" fill="#F7F6F2"/>
|
||||
<path d="M207.278 7.21579H203.706V17.7408H201.813V7.21579H198.242V5.44079H207.275V7.21579H207.278Z" fill="#F7F6F2"/>
|
||||
<path d="M208.973 17.738V5.43801H210.866V17.738H208.973Z" fill="#F7F6F2"/>
|
||||
<path d="M218.873 5.24079C219.726 5.22968 220.527 5.38523 221.269 5.70468C222.014 6.02412 222.645 6.46579 223.167 7.02968C223.687 7.59357 224.095 8.27135 224.39 9.05746C224.685 9.84635 224.826 10.688 224.815 11.5852C224.826 12.488 224.685 13.338 224.39 14.1297C224.095 14.9241 223.687 15.5991 223.167 16.163C222.647 16.7241 222.014 17.1658 221.269 17.4852C220.524 17.8047 219.726 17.9602 218.873 17.9491C218.02 17.9602 217.222 17.8047 216.48 17.4852C215.738 17.1658 215.107 16.7241 214.587 16.1602C214.067 15.5963 213.659 14.9186 213.367 14.1325C213.075 13.3463 212.933 12.5019 212.945 11.6047C212.933 10.7075 213.075 9.86301 213.367 9.07412C213.659 8.28523 214.065 7.60746 214.587 7.04079C215.107 6.4769 215.738 6.03246 216.48 5.71023C217.222 5.38801 218.02 5.23246 218.873 5.24357V5.24079ZM216.01 14.8602C216.736 15.6825 217.695 16.0963 218.89 16.0963C220.085 16.0963 221.044 15.6852 221.766 14.8602C222.489 14.038 222.85 12.9463 222.85 11.5852C222.85 10.2241 222.489 9.12412 221.766 8.3019C221.044 7.47968 220.085 7.06579 218.89 7.06579C217.695 7.06579 216.733 7.47968 216.01 8.30468C215.285 9.12968 214.924 10.2241 214.924 11.5852C214.924 12.9463 215.285 14.038 216.01 14.8602Z" fill="#F7F6F2"/>
|
||||
<path d="M236.728 5.43801V17.738H234.852L228.784 8.46857V17.738H226.908V5.43801H228.784L234.852 14.7241V5.43801H236.728Z" fill="#F7F6F2"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 8.7 KiB |
@@ -0,0 +1,22 @@
|
||||
<svg width="237" height="24" viewBox="0 0 237 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M8.54181 0.297615C8.14709 -0.0992051 7.5012 -0.0992051 7.1082 0.297615L0.717655 6.72232C0.322945 7.11914 0 7.90248 0 8.46249V14.2894C0 14.8494 0.456224 15.3098 1.01497 15.3098H14.635C15.1921 15.3098 15.65 14.8511 15.65 14.2894V8.46249C15.65 7.90248 15.3271 7.11914 14.9324 6.72232L8.54181 0.297615Z" fill="#09202E"/>
|
||||
<path d="M14.8672 17.9182C15.2993 17.9182 15.6502 18.2684 15.6504 18.7004V22.4172C15.6504 22.8494 15.2994 23.2004 14.8672 23.2004H13.8896C13.4575 23.2004 13.1064 22.8494 13.1064 22.4172V20.4612H2.54297V22.4172C2.54297 22.8494 2.19285 23.2004 1.76074 23.2004H0.782227C0.350189 23.2003 0 22.8493 0 22.4172V18.7004C0.000147852 18.2685 0.350281 17.9184 0.782227 17.9182H14.8672Z" fill="#09202E"/>
|
||||
<path d="M28.0439 5.23999C28.9236 5.23999 29.7386 5.39833 30.4891 5.71709C31.2396 6.03377 31.8754 6.47337 32.3965 7.03381C32.9177 7.59425 33.3242 8.26511 33.616 9.05056C33.9058 9.83601 34.0517 10.684 34.0517 11.5965C34.0621 12.4986 33.9204 13.3445 33.6285 14.1383C33.3346 14.932 32.9239 15.6092 32.3965 16.1717C31.8691 16.7342 31.227 17.1759 30.4724 17.4947C29.7178 17.8155 28.9027 17.9697 28.0272 17.9593C26.8702 17.9759 25.83 17.7092 24.9086 17.1613C23.9872 16.6134 23.2764 15.8529 22.7761 14.88C22.2757 13.907 22.0339 12.8174 22.0506 11.6132C22.0402 10.711 22.1819 9.86518 22.4738 9.0714C22.7677 8.27761 23.1763 7.6005 23.7016 7.03381C24.2269 6.46921 24.8648 6.02544 25.6174 5.70668C26.3699 5.38791 27.1787 5.23166 28.0439 5.24207V5.23999ZM28.0584 15.5237C29.0966 15.5237 29.9179 15.1716 30.5245 14.4695C31.1312 13.7653 31.4334 12.809 31.4334 11.5965C31.4334 10.3839 31.1312 9.41308 30.5287 8.71096C29.9263 8.01094 29.1028 7.66092 28.0584 7.66092C27.0141 7.66092 26.1906 8.01094 25.5882 8.71096C24.9857 9.41099 24.6835 10.3735 24.6835 11.5965C24.6835 12.8195 24.9857 13.7799 25.5882 14.4779C26.1906 15.1758 27.0141 15.5258 28.0584 15.5258V15.5237Z" fill="#09202E"/>
|
||||
<path d="M44.9854 9.52558C44.9854 10.759 44.6123 11.7507 43.8681 12.5028C43.1239 13.2549 42.1108 13.632 40.8267 13.632H38.3898V17.7551H35.9028V5.45875H40.86C42.1483 5.45875 43.1572 5.82126 43.8889 6.54629C44.6206 7.27132 44.9854 8.26511 44.9854 9.5235V9.52558ZM42.3588 9.47766C42.3588 8.95681 42.19 8.52554 41.8502 8.18594C41.5104 7.84635 41.0205 7.67759 40.3785 7.67759H38.3898V11.4673H40.3785C41.0247 11.4673 41.5167 11.284 41.8544 10.9173C42.1921 10.5506 42.3588 10.0714 42.3588 9.47975V9.47766Z" fill="#09202E"/>
|
||||
<path d="M54.4829 17.7551H46.7386V5.45875H54.4829V7.82134H49.2234V10.4444H53.895V12.6445H49.2234V15.4154H54.4829V17.7551Z" fill="#09202E"/>
|
||||
<path d="M67.397 5.46083V17.7572H64.9268L59.5381 9.42974V17.7572H57.0678V5.46083H59.5381L64.9268 13.8049V5.46083H67.397Z" fill="#09202E"/>
|
||||
<path d="M74.4409 5.46083H76.9279V10.4319L81.8037 10.4485V5.46083H84.3074V17.7572H81.8037V12.6653L76.9279 12.632V17.7572H74.4409V5.46083Z" fill="#09202E"/>
|
||||
<path d="M92.133 5.23999C93.0127 5.23999 93.8278 5.39833 94.5782 5.71709C95.3287 6.03377 95.9645 6.47337 96.4856 7.03381C97.0068 7.59425 97.4133 8.26511 97.7051 9.05056C97.9949 9.83601 98.1408 10.684 98.1408 11.5965C98.1512 12.4986 98.0095 13.3445 97.7176 14.1383C97.4237 14.932 97.013 15.6092 96.4856 16.1717C95.9582 16.7342 95.3162 17.1759 94.5615 17.4947C93.8069 17.8155 92.9918 17.9697 92.1163 17.9593C90.9593 17.9759 89.9191 17.7092 88.9977 17.1613C88.0763 16.6134 87.3655 15.8529 86.8652 14.88C86.3649 13.907 86.1231 12.8174 86.1397 11.6132C86.1293 10.711 86.2711 9.86518 86.5629 9.0714C86.8568 8.27761 87.2654 7.6005 87.7907 7.03381C88.3161 6.46921 88.954 6.02544 89.7065 5.70668C90.459 5.38791 91.2679 5.23166 92.133 5.24207V5.23999ZM92.1476 15.5237C93.1857 15.5237 94.007 15.1716 94.6137 14.4695C95.2203 13.7653 95.5225 12.809 95.5225 11.5965C95.5225 10.3839 95.2203 9.41308 94.6178 8.71096C94.0154 8.01094 93.192 7.66092 92.1476 7.66092C91.1032 7.66092 90.2798 8.01094 89.6773 8.71096C89.0749 9.41099 88.7726 10.3735 88.7726 11.5965C88.7726 12.8195 89.0749 13.7799 89.6773 14.4779C90.2798 15.1758 91.1032 15.5258 92.1476 15.5258V15.5237Z" fill="#09202E"/>
|
||||
<path d="M112.408 5.46083V17.7572H109.954V11.034L107.321 17.7572H104.966L102.364 11.084V17.7572H99.9919V5.46083H102.364L106.148 14.855L109.954 5.46083H112.408Z" fill="#09202E"/>
|
||||
<path d="M122.762 17.7551H115.018V5.45875H122.762V7.82134H117.503V10.4444H122.174V12.6445H117.503V15.4154H122.762V17.7551Z" fill="#09202E"/>
|
||||
<path d="M131.526 7.21555V10.9072H136.254V12.5933H131.526V17.7405H129.65V5.44054H137.146V7.21555H131.526Z" fill="#09202E"/>
|
||||
<path d="M144.303 5.24055C145.156 5.22943 145.956 5.38499 146.699 5.70443C147.441 6.02388 148.074 6.46555 148.597 7.02943C149.117 7.59332 149.525 8.2711 149.82 9.05721C150.114 9.8461 150.256 10.6878 150.245 11.585C150.256 12.4878 150.114 13.3378 149.82 14.1294C149.525 14.9239 149.117 15.5989 148.597 16.1628C148.077 16.7239 147.443 17.1655 146.699 17.485C145.954 17.8044 145.156 17.96 144.303 17.9489C143.449 17.96 142.652 17.8044 141.91 17.485C141.168 17.1655 140.537 16.7239 140.017 16.16C139.497 15.5961 139.089 14.9183 138.797 14.1322C138.505 13.3461 138.363 12.5017 138.374 11.6044C138.363 10.7072 138.505 9.86277 138.797 9.07388C139.089 8.28499 139.494 7.60721 140.017 7.04055C140.537 6.47666 141.168 6.03221 141.91 5.70999C142.652 5.38777 143.449 5.23221 144.303 5.24332V5.24055ZM141.44 14.86C142.165 15.6822 143.124 16.0961 144.319 16.0961C145.515 16.0961 146.473 15.685 147.196 14.86C147.919 14.0378 148.28 12.9461 148.28 11.585C148.28 10.2239 147.919 9.12388 147.196 8.30166C146.473 7.47943 145.515 7.06555 144.319 7.06555C143.124 7.06555 142.163 7.47943 141.44 8.30443C140.715 9.12943 140.353 10.2239 140.353 11.585C140.353 12.9461 140.715 14.0378 141.44 14.86Z" fill="#09202E"/>
|
||||
<path d="M154.097 5.43777V13.3017C154.103 14.2378 154.378 14.9461 154.923 15.4239C155.467 15.9044 156.184 16.1433 157.071 16.1433C157.958 16.1433 158.636 15.8905 159.203 15.3878C159.77 14.885 160.053 14.1878 160.053 13.3017V5.43777H161.929V13.3767C161.929 14.0989 161.799 14.7517 161.54 15.3378C161.282 15.9239 160.929 16.4072 160.487 16.7822C160.042 17.16 159.531 17.4489 158.944 17.6544C158.358 17.86 157.735 17.96 157.068 17.96C156.401 17.96 155.804 17.86 155.226 17.6628C154.647 17.4655 154.131 17.1794 153.68 16.8072C153.227 16.435 152.871 15.9544 152.61 15.3628C152.349 14.7711 152.218 14.1072 152.218 13.3767V5.43777H154.097Z" fill="#09202E"/>
|
||||
<path d="M174.473 5.43777V17.7378H172.597L166.529 8.46832V17.7378H164.653V5.43777H166.529L172.597 14.7239V5.43777H174.473Z" fill="#09202E"/>
|
||||
<path d="M187.583 11.6017C187.588 12.4822 187.436 13.31 187.124 14.0794C186.813 14.8517 186.382 15.5072 185.835 16.0461C185.287 16.585 184.628 17.0072 183.859 17.31C183.089 17.6128 182.269 17.7572 181.391 17.7405H177.291V5.44054H181.391C182.547 5.42388 183.603 5.67943 184.553 6.20999C185.504 6.73777 186.249 7.47666 186.788 8.42388C187.327 9.3711 187.591 10.4322 187.583 11.6044V11.6017ZM185.59 11.6017C185.59 10.3017 185.204 9.2461 184.428 8.43499C183.653 7.62388 182.636 7.21555 181.374 7.21555H179.184V15.9961H181.374C182.647 15.9961 183.667 15.5933 184.437 14.785C185.207 13.9794 185.59 12.9155 185.59 11.6017Z" fill="#09202E"/>
|
||||
<path d="M195.821 14.7822H190.91L189.843 17.7405H187.916L192.363 5.44054H194.381L198.853 17.7405H196.869L195.818 14.7822H195.821ZM195.276 13.1878L193.383 7.82666L191.474 13.1878H195.276Z" fill="#09202E"/>
|
||||
<path d="M207.278 7.21555H203.706V17.7405H201.813V7.21555H198.242V5.44054H207.275V7.21555H207.278Z" fill="#09202E"/>
|
||||
<path d="M208.973 17.7378V5.43777H210.866V17.7378H208.973Z" fill="#09202E"/>
|
||||
<path d="M218.873 5.24055C219.726 5.22943 220.527 5.38499 221.269 5.70443C222.014 6.02388 222.645 6.46555 223.167 7.02943C223.687 7.59332 224.095 8.2711 224.39 9.05721C224.685 9.8461 224.826 10.6878 224.815 11.585C224.826 12.4878 224.685 13.3378 224.39 14.1294C224.095 14.9239 223.687 15.5989 223.167 16.1628C222.647 16.7239 222.014 17.1655 221.269 17.485C220.524 17.8044 219.726 17.96 218.873 17.9489C218.02 17.96 217.222 17.8044 216.48 17.485C215.738 17.1655 215.107 16.7239 214.587 16.16C214.067 15.5961 213.659 14.9183 213.367 14.1322C213.075 13.3461 212.933 12.5017 212.945 11.6044C212.933 10.7072 213.075 9.86277 213.367 9.07388C213.659 8.28499 214.065 7.60721 214.587 7.04055C215.107 6.47666 215.738 6.03221 216.48 5.70999C217.222 5.38777 218.02 5.23221 218.873 5.24332V5.24055ZM216.01 14.86C216.736 15.6822 217.695 16.0961 218.89 16.0961C220.085 16.0961 221.044 15.685 221.766 14.86C222.489 14.0378 222.85 12.9461 222.85 11.585C222.85 10.2239 222.489 9.12388 221.766 8.30166C221.044 7.47943 220.085 7.06555 218.89 7.06555C217.695 7.06555 216.733 7.47943 216.01 8.30443C215.285 9.12943 214.924 10.2239 214.924 11.585C214.924 12.9461 215.285 14.0378 216.01 14.86Z" fill="#09202E"/>
|
||||
<path d="M236.728 5.43777V17.7378H234.852L228.784 8.46832V17.7378H226.908V5.43777H228.784L234.852 14.7239V5.43777H236.728Z" fill="#09202E"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 8.7 KiB |
+2
-1
@@ -106,7 +106,8 @@
|
||||
{
|
||||
"description": "Group Playwright package and CI container updates",
|
||||
"groupName": "Playwright",
|
||||
"matchPackageNames": ["@playwright/test", "mcr.microsoft.com/playwright"]
|
||||
"matchPackageNames": ["@playwright/test", "mcr.microsoft.com/playwright"],
|
||||
"minimumGroupSize": 5
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
+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;
|
||||
};
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
import type {
|
||||
ReactiveController,
|
||||
ReactiveControllerHost,
|
||||
} from "@lit/reactive-element/reactive-controller";
|
||||
import type { LitElement } from "lit";
|
||||
import type { Ref } from "lit/directives/ref";
|
||||
|
||||
const scrollParent = (element: Element): HTMLElement | undefined => {
|
||||
let node = element.parentElement;
|
||||
while (node) {
|
||||
const { overflowY } = getComputedStyle(node);
|
||||
if (overflowY === "auto" || overflowY === "scroll") {
|
||||
return node;
|
||||
}
|
||||
node = node.parentElement;
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
/**
|
||||
* Does what CSS scroll anchoring does in Chrome and Firefox but not in Safari.
|
||||
* Point the ref at the element that grows, not at the scroller. Turns native
|
||||
* anchoring off on that scroller, so growth elsewhere in it is no longer
|
||||
* compensated either.
|
||||
*/
|
||||
export class PreserveScrollPositionController implements ReactiveController {
|
||||
private _target: Ref<HTMLElement>;
|
||||
|
||||
private _element?: HTMLElement;
|
||||
|
||||
private _scroller?: HTMLElement;
|
||||
|
||||
private _observer?: ResizeObserver;
|
||||
|
||||
private _height = 0;
|
||||
|
||||
constructor(
|
||||
host: ReactiveControllerHost & LitElement,
|
||||
target: Ref<HTMLElement>
|
||||
) {
|
||||
this._target = target;
|
||||
host.addController(this);
|
||||
}
|
||||
|
||||
hostConnected() {
|
||||
this._sync();
|
||||
}
|
||||
|
||||
hostUpdated() {
|
||||
this._sync();
|
||||
}
|
||||
|
||||
hostDisconnected() {
|
||||
this._detach();
|
||||
}
|
||||
|
||||
private _sync() {
|
||||
const element = this._target.value;
|
||||
|
||||
if (element === this._element) {
|
||||
return;
|
||||
}
|
||||
|
||||
this._detach();
|
||||
this._element = element;
|
||||
|
||||
if (element) {
|
||||
this._height = element.getBoundingClientRect().height;
|
||||
this._observer = new ResizeObserver((entries) =>
|
||||
this._compensate(entries)
|
||||
);
|
||||
this._observer.observe(element);
|
||||
}
|
||||
}
|
||||
|
||||
private _detach() {
|
||||
this._observer?.disconnect();
|
||||
this._observer = undefined;
|
||||
this._element = undefined;
|
||||
if (this._scroller) {
|
||||
this._scroller.style.removeProperty("overflow-anchor");
|
||||
this._scroller = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
private _resolveScroller(): HTMLElement | undefined {
|
||||
if (!this._scroller && this._element) {
|
||||
this._scroller = scrollParent(this._element);
|
||||
if (this._scroller) {
|
||||
// Chrome and Firefox would otherwise anchor on top of this controller
|
||||
// and correct twice.
|
||||
this._scroller.style.overflowAnchor = "none";
|
||||
}
|
||||
}
|
||||
return this._scroller;
|
||||
}
|
||||
|
||||
private _compensate(entries: ResizeObserverEntry[]) {
|
||||
const element = this._element;
|
||||
if (!element) {
|
||||
return;
|
||||
}
|
||||
|
||||
const height =
|
||||
entries[0]?.borderBoxSize?.[0]?.blockSize ?? element.offsetHeight;
|
||||
const delta = height - this._height;
|
||||
this._height = height;
|
||||
if (!delta) {
|
||||
return;
|
||||
}
|
||||
|
||||
const scroller = this._resolveScroller();
|
||||
if (
|
||||
scroller &&
|
||||
element.getBoundingClientRect().top < scroller.getBoundingClientRect().top
|
||||
) {
|
||||
scroller.scrollTop += delta;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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";
|
||||
};
|
||||
+11
-18
@@ -64,22 +64,15 @@ export const navigate = async (path: string, options?: NavigateOptions) => {
|
||||
const replace = options?.replace || false;
|
||||
|
||||
if (__DEMO__) {
|
||||
if (path.includes("#")) {
|
||||
if (replace) {
|
||||
mainWindow.history.replaceState(
|
||||
mainWindow.history.state?.root
|
||||
? { root: true }
|
||||
: (options?.data ?? null),
|
||||
"",
|
||||
path
|
||||
);
|
||||
} else {
|
||||
mainWindow.history.pushState(options?.data ?? null, "", path);
|
||||
}
|
||||
fireEvent(mainWindow, "location-changed", {
|
||||
replace,
|
||||
});
|
||||
return true;
|
||||
if (!path.includes("#")) {
|
||||
// The demo routes with the hash instead of the pathname. Resolve the
|
||||
// path like the browser would do for pushState, and keep the query
|
||||
// parameters in the URL query instead of inside the hash.
|
||||
const url = new URL(
|
||||
path,
|
||||
`${mainWindow.location.origin}${mainWindow.location.hash.substring(1)}`
|
||||
);
|
||||
path = `${mainWindow.location.pathname}${url.search}#${url.pathname}`;
|
||||
}
|
||||
if (replace) {
|
||||
mainWindow.history.replaceState(
|
||||
@@ -87,10 +80,10 @@ export const navigate = async (path: string, options?: NavigateOptions) => {
|
||||
? { root: true }
|
||||
: (options?.data ?? null),
|
||||
"",
|
||||
`${mainWindow.location.pathname}#${path}`
|
||||
path
|
||||
);
|
||||
} else {
|
||||
mainWindow.location.hash = path;
|
||||
mainWindow.history.pushState(options?.data ?? null, "", path);
|
||||
}
|
||||
} else if (replace) {
|
||||
mainWindow.history.replaceState(
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
`;
|
||||
|
||||
@@ -36,6 +36,10 @@ export function downSampleLineData<
|
||||
const min = minX ?? getPointData(data[0]!)[0];
|
||||
const max = maxX ?? getPointData(data[data.length - 1]!)[0];
|
||||
const step = Math.ceil((max - min) / Math.floor(maxDetails));
|
||||
if (!Number.isFinite(step) || step <= 0) {
|
||||
// a degenerate frame size would put every point in a single frame
|
||||
return data;
|
||||
}
|
||||
|
||||
if (useMean) {
|
||||
// Group points into frames, accumulating sums in insertion order.
|
||||
|
||||
@@ -51,6 +51,7 @@ export const MIN_TIME_BETWEEN_UPDATES = 60 * 5 * 1000;
|
||||
const LEGEND_OVERFLOW_LIMIT = 10;
|
||||
const LEGEND_OVERFLOW_LIMIT_MOBILE = 6;
|
||||
const DOUBLE_TAP_TIME = 300;
|
||||
const DEFAULT_CHART_WIDTH = 500;
|
||||
|
||||
type RawSeriesOption = Exclude<
|
||||
NonNullable<ECOption["series"]>,
|
||||
@@ -1048,7 +1049,9 @@ export class HaChartBase extends LitElement {
|
||||
sampling: undefined,
|
||||
data: downSampleLineData(
|
||||
data as LineSeriesOption["data"],
|
||||
this.clientWidth * window.devicePixelRatio,
|
||||
// 0 while inside a hidden container, e.g. a section with a visibility condition
|
||||
(this.clientWidth || DEFAULT_CHART_WIDTH) *
|
||||
window.devicePixelRatio,
|
||||
minX,
|
||||
maxX
|
||||
),
|
||||
|
||||
@@ -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"]) {
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
import { mdiDevices } from "@mdi/js";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import memoizeOne from "memoize-one";
|
||||
import { fireEvent } from "../../common/dom/fire_event";
|
||||
import { computeAreaName } from "../../common/entity/compute_area_name";
|
||||
import { computeDeviceName } from "../../common/entity/compute_device_name";
|
||||
import { getDeviceArea } from "../../common/entity/context/get_device_context";
|
||||
import type { HomeAssistant } from "../../types";
|
||||
import type { HassDialog } from "../../dialogs/make-dialog-manager";
|
||||
import "../ha-dialog";
|
||||
import "../ha-svg-icon";
|
||||
import "../item/ha-list-item-button";
|
||||
import "../list/ha-list-base";
|
||||
import type { DeviceReplacedDialogParams } from "./show-dialog-device-replaced";
|
||||
|
||||
@customElement("dialog-device-replaced")
|
||||
export class DialogDeviceReplaced
|
||||
extends LitElement
|
||||
implements HassDialog<DeviceReplacedDialogParams>
|
||||
{
|
||||
@state() private _params?: DeviceReplacedDialogParams;
|
||||
|
||||
@state() private _open = false;
|
||||
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
|
||||
public async showDialog(params: DeviceReplacedDialogParams): Promise<void> {
|
||||
this._params = params;
|
||||
this._open = true;
|
||||
}
|
||||
|
||||
public closeDialog(): boolean {
|
||||
this._open = false;
|
||||
return true;
|
||||
}
|
||||
|
||||
private _dialogClosed(): void {
|
||||
this._params = undefined;
|
||||
fireEvent(this, "dialog-closed", { dialog: this.localName });
|
||||
}
|
||||
|
||||
private _pick(ev: Event): void {
|
||||
const item = (ev.target as HTMLElement).closest("ha-list-item-button") as
|
||||
(HTMLElement & { deviceId?: string }) | null;
|
||||
if (!item?.deviceId) {
|
||||
return;
|
||||
}
|
||||
this._params?.onResolved(item.deviceId);
|
||||
this.closeDialog();
|
||||
}
|
||||
|
||||
private _items = memoizeOne(
|
||||
(
|
||||
candidates: string[],
|
||||
primaryId: string | null,
|
||||
devices: HomeAssistant["devices"],
|
||||
areas: HomeAssistant["areas"]
|
||||
) =>
|
||||
candidates.map((deviceId) => {
|
||||
const device = devices[deviceId];
|
||||
const area = device ? getDeviceArea(device, areas) : undefined;
|
||||
return {
|
||||
deviceId,
|
||||
name: device ? computeDeviceName(device) : deviceId,
|
||||
secondary: area ? computeAreaName(area) : undefined,
|
||||
isPrimary: deviceId === primaryId,
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
protected render() {
|
||||
if (!this._params || !this.hass) {
|
||||
return nothing;
|
||||
}
|
||||
|
||||
return html`
|
||||
<ha-dialog
|
||||
.open=${this._open}
|
||||
.headerTitle=${this.hass.localize(
|
||||
"ui.components.device-picker.replaced_dialog.title"
|
||||
)}
|
||||
@closed=${this._dialogClosed}
|
||||
>
|
||||
<p class="description">
|
||||
${this.hass.localize(
|
||||
"ui.components.device-picker.replaced_dialog.description"
|
||||
)}
|
||||
</p>
|
||||
<ha-list-base @click=${this._pick}>
|
||||
${this._items(
|
||||
this._params.candidates,
|
||||
this._params.primaryId,
|
||||
this.hass.devices,
|
||||
this.hass.areas
|
||||
).map(
|
||||
(item) => html`
|
||||
<ha-list-item-button .deviceId=${item.deviceId}>
|
||||
<ha-svg-icon slot="start" .path=${mdiDevices}></ha-svg-icon>
|
||||
<span slot="headline">${item.name}</span>
|
||||
${
|
||||
item.secondary || item.isPrimary
|
||||
? html`<span slot="supporting-text">
|
||||
${[
|
||||
item.secondary,
|
||||
item.isPrimary
|
||||
? this.hass.localize(
|
||||
"ui.components.device-picker.replaced_dialog.recommended"
|
||||
)
|
||||
: undefined,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" • ")}
|
||||
</span>`
|
||||
: nothing
|
||||
}
|
||||
</ha-list-item-button>
|
||||
`
|
||||
)}
|
||||
</ha-list-base>
|
||||
</ha-dialog>
|
||||
`;
|
||||
}
|
||||
|
||||
static styles = css`
|
||||
ha-dialog {
|
||||
--dialog-content-padding: 0;
|
||||
--ha-row-item-padding-inline: var(--ha-space-6);
|
||||
}
|
||||
.description {
|
||||
margin: 0;
|
||||
padding: 0 var(--ha-space-6) var(--ha-space-4);
|
||||
color: var(--secondary-text-color);
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"dialog-device-replaced": DialogDeviceReplaced;
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import { fullEntitiesContext } from "../../data/context";
|
||||
import type { DeviceAutomation } from "../../data/device/device_automation";
|
||||
import {
|
||||
deviceAutomationsEqual,
|
||||
deviceAutomationsSimilar,
|
||||
sortDeviceAutomations,
|
||||
} from "../../data/device/device_automation";
|
||||
import type { EntityRegistryEntry } from "../../data/entity/entity_registry";
|
||||
@@ -201,12 +202,22 @@ export abstract class HaDeviceAutomationPicker<
|
||||
: // No device, clear the list of automations
|
||||
[];
|
||||
|
||||
// If there is no value, or if we have changed the device ID, reset the value.
|
||||
// If there is no value, or if we have changed the device ID, reset the
|
||||
// value. When the device changed (for example after replacing a removed
|
||||
// device), try to keep the same automation type/subtype on the new device
|
||||
// before falling back to the first available automation.
|
||||
if (!this.value || this.value.device_id !== this.deviceId) {
|
||||
const equivalent =
|
||||
this.value && this.deviceId
|
||||
? this._automations.find((automation) =>
|
||||
deviceAutomationsSimilar(automation, this.value!)
|
||||
)
|
||||
: undefined;
|
||||
this._setValue(
|
||||
this._automations.length
|
||||
? this._automations[0]
|
||||
: this._createNoAutomation(this.deviceId)
|
||||
equivalent ||
|
||||
(this._automations.length
|
||||
? this._automations[0]
|
||||
: this._createNoAutomation(this.deviceId))
|
||||
);
|
||||
}
|
||||
this._renderEmpty = true;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { mdiAlertOutline } from "@mdi/js";
|
||||
import type { RenderItemFunction } from "@lit-labs/virtualizer/virtualize";
|
||||
import type { HassEntity } from "home-assistant-js-websocket";
|
||||
import { html, LitElement, nothing, type PropertyValues } from "lit";
|
||||
import { css, html, LitElement, nothing, type PropertyValues } from "lit";
|
||||
import { customElement, property, query, state } from "lit/decorators";
|
||||
import memoizeOne from "memoize-one";
|
||||
import { fireEvent } from "../../common/dom/fire_event";
|
||||
@@ -13,12 +14,20 @@ import {
|
||||
getDevices,
|
||||
type DevicePickerItem,
|
||||
} from "../../data/device/device_picker";
|
||||
import type { DeviceRegistryEntry } from "../../data/device/device_registry";
|
||||
import {
|
||||
fetchDeviceCompositeSplits,
|
||||
type DeviceCompositeSplits,
|
||||
type DeviceRegistryEntry,
|
||||
} from "../../data/device/device_registry";
|
||||
import type { HaEntityPickerEntityFilterFunc } from "../../data/entity/entity";
|
||||
import type { HomeAssistant } from "../../types";
|
||||
import { brandsUrl } from "../../util/brands-url";
|
||||
import "../ha-alert";
|
||||
import "../ha-button";
|
||||
import "../ha-generic-picker";
|
||||
import type { HaGenericPicker } from "../ha-generic-picker";
|
||||
import "../ha-svg-icon";
|
||||
import { showDeviceReplacedDialog } from "./show-dialog-device-replaced";
|
||||
|
||||
export type HaDevicePickerDeviceFilterFunc = (
|
||||
device: DeviceRegistryEntry
|
||||
@@ -95,6 +104,10 @@ export class HaDevicePicker extends LitElement {
|
||||
|
||||
@state() private _configEntryLookup: Record<string, ConfigEntry> = {};
|
||||
|
||||
@state() private _compositeSplits?: DeviceCompositeSplits;
|
||||
|
||||
private _loadingCompositeSplits = false;
|
||||
|
||||
private _getDevicesMemoized = memoizeOne(
|
||||
(
|
||||
_devices: HomeAssistant["devices"],
|
||||
@@ -123,6 +136,29 @@ export class HaDevicePicker extends LitElement {
|
||||
this._loadConfigEntries();
|
||||
}
|
||||
|
||||
protected willUpdate(changedProperties: PropertyValues<this>): void {
|
||||
if (
|
||||
!this.hass ||
|
||||
!this.value ||
|
||||
this._compositeSplits !== undefined ||
|
||||
this._loadingCompositeSplits
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const oldHass = changedProperties.get("hass") as HomeAssistant | undefined;
|
||||
const devicesChanged =
|
||||
changedProperties.has("hass") && this.hass.devices !== oldHass?.devices;
|
||||
if (
|
||||
(changedProperties.has("value") || devicesChanged) &&
|
||||
!this.hass.devices[this.value]
|
||||
) {
|
||||
// The selected device is not in the registry; it might be a legacy
|
||||
// composite device that was split into separate devices. Fetch the
|
||||
// split map so we can offer to replace the reference.
|
||||
this._loadCompositeSplits();
|
||||
}
|
||||
}
|
||||
|
||||
private async _loadConfigEntries() {
|
||||
const configEntries = await getConfigEntries(this.hass);
|
||||
this._configEntryLookup = Object.fromEntries(
|
||||
@@ -130,6 +166,43 @@ export class HaDevicePicker extends LitElement {
|
||||
);
|
||||
}
|
||||
|
||||
private async _loadCompositeSplits() {
|
||||
this._loadingCompositeSplits = true;
|
||||
try {
|
||||
this._compositeSplits = await fetchDeviceCompositeSplits(this.hass);
|
||||
} catch (_err) {
|
||||
this._compositeSplits = {};
|
||||
} finally {
|
||||
this._loadingCompositeSplits = false;
|
||||
}
|
||||
}
|
||||
|
||||
private _getReplacement = memoizeOne(
|
||||
(
|
||||
value: string | undefined,
|
||||
_devices: HomeAssistant["devices"],
|
||||
compositeSplits: DeviceCompositeSplits | undefined,
|
||||
items: (DevicePickerItem | string)[]
|
||||
) => {
|
||||
if (!value || !compositeSplits || this.hass.devices[value]) {
|
||||
return undefined;
|
||||
}
|
||||
const split = compositeSplits[value];
|
||||
if (!split) {
|
||||
return undefined;
|
||||
}
|
||||
// Keep only the split devices that pass this picker's filters. In
|
||||
// practice usually exactly one of the split devices matches.
|
||||
const selectableIds = new Set(
|
||||
items
|
||||
.filter((item): item is DevicePickerItem => typeof item !== "string")
|
||||
.map((item) => item.id)
|
||||
);
|
||||
const candidates = split.split_ids.filter((id) => selectableIds.has(id));
|
||||
return { candidates, primaryId: split.primary_id };
|
||||
}
|
||||
);
|
||||
|
||||
private _getItems = () =>
|
||||
this._getDevicesMemoized(
|
||||
this.hass.devices,
|
||||
@@ -144,49 +217,66 @@ export class HaDevicePicker extends LitElement {
|
||||
);
|
||||
|
||||
private _valueRenderer = memoizeOne(
|
||||
(configEntriesLookup: Record<string, ConfigEntry>) => (value: string) => {
|
||||
const deviceId = value;
|
||||
const device = this.hass.devices[deviceId];
|
||||
(
|
||||
configEntriesLookup: Record<string, ConfigEntry>,
|
||||
replacementName: string | undefined
|
||||
) =>
|
||||
(value: string) => {
|
||||
const deviceId = value;
|
||||
const device = this.hass.devices[deviceId];
|
||||
|
||||
if (!device) {
|
||||
return html`<span slot="headline">${deviceId}</span>`;
|
||||
}
|
||||
|
||||
const area = getDeviceArea(device, this.hass.areas);
|
||||
|
||||
const deviceName = device ? computeDeviceName(device) : undefined;
|
||||
const areaName = area ? computeAreaName(area) : undefined;
|
||||
|
||||
const primary = deviceName;
|
||||
const secondary = areaName;
|
||||
|
||||
const configEntry = device.primary_config_entry
|
||||
? configEntriesLookup[device.primary_config_entry]
|
||||
: undefined;
|
||||
|
||||
return html`
|
||||
${
|
||||
configEntry
|
||||
? html`<img
|
||||
if (!device) {
|
||||
// When the device was replaced and a replacement is available, show
|
||||
// the replacement device's name. Otherwise fall back to the normal
|
||||
// "not found" display of the raw id.
|
||||
if (replacementName) {
|
||||
return html`
|
||||
<ha-svg-icon
|
||||
slot="start"
|
||||
alt=""
|
||||
crossorigin="anonymous"
|
||||
referrerpolicy="no-referrer"
|
||||
src=${brandsUrl(
|
||||
{
|
||||
domain: configEntry.domain,
|
||||
type: "icon",
|
||||
darkOptimized: this.hass.themes?.darkMode,
|
||||
},
|
||||
this.hass.auth.data.hassUrl
|
||||
)}
|
||||
/>`
|
||||
: nothing
|
||||
style="color: var(--warning-color)"
|
||||
.path=${mdiAlertOutline}
|
||||
></ha-svg-icon>
|
||||
<span slot="headline">${replacementName}</span>
|
||||
`;
|
||||
}
|
||||
return html`<span slot="headline">${deviceId}</span>`;
|
||||
}
|
||||
<span slot="headline">${primary}</span>
|
||||
<span slot="supporting-text">${secondary}</span>
|
||||
`;
|
||||
}
|
||||
|
||||
const area = getDeviceArea(device, this.hass.areas);
|
||||
|
||||
const deviceName = device ? computeDeviceName(device) : undefined;
|
||||
const areaName = area ? computeAreaName(area) : undefined;
|
||||
|
||||
const primary = deviceName;
|
||||
const secondary = areaName;
|
||||
|
||||
const configEntry = device.primary_config_entry
|
||||
? configEntriesLookup[device.primary_config_entry]
|
||||
: undefined;
|
||||
|
||||
return html`
|
||||
${
|
||||
configEntry
|
||||
? html`<img
|
||||
slot="start"
|
||||
alt=""
|
||||
crossorigin="anonymous"
|
||||
referrerpolicy="no-referrer"
|
||||
src=${brandsUrl(
|
||||
{
|
||||
domain: configEntry.domain,
|
||||
type: "icon",
|
||||
darkOptimized: this.hass.themes?.darkMode,
|
||||
},
|
||||
this.hass.auth.data.hassUrl
|
||||
)}
|
||||
/>`
|
||||
: nothing
|
||||
}
|
||||
<span slot="headline">${primary}</span>
|
||||
<span slot="supporting-text">${secondary}</span>
|
||||
`;
|
||||
}
|
||||
);
|
||||
|
||||
private _rowRenderer: RenderItemFunction<DevicePickerItem> = (item) => html`
|
||||
@@ -235,7 +325,39 @@ export class HaDevicePicker extends LitElement {
|
||||
this.placeholder ??
|
||||
this.hass.localize("ui.components.device-picker.placeholder");
|
||||
|
||||
const valueRenderer = this._valueRenderer(this._configEntryLookup);
|
||||
// Only resolve a replacement (which needs the full item list) when the
|
||||
// value is a missing device that we know was replaced, to avoid computing
|
||||
// the item list on every render for the common case.
|
||||
const replacement =
|
||||
this.value &&
|
||||
!this.hass.devices[this.value] &&
|
||||
this._compositeSplits?.[this.value]
|
||||
? this._getReplacement(
|
||||
this.value,
|
||||
this.hass.devices,
|
||||
this._compositeSplits,
|
||||
this._getItems()
|
||||
)
|
||||
: undefined;
|
||||
|
||||
// Only treat the value as "replaced" when there is an available
|
||||
// replacement device; otherwise fall back to normal "not found" behavior.
|
||||
const canReplace = !!replacement?.candidates.length;
|
||||
const replacementName = canReplace
|
||||
? computeDeviceName(
|
||||
this.hass.devices[
|
||||
replacement!.primaryId &&
|
||||
replacement!.candidates.includes(replacement!.primaryId)
|
||||
? replacement!.primaryId
|
||||
: replacement!.candidates[0]
|
||||
]
|
||||
)
|
||||
: undefined;
|
||||
|
||||
const valueRenderer = this._valueRenderer(
|
||||
this._configEntryLookup,
|
||||
replacementName
|
||||
);
|
||||
|
||||
return html`
|
||||
<ha-generic-picker
|
||||
@@ -256,15 +378,84 @@ export class HaDevicePicker extends LitElement {
|
||||
.hideClearIcon=${this.hideClearIcon}
|
||||
.valueRenderer=${valueRenderer}
|
||||
.searchKeys=${deviceComboBoxKeys}
|
||||
.unknownItemText=${this.hass.localize(
|
||||
"ui.components.device-picker.unknown"
|
||||
)}
|
||||
.unknownItemText=${
|
||||
replacement?.candidates.length
|
||||
? this.hass.localize(
|
||||
"ui.components.device-picker.device_replaced_count",
|
||||
{ count: replacement.candidates.length }
|
||||
)
|
||||
: this.hass.localize("ui.components.device-picker.unknown")
|
||||
}
|
||||
@value-changed=${this._valueChanged}
|
||||
>
|
||||
</ha-generic-picker>
|
||||
${canReplace ? this._renderReplacedAlert(replacement!) : nothing}
|
||||
`;
|
||||
}
|
||||
|
||||
private _renderReplacedAlert(replacement: {
|
||||
candidates: string[];
|
||||
primaryId: string | null;
|
||||
}) {
|
||||
const { candidates } = replacement;
|
||||
|
||||
const replacementName =
|
||||
candidates.length === 1
|
||||
? computeDeviceName(this.hass.devices[candidates[0]])
|
||||
: undefined;
|
||||
|
||||
return html`
|
||||
<ha-alert alert-type="warning">
|
||||
${
|
||||
replacementName
|
||||
? this.hass.localize(
|
||||
"ui.components.device-picker.device_replaced_by_one",
|
||||
{ device: replacementName }
|
||||
)
|
||||
: this.hass.localize(
|
||||
"ui.components.device-picker.device_replaced_by_multiple",
|
||||
{ count: candidates.length }
|
||||
)
|
||||
}
|
||||
<ha-button
|
||||
slot="action"
|
||||
appearance="plain"
|
||||
@click=${this._handleReplace}
|
||||
>
|
||||
${this.hass.localize("ui.components.device-picker.replace_device")}
|
||||
</ha-button>
|
||||
</ha-alert>
|
||||
`;
|
||||
}
|
||||
|
||||
private _handleReplace = () => {
|
||||
const replacement = this._getReplacement(
|
||||
this.value,
|
||||
this.hass.devices,
|
||||
this._compositeSplits,
|
||||
this._getItems()
|
||||
);
|
||||
if (!replacement?.candidates.length) {
|
||||
return;
|
||||
}
|
||||
const { candidates, primaryId } = replacement;
|
||||
if (candidates.length === 1) {
|
||||
this._setValue(candidates[0]);
|
||||
return;
|
||||
}
|
||||
showDeviceReplacedDialog(this, {
|
||||
originalDeviceId: this.value!,
|
||||
candidates,
|
||||
primaryId,
|
||||
onResolved: (deviceId) => this._setValue(deviceId),
|
||||
});
|
||||
};
|
||||
|
||||
private _setValue(value: string) {
|
||||
this.value = value;
|
||||
fireEvent(this, "value-changed", { value });
|
||||
}
|
||||
|
||||
public async open() {
|
||||
await this.updateComplete;
|
||||
await this._picker?.open();
|
||||
@@ -281,6 +472,13 @@ export class HaDevicePicker extends LitElement {
|
||||
this.hass.localize("ui.components.device-picker.no_match", {
|
||||
term: html`<b>‘${search}’</b>`,
|
||||
});
|
||||
|
||||
static styles = css`
|
||||
ha-alert {
|
||||
display: block;
|
||||
margin-top: 8px;
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
declare global {
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import { fireEvent } from "../../common/dom/fire_event";
|
||||
|
||||
export interface DeviceReplacedDialogParams {
|
||||
/** The removed composite device that is being referenced. */
|
||||
originalDeviceId: string;
|
||||
/** The split devices the reference can be pointed at. */
|
||||
candidates: string[];
|
||||
/** The split device that took over the composite's primary config entry. */
|
||||
primaryId: string | null;
|
||||
/** Called with the device the user picked as replacement. */
|
||||
onResolved: (deviceId: string) => void;
|
||||
}
|
||||
|
||||
export const showDeviceReplacedDialog = (
|
||||
element: HTMLElement,
|
||||
params: DeviceReplacedDialogParams
|
||||
) =>
|
||||
fireEvent(element, "show-dialog", {
|
||||
dialogTag: "dialog-device-replaced",
|
||||
dialogImport: () => import("./dialog-device-replaced"),
|
||||
dialogParams: params,
|
||||
});
|
||||
+37
-10
@@ -1,6 +1,7 @@
|
||||
import { ResizeController } from "@lit-labs/observers/resize-controller";
|
||||
import type { PropertyValues } from "lit";
|
||||
import { css, LitElement, svg } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import { customElement, property, query, state } from "lit/decorators";
|
||||
import { styleMap } from "lit/directives/style-map";
|
||||
import { formatNumber } from "../common/number/format_number";
|
||||
import { blankBeforePercent } from "../common/translations/blank_before_percent";
|
||||
@@ -46,15 +47,32 @@ export class HaGauge extends LitElement {
|
||||
|
||||
@state() private _segment_label?: string = "";
|
||||
|
||||
@query(".text") private _textSvg?: SVGSVGElement;
|
||||
|
||||
@query(".value-text") private _valueText?: SVGTextElement;
|
||||
|
||||
private _sortedLevels?: LevelDefinition[];
|
||||
|
||||
private _rescaleOnConnect = false;
|
||||
// Set when the value text could not be measured because we have no layout box
|
||||
// yet, either disconnected or inside a hidden container.
|
||||
private _rescalePending = false;
|
||||
|
||||
// Measure again once we get a layout box, e.g. when a section hidden by a
|
||||
// visibility condition is revealed. Nothing else re-renders the gauge then.
|
||||
// @ts-ignore side-effect-only controller, its value is never read
|
||||
private _resizeController = new ResizeController(this, {
|
||||
skipInitial: true,
|
||||
callback: (entries) => {
|
||||
if (this._rescalePending && entries[0]?.contentRect.width) {
|
||||
this._rescaleSvg();
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
public connectedCallback(): void {
|
||||
super.connectedCallback();
|
||||
if (this._rescaleOnConnect) {
|
||||
if (this._rescalePending && this.hasUpdated) {
|
||||
this._rescaleSvg();
|
||||
this._rescaleOnConnect = false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -221,15 +239,22 @@ export class HaGauge extends LitElement {
|
||||
// fit the text
|
||||
// That way it will auto-scale correctly
|
||||
|
||||
if (!this.isConnected) {
|
||||
// Retry this later if we're disconnected, otherwise we get a 0 bbox and missing label
|
||||
this._rescaleOnConnect = true;
|
||||
if (!this._textSvg || !this._valueText || !this.isConnected) {
|
||||
this._rescalePending = true;
|
||||
return;
|
||||
}
|
||||
|
||||
const svgRoot = this.shadowRoot!.querySelector(".text")!;
|
||||
const box = svgRoot.querySelector("text")!.getBBox()!;
|
||||
svgRoot.setAttribute(
|
||||
const box = this._valueText.getBBox();
|
||||
|
||||
// An empty box means we have no layout, so keep the last known good viewBox
|
||||
// and retry later. A viewBox with a 0 width or height would hide the label.
|
||||
if (!box.width || !box.height) {
|
||||
this._rescalePending = true;
|
||||
return;
|
||||
}
|
||||
|
||||
this._rescalePending = false;
|
||||
this._textSvg.setAttribute(
|
||||
"viewBox",
|
||||
`${box.x} ${box.y} ${box.width} ${box.height}`
|
||||
);
|
||||
@@ -248,6 +273,8 @@ export class HaGauge extends LitElement {
|
||||
|
||||
static styles = css`
|
||||
:host {
|
||||
/* a non replaced inline element never reports a size to a resize observer */
|
||||
display: block;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -141,7 +141,7 @@ export class HaChooseSelector extends LitElement {
|
||||
}
|
||||
|
||||
private _value(choice?: string): any {
|
||||
if (!this.value) {
|
||||
if (this.value === null || this.value === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
return typeof this.value === "object"
|
||||
|
||||
@@ -542,20 +542,14 @@ export class HaServiceControl extends LitElement {
|
||||
}
|
||||
${
|
||||
serviceData && "target" in serviceData
|
||||
? html`<ha-settings-row
|
||||
.narrow=${this.narrow || isFullWidthSelector(targetSelector)}
|
||||
>
|
||||
<span slot="heading"
|
||||
>${this.hass.localize("ui.components.service-control.target")}</span
|
||||
>
|
||||
<ha-selector
|
||||
.hass=${this.hass}
|
||||
.selector=${targetSelector}
|
||||
.disabled=${this.disabled}
|
||||
@value-changed=${this._targetChanged}
|
||||
.value=${this._value?.target}
|
||||
></ha-selector
|
||||
></ha-settings-row>`
|
||||
? html`<ha-selector
|
||||
class="target-selector"
|
||||
.hass=${this.hass}
|
||||
.selector=${targetSelector}
|
||||
.disabled=${this.disabled}
|
||||
@value-changed=${this._targetChanged}
|
||||
.value=${this._value?.target}
|
||||
></ha-selector>`
|
||||
: entityId
|
||||
? html`<ha-entity-picker
|
||||
.disabled=${this.disabled}
|
||||
@@ -1059,6 +1053,14 @@ export class HaServiceControl extends LitElement {
|
||||
display: block;
|
||||
margin: var(--service-control-padding, 0 var(--ha-space-4));
|
||||
}
|
||||
ha-selector.target-selector {
|
||||
display: block;
|
||||
padding: var(--ha-space-2) var(--ha-space-4);
|
||||
border-top: var(
|
||||
--service-control-items-border-top,
|
||||
1px solid var(--divider-color)
|
||||
);
|
||||
}
|
||||
ha-yaml-editor {
|
||||
padding: var(--ha-space-4) 0;
|
||||
}
|
||||
|
||||
@@ -27,6 +27,10 @@ import {
|
||||
getDevices,
|
||||
type DevicePickerItem,
|
||||
} from "../data/device/device_picker";
|
||||
import {
|
||||
fetchDeviceCompositeSplits,
|
||||
type DeviceCompositeSplits,
|
||||
} from "../data/device/device_registry";
|
||||
import type { HaEntityPickerEntityFilterFunc } from "../data/entity/entity";
|
||||
import {
|
||||
entityComboBoxKeys,
|
||||
@@ -122,6 +126,10 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
|
||||
|
||||
@state() private _configEntryLookup: Record<string, ConfigEntry> = {};
|
||||
|
||||
@state() private _compositeSplits?: DeviceCompositeSplits;
|
||||
|
||||
private _loadingCompositeSplits = false;
|
||||
|
||||
@state()
|
||||
@consume({ context: labelsContext, subscribe: true })
|
||||
private _labelRegistry!: LabelRegistryEntry[];
|
||||
@@ -211,6 +219,24 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
|
||||
this._loadConfigEntries();
|
||||
}
|
||||
|
||||
const devicesChanged =
|
||||
changedProps.has("hass") &&
|
||||
this.hass.devices !== changedProps.get("hass")?.devices;
|
||||
if (
|
||||
(changedProps.has("value") || devicesChanged) &&
|
||||
this.hass &&
|
||||
this._compositeSplits === undefined &&
|
||||
!this._loadingCompositeSplits &&
|
||||
this.value?.device_id &&
|
||||
ensureArray(this.value.device_id).some(
|
||||
(deviceId) => !this.hass.devices[deviceId]
|
||||
)
|
||||
) {
|
||||
// A referenced device is missing from the registry; it might be a legacy
|
||||
// composite device that was split. Fetch the split map to offer a fix.
|
||||
this._loadCompositeSplits();
|
||||
}
|
||||
|
||||
if (
|
||||
this._pendingEntityId &&
|
||||
changedProps.has("hass") &&
|
||||
@@ -297,6 +323,7 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
|
||||
.hass=${this.hass}
|
||||
type="device"
|
||||
.itemId=${device_id}
|
||||
.compositeSplits=${this._compositeSplits}
|
||||
@remove-target-item=${this._handleRemove}
|
||||
@expand-target-item=${this._handleExpand}
|
||||
></ha-target-picker-value-chip>
|
||||
@@ -390,6 +417,7 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
|
||||
<ha-target-picker-item-group
|
||||
@remove-target-item=${this._handleRemove}
|
||||
@replace-target-item=${this._handleReplace}
|
||||
@migrate-target-item=${this._handleMigrate}
|
||||
type="device"
|
||||
.hass=${this.hass}
|
||||
.items=${{ device: deviceIds }}
|
||||
@@ -398,6 +426,7 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
|
||||
.includeDomains=${this.includeDomains}
|
||||
.includeDeviceClasses=${this.includeDeviceClasses}
|
||||
.primaryEntitiesOnly=${this.primaryEntitiesOnly}
|
||||
.compositeSplits=${this._compositeSplits}
|
||||
>
|
||||
</ha-target-picker-item-group>
|
||||
`
|
||||
@@ -1175,6 +1204,31 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
|
||||
);
|
||||
}
|
||||
|
||||
private async _loadCompositeSplits() {
|
||||
this._loadingCompositeSplits = true;
|
||||
try {
|
||||
this._compositeSplits = await fetchDeviceCompositeSplits(this.hass);
|
||||
} catch (_err) {
|
||||
this._compositeSplits = {};
|
||||
} finally {
|
||||
this._loadingCompositeSplits = false;
|
||||
}
|
||||
}
|
||||
|
||||
private _handleMigrate(
|
||||
ev: HASSDomEvent<HASSDomEvents["migrate-target-item"]>
|
||||
) {
|
||||
const { id, replacements } = ev.detail;
|
||||
let value = this._removeItem(this.value, "device", id);
|
||||
for (const replacement of replacements) {
|
||||
value = this._addTargetToValue(value, {
|
||||
type: "device",
|
||||
id: replacement,
|
||||
});
|
||||
}
|
||||
fireEvent(this, "value-changed", { value });
|
||||
}
|
||||
|
||||
private _renderRow = (
|
||||
item:
|
||||
| PickerComboBoxItem
|
||||
@@ -1342,7 +1396,7 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
|
||||
}
|
||||
.item-groups {
|
||||
overflow: hidden;
|
||||
border: 2px solid var(--divider-color);
|
||||
border: var(--ha-border-width-sm) solid var(--divider-color);
|
||||
border-radius: var(--ha-border-radius-lg);
|
||||
}
|
||||
`;
|
||||
@@ -1357,6 +1411,7 @@ declare global {
|
||||
"remove-target-item": TargetItem;
|
||||
"expand-target-item": TargetItem;
|
||||
"replace-target-item": TargetItem;
|
||||
"migrate-target-item": { id: string; replacements: string[] };
|
||||
"remove-target-group": string;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,6 +24,8 @@ export interface ToastClosedEventDetail {
|
||||
export class HaToast extends LitElement {
|
||||
@property({ attribute: "label-text" }) public labelText = "";
|
||||
|
||||
@property({ attribute: "announce-text" }) public announceText?: string;
|
||||
|
||||
@property({ type: Number, attribute: "timeout-ms" }) public timeoutMs = 4000;
|
||||
|
||||
@property({ type: Number, attribute: "bottom-offset" }) public bottomOffset =
|
||||
@@ -190,8 +192,6 @@ export class HaToast extends LitElement {
|
||||
style=${styleMap({
|
||||
"--ha-toast-bottom-offset": `${this.bottomOffset}px`,
|
||||
})}
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
popover=${ifDefined(popoverSupported ? "manual" : undefined)}
|
||||
>
|
||||
<span class="message">${this.labelText}</span>
|
||||
@@ -200,6 +200,14 @@ export class HaToast extends LitElement {
|
||||
<slot name="dismiss"></slot>
|
||||
</div>
|
||||
</div>
|
||||
<span
|
||||
class="assistive-message"
|
||||
role="status"
|
||||
aria-live=${this._active ? "polite" : "off"}
|
||||
aria-atomic="true"
|
||||
>
|
||||
${this.announceText ?? this.labelText}
|
||||
</span>
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -254,6 +262,18 @@ export class HaToast extends LitElement {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.assistive-message {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
white-space: nowrap;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
import type { LitVirtualizer } from "@lit-labs/virtualizer";
|
||||
import { grid } from "@lit-labs/virtualizer/layouts/grid";
|
||||
|
||||
import { mdiArrowUpRight, mdiKeyboard, mdiPlay, mdiPlus } from "@mdi/js";
|
||||
import {
|
||||
mdiArrowUpRight,
|
||||
mdiFilterVariant,
|
||||
mdiKeyboard,
|
||||
mdiPlay,
|
||||
mdiPlus,
|
||||
} from "@mdi/js";
|
||||
import type { CSSResultGroup, PropertyValues, TemplateResult } from "lit";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import {
|
||||
@@ -14,6 +20,7 @@ import {
|
||||
import { classMap } from "lit/directives/class-map";
|
||||
import { styleMap } from "lit/directives/style-map";
|
||||
import { fireEvent } from "../../common/dom/fire_event";
|
||||
import { caseInsensitiveStringCompare } from "../../common/string/compare";
|
||||
import { slugify } from "../../common/string/slugify";
|
||||
import { debounce } from "../../common/util/debounce";
|
||||
import { UNAVAILABLE } from "../../data/entity/entity";
|
||||
@@ -32,6 +39,7 @@ import {
|
||||
browseLocalMediaPlayer,
|
||||
isManualMediaSourceContentId,
|
||||
MANUAL_MEDIA_SOURCE_PREFIX,
|
||||
searchMedia,
|
||||
} from "../../data/media_source";
|
||||
import { isTTSMediaSource } from "../../data/tts";
|
||||
import { showAlertDialog } from "../../dialogs/generic/show-dialog-box";
|
||||
@@ -43,9 +51,14 @@ import "../entity/ha-entity-picker";
|
||||
import "../ha-alert";
|
||||
import "../ha-button";
|
||||
import "../ha-card";
|
||||
import "../ha-dropdown";
|
||||
import type { HaDropdownSelectEvent } from "../ha-dropdown";
|
||||
import "../ha-dropdown-item";
|
||||
import "../ha-icon-button";
|
||||
import "../ha-list";
|
||||
import "../ha-list-item";
|
||||
import "../input/ha-input-search";
|
||||
import type { HaInputSearch } from "../input/ha-input-search";
|
||||
import "./ha-media-browser-thumbnail";
|
||||
import "../ha-spinner";
|
||||
import "../ha-svg-icon";
|
||||
@@ -74,6 +87,8 @@ export interface MediaPlayerItemId {
|
||||
media_content_type?: string | undefined;
|
||||
}
|
||||
|
||||
type MediaClass = MediaPlayerItem["media_class"];
|
||||
|
||||
const MANUAL_ITEM_BASE: Omit<MediaPlayerItem, "title"> = {
|
||||
can_expand: true,
|
||||
can_play: false,
|
||||
@@ -122,6 +137,16 @@ export class HaMediaPlayerBrowse extends LitElement {
|
||||
|
||||
@state() private _currentItem?: MediaPlayerItem;
|
||||
|
||||
@state() private _searchQuery = "";
|
||||
|
||||
@state() private _searchResults?: MediaPlayerItem[];
|
||||
|
||||
@state() private _searching = false;
|
||||
|
||||
@state() private _mediaClassFilter: MediaClass[] = [];
|
||||
|
||||
private _searchRequestId = 0;
|
||||
|
||||
@query(".header") private _header?: HTMLDivElement;
|
||||
|
||||
@query(".content") private _content?: HTMLDivElement;
|
||||
@@ -197,6 +222,10 @@ export class HaMediaPlayerBrowse extends LitElement {
|
||||
const oldParentItem = this._parentItem;
|
||||
this._currentItem = undefined;
|
||||
this._parentItem = undefined;
|
||||
this._abortSearch();
|
||||
this._searchQuery = "";
|
||||
this._searchResults = undefined;
|
||||
this._mediaClassFilter = [];
|
||||
const currentId = navigateIds[navigateIds.length - 1];
|
||||
const parentId =
|
||||
navigateIds.length > 1 ? navigateIds[navigateIds.length - 2] : undefined;
|
||||
@@ -328,11 +357,14 @@ export class HaMediaPlayerBrowse extends LitElement {
|
||||
protected updated(changedProps: PropertyValues): void {
|
||||
super.updated(changedProps);
|
||||
|
||||
if (changedProps.has("_scrolled")) {
|
||||
if (changedProps.has("_scrolled") || changedProps.has("_currentItem")) {
|
||||
// Re-measure across frames rather than once: the search input sizes
|
||||
// asynchronously, so a single measurement can be too small and the
|
||||
// content would clip under the header.
|
||||
this._animateHeaderHeight();
|
||||
} else if (changedProps.has("_currentItem")) {
|
||||
this._setHeaderHeight();
|
||||
}
|
||||
|
||||
if (changedProps.has("_currentItem")) {
|
||||
// This fixes a race condition for resizing of the cards using the grid layout
|
||||
if (this._observed) {
|
||||
return;
|
||||
@@ -365,10 +397,15 @@ export class HaMediaPlayerBrowse extends LitElement {
|
||||
|
||||
const currentItem = this._currentItem;
|
||||
|
||||
const isSearching = this._searchResults !== undefined;
|
||||
|
||||
const subtitle = this.hass.localize(
|
||||
`ui.components.media-browser.class.${currentItem.media_class}`
|
||||
);
|
||||
let children = currentItem.children || [];
|
||||
let children = isSearching
|
||||
? this._searchResults!
|
||||
: currentItem.children || [];
|
||||
const notShown = isSearching ? 0 : currentItem.not_shown || 0;
|
||||
const canPlayChildren = new Set<string>();
|
||||
|
||||
// Filter children based on accept property if provided
|
||||
@@ -399,6 +436,26 @@ export class HaMediaPlayerBrowse extends LitElement {
|
||||
});
|
||||
}
|
||||
|
||||
// Search is available on non-root pages that opt in via can_search, aside
|
||||
// from the manual-entry and TTS pseudo-sources.
|
||||
const showSearch =
|
||||
this.navigateIds.length > 1 &&
|
||||
!isManualMediaSourceContentId(currentItem.media_content_id) &&
|
||||
!isTTSMediaSource(currentItem.media_content_id) &&
|
||||
currentItem.can_search;
|
||||
// The backend reports which media classes are worth filtering by for this
|
||||
// item; without them we still allow searching, just without the filter.
|
||||
const mediaClassFilterOptions =
|
||||
showSearch && currentItem.search_media_classes
|
||||
? [...currentItem.search_media_classes].sort((a, b) =>
|
||||
caseInsensitiveStringCompare(
|
||||
this._localizeMediaClass(a),
|
||||
this._localizeMediaClass(b),
|
||||
this.hass.locale.language
|
||||
)
|
||||
)
|
||||
: [];
|
||||
|
||||
const mediaClass = MediaClassBrowserSettings[currentItem.media_class];
|
||||
const childrenMediaClass = currentItem.children_media_class
|
||||
? MediaClassBrowserSettings[currentItem.children_media_class]
|
||||
@@ -406,94 +463,109 @@ export class HaMediaPlayerBrowse extends LitElement {
|
||||
|
||||
return html`
|
||||
${
|
||||
currentItem.can_play
|
||||
currentItem.can_play || showSearch
|
||||
? html`
|
||||
<div
|
||||
class="header ${classMap({
|
||||
"no-img": !currentItem.thumbnail,
|
||||
"no-dialog": !this.dialog,
|
||||
"search-only": !currentItem.can_play,
|
||||
})}"
|
||||
@transitionend=${this._setHeaderHeight}
|
||||
>
|
||||
<div class="header-content">
|
||||
${
|
||||
currentItem.thumbnail
|
||||
? html`
|
||||
<div class="img">
|
||||
<ha-media-browser-thumbnail
|
||||
.hass=${this.hass}
|
||||
.url=${currentItem.thumbnail}
|
||||
></ha-media-browser-thumbnail>
|
||||
${
|
||||
showSearch
|
||||
? this._renderSearchRow(
|
||||
currentItem,
|
||||
mediaClassFilterOptions
|
||||
)
|
||||
: nothing
|
||||
}
|
||||
${
|
||||
currentItem.can_play
|
||||
? html`<div class="header-content">
|
||||
${
|
||||
currentItem.thumbnail
|
||||
? html`
|
||||
<div class="img">
|
||||
<ha-media-browser-thumbnail
|
||||
.hass=${this.hass}
|
||||
.url=${currentItem.thumbnail}
|
||||
></ha-media-browser-thumbnail>
|
||||
${
|
||||
this.narrow &&
|
||||
currentItem?.can_play &&
|
||||
(!this.accept ||
|
||||
canPlayChildren.has(
|
||||
currentItem.media_content_id
|
||||
))
|
||||
? html`
|
||||
<ha-button
|
||||
class="fab"
|
||||
.item=${currentItem}
|
||||
@click=${this._actionClicked}
|
||||
.title=${this.hass.localize(
|
||||
`ui.components.media-browser.${this.action}`
|
||||
)}
|
||||
>
|
||||
<ha-svg-icon
|
||||
.path=${
|
||||
this.action === "play"
|
||||
? mdiPlay
|
||||
: mdiPlus
|
||||
}
|
||||
></ha-svg-icon>
|
||||
</ha-button>
|
||||
`
|
||||
: ""
|
||||
}
|
||||
</div>
|
||||
`
|
||||
: nothing
|
||||
}
|
||||
<div class="header-info">
|
||||
<div class="breadcrumb">
|
||||
<h1 class="title">${currentItem.title}</h1>
|
||||
${
|
||||
this.narrow &&
|
||||
currentItem?.can_play &&
|
||||
(!this.accept ||
|
||||
canPlayChildren.has(
|
||||
currentItem.media_content_id
|
||||
))
|
||||
subtitle
|
||||
? html`
|
||||
<ha-button
|
||||
class="fab"
|
||||
.item=${currentItem}
|
||||
@click=${this._actionClicked}
|
||||
.title=${this.hass.localize(
|
||||
`ui.components.media-browser.${this.action}`
|
||||
)}
|
||||
>
|
||||
<ha-svg-icon
|
||||
.path=${
|
||||
this.action === "play"
|
||||
? mdiPlay
|
||||
: mdiPlus
|
||||
}
|
||||
></ha-svg-icon>
|
||||
</ha-button>
|
||||
<h2 class="subtitle">
|
||||
${subtitle}
|
||||
</h2>
|
||||
`
|
||||
: ""
|
||||
}
|
||||
</div>
|
||||
`
|
||||
: nothing
|
||||
}
|
||||
<div class="header-info">
|
||||
<div class="breadcrumb">
|
||||
<h1 class="title">${currentItem.title}</h1>
|
||||
${
|
||||
subtitle
|
||||
? html`
|
||||
<h2 class="subtitle">${subtitle}</h2>
|
||||
`
|
||||
: ""
|
||||
}
|
||||
</div>
|
||||
${
|
||||
currentItem.can_play &&
|
||||
(!currentItem.thumbnail || !this.narrow)
|
||||
? html`
|
||||
<ha-button
|
||||
.item=${currentItem}
|
||||
@click=${this._actionClicked}
|
||||
>
|
||||
<ha-svg-icon
|
||||
.label=${this.hass.localize(
|
||||
`ui.components.media-browser.${this.action}-media`
|
||||
)}
|
||||
.path=${
|
||||
this.action === "play"
|
||||
? mdiPlay
|
||||
: mdiPlus
|
||||
}
|
||||
slot="start"
|
||||
></ha-svg-icon>
|
||||
${this.hass.localize(
|
||||
`ui.components.media-browser.${this.action}`
|
||||
)}
|
||||
</ha-button>
|
||||
`
|
||||
: ""
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
${
|
||||
currentItem.can_play &&
|
||||
(!currentItem.thumbnail || !this.narrow)
|
||||
? html`
|
||||
<ha-button
|
||||
.item=${currentItem}
|
||||
@click=${this._actionClicked}
|
||||
>
|
||||
<ha-svg-icon
|
||||
.label=${this.hass.localize(
|
||||
`ui.components.media-browser.${this.action}-media`
|
||||
)}
|
||||
.path=${
|
||||
this.action === "play"
|
||||
? mdiPlay
|
||||
: mdiPlus
|
||||
}
|
||||
slot="start"
|
||||
></ha-svg-icon>
|
||||
${this.hass.localize(
|
||||
`ui.components.media-browser.${this.action}`
|
||||
)}
|
||||
</ha-button>
|
||||
`
|
||||
: ""
|
||||
}
|
||||
</div>
|
||||
</div>`
|
||||
: nothing
|
||||
}
|
||||
</div>
|
||||
`
|
||||
: ""
|
||||
@@ -504,12 +576,10 @@ export class HaMediaPlayerBrowse extends LitElement {
|
||||
@touchmove=${this._scroll}
|
||||
>
|
||||
${
|
||||
this._error
|
||||
this._searching
|
||||
? html`
|
||||
<div class="container">
|
||||
<ha-alert alert-type="error">
|
||||
${this._renderError(this._error)}
|
||||
</ha-alert>
|
||||
<ha-spinner></ha-spinner>
|
||||
</div>
|
||||
`
|
||||
: isManualMediaSourceContentId(currentItem.media_content_id)
|
||||
@@ -532,29 +602,33 @@ export class HaMediaPlayerBrowse extends LitElement {
|
||||
@tts-picked=${this._ttsPicked}
|
||||
></ha-browse-media-tts>
|
||||
`
|
||||
: !children.length && !currentItem.not_shown
|
||||
: !children.length && !notShown
|
||||
? html`
|
||||
<div class="container no-items">
|
||||
${
|
||||
currentItem.media_content_id ===
|
||||
"media-source://media_source/local/."
|
||||
? html`
|
||||
<div class="highlight-add-button">
|
||||
<span>
|
||||
<ha-svg-icon
|
||||
.path=${mdiArrowUpRight}
|
||||
></ha-svg-icon>
|
||||
</span>
|
||||
<span>
|
||||
${this.hass.localize(
|
||||
"ui.components.media-browser.file_management.highlight_button"
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
`
|
||||
: this.hass.localize(
|
||||
"ui.components.media-browser.no_items"
|
||||
isSearching
|
||||
? this.hass.localize(
|
||||
"ui.components.media-browser.search.no_results"
|
||||
)
|
||||
: currentItem.media_content_id ===
|
||||
"media-source://media_source/local/."
|
||||
? html`
|
||||
<div class="highlight-add-button">
|
||||
<span>
|
||||
<ha-svg-icon
|
||||
.path=${mdiArrowUpRight}
|
||||
></ha-svg-icon>
|
||||
</span>
|
||||
<span>
|
||||
${this.hass.localize(
|
||||
"ui.components.media-browser.file_management.highlight_button"
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
`
|
||||
: this.hass.localize(
|
||||
"ui.components.media-browser.no_items"
|
||||
)
|
||||
}
|
||||
</div>
|
||||
`
|
||||
@@ -584,17 +658,17 @@ export class HaMediaPlayerBrowse extends LitElement {
|
||||
portrait:
|
||||
childrenMediaClass.thumbnail_ratio ===
|
||||
"portrait",
|
||||
not_shown: !!currentItem.not_shown,
|
||||
not_shown: !!notShown,
|
||||
})}"
|
||||
></lit-virtualizer>
|
||||
${
|
||||
currentItem.not_shown
|
||||
notShown
|
||||
? html`
|
||||
<div class="grid not-shown">
|
||||
<div class="title">
|
||||
${this.hass.localize(
|
||||
"ui.components.media-browser.not_shown",
|
||||
{ count: currentItem.not_shown }
|
||||
{ count: notShown }
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -614,7 +688,7 @@ export class HaMediaPlayerBrowse extends LitElement {
|
||||
.renderItem=${this._renderListItem}
|
||||
></lit-virtualizer>
|
||||
${
|
||||
currentItem.not_shown
|
||||
notShown
|
||||
? html`
|
||||
<ha-list-item
|
||||
noninteractive
|
||||
@@ -628,7 +702,7 @@ export class HaMediaPlayerBrowse extends LitElement {
|
||||
<span class="title">
|
||||
${this.hass.localize(
|
||||
"ui.components.media-browser.not_shown",
|
||||
{ count: currentItem.not_shown }
|
||||
{ count: notShown }
|
||||
)}
|
||||
</span>
|
||||
</ha-list-item>
|
||||
@@ -644,6 +718,188 @@ export class HaMediaPlayerBrowse extends LitElement {
|
||||
`;
|
||||
}
|
||||
|
||||
private _renderSearchRow(
|
||||
currentItem: MediaPlayerItem,
|
||||
mediaClassFilterOptions: MediaClass[]
|
||||
): TemplateResult {
|
||||
return html`
|
||||
<div class="search-row">
|
||||
${
|
||||
currentItem.can_search
|
||||
? html`
|
||||
<ha-input-search
|
||||
class="search-input"
|
||||
appearance="outlined"
|
||||
.value=${this._searchQuery}
|
||||
.placeholder=${this.hass.localize(
|
||||
"ui.components.media-browser.search.search_placeholder",
|
||||
{ name: currentItem.title }
|
||||
)}
|
||||
@input=${this._handleSearchInput}
|
||||
@keydown=${this._handleSearchKeydown}
|
||||
></ha-input-search>
|
||||
${
|
||||
mediaClassFilterOptions.length
|
||||
? this._renderMediaClassFilter(mediaClassFilterOptions)
|
||||
: nothing
|
||||
}
|
||||
<ha-button
|
||||
class="search-button"
|
||||
appearance="filled"
|
||||
.disabled=${!this._searchQuery.trim()}
|
||||
@click=${this._search}
|
||||
>
|
||||
${this.hass.localize("ui.common.search")}
|
||||
</ha-button>
|
||||
`
|
||||
: nothing
|
||||
}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private _renderMediaClassFilter(
|
||||
mediaClassFilterOptions: MediaClass[]
|
||||
): TemplateResult {
|
||||
const selectedCount = this._mediaClassFilter.length;
|
||||
return html`
|
||||
<div class="media-class-filter">
|
||||
<ha-dropdown
|
||||
placement="bottom-end"
|
||||
@wa-select=${this._toggleMediaClassFilter}
|
||||
>
|
||||
<ha-icon-button
|
||||
slot="trigger"
|
||||
class="filter-button ${classMap({ active: selectedCount > 0 })}"
|
||||
.path=${mdiFilterVariant}
|
||||
.label=${this.hass.localize(
|
||||
"ui.components.media-browser.filter_media_type"
|
||||
)}
|
||||
></ha-icon-button>
|
||||
${mediaClassFilterOptions.map((mediaClass) => {
|
||||
const selected = this._mediaClassFilter.includes(mediaClass);
|
||||
return html`
|
||||
<ha-dropdown-item
|
||||
.value=${mediaClass}
|
||||
.action=${selected ? "remove" : "add"}
|
||||
type="checkbox"
|
||||
.checked=${selected}
|
||||
>
|
||||
${this._localizeMediaClass(mediaClass)}
|
||||
</ha-dropdown-item>
|
||||
`;
|
||||
})}
|
||||
</ha-dropdown>
|
||||
${
|
||||
selectedCount
|
||||
? html`<div class="filter-badge">${selectedCount}</div>`
|
||||
: nothing
|
||||
}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private _localizeMediaClass(mediaClass: MediaClass): string {
|
||||
return (
|
||||
this.hass.localize(`ui.components.media-browser.class.${mediaClass}`) ||
|
||||
mediaClass
|
||||
);
|
||||
}
|
||||
|
||||
private _toggleMediaClassFilter(ev: HaDropdownSelectEvent<MediaClass>): void {
|
||||
ev.preventDefault(); // keep the dropdown open for multi-select
|
||||
const value = ev.detail.item.value;
|
||||
const action = (ev.detail.item as { action?: "add" | "remove" }).action;
|
||||
this._mediaClassFilter =
|
||||
action === "add"
|
||||
? [...this._mediaClassFilter, value]
|
||||
: this._mediaClassFilter.filter((mediaClass) => mediaClass !== value);
|
||||
// Only refine results already on screen; before a search has run, the
|
||||
// filter is just staged for the next Enter/search-button submission.
|
||||
if (this._searchResults !== undefined) {
|
||||
this._search();
|
||||
}
|
||||
}
|
||||
|
||||
private _handleSearchInput(ev: InputEvent): void {
|
||||
const value = (ev.target as HaInputSearch).value ?? "";
|
||||
this._searchQuery = value;
|
||||
// Searching is explicit (Enter or the search button). Emptying the field —
|
||||
// e.g. via the clear button — returns to the browse view.
|
||||
if (!value) {
|
||||
this._clearSearch();
|
||||
}
|
||||
}
|
||||
|
||||
private _handleSearchKeydown(ev: KeyboardEvent): void {
|
||||
if (ev.key === "Enter") {
|
||||
ev.preventDefault();
|
||||
this._search();
|
||||
}
|
||||
}
|
||||
|
||||
private _abortSearch(): void {
|
||||
// Invalidate any in-flight search so a late response is ignored, and clear
|
||||
// the loading state.
|
||||
this._searchRequestId++;
|
||||
this._searching = false;
|
||||
}
|
||||
|
||||
private _clearSearch(): void {
|
||||
this._abortSearch();
|
||||
this._searchQuery = "";
|
||||
this._searchResults = undefined;
|
||||
}
|
||||
|
||||
private async _search(): Promise<void> {
|
||||
const searchQuery = this._searchQuery.trim();
|
||||
if (!searchQuery) {
|
||||
// Nothing to search; drop any stale results but keep the input.
|
||||
this._abortSearch();
|
||||
this._searchResults = undefined;
|
||||
return;
|
||||
}
|
||||
|
||||
const navigateId = this.navigateIds[this.navigateIds.length - 1];
|
||||
const requestId = ++this._searchRequestId;
|
||||
|
||||
this._searching = true;
|
||||
// Clear previous results so stale data isn't shown while searching or on error
|
||||
this._searchResults = undefined;
|
||||
const mediaFilterClasses = this._mediaClassFilter.length
|
||||
? this._mediaClassFilter
|
||||
: undefined;
|
||||
try {
|
||||
const { result } = await searchMedia(
|
||||
this.hass,
|
||||
navigateId.media_content_id,
|
||||
searchQuery,
|
||||
mediaFilterClasses
|
||||
);
|
||||
// Ignore the response if a newer search started or we navigated away
|
||||
if (requestId !== this._searchRequestId) {
|
||||
return;
|
||||
}
|
||||
this._searchResults = result;
|
||||
} catch (err) {
|
||||
// Ignore errors from superseded searches
|
||||
if (requestId !== this._searchRequestId) {
|
||||
return;
|
||||
}
|
||||
showAlertDialog(this, {
|
||||
title: this.hass.localize(
|
||||
"ui.components.media-browser.media_browsing_error"
|
||||
),
|
||||
text: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
} finally {
|
||||
// Only the most recent search controls the loading state
|
||||
if (requestId === this._searchRequestId) {
|
||||
this._searching = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private _renderGridItem = (child: MediaPlayerItem): TemplateResult => {
|
||||
return html`
|
||||
<div class="child" .item=${child} @click=${this._childClicked}>
|
||||
@@ -1009,7 +1265,8 @@ export class HaMediaPlayerBrowse extends LitElement {
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
flex-direction: column;
|
||||
gap: var(--ha-space-2);
|
||||
border-bottom: 1px solid var(--divider-color);
|
||||
background-color: var(--card-background-color);
|
||||
position: absolute;
|
||||
@@ -1019,6 +1276,56 @@ export class HaMediaPlayerBrowse extends LitElement {
|
||||
z-index: 3;
|
||||
padding: 16px;
|
||||
}
|
||||
.header.search-only {
|
||||
padding: 8px 16px;
|
||||
}
|
||||
.search-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: var(--ha-space-2);
|
||||
}
|
||||
.search-input {
|
||||
flex: 1;
|
||||
--ha-input-padding-top: 0;
|
||||
--ha-input-padding-bottom: 0;
|
||||
}
|
||||
.search-button {
|
||||
flex: none;
|
||||
}
|
||||
:host([narrow]) .search-row {
|
||||
box-sizing: border-box;
|
||||
padding: 8px 16px;
|
||||
}
|
||||
.media-class-filter {
|
||||
position: relative;
|
||||
flex: none;
|
||||
}
|
||||
.filter-button {
|
||||
--ha-icon-button-size: 40px;
|
||||
color: var(--secondary-text-color);
|
||||
}
|
||||
.filter-button.active {
|
||||
color: var(--primary-color);
|
||||
}
|
||||
.filter-badge {
|
||||
position: absolute;
|
||||
top: -4px;
|
||||
right: -4px;
|
||||
inset-inline-end: -4px;
|
||||
inset-inline-start: initial;
|
||||
min-width: 16px;
|
||||
box-sizing: border-box;
|
||||
border-radius: var(--ha-border-radius-circle);
|
||||
font-size: var(--ha-font-size-xs);
|
||||
font-weight: var(--ha-font-weight-normal);
|
||||
background-color: var(--primary-color);
|
||||
line-height: var(--ha-line-height-normal);
|
||||
text-align: center;
|
||||
padding: 0px 2px;
|
||||
color: var(--text-primary-color);
|
||||
pointer-events: none;
|
||||
}
|
||||
.header_button {
|
||||
position: relative;
|
||||
right: -8px;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property } from "lit/decorators";
|
||||
import type { DeviceCompositeSplits } from "../../data/device/device_registry";
|
||||
import type { HaEntityPickerEntityFilterFunc } from "../../data/entity/entity";
|
||||
import type { TargetType, TargetTypeFloorless } from "../../data/target";
|
||||
import type { HomeAssistant } from "../../types";
|
||||
@@ -8,6 +9,13 @@ import "../ha-expansion-panel";
|
||||
import "../list/ha-list-base";
|
||||
import "./ha-target-picker-item-row";
|
||||
|
||||
const TYPE_PLURAL = {
|
||||
entity: "entities",
|
||||
device: "devices",
|
||||
area: "areas",
|
||||
label: "labels",
|
||||
} as const satisfies Record<TargetTypeFloorless, string>;
|
||||
|
||||
@customElement("ha-target-picker-item-group")
|
||||
export class HaTargetPickerItemGroup extends LitElement {
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
@@ -45,6 +53,9 @@ export class HaTargetPickerItemGroup extends LitElement {
|
||||
@property({ type: Boolean, attribute: "primary-entities-only" })
|
||||
public primaryEntitiesOnly?: boolean;
|
||||
|
||||
@property({ attribute: false })
|
||||
public compositeSplits?: DeviceCompositeSplits;
|
||||
|
||||
protected render() {
|
||||
let count = 0;
|
||||
Object.values(this.items).forEach((items) => {
|
||||
@@ -60,11 +71,11 @@ export class HaTargetPickerItemGroup extends LitElement {
|
||||
>
|
||||
<div slot="header" class="heading">
|
||||
${this.hass.localize(
|
||||
`ui.components.target-picker.selected.${this.type}`,
|
||||
{
|
||||
count,
|
||||
}
|
||||
`ui.components.target-picker.type.${TYPE_PLURAL[this.type]}`
|
||||
)}
|
||||
${
|
||||
this.collapsed ? html`<span class="count">(${count})</span>` : nothing
|
||||
}
|
||||
</div>
|
||||
<ha-list-base>
|
||||
${Object.entries(this.items).map(([type, items]) =>
|
||||
@@ -80,6 +91,7 @@ export class HaTargetPickerItemGroup extends LitElement {
|
||||
.includeDomains=${this.includeDomains}
|
||||
.includeDeviceClasses=${this.includeDeviceClasses}
|
||||
.primaryEntitiesOnly=${this.primaryEntitiesOnly}
|
||||
.compositeSplits=${this.compositeSplits}
|
||||
></ha-target-picker-item-row>`
|
||||
)
|
||||
: nothing
|
||||
@@ -106,6 +118,10 @@ export class HaTargetPickerItemGroup extends LitElement {
|
||||
justify-content: space-between;
|
||||
min-height: unset;
|
||||
}
|
||||
.count {
|
||||
color: var(--secondary-text-color);
|
||||
font-weight: var(--ha-font-weight-normal);
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
|
||||
@@ -34,7 +34,10 @@ import { computeRTL } from "../../common/util/compute_rtl";
|
||||
import type { AreaRegistryEntry } from "../../data/area/area_registry";
|
||||
import { getConfigEntry } from "../../data/config_entries";
|
||||
import { labelsContext } from "../../data/context";
|
||||
import type { DeviceRegistryEntry } from "../../data/device/device_registry";
|
||||
import type {
|
||||
DeviceCompositeSplits,
|
||||
DeviceRegistryEntry,
|
||||
} from "../../data/device/device_registry";
|
||||
import type { HaEntityPickerEntityFilterFunc } from "../../data/entity/entity";
|
||||
import type { FloorRegistryEntry } from "../../data/floor_registry";
|
||||
import { domainToName } from "../../data/integration";
|
||||
@@ -54,6 +57,7 @@ import type { HomeAssistant } from "../../types";
|
||||
import { brandsUrl } from "../../util/brands-url";
|
||||
import type { HaDevicePickerDeviceFilterFunc } from "../device/ha-device-picker";
|
||||
import { floorDefaultIconPath } from "../ha-floor-icon";
|
||||
import "../ha-button";
|
||||
import "../ha-icon-button";
|
||||
import "../ha-state-icon";
|
||||
import "../ha-svg-icon";
|
||||
@@ -108,6 +112,9 @@ export class HaTargetPickerItemRow extends LitElement {
|
||||
@property({ type: Boolean, attribute: "primary-entities-only" })
|
||||
public primaryEntitiesOnly?: boolean;
|
||||
|
||||
@property({ attribute: false })
|
||||
public compositeSplits?: DeviceCompositeSplits;
|
||||
|
||||
@state() private _iconImg?: string;
|
||||
|
||||
@state() private _domainName?: string;
|
||||
@@ -128,6 +135,15 @@ export class HaTargetPickerItemRow extends LitElement {
|
||||
const { name, context, iconPath, fallbackIconPath, stateObject, notFound } =
|
||||
this._itemData(this.type, this.itemId);
|
||||
|
||||
const replacement =
|
||||
this.type === "device" && notFound
|
||||
? this._getReplacement(this.itemId)
|
||||
: undefined;
|
||||
// Only surface the "replaced" state when there is at least one available
|
||||
// replacement device to migrate to. If every replacement device was
|
||||
// deleted (or filtered out), fall back to the plain "not found" state.
|
||||
const canMigrate = !!replacement?.candidates.length;
|
||||
|
||||
const showEntities = this.type !== "entity" && !notFound;
|
||||
|
||||
const entries = this.parentEntries || this._entries;
|
||||
@@ -174,15 +190,20 @@ export class HaTargetPickerItemRow extends LitElement {
|
||||
}
|
||||
</div>
|
||||
|
||||
<div slot="headline">${name}</div>
|
||||
<div slot="headline">${(canMigrate && replacement?.name) || name}</div>
|
||||
${
|
||||
notFound || (context && !this.hideContext)
|
||||
? html`<span slot="supporting-text"
|
||||
>${
|
||||
notFound
|
||||
? this.hass.localize(
|
||||
`ui.components.target-picker.${this.type}_not_found`
|
||||
)
|
||||
? canMigrate
|
||||
? this.hass.localize(
|
||||
"ui.components.target-picker.device_replaced",
|
||||
{ count: replacement!.candidates.length }
|
||||
)
|
||||
: this.hass.localize(
|
||||
`ui.components.target-picker.${this.type}_not_found`
|
||||
)
|
||||
: context
|
||||
}</span
|
||||
>`
|
||||
@@ -229,6 +250,24 @@ export class HaTargetPickerItemRow extends LitElement {
|
||||
`
|
||||
: nothing
|
||||
}
|
||||
${
|
||||
canMigrate
|
||||
? html`
|
||||
<ha-button
|
||||
class="migrate"
|
||||
slot="end"
|
||||
appearance="plain"
|
||||
variant="warning"
|
||||
size="s"
|
||||
@click=${this._migrate}
|
||||
>
|
||||
${this.hass.localize(
|
||||
"ui.components.target-picker.replace_device"
|
||||
)}
|
||||
</ha-button>
|
||||
`
|
||||
: nothing
|
||||
}
|
||||
${
|
||||
!this.expand && !this.subEntry
|
||||
? html`
|
||||
@@ -707,6 +746,51 @@ export class HaTargetPickerItemRow extends LitElement {
|
||||
});
|
||||
}
|
||||
|
||||
// Returns the split devices that replaced a removed composite device and
|
||||
// pass this row's filters, or undefined if the item is not a replaced device.
|
||||
private _getReplacement(item: string) {
|
||||
const split = this.compositeSplits?.[item];
|
||||
if (!split || this.hass.devices[item]) {
|
||||
return undefined;
|
||||
}
|
||||
const candidates = split.split_ids.filter((id) => {
|
||||
const device = this.hass.devices[id];
|
||||
return (
|
||||
device &&
|
||||
deviceMeetsFilter(
|
||||
device,
|
||||
this.hass.entities,
|
||||
this.deviceFilter,
|
||||
this.includeDomains,
|
||||
this.includeDeviceClasses,
|
||||
this.hass.states,
|
||||
this.entityFilter,
|
||||
!this.primaryEntitiesOnly
|
||||
)
|
||||
);
|
||||
});
|
||||
// Display the replaced reference using the primary replacement device's
|
||||
// name instead of the removed composite device id. Fall back to the first
|
||||
// available candidate if the primary device itself was deleted.
|
||||
const nameDevice =
|
||||
(split.primary_id && this.hass.devices[split.primary_id]) ||
|
||||
(candidates.length ? this.hass.devices[candidates[0]] : undefined);
|
||||
const name = nameDevice ? computeDeviceName(nameDevice) : undefined;
|
||||
return { candidates, name };
|
||||
}
|
||||
|
||||
private _migrate = (ev: MouseEvent) => {
|
||||
ev.stopPropagation();
|
||||
const replacement = this._getReplacement(this.itemId);
|
||||
if (!replacement?.candidates.length) {
|
||||
return;
|
||||
}
|
||||
fireEvent(this, "migrate-target-item", {
|
||||
id: this.itemId,
|
||||
replacements: replacement.candidates,
|
||||
});
|
||||
};
|
||||
|
||||
private _openDetails(ev: MouseEvent) {
|
||||
ev.stopPropagation();
|
||||
showTargetDetailsDialog(this, {
|
||||
@@ -746,6 +830,11 @@ export class HaTargetPickerItemRow extends LitElement {
|
||||
color: var(--ha-color-on-warning-normal);
|
||||
}
|
||||
|
||||
.migrate {
|
||||
align-self: center;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.replaceable {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import "@home-assistant/webawesome/dist/components/tag/tag";
|
||||
import { consume } from "@lit/context";
|
||||
import {
|
||||
mdiAlertOutline,
|
||||
mdiDevices,
|
||||
mdiHome,
|
||||
mdiLabel,
|
||||
@@ -9,6 +10,7 @@ import {
|
||||
} from "@mdi/js";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import { classMap } from "lit/directives/class-map";
|
||||
import memoizeOne from "memoize-one";
|
||||
import { computeCssColor } from "../../common/color/compute-color";
|
||||
import { hex2rgb } from "../../common/color/convert-color";
|
||||
@@ -19,6 +21,7 @@ import { computeStateName } from "../../common/entity/compute_state_name";
|
||||
import { slugify } from "../../common/string/slugify";
|
||||
import { getConfigEntry } from "../../data/config_entries";
|
||||
import { labelsContext } from "../../data/context";
|
||||
import type { DeviceCompositeSplits } from "../../data/device/device_registry";
|
||||
import { domainToName } from "../../data/integration";
|
||||
import type { LabelRegistryEntry } from "../../data/label/label_registry";
|
||||
import type { TargetType } from "../../data/target";
|
||||
@@ -39,6 +42,9 @@ export class HaTargetPickerValueChip extends LitElement {
|
||||
|
||||
@property({ attribute: "item-id" }) public itemId!: string;
|
||||
|
||||
@property({ attribute: false })
|
||||
public compositeSplits?: DeviceCompositeSplits;
|
||||
|
||||
@state() private _domainName?: string;
|
||||
|
||||
@state() private _iconImg?: string;
|
||||
@@ -51,38 +57,74 @@ export class HaTargetPickerValueChip extends LitElement {
|
||||
const { name, iconPath, fallbackIconPath, stateObject, color } =
|
||||
this._itemData(this.type, this.itemId);
|
||||
|
||||
const split =
|
||||
this.type === "device" && !this.hass.devices?.[this.itemId]
|
||||
? this.compositeSplits?.[this.itemId]
|
||||
: undefined;
|
||||
// Show the replaced reference using the primary replacement device's name,
|
||||
// falling back to the first still-existing split device if the primary
|
||||
// device itself was deleted. If no replacement device exists at all, fall
|
||||
// back to the normal "not found" display.
|
||||
const replacementDevice = split
|
||||
? (split.primary_id && this.hass.devices?.[split.primary_id]) ||
|
||||
split.split_ids
|
||||
.map((id) => this.hass.devices?.[id])
|
||||
.find((device) => device)
|
||||
: undefined;
|
||||
const replaced = !!replacementDevice;
|
||||
const replacedName = replacementDevice
|
||||
? computeDeviceNameDisplay(
|
||||
replacementDevice,
|
||||
this.hass.localize,
|
||||
this.hass.states
|
||||
)
|
||||
: undefined;
|
||||
|
||||
return html`
|
||||
<wa-tag
|
||||
pill
|
||||
with-remove
|
||||
class=${this.type}
|
||||
class=${classMap({ [this.type]: true, replaced })}
|
||||
style=${color ? `--color: rgb(${color});` : ""}
|
||||
@wa-remove=${this._removeItem}
|
||||
>
|
||||
<div class="icon">
|
||||
${
|
||||
iconPath
|
||||
? html`<ha-icon .icon=${iconPath}></ha-icon>`
|
||||
: this._iconImg
|
||||
? html`<img
|
||||
alt=${this._domainName || ""}
|
||||
width="24"
|
||||
crossorigin="anonymous"
|
||||
referrerpolicy="no-referrer"
|
||||
src=${this._iconImg}
|
||||
/>`
|
||||
: fallbackIconPath
|
||||
? html`<ha-svg-icon .path=${fallbackIconPath}></ha-svg-icon>`
|
||||
: stateObject
|
||||
? html`<ha-state-icon
|
||||
.stateObj=${stateObject}
|
||||
></ha-state-icon>`
|
||||
: nothing
|
||||
replaced
|
||||
? html`<ha-svg-icon .path=${mdiAlertOutline}></ha-svg-icon>`
|
||||
: iconPath
|
||||
? html`<ha-icon .icon=${iconPath}></ha-icon>`
|
||||
: this._iconImg
|
||||
? html`<img
|
||||
alt=${this._domainName || ""}
|
||||
width="24"
|
||||
crossorigin="anonymous"
|
||||
referrerpolicy="no-referrer"
|
||||
src=${this._iconImg}
|
||||
/>`
|
||||
: fallbackIconPath
|
||||
? html`<ha-svg-icon
|
||||
.path=${fallbackIconPath}
|
||||
></ha-svg-icon>`
|
||||
: stateObject
|
||||
? html`<ha-state-icon
|
||||
.stateObj=${stateObject}
|
||||
></ha-state-icon>`
|
||||
: nothing
|
||||
}
|
||||
</div>
|
||||
<span class="name"> ${name} </span>
|
||||
<span class="name">
|
||||
${
|
||||
replaced
|
||||
? replacedName ||
|
||||
this.hass.localize(
|
||||
"ui.components.target-picker.replaced_device"
|
||||
)
|
||||
: name
|
||||
}
|
||||
</span>
|
||||
${
|
||||
this.type === "entity"
|
||||
this.type === "entity" || replaced
|
||||
? nothing
|
||||
: html`<ha-tooltip .for="expand-${slugify(this.itemId)}"
|
||||
>${this.hass.localize(
|
||||
@@ -125,7 +167,7 @@ export class HaTargetPickerValueChip extends LitElement {
|
||||
if (type === "device") {
|
||||
const device = this.hass.devices?.[itemId];
|
||||
|
||||
if (device.primary_config_entry) {
|
||||
if (device?.primary_config_entry) {
|
||||
this._getDeviceDomain(device.primary_config_entry);
|
||||
}
|
||||
|
||||
@@ -240,6 +282,11 @@ export class HaTargetPickerValueChip extends LitElement {
|
||||
--background-color: var(--color);
|
||||
--icon-primary-color: var(--primary-text-color);
|
||||
}
|
||||
wa-tag.replaced {
|
||||
border-color: var(--ha-color-border-warning-normal, var(--warning-color));
|
||||
--background-color: var(--warning-color);
|
||||
color: var(--ha-color-on-warning-normal, var(--warning-color));
|
||||
}
|
||||
|
||||
.name {
|
||||
overflow: hidden;
|
||||
|
||||
@@ -112,7 +112,7 @@ export class HaTileContainer extends LitElement {
|
||||
flex-direction: column;
|
||||
text-align: center;
|
||||
justify-content: center;
|
||||
padding: 10px 0;
|
||||
padding: 10px var(--ha-space-1);
|
||||
}
|
||||
.vertical ::slotted([slot="info"]) {
|
||||
width: 100%;
|
||||
|
||||
@@ -18,6 +18,8 @@ import { customElement, property } from "lit/decorators";
|
||||
* @cssprop --ha-tile-info-primary-font-size - The font size of the primary text. defaults to `var(--ha-font-size-m)`.
|
||||
* @cssprop --ha-tile-info-primary-font-weight - The font weight of the primary text. defaults to `var(--ha-font-weight-medium)`.
|
||||
* @cssprop --ha-tile-info-primary-line-height - The line height of the primary text. defaults to `var(--ha-line-height-normal)`.
|
||||
* @cssprop --ha-tile-info-primary-line-clamp - The maximum number of lines for the primary text before truncating with an ellipsis. defaults to `1`.
|
||||
* @cssprop --ha-tile-info-primary-padding-block - The vertical padding around the primary text. defaults to `0`.
|
||||
* @cssprop --ha-tile-info-primary-letter-spacing - The letter spacing of the primary text. defaults to `0.1px`.
|
||||
* @cssprop --ha-tile-info-primary-color - The color of the primary text. defaults to `var(--primary-text-color)`.
|
||||
* @cssprop --ha-tile-info-secondary-font-size - The font size of the secondary text. defaults to `var(--ha-font-size-s)`.
|
||||
@@ -71,6 +73,11 @@ export class HaTileInfo extends LitElement {
|
||||
--ha-tile-info-primary-line-height,
|
||||
var(--ha-line-height-normal)
|
||||
);
|
||||
--tile-info-primary-line-clamp: var(--ha-tile-info-primary-line-clamp, 1);
|
||||
--tile-info-primary-padding-block: var(
|
||||
--ha-tile-info-primary-padding-block,
|
||||
0
|
||||
);
|
||||
--tile-info-primary-letter-spacing: var(
|
||||
--ha-tile-info-primary-letter-spacing,
|
||||
0.1px
|
||||
@@ -107,13 +114,22 @@ export class HaTileInfo extends LitElement {
|
||||
align-items: flex-start;
|
||||
justify-content: center;
|
||||
}
|
||||
span,
|
||||
::slotted(*) {
|
||||
.secondary span,
|
||||
::slotted([slot="secondary"]) {
|
||||
text-overflow: ellipsis;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
width: 100%;
|
||||
}
|
||||
.primary span,
|
||||
::slotted([slot="primary"]) {
|
||||
display: -webkit-box;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: var(--tile-info-primary-line-clamp);
|
||||
overflow: hidden;
|
||||
width: 100%;
|
||||
padding-block: var(--tile-info-primary-padding-block);
|
||||
}
|
||||
.primary {
|
||||
font-size: var(--tile-info-primary-font-size);
|
||||
font-weight: var(--tile-info-primary-font-weight);
|
||||
|
||||
+13
-11
@@ -49,23 +49,25 @@ export const YAML_ONLY_ACTION_TYPES = new Set<keyof typeof ACTION_ICONS>([
|
||||
export const ACTION_COLLECTIONS: AutomationElementGroupCollection[] = [
|
||||
{
|
||||
groups: {
|
||||
device_id: {},
|
||||
dynamicGroups: {},
|
||||
},
|
||||
},
|
||||
{
|
||||
titleKey: "ui.panel.config.automation.editor.actions.groups.helpers.label",
|
||||
groups: {
|
||||
event: {},
|
||||
service: {},
|
||||
set_conversation_response: {},
|
||||
helpers: {},
|
||||
},
|
||||
},
|
||||
{
|
||||
titleKey: "ui.panel.config.automation.editor.actions.groups.other.label",
|
||||
titleKey: "ui.panel.config.automation.editor.actions.groups.generic.label",
|
||||
generic: true,
|
||||
groups: {
|
||||
event: {},
|
||||
service: {},
|
||||
set_conversation_response: {},
|
||||
other: {},
|
||||
device_id: {},
|
||||
},
|
||||
},
|
||||
{
|
||||
titleKey:
|
||||
"ui.panel.config.automation.editor.actions.groups.integrations.label",
|
||||
groups: {
|
||||
integrationGroups: {},
|
||||
},
|
||||
},
|
||||
] as const;
|
||||
|
||||
@@ -21,7 +21,6 @@ export const CONDITION_COLLECTIONS: AutomationElementGroupCollection[] = [
|
||||
helpers: {},
|
||||
template: {},
|
||||
trigger: {},
|
||||
other: {},
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -35,9 +34,9 @@ export const CONDITION_COLLECTIONS: AutomationElementGroupCollection[] = [
|
||||
},
|
||||
{
|
||||
titleKey:
|
||||
"ui.panel.config.automation.editor.conditions.groups.custom_integrations.label",
|
||||
"ui.panel.config.automation.editor.conditions.groups.integrations.label",
|
||||
groups: {
|
||||
customDynamicGroups: {},
|
||||
integrationGroups: {},
|
||||
},
|
||||
},
|
||||
] as const;
|
||||
|
||||
@@ -36,3 +36,13 @@ export interface DirtyStateContext<
|
||||
* boundary.
|
||||
*/
|
||||
export const dirtyStateContext = createContext<DirtyStateContext>("dirtyState");
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
isDirtyState?: boolean;
|
||||
}
|
||||
|
||||
interface HASSDomEvents {
|
||||
"dirty-state-changed": { isDirty: boolean };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,9 +2,10 @@ import type { HassEntities } from "home-assistant-js-websocket";
|
||||
import { computeStateName } from "../../common/entity/compute_state_name";
|
||||
import type { LocalizeFunc } from "../../common/translations/localize";
|
||||
import type { HaFormSchema } from "../../components/ha-form/types";
|
||||
import type { CallWS } from "../../types";
|
||||
import type { CallWS, HomeAssistant } from "../../types";
|
||||
import type { BaseTrigger } from "../automation";
|
||||
import { migrateAutomationTrigger } from "../automation";
|
||||
import type { DeviceCompositeSplits } from "./device_registry";
|
||||
import type { EntityRegistryEntry } from "../entity/entity_registry";
|
||||
import {
|
||||
entityRegistryByEntityId,
|
||||
@@ -158,6 +159,51 @@ export const deviceAutomationsEqual = (
|
||||
return true;
|
||||
};
|
||||
|
||||
// Decides how a device automation editor should handle its referenced device.
|
||||
// A missing device that was replaced by a split device stays editable so the
|
||||
// device picker can offer to fix the reference; a genuinely unknown device
|
||||
// cannot be edited visually. Returns "loading" while the split map is unknown.
|
||||
export const deviceAutomationEditorMode = (
|
||||
hass: HomeAssistant,
|
||||
deviceId: string | undefined,
|
||||
compositeSplits: DeviceCompositeSplits | undefined
|
||||
): "editable" | "loading" | "unknown-device" => {
|
||||
if (!deviceId || deviceId in hass.devices) {
|
||||
return "editable";
|
||||
}
|
||||
if (compositeSplits === undefined) {
|
||||
return "loading";
|
||||
}
|
||||
// Only editable if at least one of the split (replacement) devices still
|
||||
// exists; otherwise the reference is stale and cannot be fixed here.
|
||||
const split = compositeSplits[deviceId];
|
||||
return split?.split_ids.some((id) => id in hass.devices)
|
||||
? "editable"
|
||||
: "unknown-device";
|
||||
};
|
||||
|
||||
// Like deviceAutomationsEqual, but ignores device_id and entity_id so an
|
||||
// automation can be matched to the equivalent one on a different device (for
|
||||
// example when a referenced device was replaced by a split device).
|
||||
export const deviceAutomationsSimilar = (
|
||||
a: DeviceAutomation,
|
||||
b: DeviceAutomation
|
||||
) => {
|
||||
if (typeof a !== typeof b) {
|
||||
return false;
|
||||
}
|
||||
return deviceAutomationIdentifiers
|
||||
.filter((property) => property !== "device_id" && property !== "entity_id")
|
||||
.every((property) => {
|
||||
const inA = property in a;
|
||||
const inB = property in b;
|
||||
if (!inA && !inB) {
|
||||
return true;
|
||||
}
|
||||
return Object.is(a[property], b[property]);
|
||||
});
|
||||
};
|
||||
|
||||
const compareEntityIdWithEntityRegId = (
|
||||
entityRegistry: EntityRegistryEntry[],
|
||||
entityIdA?: string,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { Connection } from "home-assistant-js-websocket";
|
||||
import { computeStateName } from "../../common/entity/compute_state_name";
|
||||
import { caseInsensitiveStringCompare } from "../../common/string/compare";
|
||||
import type { HomeAssistant } from "../../types";
|
||||
@@ -54,6 +55,69 @@ export interface DeviceRegistryEntryMutableParams {
|
||||
labels?: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Describes how a legacy composite device (that lived on multiple config
|
||||
* entries) was split into separate devices. The composite device no longer
|
||||
* exists in the registry; references to it (in automations, targets, ...) now
|
||||
* need to point at one or more of the split devices instead.
|
||||
*/
|
||||
export interface DeviceCompositeSplit {
|
||||
/** Ids of the devices that replaced the composite device. */
|
||||
split_ids: string[];
|
||||
/** The split device that took over the composite's primary config entry. */
|
||||
primary_id: string | null;
|
||||
}
|
||||
|
||||
/** Map of removed composite device id -> its split information. */
|
||||
export type DeviceCompositeSplits = Record<string, DeviceCompositeSplit>;
|
||||
|
||||
// The composite split migration in core is a one-time operation, so the split
|
||||
// map is static for the lifetime of the connection. Cache the request per
|
||||
// connection so it is fetched once and shared across all pickers, instead of
|
||||
// being requested again by every device/target picker.
|
||||
const compositeSplitsCache = new WeakMap<
|
||||
Connection,
|
||||
Promise<DeviceCompositeSplits>
|
||||
>();
|
||||
|
||||
export const fetchDeviceCompositeSplits = (
|
||||
hass: Pick<HomeAssistant, "connection" | "callWS">
|
||||
): Promise<DeviceCompositeSplits> => {
|
||||
const conn = hass.connection;
|
||||
|
||||
let request = compositeSplitsCache.get(conn);
|
||||
if (!request) {
|
||||
request = hass
|
||||
.callWS<DeviceCompositeSplits>({
|
||||
type: "config/device_registry/list_composite_splits",
|
||||
})
|
||||
.catch((err) => {
|
||||
// Don't cache failures so the next caller retries.
|
||||
compositeSplitsCache.delete(conn);
|
||||
throw err;
|
||||
});
|
||||
compositeSplitsCache.set(conn, request);
|
||||
}
|
||||
return request;
|
||||
};
|
||||
|
||||
/**
|
||||
* Fetch the devices that are linked to the given device because they share at
|
||||
* least one connection or identifier. These are separate devices (one per
|
||||
* config entry) that represent the same physical hardware, managed by
|
||||
* different integrations.
|
||||
*/
|
||||
export const fetchLinkedDevices = (
|
||||
hass: Pick<HomeAssistant, "callWS">,
|
||||
deviceId: string
|
||||
): Promise<string[]> =>
|
||||
hass
|
||||
.callWS<{ linked_devices: string[] }>({
|
||||
type: "config/device_registry/list_linked_devices",
|
||||
device_id: deviceId,
|
||||
})
|
||||
.then((result) => result.linked_devices);
|
||||
|
||||
export const fallbackDeviceName = (
|
||||
hass: HomeAssistant,
|
||||
entities: EntityRegistryEntry[] | EntityRegistryDisplayEntry[] | string[]
|
||||
|
||||
@@ -173,6 +173,7 @@ export interface BatterySourceTypeEnergyPreference {
|
||||
stat_rate?: string; // always available if power_config is set
|
||||
power_config?: PowerConfig;
|
||||
stat_soc?: string;
|
||||
capacity?: number; // usable capacity in kWh, used to weight the combined SOC
|
||||
name?: string;
|
||||
}
|
||||
export interface GasSourceTypeEnergyPreference {
|
||||
|
||||
@@ -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);
|
||||
@@ -10,6 +10,7 @@ export interface CoreFrontendUserData {
|
||||
showEntityIdPicker?: boolean;
|
||||
default_panel?: string;
|
||||
apps_info_dismissed?: boolean;
|
||||
dashboard_favorite_card_types?: string[];
|
||||
}
|
||||
|
||||
export interface SidebarFrontendUserData {
|
||||
|
||||
@@ -95,7 +95,7 @@ export interface HassioAddonDetails extends HassioAddonInfo {
|
||||
options: Record<string, unknown>;
|
||||
privileged: any;
|
||||
protected: boolean;
|
||||
rating: "1-8";
|
||||
rating: number;
|
||||
schema: HaFormSchema[] | null;
|
||||
services_role: string[];
|
||||
signed: boolean;
|
||||
|
||||
+32
-3
@@ -15,10 +15,26 @@ export interface HttpConfig {
|
||||
ssl_profile?: "modern" | "intermediate";
|
||||
}
|
||||
|
||||
// The slot the running HTTP server was actually started with.
|
||||
export type ActiveConfigType = "stable" | "pending" | "default";
|
||||
|
||||
// A stored config slot carries metadata alongside the editable fields:
|
||||
// - created_at: when the slot was staged
|
||||
// - error: null while healthy; set once a slot could not be applied or a
|
||||
// pending trial was not confirmed (then it is kept for display, not retried)
|
||||
export interface HttpConfigWithMeta extends HttpConfig {
|
||||
created_at?: string;
|
||||
error?: string | null;
|
||||
}
|
||||
|
||||
export interface HttpConfigState {
|
||||
stable: HttpConfig;
|
||||
pending: HttpConfig | null;
|
||||
stable: HttpConfigWithMeta;
|
||||
pending: HttpConfigWithMeta | null;
|
||||
revert_at: string | null;
|
||||
// Added in the "active HTTP config slot" backend change; optional so the
|
||||
// frontend keeps working against cores without it.
|
||||
active_config_type?: ActiveConfigType;
|
||||
default?: HttpConfigWithMeta;
|
||||
}
|
||||
|
||||
export const HTTP_CONFIG_FIELDS: (keyof HttpConfig)[] = [
|
||||
@@ -40,6 +56,19 @@ export interface SaveHttpConfigResult {
|
||||
restart: boolean;
|
||||
}
|
||||
|
||||
// Keep only the editable fields; the backend storage schema rejects unknown
|
||||
// keys, so the created_at/error metadata that rides along on a fetched slot
|
||||
// must be dropped before configuring.
|
||||
export const stripHttpConfigMeta = (config: HttpConfig): HttpConfig => {
|
||||
const stripped: Partial<Record<keyof HttpConfig, unknown>> = {};
|
||||
for (const key of HTTP_CONFIG_FIELDS) {
|
||||
if (config[key] !== undefined) {
|
||||
stripped[key] = config[key];
|
||||
}
|
||||
}
|
||||
return stripped as HttpConfig;
|
||||
};
|
||||
|
||||
export const fetchHttpConfig = (hass: HomeAssistant) =>
|
||||
hass.callWS<HttpConfigState>({ type: "http/config" });
|
||||
|
||||
@@ -49,7 +78,7 @@ export const saveHttpConfig = (
|
||||
) =>
|
||||
hass.callWS<SaveHttpConfigResult>({
|
||||
type: "http/config/configure",
|
||||
config,
|
||||
config: config ? stripHttpConfigMeta(config) : null,
|
||||
});
|
||||
|
||||
export const promoteHttpConfig = (hass: HomeAssistant) =>
|
||||
|
||||
@@ -186,6 +186,9 @@ export interface MediaPlayerItem {
|
||||
can_play: boolean;
|
||||
can_expand: boolean;
|
||||
can_search: boolean;
|
||||
search_media_classes?:
|
||||
| (keyof TranslationDict["ui"]["components"]["media-browser"]["class"])[]
|
||||
| null;
|
||||
thumbnail?: string;
|
||||
iconPath?: string;
|
||||
children?: MediaPlayerItem[];
|
||||
|
||||
@@ -24,6 +24,23 @@ export const browseLocalMediaPlayer = (
|
||||
media_content_id: mediaContentId,
|
||||
});
|
||||
|
||||
export interface SearchMediaResult {
|
||||
result: MediaPlayerItem[];
|
||||
}
|
||||
|
||||
export const searchMedia = (
|
||||
hass: HomeAssistant,
|
||||
mediaContentId: string | undefined,
|
||||
searchQuery: string,
|
||||
mediaFilterClasses?: string[]
|
||||
): Promise<SearchMediaResult> =>
|
||||
hass.callWS<SearchMediaResult>({
|
||||
type: "media_source/search_media",
|
||||
media_content_id: mediaContentId,
|
||||
search_query: searchQuery,
|
||||
media_filter_classes: mediaFilterClasses,
|
||||
});
|
||||
|
||||
export const MANUAL_MEDIA_SOURCE_PREFIX = "__MANUAL_ENTRY__";
|
||||
|
||||
export const isManualMediaSourceContentId = (mediaContentId: string) =>
|
||||
|
||||
@@ -1,9 +1,23 @@
|
||||
import type { HassEntity } from "home-assistant-js-websocket";
|
||||
import type { HomeAssistant } from "../types";
|
||||
|
||||
export interface NumberDeviceClassUnits {
|
||||
units: string[];
|
||||
}
|
||||
|
||||
const AUTO_MODE_MAX_STEPS = 256;
|
||||
|
||||
export const showNumberSlider = (stateObj: HassEntity): boolean => {
|
||||
const { mode, min, max, step } = stateObj.attributes;
|
||||
if (mode === "slider") {
|
||||
return true;
|
||||
}
|
||||
return (
|
||||
mode === "auto" &&
|
||||
(Number(max) - Number(min)) / Number(step) <= AUTO_MODE_MAX_STEPS
|
||||
);
|
||||
};
|
||||
|
||||
export const getNumberDeviceClassConvertibleUnits = (
|
||||
hass: HomeAssistant,
|
||||
deviceClass: string
|
||||
|
||||
@@ -26,6 +26,7 @@ export interface StoreAddonDetails extends StoreAddon {
|
||||
apparmor: boolean;
|
||||
arch: SupervisorArch[];
|
||||
auth_api: boolean;
|
||||
changelog: boolean;
|
||||
detached: boolean;
|
||||
docker_api: boolean;
|
||||
documentation: boolean;
|
||||
|
||||
+2
-3
@@ -35,7 +35,6 @@ export const TRIGGER_COLLECTIONS: AutomationElementGroupCollection[] = [
|
||||
webhook: {},
|
||||
persistent_notification: {},
|
||||
helpers: {},
|
||||
other: {},
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -48,9 +47,9 @@ export const TRIGGER_COLLECTIONS: AutomationElementGroupCollection[] = [
|
||||
},
|
||||
{
|
||||
titleKey:
|
||||
"ui.panel.config.automation.editor.triggers.groups.custom_integrations.label",
|
||||
"ui.panel.config.automation.editor.triggers.groups.integrations.label",
|
||||
groups: {
|
||||
customDynamicGroups: {},
|
||||
integrationGroups: {},
|
||||
},
|
||||
},
|
||||
] as const;
|
||||
|
||||
@@ -21,6 +21,7 @@ import { isTiltOnly } from "../../../data/cover";
|
||||
import { UNAVAILABLE, UNKNOWN } from "../../../data/entity/entity";
|
||||
import type { ImageEntity } from "../../../data/image";
|
||||
import { computeImageUrl } from "../../../data/image";
|
||||
import { showNumberSlider } from "../../../data/number";
|
||||
import "../../../panels/lovelace/components/hui-timestamp-display";
|
||||
import type { HomeAssistant } from "../../../types";
|
||||
import {
|
||||
@@ -249,15 +250,9 @@ class EntityPreviewRow extends LitElement {
|
||||
}
|
||||
|
||||
if (domain === "number") {
|
||||
const showNumberSlider =
|
||||
stateObj.attributes.mode === "slider" ||
|
||||
(stateObj.attributes.mode === "auto" &&
|
||||
(Number(stateObj.attributes.max) - Number(stateObj.attributes.min)) /
|
||||
Number(stateObj.attributes.step) <=
|
||||
256);
|
||||
return html`
|
||||
${
|
||||
showNumberSlider
|
||||
showNumberSlider(stateObj)
|
||||
? html`
|
||||
<div class="numberflex">
|
||||
<ha-slider
|
||||
|
||||
@@ -108,7 +108,7 @@ class StepFlowForm extends LitElement {
|
||||
: nothing
|
||||
}
|
||||
${
|
||||
step.data_schema.length
|
||||
step.data_schema.length || this._errors
|
||||
? html`<ha-form
|
||||
${ref(this._formRef)}
|
||||
?autofocus=${this.autoFocus}
|
||||
|
||||
@@ -54,6 +54,7 @@ export class HaImagecropperDialog
|
||||
}
|
||||
|
||||
protected updated(changedProperties: PropertyValues) {
|
||||
super.updated(changedProperties);
|
||||
if (!changedProperties.has("_params") || !this._params) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -78,18 +78,21 @@ class MoreInfoLawnMower extends LitElement {
|
||||
);
|
||||
}
|
||||
|
||||
private get _startPauseIcon(): string {
|
||||
if (!this.stateObj) return mdiPlay;
|
||||
return isMowing(this.stateObj) &&
|
||||
private get _showPause(): boolean {
|
||||
if (!this.stateObj) return false;
|
||||
return (
|
||||
isMowing(this.stateObj) &&
|
||||
supportsFeature(this.stateObj, LawnMowerEntityFeature.PAUSE)
|
||||
? mdiPause
|
||||
: mdiPlay;
|
||||
);
|
||||
}
|
||||
|
||||
private get _startPauseIcon(): string {
|
||||
return this._showPause ? mdiPause : mdiPlay;
|
||||
}
|
||||
|
||||
private get _startPauseLabel(): string {
|
||||
if (!this.stateObj) return "";
|
||||
return isMowing(this.stateObj) &&
|
||||
supportsFeature(this.stateObj, LawnMowerEntityFeature.PAUSE)
|
||||
return this._showPause
|
||||
? this._i18n.localize("ui.dialogs.more_info_control.lawn_mower.pause")
|
||||
: this._i18n.localize(
|
||||
"ui.dialogs.more_info_control.lawn_mower.start_mowing"
|
||||
@@ -99,8 +102,11 @@ class MoreInfoLawnMower extends LitElement {
|
||||
private get _startPauseDisabled(): boolean {
|
||||
if (!this.stateObj) return true;
|
||||
if (this.stateObj.state === UNAVAILABLE) return true;
|
||||
if (isMowing(this.stateObj)) return false;
|
||||
return !canStartMowing(this.stateObj);
|
||||
if (this._showPause) return false;
|
||||
return (
|
||||
!supportsFeature(this.stateObj, LawnMowerEntityFeature.START_MOWING) ||
|
||||
!canStartMowing(this.stateObj)
|
||||
);
|
||||
}
|
||||
|
||||
private _renderBattery() {
|
||||
@@ -156,7 +162,7 @@ class MoreInfoLawnMower extends LitElement {
|
||||
private _handleStartPause() {
|
||||
if (!this.stateObj) return;
|
||||
forwardHaptic(this, "light");
|
||||
if (isMowing(this.stateObj)) {
|
||||
if (this._showPause) {
|
||||
this._api.callService("lawn_mower", "pause", {
|
||||
entity_id: this.stateObj.entity_id,
|
||||
});
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user