mirror of
https://github.com/home-assistant/frontend.git
synced 2026-08-12 01:22:30 +00:00
Compare commits
152
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dc611372c9 | ||
|
|
b3a3e79946 | ||
|
|
e0c08e0a1c | ||
|
|
74192c65ef | ||
|
|
020887d24e | ||
|
|
cb8aaf0092 | ||
|
|
a2e9c47ed1 | ||
|
|
a958363ee0 | ||
|
|
c738672b86 | ||
|
|
5eff00f7a5 | ||
|
|
bca924e019 | ||
|
|
214afa3693 | ||
|
|
da4b885fdb | ||
|
|
a1370e331f | ||
|
|
75e862540f | ||
|
|
fd0fdb37fe | ||
|
|
4893e65841 | ||
|
|
38946ad69d | ||
|
|
c0b296a5a2 | ||
|
|
8b9cf84ee8 | ||
|
|
c61622de43 | ||
|
|
affce99c9b | ||
|
|
22993a9d0a | ||
|
|
89b7ff694d | ||
|
|
5a7c2ac175 | ||
|
|
6e007c8c9a | ||
|
|
a0969d0b08 | ||
|
|
070251f360 | ||
|
|
11d38fcd6a | ||
|
|
ce6da0c03d | ||
|
|
011af7c8ea | ||
|
|
94aa373387 | ||
|
|
c5bfbd7478 | ||
|
|
72fc9c7278 | ||
|
|
79cf8b0f04 | ||
|
|
4a8fca8bb8 | ||
|
|
6a18686b1c | ||
|
|
f456bd7dc6 | ||
|
|
78a2d4d6df | ||
|
|
0f8d7b4d41 | ||
|
|
cf3009f408 | ||
|
|
bdf629f836 | ||
|
|
464b58af04 | ||
|
|
29a75209ac | ||
|
|
f89b3c23c7 | ||
|
|
29b631b960 | ||
|
|
dd94a7bac1 | ||
|
|
34154195c8 | ||
|
|
0b34fcb559 | ||
|
|
ea5a1bca99 | ||
|
|
7ae5dafc95 | ||
|
|
52cc65cbfc | ||
|
|
f36330274d | ||
|
|
a4912d0706 | ||
|
|
3900a804f0 | ||
|
|
cd5dfdb86f | ||
|
|
a602865117 | ||
|
|
f42a5012a9 | ||
|
|
90f5c7a349 | ||
|
|
d03ba15c09 | ||
|
|
7143acc860 | ||
|
|
c9c46d4507 | ||
|
|
5916775745 | ||
|
|
52ee78d8a2 | ||
|
|
146a089044 | ||
|
|
8c566b43b6 | ||
|
|
1089c5d1c5 | ||
|
|
2894113033 | ||
|
|
b9bbef3bfc | ||
|
|
706382cb68 | ||
|
|
cc17921c22 | ||
|
|
b5ef563b71 | ||
|
|
1b6e150def | ||
|
|
939b7012e2 | ||
|
|
488d3d9b37 | ||
|
|
08dedc415f | ||
|
|
400bf78ce0 | ||
|
|
9b2093125e | ||
|
|
c0d963747a | ||
|
|
1bcd43fb75 | ||
|
|
ce5925388c | ||
|
|
088f72fcc4 | ||
|
|
b93f09ffb2 | ||
|
|
fb499093e5 | ||
|
|
c97716d4ad | ||
|
|
b36361ca34 | ||
|
|
007a7916c0 | ||
|
|
637f6955a8 | ||
|
|
b574aaa084 | ||
|
|
c1dca9b377 | ||
|
|
c9963b7f92 | ||
|
|
42954152dc | ||
|
|
f5a28977c2 | ||
|
|
9b1f407ff4 | ||
|
|
277822e51c | ||
|
|
4a2bca6d76 | ||
|
|
8e47b5369e | ||
|
|
759ab155c7 | ||
|
|
8de939a696 | ||
|
|
5c3d74502b | ||
|
|
0bf67b603c | ||
|
|
01915f2e5a | ||
|
|
cee1c91ed8 | ||
|
|
954662f454 | ||
|
|
c07fb2b7ec | ||
|
|
041219a40c | ||
|
|
f28a1c5370 | ||
|
|
4099de96c3 | ||
|
|
673ca45cde | ||
|
|
4dfc626363 | ||
|
|
1206f0ae5d | ||
|
|
f71a938d02 | ||
|
|
e2cade842e | ||
|
|
e6df5fd159 | ||
|
|
0da3ec1151 | ||
|
|
46fdf9c91b | ||
|
|
80a026d9bf | ||
|
|
dc5699fec3 | ||
|
|
3a1d330a2f | ||
|
|
c8a29ae2f2 | ||
|
|
b1ccb6355d | ||
|
|
5f2086765a | ||
|
|
02c51a2789 | ||
|
|
706421f3c5 | ||
|
|
cbc2e34638 | ||
|
|
0bc0b08978 | ||
|
|
48394db843 | ||
|
|
83dac08059 | ||
|
|
3c6b70ded8 | ||
|
|
1e414f31dc | ||
|
|
d013051f81 | ||
|
|
eb2efbb669 | ||
|
|
7e4d745b21 | ||
|
|
d01f7d98a5 | ||
|
|
6df6b556bd | ||
|
|
f5c6420fbc | ||
|
|
71ec76185f | ||
|
|
f08ab2f331 | ||
|
|
77b0b9b5b8 | ||
|
|
4fcc1adf3a | ||
|
|
9699851c26 | ||
|
|
a318593c9f | ||
|
|
749dd180ae | ||
|
|
247780e4c8 | ||
|
|
7991090313 | ||
|
|
2efa0bd86d | ||
|
|
0fad4f0097 | ||
|
|
2b90c9a628 | ||
|
|
f3848df19d | ||
|
|
e2a91c358d | ||
|
|
8793cb58b9 | ||
|
|
2442bf662a |
@@ -7,6 +7,8 @@ description: Home Assistant frontend component patterns. Use when implementing o
|
||||
|
||||
Use this skill when creating or reviewing Home Assistant UI components and common interaction patterns.
|
||||
|
||||
Cross-load `ha-frontend-events` when component work includes event listener typing, custom event dispatch, or event-map declarations.
|
||||
|
||||
## Dialogs
|
||||
|
||||
Open dialogs through the fire-event pattern:
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
---
|
||||
name: ha-frontend-events
|
||||
description: Home Assistant frontend event patterns. Use when typing event handlers, using HASSDomEvent types, dispatching with fireEvent, or declaring HASSDomEvents and event maps.
|
||||
---
|
||||
|
||||
# HA Frontend Events
|
||||
|
||||
Use this skill when implementing or reviewing event listeners and custom event contracts. Cross-load `ha-frontend-components` when the work also involves dialogs, forms, alerts, shortcuts, tooltips, panels, Lovelace cards, or buttons.
|
||||
|
||||
## Event Handling
|
||||
|
||||
Use the event types from `src/common/dom/fire_event.ts` instead of plain `Event`, generic `CustomEvent`, or element casts when they express the handler contract:
|
||||
|
||||
- Use `HASSDomCurrentTargetEvent<T>` to read the element on which the listener was registered through `ev.currentTarget`.
|
||||
- Use `HASSDomTargetEvent<T>` only to read the element that originated the event through `ev.target`.
|
||||
- Use `HASSDomEvent<T>` to read a custom event payload through `ev.detail`.
|
||||
- Use `ValueChangedEvent<T>` from `src/types.ts` for the standard `value-changed` event.
|
||||
- Prefer an event type exported by the component being listened to, such as `HaSelectSelectEvent<T, Clearable>` or `HaDropdownSelectEvent<TValue, TData>`, over reconstructing its detail type.
|
||||
- Use `ActionHandlerEvent` from `src/data/lovelace/action_handler.ts` for Lovelace tap, hold, and double-tap handlers.
|
||||
|
||||
Import event and element types with `import type`:
|
||||
|
||||
```ts
|
||||
import type {
|
||||
HASSDomCurrentTargetEvent,
|
||||
HASSDomEvent,
|
||||
HASSDomTargetEvent,
|
||||
} from "../common/dom/fire_event";
|
||||
import type { HaCheckbox } from "../components/ha-checkbox";
|
||||
import type { HaEntityPicker } from "../components/entity/ha-entity-picker";
|
||||
import type { HaRadioGroup } from "../components/radio/ha-radio-group";
|
||||
import type { ValueChangedEvent } from "../types";
|
||||
```
|
||||
|
||||
Type the handler so the selected property can be read directly. Do not cast `ev.currentTarget` or assign it to a single-use variable:
|
||||
|
||||
```ts
|
||||
private _scopeChanged(ev: HASSDomCurrentTargetEvent<HaRadioGroup>): void {
|
||||
this._scope = ev.currentTarget.value;
|
||||
}
|
||||
|
||||
private _checkedChanged(ev: HASSDomTargetEvent<HaCheckbox>): void {
|
||||
this._checked = ev.target.checked;
|
||||
}
|
||||
|
||||
private _valueChanged(ev: ValueChangedEvent<string>): void {
|
||||
this._value = ev.detail.value;
|
||||
}
|
||||
|
||||
private _itemSelected(ev: HASSDomEvent<{ id: string }>): void {
|
||||
this._selectedId = ev.detail.id;
|
||||
}
|
||||
```
|
||||
|
||||
Use intersections when a handler needs more than one facet of an event. Keep the native event type when the handler reads native fields such as `key`, modifier keys, `dataTransfer`, or focus relationships:
|
||||
|
||||
```ts
|
||||
private _entityChanged(
|
||||
ev: ValueChangedEvent<string> & HASSDomCurrentTargetEvent<HaEntityPicker>
|
||||
): void {
|
||||
ev.currentTarget.value = ev.detail.value;
|
||||
}
|
||||
|
||||
private _keyDown(
|
||||
ev: KeyboardEvent & HASSDomCurrentTargetEvent<HTMLInputElement>
|
||||
): void {
|
||||
if (ev.key === "Enter") {
|
||||
this._submit(ev.currentTarget.value);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Dispatch Home Assistant component events with `fireEvent()` instead of constructing `Event` or `CustomEvent` directly. Register the event name and detail type by augmenting `HASSDomEvents`; use `undefined` when an event has no detail. `fireEvent()` constrains event names and supplied detail, and events bubble and cross shadow boundaries by default:
|
||||
|
||||
```ts
|
||||
fireEvent(this, "item-selected", { id: item.id });
|
||||
fireEvent(this, "refresh-requested");
|
||||
```
|
||||
|
||||
When an event is already registered, derive handler and listener types from its registration rather than repeating the payload shape:
|
||||
|
||||
```ts
|
||||
private _itemSelected(
|
||||
ev: HASSDomEvent<HASSDomEvents["item-selected"]>
|
||||
): void {
|
||||
this._selectedId = ev.detail.id;
|
||||
}
|
||||
```
|
||||
|
||||
`HASSDomEvents` types `fireEvent()` calls. Augment `HTMLElementEventMap` for typed listeners on HTML elements, or `GlobalEventHandlersEventMap` when the event is handled on global event targets.
|
||||
|
||||
In component files, prefer placing global event declarations after the class at the bottom of the file. Preserve the existing placement when editing established files; foundational type, helper, and mixin files commonly keep declarations near the top before their consumers.
|
||||
|
||||
```ts
|
||||
declare global {
|
||||
interface HASSDomEvents {
|
||||
"item-selected": { id: string };
|
||||
"refresh-requested": undefined;
|
||||
}
|
||||
|
||||
interface HTMLElementEventMap {
|
||||
"item-selected": HASSDomEvent<HASSDomEvents["item-selected"]>;
|
||||
}
|
||||
}
|
||||
```
|
||||
@@ -0,0 +1,39 @@
|
||||
---
|
||||
name: ha-frontend-lit
|
||||
description: Home Assistant frontend Lit conventions. Use when working with reactive properties, internal state, DOM queries, lifecycle methods, or render-derived state.
|
||||
---
|
||||
|
||||
# HA Frontend Lit
|
||||
|
||||
Use this skill when implementing or reviewing Lit component state, DOM access, lifecycle methods, or rendering behavior. Cross-load `ha-frontend-types` for Home Assistant data contracts, assertions, and lifecycle parameter types.
|
||||
|
||||
## Reactive Fields
|
||||
|
||||
This project currently uses Lit's TypeScript experimental decorators with `useDefineForClassFields: false`. Match existing declarations and do not introduce standard-decorator `accessor` syntax unless the project changes decorator mode.
|
||||
|
||||
- Use `@property()` for public reactive API and `@state()` for private reactive state.
|
||||
- Prefer inferred types for initialized reactive fields when inference preserves the intended type; annotate when widening or an external contract requires it.
|
||||
|
||||
## DOM Queries
|
||||
|
||||
Prefer Lit's `@query()` or `@queryAll()` decorators for fixed selectors in the component's render root.
|
||||
|
||||
- Type the decorated field with the narrowest useful DOM or component interface.
|
||||
- Keep the field optional when it may be absent at the point of access, including conditional rendering or pre-render lifecycle access.
|
||||
- Use a definite assignment assertion only when every call site runs after the node is guaranteed to exist.
|
||||
- The optional second argument to `@query()`, as in `@query("#target", true)`, caches the first query result. Use it only when later renders cannot replace the queried node.
|
||||
- Use a direct query when the selector is dynamic or the target is outside the component's render root. Before querying a child, consider whether the required value belongs in parent state or data flow.
|
||||
|
||||
## Render-Derived State
|
||||
|
||||
- Prefer render-local values for inexpensive structures used only by that render.
|
||||
- Assign a render-local value once when repeated evaluation is non-trivial or a local name improves clarity.
|
||||
- Keep purely presentational derivations in `render()`. Use stored state or `willUpdate()` when the value must participate in lifecycle work, reflection, CSS, or non-render consumers.
|
||||
- Use `memoizeOne` for pure, argument-derived transforms when stable input identity avoids meaningful repeated work. Keep inputs explicit and limited, and do not add caching without a credible benefit over computing the value directly.
|
||||
|
||||
## References
|
||||
|
||||
- [Reactive properties](https://lit.dev/docs/components/properties/)
|
||||
- [Decorators](https://lit.dev/docs/components/decorators/)
|
||||
- [Shadow DOM queries](https://lit.dev/docs/components/shadow-dom/#query)
|
||||
- [Reactive update cycle](https://lit.dev/docs/components/lifecycle/)
|
||||
@@ -31,6 +31,33 @@ When creating a pull request, use `.github/PULL_REQUEST_TEMPLATE.md` as the body
|
||||
|
||||
## Recurring Review Issues
|
||||
|
||||
Scope and public surface:
|
||||
|
||||
- Keep changes independently reviewable and limited to the requested area.
|
||||
- Prefer existing Home Assistant helpers, Lit primitives, and component seams over parallel implementations.
|
||||
- Challenge new public properties and optional feature surface when transient options or existing seams meet the requirement with less lifecycle and consistency cost.
|
||||
|
||||
Stateful and asynchronous UI:
|
||||
|
||||
- Review transitions in both directions, not only individual rendered states.
|
||||
- When controls reappear, restore valid defaults instead of retaining state that was only valid while they were hidden.
|
||||
- Establish immutable dirty-state baselines before asynchronous work, guard against stale responses, and preserve unsaved state in mounted editors.
|
||||
- Determine an action's current meaning before applying dirty-state checks, especially when an action can change between Save and Close.
|
||||
|
||||
Readiness and invalidation:
|
||||
|
||||
- Treat readiness as the first displayable terminal result, including stable empty and error states.
|
||||
- Register child readiness before resolving the parent, do not treat fallback work as terminal, and replay readiness correctly for cached or reused panels.
|
||||
- Ensure every value read by memoized output participates in its invalidation.
|
||||
|
||||
Repository-owned contracts:
|
||||
|
||||
- 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.
|
||||
- Promote AI-review concerns into durable guidance only when supported by code evidence, reproduced behavior, an accepted corrective commit, or human-maintainer validation.
|
||||
|
||||
User experience and accessibility:
|
||||
|
||||
- Forms need proper labels, helper text, and validation feedback.
|
||||
@@ -70,4 +97,4 @@ Configuration and props:
|
||||
- Identify behavioral regressions, bugs, accessibility issues, and missing tests first.
|
||||
- Keep style-only comments secondary unless they affect maintainability or user experience.
|
||||
- Prefer small, direct fixes over large refactors during review follow-up.
|
||||
- Cross-load `ha-frontend-contexts`, `ha-frontend-components`, `ha-frontend-styling`, `ha-frontend-testing`, or `ha-frontend-user-facing-text` when a finding falls in that area.
|
||||
- Load the matching `ha-frontend-*` skill when a finding falls within its area.
|
||||
|
||||
@@ -20,6 +20,7 @@ yarn lint # ESLint + Prettier + TypeScript + Lit
|
||||
yarn format # Auto-fix ESLint + Prettier
|
||||
yarn lint:types # TypeScript compiler, run without file arguments
|
||||
yarn test # Vitest
|
||||
yarn build # Full production build
|
||||
yarn dev # App dev server
|
||||
yarn dev:serve # Local serving dev server
|
||||
```
|
||||
@@ -28,6 +29,26 @@ Never run `tsc` or `yarn lint:types` with file arguments. File arguments make `t
|
||||
|
||||
For focused type feedback on one file, use editor diagnostics instead of a file-scoped `tsc` command.
|
||||
|
||||
## Production Builds
|
||||
|
||||
Production builds support foreground and managed background execution:
|
||||
|
||||
```bash
|
||||
yarn build # Full foreground build
|
||||
yarn build --background # Full managed background build
|
||||
yarn build --modern # Modern frontend_latest bundle only
|
||||
yarn build --modern --background # Modern managed background build
|
||||
yarn build --status
|
||||
yarn build --logs [--follow]
|
||||
yarn build --stop
|
||||
```
|
||||
|
||||
Use `yarn build --modern --background` for production bundle-size or browser performance comparisons that only need modern browser output. It runs the normal metadata and static preparation, minifies and compresses the modern `frontend_latest` bundle and shared static assets, and generates modern-only entry pages and service workers. It deliberately skips the legacy bundle and its service worker.
|
||||
|
||||
Do not pass `--help`, `--background`, or `--modern` to `script/build_frontend`; that raw script does not parse arguments and always starts the full foreground build. Use `yarn build` for managed builds. App builds and development servers keep exclusive ownership of `hass_frontend/` for their lifetime.
|
||||
|
||||
Managed app, demo, gallery, and E2E app workflows share one lifetime lock, so only one build or development server can run at a time.
|
||||
|
||||
## Unit And Utility Tests
|
||||
|
||||
- Add or update Vitest tests for data processing, utility code, and behavior that can be tested without a browser.
|
||||
@@ -41,7 +62,7 @@ For focused type feedback on one file, use editor diagnostics instead of a file-
|
||||
|
||||
`yarn dev:serve` also serves locally and supports `-c` for the core URL and `-p` for the port. The default is 8124, or 8123 in a devcontainer.
|
||||
|
||||
Dev server commands support `--background`, `--status`, `--stop`, and `--logs [--follow]`. Prefer managed background mode while iterating so the watcher stays available across test runs without occupying the terminal.
|
||||
Dev server commands support `--background`, `--status`, `--stop`, and `--logs [--follow]`. `yarn dev`, `yarn dev:serve`, `yarn dev:demo`, and `yarn dev:gallery` also support `--fetch-translations`; this runs translation fetching, including first-time GitHub device authentication, under the workflow lock before starting the watcher. It works in foreground and background modes. Prefer managed background mode while iterating so the watcher stays available across test runs without occupying the terminal. `yarn dev` and `yarn dev:serve` share one managed process slot because both write the app output.
|
||||
|
||||
## Playwright E2E
|
||||
|
||||
@@ -59,7 +80,7 @@ The custom development wrappers use `/__ha_dev_status` to identify and manage th
|
||||
|
||||
Local runs against a watched development server do not always match CI's clean build artifacts, environment, sharding, or worker configuration. Use background servers for the fast iteration loop, but confirm the relevant CI jobs complete successfully before considering E2E changes verified.
|
||||
|
||||
Use `-g "<title>" --project=chromium` to narrow a run. `yarn test:e2e` runs all three suites in parallel when every managed server is available, otherwise it runs them sequentially to prevent cold builds racing over shared generated assets. Run suites directly; piping through output truncation hides progress and failures.
|
||||
Use `-g "<title>" --project=chromium` to narrow a run. `yarn test:e2e` runs suites sequentially when managed servers are unavailable to prevent cold builds racing over shared generated assets. Run suites directly; piping through output truncation hides progress and failures.
|
||||
|
||||
The app suite uses a stripped-down harness for e2e. Demo and gallery use their normal dev servers.
|
||||
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
---
|
||||
name: ha-frontend-types
|
||||
description: Home Assistant frontend TypeScript conventions. Use when defining or reviewing backend data contracts, optional schemas, shared types, assertions, or Lit lifecycle types.
|
||||
---
|
||||
|
||||
# HA Frontend Types
|
||||
|
||||
Use this skill for Home Assistant-specific TypeScript contracts and type choices.
|
||||
|
||||
## Home Assistant Data Contracts
|
||||
|
||||
Verify data contracts against the source that owns them, such as Home Assistant Core, Supervisor, a WebSocket handler, or an exported component type. Do not shape a type around assumptions made by its current frontend consumers.
|
||||
|
||||
- Match required, optional, nullable, and defaulted fields to the producer and runtime contract. Preserve optional configuration fields when omission is supported and has defined behavior.
|
||||
- Use distinct request and response types when their wire shapes differ.
|
||||
- When changing a shared contract, check affected consumers, tests, fixtures, and mocks.
|
||||
|
||||
## Reuse Home Assistant Contracts
|
||||
|
||||
- Reuse the canonical Home Assistant type when one exists. Define shared contract types in the data or API module that owns them.
|
||||
- Reuse types exported by components and helpers rather than reconstructing their payloads. For event types, follow `ha-frontend-events`.
|
||||
- When one domain contract is used across modules, define and export it from the module that owns that contract.
|
||||
|
||||
Prefer an existing owning contract. Introduce a frontend-specific type when the frontend shape or boundary genuinely differs.
|
||||
|
||||
## Assertions
|
||||
|
||||
Prefer accurate types and runtime narrowing. Use assertions or TypeScript suppressions at boundaries where the runtime invariant is understood but cannot be expressed cleanly; keep them narrow and explain non-obvious invariants.
|
||||
|
||||
## Lit Lifecycle Types
|
||||
|
||||
For Lit lifecycle methods that receive changed properties, use `PropertyValues<this>` when the method only needs public reactive properties:
|
||||
|
||||
```ts
|
||||
protected willUpdate(changedProperties: PropertyValues<this>) {
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
Use unparameterized `PropertyValues` when the method inspects private or protected reactive properties, which are not keys of `this`. Do not add assertions solely to retain `PropertyValues<this>`.
|
||||
|
||||
## Enforced Baseline
|
||||
|
||||
Use `import type` for type-only imports. This is enforced by the repository ESLint configuration.
|
||||
@@ -32,12 +32,12 @@ jobs:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@7188fc363630916deb702c7fdcf4e481b751f97a # v4.37.1
|
||||
uses: github/codeql-action/init@f205ea1c3313d32999d8d6a48b4f6530d4437b38 # v4.37.4
|
||||
with:
|
||||
languages: javascript-typescript
|
||||
build-mode: none
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@7188fc363630916deb702c7fdcf4e481b751f97a # v4.37.1
|
||||
uses: github/codeql-action/analyze@f205ea1c3313d32999d8d6a48b4f6530d4437b38 # v4.37.4
|
||||
with:
|
||||
category: "/language:javascript-typescript"
|
||||
|
||||
@@ -38,7 +38,7 @@ jobs:
|
||||
name: Prepare container dependencies
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: mcr.microsoft.com/playwright:v1.62.0-noble
|
||||
image: mcr.microsoft.com/playwright:v1.62.1-noble
|
||||
options: --user 1001
|
||||
defaults:
|
||||
run:
|
||||
@@ -154,7 +154,7 @@ jobs:
|
||||
- prepare-container-dependencies
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: mcr.microsoft.com/playwright:v1.62.0-noble
|
||||
image: mcr.microsoft.com/playwright:v1.62.1-noble
|
||||
options: --user 1001 --ipc=host
|
||||
defaults:
|
||||
run:
|
||||
@@ -206,7 +206,7 @@ jobs:
|
||||
- prepare-container-dependencies
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: mcr.microsoft.com/playwright:v1.62.0-noble
|
||||
image: mcr.microsoft.com/playwright:v1.62.1-noble
|
||||
options: --user 1001 --ipc=host
|
||||
defaults:
|
||||
run:
|
||||
@@ -260,7 +260,7 @@ jobs:
|
||||
- prepare-container-dependencies
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: mcr.microsoft.com/playwright:v1.62.0-noble
|
||||
image: mcr.microsoft.com/playwright:v1.62.1-noble
|
||||
options: --user 1001 --ipc=host
|
||||
defaults:
|
||||
run:
|
||||
|
||||
@@ -42,7 +42,20 @@ jobs:
|
||||
- name: Bump version
|
||||
run: script/version_bump.js nightly
|
||||
|
||||
# Warm the shared compression cache so releases reuse it (see release.yaml).
|
||||
- name: Restore compression cache
|
||||
id: compress-cache
|
||||
continue-on-error: true
|
||||
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
with:
|
||||
path: .compress-cache
|
||||
key: compress-cache-${{ runner.os }}-${{ github.run_id }}-${{ github.run_attempt }}
|
||||
restore-keys: |
|
||||
compress-cache-${{ runner.os }}-
|
||||
|
||||
- name: Build nightly Python wheels
|
||||
env:
|
||||
COMPRESS_CACHE_DIR: ${{ github.workspace }}/.compress-cache
|
||||
run: |
|
||||
pip install build
|
||||
yarn install
|
||||
@@ -51,6 +64,16 @@ jobs:
|
||||
rm -rf dist home_assistant_frontend.egg-info
|
||||
python3 -m build
|
||||
|
||||
# Not gated on the restore step: a transient restore failure (it is
|
||||
# continue-on-error) must not stop us persisting a freshly built cache.
|
||||
- name: Save compression cache
|
||||
if: success()
|
||||
continue-on-error: true
|
||||
uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
with:
|
||||
path: .compress-cache
|
||||
key: compress-cache-${{ runner.os }}-${{ github.run_id }}-${{ github.run_attempt }}
|
||||
|
||||
- name: Archive translations
|
||||
run: tar -czvf translations.tar.gz translations
|
||||
|
||||
|
||||
@@ -18,6 +18,6 @@ jobs:
|
||||
pull-requests: read
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: release-drafter/release-drafter@eada3c96a64734dd381cfbda23511034e328ddb0 # v7.6.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@e3fb68ebda13d88a0d695082f471ba2c83d025fb # master
|
||||
uses: home-assistant/actions/helpers/verify-version@a7c616ce81ccda50150bf1595786c71b1883fabb # master
|
||||
|
||||
- name: Setup Node and install
|
||||
uses: ./.github/actions/setup
|
||||
@@ -48,15 +48,44 @@ jobs:
|
||||
run: ./script/translations_download
|
||||
env:
|
||||
LOKALISE_TOKEN: ${{ secrets.LOKALISE_TOKEN }}
|
||||
# The app fetches core (backend) translations live from HA, so the
|
||||
# release build does not need Lokalise's backend project.
|
||||
SKIP_BACKEND_TRANSLATIONS: "1"
|
||||
|
||||
# Restore the content-addressed compression cache. Unchanged chunks and
|
||||
# static assets are then reused instead of re-run through brotli/zopfli.
|
||||
- name: Restore compression cache
|
||||
id: compress-cache
|
||||
continue-on-error: true
|
||||
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
with:
|
||||
path: .compress-cache
|
||||
key: compress-cache-${{ runner.os }}-${{ github.run_id }}-${{ github.run_attempt }}
|
||||
restore-keys: |
|
||||
compress-cache-${{ runner.os }}-
|
||||
|
||||
- name: Build and release package
|
||||
env:
|
||||
COMPRESS_CACHE_DIR: ${{ github.workspace }}/.compress-cache
|
||||
run: |
|
||||
python3 -m pip install build
|
||||
export SKIP_FETCH_NIGHTLY_TRANSLATIONS=1
|
||||
script/release
|
||||
|
||||
# The build keeps the cache under its size budget, so saving stays bounded.
|
||||
# A unique key always writes; restore-keys picks the newest on the next
|
||||
# run. Not gated on the restore step: a transient restore failure (it is
|
||||
# continue-on-error) must not stop us persisting a freshly built cache.
|
||||
- name: Save compression cache
|
||||
if: success()
|
||||
continue-on-error: true
|
||||
uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
with:
|
||||
path: .compress-cache
|
||||
key: compress-cache-${{ runner.os }}-${{ github.run_id }}-${{ github.run_attempt }}
|
||||
|
||||
- name: Publish to PyPI
|
||||
uses: pypa/gh-action-pypi-publish@ba38be9e461d3875417946c167d0b5f3d385a247 # v1.14.1
|
||||
uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # v1.14.2
|
||||
with:
|
||||
skip-existing: true
|
||||
|
||||
@@ -76,19 +105,21 @@ jobs:
|
||||
GITHUB_REF: ${{ github.ref }}
|
||||
run: |
|
||||
version=$(echo "$GITHUB_REF" | awk -F"/" '{print $NF}' )
|
||||
# Wait for the package to become available on PyPI
|
||||
echo "Waiting for home-assistant-frontend==$version to appear on PyPI..."
|
||||
# Wait for the exact wheel to appear on the simple index (the surface
|
||||
# the wheels build's pip uses). The JSON API can report a version as
|
||||
# available before it propagates here, which fails the wheels build.
|
||||
wheel="home_assistant_frontend-${version}-py3-none-any.whl"
|
||||
echo "Waiting for $wheel to appear on the PyPI simple index..."
|
||||
for i in $(seq 1 30); do
|
||||
status=$(curl -s -o /dev/null -w "%{http_code}" "https://pypi.org/pypi/home-assistant-frontend/$version/json")
|
||||
if [ "$status" = "200" ]; then
|
||||
echo "Package is available on PyPI!"
|
||||
if curl -sf "https://pypi.org/simple/home-assistant-frontend/" | grep -qF "$wheel"; then
|
||||
echo "Package is available on the PyPI simple index!"
|
||||
break
|
||||
fi
|
||||
if [ "$i" = "30" ]; then
|
||||
echo "Timed out waiting for package to appear on PyPI"
|
||||
exit 1
|
||||
fi
|
||||
echo "Not available yet (HTTP $status), retrying in 30 seconds... ($i/30)"
|
||||
echo "Not available yet, retrying in 30 seconds... ($i/30)"
|
||||
sleep 30
|
||||
done
|
||||
echo "home-assistant-frontend==$version" > ./requirements.txt
|
||||
@@ -123,6 +154,8 @@ jobs:
|
||||
run: ./script/translations_download
|
||||
env:
|
||||
LOKALISE_TOKEN: ${{ secrets.LOKALISE_TOKEN }}
|
||||
# The landing-page build does not merge backend translations.
|
||||
SKIP_BACKEND_TRANSLATIONS: "1"
|
||||
- name: Build landing-page
|
||||
run: landing-page/script/build_landing_page
|
||||
- name: Tar folder
|
||||
|
||||
@@ -15,7 +15,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: 90 days stale policy
|
||||
uses: actions/stale@1e223db275d687790206a7acac4d1a11bd6fe629 # v10.4.0
|
||||
uses: actions/stale@4391f3da665fdf50b6810c1a66712fb9ba21aa93 # v11.0.0
|
||||
with:
|
||||
repo-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
days-before-stale: 90
|
||||
|
||||
@@ -6,6 +6,7 @@ build/
|
||||
dist/
|
||||
/hass_frontend/
|
||||
/translations/
|
||||
/.compress-cache/
|
||||
# Composite action source, not build output
|
||||
!/.github/actions/build/
|
||||
|
||||
|
||||
Vendored
-944
File diff suppressed because one or more lines are too long
+1000
File diff suppressed because one or more lines are too long
+1
-1
@@ -13,4 +13,4 @@ nodeLinker: node-modules
|
||||
|
||||
npmMinimalAgeGate: 3d
|
||||
|
||||
yarnPath: .yarn/releases/yarn-4.17.1.cjs
|
||||
yarnPath: .yarn/releases/yarn-4.18.0.cjs
|
||||
|
||||
@@ -40,6 +40,9 @@ Detailed guidance lives in project skills under `.agents/skills/`. Load the matc
|
||||
|
||||
- `ha-frontend-contexts`: Lit contexts, `hass` migration, and rerender-sensitive state access.
|
||||
- `ha-frontend-components`: dialogs, forms, alerts, shortcuts, tooltips, panels, and Lovelace cards.
|
||||
- `ha-frontend-events`: event handler typing, custom event dispatch, and event-map declarations.
|
||||
- `ha-frontend-types`: backend data contracts, optional schemas, shared types, assertions, and lifecycle types.
|
||||
- `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-user-facing-text`: localization, terminology, sentence case, and Home Assistant text style.
|
||||
|
||||
@@ -12,7 +12,7 @@ This is the repository for the official [Home Assistant](https://home-assistant.
|
||||
|
||||
- Initial setup: `script/setup`
|
||||
- Development: [Instructions](https://developers.home-assistant.io/docs/frontend/development/)
|
||||
- Production build: `script/build_frontend`
|
||||
- Production build: `yarn build`
|
||||
- Gallery: `cd gallery && script/develop_gallery`
|
||||
|
||||
## Frontend development
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
// Gulp transform that brotli-compresses files, several at a time.
|
||||
//
|
||||
// Drop-in replacement for gulp-brotli. zlib already does the work off the main
|
||||
// thread, but that plugin wraps it in through2, which waits for each file
|
||||
// before starting the next, so only one compression is ever in flight. The
|
||||
// compressed bytes are unchanged; only how many run at once differs.
|
||||
//
|
||||
// The real ceiling is libuv's threadpool, which zlib runs on. It sizes itself
|
||||
// from UV_THREADPOOL_SIZE before any JavaScript runs, so it can only be raised
|
||||
// from the environment, never from inside the build.
|
||||
|
||||
import { availableParallelism } from "node:os";
|
||||
import { buffer as readStream } from "node:stream/consumers";
|
||||
import { promisify } from "node:util";
|
||||
import { brotliCompress, constants } from "node:zlib";
|
||||
import { withCache } from "./compress-cache.mjs";
|
||||
import { ParallelTransform } from "./parallel-transform.mjs";
|
||||
|
||||
const EXTENSION = ".br";
|
||||
|
||||
const compress = promisify(brotliCompress);
|
||||
|
||||
/**
|
||||
* @param {object} [options]
|
||||
* @param {boolean} [options.skipLarger] Drop files that compression grows.
|
||||
* @param {object} [options.params] Brotli parameters, passed to zlib as-is.
|
||||
*/
|
||||
export default ({ skipLarger = false, params } = {}) => {
|
||||
// Isolate cache entries by anything that changes the output bytes: the brotli
|
||||
// quality, and the node major that produced them.
|
||||
const quality = params?.[constants.BROTLI_PARAM_QUALITY] ?? "default";
|
||||
const namespace = `brotli-q${quality}-node${process.versions.node.split(".")[0]}`;
|
||||
|
||||
return new ParallelTransform(availableParallelism(), async (file) => {
|
||||
if (file.isNull()) {
|
||||
return file;
|
||||
}
|
||||
if (file.isStream()) {
|
||||
file.contents = await readStream(file.contents);
|
||||
}
|
||||
|
||||
const compressed = await withCache(namespace, file.contents, async () => {
|
||||
const out = await compress(file.contents, { params });
|
||||
// Dropped rather than passed through, as gulp-brotli did: the
|
||||
// uncompressed file is already in the output directory.
|
||||
return skipLarger && out.length >= file.contents.length ? undefined : out;
|
||||
});
|
||||
if (compressed === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
file.contents = compressed;
|
||||
file.path += EXTENSION;
|
||||
return file;
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,311 @@
|
||||
// Manage a Home Assistant frontend production build with an agent-friendly
|
||||
// interface, matching build-scripts/dev-server.mjs.
|
||||
//
|
||||
// node build-scripts/build-manager.mjs [--modern] [mode]
|
||||
//
|
||||
// (no mode) Run in the foreground.
|
||||
// --background Start detached, print the pid, then exit and leave it
|
||||
// running.
|
||||
// --status Report whether a managed build is running.
|
||||
// --stop Stop a managed build.
|
||||
// --logs [--follow] Print (or follow) the background build log.
|
||||
//
|
||||
// --modern Build only the modern frontend_latest bundle.
|
||||
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import {
|
||||
LIFECYCLE_MODE_FLAGS,
|
||||
acquireProcessRecord,
|
||||
isProcessRecordAlive,
|
||||
outputLog,
|
||||
offerToStopProcessRecord,
|
||||
processStartTime,
|
||||
readProcessRecord,
|
||||
releaseProcessRecord,
|
||||
runCli,
|
||||
spawnDetachedToLog,
|
||||
spawnForeground,
|
||||
terminateDetachedProcess,
|
||||
terminateProcess,
|
||||
waitFor,
|
||||
writeProcessRecord,
|
||||
} from "./managed-process.mjs";
|
||||
import {
|
||||
buildCacheDir,
|
||||
describeOutputOwner,
|
||||
workflowLockEnv,
|
||||
workflowLockFile,
|
||||
} from "./output-lock.mjs";
|
||||
|
||||
const repoRoot = path.resolve(
|
||||
path.dirname(fileURLToPath(import.meta.url)),
|
||||
".."
|
||||
);
|
||||
const gulpBin = path.join(repoRoot, "node_modules", ".bin", "gulp");
|
||||
const stateDir = path.join(buildCacheDir, "ha-build");
|
||||
const logFile = path.join(stateDir, "build.log");
|
||||
const lockFile = workflowLockFile;
|
||||
|
||||
const usage = () => {
|
||||
process.stderr.write(
|
||||
"Usage: node build-scripts/build-manager.mjs [--modern] " +
|
||||
"[--background | --status | --stop | --logs [--follow]]\n"
|
||||
);
|
||||
};
|
||||
|
||||
const parseArgs = (argv) => {
|
||||
const args = {
|
||||
mode: "foreground",
|
||||
modes: [],
|
||||
follow: false,
|
||||
modern: false,
|
||||
unknown: [],
|
||||
};
|
||||
for (const arg of argv) {
|
||||
if (LIFECYCLE_MODE_FLAGS.has(arg)) {
|
||||
args.mode = LIFECYCLE_MODE_FLAGS.get(arg);
|
||||
args.modes.push(arg);
|
||||
} else if (arg === "--modern") {
|
||||
args.modern = true;
|
||||
} else if (arg === "--follow") {
|
||||
args.follow = true;
|
||||
} else {
|
||||
args.unknown.push(arg);
|
||||
}
|
||||
}
|
||||
return args;
|
||||
};
|
||||
|
||||
const hints = () =>
|
||||
" Stop: yarn build --stop\n" +
|
||||
" Status: yarn build --status\n" +
|
||||
" Logs: yarn build --logs\n";
|
||||
|
||||
const devCommand = (suite) => {
|
||||
switch (suite) {
|
||||
case "app-serve":
|
||||
return "dev:serve";
|
||||
case "demo":
|
||||
return "dev:demo";
|
||||
case "gallery":
|
||||
return "dev:gallery";
|
||||
case "e2e-app":
|
||||
return "test:e2e:app:dev";
|
||||
default:
|
||||
return "dev";
|
||||
}
|
||||
};
|
||||
|
||||
const readBuild = () => readProcessRecord(lockFile);
|
||||
|
||||
const releaseBuild = (token) => releaseProcessRecord(lockFile, token);
|
||||
|
||||
const stopCommandFor = (owner) =>
|
||||
owner?.kind === "build"
|
||||
? "yarn build --stop"
|
||||
: owner?.kind === "dev"
|
||||
? `yarn ${devCommand(owner.suite)} --stop`
|
||||
: undefined;
|
||||
|
||||
const acquireBuild = async (modern, foreground) => {
|
||||
const token = `${process.pid}-${Date.now()}-${Math.random()}`;
|
||||
const record = {
|
||||
pid: process.pid,
|
||||
startTime: processStartTime(process.pid),
|
||||
processGroup: false,
|
||||
foreground,
|
||||
kind: "build",
|
||||
modern,
|
||||
starting: true,
|
||||
token,
|
||||
};
|
||||
const result = acquireProcessRecord(lockFile, record);
|
||||
if (result.acquired) {
|
||||
return { token };
|
||||
}
|
||||
reportExisting(result.existing);
|
||||
return (await offerToStopProcessRecord({
|
||||
file: lockFile,
|
||||
owner: result.existing,
|
||||
ownerDescription: describeOutputOwner(result.existing),
|
||||
stopCommand: stopCommandFor(result.existing),
|
||||
}))
|
||||
? acquireBuild(modern, foreground)
|
||||
: { existing: result.existing };
|
||||
};
|
||||
|
||||
const updateBuild = (token, child, processGroup) => {
|
||||
const existing = readBuild();
|
||||
if (existing?.token !== token) {
|
||||
throw Error("Frontend build lock ownership was lost during startup.");
|
||||
}
|
||||
writeProcessRecord(lockFile, {
|
||||
...existing,
|
||||
pid: child.pid,
|
||||
startTime: processStartTime(child.pid),
|
||||
processGroup,
|
||||
starting: false,
|
||||
});
|
||||
};
|
||||
|
||||
const taskFor = (modern) => (modern ? "build-app-modern" : "build-app");
|
||||
|
||||
const reportExisting = (existing) => {
|
||||
if (existing?.kind === "output") {
|
||||
process.stdout.write(
|
||||
`${describeOutputOwner(existing)} already owns the build and development workflow` +
|
||||
`${existing.pid ? ` (pid ${existing.pid})` : ""}.\n`
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (existing?.kind === "dev") {
|
||||
const command = devCommand(existing.suite);
|
||||
process.stdout.write(
|
||||
`Dev server (${existing.suite}) already running` +
|
||||
`${existing.pid ? ` (pid ${existing.pid})` : ""}.\n` +
|
||||
` Stop: yarn ${command} --stop\n` +
|
||||
` Status: yarn ${command} --status\n` +
|
||||
` Logs: yarn ${command} --logs\n`
|
||||
);
|
||||
return;
|
||||
}
|
||||
process.stdout.write(
|
||||
`Frontend ${existing?.modern ? "modern " : ""}build already running` +
|
||||
`${existing?.pid ? ` (pid ${existing.pid})` : ""}.\n${hints()}`
|
||||
);
|
||||
};
|
||||
|
||||
const runForeground = async (modern) => {
|
||||
const lock = await acquireBuild(modern, true);
|
||||
if (!lock.token) {
|
||||
return 1;
|
||||
}
|
||||
try {
|
||||
return await spawnForeground({
|
||||
cmd: gulpBin,
|
||||
args: [taskFor(modern)],
|
||||
cwd: repoRoot,
|
||||
env: workflowLockEnv(lock.token),
|
||||
processGroup: true,
|
||||
onSpawn: (child) => updateBuild(lock.token, child, true),
|
||||
});
|
||||
} finally {
|
||||
releaseBuild(lock.token);
|
||||
}
|
||||
};
|
||||
|
||||
const runBackground = async (modern) => {
|
||||
const lock = await acquireBuild(modern, false);
|
||||
if (!lock.token) {
|
||||
return 1;
|
||||
}
|
||||
let child;
|
||||
try {
|
||||
child = await spawnDetachedToLog({
|
||||
cmd: gulpBin,
|
||||
args: [taskFor(modern)],
|
||||
cwd: repoRoot,
|
||||
env: workflowLockEnv(lock.token),
|
||||
logFile,
|
||||
});
|
||||
updateBuild(lock.token, child, true);
|
||||
process.stdout.write(
|
||||
`Started ${modern ? "modern " : ""}frontend build (pid ${child.pid})\n` +
|
||||
hints()
|
||||
);
|
||||
return 0;
|
||||
} catch (err) {
|
||||
if (child) {
|
||||
await terminateDetachedProcess(child);
|
||||
}
|
||||
releaseBuild(lock.token);
|
||||
throw err;
|
||||
}
|
||||
};
|
||||
|
||||
const runStatus = () => {
|
||||
const existing = readBuild();
|
||||
if (existing?.kind === "build" && isProcessRecordAlive(existing)) {
|
||||
process.stdout.write(
|
||||
`Frontend ${existing.modern ? "modern " : ""}build running (pid ${existing.pid}).\n`
|
||||
);
|
||||
} else {
|
||||
if (existing?.kind === "build") {
|
||||
releaseBuild(existing.token);
|
||||
}
|
||||
process.stdout.write("Frontend build not running.\n");
|
||||
}
|
||||
return 0;
|
||||
};
|
||||
|
||||
const runStop = async () => {
|
||||
let existing = readBuild();
|
||||
if (existing?.kind !== "build") {
|
||||
process.stdout.write("Frontend build not running.\n");
|
||||
return 0;
|
||||
}
|
||||
if (existing?.starting) {
|
||||
const token = existing.token;
|
||||
await waitFor(
|
||||
() => {
|
||||
const current = readBuild();
|
||||
return !current?.starting || current.token !== token;
|
||||
},
|
||||
100,
|
||||
5000
|
||||
);
|
||||
existing = readBuild();
|
||||
}
|
||||
if (
|
||||
!existing ||
|
||||
existing.kind !== "build" ||
|
||||
!isProcessRecordAlive(existing)
|
||||
) {
|
||||
if (existing) releaseBuild(existing.token);
|
||||
process.stdout.write("Frontend build not running.\n");
|
||||
return 0;
|
||||
}
|
||||
if (
|
||||
!(await terminateProcess({
|
||||
pid: existing.pid,
|
||||
processGroup: existing.processGroup,
|
||||
isStopped: () => !isProcessRecordAlive(existing),
|
||||
}))
|
||||
) {
|
||||
process.stderr.write(
|
||||
`Failed to stop frontend build (pid ${existing.pid}). Stop it manually.\n`
|
||||
);
|
||||
return 1;
|
||||
}
|
||||
releaseBuild(existing.token);
|
||||
process.stdout.write(`Stopped frontend build (pid ${existing.pid}).\n`);
|
||||
return 0;
|
||||
};
|
||||
|
||||
const runLogs = (follow) =>
|
||||
outputLog(logFile, follow, `No frontend build log yet (${logFile}).\n`);
|
||||
|
||||
const main = async () => {
|
||||
const args = parseArgs(process.argv.slice(2));
|
||||
if (args.unknown.length) {
|
||||
process.stderr.write(`Unknown arguments: ${args.unknown.join(" ")}\n`);
|
||||
usage();
|
||||
return 1;
|
||||
}
|
||||
if (args.modes.length > 1 || (args.follow && args.mode !== "logs")) {
|
||||
process.stderr.write("Invalid combination of build arguments.\n");
|
||||
usage();
|
||||
return 1;
|
||||
}
|
||||
const handlers = {
|
||||
foreground: () => runForeground(args.modern),
|
||||
background: () => runBackground(args.modern),
|
||||
status: runStatus,
|
||||
stop: runStop,
|
||||
logs: () => runLogs(args.follow),
|
||||
};
|
||||
return handlers[args.mode]();
|
||||
};
|
||||
|
||||
runCli(main);
|
||||
@@ -311,7 +311,15 @@ module.exports.config = {
|
||||
return {
|
||||
name: "e2e-test-app" + nameSuffix(latestBuild),
|
||||
entry: {
|
||||
dashboard: path.resolve(
|
||||
paths.e2eTestApp_dir,
|
||||
"src/dashboard-entrypoint.ts"
|
||||
),
|
||||
main: path.resolve(paths.e2eTestApp_dir, "src/entrypoint.ts"),
|
||||
onboarding: path.resolve(
|
||||
paths.e2eTestApp_dir,
|
||||
"src/onboarding-entrypoint.ts"
|
||||
),
|
||||
},
|
||||
outputPath: outputPath(paths.e2eTestApp_output_root, latestBuild),
|
||||
publicPath: publicPath(latestBuild),
|
||||
|
||||
@@ -0,0 +1,212 @@
|
||||
// Content-addressed cache for compression output.
|
||||
//
|
||||
// brotli (quality 11) and zopfli are the slowest part of a production build,
|
||||
// and they redo every file from scratch on every run. But production chunks are
|
||||
// content-hashed in their filenames, so a chunk that didn't change produces
|
||||
// byte-identical input here. Keying the compressed output by a hash of the
|
||||
// input bytes lets an unchanged file skip compression entirely, and lets a
|
||||
// nightly build warm the cache a release reuses the next day.
|
||||
//
|
||||
// Disabled unless COMPRESS_CACHE_DIR points at a directory. Local builds set
|
||||
// nothing and behave exactly as before. The compressed bytes are unchanged
|
||||
// either way; only whether they were recomputed differs.
|
||||
|
||||
import { createHash } from "node:crypto";
|
||||
import { existsSync } from "node:fs";
|
||||
import {
|
||||
mkdir,
|
||||
readFile,
|
||||
readdir,
|
||||
rename,
|
||||
rm,
|
||||
stat,
|
||||
utimes,
|
||||
writeFile,
|
||||
} from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
|
||||
const rootDir = process.env.COMPRESS_CACHE_DIR;
|
||||
export const cacheEnabled = Boolean(rootDir);
|
||||
|
||||
// Total cache size to keep across builds, least-recently-used first when over.
|
||||
// The cache is shared between branches (a dev nightly warms it for a release,
|
||||
// which builds from rc/master), so pruning must NOT drop everything the current
|
||||
// build didn't touch — that would make each branch evict the other's
|
||||
// branch-specific files every run. Instead the current build is pinned and the
|
||||
// rest is kept up to this budget, so nothing is dropped unless the cache is
|
||||
// genuinely too large. Override with COMPRESS_CACHE_MAX_BYTES.
|
||||
const DEFAULT_MAX_BYTES = 1024 * 1024 * 1024; // 1 GiB
|
||||
const maxBytes = () => {
|
||||
// A valid, explicit budget wins — including 0, which keeps only the current
|
||||
// build (evict everything else). An unset or invalid value uses the default.
|
||||
const configured = Number(process.env.COMPRESS_CACHE_MAX_BYTES);
|
||||
return Number.isFinite(configured) && configured >= 0
|
||||
? configured
|
||||
: DEFAULT_MAX_BYTES;
|
||||
};
|
||||
|
||||
// Every cache key touched this build, hits and writes alike, so a prune can pin
|
||||
// the current dist and never evict a file this build depends on.
|
||||
const touched = new Set();
|
||||
|
||||
// Per-namespace directory, created lazily and only once.
|
||||
const dirs = new Map();
|
||||
|
||||
let tmpCounter = 0;
|
||||
|
||||
const sha256 = (contents) =>
|
||||
createHash("sha256").update(contents).digest("hex");
|
||||
|
||||
const namespaceDir = (namespace) => {
|
||||
let dir = dirs.get(namespace);
|
||||
if (!dir) {
|
||||
const dirPath = path.join(rootDir, namespace);
|
||||
dir = { path: dirPath, ready: undefined };
|
||||
dirs.set(namespace, dir);
|
||||
}
|
||||
return dir;
|
||||
};
|
||||
|
||||
const ensureDir = (dir) => {
|
||||
dir.ready ??= mkdir(dir.path, { recursive: true });
|
||||
return dir.ready;
|
||||
};
|
||||
|
||||
// Written via a unique temp file and renamed into place so a concurrent reader
|
||||
// in the same build never sees a half-written entry (rename is atomic on the
|
||||
// same filesystem).
|
||||
const writeAtomic = async (dir, name, contents) => {
|
||||
await ensureDir(dir);
|
||||
tmpCounter += 1;
|
||||
const tmp = path.join(dir.path, `.${process.pid}-${tmpCounter}.tmp`);
|
||||
const dest = path.join(dir.path, name);
|
||||
await writeFile(tmp, contents);
|
||||
try {
|
||||
await rename(tmp, dest);
|
||||
} catch (error) {
|
||||
// Two files with identical contents can miss and write the same entry at
|
||||
// once. On POSIX the rename just overwrites, but Windows rejects a rename
|
||||
// onto an existing path. Either way the destination already holds the same
|
||||
// bytes (content-addressed), so drop our temp and treat it as done.
|
||||
if (existsSync(dest)) {
|
||||
await rm(tmp, { force: true });
|
||||
} else {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Return the cached compression result for `contents`, or run `compute` and
|
||||
* cache what it returns. `compute` resolves to a Buffer for compressed output,
|
||||
* or `undefined` when the file should be dropped (brotli skipLarger); both
|
||||
* outcomes are cached, so a dropped file is not recompressed on the next build.
|
||||
*
|
||||
* @param {string} namespace Isolates entries by algorithm, parameters and tool
|
||||
* version, so a change to any of them can never return a stale result.
|
||||
* @param {Buffer} contents Uncompressed input bytes, used as the cache key.
|
||||
* @param {() => Promise<Buffer | undefined>} compute Runs on a cache miss.
|
||||
* @returns {Promise<Buffer | undefined>}
|
||||
*/
|
||||
export const withCache = async (namespace, contents, compute) => {
|
||||
if (!cacheEnabled) {
|
||||
return compute();
|
||||
}
|
||||
|
||||
const dir = namespaceDir(namespace);
|
||||
const hash = sha256(contents);
|
||||
touched.add(`${namespace}/${hash}`);
|
||||
|
||||
const dataPath = path.join(dir.path, hash);
|
||||
const dropName = `${hash}.drop`;
|
||||
|
||||
try {
|
||||
return await readFile(dataPath);
|
||||
} catch {
|
||||
// Miss (or unreadable) — fall through and compute.
|
||||
}
|
||||
if (existsSync(path.join(dir.path, dropName))) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const result = await compute();
|
||||
if (result === undefined) {
|
||||
await writeAtomic(dir, dropName, "");
|
||||
} else {
|
||||
await writeAtomic(dir, hash, result);
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
/**
|
||||
* Keep the cache under `maxBytes`, evicting least-recently-used entries first.
|
||||
* Files this build touched are always kept (and their timestamp refreshed, so
|
||||
* shared files stay warm across branches); the remainder — including another
|
||||
* branch's entries — is kept up to the budget. No-op when the cache is disabled
|
||||
* or nothing was compressed, so it never wipes a warm cache on a build that
|
||||
* skipped compression.
|
||||
*/
|
||||
export const pruneCache = async () => {
|
||||
if (!cacheEnabled || touched.size === 0 || !existsSync(rootDir)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
const pinned = [];
|
||||
const others = [];
|
||||
|
||||
// Scan every namespace on disk, not just the ones this build used, so entries
|
||||
// from an old tool version (a different namespace) are eligible for eviction.
|
||||
const namespaces = await readdir(rootDir, { withFileTypes: true });
|
||||
await Promise.all(
|
||||
namespaces.map(async (ns) => {
|
||||
if (!ns.isDirectory()) {
|
||||
return;
|
||||
}
|
||||
const nsDir = path.join(rootDir, ns.name);
|
||||
const entries = await readdir(nsDir);
|
||||
await Promise.all(
|
||||
entries.map(async (entry) => {
|
||||
const entryPath = path.join(nsDir, entry);
|
||||
// Stray temp files from an interrupted write are never valid entries.
|
||||
if (entry.endsWith(".tmp")) {
|
||||
await rm(entryPath, { force: true });
|
||||
return;
|
||||
}
|
||||
const hash = entry.endsWith(".drop") ? entry.slice(0, -5) : entry;
|
||||
const info = await stat(entryPath);
|
||||
if (touched.has(`${ns.name}/${hash}`)) {
|
||||
pinned.push({ entryPath, size: info.size });
|
||||
} else {
|
||||
others.push({ entryPath, size: info.size, mtimeMs: info.mtimeMs });
|
||||
}
|
||||
})
|
||||
);
|
||||
})
|
||||
);
|
||||
|
||||
// Pinned files stay and are refreshed so they rank as most-recently-used for
|
||||
// future prunes; they always count against the budget first.
|
||||
let kept = 0;
|
||||
await Promise.all(
|
||||
pinned.map(async ({ entryPath, size }) => {
|
||||
kept += size;
|
||||
// A failed timestamp refresh only affects future LRU ordering, not
|
||||
// correctness, so it is safe to ignore.
|
||||
await utimes(entryPath, now, now).catch(() => undefined);
|
||||
})
|
||||
);
|
||||
|
||||
// Keep the most-recently-used others until the budget is spent; drop the rest.
|
||||
others.sort((a, b) => b.mtimeMs - a.mtimeMs);
|
||||
const budget = maxBytes();
|
||||
const toDelete = [];
|
||||
for (const entry of others) {
|
||||
if (kept + entry.size <= budget) {
|
||||
kept += entry.size;
|
||||
} else {
|
||||
toDelete.push(entry.entryPath);
|
||||
}
|
||||
}
|
||||
await Promise.all(toDelete.map((p) => rm(p, { force: true })));
|
||||
};
|
||||
+478
-395
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,6 @@
|
||||
import gulp from "gulp";
|
||||
import env from "../env.cjs";
|
||||
import { createWorkflowLockTask } from "../output-lock.mjs";
|
||||
import "./clean.js";
|
||||
import "./compress.js";
|
||||
import "./entry-html.js";
|
||||
@@ -17,6 +18,7 @@ gulp.task(
|
||||
async function setEnv() {
|
||||
process.env.NODE_ENV = "development";
|
||||
},
|
||||
createWorkflowLockTask("develop-app"),
|
||||
"clean",
|
||||
gulp.parallel(
|
||||
"gen-service-worker-app-dev",
|
||||
@@ -36,6 +38,7 @@ gulp.task(
|
||||
async function setEnv() {
|
||||
process.env.NODE_ENV = "production";
|
||||
},
|
||||
createWorkflowLockTask("build-app"),
|
||||
"clean",
|
||||
gulp.parallel(
|
||||
"gen-icons-json",
|
||||
@@ -47,6 +50,32 @@ gulp.task(
|
||||
"rspack-prod-app",
|
||||
gulp.parallel("gen-pages-app-prod", "gen-service-worker-app-prod"),
|
||||
// Don't compress running tests
|
||||
...(env.isTestBuild() || env.isStatsBuild()
|
||||
? []
|
||||
: ["compress-app", "prune-compress-cache"])
|
||||
)
|
||||
);
|
||||
|
||||
gulp.task(
|
||||
"build-app-modern",
|
||||
gulp.series(
|
||||
async function setEnv() {
|
||||
process.env.NODE_ENV = "production";
|
||||
},
|
||||
createWorkflowLockTask("build-app-modern"),
|
||||
"clean",
|
||||
gulp.parallel(
|
||||
"gen-icons-json",
|
||||
"build-translations",
|
||||
"build-locale-data",
|
||||
"gen-licenses"
|
||||
),
|
||||
"copy-static-app",
|
||||
"rspack-prod-app-modern",
|
||||
gulp.parallel(
|
||||
"gen-pages-app-prod-modern",
|
||||
"gen-service-worker-app-prod-modern"
|
||||
),
|
||||
...(env.isTestBuild() || env.isStatsBuild() ? [] : ["compress-app"])
|
||||
)
|
||||
);
|
||||
|
||||
@@ -2,9 +2,10 @@
|
||||
|
||||
import { constants } from "node:zlib";
|
||||
import gulp from "gulp";
|
||||
import brotli from "gulp-brotli";
|
||||
import zopfli from "gulp-zopfli-green";
|
||||
import brotli from "../brotli.mjs";
|
||||
import { pruneCache } from "../compress-cache.mjs";
|
||||
import paths from "../paths.cjs";
|
||||
import zopfli from "../zopfli.mjs";
|
||||
|
||||
const filesGlob = "*.{js,json,css,svg,xml}";
|
||||
const brotliOptions = {
|
||||
@@ -57,3 +58,7 @@ gulp.task(
|
||||
compressAppOtherZopfli
|
||||
)
|
||||
);
|
||||
|
||||
// Keep the compression cache under its size budget (LRU, this build pinned).
|
||||
// No-op unless COMPRESS_CACHE_DIR is set.
|
||||
gulp.task("prune-compress-cache", () => pruneCache());
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import gulp from "gulp";
|
||||
import { createWorkflowLockTask } from "../output-lock.mjs";
|
||||
import "./clean.js";
|
||||
import "./entry-html.js";
|
||||
import "./gather-static.js";
|
||||
@@ -13,6 +14,7 @@ gulp.task(
|
||||
async function setEnv() {
|
||||
process.env.NODE_ENV = "development";
|
||||
},
|
||||
createWorkflowLockTask("develop-demo"),
|
||||
"clean-demo",
|
||||
"translations-enable-merge-backend",
|
||||
gulp.parallel(
|
||||
@@ -32,6 +34,7 @@ gulp.task(
|
||||
async function setEnv() {
|
||||
process.env.NODE_ENV = "production";
|
||||
},
|
||||
createWorkflowLockTask("build-demo"),
|
||||
"clean-demo",
|
||||
// Cast needs to be backwards compatible and older HA has no translations
|
||||
"translations-enable-merge-backend",
|
||||
|
||||
@@ -155,8 +155,22 @@ gulp.task("fetch-lokalise", async function () {
|
||||
fs.mkdir(inDirBackend, { recursive: true }),
|
||||
]);
|
||||
|
||||
// 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(
|
||||
([project]) =>
|
||||
!(project === "backend" && process.env.SKIP_BACKEND_TRANSLATIONS)
|
||||
);
|
||||
if (projects.length !== Object.keys(lokaliseProjects).length) {
|
||||
console.log(
|
||||
"Skipping backend translations download (SKIP_BACKEND_TRANSLATIONS)"
|
||||
);
|
||||
}
|
||||
|
||||
await Promise.all(
|
||||
Object.entries(lokaliseProjects).map(async ([project, projectId]) => {
|
||||
projects.map(async ([project, projectId]) => {
|
||||
try {
|
||||
const exportProcess = await lokaliseApi
|
||||
.files()
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import gulp from "gulp";
|
||||
import { createWorkflowLockTask } from "../output-lock.mjs";
|
||||
import "./clean.js";
|
||||
import "./entry-html.js";
|
||||
import "./gather-static.js";
|
||||
@@ -12,6 +13,7 @@ gulp.task(
|
||||
async function setEnv() {
|
||||
process.env.NODE_ENV = "development";
|
||||
},
|
||||
createWorkflowLockTask("develop-e2e-test-app"),
|
||||
"clean-e2e-test-app",
|
||||
"translations-enable-merge-backend",
|
||||
gulp.parallel(
|
||||
@@ -31,6 +33,7 @@ gulp.task(
|
||||
async function setEnv() {
|
||||
process.env.NODE_ENV = "production";
|
||||
},
|
||||
createWorkflowLockTask("build-e2e-test-app"),
|
||||
"clean-e2e-test-app",
|
||||
"translations-enable-merge-backend",
|
||||
gulp.parallel("gen-icons-json", "build-translations", "build-locale-data"),
|
||||
|
||||
@@ -58,6 +58,12 @@ const getCommonTemplateVars = () => {
|
||||
return {
|
||||
modernRegex: compileRegex(browserRegexes.concat(haMacOSRegex)).toString(),
|
||||
hassUrl: process.env.HASS_URL || "",
|
||||
// Single source for the stale-build recovery patterns, shared with the
|
||||
// bundled src/util/recover-stale-build.ts and injected into the inline
|
||||
// boot guard (_bootstrap_recovery.html.template).
|
||||
staleBuildPatterns: fs.readJsonSync(
|
||||
resolve(paths.root_dir, "src/util/stale-build-patterns.json")
|
||||
),
|
||||
};
|
||||
};
|
||||
|
||||
@@ -107,6 +113,9 @@ const genPagesDevTask =
|
||||
resolve(inputRoot, inputSub, `${page}.template`),
|
||||
{
|
||||
...commonVars,
|
||||
// Dev entries are unhashed, so the stale-index recovery guard has
|
||||
// nothing to key off and rebuild churn could cause spurious reloads.
|
||||
useCacheRecovery: false,
|
||||
latestEntryJS: entries.map(
|
||||
(entry) => `${publicRoot}/frontend_latest/${entry}.js`
|
||||
),
|
||||
@@ -146,10 +155,15 @@ const genPagesProdTask =
|
||||
resolve(inputRoot, inputSub, `${page}.template`),
|
||||
{
|
||||
...commonVars,
|
||||
// Recover from a stale index.html that pins deleted hashed entry
|
||||
// bundles (see _bootstrap_recovery.html.template).
|
||||
useCacheRecovery: true,
|
||||
latestEntryJS: entries.map((entry) => latestManifest[`${entry}.js`]),
|
||||
es5EntryJS: entries.map((entry) => es5Manifest[`${entry}.js`]),
|
||||
es5EntryJS: outputES5
|
||||
? entries.map((entry) => es5Manifest[`${entry}.js`])
|
||||
: [],
|
||||
latestCustomPanelJS: latestManifest["custom-panel.js"],
|
||||
es5CustomPanelJS: es5Manifest["custom-panel.js"],
|
||||
es5CustomPanelJS: outputES5 ? es5Manifest["custom-panel.js"] : "",
|
||||
}
|
||||
);
|
||||
minifiedHTML.push(
|
||||
@@ -184,6 +198,17 @@ gulp.task(
|
||||
)
|
||||
);
|
||||
|
||||
gulp.task(
|
||||
"gen-pages-app-prod-modern",
|
||||
genPagesProdTask(
|
||||
APP_PAGE_ENTRIES,
|
||||
paths.root_dir,
|
||||
paths.app_output_root,
|
||||
paths.app_output_latest,
|
||||
undefined
|
||||
)
|
||||
);
|
||||
|
||||
const CAST_PAGE_ENTRIES = {
|
||||
"faq.html": ["launcher"],
|
||||
"index.html": ["launcher"],
|
||||
@@ -278,7 +303,11 @@ gulp.task(
|
||||
)
|
||||
);
|
||||
|
||||
const E2E_TEST_APP_PAGE_ENTRIES = { "index.html": ["main"] };
|
||||
const E2E_TEST_APP_PAGE_ENTRIES = {
|
||||
"index.html": ["main"],
|
||||
"dashboard.html": ["dashboard"],
|
||||
"onboarding.html": ["onboarding"],
|
||||
};
|
||||
|
||||
gulp.task(
|
||||
"gen-pages-e2e-test-app-dev",
|
||||
|
||||
@@ -4,6 +4,7 @@ import gulp from "gulp";
|
||||
import { load as loadYaml } from "js-yaml";
|
||||
import { marked } from "marked";
|
||||
import path from "path";
|
||||
import { createWorkflowLockTask } from "../output-lock.mjs";
|
||||
import paths from "../paths.cjs";
|
||||
import "./clean.js";
|
||||
import "./entry-html.js";
|
||||
@@ -164,6 +165,7 @@ gulp.task(
|
||||
async function setEnv() {
|
||||
process.env.NODE_ENV = "development";
|
||||
},
|
||||
createWorkflowLockTask("develop-gallery"),
|
||||
"clean-gallery",
|
||||
"translations-enable-merge-backend",
|
||||
gulp.parallel(
|
||||
@@ -195,6 +197,7 @@ gulp.task(
|
||||
async function setEnv() {
|
||||
process.env.NODE_ENV = "production";
|
||||
},
|
||||
createWorkflowLockTask("build-gallery"),
|
||||
"clean-gallery",
|
||||
"translations-enable-merge-backend",
|
||||
gulp.parallel(
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import merge from "lodash.merge";
|
||||
|
||||
const isMergeableObject = (value) =>
|
||||
typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
|
||||
// Keys that must never be written to, to avoid prototype pollution when the
|
||||
// overlay comes from an untrusted source (JSON.parse can produce an own
|
||||
// `__proto__` key from `{"__proto__": ...}`).
|
||||
const FORBIDDEN_KEYS = new Set(["__proto__", "constructor", "prototype"]);
|
||||
|
||||
// Deep-merge `overlay` onto `base`, keeping only keys that already exist as own
|
||||
// properties of `base`. Overlay keys with no counterpart in the base - e.g.
|
||||
// translations for source strings that have since been removed from or renamed
|
||||
// in en.json but still linger in Lokalise - are dropped so we don't ship stale
|
||||
// keys. `base` is mutated and returned.
|
||||
export const restrictedMerge = (base, overlay) => {
|
||||
for (const key of Object.keys(overlay)) {
|
||||
// Own-property check (not `in`) so inherited keys like `__proto__` or
|
||||
// `toString` from the overlay are ignored rather than merged.
|
||||
if (FORBIDDEN_KEYS.has(key) || !Object.hasOwn(base, key)) {
|
||||
continue;
|
||||
}
|
||||
const baseValue = base[key];
|
||||
const overlayValue = overlay[key];
|
||||
if (isMergeableObject(baseValue) && isMergeableObject(overlayValue)) {
|
||||
restrictedMerge(baseValue, overlayValue);
|
||||
} else if (
|
||||
!isMergeableObject(baseValue) &&
|
||||
!isMergeableObject(overlayValue)
|
||||
) {
|
||||
base[key] = overlayValue;
|
||||
}
|
||||
// Mismatched shapes keep the base (English) value as a safe fallback.
|
||||
}
|
||||
return base;
|
||||
};
|
||||
|
||||
// Merge translation `objects` onto `startObj`. When `prune` is set, the result
|
||||
// is restricted to the key shape of `startObj` (the English master), so keys
|
||||
// that no longer exist in en.json are not shipped. Otherwise keys are merged
|
||||
// additively (used when building the English master itself, which starts empty).
|
||||
export const mergeTranslations = (startObj, objects, prune = false) =>
|
||||
prune
|
||||
? objects.reduce(restrictedMerge, startObj)
|
||||
: merge(startObj, ...objects);
|
||||
@@ -108,7 +108,7 @@ const runDevServer = async ({
|
||||
}
|
||||
};
|
||||
|
||||
const doneHandler = (done) => (err, stats) => {
|
||||
const doneHandler = () => (err, stats) => {
|
||||
if (err) {
|
||||
log.error(err.stack || err);
|
||||
if (err.details) {
|
||||
@@ -122,18 +122,26 @@ const doneHandler = (done) => (err, stats) => {
|
||||
}
|
||||
|
||||
log(`Build done @ ${new Date().toLocaleTimeString()}`);
|
||||
|
||||
if (done) {
|
||||
done();
|
||||
}
|
||||
};
|
||||
|
||||
const prodBuild = (conf) =>
|
||||
new Promise((resolve) => {
|
||||
new Promise((resolve, reject) => {
|
||||
rspack(
|
||||
conf,
|
||||
// Resolve promise when done. Because we pass a callback, rspack closes itself
|
||||
doneHandler(resolve)
|
||||
(err, stats) => {
|
||||
if (err) {
|
||||
reject(err);
|
||||
} else if (stats.hasErrors()) {
|
||||
reject(Error(stats.toString("errors-only")));
|
||||
} else {
|
||||
if (stats.hasWarnings()) {
|
||||
console.log(stats.toString("minimal"));
|
||||
}
|
||||
log(`Build done @ ${new Date().toLocaleTimeString()}`);
|
||||
resolve();
|
||||
}
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
@@ -160,6 +168,17 @@ gulp.task("rspack-prod-app", () =>
|
||||
)
|
||||
);
|
||||
|
||||
gulp.task("rspack-prod-app-modern", () =>
|
||||
prodBuild(
|
||||
createAppConfig({
|
||||
isProdBuild: true,
|
||||
isStatsBuild: env.isStatsBuild(),
|
||||
isTestBuild: env.isTestBuild(),
|
||||
latestBuild: true,
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
gulp.task("rspack-dev-server-demo", () =>
|
||||
runDevServer({
|
||||
compiler: rspack(
|
||||
|
||||
@@ -34,9 +34,9 @@ gulp.task("gen-service-worker-app-dev", async () => {
|
||||
);
|
||||
});
|
||||
|
||||
gulp.task("gen-service-worker-app-prod", () =>
|
||||
const genServiceWorker = (builds) =>
|
||||
Promise.all(
|
||||
Object.entries(SW_MAP).map(async ([outPath, build]) => {
|
||||
builds.map(async ([outPath, build]) => {
|
||||
const manifest = JSON.parse(
|
||||
await readFile(join(outPath, "manifest.json"), "utf-8")
|
||||
);
|
||||
@@ -83,5 +83,12 @@ gulp.task("gen-service-worker-app-prod", () =>
|
||||
await symlink(basename(swDest), swOld);
|
||||
}
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
gulp.task("gen-service-worker-app-prod", () =>
|
||||
genServiceWorker(Object.entries(SW_MAP))
|
||||
);
|
||||
|
||||
gulp.task("gen-service-worker-app-prod-modern", () =>
|
||||
genServiceWorker([[paths.app_output_latest, "modern"]])
|
||||
);
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
/* eslint-disable max-classes-per-file */
|
||||
|
||||
import { deleteAsync } from "del";
|
||||
import { glob } from "glob";
|
||||
import gulp from "gulp";
|
||||
import rename from "gulp-rename";
|
||||
import merge from "lodash.merge";
|
||||
import { createHash } from "node:crypto";
|
||||
import { mkdir, readFile } from "node:fs/promises";
|
||||
import { basename, join } from "node:path";
|
||||
@@ -12,6 +9,7 @@ import { PassThrough, Transform } from "node:stream";
|
||||
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.js";
|
||||
|
||||
const inFrontendDir = "translations/frontend";
|
||||
@@ -58,11 +56,12 @@ class CustomJSON extends Transform {
|
||||
class MergeJSON extends Transform {
|
||||
_objects = [];
|
||||
|
||||
constructor(stem, startObj = {}, reviver = null) {
|
||||
constructor(stem, startObj = {}, reviver = null, prune = false) {
|
||||
super({ objectMode: true, allowHalfOpen: false });
|
||||
this._stem = stem;
|
||||
this._startObj = structuredClone(startObj);
|
||||
this._reviver = reviver;
|
||||
this._prune = prune;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
@@ -74,7 +73,11 @@ class MergeJSON extends Transform {
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
async _flush(callback) {
|
||||
const mergedObj = merge(this._startObj, ...this._objects);
|
||||
const mergedObj = mergeTranslations(
|
||||
this._startObj,
|
||||
this._objects,
|
||||
this._prune
|
||||
);
|
||||
this._outFile.contents = Buffer.from(JSON.stringify(mergedObj));
|
||||
this._outFile.stem = this._stem;
|
||||
callback(null, this._outFile);
|
||||
@@ -259,7 +262,7 @@ const createTranslations = async () => {
|
||||
}
|
||||
const mergeStream = gulp
|
||||
.src(mergeFiles, { allowEmpty: true })
|
||||
.pipe(new MergeJSON(locale, enMaster, emptyReviver));
|
||||
.pipe(new MergeJSON(locale, enMaster, emptyReviver, true));
|
||||
mergesFinished.push(finished(mergeStream));
|
||||
mergeStream.pipe(hashStream, { end: false });
|
||||
}
|
||||
|
||||
@@ -0,0 +1,552 @@
|
||||
import { spawn, execFileSync } from "node:child_process";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { createInterface } from "node:readline/promises";
|
||||
|
||||
export const LIFECYCLE_MODE_FLAGS = new Map([
|
||||
["--background", "background"],
|
||||
["--status", "status"],
|
||||
["--stop", "stop"],
|
||||
["--logs", "logs"],
|
||||
]);
|
||||
|
||||
const AGENT_PROVIDERS = [
|
||||
{
|
||||
id: "opencode",
|
||||
env: [
|
||||
"OPENCODE",
|
||||
"OPENCODE_BIN_PATH",
|
||||
"OPENCODE_SERVER",
|
||||
"OPENCODE_APP_INFO",
|
||||
"OPENCODE_MODES",
|
||||
],
|
||||
processes: ["opencode"],
|
||||
},
|
||||
{
|
||||
id: "claude-code",
|
||||
env: ["CLAUDECODE"],
|
||||
processes: ["claude"],
|
||||
},
|
||||
{
|
||||
id: "cursor",
|
||||
env: ["CURSOR_TRACE_ID"],
|
||||
processes: [],
|
||||
},
|
||||
{
|
||||
id: "github-copilot",
|
||||
matchesEnv: (env) =>
|
||||
env.TERM_PROGRAM === "vscode" && env.GIT_PAGER === "cat",
|
||||
processes: [],
|
||||
},
|
||||
{
|
||||
id: "generic",
|
||||
env: ["AGENT", "AI_AGENT"],
|
||||
processes: [],
|
||||
},
|
||||
];
|
||||
const MAX_ANCESTRY_HOPS = 24;
|
||||
|
||||
const readProcessParent = (pid) => {
|
||||
try {
|
||||
const stat = fs.readFileSync(`/proc/${pid}/stat`, "utf8");
|
||||
const commandEnd = stat.lastIndexOf(")");
|
||||
const commandStart = stat.indexOf("(");
|
||||
if (commandStart === -1 || commandEnd === -1) {
|
||||
return undefined;
|
||||
}
|
||||
const parentPid = Number.parseInt(
|
||||
stat.slice(commandEnd + 2).split(" ")[1] ?? "",
|
||||
10
|
||||
);
|
||||
return Number.isFinite(parentPid)
|
||||
? {
|
||||
command: stat.slice(commandStart + 1, commandEnd).toLowerCase(),
|
||||
parentPid,
|
||||
}
|
||||
: undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
|
||||
export const detectCodingAgent = (env = process.env, pid = process.pid) => {
|
||||
if (env.HA_CODING_AGENT === "0") {
|
||||
return undefined;
|
||||
}
|
||||
const envMatch = AGENT_PROVIDERS.find(
|
||||
(provider) =>
|
||||
provider.matchesEnv?.(env) || provider.env?.some((name) => env[name])
|
||||
);
|
||||
if (envMatch) {
|
||||
return envMatch.id;
|
||||
}
|
||||
if (env.HA_CODING_AGENT === "1") {
|
||||
return "unknown";
|
||||
}
|
||||
let currentPid = pid;
|
||||
for (let hop = 0; hop < MAX_ANCESTRY_HOPS; hop++) {
|
||||
const processInfo = readProcessParent(currentPid);
|
||||
if (!processInfo) {
|
||||
return undefined;
|
||||
}
|
||||
const processMatch = AGENT_PROVIDERS.find((provider) =>
|
||||
provider.processes.some((name) => processInfo.command.includes(name))
|
||||
);
|
||||
if (processMatch) {
|
||||
return processMatch.id;
|
||||
}
|
||||
if (processInfo.parentPid <= 1) {
|
||||
return undefined;
|
||||
}
|
||||
currentPid = processInfo.parentPid;
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const canPromptForConflict = () =>
|
||||
Boolean(process.stdin.isTTY && process.stderr.isTTY && !detectCodingAgent());
|
||||
|
||||
const formatCommand = (command) =>
|
||||
!("NO_COLOR" in process.env)
|
||||
? `\u001b[1;36m${command}\u001b[0m`
|
||||
: `\`${command}\``;
|
||||
|
||||
const confirmStopConflict = async (ownerDescription, stopCommand) => {
|
||||
const readline = createInterface({
|
||||
input: process.stdin,
|
||||
output: process.stderr,
|
||||
});
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), 30_000);
|
||||
try {
|
||||
process.stderr.write(
|
||||
`${ownerDescription} can be stopped with ${formatCommand(stopCommand)}.\n`
|
||||
);
|
||||
const answer = await readline.question("Stop it and continue? [y/N] ", {
|
||||
signal: controller.signal,
|
||||
});
|
||||
return ["y", "yes"].includes(answer.trim().toLowerCase());
|
||||
} catch (err) {
|
||||
if (err?.name !== "AbortError") {
|
||||
throw err;
|
||||
}
|
||||
process.stderr.write("\n");
|
||||
return false;
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
readline.close();
|
||||
}
|
||||
};
|
||||
|
||||
export const sleep = (ms) =>
|
||||
new Promise((resolve) => {
|
||||
setTimeout(resolve, ms);
|
||||
});
|
||||
|
||||
export const waitFor = async (predicate, intervalMs, timeoutMs) => {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
const poll = async () => {
|
||||
if (await predicate()) {
|
||||
return true;
|
||||
}
|
||||
if (Date.now() >= deadline) {
|
||||
return false;
|
||||
}
|
||||
await sleep(intervalMs);
|
||||
return poll();
|
||||
};
|
||||
return poll();
|
||||
};
|
||||
|
||||
export const isProcessAlive = (pid) => {
|
||||
if (!Number.isInteger(pid) || pid <= 0) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
process.kill(pid, 0);
|
||||
return true;
|
||||
} catch (err) {
|
||||
// EPERM means the process exists but is owned by someone else.
|
||||
return err.code === "EPERM";
|
||||
}
|
||||
};
|
||||
|
||||
export const processStartTime = (pid) => {
|
||||
if (!isProcessAlive(pid)) {
|
||||
return undefined;
|
||||
}
|
||||
try {
|
||||
const stat = fs.readFileSync(`/proc/${pid}/stat`, "utf8");
|
||||
return stat.slice(stat.lastIndexOf(")") + 2).split(" ")[19];
|
||||
} catch {
|
||||
try {
|
||||
return execFileSync("ps", ["-o", "lstart=", "-p", String(pid)], {
|
||||
encoding: "utf8",
|
||||
stdio: ["ignore", "pipe", "ignore"],
|
||||
}).trim();
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export const isProcessRecordAlive = ({ pid, startTime }) =>
|
||||
Boolean(startTime) && processStartTime(pid) === startTime;
|
||||
|
||||
export const readProcessRecord = (file) => {
|
||||
try {
|
||||
const data = JSON.parse(fs.readFileSync(file, "utf8"));
|
||||
return data && Number.isInteger(data.pid) ? data : undefined;
|
||||
} catch (err) {
|
||||
if (err.code === "ENOENT" || err instanceof SyntaxError) {
|
||||
return undefined;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
};
|
||||
|
||||
export const writeProcessRecord = (file, data) => {
|
||||
fs.mkdirSync(path.dirname(file), { recursive: true });
|
||||
const temporary = `${file}.${process.pid}.${Date.now()}.tmp`;
|
||||
try {
|
||||
fs.writeFileSync(temporary, JSON.stringify(data));
|
||||
fs.renameSync(temporary, file);
|
||||
} finally {
|
||||
removeFileIfExists(temporary);
|
||||
}
|
||||
};
|
||||
|
||||
export const removeProcessRecord = (file) => {
|
||||
removeFileIfExists(file);
|
||||
};
|
||||
|
||||
export const acquireProcessRecord = (file, data) => {
|
||||
fs.mkdirSync(path.dirname(file), { recursive: true });
|
||||
for (let attempt = 0; attempt < 2; attempt++) {
|
||||
try {
|
||||
const fd = fs.openSync(file, "wx");
|
||||
try {
|
||||
fs.writeFileSync(fd, JSON.stringify(data));
|
||||
} finally {
|
||||
fs.closeSync(fd);
|
||||
}
|
||||
return { acquired: true };
|
||||
} catch (err) {
|
||||
if (err.code !== "EEXIST") {
|
||||
throw err;
|
||||
}
|
||||
const existing = readProcessRecord(file);
|
||||
if (existing && isProcessRecordAlive(existing)) {
|
||||
return { acquired: false, existing };
|
||||
}
|
||||
if (!existing && isRecentFile(file)) {
|
||||
return { acquired: false };
|
||||
}
|
||||
const removed = withExclusiveFileLockSync(`${file}.cleanup`, () => {
|
||||
const current = readProcessRecord(file);
|
||||
if (
|
||||
current &&
|
||||
(current.token !== existing?.token || isProcessRecordAlive(current))
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
removeProcessRecord(file);
|
||||
return true;
|
||||
});
|
||||
if (!removed.acquired || !removed.value) {
|
||||
return { acquired: false, existing: readProcessRecord(file) };
|
||||
}
|
||||
}
|
||||
}
|
||||
return { acquired: false, existing: readProcessRecord(file) };
|
||||
};
|
||||
|
||||
export const releaseProcessRecord = (file, token, onRelease) => {
|
||||
if (!token) {
|
||||
return;
|
||||
}
|
||||
withExclusiveFileLockSync(`${file}.cleanup`, () => {
|
||||
const existing = readProcessRecord(file);
|
||||
if (!existing || existing.token === token) {
|
||||
onRelease?.();
|
||||
if (existing) {
|
||||
removeProcessRecord(file);
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const removeFileIfExists = (file) => {
|
||||
try {
|
||||
fs.rmSync(file);
|
||||
} catch (err) {
|
||||
if (err.code !== "ENOENT") {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const isRecentFile = (file) => {
|
||||
try {
|
||||
return Date.now() - fs.statSync(file).mtimeMs < 5000;
|
||||
} catch (err) {
|
||||
if (err.code === "ENOENT") {
|
||||
return false;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
};
|
||||
|
||||
export const withExclusiveFileLockSync = (file, operation) => {
|
||||
fs.mkdirSync(path.dirname(file), { recursive: true });
|
||||
for (let attempt = 0; attempt < 2; attempt++) {
|
||||
let fd;
|
||||
try {
|
||||
fd = fs.openSync(file, "wx");
|
||||
} catch (err) {
|
||||
if (err.code !== "EEXIST") {
|
||||
throw err;
|
||||
}
|
||||
const owner = readProcessRecord(file);
|
||||
if (owner && isProcessRecordAlive(owner)) {
|
||||
return { acquired: false };
|
||||
}
|
||||
if (!owner && isRecentFile(file)) {
|
||||
return { acquired: false };
|
||||
}
|
||||
removeFileIfExists(file);
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
fs.writeFileSync(
|
||||
fd,
|
||||
JSON.stringify({
|
||||
pid: process.pid,
|
||||
startTime: processStartTime(process.pid),
|
||||
})
|
||||
);
|
||||
return { acquired: true, value: operation() };
|
||||
} finally {
|
||||
try {
|
||||
fs.closeSync(fd);
|
||||
} finally {
|
||||
removeFileIfExists(file);
|
||||
}
|
||||
}
|
||||
}
|
||||
return { acquired: false };
|
||||
};
|
||||
|
||||
export const spawnForeground = ({
|
||||
cmd,
|
||||
args,
|
||||
cwd,
|
||||
env,
|
||||
processGroup = false,
|
||||
onSpawn,
|
||||
}) =>
|
||||
new Promise((resolve, reject) => {
|
||||
const child = spawn(cmd, args, {
|
||||
cwd,
|
||||
detached: processGroup,
|
||||
env,
|
||||
stdio: "inherit",
|
||||
});
|
||||
let settled = false;
|
||||
const forwardSigint = () =>
|
||||
signalProcess(child.pid, "SIGINT", processGroup);
|
||||
const forwardSigterm = () =>
|
||||
signalProcess(child.pid, "SIGTERM", processGroup);
|
||||
const forwardSighup = () =>
|
||||
signalProcess(child.pid, "SIGHUP", processGroup);
|
||||
const removeSignalHandlers = () => {
|
||||
process.off("SIGINT", forwardSigint);
|
||||
process.off("SIGTERM", forwardSigterm);
|
||||
process.off("SIGHUP", forwardSighup);
|
||||
};
|
||||
if (processGroup) {
|
||||
process.on("SIGINT", forwardSigint);
|
||||
process.on("SIGTERM", forwardSigterm);
|
||||
process.on("SIGHUP", forwardSighup);
|
||||
}
|
||||
child.once("spawn", () => {
|
||||
try {
|
||||
onSpawn?.(child);
|
||||
} catch (err) {
|
||||
settled = true;
|
||||
signalProcess(child.pid, "SIGTERM", processGroup);
|
||||
removeSignalHandlers();
|
||||
reject(err);
|
||||
}
|
||||
});
|
||||
child.once("error", (err) => {
|
||||
if (!settled) {
|
||||
settled = true;
|
||||
removeSignalHandlers();
|
||||
process.stderr.write(`Failed to start ${cmd}: ${err.message}\n`);
|
||||
resolve(1);
|
||||
}
|
||||
});
|
||||
child.once("exit", (code) => {
|
||||
if (!settled) {
|
||||
settled = true;
|
||||
removeSignalHandlers();
|
||||
resolve(code ?? 1);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
export const spawnDetachedToLog = ({ cmd, args, cwd, env, logFile }) =>
|
||||
new Promise((resolve, reject) => {
|
||||
fs.mkdirSync(path.dirname(logFile), { recursive: true });
|
||||
const fd = fs.openSync(logFile, "w");
|
||||
let child;
|
||||
try {
|
||||
child = spawn(cmd, args, {
|
||||
cwd,
|
||||
detached: true,
|
||||
env,
|
||||
stdio: ["ignore", fd, fd],
|
||||
});
|
||||
} finally {
|
||||
fs.closeSync(fd);
|
||||
}
|
||||
child.once("spawn", () => {
|
||||
child.unref();
|
||||
resolve(child);
|
||||
});
|
||||
child.once("error", reject);
|
||||
});
|
||||
|
||||
const signalProcess = (pid, signal, processGroup) => {
|
||||
if (processGroup) {
|
||||
try {
|
||||
process.kill(-pid, signal);
|
||||
return;
|
||||
} catch {
|
||||
// Fall back to the process itself.
|
||||
}
|
||||
}
|
||||
try {
|
||||
process.kill(pid, signal);
|
||||
} catch {
|
||||
// Already gone.
|
||||
}
|
||||
};
|
||||
|
||||
export const terminateProcess = async ({
|
||||
pid,
|
||||
isStopped,
|
||||
processGroup = true,
|
||||
graceMs = 10_000,
|
||||
}) => {
|
||||
signalProcess(pid, "SIGTERM", processGroup);
|
||||
if (await waitFor(isStopped, 300, graceMs)) {
|
||||
return true;
|
||||
}
|
||||
signalProcess(pid, "SIGKILL", processGroup);
|
||||
await sleep(300);
|
||||
return await isStopped();
|
||||
};
|
||||
|
||||
const sameProcessRecord = (current, expected) =>
|
||||
Boolean(expected?.token) && current?.token === expected.token;
|
||||
|
||||
const stopProcessRecord = async (file, owner) => {
|
||||
let current = readProcessRecord(file);
|
||||
if (!current) {
|
||||
return true;
|
||||
}
|
||||
if (!sameProcessRecord(current, owner)) {
|
||||
return false;
|
||||
}
|
||||
if (!isProcessRecordAlive(current)) {
|
||||
releaseProcessRecord(file, current.token);
|
||||
return true;
|
||||
}
|
||||
const stopped = await terminateProcess({
|
||||
pid: current.pid,
|
||||
processGroup: current.processGroup ?? false,
|
||||
isStopped: () => {
|
||||
const latest = readProcessRecord(file);
|
||||
return (
|
||||
!sameProcessRecord(latest, owner) ||
|
||||
!latest ||
|
||||
!isProcessRecordAlive(latest)
|
||||
);
|
||||
},
|
||||
});
|
||||
current = readProcessRecord(file);
|
||||
if (current && !sameProcessRecord(current, owner)) {
|
||||
return false;
|
||||
}
|
||||
if (!stopped && current) {
|
||||
return false;
|
||||
}
|
||||
releaseProcessRecord(file, owner.token);
|
||||
return true;
|
||||
};
|
||||
|
||||
export const offerToStopProcessRecord = async ({
|
||||
file,
|
||||
owner,
|
||||
ownerDescription,
|
||||
stopCommand,
|
||||
}) => {
|
||||
if (
|
||||
!owner ||
|
||||
!stopCommand ||
|
||||
!canPromptForConflict() ||
|
||||
!(await confirmStopConflict(ownerDescription, stopCommand))
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
return stopProcessRecord(file, owner);
|
||||
};
|
||||
|
||||
export const terminateDetachedProcess = (child) =>
|
||||
terminateProcess({
|
||||
pid: child.pid,
|
||||
processGroup: true,
|
||||
isStopped: () => !isProcessAlive(child.pid),
|
||||
});
|
||||
|
||||
export const outputLog = (logFile, follow, missingMessage) => {
|
||||
if (!fs.existsSync(logFile)) {
|
||||
process.stdout.write(missingMessage);
|
||||
return Promise.resolve(0);
|
||||
}
|
||||
if (!follow) {
|
||||
process.stdout.write(fs.readFileSync(logFile, "utf8"));
|
||||
return Promise.resolve(0);
|
||||
}
|
||||
return new Promise((resolve) => {
|
||||
const tail = spawn("tail", ["-f", logFile], { stdio: "inherit" });
|
||||
let settled = false;
|
||||
tail.once("error", () => {
|
||||
if (!settled) {
|
||||
settled = true;
|
||||
process.stdout.write(fs.readFileSync(logFile, "utf8"));
|
||||
resolve(0);
|
||||
}
|
||||
});
|
||||
tail.once("exit", (code) => {
|
||||
if (!settled) {
|
||||
settled = true;
|
||||
resolve(code ?? 1);
|
||||
}
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
export const runCli = (main) => {
|
||||
main().then(
|
||||
(code) => {
|
||||
process.exitCode = code;
|
||||
},
|
||||
(err) => {
|
||||
process.stderr.write(`${err?.stack || err}\n`);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,127 @@
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import {
|
||||
acquireProcessRecord,
|
||||
processStartTime,
|
||||
readProcessRecord,
|
||||
releaseProcessRecord,
|
||||
} from "./managed-process.mjs";
|
||||
|
||||
const repoRoot = path.resolve(
|
||||
path.dirname(fileURLToPath(import.meta.url)),
|
||||
".."
|
||||
);
|
||||
export const buildCacheDir =
|
||||
process.env.HA_BUILD_CACHE_DIR ??
|
||||
path.join(repoRoot, "node_modules", ".cache");
|
||||
|
||||
const WORKFLOW_LOCK_TOKEN_ENV = "HA_WORKFLOW_LOCK_TOKEN";
|
||||
const signalCleanups = new Set();
|
||||
const cleanupSignals = ["SIGINT", "SIGTERM", "SIGHUP"];
|
||||
|
||||
const handleSignal = (signal) => {
|
||||
for (const cleanup of signalCleanups) {
|
||||
cleanup();
|
||||
}
|
||||
for (const cleanupSignal of cleanupSignals) {
|
||||
process.off(cleanupSignal, handleSignal);
|
||||
}
|
||||
process.kill(process.pid, signal);
|
||||
};
|
||||
|
||||
const registerSignalCleanup = (cleanup) => {
|
||||
if (signalCleanups.size === 0) {
|
||||
for (const signal of cleanupSignals) {
|
||||
process.on(signal, handleSignal);
|
||||
}
|
||||
}
|
||||
signalCleanups.add(cleanup);
|
||||
};
|
||||
|
||||
const unregisterSignalCleanup = (cleanup) => {
|
||||
signalCleanups.delete(cleanup);
|
||||
if (signalCleanups.size === 0) {
|
||||
for (const signal of cleanupSignals) {
|
||||
process.off(signal, handleSignal);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export const workflowLockFile = path.join(buildCacheDir, "ha-workflow.lock");
|
||||
|
||||
export const workflowLockEnv = (token) => ({
|
||||
...process.env,
|
||||
[WORKFLOW_LOCK_TOKEN_ENV]: token,
|
||||
});
|
||||
|
||||
export const describeOutputOwner = (owner) => {
|
||||
if (owner?.kind === "build") {
|
||||
return `frontend ${owner.modern ? "modern " : ""}build`;
|
||||
}
|
||||
if (owner?.kind === "dev") {
|
||||
return `dev server (${owner.suite ?? "app"})`;
|
||||
}
|
||||
return owner?.target ? `Gulp task ${owner.target}` : "another process";
|
||||
};
|
||||
|
||||
const createLockTask = ({ file, inheritedTokenEnv, kind, label, target }) => {
|
||||
let exitToken;
|
||||
|
||||
const cleanup = () => {
|
||||
if (!exitToken) {
|
||||
return;
|
||||
}
|
||||
releaseProcessRecord(file, exitToken);
|
||||
exitToken = undefined;
|
||||
process.off("exit", cleanup);
|
||||
unregisterSignalCleanup(cleanup);
|
||||
};
|
||||
const acquire = async () => {
|
||||
const inheritedToken = process.env[inheritedTokenEnv];
|
||||
if (inheritedToken) {
|
||||
if (readProcessRecord(file)?.token !== inheritedToken) {
|
||||
throw Error(
|
||||
`${label} lock ownership was lost before ${target} started.`
|
||||
);
|
||||
}
|
||||
exitToken = inheritedToken;
|
||||
process.once("exit", cleanup);
|
||||
registerSignalCleanup(cleanup);
|
||||
return;
|
||||
}
|
||||
|
||||
const token = `${process.pid}-${Date.now()}-${Math.random()}`;
|
||||
const record = {
|
||||
pid: process.pid,
|
||||
startTime: processStartTime(process.pid),
|
||||
processGroup: false,
|
||||
kind,
|
||||
target,
|
||||
token,
|
||||
};
|
||||
const result = acquireProcessRecord(file, record);
|
||||
if (!result.acquired) {
|
||||
const pid = result.existing?.pid;
|
||||
throw Error(
|
||||
`Cannot run ${target}: ${describeOutputOwner(result.existing)} ` +
|
||||
`already owns ${label}${pid ? ` (pid ${pid})` : ""}.`
|
||||
);
|
||||
}
|
||||
|
||||
exitToken = token;
|
||||
process.once("exit", cleanup);
|
||||
registerSignalCleanup(cleanup);
|
||||
};
|
||||
|
||||
acquire.displayName = `lock-${label}:${target}`;
|
||||
return acquire;
|
||||
};
|
||||
|
||||
export const createWorkflowLockTask = (target) =>
|
||||
createLockTask({
|
||||
file: workflowLockFile,
|
||||
inheritedTokenEnv: WORKFLOW_LOCK_TOKEN_ENV,
|
||||
kind: "output",
|
||||
label: "build and development workflow",
|
||||
target,
|
||||
});
|
||||
@@ -0,0 +1,64 @@
|
||||
// Object-mode transform that keeps several files in flight at once.
|
||||
//
|
||||
// through2 and node's Transform both wait for the previous callback before
|
||||
// handling the next file, which serialises asynchronous work down to one file
|
||||
// at a time. This keeps `limit` files in flight and applies backpressure beyond
|
||||
// that. Files are emitted in completion order rather than input order.
|
||||
|
||||
import { Transform } from "node:stream";
|
||||
|
||||
export class ParallelTransform extends Transform {
|
||||
#limit;
|
||||
|
||||
#handle;
|
||||
|
||||
#inFlight = 0;
|
||||
|
||||
#resume;
|
||||
|
||||
#finish;
|
||||
|
||||
constructor(limit, handle) {
|
||||
super({ objectMode: true, highWaterMark: limit });
|
||||
this.#limit = limit;
|
||||
this.#handle = handle;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention -- node's Transform API
|
||||
_transform(file, _encoding, callback) {
|
||||
this.#inFlight += 1;
|
||||
this.#handle(file)
|
||||
.then((result) => {
|
||||
if (result) {
|
||||
this.push(result);
|
||||
}
|
||||
})
|
||||
.catch((error) => this.destroy(error))
|
||||
.finally(() => {
|
||||
this.#inFlight -= 1;
|
||||
const resume = this.#resume;
|
||||
this.#resume = undefined;
|
||||
resume?.();
|
||||
if (this.#inFlight === 0) {
|
||||
const finish = this.#finish;
|
||||
this.#finish = undefined;
|
||||
finish?.();
|
||||
}
|
||||
});
|
||||
|
||||
if (this.#inFlight < this.#limit) {
|
||||
callback();
|
||||
} else {
|
||||
this.#resume = callback;
|
||||
}
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention -- node's Transform API
|
||||
_flush(callback) {
|
||||
if (this.#inFlight === 0) {
|
||||
callback();
|
||||
} else {
|
||||
this.#finish = callback;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
// Worker side of the zopfli pool. @gfx/zopfli is a synchronous WASM build, so
|
||||
// compressing on the main thread blocks the event loop; one instance per worker
|
||||
// is what makes the work parallel.
|
||||
|
||||
import { parentPort } from "node:worker_threads";
|
||||
import zopfli from "@gfx/zopfli";
|
||||
|
||||
parentPort.on("message", ({ contents, options }) => {
|
||||
zopfli.gzip(contents, options, (error, result) => {
|
||||
if (error) {
|
||||
parentPort.postMessage({ error: error.message ?? String(error) });
|
||||
return;
|
||||
}
|
||||
// `result` is a fresh Uint8Array copied out of the WASM heap, so it owns
|
||||
// its ArrayBuffer and can be transferred instead of cloned.
|
||||
parentPort.postMessage({ result }, [result.buffer]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,148 @@
|
||||
// Gulp transform that gzips files with zopfli across a pool of worker threads.
|
||||
//
|
||||
// Drop-in replacement for gulp-zopfli-green. That plugin compresses on the main
|
||||
// thread, and @gfx/zopfli is synchronous WASM, so it pins a single core and
|
||||
// blocks the event loop for the whole compression step. Running one WASM
|
||||
// instance per worker parallelises it; the output bytes are unchanged.
|
||||
|
||||
import { createRequire } from "node:module";
|
||||
import { availableParallelism } from "node:os";
|
||||
import { buffer as readStream } from "node:stream/consumers";
|
||||
import { Worker } from "node:worker_threads";
|
||||
import { withCache } from "./compress-cache.mjs";
|
||||
import { ParallelTransform } from "./parallel-transform.mjs";
|
||||
|
||||
const WORKER_URL = new URL("./zopfli-worker.mjs", import.meta.url);
|
||||
const EXTENSION = ".gz";
|
||||
|
||||
// Cache namespace tied to the zopfli version, since a different version can
|
||||
// produce different bytes for the same input.
|
||||
const ZOPFLI_VERSION = createRequire(import.meta.url)(
|
||||
"@gfx/zopfli/package.json"
|
||||
).version;
|
||||
const NAMESPACE = `gzip-zopfli${ZOPFLI_VERSION}`;
|
||||
|
||||
// Left empty on purpose: @gfx/zopfli then applies its own defaults, which is
|
||||
// what gulp-zopfli-green did, so compressed output stays byte-identical.
|
||||
const ZOPFLI_OPTIONS = {};
|
||||
|
||||
const poolSize = () => {
|
||||
const configured = Number(process.env.ZOPFLI_WORKERS);
|
||||
return Number.isInteger(configured) && configured > 0
|
||||
? configured
|
||||
: availableParallelism();
|
||||
};
|
||||
|
||||
// One job per worker at a time, so the worker itself is the job slot.
|
||||
const createPool = (size) => {
|
||||
const live = new Set();
|
||||
const idle = [];
|
||||
const waiting = [];
|
||||
const inFlight = new Map();
|
||||
|
||||
// A dead worker must leave the pool, or it gets handed a job that never
|
||||
// completes and the build hangs instead of failing.
|
||||
const retire = (worker, error) => {
|
||||
if (!live.delete(worker)) {
|
||||
return;
|
||||
}
|
||||
const index = idle.indexOf(worker);
|
||||
if (index !== -1) {
|
||||
idle.splice(index, 1);
|
||||
}
|
||||
const job = inFlight.get(worker);
|
||||
inFlight.delete(worker);
|
||||
job?.reject(error);
|
||||
waiting.shift()?.();
|
||||
};
|
||||
|
||||
const spawn = () => {
|
||||
const worker = new Worker(WORKER_URL);
|
||||
live.add(worker);
|
||||
// Idle workers must not hold the process open. Each job refs its worker for
|
||||
// as long as it runs, so a pending compression always keeps the event loop
|
||||
// alive.
|
||||
worker.unref();
|
||||
|
||||
worker.on("message", ({ error, result }) => {
|
||||
const job = inFlight.get(worker);
|
||||
if (!job) {
|
||||
return;
|
||||
}
|
||||
inFlight.delete(worker);
|
||||
worker.unref();
|
||||
idle.push(worker);
|
||||
if (error) {
|
||||
job.reject(new Error(error));
|
||||
} else {
|
||||
job.resolve(
|
||||
Buffer.from(result.buffer, result.byteOffset, result.byteLength)
|
||||
);
|
||||
}
|
||||
waiting.shift()?.();
|
||||
});
|
||||
|
||||
worker.on("error", (error) => retire(worker, error));
|
||||
worker.on("exit", () =>
|
||||
retire(worker, new Error("zopfli worker exited unexpectedly"))
|
||||
);
|
||||
|
||||
return worker;
|
||||
};
|
||||
|
||||
return (contents) =>
|
||||
new Promise((resolve, reject) => {
|
||||
const start = () => {
|
||||
const worker = idle.pop() ?? (live.size < size ? spawn() : undefined);
|
||||
if (!worker) {
|
||||
waiting.push(start);
|
||||
return;
|
||||
}
|
||||
inFlight.set(worker, { resolve, reject });
|
||||
worker.ref();
|
||||
// `contents` is cloned rather than transferred: buffers read by vinyl
|
||||
// can share a pooled ArrayBuffer with unrelated buffers, and
|
||||
// transferring would detach those too.
|
||||
worker.postMessage({ contents, options: ZOPFLI_OPTIONS });
|
||||
};
|
||||
start();
|
||||
});
|
||||
};
|
||||
|
||||
// Shared by every transform this module hands out, so the worker count is a
|
||||
// property of the process rather than of how many streams happen to run.
|
||||
let pool;
|
||||
|
||||
const sharedPool = () => {
|
||||
if (!pool) {
|
||||
const size = poolSize();
|
||||
pool = { size, compress: createPool(size) };
|
||||
}
|
||||
return pool;
|
||||
};
|
||||
|
||||
/**
|
||||
* @param {object} [options]
|
||||
* @param {number} [options.threshold] Skip files smaller than this many bytes.
|
||||
*/
|
||||
export default ({ threshold = 0 } = {}) => {
|
||||
const { size, compress } = sharedPool();
|
||||
|
||||
return new ParallelTransform(size, async (file) => {
|
||||
if (file.isNull()) {
|
||||
return file;
|
||||
}
|
||||
if (file.isStream()) {
|
||||
file.contents = await readStream(file.contents);
|
||||
}
|
||||
if (threshold && file.contents.length < threshold) {
|
||||
// Passed through unrenamed and uncompressed, as gulp-zopfli-green did.
|
||||
return file;
|
||||
}
|
||||
file.contents = await withCache(NAMESPACE, file.contents, () =>
|
||||
compress(file.contents)
|
||||
);
|
||||
file.path += EXTENSION;
|
||||
return file;
|
||||
});
|
||||
};
|
||||
@@ -1,304 +0,0 @@
|
||||
import { mdiClose, mdiFlaskOutline } from "@mdi/js";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, state } from "lit/decorators";
|
||||
import { fireEvent } from "../../../src/common/dom/fire_event";
|
||||
import { mainWindow } from "../../../src/common/dom/get_main_window";
|
||||
import { navigate } from "../../../src/common/navigate";
|
||||
import "../../../src/components/ha-button";
|
||||
import "../../../src/components/ha-card";
|
||||
import "../../../src/components/ha-icon-button";
|
||||
import "../../../src/components/ha-svg-icon";
|
||||
import "../../../src/components/ha-switch";
|
||||
import type { HaSwitch } from "../../../src/components/ha-switch";
|
||||
import type { CloudDemoScenario } from "../stubs/cloud-demo-state";
|
||||
import {
|
||||
getCloudDemoScenario,
|
||||
setCloudDemoScenario,
|
||||
subscribeCloudDemoScenario,
|
||||
} from "../stubs/cloud-demo-state";
|
||||
|
||||
// Walk the DOM, descending into shadow roots, to find the first matching
|
||||
// element. Used to reach <ha-panel-config> (which owns the cloud status) so we
|
||||
// can ask it to re-fetch after a scenario change.
|
||||
const deepQuery = (
|
||||
selector: string,
|
||||
root: Document | ShadowRoot = document
|
||||
): Element | null => {
|
||||
const direct = root.querySelector(selector);
|
||||
if (direct) {
|
||||
return direct;
|
||||
}
|
||||
const elements = root.querySelectorAll("*");
|
||||
for (const element of elements) {
|
||||
const shadow = element.shadowRoot;
|
||||
if (shadow) {
|
||||
const found = deepQuery(selector, shadow);
|
||||
if (found) {
|
||||
return found;
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Demo-only floating panel that flips the mocked Home Assistant Cloud state so
|
||||
* reviewers can preview every UI state of the cloud account page. It writes to
|
||||
* the shared {@link CloudDemoScenario} (which the cloud/backup mocks read) and
|
||||
* then nudges the page to re-read it. Lives entirely under demo/.
|
||||
*/
|
||||
@customElement("cloud-demo-controls")
|
||||
export class CloudDemoControls extends LitElement {
|
||||
@state() private _open = true;
|
||||
|
||||
@state() private _visible = false;
|
||||
|
||||
@state() private _scenario: CloudDemoScenario = getCloudDemoScenario();
|
||||
|
||||
private _unsub?: () => void;
|
||||
|
||||
// The demo uses hash-based routing (navigate() sets location.hash), so the
|
||||
// active route lives in the hash, not the pathname.
|
||||
private get _currentPath(): string {
|
||||
const hash = mainWindow.location.hash;
|
||||
return hash.startsWith("#/") ? hash.slice(1) : mainWindow.location.pathname;
|
||||
}
|
||||
|
||||
private _locationChanged = () => {
|
||||
this._visible = this._currentPath.startsWith("/config/cloud");
|
||||
};
|
||||
|
||||
public connectedCallback(): void {
|
||||
super.connectedCallback();
|
||||
this._locationChanged();
|
||||
mainWindow.addEventListener("location-changed", this._locationChanged);
|
||||
mainWindow.addEventListener("popstate", this._locationChanged);
|
||||
mainWindow.addEventListener("hashchange", this._locationChanged);
|
||||
this._unsub = subscribeCloudDemoScenario((scenario) => {
|
||||
this._scenario = { ...scenario };
|
||||
});
|
||||
}
|
||||
|
||||
public disconnectedCallback(): void {
|
||||
super.disconnectedCallback();
|
||||
mainWindow.removeEventListener("location-changed", this._locationChanged);
|
||||
mainWindow.removeEventListener("popstate", this._locationChanged);
|
||||
mainWindow.removeEventListener("hashchange", this._locationChanged);
|
||||
this._unsub?.();
|
||||
}
|
||||
|
||||
protected render() {
|
||||
if (!this._visible) {
|
||||
return nothing;
|
||||
}
|
||||
if (!this._open) {
|
||||
return html`
|
||||
<ha-icon-button
|
||||
class="fab"
|
||||
label="Cloud demo controls"
|
||||
.path=${mdiFlaskOutline}
|
||||
@click=${this._toggleOpen}
|
||||
></ha-icon-button>
|
||||
`;
|
||||
}
|
||||
return html`
|
||||
<ha-card>
|
||||
<div class="header">
|
||||
<ha-svg-icon .path=${mdiFlaskOutline}></ha-svg-icon>
|
||||
<span class="title">Cloud demo controls</span>
|
||||
<ha-icon-button
|
||||
label="Close"
|
||||
.path=${mdiClose}
|
||||
@click=${this._toggleOpen}
|
||||
></ha-icon-button>
|
||||
</div>
|
||||
<p class="note">
|
||||
Demo only. Flips the mocked cloud state shown on this page.
|
||||
</p>
|
||||
<div class="controls">
|
||||
${this._segment("Subscription", "account", [
|
||||
["active", "Active"],
|
||||
["trialing", "Trialing"],
|
||||
["canceled", "Canceled"],
|
||||
["expired", "Expired"],
|
||||
["unknown", "Unknown"],
|
||||
])}
|
||||
${this._toggle("Onboarded", "onboarded")}
|
||||
${this._toggle("Onboarding postponed", "postponed")}
|
||||
${this._toggle("Remote access", "remote")}
|
||||
${this._segment("Remote status", "remoteStatus", [
|
||||
["ready", "Ready"],
|
||||
["generating", "Preparing"],
|
||||
["loading", "Loading"],
|
||||
["loaded", "Loaded"],
|
||||
["error", "Error"],
|
||||
])}
|
||||
${this._segment("Backups", "backup", [
|
||||
["fresh", "Recent"],
|
||||
["stale", "Old"],
|
||||
["failed", "Failed"],
|
||||
["local", "Local only"],
|
||||
["none", "None"],
|
||||
])}
|
||||
${this._toggle("Alexa linked", "alexa")}
|
||||
${this._toggle("Google linked", "google")}
|
||||
${this._toggle("Cameras (WebRTC)", "webrtc")}
|
||||
${this._toggle("Has webhooks", "webhooks")}
|
||||
</div>
|
||||
</ha-card>
|
||||
`;
|
||||
}
|
||||
|
||||
private _segment(
|
||||
label: string,
|
||||
field: keyof CloudDemoScenario,
|
||||
options: [string, string][]
|
||||
) {
|
||||
return html`
|
||||
<div class="row">
|
||||
<span>${label}</span>
|
||||
<div class="segment">
|
||||
${options.map(
|
||||
([value, text]) => html`
|
||||
<ha-button
|
||||
size="s"
|
||||
appearance=${
|
||||
this._scenario[field] === value ? "filled" : "plain"
|
||||
}
|
||||
data-field=${field}
|
||||
data-value=${value}
|
||||
@click=${this._segmentClick}
|
||||
>
|
||||
${text}
|
||||
</ha-button>
|
||||
`
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private _toggle(label: string, field: keyof CloudDemoScenario) {
|
||||
return html`
|
||||
<div class="row">
|
||||
<span>${label}</span>
|
||||
<ha-switch
|
||||
.checked=${this._scenario[field] as boolean}
|
||||
data-field=${field}
|
||||
@change=${this._toggleChange}
|
||||
></ha-switch>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private _toggleOpen() {
|
||||
this._open = !this._open;
|
||||
}
|
||||
|
||||
private _segmentClick(ev: Event) {
|
||||
const target = ev.currentTarget as HTMLElement;
|
||||
this._set(
|
||||
target.dataset.field as keyof CloudDemoScenario,
|
||||
target.dataset.value!
|
||||
);
|
||||
}
|
||||
|
||||
private _toggleChange(ev: Event) {
|
||||
const target = ev.target as HaSwitch;
|
||||
this._set(target.dataset.field as keyof CloudDemoScenario, target.checked);
|
||||
}
|
||||
|
||||
private _set(field: keyof CloudDemoScenario, value: string | boolean) {
|
||||
setCloudDemoScenario({ [field]: value } as Partial<CloudDemoScenario>);
|
||||
this._refresh();
|
||||
}
|
||||
|
||||
private _refresh() {
|
||||
// Refresh the shared cloud status so login-state changes (signed out) and
|
||||
// status-derived fields update.
|
||||
const panel = deepQuery("ha-panel-config");
|
||||
if (panel) {
|
||||
fireEvent(panel as HTMLElement, "ha-refresh-cloud-status");
|
||||
}
|
||||
// cloud-account fetches its subscription/backup/webhook data once on mount
|
||||
// and is not cached by the router, so bounce through a sibling cloud route
|
||||
// to force a clean remount that re-reads the updated mocks.
|
||||
const path = this._currentPath;
|
||||
if (path.startsWith("/config/cloud") && path !== "/config/cloud/login") {
|
||||
const sibling =
|
||||
path === "/config/cloud/remote"
|
||||
? "/config/cloud/account"
|
||||
: "/config/cloud/remote";
|
||||
navigate(sibling, { replace: true });
|
||||
window.setTimeout(() => navigate(path, { replace: true }), 0);
|
||||
}
|
||||
}
|
||||
|
||||
static styles = css`
|
||||
:host {
|
||||
position: fixed;
|
||||
right: 16px;
|
||||
bottom: 16px;
|
||||
z-index: 9999;
|
||||
}
|
||||
.fab {
|
||||
--mdc-icon-button-size: 48px;
|
||||
--mdc-icon-size: 24px;
|
||||
background-color: var(--primary-color);
|
||||
color: var(--text-primary-color, #fff);
|
||||
border-radius: 50%;
|
||||
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
ha-card {
|
||||
display: block;
|
||||
width: 320px;
|
||||
max-height: 80vh;
|
||||
overflow: auto;
|
||||
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 8px 8px 16px;
|
||||
border-bottom: 1px solid var(--divider-color);
|
||||
}
|
||||
.header .title {
|
||||
flex: 1;
|
||||
font-weight: var(--ha-font-weight-medium, 500);
|
||||
}
|
||||
.header ha-svg-icon {
|
||||
color: var(--secondary-text-color);
|
||||
}
|
||||
.note {
|
||||
margin: 8px 16px;
|
||||
color: var(--secondary-text-color);
|
||||
font-size: var(--ha-font-size-s, 0.875rem);
|
||||
}
|
||||
.controls {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
padding: 0 16px 16px;
|
||||
}
|
||||
.row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
min-height: 36px;
|
||||
}
|
||||
.segment {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"cloud-demo-controls": CloudDemoControls;
|
||||
}
|
||||
}
|
||||
+2
-6
@@ -32,7 +32,6 @@ import { mockTemplate } from "./stubs/template";
|
||||
import { mockTodo } from "./stubs/todo";
|
||||
import { mockTranslations } from "./stubs/translations";
|
||||
import { mockUsagePrediction } from "./stubs/usage_prediction";
|
||||
import "./cloud/cloud-demo-controls";
|
||||
|
||||
// WS command / REST path prefixes whose mocks live in the lazily imported
|
||||
// config-panel chunk (see ./stubs/config-panel). Must stay in sync with it.
|
||||
@@ -59,6 +58,8 @@ const CONFIG_PANEL_COMMANDS = [
|
||||
"search/related",
|
||||
"tag/list",
|
||||
"assist_pipeline/",
|
||||
"config/entity_registry/settings/",
|
||||
"slugify",
|
||||
];
|
||||
|
||||
@customElement("ha-demo")
|
||||
@@ -90,11 +91,6 @@ export class HaDemo extends HomeAssistantAppEl {
|
||||
},
|
||||
});
|
||||
|
||||
// Demo-only floating panel to flip the mocked cloud state. Mounted once at
|
||||
// the document level; it shows itself only on the cloud panel.
|
||||
if (!document.querySelector("cloud-demo-controls")) {
|
||||
document.body.appendChild(document.createElement("cloud-demo-controls"));
|
||||
}
|
||||
const localizePromise =
|
||||
// @ts-ignore
|
||||
this._loadFragmentTranslations(hass.language, "page-demo").then(
|
||||
|
||||
@@ -34,16 +34,8 @@
|
||||
content="width=device-width, initial-scale=1, shrink-to-fit=no"
|
||||
/>
|
||||
<meta name="theme-color" content="#03a9f4" />
|
||||
<link rel="preload" href="/static/fonts/roboto/Roboto-Regular.woff2" as="font" type="font/woff2" crossorigin>
|
||||
<%= renderTemplate("_social_meta.html.template") %>
|
||||
<style>
|
||||
@font-face {
|
||||
font-family: "Roboto Launch Screen";
|
||||
font-display: block;
|
||||
src: url("/static/fonts/roboto/Roboto-Regular.woff2") format("woff2");
|
||||
font-weight: 400;
|
||||
font-style: normal;
|
||||
}
|
||||
html {
|
||||
background-color: var(--primary-background-color, #fafafa);
|
||||
color: var(--primary-text-color, #212121);
|
||||
@@ -64,12 +56,14 @@
|
||||
padding: 0;
|
||||
}
|
||||
#ha-launch-screen {
|
||||
font-family: "Roboto Launch Screen", sans-serif;
|
||||
font-family: ui-sans-serif, system-ui, sans-serif;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
transition: opacity var(--ha-animation-duration-normal, 250ms) ease-out;
|
||||
}
|
||||
#ha-launch-screen.removing {
|
||||
@@ -104,7 +98,7 @@
|
||||
}
|
||||
#ha-launch-screen .ha-launch-screen-spacer-bottom {
|
||||
flex: 1;
|
||||
padding-top: 48px;
|
||||
padding-top: 16px;
|
||||
}
|
||||
.ohf-logo {
|
||||
margin: max(var(--safe-area-inset-bottom, 0px), 48px) 0;
|
||||
|
||||
+25
-109
@@ -7,31 +7,43 @@ import type {
|
||||
import { BackupScheduleRecurrence } from "../../../src/data/backup";
|
||||
import type { ManagerStateEvent } from "../../../src/data/backup_manager";
|
||||
import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
|
||||
import type { DemoCloudBackup } from "./cloud-demo-state";
|
||||
import {
|
||||
getCloudDemoScenario,
|
||||
setCloudDemoScenario,
|
||||
subscribeCloudDemoScenario,
|
||||
} from "./cloud-demo-state";
|
||||
|
||||
const CLOUD_AGENT = "cloud.cloud";
|
||||
|
||||
// Fixed "recent" backup state: an automatic backup completed 12h ago to both the
|
||||
// local and cloud agents, with the next run scheduled for tomorrow. This is the
|
||||
// healthy state the cloud overview status line and backup sub-page render.
|
||||
const now = Date.now();
|
||||
const recent = new Date(now - 12 * 3600 * 1000).toISOString();
|
||||
const future = new Date(now + 86400000).toISOString();
|
||||
|
||||
const backupInfo: BackupInfo = {
|
||||
backups: [],
|
||||
backups: [
|
||||
{
|
||||
backup_id: "demo-backup-1",
|
||||
name: "Automatic backup DEMO",
|
||||
date: recent,
|
||||
with_automatic_settings: true,
|
||||
agents: {
|
||||
"backup.local": { size: 1024 * 1024 * 512, protected: true },
|
||||
"cloud.cloud": { size: 1024 * 1024 * 512, protected: true },
|
||||
},
|
||||
} as BackupContent,
|
||||
],
|
||||
agent_errors: {},
|
||||
last_attempted_automatic_backup: null,
|
||||
last_completed_automatic_backup: null,
|
||||
last_attempted_automatic_backup: recent,
|
||||
last_completed_automatic_backup: recent,
|
||||
last_action_event: { manager_state: "idle" },
|
||||
next_automatic_backup: null,
|
||||
next_automatic_backup: future,
|
||||
next_automatic_backup_additional: false,
|
||||
state: "idle",
|
||||
};
|
||||
|
||||
const backupConfig: BackupConfig = {
|
||||
automatic_backups_configured: true,
|
||||
last_attempted_automatic_backup: null,
|
||||
last_completed_automatic_backup: null,
|
||||
next_automatic_backup: null,
|
||||
last_attempted_automatic_backup: recent,
|
||||
last_completed_automatic_backup: recent,
|
||||
next_automatic_backup: future,
|
||||
next_automatic_backup_additional: false,
|
||||
create_backup: {
|
||||
agent_ids: ["backup.local", CLOUD_AGENT],
|
||||
@@ -61,88 +73,6 @@ const agentsInfo: BackupAgentsInfo = {
|
||||
],
|
||||
};
|
||||
|
||||
// Map the demo "Backups" scenario onto the mutable backup config/info, so the
|
||||
// cloud overview status line and the backup sub-page reflect the chosen state.
|
||||
const applyScenario = () => {
|
||||
const kind = getCloudDemoScenario().backup;
|
||||
const now = Date.now();
|
||||
const recent = new Date(now - 12 * 3600 * 1000).toISOString();
|
||||
const old = new Date(now - 5 * 86400000).toISOString();
|
||||
const future = new Date(now + 86400000).toISOString();
|
||||
// Comfortably past BACKUP_OVERDUE_MARGIN_HOURS (3h) so the "stale" scenario
|
||||
// actually reads as overdue rather than slipping under the margin.
|
||||
const overdue = new Date(now - 6 * 3600 * 1000).toISOString();
|
||||
|
||||
// The cloud agent is a backup target for the cloud-backed states only. For
|
||||
// "local" a backup exists but is stored locally (no cloud copy), and for
|
||||
// "none" there are no automatic backups at all.
|
||||
const cloudEnabled =
|
||||
kind === "fresh" || kind === "stale" || kind === "failed";
|
||||
backupConfig.create_backup.agent_ids = cloudEnabled
|
||||
? ["backup.local", CLOUD_AGENT]
|
||||
: ["backup.local"];
|
||||
|
||||
switch (kind) {
|
||||
case "fresh":
|
||||
backupConfig.automatic_backups_configured = true;
|
||||
backupConfig.last_completed_automatic_backup = recent;
|
||||
backupConfig.last_attempted_automatic_backup = recent;
|
||||
backupConfig.next_automatic_backup = future;
|
||||
break;
|
||||
case "local":
|
||||
// Automatic backups run, but only to the local agent.
|
||||
backupConfig.automatic_backups_configured = true;
|
||||
backupConfig.last_completed_automatic_backup = recent;
|
||||
backupConfig.last_attempted_automatic_backup = recent;
|
||||
backupConfig.next_automatic_backup = future;
|
||||
break;
|
||||
case "stale":
|
||||
backupConfig.automatic_backups_configured = true;
|
||||
backupConfig.last_completed_automatic_backup = old;
|
||||
backupConfig.last_attempted_automatic_backup = old;
|
||||
// Next scheduled backup is in the past, so it reads as overdue.
|
||||
backupConfig.next_automatic_backup = overdue;
|
||||
break;
|
||||
case "failed":
|
||||
backupConfig.automatic_backups_configured = true;
|
||||
backupConfig.last_completed_automatic_backup = old;
|
||||
// Most recent attempt is newer than the last success, so it failed.
|
||||
backupConfig.last_attempted_automatic_backup = recent;
|
||||
backupConfig.next_automatic_backup = future;
|
||||
break;
|
||||
case "none":
|
||||
backupConfig.automatic_backups_configured = false;
|
||||
backupConfig.last_completed_automatic_backup = null;
|
||||
backupConfig.last_attempted_automatic_backup = null;
|
||||
backupConfig.next_automatic_backup = null;
|
||||
break;
|
||||
}
|
||||
|
||||
backupInfo.last_completed_automatic_backup =
|
||||
backupConfig.last_completed_automatic_backup;
|
||||
backupInfo.last_attempted_automatic_backup =
|
||||
backupConfig.last_attempted_automatic_backup;
|
||||
backupInfo.next_automatic_backup = backupConfig.next_automatic_backup;
|
||||
backupInfo.backups =
|
||||
cloudEnabled && backupConfig.last_completed_automatic_backup
|
||||
? [
|
||||
{
|
||||
backup_id: "demo-backup-1",
|
||||
name: "Automatic backup DEMO",
|
||||
date: backupConfig.last_completed_automatic_backup,
|
||||
with_automatic_settings: true,
|
||||
agents: {
|
||||
"backup.local": { size: 1024 * 1024 * 512, protected: true },
|
||||
"cloud.cloud": { size: 1024 * 1024 * 512, protected: true },
|
||||
},
|
||||
} as BackupContent,
|
||||
]
|
||||
: [];
|
||||
};
|
||||
|
||||
applyScenario();
|
||||
subscribeCloudDemoScenario(applyScenario);
|
||||
|
||||
export const mockBackup = (hass: MockHomeAssistant) => {
|
||||
// Fresh objects each fetch so re-reading after a mutation actually re-renders
|
||||
// (Lit change detection is identity-based; the real WS API returns new
|
||||
@@ -171,20 +101,6 @@ export const mockBackup = (hass: MockHomeAssistant) => {
|
||||
if (update.agents) {
|
||||
backupConfig.agents = { ...backupConfig.agents, ...update.agents };
|
||||
}
|
||||
// Reflect the UI-driven backup change into the demo scenario so the demo
|
||||
// controls panel stays in sync with the mocked state.
|
||||
const cloudNow = backupConfig.create_backup.agent_ids.includes(CLOUD_AGENT);
|
||||
const current = getCloudDemoScenario().backup;
|
||||
const next: DemoCloudBackup = !backupConfig.automatic_backups_configured
|
||||
? "none"
|
||||
: cloudNow
|
||||
? current === "fresh" || current === "stale" || current === "failed"
|
||||
? current
|
||||
: "fresh"
|
||||
: "local";
|
||||
if (next !== current) {
|
||||
setCloudDemoScenario({ backup: next });
|
||||
}
|
||||
return null;
|
||||
});
|
||||
hass.mockWS(
|
||||
|
||||
@@ -1,89 +0,0 @@
|
||||
// Demo-only switchable Home Assistant Cloud scenario.
|
||||
//
|
||||
// The redesigned cloud account page (src/panels/config/cloud/account) renders
|
||||
// purely from real WS data. To let reviewers preview every UI state without a
|
||||
// real cloud account, this module holds a mutable "scenario" that the cloud and
|
||||
// backup mocks read from, plus the floating <cloud-demo-controls> panel writes
|
||||
// to. It is persisted to localStorage so the choice survives the data the page
|
||||
// fetches once per visit (subscription, backup config, webhooks).
|
||||
//
|
||||
// This lives entirely under demo/ — no production code imports it.
|
||||
|
||||
import type { RemoteCertificateStatus } from "../../../src/data/cloud";
|
||||
|
||||
// The five PaymentSubscriptionState values.
|
||||
export type DemoCloudAccount =
|
||||
"active" | "trialing" | "canceled" | "expired" | "unknown";
|
||||
|
||||
// "local": automatic backups are configured, but not to the cloud agent
|
||||
// (a backup exists, just no cloud copy). "none": no automatic backups at all.
|
||||
export type DemoCloudBackup = "fresh" | "stale" | "failed" | "local" | "none";
|
||||
|
||||
export interface CloudDemoScenario {
|
||||
account: DemoCloudAccount;
|
||||
onboarded: boolean;
|
||||
// Onboarding postponed server-side (maps to onboarding_postponed); hides
|
||||
// the onboarding UI without marking it completed.
|
||||
postponed: boolean;
|
||||
remote: boolean;
|
||||
remoteStatus: RemoteCertificateStatus;
|
||||
backup: DemoCloudBackup;
|
||||
alexa: boolean;
|
||||
google: boolean;
|
||||
webrtc: boolean;
|
||||
webhooks: boolean;
|
||||
}
|
||||
|
||||
export const DEFAULT_CLOUD_DEMO_SCENARIO: CloudDemoScenario = {
|
||||
account: "active",
|
||||
onboarded: true,
|
||||
postponed: false,
|
||||
remote: true,
|
||||
remoteStatus: "ready",
|
||||
backup: "fresh",
|
||||
alexa: true,
|
||||
google: true,
|
||||
webrtc: true,
|
||||
webhooks: true,
|
||||
};
|
||||
|
||||
const STORAGE_KEY = "cloudDemoScenario";
|
||||
|
||||
const readScenario = (): CloudDemoScenario => {
|
||||
try {
|
||||
const raw = window.localStorage.getItem(STORAGE_KEY);
|
||||
if (raw) {
|
||||
return { ...DEFAULT_CLOUD_DEMO_SCENARIO, ...JSON.parse(raw) };
|
||||
}
|
||||
} catch (_err) {
|
||||
// Ignore malformed or unavailable storage and fall back to the default.
|
||||
}
|
||||
return { ...DEFAULT_CLOUD_DEMO_SCENARIO };
|
||||
};
|
||||
|
||||
let scenario: CloudDemoScenario = readScenario();
|
||||
|
||||
const listeners = new Set<(scenario: CloudDemoScenario) => void>();
|
||||
|
||||
export const getCloudDemoScenario = (): CloudDemoScenario => scenario;
|
||||
|
||||
export const subscribeCloudDemoScenario = (
|
||||
listener: (scenario: CloudDemoScenario) => void
|
||||
): (() => void) => {
|
||||
listeners.add(listener);
|
||||
return () => {
|
||||
listeners.delete(listener);
|
||||
};
|
||||
};
|
||||
|
||||
export const setCloudDemoScenario = (
|
||||
partial: Partial<CloudDemoScenario>
|
||||
): void => {
|
||||
scenario = { ...scenario, ...partial };
|
||||
try {
|
||||
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(scenario));
|
||||
} catch (_err) {
|
||||
// Ignore storage failures (e.g. private mode); state still applies in-memory.
|
||||
}
|
||||
listeners.forEach((listener) => listener(scenario));
|
||||
};
|
||||
+24
-107
@@ -6,11 +6,6 @@ import { ONBOARDING_ITEMS } from "../../../src/data/cloud";
|
||||
import type { CloudTTSInfo } from "../../../src/data/cloud/tts";
|
||||
import type { Webhook } from "../../../src/data/webhook";
|
||||
import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
|
||||
import {
|
||||
getCloudDemoScenario,
|
||||
setCloudDemoScenario,
|
||||
subscribeCloudDemoScenario,
|
||||
} from "./cloud-demo-state";
|
||||
|
||||
const emptyFilter = () => ({
|
||||
include_domains: [],
|
||||
@@ -34,14 +29,30 @@ const demoWebhooks: Webhook[] = [
|
||||
},
|
||||
];
|
||||
|
||||
// A single mutable status object so that preference changes made in the demo
|
||||
// (both via the real UI and the demo scenario controls) are reflected back.
|
||||
const demoCloudhooks = Object.fromEntries(
|
||||
demoWebhooks.map((webhook) => [
|
||||
webhook.webhook_id,
|
||||
{
|
||||
webhook_id: webhook.webhook_id,
|
||||
cloudhook_id: `demo-${webhook.webhook_id}`,
|
||||
cloudhook_url: `https://hooks.nabu.casa/demo-${webhook.webhook_id}`,
|
||||
managed: false,
|
||||
},
|
||||
])
|
||||
);
|
||||
|
||||
// A single mutable status object seeded to one fixed demo state: an active cloud
|
||||
// subscription with remote ready, cloud backups recent, webhooks set up and voice
|
||||
// set up (Alexa linked), but cameras (WebRTC) off, so onboarding is still in
|
||||
// progress (streaming is the remaining step) and not postponed. Page-driven
|
||||
// changes (connect remote, postpone onboarding, add/delete webhooks) mutate this
|
||||
// object directly and reset on reload.
|
||||
const cloudStatus: CloudStatusLoggedIn = {
|
||||
logged_in: true,
|
||||
cloud: "connected",
|
||||
cloud_last_disconnect_reason: null,
|
||||
email: "[email protected]",
|
||||
google_registered: true,
|
||||
google_registered: false,
|
||||
google_entities: emptyFilter(),
|
||||
google_domains: ["light", "switch", "climate", "cover"],
|
||||
alexa_registered: true,
|
||||
@@ -58,20 +69,20 @@ const cloudStatus: CloudStatusLoggedIn = {
|
||||
http_use_ssl: false,
|
||||
active_subscription: true,
|
||||
onboarding_postponed: false,
|
||||
onboarding_completed: true,
|
||||
onboarding_completed: false,
|
||||
prefs: {
|
||||
google_enabled: true,
|
||||
google_enabled: false,
|
||||
alexa_enabled: true,
|
||||
remote_enabled: true,
|
||||
remote_allow_remote_enable: true,
|
||||
strict_connection: "disabled",
|
||||
google_secure_devices_pin: undefined,
|
||||
cloudhooks: {},
|
||||
cloudhooks: demoCloudhooks,
|
||||
alexa_report_state: true,
|
||||
google_report_state: true,
|
||||
tts_default_voice: ["en-US", "JennyNeural"],
|
||||
cloud_ice_servers_enabled: true,
|
||||
onboarded_items: [...ONBOARDING_ITEMS],
|
||||
cloud_ice_servers_enabled: false,
|
||||
onboarded_items: [],
|
||||
onboarding_postponed_until: null,
|
||||
},
|
||||
};
|
||||
@@ -93,94 +104,6 @@ const ttsInfo: CloudTTSInfo = {
|
||||
],
|
||||
};
|
||||
|
||||
// Map the high-level demo scenario onto the mutable cloud status / subscription.
|
||||
const applyScenario = () => {
|
||||
const scenario = getCloudDemoScenario();
|
||||
|
||||
switch (scenario.account) {
|
||||
case "trialing":
|
||||
cloudStatus.active_subscription = true;
|
||||
subscription.subscription = { status: "trialing" };
|
||||
break;
|
||||
case "canceled":
|
||||
cloudStatus.active_subscription = false;
|
||||
subscription.subscription = { status: "canceled" };
|
||||
break;
|
||||
case "expired":
|
||||
cloudStatus.active_subscription = false;
|
||||
subscription.subscription = { status: "expired" };
|
||||
break;
|
||||
case "unknown":
|
||||
cloudStatus.active_subscription = true;
|
||||
subscription.subscription = { status: "unknown" };
|
||||
break;
|
||||
default:
|
||||
// "active"
|
||||
cloudStatus.active_subscription = true;
|
||||
subscription.subscription = { status: "active" };
|
||||
}
|
||||
|
||||
cloudStatus.prefs.onboarded_items = scenario.onboarded
|
||||
? [...ONBOARDING_ITEMS]
|
||||
: [];
|
||||
cloudStatus.onboarding_completed = scenario.onboarded;
|
||||
cloudStatus.onboarding_postponed = scenario.postponed;
|
||||
cloudStatus.prefs.onboarding_postponed_until = scenario.postponed
|
||||
? new Date(Date.now() + 24 * 3600 * 1000).toISOString()
|
||||
: null;
|
||||
cloudStatus.prefs.remote_enabled = scenario.remote;
|
||||
cloudStatus.remote_connected = scenario.remote;
|
||||
cloudStatus.remote_certificate_status = scenario.remoteStatus;
|
||||
cloudStatus.alexa_registered = scenario.alexa;
|
||||
cloudStatus.google_registered = scenario.google;
|
||||
cloudStatus.prefs.cloud_ice_servers_enabled = scenario.webrtc;
|
||||
|
||||
const hasCloudhooks = Object.keys(cloudStatus.prefs.cloudhooks).length > 0;
|
||||
if (scenario.webhooks && !hasCloudhooks) {
|
||||
cloudStatus.prefs.cloudhooks = Object.fromEntries(
|
||||
demoWebhooks.map((webhook) => [
|
||||
webhook.webhook_id,
|
||||
{
|
||||
webhook_id: webhook.webhook_id,
|
||||
cloudhook_id: `demo-${webhook.webhook_id}`,
|
||||
cloudhook_url: `https://hooks.nabu.casa/demo-${webhook.webhook_id}`,
|
||||
managed: false,
|
||||
},
|
||||
])
|
||||
);
|
||||
} else if (!scenario.webhooks && hasCloudhooks) {
|
||||
cloudStatus.prefs.cloudhooks = {};
|
||||
}
|
||||
};
|
||||
|
||||
applyScenario();
|
||||
subscribeCloudDemoScenario(applyScenario);
|
||||
|
||||
// Reflect UI-driven changes (onboarding toggles, remote connect/disconnect)
|
||||
// back into the demo scenario so the demo controls panel stays in sync with the
|
||||
// mocked state. Only writes when a value actually changed, to avoid needless
|
||||
// re-projection. `applyScenario` re-applies the (now matching) scenario, so
|
||||
// this stays idempotent and does not fight the direct mutation above.
|
||||
const syncScenarioFromStatus = () => {
|
||||
const scenario = getCloudDemoScenario();
|
||||
const next = {
|
||||
onboarded: cloudStatus.onboarding_completed,
|
||||
postponed: cloudStatus.onboarding_postponed,
|
||||
remote: cloudStatus.prefs.remote_enabled,
|
||||
webrtc: cloudStatus.prefs.cloud_ice_servers_enabled,
|
||||
webhooks: Object.keys(cloudStatus.prefs.cloudhooks).length > 0,
|
||||
};
|
||||
if (
|
||||
scenario.onboarded !== next.onboarded ||
|
||||
scenario.postponed !== next.postponed ||
|
||||
scenario.remote !== next.remote ||
|
||||
scenario.webrtc !== next.webrtc ||
|
||||
scenario.webhooks !== next.webhooks
|
||||
) {
|
||||
setCloudDemoScenario(next);
|
||||
}
|
||||
};
|
||||
|
||||
export const mockCloud = (hass: MockHomeAssistant) => {
|
||||
hass.mockWS("cloud/status", () => ({
|
||||
...cloudStatus,
|
||||
@@ -193,7 +116,6 @@ export const mockCloud = (hass: MockHomeAssistant) => {
|
||||
hass.mockWS("cloud/update_prefs", (msg) => {
|
||||
const { type, ...prefs } = msg;
|
||||
cloudStatus.prefs = { ...cloudStatus.prefs, ...prefs };
|
||||
syncScenarioFromStatus();
|
||||
return { success: true };
|
||||
});
|
||||
|
||||
@@ -202,7 +124,6 @@ export const mockCloud = (hass: MockHomeAssistant) => {
|
||||
Date.now() + 24 * 3600 * 1000
|
||||
).toISOString();
|
||||
cloudStatus.onboarding_postponed = true;
|
||||
syncScenarioFromStatus();
|
||||
// Backend returns the full logged-in status object.
|
||||
return { ...cloudStatus, prefs: { ...cloudStatus.prefs } };
|
||||
});
|
||||
@@ -222,7 +143,6 @@ export const mockCloud = (hass: MockHomeAssistant) => {
|
||||
(onboardingItem) =>
|
||||
cloudStatus.prefs.onboarded_items.includes(onboardingItem)
|
||||
);
|
||||
syncScenarioFromStatus();
|
||||
// Backend returns the full logged-in status object.
|
||||
return { ...cloudStatus, prefs: { ...cloudStatus.prefs } };
|
||||
});
|
||||
@@ -245,20 +165,17 @@ export const mockCloud = (hass: MockHomeAssistant) => {
|
||||
const cloudhooks = { ...cloudStatus.prefs.cloudhooks };
|
||||
delete cloudhooks[msg.webhook_id];
|
||||
cloudStatus.prefs.cloudhooks = cloudhooks;
|
||||
syncScenarioFromStatus();
|
||||
return null;
|
||||
});
|
||||
|
||||
hass.mockWS("cloud/remote/connect", () => {
|
||||
cloudStatus.remote_connected = true;
|
||||
cloudStatus.prefs.remote_enabled = true;
|
||||
syncScenarioFromStatus();
|
||||
return null;
|
||||
});
|
||||
hass.mockWS("cloud/remote/disconnect", () => {
|
||||
cloudStatus.remote_connected = false;
|
||||
cloudStatus.prefs.remote_enabled = false;
|
||||
syncScenarioFromStatus();
|
||||
return null;
|
||||
});
|
||||
|
||||
|
||||
@@ -8,12 +8,14 @@ import { mockCloud } from "./cloud";
|
||||
import { mockConfig } from "./config";
|
||||
import { mockConfigEntries } from "./config_entries";
|
||||
import { mockDeviceAutomation } from "./device_automation";
|
||||
import { mockEntityRegistrySettings } from "./entity_registry_settings";
|
||||
import { mockEntitySources } from "./entity_sources";
|
||||
import { mockExpose } from "./expose";
|
||||
import { mockNetwork } from "./network";
|
||||
import { mockPerson } from "./person";
|
||||
import { mockScene } from "./scene";
|
||||
import { mockSearch } from "./search";
|
||||
import { mockSlugify } from "./slugify";
|
||||
import { mockSystemHealth } from "./system_health";
|
||||
import { mockTags } from "./tags";
|
||||
import { mockZone } from "./zone";
|
||||
@@ -39,4 +41,6 @@ export const mockConfigPanel = (hass: MockHomeAssistant) => {
|
||||
mockSearch(hass);
|
||||
mockTags(hass);
|
||||
mockAssist(hass);
|
||||
mockEntityRegistrySettings(hass);
|
||||
mockSlugify(hass);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import type {
|
||||
EntityRegistrySettings,
|
||||
fetchEntityRegistrySettings,
|
||||
updateEntityRegistrySettings,
|
||||
} from "../../../src/data/entity/entity_registry_settings";
|
||||
import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
|
||||
|
||||
export const mockEntityRegistrySettings = (hass: MockHomeAssistant) => {
|
||||
let settings: EntityRegistrySettings = { entity_id_parts: null };
|
||||
|
||||
hass.mockWS<typeof fetchEntityRegistrySettings>(
|
||||
"config/entity_registry/settings/get",
|
||||
() => settings
|
||||
);
|
||||
hass.mockWS<typeof updateEntityRegistrySettings>(
|
||||
"config/entity_registry/settings/update",
|
||||
(msg: Partial<EntityRegistrySettings>) => {
|
||||
settings = { ...settings, ...msg };
|
||||
return settings;
|
||||
}
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,9 @@
|
||||
import { slugify } from "../../../src/common/string/slugify";
|
||||
import type { fetchSlug } from "../../../src/data/ws-slugify";
|
||||
import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
|
||||
|
||||
export const mockSlugify = (hass: MockHomeAssistant) => {
|
||||
hass.mockWS<typeof fetchSlug>("slugify", (msg: { text: string }) => ({
|
||||
slug: slugify(msg.text),
|
||||
}));
|
||||
};
|
||||
@@ -228,6 +228,7 @@ export default [
|
||||
"entity-state",
|
||||
"ha-markdown",
|
||||
"integration-card",
|
||||
"cloud-account",
|
||||
"box-shadow",
|
||||
"util-long-press",
|
||||
"remove-delete-add-create",
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
import type { LovelaceCardConfig } from "../../../src/data/lovelace/config/card";
|
||||
import { getCardElementClass } from "../../../src/panels/lovelace/create-element/create-card-element";
|
||||
|
||||
export const validateCardConfig = async (config: LovelaceCardConfig) => {
|
||||
const cardClass = await getCardElementClass(config.type);
|
||||
new cardClass().setConfig(config);
|
||||
};
|
||||
@@ -1,15 +1,21 @@
|
||||
import { load } from "js-yaml";
|
||||
import { dump } from "js-yaml";
|
||||
import type { PropertyValues } from "lit";
|
||||
import { LitElement, css, html, nothing } from "lit";
|
||||
import { customElement, property, query, state } from "lit/decorators";
|
||||
import memoizeOne from "memoize-one";
|
||||
import "../../../src/components/ha-alert";
|
||||
import type { LovelaceCardConfig } from "../../../src/data/lovelace/config/card";
|
||||
import "../../../src/panels/lovelace/cards/hui-card";
|
||||
import type { HuiCard } from "../../../src/panels/lovelace/cards/hui-card";
|
||||
import type { HomeAssistant } from "../../../src/types";
|
||||
import { validateCardConfig } from "../common/validate-card-config";
|
||||
|
||||
export interface DemoCardConfig {
|
||||
export interface DemoCardConfig<
|
||||
T extends LovelaceCardConfig = LovelaceCardConfig,
|
||||
> {
|
||||
heading: string;
|
||||
config: string;
|
||||
config: T;
|
||||
expectConfigError?: boolean;
|
||||
}
|
||||
|
||||
@customElement("demo-card")
|
||||
@@ -23,12 +29,29 @@ class DemoCard extends LitElement {
|
||||
|
||||
@state() private _size?: number;
|
||||
|
||||
@state() private _configError?: string;
|
||||
|
||||
@query("hui-card", false) private _card?: HuiCard;
|
||||
|
||||
private _config = memoizeOne((config: string) => {
|
||||
const c = (load(config) as any)[0];
|
||||
return c;
|
||||
});
|
||||
private _yamlConfig = memoizeOne((config: LovelaceCardConfig) =>
|
||||
dump([config]).trim()
|
||||
);
|
||||
|
||||
protected async firstUpdated() {
|
||||
try {
|
||||
await validateCardConfig(this.config.config);
|
||||
} catch (err) {
|
||||
if (this.config.expectConfigError) {
|
||||
return;
|
||||
}
|
||||
this._configError = err instanceof Error ? err.message : String(err);
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.config.expectConfigError) {
|
||||
this._configError = `Expected config error for ${this.config.heading}`;
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
return html`
|
||||
@@ -40,15 +63,20 @@ class DemoCard extends LitElement {
|
||||
: ""
|
||||
}
|
||||
</h2>
|
||||
${
|
||||
this._configError
|
||||
? html`<ha-alert alert-type="error">${this._configError}</ha-alert>`
|
||||
: nothing
|
||||
}
|
||||
<div class="root">
|
||||
<hui-card
|
||||
.config=${this._config(this.config.config)}
|
||||
.config=${this.config.config}
|
||||
.hass=${this.hass}
|
||||
@card-updated=${this._cardUpdated}
|
||||
></hui-card>
|
||||
${
|
||||
this.showConfig
|
||||
? html`<pre>${this.config.config.trim()}</pre>`
|
||||
? html`<pre>${this._yamlConfig(this.config.config)}</pre>`
|
||||
: nothing
|
||||
}
|
||||
</div>
|
||||
@@ -81,6 +109,9 @@ class DemoCard extends LitElement {
|
||||
font-size: 0.5em;
|
||||
color: var(--primary-text-color);
|
||||
}
|
||||
ha-alert {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
hui-card {
|
||||
max-width: 400px;
|
||||
width: 100vw;
|
||||
|
||||
@@ -23,7 +23,7 @@ export class DemoHaProgressButton extends LitElement {
|
||||
<ha-progress-button @click=${this._clickedFail}>
|
||||
Fail
|
||||
</ha-progress-button>
|
||||
<ha-progress-button size="small" @click=${this._clickedSuccess}>
|
||||
<ha-progress-button size="s" @click=${this._clickedSuccess}>
|
||||
small
|
||||
</ha-progress-button>
|
||||
<ha-progress-button
|
||||
|
||||
@@ -106,6 +106,17 @@ export class DemoHaSelectBox extends LitElement {
|
||||
</ha-card>
|
||||
`;
|
||||
})}
|
||||
<ha-card>
|
||||
<div class="card-content">
|
||||
<label>Disabled with a selected option</label>
|
||||
<ha-select-box
|
||||
.value=${"card"}
|
||||
.options=${fullOptions}
|
||||
.disabled=${true}
|
||||
>
|
||||
</ha-select-box>
|
||||
</div>
|
||||
</ha-card>
|
||||
<ha-card>
|
||||
<div class="card-content">
|
||||
<p class="title"><b>Column layout</b></p>
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import type { PropertyValues, TemplateResult } from "lit";
|
||||
import { html, LitElement } from "lit";
|
||||
import { customElement, query } from "lit/decorators";
|
||||
import type { AlarmPanelCardConfig } from "../../../../src/panels/lovelace/cards/types";
|
||||
import type { DemoCardConfig } from "../../components/demo-card";
|
||||
import { provideHass } from "../../../../src/fake_data/provide_hass";
|
||||
import "../../components/demo-cards";
|
||||
import { mockIcons } from "../../../../demo/src/stubs/icons";
|
||||
@@ -40,52 +42,50 @@ const ENTITIES = [
|
||||
const CONFIGS = [
|
||||
{
|
||||
heading: "Basic Example",
|
||||
config: `
|
||||
- type: alarm-panel
|
||||
entity: alarm_control_panel.alarm
|
||||
`,
|
||||
config: {
|
||||
type: "alarm-panel",
|
||||
entity: "alarm_control_panel.alarm",
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: "With Title",
|
||||
config: `
|
||||
- type: alarm-panel
|
||||
entity: alarm_control_panel.alarm_armed
|
||||
name: My Alarm
|
||||
`,
|
||||
config: {
|
||||
type: "alarm-panel",
|
||||
entity: "alarm_control_panel.alarm_armed",
|
||||
name: "My Alarm",
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: "Code Example",
|
||||
config: `
|
||||
- type: alarm-panel
|
||||
entity: alarm_control_panel.alarm_code
|
||||
`,
|
||||
config: {
|
||||
type: "alarm-panel",
|
||||
entity: "alarm_control_panel.alarm_code",
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: "Using only Arm_Home State",
|
||||
config: `
|
||||
- type: alarm-panel
|
||||
entity: alarm_control_panel.alarm
|
||||
states:
|
||||
- arm_home
|
||||
`,
|
||||
config: {
|
||||
type: "alarm-panel",
|
||||
entity: "alarm_control_panel.alarm",
|
||||
states: ["arm_home"],
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: "Unavailable",
|
||||
config: `
|
||||
- type: alarm-panel
|
||||
entity: alarm_control_panel.unavailable
|
||||
states:
|
||||
- arm_home
|
||||
`,
|
||||
config: {
|
||||
type: "alarm-panel",
|
||||
entity: "alarm_control_panel.unavailable",
|
||||
states: ["arm_home"],
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: "Invalid Entity",
|
||||
config: `
|
||||
- type: alarm-panel
|
||||
entity: alarm_control_panel.alarm1
|
||||
`,
|
||||
config: {
|
||||
type: "alarm-panel",
|
||||
entity: "alarm_control_panel.alarm1",
|
||||
},
|
||||
},
|
||||
];
|
||||
] satisfies DemoCardConfig<AlarmPanelCardConfig>[];
|
||||
|
||||
@customElement("demo-lovelace-alarm-panel-card")
|
||||
class DemoAlarmPanelEntity extends LitElement {
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import type { PropertyValues, TemplateResult } from "lit";
|
||||
import { html, LitElement } from "lit";
|
||||
import { customElement, query } from "lit/decorators";
|
||||
import type { AreaCardConfig } from "../../../../src/panels/lovelace/cards/types";
|
||||
import type { DemoCardConfig } from "../../components/demo-card";
|
||||
import { provideHass } from "../../../../src/fake_data/provide_hass";
|
||||
import "../../components/demo-cards";
|
||||
import { mockIcons } from "../../../../demo/src/stubs/icons";
|
||||
@@ -80,33 +82,33 @@ const ENTITIES = [
|
||||
const CONFIGS = [
|
||||
{
|
||||
heading: "Bedroom",
|
||||
config: `
|
||||
- type: area
|
||||
area: bedroom
|
||||
`,
|
||||
config: {
|
||||
type: "area",
|
||||
area: "bedroom",
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: "Living Room",
|
||||
config: `
|
||||
- type: area
|
||||
area: living_room
|
||||
`,
|
||||
config: {
|
||||
type: "area",
|
||||
area: "living_room",
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: "Office",
|
||||
config: `
|
||||
- type: area
|
||||
area: office
|
||||
`,
|
||||
config: {
|
||||
type: "area",
|
||||
area: "office",
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: "Kitchen",
|
||||
config: `
|
||||
- type: area
|
||||
area: kitchen
|
||||
`,
|
||||
config: {
|
||||
type: "area",
|
||||
area: "kitchen",
|
||||
},
|
||||
},
|
||||
];
|
||||
] satisfies DemoCardConfig<AreaCardConfig>[];
|
||||
|
||||
@customElement("demo-lovelace-area-card")
|
||||
class DemoArea extends LitElement {
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
import type { PropertyValues, TemplateResult } from "lit";
|
||||
import { html, LitElement } from "lit";
|
||||
import { customElement, query } from "lit/decorators";
|
||||
import type { DemoCardConfig } from "../../components/demo-card";
|
||||
import { provideHass } from "../../../../src/fake_data/provide_hass";
|
||||
import type {
|
||||
ConditionalCardConfig,
|
||||
EntitiesCardConfig,
|
||||
} from "../../../../src/panels/lovelace/cards/types";
|
||||
import "../../components/demo-cards";
|
||||
import { mockIcons } from "../../../../demo/src/stubs/icons";
|
||||
|
||||
@@ -39,35 +44,37 @@ const ENTITIES = [
|
||||
const CONFIGS = [
|
||||
{
|
||||
heading: "Controller",
|
||||
config: `
|
||||
- type: entities
|
||||
entities:
|
||||
- light.controller_1
|
||||
- light.controller_2
|
||||
- type: divider
|
||||
- light.floor
|
||||
- light.kitchen
|
||||
`,
|
||||
config: {
|
||||
type: "entities",
|
||||
entities: [
|
||||
"light.controller_1",
|
||||
"light.controller_2",
|
||||
{ type: "divider" },
|
||||
"light.floor",
|
||||
"light.kitchen",
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: "Demo",
|
||||
config: `
|
||||
- type: conditional
|
||||
conditions:
|
||||
- entity: light.controller_1
|
||||
state: "on"
|
||||
- entity: light.controller_2
|
||||
state_not: "off"
|
||||
card:
|
||||
type: entities
|
||||
entities:
|
||||
- light.controller_1
|
||||
- light.controller_2
|
||||
- light.floor
|
||||
- light.kitchen
|
||||
`,
|
||||
config: {
|
||||
type: "conditional",
|
||||
conditions: [
|
||||
{ entity: "light.controller_1", state: "on" },
|
||||
{ entity: "light.controller_2", state_not: "off" },
|
||||
],
|
||||
card: {
|
||||
type: "entities",
|
||||
entities: [
|
||||
"light.controller_1",
|
||||
"light.controller_2",
|
||||
"light.floor",
|
||||
"light.kitchen",
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
] satisfies DemoCardConfig<EntitiesCardConfig | ConditionalCardConfig>[];
|
||||
|
||||
@customElement("demo-lovelace-conditional-card")
|
||||
class DemoConditional extends LitElement {
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
import type { PropertyValues, TemplateResult } from "lit";
|
||||
import { html, LitElement } from "lit";
|
||||
import { customElement, query } from "lit/decorators";
|
||||
import type { DemoCardConfig } from "../../components/demo-card";
|
||||
import { provideHass } from "../../../../src/fake_data/provide_hass";
|
||||
import type {
|
||||
EntitiesCardConfig,
|
||||
EntitiesCardEntityConfig,
|
||||
} from "../../../../src/panels/lovelace/cards/types";
|
||||
import type { CallServiceConfig } from "../../../../src/panels/lovelace/entity-rows/types";
|
||||
import "../../components/demo-cards";
|
||||
import { mockIcons } from "../../../../demo/src/stubs/icons";
|
||||
|
||||
@@ -254,169 +260,194 @@ const ENTITIES = [
|
||||
},
|
||||
];
|
||||
|
||||
type GalleryEntitiesCardConfig = Omit<EntitiesCardConfig, "entities"> & {
|
||||
type: EntitiesCardConfig["type"];
|
||||
entities: (
|
||||
| EntitiesCardConfig["entities"][number]
|
||||
| Pick<EntitiesCardEntityConfig, "entity" | "secondary_info">
|
||||
| Omit<CallServiceConfig, "entity">
|
||||
)[];
|
||||
};
|
||||
|
||||
const CONFIGS = [
|
||||
{
|
||||
heading: "Basic",
|
||||
config: `
|
||||
- type: entities
|
||||
entities:
|
||||
- scene.romantic_lights
|
||||
- device_tracker.demo_paulus
|
||||
- cover.kitchen_window
|
||||
- group.kitchen
|
||||
- lock.kitchen_door
|
||||
- light.bed_light
|
||||
- light.non_existing
|
||||
- climate.ecobee
|
||||
- input_number.number
|
||||
- sensor.humidity
|
||||
- text.message
|
||||
- event.doorbell
|
||||
`,
|
||||
config: {
|
||||
type: "entities",
|
||||
entities: [
|
||||
"scene.romantic_lights",
|
||||
"device_tracker.demo_paulus",
|
||||
"cover.kitchen_window",
|
||||
"group.kitchen",
|
||||
"lock.kitchen_door",
|
||||
"light.bed_light",
|
||||
"light.non_existing",
|
||||
"climate.ecobee",
|
||||
"input_number.number",
|
||||
"sensor.humidity",
|
||||
"text.message",
|
||||
"event.doorbell",
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: "With enabled state color",
|
||||
config: `
|
||||
- type: entities
|
||||
state_color: true
|
||||
entities:
|
||||
- scene.romantic_lights
|
||||
- device_tracker.demo_paulus
|
||||
- cover.kitchen_window
|
||||
- group.kitchen
|
||||
- lock.kitchen_door
|
||||
- light.bed_light
|
||||
- light.non_existing
|
||||
- climate.ecobee
|
||||
- input_number.number
|
||||
- sensor.humidity
|
||||
- text.message
|
||||
`,
|
||||
config: {
|
||||
type: "entities",
|
||||
state_color: true,
|
||||
entities: [
|
||||
"scene.romantic_lights",
|
||||
"device_tracker.demo_paulus",
|
||||
"cover.kitchen_window",
|
||||
"group.kitchen",
|
||||
"lock.kitchen_door",
|
||||
"light.bed_light",
|
||||
"light.non_existing",
|
||||
"climate.ecobee",
|
||||
"input_number.number",
|
||||
"sensor.humidity",
|
||||
"text.message",
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: "Helpers",
|
||||
config: `
|
||||
- type: entities
|
||||
title: Helpers
|
||||
entities:
|
||||
- entity: input_boolean.toggle
|
||||
- entity: input_datetime.date_and_time
|
||||
- entity: input_number.number
|
||||
- entity: input_select.dropdown
|
||||
- entity: input_text.text
|
||||
- entity: timer.timer
|
||||
- entity: counter.counter
|
||||
`,
|
||||
config: {
|
||||
type: "entities",
|
||||
title: "Helpers",
|
||||
entities: [
|
||||
{ entity: "input_boolean.toggle" },
|
||||
{ entity: "input_datetime.date_and_time" },
|
||||
{ entity: "input_number.number" },
|
||||
{ entity: "input_select.dropdown" },
|
||||
{ entity: "input_text.text" },
|
||||
{ entity: "timer.timer" },
|
||||
{ entity: "counter.counter" },
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: "With title, toggle-able",
|
||||
config: `
|
||||
- type: entities
|
||||
entities:
|
||||
- scene.romantic_lights
|
||||
- device_tracker.demo_paulus
|
||||
- cover.kitchen_window
|
||||
- group.kitchen
|
||||
- lock.kitchen_door
|
||||
- light.bed_light
|
||||
- climate.ecobee
|
||||
- input_number.number
|
||||
title: Random group
|
||||
`,
|
||||
config: {
|
||||
type: "entities",
|
||||
entities: [
|
||||
"scene.romantic_lights",
|
||||
"device_tracker.demo_paulus",
|
||||
"cover.kitchen_window",
|
||||
"group.kitchen",
|
||||
"lock.kitchen_door",
|
||||
"light.bed_light",
|
||||
"climate.ecobee",
|
||||
"input_number.number",
|
||||
],
|
||||
title: "Random group",
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: "With title, toggle = false",
|
||||
config: `
|
||||
- type: entities
|
||||
entities:
|
||||
- scene.romantic_lights
|
||||
- device_tracker.demo_paulus
|
||||
- cover.kitchen_window
|
||||
- group.kitchen
|
||||
- lock.kitchen_door
|
||||
- light.bed_light
|
||||
- climate.ecobee
|
||||
- input_number.number
|
||||
title: Random group
|
||||
show_header_toggle: false
|
||||
`,
|
||||
config: {
|
||||
type: "entities",
|
||||
entities: [
|
||||
"scene.romantic_lights",
|
||||
"device_tracker.demo_paulus",
|
||||
"cover.kitchen_window",
|
||||
"group.kitchen",
|
||||
"lock.kitchen_door",
|
||||
"light.bed_light",
|
||||
"climate.ecobee",
|
||||
"input_number.number",
|
||||
],
|
||||
title: "Random group",
|
||||
show_header_toggle: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: "With title, can't toggle",
|
||||
config: `
|
||||
- type: entities
|
||||
entities:
|
||||
- device_tracker.demo_paulus
|
||||
title: Random group
|
||||
`,
|
||||
config: {
|
||||
type: "entities",
|
||||
entities: ["device_tracker.demo_paulus"],
|
||||
title: "Random group",
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: "Unavailable",
|
||||
config: `
|
||||
- type: entities
|
||||
entities:
|
||||
- scene.unavailable
|
||||
- device_tracker.unavailable
|
||||
- cover.unavailable
|
||||
- lock.unavailable
|
||||
- light.unavailable
|
||||
- climate.unavailable
|
||||
- input_number.unavailable
|
||||
- input_select.unavailable
|
||||
- text.unavailable
|
||||
- event.unavailable
|
||||
`,
|
||||
config: {
|
||||
type: "entities",
|
||||
entities: [
|
||||
"scene.unavailable",
|
||||
"device_tracker.unavailable",
|
||||
"cover.unavailable",
|
||||
"lock.unavailable",
|
||||
"light.unavailable",
|
||||
"climate.unavailable",
|
||||
"input_number.unavailable",
|
||||
"input_select.unavailable",
|
||||
"text.unavailable",
|
||||
"event.unavailable",
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: "Custom name, secondary info, custom icon",
|
||||
config: `
|
||||
- type: entities
|
||||
entities:
|
||||
- entity: scene.romantic_lights
|
||||
name: ¯\\_(ツ)_/¯
|
||||
- entity: device_tracker.demo_paulus
|
||||
secondary_info: entity-id
|
||||
- entity: cover.kitchen_window
|
||||
secondary_info: last-changed
|
||||
- entity: group.kitchen
|
||||
icon: mdi:home-assistant
|
||||
- lock.kitchen_door
|
||||
- entity: light.bed_light
|
||||
icon: mdi:alarm-light
|
||||
name: Bed Light Custom Icon
|
||||
- climate.ecobee
|
||||
- input_number.number
|
||||
title: Random group
|
||||
show_header_toggle: false
|
||||
`,
|
||||
config: {
|
||||
type: "entities",
|
||||
entities: [
|
||||
{ entity: "scene.romantic_lights", name: "¯\\_(ツ)_/¯" },
|
||||
{
|
||||
entity: "device_tracker.demo_paulus",
|
||||
secondary_info: "entity-id",
|
||||
},
|
||||
{
|
||||
entity: "cover.kitchen_window",
|
||||
secondary_info: "last-changed",
|
||||
},
|
||||
{ entity: "group.kitchen", icon: "mdi:home-assistant" },
|
||||
"lock.kitchen_door",
|
||||
{
|
||||
entity: "light.bed_light",
|
||||
icon: "mdi:alarm-light",
|
||||
name: "Bed Light Custom Icon",
|
||||
},
|
||||
"climate.ecobee",
|
||||
"input_number.number",
|
||||
],
|
||||
title: "Random group",
|
||||
show_header_toggle: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: "Special rows",
|
||||
config: `
|
||||
- type: entities
|
||||
entities:
|
||||
- type: perform-action
|
||||
icon: mdi:power
|
||||
name: Bed light
|
||||
action_name: Toggle light
|
||||
action: light.toggle
|
||||
data:
|
||||
entity_id: light.bed_light
|
||||
- type: section
|
||||
label: Links
|
||||
- type: weblink
|
||||
url: http://google.com/
|
||||
icon: mdi:google
|
||||
name: Google
|
||||
- type: divider
|
||||
- type: divider
|
||||
style:
|
||||
height: 30px
|
||||
margin: 4px 0
|
||||
background: center / contain url("/images/divider.png") no-repeat
|
||||
`,
|
||||
config: {
|
||||
type: "entities",
|
||||
entities: [
|
||||
{
|
||||
type: "perform-action",
|
||||
icon: "mdi:power",
|
||||
name: "Bed light",
|
||||
action_name: "Toggle light",
|
||||
action: "light.toggle",
|
||||
data: { entity_id: "light.bed_light" },
|
||||
},
|
||||
{ type: "section", label: "Links" },
|
||||
{
|
||||
type: "weblink",
|
||||
url: "http://google.com/",
|
||||
icon: "mdi:google",
|
||||
name: "Google",
|
||||
},
|
||||
{ type: "divider" },
|
||||
{
|
||||
type: "divider",
|
||||
style: {
|
||||
height: "30px",
|
||||
margin: "4px 0",
|
||||
background: 'center / contain url("/images/divider.png") no-repeat',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
];
|
||||
] satisfies DemoCardConfig<GalleryEntitiesCardConfig>[];
|
||||
|
||||
@customElement("demo-lovelace-entities-card")
|
||||
class DemoEntities extends LitElement {
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import type { PropertyValues, TemplateResult } from "lit";
|
||||
import { html, LitElement } from "lit";
|
||||
import { customElement, query } from "lit/decorators";
|
||||
import type { ButtonCardConfig } from "../../../../src/panels/lovelace/cards/types";
|
||||
import type { DemoCardConfig } from "../../components/demo-card";
|
||||
import { provideHass } from "../../../../src/fake_data/provide_hass";
|
||||
import "../../components/demo-cards";
|
||||
import { mockIcons } from "../../../../demo/src/stubs/icons";
|
||||
@@ -18,60 +20,64 @@ const ENTITIES = [
|
||||
const CONFIGS = [
|
||||
{
|
||||
heading: "Basic example",
|
||||
config: `
|
||||
- type: button
|
||||
entity: light.bed_light
|
||||
`,
|
||||
config: {
|
||||
type: "button",
|
||||
entity: "light.bed_light",
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: "With Name (defined in card)",
|
||||
config: `
|
||||
- type: button
|
||||
name: Custom Name
|
||||
entity: light.bed_light
|
||||
`,
|
||||
config: {
|
||||
type: "button",
|
||||
name: "Custom Name",
|
||||
entity: "light.bed_light",
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: "With Icon",
|
||||
config: `
|
||||
- type: button
|
||||
entity: light.bed_light
|
||||
icon: mdi:tools
|
||||
`,
|
||||
config: {
|
||||
type: "button",
|
||||
entity: "light.bed_light",
|
||||
icon: "mdi:tools",
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: "With State",
|
||||
config: `
|
||||
- type: button
|
||||
entity: light.bed_light
|
||||
show_state: true
|
||||
`,
|
||||
config: {
|
||||
type: "button",
|
||||
entity: "light.bed_light",
|
||||
show_state: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: "Custom Tap Action (toggle)",
|
||||
config: `
|
||||
- type: button
|
||||
entity: light.bed_light
|
||||
tap_action:
|
||||
action: toggle
|
||||
`,
|
||||
config: {
|
||||
type: "button",
|
||||
entity: "light.bed_light",
|
||||
tap_action: {
|
||||
action: "toggle",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: "Running Service",
|
||||
config: `
|
||||
- type: button
|
||||
entity: light.bed_light
|
||||
service: light.toggle
|
||||
`,
|
||||
config: {
|
||||
type: "button",
|
||||
entity: "light.bed_light",
|
||||
tap_action: {
|
||||
action: "perform-action",
|
||||
perform_action: "light.toggle",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: "Invalid Entity",
|
||||
config: `
|
||||
- type: button
|
||||
entity: sensor.invalid_entity
|
||||
`,
|
||||
config: {
|
||||
type: "button",
|
||||
entity: "sensor.invalid_entity",
|
||||
},
|
||||
},
|
||||
];
|
||||
] satisfies DemoCardConfig<ButtonCardConfig>[];
|
||||
|
||||
@customElement("demo-lovelace-entity-button-card")
|
||||
class DemoButtonEntity extends LitElement {
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
import type { PropertyValues, TemplateResult } from "lit";
|
||||
import { html, LitElement } from "lit";
|
||||
import { customElement, query } from "lit/decorators";
|
||||
import type { DemoCardConfig } from "../../components/demo-card";
|
||||
import { provideHass } from "../../../../src/fake_data/provide_hass";
|
||||
import type {
|
||||
EntitiesCardConfig,
|
||||
EntityFilterCardConfig,
|
||||
} from "../../../../src/panels/lovelace/cards/types";
|
||||
import "../../components/demo-cards";
|
||||
import { mockIcons } from "../../../../demo/src/stubs/icons";
|
||||
|
||||
@@ -114,184 +119,198 @@ const ENTITIES = [
|
||||
},
|
||||
];
|
||||
|
||||
const CONFIGS = [
|
||||
type StateFilterEntityFilterCardConfig = Pick<
|
||||
EntityFilterCardConfig,
|
||||
"type" | "entities" | "card" | "show_empty"
|
||||
> & {
|
||||
conditions?: never;
|
||||
state_filter: NonNullable<EntityFilterCardConfig["state_filter"]>;
|
||||
};
|
||||
|
||||
const VALID_CONFIGS = [
|
||||
{
|
||||
heading: "Unfiltered entities",
|
||||
config: `
|
||||
- type: entities
|
||||
entities:
|
||||
- device_tracker.demo_anne_therese
|
||||
- device_tracker.demo_home_boy
|
||||
- device_tracker.demo_paulus
|
||||
- light.bed_light
|
||||
- light.ceiling_lights
|
||||
- light.kitchen_lights
|
||||
`,
|
||||
config: {
|
||||
type: "entities",
|
||||
entities: [
|
||||
"device_tracker.demo_anne_therese",
|
||||
"device_tracker.demo_home_boy",
|
||||
"device_tracker.demo_paulus",
|
||||
"light.bed_light",
|
||||
"light.ceiling_lights",
|
||||
"light.kitchen_lights",
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: "On and home entities",
|
||||
config: `
|
||||
- type: entity-filter
|
||||
entities:
|
||||
- device_tracker.demo_anne_therese
|
||||
- device_tracker.demo_home_boy
|
||||
- device_tracker.demo_paulus
|
||||
- light.bed_light
|
||||
- light.ceiling_lights
|
||||
- light.kitchen_lights
|
||||
conditions:
|
||||
- condition: state
|
||||
state:
|
||||
- "on"
|
||||
- home
|
||||
`,
|
||||
config: {
|
||||
type: "entity-filter",
|
||||
entities: [
|
||||
"device_tracker.demo_anne_therese",
|
||||
"device_tracker.demo_home_boy",
|
||||
"device_tracker.demo_paulus",
|
||||
"light.bed_light",
|
||||
"light.ceiling_lights",
|
||||
"light.kitchen_lights",
|
||||
],
|
||||
conditions: [{ condition: "state", state: ["on", "home"] }],
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: "Same state as Bed Light",
|
||||
config: `
|
||||
- type: entity-filter
|
||||
entities:
|
||||
- device_tracker.demo_anne_therese
|
||||
- device_tracker.demo_home_boy
|
||||
- device_tracker.demo_paulus
|
||||
- light.bed_light
|
||||
- light.ceiling_lights
|
||||
- light.kitchen_lights
|
||||
conditions:
|
||||
- condition: state
|
||||
state:
|
||||
- light.bed_light
|
||||
`,
|
||||
config: {
|
||||
type: "entity-filter",
|
||||
entities: [
|
||||
"device_tracker.demo_anne_therese",
|
||||
"device_tracker.demo_home_boy",
|
||||
"device_tracker.demo_paulus",
|
||||
"light.bed_light",
|
||||
"light.ceiling_lights",
|
||||
"light.kitchen_lights",
|
||||
],
|
||||
conditions: [{ condition: "state", state: ["light.bed_light"] }],
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: 'With "entities" card config',
|
||||
config: `
|
||||
- type: entity-filter
|
||||
entities:
|
||||
- device_tracker.demo_anne_therese
|
||||
- device_tracker.demo_home_boy
|
||||
- device_tracker.demo_paulus
|
||||
- light.bed_light
|
||||
- light.ceiling_lights
|
||||
- light.kitchen_lights
|
||||
conditions:
|
||||
- condition: state
|
||||
state:
|
||||
- "on"
|
||||
- home
|
||||
card:
|
||||
type: entities
|
||||
title: Custom Title
|
||||
show_header_toggle: false
|
||||
`,
|
||||
config: {
|
||||
type: "entity-filter",
|
||||
entities: [
|
||||
"device_tracker.demo_anne_therese",
|
||||
"device_tracker.demo_home_boy",
|
||||
"device_tracker.demo_paulus",
|
||||
"light.bed_light",
|
||||
"light.ceiling_lights",
|
||||
"light.kitchen_lights",
|
||||
],
|
||||
conditions: [{ condition: "state", state: ["on", "home"] }],
|
||||
card: {
|
||||
type: "entities",
|
||||
title: "Custom Title",
|
||||
show_header_toggle: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: 'With "glance" card config',
|
||||
config: `
|
||||
- type: entity-filter
|
||||
entities:
|
||||
- device_tracker.demo_anne_therese
|
||||
- device_tracker.demo_home_boy
|
||||
- device_tracker.demo_paulus
|
||||
- light.bed_light
|
||||
- light.ceiling_lights
|
||||
- light.kitchen_lights
|
||||
conditions:
|
||||
- condition: state
|
||||
state:
|
||||
- "on"
|
||||
- home
|
||||
card:
|
||||
type: glance
|
||||
show_state: true
|
||||
title: Custom Title
|
||||
`,
|
||||
config: {
|
||||
type: "entity-filter",
|
||||
entities: [
|
||||
"device_tracker.demo_anne_therese",
|
||||
"device_tracker.demo_home_boy",
|
||||
"device_tracker.demo_paulus",
|
||||
"light.bed_light",
|
||||
"light.ceiling_lights",
|
||||
"light.kitchen_lights",
|
||||
],
|
||||
conditions: [{ condition: "state", state: ["on", "home"] }],
|
||||
card: {
|
||||
type: "glance",
|
||||
show_state: true,
|
||||
title: "Custom Title",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
heading:
|
||||
"Filtered entities by battery attribute (< '30') using state filter",
|
||||
config: `
|
||||
- type: entity-filter
|
||||
entities:
|
||||
- device_tracker.demo_anne_therese
|
||||
- device_tracker.demo_home_boy
|
||||
- device_tracker.demo_paulus
|
||||
state_filter:
|
||||
- operator: <
|
||||
attribute: battery
|
||||
value: "30"
|
||||
`,
|
||||
config: {
|
||||
type: "entity-filter",
|
||||
entities: [
|
||||
"device_tracker.demo_anne_therese",
|
||||
"device_tracker.demo_home_boy",
|
||||
"device_tracker.demo_paulus",
|
||||
],
|
||||
state_filter: [{ operator: "<", attribute: "battery", value: "30" }],
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: "Unfiltered number entities",
|
||||
config: `
|
||||
- type: entities
|
||||
entities:
|
||||
- input_number.min_battery_level
|
||||
- sensor.battery_1
|
||||
- sensor.battery_3
|
||||
- sensor.battery_2
|
||||
- sensor.battery_4
|
||||
`,
|
||||
config: {
|
||||
type: "entities",
|
||||
entities: [
|
||||
"input_number.min_battery_level",
|
||||
"sensor.battery_1",
|
||||
"sensor.battery_3",
|
||||
"sensor.battery_2",
|
||||
"sensor.battery_4",
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: "Battery lower than 50%",
|
||||
config: `
|
||||
- type: entity-filter
|
||||
entities:
|
||||
- sensor.battery_1
|
||||
- sensor.battery_3
|
||||
- sensor.battery_2
|
||||
- sensor.battery_4
|
||||
conditions:
|
||||
- condition: numeric_state
|
||||
below: 50
|
||||
`,
|
||||
config: {
|
||||
type: "entity-filter",
|
||||
entities: [
|
||||
"sensor.battery_1",
|
||||
"sensor.battery_3",
|
||||
"sensor.battery_2",
|
||||
"sensor.battery_4",
|
||||
],
|
||||
conditions: [{ condition: "numeric_state", below: 50 }],
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: "Battery lower than min battery level",
|
||||
config: `
|
||||
- type: entity-filter
|
||||
entities:
|
||||
- sensor.battery_1
|
||||
- sensor.battery_3
|
||||
- sensor.battery_2
|
||||
- sensor.battery_4
|
||||
conditions:
|
||||
- condition: numeric_state
|
||||
below: input_number.min_battery_level
|
||||
`,
|
||||
config: {
|
||||
type: "entity-filter",
|
||||
entities: [
|
||||
"sensor.battery_1",
|
||||
"sensor.battery_3",
|
||||
"sensor.battery_2",
|
||||
"sensor.battery_4",
|
||||
],
|
||||
conditions: [
|
||||
{ condition: "numeric_state", below: "input_number.min_battery_level" },
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: "Battery between min battery level and 70%",
|
||||
config: `
|
||||
- type: entity-filter
|
||||
entities:
|
||||
- sensor.battery_1
|
||||
- sensor.battery_3
|
||||
- sensor.battery_2
|
||||
- sensor.battery_4
|
||||
conditions:
|
||||
- condition: numeric_state
|
||||
above: input_number.min_battery_level
|
||||
below: 70
|
||||
`,
|
||||
config: {
|
||||
type: "entity-filter",
|
||||
entities: [
|
||||
"sensor.battery_1",
|
||||
"sensor.battery_3",
|
||||
"sensor.battery_2",
|
||||
"sensor.battery_4",
|
||||
],
|
||||
conditions: [
|
||||
{
|
||||
condition: "numeric_state",
|
||||
above: "input_number.min_battery_level",
|
||||
below: 70,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
] satisfies DemoCardConfig<
|
||||
| EntitiesCardConfig
|
||||
| EntityFilterCardConfig
|
||||
| StateFilterEntityFilterCardConfig
|
||||
>[];
|
||||
|
||||
const INVALID_CONFIGS = [
|
||||
{
|
||||
heading: "Error: Entities must be specified",
|
||||
config: `
|
||||
- type: entity-filter
|
||||
`,
|
||||
config: { type: "entity-filter" },
|
||||
expectConfigError: true,
|
||||
},
|
||||
{
|
||||
heading: "Error: Incorrect filter config",
|
||||
config: `
|
||||
- type: entity-filter
|
||||
entities:
|
||||
- sensor.gas_station_lowest_price
|
||||
`,
|
||||
config: {
|
||||
type: "entity-filter",
|
||||
entities: ["sensor.gas_station_lowest_price"],
|
||||
},
|
||||
expectConfigError: true,
|
||||
},
|
||||
];
|
||||
] satisfies DemoCardConfig<
|
||||
| Pick<EntityFilterCardConfig, "type">
|
||||
| Pick<EntityFilterCardConfig, "type" | "entities">
|
||||
>[];
|
||||
|
||||
const CONFIGS = [...VALID_CONFIGS, ...INVALID_CONFIGS];
|
||||
|
||||
@customElement("demo-lovelace-entity-filter-card")
|
||||
class DemoEntityFilter extends LitElement {
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import type { PropertyValues, TemplateResult } from "lit";
|
||||
import { html, LitElement } from "lit";
|
||||
import { customElement, query } from "lit/decorators";
|
||||
import type { DemoCardConfig } from "../../components/demo-card";
|
||||
import { provideHass } from "../../../../src/fake_data/provide_hass";
|
||||
import type { GaugeCardConfig } from "../../../../src/panels/lovelace/cards/types";
|
||||
import "../../components/demo-cards";
|
||||
import { mockIcons } from "../../../../demo/src/stubs/icons";
|
||||
|
||||
@@ -30,158 +32,156 @@ const ENTITIES = [
|
||||
const CONFIGS = [
|
||||
{
|
||||
heading: "Basic example",
|
||||
config: `
|
||||
- type: gauge
|
||||
entity: sensor.outside_humidity
|
||||
name: Outside Humidity
|
||||
`,
|
||||
config: {
|
||||
type: "gauge",
|
||||
entity: "sensor.outside_humidity",
|
||||
name: "Outside Humidity",
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: "Custom unit of measurement",
|
||||
config: `
|
||||
- type: gauge
|
||||
entity: sensor.outside_temperature
|
||||
unit_of_measurement: C
|
||||
name: Outside Temperature
|
||||
`,
|
||||
config: {
|
||||
type: "gauge",
|
||||
entity: "sensor.outside_temperature",
|
||||
unit: "C",
|
||||
name: "Outside Temperature",
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: "Rendering needle",
|
||||
config: `
|
||||
- type: gauge
|
||||
entity: sensor.outside_humidity
|
||||
name: Outside Humidity
|
||||
needle: true
|
||||
`,
|
||||
config: {
|
||||
type: "gauge",
|
||||
entity: "sensor.outside_humidity",
|
||||
name: "Outside Humidity",
|
||||
needle: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: "Rendering needle and severity levels",
|
||||
config: `
|
||||
- type: gauge
|
||||
entity: sensor.brightness_high
|
||||
name: Brightness High
|
||||
needle: true
|
||||
severity:
|
||||
red: 75
|
||||
green: 0
|
||||
yellow: 50
|
||||
`,
|
||||
config: {
|
||||
type: "gauge",
|
||||
entity: "sensor.brightness_high",
|
||||
name: "Brightness High",
|
||||
needle: true,
|
||||
severity: {
|
||||
red: 75,
|
||||
green: 0,
|
||||
yellow: 50,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: "Setting severity levels",
|
||||
config: `
|
||||
- type: gauge
|
||||
entity: sensor.brightness
|
||||
name: Brightness Low
|
||||
severity:
|
||||
red: 75
|
||||
green: 0
|
||||
yellow: 50
|
||||
`,
|
||||
config: {
|
||||
type: "gauge",
|
||||
entity: "sensor.brightness",
|
||||
name: "Brightness Low",
|
||||
severity: {
|
||||
red: 75,
|
||||
green: 0,
|
||||
yellow: 50,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: "Setting severity levels",
|
||||
config: `
|
||||
- type: gauge
|
||||
entity: sensor.brightness_medium
|
||||
name: Brightness Medium
|
||||
severity:
|
||||
red: 75
|
||||
green: 0
|
||||
yellow: 50
|
||||
`,
|
||||
config: {
|
||||
type: "gauge",
|
||||
entity: "sensor.brightness_medium",
|
||||
name: "Brightness Medium",
|
||||
severity: {
|
||||
red: 75,
|
||||
green: 0,
|
||||
yellow: 50,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: "Setting severity levels",
|
||||
config: `
|
||||
- type: gauge
|
||||
entity: sensor.brightness_high
|
||||
name: Brightness High
|
||||
severity:
|
||||
red: 75
|
||||
green: 0
|
||||
yellow: 50
|
||||
`,
|
||||
config: {
|
||||
type: "gauge",
|
||||
entity: "sensor.brightness_high",
|
||||
name: "Brightness High",
|
||||
severity: {
|
||||
red: 75,
|
||||
green: 0,
|
||||
yellow: 50,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: "Setting min (0) and mx (15) values",
|
||||
config: `
|
||||
- type: gauge
|
||||
entity: sensor.brightness
|
||||
name: Brightness
|
||||
min: 0
|
||||
max: 15
|
||||
`,
|
||||
config: {
|
||||
type: "gauge",
|
||||
entity: "sensor.brightness",
|
||||
name: "Brightness",
|
||||
min: 0,
|
||||
max: 15,
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: "Invalid entity",
|
||||
config: `
|
||||
- type: gauge
|
||||
entity: sensor.invalid_entity
|
||||
`,
|
||||
config: {
|
||||
type: "gauge",
|
||||
entity: "sensor.invalid_entity",
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: "Non-numeric value",
|
||||
config: `
|
||||
- type: gauge
|
||||
entity: plant.bonsai
|
||||
`,
|
||||
config: {
|
||||
type: "gauge",
|
||||
entity: "plant.bonsai",
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: "Unavailable entity",
|
||||
config: `
|
||||
- type: gauge
|
||||
entity: sensor.not_working
|
||||
`,
|
||||
config: {
|
||||
type: "gauge",
|
||||
entity: "sensor.not_working",
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: "Lower minimum",
|
||||
config: `
|
||||
- type: gauge
|
||||
entity: sensor.brightness_high
|
||||
needle: true
|
||||
severity:
|
||||
green: 0
|
||||
yellow: 0.45
|
||||
red: 0.9
|
||||
min: -0.05
|
||||
name: " "
|
||||
max: 1.9
|
||||
unit: GBP/h`,
|
||||
config: {
|
||||
type: "gauge",
|
||||
entity: "sensor.brightness_high",
|
||||
needle: true,
|
||||
severity: {
|
||||
green: 0,
|
||||
yellow: 0.45,
|
||||
red: 0.9,
|
||||
},
|
||||
min: -0.05,
|
||||
name: " ",
|
||||
max: 1.9,
|
||||
unit: "GBP/h",
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: "A lot of segments",
|
||||
config: `
|
||||
- type: gauge
|
||||
needle: true
|
||||
name: Percent gauge
|
||||
entity: sensor.brightness_high
|
||||
unit: "%"
|
||||
min: 0
|
||||
max: 100
|
||||
segments:
|
||||
- from: 0
|
||||
color: "#db4437"
|
||||
- from: 10
|
||||
color: "#cc4d39"
|
||||
- from: 20
|
||||
color: "#bd563a"
|
||||
- from: 30
|
||||
color: "#ad603c"
|
||||
- from: 40
|
||||
color: "#9e693d"
|
||||
- from: 50
|
||||
color: "#8f723f"
|
||||
- from: 60
|
||||
color: "#807b41"
|
||||
- from: 70
|
||||
color: "#718442"
|
||||
- from: 80
|
||||
color: "#618e44"
|
||||
- from: 90
|
||||
color: "#43a047"`,
|
||||
config: {
|
||||
type: "gauge",
|
||||
needle: true,
|
||||
name: "Percent gauge",
|
||||
entity: "sensor.brightness_high",
|
||||
unit: "%",
|
||||
min: 0,
|
||||
max: 100,
|
||||
segments: [
|
||||
{ from: 0, color: "#db4437" },
|
||||
{ from: 10, color: "#cc4d39" },
|
||||
{ from: 20, color: "#bd563a" },
|
||||
{ from: 30, color: "#ad603c" },
|
||||
{ from: 40, color: "#9e693d" },
|
||||
{ from: 50, color: "#8f723f" },
|
||||
{ from: 60, color: "#807b41" },
|
||||
{ from: 70, color: "#718442" },
|
||||
{ from: 80, color: "#618e44" },
|
||||
{ from: 90, color: "#43a047" },
|
||||
],
|
||||
},
|
||||
},
|
||||
];
|
||||
] satisfies DemoCardConfig<GaugeCardConfig>[];
|
||||
|
||||
@customElement("demo-lovelace-gauge-card")
|
||||
class DemoGaugeEntity extends LitElement {
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
import type { PropertyValues, TemplateResult } from "lit";
|
||||
import { html, LitElement } from "lit";
|
||||
import { customElement, query } from "lit/decorators";
|
||||
import type { DemoCardConfig } from "../../components/demo-card";
|
||||
import { provideHass } from "../../../../src/fake_data/provide_hass";
|
||||
import type {
|
||||
GlanceCardConfig,
|
||||
GlanceConfigEntity,
|
||||
} from "../../../../src/panels/lovelace/cards/types";
|
||||
import "../../components/demo-cards";
|
||||
import { mockIcons } from "../../../../demo/src/stubs/icons";
|
||||
|
||||
@@ -86,172 +91,193 @@ const ENTITIES = [
|
||||
},
|
||||
];
|
||||
|
||||
type LegacyNullNameGlanceCardConfig = Omit<GlanceCardConfig, "entities"> & {
|
||||
type: GlanceCardConfig["type"];
|
||||
entities: (
|
||||
| string
|
||||
| GlanceConfigEntity
|
||||
| (Omit<GlanceConfigEntity, "name"> & { name: null })
|
||||
)[];
|
||||
};
|
||||
|
||||
const CONFIGS = [
|
||||
{
|
||||
heading: "Basic example",
|
||||
config: `
|
||||
- type: glance
|
||||
entities:
|
||||
- device_tracker.demo_paulus
|
||||
- media_player.living_room
|
||||
- sun.sun
|
||||
- cover.kitchen_window
|
||||
- light.kitchen_lights
|
||||
- lock.kitchen_door
|
||||
- light.ceiling_lights
|
||||
`,
|
||||
config: {
|
||||
type: "glance",
|
||||
entities: [
|
||||
"device_tracker.demo_paulus",
|
||||
"media_player.living_room",
|
||||
"sun.sun",
|
||||
"cover.kitchen_window",
|
||||
"light.kitchen_lights",
|
||||
"lock.kitchen_door",
|
||||
"light.ceiling_lights",
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: "No state colors",
|
||||
config: `
|
||||
- type: glance
|
||||
state_color: false
|
||||
entities:
|
||||
- device_tracker.demo_paulus
|
||||
- media_player.living_room
|
||||
- sun.sun
|
||||
- cover.kitchen_window
|
||||
- light.kitchen_lights
|
||||
- lock.kitchen_door
|
||||
- light.ceiling_lights
|
||||
`,
|
||||
config: {
|
||||
type: "glance",
|
||||
state_color: false,
|
||||
entities: [
|
||||
"device_tracker.demo_paulus",
|
||||
"media_player.living_room",
|
||||
"sun.sun",
|
||||
"cover.kitchen_window",
|
||||
"light.kitchen_lights",
|
||||
"lock.kitchen_door",
|
||||
"light.ceiling_lights",
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: "With title",
|
||||
config: `
|
||||
- type: glance
|
||||
title: Custom title
|
||||
columns: 4
|
||||
entities:
|
||||
- device_tracker.demo_paulus
|
||||
- media_player.living_room
|
||||
- sun.sun
|
||||
- cover.kitchen_window
|
||||
- light.kitchen_lights
|
||||
- lock.kitchen_door
|
||||
- light.ceiling_lights
|
||||
`,
|
||||
config: {
|
||||
type: "glance",
|
||||
title: "Custom title",
|
||||
columns: 4,
|
||||
entities: [
|
||||
"device_tracker.demo_paulus",
|
||||
"media_player.living_room",
|
||||
"sun.sun",
|
||||
"cover.kitchen_window",
|
||||
"light.kitchen_lights",
|
||||
"lock.kitchen_door",
|
||||
"light.ceiling_lights",
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: "Custom number of columns",
|
||||
config: `
|
||||
- type: glance
|
||||
columns: 7
|
||||
entities:
|
||||
- device_tracker.demo_paulus
|
||||
- media_player.living_room
|
||||
- sun.sun
|
||||
- cover.kitchen_window
|
||||
- light.kitchen_lights
|
||||
- lock.kitchen_door
|
||||
- light.ceiling_lights
|
||||
`,
|
||||
config: {
|
||||
type: "glance",
|
||||
columns: 7,
|
||||
entities: [
|
||||
"device_tracker.demo_paulus",
|
||||
"media_player.living_room",
|
||||
"sun.sun",
|
||||
"cover.kitchen_window",
|
||||
"light.kitchen_lights",
|
||||
"lock.kitchen_door",
|
||||
"light.ceiling_lights",
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: "No entity names",
|
||||
config: `
|
||||
- type: glance
|
||||
columns: 4
|
||||
show_name: false
|
||||
entities:
|
||||
- device_tracker.demo_paulus
|
||||
- media_player.living_room
|
||||
- sun.sun
|
||||
- cover.kitchen_window
|
||||
- light.kitchen_lights
|
||||
- lock.kitchen_door
|
||||
- light.ceiling_lights
|
||||
`,
|
||||
config: {
|
||||
type: "glance",
|
||||
columns: 4,
|
||||
show_name: false,
|
||||
entities: [
|
||||
"device_tracker.demo_paulus",
|
||||
"media_player.living_room",
|
||||
"sun.sun",
|
||||
"cover.kitchen_window",
|
||||
"light.kitchen_lights",
|
||||
"lock.kitchen_door",
|
||||
"light.ceiling_lights",
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: "No state labels",
|
||||
config: `
|
||||
- type: glance
|
||||
columns: 4
|
||||
show_state: false
|
||||
entities:
|
||||
- device_tracker.demo_paulus
|
||||
- media_player.living_room
|
||||
- sun.sun
|
||||
- cover.kitchen_window
|
||||
- light.kitchen_lights
|
||||
- lock.kitchen_door
|
||||
- light.ceiling_lights
|
||||
`,
|
||||
config: {
|
||||
type: "glance",
|
||||
columns: 4,
|
||||
show_state: false,
|
||||
entities: [
|
||||
"device_tracker.demo_paulus",
|
||||
"media_player.living_room",
|
||||
"sun.sun",
|
||||
"cover.kitchen_window",
|
||||
"light.kitchen_lights",
|
||||
"lock.kitchen_door",
|
||||
"light.ceiling_lights",
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: "No names and no state labels",
|
||||
config: `
|
||||
- type: glance
|
||||
columns: 4
|
||||
show_name: false
|
||||
show_state: false
|
||||
entities:
|
||||
- device_tracker.demo_paulus
|
||||
- media_player.living_room
|
||||
- sun.sun
|
||||
- cover.kitchen_window
|
||||
- light.kitchen_lights
|
||||
- lock.kitchen_door
|
||||
- light.ceiling_lights
|
||||
`,
|
||||
config: {
|
||||
type: "glance",
|
||||
columns: 4,
|
||||
show_name: false,
|
||||
show_state: false,
|
||||
entities: [
|
||||
"device_tracker.demo_paulus",
|
||||
"media_player.living_room",
|
||||
"sun.sun",
|
||||
"cover.kitchen_window",
|
||||
"light.kitchen_lights",
|
||||
"lock.kitchen_door",
|
||||
"light.ceiling_lights",
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: "Custom name + custom icon",
|
||||
config: `
|
||||
- type: glance
|
||||
columns: 4
|
||||
entities:
|
||||
- entity: device_tracker.demo_paulus
|
||||
name: ¯\\_(ツ)_/¯
|
||||
icon: mdi:home-assistant
|
||||
- entity: media_player.living_room
|
||||
name: ¯\\_(ツ)_/¯
|
||||
icon: mdi:home-assistant
|
||||
`,
|
||||
config: {
|
||||
type: "glance",
|
||||
columns: 4,
|
||||
entities: [
|
||||
{
|
||||
entity: "device_tracker.demo_paulus",
|
||||
name: "¯\\_(ツ)_/¯",
|
||||
icon: "mdi:home-assistant",
|
||||
},
|
||||
{
|
||||
entity: "media_player.living_room",
|
||||
name: "¯\\_(ツ)_/¯",
|
||||
icon: "mdi:home-assistant",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: "Selectively hidden name",
|
||||
config: `
|
||||
- type: glance
|
||||
columns: 4
|
||||
entities:
|
||||
- device_tracker.demo_paulus
|
||||
- entity: media_player.living_room
|
||||
name:
|
||||
- sun.sun
|
||||
- entity: cover.kitchen_window
|
||||
name:
|
||||
- light.kitchen_lights
|
||||
- entity: lock.kitchen_door
|
||||
name:
|
||||
- light.ceiling_lights
|
||||
`,
|
||||
config: {
|
||||
type: "glance",
|
||||
columns: 4,
|
||||
entities: [
|
||||
"device_tracker.demo_paulus",
|
||||
{ entity: "media_player.living_room", name: null },
|
||||
"sun.sun",
|
||||
{ entity: "cover.kitchen_window", name: null },
|
||||
"light.kitchen_lights",
|
||||
{ entity: "lock.kitchen_door", name: null },
|
||||
"light.ceiling_lights",
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: "Custom tap action",
|
||||
config: `
|
||||
- type: glance
|
||||
columns: 4
|
||||
entities:
|
||||
- entity: lock.kitchen_door
|
||||
name: Custom
|
||||
tap_action:
|
||||
type: toggle
|
||||
- entity: light.ceiling_lights
|
||||
name: Custom
|
||||
tap_action:
|
||||
action: call-service
|
||||
service: light.turn_on
|
||||
data:
|
||||
entity_id: light.ceiling_lights
|
||||
- entity: sun.sun
|
||||
name: Regular
|
||||
- entity: light.kitchen_lights
|
||||
name: Regular
|
||||
`,
|
||||
config: {
|
||||
type: "glance",
|
||||
columns: 4,
|
||||
entities: [
|
||||
{
|
||||
entity: "lock.kitchen_door",
|
||||
name: "Custom",
|
||||
tap_action: { action: "toggle" },
|
||||
},
|
||||
{
|
||||
entity: "light.ceiling_lights",
|
||||
name: "Custom",
|
||||
tap_action: {
|
||||
action: "perform-action",
|
||||
perform_action: "light.turn_on",
|
||||
data: { entity_id: "light.ceiling_lights" },
|
||||
},
|
||||
},
|
||||
{ entity: "sun.sun", name: "Regular" },
|
||||
{ entity: "light.kitchen_lights", name: "Regular" },
|
||||
],
|
||||
},
|
||||
},
|
||||
];
|
||||
] satisfies DemoCardConfig<GlanceCardConfig | LegacyNullNameGlanceCardConfig>[];
|
||||
|
||||
@customElement("demo-lovelace-glance-card")
|
||||
class DemoGlanceEntity extends LitElement {
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
import type { PropertyValues, TemplateResult } from "lit";
|
||||
import { html, LitElement } from "lit";
|
||||
import { customElement, query } from "lit/decorators";
|
||||
import type { DemoCardConfig } from "../../components/demo-card";
|
||||
import { mockHistory } from "../../../../demo/src/stubs/history";
|
||||
import { provideHass } from "../../../../src/fake_data/provide_hass";
|
||||
import type {
|
||||
GridCardConfig,
|
||||
StackCardConfig,
|
||||
} from "../../../../src/panels/lovelace/cards/types";
|
||||
import "../../components/demo-cards";
|
||||
import { mockIcons } from "../../../../demo/src/stubs/icons";
|
||||
|
||||
@@ -70,159 +75,159 @@ const ENTITIES = [
|
||||
const CONFIGS = [
|
||||
{
|
||||
heading: "Default Grid",
|
||||
config: `
|
||||
- type: grid
|
||||
cards:
|
||||
- type: entity
|
||||
entity: light.kitchen_lights
|
||||
- type: entity
|
||||
entity: light.bed_light
|
||||
- type: entity
|
||||
entity: device_tracker.demo_paulus
|
||||
- type: sensor
|
||||
entity: sensor.illumination
|
||||
graph: line
|
||||
- type: entity
|
||||
entity: device_tracker.demo_anne_therese
|
||||
`,
|
||||
config: {
|
||||
type: "grid",
|
||||
cards: [
|
||||
{ type: "entity", entity: "light.kitchen_lights" },
|
||||
{ type: "entity", entity: "light.bed_light" },
|
||||
{ type: "entity", entity: "device_tracker.demo_paulus" },
|
||||
{ type: "sensor", entity: "sensor.illumination", graph: "line" },
|
||||
{ type: "entity", entity: "device_tracker.demo_anne_therese" },
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: "Non-square Grid with 2 columns",
|
||||
config: `
|
||||
- type: grid
|
||||
columns: 2
|
||||
square: false
|
||||
cards:
|
||||
- type: entity
|
||||
entity: light.kitchen_lights
|
||||
- type: entity
|
||||
entity: light.bed_light
|
||||
- type: entity
|
||||
entity: device_tracker.demo_paulus
|
||||
- type: sensor
|
||||
entity: sensor.illumination
|
||||
graph: line
|
||||
`,
|
||||
config: {
|
||||
type: "grid",
|
||||
columns: 2,
|
||||
square: false,
|
||||
cards: [
|
||||
{ type: "entity", entity: "light.kitchen_lights" },
|
||||
{ type: "entity", entity: "light.bed_light" },
|
||||
{ type: "entity", entity: "device_tracker.demo_paulus" },
|
||||
{ type: "sensor", entity: "sensor.illumination", graph: "line" },
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: "Default Grid with title",
|
||||
config: `
|
||||
- type: grid
|
||||
title: Kitchen
|
||||
cards:
|
||||
- type: entity
|
||||
entity: light.kitchen_lights
|
||||
- type: entity
|
||||
entity: light.bed_light
|
||||
- type: entity
|
||||
entity: device_tracker.demo_paulus
|
||||
- type: sensor
|
||||
entity: sensor.illumination
|
||||
graph: line
|
||||
- type: entity
|
||||
entity: device_tracker.demo_anne_therese
|
||||
`,
|
||||
config: {
|
||||
type: "grid",
|
||||
title: "Kitchen",
|
||||
cards: [
|
||||
{ type: "entity", entity: "light.kitchen_lights" },
|
||||
{ type: "entity", entity: "light.bed_light" },
|
||||
{ type: "entity", entity: "device_tracker.demo_paulus" },
|
||||
{ type: "sensor", entity: "sensor.illumination", graph: "line" },
|
||||
{ type: "entity", entity: "device_tracker.demo_anne_therese" },
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: "Columns 4",
|
||||
config: `
|
||||
- type: grid
|
||||
columns: 4
|
||||
cards:
|
||||
- type: entity
|
||||
entity: light.kitchen_lights
|
||||
- type: entity
|
||||
entity: light.bed_light
|
||||
- type: entity
|
||||
entity: device_tracker.demo_paulus
|
||||
- type: sensor
|
||||
entity: sensor.illumination
|
||||
graph: line
|
||||
`,
|
||||
config: {
|
||||
type: "grid",
|
||||
columns: 4,
|
||||
cards: [
|
||||
{ type: "entity", entity: "light.kitchen_lights" },
|
||||
{ type: "entity", entity: "light.bed_light" },
|
||||
{ type: "entity", entity: "device_tracker.demo_paulus" },
|
||||
{ type: "sensor", entity: "sensor.illumination", graph: "line" },
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: "Columns 2",
|
||||
config: `
|
||||
- type: grid
|
||||
columns: 2
|
||||
cards:
|
||||
- type: entity
|
||||
entity: light.kitchen_lights
|
||||
- type: entity
|
||||
entity: light.bed_light
|
||||
`,
|
||||
config: {
|
||||
type: "grid",
|
||||
columns: 2,
|
||||
cards: [
|
||||
{ type: "entity", entity: "light.kitchen_lights" },
|
||||
{ type: "entity", entity: "light.bed_light" },
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: "Columns 1",
|
||||
config: `
|
||||
- type: grid
|
||||
columns: 1
|
||||
cards:
|
||||
- type: entity
|
||||
entity: light.kitchen_lights
|
||||
`,
|
||||
config: {
|
||||
type: "grid",
|
||||
columns: 1,
|
||||
cards: [{ type: "entity", entity: "light.kitchen_lights" }],
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: "Size for single card",
|
||||
config: `
|
||||
- type: grid
|
||||
cards:
|
||||
- type: entity
|
||||
entity: light.kitchen_lights
|
||||
`,
|
||||
config: {
|
||||
type: "grid",
|
||||
cards: [{ type: "entity", entity: "light.kitchen_lights" }],
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
heading: "Vertical Stack",
|
||||
config: `
|
||||
- type: vertical-stack
|
||||
cards:
|
||||
- type: picture-entity
|
||||
image: /images/kitchen.png
|
||||
entity: light.kitchen_lights
|
||||
- type: glance
|
||||
entities:
|
||||
- device_tracker.demo_anne_therese
|
||||
- device_tracker.demo_home_boy
|
||||
- device_tracker.demo_paulus
|
||||
`,
|
||||
config: {
|
||||
type: "vertical-stack",
|
||||
cards: [
|
||||
{
|
||||
type: "picture-entity",
|
||||
image: "/images/kitchen.png",
|
||||
entity: "light.kitchen_lights",
|
||||
},
|
||||
{
|
||||
type: "glance",
|
||||
entities: [
|
||||
"device_tracker.demo_anne_therese",
|
||||
"device_tracker.demo_home_boy",
|
||||
"device_tracker.demo_paulus",
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: "Horizontal Stack",
|
||||
config: `
|
||||
- type: horizontal-stack
|
||||
cards:
|
||||
- type: picture-entity
|
||||
image: /images/kitchen.png
|
||||
entity: light.kitchen_lights
|
||||
- type: glance
|
||||
entities:
|
||||
- device_tracker.demo_anne_therese
|
||||
- device_tracker.demo_home_boy
|
||||
- device_tracker.demo_paulus
|
||||
`,
|
||||
config: {
|
||||
type: "horizontal-stack",
|
||||
cards: [
|
||||
{
|
||||
type: "picture-entity",
|
||||
image: "/images/kitchen.png",
|
||||
entity: "light.kitchen_lights",
|
||||
},
|
||||
{
|
||||
type: "glance",
|
||||
entities: [
|
||||
"device_tracker.demo_anne_therese",
|
||||
"device_tracker.demo_home_boy",
|
||||
"device_tracker.demo_paulus",
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: "Combination of both",
|
||||
config: `
|
||||
- type: vertical-stack
|
||||
cards:
|
||||
- type: horizontal-stack
|
||||
cards:
|
||||
- type: picture-entity
|
||||
image: /images/kitchen.png
|
||||
entity: light.kitchen_lights
|
||||
- type: glance
|
||||
entities:
|
||||
- device_tracker.demo_anne_therese
|
||||
- device_tracker.demo_home_boy
|
||||
- device_tracker.demo_paulus
|
||||
- type: picture-entity
|
||||
image: /images/bed.png
|
||||
entity: light.bed_light
|
||||
`,
|
||||
config: {
|
||||
type: "vertical-stack",
|
||||
cards: [
|
||||
{
|
||||
type: "horizontal-stack",
|
||||
cards: [
|
||||
{
|
||||
type: "picture-entity",
|
||||
image: "/images/kitchen.png",
|
||||
entity: "light.kitchen_lights",
|
||||
},
|
||||
{
|
||||
type: "glance",
|
||||
entities: [
|
||||
"device_tracker.demo_anne_therese",
|
||||
"device_tracker.demo_home_boy",
|
||||
"device_tracker.demo_paulus",
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: "picture-entity",
|
||||
image: "/images/bed.png",
|
||||
entity: "light.bed_light",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
];
|
||||
] satisfies DemoCardConfig<GridCardConfig | StackCardConfig>[];
|
||||
|
||||
@customElement("demo-lovelace-grid-and-stack-card")
|
||||
class DemoStack extends LitElement {
|
||||
|
||||
@@ -1,42 +1,44 @@
|
||||
import type { PropertyValues, TemplateResult } from "lit";
|
||||
import { html, LitElement } from "lit";
|
||||
import { customElement, query } from "lit/decorators";
|
||||
import type { IframeCardConfig } from "../../../../src/panels/lovelace/cards/types";
|
||||
import type { DemoCardConfig } from "../../components/demo-card";
|
||||
import { provideHass } from "../../../../src/fake_data/provide_hass";
|
||||
import "../../components/demo-cards";
|
||||
|
||||
const CONFIGS = [
|
||||
{
|
||||
heading: "Without title",
|
||||
config: `
|
||||
- type: iframe
|
||||
url: https://embed.windy.com/embed2.html
|
||||
`,
|
||||
config: {
|
||||
type: "iframe",
|
||||
url: "https://embed.windy.com/embed2.html",
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: "With title",
|
||||
config: `
|
||||
- type: iframe
|
||||
url: https://embed.windy.com/embed2.html
|
||||
title: Weather radar
|
||||
`,
|
||||
config: {
|
||||
type: "iframe",
|
||||
url: "https://embed.windy.com/embed2.html",
|
||||
title: "Weather radar",
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: "Height-Width 3:4",
|
||||
config: `
|
||||
- type: iframe
|
||||
url: https://embed.windy.com/embed2.html
|
||||
aspect_ratio: 75%
|
||||
`,
|
||||
config: {
|
||||
type: "iframe",
|
||||
url: "https://embed.windy.com/embed2.html",
|
||||
aspect_ratio: "75%",
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: "Height-Width 1:1",
|
||||
config: `
|
||||
- type: iframe
|
||||
url: https://embed.windy.com/embed2.html
|
||||
aspect_ratio: 100%
|
||||
`,
|
||||
config: {
|
||||
type: "iframe",
|
||||
url: "https://embed.windy.com/embed2.html",
|
||||
aspect_ratio: "100%",
|
||||
},
|
||||
},
|
||||
];
|
||||
] satisfies DemoCardConfig<IframeCardConfig>[];
|
||||
|
||||
@customElement("demo-lovelace-iframe-card")
|
||||
class DemoIframe extends LitElement {
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import type { PropertyValues, TemplateResult } from "lit";
|
||||
import { html, LitElement } from "lit";
|
||||
import { customElement, query } from "lit/decorators";
|
||||
import type { LightCardConfig } from "../../../../src/panels/lovelace/cards/types";
|
||||
import type { DemoCardConfig } from "../../components/demo-card";
|
||||
import { provideHass } from "../../../../src/fake_data/provide_hass";
|
||||
import "../../components/demo-cards";
|
||||
import { mockIcons } from "../../../../demo/src/stubs/icons";
|
||||
@@ -44,40 +46,40 @@ const ENTITIES = [
|
||||
const CONFIGS = [
|
||||
{
|
||||
heading: "Switchable Light",
|
||||
config: `
|
||||
- type: light
|
||||
entity: light.bed_light
|
||||
`,
|
||||
config: {
|
||||
type: "light",
|
||||
entity: "light.bed_light",
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: "Dimmable Light On",
|
||||
config: `
|
||||
- type: light
|
||||
entity: light.dim_on
|
||||
`,
|
||||
config: {
|
||||
type: "light",
|
||||
entity: "light.dim_on",
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: "Dimmable Light Off",
|
||||
config: `
|
||||
- type: light
|
||||
entity: light.dim_off
|
||||
`,
|
||||
config: {
|
||||
type: "light",
|
||||
entity: "light.dim_off",
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: "Unavailable",
|
||||
config: `
|
||||
- type: light
|
||||
entity: light.unavailable
|
||||
`,
|
||||
config: {
|
||||
type: "light",
|
||||
entity: "light.unavailable",
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: "Non existing",
|
||||
config: `
|
||||
- type: light
|
||||
entity: light.nonexisting
|
||||
`,
|
||||
config: {
|
||||
type: "light",
|
||||
entity: "light.nonexisting",
|
||||
},
|
||||
},
|
||||
];
|
||||
] satisfies DemoCardConfig<LightCardConfig>[];
|
||||
|
||||
@customElement("demo-lovelace-light-card")
|
||||
class DemoLightEntity extends LitElement {
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import type { PropertyValues, TemplateResult } from "lit";
|
||||
import { html, LitElement } from "lit";
|
||||
import { customElement, query } from "lit/decorators";
|
||||
import type { DemoCardConfig } from "../../components/demo-card";
|
||||
import { provideHass } from "../../../../src/fake_data/provide_hass";
|
||||
import type { MapCardConfig } from "../../../../src/panels/lovelace/cards/types";
|
||||
import "../../components/demo-cards";
|
||||
|
||||
const ENTITIES = [
|
||||
@@ -86,107 +88,101 @@ const ENTITIES = [
|
||||
const CONFIGS = [
|
||||
{
|
||||
heading: "Without title",
|
||||
config: `
|
||||
- type: map
|
||||
entities:
|
||||
- entity: device_tracker.demo_paulus
|
||||
- device_tracker.demo_home_boy
|
||||
- zone.home
|
||||
`,
|
||||
config: {
|
||||
type: "map",
|
||||
entities: [
|
||||
{ entity: "device_tracker.demo_paulus" },
|
||||
"device_tracker.demo_home_boy",
|
||||
"zone.home",
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: "With title",
|
||||
config: `
|
||||
- type: map
|
||||
entities:
|
||||
- entity: device_tracker.demo_paulus
|
||||
- zone.home
|
||||
title: Where is Paulus?
|
||||
`,
|
||||
config: {
|
||||
type: "map",
|
||||
entities: [{ entity: "device_tracker.demo_paulus" }, "zone.home"],
|
||||
title: "Where is Paulus?",
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: "Height-Width 1:2",
|
||||
config: `
|
||||
- type: map
|
||||
entities:
|
||||
- entity: device_tracker.demo_paulus
|
||||
- zone.home
|
||||
aspect_ratio: 50%
|
||||
`,
|
||||
config: {
|
||||
type: "map",
|
||||
entities: [{ entity: "device_tracker.demo_paulus" }, "zone.home"],
|
||||
aspect_ratio: "50%",
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: "Default Zoom",
|
||||
config: `
|
||||
- type: map
|
||||
default_zoom: 12
|
||||
entities:
|
||||
- entity: device_tracker.demo_paulus
|
||||
- zone.home
|
||||
`,
|
||||
config: {
|
||||
type: "map",
|
||||
default_zoom: 12,
|
||||
entities: [{ entity: "device_tracker.demo_paulus" }, "zone.home"],
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: "Default Zoom too High",
|
||||
config: `
|
||||
- type: map
|
||||
default_zoom: 20
|
||||
entities:
|
||||
- entity: device_tracker.demo_paulus
|
||||
- zone.home
|
||||
`,
|
||||
config: {
|
||||
type: "map",
|
||||
default_zoom: 20,
|
||||
entities: [{ entity: "device_tracker.demo_paulus" }, "zone.home"],
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: "Single Marker",
|
||||
config: `
|
||||
- type: map
|
||||
entities:
|
||||
- device_tracker.demo_paulus
|
||||
`,
|
||||
config: {
|
||||
type: "map",
|
||||
entities: ["device_tracker.demo_paulus"],
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: "Single Marker Default Zoom",
|
||||
config: `
|
||||
- type: map
|
||||
default_zoom: 8
|
||||
entities:
|
||||
- device_tracker.demo_paulus
|
||||
`,
|
||||
config: {
|
||||
type: "map",
|
||||
default_zoom: 8,
|
||||
entities: ["device_tracker.demo_paulus"],
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: "No Entities",
|
||||
config: `
|
||||
- type: map
|
||||
entities:
|
||||
- light.bed_light
|
||||
`,
|
||||
config: {
|
||||
type: "map",
|
||||
entities: ["light.bed_light"],
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: "No Entities, Default Zoom",
|
||||
config: `
|
||||
- type: map
|
||||
default_zoom: 8
|
||||
entities:
|
||||
- light.bed_light
|
||||
`,
|
||||
config: {
|
||||
type: "map",
|
||||
default_zoom: 8,
|
||||
entities: ["light.bed_light"],
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: "Geo Location Entities",
|
||||
config: `
|
||||
- type: map
|
||||
geo_location_sources:
|
||||
- bushfire_demo
|
||||
`,
|
||||
config: {
|
||||
type: "map",
|
||||
geo_location_sources: ["bushfire_demo"],
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: "Geo Location Entities with Home Zone",
|
||||
config: `
|
||||
- type: map
|
||||
geo_location_sources:
|
||||
- bushfire_demo
|
||||
entities:
|
||||
- zone.bushfire
|
||||
`,
|
||||
config: {
|
||||
type: "map",
|
||||
geo_location_sources: ["bushfire_demo"],
|
||||
entities: ["zone.bushfire"],
|
||||
},
|
||||
},
|
||||
];
|
||||
{
|
||||
heading: "Scale ruler",
|
||||
config: {
|
||||
type: "map",
|
||||
scale_ruler: true,
|
||||
entities: ["zone.home"],
|
||||
},
|
||||
},
|
||||
] satisfies DemoCardConfig<MapCardConfig>[];
|
||||
|
||||
@customElement("demo-lovelace-map-card")
|
||||
class DemoMap extends LitElement {
|
||||
|
||||
@@ -1,17 +1,18 @@
|
||||
import type { PropertyValues, TemplateResult } from "lit";
|
||||
import { html, LitElement } from "lit";
|
||||
import { customElement, query } from "lit/decorators";
|
||||
import type { DemoCardConfig } from "../../components/demo-card";
|
||||
import { mockTemplate } from "../../../../demo/src/stubs/template";
|
||||
import { provideHass } from "../../../../src/fake_data/provide_hass";
|
||||
import type { MarkdownCardConfig } from "../../../../src/panels/lovelace/cards/types";
|
||||
import "../../components/demo-cards";
|
||||
|
||||
const CONFIGS = [
|
||||
{
|
||||
heading: "markdown-it demo",
|
||||
config: `
|
||||
- type: markdown
|
||||
content: |
|
||||
# h1 Heading 8-)
|
||||
config: {
|
||||
type: "markdown",
|
||||
content: `# h1 Heading 8-)
|
||||
|
||||
## h2 Heading
|
||||
|
||||
@@ -278,9 +279,12 @@ const CONFIGS = [
|
||||
<ha-alert alert-type="success">This is a success alert — check it out!</ha-alert>
|
||||
<ha-alert title="Test alert">This is an alert with a title</ha-alert>
|
||||
|
||||
`,
|
||||
`
|
||||
.replace(/^ {4}/gm, "")
|
||||
.replace(/\n+$/, "\n"),
|
||||
},
|
||||
},
|
||||
];
|
||||
] satisfies DemoCardConfig<MarkdownCardConfig>[];
|
||||
|
||||
@customElement("demo-lovelace-markdown-card")
|
||||
class DemoMarkdown extends LitElement {
|
||||
|
||||
@@ -1,162 +1,165 @@
|
||||
import type { PropertyValues, TemplateResult } from "lit";
|
||||
import { html, LitElement } from "lit";
|
||||
import { customElement, query } from "lit/decorators";
|
||||
import type { DemoCardConfig } from "../../components/demo-card";
|
||||
import { provideHass } from "../../../../src/fake_data/provide_hass";
|
||||
import type {
|
||||
GridCardConfig,
|
||||
MediaControlCardConfig,
|
||||
} from "../../../../src/panels/lovelace/cards/types";
|
||||
import "../../components/demo-cards";
|
||||
import { createMediaPlayerEntities } from "../../data/media_players";
|
||||
|
||||
const CONFIGS = [
|
||||
{
|
||||
heading: "Paused Music",
|
||||
config: `
|
||||
- type: media-control
|
||||
entity: media_player.music_paused
|
||||
`,
|
||||
config: {
|
||||
type: "media-control",
|
||||
entity: "media_player.music_paused",
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: "Playing Music",
|
||||
config: `
|
||||
- type: media-control
|
||||
entity: media_player.music_playing
|
||||
`,
|
||||
config: {
|
||||
type: "media-control",
|
||||
entity: "media_player.music_playing",
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: "Playing Stream",
|
||||
config: `
|
||||
- type: media-control
|
||||
entity: media_player.stream_playing
|
||||
`,
|
||||
config: {
|
||||
type: "media-control",
|
||||
entity: "media_player.stream_playing",
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: "Paused Stream",
|
||||
config: `
|
||||
- type: media-control
|
||||
entity: media_player.stream_paused
|
||||
`,
|
||||
config: {
|
||||
type: "media-control",
|
||||
entity: "media_player.stream_paused",
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: 'Playing Stream (with "previous" support)',
|
||||
config: `
|
||||
- type: media-control
|
||||
entity: media_player.stream_playing_previous
|
||||
`,
|
||||
config: {
|
||||
type: "media-control",
|
||||
entity: "media_player.stream_playing_previous",
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: "Playing non-skip TV Show",
|
||||
config: `
|
||||
- type: media-control
|
||||
entity: media_player.tv_playing
|
||||
`,
|
||||
config: {
|
||||
type: "media-control",
|
||||
entity: "media_player.tv_playing",
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: "Screen Casting",
|
||||
config: `
|
||||
- type: media-control
|
||||
entity: media_player.android_cast
|
||||
`,
|
||||
config: {
|
||||
type: "media-control",
|
||||
entity: "media_player.android_cast",
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: "Digital Picture Frame",
|
||||
config: `
|
||||
- type: media-control
|
||||
entity: media_player.image_display
|
||||
`,
|
||||
config: {
|
||||
type: "media-control",
|
||||
entity: "media_player.image_display",
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: "Sonos Idle",
|
||||
config: `
|
||||
- type: media-control
|
||||
entity: media_player.sonos_idle
|
||||
`,
|
||||
config: {
|
||||
type: "media-control",
|
||||
entity: "media_player.sonos_idle",
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: "Idle waiting for Browse Media",
|
||||
config: `
|
||||
- type: media-control
|
||||
entity: media_player.idle_browse_media
|
||||
`,
|
||||
config: {
|
||||
type: "media-control",
|
||||
entity: "media_player.idle_browse_media",
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: "Player Off",
|
||||
config: `
|
||||
- type: media-control
|
||||
entity: media_player.theater_off
|
||||
`,
|
||||
config: {
|
||||
type: "media-control",
|
||||
entity: "media_player.theater_off",
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: "Player On",
|
||||
config: `
|
||||
- type: media-control
|
||||
entity: media_player.theater_on
|
||||
`,
|
||||
config: {
|
||||
type: "media-control",
|
||||
entity: "media_player.theater_on",
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: "Player Off (cannot be switched on)",
|
||||
config: `
|
||||
- type: media-control
|
||||
entity: media_player.theater_off_static
|
||||
`,
|
||||
config: {
|
||||
type: "media-control",
|
||||
entity: "media_player.theater_off_static",
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: "Player On (cannot be switched off)",
|
||||
config: `
|
||||
- type: media-control
|
||||
entity: media_player.theater_on_static
|
||||
`,
|
||||
config: {
|
||||
type: "media-control",
|
||||
entity: "media_player.theater_on_static",
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: "Player Idle",
|
||||
config: `
|
||||
- type: media-control
|
||||
entity: media_player.idle
|
||||
`,
|
||||
config: {
|
||||
type: "media-control",
|
||||
entity: "media_player.idle",
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: "Player Playing",
|
||||
config: `
|
||||
- type: media-control
|
||||
entity: media_player.playing
|
||||
`,
|
||||
config: {
|
||||
type: "media-control",
|
||||
entity: "media_player.playing",
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: "Player Unavailable",
|
||||
config: `
|
||||
- type: media-control
|
||||
entity: media_player.unavailable
|
||||
`,
|
||||
config: {
|
||||
type: "media-control",
|
||||
entity: "media_player.unavailable",
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: "Player Unknown",
|
||||
config: `
|
||||
- type: media-control
|
||||
entity: media_player.unknown
|
||||
`,
|
||||
config: {
|
||||
type: "media-control",
|
||||
entity: "media_player.unknown",
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: "Receiver On (selectable sources)",
|
||||
config: `
|
||||
- type: media-control
|
||||
entity: media_player.receiver_on
|
||||
`,
|
||||
config: {
|
||||
type: "media-control",
|
||||
entity: "media_player.receiver_on",
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: "Receiver Off (selectable sources)",
|
||||
config: `
|
||||
- type: media-control
|
||||
entity: media_player.receiver_off
|
||||
`,
|
||||
config: {
|
||||
type: "media-control",
|
||||
entity: "media_player.receiver_off",
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: "Grid Full Size",
|
||||
config: `
|
||||
- type: grid
|
||||
columns: 1
|
||||
cards:
|
||||
- type: media-control
|
||||
entity: media_player.music_paused
|
||||
`,
|
||||
config: {
|
||||
type: "grid",
|
||||
columns: 1,
|
||||
cards: [{ type: "media-control", entity: "media_player.music_paused" }],
|
||||
},
|
||||
},
|
||||
];
|
||||
] satisfies DemoCardConfig<MediaControlCardConfig | GridCardConfig>[];
|
||||
|
||||
@customElement("demo-lovelace-media-control-card")
|
||||
class DemoHuiMediaControlCard extends LitElement {
|
||||
|
||||
@@ -1,59 +1,60 @@
|
||||
import type { PropertyValues, TemplateResult } from "lit";
|
||||
import { html, LitElement } from "lit";
|
||||
import { customElement, query } from "lit/decorators";
|
||||
import type { DemoCardConfig } from "../../components/demo-card";
|
||||
import { provideHass } from "../../../../src/fake_data/provide_hass";
|
||||
import type { EntitiesCardConfig } from "../../../../src/panels/lovelace/cards/types";
|
||||
import "../../components/demo-cards";
|
||||
import { createMediaPlayerEntities } from "../../data/media_players";
|
||||
|
||||
const CONFIGS = [
|
||||
{
|
||||
heading: "Media Players",
|
||||
config: `
|
||||
- type: entities
|
||||
entities:
|
||||
- entity: media_player.music_paused
|
||||
name: Paused Music
|
||||
- entity: media_player.music_playing
|
||||
name: Playing Music
|
||||
- entity: media_player.stream_playing
|
||||
name: Playing Stream
|
||||
- entity: media_player.stream_paused
|
||||
name: Paused Stream
|
||||
- entity: media_player.stream_playing_previous
|
||||
name: Playing Stream (with "previous" support)
|
||||
- entity: media_player.tv_playing
|
||||
name: Playing non-skip TV Show
|
||||
- entity: media_player.android_cast
|
||||
name: Screen casting
|
||||
- entity: media_player.image_display
|
||||
name: Digital Picture Frame
|
||||
- entity: media_player.sonos_idle
|
||||
name: Sonos Idle
|
||||
- entity: media_player.idle_browse_media
|
||||
name: Idle waiting for Browse Media
|
||||
- entity: media_player.theater_off
|
||||
name: Player Off
|
||||
- entity: media_player.theater_on
|
||||
name: Player On
|
||||
- entity: media_player.theater_off_static
|
||||
name: Player Off (cannot be switched on)
|
||||
- entity: media_player.theater_on_static
|
||||
name: Player On (cannot be switched off)
|
||||
- entity: media_player.idle
|
||||
name: Player Idle
|
||||
- entity: media_player.playing
|
||||
name: Player Playing
|
||||
- entity: media_player.unavailable
|
||||
name: Player Unavailable
|
||||
- entity: media_player.unknown
|
||||
name: Player Unknown
|
||||
- entity: media_player.receiver_on
|
||||
name: Receiver On (selectable sources)
|
||||
- entity: media_player.receiver_off
|
||||
name: Receiver Off (selectable sources)
|
||||
`,
|
||||
config: {
|
||||
type: "entities",
|
||||
entities: [
|
||||
{ entity: "media_player.music_paused", name: "Paused Music" },
|
||||
{ entity: "media_player.music_playing", name: "Playing Music" },
|
||||
{ entity: "media_player.stream_playing", name: "Playing Stream" },
|
||||
{ entity: "media_player.stream_paused", name: "Paused Stream" },
|
||||
{
|
||||
entity: "media_player.stream_playing_previous",
|
||||
name: 'Playing Stream (with "previous" support)',
|
||||
},
|
||||
{ entity: "media_player.tv_playing", name: "Playing non-skip TV Show" },
|
||||
{ entity: "media_player.android_cast", name: "Screen casting" },
|
||||
{ entity: "media_player.image_display", name: "Digital Picture Frame" },
|
||||
{ entity: "media_player.sonos_idle", name: "Sonos Idle" },
|
||||
{
|
||||
entity: "media_player.idle_browse_media",
|
||||
name: "Idle waiting for Browse Media",
|
||||
},
|
||||
{ entity: "media_player.theater_off", name: "Player Off" },
|
||||
{ entity: "media_player.theater_on", name: "Player On" },
|
||||
{
|
||||
entity: "media_player.theater_off_static",
|
||||
name: "Player Off (cannot be switched on)",
|
||||
},
|
||||
{
|
||||
entity: "media_player.theater_on_static",
|
||||
name: "Player On (cannot be switched off)",
|
||||
},
|
||||
{ entity: "media_player.idle", name: "Player Idle" },
|
||||
{ entity: "media_player.playing", name: "Player Playing" },
|
||||
{ entity: "media_player.unavailable", name: "Player Unavailable" },
|
||||
{ entity: "media_player.unknown", name: "Player Unknown" },
|
||||
{
|
||||
entity: "media_player.receiver_on",
|
||||
name: "Receiver On (selectable sources)",
|
||||
},
|
||||
{
|
||||
entity: "media_player.receiver_off",
|
||||
name: "Receiver Off (selectable sources)",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
];
|
||||
] satisfies DemoCardConfig<EntitiesCardConfig>[];
|
||||
|
||||
@customElement("demo-lovelace-media-player-row")
|
||||
export class DemoLovelaceMediaPlayerRow extends LitElement {
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import type { PropertyValues, TemplateResult } from "lit";
|
||||
import { html, LitElement } from "lit";
|
||||
import { customElement, query } from "lit/decorators";
|
||||
import type { PictureCardConfig } from "../../../../src/panels/lovelace/cards/types";
|
||||
import type { DemoCardConfig } from "../../components/demo-card";
|
||||
import { provideHass } from "../../../../src/fake_data/provide_hass";
|
||||
import "../../components/demo-cards";
|
||||
import { mockIcons } from "../../../../demo/src/stubs/icons";
|
||||
@@ -19,26 +21,26 @@ const ENTITIES = [
|
||||
const CONFIGS = [
|
||||
{
|
||||
heading: "Image URL",
|
||||
config: `
|
||||
- type: picture
|
||||
image: /images/living_room.png
|
||||
`,
|
||||
config: {
|
||||
type: "picture",
|
||||
image: "/images/living_room.png",
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: "Person entity",
|
||||
config: `
|
||||
- type: picture
|
||||
image_entity: person.paulus
|
||||
`,
|
||||
config: {
|
||||
type: "picture",
|
||||
image_entity: "person.paulus",
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: "Error: Image required",
|
||||
config: `
|
||||
- type: picture
|
||||
entity: person.paulus
|
||||
`,
|
||||
config: {
|
||||
type: "picture",
|
||||
},
|
||||
expectConfigError: true,
|
||||
},
|
||||
];
|
||||
] satisfies DemoCardConfig<PictureCardConfig>[];
|
||||
|
||||
@customElement("demo-lovelace-picture-card")
|
||||
class DemoPicture extends LitElement {
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
import type { PropertyValues, TemplateResult } from "lit";
|
||||
import { html, LitElement } from "lit";
|
||||
import { customElement, query } from "lit/decorators";
|
||||
import type { DemoCardConfig } from "../../components/demo-card";
|
||||
import { provideHass } from "../../../../src/fake_data/provide_hass";
|
||||
import type { PictureElementsCardConfig } from "../../../../src/panels/lovelace/cards/types";
|
||||
import type {
|
||||
ImageElementConfig,
|
||||
LovelaceElementConfig,
|
||||
} from "../../../../src/panels/lovelace/elements/types";
|
||||
import "../../components/demo-cards";
|
||||
import { mockIcons } from "../../../../demo/src/stubs/icons";
|
||||
|
||||
@@ -60,116 +66,141 @@ const ENTITIES = [
|
||||
},
|
||||
];
|
||||
|
||||
type LegacyImageElementConfig = Omit<
|
||||
ImageElementConfig,
|
||||
"state_filter" | "state_image"
|
||||
> & {
|
||||
state_filter?: Record<string, string>;
|
||||
state_image?: Record<string, string>;
|
||||
};
|
||||
|
||||
type GalleryPictureElementsCardConfig = Omit<
|
||||
PictureElementsCardConfig,
|
||||
"elements"
|
||||
> & {
|
||||
type: PictureElementsCardConfig["type"];
|
||||
elements: (LovelaceElementConfig | LegacyImageElementConfig)[];
|
||||
};
|
||||
|
||||
const CONFIGS = [
|
||||
{
|
||||
heading: "Card with few elements",
|
||||
config: `
|
||||
- type: picture-elements
|
||||
image: /images/floorplan.png
|
||||
elements:
|
||||
- type: service-button
|
||||
title: Lights Off
|
||||
style:
|
||||
top: 97%
|
||||
left: 90%
|
||||
padding: 0px
|
||||
service: light.turn_off
|
||||
data:
|
||||
entity_id: group.all_lights
|
||||
- type: icon
|
||||
icon: mdi:cctv
|
||||
entity: camera.demo_camera
|
||||
style:
|
||||
top: 12%
|
||||
left: 6%
|
||||
transform: rotate(-60deg) scaleX(-1)
|
||||
--mdc-icon-size: 30px
|
||||
--mdc-icon-stroke-color: black
|
||||
--mdc-icon-fill-color: rgba(50, 50, 50, .75)
|
||||
- type: image
|
||||
entity: light.bed_light
|
||||
tap_action:
|
||||
action: toggle
|
||||
image: /images/light_bulb_off.png
|
||||
state_image:
|
||||
'on': /images/light_bulb_on.png
|
||||
state_filter:
|
||||
'on': brightness(130%) saturate(1.5) drop-shadow(0px 0px 10px gold)
|
||||
'off': brightness(80%) saturate(0.8)
|
||||
style:
|
||||
top: 35%
|
||||
left: 65%
|
||||
width: 7%
|
||||
padding: 50px 50px 100px 50px
|
||||
- type: state-icon
|
||||
entity: binary_sensor.movement_backyard
|
||||
style:
|
||||
top: 8%
|
||||
left: 35%
|
||||
`,
|
||||
config: {
|
||||
type: "picture-elements",
|
||||
image: "/images/floorplan.png",
|
||||
elements: [
|
||||
{
|
||||
type: "service-button",
|
||||
title: "Lights Off",
|
||||
style: { top: "97%", left: "90%", padding: "0px" },
|
||||
service: "light.turn_off",
|
||||
data: { entity_id: "group.all_lights" },
|
||||
},
|
||||
{
|
||||
type: "icon",
|
||||
icon: "mdi:cctv",
|
||||
entity: "camera.demo_camera",
|
||||
style: {
|
||||
top: "12%",
|
||||
left: "6%",
|
||||
transform: "rotate(-60deg) scaleX(-1)",
|
||||
"--mdc-icon-size": "30px",
|
||||
"--mdc-icon-stroke-color": "black",
|
||||
"--mdc-icon-fill-color": "rgba(50, 50, 50, .75)",
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "image",
|
||||
entity: "light.bed_light",
|
||||
tap_action: { action: "toggle" },
|
||||
image: "/images/light_bulb_off.png",
|
||||
state_image: { on: "/images/light_bulb_on.png" },
|
||||
state_filter: {
|
||||
on: "brightness(130%) saturate(1.5) drop-shadow(0px 0px 10px gold)",
|
||||
off: "brightness(80%) saturate(0.8)",
|
||||
},
|
||||
style: {
|
||||
top: "35%",
|
||||
left: "65%",
|
||||
width: "7%",
|
||||
padding: "50px 50px 100px 50px",
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "state-icon",
|
||||
entity: "binary_sensor.movement_backyard",
|
||||
style: { top: "8%", left: "35%" },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: "Card with header",
|
||||
config: `
|
||||
- type: picture-elements
|
||||
image: /images/floorplan.png
|
||||
title: My House
|
||||
elements:
|
||||
- type: service-button
|
||||
title: Lights Off
|
||||
style:
|
||||
top: 97%
|
||||
left: 90%
|
||||
padding: 0px
|
||||
service: light.turn_off
|
||||
data:
|
||||
entity_id: group.all_lights
|
||||
- type: icon
|
||||
icon: mdi:cctv
|
||||
entity: camera.demo_camera
|
||||
style:
|
||||
top: 12%
|
||||
left: 6%
|
||||
transform: rotate(-60deg) scaleX(-1)
|
||||
--mdc-icon-size: 30px
|
||||
--mdc-icon-stroke-color: black
|
||||
--mdc-icon-fill-color: rgba(50, 50, 50, .75)
|
||||
- type: image
|
||||
entity: light.bed_light
|
||||
tap_action:
|
||||
action: toggle
|
||||
image: /images/light_bulb_off.png
|
||||
state_image:
|
||||
'on': /images/light_bulb_on.png
|
||||
state_filter:
|
||||
'on': brightness(130%) saturate(1.5) drop-shadow(0px 0px 10px gold)
|
||||
'off': brightness(80%) saturate(0.8)
|
||||
style:
|
||||
top: 35%
|
||||
left: 65%
|
||||
width: 7%
|
||||
padding: 50px 50px 100px 50px
|
||||
- type: state-icon
|
||||
entity: binary_sensor.movement_backyard
|
||||
style:
|
||||
top: 8%
|
||||
left: 35%
|
||||
`,
|
||||
config: {
|
||||
type: "picture-elements",
|
||||
image: "/images/floorplan.png",
|
||||
title: "My House",
|
||||
elements: [
|
||||
{
|
||||
type: "service-button",
|
||||
title: "Lights Off",
|
||||
style: { top: "97%", left: "90%", padding: "0px" },
|
||||
service: "light.turn_off",
|
||||
data: { entity_id: "group.all_lights" },
|
||||
},
|
||||
{
|
||||
type: "icon",
|
||||
icon: "mdi:cctv",
|
||||
entity: "camera.demo_camera",
|
||||
style: {
|
||||
top: "12%",
|
||||
left: "6%",
|
||||
transform: "rotate(-60deg) scaleX(-1)",
|
||||
"--mdc-icon-size": "30px",
|
||||
"--mdc-icon-stroke-color": "black",
|
||||
"--mdc-icon-fill-color": "rgba(50, 50, 50, .75)",
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "image",
|
||||
entity: "light.bed_light",
|
||||
tap_action: { action: "toggle" },
|
||||
image: "/images/light_bulb_off.png",
|
||||
state_image: { on: "/images/light_bulb_on.png" },
|
||||
state_filter: {
|
||||
on: "brightness(130%) saturate(1.5) drop-shadow(0px 0px 10px gold)",
|
||||
off: "brightness(80%) saturate(0.8)",
|
||||
},
|
||||
style: {
|
||||
top: "35%",
|
||||
left: "65%",
|
||||
width: "7%",
|
||||
padding: "50px 50px 100px 50px",
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "state-icon",
|
||||
entity: "binary_sensor.movement_backyard",
|
||||
style: { top: "8%", left: "35%" },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: "Person entity",
|
||||
config: `
|
||||
- type: picture-elements
|
||||
image_entity: person.paulus
|
||||
elements:
|
||||
- type: state-icon
|
||||
entity: sensor.battery
|
||||
style:
|
||||
top: 8%
|
||||
left: 8%
|
||||
`,
|
||||
config: {
|
||||
type: "picture-elements",
|
||||
image_entity: "person.paulus",
|
||||
elements: [
|
||||
{
|
||||
type: "state-icon",
|
||||
entity: "sensor.battery",
|
||||
style: { top: "8%", left: "8%" },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
];
|
||||
] satisfies DemoCardConfig<GalleryPictureElementsCardConfig>[];
|
||||
|
||||
@customElement("demo-lovelace-picture-elements-card")
|
||||
class DemoPictureElements extends LitElement {
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import type { PropertyValues, TemplateResult } from "lit";
|
||||
import { html, LitElement } from "lit";
|
||||
import { customElement, query } from "lit/decorators";
|
||||
import type { DemoCardConfig } from "../../components/demo-card";
|
||||
import { provideHass } from "../../../../src/fake_data/provide_hass";
|
||||
import type { PictureEntityCardConfig } from "../../../../src/panels/lovelace/cards/types";
|
||||
import "../../components/demo-cards";
|
||||
import { mockIcons } from "../../../../demo/src/stubs/icons";
|
||||
|
||||
@@ -33,75 +35,73 @@ const ENTITIES = [
|
||||
const CONFIGS = [
|
||||
{
|
||||
heading: "State on",
|
||||
config: `
|
||||
- type: picture-entity
|
||||
image: /images/kitchen.png
|
||||
entity: light.kitchen_lights
|
||||
tap_action:
|
||||
action: toggle
|
||||
`,
|
||||
config: {
|
||||
type: "picture-entity",
|
||||
image: "/images/kitchen.png",
|
||||
entity: "light.kitchen_lights",
|
||||
tap_action: { action: "toggle" },
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: "State off",
|
||||
config: `
|
||||
- type: picture-entity
|
||||
image: /images/bed.png
|
||||
entity: light.bed_light
|
||||
tap_action:
|
||||
action: toggle
|
||||
`,
|
||||
config: {
|
||||
type: "picture-entity",
|
||||
image: "/images/bed.png",
|
||||
entity: "light.bed_light",
|
||||
tap_action: { action: "toggle" },
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: "Entity unavailable",
|
||||
config: `
|
||||
- type: picture-entity
|
||||
image: /images/living_room.png
|
||||
entity: light.non_existing
|
||||
`,
|
||||
config: {
|
||||
type: "picture-entity",
|
||||
image: "/images/living_room.png",
|
||||
entity: "light.non_existing",
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: "Camera entity",
|
||||
config: `
|
||||
- type: picture-entity
|
||||
entity: camera.demo_camera
|
||||
`,
|
||||
config: {
|
||||
type: "picture-entity",
|
||||
entity: "camera.demo_camera",
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: "Person entity",
|
||||
config: `
|
||||
- type: picture-entity
|
||||
entity: person.paulus
|
||||
`,
|
||||
config: {
|
||||
type: "picture-entity",
|
||||
entity: "person.paulus",
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: "Hidden name",
|
||||
config: `
|
||||
- type: picture-entity
|
||||
image: /images/kitchen.png
|
||||
entity: light.kitchen_lights
|
||||
show_name: false
|
||||
`,
|
||||
config: {
|
||||
type: "picture-entity",
|
||||
image: "/images/kitchen.png",
|
||||
entity: "light.kitchen_lights",
|
||||
show_name: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: "Hidden state",
|
||||
config: `
|
||||
- type: picture-entity
|
||||
image: /images/kitchen.png
|
||||
entity: light.kitchen_lights
|
||||
show_state: false
|
||||
`,
|
||||
config: {
|
||||
type: "picture-entity",
|
||||
image: "/images/kitchen.png",
|
||||
entity: "light.kitchen_lights",
|
||||
show_state: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: "Both hidden",
|
||||
config: `
|
||||
- type: picture-entity
|
||||
image: /images/kitchen.png
|
||||
entity: light.kitchen_lights
|
||||
show_name: false
|
||||
show_state: false
|
||||
`,
|
||||
config: {
|
||||
type: "picture-entity",
|
||||
image: "/images/kitchen.png",
|
||||
entity: "light.kitchen_lights",
|
||||
show_name: false,
|
||||
show_state: false,
|
||||
},
|
||||
},
|
||||
];
|
||||
] satisfies DemoCardConfig<PictureEntityCardConfig>[];
|
||||
|
||||
@customElement("demo-lovelace-picture-entity-card")
|
||||
class DemoPictureEntity extends LitElement {
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import type { PropertyValues, TemplateResult } from "lit";
|
||||
import { html, LitElement } from "lit";
|
||||
import { customElement, query } from "lit/decorators";
|
||||
import type { DemoCardConfig } from "../../components/demo-card";
|
||||
import { provideHass } from "../../../../src/fake_data/provide_hass";
|
||||
import type { PictureGlanceCardConfig } from "../../../../src/panels/lovelace/cards/types";
|
||||
import "../../components/demo-cards";
|
||||
import { mockIcons } from "../../../../demo/src/stubs/icons";
|
||||
|
||||
@@ -58,110 +60,110 @@ const ENTITIES = [
|
||||
const CONFIGS = [
|
||||
{
|
||||
heading: "Title, dialog, toggle",
|
||||
config: `
|
||||
- type: picture-glance
|
||||
image: /images/living_room.png
|
||||
title: Living room
|
||||
entities:
|
||||
- switch.decorative_lights
|
||||
- light.ceiling_lights
|
||||
- binary_sensor.movement_backyard
|
||||
- binary_sensor.basement_floor_wet
|
||||
`,
|
||||
config: {
|
||||
type: "picture-glance",
|
||||
image: "/images/living_room.png",
|
||||
title: "Living room",
|
||||
entities: [
|
||||
"switch.decorative_lights",
|
||||
"light.ceiling_lights",
|
||||
"binary_sensor.movement_backyard",
|
||||
"binary_sensor.basement_floor_wet",
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: "Title, dialog, no toggle",
|
||||
config: `
|
||||
- type: picture-glance
|
||||
image: /images/living_room.png
|
||||
title: Living room
|
||||
entities:
|
||||
- binary_sensor.movement_backyard
|
||||
- binary_sensor.basement_floor_wet
|
||||
`,
|
||||
config: {
|
||||
type: "picture-glance",
|
||||
image: "/images/living_room.png",
|
||||
title: "Living room",
|
||||
entities: [
|
||||
"binary_sensor.movement_backyard",
|
||||
"binary_sensor.basement_floor_wet",
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: "Title, no dialog, toggle",
|
||||
config: `
|
||||
- type: picture-glance
|
||||
image: /images/living_room.png
|
||||
title: Living room
|
||||
entities:
|
||||
- switch.decorative_lights
|
||||
- light.ceiling_lights
|
||||
`,
|
||||
config: {
|
||||
type: "picture-glance",
|
||||
image: "/images/living_room.png",
|
||||
title: "Living room",
|
||||
entities: ["switch.decorative_lights", "light.ceiling_lights"],
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: "No title, dialog, toggle",
|
||||
config: `
|
||||
- type: picture-glance
|
||||
image: /images/living_room.png
|
||||
entities:
|
||||
- switch.decorative_lights
|
||||
- light.ceiling_lights
|
||||
- binary_sensor.movement_backyard
|
||||
- binary_sensor.basement_floor_wet
|
||||
`,
|
||||
config: {
|
||||
type: "picture-glance",
|
||||
image: "/images/living_room.png",
|
||||
entities: [
|
||||
"switch.decorative_lights",
|
||||
"light.ceiling_lights",
|
||||
"binary_sensor.movement_backyard",
|
||||
"binary_sensor.basement_floor_wet",
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: "No title, dialog, no toggle",
|
||||
config: `
|
||||
- type: picture-glance
|
||||
image: /images/living_room.png
|
||||
entities:
|
||||
- binary_sensor.movement_backyard
|
||||
- binary_sensor.basement_floor_wet
|
||||
`,
|
||||
config: {
|
||||
type: "picture-glance",
|
||||
image: "/images/living_room.png",
|
||||
entities: [
|
||||
"binary_sensor.movement_backyard",
|
||||
"binary_sensor.basement_floor_wet",
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: "No title, no dialog, toggle",
|
||||
config: `
|
||||
- type: picture-glance
|
||||
image: /images/living_room.png
|
||||
entities:
|
||||
- switch.decorative_lights
|
||||
- light.ceiling_lights
|
||||
`,
|
||||
config: {
|
||||
type: "picture-glance",
|
||||
image: "/images/living_room.png",
|
||||
entities: ["switch.decorative_lights", "light.ceiling_lights"],
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: "Person entity",
|
||||
config: `
|
||||
- type: picture-glance
|
||||
image_entity: person.paulus
|
||||
entities:
|
||||
- sensor.battery
|
||||
`,
|
||||
config: {
|
||||
type: "picture-glance",
|
||||
image_entity: "person.paulus",
|
||||
entities: ["sensor.battery"],
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: "Custom icon",
|
||||
config: `
|
||||
- type: picture-glance
|
||||
image: /images/living_room.png
|
||||
title: Living room
|
||||
entities:
|
||||
- entity: switch.decorative_lights
|
||||
icon: mdi:power
|
||||
- binary_sensor.basement_floor_wet
|
||||
`,
|
||||
config: {
|
||||
type: "picture-glance",
|
||||
image: "/images/living_room.png",
|
||||
title: "Living room",
|
||||
entities: [
|
||||
{ entity: "switch.decorative_lights", icon: "mdi:power" },
|
||||
"binary_sensor.basement_floor_wet",
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: "Custom tap action",
|
||||
config: `
|
||||
- type: picture-glance
|
||||
image: /images/living_room.png
|
||||
title: Living room
|
||||
entity: light.ceiling_lights
|
||||
tap_action:
|
||||
action: toggle
|
||||
entities:
|
||||
- entity: switch.decorative_lights
|
||||
icon: mdi:power
|
||||
tap_action:
|
||||
action: toggle
|
||||
- binary_sensor.basement_floor_wet
|
||||
`,
|
||||
config: {
|
||||
type: "picture-glance",
|
||||
image: "/images/living_room.png",
|
||||
title: "Living room",
|
||||
entity: "light.ceiling_lights",
|
||||
tap_action: { action: "toggle" },
|
||||
entities: [
|
||||
{
|
||||
entity: "switch.decorative_lights",
|
||||
icon: "mdi:power",
|
||||
tap_action: { action: "toggle" },
|
||||
},
|
||||
"binary_sensor.basement_floor_wet",
|
||||
],
|
||||
},
|
||||
},
|
||||
];
|
||||
] satisfies DemoCardConfig<PictureGlanceCardConfig>[];
|
||||
|
||||
@customElement("demo-lovelace-picture-glance-card")
|
||||
class DemoPictureGlance extends LitElement {
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import type { PropertyValues, TemplateResult } from "lit";
|
||||
import { html, LitElement } from "lit";
|
||||
import { customElement, query } from "lit/decorators";
|
||||
import type { PlantStatusCardConfig } from "../../../../src/panels/lovelace/cards/types";
|
||||
import type { DemoCardConfig } from "../../components/demo-card";
|
||||
import { provideHass } from "../../../../src/fake_data/provide_hass";
|
||||
import "../../components/demo-cards";
|
||||
import { createPlantEntities } from "../../data/plants";
|
||||
@@ -9,27 +11,27 @@ import { mockIcons } from "../../../../demo/src/stubs/icons";
|
||||
const CONFIGS = [
|
||||
{
|
||||
heading: "Basic example",
|
||||
config: `
|
||||
- type: plant-status
|
||||
entity: plant.lemon_tree
|
||||
`,
|
||||
config: {
|
||||
type: "plant-status",
|
||||
entity: "plant.lemon_tree",
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: "Problem (too bright) + low battery",
|
||||
config: `
|
||||
- type: plant-status
|
||||
entity: plant.apple_tree
|
||||
`,
|
||||
config: {
|
||||
type: "plant-status",
|
||||
entity: "plant.apple_tree",
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: "With picture + multiple problems",
|
||||
config: `
|
||||
- type: plant-status
|
||||
entity: plant.sunflowers
|
||||
name: Sunflowers Name Overwrite
|
||||
`,
|
||||
config: {
|
||||
type: "plant-status",
|
||||
entity: "plant.sunflowers",
|
||||
name: "Sunflowers Name Overwrite",
|
||||
},
|
||||
},
|
||||
];
|
||||
] satisfies DemoCardConfig<PlantStatusCardConfig>[];
|
||||
|
||||
@customElement("demo-lovelace-plant-card")
|
||||
export class DemoPlantEntity extends LitElement {
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import type { PropertyValues, TemplateResult } from "lit";
|
||||
import { html, LitElement } from "lit";
|
||||
import { customElement, query } from "lit/decorators";
|
||||
import type { DemoCardConfig } from "../../components/demo-card";
|
||||
import { provideHass } from "../../../../src/fake_data/provide_hass";
|
||||
import type { ThermostatCardConfig } from "../../../../src/panels/lovelace/cards/types";
|
||||
import "../../components/demo-cards";
|
||||
import { mockIcons } from "../../../../demo/src/stubs/icons";
|
||||
|
||||
@@ -123,120 +125,131 @@ const ENTITIES = [
|
||||
const CONFIGS = [
|
||||
{
|
||||
heading: "Range example",
|
||||
config: `
|
||||
- type: thermostat
|
||||
entity: climate.ecobee
|
||||
`,
|
||||
config: {
|
||||
type: "thermostat",
|
||||
entity: "climate.ecobee",
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: "Single temp example",
|
||||
config: `
|
||||
- type: thermostat
|
||||
entity: climate.nest
|
||||
`,
|
||||
config: {
|
||||
type: "thermostat",
|
||||
entity: "climate.nest",
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: "Feature example",
|
||||
config: `
|
||||
- type: thermostat
|
||||
entity: climate.overkiz_radiator
|
||||
features:
|
||||
- type: climate-hvac-modes
|
||||
hvac_modes:
|
||||
- heat
|
||||
- 'off'
|
||||
- auto
|
||||
- type: climate-preset-modes
|
||||
style: icons
|
||||
preset_modes:
|
||||
- none
|
||||
- frost_protection
|
||||
- eco
|
||||
- comfort
|
||||
- comfort-1
|
||||
- comfort-2
|
||||
- auto
|
||||
- boost
|
||||
- external
|
||||
- prog
|
||||
- type: climate-preset-modes
|
||||
style: dropdown
|
||||
preset_modes:
|
||||
- none
|
||||
- frost_protection
|
||||
- eco
|
||||
- comfort
|
||||
- comfort-1
|
||||
- comfort-2
|
||||
- auto
|
||||
- boost
|
||||
- external
|
||||
- prog
|
||||
`,
|
||||
config: {
|
||||
type: "thermostat",
|
||||
entity: "climate.overkiz_radiator",
|
||||
features: [
|
||||
{
|
||||
type: "climate-hvac-modes",
|
||||
hvac_modes: ["heat", "off", "auto"],
|
||||
},
|
||||
{
|
||||
type: "climate-preset-modes",
|
||||
style: "icons",
|
||||
preset_modes: [
|
||||
"none",
|
||||
"frost_protection",
|
||||
"eco",
|
||||
"comfort",
|
||||
"comfort-1",
|
||||
"comfort-2",
|
||||
"auto",
|
||||
"boost",
|
||||
"external",
|
||||
"prog",
|
||||
],
|
||||
},
|
||||
{
|
||||
type: "climate-preset-modes",
|
||||
style: "dropdown",
|
||||
preset_modes: [
|
||||
"none",
|
||||
"frost_protection",
|
||||
"eco",
|
||||
"comfort",
|
||||
"comfort-1",
|
||||
"comfort-2",
|
||||
"auto",
|
||||
"boost",
|
||||
"external",
|
||||
"prog",
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: "Preset only example",
|
||||
config: `
|
||||
- type: thermostat
|
||||
entity: climate.overkiz_towel_dryer
|
||||
features:
|
||||
- type: climate-hvac-modes
|
||||
hvac_modes:
|
||||
- heat
|
||||
- 'off'
|
||||
- type: climate-preset-modes
|
||||
style: icons
|
||||
preset_modes:
|
||||
- none
|
||||
- frost_protection
|
||||
- eco
|
||||
- comfort
|
||||
- comfort-1
|
||||
- comfort-2
|
||||
`,
|
||||
config: {
|
||||
type: "thermostat",
|
||||
entity: "climate.overkiz_towel_dryer",
|
||||
features: [
|
||||
{
|
||||
type: "climate-hvac-modes",
|
||||
hvac_modes: ["heat", "off"],
|
||||
},
|
||||
{
|
||||
type: "climate-preset-modes",
|
||||
style: "icons",
|
||||
preset_modes: [
|
||||
"none",
|
||||
"frost_protection",
|
||||
"eco",
|
||||
"comfort",
|
||||
"comfort-1",
|
||||
"comfort-2",
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: "Fan only example",
|
||||
config: `
|
||||
- type: thermostat
|
||||
entity: climate.sensibo
|
||||
features:
|
||||
- type: climate-hvac-modes
|
||||
hvac_modes:
|
||||
- fan_only
|
||||
- 'off'
|
||||
- type: climate-fan-modes
|
||||
style: icons
|
||||
fan_modes:
|
||||
- low
|
||||
- high
|
||||
- type: climate-swing-modes
|
||||
style: icons
|
||||
swing_modes:
|
||||
- 'both'
|
||||
- 'rangefull'
|
||||
- 'off'
|
||||
swing_horizontal_modes:
|
||||
- 'both'
|
||||
- 'rangefull'
|
||||
- 'off'
|
||||
`,
|
||||
config: {
|
||||
type: "thermostat",
|
||||
entity: "climate.sensibo",
|
||||
features: [
|
||||
{
|
||||
type: "climate-hvac-modes",
|
||||
hvac_modes: ["fan_only", "off"],
|
||||
},
|
||||
{
|
||||
type: "climate-fan-modes",
|
||||
style: "icons",
|
||||
fan_modes: ["low", "high"],
|
||||
},
|
||||
{
|
||||
type: "climate-swing-modes",
|
||||
style: "icons",
|
||||
swing_modes: ["both", "rangefull", "off"],
|
||||
},
|
||||
{
|
||||
type: "climate-swing-horizontal-modes",
|
||||
style: "icons",
|
||||
swing_horizontal_modes: ["both", "rangefull", "off"],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: "Unavailable",
|
||||
config: `
|
||||
- type: thermostat
|
||||
entity: climate.unavailable
|
||||
`,
|
||||
config: {
|
||||
type: "thermostat",
|
||||
entity: "climate.unavailable",
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: "Non existing",
|
||||
config: `
|
||||
- type: thermostat
|
||||
entity: climate.nonexisting
|
||||
`,
|
||||
config: {
|
||||
type: "thermostat",
|
||||
entity: "climate.nonexisting",
|
||||
},
|
||||
},
|
||||
];
|
||||
] satisfies DemoCardConfig<ThermostatCardConfig>[];
|
||||
|
||||
@customElement("demo-lovelace-thermostat-card")
|
||||
class DemoThermostatEntity extends LitElement {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { PropertyValues, TemplateResult } from "lit";
|
||||
import { html, LitElement } from "lit";
|
||||
import { customElement, query } from "lit/decorators";
|
||||
import type { DemoCardConfig } from "../../components/demo-card";
|
||||
import { CoverEntityFeature } from "../../../../src/data/cover";
|
||||
import { LightColorMode } from "../../../../src/data/light";
|
||||
import { LockEntityFeature } from "../../../../src/data/lock";
|
||||
@@ -11,6 +12,7 @@ import "../../components/demo-cards";
|
||||
import { mockIcons } from "../../../../demo/src/stubs/icons";
|
||||
import { ClimateEntityFeature } from "../../../../src/data/climate";
|
||||
import { FanEntityFeature } from "../../../../src/data/fan";
|
||||
import type { TileCardConfig } from "../../../../src/panels/lovelace/cards/types";
|
||||
|
||||
const ENTITIES = [
|
||||
{
|
||||
@@ -166,190 +168,179 @@ const ENTITIES = [
|
||||
const CONFIGS = [
|
||||
{
|
||||
heading: "Basic example",
|
||||
config: `
|
||||
- type: tile
|
||||
entity: switch.tv_outlet
|
||||
`,
|
||||
config: {
|
||||
type: "tile",
|
||||
entity: "switch.tv_outlet",
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: "Vertical example",
|
||||
config: `
|
||||
- type: tile
|
||||
entity: switch.tv_outlet
|
||||
vertical: true
|
||||
`,
|
||||
config: {
|
||||
type: "tile",
|
||||
entity: "switch.tv_outlet",
|
||||
vertical: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: "Custom color",
|
||||
config: `
|
||||
- type: tile
|
||||
entity: switch.tv_outlet
|
||||
color: pink
|
||||
`,
|
||||
config: {
|
||||
type: "tile",
|
||||
entity: "switch.tv_outlet",
|
||||
color: "pink",
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: "Whole tile tap action",
|
||||
config: `
|
||||
- type: tile
|
||||
entity: switch.tv_outlet
|
||||
color: pink
|
||||
tap_action:
|
||||
action: toggle
|
||||
icon_tap_action:
|
||||
action: none
|
||||
`,
|
||||
config: {
|
||||
type: "tile",
|
||||
entity: "switch.tv_outlet",
|
||||
color: "pink",
|
||||
tap_action: {
|
||||
action: "toggle",
|
||||
},
|
||||
icon_tap_action: {
|
||||
action: "none",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: "Unknown entity",
|
||||
config: `
|
||||
- type: tile
|
||||
entity: light.unknown
|
||||
`,
|
||||
config: {
|
||||
type: "tile",
|
||||
entity: "light.unknown",
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: "Unavailable entity",
|
||||
config: `
|
||||
- type: tile
|
||||
entity: light.unavailable
|
||||
`,
|
||||
config: {
|
||||
type: "tile",
|
||||
entity: "light.unavailable",
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: "Climate",
|
||||
config: `
|
||||
- type: tile
|
||||
entity: climate.thermostat
|
||||
`,
|
||||
config: {
|
||||
type: "tile",
|
||||
entity: "climate.thermostat",
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: "Person",
|
||||
config: `
|
||||
- type: tile
|
||||
entity: person.paulus
|
||||
`,
|
||||
config: {
|
||||
type: "tile",
|
||||
entity: "person.paulus",
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: "Light brightness feature",
|
||||
config: `
|
||||
- type: tile
|
||||
entity: light.bed_light
|
||||
features:
|
||||
- type: "light-brightness"
|
||||
`,
|
||||
config: {
|
||||
type: "tile",
|
||||
entity: "light.bed_light",
|
||||
features: [{ type: "light-brightness" }],
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: "Light color temperature feature",
|
||||
config: `
|
||||
- type: tile
|
||||
entity: light.bed_light
|
||||
features:
|
||||
- type: "color-temp"
|
||||
`,
|
||||
config: {
|
||||
type: "tile",
|
||||
entity: "light.bed_light",
|
||||
features: [{ type: "light-color-temp" }],
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: "Lock commands feature",
|
||||
config: `
|
||||
- type: tile
|
||||
entity: lock.front_door
|
||||
features:
|
||||
- type: "lock-commands"
|
||||
`,
|
||||
config: {
|
||||
type: "tile",
|
||||
entity: "lock.front_door",
|
||||
features: [{ type: "lock-commands" }],
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: "Lock open door feature",
|
||||
config: `
|
||||
- type: tile
|
||||
entity: lock.front_door
|
||||
features:
|
||||
- type: "lock-open-door"
|
||||
`,
|
||||
config: {
|
||||
type: "tile",
|
||||
entity: "lock.front_door",
|
||||
features: [{ type: "lock-open-door" }],
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: "Media player volume slider feature",
|
||||
config: `
|
||||
- type: tile
|
||||
entity: media_player.living_room
|
||||
features:
|
||||
- type: "media-player-volume-slider"
|
||||
`,
|
||||
config: {
|
||||
type: "tile",
|
||||
entity: "media_player.living_room",
|
||||
features: [{ type: "media-player-volume-slider" }],
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: "Vacuum commands feature",
|
||||
config: `
|
||||
- type: tile
|
||||
entity: vacuum.first_floor_vacuum
|
||||
features:
|
||||
- type: "vacuum-commands"
|
||||
commands:
|
||||
- start_pause
|
||||
- stop
|
||||
- return_home
|
||||
`,
|
||||
config: {
|
||||
type: "tile",
|
||||
entity: "vacuum.first_floor_vacuum",
|
||||
features: [
|
||||
{
|
||||
type: "vacuum-commands",
|
||||
commands: ["start_pause", "stop", "return_home"],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: "Cover open close feature",
|
||||
config: `
|
||||
- type: tile
|
||||
entity: cover.kitchen_shutter
|
||||
features:
|
||||
- type: "cover-open-close"
|
||||
`,
|
||||
config: {
|
||||
type: "tile",
|
||||
entity: "cover.kitchen_shutter",
|
||||
features: [{ type: "cover-open-close" }],
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: "Cover tilt feature",
|
||||
config: `
|
||||
- type: tile
|
||||
entity: cover.pergola_roof
|
||||
features:
|
||||
- type: "cover-tilt"
|
||||
`,
|
||||
config: {
|
||||
type: "tile",
|
||||
entity: "cover.pergola_roof",
|
||||
features: [{ type: "cover-tilt" }],
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: "Number buttons feature",
|
||||
config: `
|
||||
- type: tile
|
||||
entity: input_number.counter
|
||||
features:
|
||||
- type: numeric-input
|
||||
style: buttons
|
||||
`,
|
||||
config: {
|
||||
type: "tile",
|
||||
entity: "input_number.counter",
|
||||
features: [{ type: "numeric-input", style: "buttons" }],
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: "Dual thermostat feature",
|
||||
config: `
|
||||
- type: tile
|
||||
entity: climate.dual_thermostat
|
||||
features:
|
||||
- type: target-temperature
|
||||
`,
|
||||
config: {
|
||||
type: "tile",
|
||||
entity: "climate.dual_thermostat",
|
||||
features: [{ type: "target-temperature" }],
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: "Fan direction feature",
|
||||
config: `
|
||||
- type: tile
|
||||
entity: fan.fan_demo
|
||||
features:
|
||||
- type: fan-direction
|
||||
`,
|
||||
config: {
|
||||
type: "tile",
|
||||
entity: "fan.fan_demo",
|
||||
features: [{ type: "fan-direction" }],
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: "Fan speed feature",
|
||||
config: `
|
||||
- type: tile
|
||||
entity: fan.fan_demo
|
||||
features:
|
||||
- type: fan-speed
|
||||
`,
|
||||
config: {
|
||||
type: "tile",
|
||||
entity: "fan.fan_demo",
|
||||
features: [{ type: "fan-speed" }],
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: "Fan oscillate feature",
|
||||
config: `
|
||||
- type: tile
|
||||
entity: fan.fan_demo
|
||||
features:
|
||||
- type: fan-oscillate
|
||||
`,
|
||||
config: {
|
||||
type: "tile",
|
||||
entity: "fan.fan_demo",
|
||||
features: [{ type: "fan-oscillate" }],
|
||||
},
|
||||
},
|
||||
];
|
||||
] satisfies DemoCardConfig<TileCardConfig>[];
|
||||
|
||||
@customElement("demo-lovelace-tile-card")
|
||||
class DemoTile extends LitElement {
|
||||
|
||||
@@ -4,6 +4,8 @@ import { customElement, query } from "lit/decorators";
|
||||
import { mockIcons } from "../../../../demo/src/stubs/icons";
|
||||
import { mockTodo } from "../../../../demo/src/stubs/todo";
|
||||
import { provideHass } from "../../../../src/fake_data/provide_hass";
|
||||
import type { TodoListCardConfig } from "../../../../src/panels/lovelace/cards/types";
|
||||
import type { DemoCardConfig } from "../../components/demo-card";
|
||||
import "../../components/demo-cards";
|
||||
|
||||
const ENTITIES = [
|
||||
@@ -27,20 +29,20 @@ const ENTITIES = [
|
||||
const CONFIGS = [
|
||||
{
|
||||
heading: "List example",
|
||||
config: `
|
||||
- type: todo-list
|
||||
entity: todo.shopping_list
|
||||
`,
|
||||
config: {
|
||||
type: "todo-list",
|
||||
entity: "todo.shopping_list",
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: "List with title example",
|
||||
config: `
|
||||
- type: todo-list
|
||||
title: Shopping List
|
||||
entity: todo.read_only
|
||||
`,
|
||||
config: {
|
||||
type: "todo-list",
|
||||
title: "Shopping List",
|
||||
entity: "todo.read_only",
|
||||
},
|
||||
},
|
||||
];
|
||||
] satisfies DemoCardConfig<TodoListCardConfig>[];
|
||||
|
||||
@customElement("demo-lovelace-todo-list-card")
|
||||
class DemoTodoListEntity extends LitElement {
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
---
|
||||
title: Cloud account
|
||||
---
|
||||
|
||||
The [Home Assistant Cloud](https://www.nabucasa.com/) account page, rendered from
|
||||
mocked cloud data. The controls at the top flip the mocked subscription, remote,
|
||||
backup, onboarding, and feature state so every UI state can be previewed here
|
||||
without a real cloud account.
|
||||
@@ -0,0 +1,568 @@
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, state } from "lit/decorators";
|
||||
import type { HASSDomEvent } from "../../../../src/common/dom/fire_event";
|
||||
import "../../../../src/components/ha-formfield";
|
||||
import "../../../../src/components/ha-select";
|
||||
import type { HaSelectSelectEvent } from "../../../../src/components/ha-select";
|
||||
import "../../../../src/components/ha-switch";
|
||||
import type { HaSwitch } from "../../../../src/components/ha-switch";
|
||||
import type { BackupConfig } from "../../../../src/data/backup";
|
||||
import { BackupScheduleRecurrence } from "../../../../src/data/backup";
|
||||
import type {
|
||||
CloudStatusLoggedIn,
|
||||
RemoteCertificateStatus,
|
||||
SubscriptionInfo,
|
||||
} from "../../../../src/data/cloud";
|
||||
import { ONBOARDING_ITEMS } from "../../../../src/data/cloud";
|
||||
import type { Webhook } from "../../../../src/data/webhook";
|
||||
import type { ShowDialogParams } from "../../../../src/dialogs/make-dialog-manager";
|
||||
import { showDialog } from "../../../../src/dialogs/make-dialog-manager";
|
||||
import type { MockHomeAssistant } from "../../../../src/fake_data/provide_hass";
|
||||
import { provideHass } from "../../../../src/fake_data/provide_hass";
|
||||
import type { ProvideHassElement } from "../../../../src/mixins/provide-hass-lit-mixin";
|
||||
import "../../../../src/panels/config/cloud/account/cloud-account-onboarding";
|
||||
import "../../../../src/panels/config/cloud/account/cloud-account-overview";
|
||||
import { onboardingComplete } from "../../../../src/panels/config/cloud/account/cloud-account-status";
|
||||
import { showCloudOnboardingDialog } from "../../../../src/panels/config/cloud/account/show-dialog-cloud-onboarding";
|
||||
import type { HomeAssistant } from "../../../../src/types";
|
||||
|
||||
// This demo renders the self-contained cloud account cards (overview +
|
||||
// onboarding) directly, driven by mocked data, with controls to flip the
|
||||
// subscription, remote, backup, onboarding, and feature state so every UI state
|
||||
// can be previewed. It intentionally does NOT render the <cloud-account> panel
|
||||
// wrapper, which adds a hass-subpage toolbar/back button and route links that
|
||||
// have no home in the gallery. See src/panels/config/cloud/account.
|
||||
|
||||
// The five PaymentSubscriptionState values.
|
||||
type DemoCloudAccount =
|
||||
"active" | "trialing" | "canceled" | "expired" | "unknown";
|
||||
|
||||
// "local": automatic backups run, but only to the local agent (no cloud copy).
|
||||
// "none": no automatic backups at all.
|
||||
type DemoCloudBackup = "fresh" | "stale" | "failed" | "local" | "none";
|
||||
|
||||
interface CloudDemoScenario {
|
||||
account: DemoCloudAccount;
|
||||
onboarded: boolean;
|
||||
postponed: boolean;
|
||||
remote: boolean;
|
||||
remoteStatus: RemoteCertificateStatus;
|
||||
backup: DemoCloudBackup;
|
||||
alexa: boolean;
|
||||
google: boolean;
|
||||
webrtc: boolean;
|
||||
webhooks: boolean;
|
||||
}
|
||||
|
||||
const DEFAULT_SCENARIO: CloudDemoScenario = {
|
||||
account: "active",
|
||||
// Onboarding not completed and streaming (WebRTC) left off, so the onboarding
|
||||
// card shows by default with streaming as the remaining step.
|
||||
onboarded: false,
|
||||
postponed: false,
|
||||
remote: true,
|
||||
remoteStatus: "ready",
|
||||
backup: "fresh",
|
||||
alexa: true,
|
||||
google: true,
|
||||
webrtc: false,
|
||||
webhooks: true,
|
||||
};
|
||||
|
||||
const SUBSCRIPTION_OPTIONS: { value: DemoCloudAccount; label: string }[] = [
|
||||
{ value: "active", label: "Active" },
|
||||
{ value: "trialing", label: "Trialing" },
|
||||
{ value: "canceled", label: "Canceled" },
|
||||
{ value: "expired", label: "Expired" },
|
||||
{ value: "unknown", label: "Unknown" },
|
||||
];
|
||||
|
||||
const REMOTE_STATUS_OPTIONS: {
|
||||
value: RemoteCertificateStatus;
|
||||
label: string;
|
||||
}[] = [
|
||||
{ value: "ready", label: "Ready" },
|
||||
{ value: "generating", label: "Preparing" },
|
||||
{ value: "loading", label: "Loading" },
|
||||
{ value: "loaded", label: "Loaded" },
|
||||
{ value: "error", label: "Error" },
|
||||
];
|
||||
|
||||
const BACKUP_OPTIONS: { value: DemoCloudBackup; label: string }[] = [
|
||||
{ value: "fresh", label: "Recent" },
|
||||
{ value: "stale", label: "Old" },
|
||||
{ value: "failed", label: "Failed" },
|
||||
{ value: "local", label: "Local only" },
|
||||
{ value: "none", label: "None" },
|
||||
];
|
||||
|
||||
const TOGGLES: [keyof CloudDemoScenario, string][] = [
|
||||
["onboarded", "Onboarded"],
|
||||
["postponed", "Onboarding postponed"],
|
||||
["remote", "Remote access"],
|
||||
["alexa", "Alexa linked"],
|
||||
["google", "Google linked"],
|
||||
["webrtc", "Cameras (WebRTC)"],
|
||||
["webhooks", "Has webhooks"],
|
||||
];
|
||||
|
||||
const CLOUD_AGENT = "cloud.cloud";
|
||||
|
||||
const emptyFilter = () => ({
|
||||
include_domains: [],
|
||||
include_entities: [],
|
||||
exclude_domains: [],
|
||||
exclude_entities: [],
|
||||
});
|
||||
|
||||
const demoWebhooks: Webhook[] = [
|
||||
{
|
||||
webhook_id: "demo_front_door",
|
||||
domain: "automation",
|
||||
name: "Front door motion",
|
||||
local_only: false,
|
||||
},
|
||||
{
|
||||
webhook_id: "demo_companion_app",
|
||||
domain: "mobile_app",
|
||||
name: "Companion app",
|
||||
local_only: false,
|
||||
},
|
||||
];
|
||||
|
||||
const buildSubscription = (scenario: CloudDemoScenario): SubscriptionInfo => ({
|
||||
human_description: "Demo subscription, renews automatically",
|
||||
provider: "Nabu Casa, Inc.",
|
||||
plan_renewal_date: 4102444800,
|
||||
subscription: { status: scenario.account },
|
||||
});
|
||||
|
||||
const buildCloudStatus = (scenario: CloudDemoScenario): CloudStatusLoggedIn => {
|
||||
const active =
|
||||
scenario.account !== "canceled" && scenario.account !== "expired";
|
||||
const cloudhooks = scenario.webhooks
|
||||
? Object.fromEntries(
|
||||
demoWebhooks.map((webhook) => [
|
||||
webhook.webhook_id,
|
||||
{
|
||||
webhook_id: webhook.webhook_id,
|
||||
cloudhook_id: `demo-${webhook.webhook_id}`,
|
||||
cloudhook_url: `https://hooks.nabu.casa/demo-${webhook.webhook_id}`,
|
||||
managed: false,
|
||||
},
|
||||
])
|
||||
)
|
||||
: {};
|
||||
return {
|
||||
logged_in: true,
|
||||
cloud: "connected",
|
||||
cloud_last_disconnect_reason: null,
|
||||
email: "[email protected]",
|
||||
google_registered: scenario.google,
|
||||
google_entities: emptyFilter(),
|
||||
google_domains: ["light", "switch", "climate", "cover"],
|
||||
alexa_registered: scenario.alexa,
|
||||
alexa_entities: emptyFilter(),
|
||||
remote_domain: "demo-instance.ui.nabu.casa",
|
||||
remote_connected: scenario.remote,
|
||||
remote_certificate: {
|
||||
common_name: "demo-instance.ui.nabu.casa",
|
||||
expire_date: "2099-01-01T00:00:00+00:00",
|
||||
fingerprint: "demodemodemodemodemodemodemodemodemodemodemodemodemo",
|
||||
alternative_names: ["demo-instance.ui.nabu.casa"],
|
||||
},
|
||||
remote_certificate_status: scenario.remoteStatus,
|
||||
http_use_ssl: false,
|
||||
active_subscription: active,
|
||||
onboarding_postponed: scenario.postponed,
|
||||
onboarding_completed: scenario.onboarded,
|
||||
prefs: {
|
||||
google_enabled: scenario.google,
|
||||
alexa_enabled: scenario.alexa,
|
||||
remote_enabled: scenario.remote,
|
||||
remote_allow_remote_enable: true,
|
||||
strict_connection: "disabled",
|
||||
google_secure_devices_pin: undefined,
|
||||
cloudhooks,
|
||||
alexa_report_state: true,
|
||||
google_report_state: true,
|
||||
tts_default_voice: ["en-US", "JennyNeural"],
|
||||
cloud_ice_servers_enabled: scenario.webrtc,
|
||||
onboarded_items: scenario.onboarded ? [...ONBOARDING_ITEMS] : [],
|
||||
onboarding_postponed_until: scenario.postponed
|
||||
? new Date(Date.now() + 24 * 3600 * 1000).toISOString()
|
||||
: null,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const buildBackupConfig = (scenario: CloudDemoScenario): BackupConfig => {
|
||||
const now = Date.now();
|
||||
const recent = new Date(now - 12 * 3600 * 1000).toISOString();
|
||||
const old = new Date(now - 5 * 86400000).toISOString();
|
||||
const future = new Date(now + 86400000).toISOString();
|
||||
const overdue = new Date(now - 6 * 3600 * 1000).toISOString();
|
||||
|
||||
const cloudEnabled =
|
||||
scenario.backup === "fresh" ||
|
||||
scenario.backup === "stale" ||
|
||||
scenario.backup === "failed";
|
||||
|
||||
let configured = true;
|
||||
let lastCompleted: string | null = null;
|
||||
let lastAttempted: string | null = null;
|
||||
let next: string | null = null;
|
||||
switch (scenario.backup) {
|
||||
case "fresh":
|
||||
case "local":
|
||||
lastCompleted = recent;
|
||||
lastAttempted = recent;
|
||||
next = future;
|
||||
break;
|
||||
case "stale":
|
||||
lastCompleted = old;
|
||||
lastAttempted = old;
|
||||
next = overdue;
|
||||
break;
|
||||
case "failed":
|
||||
lastCompleted = old;
|
||||
lastAttempted = recent;
|
||||
next = future;
|
||||
break;
|
||||
case "none":
|
||||
configured = false;
|
||||
break;
|
||||
}
|
||||
|
||||
return {
|
||||
automatic_backups_configured: configured,
|
||||
last_attempted_automatic_backup: lastAttempted,
|
||||
last_completed_automatic_backup: lastCompleted,
|
||||
next_automatic_backup: next,
|
||||
next_automatic_backup_additional: false,
|
||||
create_backup: {
|
||||
agent_ids: cloudEnabled
|
||||
? ["backup.local", CLOUD_AGENT]
|
||||
: ["backup.local"],
|
||||
include_addons: [],
|
||||
include_all_addons: true,
|
||||
include_database: true,
|
||||
include_folders: [],
|
||||
name: null,
|
||||
password: null,
|
||||
},
|
||||
retention: { copies: 3, days: null },
|
||||
schedule: {
|
||||
recurrence: BackupScheduleRecurrence.DAILY,
|
||||
time: null,
|
||||
days: [],
|
||||
},
|
||||
agents: {
|
||||
"backup.local": { protected: true, retention: null },
|
||||
"cloud.cloud": { protected: true, retention: null },
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
@customElement("demo-misc-cloud-account")
|
||||
export class DemoMiscCloudAccount
|
||||
extends LitElement
|
||||
implements ProvideHassElement
|
||||
{
|
||||
@state() private hass!: HomeAssistant;
|
||||
|
||||
@state() private _scenario: CloudDemoScenario = { ...DEFAULT_SCENARIO };
|
||||
|
||||
@state() private _cloudStatus!: CloudStatusLoggedIn;
|
||||
|
||||
@state() private _subscription!: SubscriptionInfo;
|
||||
|
||||
@state() private _backupConfig!: BackupConfig;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
const hass = provideHass(this);
|
||||
hass.updateTranslations(null, "en");
|
||||
hass.updateTranslations("config", "en");
|
||||
|
||||
hass.updateHass({
|
||||
config: {
|
||||
...hass.config,
|
||||
components: [
|
||||
...(hass.config?.components ?? []),
|
||||
"cloud",
|
||||
"backup",
|
||||
"webhook",
|
||||
],
|
||||
},
|
||||
});
|
||||
this._registerMocks(hass);
|
||||
this._applyScenario();
|
||||
}
|
||||
|
||||
public provideHass(el) {
|
||||
el.hass = this.hass;
|
||||
}
|
||||
|
||||
public connectedCallback() {
|
||||
super.connectedCallback();
|
||||
this.addEventListener("show-dialog", this._showDialog);
|
||||
this.addEventListener("cloud-open-onboarding", this._openOnboarding);
|
||||
this.addEventListener("ha-refresh-cloud-status", this._refreshFromMocks);
|
||||
// The overview and onboarding dialog contain real <a href="/config/..">
|
||||
// links to panel routes that do not exist in the gallery. Keep them inert.
|
||||
this.addEventListener("click", this._neutralizeNavigation);
|
||||
}
|
||||
|
||||
public disconnectedCallback() {
|
||||
super.disconnectedCallback();
|
||||
this.removeEventListener("show-dialog", this._showDialog);
|
||||
this.removeEventListener("cloud-open-onboarding", this._openOnboarding);
|
||||
this.removeEventListener("ha-refresh-cloud-status", this._refreshFromMocks);
|
||||
this.removeEventListener("click", this._neutralizeNavigation);
|
||||
}
|
||||
|
||||
protected render() {
|
||||
if (!this.hass) {
|
||||
return nothing;
|
||||
}
|
||||
const showOnboarding =
|
||||
this._cloudStatus.active_subscription &&
|
||||
!this._cloudStatus.onboarding_completed &&
|
||||
!this._cloudStatus.onboarding_postponed &&
|
||||
!onboardingComplete(this._cloudStatus, this._backupConfig);
|
||||
|
||||
return html`
|
||||
<div class="options">
|
||||
<div class="selects">
|
||||
${this._select("Subscription", "account", SUBSCRIPTION_OPTIONS)}
|
||||
${this._select("Remote status", "remoteStatus", REMOTE_STATUS_OPTIONS)}
|
||||
${this._select("Backups", "backup", BACKUP_OPTIONS)}
|
||||
</div>
|
||||
<div class="switches">
|
||||
${TOGGLES.map(([field, label]) => this._toggle(label, field))}
|
||||
</div>
|
||||
</div>
|
||||
<div class="preview">
|
||||
${
|
||||
showOnboarding
|
||||
? html`
|
||||
<cloud-account-onboarding
|
||||
.hass=${this.hass}
|
||||
.cloudStatus=${this._cloudStatus}
|
||||
.backupConfig=${this._backupConfig}
|
||||
></cloud-account-onboarding>
|
||||
`
|
||||
: nothing
|
||||
}
|
||||
<cloud-account-overview
|
||||
.hass=${this.hass}
|
||||
.cloudStatus=${this._cloudStatus}
|
||||
.subscription=${this._subscription}
|
||||
.backupConfig=${this._backupConfig}
|
||||
.webhooks=${demoWebhooks}
|
||||
></cloud-account-overview>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private _select(
|
||||
label: string,
|
||||
field: keyof CloudDemoScenario,
|
||||
options: { value: string; label: string }[]
|
||||
) {
|
||||
return html`
|
||||
<ha-select
|
||||
.label=${label}
|
||||
.value=${String(this._scenario[field])}
|
||||
.options=${options}
|
||||
data-field=${field}
|
||||
@selected=${this._selectChanged}
|
||||
></ha-select>
|
||||
`;
|
||||
}
|
||||
|
||||
private _toggle(label: string, field: keyof CloudDemoScenario) {
|
||||
return html`
|
||||
<ha-formfield .label=${label}>
|
||||
<ha-switch
|
||||
.checked=${this._scenario[field] as boolean}
|
||||
data-field=${field}
|
||||
@change=${this._switchChanged}
|
||||
></ha-switch>
|
||||
</ha-formfield>
|
||||
`;
|
||||
}
|
||||
|
||||
private _selectChanged(ev: HaSelectSelectEvent) {
|
||||
const field = (ev.currentTarget as HTMLElement).dataset
|
||||
.field as keyof CloudDemoScenario;
|
||||
this._setField(field, ev.detail.value as string);
|
||||
}
|
||||
|
||||
private _switchChanged(ev: Event) {
|
||||
const target = ev.target as HaSwitch;
|
||||
this._setField(
|
||||
target.dataset.field as keyof CloudDemoScenario,
|
||||
target.checked
|
||||
);
|
||||
}
|
||||
|
||||
private _setField(field: keyof CloudDemoScenario, value: string | boolean) {
|
||||
if (this._scenario[field] === value) {
|
||||
return;
|
||||
}
|
||||
this._scenario = { ...this._scenario, [field]: value };
|
||||
this._applyScenario();
|
||||
}
|
||||
|
||||
private _applyScenario() {
|
||||
this._cloudStatus = buildCloudStatus(this._scenario);
|
||||
this._subscription = buildSubscription(this._scenario);
|
||||
this._backupConfig = buildBackupConfig(this._scenario);
|
||||
}
|
||||
|
||||
private _openOnboarding = () => {
|
||||
showCloudOnboardingDialog(this, {
|
||||
cloudStatus: this._cloudStatus,
|
||||
backupConfig: this._backupConfig,
|
||||
onChanged: () => this._refreshFromMocks(),
|
||||
});
|
||||
};
|
||||
|
||||
private _showDialog = (ev: HASSDomEvent<ShowDialogParams<unknown>>) => {
|
||||
const { dialogTag, dialogImport, dialogParams, addHistory, parentElement } =
|
||||
ev.detail;
|
||||
showDialog(
|
||||
this,
|
||||
dialogTag,
|
||||
dialogParams,
|
||||
dialogImport,
|
||||
parentElement,
|
||||
addHistory
|
||||
);
|
||||
};
|
||||
|
||||
private _refreshFromMocks = () => {
|
||||
this._cloudStatus = {
|
||||
...this._cloudStatus,
|
||||
prefs: { ...this._cloudStatus.prefs },
|
||||
};
|
||||
this._backupConfig = { ...this._backupConfig };
|
||||
const cloudBackup =
|
||||
this._backupConfig.create_backup.agent_ids.includes(CLOUD_AGENT);
|
||||
this._scenario = {
|
||||
...this._scenario,
|
||||
onboarded: this._cloudStatus.onboarding_completed,
|
||||
postponed: this._cloudStatus.onboarding_postponed,
|
||||
remote: this._cloudStatus.prefs.remote_enabled,
|
||||
webrtc: this._cloudStatus.prefs.cloud_ice_servers_enabled,
|
||||
backup: !this._backupConfig.automatic_backups_configured
|
||||
? "none"
|
||||
: cloudBackup
|
||||
? this._scenario.backup === "fresh" ||
|
||||
this._scenario.backup === "stale" ||
|
||||
this._scenario.backup === "failed"
|
||||
? this._scenario.backup
|
||||
: "fresh"
|
||||
: "local",
|
||||
};
|
||||
};
|
||||
|
||||
private _neutralizeNavigation = (ev: MouseEvent) => {
|
||||
const anchor = ev
|
||||
.composedPath()
|
||||
.find((el): el is HTMLAnchorElement => el instanceof HTMLAnchorElement);
|
||||
if (anchor?.getAttribute("href")?.startsWith("/")) {
|
||||
ev.preventDefault();
|
||||
}
|
||||
};
|
||||
|
||||
private _registerMocks(hass: MockHomeAssistant) {
|
||||
hass.mockWS("cloud/status", () => ({
|
||||
...this._cloudStatus,
|
||||
prefs: { ...this._cloudStatus.prefs },
|
||||
}));
|
||||
|
||||
hass.mockWS("cloud/update_prefs", (msg) => {
|
||||
const { type, ...prefs } = msg;
|
||||
this._cloudStatus.prefs = { ...this._cloudStatus.prefs, ...prefs };
|
||||
return { success: true };
|
||||
});
|
||||
|
||||
hass.mockWS("cloud/onboarding/postpone", () => {
|
||||
this._cloudStatus.onboarding_postponed = true;
|
||||
this._cloudStatus.prefs.onboarding_postponed_until = new Date(
|
||||
Date.now() + 24 * 3600 * 1000
|
||||
).toISOString();
|
||||
return { ...this._cloudStatus, prefs: { ...this._cloudStatus.prefs } };
|
||||
});
|
||||
|
||||
hass.mockWS("cloud/remote/connect", () => {
|
||||
this._cloudStatus.remote_connected = true;
|
||||
this._cloudStatus.prefs.remote_enabled = true;
|
||||
return null;
|
||||
});
|
||||
hass.mockWS("cloud/remote/disconnect", () => {
|
||||
this._cloudStatus.remote_connected = false;
|
||||
this._cloudStatus.prefs.remote_enabled = false;
|
||||
return null;
|
||||
});
|
||||
|
||||
hass.mockWS("backup/config/info", () => ({
|
||||
config: { ...this._backupConfig },
|
||||
}));
|
||||
hass.mockWS("backup/config/update", (msg) => {
|
||||
const { type, ...update } = msg;
|
||||
if (update.create_backup) {
|
||||
this._backupConfig.create_backup = {
|
||||
...this._backupConfig.create_backup,
|
||||
...update.create_backup,
|
||||
};
|
||||
}
|
||||
if (update.automatic_backups_configured !== undefined) {
|
||||
this._backupConfig.automatic_backups_configured =
|
||||
update.automatic_backups_configured;
|
||||
}
|
||||
return null;
|
||||
});
|
||||
}
|
||||
|
||||
static styles = css`
|
||||
.options {
|
||||
max-width: 600px;
|
||||
margin: 16px auto 0;
|
||||
padding: 0 16px 16px;
|
||||
border-bottom: 1px solid var(--divider-color);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
.selects {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
}
|
||||
.selects ha-select {
|
||||
min-width: 160px;
|
||||
flex: 1;
|
||||
}
|
||||
.switches {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px 16px;
|
||||
}
|
||||
.preview {
|
||||
padding: 24px 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--ha-space-6, 24px);
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"demo-misc-cloud-account": DemoMiscCloudAccount;
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1 @@
|
||||
import { availableParallelism } from "node:os";
|
||||
import "./build-scripts/gulp/index.mjs";
|
||||
|
||||
process.env.UV_THREADPOOL_SIZE = availableParallelism();
|
||||
|
||||
+25
-26
@@ -7,7 +7,7 @@
|
||||
"name": "home-assistant-frontend",
|
||||
"version": "1.0.0",
|
||||
"scripts": {
|
||||
"build": "script/build_frontend",
|
||||
"build": "node build-scripts/build-manager.mjs",
|
||||
"lint:eslint": "eslint \"**/src/**/*.{js,ts,html}\" --cache --cache-strategy=content --cache-location=node_modules/.cache/eslint/.eslintcache --ignore-pattern=.gitignore --max-warnings=0",
|
||||
"format:eslint": "eslint \"**/src/**/*.{js,ts,html}\" --cache --cache-strategy=content --cache-location=node_modules/.cache/eslint/.eslintcache --ignore-pattern=.gitignore --fix",
|
||||
"lint:prettier": "prettier . --cache --check",
|
||||
@@ -49,16 +49,16 @@
|
||||
"@codemirror/lint": "6.9.7",
|
||||
"@codemirror/search": "6.7.1",
|
||||
"@codemirror/state": "6.7.1",
|
||||
"@codemirror/view": "6.43.6",
|
||||
"@codemirror/view": "6.43.8",
|
||||
"@date-fns/tz": "1.5.0",
|
||||
"@egjs/hammerjs": "2.0.17",
|
||||
"@formatjs/intl-datetimeformat": "7.5.2",
|
||||
"@formatjs/intl-datetimeformat": "7.6.0",
|
||||
"@formatjs/intl-displaynames": "7.3.13",
|
||||
"@formatjs/intl-durationformat": "0.10.18",
|
||||
"@formatjs/intl-getcanonicallocales": "3.2.11",
|
||||
"@formatjs/intl-listformat": "8.3.13",
|
||||
"@formatjs/intl-locale": "5.3.10",
|
||||
"@formatjs/intl-numberformat": "9.3.14",
|
||||
"@formatjs/intl-numberformat": "9.4.0",
|
||||
"@formatjs/intl-pluralrules": "6.3.13",
|
||||
"@formatjs/intl-relativetimeformat": "12.3.13",
|
||||
"@fullcalendar/core": "6.1.21",
|
||||
@@ -93,7 +93,7 @@
|
||||
"cally": "0.9.2",
|
||||
"color-name": "2.1.1",
|
||||
"comlink": "4.4.2",
|
||||
"core-js": "3.49.0",
|
||||
"core-js": "3.50.0",
|
||||
"cropperjs": "1.6.2",
|
||||
"culori": "4.0.2",
|
||||
"date-fns": "4.4.0",
|
||||
@@ -103,19 +103,18 @@
|
||||
"echarts": "6.1.0",
|
||||
"element-internals-polyfill": "3.0.2",
|
||||
"fuse.js": "7.5.0",
|
||||
"gulp-zopfli-green": "7.0.0",
|
||||
"hls.js": "1.6.16",
|
||||
"hls.js": "1.6.17",
|
||||
"home-assistant-js-websocket": "9.6.0",
|
||||
"idb-keyval": "6.3.0",
|
||||
"intl-messageformat": "11.2.12",
|
||||
"js-yaml": "5.2.2",
|
||||
"intl-messageformat": "11.2.13",
|
||||
"js-yaml": "5.2.3",
|
||||
"leaflet": "1.9.4",
|
||||
"leaflet-draw": "patch:leaflet-draw@npm%3A1.0.4#./.yarn/patches/leaflet-draw-npm-1.0.4-0ca0ebcf65.patch",
|
||||
"leaflet.markercluster": "1.5.3",
|
||||
"lit": "3.3.3",
|
||||
"lit-html": "3.3.3",
|
||||
"luxon": "3.7.2",
|
||||
"marked": "18.0.7",
|
||||
"marked": "18.0.9",
|
||||
"memoize-one": "6.0.0",
|
||||
"node-vibrant": "4.0.4",
|
||||
"object-hash": "3.0.0",
|
||||
@@ -145,26 +144,27 @@
|
||||
"@babel/preset-env": "8.0.2",
|
||||
"@bundle-stats/plugin-webpack-filter": "4.22.2",
|
||||
"@eslint/js": "10.0.1",
|
||||
"@gfx/zopfli": "1.0.15",
|
||||
"@html-eslint/eslint-plugin": "0.64.0",
|
||||
"@lokalise/node-api": "16.3.0",
|
||||
"@octokit/auth-oauth-device": "8.0.3",
|
||||
"@octokit/plugin-retry": "8.1.0",
|
||||
"@octokit/auth-oauth-device": "8.0.4",
|
||||
"@octokit/plugin-retry": "8.1.1",
|
||||
"@octokit/rest": "22.0.1",
|
||||
"@playwright/test": "1.62.0",
|
||||
"@playwright/test": "1.62.1",
|
||||
"@rsdoctor/rspack-plugin": "1.6.1",
|
||||
"@rspack/core": "2.1.5",
|
||||
"@rspack/dev-server": "2.1.0",
|
||||
"@rspack/core": "2.1.8",
|
||||
"@rspack/dev-server": "2.2.0",
|
||||
"@types/babel__plugin-transform-runtime": "7.9.5",
|
||||
"@types/chromecast-caf-receiver": "6.0.26",
|
||||
"@types/chromecast-caf-sender": "1.0.11",
|
||||
"@types/color-name": "2.0.0",
|
||||
"@types/culori": "4.0.1",
|
||||
"@types/html-minifier-terser": "7.0.2",
|
||||
"@types/leaflet": "1.9.21",
|
||||
"@types/leaflet": "1.9.22",
|
||||
"@types/leaflet-draw": "1.0.13",
|
||||
"@types/leaflet.markercluster": "1.5.6",
|
||||
"@types/lodash.merge": "4.6.9",
|
||||
"@types/luxon": "3.7.2",
|
||||
"@types/luxon": "3.7.4",
|
||||
"@types/qrcode": "1.5.6",
|
||||
"@types/sortablejs": "1.15.9",
|
||||
"@types/tar": "7.0.87",
|
||||
@@ -174,7 +174,7 @@
|
||||
"babel-plugin-polyfill-corejs3": "1.0.0",
|
||||
"browserslist-useragent-regexp": "4.1.4",
|
||||
"del": "8.0.1",
|
||||
"eslint": "10.8.0",
|
||||
"eslint": "10.8.1",
|
||||
"eslint-config-prettier": "10.1.8",
|
||||
"eslint-import-resolver-webpack": "0.13.11",
|
||||
"eslint-plugin-import-x": "4.17.1",
|
||||
@@ -186,17 +186,16 @@
|
||||
"fs-extra": "11.4.0",
|
||||
"generate-license-file": "4.2.1",
|
||||
"glob": "13.0.6",
|
||||
"globals": "17.7.0",
|
||||
"globals": "17.9.0",
|
||||
"gulp": "5.0.1",
|
||||
"gulp-brotli": "3.0.0",
|
||||
"gulp-json-transform": "0.5.0",
|
||||
"gulp-rename": "2.1.0",
|
||||
"html-minifier-terser": "7.2.0",
|
||||
"husky": "9.1.7",
|
||||
"jsdom": "29.1.1",
|
||||
"jsdom": "30.0.1",
|
||||
"jszip": "3.10.1",
|
||||
"license-checker-rseidelsohn": "5.0.1",
|
||||
"lint-staged": "17.2.0",
|
||||
"lint-staged": "17.3.0",
|
||||
"lit-analyzer": "2.0.3",
|
||||
"lodash.merge": "4.6.2",
|
||||
"lodash.template": "4.18.1",
|
||||
@@ -211,7 +210,7 @@
|
||||
"terser-webpack-plugin": "5.6.1",
|
||||
"ts-lit-plugin": "2.0.2",
|
||||
"typescript": "6.0.3",
|
||||
"typescript-eslint": "8.65.0",
|
||||
"typescript-eslint": "8.66.0",
|
||||
"vite-tsconfig-paths": "6.1.1",
|
||||
"vitest": "4.1.10",
|
||||
"webpack-stats-plugin": "1.1.3",
|
||||
@@ -224,12 +223,12 @@
|
||||
"clean-css": "5.3.3",
|
||||
"@lit/reactive-element": "2.1.2",
|
||||
"@fullcalendar/daygrid": "6.1.21",
|
||||
"globals": "17.7.0",
|
||||
"globals": "17.9.0",
|
||||
"tslib": "2.8.1",
|
||||
"@material/mwc-list@^0.27.0": "patch:@material/mwc-list@npm%3A0.27.0#~/.yarn/patches/@material-mwc-list-npm-0.27.0-5344fc9de4.patch"
|
||||
},
|
||||
"packageManager": "[email protected]7.1",
|
||||
"packageManager": "[email protected]8.0",
|
||||
"volta": {
|
||||
"node": "24.18.0"
|
||||
"node": "24.19.0"
|
||||
}
|
||||
}
|
||||
|
||||
+2
-2
@@ -8,10 +8,10 @@
|
||||
":prConcurrentLimit10",
|
||||
":semanticCommitsDisabled",
|
||||
"group:monorepos",
|
||||
"group:recommended",
|
||||
"security:minimumReleaseAgeNpm"
|
||||
"group:recommended"
|
||||
],
|
||||
"enabledManagers": ["npm", "nvm", "custom.regex"],
|
||||
"minimumReleaseAge": "3 days",
|
||||
"postUpdateOptions": ["yarnDedupeHighest"],
|
||||
"lockFileMaintenance": {
|
||||
"description": ["Run after patch releases but before next beta"],
|
||||
|
||||
@@ -61,10 +61,44 @@ fi
|
||||
echo Core is used from ${coreUrl}
|
||||
|
||||
# build the frontend so it connects to the passed core
|
||||
HASS_URL="$coreUrl" ./script/develop &
|
||||
HASS_URL="$coreUrl" ./node_modules/.bin/gulp develop-app &
|
||||
develop_pid=$!
|
||||
|
||||
# serve the frontend
|
||||
./node_modules/.bin/serve -p $frontendPort --single --no-port-switching --config ../script/serve-config.json ./hass_frontend &
|
||||
serve_pid=$!
|
||||
|
||||
# keep the script running while serving
|
||||
wait
|
||||
stop_children() {
|
||||
trap - EXIT INT TERM HUP
|
||||
kill "$develop_pid" "$serve_pid" 2>/dev/null || true
|
||||
wait "$develop_pid" 2>/dev/null || true
|
||||
wait "$serve_pid" 2>/dev/null || true
|
||||
}
|
||||
|
||||
trap stop_children EXIT
|
||||
trap 'stop_children; exit 130' INT
|
||||
trap 'stop_children; exit 143' TERM
|
||||
trap 'stop_children; exit 129' HUP
|
||||
|
||||
while kill -0 "$develop_pid" 2>/dev/null && kill -0 "$serve_pid" 2>/dev/null; do
|
||||
sleep 1
|
||||
done
|
||||
|
||||
develop_status=
|
||||
serve_status=
|
||||
if ! kill -0 "$develop_pid" 2>/dev/null; then
|
||||
if wait "$develop_pid"; then develop_status=0; else develop_status=$?; fi
|
||||
fi
|
||||
if ! kill -0 "$serve_pid" 2>/dev/null; then
|
||||
if wait "$serve_pid"; then serve_status=0; else serve_status=$?; fi
|
||||
fi
|
||||
|
||||
if [ -n "$develop_status" ] && [ "$develop_status" -ne 0 ]; then
|
||||
status=$develop_status
|
||||
elif [ -n "$serve_status" ]; then
|
||||
status=$serve_status
|
||||
else
|
||||
status=$develop_status
|
||||
fi
|
||||
|
||||
exit "$status"
|
||||
|
||||
@@ -294,3 +294,26 @@ export const formatCallyDateRange = (
|
||||
|
||||
return `${startDate}/${endDate}`;
|
||||
};
|
||||
|
||||
/**
|
||||
* August 2021, for calendar days coming out of cally.
|
||||
*
|
||||
* cally hands out calendar days as `Date` objects anchored to UTC midnight, and
|
||||
* formats its own headings in UTC too. Anything derived from a cally event has
|
||||
* to be formatted the same way: the resolved time zone shifts the day back for
|
||||
* negative UTC offsets, which becomes a wrong month — and in January a wrong
|
||||
* year — whenever the day is the 1st.
|
||||
*/
|
||||
export const formatCallyMonthYear = (
|
||||
dateObj: Date,
|
||||
locale: FrontendLocaleData
|
||||
) => formatCallyMonthYearMem(locale.language).format(dateObj);
|
||||
|
||||
const formatCallyMonthYearMem = memoizeOne(
|
||||
(language: string) =>
|
||||
new Intl.DateTimeFormat(language, {
|
||||
month: "long",
|
||||
year: "numeric",
|
||||
timeZone: "UTC",
|
||||
})
|
||||
);
|
||||
|
||||
@@ -23,24 +23,24 @@ export const isNavigationClick = (e: MouseEvent, preventDefault = true) => {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
let href = anchor.href;
|
||||
if (!href || href.indexOf("mailto:") !== -1) {
|
||||
let url: URL;
|
||||
try {
|
||||
// anchor.href is always absolute; an empty or unparseable value throws.
|
||||
url = new URL(anchor.href);
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const location = window.location;
|
||||
const origin = location.origin || location.protocol + "//" + location.host;
|
||||
if (!href.startsWith(origin)) {
|
||||
return undefined;
|
||||
}
|
||||
href = href.slice(origin.length);
|
||||
|
||||
if (href === "#") {
|
||||
// Only intercept same-origin links. A different scheme, host, or port is a
|
||||
// different origin (e.g. another port like ":8123") and must trigger a full
|
||||
// browser navigation instead of an in-app route change. Non-http(s) schemes
|
||||
// such as mailto: resolve to a null origin and are excluded here too.
|
||||
if (url.origin !== window.location.origin) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (preventDefault) {
|
||||
e.preventDefault();
|
||||
}
|
||||
return href;
|
||||
return url.pathname + url.search + url.hash;
|
||||
};
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
import type { EntityIdPart } from "../../data/entity_id_format";
|
||||
import { slugify } from "../string/slugify";
|
||||
|
||||
export const computeEntityIdFormatExample = (
|
||||
format: EntityIdPart[],
|
||||
examples: Record<EntityIdPart, string>
|
||||
): string => {
|
||||
const parts = format
|
||||
.map((item) => examples[item])
|
||||
.filter(Boolean)
|
||||
.map((part) => slugify(part, "_"))
|
||||
.filter(Boolean);
|
||||
|
||||
return parts.join("_") || "unknown";
|
||||
};
|
||||
@@ -52,7 +52,7 @@ export function stateActive(stateObj: HassEntity, state?: string): boolean {
|
||||
case "timer":
|
||||
return compareState === "active";
|
||||
case "camera":
|
||||
return compareState === "streaming";
|
||||
return ["streaming", "recording"].includes(compareState);
|
||||
}
|
||||
|
||||
return true;
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
// RFC 1123 hostname label: 1-63 chars, alphanumeric, with hyphens allowed
|
||||
// only between the first and last character. The hyphen is escaped because a
|
||||
// `pattern` attribute is compiled with the `v` flag, under which a trailing
|
||||
// unescaped hyphen is an invalid character class — and a pattern that fails to
|
||||
// compile is silently ignored, disabling validation altogether.
|
||||
const LABEL = "[a-zA-Z0-9](?:[a-zA-Z0-9\\-]{0,61}[a-zA-Z0-9])?";
|
||||
|
||||
// Hostname such as "localhost" or "homeassistant.lan", as dot-separated
|
||||
// labels. Unanchored, for use as an HTML `pattern` attribute (the browser
|
||||
// anchors it as `^(?:…)$`). The final label may not be all digits, so a
|
||||
// mistyped IP address like "300.1.1.1" is rejected rather than accepted as a
|
||||
// hostname. Deliberately excludes underscores and a trailing dot.
|
||||
export const HOSTNAME_PATTERN = `(?:${LABEL}\\.)*(?!\\d+$)${LABEL}`;
|
||||
@@ -0,0 +1,52 @@
|
||||
import { sanitizeNavigationPath } from "./sanitize-navigation-path";
|
||||
|
||||
const HOME_ASSISTANT_SCHEME = "homeassistant://";
|
||||
|
||||
/**
|
||||
* Returns the URL if it is safe to use as a link target, `undefined` otherwise.
|
||||
* Only absolute `http:` and `https:` URLs pass, the same check
|
||||
* `ha-attribute-value` already applies to attribute links.
|
||||
*
|
||||
* Use for every URL that reaches the frontend as data — from an integration
|
||||
* manifest, an add-on, a config flow or an entity attribute — so that a
|
||||
* `javascript:` URI can never become a clickable link.
|
||||
*/
|
||||
export const sanitizeHttpUrl = (
|
||||
url: string | null | undefined
|
||||
): string | undefined => {
|
||||
if (!url) {
|
||||
return undefined;
|
||||
}
|
||||
try {
|
||||
const { protocol } = new URL(url);
|
||||
return protocol === "http:" || protocol === "https:" ? url : undefined;
|
||||
} catch (_err) {
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
|
||||
/** Whether the URL is a `homeassistant://` deep link into the frontend. */
|
||||
export const isHomeAssistantUrl = (url: string | null | undefined): boolean =>
|
||||
!!url?.startsWith(HOME_ASSISTANT_SCHEME);
|
||||
|
||||
/**
|
||||
* Turns a `homeassistant://` deep link into an in-app path, or returns
|
||||
* `undefined` when it does not point inside the frontend. Rewriting the scheme
|
||||
* on its own is not enough: `homeassistant:///example.com` would become
|
||||
* `//example.com`, which resolves to another origin.
|
||||
*/
|
||||
export const homeAssistantUrlToPath = (
|
||||
url: string | null | undefined
|
||||
): string | undefined =>
|
||||
isHomeAssistantUrl(url)
|
||||
? sanitizeNavigationPath(`/${url!.slice(HOME_ASSISTANT_SCHEME.length)}`)
|
||||
: undefined;
|
||||
|
||||
/**
|
||||
* Sanitizes a URL that may be either an external link or a `homeassistant://`
|
||||
* deep link, returning something safe to bind to an `href`.
|
||||
*/
|
||||
export const sanitizeLinkUrl = (
|
||||
url: string | null | undefined
|
||||
): string | undefined =>
|
||||
isHomeAssistantUrl(url) ? homeAssistantUrlToPath(url) : sanitizeHttpUrl(url);
|
||||
@@ -11,13 +11,23 @@
|
||||
export const preserveUnchangedRecord = <T>(
|
||||
previous: Record<string, T> | undefined,
|
||||
next: Record<string, T>,
|
||||
equal: (a: T, b: T) => boolean
|
||||
equal: (a: T, b: T) => boolean,
|
||||
compareOrder = false
|
||||
): Record<string, T> => {
|
||||
if (!previous) {
|
||||
return next;
|
||||
}
|
||||
let changed = Object.keys(previous).length !== Object.keys(next).length;
|
||||
for (const key of Object.keys(next)) {
|
||||
|
||||
const previousKeys = Object.keys(previous);
|
||||
const nextKeys = Object.keys(next);
|
||||
|
||||
let changed = previousKeys.length !== nextKeys.length;
|
||||
|
||||
if (!changed && compareOrder) {
|
||||
changed = previousKeys.some((key, index) => key !== nextKeys[index]);
|
||||
}
|
||||
|
||||
for (const key of nextKeys) {
|
||||
const previousItem = previous[key];
|
||||
if (previousItem !== undefined && equal(previousItem, next[key])) {
|
||||
next[key] = previousItem;
|
||||
|
||||
@@ -18,6 +18,8 @@ export class HaProgressButton extends LitElement {
|
||||
|
||||
@property() appearance: Appearance = "accent";
|
||||
|
||||
@property() size: "xs" | "s" | "m" | "l" | "xl" = "m";
|
||||
|
||||
@property({ attribute: false }) public iconPath?: string;
|
||||
|
||||
@property() variant: "brand" | "danger" | "neutral" | "warning" | "success" =
|
||||
@@ -32,6 +34,7 @@ export class HaProgressButton extends LitElement {
|
||||
return html`
|
||||
<ha-button
|
||||
.appearance=${appearance}
|
||||
.size=${this.size}
|
||||
.disabled=${this.disabled}
|
||||
.loading=${this.progress}
|
||||
.variant=${
|
||||
@@ -118,10 +121,21 @@ export class HaProgressButton extends LitElement {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* The icon lives in this shadow root, so callers cannot size it themselves. */
|
||||
ha-button[size="xs"] ha-svg-icon[slot="start"],
|
||||
ha-button[size="s"] ha-svg-icon[slot="start"] {
|
||||
--mdc-icon-size: 16px;
|
||||
}
|
||||
|
||||
/* Fade the content out rather than hiding it, so the button keeps its
|
||||
accessible name while the result icon covers it. */
|
||||
ha-button.result::part(start),
|
||||
ha-button.result::part(end),
|
||||
ha-button.result::part(label),
|
||||
ha-button.result::part(caret),
|
||||
ha-button.result::part(caret) {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
ha-button.result::part(spinner) {
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
@@ -18,6 +18,37 @@ interface MinMaxFrame {
|
||||
maxY: number;
|
||||
}
|
||||
|
||||
const SECOND = 1000;
|
||||
const MINUTE = 60 * SECOND;
|
||||
const HOUR = 60 * MINUTE;
|
||||
const DAY = 24 * HOUR;
|
||||
|
||||
// Frame sizes that divide the clock evenly. Frames are placed on absolute time
|
||||
// rather than relative to the window, so charts that follow "now" keep picking
|
||||
// the same points every redraw instead of redrawing with a different shape.
|
||||
const FRAME_SIZES = [
|
||||
[1, 2, 3, 5, 10, 20, 30, 50, 100, 200, 300, 500],
|
||||
[1, 2, 3, 5, 10, 15, 20, 30].map((n) => n * SECOND),
|
||||
[1, 2, 3, 5, 10, 15, 20, 30].map((n) => n * MINUTE),
|
||||
[1, 2, 3, 4, 6, 8, 12].map((n) => n * HOUR),
|
||||
[DAY],
|
||||
].flat();
|
||||
|
||||
// Always rounds down, so no chart ends up with fewer frames than it asked for.
|
||||
function snapFrameSize(step: number): number {
|
||||
if (step >= DAY) {
|
||||
return Math.floor(step / DAY) * DAY;
|
||||
}
|
||||
let snapped = FRAME_SIZES[0];
|
||||
for (const size of FRAME_SIZES) {
|
||||
if (size > step) {
|
||||
break;
|
||||
}
|
||||
snapped = size;
|
||||
}
|
||||
return snapped;
|
||||
}
|
||||
|
||||
export function downSampleLineData<
|
||||
T extends [number, number] | NonNullable<LineSeriesOption["data"]>[number],
|
||||
>(
|
||||
@@ -35,11 +66,13 @@ export function downSampleLineData<
|
||||
}
|
||||
const min = minX ?? getPointData(data[0]!)[0];
|
||||
const max = maxX ?? getPointData(data[data.length - 1]!)[0];
|
||||
const step = Math.ceil((max - min) / Math.floor(maxDetails));
|
||||
if (!Number.isFinite(step) || step <= 0) {
|
||||
const rawStep = Math.ceil((max - min) / Math.floor(maxDetails));
|
||||
if (!Number.isFinite(rawStep) || rawStep <= 0) {
|
||||
// a degenerate frame size would put every point in a single frame
|
||||
return data;
|
||||
}
|
||||
// snapped after the guard above, which relies on the unsnapped value
|
||||
const step = snapFrameSize(rawStep);
|
||||
|
||||
if (useMean) {
|
||||
// Group points into frames, accumulating sums in insertion order.
|
||||
@@ -52,7 +85,7 @@ export function downSampleLineData<
|
||||
const y = Number(pointData[1]);
|
||||
if (isNaN(x) || isNaN(y)) continue;
|
||||
|
||||
const frameIndex = Math.floor((x - min) / step);
|
||||
const frameIndex = Math.floor(x / step);
|
||||
const frame = frames.get(frameIndex);
|
||||
if (!frame) {
|
||||
frames.set(frameIndex, {
|
||||
@@ -90,7 +123,7 @@ export function downSampleLineData<
|
||||
const y = Number(pointData[1]);
|
||||
if (isNaN(x) || isNaN(y)) continue;
|
||||
|
||||
const frameIndex = Math.floor((x - min) / step);
|
||||
const frameIndex = Math.floor(x / step);
|
||||
const frame = frames.get(frameIndex);
|
||||
if (!frame) {
|
||||
frames.set(frameIndex, {
|
||||
|
||||
@@ -26,7 +26,10 @@ import { styleMap } from "lit/directives/style-map";
|
||||
import { ensureArray } from "../../common/array/ensure-array";
|
||||
import { getAllGraphColors } from "../../common/color/colors";
|
||||
import { transform } from "../../common/decorators/transform";
|
||||
import type { HASSDomEvent } from "../../common/dom/fire_event";
|
||||
import type {
|
||||
HASSDomCurrentTargetEvent,
|
||||
HASSDomEvent,
|
||||
} from "../../common/dom/fire_event";
|
||||
import { fireEvent } from "../../common/dom/fire_event";
|
||||
import { listenMediaQuery } from "../../common/dom/media_query";
|
||||
import { afterNextRender } from "../../common/util/render-status";
|
||||
@@ -1216,7 +1219,9 @@ export class HaChartBase extends LitElement {
|
||||
}
|
||||
|
||||
// Long-press to solo on touch/pen devices (500ms, consistent with action-handler-directive)
|
||||
private _legendPointerDown(ev: PointerEvent) {
|
||||
private _legendPointerDown(
|
||||
ev: PointerEvent & HASSDomCurrentTargetEvent<HTMLElement>
|
||||
) {
|
||||
// Mouse uses Ctrl/Cmd+click instead
|
||||
if (ev.pointerType === "mouse") {
|
||||
return;
|
||||
@@ -1246,7 +1251,9 @@ export class HaChartBase extends LitElement {
|
||||
}
|
||||
}
|
||||
|
||||
private _toggleDataset(ev: MouseEvent) {
|
||||
private _toggleDataset(
|
||||
ev: MouseEvent & HASSDomCurrentTargetEvent<HTMLElement>
|
||||
) {
|
||||
ev.stopPropagation();
|
||||
if (!this.chart) {
|
||||
return;
|
||||
@@ -1268,7 +1275,7 @@ export class HaChartBase extends LitElement {
|
||||
this._handleDatasetToggle(id);
|
||||
}
|
||||
|
||||
private _labelClick(ev: MouseEvent) {
|
||||
private _labelClick(ev: MouseEvent & HASSDomCurrentTargetEvent<HTMLElement>) {
|
||||
ev.stopPropagation();
|
||||
if (!this.chart) {
|
||||
return;
|
||||
|
||||
@@ -2,13 +2,10 @@ import { customElement, property, state } from "lit/decorators";
|
||||
import { LitElement, html, css } from "lit";
|
||||
import type { EChartsType } from "echarts/core";
|
||||
import type { SankeySeriesOption } from "echarts/types/dist/echarts";
|
||||
import type {
|
||||
CallbackDataParams,
|
||||
ECElementEvent,
|
||||
} from "echarts/types/src/util/types";
|
||||
import type { CallbackDataParams } from "echarts/types/src/util/types";
|
||||
import memoizeOne from "memoize-one";
|
||||
import { ResizeController } from "@lit-labs/observers/resize-controller";
|
||||
import { fireEvent } from "../../common/dom/fire_event";
|
||||
import { fireEvent, type HASSDomEvent } from "../../common/dom/fire_event";
|
||||
import SankeyChart from "../../resources/echarts/components/sankey/install";
|
||||
import type { HomeAssistant } from "../../types";
|
||||
import type { HaECOption } from "../../resources/echarts/echarts";
|
||||
@@ -121,11 +118,15 @@ export class HaSankeyChart extends LitElement {
|
||||
return null;
|
||||
};
|
||||
|
||||
private _handleChartSankeyRoam = (ev: CustomEvent) => {
|
||||
private _handleChartSankeyRoam = (
|
||||
ev: HASSDomEvent<HASSDomEvents["chart-sankeyroam"]>
|
||||
) => {
|
||||
this._currentZoom = ev.detail.zoom;
|
||||
};
|
||||
|
||||
private _handleChartClick = (ev: CustomEvent<ECElementEvent>) => {
|
||||
private _handleChartClick = (
|
||||
ev: HASSDomEvent<HASSDomEvents["chart-click"]>
|
||||
) => {
|
||||
const detail = ev.detail;
|
||||
// Only handle node clicks (not links)
|
||||
if (detail.dataType !== "node") {
|
||||
|
||||
@@ -216,11 +216,13 @@ export class StateHistoryChartLine extends LitElement {
|
||||
})}`;
|
||||
};
|
||||
|
||||
private _datasetHidden(ev: CustomEvent) {
|
||||
private _datasetHidden(ev: HASSDomEvent<HASSDomEvents["dataset-hidden"]>) {
|
||||
this._hiddenStats.add(ev.detail.id);
|
||||
}
|
||||
|
||||
private _datasetUnhidden(ev: CustomEvent) {
|
||||
private _datasetUnhidden(
|
||||
ev: HASSDomEvent<HASSDomEvents["dataset-unhidden"]>
|
||||
) {
|
||||
this._hiddenStats.delete(ev.detail.id);
|
||||
}
|
||||
|
||||
@@ -229,7 +231,7 @@ export class StateHistoryChartLine extends LitElement {
|
||||
chartBase.zoom(start, end, true);
|
||||
}
|
||||
|
||||
private _handleDataZoom(ev: CustomEvent) {
|
||||
private _handleDataZoom(ev: HASSDomEvent<HASSDomEvents["chart-zoom"]>) {
|
||||
fireEvent(this, "chart-zoom-with-index", {
|
||||
start: ev.detail.start ?? 0,
|
||||
end: ev.detail.end ?? 100,
|
||||
|
||||
@@ -4,7 +4,6 @@ import { customElement, property, state } from "lit/decorators";
|
||||
import type {
|
||||
CustomSeriesOption,
|
||||
CustomSeriesRenderItem,
|
||||
ECElementEvent,
|
||||
TooltipPositionCallbackParams,
|
||||
} from "echarts/types/dist/shared";
|
||||
import { formatDateTimeWithSeconds } from "../../common/datetime/format_date_time";
|
||||
@@ -21,7 +20,7 @@ import echarts from "../../resources/echarts/echarts";
|
||||
import { luminosity } from "../../common/color/rgb";
|
||||
import { hex2rgb } from "../../common/color/convert-color";
|
||||
import { measureTextWidth } from "../../util/text";
|
||||
import { fireEvent } from "../../common/dom/fire_event";
|
||||
import { fireEvent, type HASSDomEvent } from "../../common/dom/fire_event";
|
||||
|
||||
@customElement("state-history-chart-timeline")
|
||||
export class StateHistoryChartTimeline extends LitElement {
|
||||
@@ -271,7 +270,7 @@ export class StateHistoryChartTimeline extends LitElement {
|
||||
chartBase.zoom(start, end, true);
|
||||
}
|
||||
|
||||
private _handleDataZoom(ev: CustomEvent) {
|
||||
private _handleDataZoom(ev: HASSDomEvent<HASSDomEvents["chart-zoom"]>) {
|
||||
fireEvent(this, "chart-zoom-with-index", {
|
||||
start: ev.detail.start ?? 0,
|
||||
end: ev.detail.end ?? 100,
|
||||
@@ -385,7 +384,9 @@ export class StateHistoryChartTimeline extends LitElement {
|
||||
this._chartData = datasets;
|
||||
}
|
||||
|
||||
private _handleChartClick(e: CustomEvent<ECElementEvent>): void {
|
||||
private _handleChartClick(
|
||||
e: HASSDomEvent<HASSDomEvents["chart-click"]>
|
||||
): void {
|
||||
if (e.detail.targetType === "axisLabel") {
|
||||
const dataset = this._chartData[e.detail.dataIndex];
|
||||
if (dataset) {
|
||||
|
||||
@@ -11,6 +11,10 @@ import {
|
||||
} from "lit/decorators";
|
||||
import { isComponentLoaded } from "../../common/config/is_component_loaded";
|
||||
import { restoreScroll } from "../../common/decorators/restore-scroll";
|
||||
import type {
|
||||
HASSDomEvent,
|
||||
HASSDomTargetEvent,
|
||||
} from "../../common/dom/fire_event";
|
||||
import type {
|
||||
HistoryResult,
|
||||
LineChartUnit,
|
||||
@@ -316,13 +320,13 @@ export class StateHistoryCharts extends LitElement {
|
||||
}
|
||||
}
|
||||
|
||||
private _yWidthChanged(e: CustomEvent<HASSDomEvents["y-width-changed"]>) {
|
||||
private _yWidthChanged(e: HASSDomEvent<HASSDomEvents["y-width-changed"]>) {
|
||||
this._childYWidths[e.detail.chartIndex] = e.detail.value;
|
||||
this._maxYWidth = Math.max(...Object.values(this._childYWidths), 0);
|
||||
}
|
||||
|
||||
private _handleTimelineSync(
|
||||
e: CustomEvent<HASSDomEvents["chart-zoom-with-index"]>
|
||||
e: HASSDomEvent<HASSDomEvents["chart-zoom-with-index"]>
|
||||
) {
|
||||
if (!this.syncCharts || this._isSyncing) {
|
||||
return;
|
||||
@@ -384,7 +388,7 @@ export class StateHistoryCharts extends LitElement {
|
||||
}
|
||||
|
||||
@eventOptions({ passive: true })
|
||||
private _saveScrollPos(e: Event) {
|
||||
private _saveScrollPos(e: HASSDomTargetEvent<HTMLDivElement>) {
|
||||
this._savedScrollPos = (e.target as HTMLDivElement).scrollTop;
|
||||
}
|
||||
|
||||
|
||||
@@ -204,12 +204,14 @@ export class StatisticsChart extends LitElement {
|
||||
`;
|
||||
}
|
||||
|
||||
private _datasetHidden(ev: CustomEvent) {
|
||||
private _datasetHidden(ev: HASSDomEvent<HASSDomEvents["dataset-hidden"]>) {
|
||||
this._hiddenStats.add(ev.detail.id);
|
||||
this.requestUpdate("_hiddenStats");
|
||||
}
|
||||
|
||||
private _datasetUnhidden(ev: CustomEvent) {
|
||||
private _datasetUnhidden(
|
||||
ev: HASSDomEvent<HASSDomEvents["dataset-unhidden"]>
|
||||
) {
|
||||
this._hiddenStats.delete(ev.detail.id);
|
||||
this.requestUpdate("_hiddenStats");
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ export class HaInputChip extends InputChip {
|
||||
styles,
|
||||
css`
|
||||
:host {
|
||||
max-width: 100%;
|
||||
--md-sys-color-primary: var(--primary-text-color);
|
||||
--md-sys-color-on-surface: var(--primary-text-color);
|
||||
--md-sys-color-on-surface-variant: var(--primary-text-color);
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
} from "lit/decorators";
|
||||
import { classMap } from "lit/directives/class-map";
|
||||
import { ifDefined } from "lit/directives/if-defined";
|
||||
import { join } from "lit/directives/join";
|
||||
import { styleMap } from "lit/directives/style-map";
|
||||
import memoizeOne from "memoize-one";
|
||||
import { STRINGS_SEPARATOR_DOT } from "../../common/const";
|
||||
@@ -483,13 +484,7 @@ export class HaDataTable extends LitElement {
|
||||
: ""
|
||||
}
|
||||
${Object.entries(columns).map(([key, column]) => {
|
||||
if (
|
||||
column.hidden ||
|
||||
(this.columnOrder && this.columnOrder.includes(key)
|
||||
? (this.hiddenColumns?.includes(key) ??
|
||||
column.defaultHidden)
|
||||
: column.defaultHidden)
|
||||
) {
|
||||
if (!this._isColumnVisible(key, column)) {
|
||||
return nothing;
|
||||
}
|
||||
const sorted = key === this.sortColumn;
|
||||
@@ -657,10 +652,7 @@ export class HaDataTable extends LitElement {
|
||||
${Object.entries(columns).map(([key, column]) => {
|
||||
if (
|
||||
(narrow && !column.main && !column.showNarrow) ||
|
||||
column.hidden ||
|
||||
(this.columnOrder && this.columnOrder.includes(key)
|
||||
? (this.hiddenColumns?.includes(key) ?? column.defaultHidden)
|
||||
: column.defaultHidden)
|
||||
!this._isColumnVisible(key, column)
|
||||
) {
|
||||
return nothing;
|
||||
}
|
||||
@@ -692,28 +684,19 @@ export class HaDataTable extends LitElement {
|
||||
: narrow && column.main
|
||||
? html`<div class="primary">${row[key]}</div>
|
||||
<div class="secondary">
|
||||
${Object.entries(columns)
|
||||
.filter(
|
||||
([key2, column2]) =>
|
||||
!column2.hidden &&
|
||||
!column2.main &&
|
||||
!column2.showNarrow &&
|
||||
!(this.columnOrder &&
|
||||
this.columnOrder.includes(key2)
|
||||
? (this.hiddenColumns?.includes(key2) ??
|
||||
column2.defaultHidden)
|
||||
: column2.defaultHidden)
|
||||
)
|
||||
.map(
|
||||
([key2, column2], i) =>
|
||||
html`${
|
||||
i !== 0 ? STRINGS_SEPARATOR_DOT : nothing
|
||||
}${
|
||||
column2.template
|
||||
? column2.template(row)
|
||||
: row[key2]
|
||||
}`
|
||||
)}
|
||||
${join(
|
||||
Object.entries(columns)
|
||||
.filter(([key2, column2]) =>
|
||||
this._isSecondaryColumnVisible(key2, column2)
|
||||
)
|
||||
.map(([key2, column2]) =>
|
||||
column2.template
|
||||
? column2.template(row)
|
||||
: row[key2]
|
||||
)
|
||||
.filter(this._hasCellValue),
|
||||
STRINGS_SEPARATOR_DOT
|
||||
)}
|
||||
</div>
|
||||
${
|
||||
column.extraTemplate
|
||||
@@ -733,6 +716,29 @@ export class HaDataTable extends LitElement {
|
||||
`;
|
||||
};
|
||||
|
||||
private _isColumnVisible(key: string, column: DataTableColumnData): boolean {
|
||||
if (column.hidden) {
|
||||
return false;
|
||||
}
|
||||
if (!this.columnOrder?.includes(key)) {
|
||||
return !column.defaultHidden;
|
||||
}
|
||||
return !(this.hiddenColumns?.includes(key) ?? column.defaultHidden);
|
||||
}
|
||||
|
||||
private _isSecondaryColumnVisible(
|
||||
key: string,
|
||||
column: DataTableColumnData
|
||||
): boolean {
|
||||
if (column.main || column.showNarrow) {
|
||||
return false;
|
||||
}
|
||||
return this._isColumnVisible(key, column);
|
||||
}
|
||||
|
||||
private _hasCellValue = (value: unknown): boolean =>
|
||||
value !== undefined && value !== null && value !== "" && value !== nothing;
|
||||
|
||||
private async _sortFilterData() {
|
||||
const startTime = new Date().getTime();
|
||||
const timeBetweenUpdate = startTime - this._lastUpdate;
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user