mirror of
https://github.com/home-assistant/frontend.git
synced 2026-10-21 21:23:32 +00:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b7f8d0f48f |
No files matched your search
@@ -16,7 +16,7 @@ Container components may keep `hass` when they own it and feed providers. Leaf c
|
||||
## Core Files
|
||||
|
||||
- Context definitions: `src/data/context/index.ts`
|
||||
- `consume` decorator, `ContextSubscriptionController`, and `consumeContext` / `ContextController` for reactive controllers: `src/common/decorators/consume.ts` (use instead of `@lit/context`'s `consume` and `ContextConsumer`, enforced by ESLint)
|
||||
- `consume` decorator and `ContextSubscriptionController`: `src/common/decorators/consume.ts` (use instead of `@lit/context`'s `consume` and `ContextConsumer`, enforced by ESLint)
|
||||
- Entity-scoped consume helpers: `src/common/decorators/consume-context-entry.ts`
|
||||
- Transform decorator: `src/common/decorators/transform.ts`
|
||||
- Canonical migration example: `src/panels/lovelace/cards/hui-button-card.ts`
|
||||
@@ -37,7 +37,6 @@ Container components may keep `hass` when they own it and feed providers. Leaf c
|
||||
| `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 |
|
||||
| `reducedMotionContext` | `prefers-reduced-motion` media query |
|
||||
|
||||
Lazy contexts subscribe on first consumer and tear down after the last consumer: `labelsContext`, `fullEntitiesContext`, `configEntriesContext`, `manifestsContext`, `triggerDescriptionsContext`, and `conditionDescriptionsContext`.
|
||||
|
||||
@@ -51,9 +50,7 @@ Import `consume` from `src/common/decorators/consume`, not from `@lit/context`.
|
||||
- `@state()` plus `@consume` plus `@transform`: rerenders only when the transformed value changes.
|
||||
- `@consume` alone: the field stays current but never triggers a render. Use this for values only read in event handlers or callbacks, such as `apiContext`, or for data read on demand, such as picker item callbacks.
|
||||
|
||||
In reactive controllers, extend `ContextController` and decorate fields with `consumeContext({ context, subscribe, transform })` from the same file. `transform` stores one part of the context value, and `contextUpdated()` runs only when that part changes. `@state()` and `@transform` do not work on controller fields; call `host.requestUpdate()` when the host needs to rerender. Example: `src/data/service-info-controller.ts`.
|
||||
|
||||
For lazily created subscriptions, use `ContextSubscriptionController` from the same file instead of `@lit/context`'s `ContextConsumer`, which also forces a host update. Its callback must store the value in a reactive field or call `host.requestUpdate()` itself when the host needs to rerender.
|
||||
In controllers or for lazily created subscriptions, use `ContextSubscriptionController` from the same file instead of `@lit/context`'s `ContextConsumer`, which also forces a host update. Its callback must store the value in a reactive field or call `host.requestUpdate()` itself when the host needs to rerender.
|
||||
|
||||
Use entity-scoped helpers when the component watches an entity ID held on the host:
|
||||
|
||||
|
||||
@@ -54,10 +54,6 @@ http://localhost:8090/?demo=<second slug>#/energy/water
|
||||
- `demo/src/stubs/`: Mocked WebSocket and REST APIs.
|
||||
- `demo/script/develop_demo`, `demo/script/build_demo`: Development server and static build wrappers.
|
||||
|
||||
## Core Data
|
||||
|
||||
The service descriptions in `src/fake_data/demo_services.ts` and the trigger and condition descriptions and icons in `demo/src/stubs/automation_platforms_*.ts` are generated from Home Assistant Core. Do not edit them by hand. Run `script/gen_demo_core_data` to regenerate them from the core `dev` branch, and add a domain to the lists in `build-scripts/gulp/gen-demo-core-data.js` when the demo needs its services, triggers, or conditions. Their translations come from the backend translations merged into the demo build.
|
||||
|
||||
## 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.
|
||||
|
||||
@@ -1,90 +0,0 @@
|
||||
---
|
||||
name: ha-frontend-query-params
|
||||
description: Home Assistant frontend URL query param patterns. Use when keeping page state such as filters, search, sorting or grouping in the URL, adding or reading typed query params, using QueryParamsController or the data table's sync-url and column urlName, building in-app links with a query string, or adding params to My Home Assistant redirects.
|
||||
---
|
||||
|
||||
# HA Frontend Query Params
|
||||
|
||||
Use this skill when implementing or reviewing state kept in the URL's query string, or links that set it. Cross-load `ha-frontend-lit` for the controller and lifecycle work, `ha-frontend-testing` for running tests, and `ha-frontend-review` when reviewing.
|
||||
|
||||
## Typed Params
|
||||
|
||||
`src/common/url/query-params.ts` turns a typed config into values and back:
|
||||
|
||||
- `QueryParamConfig` lists keys by type: `string`, `list` (one comma-separated param, `?label=a,b`), `date` (ISO string) and `boolean` (`{ key, trueValue }`, true only when the param equals `trueValue`).
|
||||
- `QueryParamValues<typeof config>` is the matching partial values type.
|
||||
- `decodeQueryParams(source, config)` reads a `SearchParamsSource` (a `URLSearchParams`, a plain record or a query string). Empty values are skipped.
|
||||
- `createQueryString(values, config)` writes the values, leaving out empty strings, empty lists and false booleans.
|
||||
- `updateQueryString(current, values, config)` replaces only the config's params in an existing query string and keeps every other param exactly as written.
|
||||
- `hasQueryParams(source, config)` tells whether a URL has any of the config's params, which is how a page knows whether a link asked for anything.
|
||||
- `serviceTargetFromQueryParams` and `queryParamsFromServiceTarget` map `HassServiceTarget` keys to list params.
|
||||
|
||||
Give each feature its own `*-query-params.ts` in `src/common/url/` holding its config (`as const satisfies QueryParamConfig`), its values type, and the helpers that turn params into page state and back. Follow the existing ones: `data-table-query-params.ts`, `marketplace-query-params.ts`, `history-logbook-query-params.ts` and `todo-query-params.ts`. Helpers in `src/data/` that build outside links use a local config the same way, as `src/data/integration.ts` and `src/data/system_log_report.ts` do. In new code, prefer these over hand-built `URLSearchParams`, string concatenation or `extractSearchParam` reads from `src/common/url/search-params.ts`.
|
||||
|
||||
## QueryParamsController
|
||||
|
||||
`src/common/controllers/query-params-controller.ts` keeps part of a page's state in the URL. Construct it with the host, the feature's config and options:
|
||||
|
||||
- `enabled()`: whether the host syncs right now. It can change, as on the Marketplace dashboard, which turns it off on the Discover tab.
|
||||
- `navigated()`: called when the page's URL changes while the host is connected (`location-changed` or `popstate`), and when the host reconnects after its first update. Read the URL with `searchParams()`, apply it, then `write()` the state back.
|
||||
|
||||
Methods:
|
||||
|
||||
- `searchParams()` returns the current params, or `undefined` when the controller is disabled or the URL is on another path.
|
||||
- `write(values, debounced?)` updates only the config's params with `replaceCurrentUrl`, so it replaces the current history entry. Pass `debounced` for values that change while typing; the write lands 300 ms after the last one and any later write replaces it.
|
||||
- `flush()` writes a pending debounced write now. The data table calls it when the search box loses focus. Call it before anything that adds its own history entry, such as a dialog.
|
||||
|
||||
The controller binds to `currentPath()` when the host connects and does nothing while the URL is on another path. Navigating away drops a pending write. When a page is reached through a router `defaultPage` (such as `/marketplace` landing on `/marketplace/browse`), check that a link to the bare path still syncs, since the redirect may land after the host connects.
|
||||
|
||||
Read the URL yourself on the first render, after the initial state is set (in `willUpdate` before `hasUpdated`, or `firstUpdated`), and leave later navigations to `navigated()`. `ha-marketplace-dashboard.ts` and `lorawan-devices.ts` show both. When moving a page to the controller, remove its own `location-changed` and `popstate` listeners and any search kept in history state, since the controller replaces them.
|
||||
|
||||
When a router reuses one element for different items, such as one app's tab after another's, key it on the item so the controller binds again to the new path.
|
||||
|
||||
## URL Rules
|
||||
|
||||
- Writes replace the current history entry, so Back leaves the page instead of undoing each change. Push a new entry only for a deliberate navigation, such as picking another log provider.
|
||||
- Debounce search writes. Write sort, group and filter changes straight away.
|
||||
- Leave empty and default values out of the URL, such as an empty search or ascending direction.
|
||||
- A param in the URL wins over stored preferences. A link with none of the page's params keeps what the user picked before; `hasQueryParams` makes that check.
|
||||
- A link with any of the page's params replaces the search and every filter, since a filter left over from before would hide part of what the link shows. Show what the link applied, but don't store it: links often carry someone else's choices, such as a My Home Assistant link made with `m`. Store only what the user changes on the page, and only that setting.
|
||||
- The URL is the whole state. Don't add filters while applying a link; a link that needs one, such as showing disabled devices, says so in its params (`status=disabled`).
|
||||
- A link to a page that is already open must apply the new URL. Handle `navigated()`, not only the first render. The router often keeps a page alive, so this is the common case, not an edge case.
|
||||
- Use one comma-separated param for several values (`?status=new,installed`), which `list` params do.
|
||||
- Use the names other pages already use for the same thing: `search`, `sort`, `direction` and `group` for the table, and `floor`, `area`, `device`, `label`, `config_entry`, `sub_entry` and `assistants` for the shared filters.
|
||||
- Keep existing param names so existing links and My Home Assistant links keep working. When renaming, read the old name as well for about six months, with a code comment naming the release it can be removed in, and write only the new name. Keep the old name in the config, use it only as a fallback when reading, and leave it out of the values you write, so the next write removes it from the URL.
|
||||
- Pages opt in. Some use params for other things, such as `?domain=` starting the add integration flow on the integrations dashboard, so check what a page already reads before syncing it.
|
||||
- Collapsed groups, column order and hidden columns stay in storage (`@storage`), not the URL.
|
||||
- Link to a page by its main path, not an alias, so a link to an open page lands on the same path and applies.
|
||||
|
||||
## Data Tables
|
||||
|
||||
`hass-tabs-subpage-data-table` with the `sync-url` attribute keeps `search`, `sort`, `direction` and `group` in the URL through `dataTableQueryParamsFromState` and `dataTableQueryStateFromUrl`. A linked search replaces the current one, and sorting or grouping a link leaves out keeps what was picked before. The table fires `search-changed`, `sorting-changed` and `grouping-changed` when a link changes them, with `link` set, so the page shows them without storing them. Use `DataTableSettingsController` (`src/common/controllers/data-table-settings-controller.ts`) for the stored search, sorting and grouping: give it the page's storage keys, bind its `search`, `sorting` and `grouping` to the table, and its `searchChanged`, `sortingChanged` and `groupingChanged` to the table's events. Call `showSearch` for a search the page reads from a link itself. A page the router keeps open shows what the user stored when it's opened again without params, like from the sidebar: the controller and table restore the search, sorting and grouping, and a page with filters restores its stored filters when its URL has no params.
|
||||
|
||||
- Give sortable and groupable columns a short public `urlName` (for example `type`, not `translated_category`). Sorting falls back to the column id without one; grouping is only written for columns that have one, so internal ids don't end up in links.
|
||||
- When a column definition is shared between pages, set its `urlName` where the shared column is defined, so every page uses the same name.
|
||||
- The table reads the URL once, as soon as it has columns. When a column only exists once data has loaded, such as Filename on the dashboards page, turn syncing on only after loading succeeds (`?sync-url=${!this._loading && !this._loadFailed}`), or a link that sorts or groups by that column is dropped.
|
||||
- The page owns its filters. Give it a filter config in its `*-query-params.ts`, a helper that turns filter values into params, and one that turns a link back into the search and filter values. The link helper returns `undefined` when the URL has none of the filter params and none of the table's (`dataTableQueryParamConfig`), so stored choices stay. Use a second controller on the page for them, as `ha-marketplace-dashboard.ts` and `lorawan-devices.ts` do. `browseUrl` and `browseLinkFromUrl` in `src/panels/marketplace/dashboards/dashboard-repositories.ts` show the pair.
|
||||
- Filter components also send the value the page gave them once they have looked up their items, so a filter event isn't always the user's. Store a filter only when it changes what the URL holds, comparing the filters' query strings before and after, as `helpersFilterQueryString` does, and store only that filter.
|
||||
- A memoized filter that also reads async state (anything loaded after the page connects) must take that state as a memoize argument, or a linked filter shows stale results until something else changes.
|
||||
- A filter component that looks up related items asynchronously should drop a lookup whose value has since changed, or two links landing within one lookup apply the older one.
|
||||
|
||||
## Coverage Check
|
||||
|
||||
Before calling a page done:
|
||||
|
||||
1. List every filter, sortable column, groupable column, the search box and any view toggle (such as a tab, raw view or provider). Confirm each one is written to the URL and read back from a link, both on first render and on a page that is already open.
|
||||
2. Search the codebase for in-app links and `navigate()` calls into the page with a query string, and move them to the current param names and the page's main path. `lorawan-connections.ts` and `lorawan-overview.ts` link into `lorawan-devices.ts` this way.
|
||||
|
||||
## My Home Assistant Links
|
||||
|
||||
When a page gains params, add them to its redirect in `getMyRedirects()` in `src/panels/my/ha-panel-my.ts`:
|
||||
|
||||
- List them under `params` as `"string?"`, matching what the page reads. For a data table page that includes `search`, `sort`, `direction` and `group` as well as its filters. Keep the list in a const next to the others, such as `marketplaceTableParams`, which several redirects share.
|
||||
- Point the redirect at the page's main path, the one the page's controller binds to.
|
||||
- Don't add legacy param names.
|
||||
- Add the same params to the redirect's entry in `redirect.json` in `home-assistant/my.home-assistant.io`, in a separate pull request that links the frontend pull request. Merge it only once the frontend pull request is in `dev`.
|
||||
- In the frontend pull request, link the My Home Assistant pull request under "Additional information": `- Link to My Home Assistant pull request: home-assistant/my.home-assistant.io#<n>`.
|
||||
|
||||
## Tests
|
||||
|
||||
Follow `test/common/url/query-params.test.ts`, `test/common/url/data-table-query-params.test.ts` and `test/common/controllers/query-params-controller.test.ts`. For a feature's params file, cover the encode and decode round trip, defaults being left out, and legacy names still being read. Test the helpers, not the page rendering, and only where the logic could break without anyone noticing.
|
||||
@@ -58,7 +58,7 @@ Readiness and invalidation:
|
||||
|
||||
Repository-owned contracts:
|
||||
|
||||
- Consult the public [frontend contributor documentation](https://contributors.home-assistant.io/frontend/) for documented architecture, data flow, design, and development workflows.
|
||||
- Consult the public [frontend developer documentation](https://developers.home-assistant.io/docs/frontend/) for documented architecture, data flow, design, and development workflows.
|
||||
- For new leaf components, load `ha-frontend-contexts` and verify they consume narrow contexts instead of introducing a broad `hass` property; containers and external APIs may still require `hass`.
|
||||
- Verify backend assumptions against the owning Core, Supervisor, or WebSocket implementation, and component assumptions against the exported component contract.
|
||||
- Prefer canonical repository helpers and test setup over duplicate local implementations.
|
||||
|
||||
@@ -105,4 +105,4 @@ To help with the load of incoming pull requests:
|
||||
Below, some useful links you could explore:
|
||||
-->
|
||||
[docs-repository]: https://github.com/home-assistant/home-assistant.io
|
||||
[perfect-pr]: https://contributors.home-assistant.io/start/pull-requests/#before-you-open-it
|
||||
[perfect-pr]: https://developers.home-assistant.io/docs/review-process/#creating-the-perfect-pr
|
||||
@@ -34,7 +34,7 @@ runs:
|
||||
hashFiles('.nvmrc', 'package.json', 'pnpm-lock.yaml', 'pnpm-workspace.yaml', 'patches/**') }}
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
|
||||
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
with:
|
||||
node-version-file: ".nvmrc"
|
||||
|
||||
|
||||
@@ -1,23 +1,3 @@
|
||||
# Path rules for .github/workflows/labeler.yaml, which runs actions/labeler on
|
||||
# every pull request. A label is added when a pull request changes a matching
|
||||
# file, and removed again when it no longer does, including one added by hand.
|
||||
#
|
||||
# Do not add these labels here. .github/scripts/label-content.mts applies and
|
||||
# removes them from paths and code. A rule here would remove them whenever its
|
||||
# paths don't match, even when the script's code check does:
|
||||
#
|
||||
# - Companion App
|
||||
# - Home Assistant Link
|
||||
# - Labs
|
||||
# - Supervisor
|
||||
|
||||
Activity:
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- src/panels/logbook/**
|
||||
- src/panels/lovelace/**/*logbook*
|
||||
- src/data/logbook.ts
|
||||
|
||||
Agents:
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
@@ -36,36 +16,6 @@ Agents:
|
||||
- .github/skills/**
|
||||
- .github/workflows/copilot-setup-steps.yml
|
||||
|
||||
AI:
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- "src/panels/config/core/{ha-config-section-ai,ai-task-pref,mcp-pref}.ts"
|
||||
- src/components/ha-suggest-with-ai-button.ts
|
||||
- "src/data/{ai_task,llm}.ts"
|
||||
|
||||
Areas:
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- src/panels/config/areas/**
|
||||
- src/data/area/**
|
||||
- "src/data/{area_floor_picker,floor_registry,ws-area_registry,ws-floor_registry}.ts"
|
||||
|
||||
Automations:
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- "src/panels/config/{automation,script,scene,blueprint}/**"
|
||||
- src/components/automation/**
|
||||
- src/components/trace/**
|
||||
- src/data/trace*.ts
|
||||
- "src/data/{automation,automation_i18n,script,script_i18n,scene,blueprint,trigger,condition,action}.ts"
|
||||
|
||||
Backups:
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- src/panels/config/backup/**
|
||||
- src/data/backup*.ts
|
||||
- src/onboarding/onboarding-restore-backup.ts
|
||||
|
||||
Build:
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
@@ -73,37 +23,12 @@ Build:
|
||||
- .browserslistrc
|
||||
- gulpfile.js
|
||||
|
||||
Calendar:
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- src/panels/calendar/**
|
||||
- src/panels/lovelace/**/*calendar*
|
||||
- src/data/calendar.ts
|
||||
|
||||
Cast:
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- cast/src/**
|
||||
- src/cast/**
|
||||
|
||||
Connectivity:
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- src/panels/config/connectivity/**
|
||||
- src/panels/config/tags/**
|
||||
- "src/panels/config/integrations/integration-panels/{bluetooth,infrared,matter,modbus,mqtt,radio_frequency,serial,thread,zha,zwave_js}/**"
|
||||
- "src/data/{bluetooth,infrared,matter,matter-lock,modbus,mqtt,otbr,radio_frequency,tag,thread,usb,zha,zwave_js,zwave_js-credentials}.ts"
|
||||
|
||||
Dashboards:
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- src/panels/lovelace/**
|
||||
- "src/panels/{home,climate,light,security,maintenance,map}/**"
|
||||
- src/panels/config/lovelace/**
|
||||
- "src/panels/config/dashboard/{dashboard-card,dialog-new-dashboard,show-dialog-new-dashboard}.ts"
|
||||
- src/data/lovelace/**
|
||||
- src/data/lovelace*.ts
|
||||
|
||||
Demo:
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
@@ -132,29 +57,6 @@ Dependencies:
|
||||
- "^renovate/"
|
||||
- "^dependabot/"
|
||||
|
||||
Devices:
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- src/panels/config/devices/**
|
||||
- src/data/device/device_registry.ts
|
||||
- src/data/ws-device_registry.ts
|
||||
|
||||
Energy:
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- src/panels/energy/**
|
||||
- src/panels/config/energy/**
|
||||
- src/panels/lovelace/cards/energy/**
|
||||
- src/panels/lovelace/**/*energy*
|
||||
- src/data/energy*.ts
|
||||
|
||||
Entities:
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- src/panels/config/entities/**
|
||||
- src/data/entity/entity_registry*.ts
|
||||
- src/data/ws-entity_registry_display.ts
|
||||
|
||||
GitHub Actions:
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
@@ -162,100 +64,6 @@ GitHub Actions:
|
||||
- .github/workflows/**
|
||||
- .github/*.yml
|
||||
|
||||
Helpers:
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- src/panels/config/helpers/**
|
||||
- src/data/helpers_crud.ts
|
||||
|
||||
History:
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- src/panels/history/**
|
||||
- src/panels/lovelace/**/*history*
|
||||
- src/data/history.ts
|
||||
|
||||
Integrations:
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- src/panels/config/integrations/**
|
||||
- src/panels/config/application_credentials/**
|
||||
- src/dialogs/config-flow/**
|
||||
- src/data/application_credential.ts
|
||||
- src/data/config_entries.ts
|
||||
- "src/data/{config_flow,data_entry_flow,options_flow,sub_config_flow}.ts"
|
||||
|
||||
Labels:
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- src/panels/config/labels/**
|
||||
- src/data/label/**
|
||||
|
||||
Landing Page:
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- landing-page/**
|
||||
|
||||
Marketplace:
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- src/panels/marketplace/**
|
||||
- src/data/marketplace/**
|
||||
- src/panels/config/integrations/offer-marketplace-uninstall.ts
|
||||
|
||||
Media:
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- src/panels/media-browser/**
|
||||
- src/components/media-player/**
|
||||
- src/data/media_source.ts
|
||||
|
||||
Notifications:
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- src/dialogs/notifications/**
|
||||
- src/data/persistent_notification.ts
|
||||
|
||||
Onboarding:
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- src/onboarding/**
|
||||
- src/auth/**
|
||||
- src/data/onboarding.ts
|
||||
- src/data/auth.ts
|
||||
|
||||
People:
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- "src/panels/config/{person,users}/**"
|
||||
- "src/data/{person,user,ws-user}.ts"
|
||||
|
||||
Profile:
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- src/panels/profile/**
|
||||
|
||||
Search:
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- src/dialogs/quick-bar/**
|
||||
- src/state/quick-bar-mixin.ts
|
||||
- "src/data/{quick_bar,search}.ts"
|
||||
|
||||
Sidebar:
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- src/components/ha-sidebar.ts
|
||||
- src/dialogs/sidebar/**
|
||||
|
||||
System:
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- "src/panels/config/{core,hardware,info,logs,network,repairs,storage}/**"
|
||||
- src/panels/config/dashboard/ha-config-updates.ts
|
||||
- "src/dialogs/{repairs-flow,restart}/**"
|
||||
- "src/data/{analytics,entity_id_format,error_log,hardware,network,repairs,system_health,system_log,system_log_report,update}.ts"
|
||||
|
||||
"Tests: E2E":
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
@@ -285,37 +93,3 @@ System:
|
||||
- any-glob-to-any-file:
|
||||
- test/*.ts
|
||||
- "test/!(e2e)/**"
|
||||
|
||||
Theming:
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- src/resources/theme/**
|
||||
- src/state/themes-mixin.ts
|
||||
- src/common/dom/apply_themes_on_element.ts
|
||||
- "src/data/{theme,ws-themes}.ts"
|
||||
|
||||
To-do:
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- src/panels/todo/**
|
||||
- src/panels/lovelace/**/*todo*
|
||||
- src/data/todo.ts
|
||||
|
||||
Tools:
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- src/panels/config/tools/**
|
||||
|
||||
Voice:
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- src/panels/config/voice-assistants/**
|
||||
- "src/dialogs/{voice-assistant-setup,voice-command-dialog}/**"
|
||||
- src/components/ha-assist-chat.ts
|
||||
- "src/data/{assist_pipeline,assist_satellite,conversation,stt,tts,wake_word}.ts"
|
||||
|
||||
Zones:
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- src/panels/config/zone/**
|
||||
- src/data/zone.ts
|
||||
@@ -0,0 +1,38 @@
|
||||
#!/usr/bin/env node
|
||||
// Fails the check when a pull request carries a label that blocks merging, and
|
||||
// writes the outcome to the job summary. Invoked from the `check` job in
|
||||
// .github/workflows/blocking-labels.yaml via actions/github-script:
|
||||
//
|
||||
// const { default: checkBlockingLabels } =
|
||||
// await import(`${process.env.GITHUB_WORKSPACE}/.github/scripts/check-blocking-labels.mjs`);
|
||||
// await checkBlockingLabels({ github, context, core });
|
||||
|
||||
export default async function checkBlockingLabels({ context, core }) {
|
||||
const blockingLabels = [
|
||||
"wait for backend",
|
||||
"Needs UX",
|
||||
"Do Not Review",
|
||||
"Blocked",
|
||||
"has-parent",
|
||||
];
|
||||
const prLabels = context.payload.pull_request.labels.map((l) => l.name);
|
||||
const found = blockingLabels.filter((bl) => prLabels.includes(bl));
|
||||
if (found.length > 0) {
|
||||
const message = `This Pull Request is blocked by label${found.length > 1 ? "s" : ""}: ${found.join(", ")}`;
|
||||
await core.summary
|
||||
.addHeading(":no_entry_sign: Pull Request is blocked", 2)
|
||||
.addRaw(message)
|
||||
.write();
|
||||
core.setFailed(message);
|
||||
} else {
|
||||
await core.summary
|
||||
.addHeading(
|
||||
":white_check_mark: Pull Request is clear to merge after review",
|
||||
2
|
||||
)
|
||||
.addRaw(
|
||||
"This Pull Request is not blocked by any labels which prevent it from being merged."
|
||||
)
|
||||
.write();
|
||||
}
|
||||
}
|
||||
@@ -1,289 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
// Checks npm registry metadata for the direct dependencies in package.json:
|
||||
// deprecation, source repository and provenance. On pull requests only newly
|
||||
// added packages are checked, and a deprecated one fails the check. Scheduled
|
||||
// and manual runs report on every direct dependency without failing. Invoked
|
||||
// from the `npm-metadata` job in .github/workflows/dependency-review.yaml via
|
||||
// actions/github-script:
|
||||
//
|
||||
// const { default: checkNpmMetadata } =
|
||||
// await import(`${process.env.GITHUB_WORKSPACE}/.github/scripts/check-npm-metadata.mts`);
|
||||
// await checkNpmMetadata({ github, context, core });
|
||||
|
||||
import type { Octokit } from "@octokit/rest";
|
||||
import type {
|
||||
Context,
|
||||
Core,
|
||||
GitHubScriptArgs,
|
||||
PullRequestPayload,
|
||||
} from "./github-script.d.ts";
|
||||
import { withRetry } from "./github-retry.mts";
|
||||
|
||||
// Scheduled and manual runs have no pull request
|
||||
type Payload = Partial<PullRequestPayload>;
|
||||
|
||||
type Section =
|
||||
| "dependencies"
|
||||
| "devDependencies"
|
||||
| "optionalDependencies"
|
||||
| "peerDependencies";
|
||||
|
||||
type PackageJson = Partial<Record<Section, Record<string, string>>>;
|
||||
|
||||
interface Dependency {
|
||||
name: string;
|
||||
// The package npm installs, which differs from name for npm: aliases
|
||||
registryName: string;
|
||||
version: string;
|
||||
section: Section;
|
||||
}
|
||||
|
||||
// The fields read from a version document on registry.npmjs.org
|
||||
interface NpmManifest {
|
||||
deprecated?: string;
|
||||
repository?: string | { url?: string };
|
||||
dist?: { attestations?: object };
|
||||
}
|
||||
|
||||
interface Result {
|
||||
name: string;
|
||||
version: string;
|
||||
section: Section;
|
||||
skipped?: string;
|
||||
deprecated?: string;
|
||||
repository?: string;
|
||||
provenance?: boolean;
|
||||
}
|
||||
|
||||
const SECTIONS: Section[] = [
|
||||
"dependencies",
|
||||
"devDependencies",
|
||||
"optionalDependencies",
|
||||
"peerDependencies",
|
||||
];
|
||||
|
||||
const EXACT_VERSION = /^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/;
|
||||
|
||||
const NPM_ALIAS = /^npm:((?:@[^/]+\/)?[^@]+)@(.+)$/;
|
||||
|
||||
const CONCURRENCY = 8;
|
||||
|
||||
// Returns undefined when the registry reports 404, and throws on any other
|
||||
// failure so a registry outage fails the job instead of skipping the check.
|
||||
const fetchJson = async <T,>(url: string): Promise<T | undefined> => {
|
||||
const response = await fetch(url);
|
||||
|
||||
if (response.status === 404) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
`Request to ${url} failed: ${response.status} ${response.statusText}`
|
||||
);
|
||||
}
|
||||
|
||||
return JSON.parse(await response.text());
|
||||
};
|
||||
|
||||
const readPackageJson = async (
|
||||
github: Octokit,
|
||||
context: Context<Payload>,
|
||||
ref: string
|
||||
): Promise<PackageJson> => {
|
||||
const { data } = await withRetry(`package.json lookup at ${ref}`, () =>
|
||||
github.rest.repos.getContent({
|
||||
...context.repo,
|
||||
path: "package.json",
|
||||
ref,
|
||||
mediaType: { format: "raw" },
|
||||
})
|
||||
);
|
||||
|
||||
// The raw media type returns the file contents as a string, but Octokit
|
||||
// types the response as the JSON form
|
||||
if (typeof data !== "string") {
|
||||
throw new Error("Expected raw package.json contents");
|
||||
}
|
||||
|
||||
return JSON.parse(data);
|
||||
};
|
||||
|
||||
const listDependencies = (pkg: PackageJson): Dependency[] =>
|
||||
SECTIONS.flatMap((section) =>
|
||||
Object.entries(pkg[section] ?? {}).map(([name, spec]) => {
|
||||
// npm: aliases install another package, e.g. npm:[email protected]
|
||||
const alias = spec.match(NPM_ALIAS);
|
||||
|
||||
return {
|
||||
name,
|
||||
registryName: alias?.[1] ?? name,
|
||||
version: alias?.[2] ?? spec,
|
||||
section,
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
const repositoryUrl = (repository: NpmManifest["repository"]) => {
|
||||
const url = typeof repository === "string" ? repository : repository?.url;
|
||||
|
||||
return url?.replace(/^git\+/, "").replace(/\.git$/, "");
|
||||
};
|
||||
|
||||
const checkPackage = async ({
|
||||
name,
|
||||
registryName,
|
||||
version,
|
||||
section,
|
||||
}: Dependency): Promise<Result> => {
|
||||
const result = {
|
||||
name: registryName === name ? name : `${name} (npm:${registryName})`,
|
||||
version,
|
||||
section,
|
||||
};
|
||||
|
||||
if (!EXACT_VERSION.test(version)) {
|
||||
return { ...result, skipped: "not an exact version" };
|
||||
}
|
||||
|
||||
const manifest = await fetchJson<NpmManifest>(
|
||||
`https://registry.npmjs.org/${encodeURIComponent(registryName)}/${version}`
|
||||
);
|
||||
|
||||
if (!manifest) {
|
||||
return { ...result, skipped: "not found on the npm registry" };
|
||||
}
|
||||
|
||||
return {
|
||||
...result,
|
||||
deprecated: manifest.deprecated,
|
||||
repository: repositoryUrl(manifest.repository),
|
||||
provenance: Boolean(manifest.dist?.attestations),
|
||||
};
|
||||
};
|
||||
|
||||
const checkAll = async (dependencies: Dependency[]) => {
|
||||
const results: Result[] = [];
|
||||
|
||||
for (let i = 0; i < dependencies.length; i += CONCURRENCY) {
|
||||
results.push(
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
...(await Promise.all(
|
||||
dependencies.slice(i, i + CONCURRENCY).map(checkPackage)
|
||||
))
|
||||
);
|
||||
}
|
||||
|
||||
return results;
|
||||
};
|
||||
|
||||
const writeSummary = async (core: Core, heading: string, results: Result[]) => {
|
||||
await core.summary
|
||||
.addHeading(heading, 2)
|
||||
.addTable([
|
||||
[
|
||||
"Package",
|
||||
"Version",
|
||||
"Section",
|
||||
"Deprecated",
|
||||
"Provenance",
|
||||
"Repository",
|
||||
].map((data) => ({ data, header: true })),
|
||||
...results.map((result) =>
|
||||
result.skipped
|
||||
? [
|
||||
result.name,
|
||||
result.version,
|
||||
result.section,
|
||||
`skipped: ${result.skipped}`,
|
||||
"-",
|
||||
"-",
|
||||
]
|
||||
: [
|
||||
result.name,
|
||||
result.version,
|
||||
result.section,
|
||||
result.deprecated ? "yes" : "no",
|
||||
result.provenance ? "yes" : "no",
|
||||
result.repository ?? "none",
|
||||
]
|
||||
),
|
||||
])
|
||||
.write();
|
||||
};
|
||||
|
||||
export default async function checkNpmMetadata({
|
||||
github,
|
||||
context,
|
||||
core,
|
||||
}: GitHubScriptArgs<Payload>) {
|
||||
const pr = context.payload.pull_request;
|
||||
|
||||
if (!pr) {
|
||||
const pkg = await readPackageJson(github, context, context.sha);
|
||||
const results = await checkAll(listDependencies(pkg));
|
||||
|
||||
const flagged = (result: Result) =>
|
||||
Number(
|
||||
Boolean(result.skipped || result.deprecated || !result.repository)
|
||||
);
|
||||
|
||||
results.sort(
|
||||
(a, b) => flagged(b) - flagged(a) || a.name.localeCompare(b.name)
|
||||
);
|
||||
await writeSummary(core, "Direct dependencies", results);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const [base, head] = await Promise.all([
|
||||
readPackageJson(github, context, pr.base.sha),
|
||||
readPackageJson(github, context, pr.head.sha),
|
||||
]);
|
||||
|
||||
// Compare the packages npm installs, so pointing an npm: alias at another
|
||||
// package counts as new, but moving a package between sections does not
|
||||
const existing = new Set(
|
||||
listDependencies(base).map(({ registryName }) => registryName)
|
||||
);
|
||||
|
||||
const added = listDependencies(head).filter(
|
||||
({ registryName }) => !existing.has(registryName)
|
||||
);
|
||||
|
||||
if (added.length === 0) {
|
||||
core.info("No new dependencies");
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const results = await checkAll(added);
|
||||
const annotation = { file: "package.json" };
|
||||
|
||||
for (const result of results) {
|
||||
const label = `${result.name}@${result.version}`;
|
||||
|
||||
if (result.skipped) {
|
||||
core.warning(`${label}: ${result.skipped}, not checked`, annotation);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (result.deprecated) {
|
||||
core.error(`${label} is deprecated: ${result.deprecated}`, annotation);
|
||||
}
|
||||
|
||||
if (!result.repository) {
|
||||
core.warning(`${label} has no source repository`, annotation);
|
||||
}
|
||||
|
||||
if (!result.provenance) {
|
||||
core.notice(`${label} was published without provenance`, annotation);
|
||||
}
|
||||
}
|
||||
|
||||
await writeSummary(core, "New dependencies", results);
|
||||
|
||||
if (results.some((result) => result.deprecated)) {
|
||||
core.setFailed("New dependencies must not be deprecated on npm");
|
||||
}
|
||||
}
|
||||
+39
-64
@@ -6,20 +6,14 @@
|
||||
// via actions/github-script:
|
||||
//
|
||||
// const { default: checkStandards } =
|
||||
// await import(`${process.env.GITHUB_WORKSPACE}/.github/scripts/check-pull-request-standards.mts`);
|
||||
// await import(`${process.env.GITHUB_WORKSPACE}/.github/scripts/check-pull-request-standards.mjs`);
|
||||
// await checkStandards({ github, context, core });
|
||||
|
||||
import type {
|
||||
GitHubScriptArgs,
|
||||
PullRequestPayload,
|
||||
} from "./github-script.d.ts";
|
||||
import { withRetry } from "./github-retry.mts";
|
||||
|
||||
export default async function checkPullRequestStandards({
|
||||
github,
|
||||
context,
|
||||
core,
|
||||
}: GitHubScriptArgs<PullRequestPayload>) {
|
||||
}) {
|
||||
const pr = context.payload.pull_request;
|
||||
|
||||
// Exempt bots (Copilot agent, dependabot), drafts, and maintainers.
|
||||
@@ -32,12 +26,10 @@ export default async function checkPullRequestStandards({
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await withRetry("organization membership check", () =>
|
||||
github.rest.orgs.checkMembershipForUser({
|
||||
org: "home-assistant",
|
||||
username: pr.user.login,
|
||||
})
|
||||
);
|
||||
await github.rest.orgs.checkMembershipForUser({
|
||||
org: "home-assistant",
|
||||
username: pr.user.login,
|
||||
});
|
||||
core.info(`Skipping organization member: ${pr.user.login}`);
|
||||
return;
|
||||
} catch (_error) {
|
||||
@@ -60,16 +52,11 @@ export default async function checkPullRequestStandards({
|
||||
const normalized = body.toLowerCase();
|
||||
|
||||
// Ignore 404s from mutations that race manual edits or cancelled runs.
|
||||
const ignoreMissing = async <T,>(fn: () => Promise<T>) => {
|
||||
const ignoreMissing = async (fn) => {
|
||||
try {
|
||||
await fn();
|
||||
} catch (error) {
|
||||
if (
|
||||
typeof error === "object" &&
|
||||
error !== null &&
|
||||
"status" in error &&
|
||||
error.status === 404
|
||||
) {
|
||||
if (error.status === 404) {
|
||||
core.info("Target already removed, nothing to do");
|
||||
return;
|
||||
}
|
||||
@@ -78,7 +65,7 @@ export default async function checkPullRequestStandards({
|
||||
};
|
||||
|
||||
// Hide/restore our comment via GraphQL (REST cannot minimize).
|
||||
const setMinimized = async (subjectId: string, minimized: boolean) => {
|
||||
const setMinimized = async (subjectId, minimized) => {
|
||||
const mutation = minimized
|
||||
? `mutation($id: ID!) {
|
||||
minimizeComment(input: { subjectId: $id, classifier: RESOLVED }) {
|
||||
@@ -91,18 +78,16 @@ export default async function checkPullRequestStandards({
|
||||
}
|
||||
}`;
|
||||
try {
|
||||
await withRetry(`comment ${minimized ? "minimize" : "restore"}`, () =>
|
||||
github.graphql(mutation, { id: subjectId })
|
||||
);
|
||||
await github.graphql(mutation, { id: subjectId });
|
||||
} catch (error) {
|
||||
core.info(
|
||||
`Could not ${minimized ? "minimize" : "restore"} comment: ${error instanceof Error ? error.message : error}`
|
||||
`Could not ${minimized ? "minimize" : "restore"} comment: ${error.message}`
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
// Content of a "## <name>" section, or null when the heading is absent.
|
||||
const section = (name: string) => {
|
||||
const section = (name) => {
|
||||
const match = body.match(
|
||||
new RegExp(`##\\s${name}([\\s\\S]*?)(?=\\n##\\s|$)`, "i")
|
||||
);
|
||||
@@ -137,16 +122,13 @@ export default async function checkPullRequestStandards({
|
||||
|
||||
const isValid = problems.length === 0;
|
||||
|
||||
const comments = await withRetry("comment list", () =>
|
||||
github.paginate(github.rest.issues.listComments, {
|
||||
owner,
|
||||
repo,
|
||||
issue_number,
|
||||
per_page: 100,
|
||||
})
|
||||
);
|
||||
|
||||
const existing = comments.find((c) => c.body?.includes(marker));
|
||||
const comments = await github.paginate(github.rest.issues.listComments, {
|
||||
owner,
|
||||
repo,
|
||||
issue_number,
|
||||
per_page: 100,
|
||||
});
|
||||
const existing = comments.find((c) => c.body.includes(marker));
|
||||
const hasLabel = pr.labels.some((l) => l.name === label);
|
||||
|
||||
if (isValid) {
|
||||
@@ -154,14 +136,12 @@ export default async function checkPullRequestStandards({
|
||||
|
||||
if (hasLabel) {
|
||||
await ignoreMissing(() =>
|
||||
withRetry("label removal", () =>
|
||||
github.rest.issues.removeLabel({
|
||||
owner,
|
||||
repo,
|
||||
issue_number,
|
||||
name: label,
|
||||
})
|
||||
)
|
||||
github.rest.issues.removeLabel({
|
||||
owner,
|
||||
repo,
|
||||
issue_number,
|
||||
name: label,
|
||||
})
|
||||
);
|
||||
}
|
||||
if (existing) {
|
||||
@@ -173,14 +153,12 @@ export default async function checkPullRequestStandards({
|
||||
core.info(`Pull request standards not met:\n- ${problems.join("\n- ")}`);
|
||||
|
||||
if (!hasLabel) {
|
||||
await withRetry("label addition", () =>
|
||||
github.rest.issues.addLabels({
|
||||
owner,
|
||||
repo,
|
||||
issue_number,
|
||||
labels: [label],
|
||||
})
|
||||
);
|
||||
await github.rest.issues.addLabels({
|
||||
owner,
|
||||
repo,
|
||||
issue_number,
|
||||
labels: [label],
|
||||
});
|
||||
}
|
||||
|
||||
const message =
|
||||
@@ -192,21 +170,18 @@ export default async function checkPullRequestStandards({
|
||||
`\n\n` +
|
||||
`Please complete the ` +
|
||||
`[PR template](https://github.com/home-assistant/frontend/blob/dev/.github/PULL_REQUEST_TEMPLATE.md?plain=1) ` +
|
||||
`and see the [contributor docs](https://contributors.home-assistant.io/start/pull-requests/#fill-out-the-template) ` +
|
||||
`for more on creating a great pull request.`;
|
||||
`and see the [developer docs](https://developers.home-assistant.io/docs/review-process) ` +
|
||||
`for more on creating a great pull request (see point 6).`;
|
||||
|
||||
if (existing) {
|
||||
await withRetry("comment update", () =>
|
||||
github.rest.issues.updateComment({
|
||||
owner,
|
||||
repo,
|
||||
comment_id: existing.id,
|
||||
body: message,
|
||||
})
|
||||
);
|
||||
await github.rest.issues.updateComment({
|
||||
owner,
|
||||
repo,
|
||||
comment_id: existing.id,
|
||||
body: message,
|
||||
});
|
||||
await setMinimized(existing.node_id, false);
|
||||
} else {
|
||||
// Not retried, as a retry after GitHub created the comment would post it twice
|
||||
await github.rest.issues.createComment({
|
||||
owner,
|
||||
repo,
|
||||
+19
-29
@@ -5,35 +5,29 @@
|
||||
// actions/github-script:
|
||||
//
|
||||
// const { default: checkTaskAuthorization } =
|
||||
// await import(`${process.env.GITHUB_WORKSPACE}/.github/scripts/check-task-authorization.mts`);
|
||||
// await import(`${process.env.GITHUB_WORKSPACE}/.github/scripts/check-task-authorization.mjs`);
|
||||
// await checkTaskAuthorization({ github, context, core });
|
||||
|
||||
import type { GitHubScriptArgs, IssuePayload } from "./github-script.d.ts";
|
||||
import { withRetry } from "./github-retry.mts";
|
||||
|
||||
export default async function checkTaskAuthorization({
|
||||
github,
|
||||
context,
|
||||
core,
|
||||
}: GitHubScriptArgs<IssuePayload>) {
|
||||
}) {
|
||||
const issueAuthor = context.payload.issue.user.login;
|
||||
|
||||
// Check if user is an organization member
|
||||
try {
|
||||
await withRetry("organization membership check", () =>
|
||||
github.rest.orgs.checkMembershipForUser({
|
||||
org: "home-assistant",
|
||||
username: issueAuthor,
|
||||
})
|
||||
);
|
||||
await github.rest.orgs.checkMembershipForUser({
|
||||
org: "home-assistant",
|
||||
username: issueAuthor,
|
||||
});
|
||||
core.info(`✅ ${issueAuthor} is an organization member`);
|
||||
return; // Authorized
|
||||
} catch (_error) {
|
||||
core.info(`❌ ${issueAuthor} is not authorized to create Task issues`);
|
||||
}
|
||||
|
||||
// Close the issue with a comment. Not retried, as a retry after GitHub
|
||||
// created the comment would post it twice.
|
||||
// Close the issue with a comment
|
||||
await github.rest.issues.createComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
@@ -47,22 +41,18 @@ export default async function checkTaskAuthorization({
|
||||
`If you believe you should have access to create Task issues, please contact the maintainers.`,
|
||||
});
|
||||
|
||||
await withRetry("issue close", () =>
|
||||
github.rest.issues.update({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: context.issue.number,
|
||||
state: "closed",
|
||||
})
|
||||
);
|
||||
await github.rest.issues.update({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: context.issue.number,
|
||||
state: "closed",
|
||||
});
|
||||
|
||||
// Add a label to indicate this was auto-closed
|
||||
await withRetry("auto-closed label", () =>
|
||||
github.rest.issues.addLabels({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: context.issue.number,
|
||||
labels: ["auto-closed"],
|
||||
})
|
||||
);
|
||||
await github.rest.issues.addLabels({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: context.issue.number,
|
||||
labels: ["auto-closed"],
|
||||
});
|
||||
}
|
||||
@@ -1,69 +0,0 @@
|
||||
// Retries GitHub API requests with jittered exponential backoff. Shared by the
|
||||
// workflow scripts in this directory, test/e2e/post-report-comment.mjs and the
|
||||
// fetch-nightly-translations gulp task. Only wrap requests that are safe to
|
||||
// repeat: a create that fails after GitHub has applied it would run twice.
|
||||
//
|
||||
// This replaces @octokit/plugin-retry, which only retries HTTP error statuses
|
||||
// on a fixed 1s, 4s, 9s delay with no jitter or logging. GitHub can also
|
||||
// answer 200 with briefly stale data, such as the nightly translations lookup
|
||||
// returning an old run with no artifact, and the plugin cannot retry that.
|
||||
// github-script also leaves its built-in retries off by default.
|
||||
|
||||
const RETRIES = 3;
|
||||
|
||||
const BASE_DELAY = 1000; // ms
|
||||
|
||||
const MAX_DELAY = 10000; // ms
|
||||
|
||||
const TRANSIENT_STATUSES = new Set([408, 429, 500, 502, 503, 504]);
|
||||
|
||||
// Throw from a request to retry a response GitHub may answer differently on
|
||||
// the next attempt, such as briefly stale data
|
||||
export class RetryableError extends Error {}
|
||||
|
||||
// Octokit reports network failures as status 500
|
||||
const isRetryable = (err: unknown) => {
|
||||
if (err instanceof RetryableError) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!(err instanceof Error) || !("status" in err)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const status = Number(err.status);
|
||||
|
||||
return (
|
||||
TRANSIENT_STATUSES.has(status) ||
|
||||
(status === 403 && /rate limit/i.test(err.message))
|
||||
);
|
||||
};
|
||||
|
||||
export async function withRetry<T>(
|
||||
label: string,
|
||||
request: () => Promise<T>
|
||||
): Promise<T> {
|
||||
for (let attempt = 1; ; attempt++) {
|
||||
try {
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
return await request();
|
||||
} catch (err) {
|
||||
if (attempt > RETRIES || !isRetryable(err)) {
|
||||
throw err;
|
||||
}
|
||||
|
||||
const delay =
|
||||
Math.min(BASE_DELAY * 2 ** (attempt - 1), MAX_DELAY) *
|
||||
(0.8 + Math.random() * 0.4);
|
||||
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(
|
||||
`Retrying ${label} after ${(delay / 1000).toFixed(1)}s (attempt ${attempt}/${RETRIES}): ${err instanceof Error ? err.message : err}`
|
||||
);
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
await new Promise((resolve) => {
|
||||
setTimeout(resolve, delay);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,61 +0,0 @@
|
||||
// The arguments actions/github-script passes to the scripts in this directory,
|
||||
// limited to the parts they use.
|
||||
import type { Octokit } from "@octokit/rest";
|
||||
|
||||
export type SummaryTableCell = string | { data: string; header?: boolean };
|
||||
|
||||
export interface Summary {
|
||||
addHeading(text: string, level?: number): Summary;
|
||||
addRaw(text: string): Summary;
|
||||
addTable(rows: SummaryTableCell[][]): Summary;
|
||||
write(): Promise<Summary>;
|
||||
}
|
||||
|
||||
export interface AnnotationProperties {
|
||||
file?: string;
|
||||
}
|
||||
|
||||
export interface Core {
|
||||
info(message: string): void;
|
||||
notice(message: string, properties?: AnnotationProperties): void;
|
||||
warning(message: string, properties?: AnnotationProperties): void;
|
||||
error(message: string, properties?: AnnotationProperties): void;
|
||||
setFailed(message: string): void;
|
||||
summary: Summary;
|
||||
}
|
||||
|
||||
export interface User {
|
||||
login: string;
|
||||
type: string;
|
||||
}
|
||||
|
||||
export interface PullRequestPayload {
|
||||
pull_request: {
|
||||
number: number;
|
||||
body: string | null;
|
||||
draft: boolean;
|
||||
user: User;
|
||||
labels: { name: string }[];
|
||||
base: { sha: string };
|
||||
head: { sha: string };
|
||||
};
|
||||
}
|
||||
|
||||
export interface IssuePayload {
|
||||
issue: {
|
||||
user: User;
|
||||
};
|
||||
}
|
||||
|
||||
export interface Context<Payload> {
|
||||
sha: string;
|
||||
repo: { owner: string; repo: string };
|
||||
issue: { owner: string; repo: string; number: number };
|
||||
payload: Payload;
|
||||
}
|
||||
|
||||
export interface GitHubScriptArgs<Payload> {
|
||||
github: Octokit;
|
||||
context: Context<Payload>;
|
||||
core: Core;
|
||||
}
|
||||
@@ -1,56 +0,0 @@
|
||||
/**
|
||||
* Builds a section of a workflow run's summary: a heading, a table of rows and
|
||||
* the warnings raised along the way.
|
||||
*
|
||||
* Warnings are also logged as annotations, so a script can report problems
|
||||
* without failing its step.
|
||||
*/
|
||||
|
||||
import type { AnnotationProperties, Core } from "./github-script.d.ts";
|
||||
|
||||
interface SummaryOptions {
|
||||
heading: string;
|
||||
columns: string[];
|
||||
// Shown instead of the table when no rows were added
|
||||
empty: string;
|
||||
}
|
||||
|
||||
export const createSummary = (
|
||||
core: Core,
|
||||
{ heading, columns, empty }: SummaryOptions
|
||||
) => {
|
||||
const rows: string[][] = [];
|
||||
const warnings: string[] = [];
|
||||
|
||||
return {
|
||||
addRow: (...cells: string[]) => {
|
||||
rows.push(cells);
|
||||
},
|
||||
|
||||
warn: (message: string, properties?: AnnotationProperties) => {
|
||||
core.warning(message, properties);
|
||||
warnings.push(message);
|
||||
},
|
||||
|
||||
write: async () => {
|
||||
core.summary.addHeading(heading, 2);
|
||||
|
||||
if (rows.length > 0) {
|
||||
core.summary.addTable([
|
||||
columns.map((data) => ({ data, header: true })),
|
||||
...rows,
|
||||
]);
|
||||
} else {
|
||||
core.summary.addRaw(`${empty}\n`);
|
||||
}
|
||||
|
||||
if (warnings.length > 0) {
|
||||
core.summary
|
||||
.addHeading("Warnings", 3)
|
||||
.addRaw(`${warnings.map((warning) => `- ${warning}`).join("\n")}\n`);
|
||||
}
|
||||
|
||||
await core.summary.write();
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -1,324 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Applies the Companion App, Home Assistant Link, Labs and Supervisor labels
|
||||
* to pull requests.
|
||||
*
|
||||
* These areas are often changed from code outside their folders, which
|
||||
* actions/labeler can't see, so this script owns them and .github/labeler.yml
|
||||
* leaves them out.
|
||||
*
|
||||
* A label matches when the pull request:
|
||||
* - changes a file in one of the area's paths, or
|
||||
* - adds or removes a line that uses the area's API, such as gating a feature
|
||||
* behind Labs or only showing it on Supervisor installs.
|
||||
*
|
||||
* Like actions/labeler, a label is removed once the pull request no longer
|
||||
* matches, including one added by hand.
|
||||
*
|
||||
* Invoked from the `triage` job in .github/workflows/labeler.yaml.
|
||||
*/
|
||||
|
||||
import type {
|
||||
GitHubScriptArgs,
|
||||
PullRequestPayload,
|
||||
} from "./github-script.d.ts";
|
||||
import { withRetry } from "./github-retry.mts";
|
||||
import { createSummary } from "./github-summary.mts";
|
||||
|
||||
const RULES: { label: string; paths: RegExp[]; pattern: RegExp }[] = [
|
||||
{
|
||||
label: "Companion App",
|
||||
paths: [
|
||||
/^src\/external_app\//,
|
||||
/^src\/data\/external\.ts$/,
|
||||
/^src\/util\/is_ios\.ts$/,
|
||||
],
|
||||
// Reads or messages the companion app the frontend is running in, or
|
||||
// imports its modules. Matches imports of data/external rather than
|
||||
// isExternal, which is also a common local name.
|
||||
pattern:
|
||||
/\bauth\??\.external\b(?:[!?]*\.\w+)*(?:\([^)]*)?|\bfireExternalBusMessage\([^)]*|(?:\bimport\s[^;]*?from\s*|\bimport\(\s*)["'][^"']*\/(?:external_app\/[^"']*|data\/external)["']/g,
|
||||
},
|
||||
{
|
||||
label: "Home Assistant Link",
|
||||
paths: [
|
||||
/^src\/panels\/config\/cloud\//,
|
||||
/^src\/data\/cloud\//,
|
||||
/^src\/data\/cloud\.ts$/,
|
||||
],
|
||||
// Only runs when Home Assistant Link is set up
|
||||
pattern: /isComponentLoaded\([^)]*["']cloud["']/g,
|
||||
},
|
||||
{
|
||||
label: "Labs",
|
||||
paths: [/^src\/panels\/config\/labs\//, /^src\/data\/labs\.ts$/],
|
||||
// Gates a feature behind, or toggles, a Labs preview feature
|
||||
pattern:
|
||||
/\b(?:subscribeLabFeatures?|fetchLabFeatures|labsUpdatePreviewFeature)\([^)]*/g,
|
||||
},
|
||||
{
|
||||
label: "Supervisor",
|
||||
paths: [
|
||||
/^src\/panels\/config\/apps\//,
|
||||
/^src\/panels\/app\//,
|
||||
/^src\/data\/hassio\//,
|
||||
/^src\/data\/supervisor\//,
|
||||
],
|
||||
// Only runs on installs with the Supervisor
|
||||
pattern: /isComponentLoaded\([^)]*["']hassio["']/g,
|
||||
},
|
||||
];
|
||||
|
||||
interface HunkSide {
|
||||
lines: string[];
|
||||
changed: boolean[];
|
||||
}
|
||||
|
||||
interface CodeChange {
|
||||
code: string;
|
||||
filename: string;
|
||||
}
|
||||
|
||||
const parseHunks = (patch: string) => {
|
||||
const hunks: { before: HunkSide; after: HunkSide }[] = [];
|
||||
|
||||
for (const line of patch.split("\n")) {
|
||||
if (line.startsWith("@@")) {
|
||||
hunks.push({
|
||||
before: { lines: [], changed: [] },
|
||||
after: { lines: [], changed: [] },
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
const hunk = hunks.at(-1);
|
||||
|
||||
if (!hunk || line.startsWith("\\")) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const text = line.slice(1);
|
||||
|
||||
if (!line.startsWith("+")) {
|
||||
hunk.before.lines.push(text);
|
||||
hunk.before.changed.push(line.startsWith("-"));
|
||||
}
|
||||
|
||||
if (!line.startsWith("-")) {
|
||||
hunk.after.lines.push(text);
|
||||
hunk.after.changed.push(line.startsWith("+"));
|
||||
}
|
||||
}
|
||||
|
||||
return hunks;
|
||||
};
|
||||
|
||||
// Ignores whitespace and trailing commas outside strings, as Prettier changes
|
||||
// them when wrapping
|
||||
const normaliseCode = (code: string) => {
|
||||
const parts = code.split(
|
||||
/("(?:[^"\\\n]|\\.)*"|'(?:[^'\\\n]|\\.)*'|`(?:[^`\\]|\\.)*`)/
|
||||
);
|
||||
|
||||
return parts
|
||||
.map((part, i) => {
|
||||
if (i % 2 === 1) {
|
||||
return part;
|
||||
}
|
||||
|
||||
const stripped = part.replace(/\s+/g, "").replace(/,(?=[}\])])/g, "");
|
||||
|
||||
return i === parts.length - 1 ? stripped.replace(/,$/, "") : stripped;
|
||||
})
|
||||
.join("");
|
||||
};
|
||||
|
||||
// Matches whole calls, which can span lines, that touch a changed line
|
||||
const changedMatches = (side: HunkSide, pattern: RegExp) => {
|
||||
const text = side.lines.join("\n");
|
||||
const matches: string[] = [];
|
||||
|
||||
for (const match of text.matchAll(pattern)) {
|
||||
const first = text.slice(0, match.index).split("\n").length - 1;
|
||||
const last = first + match[0].split("\n").length - 1;
|
||||
|
||||
if (side.changed.slice(first, last + 1).some(Boolean)) {
|
||||
matches.push(normaliseCode(match[0]));
|
||||
}
|
||||
}
|
||||
|
||||
return matches;
|
||||
};
|
||||
|
||||
// Matches that were only moved, reindented or reformatted cancel out in pairs,
|
||||
// returning a file with a match that doesn't
|
||||
const unmatchedChange = (added: CodeChange[], removed: CodeChange[]) => {
|
||||
const remaining = [...removed];
|
||||
|
||||
for (const change of added) {
|
||||
const index = remaining.findIndex(({ code }) => code === change.code);
|
||||
|
||||
if (index === -1) {
|
||||
return change.filename;
|
||||
}
|
||||
|
||||
remaining.splice(index, 1);
|
||||
}
|
||||
|
||||
return remaining[0]?.filename;
|
||||
};
|
||||
|
||||
const describeError = (cause: unknown) =>
|
||||
cause instanceof Error ? cause.message : String(cause);
|
||||
|
||||
const isNotFound = (cause: unknown) =>
|
||||
cause instanceof Error && "status" in cause && cause.status === 404;
|
||||
|
||||
export default async function labelContent({
|
||||
github,
|
||||
context,
|
||||
core,
|
||||
}: GitHubScriptArgs<PullRequestPayload>) {
|
||||
const pr = context.payload.pull_request;
|
||||
|
||||
const summary = createSummary(core, {
|
||||
heading: "Content labels",
|
||||
columns: ["Label", "Change", "Reason"],
|
||||
empty: "No content labels changed.",
|
||||
});
|
||||
|
||||
if (process.env.LABELER_OUTCOME === "failure") {
|
||||
summary.warn(
|
||||
"Applying labels from .github/labeler.yml failed, see its step"
|
||||
);
|
||||
}
|
||||
|
||||
// Bot pull requests, such as Prettier bumps, rewrite code they don't change
|
||||
const checkCode = pr.user.type !== "Bot";
|
||||
|
||||
const { owner, repo } = context.repo;
|
||||
|
||||
let files;
|
||||
let existing: Set<string>;
|
||||
|
||||
try {
|
||||
// Read the current labels, as the event's snapshot can be stale by now
|
||||
const [fileList, labels] = await Promise.all([
|
||||
withRetry("pull request files", () =>
|
||||
github.paginate(github.rest.pulls.listFiles, {
|
||||
owner,
|
||||
repo,
|
||||
pull_number: pr.number,
|
||||
per_page: 100,
|
||||
})
|
||||
),
|
||||
withRetry("pull request labels", () =>
|
||||
github.paginate(github.rest.issues.listLabelsOnIssue, {
|
||||
owner,
|
||||
repo,
|
||||
issue_number: pr.number,
|
||||
per_page: 100,
|
||||
})
|
||||
),
|
||||
]);
|
||||
|
||||
files = fileList;
|
||||
existing = new Set(labels.map((l) => l.name));
|
||||
} catch (error) {
|
||||
summary.warn(
|
||||
`Could not read the pull request's files and labels: ${describeError(error)}`
|
||||
);
|
||||
await summary.write();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const add: { label: string; reason: string }[] = [];
|
||||
const remove: string[] = [];
|
||||
|
||||
for (const rule of RULES) {
|
||||
const added: CodeChange[] = [];
|
||||
const removed: CodeChange[] = [];
|
||||
|
||||
if (checkCode) {
|
||||
for (const { filename, patch } of files) {
|
||||
for (const { before, after } of parseHunks(patch ?? "")) {
|
||||
for (const code of changedMatches(after, rule.pattern)) {
|
||||
added.push({ code, filename });
|
||||
}
|
||||
|
||||
for (const code of changedMatches(before, rule.pattern)) {
|
||||
removed.push({ code, filename });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Renames count for both locations, as moving a file out of an area changes it
|
||||
const match =
|
||||
files.find(({ filename, previous_filename }) =>
|
||||
rule.paths.some(
|
||||
(path) =>
|
||||
path.test(filename) ||
|
||||
(previous_filename !== undefined && path.test(previous_filename))
|
||||
)
|
||||
)?.filename ?? unmatchedChange(added, removed);
|
||||
|
||||
if (match && !existing.has(rule.label)) {
|
||||
core.info(`Adding ${rule.label} for ${match}`);
|
||||
add.push({ label: rule.label, reason: match });
|
||||
} else if (!match && existing.has(rule.label)) {
|
||||
core.info(`Removing ${rule.label}, no longer matched`);
|
||||
remove.push(rule.label);
|
||||
}
|
||||
}
|
||||
|
||||
const removals = await Promise.allSettled(
|
||||
remove.map((name) =>
|
||||
withRetry("label removal", () =>
|
||||
github.rest.issues.removeLabel({
|
||||
owner,
|
||||
repo,
|
||||
issue_number: pr.number,
|
||||
name,
|
||||
})
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
removals.forEach((removal, i) => {
|
||||
const name = remove[i];
|
||||
|
||||
// Already removed, such as by hand or a run that raced this one
|
||||
if (removal.status === "fulfilled" || isNotFound(removal.reason)) {
|
||||
summary.addRow(name, "Removed", "No longer matched");
|
||||
} else {
|
||||
summary.warn(
|
||||
`Could not remove ${name}: ${describeError(removal.reason)}`
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
if (add.length > 0) {
|
||||
try {
|
||||
await withRetry("label addition", () =>
|
||||
github.rest.issues.addLabels({
|
||||
owner,
|
||||
repo,
|
||||
issue_number: pr.number,
|
||||
labels: add.map(({ label }) => label),
|
||||
})
|
||||
);
|
||||
|
||||
for (const { label, reason } of add) {
|
||||
summary.addRow(label, "Added", reason);
|
||||
}
|
||||
} catch (error) {
|
||||
summary.warn(
|
||||
`Could not add ${add.map(({ label }) => label).join(", ")}: ${describeError(error)}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
await summary.write();
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2024",
|
||||
"lib": ["ES2024"],
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
// actions/github-script imports these directly, relying on Node's type stripping
|
||||
"erasableSyntaxOnly": true,
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"noEmit": true,
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"skipLibCheck": true,
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["*.mts", "*.d.ts"]
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
name: Blocking labels
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types:
|
||||
- opened
|
||||
- synchronize
|
||||
- reopened
|
||||
- labeled
|
||||
- unlabeled
|
||||
branches:
|
||||
- dev
|
||||
- master
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
check:
|
||||
name: Check for labels which block the Pull Request from being merged
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out workflow scripts
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
sparse-checkout: .github/scripts
|
||||
- name: Check for blocking labels
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
with:
|
||||
script: |
|
||||
const { default: checkBlockingLabels } = await import(
|
||||
`${process.env.GITHUB_WORKSPACE}/.github/scripts/check-blocking-labels.mjs`
|
||||
);
|
||||
await checkBlockingLabels({ github, context, core });
|
||||
@@ -100,9 +100,12 @@ jobs:
|
||||
run: pnpm run test
|
||||
build:
|
||||
name: Build frontend
|
||||
needs:
|
||||
- lint
|
||||
- test
|
||||
# Runs alongside lint and test rather than after them: the build only needs
|
||||
# the dependency tree, and with the rspack cache it is no longer expensive
|
||||
# enough to be worth serialising behind the other checks. The
|
||||
# cancel-on-failure job below stops the run as soon as a check fails, so a
|
||||
# broken pull request does not finish building.
|
||||
needs: prepare-dependencies
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out files from GitHub
|
||||
@@ -134,7 +137,7 @@ jobs:
|
||||
is-test: true
|
||||
rspack-cache: readonly
|
||||
- name: Upload bundle stats
|
||||
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: frontend-bundle-stats
|
||||
path: build/stats/*.json
|
||||
@@ -142,9 +145,60 @@ jobs:
|
||||
- name: Check entrypoint bundle size budget
|
||||
run: pnpm run check-bundlesize
|
||||
- name: Upload frontend build
|
||||
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: frontend-build
|
||||
path: hass_frontend/
|
||||
if-no-files-found: error
|
||||
retention-days: 7
|
||||
|
||||
# Now that the checks run in parallel, a failing lint or test no longer stops
|
||||
# the build from finishing on its own, so this watches them and cancels the
|
||||
# whole run on the first failure.
|
||||
#
|
||||
# It is a separate job on purpose. Cancelling needs `actions: write`, and the
|
||||
# other jobs check out the pull request and run its build scripts — handing
|
||||
# them that scope would give PR-controlled code (or a compromised dependency)
|
||||
# write access to Actions. This job never checks out the repository, so the
|
||||
# elevated token stays away from PR code. It also cannot be a job that
|
||||
# `needs` the checks: that would only start once they have all finished, which
|
||||
# is exactly too late to cancel anything.
|
||||
cancel-on-failure:
|
||||
name: Cancel run on failure
|
||||
needs: prepare-dependencies
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
actions: write
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- name: Cancel the run when a check fails
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
REPO: ${{ github.repository }}
|
||||
RUN_ID: ${{ github.run_id }}
|
||||
run: |
|
||||
watched='^(Lint and check format|Run tests|Build frontend)$'
|
||||
while :; do
|
||||
jobs=$(gh api "repos/$REPO/actions/runs/$RUN_ID/jobs?per_page=100" \
|
||||
--paginate --jq '.jobs[] | [.name, .status, (.conclusion // "")] | @tsv' \
|
||||
2>/dev/null || true)
|
||||
|
||||
failed=$(printf '%s\n' "$jobs" | awk -F'\t' -v w="$watched" \
|
||||
'$1 ~ w && ($3 == "failure" || $3 == "timed_out") { print $1 }')
|
||||
if [ -n "$failed" ]; then
|
||||
echo "Cancelling the run, these checks failed:"
|
||||
printf '%s\n' "$failed"
|
||||
gh run cancel "$RUN_ID" --repo "$REPO" || true
|
||||
exit 0
|
||||
fi
|
||||
|
||||
found=$(printf '%s\n' "$jobs" | awk -F'\t' -v w="$watched" '$1 ~ w' | wc -l)
|
||||
running=$(printf '%s\n' "$jobs" | awk -F'\t' -v w="$watched" \
|
||||
'$1 ~ w && $2 != "completed" { print $1 }')
|
||||
if [ "$found" -ge 3 ] && [ -z "$running" ]; then
|
||||
echo "All checks finished without failure"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
sleep 15
|
||||
done
|
||||
@@ -32,12 +32,12 @@ jobs:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@24c54180a607b1449ed407dd24f251e4e9147c8d # v4.38.3
|
||||
uses: github/codeql-action/init@1c5b675653bb5c22dbe9b12b556ec555138e09fd # v4.38.1
|
||||
with:
|
||||
languages: javascript-typescript
|
||||
build-mode: none
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@24c54180a607b1449ed407dd24f251e4e9147c8d # v4.38.3
|
||||
uses: github/codeql-action/analyze@1c5b675653bb5c22dbe9b12b556ec555138e09fd # v4.38.1
|
||||
with:
|
||||
category: "/language:javascript-typescript"
|
||||
@@ -1,55 +0,0 @@
|
||||
name: Dependency review
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches:
|
||||
- dev
|
||||
paths:
|
||||
- package.json
|
||||
- pnpm-lock.yaml
|
||||
- pnpm-workspace.yaml
|
||||
schedule:
|
||||
- cron: "0 6 1 * *" # Monthly report on all direct dependencies
|
||||
workflow_dispatch:
|
||||
|
||||
permissions: {}
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
dependency-review:
|
||||
name: Review dependency changes
|
||||
if: github.event_name == 'pull_request'
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read # To compare the dependency graph between base and head
|
||||
steps:
|
||||
- name: Dependency review
|
||||
uses: actions/dependency-review-action@a1d282b36b6f3519aa1f3fc636f609c47dddb294 # v5.0.0
|
||||
with:
|
||||
license-check: false # Covered by pnpm lint:licenses
|
||||
fail-on-severity: high
|
||||
show-openssf-scorecard: true
|
||||
warn-on-openssf-scorecard-level: 3
|
||||
|
||||
npm-metadata:
|
||||
name: Check npm metadata
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read # To read package.json
|
||||
steps:
|
||||
- name: Check out workflow scripts
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
sparse-checkout: .github/scripts
|
||||
- name: Check npm metadata
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
with:
|
||||
script: |
|
||||
const { default: checkNpmMetadata } = await import(
|
||||
`${process.env.GITHUB_WORKSPACE}/.github/scripts/check-npm-metadata.mts`
|
||||
);
|
||||
await checkNpmMetadata({ github, context, core });
|
||||
+17
-17
@@ -38,7 +38,7 @@ jobs:
|
||||
name: Prepare container dependencies
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: mcr.microsoft.com/playwright:v1.64.1-noble
|
||||
image: mcr.microsoft.com/playwright:v1.63.0-noble
|
||||
options: --user 1001
|
||||
defaults:
|
||||
run:
|
||||
@@ -78,7 +78,7 @@ jobs:
|
||||
is-test: true
|
||||
|
||||
- name: Upload demo build
|
||||
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: demo-dist
|
||||
path: demo/dist/
|
||||
@@ -109,7 +109,7 @@ jobs:
|
||||
is-test: true
|
||||
|
||||
- name: Upload e2e test app build
|
||||
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: e2e-test-app-dist
|
||||
path: test/e2e/app/dist/
|
||||
@@ -140,7 +140,7 @@ jobs:
|
||||
is-test: true
|
||||
|
||||
- name: Upload gallery build
|
||||
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: gallery-dist
|
||||
path: gallery/dist/
|
||||
@@ -155,7 +155,7 @@ jobs:
|
||||
- prepare-container-dependencies
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: mcr.microsoft.com/playwright:v1.64.1-noble
|
||||
image: mcr.microsoft.com/playwright:v1.63.0-noble
|
||||
options: --user 1001 --ipc=host
|
||||
defaults:
|
||||
run:
|
||||
@@ -182,7 +182,7 @@ jobs:
|
||||
node-modules-cache-key: node-modules-container-v1
|
||||
|
||||
- name: Download demo build
|
||||
uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
with:
|
||||
name: demo-dist
|
||||
path: demo/dist/
|
||||
@@ -192,7 +192,7 @@ jobs:
|
||||
timeout-minutes: 15
|
||||
|
||||
- name: Upload demo blob report
|
||||
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
if: always()
|
||||
with:
|
||||
name: blob-report-demo-${{ matrix.shardIndex }}
|
||||
@@ -207,7 +207,7 @@ jobs:
|
||||
- prepare-container-dependencies
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: mcr.microsoft.com/playwright:v1.64.1-noble
|
||||
image: mcr.microsoft.com/playwright:v1.63.0-noble
|
||||
options: --user 1001 --ipc=host
|
||||
defaults:
|
||||
run:
|
||||
@@ -236,7 +236,7 @@ jobs:
|
||||
node-modules-cache-key: node-modules-container-v1
|
||||
|
||||
- name: Download e2e test app build
|
||||
uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
with:
|
||||
name: e2e-test-app-dist
|
||||
path: test/e2e/app/dist/
|
||||
@@ -246,7 +246,7 @@ jobs:
|
||||
timeout-minutes: 15
|
||||
|
||||
- name: Upload app blob report
|
||||
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
if: always()
|
||||
with:
|
||||
name: blob-report-app-${{ matrix.shardIndex }}
|
||||
@@ -261,7 +261,7 @@ jobs:
|
||||
- prepare-container-dependencies
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: mcr.microsoft.com/playwright:v1.64.1-noble
|
||||
image: mcr.microsoft.com/playwright:v1.63.0-noble
|
||||
options: --user 1001 --ipc=host
|
||||
defaults:
|
||||
run:
|
||||
@@ -290,7 +290,7 @@ jobs:
|
||||
node-modules-cache-key: node-modules-container-v1
|
||||
|
||||
- name: Download gallery build
|
||||
uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
with:
|
||||
name: gallery-dist
|
||||
path: gallery/dist/
|
||||
@@ -300,7 +300,7 @@ jobs:
|
||||
timeout-minutes: 15
|
||||
|
||||
- name: Upload gallery blob report
|
||||
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
if: always()
|
||||
with:
|
||||
name: blob-report-gallery-${{ matrix.shardIndex }}
|
||||
@@ -331,21 +331,21 @@ jobs:
|
||||
node-modules-cache: true
|
||||
|
||||
- name: Download demo blob reports
|
||||
uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
continue-on-error: true
|
||||
with:
|
||||
pattern: blob-report-demo-*
|
||||
path: test/e2e/reports/demo/
|
||||
|
||||
- name: Download app blob reports
|
||||
uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
continue-on-error: true
|
||||
with:
|
||||
pattern: blob-report-app-*
|
||||
path: test/e2e/reports/app/
|
||||
|
||||
- name: Download gallery blob reports
|
||||
uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
continue-on-error: true
|
||||
with:
|
||||
pattern: blob-report-gallery-*
|
||||
@@ -359,7 +359,7 @@ jobs:
|
||||
|
||||
- name: Upload merged HTML report
|
||||
id: upload-report
|
||||
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: playwright-report
|
||||
path: test/e2e/reports/combined/
|
||||
|
||||
@@ -1,11 +1,6 @@
|
||||
name: "Pull Request Labeler"
|
||||
|
||||
on: pull_request_target # zizmor: ignore[dangerous-triggers] -- safe: checks out base repo scripts only and reads PR files through the API, never PR head code
|
||||
|
||||
# Runs one at a time per pull request, so an older run can't undo a newer one
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number }}
|
||||
cancel-in-progress: true
|
||||
on: pull_request_target # zizmor: ignore[dangerous-triggers] -- safe: only runs actions/labeler, no PR code checkout
|
||||
|
||||
jobs:
|
||||
triage:
|
||||
@@ -15,24 +10,6 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Apply labels
|
||||
id: labeler
|
||||
continue-on-error: true
|
||||
uses: actions/labeler@bf12e9b00b37c5c0ca2b87b79b2daf7891dbda13 # v7.0.0
|
||||
with:
|
||||
sync-labels: true
|
||||
- name: Check out workflow scripts
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
sparse-checkout: .github/scripts
|
||||
- name: Apply content labels
|
||||
continue-on-error: true
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
env:
|
||||
LABELER_OUTCOME: ${{ steps.labeler.outcome }}
|
||||
with:
|
||||
script: |
|
||||
const { default: labelContent } = await import(
|
||||
`${process.env.GITHUB_WORKSPACE}/.github/scripts/label-content.mts`
|
||||
);
|
||||
await labelContent({ github, context, core });
|
||||
@@ -104,7 +104,7 @@ jobs:
|
||||
key: rspack-cache-${{ runner.os }}-${{ github.run_id }}-${{ github.run_attempt }}
|
||||
|
||||
- name: Upload build artifacts
|
||||
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: wheels
|
||||
path: dist/home_assistant_frontend*.whl
|
||||
@@ -137,7 +137,7 @@ jobs:
|
||||
run: tar -czvf translations.tar.gz translations
|
||||
|
||||
- name: Upload translations
|
||||
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: translations
|
||||
path: translations.tar.gz
|
||||
|
||||
@@ -33,6 +33,6 @@ jobs:
|
||||
with:
|
||||
script: |
|
||||
const { default: checkStandards } = await import(
|
||||
`${process.env.GITHUB_WORKSPACE}/.github/scripts/check-pull-request-standards.mts`
|
||||
`${process.env.GITHUB_WORKSPACE}/.github/scripts/check-pull-request-standards.mjs`
|
||||
);
|
||||
await checkStandards({ github, context, core });
|
||||
@@ -18,7 +18,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Send bundle stats and build information to RelativeCI
|
||||
uses: relative-ci/agent-action@711f3a8fa1b928e6afde280011110c9b0a224a60 # v3.2.4
|
||||
uses: relative-ci/agent-action@fcf45416581928e8dd62eded78ce98c78e5149f8 # v3.2.3
|
||||
with:
|
||||
key: ${{ secrets.RELATIVE_CI_KEY_frontend_modern }}
|
||||
token: ${{ github.token }}
|
||||
@@ -31,7 +31,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Send bundle stats and build information to RelativeCI
|
||||
uses: relative-ci/agent-action@711f3a8fa1b928e6afde280011110c9b0a224a60 # v3.2.4
|
||||
uses: relative-ci/agent-action@fcf45416581928e8dd62eded78ce98c78e5149f8 # v3.2.3
|
||||
with:
|
||||
key: ${{ secrets.RELATIVE_CI_KEY_frontend_legacy }}
|
||||
token: ${{ github.token }}
|
||||
|
||||
@@ -19,6 +19,6 @@ jobs:
|
||||
pull-requests: read
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: release-drafter/release-drafter@72967cdc98ddd3160f1fc3dff619de365e137dd0 # v7.9.0
|
||||
- uses: release-drafter/release-drafter@34d80673e067bdc0c24568d3af899c216adcfaa9 # v7.7.0
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
@@ -36,7 +36,7 @@ jobs:
|
||||
python-version: ${{ env.PYTHON_VERSION }}
|
||||
|
||||
- name: Verify version
|
||||
uses: home-assistant/actions/helpers/verify-version@cd6f3635a740c84d8a14f6121a90068b8ece6bf6 # master
|
||||
uses: home-assistant/actions/helpers/verify-version@58bff37c8947f690ace498be413a9b78d6f30f93 # master
|
||||
|
||||
- name: Setup Node and install
|
||||
uses: ./.github/actions/setup
|
||||
|
||||
@@ -15,33 +15,22 @@ jobs:
|
||||
name: Add no-stale label
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read # To check out workflow scripts
|
||||
issues: write # To add labels to issues
|
||||
if: >-
|
||||
github.event.issue.type.name == 'Task'
|
||||
|| github.event.issue.type.name == 'Epic'
|
||||
|| github.event.issue.type.name == 'Opportunity'
|
||||
steps:
|
||||
- name: Check out workflow scripts
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
sparse-checkout: .github/scripts
|
||||
- name: Add no-stale label
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
with:
|
||||
script: |
|
||||
const { withRetry } = await import(
|
||||
`${process.env.GITHUB_WORKSPACE}/.github/scripts/github-retry.mts`
|
||||
);
|
||||
await withRetry("no-stale label", () =>
|
||||
github.rest.issues.addLabels({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: context.issue.number,
|
||||
labels: ['no-stale']
|
||||
})
|
||||
);
|
||||
await github.rest.issues.addLabels({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: context.issue.number,
|
||||
labels: ['no-stale']
|
||||
});
|
||||
|
||||
check-authorization:
|
||||
name: Check authorization
|
||||
@@ -62,6 +51,6 @@ jobs:
|
||||
with:
|
||||
script: |
|
||||
const { default: checkTaskAuthorization } = await import(
|
||||
`${process.env.GITHUB_WORKSPACE}/.github/scripts/check-task-authorization.mts`
|
||||
`${process.env.GITHUB_WORKSPACE}/.github/scripts/check-task-authorization.mjs`
|
||||
);
|
||||
await checkTaskAuthorization({ github, context, core });
|
||||
@@ -15,12 +15,11 @@ jobs:
|
||||
if: github.repository == 'home-assistant/frontend'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Stale policy
|
||||
- name: 90 days stale policy
|
||||
uses: actions/stale@4391f3da665fdf50b6810c1a66712fb9ba21aa93 # v11.0.0
|
||||
with:
|
||||
repo-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
days-before-issue-stale: 90
|
||||
days-before-pr-stale: 60
|
||||
days-before-stale: 90
|
||||
days-before-close: 7
|
||||
operations-per-run: 25
|
||||
remove-stale-when-updated: true
|
||||
|
||||
@@ -14,7 +14,6 @@ dist/
|
||||
# Node
|
||||
node_modules/
|
||||
npm-debug.log
|
||||
.pnpm-store/
|
||||
|
||||
# Python stuff
|
||||
*.py[cod]
|
||||
|
||||
@@ -46,7 +46,6 @@ Detailed guidance lives in `.agents/skills/<name>/SKILL.md`. Load every matching
|
||||
- `ha-frontend-lit`: reactive fields, DOM queries, lifecycle behavior, and render-derived state.
|
||||
- `ha-frontend-styling`: theme variables, spacing tokens, responsive layout, RTL, and view transitions.
|
||||
- `ha-frontend-testing`: lint, typecheck, Vitest, Playwright e2e dev servers, and benchmarks.
|
||||
- `ha-frontend-query-params`: typed URL query params, `QueryParamsController`, data table URL sync, in-app links, and My Home Assistant redirect params.
|
||||
- `ha-frontend-user-facing-text`: localization, terminology, sentence case, and Home Assistant text style.
|
||||
- `ha-frontend-review`: PR template use, existing review feedback, review checklist, recurring issues, and UI/UX evidence including gallery specifications.
|
||||
- `ha-frontend-ux-readiness`: UI/UX routing that complements frontend review for new user experiences.
|
||||
|
||||
+1
-1
@@ -41,5 +41,5 @@ Contributions that do not follow this policy will be closed. Repeated violations
|
||||
---
|
||||
|
||||
The canonical version of this policy is published at
|
||||
<https://contributors.home-assistant.io/start/ai-policy/>. In case of differences,
|
||||
<https://developers.home-assistant.io/docs/ai_policy>. In case of differences,
|
||||
the published version applies.
|
||||
+162
-169
@@ -1,201 +1,194 @@
|
||||
Apache License
|
||||
Version 2.0, January 2004
|
||||
http://www.apache.org/licenses/
|
||||
Apache License
|
||||
==============
|
||||
|
||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||
_Version 2.0, January 2004_
|
||||
_<<http://www.apache.org/licenses/>>_
|
||||
|
||||
1. Definitions.
|
||||
### Terms and Conditions for use, reproduction, and distribution
|
||||
|
||||
"License" shall mean the terms and conditions for use, reproduction,
|
||||
and distribution as defined by Sections 1 through 9 of this document.
|
||||
#### 1. Definitions
|
||||
|
||||
"Licensor" shall mean the copyright owner or entity authorized by
|
||||
the copyright owner that is granting the License.
|
||||
“License” shall mean the terms and conditions for use, reproduction, and
|
||||
distribution as defined by Sections 1 through 9 of this document.
|
||||
|
||||
"Legal Entity" shall mean the union of the acting entity and all
|
||||
other entities that control, are controlled by, or are under common
|
||||
control with that entity. For the purposes of this definition,
|
||||
"control" means (i) the power, direct or indirect, to cause the
|
||||
direction or management of such entity, whether by contract or
|
||||
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||
“Licensor” shall mean the copyright owner or entity authorized by the copyright
|
||||
owner that is granting the License.
|
||||
|
||||
"You" (or "Your") shall mean an individual or Legal Entity
|
||||
exercising permissions granted by this License.
|
||||
“Legal Entity” shall mean the union of the acting entity and all other entities
|
||||
that control, are controlled by, or are under common control with that entity.
|
||||
For the purposes of this definition, “control” means **(i)** the power, direct or
|
||||
indirect, to cause the direction or management of such entity, whether by
|
||||
contract or otherwise, or **(ii)** ownership of fifty percent (50%) or more of the
|
||||
outstanding shares, or **(iii)** beneficial ownership of such entity.
|
||||
|
||||
"Source" form shall mean the preferred form for making modifications,
|
||||
including but not limited to software source code, documentation
|
||||
source, and configuration files.
|
||||
“You” (or “Your”) shall mean an individual or Legal Entity exercising
|
||||
permissions granted by this License.
|
||||
|
||||
"Object" form shall mean any form resulting from mechanical
|
||||
transformation or translation of a Source form, including but
|
||||
not limited to compiled object code, generated documentation,
|
||||
and conversions to other media types.
|
||||
“Source” form shall mean the preferred form for making modifications, including
|
||||
but not limited to software source code, documentation source, and configuration
|
||||
files.
|
||||
|
||||
"Work" shall mean the work of authorship, whether in Source or
|
||||
Object form, made available under the License, as indicated by a
|
||||
copyright notice that is included in or attached to the work
|
||||
(an example is provided in the Appendix below).
|
||||
“Object” form shall mean any form resulting from mechanical transformation or
|
||||
translation of a Source form, including but not limited to compiled object code,
|
||||
generated documentation, and conversions to other media types.
|
||||
|
||||
"Derivative Works" shall mean any work, whether in Source or Object
|
||||
form, that is based on (or derived from) the Work and for which the
|
||||
editorial revisions, annotations, elaborations, or other modifications
|
||||
represent, as a whole, an original work of authorship. For the purposes
|
||||
of this License, Derivative Works shall not include works that remain
|
||||
separable from, or merely link (or bind by name) to the interfaces of,
|
||||
the Work and Derivative Works thereof.
|
||||
“Work” shall mean the work of authorship, whether in Source or Object form, made
|
||||
available under the License, as indicated by a copyright notice that is included
|
||||
in or attached to the work (an example is provided in the Appendix below).
|
||||
|
||||
"Contribution" shall mean any work of authorship, including
|
||||
the original version of the Work and any modifications or additions
|
||||
to that Work or Derivative Works thereof, that is intentionally
|
||||
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||
or by an individual or Legal Entity authorized to submit on behalf of
|
||||
the copyright owner. For the purposes of this definition, "submitted"
|
||||
means any form of electronic, verbal, or written communication sent
|
||||
to the Licensor or its representatives, including but not limited to
|
||||
communication on electronic mailing lists, source code control systems,
|
||||
and issue tracking systems that are managed by, or on behalf of, the
|
||||
Licensor for the purpose of discussing and improving the Work, but
|
||||
excluding communication that is conspicuously marked or otherwise
|
||||
designated in writing by the copyright owner as "Not a Contribution."
|
||||
“Derivative Works” shall mean any work, whether in Source or Object form, that
|
||||
is based on (or derived from) the Work and for which the editorial revisions,
|
||||
annotations, elaborations, or other modifications represent, as a whole, an
|
||||
original work of authorship. For the purposes of this License, Derivative Works
|
||||
shall not include works that remain separable from, or merely link (or bind by
|
||||
name) to the interfaces of, the Work and Derivative Works thereof.
|
||||
|
||||
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||
on behalf of whom a Contribution has been received by Licensor and
|
||||
subsequently incorporated within the Work.
|
||||
“Contribution” shall mean any work of authorship, including the original version
|
||||
of the Work and any modifications or additions to that Work or Derivative Works
|
||||
thereof, that is intentionally submitted to Licensor for inclusion in the Work
|
||||
by the copyright owner or by an individual or Legal Entity authorized to submit
|
||||
on behalf of the copyright owner. For the purposes of this definition,
|
||||
“submitted” means any form of electronic, verbal, or written communication sent
|
||||
to the Licensor or its representatives, including but not limited to
|
||||
communication on electronic mailing lists, source code control systems, and
|
||||
issue tracking systems that are managed by, or on behalf of, the Licensor for
|
||||
the purpose of discussing and improving the Work, but excluding communication
|
||||
that is conspicuously marked or otherwise designated in writing by the copyright
|
||||
owner as “Not a Contribution.”
|
||||
|
||||
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
copyright license to reproduce, prepare Derivative Works of,
|
||||
publicly display, publicly perform, sublicense, and distribute the
|
||||
Work and such Derivative Works in Source or Object form.
|
||||
“Contributor” shall mean Licensor and any individual or Legal Entity on behalf
|
||||
of whom a Contribution has been received by Licensor and subsequently
|
||||
incorporated within the Work.
|
||||
|
||||
3. Grant of Patent License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
(except as stated in this section) patent license to make, have made,
|
||||
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||
where such license applies only to those patent claims licensable
|
||||
by such Contributor that are necessarily infringed by their
|
||||
Contribution(s) alone or by combination of their Contribution(s)
|
||||
with the Work to which such Contribution(s) was submitted. If You
|
||||
institute patent litigation against any entity (including a
|
||||
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||
or a Contribution incorporated within the Work constitutes direct
|
||||
or contributory patent infringement, then any patent licenses
|
||||
granted to You under this License for that Work shall terminate
|
||||
as of the date such litigation is filed.
|
||||
#### 2. Grant of Copyright License
|
||||
|
||||
4. Redistribution. You may reproduce and distribute copies of the
|
||||
Work or Derivative Works thereof in any medium, with or without
|
||||
modifications, and in Source or Object form, provided that You
|
||||
meet the following conditions:
|
||||
Subject to the terms and conditions of this License, each Contributor hereby
|
||||
grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free,
|
||||
irrevocable copyright license to reproduce, prepare Derivative Works of,
|
||||
publicly display, publicly perform, sublicense, and distribute the Work and such
|
||||
Derivative Works in Source or Object form.
|
||||
|
||||
(a) You must give any other recipients of the Work or
|
||||
Derivative Works a copy of this License; and
|
||||
#### 3. Grant of Patent License
|
||||
|
||||
(b) You must cause any modified files to carry prominent notices
|
||||
stating that You changed the files; and
|
||||
Subject to the terms and conditions of this License, each Contributor hereby
|
||||
grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free,
|
||||
irrevocable (except as stated in this section) patent license to make, have
|
||||
made, use, offer to sell, sell, import, and otherwise transfer the Work, where
|
||||
such license applies only to those patent claims licensable by such Contributor
|
||||
that are necessarily infringed by their Contribution(s) alone or by combination
|
||||
of their Contribution(s) with the Work to which such Contribution(s) was
|
||||
submitted. If You institute patent litigation against any entity (including a
|
||||
cross-claim or counterclaim in a lawsuit) alleging that the Work or a
|
||||
Contribution incorporated within the Work constitutes direct or contributory
|
||||
patent infringement, then any patent licenses granted to You under this License
|
||||
for that Work shall terminate as of the date such litigation is filed.
|
||||
|
||||
(c) You must retain, in the Source form of any Derivative Works
|
||||
that You distribute, all copyright, patent, trademark, and
|
||||
attribution notices from the Source form of the Work,
|
||||
excluding those notices that do not pertain to any part of
|
||||
the Derivative Works; and
|
||||
#### 4. Redistribution
|
||||
|
||||
(d) If the Work includes a "NOTICE" text file as part of its
|
||||
distribution, then any Derivative Works that You distribute must
|
||||
include a readable copy of the attribution notices contained
|
||||
within such NOTICE file, excluding those notices that do not
|
||||
pertain to any part of the Derivative Works, in at least one
|
||||
of the following places: within a NOTICE text file distributed
|
||||
as part of the Derivative Works; within the Source form or
|
||||
documentation, if provided along with the Derivative Works; or,
|
||||
within a display generated by the Derivative Works, if and
|
||||
wherever such third-party notices normally appear. The contents
|
||||
of the NOTICE file are for informational purposes only and
|
||||
do not modify the License. You may add Your own attribution
|
||||
notices within Derivative Works that You distribute, alongside
|
||||
or as an addendum to the NOTICE text from the Work, provided
|
||||
that such additional attribution notices cannot be construed
|
||||
as modifying the License.
|
||||
You may reproduce and distribute copies of the Work or Derivative Works thereof
|
||||
in any medium, with or without modifications, and in Source or Object form,
|
||||
provided that You meet the following conditions:
|
||||
|
||||
You may add Your own copyright statement to Your modifications and
|
||||
may provide additional or different license terms and conditions
|
||||
for use, reproduction, or distribution of Your modifications, or
|
||||
for any such Derivative Works as a whole, provided Your use,
|
||||
reproduction, and distribution of the Work otherwise complies with
|
||||
the conditions stated in this License.
|
||||
* **(a)** You must give any other recipients of the Work or Derivative Works a copy of
|
||||
this License; and
|
||||
* **(b)** You must cause any modified files to carry prominent notices stating that You
|
||||
changed the files; and
|
||||
* **(c)** You must retain, in the Source form of any Derivative Works that You distribute,
|
||||
all copyright, patent, trademark, and attribution notices from the Source form
|
||||
of the Work, excluding those notices that do not pertain to any part of the
|
||||
Derivative Works; and
|
||||
* **(d)** If the Work includes a “NOTICE” text file as part of its distribution, then any
|
||||
Derivative Works that You distribute must include a readable copy of the
|
||||
attribution notices contained within such NOTICE file, excluding those notices
|
||||
that do not pertain to any part of the Derivative Works, in at least one of the
|
||||
following places: within a NOTICE text file distributed as part of the
|
||||
Derivative Works; within the Source form or documentation, if provided along
|
||||
with the Derivative Works; or, within a display generated by the Derivative
|
||||
Works, if and wherever such third-party notices normally appear. The contents of
|
||||
the NOTICE file are for informational purposes only and do not modify the
|
||||
License. You may add Your own attribution notices within Derivative Works that
|
||||
You distribute, alongside or as an addendum to the NOTICE text from the Work,
|
||||
provided that such additional attribution notices cannot be construed as
|
||||
modifying the License.
|
||||
|
||||
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||
any Contribution intentionally submitted for inclusion in the Work
|
||||
by You to the Licensor shall be under the terms and conditions of
|
||||
this License, without any additional terms or conditions.
|
||||
Notwithstanding the above, nothing herein shall supersede or modify
|
||||
the terms of any separate license agreement you may have executed
|
||||
with Licensor regarding such Contributions.
|
||||
You may add Your own copyright statement to Your modifications and may provide
|
||||
additional or different license terms and conditions for use, reproduction, or
|
||||
distribution of Your modifications, or for any such Derivative Works as a whole,
|
||||
provided Your use, reproduction, and distribution of the Work otherwise complies
|
||||
with the conditions stated in this License.
|
||||
|
||||
6. Trademarks. This License does not grant permission to use the trade
|
||||
names, trademarks, service marks, or product names of the Licensor,
|
||||
except as required for reasonable and customary use in describing the
|
||||
origin of the Work and reproducing the content of the NOTICE file.
|
||||
#### 5. Submission of Contributions
|
||||
|
||||
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||
agreed to in writing, Licensor provides the Work (and each
|
||||
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||
implied, including, without limitation, any warranties or conditions
|
||||
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||
appropriateness of using or redistributing the Work and assume any
|
||||
risks associated with Your exercise of permissions under this License.
|
||||
Unless You explicitly state otherwise, any Contribution intentionally submitted
|
||||
for inclusion in the Work by You to the Licensor shall be under the terms and
|
||||
conditions of this License, without any additional terms or conditions.
|
||||
Notwithstanding the above, nothing herein shall supersede or modify the terms of
|
||||
any separate license agreement you may have executed with Licensor regarding
|
||||
such Contributions.
|
||||
|
||||
8. Limitation of Liability. In no event and under no legal theory,
|
||||
whether in tort (including negligence), contract, or otherwise,
|
||||
unless required by applicable law (such as deliberate and grossly
|
||||
negligent acts) or agreed to in writing, shall any Contributor be
|
||||
liable to You for damages, including any direct, indirect, special,
|
||||
incidental, or consequential damages of any character arising as a
|
||||
result of this License or out of the use or inability to use the
|
||||
Work (including but not limited to damages for loss of goodwill,
|
||||
work stoppage, computer failure or malfunction, or any and all
|
||||
other commercial damages or losses), even if such Contributor
|
||||
has been advised of the possibility of such damages.
|
||||
#### 6. Trademarks
|
||||
|
||||
9. Accepting Warranty or Additional Liability. While redistributing
|
||||
the Work or Derivative Works thereof, You may choose to offer,
|
||||
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||
or other liability obligations and/or rights consistent with this
|
||||
License. However, in accepting such obligations, You may act only
|
||||
on Your own behalf and on Your sole responsibility, not on behalf
|
||||
of any other Contributor, and only if You agree to indemnify,
|
||||
defend, and hold each Contributor harmless for any liability
|
||||
incurred by, or claims asserted against, such Contributor by reason
|
||||
of your accepting any such warranty or additional liability.
|
||||
This License does not grant permission to use the trade names, trademarks,
|
||||
service marks, or product names of the Licensor, except as required for
|
||||
reasonable and customary use in describing the origin of the Work and
|
||||
reproducing the content of the NOTICE file.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
#### 7. Disclaimer of Warranty
|
||||
|
||||
APPENDIX: How to apply the Apache License to your work.
|
||||
Unless required by applicable law or agreed to in writing, Licensor provides the
|
||||
Work (and each Contributor provides its Contributions) on an “AS IS” BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied,
|
||||
including, without limitation, any warranties or conditions of TITLE,
|
||||
NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are
|
||||
solely responsible for determining the appropriateness of using or
|
||||
redistributing the Work and assume any risks associated with Your exercise of
|
||||
permissions under this License.
|
||||
|
||||
To apply the Apache License to your work, attach the following
|
||||
boilerplate notice, with the fields enclosed by brackets "[]"
|
||||
replaced with your own identifying information. (Don't include
|
||||
the brackets!) The text should be enclosed in the appropriate
|
||||
comment syntax for the file format. We also recommend that a
|
||||
file or class name and description of purpose be included on the
|
||||
same "printed page" as the copyright notice for easier
|
||||
identification within third-party archives.
|
||||
#### 8. Limitation of Liability
|
||||
|
||||
Copyright [yyyy] [name of copyright owner]
|
||||
In no event and under no legal theory, whether in tort (including negligence),
|
||||
contract, or otherwise, unless required by applicable law (such as deliberate
|
||||
and grossly negligent acts) or agreed to in writing, shall any Contributor be
|
||||
liable to You for damages, including any direct, indirect, special, incidental,
|
||||
or consequential damages of any character arising as a result of this License or
|
||||
out of the use or inability to use the Work (including but not limited to
|
||||
damages for loss of goodwill, work stoppage, computer failure or malfunction, or
|
||||
any and all other commercial damages or losses), even if such Contributor has
|
||||
been advised of the possibility of such damages.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
#### 9. Accepting Warranty or Additional Liability
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
While redistributing the Work or Derivative Works thereof, You may choose to
|
||||
offer, and charge a fee for, acceptance of support, warranty, indemnity, or
|
||||
other liability obligations and/or rights consistent with this License. However,
|
||||
in accepting such obligations, You may act only on Your own behalf and on Your
|
||||
sole responsibility, not on behalf of any other Contributor, and only if You
|
||||
agree to indemnify, defend, and hold each Contributor harmless for any liability
|
||||
incurred by, or claims asserted against, such Contributor by reason of your
|
||||
accepting any such warranty or additional liability.
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
_END OF TERMS AND CONDITIONS_
|
||||
|
||||
### APPENDIX: How to apply the Apache License to your work
|
||||
|
||||
To apply the Apache License to your work, attach the following boilerplate
|
||||
notice, with the fields enclosed by brackets `[]` replaced with your own
|
||||
identifying information. (Don't include the brackets!) The text should be
|
||||
enclosed in the appropriate comment syntax for the file format. We also
|
||||
recommend that a file or class name and description of purpose be included on
|
||||
the same “printed page” as the copyright notice for easier identification within
|
||||
third-party archives.
|
||||
|
||||
Copyright [yyyy] [name of copyright owner]
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
@@ -6,12 +6,12 @@ This is the repository for the official [Home Assistant](https://home-assistant.
|
||||
|
||||
- [View demo of Home Assistant](https://demo.home-assistant.io/)
|
||||
- [More information about Home Assistant](https://home-assistant.io)
|
||||
- [Frontend development instructions](https://contributors.home-assistant.io/frontend/development/)
|
||||
- [Frontend development instructions](https://developers.home-assistant.io/docs/frontend/development/)
|
||||
|
||||
## Development
|
||||
|
||||
- Initial setup: `script/setup`
|
||||
- Development: [Instructions](https://contributors.home-assistant.io/frontend/development/)
|
||||
- Development: [Instructions](https://developers.home-assistant.io/docs/frontend/development/)
|
||||
- Production build: `pnpm build`
|
||||
- Gallery: `cd gallery && script/develop_gallery`
|
||||
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
{
|
||||
"_comment": "Initial JS budget (raw/uncompressed bytes) for the cold-load critical entrypoints. Enforced by build-scripts/check-bundle-size.cjs in CI. Re-seed after an intentional change with `--update --headroom=<percent>`.",
|
||||
"frontend-modern": {
|
||||
"app": 624240,
|
||||
"core": 55144,
|
||||
"authorize": 594400,
|
||||
"onboarding": 705499
|
||||
"app": 593517,
|
||||
"core": 54884,
|
||||
"authorize": 557490,
|
||||
"onboarding": 676131
|
||||
},
|
||||
"frontend-legacy": {
|
||||
"app": 776696,
|
||||
"core": 188309,
|
||||
"authorize": 763316,
|
||||
"onboarding": 879807
|
||||
"app": 738388,
|
||||
"core": 188119,
|
||||
"authorize": 716748,
|
||||
"onboarding": 840680
|
||||
}
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
// @ts-check
|
||||
|
||||
import { noUnusedElementImport } from "./no-unused-element-import.mjs";
|
||||
|
||||
export default {
|
||||
meta: { name: "eslint-plugin-ha" },
|
||||
rules: {
|
||||
"no-unused-element-import": noUnusedElementImport,
|
||||
},
|
||||
};
|
||||
@@ -1,188 +0,0 @@
|
||||
// @ts-check
|
||||
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
const TAG_PATTERN =
|
||||
/@customElement\(\s*["'`]([a-z0-9_-]+)["'`]|customElements\.define\(\s*["'`]([a-z0-9_-]+)["'`]/g;
|
||||
|
||||
const EXTENSIONS = /\.(ts|js)$/;
|
||||
|
||||
// Entry points register the root element that the served HTML renders.
|
||||
const ENTRY_POINT = /(^|[\\/])(entrypoints[\\/]|entrypoint\.(ts|js)$)/;
|
||||
|
||||
// These resolve a tag from config at runtime, so they register every candidate
|
||||
// they may be asked for.
|
||||
const RUNTIME_REGISTRY =
|
||||
/[\\/]create-element[\\/]|createLovelaceElement\(|getLovelaceElementClass\(/;
|
||||
|
||||
/** @type {Map<string, string[]>} */
|
||||
const tagCache = new Map();
|
||||
|
||||
/** Tags the module at `file` registers when imported. */
|
||||
const registeredTags = (file) => {
|
||||
let tags = tagCache.get(file);
|
||||
if (!tags) {
|
||||
try {
|
||||
const source = fs.readFileSync(file, "utf8");
|
||||
tags = [...source.matchAll(TAG_PATTERN)].map((m) => m[1] || m[2]);
|
||||
} catch {
|
||||
tags = [];
|
||||
}
|
||||
tagCache.set(file, tags);
|
||||
}
|
||||
return tags;
|
||||
};
|
||||
|
||||
const isFile = (candidate) => {
|
||||
try {
|
||||
return fs.statSync(candidate).isFile();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const resolveModule = (fromFile, request) => {
|
||||
const base = path.resolve(path.dirname(fromFile), request);
|
||||
return [
|
||||
base,
|
||||
`${base}.ts`,
|
||||
`${base}.js`,
|
||||
base.replace(/\.js$/, ".ts"),
|
||||
path.join(base, "index.ts"),
|
||||
path.join(base, "index.js"),
|
||||
].find((candidate) => EXTENSIONS.test(candidate) && isFile(candidate));
|
||||
};
|
||||
|
||||
// `hui-${type}-card` or "state-card-" + domain never spell the tag out.
|
||||
const buildsTagAtRuntime = (source, tag) => {
|
||||
const segments = tag.split("-");
|
||||
for (let end = 1; end < segments.length; end++) {
|
||||
const prefix = `${segments.slice(0, end).join("-")}-`;
|
||||
if (
|
||||
source.includes(`\`${prefix}\${`) ||
|
||||
source.includes(`"${prefix}" +`) ||
|
||||
source.includes(`'${prefix}' +`)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
// Plain substring matching, so <ha-icon-button> counts as a use of ha-icon.
|
||||
// That direction is deliberate: it misses dead imports rather than asking for
|
||||
// a removal that breaks registration.
|
||||
const usesTag = (body, tag) => body.includes(tag);
|
||||
|
||||
// A gallery page registers the elements its sibling markdown demos render, so
|
||||
// that markdown is part of what the module is used by.
|
||||
const siblingMarkup = (file) => {
|
||||
const markdown = file.replace(EXTENSIONS, ".markdown");
|
||||
try {
|
||||
return fs.readFileSync(markdown, "utf8");
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
};
|
||||
|
||||
/** Blanks out the given ranges so an import path cannot count as a usage. */
|
||||
const withoutRanges = (source, ranges) => {
|
||||
let result = source;
|
||||
for (const [start, end] of ranges) {
|
||||
result =
|
||||
result.slice(0, start) + " ".repeat(end - start) + result.slice(end);
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
/**
|
||||
* Side-effect imports register a custom element. ESLint's unused-import rules
|
||||
* cannot judge them — there is no binding to be unused — so a registration
|
||||
* outlives the template that needed it and nothing reports the dead import.
|
||||
*
|
||||
* Deliberately not fixable: removing the import is only safe once every file
|
||||
* that renders the element imports it itself, which this rule cannot see.
|
||||
*
|
||||
* Off in eslint.config.mjs while a backlog of those paired cases remains;
|
||||
* `pnpm lint:element-imports` enables it as a warning to list them. Flip it to
|
||||
* "error" there once the list is empty.
|
||||
*
|
||||
* No disable comments while it is off: ESLint reports them as unused
|
||||
* directives, which fails lint:eslint. Imports that must stay get theirs in
|
||||
* the change that turns the rule on.
|
||||
*/
|
||||
export const noUnusedElementImport = {
|
||||
meta: {
|
||||
type: "suggestion",
|
||||
docs: {
|
||||
description:
|
||||
"Disallow side-effect imports of a custom element the importing file never uses",
|
||||
},
|
||||
schema: [],
|
||||
messages: {
|
||||
unused:
|
||||
'"{{request}}" only registers <{{tags}}>, which this file never uses. Remove the import, adding it to whichever file renders the element if that file relies on this registration.',
|
||||
},
|
||||
},
|
||||
|
||||
create(context) {
|
||||
const filename = context.filename;
|
||||
if (!EXTENSIONS.test(filename) || ENTRY_POINT.test(filename)) {
|
||||
return {};
|
||||
}
|
||||
|
||||
return {
|
||||
Program(program) {
|
||||
const imports = program.body.filter(
|
||||
(node) => node.type === "ImportDeclaration"
|
||||
);
|
||||
const sideEffectImports = imports.filter(
|
||||
(node) =>
|
||||
node.specifiers.length === 0 &&
|
||||
typeof node.source.value === "string" &&
|
||||
node.source.value.startsWith(".")
|
||||
);
|
||||
if (!sideEffectImports.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
const source = context.sourceCode.getText();
|
||||
if (RUNTIME_REGISTRY.test(filename) || RUNTIME_REGISTRY.test(source)) {
|
||||
return;
|
||||
}
|
||||
// Every import declaration, not just the side-effect ones: a module
|
||||
// path is never a usage, and `import type { HaCard } from "./ha-card"`
|
||||
// would otherwise pass off its own path as one.
|
||||
const body =
|
||||
withoutRanges(
|
||||
source,
|
||||
imports.map((node) => node.range)
|
||||
) + siblingMarkup(filename);
|
||||
|
||||
for (const node of sideEffectImports) {
|
||||
const request = node.source.value;
|
||||
const target = resolveModule(filename, request);
|
||||
if (!target) {
|
||||
continue;
|
||||
}
|
||||
// No tags means no registration to judge: styles, polyfills, mocks.
|
||||
const tags = registeredTags(target);
|
||||
if (
|
||||
!tags.length ||
|
||||
tags.some(
|
||||
(tag) => usesTag(body, tag) || buildsTagAtRuntime(body, tag)
|
||||
)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
context.report({
|
||||
node,
|
||||
messageId: "unused",
|
||||
data: { request, tags: tags.join(">, <") },
|
||||
});
|
||||
}
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -5,7 +5,6 @@ import mapStream from "map-stream";
|
||||
import transform from "gulp-json-transform";
|
||||
import { LokaliseApi } from "@lokalise/node-api";
|
||||
import JSZip from "jszip";
|
||||
import { SERVICE_DOMAINS } from "./gen-demo-core-data.js";
|
||||
|
||||
const inDir = "translations";
|
||||
const inDirFrontend = `${inDir}/frontend`;
|
||||
@@ -52,34 +51,15 @@ function convertBackendTranslations(data, _file) {
|
||||
if (!data.component) {
|
||||
return output;
|
||||
}
|
||||
// Keep the entity domains and the domains with automation triggers or
|
||||
// conditions, so the demo can show states and describe automations. Only
|
||||
// keep the services of the domains whose services the demo mocks.
|
||||
Object.keys(data.component).forEach((domain) => {
|
||||
const {
|
||||
entity_component,
|
||||
services,
|
||||
triggers,
|
||||
conditions,
|
||||
selector,
|
||||
title,
|
||||
} = data.component[domain];
|
||||
if (
|
||||
!entity_component &&
|
||||
!triggers &&
|
||||
!conditions &&
|
||||
!SERVICE_DOMAINS.includes(domain)
|
||||
) {
|
||||
if (!("entity_component" in data.component[domain])) {
|
||||
return;
|
||||
}
|
||||
output.component[domain] = {
|
||||
entity_component,
|
||||
services: SERVICE_DOMAINS.includes(domain) ? services : undefined,
|
||||
triggers,
|
||||
conditions,
|
||||
selector,
|
||||
title,
|
||||
};
|
||||
output.component[domain] = { entity_component: {} };
|
||||
Object.keys(data.component[domain].entity_component).forEach((key) => {
|
||||
output.component[domain].entity_component[key] =
|
||||
data.component[domain].entity_component[key];
|
||||
});
|
||||
});
|
||||
return output;
|
||||
}
|
||||
@@ -175,9 +155,8 @@ gulp.task("fetch-lokalise", async function () {
|
||||
fs.mkdir(inDirBackend, { recursive: true }),
|
||||
]);
|
||||
|
||||
// The backend project only provides the translations the mocked backend
|
||||
// needs (see convertBackendTranslations), which are merged into the demo,
|
||||
// gallery, cast and e2e builds. The shipped app fetches
|
||||
// The backend project only provides entity_component translations, which are
|
||||
// merged into the demo, gallery, cast and e2e builds. The shipped app fetches
|
||||
// them live from core, so builds that only produce the app (release, release
|
||||
// landing-page) can skip this second, whole-project export to save time.
|
||||
const projects = Object.entries(lokaliseProjects).filter(
|
||||
|
||||
+46
-76
@@ -1,6 +1,7 @@
|
||||
// Task to download the latest Lokalise translations from the nightly workflow artifacts
|
||||
|
||||
import { createOAuthDeviceAuth } from "@octokit/auth-oauth-device";
|
||||
import { retry } from "@octokit/plugin-retry";
|
||||
import { Octokit } from "@octokit/rest";
|
||||
import { deleteAsync } from "del";
|
||||
import { mkdir, readFile, writeFile } from "fs/promises";
|
||||
@@ -9,20 +10,11 @@ import jszip from "jszip";
|
||||
import path from "path";
|
||||
import process from "process";
|
||||
import { extract } from "tar";
|
||||
import {
|
||||
RetryableError,
|
||||
withRetry,
|
||||
} from "../../.github/scripts/github-retry.mts";
|
||||
|
||||
type Artifact = Awaited<
|
||||
ReturnType<Octokit["rest"]["actions"]["listArtifactsForRepo"]>
|
||||
>["data"]["artifacts"][number];
|
||||
|
||||
const MAX_AGE = 24; // hours
|
||||
const OWNER = "home-assistant";
|
||||
const REPO = "frontend";
|
||||
|
||||
const BRANCH = "dev";
|
||||
const WORKFLOW_NAME = "nightly.yaml";
|
||||
const ARTIFACT_NAME = "translations";
|
||||
const CLIENT_ID = "Iv1.3914e28cb27834d1";
|
||||
const EXTRACT_DIR = "translations";
|
||||
@@ -44,16 +36,11 @@ gulp.task("fetch-nightly-translations", async function () {
|
||||
|
||||
// Read current translations artifact info if it exists,
|
||||
// and stop if they are not old enough
|
||||
let currentArtifact: Artifact | null;
|
||||
let currentArtifact;
|
||||
try {
|
||||
const artifact: Artifact = JSON.parse(
|
||||
await readFile(ARTIFACT_FILE, "utf-8")
|
||||
);
|
||||
|
||||
currentArtifact = artifact;
|
||||
|
||||
currentArtifact = JSON.parse(await readFile(ARTIFACT_FILE, "utf-8"));
|
||||
const currentAge =
|
||||
(Date.now() - Date.parse(artifact.created_at ?? "")) / 3600000;
|
||||
(Date.now() - Date.parse(currentArtifact.created_at)) / 3600000;
|
||||
if (currentAge < MAX_AGE) {
|
||||
console.log(
|
||||
"Keeping current translations (only %s hours old)",
|
||||
@@ -76,19 +63,19 @@ gulp.task("fetch-nightly-translations", async function () {
|
||||
}
|
||||
console.warn(
|
||||
"Failed to fetch nightly translations, continuing with English only:",
|
||||
err instanceof Error ? err.message : err
|
||||
err?.message || err
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
async function fetchTranslations(currentArtifact: Artifact | null) {
|
||||
async function fetchTranslations(currentArtifact) {
|
||||
// To store file writing promises
|
||||
const createExtractDir = mkdir(EXTRACT_DIR, { recursive: true });
|
||||
const writings: Promise<void>[] = [];
|
||||
const writings = [];
|
||||
|
||||
// Authenticate to GitHub using GitHub action token if it exists,
|
||||
// otherwise look for a saved user token or generate a new one if none
|
||||
let tokenAuth: { token: string };
|
||||
let tokenAuth;
|
||||
if (process.env.GITHUB_TOKEN) {
|
||||
tokenAuth = { token: process.env.GITHUB_TOKEN };
|
||||
} else {
|
||||
@@ -117,86 +104,69 @@ async function fetchTranslations(currentArtifact: Artifact | null) {
|
||||
});
|
||||
tokenAuth = await auth({ type: "oauth" });
|
||||
writings.push(
|
||||
createExtractDir.then(() =>
|
||||
createExtractDir.then(
|
||||
writeFile(TOKEN_FILE, JSON.stringify(tokenAuth, null, 2))
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Authenticate with token and request workflow runs from GitHub
|
||||
console.log("Fetching new translations...");
|
||||
|
||||
// Authenticate with token and find the newest translations artifact
|
||||
const octokit = new Octokit({
|
||||
const octokit = new (Octokit.plugin(retry))({
|
||||
userAgent: "Fetch Nightly Translations",
|
||||
auth: tokenAuth.token,
|
||||
});
|
||||
|
||||
// Only the nightly workflow uploads this artifact. Requiring a run from this
|
||||
// repository's own branch excludes artifacts uploaded by fork pull requests.
|
||||
const latestArtifact = await withRetry(
|
||||
"translations artifact lookup",
|
||||
async () => {
|
||||
const { data } = await octokit.rest.actions.listArtifactsForRepo({
|
||||
owner: OWNER,
|
||||
repo: REPO,
|
||||
name: ARTIFACT_NAME,
|
||||
per_page: 10,
|
||||
});
|
||||
const workflowRunsResponse = await octokit.rest.actions.listWorkflowRuns({
|
||||
owner: OWNER,
|
||||
repo: REPO,
|
||||
workflow_id: WORKFLOW_NAME,
|
||||
status: "success",
|
||||
event: "schedule",
|
||||
per_page: 1,
|
||||
exclude_pull_requests: true,
|
||||
});
|
||||
if (workflowRunsResponse.data.total_count === 0) {
|
||||
throw Error("No successful nightly workflow runs found");
|
||||
}
|
||||
const latestNightlyRun = workflowRunsResponse.data.workflow_runs[0];
|
||||
|
||||
const artifact = data.artifacts.find(
|
||||
({ expired, workflow_run: run }) =>
|
||||
!expired &&
|
||||
run?.head_branch === BRANCH &&
|
||||
run.head_repository_id === run.repository_id
|
||||
);
|
||||
|
||||
if (!artifact) {
|
||||
throw new RetryableError(
|
||||
`No ${ARTIFACT_NAME} artifact found from ${BRANCH}`
|
||||
);
|
||||
}
|
||||
|
||||
return artifact;
|
||||
}
|
||||
);
|
||||
|
||||
console.log(
|
||||
"Latest translations artifact is %s from workflow run %s (%s)",
|
||||
latestArtifact.id,
|
||||
latestArtifact.workflow_run?.id,
|
||||
latestArtifact.created_at
|
||||
);
|
||||
|
||||
// Stop if current is already the latest
|
||||
if (currentArtifact?.id === latestArtifact.id) {
|
||||
// Stop if current is already the latest, otherwise Find the translations artifact
|
||||
if (currentArtifact?.workflow_run.id === latestNightlyRun.id) {
|
||||
console.log("Stopping because current translations are still the latest");
|
||||
return;
|
||||
}
|
||||
const latestArtifact = (
|
||||
await octokit.actions.listWorkflowRunArtifacts({
|
||||
owner: OWNER,
|
||||
repo: REPO,
|
||||
run_id: latestNightlyRun.id,
|
||||
})
|
||||
).data.artifacts.find((artifact) => artifact.name === ARTIFACT_NAME);
|
||||
if (!latestArtifact) {
|
||||
throw Error("Latest nightly workflow run has no translations artifact");
|
||||
}
|
||||
|
||||
// Remove the current translations
|
||||
const deleteCurrent = Promise.all(writings).then(() =>
|
||||
const deleteCurrent = Promise.all(writings).then(
|
||||
deleteAsync([`${EXTRACT_DIR}/*`, `!${ARTIFACT_FILE}`, `!${TOKEN_FILE}`])
|
||||
);
|
||||
|
||||
// Get the download URL and follow the redirect to download (stored as ArrayBuffer)
|
||||
const downloadResponse = await withRetry("translations download", () =>
|
||||
octokit.rest.actions.downloadArtifact({
|
||||
owner: OWNER,
|
||||
repo: REPO,
|
||||
artifact_id: latestArtifact.id,
|
||||
archive_format: "zip",
|
||||
})
|
||||
);
|
||||
|
||||
// Octokit types the redirect, but fetch follows it to the archive
|
||||
if ((downloadResponse.status as number) !== 200) {
|
||||
const downloadResponse = await octokit.actions.downloadArtifact({
|
||||
owner: OWNER,
|
||||
repo: REPO,
|
||||
artifact_id: latestArtifact.id,
|
||||
archive_format: "zip",
|
||||
});
|
||||
if (downloadResponse.status !== 200) {
|
||||
throw Error("Failure downloading translations artifact");
|
||||
}
|
||||
|
||||
// Artifact is a tarball, but GitHub adds it to a zip file
|
||||
console.log("Unpacking downloaded translations...");
|
||||
const zip = await jszip.loadAsync(downloadResponse.data as ArrayBuffer);
|
||||
const zip = await jszip.loadAsync(downloadResponse.data);
|
||||
await deleteCurrent;
|
||||
const extractStream = zip.file(/.*/)[0].nodeStream().pipe(extract());
|
||||
await new Promise((resolve, reject) => {
|
||||
@@ -2,14 +2,10 @@
|
||||
|
||||
import fs from "fs-extra";
|
||||
import gulp from "gulp";
|
||||
import { createHash } from "node:crypto";
|
||||
import { createRequire } from "node:module";
|
||||
import path from "path";
|
||||
import paths from "../paths.cjs";
|
||||
import { ensureMapAssets, mapAssetsDir } from "./map-assets.js";
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
|
||||
const npmPath = (...parts) =>
|
||||
path.resolve(paths.root_dir, "node_modules", ...parts);
|
||||
const polyPath = (...parts) => path.resolve(paths.root_dir, ...parts);
|
||||
@@ -81,30 +77,19 @@ async function copyMapPanel(staticDir) {
|
||||
// Style, glyphs and sprites for the vector base map
|
||||
await ensureMapAssets();
|
||||
fs.copySync(mapAssetsDir, staticPath("map/"));
|
||||
copyFileDir(
|
||||
npmPath("@mapbox/mapbox-gl-rtl-text/dist/mapbox-gl-rtl-text.js"),
|
||||
staticPath("map/")
|
||||
);
|
||||
// Controls and popups of the native MapLibre engine
|
||||
copyFileDir(npmPath("maplibre-gl/dist/maplibre-gl.css"), staticPath("map/"));
|
||||
}
|
||||
|
||||
function copyZXingWasm(staticDir) {
|
||||
const staticPath = genStaticPath(staticDir);
|
||||
// Resolve through barcode-detector, the wasm must match the zxing-wasm JS it bundles
|
||||
const zxingWasm = createRequire(require.resolve("barcode-detector")).resolve(
|
||||
"zxing-wasm/reader/zxing_reader.wasm"
|
||||
);
|
||||
const {
|
||||
ZXING_WASM_SHA256,
|
||||
ZXING_WASM_VERSION,
|
||||
} = require("barcode-detector/pure");
|
||||
const wasm = fs.readFileSync(zxingWasm);
|
||||
if (createHash("sha256").update(wasm).digest("hex") !== ZXING_WASM_SHA256) {
|
||||
throw new Error(
|
||||
`${zxingWasm} does not match the zxing-wasm ${ZXING_WASM_VERSION} bundled in barcode-detector`
|
||||
);
|
||||
}
|
||||
// Versioned file name, see locateFile in ha-qr-scanner
|
||||
fs.outputFileSync(
|
||||
staticPath("js", `zxing_reader-${ZXING_WASM_VERSION}.wasm`),
|
||||
wasm
|
||||
copyFileDir(
|
||||
npmPath("zxing-wasm/dist/reader/zxing_reader.wasm"),
|
||||
staticPath("js")
|
||||
);
|
||||
}
|
||||
|
||||
@@ -192,7 +177,6 @@ gulp.task("copy-static-landing-page", async () => {
|
||||
|
||||
copyFonts(paths.landingPage_output_static);
|
||||
copyTranslations(paths.landingPage_output_static);
|
||||
copyLocaleData(paths.landingPage_output_static);
|
||||
});
|
||||
|
||||
gulp.task("copy-static-e2e-test-app", async () => {
|
||||
|
||||
@@ -1,422 +0,0 @@
|
||||
// Generates the service, trigger and condition descriptions the demo and
|
||||
// gallery serve instead of a backend, from the files in Home Assistant Core.
|
||||
import gulp from "gulp";
|
||||
import { CORE_SCHEMA, load, mergeTag } from "js-yaml";
|
||||
import { writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import process from "node:process";
|
||||
import prettier from "prettier";
|
||||
import paths from "../paths.cjs";
|
||||
|
||||
const CORE_URL =
|
||||
process.env.CORE_COMPONENTS_URL ||
|
||||
"https://raw.githubusercontent.com/home-assistant/core/refs/heads/dev/homeassistant/components";
|
||||
|
||||
// Integrations whose services the mocked backend provides
|
||||
export const SERVICE_DOMAINS = [
|
||||
"alarm_control_panel",
|
||||
"automation",
|
||||
"button",
|
||||
"calendar",
|
||||
"camera",
|
||||
"climate",
|
||||
"conversation",
|
||||
"counter",
|
||||
"cover",
|
||||
"device_tracker",
|
||||
"fan",
|
||||
"frontend",
|
||||
"group",
|
||||
"homeassistant",
|
||||
"humidifier",
|
||||
"image_processing",
|
||||
"input_boolean",
|
||||
"input_button",
|
||||
"input_datetime",
|
||||
"input_number",
|
||||
"input_select",
|
||||
"input_text",
|
||||
"lawn_mower",
|
||||
"light",
|
||||
"lock",
|
||||
"logger",
|
||||
"media_player",
|
||||
"notify",
|
||||
"number",
|
||||
"persistent_notification",
|
||||
"person",
|
||||
"recorder",
|
||||
"remote",
|
||||
"scene",
|
||||
"schedule",
|
||||
"script",
|
||||
"select",
|
||||
"siren",
|
||||
"switch",
|
||||
"system_log",
|
||||
"text",
|
||||
"timer",
|
||||
"todo",
|
||||
"tts",
|
||||
"update",
|
||||
"vacuum",
|
||||
"valve",
|
||||
"water_heater",
|
||||
"weather",
|
||||
"zone",
|
||||
];
|
||||
|
||||
// Services that return data. Core sets this when it registers a service, it is
|
||||
// not part of services.yaml. The value is whether the response is optional.
|
||||
const RESPONSE_SERVICES = {
|
||||
"calendar.get_events": false,
|
||||
"conversation.process": true,
|
||||
"media_player.browse_media": false,
|
||||
"media_player.search_media": false,
|
||||
"recorder.get_statistics": false,
|
||||
"schedule.get_schedule": false,
|
||||
"todo.get_items": false,
|
||||
"weather.get_forecasts": false,
|
||||
};
|
||||
|
||||
// Services that are still in services.yaml, but that core no longer registers
|
||||
const UNREGISTERED_SERVICES = ["weather.get_forecast"];
|
||||
|
||||
// Integrations that provide the new style triggers and conditions
|
||||
const AUTOMATION_PLATFORM_DOMAINS = [
|
||||
"air_quality",
|
||||
"alarm_control_panel",
|
||||
"assist_satellite",
|
||||
"battery",
|
||||
"button",
|
||||
"calendar",
|
||||
"climate",
|
||||
"counter",
|
||||
"cover",
|
||||
"door",
|
||||
"doorbell",
|
||||
"event",
|
||||
"fan",
|
||||
"garage_door",
|
||||
"gate",
|
||||
"humidifier",
|
||||
"humidity",
|
||||
"illuminance",
|
||||
"lawn_mower",
|
||||
"light",
|
||||
"lock",
|
||||
"media_player",
|
||||
"moisture",
|
||||
"moon",
|
||||
"motion",
|
||||
"occupancy",
|
||||
"power",
|
||||
"remote",
|
||||
"scene",
|
||||
"schedule",
|
||||
"select",
|
||||
"siren",
|
||||
"sun",
|
||||
"switch",
|
||||
"temperature",
|
||||
"text",
|
||||
"timer",
|
||||
"todo",
|
||||
"update",
|
||||
"vacuum",
|
||||
"valve",
|
||||
"vibration",
|
||||
"water_heater",
|
||||
"window",
|
||||
"zone",
|
||||
];
|
||||
|
||||
// YAML 1.2 with merge keys, which matches how core reads these files
|
||||
const YAML_SCHEMA = CORE_SCHEMA.withTags([mergeTag]);
|
||||
|
||||
const HEADER = `// This file is auto-generated from Home Assistant Core. Do not edit by hand.
|
||||
// Regenerate with \`script/gen_demo_core_data\`.`;
|
||||
|
||||
const fetchCoreFile = async (domain, file) => {
|
||||
const url = `${CORE_URL}/${domain}/${file}`;
|
||||
const response = await fetch(url);
|
||||
if (response.status === 404) {
|
||||
return undefined;
|
||||
}
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch ${url}: ${response.status}`);
|
||||
}
|
||||
return response.text();
|
||||
};
|
||||
|
||||
// Fetches a file of every domain, keyed by domain
|
||||
const fetchCoreFiles = async (domains, file) =>
|
||||
Object.fromEntries(
|
||||
await Promise.all(
|
||||
domains.map(async (domain) => [domain, await fetchCoreFile(domain, file)])
|
||||
)
|
||||
);
|
||||
|
||||
const parseYaml = (content) => load(content, { schema: YAML_SCHEMA }) ?? {};
|
||||
|
||||
// Enum references look like "cover.CoverEntityFeature.OPEN"
|
||||
const ENUM_REFERENCE = /^([a-z_]+)\.[A-Z]\w*\.[A-Z0-9_]+$/;
|
||||
|
||||
const enumDomains = (value, domains = new Set()) => {
|
||||
if (typeof value === "string") {
|
||||
const match = value.match(ENUM_REFERENCE);
|
||||
if (match) {
|
||||
domains.add(match[1]);
|
||||
}
|
||||
} else if (value && typeof value === "object") {
|
||||
Object.values(value).forEach((val) => enumDomains(val, domains));
|
||||
}
|
||||
return domains;
|
||||
};
|
||||
|
||||
// Resolves "<domain>.<Enum>.<MEMBER>" to the value of the member, like core
|
||||
const enumResolver = async (yamls) => {
|
||||
const domains = [...enumDomains(yamls)];
|
||||
const [consts, inits] = await Promise.all([
|
||||
fetchCoreFiles(domains, "const.py"),
|
||||
fetchCoreFiles(domains, "__init__.py"),
|
||||
]);
|
||||
return (reference) => {
|
||||
const [domain, enumName, member] = reference.split(".");
|
||||
for (const source of [consts[domain], inits[domain]]) {
|
||||
const match = source
|
||||
?.split(`class ${enumName}(`)[1]
|
||||
?.match(new RegExp(`\\n\\s+${member} = ("[^"]*"|\\d+)`));
|
||||
if (match) {
|
||||
return JSON.parse(match[1]);
|
||||
}
|
||||
}
|
||||
throw new Error(`Unknown enum ${reference}`);
|
||||
};
|
||||
};
|
||||
|
||||
const createResolvers = (enumValue) => {
|
||||
// In entity filters, every item of the supported features, a feature or a
|
||||
// list of features, becomes one bit mask
|
||||
const featureMask = (features) =>
|
||||
[].concat(features).reduce(
|
||||
// eslint-disable-next-line no-bitwise
|
||||
(mask, feature) => mask | enumValue(feature),
|
||||
0
|
||||
);
|
||||
|
||||
const resolveSelector = (value) => {
|
||||
if (Array.isArray(value)) {
|
||||
return value.map(resolveSelector);
|
||||
}
|
||||
if (!value || typeof value !== "object") {
|
||||
return value;
|
||||
}
|
||||
return Object.fromEntries(
|
||||
Object.entries(value).map(([key, val]) => [
|
||||
key,
|
||||
key === "supported_features"
|
||||
? [].concat(val).map(featureMask)
|
||||
: resolveSelector(val),
|
||||
])
|
||||
);
|
||||
};
|
||||
|
||||
// Like core, an empty target allows any target
|
||||
const resolveTarget = (target) => resolveSelector(target ?? {});
|
||||
|
||||
// Field filters keep their lists, with every enum resolved on its own
|
||||
const resolveField = (field) => {
|
||||
const result = { ...field };
|
||||
if (field.selector) {
|
||||
result.selector = resolveSelector(field.selector);
|
||||
}
|
||||
if (field.filter?.supported_features) {
|
||||
result.filter = {
|
||||
...result.filter,
|
||||
supported_features: field.filter.supported_features.map(enumValue),
|
||||
};
|
||||
}
|
||||
if (field.filter?.attribute) {
|
||||
result.filter = {
|
||||
...result.filter,
|
||||
attribute: Object.fromEntries(
|
||||
Object.entries(field.filter.attribute).map(([name, options]) => [
|
||||
name,
|
||||
options.map(enumValue),
|
||||
])
|
||||
),
|
||||
};
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
// Fields can be grouped in sections, which hold fields themselves
|
||||
const resolveFields = (fields) =>
|
||||
Object.fromEntries(
|
||||
Object.entries(fields ?? {}).map(([key, field]) => [
|
||||
key,
|
||||
field && "fields" in field && !("selector" in field)
|
||||
? { ...field, fields: resolveFields(field.fields) }
|
||||
: resolveField(field ?? {}),
|
||||
])
|
||||
);
|
||||
|
||||
return { resolveFields, resolveTarget };
|
||||
};
|
||||
|
||||
// Mirrors how core builds the response of get_services
|
||||
const serviceDescriptions = (yamls, { resolveFields, resolveTarget }) =>
|
||||
Object.fromEntries(
|
||||
SERVICE_DOMAINS.map((domain) => {
|
||||
if (!yamls[domain]) {
|
||||
throw new Error(`No services.yaml found for ${domain}`);
|
||||
}
|
||||
const services = Object.entries(yamls[domain])
|
||||
.filter(
|
||||
([service]) =>
|
||||
!service.startsWith(".") &&
|
||||
!UNREGISTERED_SERVICES.includes(`${domain}.${service}`)
|
||||
)
|
||||
.map(([service, value]) => {
|
||||
const description = { fields: resolveFields(value?.fields) };
|
||||
for (const item of ["description", "name"]) {
|
||||
if (value && item in value) {
|
||||
description[item] = value[item];
|
||||
}
|
||||
}
|
||||
if (value && "target" in value) {
|
||||
description.target = resolveTarget(value.target);
|
||||
}
|
||||
const key = `${domain}.${service}`;
|
||||
if (key in RESPONSE_SERVICES) {
|
||||
description.response = { optional: RESPONSE_SERVICES[key] };
|
||||
}
|
||||
return [service, description];
|
||||
});
|
||||
return [domain, Object.fromEntries(services)];
|
||||
})
|
||||
);
|
||||
|
||||
// Mirrors how core builds the trigger and condition platform descriptions
|
||||
const platformDescriptions = (yamls, { resolveFields, resolveTarget }) =>
|
||||
Object.fromEntries(
|
||||
AUTOMATION_PLATFORM_DOMAINS.flatMap((domain) =>
|
||||
Object.entries(yamls[domain] ?? {})
|
||||
// Keys starting with a dot only hold YAML anchors
|
||||
.filter(([key, value]) => !key.startsWith(".") && value)
|
||||
.map(([key, value]) => {
|
||||
const absoluteKey = !key.startsWith("_")
|
||||
? `${domain}.${key}`
|
||||
: key.slice(1) || domain;
|
||||
const description = { fields: resolveFields(value.fields) };
|
||||
if ("target" in value) {
|
||||
description.target = resolveTarget(value.target);
|
||||
}
|
||||
return [absoluteKey, description];
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
// The trigger or condition icons of every domain that has them
|
||||
const platformIcons = (icons, category) =>
|
||||
Object.fromEntries(
|
||||
Object.entries(icons)
|
||||
.map(([domain, content]) => [
|
||||
domain,
|
||||
content ? JSON.parse(content)[category] : undefined,
|
||||
])
|
||||
.filter(([, categoryIcons]) => categoryIcons)
|
||||
);
|
||||
|
||||
const parseYamls = (files) =>
|
||||
Object.fromEntries(
|
||||
Object.entries(files)
|
||||
.filter(([, content]) => content !== undefined)
|
||||
.map(([domain, content]) => [domain, parseYaml(content)])
|
||||
);
|
||||
|
||||
const writeTs = async (path, content) => {
|
||||
const file = join(paths.root_dir, path);
|
||||
const options = await prettier.resolveConfig(file);
|
||||
await writeFile(
|
||||
file,
|
||||
await prettier.format(`${HEADER}\n${content}`, {
|
||||
...options,
|
||||
filepath: file,
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
gulp.task("gen-demo-core-data", async () => {
|
||||
const [servicesFiles, triggersFiles, conditionsFiles, iconsFiles] =
|
||||
await Promise.all([
|
||||
fetchCoreFiles(SERVICE_DOMAINS, "services.yaml"),
|
||||
fetchCoreFiles(AUTOMATION_PLATFORM_DOMAINS, "triggers.yaml"),
|
||||
fetchCoreFiles(AUTOMATION_PLATFORM_DOMAINS, "conditions.yaml"),
|
||||
fetchCoreFiles(AUTOMATION_PLATFORM_DOMAINS, "icons.json"),
|
||||
]);
|
||||
const servicesYamls = parseYamls(servicesFiles);
|
||||
const triggersYamls = parseYamls(triggersFiles);
|
||||
const conditionsYamls = parseYamls(conditionsFiles);
|
||||
const resolvers = createResolvers(
|
||||
await enumResolver([servicesYamls, triggersYamls, conditionsYamls])
|
||||
);
|
||||
for (const key of UNREGISTERED_SERVICES) {
|
||||
const [domain, service] = key.split(".");
|
||||
if (!(service in (servicesYamls[domain] ?? {}))) {
|
||||
throw new Error(`Remove ${key} from UNREGISTERED_SERVICES, it is gone`);
|
||||
}
|
||||
}
|
||||
const services = serviceDescriptions(servicesYamls, resolvers);
|
||||
for (const key of Object.keys(RESPONSE_SERVICES)) {
|
||||
const [domain, service] = key.split(".");
|
||||
if (!services[domain]?.[service]) {
|
||||
throw new Error(`Service ${key} that returns a response does not exist`);
|
||||
}
|
||||
}
|
||||
const triggers = platformDescriptions(triggersYamls, resolvers);
|
||||
const conditions = platformDescriptions(conditionsYamls, resolvers);
|
||||
const icons = {
|
||||
triggers: platformIcons(iconsFiles, "triggers"),
|
||||
conditions: platformIcons(iconsFiles, "conditions"),
|
||||
};
|
||||
|
||||
await Promise.all([
|
||||
writeTs(
|
||||
"src/fake_data/demo_services.ts",
|
||||
`import type { HassServices } from "home-assistant-js-websocket";
|
||||
|
||||
// Core sends more than the HassServices type covers, like sections and list
|
||||
// examples.
|
||||
export const demoServices = ${JSON.stringify(services)} as unknown as HassServices;
|
||||
`
|
||||
),
|
||||
writeTs(
|
||||
"demo/src/stubs/automation_platform_domains.ts",
|
||||
`
|
||||
// Integrations that provide the new style triggers and conditions. Their
|
||||
// translations are part of the demo build.
|
||||
export const automationPlatformDomains = ${JSON.stringify(AUTOMATION_PLATFORM_DOMAINS)};
|
||||
`
|
||||
),
|
||||
writeTs(
|
||||
"demo/src/stubs/automation_platforms_data.ts",
|
||||
`import type { ConditionDescriptions } from "../../../src/data/condition";
|
||||
import type { TriggerDescriptions } from "../../../src/data/trigger";
|
||||
|
||||
export const triggerDescriptions: TriggerDescriptions = ${JSON.stringify(triggers)};
|
||||
|
||||
export const conditionDescriptions: ConditionDescriptions = ${JSON.stringify(conditions)};
|
||||
`
|
||||
),
|
||||
writeTs(
|
||||
"demo/src/stubs/automation_platforms_icons.ts",
|
||||
`
|
||||
export const triggerIcons = ${JSON.stringify(icons.triggers)};
|
||||
|
||||
export const conditionIcons = ${JSON.stringify(icons.conditions)};
|
||||
`
|
||||
),
|
||||
]);
|
||||
});
|
||||
@@ -69,7 +69,7 @@ export const SENSOR_STATE_CLASSES: string[] = [
|
||||
${stateClasses.map((stateClass) => ` "${stateClass}",`).join("\n")}
|
||||
];
|
||||
|
||||
export const SENSOR_STATE_CLASS_UNITS: Record<string, (string | null)[]> = {
|
||||
export const SENSOR_STATE_CLASS_UNITS: Record<string, string[]> = {
|
||||
${Object.entries(stateClassUnits)
|
||||
.map(
|
||||
([stateClass, units]) =>
|
||||
|
||||
@@ -6,10 +6,9 @@ import "./demo.js";
|
||||
import "./download-translations.js";
|
||||
import "./e2e-test-app.js";
|
||||
import "./entry-html.js";
|
||||
import "./fetch-nightly-translations.mts";
|
||||
import "./fetch-nightly-translations.js";
|
||||
import "./gallery.js";
|
||||
import "./gather-static.js";
|
||||
import "./gen-demo-core-data.js";
|
||||
import "./gen-device-classes.js";
|
||||
import "./gen-icons-json.js";
|
||||
import "./gen-sensor-entity-constants.js";
|
||||
|
||||
@@ -29,7 +29,7 @@ const LICENSE_OVERRIDES = [
|
||||
// type-fest ships two license files (MIT for code, CC0 for types).
|
||||
// We use the MIT license since that covers the bundled code.
|
||||
packageName: "type-fest",
|
||||
version: "5.10.0",
|
||||
version: "5.8.0",
|
||||
licenseFile: "license-mit",
|
||||
},
|
||||
];
|
||||
|
||||
@@ -153,9 +153,7 @@ gulp.task("rspack-watch-app", () => {
|
||||
: createAppConfig({ isProdBuild: false, latestBuild: true })
|
||||
).watch({ poll: isWsl }, doneHandler());
|
||||
gulp.watch(
|
||||
// Watch the folder via a glob; git replaces en.json on checkout/stash,
|
||||
// which can permanently drop a watch on the file itself
|
||||
path.join(paths.translations_src, "*.json"),
|
||||
path.join(paths.translations_src, "en.json"),
|
||||
gulp.series("build-translations", "copy-translations-app")
|
||||
);
|
||||
});
|
||||
|
||||
@@ -10,7 +10,7 @@ import { finished } from "node:stream/promises";
|
||||
import env from "../env.cjs";
|
||||
import paths from "../paths.cjs";
|
||||
import { mergeTranslations } from "./merge-translations.js";
|
||||
import "./fetch-nightly-translations.mts";
|
||||
import "./fetch-nightly-translations.js";
|
||||
|
||||
const inFrontendDir = "translations/frontend";
|
||||
const inBackendDir = "translations/backend";
|
||||
|
||||
@@ -40,21 +40,6 @@ const TOOLCHAIN_PACKAGES = [
|
||||
"caniuse-lite",
|
||||
];
|
||||
|
||||
// Not direct dependencies: read the copy the package that loads them uses.
|
||||
const TOOLCHAIN_PACKAGE_PARENTS = {
|
||||
terser: "terser-webpack-plugin",
|
||||
"caniuse-lite": "browserslist",
|
||||
};
|
||||
|
||||
const toolchainVersion = (pkg) => {
|
||||
const parent = TOOLCHAIN_PACKAGE_PARENTS[pkg];
|
||||
const lookup = parent && [
|
||||
path.dirname(require.resolve(`${parent}/package.json`)),
|
||||
];
|
||||
return require(require.resolve(`${pkg}/package.json`, { paths: lookup }))
|
||||
.version;
|
||||
};
|
||||
|
||||
// Our own build logic — the config, loaders and babel plugins. Their contents
|
||||
// (not their paths) go into the cache version, so a change invalidates the
|
||||
// cache the same way `buildDependencies` would, but without tying validity to
|
||||
@@ -69,25 +54,27 @@ const CONFIG_FILES = [
|
||||
path.join(__dirname, "babel-plugins", "inline-constants-plugin.cjs"),
|
||||
];
|
||||
|
||||
// Patches change a package's files but not its version, which is all the
|
||||
// Yarn patches change a package's files but not its version, which is all the
|
||||
// node_modules snapshot checks, so their contents go into the version too.
|
||||
const PATCHES_DIR = path.join(paths.root_dir, "patches");
|
||||
const YARN_PATCHES_DIR = path.join(paths.root_dir, ".yarn", "patches");
|
||||
|
||||
// Content hash of the toolchain versions and our own build files, used as the
|
||||
// persistent cache `version`. Everything here is path-independent so the cache
|
||||
// stays valid when reused on a different machine or checkout path.
|
||||
const cacheVersion = () => {
|
||||
const parts = [
|
||||
...TOOLCHAIN_PACKAGES.map((pkg) => `${pkg}@${toolchainVersion(pkg)}`),
|
||||
...TOOLCHAIN_PACKAGES.map(
|
||||
(pkg) => `${pkg}@${require(`${pkg}/package.json`).version}`
|
||||
),
|
||||
...CONFIG_FILES.map(
|
||||
(file) => `${path.basename(file)}:${fs.readFileSync(file, "utf8")}`
|
||||
),
|
||||
...(existsSync(PATCHES_DIR) ? fs.readdirSync(PATCHES_DIR) : [])
|
||||
...(existsSync(YARN_PATCHES_DIR) ? fs.readdirSync(YARN_PATCHES_DIR) : [])
|
||||
.filter((file) => file.endsWith(".patch"))
|
||||
.sort()
|
||||
.map(
|
||||
(file) =>
|
||||
`${file}:${fs.readFileSync(path.join(PATCHES_DIR, file), "utf8")}`
|
||||
`${file}:${fs.readFileSync(path.join(YARN_PATCHES_DIR, file), "utf8")}`
|
||||
),
|
||||
];
|
||||
return require("crypto")
|
||||
@@ -128,10 +115,7 @@ const createRspackConfig = ({
|
||||
dontHash = new Set();
|
||||
}
|
||||
const ignorePackages = bundle.ignorePackages({ latestBuild });
|
||||
// pnpm links node_modules/lit-html into its store; rspack compares real paths
|
||||
const litHtmlRoot = fs.realpathSync(
|
||||
path.resolve(__dirname, "../node_modules/lit-html")
|
||||
);
|
||||
const litHtmlRoot = path.resolve(__dirname, "../node_modules/lit-html");
|
||||
const litHtmlDevelopmentRoot = path.join(litHtmlRoot, "development");
|
||||
const litDisableDevModeLoader = path.join(
|
||||
__dirname,
|
||||
@@ -393,14 +377,6 @@ const createRspackConfig = ({
|
||||
resolve: {
|
||||
extensions: [".ts", ".js", ".json"],
|
||||
alias: {
|
||||
// @formatjs/bigdecimal uses BigInt (Safari 14, Chrome 67), which
|
||||
// breaks the NumberFormat and PluralRules polyfills
|
||||
...(!latestBuild && {
|
||||
"@formatjs/bigdecimal$": path.join(
|
||||
paths.root_dir,
|
||||
"src/resources/polyfills/bigdecimal.ts"
|
||||
),
|
||||
}),
|
||||
"lit/static-html$": "lit/static-html.js",
|
||||
"lit/decorators$": "lit/decorators.js",
|
||||
"lit/directive$": "lit/directive.js",
|
||||
@@ -513,7 +489,7 @@ const createRspackConfig = ({
|
||||
// `name` is already unique per variant (frontend-modern/-legacy).
|
||||
name,
|
||||
// Content-based version (node major + toolchain versions + our own
|
||||
// build files + patches). Everything is path-independent, so
|
||||
// build files + Yarn patches). Everything is path-independent, so
|
||||
// the cache stays valid when reused on another machine/checkout.
|
||||
// Runtime deps are deliberately absent — rspack's node_modules
|
||||
// snapshot invalidates their modules per-package, so a single
|
||||
|
||||
@@ -4,7 +4,7 @@ import { castContext } from "../cast_context";
|
||||
|
||||
export const castDemoLovelace: () => LovelaceConfig = () => {
|
||||
const touchSupported =
|
||||
castContext.getDeviceCapabilities()?.touch_input_supported;
|
||||
castContext.getDeviceCapabilities().touch_input_supported;
|
||||
return {
|
||||
views: [
|
||||
{
|
||||
|
||||
@@ -19,7 +19,7 @@ const playerStylesAdded = false;
|
||||
let controls: HTMLElement | null;
|
||||
|
||||
const setTouchControlsVisibility = (visible: boolean) => {
|
||||
if (!castContext.getDeviceCapabilities()?.touch_input_supported) {
|
||||
if (!castContext.getDeviceCapabilities().touch_input_supported) {
|
||||
return;
|
||||
}
|
||||
controls =
|
||||
@@ -51,7 +51,7 @@ const playDummyMedia = (viewTitle?: string) => {
|
||||
clearTimeout(timeOut);
|
||||
timeOut = undefined;
|
||||
}
|
||||
if (castContext.getDeviceCapabilities()?.touch_input_supported) {
|
||||
if (castContext.getDeviceCapabilities().touch_input_supported) {
|
||||
timeOut = window.setTimeout(() => playDummyMedia(viewTitle), 540000); // repeat every 9 minutes to keep it active (gets deactivated after 10 minutes)
|
||||
}
|
||||
};
|
||||
|
||||
@@ -27,7 +27,6 @@ import type {
|
||||
LovelaceConfig,
|
||||
LovelaceDashboardStrategyConfig,
|
||||
} from "../../../../src/data/lovelace/config/types";
|
||||
import { normalizeLovelaceConfig } from "../../../../src/data/lovelace/config/normalize";
|
||||
import { isStrategyDashboard } from "../../../../src/data/lovelace/config/types";
|
||||
import { fetchResources } from "../../../../src/data/lovelace/resource";
|
||||
import { loadLovelaceResources } from "../../../../src/panels/lovelace/common/load-resources";
|
||||
@@ -35,6 +34,7 @@ import { HassElement } from "../../../../src/state/hass-element";
|
||||
import { castContext } from "../cast_context";
|
||||
import "./hc-launch-screen";
|
||||
import { getPanelTitleFromUrlPath } from "../../../../src/data/panel";
|
||||
import { checkLovelaceConfig } from "../../../../src/panels/lovelace/common/check-lovelace-config";
|
||||
|
||||
const DEFAULT_CONFIG: LovelaceDashboardStrategyConfig = {
|
||||
strategy: {
|
||||
@@ -359,7 +359,7 @@ export class HcMain extends HassElement {
|
||||
this._urlPath || "lovelace"
|
||||
);
|
||||
castContext.setApplicationState(title || "");
|
||||
this._lovelaceConfig = normalizeLovelaceConfig(
|
||||
this._lovelaceConfig = checkLovelaceConfig(
|
||||
lovelaceConfig
|
||||
) as LovelaceConfig;
|
||||
}
|
||||
|
||||
@@ -1,16 +1,12 @@
|
||||
import { navigate } from "../../../src/common/navigate";
|
||||
import { slugify } from "../../../src/common/string/slugify";
|
||||
import type { LocalizeFunc } from "../../../src/common/translations/localize";
|
||||
import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
|
||||
import type { Lovelace } from "../../../src/panels/lovelace/types";
|
||||
import { setDemoAreas } from "../stubs/area_registry";
|
||||
import { setPlatformEntityRegistryEntries } from "../stubs/entity_registry";
|
||||
import { energyEntities } from "../stubs/entities";
|
||||
import { connectivityEntities } from "../stubs/connectivity/fixtures";
|
||||
import { setDemoFloors } from "../stubs/floor_registry";
|
||||
import { getDemoTheme } from "../stubs/frontend";
|
||||
import type { EntityInput } from "../../../src/fake_data/entities/types";
|
||||
import type { DemoAutomation, DemoConfig, DemoTheme } from "./types";
|
||||
import type { DemoConfig, DemoTheme } from "./types";
|
||||
|
||||
export const applyDemoTheme = (hass: MockHomeAssistant, theme: DemoTheme) => {
|
||||
if (typeof theme === "function") {
|
||||
@@ -20,95 +16,6 @@ export const applyDemoTheme = (hass: MockHomeAssistant, theme: DemoTheme) => {
|
||||
hass.mockTheme(null, getDemoTheme(theme));
|
||||
};
|
||||
|
||||
export const automationEntity = ({
|
||||
config: automation,
|
||||
entityId,
|
||||
state = "on",
|
||||
icon,
|
||||
lastTriggered,
|
||||
}: DemoAutomation): EntityInput => ({
|
||||
entity_id: entityId ?? `automation.${slugify(automation.alias)}`,
|
||||
platform: "automation",
|
||||
state,
|
||||
attributes: {
|
||||
id: automation.id,
|
||||
friendly_name: automation.alias,
|
||||
last_triggered:
|
||||
lastTriggered === undefined
|
||||
? null
|
||||
: new Date(Date.now() - lastTriggered * 60000).toISOString(),
|
||||
mode: automation.mode ?? "single",
|
||||
current: 0,
|
||||
// Like core, only the modes that can run more than once have a maximum
|
||||
...((automation.mode === "queued" || automation.mode === "parallel") && {
|
||||
max: automation.max ?? 10,
|
||||
}),
|
||||
icon,
|
||||
},
|
||||
});
|
||||
|
||||
// Automations saved in the editor during this session, per demo
|
||||
// Automations saved in the editor during this session, per demo. A deleted
|
||||
// automation is null.
|
||||
const savedAutomations: Record<
|
||||
string,
|
||||
Record<string, DemoAutomation | null>
|
||||
> = {};
|
||||
|
||||
export const saveDemoAutomation = (
|
||||
id: string,
|
||||
automation: DemoAutomation | null
|
||||
) => {
|
||||
savedAutomations[selectedDemo] = {
|
||||
...savedAutomations[selectedDemo],
|
||||
[id]: automation,
|
||||
};
|
||||
};
|
||||
|
||||
// The automations of a demo config, with the ones saved in the editor
|
||||
export const demoAutomations = (
|
||||
demo: string,
|
||||
config: DemoConfig
|
||||
): DemoAutomation[] => {
|
||||
const saved = savedAutomations[demo] ?? {};
|
||||
const configured = config.automations ?? [];
|
||||
const ids = new Set(configured.map((automation) => automation.config.id));
|
||||
return [
|
||||
...configured.map((automation) =>
|
||||
automation.config.id in saved ? saved[automation.config.id] : automation
|
||||
),
|
||||
...Object.values(saved).filter(
|
||||
(automation) => automation && !ids.has(automation.config.id)
|
||||
),
|
||||
].filter((automation): automation is DemoAutomation => automation !== null);
|
||||
};
|
||||
|
||||
// The automation entities are derived from the configs the editor shows, so
|
||||
// their names and IDs always match.
|
||||
export const demoConfigEntities = (
|
||||
demo: string,
|
||||
config: DemoConfig,
|
||||
localize: LocalizeFunc
|
||||
): EntityInput[] => [
|
||||
...config.entities(localize),
|
||||
...demoAutomations(demo, config).map(automationEntity),
|
||||
];
|
||||
|
||||
// Like core, the automations of a demo config are in the entity registry
|
||||
export const registerDemoAutomations = (
|
||||
hass: MockHomeAssistant,
|
||||
demo: string,
|
||||
config: DemoConfig
|
||||
) =>
|
||||
setPlatformEntityRegistryEntries(
|
||||
hass,
|
||||
"automation",
|
||||
demoAutomations(demo, config).map((automation) => ({
|
||||
entity_id: automationEntity(automation).entity_id,
|
||||
unique_id: automation.config.id,
|
||||
}))
|
||||
);
|
||||
|
||||
export const demoConfigs: Record<string, () => Promise<DemoConfig>> = {
|
||||
sections: () => import("./sections").then((mod) => mod.demoSections),
|
||||
home: () => import("./home").then((mod) => mod.demoHome),
|
||||
@@ -145,8 +52,7 @@ export const setDemoConfig = async (
|
||||
|
||||
setDemoFloors(hass, config.floors);
|
||||
setDemoAreas(hass, config.areas);
|
||||
hass.addEntities(demoConfigEntities(demo, config, hass.localize), true);
|
||||
registerDemoAutomations(hass, demo, config);
|
||||
hass.addEntities(config.entities(hass.localize), true);
|
||||
hass.addEntities(energyEntities());
|
||||
// Replaced the whole state map above, so the entities that do not belong to a
|
||||
// demo config have to be added back.
|
||||
|
||||
@@ -7,7 +7,7 @@ import type { HomeDashboardStrategyConfig } from "../../../../src/panels/lovelac
|
||||
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 type { HeadingCardConfig } from "../../../../src/panels/lovelace/cards/types";
|
||||
import { generateLovelaceSectionStrategy } from "../../../../src/panels/lovelace/strategies/get-strategy";
|
||||
import type { HomeAssistant } from "../../../../src/types";
|
||||
|
||||
export interface DemoHomeDashboardStrategyConfig extends Omit<
|
||||
@@ -61,33 +61,29 @@ class DemoHomeOverviewViewStrategy extends ReactiveElement {
|
||||
{ ...config, type: "home-overview" },
|
||||
hass
|
||||
);
|
||||
// Add the demo card to the favorites section, found by its heading
|
||||
const favoritesHeading = hass.localize(
|
||||
"ui.panel.lovelace.strategy.home.favorites"
|
||||
// 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],
|
||||
};
|
||||
})
|
||||
);
|
||||
const sections = (view.sections || []).map((section) => {
|
||||
if (
|
||||
isStrategySection(section) ||
|
||||
!section.cards?.some(
|
||||
(card) =>
|
||||
card.type === "heading" &&
|
||||
(card as HeadingCardConfig).heading === favoritesHeading
|
||||
)
|
||||
) {
|
||||
return section;
|
||||
}
|
||||
const [heading, ...cards] = section.cards;
|
||||
return {
|
||||
...section,
|
||||
// Place the demo card first so the tiles fill the rows next to it. It
|
||||
// takes up the space of two tiles, so keep two less, but at least six.
|
||||
cards: [
|
||||
heading,
|
||||
{ type: "custom:ha-demo-next-card" },
|
||||
...cards.slice(0, Math.max(6, cards.length - 2)),
|
||||
],
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
...view,
|
||||
|
||||
@@ -1,361 +0,0 @@
|
||||
import type { DemoAutomation } from "../types";
|
||||
|
||||
export const demoAutomationsSections: DemoAutomation[] = [
|
||||
{
|
||||
lastTriggered: 60 * 9,
|
||||
state: "off",
|
||||
icon: "mdi:auto-mode",
|
||||
config: {
|
||||
id: "1700669321947",
|
||||
alias: "Home Assistant Auto-update",
|
||||
description:
|
||||
"Installs Home Assistant updates at night, so nobody is bothered by the restart.",
|
||||
triggers: [{ trigger: "time", at: "03:00:00" }],
|
||||
conditions: [
|
||||
{
|
||||
condition: "update.is_available",
|
||||
target: { entity_id: "update.home_assistant_core_update" },
|
||||
options: { for: "00:00:00" },
|
||||
},
|
||||
],
|
||||
actions: [
|
||||
{
|
||||
action: "update.install",
|
||||
target: { entity_id: "update.home_assistant_core_update" },
|
||||
data: { backup: true },
|
||||
},
|
||||
],
|
||||
mode: "queued",
|
||||
},
|
||||
},
|
||||
{
|
||||
lastTriggered: 25,
|
||||
config: {
|
||||
id: "1700669322001",
|
||||
alias: "Kitchen lights on motion",
|
||||
description:
|
||||
"Turns on the kitchen lights when motion is detected and turns them off again once the kitchen has been empty for 2 minutes.",
|
||||
triggers: [
|
||||
{
|
||||
trigger: "motion.detected",
|
||||
target: { entity_id: "binary_sensor.kitchen_motion" },
|
||||
options: { for: "00:00:00" },
|
||||
},
|
||||
],
|
||||
conditions: [],
|
||||
actions: [
|
||||
{
|
||||
action: "light.turn_on",
|
||||
target: {
|
||||
entity_id: ["light.kitchen_spotlights", "light.worktop_spotlights"],
|
||||
},
|
||||
data: { brightness_pct: 80 },
|
||||
},
|
||||
{
|
||||
wait_for_trigger: [
|
||||
{
|
||||
trigger: "motion.cleared",
|
||||
target: { entity_id: "binary_sensor.kitchen_motion" },
|
||||
options: { for: "00:02:00" },
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
action: "light.turn_off",
|
||||
target: {
|
||||
entity_id: ["light.kitchen_spotlights", "light.worktop_spotlights"],
|
||||
},
|
||||
},
|
||||
],
|
||||
mode: "restart",
|
||||
},
|
||||
},
|
||||
{
|
||||
lastTriggered: 60 * 3,
|
||||
config: {
|
||||
id: "1700669322002",
|
||||
alias: "Outdoor light from sunset to sunrise",
|
||||
description:
|
||||
"Uses trigger IDs to handle both sunset and sunrise in one automation.",
|
||||
triggers: [
|
||||
{
|
||||
trigger: "sun.sunset",
|
||||
id: "sunset",
|
||||
options: {
|
||||
offset: { days: 0, hours: 0, minutes: 15, seconds: 0 },
|
||||
offset_type: "before",
|
||||
},
|
||||
},
|
||||
{
|
||||
trigger: "sun.sunrise",
|
||||
id: "sunrise",
|
||||
options: {
|
||||
offset: { days: 0, hours: 0, minutes: 0, seconds: 0 },
|
||||
offset_type: "before",
|
||||
},
|
||||
},
|
||||
],
|
||||
conditions: [],
|
||||
actions: [
|
||||
{
|
||||
choose: [
|
||||
{
|
||||
conditions: [{ condition: "trigger", id: "sunset" }],
|
||||
sequence: [
|
||||
{
|
||||
action: "light.turn_on",
|
||||
target: { entity_id: "light.outdoor_light" },
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
conditions: [{ condition: "trigger", id: "sunrise" }],
|
||||
sequence: [
|
||||
{
|
||||
action: "light.turn_off",
|
||||
target: { entity_id: "light.outdoor_light" },
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
mode: "single",
|
||||
},
|
||||
},
|
||||
{
|
||||
lastTriggered: 60 * 14,
|
||||
config: {
|
||||
id: "1700669322003",
|
||||
alias: "Flood light on motion when dark",
|
||||
description:
|
||||
"Turns on the flood light for 5 minutes when someone walks by in the dark.",
|
||||
triggers: [
|
||||
{
|
||||
trigger: "motion.detected",
|
||||
target: { entity_id: "binary_sensor.outdoor_motion_sensor_motion" },
|
||||
options: { for: "00:00:00" },
|
||||
},
|
||||
],
|
||||
conditions: [
|
||||
{
|
||||
condition: "illuminance.is_value",
|
||||
target: { entity_id: "sensor.outdoor_motion_sensor_illuminance" },
|
||||
options: {
|
||||
threshold: {
|
||||
type: "below",
|
||||
value: { number: 50, unit_of_measurement: "lx" },
|
||||
},
|
||||
for: "00:00:00",
|
||||
},
|
||||
},
|
||||
],
|
||||
actions: [
|
||||
{
|
||||
action: "light.turn_on",
|
||||
target: { entity_id: "light.flood_light" },
|
||||
},
|
||||
{ delay: { hours: 0, minutes: 5, seconds: 0, milliseconds: 0 } },
|
||||
{
|
||||
action: "light.turn_off",
|
||||
target: { entity_id: "light.flood_light" },
|
||||
},
|
||||
],
|
||||
mode: "restart",
|
||||
},
|
||||
},
|
||||
{
|
||||
lastTriggered: 60 * 26,
|
||||
config: {
|
||||
id: "1700669322004",
|
||||
alias: "Close the shutters when it gets hot",
|
||||
description:
|
||||
"Keeps the living room cool by closing the shutters on hot, sunny days.",
|
||||
triggers: [
|
||||
{
|
||||
trigger: "temperature.crossed_threshold",
|
||||
target: { entity_id: "sensor.outdoor_temperature" },
|
||||
options: {
|
||||
threshold: {
|
||||
type: "above",
|
||||
value: { number: 25, unit_of_measurement: "°C" },
|
||||
},
|
||||
for: "00:10:00",
|
||||
},
|
||||
},
|
||||
],
|
||||
conditions: [{ condition: "sun.is_up" }],
|
||||
actions: [
|
||||
{
|
||||
action: "cover.close_cover",
|
||||
target: {
|
||||
entity_id: [
|
||||
"cover.living_room_garden_shutter",
|
||||
"cover.living_room_graveyard_shutter",
|
||||
"cover.living_room_left_shutter",
|
||||
"cover.living_room_right_shutter",
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
mode: "single",
|
||||
},
|
||||
},
|
||||
{
|
||||
lastTriggered: 60 * 50,
|
||||
config: {
|
||||
id: "1700669322005",
|
||||
alias: "Fridge door left open",
|
||||
description:
|
||||
"Sends a reminder when the fridge door has been open for 2 minutes.",
|
||||
triggers: [
|
||||
{
|
||||
trigger: "door.opened",
|
||||
target: { entity_id: "binary_sensor.fridge_door" },
|
||||
options: { for: "00:02:00" },
|
||||
},
|
||||
],
|
||||
conditions: [],
|
||||
actions: [
|
||||
{
|
||||
action: "notify.notify",
|
||||
data: {
|
||||
title: "Fridge door",
|
||||
message: "The fridge door has been open for 2 minutes.",
|
||||
},
|
||||
},
|
||||
],
|
||||
mode: "single",
|
||||
},
|
||||
},
|
||||
{
|
||||
lastTriggered: 90,
|
||||
config: {
|
||||
id: "1700669322006",
|
||||
alias: "Meeting mode",
|
||||
description:
|
||||
"Pauses the music in the study and turns on the lights when a meeting starts.",
|
||||
triggers: [
|
||||
{
|
||||
trigger: "switch.turned_on",
|
||||
target: { entity_id: "switch.in_meeting" },
|
||||
options: { for: "00:00:00" },
|
||||
},
|
||||
],
|
||||
conditions: [],
|
||||
actions: [
|
||||
{
|
||||
action: "media_player.media_pause",
|
||||
target: { entity_id: "media_player.study_nest_hub" },
|
||||
},
|
||||
{
|
||||
action: "light.turn_on",
|
||||
target: { entity_id: "light.study_spotlights" },
|
||||
data: { brightness_pct: 100, color_temp_kelvin: 4000 },
|
||||
},
|
||||
],
|
||||
mode: "single",
|
||||
},
|
||||
},
|
||||
{
|
||||
lastTriggered: 60 * 20,
|
||||
config: {
|
||||
id: "1700669322009",
|
||||
alias: "Lights up when the music pauses",
|
||||
description:
|
||||
"Turns on the living room spotlights when the music is paused in the evening.",
|
||||
triggers: [
|
||||
{
|
||||
trigger: "media_player.paused_playing",
|
||||
target: { entity_id: "media_player.living_room_nest_mini" },
|
||||
options: { for: "00:00:00" },
|
||||
},
|
||||
],
|
||||
conditions: [{ condition: "sun.is_set" }],
|
||||
actions: [
|
||||
{
|
||||
action: "light.turn_on",
|
||||
target: { entity_id: "light.living_room_spotlights" },
|
||||
data: { brightness_pct: 40 },
|
||||
},
|
||||
],
|
||||
mode: "single",
|
||||
},
|
||||
},
|
||||
{
|
||||
lastTriggered: 60 * 7,
|
||||
config: {
|
||||
id: "1700669322007",
|
||||
alias: "Lower heating when the car leaves",
|
||||
triggers: [
|
||||
{
|
||||
trigger: "zone.left",
|
||||
target: { entity_id: "device_tracker.car" },
|
||||
options: { zone: "zone.home", for: "00:00:00" },
|
||||
},
|
||||
],
|
||||
conditions: [
|
||||
{
|
||||
condition: "time",
|
||||
weekday: ["mon", "tue", "wed", "thu", "fri"],
|
||||
},
|
||||
],
|
||||
actions: [
|
||||
{
|
||||
action: "climate.set_temperature",
|
||||
target: {
|
||||
entity_id: ["climate.ground_floor", "climate.first_floor"],
|
||||
},
|
||||
data: { temperature: 17 },
|
||||
},
|
||||
],
|
||||
mode: "single",
|
||||
},
|
||||
},
|
||||
{
|
||||
lastTriggered: 60 * 11,
|
||||
config: {
|
||||
id: "1700669322008",
|
||||
alias: "Good night",
|
||||
description:
|
||||
"Turns off all lights and closes the shutters at 23:00 on weeknights.",
|
||||
triggers: [
|
||||
{
|
||||
trigger: "time",
|
||||
at: "23:00:00",
|
||||
weekday: ["sun", "mon", "tue", "wed", "thu"],
|
||||
},
|
||||
],
|
||||
conditions: [],
|
||||
actions: [
|
||||
{
|
||||
action: "light.turn_off",
|
||||
target: {
|
||||
entity_id: [
|
||||
"light.floor_lamp",
|
||||
"light.living_room_spotlights",
|
||||
"light.bar_lamp",
|
||||
"light.kitchen_spotlights",
|
||||
"light.worktop_spotlights",
|
||||
"light.study_spotlights",
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
action: "cover.close_cover",
|
||||
target: {
|
||||
entity_id: [
|
||||
"cover.living_room_garden_shutter",
|
||||
"cover.living_room_graveyard_shutter",
|
||||
"cover.living_room_left_shutter",
|
||||
"cover.living_room_right_shutter",
|
||||
"cover.kitchen_shutter",
|
||||
"cover.study_shutter",
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
mode: "single",
|
||||
},
|
||||
},
|
||||
];
|
||||
@@ -140,18 +140,6 @@ export const demoEntitiesSections: DemoConfig["entities"] = (localize) =>
|
||||
friendly_name: "Outdoor humidity",
|
||||
},
|
||||
},
|
||||
// Used by the automation for when the car leaves home
|
||||
"zone.home": {
|
||||
entity_id: "zone.home",
|
||||
state: "0",
|
||||
attributes: {
|
||||
latitude: 52.3731339,
|
||||
longitude: 4.8903147,
|
||||
radius: 100,
|
||||
friendly_name: "Home",
|
||||
icon: "mdi:home",
|
||||
},
|
||||
},
|
||||
"device_tracker.car": {
|
||||
entity_id: "device_tracker.car",
|
||||
state: "not_home",
|
||||
@@ -526,6 +514,19 @@ export const demoEntitiesSections: DemoConfig["entities"] = (localize) =>
|
||||
friendly_name: "Outdoor motion sensor Illuminance",
|
||||
},
|
||||
},
|
||||
"automation.home_assistant_auto_update": {
|
||||
entity_id: "automation.home_assistant_auto_update",
|
||||
state: "off",
|
||||
attributes: {
|
||||
id: "1700669321947",
|
||||
last_triggered: "2024-02-29T18:02:05.343139+00:00",
|
||||
mode: "queued",
|
||||
current: 0,
|
||||
max: 50,
|
||||
icon: "mdi:auto-mode",
|
||||
friendly_name: "Home Assistant Auto-update",
|
||||
},
|
||||
},
|
||||
"update.home_assistant_operating_system_update": {
|
||||
entity_id: "update.home_assistant_operating_system_update",
|
||||
state: "off",
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import type { DemoConfig } from "../types";
|
||||
import { demoAutomationsSections } from "./automations";
|
||||
import { demoEntitiesSections } from "./entities";
|
||||
import { demoLovelaceSections } from "./lovelace";
|
||||
|
||||
@@ -9,6 +8,5 @@ export const demoSections: DemoConfig = {
|
||||
name: "Home Demo",
|
||||
lovelace: demoLovelaceSections,
|
||||
entities: demoEntitiesSections,
|
||||
automations: demoAutomationsSections,
|
||||
theme: { theme: "default", dark: false },
|
||||
};
|
||||
@@ -1,6 +1,5 @@
|
||||
import type { TemplateResult } from "lit";
|
||||
import type { LocalizeFunc } from "../../../src/common/translations/localize";
|
||||
import type { ManualAutomationConfig } from "../../../src/data/automation";
|
||||
import type { LovelaceRawConfig } from "../../../src/data/lovelace/config/types";
|
||||
import type { EntityInput } from "../../../src/fake_data/entities/types";
|
||||
import type { ThemeSettings } from "../../../src/types";
|
||||
@@ -9,16 +8,6 @@ import type { DemoFloor } from "../stubs/floor_registry";
|
||||
|
||||
export type DemoTheme = ThemeSettings | (() => Record<string, string> | null);
|
||||
|
||||
export interface DemoAutomation {
|
||||
config: ManualAutomationConfig & { id: string; alias: string };
|
||||
/** Defaults to the alias as slug */
|
||||
entityId?: string;
|
||||
state?: "on" | "off";
|
||||
icon?: string;
|
||||
/** Minutes ago */
|
||||
lastTriggered?: number;
|
||||
}
|
||||
|
||||
export interface DemoConfig {
|
||||
index?: number;
|
||||
name: string;
|
||||
@@ -28,7 +17,6 @@ export interface DemoConfig {
|
||||
string | ((localize: LocalizeFunc) => string | TemplateResult<1>);
|
||||
lovelace: (localize: LocalizeFunc) => LovelaceRawConfig;
|
||||
entities: (localize: LocalizeFunc) => EntityInput[];
|
||||
automations?: DemoAutomation[];
|
||||
floors?: DemoFloor[];
|
||||
areas?: DemoArea[];
|
||||
theme: DemoTheme;
|
||||
|
||||
+14
-120
@@ -1,18 +1,11 @@
|
||||
import { customElement } from "lit/decorators";
|
||||
import type { LocalizeFunc } from "../../src/common/translations/localize";
|
||||
import { isNavigationClick } from "../../src/common/dom/is-navigation-click";
|
||||
import { navigate } from "../../src/common/navigate";
|
||||
import type { MockHomeAssistant } from "../../src/fake_data/provide_hass";
|
||||
import { provideHass } from "../../src/fake_data/provide_hass";
|
||||
import { HomeAssistantAppEl } from "../../src/layouts/home-assistant";
|
||||
import type { HomeAssistant } from "../../src/types";
|
||||
import {
|
||||
applyDemoTheme,
|
||||
demoConfigEntities,
|
||||
registerDemoAutomations,
|
||||
selectedDemo,
|
||||
selectedDemoConfig,
|
||||
} from "./configs/demo-configs";
|
||||
import { applyDemoTheme, selectedDemoConfig } from "./configs/demo-configs";
|
||||
import { mockAreaRegistry, setDemoAreas } from "./stubs/area_registry";
|
||||
import {
|
||||
connectivityCommands,
|
||||
@@ -25,10 +18,7 @@ import { demoDevices } from "./stubs/devices";
|
||||
import { mockDeviceRegistry } from "./stubs/device_registry";
|
||||
import { mockEnergy } from "./stubs/energy";
|
||||
import { energyEntities } from "./stubs/entities";
|
||||
import {
|
||||
mockEntityRegistry,
|
||||
mockEntityRegistryDisplay,
|
||||
} from "./stubs/entity_registry";
|
||||
import { mockEntityRegistry } from "./stubs/entity_registry";
|
||||
import { mockEvents } from "./stubs/events";
|
||||
import { mockFloorRegistry, setDemoFloors } from "./stubs/floor_registry";
|
||||
import { mockFrontend } from "./stubs/frontend";
|
||||
@@ -36,11 +26,9 @@ import { mockHardware } from "./stubs/hardware";
|
||||
import { mockHassioSupervisor } from "./stubs/hassio_supervisor";
|
||||
import { mockIntegration } from "./stubs/integration";
|
||||
import { mockLabelRegistry } from "./stubs/label_registry";
|
||||
import { automationPlatformDomains } from "./stubs/automation_platform_domains";
|
||||
import { mockIcons } from "./stubs/icons";
|
||||
import { mockHistory } from "./stubs/history";
|
||||
import { mockLovelace } from "./stubs/lovelace";
|
||||
import { zoneRegistryEntries } from "./stubs/map";
|
||||
import { mockMediaPlayer } from "./stubs/media_player";
|
||||
import { mockPersistentNotification } from "./stubs/persistent_notification";
|
||||
import { mockRecorder } from "./stubs/recorder";
|
||||
@@ -69,12 +57,6 @@ const CONFIG_PANEL_COMMANDS = [
|
||||
"system_health/",
|
||||
"backup/",
|
||||
"automation/config",
|
||||
"trigger_platforms/subscribe",
|
||||
"condition_platforms/subscribe",
|
||||
"get_triggers_for_target",
|
||||
"get_conditions_for_target",
|
||||
"get_services_for_target",
|
||||
"extract_from_target",
|
||||
"script/config",
|
||||
"config/automation/config",
|
||||
"config/script/config",
|
||||
@@ -84,7 +66,6 @@ const CONFIG_PANEL_COMMANDS = [
|
||||
"assist_pipeline/",
|
||||
"config/entity_registry/settings/",
|
||||
"slugify",
|
||||
"marketplace/",
|
||||
...connectivityCommands,
|
||||
];
|
||||
|
||||
@@ -106,81 +87,25 @@ export class HaDemo extends HomeAssistantAppEl {
|
||||
config: {
|
||||
...hass.config,
|
||||
components: [
|
||||
// Some automation platforms are loaded already
|
||||
...new Set([
|
||||
...(hass.config?.components ?? []),
|
||||
"backup",
|
||||
"webhook",
|
||||
"usage_prediction",
|
||||
"assist_pipeline",
|
||||
"hassio",
|
||||
"hardware",
|
||||
"marketplace",
|
||||
...connectivityComponents,
|
||||
...automationPlatformDomains,
|
||||
]),
|
||||
...(hass.config?.components ?? []),
|
||||
"backup",
|
||||
"webhook",
|
||||
"usage_prediction",
|
||||
"assist_pipeline",
|
||||
"hassio",
|
||||
"hardware",
|
||||
...connectivityComponents,
|
||||
],
|
||||
},
|
||||
panels: {
|
||||
...hass.panels,
|
||||
// Opened from the settings page, not listed in the sidebar
|
||||
marketplace: {
|
||||
component_name: "marketplace",
|
||||
icon: null,
|
||||
title: null,
|
||||
config: null,
|
||||
url_path: "marketplace",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const localizePromise =
|
||||
// @ts-ignore
|
||||
this._loadFragmentTranslations(hass.language, "page-demo").then(
|
||||
// hass is not updated yet when another fragment started loading
|
||||
// meanwhile, so use the localize with these translations directly
|
||||
(localize?: LocalizeFunc) => localize ?? this.hass!.localize
|
||||
() => this.hass!.localize
|
||||
);
|
||||
|
||||
mockLovelace(hass, localizePromise, [
|
||||
{
|
||||
id: "music",
|
||||
url_path: "music",
|
||||
require_admin: false,
|
||||
show_in_sidebar: true,
|
||||
icon: "mdi:music",
|
||||
title: "Music",
|
||||
mode: "storage",
|
||||
},
|
||||
{
|
||||
id: "cameras",
|
||||
url_path: "cameras",
|
||||
require_admin: false,
|
||||
show_in_sidebar: true,
|
||||
icon: "mdi:cctv",
|
||||
title: "Cameras",
|
||||
mode: "storage",
|
||||
},
|
||||
{
|
||||
id: "garden",
|
||||
url_path: "garden",
|
||||
require_admin: false,
|
||||
show_in_sidebar: true,
|
||||
icon: "mdi:flower",
|
||||
title: "Garden",
|
||||
mode: "storage",
|
||||
},
|
||||
{
|
||||
id: "lovelace-advanced",
|
||||
url_path: "lovelace-advanced",
|
||||
require_admin: false,
|
||||
show_in_sidebar: false,
|
||||
icon: "mdi:cog",
|
||||
title: "Advanced",
|
||||
mode: "yaml",
|
||||
filename: "lovelace-advanced.yaml",
|
||||
},
|
||||
]);
|
||||
mockLovelace(hass, localizePromise);
|
||||
mockAuth(hass);
|
||||
mockTranslations(hass, localizePromise);
|
||||
mockHistory(hass);
|
||||
@@ -210,37 +135,8 @@ export class HaDemo extends HomeAssistantAppEl {
|
||||
mockAreaRegistry(hass);
|
||||
mockDeviceRegistry(hass, demoDevices);
|
||||
mockFloorRegistry(hass);
|
||||
mockLabelRegistry(hass, [
|
||||
{
|
||||
label_id: "kitchen",
|
||||
name: "Kitchen",
|
||||
icon: null,
|
||||
color: "red",
|
||||
description: "Everything in the kitchen",
|
||||
created_at: 1735689600,
|
||||
modified_at: 1735689600,
|
||||
},
|
||||
{
|
||||
label_id: "garden",
|
||||
name: "Garden",
|
||||
icon: "mdi:flower",
|
||||
color: "green",
|
||||
description: null,
|
||||
created_at: 1735689600,
|
||||
modified_at: 1735689600,
|
||||
},
|
||||
{
|
||||
label_id: "battery-powered",
|
||||
name: "Battery powered",
|
||||
icon: null,
|
||||
color: "orange",
|
||||
description: "Runs on batteries",
|
||||
created_at: 1735689600,
|
||||
modified_at: 1735689600,
|
||||
},
|
||||
]);
|
||||
mockLabelRegistry(hass);
|
||||
mockUsagePrediction(hass);
|
||||
mockEntityRegistryDisplay(hass);
|
||||
mockEntityRegistry(hass, [
|
||||
{
|
||||
config_entry_id: "co2signal",
|
||||
@@ -285,7 +181,6 @@ export class HaDemo extends HomeAssistantAppEl {
|
||||
modified_at: 0,
|
||||
},
|
||||
...connectivityEntityRegistryEntries,
|
||||
...zoneRegistryEntries,
|
||||
]);
|
||||
|
||||
hass.addEntities(energyEntities());
|
||||
@@ -296,8 +191,7 @@ export class HaDemo extends HomeAssistantAppEl {
|
||||
([conf, localize]) => {
|
||||
setDemoFloors(hass, conf.floors);
|
||||
setDemoAreas(hass, conf.areas);
|
||||
hass.addEntities(demoConfigEntities(selectedDemo, conf, localize));
|
||||
registerDemoAutomations(hass, selectedDemo, conf);
|
||||
hass.addEntities(conf.entities(localize));
|
||||
applyDemoTheme(hass, conf.theme);
|
||||
}
|
||||
);
|
||||
|
||||
@@ -39,9 +39,6 @@
|
||||
html {
|
||||
background-color: var(--primary-background-color, #fafafa);
|
||||
color: var(--primary-text-color, #212121);
|
||||
/* Prevent iOS Safari from inflating text when rotating to landscape */
|
||||
-webkit-text-size-adjust: 100%;
|
||||
text-size-adjust: 100%;
|
||||
}
|
||||
@media (prefers-color-scheme: dark) {
|
||||
html {
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import type { OwnProfileMutableParams } from "../../../src/data/person";
|
||||
import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
|
||||
|
||||
export const mockAuth = (hass: MockHomeAssistant) => {
|
||||
@@ -7,22 +6,4 @@ export const mockAuth = (hass: MockHomeAssistant) => {
|
||||
hass.mockWS("auth/sign_path", (msg: { path: string }) => ({
|
||||
path: msg.path,
|
||||
}));
|
||||
|
||||
let name: string | undefined;
|
||||
// The profile dialog refreshes the user subscription after saving, which
|
||||
// writes the updated user back to hass.user. Read the current user on each
|
||||
// call so later changes to it, such as a non-admin user, are kept.
|
||||
hass.mockWS("auth/current_user", (_msg, currentHass: MockHomeAssistant) =>
|
||||
name && currentHass.user ? { ...currentHass.user, name } : currentHass.user
|
||||
);
|
||||
hass.mockWS(
|
||||
"person/update_own_profile",
|
||||
(msg: Partial<OwnProfileMutableParams>, currentHass: MockHomeAssistant) => {
|
||||
if (msg.name) {
|
||||
name = msg.name;
|
||||
}
|
||||
|
||||
return { user_name: name ?? currentHass.user?.name, person: null };
|
||||
}
|
||||
);
|
||||
};
|
||||
+56
-142
@@ -1,155 +1,69 @@
|
||||
import type { AutomationConfig } from "../../../src/data/automation";
|
||||
import type { ScriptConfig } from "../../../src/data/script";
|
||||
import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
|
||||
import { computeDomain } from "../../../src/common/entity/compute_domain";
|
||||
import { slugify } from "../../../src/common/string/slugify";
|
||||
import {
|
||||
automationEntity,
|
||||
demoAutomations,
|
||||
saveDemoAutomation,
|
||||
selectedDemo,
|
||||
selectedDemoConfig,
|
||||
} from "../configs/demo-configs";
|
||||
import type { DemoAutomation } from "../configs/types";
|
||||
import { mockAutomationPlatforms } from "./automation_platforms";
|
||||
import {
|
||||
addEntityRegistryEntry,
|
||||
getEntityRegistryEntry,
|
||||
removeEntityRegistryEntry,
|
||||
} from "./entity_registry";
|
||||
|
||||
const automationConfig = async (
|
||||
id: string | undefined
|
||||
): Promise<AutomationConfig> => {
|
||||
const automation = demoAutomations(
|
||||
selectedDemo,
|
||||
await selectedDemoConfig
|
||||
).find((a) => a.config.id === id);
|
||||
if (!automation) {
|
||||
throw new Error("Automation not found");
|
||||
}
|
||||
return automation.config;
|
||||
};
|
||||
const demoAutomationConfig = (entityId: string): AutomationConfig => ({
|
||||
id: entityId.split(".")[1],
|
||||
alias: "Demo automation",
|
||||
description: "An example automation shown in the demo.",
|
||||
triggers: [
|
||||
{ trigger: "state", entity_id: "binary_sensor.basement_floor_wet" },
|
||||
],
|
||||
conditions: [],
|
||||
actions: [
|
||||
{
|
||||
action: "light.turn_on",
|
||||
target: { entity_id: "light.bed_light" },
|
||||
},
|
||||
],
|
||||
mode: "single",
|
||||
});
|
||||
|
||||
const scriptConfig = (
|
||||
currentHass: MockHomeAssistant,
|
||||
entityId: string
|
||||
): ScriptConfig => {
|
||||
const alias =
|
||||
currentHass.states[entityId]?.attributes.friendly_name ?? entityId;
|
||||
return {
|
||||
alias,
|
||||
sequence: [
|
||||
{
|
||||
action: "persistent_notification.create",
|
||||
data: { message: `${alias} ran` },
|
||||
},
|
||||
],
|
||||
mode: "single",
|
||||
};
|
||||
};
|
||||
const demoScriptConfig = (): ScriptConfig => ({
|
||||
alias: "Demo script",
|
||||
description: "An example script shown in the demo.",
|
||||
sequence: [
|
||||
{
|
||||
action: "light.turn_on",
|
||||
target: { entity_id: "light.bed_light" },
|
||||
},
|
||||
],
|
||||
mode: "single",
|
||||
});
|
||||
|
||||
export const mockAutomation = (hass: MockHomeAssistant) => {
|
||||
hass.mockWS(
|
||||
"automation/config",
|
||||
async (msg: { entity_id: string }, currentHass: MockHomeAssistant) => ({
|
||||
config: await automationConfig(
|
||||
currentHass.states[msg.entity_id]?.attributes.id
|
||||
),
|
||||
})
|
||||
hass.mockWS("automation/config", (msg: { entity_id: string }) => ({
|
||||
config: demoAutomationConfig(msg.entity_id),
|
||||
}));
|
||||
hass.mockWS("script/config", () => ({ config: demoScriptConfig() }));
|
||||
|
||||
hass.mockAPI(/config\/automation\/config\/.+/, () =>
|
||||
demoAutomationConfig("automation.demo")
|
||||
);
|
||||
hass.mockAPI(
|
||||
/config\/automation\/config\/.+/,
|
||||
(currentHass, method, path, parameters) => {
|
||||
const id = decodeURIComponent(path.split("/").pop()!);
|
||||
const existing = Object.values(currentHass.states).find(
|
||||
(stateObj) =>
|
||||
computeDomain(stateObj.entity_id) === "automation" &&
|
||||
stateObj.attributes.id === id
|
||||
);
|
||||
if (method === "DELETE") {
|
||||
// Like core, remove the automation with its entity
|
||||
saveDemoAutomation(id, null);
|
||||
if (existing) {
|
||||
const { [existing.entity_id]: _state, ...states } =
|
||||
currentHass.states;
|
||||
const { [existing.entity_id]: _entity, ...entities } =
|
||||
currentHass.entities;
|
||||
currentHass.updateHass({ states, entities });
|
||||
// Otherwise a service call to it would add its state again
|
||||
delete currentHass.mockEntities[existing.entity_id];
|
||||
removeEntityRegistryEntry(currentHass, existing.entity_id);
|
||||
}
|
||||
return { result: "ok" };
|
||||
}
|
||||
if (method === "POST") {
|
||||
// Core stores the ID in the config
|
||||
const config = {
|
||||
...(parameters as DemoAutomation["config"]),
|
||||
id,
|
||||
};
|
||||
config.alias ||= id;
|
||||
// Like core, update the automation entity, or add it for a new one
|
||||
// A new automation gets a free entity ID, like core
|
||||
const slug = `automation.${slugify(config.alias)}`;
|
||||
let entityId = existing?.entity_id ?? slug;
|
||||
for (let i = 2; !existing && entityId in currentHass.states; i++) {
|
||||
entityId = `${slug}_${i}`;
|
||||
}
|
||||
const lastTriggered = existing?.attributes.last_triggered;
|
||||
const automation: DemoAutomation = {
|
||||
config,
|
||||
entityId,
|
||||
state: existing?.state as DemoAutomation["state"],
|
||||
icon: existing?.attributes.icon,
|
||||
lastTriggered: lastTriggered
|
||||
? (Date.now() - Date.parse(lastTriggered)) / 60000
|
||||
: undefined,
|
||||
};
|
||||
// Kept per demo, so it is still there after switching demos
|
||||
saveDemoAutomation(id, automation);
|
||||
// A display entry of a new automation is left over from another demo
|
||||
const display = existing ? currentHass.entities[entityId] : undefined;
|
||||
currentHass.addEntities(automationEntity(automation));
|
||||
// Adding the entity resets its display entry, keep what the registry
|
||||
// holds
|
||||
if (display) {
|
||||
const registryName = getEntityRegistryEntry(entityId)?.name;
|
||||
currentHass.entities[entityId] = {
|
||||
...currentHass.entities[entityId],
|
||||
...(registryName && { name: registryName }),
|
||||
area_id: display.area_id,
|
||||
labels: display.labels,
|
||||
icon: display.icon,
|
||||
hidden: display.hidden,
|
||||
};
|
||||
}
|
||||
if (!existing) {
|
||||
addEntityRegistryEntry(currentHass, {
|
||||
entity_id: entityId,
|
||||
platform: "automation",
|
||||
unique_id: id,
|
||||
});
|
||||
}
|
||||
return { result: "ok" };
|
||||
}
|
||||
return automationConfig(id);
|
||||
hass.mockAPI(/config\/script\/config\/.+/, () => demoScriptConfig());
|
||||
|
||||
// Trigger/condition type pickers subscribe for integration-provided
|
||||
// platforms. The demo only uses the built-in ones, so emit empty records.
|
||||
hass.mockWS(
|
||||
"trigger_platforms/subscribe",
|
||||
(
|
||||
_msg,
|
||||
_hass,
|
||||
onChange?: (descriptions: Record<string, unknown>) => void
|
||||
) => {
|
||||
onChange?.({});
|
||||
return () => undefined;
|
||||
}
|
||||
);
|
||||
|
||||
// Scripts do not have demo configs yet, show the script under its own name.
|
||||
hass.mockWS(
|
||||
"script/config",
|
||||
(msg: { entity_id: string }, currentHass: MockHomeAssistant) => ({
|
||||
config: scriptConfig(currentHass, msg.entity_id),
|
||||
})
|
||||
"condition_platforms/subscribe",
|
||||
(
|
||||
_msg,
|
||||
_hass,
|
||||
onChange?: (descriptions: Record<string, unknown>) => void
|
||||
) => {
|
||||
onChange?.({});
|
||||
return () => undefined;
|
||||
}
|
||||
);
|
||||
hass.mockAPI(/config\/script\/config\/.+/, (currentHass, _method, path) =>
|
||||
scriptConfig(
|
||||
currentHass,
|
||||
`script.${decodeURIComponent(path.split("/").pop()!)}`
|
||||
)
|
||||
);
|
||||
|
||||
mockAutomationPlatforms(hass);
|
||||
};
|
||||
@@ -1,52 +0,0 @@
|
||||
// This file is auto-generated from Home Assistant Core. Do not edit by hand.
|
||||
// Regenerate with `script/gen_demo_core_data`.
|
||||
|
||||
// Integrations that provide the new style triggers and conditions. Their
|
||||
// translations are part of the demo build.
|
||||
export const automationPlatformDomains = [
|
||||
"air_quality",
|
||||
"alarm_control_panel",
|
||||
"assist_satellite",
|
||||
"battery",
|
||||
"button",
|
||||
"calendar",
|
||||
"climate",
|
||||
"counter",
|
||||
"cover",
|
||||
"door",
|
||||
"doorbell",
|
||||
"event",
|
||||
"fan",
|
||||
"garage_door",
|
||||
"gate",
|
||||
"humidifier",
|
||||
"humidity",
|
||||
"illuminance",
|
||||
"lawn_mower",
|
||||
"light",
|
||||
"lock",
|
||||
"media_player",
|
||||
"moisture",
|
||||
"moon",
|
||||
"motion",
|
||||
"occupancy",
|
||||
"power",
|
||||
"remote",
|
||||
"scene",
|
||||
"schedule",
|
||||
"select",
|
||||
"siren",
|
||||
"sun",
|
||||
"switch",
|
||||
"temperature",
|
||||
"text",
|
||||
"timer",
|
||||
"todo",
|
||||
"update",
|
||||
"vacuum",
|
||||
"valve",
|
||||
"vibration",
|
||||
"water_heater",
|
||||
"window",
|
||||
"zone",
|
||||
];
|
||||
@@ -1,270 +0,0 @@
|
||||
import type { HassServiceTarget } from "home-assistant-js-websocket";
|
||||
import { ensureArray } from "../../../src/common/array/ensure-array";
|
||||
import { computeDomain } from "../../../src/common/entity/compute_domain";
|
||||
import type { ConditionDescriptions } from "../../../src/data/condition";
|
||||
import type { ExtractFromTargetResult } from "../../../src/data/target";
|
||||
import type { TriggerDescriptions } from "../../../src/data/trigger";
|
||||
import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
|
||||
import { getLabelIds } from "./label_registry";
|
||||
import {
|
||||
conditionDescriptions,
|
||||
triggerDescriptions,
|
||||
} from "./automation_platforms_data";
|
||||
|
||||
type Descriptions = TriggerDescriptions | ConditionDescriptions;
|
||||
|
||||
interface EntityFilter {
|
||||
integration?: string;
|
||||
domain?: string | string[];
|
||||
device_class?: string | string[];
|
||||
supported_features?: number[];
|
||||
}
|
||||
|
||||
// Expands a target to its areas, devices and entities, like core does
|
||||
const expandTarget = (
|
||||
hass: MockHomeAssistant,
|
||||
target: HassServiceTarget,
|
||||
primaryEntitiesOnly = true
|
||||
) => {
|
||||
const missing = {
|
||||
floors: [] as string[],
|
||||
areas: [] as string[],
|
||||
devices: [] as string[],
|
||||
labels: [] as string[],
|
||||
};
|
||||
const knownLabelIds = getLabelIds();
|
||||
const floorIds = ensureArray(target.floor_id ?? []);
|
||||
const labelIds = ensureArray(target.label_id ?? []);
|
||||
missing.floors.push(...floorIds.filter((id) => !(id in hass.floors)));
|
||||
// Like core, unknown labels are reported, but still expanded
|
||||
missing.labels.push(...labelIds.filter((id) => !knownLabelIds.includes(id)));
|
||||
const hasLabel = (labels?: string[]) =>
|
||||
labelIds.some((labelId) => labels?.includes(labelId));
|
||||
|
||||
// Unknown areas and devices are reported, but still referenced
|
||||
const areaIds = new Set(ensureArray(target.area_id ?? []));
|
||||
missing.areas.push(...[...areaIds].filter((id) => !(id in hass.areas)));
|
||||
for (const area of Object.values(hass.areas)) {
|
||||
if (
|
||||
(area.floor_id && floorIds.includes(area.floor_id)) ||
|
||||
hasLabel(area.labels)
|
||||
) {
|
||||
areaIds.add(area.area_id);
|
||||
}
|
||||
}
|
||||
|
||||
// A targeted or labeled device includes its child devices
|
||||
const deviceIds = new Set<string>();
|
||||
const addDevice = (deviceId: string) => {
|
||||
deviceIds.add(deviceId);
|
||||
for (const device of Object.values(hass.devices)) {
|
||||
if (device.parent_device_id === deviceId) {
|
||||
deviceIds.add(device.id);
|
||||
}
|
||||
}
|
||||
};
|
||||
for (const deviceId of ensureArray(target.device_id ?? [])) {
|
||||
if (!(deviceId in hass.devices)) {
|
||||
missing.devices.push(deviceId);
|
||||
}
|
||||
addDevice(deviceId);
|
||||
}
|
||||
for (const device of Object.values(hass.devices)) {
|
||||
if (hasLabel(device.labels)) {
|
||||
addDevice(device.id);
|
||||
}
|
||||
}
|
||||
|
||||
// A child device without its own area is in the area of its parent device
|
||||
const deviceArea = (deviceId: string) => {
|
||||
const device = hass.devices[deviceId];
|
||||
return (
|
||||
device?.area_id ??
|
||||
(device?.parent_device_id
|
||||
? hass.devices[device.parent_device_id]?.area_id
|
||||
: undefined)
|
||||
);
|
||||
};
|
||||
const areaDeviceIds = new Set<string>();
|
||||
for (const device of Object.values(hass.devices)) {
|
||||
const areaId = deviceArea(device.id);
|
||||
if (areaId && areaIds.has(areaId)) {
|
||||
areaDeviceIds.add(device.id);
|
||||
}
|
||||
}
|
||||
|
||||
const entityIds = new Set(ensureArray(target.entity_id ?? []));
|
||||
for (const entity of Object.values(hass.entities)) {
|
||||
// The display registry keeps the entities of a previous demo, and like
|
||||
// core, hidden entities are never included indirectly
|
||||
if (!(entity.entity_id in hass.states) || entity.hidden) {
|
||||
continue;
|
||||
}
|
||||
const primary = !primaryEntitiesOnly || !entity.entity_category;
|
||||
if (
|
||||
hasLabel(entity.labels) ||
|
||||
(primary &&
|
||||
((entity.device_id && deviceIds.has(entity.device_id)) ||
|
||||
(entity.area_id && areaIds.has(entity.area_id)) ||
|
||||
// An entity with its own area is not in the area of its device
|
||||
(!entity.area_id &&
|
||||
entity.device_id &&
|
||||
areaDeviceIds.has(entity.device_id))))
|
||||
) {
|
||||
entityIds.add(entity.entity_id);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
areas: [...areaIds],
|
||||
devices: [...new Set([...deviceIds, ...areaDeviceIds])],
|
||||
entities: [...entityIds],
|
||||
missing,
|
||||
};
|
||||
};
|
||||
|
||||
const matchesFilter = (
|
||||
hass: MockHomeAssistant,
|
||||
entityId: string,
|
||||
filter: EntityFilter
|
||||
) =>
|
||||
(!filter.integration ||
|
||||
hass.entities[entityId]?.platform === filter.integration) &&
|
||||
(!filter.domain ||
|
||||
ensureArray(filter.domain).includes(computeDomain(entityId))) &&
|
||||
(!filter.device_class ||
|
||||
ensureArray(filter.device_class).includes(
|
||||
hass.states[entityId]?.attributes.device_class ?? ""
|
||||
)) &&
|
||||
// Like core, the entity needs all the features of one of the feature sets
|
||||
(!filter.supported_features ||
|
||||
filter.supported_features.some(
|
||||
(features) =>
|
||||
// eslint-disable-next-line no-bitwise
|
||||
((hass.states[entityId]?.attributes.supported_features ?? 0) &
|
||||
features) ===
|
||||
features
|
||||
));
|
||||
|
||||
type TargetDescriptions = Record<
|
||||
string,
|
||||
{
|
||||
target?: {
|
||||
entity?: EntityFilter | EntityFilter[];
|
||||
primary_entities_only?: boolean;
|
||||
} | null;
|
||||
}
|
||||
>;
|
||||
|
||||
// Like core, the components with a target that apply to at least one entity
|
||||
// of the target. A target without entity filters applies to any entity.
|
||||
const componentsForTarget = (
|
||||
hass: MockHomeAssistant,
|
||||
target: HassServiceTarget,
|
||||
descriptions: TargetDescriptions
|
||||
) => {
|
||||
const entityIds = expandTarget(hass, target, false).entities;
|
||||
const targetedIds = ensureArray(target.entity_id ?? []);
|
||||
return Object.entries(descriptions)
|
||||
.filter(([, description]) => {
|
||||
if (!description.target) {
|
||||
return false;
|
||||
}
|
||||
const filters = ensureArray(description.target.entity ?? []);
|
||||
const primaryEntitiesOnly =
|
||||
description.target.primary_entities_only ?? true;
|
||||
return entityIds.some(
|
||||
(entityId) =>
|
||||
// Entities with a category only count when targeted directly,
|
||||
// unless the component allows them
|
||||
(!primaryEntitiesOnly ||
|
||||
targetedIds.includes(entityId) ||
|
||||
!hass.entities[entityId]?.entity_category) &&
|
||||
(!filters.length ||
|
||||
filters.some((filter) => matchesFilter(hass, entityId, filter)))
|
||||
);
|
||||
})
|
||||
.map(([key]) => key);
|
||||
};
|
||||
|
||||
const mockForTarget = (
|
||||
hass: MockHomeAssistant,
|
||||
type: string,
|
||||
descriptions: Descriptions
|
||||
) =>
|
||||
hass.mockWS(
|
||||
type,
|
||||
(msg: { target: HassServiceTarget }, currentHass: MockHomeAssistant) =>
|
||||
componentsForTarget(
|
||||
currentHass,
|
||||
msg.target,
|
||||
descriptions as TargetDescriptions
|
||||
)
|
||||
);
|
||||
|
||||
const mockPlatformSubscription = (
|
||||
hass: MockHomeAssistant,
|
||||
type: string,
|
||||
descriptions: Descriptions
|
||||
) =>
|
||||
hass.mockWS(
|
||||
type,
|
||||
(_msg, _hass, onChange?: (descriptions: Descriptions) => void) => {
|
||||
onChange?.(descriptions);
|
||||
return () => undefined;
|
||||
}
|
||||
);
|
||||
|
||||
export const mockAutomationPlatforms = (hass: MockHomeAssistant) => {
|
||||
mockPlatformSubscription(
|
||||
hass,
|
||||
"trigger_platforms/subscribe",
|
||||
triggerDescriptions
|
||||
);
|
||||
mockPlatformSubscription(
|
||||
hass,
|
||||
"condition_platforms/subscribe",
|
||||
conditionDescriptions
|
||||
);
|
||||
mockForTarget(hass, "get_triggers_for_target", triggerDescriptions);
|
||||
mockForTarget(hass, "get_conditions_for_target", conditionDescriptions);
|
||||
hass.mockWS(
|
||||
"extract_from_target",
|
||||
(
|
||||
msg: { target: HassServiceTarget; primary_entities_only?: boolean },
|
||||
currentHass: MockHomeAssistant
|
||||
): ExtractFromTargetResult => {
|
||||
const { areas, devices, entities, missing } = expandTarget(
|
||||
currentHass,
|
||||
msg.target,
|
||||
msg.primary_entities_only ?? true
|
||||
);
|
||||
return {
|
||||
missing_areas: missing.areas,
|
||||
missing_devices: missing.devices,
|
||||
missing_floors: missing.floors,
|
||||
missing_labels: missing.labels,
|
||||
referenced_areas: areas,
|
||||
referenced_devices: devices,
|
||||
referenced_entities: entities,
|
||||
};
|
||||
}
|
||||
);
|
||||
hass.mockWS(
|
||||
"get_services_for_target",
|
||||
(msg: { target: HassServiceTarget }, currentHass: MockHomeAssistant) =>
|
||||
componentsForTarget(
|
||||
currentHass,
|
||||
msg.target,
|
||||
// Keyed by <domain>.<service>, like the triggers and conditions
|
||||
Object.fromEntries(
|
||||
Object.entries(currentHass.services).flatMap(([domain, services]) =>
|
||||
Object.entries(services).map(([service, description]) => [
|
||||
`${domain}.${service}`,
|
||||
description,
|
||||
])
|
||||
)
|
||||
) as TargetDescriptions
|
||||
)
|
||||
);
|
||||
};
|
||||
File diff suppressed because it is too large.
Load diff
@@ -1,495 +0,0 @@
|
||||
// This file is auto-generated from Home Assistant Core. Do not edit by hand.
|
||||
// Regenerate with `script/gen_demo_core_data`.
|
||||
|
||||
export const triggerIcons = {
|
||||
air_quality: {
|
||||
co2_changed: { trigger: "mdi:molecule-co2" },
|
||||
co2_crossed_threshold: { trigger: "mdi:molecule-co2" },
|
||||
co_changed: { trigger: "mdi:molecule-co" },
|
||||
co_cleared: { trigger: "mdi:check-circle" },
|
||||
co_crossed_threshold: { trigger: "mdi:molecule-co" },
|
||||
co_detected: { trigger: "mdi:molecule-co" },
|
||||
gas_cleared: { trigger: "mdi:check-circle" },
|
||||
gas_detected: { trigger: "mdi:gas-cylinder" },
|
||||
n2o_changed: { trigger: "mdi:factory" },
|
||||
n2o_crossed_threshold: { trigger: "mdi:factory" },
|
||||
no2_changed: { trigger: "mdi:factory" },
|
||||
no2_crossed_threshold: { trigger: "mdi:factory" },
|
||||
no_changed: { trigger: "mdi:factory" },
|
||||
no_crossed_threshold: { trigger: "mdi:factory" },
|
||||
ozone_changed: { trigger: "mdi:weather-sunny-alert" },
|
||||
ozone_crossed_threshold: { trigger: "mdi:weather-sunny-alert" },
|
||||
pm10_changed: { trigger: "mdi:blur" },
|
||||
pm10_crossed_threshold: { trigger: "mdi:blur" },
|
||||
pm1_changed: { trigger: "mdi:blur" },
|
||||
pm1_crossed_threshold: { trigger: "mdi:blur" },
|
||||
pm25_changed: { trigger: "mdi:blur" },
|
||||
pm25_crossed_threshold: { trigger: "mdi:blur" },
|
||||
pm4_changed: { trigger: "mdi:blur" },
|
||||
pm4_crossed_threshold: { trigger: "mdi:blur" },
|
||||
smoke_cleared: { trigger: "mdi:check-circle" },
|
||||
smoke_detected: { trigger: "mdi:smoke-detector-variant" },
|
||||
so2_changed: { trigger: "mdi:factory" },
|
||||
so2_crossed_threshold: { trigger: "mdi:factory" },
|
||||
voc_changed: { trigger: "mdi:air-filter" },
|
||||
voc_crossed_threshold: { trigger: "mdi:air-filter" },
|
||||
voc_ratio_changed: { trigger: "mdi:air-filter" },
|
||||
voc_ratio_crossed_threshold: { trigger: "mdi:air-filter" },
|
||||
},
|
||||
alarm_control_panel: {
|
||||
armed: { trigger: "mdi:shield" },
|
||||
armed_away: { trigger: "mdi:shield-lock" },
|
||||
armed_home: { trigger: "mdi:shield-home" },
|
||||
armed_night: { trigger: "mdi:shield-moon" },
|
||||
armed_vacation: { trigger: "mdi:shield-airplane" },
|
||||
disarmed: { trigger: "mdi:shield-off" },
|
||||
triggered: { trigger: "mdi:bell-ring" },
|
||||
},
|
||||
assist_satellite: {
|
||||
idle: { trigger: "mdi:chat-sleep" },
|
||||
listening: { trigger: "mdi:chat-question" },
|
||||
processing: { trigger: "mdi:chat-processing" },
|
||||
responding: { trigger: "mdi:chat-alert" },
|
||||
},
|
||||
battery: {
|
||||
became_low: { trigger: "mdi:battery-alert" },
|
||||
level_changed: { trigger: "mdi:battery-unknown" },
|
||||
level_crossed_threshold: { trigger: "mdi:battery-alert" },
|
||||
no_longer_low: { trigger: "mdi:battery" },
|
||||
started_charging: { trigger: "mdi:battery-charging" },
|
||||
stopped_charging: { trigger: "mdi:battery" },
|
||||
},
|
||||
button: { pressed: { trigger: "mdi:gesture-tap-button" } },
|
||||
calendar: {
|
||||
event_ended: { trigger: "mdi:calendar-end" },
|
||||
event_started: { trigger: "mdi:calendar-start" },
|
||||
},
|
||||
climate: {
|
||||
hvac_mode_changed: { trigger: "mdi:thermostat" },
|
||||
started_cooling: { trigger: "mdi:snowflake" },
|
||||
started_drying: { trigger: "mdi:water-percent" },
|
||||
started_heating: { trigger: "mdi:fire" },
|
||||
target_humidity_changed: { trigger: "mdi:water-percent" },
|
||||
target_humidity_crossed_threshold: { trigger: "mdi:water-percent" },
|
||||
target_temperature_changed: { trigger: "mdi:thermometer" },
|
||||
target_temperature_crossed_threshold: { trigger: "mdi:thermometer" },
|
||||
turned_off: { trigger: "mdi:power-off" },
|
||||
turned_on: { trigger: "mdi:power-on" },
|
||||
},
|
||||
counter: {
|
||||
decremented: { trigger: "mdi:numeric-negative-1" },
|
||||
incremented: { trigger: "mdi:numeric-positive-1" },
|
||||
maximum_reached: { trigger: "mdi:sort-numeric-ascending-variant" },
|
||||
minimum_reached: { trigger: "mdi:sort-numeric-descending-variant" },
|
||||
reset: { trigger: "mdi:refresh" },
|
||||
},
|
||||
cover: {
|
||||
awning_closed: { trigger: "mdi:storefront-outline" },
|
||||
awning_opened: { trigger: "mdi:storefront-outline" },
|
||||
blind_closed: { trigger: "mdi:blinds-horizontal-closed" },
|
||||
blind_opened: { trigger: "mdi:blinds-horizontal" },
|
||||
curtain_closed: { trigger: "mdi:curtains-closed" },
|
||||
curtain_opened: { trigger: "mdi:curtains" },
|
||||
shade_closed: { trigger: "mdi:roller-shade-closed" },
|
||||
shade_opened: { trigger: "mdi:roller-shade" },
|
||||
shutter_closed: { trigger: "mdi:window-shutter" },
|
||||
shutter_opened: { trigger: "mdi:window-shutter-open" },
|
||||
},
|
||||
door: {
|
||||
closed: { trigger: "mdi:door-closed" },
|
||||
opened: { trigger: "mdi:door-open" },
|
||||
},
|
||||
doorbell: { rang: { trigger: "mdi:doorbell" } },
|
||||
event: { received: { trigger: "mdi:eye-check" } },
|
||||
fan: {
|
||||
turned_off: { trigger: "mdi:fan-off" },
|
||||
turned_on: { trigger: "mdi:fan" },
|
||||
},
|
||||
garage_door: {
|
||||
closed: { trigger: "mdi:garage" },
|
||||
opened: { trigger: "mdi:garage-open" },
|
||||
},
|
||||
gate: {
|
||||
closed: { trigger: "mdi:gate" },
|
||||
opened: { trigger: "mdi:gate-open" },
|
||||
},
|
||||
humidifier: {
|
||||
mode_changed: { trigger: "mdi:air-humidifier" },
|
||||
started_drying: { trigger: "mdi:arrow-down-bold" },
|
||||
started_humidifying: { trigger: "mdi:arrow-up-bold" },
|
||||
turned_off: { trigger: "mdi:air-humidifier-off" },
|
||||
turned_on: { trigger: "mdi:air-humidifier" },
|
||||
},
|
||||
humidity: {
|
||||
changed: { trigger: "mdi:water-percent" },
|
||||
crossed_threshold: { trigger: "mdi:water-percent" },
|
||||
},
|
||||
illuminance: {
|
||||
changed: { trigger: "mdi:brightness-6" },
|
||||
cleared: { trigger: "mdi:brightness-4" },
|
||||
crossed_threshold: { trigger: "mdi:brightness-6" },
|
||||
detected: { trigger: "mdi:brightness-7" },
|
||||
},
|
||||
lawn_mower: {
|
||||
became_idle: { trigger: "mdi:stop" },
|
||||
errored: { trigger: "mdi:alert-circle-outline" },
|
||||
paused_mowing: { trigger: "mdi:pause" },
|
||||
returned_to_dock: { trigger: "mdi:home-import-outline" },
|
||||
started_mowing: { trigger: "mdi:play" },
|
||||
started_returning: { trigger: "mdi:home-import-outline" },
|
||||
},
|
||||
light: {
|
||||
brightness_changed: { trigger: "mdi:lightbulb-on-50" },
|
||||
brightness_crossed_threshold: { trigger: "mdi:lightbulb-on-50" },
|
||||
turned_off: { trigger: "mdi:lightbulb-off" },
|
||||
turned_on: { trigger: "mdi:lightbulb-on" },
|
||||
},
|
||||
lock: {
|
||||
jammed: { trigger: "mdi:lock-alert" },
|
||||
locked: { trigger: "mdi:lock" },
|
||||
opened: { trigger: "mdi:lock-open-variant" },
|
||||
unlocked: { trigger: "mdi:lock-open-variant" },
|
||||
},
|
||||
media_player: {
|
||||
muted: { trigger: "mdi:volume-mute" },
|
||||
paused_playing: { trigger: "mdi:pause" },
|
||||
started_playing: { trigger: "mdi:play" },
|
||||
stopped_playing: { trigger: "mdi:stop" },
|
||||
turned_off: { trigger: "mdi:power" },
|
||||
turned_on: { trigger: "mdi:power" },
|
||||
unmuted: { trigger: "mdi:volume-high" },
|
||||
volume_changed: { trigger: "mdi:volume-medium" },
|
||||
volume_crossed_threshold: { trigger: "mdi:volume-medium" },
|
||||
},
|
||||
moisture: {
|
||||
changed: { trigger: "mdi:water-percent" },
|
||||
cleared: { trigger: "mdi:water-off" },
|
||||
crossed_threshold: { trigger: "mdi:water-percent" },
|
||||
detected: { trigger: "mdi:water" },
|
||||
},
|
||||
moon: { phase_changed: { trigger: "mdi:moon-waning-crescent" } },
|
||||
motion: {
|
||||
cleared: { trigger: "mdi:motion-sensor-off" },
|
||||
detected: { trigger: "mdi:motion-sensor" },
|
||||
},
|
||||
occupancy: {
|
||||
cleared: { trigger: "mdi:home-outline" },
|
||||
detected: { trigger: "mdi:home-account" },
|
||||
},
|
||||
power: {
|
||||
changed: { trigger: "mdi:flash" },
|
||||
crossed_threshold: { trigger: "mdi:flash" },
|
||||
},
|
||||
remote: {
|
||||
turned_off: { trigger: "mdi:remote-off" },
|
||||
turned_on: { trigger: "mdi:remote" },
|
||||
},
|
||||
scene: { activated: { trigger: "mdi:palette" } },
|
||||
schedule: {
|
||||
block_ended: { trigger: "mdi:calendar-blank" },
|
||||
block_started: { trigger: "mdi:calendar-clock" },
|
||||
},
|
||||
select: { selection_changed: { trigger: "mdi:format-list-bulleted" } },
|
||||
siren: {
|
||||
turned_off: { trigger: "mdi:bullhorn-outline" },
|
||||
turned_on: { trigger: "mdi:bullhorn" },
|
||||
},
|
||||
sun: {
|
||||
blue_hour_ended: { trigger: "mdi:weather-sunset" },
|
||||
blue_hour_started: { trigger: "mdi:weather-sunset" },
|
||||
dawn: { trigger: "mdi:weather-sunset-up" },
|
||||
dusk: { trigger: "mdi:weather-sunset-down" },
|
||||
elevation_changed: { trigger: "mdi:sun-angle" },
|
||||
elevation_crossed_threshold: { trigger: "mdi:sun-angle" },
|
||||
golden_hour_ended: { trigger: "mdi:weather-sunset" },
|
||||
golden_hour_started: { trigger: "mdi:weather-sunset" },
|
||||
midnight_sun_ended: { trigger: "mdi:weather-sunset" },
|
||||
midnight_sun_started: { trigger: "mdi:weather-sunny" },
|
||||
polar_night_ended: { trigger: "mdi:weather-sunset-up" },
|
||||
polar_night_started: { trigger: "mdi:weather-night" },
|
||||
solar_midnight: { trigger: "mdi:weather-night" },
|
||||
solar_noon: { trigger: "mdi:weather-sunny" },
|
||||
sunrise: { trigger: "mdi:weather-sunset-up" },
|
||||
sunset: { trigger: "mdi:weather-sunset-down" },
|
||||
},
|
||||
switch: {
|
||||
turned_off: { trigger: "mdi:toggle-switch-variant-off" },
|
||||
turned_on: { trigger: "mdi:toggle-switch-variant" },
|
||||
},
|
||||
temperature: {
|
||||
changed: { trigger: "mdi:thermometer" },
|
||||
crossed_threshold: { trigger: "mdi:thermometer" },
|
||||
},
|
||||
text: { changed: { trigger: "mdi:form-textbox" } },
|
||||
timer: {
|
||||
cancelled: { trigger: "mdi:timer-cancel" },
|
||||
finished: { trigger: "mdi:timer-check" },
|
||||
paused: { trigger: "mdi:timer-pause" },
|
||||
remaining_time_reached: { trigger: "mdi:timer-alert-outline" },
|
||||
restarted: { trigger: "mdi:timer-refresh" },
|
||||
started: { trigger: "mdi:timer-play" },
|
||||
},
|
||||
todo: {
|
||||
item_added: { trigger: "mdi:clipboard-plus" },
|
||||
item_completed: { trigger: "mdi:clipboard-check" },
|
||||
item_removed: { trigger: "mdi:clipboard-minus" },
|
||||
},
|
||||
update: { became_available: { trigger: "mdi:package-up" } },
|
||||
vacuum: {
|
||||
errored: { trigger: "mdi:alert-circle-outline" },
|
||||
paused_cleaning: { trigger: "mdi:pause" },
|
||||
returned_to_dock: { trigger: "mdi:home-outline" },
|
||||
started_cleaning: { trigger: "mdi:play" },
|
||||
started_returning: { trigger: "mdi:home-import-outline" },
|
||||
},
|
||||
valve: {
|
||||
closed: { trigger: "mdi:valve-closed" },
|
||||
opened: { trigger: "mdi:valve-open" },
|
||||
},
|
||||
vibration: {
|
||||
cleared: { trigger: "mdi:vibrate-off" },
|
||||
detected: { trigger: "mdi:vibrate" },
|
||||
},
|
||||
water_heater: {
|
||||
operation_mode_changed: { trigger: "mdi:water-boiler" },
|
||||
target_temperature_changed: { trigger: "mdi:thermometer" },
|
||||
target_temperature_crossed_threshold: { trigger: "mdi:thermometer" },
|
||||
turned_off: { trigger: "mdi:water-boiler-off" },
|
||||
turned_on: { trigger: "mdi:water-boiler" },
|
||||
},
|
||||
window: {
|
||||
closed: { trigger: "mdi:window-closed" },
|
||||
opened: { trigger: "mdi:window-open" },
|
||||
},
|
||||
zone: {
|
||||
entered: { trigger: "mdi:map-marker-plus" },
|
||||
left: { trigger: "mdi:map-marker-minus" },
|
||||
occupancy_cleared: { trigger: "mdi:account-off" },
|
||||
occupancy_detected: { trigger: "mdi:account-group" },
|
||||
},
|
||||
};
|
||||
|
||||
export const conditionIcons = {
|
||||
air_quality: {
|
||||
is_co2_value: { condition: "mdi:molecule-co2" },
|
||||
is_co_cleared: { condition: "mdi:check-circle" },
|
||||
is_co_detected: { condition: "mdi:molecule-co" },
|
||||
is_co_value: { condition: "mdi:molecule-co" },
|
||||
is_gas_cleared: { condition: "mdi:check-circle" },
|
||||
is_gas_detected: { condition: "mdi:gas-cylinder" },
|
||||
is_n2o_value: { condition: "mdi:factory" },
|
||||
is_no2_value: { condition: "mdi:factory" },
|
||||
is_no_value: { condition: "mdi:factory" },
|
||||
is_ozone_value: { condition: "mdi:weather-sunny-alert" },
|
||||
is_pm10_value: { condition: "mdi:blur" },
|
||||
is_pm1_value: { condition: "mdi:blur" },
|
||||
is_pm25_value: { condition: "mdi:blur" },
|
||||
is_pm4_value: { condition: "mdi:blur" },
|
||||
is_smoke_cleared: { condition: "mdi:check-circle" },
|
||||
is_smoke_detected: { condition: "mdi:smoke-detector-variant" },
|
||||
is_so2_value: { condition: "mdi:factory" },
|
||||
is_voc_ratio_value: { condition: "mdi:air-filter" },
|
||||
is_voc_value: { condition: "mdi:air-filter" },
|
||||
},
|
||||
alarm_control_panel: {
|
||||
is_armed: { condition: "mdi:shield" },
|
||||
is_armed_away: { condition: "mdi:shield-lock" },
|
||||
is_armed_home: { condition: "mdi:shield-home" },
|
||||
is_armed_night: { condition: "mdi:shield-moon" },
|
||||
is_armed_vacation: { condition: "mdi:shield-airplane" },
|
||||
is_disarmed: { condition: "mdi:shield-off" },
|
||||
is_triggered: { condition: "mdi:bell-ring" },
|
||||
},
|
||||
assist_satellite: {
|
||||
is_idle: { condition: "mdi:chat-sleep" },
|
||||
is_listening: { condition: "mdi:chat-question" },
|
||||
is_processing: { condition: "mdi:chat-processing" },
|
||||
is_responding: { condition: "mdi:chat-alert" },
|
||||
},
|
||||
battery: {
|
||||
is_charging: { condition: "mdi:battery-charging" },
|
||||
is_level: { condition: "mdi:battery-unknown" },
|
||||
is_low: { condition: "mdi:battery-alert" },
|
||||
is_not_charging: { condition: "mdi:battery" },
|
||||
is_not_low: { condition: "mdi:battery" },
|
||||
},
|
||||
calendar: { is_event_active: { condition: "mdi:calendar-check" } },
|
||||
climate: {
|
||||
is_cooling: { condition: "mdi:snowflake" },
|
||||
is_drying: { condition: "mdi:water-percent" },
|
||||
is_heating: { condition: "mdi:fire" },
|
||||
is_hvac_mode: { condition: "mdi:thermostat" },
|
||||
is_off: { condition: "mdi:power-off" },
|
||||
is_on: { condition: "mdi:power-on" },
|
||||
is_target_humidity: { condition: "mdi:water-percent" },
|
||||
is_target_temperature: { condition: "mdi:thermometer" },
|
||||
},
|
||||
counter: { is_value: { condition: "mdi:counter" } },
|
||||
cover: {
|
||||
awning_is_closed: { condition: "mdi:storefront-outline" },
|
||||
awning_is_open: { condition: "mdi:storefront-outline" },
|
||||
blind_is_closed: { condition: "mdi:blinds-horizontal-closed" },
|
||||
blind_is_open: { condition: "mdi:blinds-horizontal" },
|
||||
curtain_is_closed: { condition: "mdi:curtains-closed" },
|
||||
curtain_is_open: { condition: "mdi:curtains" },
|
||||
shade_is_closed: { condition: "mdi:roller-shade-closed" },
|
||||
shade_is_open: { condition: "mdi:roller-shade" },
|
||||
shutter_is_closed: { condition: "mdi:window-shutter" },
|
||||
shutter_is_open: { condition: "mdi:window-shutter-open" },
|
||||
},
|
||||
door: {
|
||||
is_closed: { condition: "mdi:door-closed" },
|
||||
is_open: { condition: "mdi:door-open" },
|
||||
},
|
||||
fan: {
|
||||
is_off: { condition: "mdi:fan-off" },
|
||||
is_on: { condition: "mdi:fan" },
|
||||
},
|
||||
garage_door: {
|
||||
is_closed: { condition: "mdi:garage" },
|
||||
is_open: { condition: "mdi:garage-open" },
|
||||
},
|
||||
gate: {
|
||||
is_closed: { condition: "mdi:gate" },
|
||||
is_open: { condition: "mdi:gate-open" },
|
||||
},
|
||||
humidifier: {
|
||||
is_drying: { condition: "mdi:arrow-down-bold" },
|
||||
is_humidifying: { condition: "mdi:arrow-up-bold" },
|
||||
is_mode: { condition: "mdi:air-humidifier" },
|
||||
is_off: { condition: "mdi:air-humidifier-off" },
|
||||
is_on: { condition: "mdi:air-humidifier" },
|
||||
is_target_humidity: { condition: "mdi:water-percent" },
|
||||
},
|
||||
humidity: { is_value: { condition: "mdi:water-percent" } },
|
||||
illuminance: {
|
||||
is_detected: { condition: "mdi:brightness-7" },
|
||||
is_not_detected: { condition: "mdi:brightness-4" },
|
||||
is_value: { condition: "mdi:brightness-percent" },
|
||||
},
|
||||
lawn_mower: {
|
||||
is_docked: { condition: "mdi:home-import-outline" },
|
||||
is_encountering_an_error: { condition: "mdi:alert-circle-outline" },
|
||||
is_idle: { condition: "mdi:stop" },
|
||||
is_mowing: { condition: "mdi:play" },
|
||||
is_paused: { condition: "mdi:pause" },
|
||||
is_returning: { condition: "mdi:home-import-outline" },
|
||||
},
|
||||
light: {
|
||||
is_brightness: { condition: "mdi:lightbulb-on-50" },
|
||||
is_off: { condition: "mdi:lightbulb-off" },
|
||||
is_on: { condition: "mdi:lightbulb-on" },
|
||||
},
|
||||
lock: {
|
||||
is_jammed: { condition: "mdi:lock-alert" },
|
||||
is_locked: { condition: "mdi:lock" },
|
||||
is_open: { condition: "mdi:lock-open-variant" },
|
||||
is_unlocked: { condition: "mdi:lock-open-variant" },
|
||||
},
|
||||
media_player: {
|
||||
is_muted: { condition: "mdi:volume-mute" },
|
||||
is_not_playing: { condition: "mdi:stop" },
|
||||
is_off: { condition: "mdi:cast-off" },
|
||||
is_on: { condition: "mdi:cast" },
|
||||
is_paused: { condition: "mdi:pause" },
|
||||
is_playing: { condition: "mdi:play" },
|
||||
is_unmuted: { condition: "mdi:volume-high" },
|
||||
is_volume: { condition: "mdi:volume-medium" },
|
||||
},
|
||||
moisture: {
|
||||
is_detected: { condition: "mdi:water" },
|
||||
is_not_detected: { condition: "mdi:water-off" },
|
||||
is_value: { condition: "mdi:water-percent" },
|
||||
},
|
||||
moon: {
|
||||
is_phase: { condition: "mdi:moon-full" },
|
||||
is_waning: { condition: "mdi:moon-waning-crescent" },
|
||||
is_waxing: { condition: "mdi:moon-waxing-crescent" },
|
||||
},
|
||||
motion: {
|
||||
is_detected: { condition: "mdi:motion-sensor" },
|
||||
is_not_detected: { condition: "mdi:motion-sensor-off" },
|
||||
},
|
||||
occupancy: {
|
||||
is_detected: { condition: "mdi:home-account" },
|
||||
is_not_detected: { condition: "mdi:home-outline" },
|
||||
},
|
||||
power: { is_value: { condition: "mdi:flash" } },
|
||||
remote: {
|
||||
is_off: { condition: "mdi:remote-off" },
|
||||
is_on: { condition: "mdi:remote" },
|
||||
},
|
||||
schedule: {
|
||||
is_off: { condition: "mdi:calendar-blank" },
|
||||
is_on: { condition: "mdi:calendar-clock" },
|
||||
},
|
||||
select: { is_option_selected: { condition: "mdi:format-list-bulleted" } },
|
||||
siren: {
|
||||
is_off: { condition: "mdi:bullhorn-outline" },
|
||||
is_on: { condition: "mdi:bullhorn" },
|
||||
},
|
||||
sun: {
|
||||
elevation: { condition: "mdi:sun-angle" },
|
||||
is_ascending: { condition: "mdi:weather-sunset-up" },
|
||||
is_blue_hour: { condition: "mdi:weather-sunset" },
|
||||
is_descending: { condition: "mdi:weather-sunset-down" },
|
||||
is_evening_twilight: { condition: "mdi:weather-sunset-down" },
|
||||
is_golden_hour: { condition: "mdi:weather-sunset" },
|
||||
is_midnight_sun: { condition: "mdi:weather-sunny" },
|
||||
is_morning_twilight: { condition: "mdi:weather-sunset-up" },
|
||||
is_night: { condition: "mdi:weather-night" },
|
||||
is_polar_night: { condition: "mdi:weather-night" },
|
||||
is_set: { condition: "mdi:weather-sunny-off" },
|
||||
is_up: { condition: "mdi:weather-sunny" },
|
||||
},
|
||||
switch: {
|
||||
is_off: { condition: "mdi:toggle-switch-variant-off" },
|
||||
is_on: { condition: "mdi:toggle-switch-variant" },
|
||||
},
|
||||
temperature: { is_value: { condition: "mdi:thermometer" } },
|
||||
text: { is_equal_to: { condition: "mdi:form-textbox" } },
|
||||
timer: {
|
||||
is_active: { condition: "mdi:timer" },
|
||||
is_idle: { condition: "mdi:timer-off" },
|
||||
is_paused: { condition: "mdi:timer-pause" },
|
||||
},
|
||||
todo: {
|
||||
all_completed: { condition: "mdi:clipboard-check" },
|
||||
incomplete: { condition: "mdi:clipboard-alert" },
|
||||
},
|
||||
update: {
|
||||
is_available: { condition: "mdi:package-up" },
|
||||
is_not_available: { condition: "mdi:package" },
|
||||
},
|
||||
vacuum: {
|
||||
is_cleaning: { condition: "mdi:play" },
|
||||
is_docked: { condition: "mdi:home-import-outline" },
|
||||
is_encountering_an_error: { condition: "mdi:alert-circle-outline" },
|
||||
is_paused: { condition: "mdi:pause" },
|
||||
is_returning: { condition: "mdi:home-import-outline" },
|
||||
},
|
||||
valve: {
|
||||
is_closed: { condition: "mdi:valve-closed" },
|
||||
is_open: { condition: "mdi:valve-open" },
|
||||
},
|
||||
vibration: {
|
||||
is_detected: { condition: "mdi:vibrate" },
|
||||
is_not_detected: { condition: "mdi:vibrate-off" },
|
||||
},
|
||||
water_heater: {
|
||||
is_off: { condition: "mdi:water-boiler-off" },
|
||||
is_on: { condition: "mdi:water-boiler" },
|
||||
is_operation_mode: { condition: "mdi:water-boiler" },
|
||||
is_target_temperature: { condition: "mdi:thermometer" },
|
||||
},
|
||||
window: {
|
||||
is_closed: { condition: "mdi:window-closed" },
|
||||
is_open: { condition: "mdi:window-open" },
|
||||
},
|
||||
zone: {
|
||||
in_zone: { condition: "mdi:map-marker-check" },
|
||||
not_in_zone: { condition: "mdi:map-marker-remove" },
|
||||
occupancy_is_detected: { condition: "mdi:account-group" },
|
||||
occupancy_is_not_detected: { condition: "mdi:account-off" },
|
||||
},
|
||||
};
|
||||
@@ -39,7 +39,6 @@ const backupInfo: BackupInfo = {
|
||||
},
|
||||
],
|
||||
agent_errors: {},
|
||||
agent_error_translations: {},
|
||||
last_attempted_automatic_backup: recent,
|
||||
last_completed_automatic_backup: recent,
|
||||
last_action_event: { manager_state: "idle" },
|
||||
|
||||
@@ -42,12 +42,4 @@ export const mockBlueprint = (hass: MockHomeAssistant) => {
|
||||
hass.mockWS("blueprint/list", (msg: { domain: BlueprintDomain }) =>
|
||||
msg.domain === "script" ? scriptBlueprints : automationBlueprints
|
||||
);
|
||||
hass.mockWS(
|
||||
"blueprint/delete",
|
||||
(msg: { domain: BlueprintDomain; path: string }) => {
|
||||
const blueprints =
|
||||
msg.domain === "script" ? scriptBlueprints : automationBlueprints;
|
||||
delete blueprints[msg.path];
|
||||
}
|
||||
);
|
||||
};
|
||||
@@ -12,7 +12,6 @@ import { mockDeviceAutomation } from "./device_automation";
|
||||
import { mockEntityRegistrySettings } from "./entity_registry_settings";
|
||||
import { mockEntitySources } from "./entity_sources";
|
||||
import { mockExpose } from "./expose";
|
||||
import { mockMarketplace } from "./marketplace";
|
||||
import { mockNetwork } from "./network";
|
||||
import { mockPerson } from "./person";
|
||||
import { mockScene } from "./scene";
|
||||
@@ -46,7 +45,6 @@ export const mockConfigPanel = (hass: MockHomeAssistant) => {
|
||||
mockAssist(hass);
|
||||
mockEntityRegistrySettings(hass);
|
||||
mockSlugify(hass);
|
||||
mockMarketplace(hass);
|
||||
hass.mockWS("llm/api/list", () => ({
|
||||
apis: [
|
||||
{ id: "assist", name: "Assist" },
|
||||
|
||||
@@ -91,15 +91,6 @@ export const demoConfigEntries: {
|
||||
supports_options: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "system",
|
||||
entry: {
|
||||
...baseEntry,
|
||||
entry_id: "mock-marketplace",
|
||||
domain: "marketplace",
|
||||
title: "Marketplace",
|
||||
},
|
||||
},
|
||||
...connectivityConfigEntries,
|
||||
];
|
||||
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { lorawanFixtures } from "./lorawan/fixtures";
|
||||
import { bluetoothFixtures } from "./bluetooth/fixtures";
|
||||
import { infraredFixtures } from "./infrared/fixtures";
|
||||
import { matterFixtures } from "./matter/fixtures";
|
||||
@@ -16,7 +15,6 @@ import { zwaveJsFixtures } from "./zwave_js/fixtures";
|
||||
// data to mock. Each owns its own fixtures, so they can be added and removed
|
||||
// one at a time.
|
||||
const INTEGRATIONS: ConnectivityFixtures[] = [
|
||||
lorawanFixtures,
|
||||
bluetoothFixtures,
|
||||
serialFixtures,
|
||||
modbusFixtures,
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { mockLoRaWAN } from "./lorawan/mock";
|
||||
import type { MockHomeAssistant } from "../../../../src/fake_data/provide_hass";
|
||||
import { mockBluetooth } from "./bluetooth/mock";
|
||||
import { mockMatter } from "./matter/mock";
|
||||
@@ -12,7 +11,6 @@ import { mockZwaveJs } from "./zwave_js/mock";
|
||||
|
||||
// The WebSocket mocks, code-split into the config panel chunk.
|
||||
const MOCKS = [
|
||||
mockLoRaWAN,
|
||||
mockBluetooth,
|
||||
mockSerial,
|
||||
mockModbus,
|
||||
|
||||
@@ -1,135 +0,0 @@
|
||||
import { manifest } from "../../manifest";
|
||||
import {
|
||||
configEntry,
|
||||
device,
|
||||
registryEntry,
|
||||
withRegistryLinks,
|
||||
} from "../helpers";
|
||||
import type { ConnectivityFixtures } from "../types";
|
||||
|
||||
export const CHIRPSTACK_ENTRY = "mock-lorawan-chirpstack";
|
||||
export const TTN_ENTRY = "mock-lorawan-ttn";
|
||||
export const SENSECAP_ENTRY = "mock-lorawan-sensecap";
|
||||
|
||||
const REGISTRY_ENTRIES = [
|
||||
registryEntry(
|
||||
"sensor.lorawan_greenhouse_temperature",
|
||||
"lorawan-greenhouse",
|
||||
SENSECAP_ENTRY,
|
||||
"sensecap_lorawan",
|
||||
"Temperature"
|
||||
),
|
||||
registryEntry(
|
||||
"sensor.lorawan_greenhouse_humidity",
|
||||
"lorawan-greenhouse",
|
||||
SENSECAP_ENTRY,
|
||||
"sensecap_lorawan",
|
||||
"Humidity"
|
||||
),
|
||||
registryEntry(
|
||||
"sensor.lorawan_study_carbon_dioxide",
|
||||
"lorawan-study",
|
||||
SENSECAP_ENTRY,
|
||||
"sensecap_lorawan",
|
||||
"Carbon dioxide"
|
||||
),
|
||||
];
|
||||
|
||||
export const lorawanFixtures: ConnectivityFixtures = {
|
||||
components: [
|
||||
"lorawan",
|
||||
"sensecap_lorawan",
|
||||
"chirpstack",
|
||||
"thethingsnetwork_lorawan",
|
||||
],
|
||||
commands: ["lorawan/"],
|
||||
manifests: [
|
||||
manifest("lorawan", "LoRaWAN", { integration_type: "helper" }),
|
||||
manifest("sensecap_lorawan", "SenseCAP", {
|
||||
integration_type: "device",
|
||||
dependencies: ["lorawan"],
|
||||
}),
|
||||
manifest("chirpstack", "ChirpStack", { integration_type: "hub" }),
|
||||
manifest("thethingsnetwork_lorawan", "The Things Network", {
|
||||
integration_type: "hub",
|
||||
}),
|
||||
],
|
||||
configEntries: [
|
||||
{
|
||||
type: "hub",
|
||||
entry: configEntry(CHIRPSTACK_ENTRY, "chirpstack", "Home ChirpStack"),
|
||||
},
|
||||
{
|
||||
type: "hub",
|
||||
entry: configEntry(
|
||||
TTN_ENTRY,
|
||||
"thethingsnetwork_lorawan",
|
||||
"The Things Network"
|
||||
),
|
||||
},
|
||||
{
|
||||
type: "device",
|
||||
entry: configEntry(SENSECAP_ENTRY, "sensecap_lorawan", "SenseCAP"),
|
||||
},
|
||||
],
|
||||
devices: [
|
||||
device(
|
||||
"lorawan-greenhouse",
|
||||
"Greenhouse",
|
||||
"Seeed Studio",
|
||||
"S2101",
|
||||
SENSECAP_ENTRY,
|
||||
{
|
||||
config_entries: [SENSECAP_ENTRY, CHIRPSTACK_ENTRY],
|
||||
identifiers: [
|
||||
["sensecap_lorawan", `${CHIRPSTACK_ENTRY}:0000000000000001`],
|
||||
],
|
||||
}
|
||||
),
|
||||
device(
|
||||
"lorawan-study",
|
||||
"Study air quality",
|
||||
"Seeed Studio",
|
||||
"S2103",
|
||||
SENSECAP_ENTRY,
|
||||
{
|
||||
config_entries: [SENSECAP_ENTRY, TTN_ENTRY],
|
||||
identifiers: [["sensecap_lorawan", `${TTN_ENTRY}:0000000000000002`]],
|
||||
}
|
||||
),
|
||||
],
|
||||
entityRegistryEntries: REGISTRY_ENTRIES,
|
||||
entities: () =>
|
||||
withRegistryLinks(REGISTRY_ENTRIES, {
|
||||
"sensor.lorawan_greenhouse_temperature": {
|
||||
entity_id: "sensor.lorawan_greenhouse_temperature",
|
||||
state: "24.3",
|
||||
attributes: {
|
||||
friendly_name: "Greenhouse temperature",
|
||||
device_class: "temperature",
|
||||
state_class: "measurement",
|
||||
unit_of_measurement: "°C",
|
||||
},
|
||||
},
|
||||
"sensor.lorawan_greenhouse_humidity": {
|
||||
entity_id: "sensor.lorawan_greenhouse_humidity",
|
||||
state: "58",
|
||||
attributes: {
|
||||
friendly_name: "Greenhouse humidity",
|
||||
device_class: "humidity",
|
||||
state_class: "measurement",
|
||||
unit_of_measurement: "%",
|
||||
},
|
||||
},
|
||||
"sensor.lorawan_study_carbon_dioxide": {
|
||||
entity_id: "sensor.lorawan_study_carbon_dioxide",
|
||||
state: "652",
|
||||
attributes: {
|
||||
friendly_name: "Study carbon dioxide",
|
||||
device_class: "carbon_dioxide",
|
||||
state_class: "measurement",
|
||||
unit_of_measurement: "ppm",
|
||||
},
|
||||
},
|
||||
}),
|
||||
};
|
||||
@@ -1,124 +0,0 @@
|
||||
import type { LoRaWANDevice } from "../../../../../src/data/lorawan";
|
||||
import type { MockHomeAssistant } from "../../../../../src/fake_data/provide_hass";
|
||||
import { minutesAgo } from "../helpers";
|
||||
import { emitInitial } from "../subscription";
|
||||
import { CHIRPSTACK_ENTRY, TTN_ENTRY } from "./fixtures";
|
||||
|
||||
const devices = (): LoRaWANDevice[] => {
|
||||
const base: LoRaWANDevice = {
|
||||
dev_eui: "0000000000000001",
|
||||
name: "Greenhouse",
|
||||
manufacturer: "Seeed Studio",
|
||||
model: "S2101",
|
||||
application_id: "home-sensors",
|
||||
device_id: "lorawan-greenhouse",
|
||||
connection: {
|
||||
entry_id: CHIRPSTACK_ENTRY,
|
||||
domain: "chirpstack",
|
||||
title: "Home ChirpStack",
|
||||
configuration_url: "https://www.chirpstack.io/",
|
||||
},
|
||||
support: "supported",
|
||||
last_uplink: minutesAgo(2),
|
||||
last_join: minutesAgo(2400),
|
||||
last_status: minutesAgo(64),
|
||||
receptions: [{ gateway_id: "home-indoor", rssi: -67, snr: 9.5 }],
|
||||
battery_level: 87,
|
||||
external_power_source: false,
|
||||
downlink_margin: 12,
|
||||
};
|
||||
return [
|
||||
base,
|
||||
{
|
||||
...base,
|
||||
dev_eui: "0000000000000002",
|
||||
name: "Study air quality",
|
||||
model: "S2103",
|
||||
device_id: "lorawan-study",
|
||||
application_id: "demo-home",
|
||||
connection: {
|
||||
entry_id: TTN_ENTRY,
|
||||
domain: "thethingsnetwork_lorawan",
|
||||
title: "The Things Network",
|
||||
configuration_url: "https://eu1.cloud.thethings.network/console",
|
||||
},
|
||||
receptions: [
|
||||
{ gateway_id: "brooklyn-rooftop", rssi: -103, snr: -2.5 },
|
||||
{ gateway_id: "brooklyn-community", rssi: -110, snr: -5.2 },
|
||||
],
|
||||
battery_level: 64,
|
||||
last_uplink: minutesAgo(4),
|
||||
last_status: minutesAgo(140),
|
||||
},
|
||||
{
|
||||
...base,
|
||||
dev_eui: "24E124128E123456",
|
||||
name: "Irrigation controller",
|
||||
manufacturer: "Milesight",
|
||||
model: "UC511",
|
||||
device_id: null,
|
||||
support: "integration_not_configured",
|
||||
battery_level: 93,
|
||||
last_uplink: minutesAgo(7),
|
||||
receptions: [{ gateway_id: "home-indoor", rssi: -83, snr: 6.8 }],
|
||||
},
|
||||
{
|
||||
...base,
|
||||
dev_eui: "A84041FFFF123456",
|
||||
name: "Garage power meter",
|
||||
manufacturer: "",
|
||||
model: "",
|
||||
device_id: null,
|
||||
support: "missing_identity",
|
||||
battery_level: null,
|
||||
external_power_source: true,
|
||||
last_uplink: minutesAgo(1),
|
||||
receptions: [{ gateway_id: "home-indoor", rssi: -75, snr: 8.1 }],
|
||||
},
|
||||
{
|
||||
...base,
|
||||
dev_eui: "2CF7F1C071500199",
|
||||
name: "New soil sensor",
|
||||
model: "S2105",
|
||||
device_id: null,
|
||||
support: "unsupported_model",
|
||||
last_uplink: null,
|
||||
last_join: null,
|
||||
last_status: null,
|
||||
receptions: [],
|
||||
battery_level: null,
|
||||
external_power_source: null,
|
||||
downlink_margin: null,
|
||||
},
|
||||
{
|
||||
...base,
|
||||
dev_eui: "2CF7F1C071500166",
|
||||
name: "Garden shed",
|
||||
device_id: null,
|
||||
support: "integration_not_configured",
|
||||
battery_level: 18,
|
||||
last_uplink: minutesAgo(180),
|
||||
last_status: minutesAgo(600),
|
||||
receptions: [{ gateway_id: "home-indoor", rssi: -112, snr: -7.5 }],
|
||||
},
|
||||
];
|
||||
};
|
||||
|
||||
export const mockLoRaWAN = (hass: MockHomeAssistant) => {
|
||||
const deviceList = devices();
|
||||
const snapshot = {
|
||||
devices: deviceList,
|
||||
connections: [
|
||||
...new Map(
|
||||
deviceList.map((device) => [
|
||||
device.connection.entry_id,
|
||||
device.connection,
|
||||
])
|
||||
).values(),
|
||||
],
|
||||
};
|
||||
hass.mockWS("lorawan/devices/list", () => snapshot);
|
||||
hass.mockWS("lorawan/devices/subscribe", (_msg, _hass, onChange) =>
|
||||
emitInitial(() => onChange?.(snapshot))
|
||||
);
|
||||
};
|
||||
@@ -1,3 +1,53 @@
|
||||
export const mapEntities = () =>
|
||||
Object.values({
|
||||
"zone.home": {
|
||||
entity_id: "zone.home",
|
||||
state: "zoning",
|
||||
attributes: {
|
||||
hidden: true,
|
||||
latitude: 52.3631339,
|
||||
longitude: 4.8903147,
|
||||
radius: 200,
|
||||
friendly_name: "Home",
|
||||
icon: "hademo:home",
|
||||
},
|
||||
},
|
||||
"zone.uva": {
|
||||
entity_id: "zone.buckhead",
|
||||
state: "zoning",
|
||||
attributes: {
|
||||
hidden: true,
|
||||
radius: 400,
|
||||
friendly_name: "UvA",
|
||||
icon: "hademo:school",
|
||||
latitude: 52.3558182,
|
||||
longitude: 4.9535376,
|
||||
},
|
||||
},
|
||||
"person.arsaboo": {
|
||||
entity_id: "person.arsaboo",
|
||||
state: "not_home",
|
||||
attributes: {
|
||||
radius: 50,
|
||||
friendly_name: "Arsaboo",
|
||||
latitude: 52.3579946,
|
||||
longitude: 4.8664597,
|
||||
entity_picture: "/assets/arsaboo/images/arsaboo.jpg",
|
||||
},
|
||||
},
|
||||
"person.melody": {
|
||||
entity_id: "person.melody",
|
||||
state: "not_home",
|
||||
attributes: {
|
||||
radius: 50,
|
||||
friendly_name: "Melody",
|
||||
latitude: 52.3408927,
|
||||
longitude: 4.8711073,
|
||||
entity_picture: "/assets/arsaboo/images/melody.jpg",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const energyEntities = () =>
|
||||
Object.values({
|
||||
"sensor.grid_fossil_fuel_percentage": {
|
||||
|
||||
@@ -1,277 +1,25 @@
|
||||
import type {
|
||||
EntityRegistryDisplayEntryResponse,
|
||||
EntityRegistryEntry,
|
||||
ExtEntityRegistryEntry,
|
||||
UpdateEntityRegistryEntryResult,
|
||||
} from "../../../src/data/entity/entity_registry";
|
||||
import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
|
||||
|
||||
let entries: EntityRegistryEntry[] = [];
|
||||
|
||||
const newEntry = (
|
||||
entry: Pick<EntityRegistryEntry, "entity_id" | "platform" | "unique_id">
|
||||
): EntityRegistryEntry => {
|
||||
const now = Date.now() / 1000;
|
||||
return {
|
||||
config_entry_id: null,
|
||||
config_subentry_id: null,
|
||||
device_id: null,
|
||||
area_id: null,
|
||||
disabled_by: null,
|
||||
id: entry.entity_id,
|
||||
name: null,
|
||||
icon: null,
|
||||
labels: [],
|
||||
categories: {},
|
||||
hidden_by: null,
|
||||
entity_category: null,
|
||||
has_entity_name: false,
|
||||
options: null,
|
||||
created_at: now,
|
||||
modified_at: now,
|
||||
...entry,
|
||||
};
|
||||
};
|
||||
|
||||
// The registry settings that the display registry holds. A display entry
|
||||
// without a registry name shows the name of its state.
|
||||
const displayFields = (entry: EntityRegistryEntry) => ({
|
||||
...(entry.name && { name: entry.name }),
|
||||
icon: entry.icon ?? undefined,
|
||||
area_id: entry.area_id ?? undefined,
|
||||
labels: entry.labels,
|
||||
hidden: entry.hidden_by !== null,
|
||||
});
|
||||
|
||||
const extEntry = (entry: EntityRegistryEntry): ExtEntityRegistryEntry => ({
|
||||
...entry,
|
||||
capabilities: {},
|
||||
aliases: [],
|
||||
});
|
||||
|
||||
export const mockEntityRegistry = (
|
||||
hass: MockHomeAssistant,
|
||||
data: EntityRegistryEntry[] = []
|
||||
) => {
|
||||
entries = [...data];
|
||||
// A new array, so subscribers see the registry changed after an update
|
||||
hass.mockWS("config/entity_registry/list", () => [...entries]);
|
||||
hass.mockWS("config/entity_registry/list", () => data);
|
||||
hass.mockWS(
|
||||
"config/entity_registry/get_entries",
|
||||
(msg: { entity_ids: string[] }) => {
|
||||
const result: Record<string, ExtEntityRegistryEntry> = {};
|
||||
for (const entityId of msg.entity_ids) {
|
||||
const entry = entries.find((e) => e.entity_id === entityId);
|
||||
const entry = data.find((e) => e.entity_id === entityId);
|
||||
if (entry) {
|
||||
result[entityId] = extEntry(entry);
|
||||
result[entityId] = { ...entry, capabilities: {}, aliases: [] };
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
);
|
||||
hass.mockWS(
|
||||
"config/entity_registry/update",
|
||||
(
|
||||
msg: Pick<
|
||||
EntityRegistryEntry,
|
||||
| "entity_id"
|
||||
| "name"
|
||||
| "icon"
|
||||
| "area_id"
|
||||
| "labels"
|
||||
| "categories"
|
||||
| "hidden_by"
|
||||
| "disabled_by"
|
||||
> & { new_entity_id?: string },
|
||||
currentHass: MockHomeAssistant
|
||||
): UpdateEntityRegistryEntryResult => {
|
||||
const {
|
||||
entity_id: entityId,
|
||||
new_entity_id: newEntityId,
|
||||
...updates
|
||||
} = msg;
|
||||
// Renaming would have to move the entity in every mock, so refuse it
|
||||
// instead of reporting a rename that did not happen
|
||||
if (newEntityId && newEntityId !== entityId) {
|
||||
throw new Error("Changing the entity ID is not supported in the demo");
|
||||
}
|
||||
// The demo entities without an entry get one, like any core entity has
|
||||
const entry = {
|
||||
...(entries.find((e) => e.entity_id === entityId) ??
|
||||
newEntry({
|
||||
entity_id: entityId,
|
||||
platform: currentHass.entities[entityId]?.platform ?? "demo",
|
||||
unique_id: entityId,
|
||||
})),
|
||||
...Object.fromEntries(
|
||||
Object.entries(updates).filter(([key]) => key !== "type")
|
||||
),
|
||||
modified_at: Date.now() / 1000,
|
||||
};
|
||||
entries = [...entries.filter((e) => e.entity_id !== entityId), entry];
|
||||
// Core updates the display registry too, only change what was updated
|
||||
const display = currentHass.entities[entityId];
|
||||
if (display) {
|
||||
const fields = displayFields(entry);
|
||||
currentHass.updateHass({
|
||||
entities: {
|
||||
...currentHass.entities,
|
||||
[entityId]: {
|
||||
...display,
|
||||
...("name" in updates && { name: fields.name }),
|
||||
...("icon" in updates && { icon: fields.icon }),
|
||||
...("area_id" in updates && { area_id: fields.area_id }),
|
||||
...("labels" in updates && { labels: fields.labels }),
|
||||
...("hidden_by" in updates && { hidden: fields.hidden }),
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
currentHass.mockEvent("entity_registry_updated");
|
||||
return { entity_entry: extEntry(entry) };
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
// Entries of a platform that were replaced, so their settings are restored
|
||||
// when the entity comes back after switching demos
|
||||
const retiredEntries = new Map<string, EntityRegistryEntry>();
|
||||
|
||||
// Replaces the entries of a platform, like core does for the entities of a
|
||||
// demo config
|
||||
export const setPlatformEntityRegistryEntries = (
|
||||
hass: MockHomeAssistant,
|
||||
platform: string,
|
||||
platformEntries: Pick<EntityRegistryEntry, "entity_id" | "unique_id">[]
|
||||
) => {
|
||||
for (const entry of entries) {
|
||||
if (entry.platform === platform) {
|
||||
retiredEntries.set(`${platform}.${entry.unique_id}`, entry);
|
||||
}
|
||||
}
|
||||
const restored: EntityRegistryEntry[] = [];
|
||||
const platformRegistryEntries = platformEntries.map((entry) => {
|
||||
const retired = retiredEntries.get(`${platform}.${entry.unique_id}`);
|
||||
if (!retired) {
|
||||
return newEntry({ ...entry, platform });
|
||||
}
|
||||
const restoredEntry = { ...retired, entity_id: entry.entity_id };
|
||||
restored.push(restoredEntry);
|
||||
return restoredEntry;
|
||||
});
|
||||
entries = [
|
||||
...entries.filter((e) => e.platform !== platform),
|
||||
...platformRegistryEntries,
|
||||
];
|
||||
// The display registry was rebuilt with the entities, restore its settings
|
||||
if (restored.length) {
|
||||
const entities = { ...hass.entities };
|
||||
for (const entry of restored) {
|
||||
if (entities[entry.entity_id]) {
|
||||
entities[entry.entity_id] = {
|
||||
...entities[entry.entity_id],
|
||||
...displayFields(entry),
|
||||
};
|
||||
}
|
||||
}
|
||||
hass.updateHass({ entities });
|
||||
}
|
||||
hass.mockEvent("entity_registry_updated");
|
||||
};
|
||||
|
||||
export const getEntityRegistryEntry = (entityId: string) =>
|
||||
entries.find((e) => e.entity_id === entityId);
|
||||
|
||||
export const removeEntityRegistryEntry = (
|
||||
hass: MockHomeAssistant,
|
||||
entityId: string
|
||||
) => {
|
||||
entries = entries.filter((e) => e.entity_id !== entityId);
|
||||
hass.mockEvent("entity_registry_updated");
|
||||
};
|
||||
|
||||
export const removeLabelFromEntityRegistry = (
|
||||
hass: MockHomeAssistant,
|
||||
labelId: string
|
||||
) => {
|
||||
const withoutLabel = (labels: string[]) =>
|
||||
labels.filter((id) => id !== labelId);
|
||||
for (const [key, entry] of retiredEntries) {
|
||||
if (entry.labels.includes(labelId)) {
|
||||
retiredEntries.set(key, { ...entry, labels: withoutLabel(entry.labels) });
|
||||
}
|
||||
}
|
||||
if (!entries.some((entry) => entry.labels.includes(labelId))) {
|
||||
return;
|
||||
}
|
||||
entries = entries.map((entry) =>
|
||||
entry.labels.includes(labelId)
|
||||
? { ...entry, labels: withoutLabel(entry.labels) }
|
||||
: entry
|
||||
);
|
||||
const entities = { ...hass.entities };
|
||||
for (const entry of entries) {
|
||||
const entity = entities[entry.entity_id];
|
||||
if (entity?.labels.includes(labelId)) {
|
||||
entities[entry.entity_id] = {
|
||||
...entity,
|
||||
labels: withoutLabel(entity.labels),
|
||||
};
|
||||
}
|
||||
}
|
||||
hass.updateHass({ entities });
|
||||
hass.mockEvent("entity_registry_updated");
|
||||
};
|
||||
|
||||
// Adds an entry like core does when an integration creates an entity
|
||||
export const addEntityRegistryEntry = (
|
||||
hass: MockHomeAssistant,
|
||||
entry: Pick<EntityRegistryEntry, "entity_id" | "platform" | "unique_id">
|
||||
) => {
|
||||
entries = [
|
||||
...entries.filter((e) => e.entity_id !== entry.entity_id),
|
||||
newEntry(entry),
|
||||
];
|
||||
hass.mockEvent("entity_registry_updated");
|
||||
};
|
||||
|
||||
// The app refetches the display registry on entity_registry_updated events.
|
||||
// Answer with the current display registry, which the demo keeps in hass.
|
||||
export const mockEntityRegistryDisplay = (hass: MockHomeAssistant) =>
|
||||
hass.mockWS(
|
||||
"config/entity_registry/list_for_display",
|
||||
(
|
||||
_msg,
|
||||
currentHass: MockHomeAssistant
|
||||
): EntityRegistryDisplayEntryResponse => {
|
||||
const categories = [
|
||||
...new Set(
|
||||
Object.values(currentHass.entities)
|
||||
.map((entity) => entity.entity_category)
|
||||
.filter((category) => category !== undefined)
|
||||
),
|
||||
];
|
||||
return {
|
||||
entities: Object.values(currentHass.entities).map((entity) => ({
|
||||
ei: entity.entity_id,
|
||||
di: entity.device_id,
|
||||
ai: entity.area_id,
|
||||
np: entity.next_name_part,
|
||||
lb: entity.labels,
|
||||
ec: entity.entity_category
|
||||
? categories.indexOf(entity.entity_category)
|
||||
: undefined,
|
||||
en: entity.name,
|
||||
ic: entity.icon,
|
||||
pl: entity.platform ?? "demo",
|
||||
tk: entity.translation_key,
|
||||
hb: entity.hidden,
|
||||
dp: entity.display_precision,
|
||||
hn: entity.has_entity_name,
|
||||
})),
|
||||
entity_categories: Object.fromEntries(
|
||||
categories.map((category, index) => [index, category])
|
||||
),
|
||||
};
|
||||
}
|
||||
);
|
||||
@@ -12,17 +12,6 @@ export const mockIcons = (hass: MockHomeAssistant) => {
|
||||
category: IconCategory;
|
||||
integration?: string;
|
||||
}) => {
|
||||
if (category === "triggers" || category === "conditions") {
|
||||
const { triggerIcons, conditionIcons } =
|
||||
await import("./automation_platforms_icons");
|
||||
const icons: Record<string, unknown> =
|
||||
category === "triggers" ? triggerIcons : conditionIcons;
|
||||
return {
|
||||
resources: integration
|
||||
? { [integration]: icons[integration] ?? {} }
|
||||
: icons,
|
||||
};
|
||||
}
|
||||
if (integration) {
|
||||
try {
|
||||
const response = await fetch(
|
||||
|
||||
@@ -1,31 +1,7 @@
|
||||
import type { LabelRegistryEntry } from "../../../src/data/label/label_registry";
|
||||
import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
|
||||
import { removeLabelFromEntityRegistry } from "./entity_registry";
|
||||
|
||||
let labels: LabelRegistryEntry[] = [];
|
||||
|
||||
export const getLabelIds = () => labels.map((label) => label.label_id);
|
||||
|
||||
export const mockLabelRegistry = (
|
||||
hass: MockHomeAssistant,
|
||||
data: LabelRegistryEntry[] = []
|
||||
) => {
|
||||
labels = [...data];
|
||||
hass.mockWS("config/label_registry/list", () =>
|
||||
labels.map((label) => ({ ...label }))
|
||||
);
|
||||
hass.mockWS(
|
||||
"config/label_registry/delete",
|
||||
(msg: { label_id: string }, currentHass: MockHomeAssistant) => {
|
||||
const index = labels.findIndex(
|
||||
(label) => label.label_id === msg.label_id
|
||||
);
|
||||
if (index === -1) {
|
||||
throw new Error(`Label ${msg.label_id} not found`);
|
||||
}
|
||||
labels.splice(index, 1);
|
||||
removeLabelFromEntityRegistry(currentHass, msg.label_id);
|
||||
hass.mockEvent("label_registry_updated");
|
||||
}
|
||||
);
|
||||
};
|
||||
) => hass.mockWS("config/label_registry/list", () => data);
|
||||
@@ -1,5 +1,4 @@
|
||||
import type { LocalizeFunc } from "../../../src/common/translations/localize";
|
||||
import type { LovelaceDashboard } from "../../../src/data/lovelace/dashboard";
|
||||
import type { LovelaceInfo } from "../../../src/data/lovelace/resource";
|
||||
import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
|
||||
import {
|
||||
@@ -10,19 +9,15 @@ import {
|
||||
import "../custom-cards/cast-demo-row";
|
||||
import "../custom-cards/ha-demo-card";
|
||||
import "../custom-cards/ha-demo-next-card";
|
||||
import { mockMap } from "./map";
|
||||
|
||||
let dashboards: LovelaceDashboard[] = [];
|
||||
import { mapEntities } from "./entities";
|
||||
|
||||
export const mockLovelace = (
|
||||
hass: MockHomeAssistant,
|
||||
localizePromise: Promise<LocalizeFunc>,
|
||||
data: LovelaceDashboard[] = []
|
||||
localizePromise: Promise<LocalizeFunc>
|
||||
) => {
|
||||
dashboards = data;
|
||||
hass.mockWS("lovelace/config", ({ url_path }) => {
|
||||
if (url_path === "map") {
|
||||
mockMap(hass);
|
||||
hass.addEntities(mapEntities());
|
||||
return {
|
||||
strategy: {
|
||||
type: "map",
|
||||
@@ -39,16 +34,7 @@ export const mockLovelace = (
|
||||
);
|
||||
hass.mockWS("lovelace/config/save", () => Promise.resolve());
|
||||
hass.mockWS("lovelace/resources", () => Promise.resolve([]));
|
||||
hass.mockWS("lovelace/dashboards/list", () =>
|
||||
dashboards.map((dashboard) => ({ ...dashboard }))
|
||||
);
|
||||
hass.mockWS("lovelace/dashboards/delete", (msg: { dashboard_id: string }) => {
|
||||
const index = dashboards.findIndex((d) => d.id === msg.dashboard_id);
|
||||
if (index === -1) {
|
||||
throw new Error(`Dashboard ${msg.dashboard_id} not found`);
|
||||
}
|
||||
dashboards.splice(index, 1);
|
||||
});
|
||||
hass.mockWS("lovelace/dashboards/list", () => Promise.resolve([]));
|
||||
};
|
||||
|
||||
customElements.whenDefined("hui-root").then(() => {
|
||||
|
||||
@@ -1,250 +0,0 @@
|
||||
import type { EntityRegistryEntry } from "../../../src/data/entity/entity_registry";
|
||||
import type { EntityHistoryState } from "../../../src/data/history";
|
||||
import type { Zone } from "../../../src/data/zone";
|
||||
import { demoConfig } from "../../../src/fake_data/demo_config";
|
||||
import type { EntityInput } from "../../../src/fake_data/entities/types";
|
||||
import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
|
||||
|
||||
const HOUR = 3600 * 1000;
|
||||
|
||||
type ZoneData = Omit<Zone, "id">;
|
||||
|
||||
const HOME: ZoneData = {
|
||||
name: "Home",
|
||||
icon: "mdi:home",
|
||||
latitude: demoConfig.latitude,
|
||||
longitude: demoConfig.longitude,
|
||||
radius: 100,
|
||||
passive: false,
|
||||
};
|
||||
|
||||
export const zones: Zone[] = [
|
||||
{
|
||||
id: "gym",
|
||||
name: "Gym",
|
||||
icon: "mdi:dumbbell",
|
||||
latitude: 52.358,
|
||||
longitude: 4.8686,
|
||||
radius: 150,
|
||||
passive: false,
|
||||
},
|
||||
{
|
||||
id: "school",
|
||||
name: "School",
|
||||
icon: "mdi:school",
|
||||
latitude: 52.3558182,
|
||||
longitude: 4.9535376,
|
||||
radius: 250,
|
||||
passive: false,
|
||||
},
|
||||
{
|
||||
id: "supermarket",
|
||||
name: "Supermarket",
|
||||
icon: "mdi:cart",
|
||||
latitude: 52.3793,
|
||||
longitude: 4.8994,
|
||||
radius: 100,
|
||||
passive: false,
|
||||
},
|
||||
{
|
||||
id: "work",
|
||||
name: "Work",
|
||||
icon: "mdi:briefcase",
|
||||
latitude: 52.3909184,
|
||||
longitude: 4.8530821,
|
||||
radius: 200,
|
||||
passive: false,
|
||||
},
|
||||
];
|
||||
|
||||
interface DemoPerson {
|
||||
id: string;
|
||||
name: string;
|
||||
picture: string;
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
// Hours ago and the state entered then, oldest first. The first entry is
|
||||
// the state before the 24 hour activity window, the last one the current
|
||||
// state.
|
||||
timeline: [number, string][];
|
||||
}
|
||||
|
||||
const PEOPLE: DemoPerson[] = [
|
||||
{
|
||||
id: "arsaboo",
|
||||
name: "Arsaboo",
|
||||
picture: "/assets/arsaboo/images/arsaboo.jpg",
|
||||
latitude: 52.3732,
|
||||
longitude: 4.8904,
|
||||
timeline: [
|
||||
[30, "home"],
|
||||
[10, "not_home"],
|
||||
[9.5, "Work"],
|
||||
[5, "not_home"],
|
||||
[4.6, "Gym"],
|
||||
[3.4, "not_home"],
|
||||
[3.1, "Supermarket"],
|
||||
[2.8, "not_home"],
|
||||
[2.4, "home"],
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "melody",
|
||||
name: "Melody",
|
||||
picture: "/assets/arsaboo/images/melody.jpg",
|
||||
latitude: 52.373,
|
||||
longitude: 4.8901,
|
||||
timeline: [
|
||||
[30, "home"],
|
||||
[9, "not_home"],
|
||||
[8.6, "School"],
|
||||
[4, "not_home"],
|
||||
[3.6, "home"],
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "oscar",
|
||||
name: "Oscar",
|
||||
picture: "/assets/kernehed/oscar.jpg",
|
||||
latitude: 52.391,
|
||||
longitude: 4.8532,
|
||||
timeline: [
|
||||
[30, "home"],
|
||||
[9, "not_home"],
|
||||
[8.7, "Work"],
|
||||
[4.5, "not_home"],
|
||||
[4.2, "Supermarket"],
|
||||
[3.8, "not_home"],
|
||||
[3.5, "Work"],
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "bella",
|
||||
name: "Bella",
|
||||
picture: "/assets/kernehed/bella.jpg",
|
||||
latitude: 52.365,
|
||||
longitude: 4.8795,
|
||||
timeline: [
|
||||
[30, "home"],
|
||||
[8, "not_home"],
|
||||
[7.6, "Work"],
|
||||
[1.2, "not_home"],
|
||||
[0.9, "Gym"],
|
||||
[0.3, "not_home"],
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const CAR: EntityInput = {
|
||||
entity_id: "device_tracker.car",
|
||||
state: "not_home",
|
||||
attributes: {
|
||||
friendly_name: "Car",
|
||||
icon: "mdi:car",
|
||||
source_type: "gps",
|
||||
latitude: 52.3805,
|
||||
longitude: 4.8718,
|
||||
gps_accuracy: 10,
|
||||
},
|
||||
};
|
||||
|
||||
const lastMove = (person: DemoPerson) =>
|
||||
person.timeline[person.timeline.length - 1];
|
||||
|
||||
const zoneEntity = (entityId: string, zone: ZoneData): EntityInput => {
|
||||
// A person's state is "home" for the home zone, the zone name otherwise
|
||||
const personState = entityId === "zone.home" ? "home" : zone.name;
|
||||
const persons = PEOPLE.filter(
|
||||
(person) => lastMove(person)[1] === personState
|
||||
).map((person) => `person.${person.id}`);
|
||||
return {
|
||||
entity_id: entityId,
|
||||
state: String(persons.length),
|
||||
attributes: {
|
||||
latitude: zone.latitude,
|
||||
longitude: zone.longitude,
|
||||
radius: zone.radius,
|
||||
passive: zone.passive,
|
||||
persons,
|
||||
editable: true,
|
||||
icon: zone.icon,
|
||||
friendly_name: zone.name,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
export const zoneEntities = (): EntityInput[] => [
|
||||
zoneEntity("zone.home", HOME),
|
||||
...zones.map((zone) => zoneEntity(`zone.${zone.id}`, zone)),
|
||||
];
|
||||
|
||||
// UI zones are in the entity registry, so the zone editor does not also list
|
||||
// them as YAML zones
|
||||
export const zoneRegistryEntries: EntityRegistryEntry[] = zones.map((zone) => ({
|
||||
config_entry_id: null,
|
||||
config_subentry_id: null,
|
||||
device_id: null,
|
||||
area_id: null,
|
||||
disabled_by: null,
|
||||
entity_id: `zone.${zone.id}`,
|
||||
id: `zone.${zone.id}`,
|
||||
name: null,
|
||||
icon: null,
|
||||
labels: [],
|
||||
categories: {},
|
||||
platform: "zone",
|
||||
hidden_by: null,
|
||||
entity_category: null,
|
||||
has_entity_name: false,
|
||||
unique_id: zone.id,
|
||||
options: null,
|
||||
created_at: 0,
|
||||
modified_at: 0,
|
||||
}));
|
||||
|
||||
const personEntity = (person: DemoPerson, now: number): EntityInput => {
|
||||
const [hoursAgo, state] = lastMove(person);
|
||||
return {
|
||||
entity_id: `person.${person.id}`,
|
||||
state,
|
||||
last_changed: new Date(now - hoursAgo * HOUR).toISOString(),
|
||||
attributes: {
|
||||
friendly_name: person.name,
|
||||
entity_picture: person.picture,
|
||||
latitude: person.latitude,
|
||||
longitude: person.longitude,
|
||||
gps_accuracy: 20,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const personHistory = (person: DemoPerson, now: number): EntityHistoryState[] =>
|
||||
person.timeline.map(([hoursAgo, state]) => {
|
||||
const time = (now - hoursAgo * HOUR) / 1000;
|
||||
const zoneId =
|
||||
state === "home" ? "home" : zones.find((zone) => zone.name === state)?.id;
|
||||
return {
|
||||
s: state,
|
||||
a: { in_zones: zoneId ? [`zone.${zoneId}`] : [] },
|
||||
lu: time,
|
||||
lc: time,
|
||||
};
|
||||
});
|
||||
|
||||
export const mockMap = (hass: MockHomeAssistant) => {
|
||||
const now = Date.now();
|
||||
hass.addEntities([
|
||||
...zoneEntities(),
|
||||
...PEOPLE.map((person) => personEntity(person, now)),
|
||||
CAR,
|
||||
]);
|
||||
hass.mockWS(
|
||||
"history/history_during_period",
|
||||
({ entity_ids }: { entity_ids?: string[] }) =>
|
||||
Object.fromEntries(
|
||||
PEOPLE.filter(
|
||||
(person) => !entity_ids || entity_ids.includes(`person.${person.id}`)
|
||||
).map((person) => [`person.${person.id}`, personHistory(person, now)])
|
||||
)
|
||||
);
|
||||
};
|
||||
@@ -1,209 +0,0 @@
|
||||
import type { MarketplaceInfo } from "../../../src/data/marketplace/marketplace";
|
||||
import type {
|
||||
MarketplaceRelease,
|
||||
RepositoryBase,
|
||||
RepositoryInfo,
|
||||
} from "../../../src/data/marketplace/repository";
|
||||
import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
|
||||
|
||||
const baseRepository = {
|
||||
can_install: true,
|
||||
config_flow: false,
|
||||
custom: false,
|
||||
domain: null,
|
||||
file_name: "",
|
||||
hide: false,
|
||||
homeassistant: null,
|
||||
installed: false,
|
||||
installed_version: "",
|
||||
local_path: "",
|
||||
new: false,
|
||||
pending_upgrade: false,
|
||||
status: "default" as const,
|
||||
};
|
||||
|
||||
const repositories: RepositoryBase[] = [
|
||||
{
|
||||
...baseRepository,
|
||||
id: "1",
|
||||
category: "integration",
|
||||
full_name: "AlexxIT/WebRTC",
|
||||
name: "WebRTC Camera",
|
||||
description: "Watch camera streams with low latency through WebRTC.",
|
||||
authors: ["@AlexxIT"],
|
||||
domain: "webrtc",
|
||||
config_flow: true,
|
||||
available_version: "v3.6.1",
|
||||
downloads: 98000,
|
||||
stars: 1900,
|
||||
last_updated: "2026-09-12T08:21:00Z",
|
||||
topics: ["camera", "webrtc"],
|
||||
},
|
||||
{
|
||||
...baseRepository,
|
||||
id: "2",
|
||||
category: "integration",
|
||||
full_name: "basnijholt/adaptive-lighting",
|
||||
name: "Adaptive Lighting",
|
||||
description:
|
||||
"Change the brightness and color temperature of your lights over the day.",
|
||||
authors: ["@basnijholt"],
|
||||
domain: "adaptive_lighting",
|
||||
config_flow: true,
|
||||
available_version: "v1.26.0",
|
||||
installed: true,
|
||||
installed_version: "v1.26.0",
|
||||
status: "installed",
|
||||
downloads: 120000,
|
||||
stars: 2300,
|
||||
last_updated: "2026-08-30T17:02:00Z",
|
||||
topics: ["lights", "circadian-rhythm"],
|
||||
},
|
||||
{
|
||||
...baseRepository,
|
||||
id: "3",
|
||||
category: "integration",
|
||||
full_name: "blakeblackshear/frigate-hass-integration",
|
||||
name: "Frigate",
|
||||
description: "Use the cameras and events of a Frigate NVR.",
|
||||
authors: ["@blakeblackshear", "@dermotduffy", "@NickM-27"],
|
||||
domain: "frigate",
|
||||
config_flow: true,
|
||||
available_version: "v5.9.0",
|
||||
installed: true,
|
||||
installed_version: "v5.8.0",
|
||||
pending_upgrade: true,
|
||||
status: "pending-upgrade",
|
||||
downloads: 150000,
|
||||
stars: 900,
|
||||
last_updated: "2026-09-25T11:45:00Z",
|
||||
topics: ["camera", "nvr"],
|
||||
},
|
||||
{
|
||||
...baseRepository,
|
||||
id: "4",
|
||||
category: "plugin",
|
||||
full_name: "piitaya/lovelace-mushroom",
|
||||
name: "Mushroom",
|
||||
description: "A collection of cards to build a clean dashboard.",
|
||||
authors: ["@piitaya"],
|
||||
file_name: "mushroom.js",
|
||||
available_version: "v5.1.0",
|
||||
installed: true,
|
||||
installed_version: "v5.1.0",
|
||||
status: "installed",
|
||||
downloads: 310000,
|
||||
stars: 4400,
|
||||
last_updated: "2026-09-02T09:10:00Z",
|
||||
topics: ["cards", "dashboard"],
|
||||
},
|
||||
{
|
||||
...baseRepository,
|
||||
id: "5",
|
||||
category: "plugin",
|
||||
full_name: "thomasloven/lovelace-card-mod",
|
||||
name: "card-mod",
|
||||
description: "Add CSS styles to any card of your dashboard.",
|
||||
authors: ["@thomasloven"],
|
||||
file_name: "card-mod.js",
|
||||
available_version: "v4.0.0",
|
||||
downloads: 280000,
|
||||
stars: 1500,
|
||||
last_updated: "2026-07-19T14:33:00Z",
|
||||
topics: ["css", "dashboard"],
|
||||
},
|
||||
{
|
||||
...baseRepository,
|
||||
id: "6",
|
||||
category: "plugin",
|
||||
full_name: "custom-cards/button-card",
|
||||
name: "button-card",
|
||||
description: "A button card that you can change in almost every way.",
|
||||
authors: ["@RomRider"],
|
||||
file_name: "button-card.js",
|
||||
available_version: "v5.0.0",
|
||||
new: true,
|
||||
status: "new",
|
||||
downloads: 240000,
|
||||
stars: 2100,
|
||||
last_updated: "2026-09-28T19:05:00Z",
|
||||
topics: ["button", "cards"],
|
||||
},
|
||||
{
|
||||
...baseRepository,
|
||||
id: "7",
|
||||
category: "theme",
|
||||
full_name: "catppuccin/home-assistant",
|
||||
name: "Catppuccin",
|
||||
description: "Soothing pastel themes for Home Assistant.",
|
||||
authors: ["@catppuccin"],
|
||||
file_name: "catppuccin.yaml",
|
||||
available_version: "v2.1.0",
|
||||
downloads: 45000,
|
||||
stars: 300,
|
||||
last_updated: "2026-06-11T07:48:00Z",
|
||||
topics: ["theme"],
|
||||
},
|
||||
];
|
||||
|
||||
const repositoryInfo = (repository: RepositoryBase): RepositoryInfo => ({
|
||||
...repository,
|
||||
additional_info: `# ${repository.name}\n\n${repository.description}\n\nThis is a demo. Installing is not available.`,
|
||||
default_branch: "main",
|
||||
issues: 12,
|
||||
releases: [repository.available_version],
|
||||
ref: repository.available_version,
|
||||
replaces_built_in: false,
|
||||
selected_tag: null,
|
||||
update_entity_id: null,
|
||||
version_or_commit: "version",
|
||||
});
|
||||
|
||||
const info: MarketplaceInfo = {
|
||||
categories: ["integration", "plugin", "theme"],
|
||||
disabled_reason: null,
|
||||
github_connected: false,
|
||||
has_pending_tasks: false,
|
||||
lovelace_mode: "storage",
|
||||
stage: "running",
|
||||
startup: false,
|
||||
version: "2.0.0",
|
||||
warning_accepted: true,
|
||||
};
|
||||
|
||||
// Commands that change the Marketplace are not mocked, so the data never
|
||||
// changes and no signal fires.
|
||||
export const mockMarketplace = (hass: MockHomeAssistant) => {
|
||||
hass.mockWS("marketplace/info", () => info);
|
||||
|
||||
hass.mockWS("marketplace/repositories/list", () => repositories);
|
||||
|
||||
hass.mockWS(
|
||||
"marketplace/repository/info",
|
||||
({ repository_id }: { repository_id: string }) => {
|
||||
const repository = repositories.find((repo) => repo.id === repository_id);
|
||||
return repository
|
||||
? repositoryInfo(repository)
|
||||
: Promise.reject({ code: "not_found", message: "Unknown repository" });
|
||||
}
|
||||
);
|
||||
|
||||
hass.mockWS(
|
||||
"marketplace/repository/releases",
|
||||
({ repository_id }: { repository_id: string }): MarketplaceRelease[] => {
|
||||
const repository = repositories.find((repo) => repo.id === repository_id);
|
||||
return repository
|
||||
? [
|
||||
{
|
||||
tag: repository.available_version,
|
||||
name: repository.available_version,
|
||||
published_at: String(repository.last_updated),
|
||||
prerelease: false,
|
||||
},
|
||||
]
|
||||
: [];
|
||||
}
|
||||
);
|
||||
|
||||
hass.mockWS("marketplace/subscribe", () => () => undefined);
|
||||
};
|
||||
+22
-69
@@ -1,10 +1,8 @@
|
||||
import { TZDate } from "@date-fns/tz";
|
||||
import {
|
||||
addDays,
|
||||
addHours,
|
||||
addMinutes,
|
||||
addMonths,
|
||||
constructFrom,
|
||||
differenceInHours,
|
||||
endOfDay,
|
||||
} from "date-fns";
|
||||
@@ -15,18 +13,6 @@ import type {
|
||||
} from "../../../src/data/recorder";
|
||||
import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
|
||||
|
||||
const getFirstDate = (
|
||||
start: Date,
|
||||
period: "5minute" | "hour" | "day" | "month"
|
||||
): Date => {
|
||||
if (period === "day" || period === "month") {
|
||||
return constructFrom(start, start);
|
||||
}
|
||||
// Core returns UTC-aligned buckets whose start is at or after the request.
|
||||
const interval = (period === "hour" ? 60 : 5) * 60 * 1000;
|
||||
return constructFrom(start, Math.ceil(start.getTime() / interval) * interval);
|
||||
};
|
||||
|
||||
const getNextDate = (
|
||||
currentDate: Date,
|
||||
period: "5minute" | "hour" | "day" | "month"
|
||||
@@ -47,7 +33,8 @@ const generateMeanStatistics = (
|
||||
maxDiff: number
|
||||
): StatisticValue[] => {
|
||||
const statistics: StatisticValue[] = [];
|
||||
let currentDate = getFirstDate(start, period);
|
||||
let currentDate = new Date(start);
|
||||
currentDate.setMinutes(0, 0, 0);
|
||||
const now = new Date();
|
||||
while (end > currentDate && currentDate < now) {
|
||||
const delta = Math.random() * maxDiff;
|
||||
@@ -73,17 +60,16 @@ const generateSumStatistics = (
|
||||
end: Date,
|
||||
period: "5minute" | "hour" | "day" | "month" = "hour",
|
||||
initValue: number,
|
||||
maxDiff: number | ((start: Date, end: Date) => number)
|
||||
maxDiff: number
|
||||
): StatisticValue[] => {
|
||||
const statistics: StatisticValue[] = [];
|
||||
let currentDate = getFirstDate(start, period);
|
||||
let currentDate = new Date(start);
|
||||
currentDate.setMinutes(0, 0, 0);
|
||||
let sum = initValue;
|
||||
const now = new Date();
|
||||
while (end > currentDate && currentDate < now) {
|
||||
const nextDate = getNextDate(currentDate, period);
|
||||
const bound =
|
||||
typeof maxDiff === "function" ? maxDiff(currentDate, nextDate) : maxDiff;
|
||||
const add = Math.random() * bound;
|
||||
const add = Math.random() * maxDiff;
|
||||
sum += add;
|
||||
statistics.push({
|
||||
start: currentDate.getTime(),
|
||||
@@ -110,7 +96,8 @@ const generateCurvedStatistics = (
|
||||
metered: boolean
|
||||
): StatisticValue[] => {
|
||||
const statistics: StatisticValue[] = [];
|
||||
let currentDate = getFirstDate(start, period);
|
||||
let currentDate = new Date(start);
|
||||
currentDate.setMinutes(0, 0, 0);
|
||||
let sum = initValue;
|
||||
const hours = differenceInHours(end, start) - 1;
|
||||
let i = 0;
|
||||
@@ -149,15 +136,6 @@ const statisticsFunctions: Record<
|
||||
period: "5minute" | "hour" | "day" | "month"
|
||||
) => StatisticValue[]
|
||||
> = {
|
||||
"sensor.energy_water": (_id, start, end, period = "hour") =>
|
||||
generateSumStatistics(
|
||||
start,
|
||||
end,
|
||||
period,
|
||||
4000,
|
||||
(bucketStart, bucketEnd) =>
|
||||
(20 * (bucketEnd.getTime() - bucketStart.getTime())) / 3600000
|
||||
),
|
||||
"sensor.energy_consumption_tarif_1": (
|
||||
_id: string,
|
||||
start: Date,
|
||||
@@ -173,9 +151,9 @@ const statisticsFunctions: Record<
|
||||
period === "day" ? 17 : 504
|
||||
);
|
||||
}
|
||||
const morningEnd = addHours(start, 10);
|
||||
const morningEnd = new Date(start.getTime() + 10 * 60 * 60 * 1000);
|
||||
const morningLow = generateSumStatistics(start, morningEnd, period, 0, 0.7);
|
||||
const eveningStart = addHours(start, 20);
|
||||
const eveningStart = new Date(start.getTime() + 20 * 60 * 60 * 1000);
|
||||
const morningFinalVal = morningLow.length
|
||||
? morningLow[morningLow.length - 1].sum!
|
||||
: 0;
|
||||
@@ -210,8 +188,8 @@ const statisticsFunctions: Record<
|
||||
period === "day" ? 17 : 504
|
||||
);
|
||||
}
|
||||
const morningEnd = addHours(start, 9);
|
||||
const eveningStart = addHours(start, 20);
|
||||
const morningEnd = new Date(start.getTime() + 9 * 60 * 60 * 1000);
|
||||
const eveningStart = new Date(start.getTime() + 20 * 60 * 60 * 1000);
|
||||
const highTarif = generateSumStatistics(
|
||||
morningEnd,
|
||||
eveningStart,
|
||||
@@ -250,12 +228,9 @@ const statisticsFunctions: Record<
|
||||
period === "day" ? 17 : 504
|
||||
);
|
||||
}
|
||||
const productionStart = addHours(start, 9);
|
||||
const productionEnd = addHours(start, 21);
|
||||
const dayEnd = constructFrom(
|
||||
end,
|
||||
Math.min(endOfDay(productionEnd).getTime(), end.getTime())
|
||||
);
|
||||
const productionStart = new Date(start.getTime() + 9 * 60 * 60 * 1000);
|
||||
const productionEnd = new Date(start.getTime() + 21 * 60 * 60 * 1000);
|
||||
const dayEnd = new Date(endOfDay(productionEnd));
|
||||
const production = generateCurvedStatistics(
|
||||
productionStart,
|
||||
productionEnd,
|
||||
@@ -294,12 +269,9 @@ const statisticsFunctions: Record<
|
||||
period === "day" ? 17 : 504
|
||||
);
|
||||
}
|
||||
const productionStart = addHours(start, 7);
|
||||
const productionEnd = addHours(start, 23);
|
||||
const dayEnd = constructFrom(
|
||||
end,
|
||||
Math.min(endOfDay(productionEnd).getTime(), end.getTime())
|
||||
);
|
||||
const productionStart = new Date(start.getTime() + 7 * 60 * 60 * 1000);
|
||||
const productionEnd = new Date(start.getTime() + 23 * 60 * 60 * 1000);
|
||||
const dayEnd = new Date(endOfDay(productionEnd));
|
||||
const production = generateCurvedStatistics(
|
||||
productionStart,
|
||||
productionEnd,
|
||||
@@ -329,11 +301,6 @@ const statisticsFunctions: Record<
|
||||
return [...morning, ...production, ...evening, ...rest];
|
||||
},
|
||||
};
|
||||
// Prices are per source unit; water uses litres (4 currency units per m³).
|
||||
const derivedCosts: Record<string, { source: string; price: number }> = {
|
||||
"sensor.energy_water_cost": { source: "sensor.energy_water", price: 0.004 },
|
||||
};
|
||||
|
||||
export const mockRecorder = (mockHass: MockHomeAssistant) => {
|
||||
mockHass.mockWS(
|
||||
"recorder/get_statistics_metadata",
|
||||
@@ -346,28 +313,14 @@ export const mockRecorder = (mockHass: MockHomeAssistant) => {
|
||||
mockHass.mockWS(
|
||||
"recorder/statistics_during_period",
|
||||
({ statistic_ids, start_time, end_time, period }, hass): Statistics => {
|
||||
// Calendar buckets follow the server timezone, independent of the browser.
|
||||
const start = new TZDate(start_time, hass.config.time_zone);
|
||||
const end = new TZDate(end_time ?? Date.now(), hass.config.time_zone);
|
||||
const start = new Date(start_time);
|
||||
const end = end_time ? new Date(end_time) : new Date();
|
||||
|
||||
const statistics: Record<string, StatisticValue[]> = {};
|
||||
const cache: Record<string, StatisticValue[]> = {};
|
||||
const getStats = (id: string) => {
|
||||
cache[id] ??= statisticsFunctions[id](id, start, end, period);
|
||||
return cache[id];
|
||||
};
|
||||
|
||||
statistic_ids.forEach((id: string) => {
|
||||
if (id in derivedCosts) {
|
||||
const { source, price } = derivedCosts[id];
|
||||
let cost = 0;
|
||||
statistics[id] = getStats(source).map((statistic) => {
|
||||
const change = statistic.change! * price;
|
||||
cost += change;
|
||||
return { ...statistic, change, state: cost, sum: cost };
|
||||
});
|
||||
} else if (id in statisticsFunctions) {
|
||||
statistics[id] = getStats(id);
|
||||
if (id in statisticsFunctions) {
|
||||
statistics[id] = statisticsFunctions[id](id, start, end, period);
|
||||
} else {
|
||||
const entityState = hass.states[id];
|
||||
const state = entityState ? Number(entityState.state) : 1;
|
||||
|
||||
+22
-2
@@ -1,7 +1,27 @@
|
||||
import type { Zone } from "../../../src/data/zone";
|
||||
import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
|
||||
import { zoneEntities, zones } from "./map";
|
||||
|
||||
const zones: Zone[] = [
|
||||
{
|
||||
id: "home",
|
||||
name: "Home",
|
||||
icon: "mdi:home",
|
||||
latitude: 52.3731339,
|
||||
longitude: 4.8903147,
|
||||
radius: 100,
|
||||
passive: false,
|
||||
},
|
||||
{
|
||||
id: "work",
|
||||
name: "Work",
|
||||
icon: "mdi:briefcase",
|
||||
latitude: 52.3909184,
|
||||
longitude: 4.8530821,
|
||||
radius: 200,
|
||||
passive: false,
|
||||
},
|
||||
];
|
||||
|
||||
export const mockZone = (hass: MockHomeAssistant) => {
|
||||
hass.mockWS("zone/list", () => zones);
|
||||
hass.addEntities(zoneEntities());
|
||||
};
|
||||
+1
-7
@@ -12,7 +12,6 @@ import { configs as wcConfigs } from "eslint-plugin-wc";
|
||||
import { configs as a11yConfigs } from "eslint-plugin-lit-a11y";
|
||||
import html from "@html-eslint/eslint-plugin";
|
||||
import importX from "eslint-plugin-import-x";
|
||||
import ha from "./build-scripts/eslint-rules/index.mjs";
|
||||
|
||||
const rspackConfigPath = fileURLToPath(
|
||||
new URL("./rspack.config.cjs", import.meta.url)
|
||||
@@ -34,7 +33,6 @@ export default tseslint.config(
|
||||
{
|
||||
plugins: {
|
||||
"unused-imports": unusedImports,
|
||||
ha,
|
||||
},
|
||||
|
||||
languageOptions: {
|
||||
@@ -233,10 +231,6 @@ export default tseslint.config(
|
||||
],
|
||||
|
||||
"unused-imports/no-unused-imports": "error",
|
||||
// Off because the existing backlog would fail lint:eslint's
|
||||
// --max-warnings=0; run lint:element-imports to see it. Disable
|
||||
// comments for it would be reported as unused, so none until it is on.
|
||||
"ha/no-unused-element-import": "off",
|
||||
"lit/attribute-names": "error",
|
||||
"lit/attribute-value-entities": "off",
|
||||
"lit/no-template-map": "off",
|
||||
@@ -296,7 +290,7 @@ export default tseslint.config(
|
||||
},
|
||||
},
|
||||
{
|
||||
files: [".github/scripts/*.mts"],
|
||||
files: [".github/scripts/*.mjs"],
|
||||
languageOptions: {
|
||||
globals: globals.node,
|
||||
},
|
||||
|
||||
@@ -40,7 +40,6 @@ export default [
|
||||
"ha-select-box",
|
||||
"ha-input",
|
||||
"ha-textarea",
|
||||
"ha-picker-combo-box",
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -53,7 +52,6 @@ export default [
|
||||
"ha-control-switch",
|
||||
"ha-slider",
|
||||
"ha-control-slider",
|
||||
"ha-control-scrubber",
|
||||
"ha-control-circular-slider",
|
||||
"ha-control-number-buttons",
|
||||
"ha-control-select",
|
||||
@@ -103,7 +101,6 @@ export default [
|
||||
"entity-button-card",
|
||||
"entity-filter-card",
|
||||
"glance-card",
|
||||
"sensor-card",
|
||||
"tile-card",
|
||||
"area-card",
|
||||
],
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { html, css, LitElement } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import "../../../src/components/ha-formfield";
|
||||
import "../../../src/components/ha-switch";
|
||||
import type { HomeAssistant } from "../../../src/types";
|
||||
import "./demo-card";
|
||||
@@ -17,7 +18,9 @@ class DemoCards extends LitElement {
|
||||
render() {
|
||||
return html`
|
||||
<ha-demo-options>
|
||||
<ha-switch @change=${this._showConfigToggled}>Show config</ha-switch>
|
||||
<ha-formfield label="Show config">
|
||||
<ha-switch @change=${this._showConfigToggled}> </ha-switch>
|
||||
</ha-formfield>
|
||||
</ha-demo-options>
|
||||
<div id="container">
|
||||
<div class="cards">
|
||||
@@ -48,7 +51,7 @@ class DemoCards extends LitElement {
|
||||
demo-card {
|
||||
margin: 16px 16px 32px;
|
||||
}
|
||||
ha-switch {
|
||||
ha-formfield {
|
||||
margin-right: 16px;
|
||||
}
|
||||
#container {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { LitElement, css, html } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import "../../../src/components/ha-formfield";
|
||||
import "../../../src/components/ha-switch";
|
||||
import type { HomeAssistant } from "../../../src/types";
|
||||
import "../ha-demo-options";
|
||||
@@ -16,7 +17,9 @@ class DemoMoreInfos extends LitElement {
|
||||
render() {
|
||||
return html`
|
||||
<ha-demo-options>
|
||||
<ha-switch @change=${this._showConfigToggled}>Show config</ha-switch>
|
||||
<ha-formfield label="Show config">
|
||||
<ha-switch @change=${this._showConfigToggled}> </ha-switch>
|
||||
</ha-formfield>
|
||||
</ha-demo-options>
|
||||
<div id="container">
|
||||
<div class="cards">
|
||||
@@ -46,7 +49,7 @@ class DemoMoreInfos extends LitElement {
|
||||
demo-more-info {
|
||||
margin: var(--ha-space-4) var(--ha-space-4) var(--ha-space-8);
|
||||
}
|
||||
ha-switch {
|
||||
ha-formfield {
|
||||
margin-right: var(--ha-space-4);
|
||||
}
|
||||
`;
|
||||
|
||||
@@ -6,10 +6,12 @@ import "../../../src/components/ha-settings-row";
|
||||
import "../../../src/components/ha-switch";
|
||||
import type { HaSwitch } from "../../../src/components/ha-switch";
|
||||
import "../../../src/components/ha-theme-settings";
|
||||
import type { ThemeSettings } from "../../../src/types";
|
||||
import type { HomeAssistant, ThemeSettings } from "../../../src/types";
|
||||
|
||||
@customElement("gallery-settings")
|
||||
class GallerySettings extends LitElement {
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
|
||||
@property({ attribute: false }) public themeSettings!: ThemeSettings;
|
||||
|
||||
@property({ type: Boolean }) public narrow = false;
|
||||
@@ -24,6 +26,7 @@ class GallerySettings extends LitElement {
|
||||
Configure how the gallery renders component previews and examples.
|
||||
</div>
|
||||
<ha-theme-settings
|
||||
.hass=${this.hass}
|
||||
.selectedTheme=${this.themeSettings}
|
||||
.narrow=${this.narrow}
|
||||
.heading=${"Theme"}
|
||||
|
||||
@@ -151,16 +151,6 @@ export const createMediaPlayerEntities = () => [
|
||||
supported_features: 289,
|
||||
},
|
||||
},
|
||||
{
|
||||
entity_id: "media_player.theater_off_assumed",
|
||||
state: "off",
|
||||
attributes: {
|
||||
friendly_name: "TV Off (assumed state, cannot be switched on)",
|
||||
assumed_state: true,
|
||||
// Off + Next + Pause
|
||||
supported_features: 289,
|
||||
},
|
||||
},
|
||||
{
|
||||
entity_id: "media_player.theater_on_static",
|
||||
state: "on",
|
||||
|
||||
@@ -9,7 +9,6 @@ import { ifDefined } from "lit/directives/if-defined";
|
||||
import { applyThemesOnElement } from "../../src/common/dom/apply_themes_on_element";
|
||||
import { dynamicElement } from "../../src/common/dom/dynamic-element-directive";
|
||||
import type { HASSDomEvent } from "../../src/common/dom/fire_event";
|
||||
import { listenMediaQuery } from "../../src/common/dom/media_query";
|
||||
import { computeEntityNameDisplayWithoutContext } from "../../src/common/entity/compute_entity_name_display";
|
||||
import { setDirectionStyles } from "../../src/common/util/compute_rtl";
|
||||
import "../../src/components/ha-button";
|
||||
@@ -32,7 +31,6 @@ import {
|
||||
floorsContext,
|
||||
formattersContext,
|
||||
internationalizationContext,
|
||||
reducedMotionContext,
|
||||
registriesContext,
|
||||
servicesContext,
|
||||
statesContext,
|
||||
@@ -163,13 +161,6 @@ class HaGallery extends LitElement {
|
||||
floors: new ContextProvider(this, { context: floorsContext }),
|
||||
};
|
||||
|
||||
private _reducedMotionProvider = new ContextProvider(this, {
|
||||
context: reducedMotionContext,
|
||||
initialValue: false,
|
||||
});
|
||||
|
||||
private _unsubReducedMotion?: () => void;
|
||||
|
||||
protected willUpdate(changedProps: PropertyValues<this>) {
|
||||
super.willUpdate(changedProps);
|
||||
// Refresh the fallback contexts before each render so theme/page changes in
|
||||
@@ -242,6 +233,7 @@ class HaGallery extends LitElement {
|
||||
${
|
||||
isSettingsPage
|
||||
? html`<gallery-settings
|
||||
.hass=${this._galleryHass}
|
||||
.themeSettings=${this._themeSettings}
|
||||
.narrow=${this._narrow}
|
||||
.rtl=${this._rtl}
|
||||
@@ -275,10 +267,6 @@ class HaGallery extends LitElement {
|
||||
connectedCallback() {
|
||||
super.connectedCallback();
|
||||
mql.addEventListener("change", this._systemDarkChanged);
|
||||
this._unsubReducedMotion = listenMediaQuery(
|
||||
"(prefers-reduced-motion: reduce)",
|
||||
(matches) => this._reducedMotionProvider.setValue(matches)
|
||||
);
|
||||
}
|
||||
|
||||
firstUpdated(changedProps: PropertyValues<this>) {
|
||||
@@ -312,8 +300,6 @@ class HaGallery extends LitElement {
|
||||
super.disconnectedCallback();
|
||||
mql.removeEventListener("change", this._systemDarkChanged);
|
||||
window.removeEventListener("hashchange", this._hashChanged);
|
||||
this._unsubReducedMotion?.();
|
||||
this._unsubReducedMotion = undefined;
|
||||
}
|
||||
|
||||
private _hashChanged = () => {
|
||||
|
||||
@@ -12,11 +12,6 @@
|
||||
<script type="module" src="<%= entry %>"></script>
|
||||
<% } %>
|
||||
<style>
|
||||
html {
|
||||
/* Prevent iOS Safari from inflating text when rotating to landscape */
|
||||
-webkit-text-size-adjust: 100%;
|
||||
text-size-adjust: 100%;
|
||||
}
|
||||
body {
|
||||
font-family: Roboto, Noto, sans-serif;
|
||||
margin: 0;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { TemplateResult } from "lit";
|
||||
import { LitElement, css, html } from "lit";
|
||||
import { customElement, state } from "lit/decorators";
|
||||
import "../../../../src/components/ha-formfield";
|
||||
import { provideHass } from "../../../../src/fake_data/provide_hass";
|
||||
import type { HomeAssistant } from "../../../../src/types";
|
||||
import "../../components/demo-black-white-row";
|
||||
@@ -63,12 +64,13 @@ class DemoHaAutomationEditorAction extends LitElement {
|
||||
protected render(): TemplateResult {
|
||||
return html`
|
||||
<div class="options">
|
||||
<ha-switch
|
||||
.name=${"disabled"}
|
||||
.checked=${this._disabled}
|
||||
@change=${this._handleOptionChange}
|
||||
>Disabled</ha-switch
|
||||
>
|
||||
<ha-formfield label="Disabled">
|
||||
<ha-switch
|
||||
.name=${"disabled"}
|
||||
.checked=${this._disabled}
|
||||
@change=${this._handleOptionChange}
|
||||
></ha-switch>
|
||||
</ha-formfield>
|
||||
</div>
|
||||
${SCHEMAS.map(
|
||||
(info, sampleIdx) => html`
|
||||
@@ -109,7 +111,7 @@ class DemoHaAutomationEditorAction extends LitElement {
|
||||
max-width: 800px;
|
||||
margin: 16px auto;
|
||||
}
|
||||
.options ha-switch {
|
||||
.options ha-formfield {
|
||||
margin-right: 16px;
|
||||
}
|
||||
`;
|
||||
|
||||
@@ -5,6 +5,7 @@ import { mockAreaRegistry } from "../../../../demo/src/stubs/area_registry";
|
||||
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 "../../../../src/components/ha-formfield";
|
||||
import type { ConditionWithShorthand } from "../../../../src/data/automation";
|
||||
import { provideHass } from "../../../../src/fake_data/provide_hass";
|
||||
import "../../../../src/panels/config/automation/condition/ha-automation-condition";
|
||||
@@ -105,12 +106,13 @@ export class DemoAutomationEditorCondition extends LitElement {
|
||||
protected render(): TemplateResult {
|
||||
return html`
|
||||
<div class="options">
|
||||
<ha-switch
|
||||
.name=${"disabled"}
|
||||
.checked=${this._disabled}
|
||||
@change=${this._handleOptionChange}
|
||||
>Disabled</ha-switch
|
||||
>
|
||||
<ha-formfield label="Disabled">
|
||||
<ha-switch
|
||||
.name=${"disabled"}
|
||||
.checked=${this._disabled}
|
||||
@change=${this._handleOptionChange}
|
||||
></ha-switch>
|
||||
</ha-formfield>
|
||||
</div>
|
||||
${SCHEMAS.map(
|
||||
(info, sampleIdx) => html`
|
||||
@@ -151,7 +153,7 @@ export class DemoAutomationEditorCondition extends LitElement {
|
||||
max-width: 800px;
|
||||
margin: 16px auto;
|
||||
}
|
||||
.options ha-switch {
|
||||
.options ha-formfield {
|
||||
margin-right: 16px;
|
||||
}
|
||||
`;
|
||||
|
||||
@@ -8,6 +8,7 @@ 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 { mockTags } from "../../../../demo/src/stubs/tags";
|
||||
import "../../../../src/components/ha-formfield";
|
||||
import type { Trigger } from "../../../../src/data/automation";
|
||||
import { provideHass } from "../../../../src/fake_data/provide_hass";
|
||||
import "../../../../src/panels/config/automation/trigger/ha-automation-trigger";
|
||||
@@ -145,12 +146,13 @@ export class DemoAutomationEditorTrigger extends LitElement {
|
||||
protected render(): TemplateResult {
|
||||
return html`
|
||||
<div class="options">
|
||||
<ha-switch
|
||||
.name=${"disabled"}
|
||||
.checked=${this._disabled}
|
||||
@change=${this._handleOptionChange}
|
||||
>Disabled</ha-switch
|
||||
>
|
||||
<ha-formfield label="Disabled">
|
||||
<ha-switch
|
||||
.name=${"disabled"}
|
||||
.checked=${this._disabled}
|
||||
@change=${this._handleOptionChange}
|
||||
></ha-switch>
|
||||
</ha-formfield>
|
||||
</div>
|
||||
${SCHEMAS.map(
|
||||
(info, sampleIdx) => html`
|
||||
@@ -191,7 +193,7 @@ export class DemoAutomationEditorTrigger extends LitElement {
|
||||
max-width: 800px;
|
||||
margin: 16px auto;
|
||||
}
|
||||
.options ha-switch {
|
||||
.options ha-formfield {
|
||||
margin-right: 16px;
|
||||
}
|
||||
`;
|
||||
|
||||
@@ -9,6 +9,7 @@ import "../../../../src/components/ha-form/ha-form";
|
||||
import "../../../../src/components/ha-icon-button";
|
||||
import type { HaFormSchema } from "../../../../src/components/ha-form/types";
|
||||
import { provideHass } from "../../../../src/fake_data/provide_hass";
|
||||
import type { HomeAssistant } from "../../../../src/types";
|
||||
|
||||
const SCHEMA: HaFormSchema[] = [
|
||||
{ type: "string", name: "Name", default: "", autofocus: true },
|
||||
@@ -30,8 +31,11 @@ type DialogType =
|
||||
export class DemoHaAdaptiveDialog extends LitElement {
|
||||
@state() private _openDialog: DialogType = false;
|
||||
|
||||
@state() private _hass?: HomeAssistant;
|
||||
|
||||
protected firstUpdated() {
|
||||
provideHass(this);
|
||||
const hass = provideHass(this);
|
||||
this._hass = hass;
|
||||
}
|
||||
|
||||
protected render() {
|
||||
@@ -85,6 +89,7 @@ export class DemoHaAdaptiveDialog extends LitElement {
|
||||
</ha-card>
|
||||
|
||||
<ha-adaptive-dialog
|
||||
.hass=${this._hass}
|
||||
.open=${this._openDialog === "basic"}
|
||||
header-title="Basic adaptive dialog"
|
||||
@closed=${this._handleClosed}
|
||||
@@ -93,6 +98,7 @@ export class DemoHaAdaptiveDialog extends LitElement {
|
||||
</ha-adaptive-dialog>
|
||||
|
||||
<ha-adaptive-dialog
|
||||
.hass=${this._hass}
|
||||
.open=${this._openDialog === "basic-subtitle-below"}
|
||||
header-title="Adaptive dialog with subtitle"
|
||||
header-subtitle="This is an adaptive dialog with a subtitle below"
|
||||
@@ -102,6 +108,7 @@ export class DemoHaAdaptiveDialog extends LitElement {
|
||||
</ha-adaptive-dialog>
|
||||
|
||||
<ha-adaptive-dialog
|
||||
.hass=${this._hass}
|
||||
.open=${this._openDialog === "basic-subtitle-above"}
|
||||
header-title="Adaptive dialog with subtitle above"
|
||||
header-subtitle="This is an adaptive dialog with a subtitle above"
|
||||
@@ -112,6 +119,7 @@ export class DemoHaAdaptiveDialog extends LitElement {
|
||||
</ha-adaptive-dialog>
|
||||
|
||||
<ha-adaptive-dialog
|
||||
.hass=${this._hass}
|
||||
.open=${this._openDialog === "small"}
|
||||
width="small"
|
||||
header-title="Small adaptive dialog"
|
||||
@@ -121,6 +129,7 @@ export class DemoHaAdaptiveDialog extends LitElement {
|
||||
</ha-adaptive-dialog>
|
||||
|
||||
<ha-adaptive-dialog
|
||||
.hass=${this._hass}
|
||||
.open=${this._openDialog === "large"}
|
||||
width="large"
|
||||
header-title="Large adaptive dialog"
|
||||
@@ -130,6 +139,7 @@ export class DemoHaAdaptiveDialog extends LitElement {
|
||||
</ha-adaptive-dialog>
|
||||
|
||||
<ha-adaptive-dialog
|
||||
.hass=${this._hass}
|
||||
.open=${this._openDialog === "form"}
|
||||
header-title="Adaptive dialog with form"
|
||||
header-subtitle="This is an adaptive dialog with a form"
|
||||
@@ -153,6 +163,7 @@ export class DemoHaAdaptiveDialog extends LitElement {
|
||||
</ha-adaptive-dialog>
|
||||
|
||||
<ha-adaptive-dialog
|
||||
.hass=${this._hass}
|
||||
.allowModeChange=${this._openDialog === "allow-mode-change"}
|
||||
header-title="Adaptive dialog with allow mode change"
|
||||
header-subtitle="Resize the window while this dialog is open"
|
||||
@@ -165,6 +176,7 @@ export class DemoHaAdaptiveDialog extends LitElement {
|
||||
</ha-adaptive-dialog>
|
||||
|
||||
<ha-adaptive-dialog
|
||||
.hass=${this._hass}
|
||||
.open=${this._openDialog === "actions"}
|
||||
header-title="Adaptive dialog with actions"
|
||||
header-subtitle="This is an adaptive dialog with header actions"
|
||||
@@ -383,6 +395,7 @@ export class DemoHaAdaptiveDialog extends LitElement {
|
||||
<h3>Example usage</h3>
|
||||
|
||||
<pre><code><ha-adaptive-dialog
|
||||
.hass=\${this.hass}
|
||||
open
|
||||
header-title="Dialog title"
|
||||
header-subtitle="Dialog subtitle"
|
||||
@@ -594,7 +607,7 @@ export class DemoHaAdaptiveDialog extends LitElement {
|
||||
|
||||
<p>Example:</p>
|
||||
|
||||
<pre><code><ha-adaptive-dialog open>
|
||||
<pre><code><ha-adaptive-dialog .hass=\${this.hass} open>
|
||||
<ha-form autofocus .schema=\${schema}></ha-form>
|
||||
</ha-adaptive-dialog></code></pre>
|
||||
</div>
|
||||
|
||||
@@ -9,6 +9,7 @@ import "../../../../src/components/ha-form/ha-form";
|
||||
import "../../../../src/components/ha-icon-button";
|
||||
import type { HASSDomCurrentTargetEvent } from "../../../../src/common/dom/fire_event";
|
||||
import { provideHass } from "../../../../src/fake_data/provide_hass";
|
||||
import type { HomeAssistant } from "../../../../src/types";
|
||||
import type { HaFormSchema } from "../../../../src/components/ha-form/types";
|
||||
|
||||
const SCHEMA: HaFormSchema[] = [
|
||||
@@ -22,10 +23,12 @@ type PopoverType = false | "basic" | "small" | "large" | "form" | "actions";
|
||||
export class DemoHaAdaptivePopover extends LitElement {
|
||||
@state() private _openPopover: PopoverType = false;
|
||||
|
||||
@state() private _hass?: HomeAssistant;
|
||||
|
||||
@state() private _dialogAnchor?: HTMLElement;
|
||||
|
||||
protected firstUpdated() {
|
||||
provideHass(this);
|
||||
this._hass = provideHass(this);
|
||||
}
|
||||
|
||||
protected render() {
|
||||
@@ -69,6 +72,7 @@ export class DemoHaAdaptivePopover extends LitElement {
|
||||
</ha-card>
|
||||
|
||||
<ha-adaptive-popover
|
||||
.hass=${this._hass}
|
||||
.open=${this._openPopover === "basic"}
|
||||
.dialogAnchor=${this._dialogAnchor}
|
||||
header-title="Basic adaptive popover"
|
||||
@@ -82,6 +86,7 @@ export class DemoHaAdaptivePopover extends LitElement {
|
||||
</ha-adaptive-popover>
|
||||
|
||||
<ha-adaptive-popover
|
||||
.hass=${this._hass}
|
||||
.open=${this._openPopover === "small"}
|
||||
.dialogAnchor=${this._dialogAnchor}
|
||||
width="small"
|
||||
@@ -92,6 +97,7 @@ export class DemoHaAdaptivePopover extends LitElement {
|
||||
</ha-adaptive-popover>
|
||||
|
||||
<ha-adaptive-popover
|
||||
.hass=${this._hass}
|
||||
.open=${this._openPopover === "large"}
|
||||
.dialogAnchor=${this._dialogAnchor}
|
||||
width="large"
|
||||
@@ -102,6 +108,7 @@ export class DemoHaAdaptivePopover extends LitElement {
|
||||
</ha-adaptive-popover>
|
||||
|
||||
<ha-adaptive-popover
|
||||
.hass=${this._hass}
|
||||
.open=${this._openPopover === "form"}
|
||||
.dialogAnchor=${this._dialogAnchor}
|
||||
header-title="Adaptive popover with form"
|
||||
@@ -126,6 +133,7 @@ export class DemoHaAdaptivePopover extends LitElement {
|
||||
</ha-adaptive-popover>
|
||||
|
||||
<ha-adaptive-popover
|
||||
.hass=${this._hass}
|
||||
.open=${this._openPopover === "actions"}
|
||||
.dialogAnchor=${this._dialogAnchor}
|
||||
header-title="Adaptive popover with actions"
|
||||
|
||||
@@ -51,12 +51,11 @@ The badge component is a small component that displays a number or status inform
|
||||
|
||||
**Properties/Attributes**
|
||||
|
||||
| Name | Type | Default | Description |
|
||||
| --------------- | ----------------------- | ----------- | ------------------------------------------------------------- |
|
||||
| type | `"badge"` or `"button"` | `"badge"` | If it's button it shows a ripple effect |
|
||||
| label | string | `undefined` | Text label for the badge, only visible if `iconOnly = false` |
|
||||
| iconOnly | boolean | `false` | Only show label |
|
||||
| accessibleLabel | string | `undefined` | Accessible name of the button, only used if `iconOnly = true` |
|
||||
| Name | Type | Default | Description |
|
||||
| -------- | ----------------------- | ----------- | ------------------------------------------------------------ |
|
||||
| type | `"badge"` or `"button"` | `"badge"` | If it's button it shows a ripple effect |
|
||||
| label | string | `undefined` | Text label for the badge, only visible if `iconOnly = false` |
|
||||
| iconOnly | boolean | `false` | Only show label |
|
||||
|
||||
**CSS Custom Properties**
|
||||
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
---
|
||||
title: Control scrubber
|
||||
---
|
||||
|
||||
A horizontal control where the value sits under a fixed window in the middle and the background strip moves. Drag the strip to change the value, or tap a point of the strip to bring it under the window.
|
||||
|
||||
Set `--control-scrubber-track-width` to make the strip wider than the control, so only a part of the range is visible at a time and each pixel of drag changes the value less. Set `wrap` for cyclic ranges such as hue, where the strip repeats and the value wraps around at the bounds. Set `show-handle` to draw the window, and leave it off when there is no current value to point at, such as a light that is off.
|
||||
@@ -1,96 +0,0 @@
|
||||
import type { TemplateResult } from "lit";
|
||||
import { css, html, LitElement } from "lit";
|
||||
import { customElement, state } from "lit/decorators";
|
||||
import "../../../../src/components/ha-card";
|
||||
import "../../../../src/components/ha-control-scrubber";
|
||||
|
||||
@customElement("demo-components-ha-control-scrubber")
|
||||
export class DemoHaControlScrubber extends LitElement {
|
||||
@state() private hue = 200;
|
||||
|
||||
@state() private position?: number;
|
||||
|
||||
handleHueChanged(e: CustomEvent) {
|
||||
this.hue = e.detail.value as number;
|
||||
}
|
||||
|
||||
handleMoved(e: CustomEvent) {
|
||||
this.position = e.detail.value as number;
|
||||
}
|
||||
|
||||
protected render(): TemplateResult {
|
||||
return html`
|
||||
<ha-card>
|
||||
<div class="card-content">
|
||||
<p><b>Scrubber values</b></p>
|
||||
<table>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>position</td>
|
||||
<td>${this.position ?? "-"}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>value</td>
|
||||
<td>${this.hue}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</ha-card>
|
||||
<ha-card>
|
||||
<div class="card-content">
|
||||
<label id="scrubber-hue">Hue (wrap)</label>
|
||||
<pre>Config: {"wrap":true,"min":0,"max":360}</pre>
|
||||
<ha-control-scrubber
|
||||
wrap
|
||||
show-handle
|
||||
min="0"
|
||||
max="360"
|
||||
unit="°"
|
||||
.value=${this.hue}
|
||||
@value-changed=${this.handleHueChanged}
|
||||
@slider-moved=${this.handleMoved}
|
||||
label="Hue"
|
||||
>
|
||||
</ha-control-scrubber>
|
||||
</div>
|
||||
</ha-card>
|
||||
`;
|
||||
}
|
||||
|
||||
static styles = css`
|
||||
ha-card {
|
||||
max-width: 600px;
|
||||
margin: 24px auto;
|
||||
}
|
||||
pre {
|
||||
margin-top: 0;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
p {
|
||||
margin: 0;
|
||||
}
|
||||
label {
|
||||
font-weight: var(--ha-font-weight-bold);
|
||||
}
|
||||
ha-control-scrubber {
|
||||
--control-scrubber-track-width: 200%;
|
||||
--control-scrubber-background: linear-gradient(
|
||||
to right,
|
||||
hsl(0 100% 50%),
|
||||
hsl(60 100% 50%),
|
||||
hsl(120 100% 50%),
|
||||
hsl(180 100% 50%),
|
||||
hsl(240 100% 50%),
|
||||
hsl(300 100% 50%),
|
||||
hsl(360 100% 50%)
|
||||
);
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"demo-components-ha-control-scrubber": DemoHaControlScrubber;
|
||||
}
|
||||
}
|
||||
@@ -17,18 +17,12 @@ A styled container with roving-tabindex keyboard navigation. Host role is
|
||||
Home/End jump to the first/last enabled item; Enter/Space activates the
|
||||
focused item.
|
||||
|
||||
With `virtual-focus`, DOM focus stays outside the list, for example in the
|
||||
search field of a picker. The list then ignores key presses. The element that
|
||||
owns the focus calls `moveActiveItem()`, and the list marks the row it points at
|
||||
with the `active` attribute and scrolls it into view instead of focusing it.
|
||||
|
||||
**Attributes**
|
||||
|
||||
| Name | Type | Default | Description |
|
||||
| --------------- | ------- | ------- | ---------------------------------------------------------------- |
|
||||
| `wrap-focus` | Boolean | `false` | Arrow keys wrap past the ends. |
|
||||
| `virtual-focus` | Boolean | `false` | Keep DOM focus outside the list and mark the active row instead. |
|
||||
| `aria-label` | String | — | Accessible name. |
|
||||
| Name | Type | Default | Description |
|
||||
| ------------ | ------- | ------- | ------------------------------ |
|
||||
| `wrap-focus` | Boolean | `false` | Arrow keys wrap past the ends. |
|
||||
| `aria-label` | String | — | Accessible name. |
|
||||
|
||||
**Events**
|
||||
|
||||
@@ -42,10 +36,6 @@ with the `active` attribute and scrolls it into view instead of focusing it.
|
||||
- `getActiveItemIndex()` — current active index, or `-1`.
|
||||
- `setActiveItemIndex(index, focusItem?)` — move the active index without
|
||||
necessarily focusing.
|
||||
- `moveActiveItem(target)` — move the active row to `"next"`, `"previous"`,
|
||||
`"first"`, `"last"`, `"next-page"` or `"previous-page"`, skipping rows that
|
||||
are not interactive or disabled. Meant for `virtual-focus`.
|
||||
- `clearActiveItem()` — clear the active row.
|
||||
- `updateListItems()` — re-discover slotted items (called automatically on
|
||||
slotchange).
|
||||
|
||||
@@ -144,8 +134,6 @@ Non-interactive list row. Host role is `listitem`. Inherits everything from
|
||||
- `interactive` (Boolean, default `false`) — opt this row into the parent
|
||||
list's roving tabindex. Useful for sortable rows that need keyboard focus
|
||||
but no click action. Interactive subclasses set this automatically.
|
||||
- `active` (Boolean) — set by a parent list with `virtual-focus` on the row
|
||||
that keyboard navigation points at.
|
||||
|
||||
**CSS custom properties**
|
||||
|
||||
@@ -155,8 +143,6 @@ Non-interactive list row. Host role is `listitem`. Inherits everything from
|
||||
the focus-in animation.
|
||||
- `--ha-list-item-focus-offset` — focus outline offset.
|
||||
- `--ha-list-item-focus-background` — background color on keyboard focus.
|
||||
- `--ha-list-item-active-background` — background color of the `active` row.
|
||||
Defaults to `--ha-list-item-focus-background`.
|
||||
|
||||
### `<ha-list-item-button>`
|
||||
|
||||
|
||||
@@ -1,91 +0,0 @@
|
||||
---
|
||||
title: Picker combo box
|
||||
---
|
||||
|
||||
# Picker combo box `<ha-picker-combo-box>`
|
||||
|
||||
The searchable list used inside pickers. `ha-generic-picker` renders it in a
|
||||
popover or dialog; entity, area, floor, label and device pickers all reach it
|
||||
that way. It is shown directly here so the list and its keyboard model can be
|
||||
inspected on their own.
|
||||
|
||||
It takes no `hass`. Items come from a `getItems` callback and locale comes from
|
||||
`internationalizationContext`.
|
||||
|
||||
## Two render paths
|
||||
|
||||
The item count picks the renderer, and the two behave differently enough that
|
||||
list changes need checking against both.
|
||||
|
||||
| Items | Renderer | Rows in the DOM |
|
||||
| ------------------------------------------- | ----------------- | --------------------- |
|
||||
| 12 or fewer rendered rows, no sections | `repeat()` | all of them |
|
||||
| more than 12 rendered rows, or any sections | `lit-virtualizer` | only the visible ones |
|
||||
|
||||
The threshold is `MAX_PLAIN_LIST_ITEMS`, counted against the initial rendered
|
||||
row set, including additional items and dialog padding. Filtering does not switch renderers.
|
||||
|
||||
## Two highlights
|
||||
|
||||
The list has two row backgrounds.
|
||||
|
||||
- **`selected`** — the row matching the `value` property, rendered onto its
|
||||
`ha-list-item-option`.
|
||||
- **`active`** — the keyboard cursor, and the row <kbd>Enter</kbd> picks. The
|
||||
list runs in `virtual-focus` mode: DOM focus stays in the search field, and
|
||||
the list owns the active row, skips disabled rows and section titles, and
|
||||
scrolls it into view.
|
||||
|
||||
## Keyboard
|
||||
|
||||
| Key | Behavior |
|
||||
| ------------------------------------- | ----------------------------------------------------- |
|
||||
| <kbd>↑</kbd> <kbd>↓</kbd> | Move the cursor |
|
||||
| <kbd>Home</kbd> <kbd>End</kbd> | Jump to the first or last item |
|
||||
| <kbd>PageUp</kbd> <kbd>PageDown</kbd> | Move the cursor a page |
|
||||
| <kbd>Enter</kbd> | Pick the row under the cursor |
|
||||
| <kbd>Ctrl/⌘</kbd> + <kbd>Enter</kbd> | Pick it and report `newTab: true` in `index-selected` |
|
||||
|
||||
With no cursor yet, <kbd>↓</kbd> and <kbd>Enter</kbd> start from the `value`
|
||||
row, or from the top match while searching. With one item in the list,
|
||||
<kbd>Enter</kbd> picks it whether or not the cursor has moved.
|
||||
|
||||
## Implementation
|
||||
|
||||
Without `sections`, the component filters the items from `getItems` itself as the
|
||||
user types, so `getItems` can ignore its search argument. With sections,
|
||||
`getItems` receives the search string and selected section and must return the
|
||||
matching rows.
|
||||
|
||||
The host is `display: flex` with `flex: 1` and expects a parent with a bounded
|
||||
height. In the app that bound comes from the popover; embedding it directly
|
||||
means supplying one.
|
||||
|
||||
### Properties/Attributes
|
||||
|
||||
| Name | Type | Default | Description |
|
||||
| ------------------ | ------------------ | --------- | --------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| getItems | Function | - | Required. Returns the items, optionally filtered by search string and section. |
|
||||
| getAdditionalItems | Function | - | Extra items appended to search results, for example "add new". |
|
||||
| value | String | - | Id of the current item. Marks its row `selected`, and the virtualized list opens with that row in view. |
|
||||
| label | String | - | Placeholder for the search field. Falls back to a localized "Search". |
|
||||
| mode | "popover"/"dialog" | "popover" | Adjusts padding for the surface the list sits in. |
|
||||
| shown | Boolean | true | Whether the surface finished animating. `ha-generic-picker` sets it so the virtualizer does not measure rows mid-animation. |
|
||||
| sections | Array | - | Filter chips. Section headers are plain strings returned by `getItems`, and the sticky title shows the one above the first visible row. |
|
||||
| selectedSection | String | - | Section filter selected when the list is initialized. |
|
||||
| searchKeys | Array | - | Fuse weighted keys for fuzzy search. |
|
||||
| searchFn | Function | - | Post-processes filtered results. |
|
||||
| rowRenderer | Function | - | Replaces the default row template. |
|
||||
| allowCustomValue | Boolean | false | Offers the typed string as an item. |
|
||||
| customValueLabel | String | - | Label for that custom item. |
|
||||
| notFoundLabel | String/Function | - | Shown when a search matches nothing. |
|
||||
| emptyLabel | String | - | Shown when there are no items at all. |
|
||||
| noSort | Boolean | false | Keeps the order `getItems` returned. |
|
||||
| clearable | Boolean | false | Adjusts search field padding for a clear affordance. |
|
||||
|
||||
### Events
|
||||
|
||||
| Event | Detail | Description |
|
||||
| ---------------- | ------------------------- | -------------------------------------------------------------- |
|
||||
| `value-changed` | `{ value }` | An item was picked. |
|
||||
| `index-selected` | `{ index, item, newTab }` | Which row was picked, and whether it should open in a new tab. |
|
||||
Loaded 100 of 1160 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user