mirror of
https://github.com/home-assistant/frontend.git
synced 2026-07-08 17:55:31 +00:00
Compare commits
16 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6c04b3ae3d | |||
| 9e76f97fa8 | |||
| 771d4c231c | |||
| fecf887f92 | |||
| e48ec88e5f | |||
| 7565450408 | |||
| 209512b498 | |||
| c39fa964f0 | |||
| 05f7a5c399 | |||
| 82c6f90cb0 | |||
| 59171d0ed7 | |||
| e4f9244ef3 | |||
| b93be88feb | |||
| 6e5114df91 | |||
| 245c152957 | |||
| 5af3808b1b |
@@ -1,98 +0,0 @@
|
||||
---
|
||||
name: ha-frontend-components
|
||||
description: Home Assistant frontend component patterns. Use when implementing or reviewing dialogs, ha-form, ha-alert, keyboard shortcuts, tooltips, panels, Lovelace cards, or ha-button usage.
|
||||
---
|
||||
|
||||
# HA Frontend Components
|
||||
|
||||
Use this skill when creating or reviewing Home Assistant UI components and common interaction patterns.
|
||||
|
||||
## Dialogs
|
||||
|
||||
Open dialogs through the fire-event pattern:
|
||||
|
||||
```ts
|
||||
fireEvent(this, "show-dialog", {
|
||||
dialogTag: "dialog-example",
|
||||
dialogImport: () => import("./dialog-example"),
|
||||
dialogParams: { title: "Example", data: someData },
|
||||
});
|
||||
```
|
||||
|
||||
Dialog implementation requirements:
|
||||
|
||||
- Use `ha-dialog`.
|
||||
- Implement `HassDialog<T>`.
|
||||
- Use `@state() private _open = false` to control visibility.
|
||||
- Set `_open = true` in `showDialog()` and `_open = false` in `closeDialog()`.
|
||||
- Return `nothing` while required params are absent.
|
||||
- Fire `dialog-closed` in the close handler.
|
||||
- Use `header-title` and `header-subtitle` for simple header text.
|
||||
- Use slots when standard header attributes are not enough.
|
||||
- Use `ha-dialog-footer` with `primaryAction` and `secondaryAction` slots.
|
||||
- Add `autofocus` to the first focusable element, such as `<ha-form autofocus>`, and forward it internally if needed.
|
||||
|
||||
Use standard dialog widths: `small`, `medium`, `large`, or `full`. Avoid custom dialog sizing unless there is a clear product need.
|
||||
|
||||
## Buttons
|
||||
|
||||
`ha-button` wraps the Web Awesome button in `src/components/ha-button.ts`.
|
||||
|
||||
Axes:
|
||||
|
||||
- `variant`: `brand`, `neutral`, `danger`, `warning`, `success`.
|
||||
- `appearance`: `accent`, `filled`, `outlined`, `plain`.
|
||||
- `size`: `xs`, `s`, `m`, `l`, `xl`.
|
||||
|
||||
Common usage:
|
||||
|
||||
- Use `appearance="filled"` for primary emphasis when needed.
|
||||
- Use `appearance="plain"` for cancel and dismiss actions.
|
||||
- Use `variant="danger"` for destructive actions.
|
||||
- Place primary actions in `slot="primaryAction"` and secondary actions in `slot="secondaryAction"`.
|
||||
|
||||
## Forms
|
||||
|
||||
`ha-form` is schema-driven with `HaFormSchema[]` and supports common selectors for entities, devices, areas, targets, numbers, booleans, time, actions, text, objects, selects, icons, media, and location.
|
||||
|
||||
Use `computeLabel`, `computeError`, and `computeHelper` for translated labels, validation, and helper text.
|
||||
|
||||
```ts
|
||||
<ha-form
|
||||
.hass=${this.hass}
|
||||
.data=${this._data}
|
||||
.schema=${this._schema}
|
||||
.error=${this._errors}
|
||||
.computeLabel=${(schema) => this.hass.localize(`ui.panel.${schema.name}`)}
|
||||
@value-changed=${this._valueChanged}
|
||||
></ha-form>
|
||||
```
|
||||
|
||||
## Alerts
|
||||
|
||||
Use `ha-alert` for user-visible status messaging.
|
||||
|
||||
- Alert types: `error`, `warning`, `info`, `success`.
|
||||
- Useful properties: `title`, `alert-type`, `dismissable`, `narrow`.
|
||||
- Slots: `icon` for custom leading icon, `action` for custom action content.
|
||||
- Content is announced by screen readers when dynamically displayed.
|
||||
|
||||
```html
|
||||
<ha-alert alert-type="error">Error message</ha-alert>
|
||||
<ha-alert alert-type="warning" title="Warning">Description</ha-alert>
|
||||
<ha-alert alert-type="success" dismissable>Success message</ha-alert>
|
||||
```
|
||||
|
||||
## Shortcuts And Tooltips
|
||||
|
||||
Use `ShortcutManager` from `src/common/keyboard/shortcuts.ts` for keyboard shortcuts. It blocks shortcuts in input fields, can prevent shortcuts during text selection, and supports character and KeyCode shortcuts for non-latin keyboards. See `src/state/quick-bar-mixin.ts` for global shortcut examples.
|
||||
|
||||
Use `ha-tooltip` from `src/components/ha-tooltip.ts` for contextual hover help. See `src/components/ha-label.ts` for an example.
|
||||
|
||||
## Panels And Lovelace Cards
|
||||
|
||||
Panels commonly extend `SubscribeMixin(LitElement)` and receive route and narrow-layout properties.
|
||||
|
||||
Lovelace cards should implement `LovelaceCard`, validate config in `setConfig()`, handle loading, error, unavailable, and missing-entity states, and add a configuration editor when needed.
|
||||
|
||||
Cards are user-story surfaces. Support different households, entity types, responsive layouts, and accessible interaction states.
|
||||
@@ -1,78 +0,0 @@
|
||||
---
|
||||
name: ha-frontend-contexts
|
||||
description: Home Assistant frontend Lit context and hass migration guidance. Use when adding or changing component state access, replacing hass reads, consuming entity or registry contexts, or reviewing rerender behavior.
|
||||
---
|
||||
|
||||
# HA Frontend Contexts
|
||||
|
||||
Use this skill when a component reads Home Assistant state, registries, localization, services, config, UI data, connection state, or API helpers.
|
||||
|
||||
## Goal
|
||||
|
||||
Move leaf components away from the broad `hass: HomeAssistant` object. Broad `hass` access rerenders components for unrelated changes, hides the data a component depends on, and makes tests harder to mock.
|
||||
|
||||
Container components may keep `hass` when they own it and feed providers. Leaf components should consume the narrowest context that covers their reads.
|
||||
|
||||
## Core Files
|
||||
|
||||
- Context definitions: `src/data/context/index.ts`
|
||||
- Entity-scoped consume helpers: `src/common/decorators/consume-context-entry.ts`
|
||||
- Transform decorator: `src/common/decorators/transform.ts`
|
||||
- Canonical migration example: `src/panels/lovelace/cards/hui-button-card.ts`
|
||||
- Providers are wired by `contextMixin` on `HassBaseEl`; consumers do not wire providers manually.
|
||||
|
||||
## Context Selection
|
||||
|
||||
| Context | Replaces |
|
||||
| -------------------------------------------------------------------- | -------------------------------------------------------------------------------------- |
|
||||
| `statesContext` | `hass.states` |
|
||||
| `entitiesContext`, `devicesContext`, `areasContext`, `floorsContext` | `hass.entities`, `hass.devices`, `hass.areas`, `hass.floors` |
|
||||
| `registriesContext` | all four registries together |
|
||||
| `servicesContext` | `hass.services` |
|
||||
| `internationalizationContext` | `hass.localize`, `hass.locale`, `hass.language` |
|
||||
| `formattersContext` | entity and attribute formatters |
|
||||
| `configContext` | `hass.config`, `hass.user`, `hass.auth`, `hass.userData` |
|
||||
| `connectionContext` | `hass.connection`, `hass.connected`, `hass.hassUrl` |
|
||||
| `apiContext` | `hass.callService`, `hass.callApi`, `hass.callWS`, `hass.sendWS`, `hass.fetchWithAuth` |
|
||||
| `uiContext` | themes, selected theme, panels, sidebar, and UI state |
|
||||
| `narrowViewportContext` | narrow-layout boolean |
|
||||
|
||||
Lazy contexts subscribe on first consumer and tear down after the last consumer: `labelsContext`, `fullEntitiesContext`, `configEntriesContext`, and `manifestsContext`.
|
||||
|
||||
The single-field contexts such as `localizeContext`, `themesContext`, and `userContext` are deprecated. Use grouped contexts instead.
|
||||
|
||||
## Consumption Patterns
|
||||
|
||||
Use entity-scoped helpers when the component watches an entity id held on the host:
|
||||
|
||||
```ts
|
||||
@state() @consumeEntityState({ entityIdPath: ["_config", "entity"] })
|
||||
private _stateObj?: HassEntity;
|
||||
|
||||
@state() @consumeEntityRegistryEntry({ entityIdPath: ["_config", "entity"] })
|
||||
private _entity?: EntityRegistryDisplayEntry;
|
||||
|
||||
@state() @consumeLocalize()
|
||||
private _localize!: LocalizeFunc;
|
||||
```
|
||||
|
||||
For a single field from a grouped context, pair `@consume` with `@transform`:
|
||||
|
||||
```ts
|
||||
@state()
|
||||
@consume({ context: uiContext, subscribe: true })
|
||||
@transform<HomeAssistantUI, Themes>({ transformer: ({ themes }) => themes })
|
||||
private _themes!: Themes;
|
||||
```
|
||||
|
||||
Use `@transform` with `watch` when the transformer depends on a host property, such as a computed entity id. `consumeEntityState` only watches the first path segment.
|
||||
|
||||
To consume a whole group untransformed, omit `@transform` and type the field as `ContextType<typeof statesContext>` or the matching context type.
|
||||
|
||||
## Review Checklist
|
||||
|
||||
- The component consumes the narrowest context needed for the data it reads.
|
||||
- A broad `hass` property is kept only when the component is a container or external API requires it.
|
||||
- Entity-scoped reads use the consume helpers rather than ad hoc context transforms.
|
||||
- Context fields are marked `@state()` so updates trigger rendering.
|
||||
- Tests and mocks only provide the data the component actually consumes.
|
||||
@@ -1,73 +0,0 @@
|
||||
---
|
||||
name: ha-frontend-review
|
||||
description: Home Assistant frontend PR and review guidance. Use when reviewing frontend changes, preparing a PR, checking recurring review issues, or applying the PR template.
|
||||
---
|
||||
|
||||
# HA Frontend Review
|
||||
|
||||
Use this skill when reviewing Home Assistant frontend changes or preparing a pull request.
|
||||
|
||||
## Pull Request Body
|
||||
|
||||
When creating a pull request, use `.github/PULL_REQUEST_TEMPLATE.md` as the body.
|
||||
|
||||
- Do not omit, reorder, or rewrite template sections.
|
||||
- Check the appropriate "Type of change" box based on the actual change.
|
||||
- Do not check checklist items on behalf of the user.
|
||||
- If the PR includes UI changes, remind the user to add screenshots or a short video.
|
||||
- Explain what the change does for users, not only implementation details.
|
||||
- Use Markdown.
|
||||
|
||||
## Pre-Submission Checklist
|
||||
|
||||
- `yarn lint` passes when practical for the scope.
|
||||
- `yarn test` or focused relevant tests are green when practical for the scope.
|
||||
- Tests are added or updated for new data processing and utilities where applicable.
|
||||
- User-facing text is localized and follows `ha-frontend-user-facing-text` guidance.
|
||||
- Components handle loading, error, unavailable, and missing-entity states.
|
||||
- Entity existence is checked before property access.
|
||||
- Event listeners and subscriptions are cleaned up.
|
||||
- UI is accessible to screen readers and keyboard users.
|
||||
|
||||
## Recurring Review Issues
|
||||
|
||||
User experience and accessibility:
|
||||
|
||||
- Forms need proper labels, helper text, and validation feedback.
|
||||
- Form markup should not cause password managers to identify fields incorrectly.
|
||||
- Clickable areas should be large enough for touch interaction.
|
||||
- Hover, active, disabled, loading, and focus states should be clear.
|
||||
|
||||
Dialog and modal patterns:
|
||||
|
||||
- Multi-step operations should show progress.
|
||||
- Dialog state should survive background operations correctly.
|
||||
- Cancel and close buttons should behave consistently.
|
||||
- Defaults should be helpful without blocking user override.
|
||||
|
||||
Component design patterns:
|
||||
|
||||
- Terminology should be consistent. Use words like "Join" or "Apply" instead of "Group" when that better matches the user action.
|
||||
- Visual hierarchy should use appropriate font sizes, weights, and spacing ratios.
|
||||
- Components should align to the design grid.
|
||||
- Badges and indicators should be placed consistently.
|
||||
|
||||
Code quality:
|
||||
|
||||
- Null and undefined paths should be handled explicitly.
|
||||
- Potentially undefined array and object access should be guarded.
|
||||
- Event handlers, timers, observers, and subscriptions should be cleaned up.
|
||||
|
||||
Configuration and props:
|
||||
|
||||
- Make configuration fields optional when sensible.
|
||||
- Provide reasonable defaults.
|
||||
- Keep APIs extensible without adding speculative abstractions.
|
||||
- Validate configuration before applying changes.
|
||||
|
||||
## Review Flow
|
||||
|
||||
- Identify behavioral regressions, bugs, accessibility issues, and missing tests first.
|
||||
- Keep style-only comments secondary unless they affect maintainability or user experience.
|
||||
- Prefer small, direct fixes over large refactors during review follow-up.
|
||||
- Cross-load `ha-frontend-contexts`, `ha-frontend-components`, `ha-frontend-styling`, `ha-frontend-testing`, or `ha-frontend-user-facing-text` when a finding falls in that area.
|
||||
@@ -1,78 +0,0 @@
|
||||
---
|
||||
name: ha-frontend-styling
|
||||
description: Home Assistant frontend styling, theming, spacing, responsive layout, RTL, and View Transitions guidance. Use when editing CSS, layout, motion, or visual component structure.
|
||||
---
|
||||
|
||||
# HA Frontend Styling
|
||||
|
||||
Use this skill when editing CSS, layout, visual hierarchy, theme integration, responsive behavior, RTL support, or view transitions.
|
||||
|
||||
## Theme And Layout Basics
|
||||
|
||||
- Use Home Assistant CSS custom properties instead of hardcoded colors.
|
||||
- Use `--ha-space-*` spacing tokens instead of hardcoded spacing where possible.
|
||||
- Keep components mobile-first and enhance for desktop.
|
||||
- Keep layouts RTL-safe. Prefer logical properties when they fit.
|
||||
- Prefer `ha-*` components and current Web Awesome wrappers.
|
||||
- Avoid adding new legacy Material Web Components (`mwc-*`).
|
||||
- Scope styles to the component. Do not rely on global styles for component internals.
|
||||
|
||||
Spacing tokens are defined in `src/resources/theme/core.globals.ts`. The scale runs from `--ha-space-1` at 4px through `--ha-space-20` at 80px in 4px increments. Common values are `--ha-space-2` at 8px, `--ha-space-4` at 16px, and `--ha-space-8` at 32px.
|
||||
|
||||
```ts
|
||||
static get styles() {
|
||||
return css`
|
||||
:host {
|
||||
padding: var(--ha-space-4);
|
||||
color: var(--primary-text-color);
|
||||
background-color: var(--card-background-color);
|
||||
}
|
||||
|
||||
.content {
|
||||
gap: var(--ha-space-2);
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
:host {
|
||||
padding: var(--ha-space-2);
|
||||
}
|
||||
}
|
||||
`;
|
||||
}
|
||||
```
|
||||
|
||||
## Interaction States
|
||||
|
||||
- Make touch targets large enough for mobile.
|
||||
- Provide clear hover, active, focus, disabled, loading, error, and unavailable states.
|
||||
- Preserve keyboard navigation and visible focus indicators.
|
||||
- Maintain WCAG AA contrast for text and essential UI affordances.
|
||||
|
||||
## View Transitions
|
||||
|
||||
Use the View Transitions API only for meaningful continuity between DOM states.
|
||||
|
||||
Core resources:
|
||||
|
||||
- Utility wrapper: `src/common/util/view-transition.ts`, `withViewTransition()`.
|
||||
- Launch-screen fade example: `src/util/launch-screen.ts`.
|
||||
- Animation keyframes: `src/resources/theme/animations.globals.ts`.
|
||||
- Animation duration tokens: `src/resources/theme/core.globals.ts`.
|
||||
|
||||
Implementation rules:
|
||||
|
||||
- Use `withViewTransition()` for fallback behavior.
|
||||
- Keep transitions simple. Subtle fades and crossfades usually work best.
|
||||
- Use `--ha-animation-duration-fast`, `--ha-animation-duration-normal`, or `--ha-animation-duration-slow` for timing.
|
||||
- Ensure each `view-transition-name` is unique at any given time.
|
||||
- Remember only one view transition can run at a time.
|
||||
- View transitions operate at document level and do not work inside Shadow DOM style isolation. For web components, set `view-transition-name` on `:host` or use document-level transitions.
|
||||
- The root gets `view-transition-name: root` by default. Target `::view-transition-group(root)` to customize the default page transition.
|
||||
|
||||
## Review Checklist
|
||||
|
||||
- Styling uses theme variables and spacing tokens where practical.
|
||||
- Layout is mobile-first and RTL-safe.
|
||||
- Component styles are scoped.
|
||||
- Interactive states are clear and accessible.
|
||||
- Motion is subtle, tokenized, and respects reduced-motion behavior through existing globals.
|
||||
@@ -1,70 +0,0 @@
|
||||
---
|
||||
name: ha-frontend-testing
|
||||
description: Home Assistant frontend validation workflow. Use when running lint, TypeScript checks, Vitest, Playwright e2e suites, dev servers, or chart-data benchmarks.
|
||||
---
|
||||
|
||||
# HA Frontend Testing
|
||||
|
||||
Use this skill when choosing or running validation for frontend changes.
|
||||
|
||||
## Core Commands
|
||||
|
||||
```bash
|
||||
yarn lint # ESLint + Prettier + TypeScript + Lit
|
||||
yarn format # Auto-fix ESLint + Prettier
|
||||
yarn lint:types # TypeScript compiler, run without file arguments
|
||||
yarn test # Vitest
|
||||
yarn dev # App dev server
|
||||
yarn dev:serve # Local serving dev server
|
||||
```
|
||||
|
||||
Never run `tsc` or `yarn lint:types` with file arguments. File arguments make `tsc` ignore `tsconfig.json` and can emit `.js` files into `src/`.
|
||||
|
||||
For focused type feedback on one file, use editor diagnostics instead of a file-scoped `tsc` command.
|
||||
|
||||
## Unit And Utility Tests
|
||||
|
||||
- Add or update Vitest tests for data processing, utility code, and behavior that can be tested without a browser.
|
||||
- Mock WebSocket connections and API calls at boundaries.
|
||||
- Cover loading, error, unavailable, and missing-entity states where relevant.
|
||||
- Test accessibility-sensitive behavior when it can be asserted without brittle DOM internals.
|
||||
|
||||
## Dev Servers
|
||||
|
||||
`yarn dev` builds and watches the app, served by a running Home Assistant core configured through `development_repo`.
|
||||
|
||||
`yarn dev:serve` also serves locally and supports `-c` for the core URL and `-p` for the port. Default local serving port is 8124.
|
||||
|
||||
Dev server commands support `--background`, `--status`, `--stop`, and `--logs [--follow]`.
|
||||
|
||||
## Playwright E2E
|
||||
|
||||
Each suite has its own dev server port. Playwright reuses an existing server locally when the port is already running; otherwise it performs a slow full build. The rspack watcher recompiles on save, so reruns should not need a restart.
|
||||
|
||||
Start the relevant suite server, then run that suite:
|
||||
|
||||
| Suite | Server | Test command |
|
||||
| ------- | ------------------------------- | ----------------------- |
|
||||
| App | `yarn test:e2e:app:dev` on 8095 | `yarn test:e2e:app` |
|
||||
| Demo | `yarn dev:demo` on 8090 | `yarn test:e2e:demo` |
|
||||
| Gallery | `yarn dev:gallery` on 8100 | `yarn test:e2e:gallery` |
|
||||
|
||||
Server reuse and `--stop` use the `/__ha_dev_status` health check, so starting or stopping twice is harmless.
|
||||
|
||||
Use `-g "<title>" --project=chromium` to narrow a run. `yarn test:e2e` runs all three suites. Run suites directly; piping through output truncation hides progress and failures.
|
||||
|
||||
The app suite uses a stripped-down harness for e2e. Demo and gallery use their normal dev servers.
|
||||
|
||||
## Benchmarks
|
||||
|
||||
For chart data transforms such as history, statistics, energy, and downsampling, follow `test/benchmarks/README.md`.
|
||||
|
||||
Use seeded fixtures, characterization snapshot tests, and `yarn test:bench` before and after optimization. Optimizations must keep output bit-identical.
|
||||
|
||||
## Verification Selection
|
||||
|
||||
- Documentation-only change: no code test required unless examples or commands changed.
|
||||
- Type-only or utility change: run focused Vitest if available, then `yarn lint:types` if practical.
|
||||
- Lit component change: run relevant tests plus lint or typecheck depending on scope.
|
||||
- E2E-sensitive flow: start the relevant e2e dev server and run the narrow Playwright suite.
|
||||
- Broad refactor: run `yarn lint` and relevant test suites when practical.
|
||||
@@ -1,98 +0,0 @@
|
||||
---
|
||||
name: ha-frontend-user-facing-text
|
||||
description: Home Assistant frontend copy, localization, terminology, and user-facing text guidance. Use when adding or reviewing labels, buttons, dialogs, errors, translations, or UI strings.
|
||||
---
|
||||
|
||||
# HA Frontend User-Facing Text
|
||||
|
||||
Use this skill for all user-facing text, translations, labels, buttons, dialog copy, errors, helper text, and review comments about wording.
|
||||
|
||||
## Localization
|
||||
|
||||
- All user-facing text must be translatable.
|
||||
- Add translation keys to `src/translations/en.json` when introducing new strings.
|
||||
- Use the localization system instead of inline user-visible strings.
|
||||
- Prefer complete localized strings with placeholders over concatenating translated fragments.
|
||||
- Give translators enough context through key naming and placeholders.
|
||||
|
||||
```ts
|
||||
this.hass.localize("ui.panel.config.updates.update_available", {
|
||||
count: 5,
|
||||
});
|
||||
```
|
||||
|
||||
## Voice And Style
|
||||
|
||||
- Use American English.
|
||||
- Use a friendly, informational tone.
|
||||
- Address users directly with "you" and "your" when appropriate.
|
||||
- Be inclusive, objective, and non-discriminatory.
|
||||
- Be concise and clear.
|
||||
- Use active voice.
|
||||
- Avoid jargon where a familiar home automation term works.
|
||||
- Always write "Home Assistant" in full. Do not use "HA" or "HASS" in user-facing copy.
|
||||
- Spell out terms when possible.
|
||||
- Use sentence case for titles, headings, buttons, labels, and UI elements.
|
||||
- Use the Oxford comma in lists.
|
||||
- Prefer "like" over "e.g." and "for example" over "i.e.".
|
||||
- Avoid all caps for emphasis. Use wording, bold, or italics instead.
|
||||
- Write for both technical and non-technical users.
|
||||
|
||||
Sentence case examples:
|
||||
|
||||
- Use: "Create new automation"
|
||||
- Avoid: "Create New Automation"
|
||||
- Use: "Device settings"
|
||||
- Avoid: "Device Settings"
|
||||
|
||||
## Terminology
|
||||
|
||||
Use "integration" instead of "component" for user-facing product language unless referring to a frontend component in developer context.
|
||||
|
||||
Technical product terms are lowercase in prose: automation, entity, device, service.
|
||||
|
||||
## Delete, Remove, Create, Add
|
||||
|
||||
Use "Remove" for actions that can be restored or reapplied:
|
||||
|
||||
- Removing a user's permission.
|
||||
- Removing a user from a group.
|
||||
- Removing links between items.
|
||||
- Removing a widget from a dashboard.
|
||||
- Removing an item from a cart.
|
||||
|
||||
Use "Delete" for permanent, non-recoverable actions:
|
||||
|
||||
- Deleting a field.
|
||||
- Deleting a value in a field.
|
||||
- Deleting a task.
|
||||
- Deleting a group.
|
||||
- Deleting a permission.
|
||||
- Deleting a calendar event.
|
||||
|
||||
Use "Add" for already-existing items:
|
||||
|
||||
- Adding a permission to a user.
|
||||
- Adding a user to a group.
|
||||
- Adding links between items.
|
||||
- Adding a widget to a dashboard.
|
||||
- Adding an item to a cart.
|
||||
|
||||
Use "Create" for something made from scratch:
|
||||
|
||||
- Creating a new field.
|
||||
- Creating a new task.
|
||||
- Creating a new group.
|
||||
- Creating a new permission.
|
||||
- Creating a new calendar event.
|
||||
|
||||
Create pairs with Delete. Add pairs with Remove.
|
||||
|
||||
## Review Checklist
|
||||
|
||||
- Text is localized.
|
||||
- Copy uses sentence case.
|
||||
- "Home Assistant" is written in full.
|
||||
- Delete/Remove and Create/Add match recoverability and object lifecycle.
|
||||
- Placeholders are used instead of string concatenation.
|
||||
- The wording is concise and understandable without implementation knowledge.
|
||||
@@ -1 +0,0 @@
|
||||
../.agents/skills
|
||||
@@ -1 +0,0 @@
|
||||
../AGENTS.md
|
||||
@@ -0,0 +1,669 @@
|
||||
# GitHub Copilot & Claude Code Instructions
|
||||
|
||||
You are an assistant helping with development of the Home Assistant frontend. The frontend is built using Lit-based Web Components and TypeScript, providing a responsive and performant interface for home automation control.
|
||||
|
||||
**Note**: This file contains high-level guidelines and references to implementation patterns. For gallery-specific documentation, demos, page structure, and usage examples, see [`gallery/AGENTS.md`](gallery/AGENTS.md).
|
||||
|
||||
## Table of Contents
|
||||
|
||||
- [Quick Reference](#quick-reference)
|
||||
- [Core Architecture](#core-architecture)
|
||||
- [State Access: Contexts Instead of `hass`](#state-access-contexts-instead-of-hass)
|
||||
- [Development Standards](#development-standards)
|
||||
- [Component Library](#component-library)
|
||||
- [Common Patterns](#common-patterns)
|
||||
- [Text and Copy Guidelines](#text-and-copy-guidelines)
|
||||
- [Development Workflow](#development-workflow)
|
||||
- [Review Guidelines](#review-guidelines)
|
||||
|
||||
## Quick Reference
|
||||
|
||||
### Essential Commands
|
||||
|
||||
```bash
|
||||
yarn lint # ESLint + Prettier + TypeScript + Lit
|
||||
yarn format # Auto-fix ESLint + Prettier
|
||||
yarn lint:types # TypeScript compiler (run WITHOUT file arguments)
|
||||
yarn test # Vitest
|
||||
yarn dev # Dev server (app; --background/--status/--stop/--logs)
|
||||
yarn dev:serve # Dev server with serve (-c core URL, -p port; --background/--status/--stop/--logs)
|
||||
```
|
||||
|
||||
> **WARNING:** Never run `tsc` or `yarn lint:types` with file arguments (e.g., `yarn lint:types src/file.ts`). When `tsc` receives file arguments, it ignores `tsconfig.json` and emits `.js` files into `src/`, polluting the codebase. Always run `yarn lint:types` without arguments. For individual file type checking, rely on IDE diagnostics. If `.js` files are accidentally generated, clean up with `git clean -fd src/`.
|
||||
|
||||
### Component Prefixes
|
||||
|
||||
- `ha-` - Home Assistant components
|
||||
- `hui-` - Lovelace UI components
|
||||
- `dialog-` - Dialog components
|
||||
|
||||
### Import Patterns
|
||||
|
||||
```typescript
|
||||
import type { HomeAssistant } from "../types";
|
||||
import { fireEvent } from "../common/dom/fire_event";
|
||||
import { showAlertDialog } from "../dialogs/generic/show-dialog-box";
|
||||
```
|
||||
|
||||
## Core Architecture
|
||||
|
||||
The Home Assistant frontend is a modern web application that:
|
||||
|
||||
- Uses Web Components (custom elements) built with Lit framework
|
||||
- Is written entirely in TypeScript with strict type checking
|
||||
- Communicates with the backend via WebSocket API
|
||||
- Provides comprehensive theming and internationalization
|
||||
|
||||
## State Access: Contexts Instead of `hass`
|
||||
|
||||
Every component used to take the whole `hass: HomeAssistant` object — a god-object that re-renders on any unrelated `hass` change, forces tests to mock everything, and hides what a component actually reads. We're moving leaf components to **fine-grained [Lit context](https://lit.dev/docs/data/context/)**: consume only the slice you need and re-render only when it changes.
|
||||
|
||||
For new code, consume the matching context instead of adding a `hass` property. `hass` stays for container components that own it and feed the providers; the canonical migration is [`hui-button-card.ts`](src/panels/lovelace/cards/hui-button-card.ts). Infrastructure: contexts in [`src/data/context/index.ts`](src/data/context/index.ts), the `consume…` helpers in [`src/common/decorators/consume-context-entry.ts`](src/common/decorators/consume-context-entry.ts), and `@transform` in [`src/common/decorators/transform.ts`](src/common/decorators/transform.ts). Providers are wired automatically by `contextMixin` on `HassBaseEl` — you only consume.
|
||||
|
||||
### Contexts
|
||||
|
||||
Consume the narrowest context that covers your reads:
|
||||
|
||||
| Context | Replaces |
|
||||
| ----------------------------------------------------------------------- | ----------------------------------------------------------------------------------------- |
|
||||
| `statesContext` | `hass.states` |
|
||||
| `entitiesContext` / `devicesContext` / `areasContext` / `floorsContext` | `hass.entities` / `.devices` / `.areas` / `.floors` (or `registriesContext` for all four) |
|
||||
| `servicesContext` | `hass.services` |
|
||||
| `internationalizationContext` | `hass.localize`, `hass.locale`, `hass.language` |
|
||||
| `formattersContext` | `hass.formatEntityName`, `hass.formatEntityState`, `hass.formatEntityAttributeName`, … |
|
||||
| `configContext` | `hass.config`, `hass.user`, `hass.auth`, `hass.userData` |
|
||||
| `connectionContext` | `hass.connection`, `hass.connected`, `hass.hassUrl` |
|
||||
| `apiContext` | `hass.callService`, `hass.callApi`, `hass.callWS`, `hass.sendWS`, `hass.fetchWithAuth` |
|
||||
| `uiContext` | `hass.themes`, `hass.selectedTheme`, `hass.panels`, `hass.dockedSidebar`, … |
|
||||
| `narrowViewportContext` | narrow-layout boolean |
|
||||
|
||||
Lazy contexts (subscribe on first consumer, tear down after the last): `labelsContext`, `fullEntitiesContext`, `configEntriesContext`, `manifestsContext`. The single-field contexts (`localizeContext`, `themesContext`, `userContext`, …) are **deprecated** — use the grouped ones above.
|
||||
|
||||
### Consuming
|
||||
|
||||
Use the `consume…` helpers for entity-scoped and `localize` reads. `entityIdPath` is resolved against `this`, so these watch `this._config.entity`:
|
||||
|
||||
```ts
|
||||
@state() @consumeEntityState({ entityIdPath: ["_config", "entity"] })
|
||||
private _stateObj?: HassEntity; // consumeEntityStates(...) for a record of several
|
||||
|
||||
@state() @consumeEntityRegistryEntry({ entityIdPath: ["_config", "entity"] })
|
||||
private _entity?: EntityRegistryDisplayEntry;
|
||||
|
||||
@state() @consumeLocalize()
|
||||
private _localize!: LocalizeFunc;
|
||||
```
|
||||
|
||||
For any other single field, pair `@consume` with `@transform`:
|
||||
|
||||
```ts
|
||||
@state()
|
||||
@consume({ context: uiContext, subscribe: true })
|
||||
@transform<HomeAssistantUI, Themes>({ transformer: ({ themes }) => themes })
|
||||
private _themes!: Themes;
|
||||
```
|
||||
|
||||
`@transform`'s `watch` option re-runs the transformer when a host prop changes — needed when an entity id is computed, since `consumeEntityState` only watches the first path segment. To consume a whole group untransformed, drop `@transform` and type it `ContextType<typeof statesContext>`.
|
||||
|
||||
## Development Standards
|
||||
|
||||
### Code Quality Requirements
|
||||
|
||||
**Linting and Formatting (Enforced by Tools)**
|
||||
|
||||
- ESLint config (flat config) extends TypeScript strict, Lit, Web Components, Accessibility (lit-a11y), and import-x
|
||||
- Prettier with ES5 trailing commas enforced
|
||||
- No console statements (`no-console: "error"`) - use proper logging
|
||||
- Import organization: No unused imports, consistent type imports
|
||||
|
||||
**Naming Conventions**
|
||||
|
||||
- PascalCase for types and classes
|
||||
- camelCase for variables, methods
|
||||
- Private methods require leading underscore
|
||||
- Public methods forbid leading underscore
|
||||
|
||||
### TypeScript Usage
|
||||
|
||||
- **Always use strict TypeScript**: Enable all strict flags, avoid `any` types
|
||||
- **Proper type imports**: Use `import type` for type-only imports
|
||||
- **Define interfaces**: Create proper interfaces for data structures
|
||||
- **Type component properties**: All Lit properties must be properly typed
|
||||
- **No unused variables**: Prefix with `_` if intentionally unused
|
||||
- **Consistent imports**: Use `@typescript-eslint/consistent-type-imports`
|
||||
|
||||
```typescript
|
||||
// Good
|
||||
import type { HomeAssistant } from "../types";
|
||||
|
||||
interface EntityConfig {
|
||||
entity: string;
|
||||
name?: string;
|
||||
}
|
||||
|
||||
@property({ type: Object })
|
||||
hass!: HomeAssistant;
|
||||
|
||||
// Bad
|
||||
@property()
|
||||
hass: any;
|
||||
```
|
||||
|
||||
### Web Components with Lit
|
||||
|
||||
- **Use Lit 3.x patterns**: Follow modern Lit practices
|
||||
- **Extend appropriate base classes**: Use `LitElement`, `SubscribeMixin`, or other mixins as needed
|
||||
- **Define custom element names**: Use `ha-` prefix for components
|
||||
|
||||
```typescript
|
||||
@customElement("ha-my-component")
|
||||
export class HaMyComponent extends LitElement {
|
||||
@property({ attribute: false })
|
||||
hass!: HomeAssistant;
|
||||
|
||||
@state()
|
||||
private _config?: MyComponentConfig;
|
||||
|
||||
static get styles() {
|
||||
return css`
|
||||
:host {
|
||||
display: block;
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
render() {
|
||||
return html`<div>Content</div>`;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Component Guidelines
|
||||
|
||||
- **Use composition**: Prefer composition over inheritance
|
||||
- **Lazy load panels**: Heavy panels should be dynamically imported
|
||||
- **Optimize renders**: Use `@state()` for internal state, `@property()` for public API
|
||||
- **Handle loading states**: Always show appropriate loading indicators
|
||||
- **Support themes**: Use CSS custom properties from theme
|
||||
|
||||
### Data Management
|
||||
|
||||
- **Use WebSocket API**: All backend communication via home-assistant-js-websocket
|
||||
- **Prefer contexts over `hass`**: For state reads, consume the relevant Lit context instead of taking the whole `hass` object — see [State Access: Contexts Instead of `hass`](#state-access-contexts-instead-of-hass)
|
||||
- **Cache appropriately**: Use collections and caching for frequently accessed data
|
||||
- **Handle errors gracefully**: All API calls should have error handling
|
||||
- **Update real-time**: Subscribe to state changes for live updates
|
||||
|
||||
```typescript
|
||||
// Good
|
||||
try {
|
||||
const result = await fetchEntityRegistry(this.hass.connection);
|
||||
this._processResult(result);
|
||||
} catch (err) {
|
||||
showAlertDialog(this, {
|
||||
text: `Failed to load: ${err.message}`,
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
### Styling Guidelines
|
||||
|
||||
- **Use CSS custom properties**: Leverage the theme system
|
||||
- **Use spacing tokens**: Prefer `--ha-space-*` tokens over hardcoded values for consistent spacing
|
||||
- Spacing scale: `--ha-space-1` (4px) through `--ha-space-20` (80px) in 4px increments
|
||||
- Defined in `src/resources/theme/core.globals.ts`
|
||||
- Common values: `--ha-space-2` (8px), `--ha-space-4` (16px), `--ha-space-8` (32px)
|
||||
- **Mobile-first responsive**: Design for mobile, enhance for desktop
|
||||
- **Prefer `ha-*` components**: Build on the Home Assistant component library (many now wrap Web Awesome components); avoid new use of legacy Material Web Components (`mwc-*`), which are being phased out
|
||||
- **Support RTL**: Ensure all layouts work in RTL languages
|
||||
|
||||
```typescript
|
||||
static get styles() {
|
||||
return css`
|
||||
:host {
|
||||
padding: var(--ha-space-4);
|
||||
color: var(--primary-text-color);
|
||||
background-color: var(--card-background-color);
|
||||
}
|
||||
|
||||
.content {
|
||||
gap: var(--ha-space-2);
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
:host {
|
||||
padding: var(--ha-space-2);
|
||||
}
|
||||
}
|
||||
`;
|
||||
}
|
||||
```
|
||||
|
||||
### View Transitions
|
||||
|
||||
The View Transitions API creates smooth animations between DOM state changes. When implementing view transitions:
|
||||
|
||||
**Core Resources:**
|
||||
|
||||
- **Utility wrapper**: `src/common/util/view-transition.ts` - `withViewTransition()` function with graceful fallback
|
||||
- **Real-world example**: `src/util/launch-screen.ts` - Launch screen fade pattern with browser support detection
|
||||
- **Animation keyframes**: `src/resources/theme/animations.globals.ts` - Global `fade-in`, `fade-out`, `scale` animations
|
||||
- **Animation duration**: `src/resources/theme/core.globals.ts` - `--ha-animation-duration-fast` (150ms), `--ha-animation-duration-normal` (250ms), `--ha-animation-duration-slow` (350ms) (all respect `prefers-reduced-motion`)
|
||||
|
||||
**Implementation Guidelines:**
|
||||
|
||||
1. Always use `withViewTransition()` wrapper for automatic fallback
|
||||
2. Keep transitions simple (subtle crossfades and fades work best)
|
||||
3. Use `--ha-animation-duration-*` CSS variables for consistent timing (`fast`, `normal`, `slow`)
|
||||
4. Assign unique `view-transition-name` to elements (must be unique at any given time)
|
||||
5. For Lit components: Override `performUpdate()` or use `::part()` for internal elements
|
||||
|
||||
**Default Root Transition:**
|
||||
|
||||
By default, `:root` receives `view-transition-name: root`, creating a full-page crossfade. Target with [`::view-transition-group(root)`](https://developer.mozilla.org/en-US/docs/Web/CSS/::view-transition-group) to customize the default page transition.
|
||||
|
||||
**Important Constraints:**
|
||||
|
||||
- Each `view-transition-name` must be unique at any given time
|
||||
- Only one view transition can run at a time
|
||||
- **Shadow DOM incompatibility**: View transitions operate at document level and do not work within Shadow DOM due to style isolation ([spec discussion](https://github.com/w3c/csswg-drafts/issues/10303)). For web components, set `view-transition-name` on the `:host` element or use document-level transitions
|
||||
|
||||
**Specification & Documentation:**
|
||||
|
||||
For browser support, API details, and current specifications, refer to these authoritative sources (note: check publication dates as specs evolve):
|
||||
|
||||
- [MDN: View Transition API](https://developer.mozilla.org/en-US/docs/Web/API/View_Transition_API) - Comprehensive API reference
|
||||
- [Chrome for Developers: View Transitions](https://developer.chrome.com/docs/web-platform/view-transitions) - Implementation guide and examples
|
||||
- [W3C Draft Specification](https://drafts.csswg.org/css-view-transitions/) - Official specification (evolving)
|
||||
|
||||
### Performance Best Practices
|
||||
|
||||
- **Code split**: Split code at the panel/dialog level
|
||||
- **Lazy load**: Use dynamic imports for heavy components
|
||||
- **Optimize bundle**: Keep initial bundle size minimal
|
||||
- **Use virtual scrolling**: For long lists, implement virtual scrolling
|
||||
- **Memoize computations**: Cache expensive calculations
|
||||
|
||||
### Testing Requirements
|
||||
|
||||
- **Write tests**: Add tests for data processing and utilities
|
||||
- **Test with Vitest**: Use the established test framework
|
||||
- **Mock appropriately**: Mock WebSocket connections and API calls
|
||||
- **Test accessibility**: Ensure components are accessible
|
||||
- **Optimizing chart data processing**: When optimizing chart data transforms (history, statistics, energy, downsampling), follow the playbook in [`test/benchmarks/README.md`](test/benchmarks/README.md) — it has seeded fixtures, characterization (snapshot) tests that pin current output, and `vitest bench` benchmarks (`yarn test:bench`) for before/after comparison. Optimizations must keep output bit-identical.
|
||||
|
||||
## Component Library
|
||||
|
||||
### Dialog Component
|
||||
|
||||
**Opening Dialogs (Fire Event Pattern - Recommended):**
|
||||
|
||||
```typescript
|
||||
fireEvent(this, "show-dialog", {
|
||||
dialogTag: "dialog-example",
|
||||
dialogImport: () => import("./dialog-example"),
|
||||
dialogParams: { title: "Example", data: someData },
|
||||
});
|
||||
```
|
||||
|
||||
**Dialog Implementation Requirements:**
|
||||
|
||||
- Use `ha-dialog` component
|
||||
- Implement `HassDialog<T>` interface
|
||||
- Use `@state() private _open = false` to control dialog visibility
|
||||
- Set `_open = true` in `showDialog()`, `_open = false` in `closeDialog()`
|
||||
- Return `nothing` when no params (loading state)
|
||||
- Fire `dialog-closed` event in `_dialogClosed()` handler
|
||||
- Use `header-title` attribute for simple titles
|
||||
- Use `header-subtitle` attribute for simple subtitles
|
||||
- Use slots for custom content where the standard attributes are not enough
|
||||
- Use `ha-dialog-footer` with `primaryAction`/`secondaryAction` slots for footer content
|
||||
- Add `autofocus` to first focusable element (e.g., `<ha-form autofocus>`). The component may need to forward this attribute internally.
|
||||
|
||||
**Dialog Sizing:**
|
||||
|
||||
- Use `width` attribute with predefined sizes: `"small"` (320px), `"medium"` (580px - default), `"large"` (1024px), or `"full"`
|
||||
- Custom sizing is NOT recommended - use the standard width presets
|
||||
|
||||
**Button Appearance Guidelines:**
|
||||
|
||||
`ha-button` (wraps the Web Awesome button — see `src/components/ha-button.ts`) has two independent axes plus size:
|
||||
|
||||
- **`variant`** (color): `"brand"` (default), `"neutral"`, `"danger"`, `"warning"`, `"success"`
|
||||
- **`appearance`** (fill style): `"accent"`, `"filled"`, `"outlined"`, `"plain"`
|
||||
- **`size`**: `"xs"` (extra small, 40px), `"s"` (small, 32px), `"m"` (medium, 40px - default), `"l"` (large, 48px), `"xl"` (extra large, 40px)
|
||||
|
||||
Common patterns:
|
||||
|
||||
- **Primary action**: `appearance="filled"` for emphasis (or the default appearance for a lighter look)
|
||||
- **Secondary action**: `appearance="plain"` for cancel/dismiss actions
|
||||
- **Destructive actions**: `variant="danger"` for delete/remove operations (the generic confirmation dialog uses `variant="danger"` for its confirm button — see `src/dialogs/generic/dialog-box.ts`)
|
||||
- Always place primary action in `slot="primaryAction"` and secondary in `slot="secondaryAction"` within `ha-dialog-footer`
|
||||
|
||||
### Form Component (ha-form)
|
||||
|
||||
- Schema-driven using `HaFormSchema[]`
|
||||
- Supports entity, device, area, target, number, boolean, time, action, text, object, select, icon, media, location selectors
|
||||
- Built-in validation with error display
|
||||
- Use `computeLabel`, `computeError`, `computeHelper` for translations
|
||||
|
||||
```typescript
|
||||
<ha-form
|
||||
.hass=${this.hass}
|
||||
.data=${this._data}
|
||||
.schema=${this._schema}
|
||||
.error=${this._errors}
|
||||
.computeLabel=${(schema) => this.hass.localize(`ui.panel.${schema.name}`)}
|
||||
@value-changed=${this._valueChanged}
|
||||
></ha-form>
|
||||
```
|
||||
|
||||
### Alert Component (ha-alert)
|
||||
|
||||
- Types: `error`, `warning`, `info`, `success`
|
||||
- Properties: `title`, `alert-type`, `dismissable`, `narrow`
|
||||
- Slots: `icon` (override the leading icon), `action` (custom action content)
|
||||
- Content announced by screen readers when dynamically displayed
|
||||
|
||||
```html
|
||||
<ha-alert alert-type="error">Error message</ha-alert>
|
||||
<ha-alert alert-type="warning" title="Warning">Description</ha-alert>
|
||||
<ha-alert alert-type="success" dismissable>Success message</ha-alert>
|
||||
```
|
||||
|
||||
### Keyboard Shortcuts (ShortcutManager)
|
||||
|
||||
The `ShortcutManager` class provides a unified way to register keyboard shortcuts with automatic input field protection.
|
||||
|
||||
**Key Features:**
|
||||
|
||||
- Automatically blocks shortcuts when input fields are focused
|
||||
- Prevents shortcuts during text selection (configurable via `allowWhenTextSelected`)
|
||||
- Supports both character-based and KeyCode-based shortcuts (for non-latin keyboards)
|
||||
|
||||
**Implementation:**
|
||||
|
||||
- **Class definition**: `src/common/keyboard/shortcuts.ts`
|
||||
- **Real-world example**: `src/state/quick-bar-mixin.ts` - Global shortcuts (e, c, d, m, a, Shift+?) with non-latin keyboard fallbacks
|
||||
|
||||
### Tooltip Component (ha-tooltip)
|
||||
|
||||
The `ha-tooltip` component wraps Web Awesome tooltip with Home Assistant theming. Use for providing contextual help text on hover.
|
||||
|
||||
**Implementation:**
|
||||
|
||||
- **Component definition**: `src/components/ha-tooltip.ts`
|
||||
- **Usage example**: `src/components/ha-label.ts`
|
||||
|
||||
## Common Patterns
|
||||
|
||||
### Creating a Panel
|
||||
|
||||
```typescript
|
||||
@customElement("ha-panel-myfeature")
|
||||
export class HaPanelMyFeature extends SubscribeMixin(LitElement) {
|
||||
@property({ attribute: false })
|
||||
hass!: HomeAssistant;
|
||||
|
||||
@property({ type: Boolean, reflect: true })
|
||||
narrow!: boolean;
|
||||
|
||||
@property()
|
||||
route!: Route;
|
||||
|
||||
hassSubscribe() {
|
||||
return [
|
||||
subscribeEntityRegistry(this.hass.connection, (entities) => {
|
||||
this._entities = entities;
|
||||
}),
|
||||
];
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### Creating a Lovelace Card
|
||||
|
||||
**Purpose**: Cards allow users to tell different stories about their house.
|
||||
|
||||
```typescript
|
||||
@customElement("hui-my-card")
|
||||
export class HuiMyCard extends LitElement implements LovelaceCard {
|
||||
@property({ attribute: false })
|
||||
hass!: HomeAssistant;
|
||||
|
||||
@state()
|
||||
private _config?: MyCardConfig;
|
||||
|
||||
public setConfig(config: MyCardConfig): void {
|
||||
if (!config.entity) {
|
||||
throw new Error("Entity required");
|
||||
}
|
||||
this._config = config;
|
||||
}
|
||||
|
||||
public getCardSize(): number {
|
||||
return 3; // Height in grid units
|
||||
}
|
||||
|
||||
// Optional: Editor for card configuration
|
||||
public static getConfigElement(): LovelaceCardEditor {
|
||||
return document.createElement("hui-my-card-editor");
|
||||
}
|
||||
|
||||
// Optional: Stub config for card picker
|
||||
public static getStubConfig(): object {
|
||||
return { entity: "" };
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Card Guidelines:**
|
||||
|
||||
- Cards are highly customizable for different households
|
||||
- Implement `LovelaceCard` interface with `setConfig()` and `getCardSize()`
|
||||
- Use proper error handling in `setConfig()`
|
||||
- Consider all possible states (loading, error, unavailable)
|
||||
- Support different entity types and states
|
||||
- Follow responsive design principles
|
||||
- Add configuration editor when needed
|
||||
|
||||
### Internationalization
|
||||
|
||||
- **Use localize**: Always use the localization system
|
||||
- **Add translation keys**: Add keys to src/translations/en.json
|
||||
- **Support placeholders**: Use proper placeholder syntax
|
||||
|
||||
```typescript
|
||||
this.hass.localize("ui.panel.config.updates.update_available", {
|
||||
count: 5,
|
||||
});
|
||||
```
|
||||
|
||||
### Accessibility
|
||||
|
||||
- **ARIA labels**: Add appropriate ARIA labels
|
||||
- **Keyboard navigation**: Ensure all interactions work with keyboard
|
||||
- **Screen reader support**: Test with screen readers
|
||||
- **Color contrast**: Meet WCAG AA standards
|
||||
|
||||
## Development Workflow
|
||||
|
||||
### Setup and Commands
|
||||
|
||||
1. **Setup**: `script/setup` - Install dependencies
|
||||
2. **Develop**: `script/develop` - Development server
|
||||
3. **Lint**: `yarn lint` - Run all linting before committing
|
||||
4. **Test**: `yarn test` - Add and run tests
|
||||
5. **Build**: `script/build_frontend` - Test production build
|
||||
|
||||
### Dev servers
|
||||
|
||||
`yarn dev` builds and watches the app, served by a running Home Assistant core (`development_repo` setting). `yarn dev:serve` also serves it locally (`-c` core URL, `-p` port, default 8124).
|
||||
|
||||
These and the e2e dev servers below take `--background`, `--status`, `--stop`, and `--logs [--follow]`.
|
||||
|
||||
### End-to-end (e2e) tests
|
||||
|
||||
Each Playwright suite has a dev server on its own port. Playwright reuses a server already on the port (`reuseExistingServer` locally); otherwise it does a slow full build. The rspack watcher recompiles on save, so re-runs need no restart.
|
||||
|
||||
Start the suite's dev server, then run the suite:
|
||||
|
||||
- **App** (8095): `yarn test:e2e:app:dev`, then `yarn test:e2e:app`
|
||||
- **Demo** (8090): `yarn dev:demo`, then `yarn test:e2e:demo`
|
||||
- **Gallery** (8100): `yarn dev:gallery`, then `yarn test:e2e:gallery`
|
||||
|
||||
Server reuse and `--stop` key off a `/__ha_dev_status` health check, so starting or stopping twice is harmless. The app suite uses a stripped-down harness built only for e2e; demo and gallery use their normal dev servers.
|
||||
|
||||
Add `-g "<title>" --project=chromium` to narrow a run; `yarn test:e2e` runs all three. Run the suite directly, since piping through `tail`/`head` hides progress and truncates results.
|
||||
|
||||
### Gallery
|
||||
|
||||
For Gallery-specific structure, page/demo naming, sidebar behavior, content standards, and commands, see [`gallery/AGENTS.md`](gallery/AGENTS.md).
|
||||
|
||||
### Common Pitfalls to Avoid
|
||||
|
||||
- Don't manually query the DOM with `querySelector` - use the `@query`/`@queryAll` decorators or component properties
|
||||
- Don't manipulate DOM directly - Let Lit handle rendering
|
||||
- Don't use global styles - Scope styles to components
|
||||
- Don't block the main thread - Use web workers for heavy computation
|
||||
- Don't ignore TypeScript errors - Fix all type issues
|
||||
|
||||
### Security Best Practices
|
||||
|
||||
- Sanitize HTML - Never use `unsafeHTML` with user content
|
||||
- Validate inputs - Always validate user inputs
|
||||
- Use HTTPS - All external resources must use HTTPS
|
||||
- CSP compliance - Ensure code works with Content Security Policy
|
||||
|
||||
### Pull Requests
|
||||
|
||||
When creating a pull request, you **must** use the PR template located at `.github/PULL_REQUEST_TEMPLATE.md`. Read the template file and use its full content as the PR body, filling in each section appropriately.
|
||||
|
||||
- Do not omit, reorder, or rewrite the template sections
|
||||
- Check the appropriate "Type of change" box based on the changes
|
||||
- Do not check the checklist items on behalf of the user — those are the user's responsibility to review and check
|
||||
- If the PR includes UI changes, remind the user to add screenshots or a short video to the PR after creating it
|
||||
- Be simple and user friendly — explain what the change does, not implementation details
|
||||
- Use markdown so the user can copy it
|
||||
|
||||
### Text and Copy Guidelines
|
||||
|
||||
#### Terminology Standards
|
||||
|
||||
**Delete vs Remove**
|
||||
|
||||
- **Use "Remove"** for actions that can be restored or reapplied:
|
||||
- Removing a user's permission
|
||||
- Removing a user from a group
|
||||
- Removing links between items
|
||||
- Removing a widget from dashboard
|
||||
- Removing an item from a cart
|
||||
- **Use "Delete"** for permanent, non-recoverable actions:
|
||||
- Deleting a field
|
||||
- Deleting a value in a field
|
||||
- Deleting a task
|
||||
- Deleting a group
|
||||
- Deleting a permission
|
||||
- Deleting a calendar event
|
||||
|
||||
**Create vs Add** (Create pairs with Delete, Add pairs with Remove)
|
||||
|
||||
- **Use "Add"** for already-existing items:
|
||||
- Adding a permission to a user
|
||||
- Adding a user to a group
|
||||
- Adding links between items
|
||||
- Adding a widget to dashboard
|
||||
- Adding an item to a cart
|
||||
- **Use "Create"** for something made from scratch:
|
||||
- Creating a new field
|
||||
- Creating a new task
|
||||
- Creating a new group
|
||||
- Creating a new permission
|
||||
- Creating a new calendar event
|
||||
|
||||
#### Writing Style (Consistent with Home Assistant Documentation)
|
||||
|
||||
- **Use American English**: Standard spelling and terminology
|
||||
- **Friendly, informational tone**: Be inspiring, personal, comforting, engaging
|
||||
- **Address users directly**: Use "you" and "your"
|
||||
- **Be inclusive**: Objective, non-discriminatory language
|
||||
- **Be concise**: Use clear, direct language
|
||||
- **Be consistent**: Follow established terminology patterns
|
||||
- **Use active voice**: "Delete the automation" not "The automation should be deleted"
|
||||
- **Avoid jargon**: Use terms familiar to home automation users
|
||||
|
||||
#### Language Standards
|
||||
|
||||
- **Always use "Home Assistant"** in full, never "HA" or "HASS"
|
||||
- **Avoid abbreviations**: Spell out terms when possible
|
||||
- **Use sentence case everywhere**: Titles, headings, buttons, labels, UI elements
|
||||
- ✅ "Create new automation"
|
||||
- ❌ "Create New Automation"
|
||||
- ✅ "Device settings"
|
||||
- ❌ "Device Settings"
|
||||
- **Oxford comma**: Use in lists (item 1, item 2, and item 3)
|
||||
- **Replace Latin terms**: Use "like" instead of "e.g.", "for example" instead of "i.e."
|
||||
- **Avoid CAPS for emphasis**: Use bold or italics instead
|
||||
- **Write for all skill levels**: Both technical and non-technical users
|
||||
|
||||
#### Key Terminology
|
||||
|
||||
- **"integration"** (preferred over "component")
|
||||
- **Technical terms**: Use lowercase (automation, entity, device, service)
|
||||
|
||||
#### Translation Considerations
|
||||
|
||||
All user-facing text must be translatable — see the **Internationalization** section (under Common Patterns) for the `localize` API and placeholder usage. From a copy perspective:
|
||||
|
||||
- **Keep context**: Provide enough context for translators
|
||||
- **Avoid concatenation**: Prefer full localized strings with placeholders over stitching translated fragments together
|
||||
|
||||
### Common Review Issues (From PR Analysis)
|
||||
|
||||
Recurring, easy-to-miss problems surfaced in real PR reviews. These complement the standards above rather than repeating them — items already covered earlier (loading states, error handling, mobile layout, theming, import hygiene) are intentionally not duplicated here.
|
||||
|
||||
#### User Experience and Accessibility
|
||||
|
||||
- **Form validation**: Always provide proper field labels and validation feedback
|
||||
- **Form accessibility**: Prevent password managers from incorrectly identifying fields
|
||||
- **Hit targets**: Make clickable areas large enough for touch interaction
|
||||
- **Visual feedback**: Provide clear indication of interactive states (hover, active, focus)
|
||||
|
||||
#### Dialog and Modal Patterns
|
||||
|
||||
- **Interview progress**: Show clear progress for multi-step operations
|
||||
- **State persistence**: Handle dialog state properly during background operations
|
||||
- **Cancel behavior**: Ensure cancel/close buttons work consistently
|
||||
- **Form prefilling**: Use smart defaults but allow user override
|
||||
|
||||
#### Component Design Patterns
|
||||
|
||||
- **Terminology consistency**: Use "Join"/"Apply" instead of "Group" when appropriate
|
||||
- **Visual hierarchy**: Ensure proper font sizes and spacing ratios
|
||||
- **Grid alignment**: Components should align to the design grid system
|
||||
- **Badge placement**: Position badges and indicators consistently
|
||||
|
||||
#### Code Quality Issues
|
||||
|
||||
- **Null checking**: Always check if entities exist before accessing properties
|
||||
- **TypeScript safety**: Handle potentially undefined array/object access
|
||||
- **Event handling and cleanup**: Subscribe/unsubscribe correctly and remove listeners to avoid memory leaks
|
||||
|
||||
#### Configuration and Props
|
||||
|
||||
- **Optional parameters**: Make configuration fields optional when sensible
|
||||
- **Smart defaults**: Provide reasonable default values
|
||||
- **Future extensibility**: Design APIs that can be extended later
|
||||
- **Validation**: Validate configuration before applying changes
|
||||
|
||||
## Review Guidelines
|
||||
|
||||
Final pre-submission checklist. Linting and formatting are enforced by tooling, so this focuses on what tools can't catch rather than restating every rule above.
|
||||
|
||||
- [ ] `yarn lint` passes (TypeScript, ESLint, Prettier, Lit analyzer) and `yarn test` is green
|
||||
- [ ] Tests added for new data processing/utilities (where applicable)
|
||||
- [ ] All user-facing text is localized and follows the Text and Copy guidelines (sentence case, "Home Assistant" in full, Delete/Remove + Create/Add)
|
||||
- [ ] Components handle all states (loading, error, unavailable)
|
||||
- [ ] Entity existence checked before property access
|
||||
- [ ] Event/subscription listeners cleaned up (no memory leaks)
|
||||
- [ ] Accessible to screen readers and keyboard
|
||||
+1
-2
@@ -63,8 +63,7 @@ test/e2e/test-results/
|
||||
test/e2e/app/dist/
|
||||
|
||||
# AI tooling
|
||||
.claude/*
|
||||
!.claude/skills
|
||||
.claude
|
||||
.cursor
|
||||
.opencode
|
||||
.serena
|
||||
|
||||
@@ -1,52 +0,0 @@
|
||||
# Home Assistant Frontend Agent Guide
|
||||
|
||||
You are helping develop the Home Assistant frontend. This repository is a TypeScript application built from Lit-based Web Components for the Home Assistant web UI.
|
||||
|
||||
For gallery-specific documentation, demos, page structure, and examples, read `gallery/AGENTS.md` when working under `gallery/`.
|
||||
|
||||
## Essential Commands
|
||||
|
||||
```bash
|
||||
yarn lint # ESLint + Prettier + TypeScript + Lit
|
||||
yarn format # Auto-fix ESLint + Prettier
|
||||
yarn lint:types # TypeScript compiler, run without file arguments
|
||||
yarn test # Vitest
|
||||
yarn dev # App dev server, supports --background/--status/--stop/--logs
|
||||
yarn dev:serve # Local serving dev server, supports -c core URL, -p port, and dev flags
|
||||
```
|
||||
|
||||
Never run `tsc` or `yarn lint:types` with file arguments. When `tsc` receives file arguments, it ignores `tsconfig.json` and can emit `.js` files into `src/`. Always run `yarn lint:types` without arguments. For individual file type checking, rely on editor diagnostics.
|
||||
|
||||
## Architecture
|
||||
|
||||
- The frontend uses custom elements built with Lit and TypeScript strict mode.
|
||||
- Components communicate with the backend through the Home Assistant WebSocket API.
|
||||
- Use `ha-` for Home Assistant components, `hui-` for Lovelace UI components, and `dialog-` for dialogs.
|
||||
- Prefer `ha-*` components and current Web Awesome wrappers. Avoid adding new legacy `mwc-*` usage.
|
||||
- Leaf components should consume narrow Lit contexts instead of taking the broad `hass` object unless they are containers that own and provide `hass`.
|
||||
|
||||
## Development Standards
|
||||
|
||||
- Use strict TypeScript, proper interfaces, and `import type` for type-only imports.
|
||||
- Avoid `any`; model data with existing Home Assistant types or narrow new types.
|
||||
- Keep imports organized and remove unused imports.
|
||||
- Do not use `console`; use existing logging or user-visible error patterns.
|
||||
- Use `@state()` for internal Lit state and `@property()` for public API.
|
||||
- Do not query or manipulate DOM manually when Lit decorators, component refs, or render state are appropriate.
|
||||
- Scope styles to components, use theme custom properties, and keep layouts mobile-first and RTL-safe.
|
||||
- All user-facing text must be localized through the translation system.
|
||||
|
||||
## Project Skills
|
||||
|
||||
Detailed guidance lives in project skills under `.agents/skills/`. Load the matching skill before detailed implementation or review:
|
||||
|
||||
- `ha-frontend-contexts`: Lit contexts, `hass` migration, and rerender-sensitive state access.
|
||||
- `ha-frontend-components`: dialogs, forms, alerts, shortcuts, tooltips, panels, and Lovelace cards.
|
||||
- `ha-frontend-styling`: theme variables, spacing tokens, responsive layout, RTL, and view transitions.
|
||||
- `ha-frontend-testing`: lint, typecheck, Vitest, Playwright e2e dev servers, and benchmarks.
|
||||
- `ha-frontend-user-facing-text`: localization, terminology, sentence case, and Home Assistant text style.
|
||||
- `ha-frontend-review`: PR template use, review checklist, and recurring review issues.
|
||||
|
||||
## Pull Requests
|
||||
|
||||
When creating a pull request, use `.github/PULL_REQUEST_TEMPLATE.md` as the PR body. Preserve template sections, check only the appropriate type-of-change boxes, and do not check checklist items on behalf of the user. If the PR includes UI changes, remind the user to add screenshots or a short video.
|
||||
@@ -22,7 +22,7 @@ export type DemoCloudBackup = "fresh" | "stale" | "failed" | "local" | "none";
|
||||
export interface CloudDemoScenario {
|
||||
account: DemoCloudAccount;
|
||||
onboarded: boolean;
|
||||
// Onboarding postponed server-side (maps to onboarding_postponed); hides
|
||||
// Onboarding postponed server-side (maps to is_onboarding_postponed); hides
|
||||
// the onboarding UI without marking it completed.
|
||||
postponed: boolean;
|
||||
remote: boolean;
|
||||
|
||||
@@ -57,7 +57,7 @@ const cloudStatus: CloudStatusLoggedIn = {
|
||||
remote_certificate_status: "ready",
|
||||
http_use_ssl: false,
|
||||
active_subscription: true,
|
||||
onboarding_postponed: false,
|
||||
is_onboarding_postponed: false,
|
||||
onboarding_completed: true,
|
||||
prefs: {
|
||||
google_enabled: true,
|
||||
@@ -124,7 +124,7 @@ const applyScenario = () => {
|
||||
? [...ONBOARDING_ITEMS]
|
||||
: [];
|
||||
cloudStatus.onboarding_completed = scenario.onboarded;
|
||||
cloudStatus.onboarding_postponed = scenario.postponed;
|
||||
cloudStatus.is_onboarding_postponed = scenario.postponed;
|
||||
cloudStatus.prefs.onboarding_postponed_until = scenario.postponed
|
||||
? new Date(Date.now() + 24 * 3600 * 1000).toISOString()
|
||||
: null;
|
||||
@@ -165,7 +165,7 @@ const syncScenarioFromStatus = () => {
|
||||
const scenario = getCloudDemoScenario();
|
||||
const next = {
|
||||
onboarded: cloudStatus.onboarding_completed,
|
||||
postponed: cloudStatus.onboarding_postponed,
|
||||
postponed: cloudStatus.is_onboarding_postponed,
|
||||
remote: cloudStatus.prefs.remote_enabled,
|
||||
webrtc: cloudStatus.prefs.cloud_ice_servers_enabled,
|
||||
webhooks: Object.keys(cloudStatus.prefs.cloudhooks).length > 0,
|
||||
@@ -201,7 +201,7 @@ export const mockCloud = (hass: MockHomeAssistant) => {
|
||||
cloudStatus.prefs.onboarding_postponed_until = new Date(
|
||||
Date.now() + 24 * 3600 * 1000
|
||||
).toISOString();
|
||||
cloudStatus.onboarding_postponed = true;
|
||||
cloudStatus.is_onboarding_postponed = true;
|
||||
syncScenarioFromStatus();
|
||||
// Backend returns the full logged-in status object.
|
||||
return { ...cloudStatus, prefs: { ...cloudStatus.prefs } };
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import { ContextEvent } from "@lit/context";
|
||||
import type { Context, ContextCallback } from "@lit/context";
|
||||
import { consume } from "@lit/context";
|
||||
import type { HassEntities, HassEntity } from "home-assistant-js-websocket";
|
||||
import type { ReactiveController, ReactiveElement } from "lit";
|
||||
import type {
|
||||
HomeAssistant,
|
||||
HomeAssistantInternationalization,
|
||||
@@ -51,86 +49,8 @@ export const preserveUnchangedEntityStatesRecord = <
|
||||
return previous;
|
||||
};
|
||||
|
||||
/**
|
||||
* Reactive controller that subscribes to a Lit context and assigns each
|
||||
* delivered value to a host property — WITHOUT forcing a host update on every
|
||||
* delivery.
|
||||
*
|
||||
* `@lit/context`'s built-in `ContextConsumer` calls `host.requestUpdate()`
|
||||
* unconditionally on every provider notification. For a hot context such as
|
||||
* `statesContext` (replaced on every entity state change) that means every
|
||||
* consumer runs an (often empty) update/render cycle on every unrelated state
|
||||
* change, even when the value it actually reads is unchanged.
|
||||
*
|
||||
* This controller instead leaves update scheduling to the property's own
|
||||
* setter. Combined with {@link transform}, that setter only requests an update
|
||||
* when the *selected* value changes (Lit gates `requestUpdate(key, oldValue)`
|
||||
* with `hasChanged`), so unrelated context churn no longer triggers renders.
|
||||
*/
|
||||
class ContextSubscriptionController<ValueType> implements ReactiveController {
|
||||
private _unsubscribe?: () => void;
|
||||
|
||||
constructor(
|
||||
private readonly _host: ReactiveElement,
|
||||
private readonly _context: Context<unknown, ValueType>,
|
||||
private readonly _assign: (value: ValueType) => void
|
||||
) {
|
||||
this._host.addController(this);
|
||||
}
|
||||
|
||||
public hostConnected(): void {
|
||||
this._host.dispatchEvent(
|
||||
new ContextEvent(this._context, this._host, this._callback, true)
|
||||
);
|
||||
}
|
||||
|
||||
public hostDisconnected(): void {
|
||||
this._unsubscribe?.();
|
||||
this._unsubscribe = undefined;
|
||||
}
|
||||
|
||||
// Class field arrow function so the identity is stable per instance, which the
|
||||
// provider's subscription bookkeeping and `ContextRoot` deduping rely on.
|
||||
private readonly _callback: ContextCallback<ValueType> = (
|
||||
value,
|
||||
unsubscribe
|
||||
) => {
|
||||
// A different provider answered (e.g. re-parenting); drop the stale one.
|
||||
if (this._unsubscribe && this._unsubscribe !== unsubscribe) {
|
||||
this._unsubscribe();
|
||||
}
|
||||
this._unsubscribe = unsubscribe;
|
||||
// Assign through the property setter, which decides — via `hasChanged` —
|
||||
// whether an update is actually needed. We intentionally never call
|
||||
// `host.requestUpdate()` here.
|
||||
this._assign(value);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Like `@consume({ subscribe: true })` from `@lit/context`, but does not force a
|
||||
* host update on every provider notification — see
|
||||
* {@link ContextSubscriptionController}. Pair with {@link transform} so an
|
||||
* update is scheduled only when the derived value actually changes.
|
||||
*/
|
||||
const subscribeContext =
|
||||
<ValueType>(context: Context<unknown, ValueType>) =>
|
||||
(proto: object, propertyKey: string): void => {
|
||||
(proto.constructor as unknown as typeof ReactiveElement).addInitializer(
|
||||
(host) => {
|
||||
new ContextSubscriptionController<ValueType>(
|
||||
host as ReactiveElement,
|
||||
context,
|
||||
(value) => {
|
||||
(host as unknown as Record<string, unknown>)[propertyKey] = value;
|
||||
}
|
||||
);
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
const composeDecorator = <T, V>(
|
||||
context: Context<unknown, T>,
|
||||
context: Parameters<typeof consume>[0]["context"],
|
||||
watchKey: string | undefined,
|
||||
select: (this: unknown, value: T) => V | undefined
|
||||
) => {
|
||||
@@ -140,7 +60,7 @@ const composeDecorator = <T, V>(
|
||||
},
|
||||
watch: watchKey ? [watchKey] : [],
|
||||
});
|
||||
const consumeDec = subscribeContext<T>(context);
|
||||
const consumeDec = consume<any>({ context, subscribe: true });
|
||||
return (proto: any, propertyKey: string) => {
|
||||
transformDec(proto, propertyKey);
|
||||
consumeDec(proto, propertyKey);
|
||||
@@ -204,7 +124,10 @@ export const consumeEntityStates = (config: ConsumeEntryConfig) => {
|
||||
},
|
||||
watch: watchKey ? [watchKey] : [],
|
||||
});
|
||||
const consumeDec = subscribeContext<HassEntities>(statesContext);
|
||||
const consumeDec = consume<any>({
|
||||
context: statesContext,
|
||||
subscribe: true,
|
||||
});
|
||||
transformDec(proto as never, propertyKey);
|
||||
consumeDec(proto as never, propertyKey);
|
||||
};
|
||||
@@ -228,7 +151,7 @@ export const consumeEntityRegistryEntry = (config: ConsumeEntryConfig) =>
|
||||
/**
|
||||
* Consumes `internationalizationContext` and narrows it to the `localize`
|
||||
* function. No host watching is needed — the decorated property updates
|
||||
* whenever `localize` changes.
|
||||
* whenever the i18n context changes.
|
||||
*/
|
||||
export const consumeLocalize = () =>
|
||||
composeDecorator<HomeAssistantInternationalization, LocalizeFunc>(
|
||||
|
||||
@@ -4,6 +4,7 @@ import { html, LitElement, nothing } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import { ensureArray } from "../../common/array/ensure-array";
|
||||
import { fireEvent } from "../../common/dom/fire_event";
|
||||
import type { HaEntityPickerEntityFilterFunc } from "../../data/entity/entity";
|
||||
import type { EntitySources } from "../../data/entity/entity_sources";
|
||||
import { fetchEntitySourcesWithCache } from "../../data/entity/entity_sources";
|
||||
import type { EntitySelector } from "../../data/selector";
|
||||
@@ -35,6 +36,10 @@ export class HaEntitySelector extends LitElement {
|
||||
|
||||
@property({ type: Boolean }) public required = true;
|
||||
|
||||
@property({ attribute: false }) public context?: {
|
||||
entityFilter?: HaEntityPickerEntityFilterFunc;
|
||||
};
|
||||
|
||||
@state() private _createDomains: string[] | undefined;
|
||||
|
||||
private _hasIntegration(selector: EntitySelector) {
|
||||
@@ -111,6 +116,9 @@ export class HaEntitySelector extends LitElement {
|
||||
}
|
||||
|
||||
private _filterEntities = (entity: HassEntity): boolean => {
|
||||
if (this.context?.entityFilter && !this.context.entityFilter(entity)) {
|
||||
return false;
|
||||
}
|
||||
if (!this.selector?.entity?.filter) {
|
||||
return true;
|
||||
}
|
||||
|
||||
+3
-4
@@ -360,10 +360,9 @@ export const cloudBackupHealth = (
|
||||
return "failed";
|
||||
}
|
||||
|
||||
const next =
|
||||
backupConfig?.next_automatic_backup &&
|
||||
new Date(backupConfig.next_automatic_backup).getTime();
|
||||
|
||||
const next = backupConfig?.next_automatic_backup
|
||||
? new Date(backupConfig.next_automatic_backup).getTime()
|
||||
: 0;
|
||||
if (next && next < Date.now() - BACKUP_OVERDUE_MARGIN_MS) {
|
||||
return "old";
|
||||
}
|
||||
|
||||
+1
-1
@@ -52,7 +52,7 @@ export interface CloudStatusLoggedIn {
|
||||
remote_certificate_status: RemoteCertificateStatus | null;
|
||||
http_use_ssl: boolean;
|
||||
active_subscription: boolean;
|
||||
onboarding_postponed: boolean;
|
||||
is_onboarding_postponed: boolean;
|
||||
onboarding_completed: boolean;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { Connection } from "home-assistant-js-websocket";
|
||||
import type { Condition } from "../panels/lovelace/common/validate-condition";
|
||||
import type { ShortcutItem } from "./home_shortcuts";
|
||||
|
||||
export interface CoreFrontendUserData {
|
||||
@@ -26,6 +27,17 @@ export interface HomeFrontendSystemData {
|
||||
shortcuts?: ShortcutItem[];
|
||||
}
|
||||
|
||||
export interface SecurityAlertEntityConfig {
|
||||
entity: string;
|
||||
color?: string;
|
||||
pulse?: boolean;
|
||||
visibility?: Condition[];
|
||||
}
|
||||
|
||||
export interface SecurityFrontendSystemData {
|
||||
alert_entities?: SecurityAlertEntityConfig[];
|
||||
}
|
||||
|
||||
export interface EnergyFrontendSystemData {
|
||||
// Stable "<view>.<card-type>" keys of energy dashboard cards the user has
|
||||
// hidden. An absent key or array means nothing is hidden (all cards visible),
|
||||
@@ -42,6 +54,7 @@ declare global {
|
||||
core: CoreFrontendSystemData;
|
||||
home: HomeFrontendSystemData;
|
||||
energy: EnergyFrontendSystemData;
|
||||
security: SecurityFrontendSystemData;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -147,15 +147,14 @@ export class CloudAccountOnboarding extends LitElement {
|
||||
display: block;
|
||||
width: 100%;
|
||||
}
|
||||
ha-card.onboarding-card {
|
||||
container-type: inline-size;
|
||||
}
|
||||
.ready-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--ha-space-6);
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: var(--ha-space-8);
|
||||
}
|
||||
.ready-left {
|
||||
flex: 1.1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
@@ -178,13 +177,16 @@ export class CloudAccountOnboarding extends LitElement {
|
||||
margin-top: var(--ha-space-4);
|
||||
}
|
||||
.ready-grid {
|
||||
flex: 1;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: var(--ha-space-4);
|
||||
gap: var(--ha-space-4) var(--ha-space-4);
|
||||
}
|
||||
@container (max-width: 450px) {
|
||||
.ready-grid {
|
||||
grid-template-columns: 1fr;
|
||||
@media (max-width: 600px) {
|
||||
.ready-card {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
gap: var(--ha-space-5);
|
||||
}
|
||||
}
|
||||
.ready-chip {
|
||||
|
||||
@@ -119,7 +119,7 @@ export class CloudAccount extends SubscribeMixin(LitElement) {
|
||||
private get _onboarded(): boolean {
|
||||
return (
|
||||
this.cloudStatus.onboarding_completed ||
|
||||
this.cloudStatus.onboarding_postponed
|
||||
this.cloudStatus.is_onboarding_postponed
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
import { computeDomain } from "../../../common/entity/compute_domain";
|
||||
import type { PictureCardConfig } from "../cards/types";
|
||||
import type { CardSuggestionProvider } from "./types";
|
||||
|
||||
export const pictureCardSuggestions: CardSuggestionProvider<PictureCardConfig> =
|
||||
{
|
||||
getEntitySuggestion(_hass, entityId) {
|
||||
if (computeDomain(entityId) !== "image") return null;
|
||||
return {
|
||||
config: {
|
||||
type: "picture",
|
||||
image_entity: entityId,
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -5,7 +5,6 @@ import { historyGraphCardSuggestions } from "./hui-history-graph-card-suggestion
|
||||
import { humidifierCardSuggestions } from "./hui-humidifier-card-suggestions";
|
||||
import { mapCardSuggestions } from "./hui-map-card-suggestions";
|
||||
import { mediaControlCardSuggestions } from "./hui-media-control-card-suggestions";
|
||||
import { pictureCardSuggestions } from "./hui-picture-card-suggestions";
|
||||
import { pictureEntityCardSuggestions } from "./hui-picture-entity-card-suggestions";
|
||||
import { plantStatusCardSuggestions } from "./hui-plant-status-card-suggestions";
|
||||
import { statisticsGraphCardSuggestions } from "./hui-statistics-graph-card-suggestions";
|
||||
@@ -24,7 +23,6 @@ export const CARD_SUGGESTION_PROVIDERS: Record<string, CardSuggestionProvider> =
|
||||
humidifier: humidifierCardSuggestions,
|
||||
map: mapCardSuggestions,
|
||||
"media-control": mediaControlCardSuggestions,
|
||||
picture: pictureCardSuggestions,
|
||||
"picture-entity": pictureEntityCardSuggestions,
|
||||
"plant-status": plantStatusCardSuggestions,
|
||||
"statistics-graph": statisticsGraphCardSuggestions,
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
import { createContext } from "@lit/context";
|
||||
import type { SecurityAlertItem } from "../../../security/strategies/security-alerts";
|
||||
|
||||
export const securityAlertsContext =
|
||||
createContext<SecurityAlertItem[]>("security-alerts");
|
||||
@@ -0,0 +1,140 @@
|
||||
import { ContextProvider, consume, type ContextType } from "@lit/context";
|
||||
import type { HassEntity } from "home-assistant-js-websocket";
|
||||
import type { PropertyValues } from "lit";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import { consumeEntityStates } from "../../../../common/decorators/consume-context-entry";
|
||||
import { fireEvent } from "../../../../common/dom/fire_event";
|
||||
import {
|
||||
configContext,
|
||||
internationalizationContext,
|
||||
} from "../../../../data/context";
|
||||
import {
|
||||
computeSecurityAlertItem,
|
||||
computeSecurityAlertItems,
|
||||
extractSecurityAlertEntityIds,
|
||||
type SecurityAlertItem,
|
||||
} from "../../../security/strategies/security-alerts";
|
||||
import type { LovelaceCard, LovelaceGridOptions } from "../../types";
|
||||
import type { SecurityAlertsCardConfig } from "../types";
|
||||
import { securityAlertsContext } from "./context";
|
||||
import "./hui-security-alerts-heading";
|
||||
import "./hui-security-alerts-list";
|
||||
|
||||
@customElement("hui-security-alerts-card")
|
||||
export class HuiSecurityAlertsCard extends LitElement implements LovelaceCard {
|
||||
public connectedWhileHidden = true;
|
||||
|
||||
@property({ type: Boolean }) public preview = false;
|
||||
|
||||
private _alertsProvider = new ContextProvider<{
|
||||
__context__: SecurityAlertItem[];
|
||||
}>(this, {
|
||||
context: securityAlertsContext,
|
||||
initialValue: [],
|
||||
});
|
||||
|
||||
@state() private _config?: SecurityAlertsCardConfig;
|
||||
|
||||
@state() private _alertEntityIds?: string[];
|
||||
|
||||
@state()
|
||||
@consumeEntityStates({ entityIdPath: ["_alertEntityIds"] })
|
||||
private _states?: Record<string, HassEntity>;
|
||||
|
||||
@state()
|
||||
@consume({ context: configContext, subscribe: true })
|
||||
private _hassConfig!: ContextType<typeof configContext>;
|
||||
|
||||
@state()
|
||||
@consume({ context: internationalizationContext, subscribe: true })
|
||||
private _i18n!: ContextType<typeof internationalizationContext>;
|
||||
|
||||
public setConfig(config: SecurityAlertsCardConfig): void {
|
||||
if (!config.alert_entities) {
|
||||
throw new Error("Specify alert entities");
|
||||
}
|
||||
this._config = config;
|
||||
this._alertEntityIds = extractSecurityAlertEntityIds(config.alert_entities);
|
||||
}
|
||||
|
||||
public getCardSize(): number {
|
||||
return this._visibleAlerts.length + 1;
|
||||
}
|
||||
|
||||
public getGridOptions(): LovelaceGridOptions {
|
||||
return {
|
||||
columns: 12,
|
||||
rows: "auto",
|
||||
min_columns: 6,
|
||||
min_rows: 1,
|
||||
};
|
||||
}
|
||||
|
||||
private get _visibleAlerts(): SecurityAlertItem[] {
|
||||
const states = this._states;
|
||||
if (!this._config || !this._alertEntityIds?.length || !states) {
|
||||
return [];
|
||||
}
|
||||
if (this.preview) {
|
||||
return this._config.alert_entities
|
||||
.map((alertEntity) => {
|
||||
const stateObj = states[alertEntity.entity];
|
||||
return stateObj
|
||||
? computeSecurityAlertItem(stateObj, alertEntity)
|
||||
: undefined;
|
||||
})
|
||||
.filter((item): item is SecurityAlertItem => Boolean(item));
|
||||
}
|
||||
return computeSecurityAlertItems(
|
||||
{ ...this._hassConfig, ...this._i18n, states },
|
||||
this._config.alert_entities
|
||||
);
|
||||
}
|
||||
|
||||
protected updated(changedProps: PropertyValues): void {
|
||||
super.updated(changedProps);
|
||||
|
||||
if (!this._config) {
|
||||
return;
|
||||
}
|
||||
|
||||
const alerts = this._visibleAlerts;
|
||||
this._alertsProvider.setValue(alerts);
|
||||
const shouldBeHidden = !this.preview && alerts.length === 0;
|
||||
|
||||
if (shouldBeHidden !== this.hidden) {
|
||||
this.style.display = shouldBeHidden ? "none" : "";
|
||||
this.toggleAttribute("hidden", shouldBeHidden);
|
||||
fireEvent(this, "card-visibility-changed", { value: !shouldBeHidden });
|
||||
this.requestUpdate();
|
||||
}
|
||||
}
|
||||
|
||||
protected render() {
|
||||
if (!this._config || this.hidden) {
|
||||
return nothing;
|
||||
}
|
||||
|
||||
return html`
|
||||
${
|
||||
this.preview
|
||||
? nothing
|
||||
: html`<hui-security-alerts-heading></hui-security-alerts-heading>`
|
||||
}
|
||||
<hui-security-alerts-list></hui-security-alerts-list>
|
||||
`;
|
||||
}
|
||||
|
||||
static styles = css`
|
||||
:host {
|
||||
display: block;
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"hui-security-alerts-card": HuiSecurityAlertsCard;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { consume } from "@lit/context";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, state } from "lit/decorators";
|
||||
import { consumeLocalize } from "../../../../common/decorators/consume-context-entry";
|
||||
import type { LocalizeFunc } from "../../../../common/translations/localize";
|
||||
import type { SecurityAlertItem } from "../../../security/strategies/security-alerts";
|
||||
import { securityAlertsContext } from "./context";
|
||||
|
||||
@customElement("hui-security-alerts-heading")
|
||||
export class HuiSecurityAlertsHeading extends LitElement {
|
||||
@state()
|
||||
@consume({ context: securityAlertsContext, subscribe: true })
|
||||
private _alerts: SecurityAlertItem[] = [];
|
||||
|
||||
@state()
|
||||
@consumeLocalize()
|
||||
private _localize!: LocalizeFunc;
|
||||
|
||||
protected render() {
|
||||
if (!this._alerts.length) {
|
||||
return nothing;
|
||||
}
|
||||
|
||||
return html`<h2>${this._localize("ui.card.security-alerts.title")}</h2>`;
|
||||
}
|
||||
|
||||
static styles = css`
|
||||
:host {
|
||||
display: block;
|
||||
min-height: 24px;
|
||||
padding: 0 var(--ha-space-1);
|
||||
}
|
||||
h2 {
|
||||
color: var(--ha-heading-card-title-color, var(--primary-text-color));
|
||||
font-size: var(--ha-heading-card-title-font-size, var(--ha-font-size-l));
|
||||
font-weight: var(
|
||||
--ha-heading-card-title-font-weight,
|
||||
var(--ha-font-weight-normal)
|
||||
);
|
||||
line-height: var(
|
||||
--ha-heading-card-title-line-height,
|
||||
var(--ha-line-height-normal)
|
||||
);
|
||||
letter-spacing: 0.1px;
|
||||
margin: 0 0 var(--ha-space-2);
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"hui-security-alerts-heading": HuiSecurityAlertsHeading;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
import { consume, type ContextType } from "@lit/context";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, state } from "lit/decorators";
|
||||
import { classMap } from "lit/directives/class-map";
|
||||
import { styleMap } from "lit/directives/style-map";
|
||||
import { computeCssColor } from "../../../../common/color/compute-color";
|
||||
import { computeStateName } from "../../../../common/entity/compute_state_name";
|
||||
import { fireEvent } from "../../../../common/dom/fire_event";
|
||||
import "../../../../components/ha-card";
|
||||
import "../../../../components/ha-relative-time";
|
||||
import "../../../../components/ha-state-icon";
|
||||
import "../../../../components/tile/ha-tile-container";
|
||||
import "../../../../components/tile/ha-tile-icon";
|
||||
import "../../../../components/tile/ha-tile-info";
|
||||
import { formattersContext } from "../../../../data/context";
|
||||
import type { ActionHandlerEvent } from "../../../../data/lovelace/action_handler";
|
||||
import { pulseOpacityAnimation } from "../../../../resources/animations";
|
||||
import type { SecurityAlertItem } from "../../../security/strategies/security-alerts";
|
||||
import { tileCardStyle } from "../tile/tile-card-style";
|
||||
import { securityAlertsContext } from "./context";
|
||||
|
||||
@customElement("hui-security-alerts-list")
|
||||
export class HuiSecurityAlertsList extends LitElement {
|
||||
@state()
|
||||
@consume({ context: securityAlertsContext, subscribe: true })
|
||||
private _alerts: SecurityAlertItem[] = [];
|
||||
|
||||
@state()
|
||||
@consume({ context: formattersContext, subscribe: true })
|
||||
private _formatters!: ContextType<typeof formattersContext>;
|
||||
|
||||
protected render() {
|
||||
if (!this._alerts.length) {
|
||||
return nothing;
|
||||
}
|
||||
|
||||
return html`
|
||||
<div class="alerts">
|
||||
${this._alerts.map((alert) => this._renderAlert(alert))}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private _handleAction(ev: ActionHandlerEvent): void {
|
||||
const entityId = (ev.currentTarget as HTMLElement).dataset.entityId;
|
||||
if (ev.detail.action === "tap" && entityId) {
|
||||
fireEvent(this, "hass-more-info", { entityId });
|
||||
}
|
||||
}
|
||||
|
||||
private _renderAlert(alert: SecurityAlertItem) {
|
||||
const stateDisplay = this._formatters.formatEntityState(alert.stateObj);
|
||||
const pulse = alert.pulse === true;
|
||||
const hasColor = alert.color !== "none";
|
||||
return html`
|
||||
<ha-card
|
||||
class=${classMap({ pulse, "no-color": !hasColor })}
|
||||
style=${styleMap({
|
||||
"--ha-security-alert-color":
|
||||
alert.color && hasColor ? computeCssColor(alert.color) : undefined,
|
||||
"--ha-security-alert-static-opacity": pulse
|
||||
? undefined
|
||||
: "var(--ha-security-alert-pulse-opacity)",
|
||||
})}
|
||||
>
|
||||
<ha-tile-container
|
||||
.interactive=${true}
|
||||
.actionHandlerOptions=${{ hasHold: false, hasDoubleClick: false }}
|
||||
data-entity-id=${alert.entityId}
|
||||
@action=${this._handleAction}
|
||||
>
|
||||
<ha-tile-icon
|
||||
slot="icon"
|
||||
.icon=${alert.icon}
|
||||
.iconPath=${alert.iconPath}
|
||||
>
|
||||
${
|
||||
!alert.icon && !alert.iconPath
|
||||
? html`<ha-state-icon
|
||||
slot="icon"
|
||||
.stateObj=${alert.stateObj}
|
||||
></ha-state-icon>`
|
||||
: nothing
|
||||
}
|
||||
</ha-tile-icon>
|
||||
<ha-tile-info slot="info">
|
||||
<span slot="primary">${computeStateName(alert.stateObj)}</span>
|
||||
<span slot="secondary">
|
||||
${stateDisplay} ·
|
||||
<ha-relative-time
|
||||
.datetime=${alert.stateObj.last_changed}
|
||||
></ha-relative-time>
|
||||
</span>
|
||||
</ha-tile-info>
|
||||
</ha-tile-container>
|
||||
</ha-card>
|
||||
`;
|
||||
}
|
||||
|
||||
static styles = [
|
||||
tileCardStyle,
|
||||
pulseOpacityAnimation,
|
||||
css`
|
||||
:host {
|
||||
display: block;
|
||||
--ha-security-alert-pulse-duration: 1s;
|
||||
--ha-security-alert-pulse-opacity: 0.3;
|
||||
--ha-security-alert-static-opacity: 0;
|
||||
}
|
||||
.alerts {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--ha-space-2);
|
||||
}
|
||||
ha-card {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
height: 100%;
|
||||
}
|
||||
ha-card:not(.no-color) {
|
||||
--tile-color: var(--ha-security-alert-color);
|
||||
}
|
||||
ha-card.no-color {
|
||||
--tile-color: var(--secondary-text-color);
|
||||
}
|
||||
ha-card::before {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border-radius: var(--ha-card-border-radius, var(--ha-border-radius-lg));
|
||||
background-color: var(--ha-security-alert-color);
|
||||
content: "";
|
||||
opacity: var(--ha-security-alert-static-opacity);
|
||||
pointer-events: none;
|
||||
}
|
||||
ha-card.pulse::before {
|
||||
--ha-pulse-opacity: var(--ha-security-alert-pulse-opacity);
|
||||
animation: pulse-opacity var(--ha-security-alert-pulse-duration)
|
||||
ease-in-out infinite alternate;
|
||||
}
|
||||
ha-card:not(.pulse)::before {
|
||||
animation: none;
|
||||
}
|
||||
ha-tile-container {
|
||||
position: relative;
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
ha-card::before {
|
||||
animation: none;
|
||||
opacity: var(--ha-security-alert-pulse-opacity);
|
||||
}
|
||||
}
|
||||
`,
|
||||
];
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"hui-security-alerts-list": HuiSecurityAlertsList;
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import type { EntityNameItem } from "../../../common/entity/compute_entity_name_
|
||||
import type { HaDurationData } from "../../../components/ha-duration-input";
|
||||
import type { MapCardMarkerLabelMode } from "../../../components/map/ha-map";
|
||||
import type { EnergySourceByType } from "../../../data/energy";
|
||||
import type { SecurityAlertEntityConfig } from "../../../data/frontend";
|
||||
import type { ActionConfig } from "../../../data/lovelace/config/action";
|
||||
import type { LovelaceCardConfig } from "../../../data/lovelace/config/card";
|
||||
import type {
|
||||
@@ -696,6 +697,10 @@ export interface ShortcutCardConfig extends LovelaceCardConfig {
|
||||
double_tap_action?: ActionConfig;
|
||||
}
|
||||
|
||||
export interface SecurityAlertsCardConfig extends LovelaceCardConfig {
|
||||
alert_entities: SecurityAlertEntityConfig[];
|
||||
}
|
||||
|
||||
export interface ToggleGroupCardConfig extends LovelaceCardConfig {
|
||||
title: string;
|
||||
entities: string[];
|
||||
|
||||
@@ -80,6 +80,8 @@ const LAZY_LOAD_TYPES = {
|
||||
shortcut: () => import("../cards/hui-shortcut-card"),
|
||||
"discovered-devices": () => import("../cards/hui-discovered-devices-card"),
|
||||
repairs: () => import("../cards/hui-repairs-card"),
|
||||
"security-alerts": () =>
|
||||
import("../cards/security-alerts/hui-security-alerts-card"),
|
||||
updates: () => import("../cards/hui-updates-card"),
|
||||
gauge: () => import("../cards/hui-gauge-card"),
|
||||
"history-graph": () => import("../cards/hui-history-graph-card"),
|
||||
|
||||
@@ -0,0 +1,216 @@
|
||||
import { mdiClose, mdiDragHorizontalVariant, mdiPencil } from "@mdi/js";
|
||||
import type { HassEntity } from "home-assistant-js-websocket";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property } from "lit/decorators";
|
||||
import { repeat } from "lit/directives/repeat";
|
||||
import { computeEntityPickerDisplay } from "../../../common/entity/compute_entity_name_display";
|
||||
import { fireEvent, type HASSDomEvent } from "../../../common/dom/fire_event";
|
||||
import type { HaEntityPicker } from "../../../components/entity/ha-entity-picker";
|
||||
import type { SecurityAlertEntityConfig } from "../../../data/frontend";
|
||||
import "../../../components/entity/ha-entity-picker";
|
||||
import "../../../components/entity/state-badge";
|
||||
import "../../../components/ha-icon-button";
|
||||
import "../../../components/ha-settings-row";
|
||||
import "../../../components/ha-sortable";
|
||||
import "../../../components/ha-svg-icon";
|
||||
import { computeDefaultSecurityAlertVisibility } from "../strategies/security-alerts";
|
||||
import { isSecurityPanelEntity } from "../strategies/security-view-strategy";
|
||||
import type { HomeAssistant, ValueChangedEvent } from "../../../types";
|
||||
|
||||
declare global {
|
||||
interface HASSDomEvents {
|
||||
"edit-security-alert-entity": { index: number };
|
||||
}
|
||||
}
|
||||
|
||||
@customElement("security-alerts-editor")
|
||||
export class SecurityAlertsEditor extends LitElement {
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
|
||||
@property({ attribute: false })
|
||||
public alertEntities: SecurityAlertEntityConfig[] = [];
|
||||
|
||||
protected render() {
|
||||
return html`
|
||||
<ha-sortable handle-selector=".handle" @item-moved=${this._moved}>
|
||||
<div class="alert-list">
|
||||
${repeat(
|
||||
this.alertEntities,
|
||||
(alertEntity) => alertEntity.entity,
|
||||
(alertEntity, index) => this._renderAlertEntity(alertEntity, index)
|
||||
)}
|
||||
</div>
|
||||
</ha-sortable>
|
||||
<ha-entity-picker
|
||||
add-button
|
||||
.addButtonLabel=${this.hass.localize(
|
||||
"ui.panel.security.editor.add_alert_entity"
|
||||
)}
|
||||
.excludeEntities=${this.alertEntities.map(({ entity }) => entity)}
|
||||
.entityFilter=${this._alertEntityFilter}
|
||||
@value-changed=${this._add}
|
||||
></ha-entity-picker>
|
||||
`;
|
||||
}
|
||||
|
||||
private _renderAlertEntity(
|
||||
alertEntity: SecurityAlertEntityConfig,
|
||||
index: number
|
||||
) {
|
||||
const stateObj = this.hass.states[alertEntity.entity];
|
||||
const { primary, secondary } = stateObj
|
||||
? computeEntityPickerDisplay(this.hass, stateObj)
|
||||
: { primary: alertEntity.entity, secondary: undefined };
|
||||
|
||||
return html`
|
||||
<div class="alert-row">
|
||||
<div class="handle">
|
||||
<ha-svg-icon .path=${mdiDragHorizontalVariant}></ha-svg-icon>
|
||||
</div>
|
||||
<ha-settings-row slim>
|
||||
<state-badge slot="prefix" .stateObj=${stateObj}></state-badge>
|
||||
<span slot="heading">${primary}</span>
|
||||
${
|
||||
secondary
|
||||
? html`<span slot="description">${secondary}</span>`
|
||||
: nothing
|
||||
}
|
||||
<ha-icon-button
|
||||
.path=${mdiPencil}
|
||||
.label=${this.hass.localize("ui.common.edit")}
|
||||
data-index=${index}
|
||||
@click=${this._editClicked}
|
||||
></ha-icon-button>
|
||||
<ha-icon-button
|
||||
.path=${mdiClose}
|
||||
.label=${this.hass.localize("ui.common.delete")}
|
||||
data-index=${index}
|
||||
@click=${this._removeClicked}
|
||||
></ha-icon-button>
|
||||
</ha-settings-row>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private _changed(next: SecurityAlertEntityConfig[]): void {
|
||||
fireEvent(this, "value-changed", { value: next });
|
||||
}
|
||||
|
||||
private _alertEntityFilter = (entity: HassEntity) =>
|
||||
isSecurityPanelEntity(this.hass, entity);
|
||||
|
||||
private _getIndex(ev: Event): number | undefined {
|
||||
const index = Number((ev.currentTarget as HTMLElement).dataset.index);
|
||||
return Number.isInteger(index) ? index : undefined;
|
||||
}
|
||||
|
||||
private _editClicked(ev: Event): void {
|
||||
ev.stopPropagation();
|
||||
const index = this._getIndex(ev);
|
||||
if (index !== undefined) {
|
||||
fireEvent(this, "edit-security-alert-entity", { index });
|
||||
}
|
||||
}
|
||||
|
||||
private _removeClicked(ev: Event): void {
|
||||
ev.stopPropagation();
|
||||
const index = this._getIndex(ev);
|
||||
if (index !== undefined) {
|
||||
const next = [...this.alertEntities];
|
||||
next.splice(index, 1);
|
||||
this._changed(next);
|
||||
}
|
||||
}
|
||||
|
||||
private _add(ev: ValueChangedEvent<string | undefined>): void {
|
||||
ev.stopPropagation();
|
||||
const entity = ev.detail.value;
|
||||
if (!entity) return;
|
||||
|
||||
(ev.currentTarget as HaEntityPicker).value = "";
|
||||
|
||||
if (
|
||||
this.alertEntities.some((alertEntity) => alertEntity.entity === entity)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
this._changed([
|
||||
...this.alertEntities,
|
||||
{
|
||||
entity,
|
||||
visibility: computeDefaultSecurityAlertVisibility(entity),
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
||||
private _moved(ev: HASSDomEvent<HASSDomEvents["item-moved"]>): void {
|
||||
ev.stopPropagation();
|
||||
const { oldIndex, newIndex } = ev.detail;
|
||||
const next = [...this.alertEntities];
|
||||
const [moved] = next.splice(oldIndex, 1);
|
||||
next.splice(newIndex, 0, moved);
|
||||
this._changed(next);
|
||||
}
|
||||
|
||||
static styles = css`
|
||||
:host {
|
||||
display: block;
|
||||
}
|
||||
.alert-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--ha-space-2);
|
||||
}
|
||||
.alert-row {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: var(--ha-space-2);
|
||||
}
|
||||
.handle {
|
||||
cursor: grab;
|
||||
color: var(--secondary-text-color);
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
min-height: 48px;
|
||||
}
|
||||
ha-settings-row {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
padding: 0;
|
||||
gap: var(--ha-space-3);
|
||||
min-height: 48px;
|
||||
--settings-row-prefix-display: contents;
|
||||
--settings-row-content-display: contents;
|
||||
--settings-row-body-padding-top: var(--ha-space-1);
|
||||
--settings-row-body-padding-bottom: var(--ha-space-1);
|
||||
}
|
||||
state-badge {
|
||||
flex-shrink: 0;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
--state-icon-color: var(--secondary-text-color);
|
||||
}
|
||||
[slot="heading"],
|
||||
[slot="description"] {
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
ha-entity-picker {
|
||||
display: block;
|
||||
padding-top: var(--ha-space-3);
|
||||
}
|
||||
ha-icon-button {
|
||||
--ha-icon-button-size: 40px;
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"security-alerts-editor": SecurityAlertsEditor;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,521 @@
|
||||
import { ContextProvider } from "@lit/context";
|
||||
import type { HassEntity } from "home-assistant-js-websocket";
|
||||
import { css, html, LitElement, nothing, type PropertyValues } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import { classMap } from "lit/directives/class-map";
|
||||
import memoizeOne from "memoize-one";
|
||||
import { fireEvent, type HASSDomEvent } from "../../../common/dom/fire_event";
|
||||
import "../../../components/ha-button";
|
||||
import "../../../components/ha-dialog";
|
||||
import "../../../components/ha-dialog-footer";
|
||||
import "../../../components/ha-expansion-panel";
|
||||
import "../../../components/ha-form/ha-form";
|
||||
import type { HaFormSchema } from "../../../components/ha-form/types";
|
||||
import "../../../components/ha-icon";
|
||||
import "../../../components/ha-icon-button";
|
||||
import "../../../components/ha-icon-button-prev";
|
||||
import type {
|
||||
SecurityAlertEntityConfig,
|
||||
SecurityFrontendSystemData,
|
||||
} from "../../../data/frontend";
|
||||
import type { HassDialog } from "../../../dialogs/make-dialog-manager";
|
||||
import { DirtyStateProviderMixin } from "../../../mixins/dirty-state-provider-mixin";
|
||||
import { haStyleDialog } from "../../../resources/styles";
|
||||
import type { HomeAssistant, ValueChangedEvent } from "../../../types";
|
||||
import "../../lovelace/cards/hui-card";
|
||||
import type { SecurityAlertsCardConfig } from "../../lovelace/cards/types";
|
||||
import "../../lovelace/editor/conditions/ha-card-conditions-editor";
|
||||
import "../../lovelace/editor/conditions/ha-visibility-status";
|
||||
import type { Condition } from "../../lovelace/common/validate-condition";
|
||||
import { conditionsEntityContext } from "../../lovelace/editor/conditions/context";
|
||||
import "../components/security-alerts-editor";
|
||||
import {
|
||||
computeSecurityAlertEntityDefaultColor,
|
||||
computeDefaultSecurityAlertVisibility,
|
||||
} from "../strategies/security-alerts";
|
||||
import { isSecurityPanelEntity } from "../strategies/security-view-strategy";
|
||||
import type { EditSecurityDialogParams } from "./show-dialog-edit-security";
|
||||
import { withViewTransition } from "../../../common/util/view-transition";
|
||||
|
||||
interface AlertEntityEditorData {
|
||||
entity: string;
|
||||
color: string;
|
||||
pulse: boolean;
|
||||
}
|
||||
|
||||
@customElement("dialog-edit-security")
|
||||
export class DialogEditSecurity
|
||||
extends DirtyStateProviderMixin<SecurityFrontendSystemData>()(LitElement)
|
||||
implements HassDialog<EditSecurityDialogParams>
|
||||
{
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
|
||||
@state() private _params?: EditSecurityDialogParams;
|
||||
|
||||
@state() private _state?: SecurityFrontendSystemData;
|
||||
|
||||
@state() private _open = false;
|
||||
|
||||
@state() private _submitting = false;
|
||||
|
||||
@state() private _editingAlertEntityIndex?: number;
|
||||
|
||||
private _conditionContextProvider = new ContextProvider(this, {
|
||||
context: conditionsEntityContext,
|
||||
initialValue: undefined,
|
||||
});
|
||||
|
||||
protected willUpdate(changedProperties: PropertyValues): void {
|
||||
super.willUpdate(changedProperties);
|
||||
if (
|
||||
changedProperties.has("_editingAlertEntityIndex") ||
|
||||
changedProperties.has("_state")
|
||||
) {
|
||||
const alertEntity = this._editingAlertEntity;
|
||||
this._conditionContextProvider.setValue(
|
||||
alertEntity
|
||||
? { mode: "current", entityId: alertEntity.entity }
|
||||
: undefined
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private get _editingAlertEntity(): SecurityAlertEntityConfig | undefined {
|
||||
return this._editingAlertEntityIndex === undefined
|
||||
? undefined
|
||||
: this._state?.alert_entities?.[this._editingAlertEntityIndex];
|
||||
}
|
||||
|
||||
public showDialog(params: EditSecurityDialogParams): void {
|
||||
this._params = params;
|
||||
this._state = {
|
||||
...params.config,
|
||||
alert_entities: params.config.alert_entities
|
||||
? [...params.config.alert_entities]
|
||||
: [],
|
||||
};
|
||||
this._initDirtyTracking({ type: "shallow" }, this._state);
|
||||
this._open = true;
|
||||
}
|
||||
|
||||
public closeDialog(): boolean {
|
||||
this._open = false;
|
||||
return true;
|
||||
}
|
||||
|
||||
private _dialogClosed(): void {
|
||||
this._params = undefined;
|
||||
this._state = undefined;
|
||||
this._submitting = false;
|
||||
this._editingAlertEntityIndex = undefined;
|
||||
fireEvent(this, "dialog-closed", { dialog: this.localName });
|
||||
}
|
||||
|
||||
protected render() {
|
||||
if (!this._params || !this._state) {
|
||||
return nothing;
|
||||
}
|
||||
|
||||
return html`
|
||||
<ha-dialog
|
||||
class=${classMap({ subview: Boolean(this._editingAlertEntity) })}
|
||||
.open=${this._open}
|
||||
.width=${this._editingAlertEntity ? "large" : "medium"}
|
||||
.headerTitle=${this.hass.localize("ui.panel.security.editor.title")}
|
||||
.headerSubtitle=${
|
||||
this._editingAlertEntity
|
||||
? undefined
|
||||
: this.hass.localize("ui.panel.security.editor.description")
|
||||
}
|
||||
.preventScrimClose=${this.isDirtyState}
|
||||
@closed=${this._dialogClosed}
|
||||
>
|
||||
${
|
||||
this._editingAlertEntity
|
||||
? html` ${this._renderAlertEntityEditor(this._editingAlertEntity)} `
|
||||
: this._renderMainEditor()
|
||||
}
|
||||
|
||||
<ha-dialog-footer slot="footer">
|
||||
<ha-button
|
||||
appearance="plain"
|
||||
slot="secondaryAction"
|
||||
@click=${this.closeDialog}
|
||||
.disabled=${this._submitting}
|
||||
>
|
||||
${this.hass.localize("ui.common.cancel")}
|
||||
</ha-button>
|
||||
<ha-button
|
||||
slot="primaryAction"
|
||||
@click=${this._save}
|
||||
.disabled=${this._submitting || !this.isDirtyState}
|
||||
>
|
||||
${this.hass.localize("ui.common.save")}
|
||||
</ha-button>
|
||||
</ha-dialog-footer>
|
||||
</ha-dialog>
|
||||
`;
|
||||
}
|
||||
|
||||
private _renderMainEditor() {
|
||||
return html`
|
||||
<ha-expansion-panel
|
||||
outlined
|
||||
expanded
|
||||
no-collapse
|
||||
.header=${this.hass.localize(
|
||||
"ui.panel.security.editor.active_alert_entities"
|
||||
)}
|
||||
.secondary=${this.hass.localize(
|
||||
"ui.panel.security.editor.active_alert_entities_description"
|
||||
)}
|
||||
>
|
||||
<ha-icon slot="leading-icon" icon="mdi:shield-alert"></ha-icon>
|
||||
<div class="expansion-content">
|
||||
<security-alerts-editor
|
||||
.hass=${this.hass}
|
||||
.alertEntities=${this._state?.alert_entities ?? []}
|
||||
@value-changed=${this._alertEntitiesChanged}
|
||||
@edit-security-alert-entity=${this._editAlertEntity}
|
||||
></security-alerts-editor>
|
||||
</div>
|
||||
</ha-expansion-panel>
|
||||
`;
|
||||
}
|
||||
|
||||
private _renderAlertEntityEditor(alertEntity: SecurityAlertEntityConfig) {
|
||||
return html`
|
||||
<div class="entity-editor">
|
||||
<div class="subpage-header">
|
||||
<ha-icon-button-prev
|
||||
.label=${this.hass.localize("ui.common.back")}
|
||||
@click=${this._closeAlertEntityEditor}
|
||||
></ha-icon-button-prev>
|
||||
<span class="subpage-title">
|
||||
${this.hass.localize("ui.panel.security.editor.edit_alert_entity")}
|
||||
</span>
|
||||
</div>
|
||||
<div class="entity-editor-content">
|
||||
<div class="element-editor">
|
||||
<p class="entity-editor-description">
|
||||
${this.hass.localize(
|
||||
"ui.panel.security.editor.alert_entity_description"
|
||||
)}
|
||||
</p>
|
||||
<ha-form
|
||||
.hass=${this.hass}
|
||||
.data=${{
|
||||
entity: alertEntity.entity,
|
||||
color: alertEntity.color,
|
||||
pulse: alertEntity.pulse ?? true,
|
||||
}}
|
||||
.schema=${this._alertEntityFormSchema()}
|
||||
.context=${{ entityFilter: this._alertEntityFilter }}
|
||||
.computeLabel=${this._computeAlertEntityEditorLabel}
|
||||
@value-changed=${this._alertEntityFormChanged}
|
||||
></ha-form>
|
||||
<div class="conditions">
|
||||
<p class="field-label">
|
||||
${this.hass.localize(
|
||||
"ui.panel.security.editor.visibility_conditions"
|
||||
)}
|
||||
</p>
|
||||
<ha-visibility-status
|
||||
.hass=${this.hass}
|
||||
.conditions=${
|
||||
alertEntity.visibility ??
|
||||
computeDefaultSecurityAlertVisibility(alertEntity.entity)
|
||||
}
|
||||
></ha-visibility-status>
|
||||
<ha-card-conditions-editor
|
||||
.hass=${this.hass}
|
||||
.conditions=${
|
||||
alertEntity.visibility ??
|
||||
computeDefaultSecurityAlertVisibility(alertEntity.entity)
|
||||
}
|
||||
@value-changed=${this._alertEntityConditionsChanged}
|
||||
></ha-card-conditions-editor>
|
||||
</div>
|
||||
</div>
|
||||
<div class="element-preview">
|
||||
<hui-card
|
||||
.hass=${this.hass}
|
||||
.config=${this._previewCardConfig(alertEntity)}
|
||||
preview
|
||||
></hui-card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private _previewCardConfig = memoizeOne(
|
||||
(alertEntity: SecurityAlertEntityConfig): SecurityAlertsCardConfig => ({
|
||||
type: "security-alerts",
|
||||
alert_entities: [alertEntity],
|
||||
})
|
||||
);
|
||||
|
||||
private _alertEntitiesChanged(
|
||||
ev: ValueChangedEvent<SecurityFrontendSystemData["alert_entities"]>
|
||||
): void {
|
||||
this._state = {
|
||||
...this._state,
|
||||
alert_entities: ev.detail.value,
|
||||
};
|
||||
this._updateDirtyState(this._state);
|
||||
}
|
||||
|
||||
private _editAlertEntity(
|
||||
ev: HASSDomEvent<HASSDomEvents["edit-security-alert-entity"]>
|
||||
): void {
|
||||
ev.stopPropagation();
|
||||
withViewTransition(() => {
|
||||
this._editingAlertEntityIndex = ev.detail.index;
|
||||
});
|
||||
}
|
||||
|
||||
private _closeAlertEntityEditor(): void {
|
||||
this._editingAlertEntityIndex = undefined;
|
||||
}
|
||||
|
||||
private _alertEntityFilter = (entity: HassEntity) =>
|
||||
isSecurityPanelEntity(this.hass, entity);
|
||||
|
||||
private _alertEntityFormSchema(): HaFormSchema[] {
|
||||
return [
|
||||
{
|
||||
name: "entity",
|
||||
required: true,
|
||||
selector: {
|
||||
entity: {
|
||||
exclude_entities: (this._state?.alert_entities ?? [])
|
||||
.filter((_, index) => index !== this._editingAlertEntityIndex)
|
||||
.map(({ entity }) => entity),
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "grid",
|
||||
name: "highlight",
|
||||
flatten: true,
|
||||
column_min_width: "0",
|
||||
schema: [
|
||||
{
|
||||
name: "color",
|
||||
selector: {
|
||||
ui_color: {
|
||||
include_none: true,
|
||||
default_color: computeSecurityAlertEntityDefaultColor(
|
||||
this.hass.states[this._editingAlertEntity?.entity ?? ""]
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "pulse",
|
||||
selector: { boolean: {} },
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
private _computeAlertEntityEditorLabel = (schema: HaFormSchema): string => {
|
||||
switch (schema.name) {
|
||||
case "entity":
|
||||
return this.hass.localize("ui.panel.security.editor.entity");
|
||||
case "color":
|
||||
return this.hass.localize("ui.panel.security.editor.alert_color.label");
|
||||
case "pulse":
|
||||
return this.hass.localize("ui.panel.security.editor.pulse");
|
||||
default:
|
||||
return schema.name;
|
||||
}
|
||||
};
|
||||
|
||||
private _updateEditingAlertEntity(
|
||||
updates: Partial<SecurityAlertEntityConfig>
|
||||
): void {
|
||||
if (this._editingAlertEntityIndex === undefined || !this._state) {
|
||||
return;
|
||||
}
|
||||
const alertEntities = [...(this._state.alert_entities ?? [])];
|
||||
const alertEntity = alertEntities[this._editingAlertEntityIndex];
|
||||
if (!alertEntity) {
|
||||
return;
|
||||
}
|
||||
alertEntities[this._editingAlertEntityIndex] = {
|
||||
...alertEntity,
|
||||
...updates,
|
||||
};
|
||||
this._state = {
|
||||
...this._state,
|
||||
alert_entities: alertEntities,
|
||||
};
|
||||
this._updateDirtyState(this._state);
|
||||
}
|
||||
|
||||
private _alertEntityFormChanged(
|
||||
ev: ValueChangedEvent<AlertEntityEditorData>
|
||||
): void {
|
||||
const updates: Partial<SecurityAlertEntityConfig> = {
|
||||
entity: ev.detail.value.entity,
|
||||
color: ev.detail.value.color,
|
||||
pulse: ev.detail.value.pulse,
|
||||
};
|
||||
if (this._editingAlertEntity?.entity !== ev.detail.value.entity) {
|
||||
updates.visibility = computeDefaultSecurityAlertVisibility(
|
||||
ev.detail.value.entity
|
||||
);
|
||||
}
|
||||
this._updateEditingAlertEntity(updates);
|
||||
}
|
||||
|
||||
private _alertEntityConditionsChanged(
|
||||
ev: ValueChangedEvent<Condition[]>
|
||||
): void {
|
||||
this._updateEditingAlertEntity({ visibility: ev.detail.value });
|
||||
}
|
||||
|
||||
private async _save(): Promise<void> {
|
||||
if (!this._params || !this._state) return;
|
||||
|
||||
this._submitting = true;
|
||||
|
||||
try {
|
||||
await this._params.saveConfig({
|
||||
...this._params.config,
|
||||
alert_entities: this._state.alert_entities?.length
|
||||
? this._state.alert_entities
|
||||
: undefined,
|
||||
});
|
||||
this._markDirtyStateClean();
|
||||
this.closeDialog();
|
||||
} catch {
|
||||
return;
|
||||
} finally {
|
||||
this._submitting = false;
|
||||
}
|
||||
}
|
||||
|
||||
static styles = [
|
||||
haStyleDialog,
|
||||
css`
|
||||
ha-dialog {
|
||||
--dialog-content-padding: var(--ha-space-6);
|
||||
}
|
||||
|
||||
ha-dialog.subview {
|
||||
--dialog-content-padding: var(--ha-space-2);
|
||||
}
|
||||
|
||||
ha-expansion-panel {
|
||||
display: block;
|
||||
--expansion-panel-content-padding: 0;
|
||||
border-radius: var(--ha-border-radius-md);
|
||||
--ha-card-border-radius: var(--ha-border-radius-md);
|
||||
}
|
||||
|
||||
.expansion-content {
|
||||
padding: var(--ha-space-3);
|
||||
}
|
||||
|
||||
.entity-editor {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.subpage-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--ha-space-2);
|
||||
padding: 0 var(--ha-space-1);
|
||||
}
|
||||
|
||||
.subpage-title {
|
||||
color: var(--primary-text-color);
|
||||
font-size: var(--ha-font-size-l);
|
||||
font-weight: var(--ha-font-weight-medium);
|
||||
}
|
||||
|
||||
.entity-editor-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--ha-space-4);
|
||||
padding: var(--ha-space-4) 0;
|
||||
}
|
||||
|
||||
.element-editor {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--ha-space-4);
|
||||
padding: var(--ha-space-4);
|
||||
}
|
||||
|
||||
.element-preview {
|
||||
position: relative;
|
||||
background: var(--primary-background-color);
|
||||
padding: var(--ha-space-4);
|
||||
border-radius: var(--ha-border-radius-sm);
|
||||
}
|
||||
|
||||
.element-preview hui-card {
|
||||
display: block;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
@media (min-width: 1000px) {
|
||||
.entity-editor-content {
|
||||
flex-direction: row;
|
||||
max-height: calc(100vh - 209px);
|
||||
}
|
||||
|
||||
.entity-editor-content > .element-editor,
|
||||
.entity-editor-content > .element-preview {
|
||||
flex-basis: 0;
|
||||
flex-grow: 1;
|
||||
flex-shrink: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.entity-editor-content > .element-preview {
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.entity-editor-content > .element-editor {
|
||||
padding-inline-end: var(--ha-space-4);
|
||||
}
|
||||
}
|
||||
|
||||
.entity-editor-description {
|
||||
margin: 0;
|
||||
font-size: var(--ha-font-size-m);
|
||||
line-height: var(--ha-line-height-normal);
|
||||
}
|
||||
|
||||
ha-form-grid {
|
||||
direction: ltr;
|
||||
--form-grid-column-count: 2;
|
||||
}
|
||||
|
||||
.field-label {
|
||||
margin: 0 0 var(--ha-space-2) 0;
|
||||
font-size: 14px;
|
||||
color: var(--primary-text-color);
|
||||
}
|
||||
|
||||
ha-visibility-status {
|
||||
display: block;
|
||||
margin-bottom: var(--ha-space-3);
|
||||
}
|
||||
`,
|
||||
];
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"dialog-edit-security": DialogEditSecurity;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { fireEvent } from "../../../common/dom/fire_event";
|
||||
import type { SecurityFrontendSystemData } from "../../../data/frontend";
|
||||
|
||||
export interface EditSecurityDialogParams {
|
||||
config: SecurityFrontendSystemData;
|
||||
saveConfig: (config: SecurityFrontendSystemData) => Promise<void>;
|
||||
}
|
||||
|
||||
export const loadEditSecurityDialog = () => import("./dialog-edit-security");
|
||||
|
||||
export const showEditSecurityDialog = (
|
||||
element: HTMLElement,
|
||||
params: EditSecurityDialogParams
|
||||
): void => {
|
||||
fireEvent(element, "show-dialog", {
|
||||
dialogTag: "dialog-edit-security",
|
||||
dialogImport: loadEditSecurityDialog,
|
||||
dialogParams: params,
|
||||
});
|
||||
};
|
||||
@@ -1,14 +1,23 @@
|
||||
import { mdiPencil } from "@mdi/js";
|
||||
import type { CSSResultGroup, PropertyValues } from "lit";
|
||||
import { LitElement, css, html, nothing } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import { debounce } from "../../common/util/debounce";
|
||||
import { deepEqual } from "../../common/util/deep-equal";
|
||||
import "../../components/ha-icon-button";
|
||||
import "../../components/ha-top-app-bar-fixed";
|
||||
import {
|
||||
fetchFrontendSystemData,
|
||||
saveFrontendSystemData,
|
||||
type SecurityFrontendSystemData,
|
||||
} from "../../data/frontend";
|
||||
import type { LovelaceStrategyViewConfig } from "../../data/lovelace/config/view";
|
||||
import { haStyle } from "../../resources/styles";
|
||||
import type { HomeAssistant } from "../../types";
|
||||
import { showToast } from "../../util/toast";
|
||||
import { generateLovelaceViewStrategy } from "../lovelace/strategies/get-strategy";
|
||||
import type { Lovelace } from "../lovelace/types";
|
||||
import { showEditSecurityDialog } from "./dialogs/show-dialog-edit-security";
|
||||
import "../lovelace/views/hui-view";
|
||||
import "../lovelace/views/hui-view-container";
|
||||
import "../lovelace/views/hui-view-background";
|
||||
@@ -29,8 +38,12 @@ class PanelSecurity extends LitElement {
|
||||
|
||||
@state() private _lovelace?: Lovelace;
|
||||
|
||||
@state() private _config: SecurityFrontendSystemData = {};
|
||||
|
||||
@state() private _searchParms = new URLSearchParams(window.location.search);
|
||||
|
||||
private _loadConfigPromise?: Promise<void>;
|
||||
|
||||
public willUpdate(changedProps: PropertyValues<this>) {
|
||||
super.willUpdate(changedProps);
|
||||
// Initial setup
|
||||
@@ -50,7 +63,7 @@ class PanelSecurity extends LitElement {
|
||||
}
|
||||
|
||||
if (oldHass && this.hass) {
|
||||
// If the entity registry changed, ask the user if they want to refresh the config
|
||||
// Refresh the generated view when registries or panels change.
|
||||
if (
|
||||
oldHass.entities !== this.hass.entities ||
|
||||
oldHass.devices !== this.hass.devices ||
|
||||
@@ -74,10 +87,25 @@ class PanelSecurity extends LitElement {
|
||||
}
|
||||
|
||||
private async _setup() {
|
||||
await this.hass.loadFragmentTranslation("lovelace");
|
||||
this._loadConfigPromise = this._loadConfig();
|
||||
await this._loadConfigPromise;
|
||||
this._setLovelace();
|
||||
}
|
||||
|
||||
private async _loadConfig() {
|
||||
try {
|
||||
const [, data] = await Promise.all([
|
||||
this.hass.loadFragmentTranslation("lovelace"),
|
||||
fetchFrontendSystemData(this.hass.connection, "security"),
|
||||
]);
|
||||
this._config = data || {};
|
||||
} catch (err) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error("Failed to load security configuration:", err);
|
||||
this._config = {};
|
||||
}
|
||||
}
|
||||
|
||||
private _debounceRegistriesChanged = debounce(
|
||||
() => this._registriesChanged(),
|
||||
200
|
||||
@@ -94,6 +122,12 @@ class PanelSecurity extends LitElement {
|
||||
.backButton=${this._searchParms.has("historyBack")}
|
||||
>
|
||||
<div slot="title">${this.hass.localize("panel.security")}</div>
|
||||
<ha-icon-button
|
||||
slot="actionItems"
|
||||
.path=${mdiPencil}
|
||||
.label=${this.hass.localize("ui.panel.security.editor.title")}
|
||||
@click=${this._editSecurity}
|
||||
></ha-icon-button>
|
||||
${
|
||||
this._lovelace
|
||||
? html`
|
||||
@@ -115,8 +149,17 @@ class PanelSecurity extends LitElement {
|
||||
}
|
||||
|
||||
private async _setLovelace() {
|
||||
if (this._loadConfigPromise) {
|
||||
await this._loadConfigPromise;
|
||||
}
|
||||
|
||||
const viewConfig = await generateLovelaceViewStrategy(
|
||||
SECURITY_LOVELACE_VIEW_CONFIG,
|
||||
{
|
||||
strategy: {
|
||||
...SECURITY_LOVELACE_VIEW_CONFIG.strategy,
|
||||
alert_entities: this._config.alert_entities,
|
||||
},
|
||||
},
|
||||
this.hass
|
||||
);
|
||||
|
||||
@@ -142,6 +185,35 @@ class PanelSecurity extends LitElement {
|
||||
};
|
||||
}
|
||||
|
||||
private _editSecurity = () => {
|
||||
showEditSecurityDialog(this, {
|
||||
config: this._config,
|
||||
saveConfig: async (config) => {
|
||||
await this._saveConfig(config);
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
private async _saveConfig(config: SecurityFrontendSystemData): Promise<void> {
|
||||
try {
|
||||
await saveFrontendSystemData(this.hass.connection, "security", config);
|
||||
this._config = config || {};
|
||||
} catch (err) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error("Failed to save security configuration:", err);
|
||||
showToast(this, {
|
||||
message: this.hass.localize("ui.panel.security.editor.save_failed"),
|
||||
duration: 0,
|
||||
dismissable: true,
|
||||
});
|
||||
throw err;
|
||||
}
|
||||
showToast(this, {
|
||||
message: this.hass.localize("ui.common.successfully_saved"),
|
||||
});
|
||||
this._setLovelace();
|
||||
}
|
||||
|
||||
static get styles(): CSSResultGroup {
|
||||
return [
|
||||
haStyle,
|
||||
|
||||
@@ -0,0 +1,224 @@
|
||||
import type { HassEntity } from "home-assistant-js-websocket";
|
||||
import { mdiCctvOff, mdiLockOpen, mdiShieldAlert, mdiWater } from "@mdi/js";
|
||||
import { computeDomain } from "../../../common/entity/compute_domain";
|
||||
import { UNAVAILABLE } from "../../../data/entity/entity";
|
||||
import type { SecurityAlertEntityConfig } from "../../../data/frontend";
|
||||
import type { HomeAssistant } from "../../../types";
|
||||
import type { Condition } from "../../lovelace/common/validate-condition";
|
||||
import {
|
||||
checkConditionsMet,
|
||||
extractConditionEntityIds,
|
||||
} from "../../lovelace/common/validate-condition";
|
||||
|
||||
export interface SecurityAlertItem {
|
||||
entityId: string;
|
||||
stateObj: HassEntity;
|
||||
color?: string;
|
||||
pulse: boolean;
|
||||
icon?: string;
|
||||
iconPath?: string;
|
||||
}
|
||||
|
||||
type SecurityAlertIcon = Pick<SecurityAlertItem, "icon" | "iconPath">;
|
||||
|
||||
export type SecurityAlertHass = Pick<
|
||||
HomeAssistant,
|
||||
"config" | "locale" | "states" | "user"
|
||||
>;
|
||||
|
||||
const DANGER_BINARY_SENSOR_DEVICE_CLASSES = [
|
||||
"carbon_monoxide",
|
||||
"gas",
|
||||
"moisture",
|
||||
"safety",
|
||||
"smoke",
|
||||
] as const;
|
||||
|
||||
const WARNING_BINARY_SENSOR_DEVICE_CLASSES = [
|
||||
"door",
|
||||
"garage_door",
|
||||
"lock",
|
||||
"opening",
|
||||
"tamper",
|
||||
"window",
|
||||
] as const;
|
||||
|
||||
const WARNING_COVER_DEVICE_CLASSES = [
|
||||
"door",
|
||||
"garage",
|
||||
"gate",
|
||||
"window",
|
||||
] as const;
|
||||
|
||||
type DangerBinarySensorDeviceClass =
|
||||
(typeof DANGER_BINARY_SENSOR_DEVICE_CLASSES)[number];
|
||||
type WarningBinarySensorDeviceClass =
|
||||
(typeof WARNING_BINARY_SENSOR_DEVICE_CLASSES)[number];
|
||||
type WarningCoverDeviceClass = (typeof WARNING_COVER_DEVICE_CLASSES)[number];
|
||||
|
||||
const DANGER_BINARY_SENSOR_DEVICE_CLASS_SET =
|
||||
new Set<DangerBinarySensorDeviceClass>(DANGER_BINARY_SENSOR_DEVICE_CLASSES);
|
||||
const WARNING_BINARY_SENSOR_DEVICE_CLASS_SET =
|
||||
new Set<WarningBinarySensorDeviceClass>(WARNING_BINARY_SENSOR_DEVICE_CLASSES);
|
||||
const WARNING_COVER_DEVICE_CLASS_SET = new Set<WarningCoverDeviceClass>(
|
||||
WARNING_COVER_DEVICE_CLASSES
|
||||
);
|
||||
|
||||
const isDangerBinarySensorDeviceClass = (
|
||||
deviceClass: string
|
||||
): deviceClass is DangerBinarySensorDeviceClass =>
|
||||
DANGER_BINARY_SENSOR_DEVICE_CLASS_SET.has(
|
||||
deviceClass as DangerBinarySensorDeviceClass
|
||||
);
|
||||
|
||||
const isWarningBinarySensorDeviceClass = (
|
||||
deviceClass: string
|
||||
): deviceClass is WarningBinarySensorDeviceClass =>
|
||||
WARNING_BINARY_SENSOR_DEVICE_CLASS_SET.has(
|
||||
deviceClass as WarningBinarySensorDeviceClass
|
||||
);
|
||||
|
||||
const isWarningCoverDeviceClass = (
|
||||
deviceClass: string
|
||||
): deviceClass is WarningCoverDeviceClass =>
|
||||
WARNING_COVER_DEVICE_CLASS_SET.has(deviceClass as WarningCoverDeviceClass);
|
||||
|
||||
export const isSecurityAlertEntity = (stateObj: HassEntity): boolean => {
|
||||
const domain = computeDomain(stateObj.entity_id);
|
||||
|
||||
switch (domain) {
|
||||
case "alarm_control_panel":
|
||||
case "camera":
|
||||
case "lock":
|
||||
return true;
|
||||
case "binary_sensor": {
|
||||
const deviceClass = stateObj.attributes.device_class;
|
||||
return (
|
||||
typeof deviceClass === "string" &&
|
||||
(isDangerBinarySensorDeviceClass(deviceClass) ||
|
||||
isWarningBinarySensorDeviceClass(deviceClass))
|
||||
);
|
||||
}
|
||||
case "cover": {
|
||||
const deviceClass = stateObj.attributes.device_class;
|
||||
return (
|
||||
typeof deviceClass === "string" &&
|
||||
isWarningCoverDeviceClass(deviceClass)
|
||||
);
|
||||
}
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
export const computeSecurityAlertEntityDefaultColor = (
|
||||
stateObj?: HassEntity
|
||||
): string => {
|
||||
if (!stateObj) {
|
||||
return "red";
|
||||
}
|
||||
|
||||
const domain = computeDomain(stateObj.entity_id);
|
||||
if (domain === "camera") {
|
||||
return "blue";
|
||||
}
|
||||
if (domain === "binary_sensor") {
|
||||
const deviceClass = stateObj.attributes.device_class;
|
||||
return typeof deviceClass === "string" &&
|
||||
isWarningBinarySensorDeviceClass(deviceClass)
|
||||
? "amber"
|
||||
: "red";
|
||||
}
|
||||
if (domain === "cover" || domain === "lock") {
|
||||
return "amber";
|
||||
}
|
||||
return "red";
|
||||
};
|
||||
|
||||
export const computeDefaultSecurityAlertVisibility = (
|
||||
entityId: string
|
||||
): Condition[] => [
|
||||
{
|
||||
condition: "state",
|
||||
entity: entityId,
|
||||
state:
|
||||
computeDomain(entityId) === "alarm_control_panel" ? "triggered" : "on",
|
||||
},
|
||||
];
|
||||
|
||||
export const extractSecurityAlertEntityIds = (
|
||||
alertEntities: SecurityAlertEntityConfig[]
|
||||
): string[] => [
|
||||
...new Set(
|
||||
alertEntities.flatMap((alertEntity) => [
|
||||
alertEntity.entity,
|
||||
...extractConditionEntityIds(
|
||||
alertEntity.visibility ??
|
||||
computeDefaultSecurityAlertVisibility(alertEntity.entity)
|
||||
),
|
||||
])
|
||||
),
|
||||
];
|
||||
|
||||
const computeSecurityAlertIcon = (stateObj: HassEntity): SecurityAlertIcon => {
|
||||
const domain = computeDomain(stateObj.entity_id);
|
||||
if (stateObj.state === UNAVAILABLE && domain === "camera") {
|
||||
return { iconPath: mdiCctvOff };
|
||||
}
|
||||
if (
|
||||
domain === "binary_sensor" &&
|
||||
stateObj.attributes.device_class === "moisture"
|
||||
) {
|
||||
return { iconPath: mdiWater };
|
||||
}
|
||||
if (domain === "lock") {
|
||||
return { iconPath: mdiLockOpen };
|
||||
}
|
||||
if (domain === "alarm_control_panel") {
|
||||
return { iconPath: mdiShieldAlert };
|
||||
}
|
||||
return typeof stateObj.attributes.icon === "string"
|
||||
? { icon: stateObj.attributes.icon }
|
||||
: {};
|
||||
};
|
||||
|
||||
export const computeSecurityAlertItem = (
|
||||
stateObj: HassEntity,
|
||||
alertEntity: SecurityAlertEntityConfig
|
||||
): SecurityAlertItem => ({
|
||||
entityId: stateObj.entity_id,
|
||||
stateObj,
|
||||
color: alertEntity.color ?? computeSecurityAlertEntityDefaultColor(stateObj),
|
||||
pulse: alertEntity.pulse === undefined || alertEntity.pulse === true,
|
||||
...computeSecurityAlertIcon(stateObj),
|
||||
});
|
||||
|
||||
export const computeSecurityAlertItems = (
|
||||
hass: SecurityAlertHass,
|
||||
alertEntities: SecurityAlertEntityConfig[]
|
||||
): SecurityAlertItem[] =>
|
||||
alertEntities
|
||||
.map((alertEntity): SecurityAlertItem | undefined => {
|
||||
const stateObj = hass.states[alertEntity.entity];
|
||||
if (!stateObj) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const visibility =
|
||||
alertEntity.visibility ??
|
||||
computeDefaultSecurityAlertVisibility(alertEntity.entity);
|
||||
|
||||
// checkConditionsMet only reads config, locale, states, and user for
|
||||
// supported condition types. Keep this helper narrowed to avoid
|
||||
// reconstructing a full HomeAssistant object from card contexts.
|
||||
if (
|
||||
!checkConditionsMet(visibility, hass as HomeAssistant, {
|
||||
entity_id: alertEntity.entity,
|
||||
})
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return computeSecurityAlertItem(stateObj, alertEntity);
|
||||
})
|
||||
.filter((item): item is SecurityAlertItem => Boolean(item));
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { HassEntity } from "home-assistant-js-websocket";
|
||||
import { ReactiveElement } from "lit";
|
||||
import { customElement } from "lit/decorators";
|
||||
import { getAreasFloorHierarchy } from "../../../common/areas/areas-floor-hierarchy";
|
||||
@@ -15,12 +16,14 @@ import type {
|
||||
LovelaceSectionRawConfig,
|
||||
} from "../../../data/lovelace/config/section";
|
||||
import type { LovelaceViewConfig } from "../../../data/lovelace/config/view";
|
||||
import type { SecurityAlertEntityConfig } from "../../../data/frontend";
|
||||
import type { HomeAssistant } from "../../../types";
|
||||
import type { LogbookCardConfig } from "../../lovelace/cards/types";
|
||||
import { computeAreaTileCardConfig } from "../../lovelace/strategies/areas/helpers/areas-strategy-helper";
|
||||
|
||||
export interface SecurityViewStrategyConfig {
|
||||
type: "security";
|
||||
alert_entities?: SecurityAlertEntityConfig[];
|
||||
}
|
||||
|
||||
export const securityEntityFilters: EntityFilter[] = [
|
||||
@@ -69,6 +72,14 @@ export const securityEntityFilters: EntityFilter[] = [
|
||||
},
|
||||
];
|
||||
|
||||
export const isSecurityPanelEntity = (
|
||||
hass: HomeAssistant,
|
||||
stateObj: HassEntity
|
||||
): boolean =>
|
||||
securityEntityFilters.some((filter) =>
|
||||
generateEntityFilter(hass, filter)(stateObj.entity_id)
|
||||
);
|
||||
|
||||
const processAreasForSecurity = (
|
||||
areaIds: string[],
|
||||
hass: HomeAssistant,
|
||||
@@ -132,7 +143,7 @@ const processUnassignedEntities = (
|
||||
@customElement("security-view-strategy")
|
||||
export class SecurityViewStrategy extends ReactiveElement {
|
||||
static async generate(
|
||||
_config: SecurityViewStrategyConfig,
|
||||
config: SecurityViewStrategyConfig,
|
||||
hass: HomeAssistant
|
||||
): Promise<LovelaceViewConfig> {
|
||||
const areas = Object.values(hass.areas);
|
||||
@@ -242,37 +253,51 @@ export class SecurityViewStrategy extends ReactiveElement {
|
||||
|
||||
const logbookEntityIds = [...entities, ...personEntities];
|
||||
|
||||
const sidebarSection: LovelaceSectionConfig | undefined =
|
||||
hasLogbook && logbookEntityIds.length > 0
|
||||
? {
|
||||
type: "grid",
|
||||
cards: [
|
||||
{
|
||||
type: "heading",
|
||||
heading: hass.localize(
|
||||
"ui.panel.lovelace.strategy.security.activity"
|
||||
),
|
||||
heading_style: "title",
|
||||
} as LovelaceCardConfig,
|
||||
{
|
||||
type: "logbook",
|
||||
target: {
|
||||
entity_id: logbookEntityIds,
|
||||
},
|
||||
hours_to_show: 24,
|
||||
grid_options: { columns: 12 },
|
||||
} satisfies LogbookCardConfig,
|
||||
],
|
||||
}
|
||||
: undefined;
|
||||
const sidebarSections: LovelaceSectionConfig[] = [];
|
||||
|
||||
if (config.alert_entities?.length) {
|
||||
sidebarSections.push({
|
||||
type: "grid",
|
||||
cards: [
|
||||
{
|
||||
type: "security-alerts",
|
||||
alert_entities: config.alert_entities,
|
||||
grid_options: { columns: 12 },
|
||||
},
|
||||
] satisfies LovelaceCardConfig[],
|
||||
});
|
||||
}
|
||||
|
||||
if (hasLogbook && logbookEntityIds.length > 0) {
|
||||
sidebarSections.push({
|
||||
type: "grid",
|
||||
cards: [
|
||||
{
|
||||
type: "heading",
|
||||
heading: hass.localize(
|
||||
"ui.panel.lovelace.strategy.security.activity"
|
||||
),
|
||||
heading_style: "title",
|
||||
} as LovelaceCardConfig,
|
||||
{
|
||||
type: "logbook",
|
||||
target: {
|
||||
entity_id: logbookEntityIds,
|
||||
},
|
||||
hours_to_show: 24,
|
||||
grid_options: { columns: 12 },
|
||||
} satisfies LogbookCardConfig,
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
type: "sections",
|
||||
max_columns: 3,
|
||||
sections: sections,
|
||||
...(sidebarSection && {
|
||||
...(sidebarSections.length > 0 && {
|
||||
sidebar: {
|
||||
sections: [sidebarSection],
|
||||
sections: sidebarSections,
|
||||
content_label: hass.localize(
|
||||
"ui.panel.lovelace.strategy.security.devices"
|
||||
),
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
import { css } from "lit";
|
||||
|
||||
export const pulseOpacityAnimation = css`
|
||||
@keyframes pulse-opacity {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: var(--ha-pulse-opacity, 0.3);
|
||||
}
|
||||
}
|
||||
`;
|
||||
@@ -244,6 +244,9 @@
|
||||
"count_updates": "{count} {count, plural,\n one {update available}\n other {updates available}\n}",
|
||||
"no_updates": "Up to date"
|
||||
},
|
||||
"security-alerts": {
|
||||
"title": "Active alerts"
|
||||
},
|
||||
"media_player": {
|
||||
"source": "Source",
|
||||
"sound_mode": "Sound mode",
|
||||
@@ -2574,6 +2577,24 @@
|
||||
"learn_more": "Learn more"
|
||||
}
|
||||
},
|
||||
"security": {
|
||||
"editor": {
|
||||
"title": "Edit security and safety page",
|
||||
"description": "Configure your security and safety display preferences.",
|
||||
"active_alert_entities": "Active alert entities",
|
||||
"active_alert_entities_description": "Display any entities that require attention.",
|
||||
"edit_alert_entity": "Edit alert entity",
|
||||
"alert_entity_description": "This entity will be displayed and highlighted with the selected color when all the conditions are fulfilled.",
|
||||
"entity": "Entity",
|
||||
"add_alert_entity": "Add entity",
|
||||
"alert_color": {
|
||||
"label": "Alert color"
|
||||
},
|
||||
"pulse": "Pulsate",
|
||||
"visibility_conditions": "Visibility conditions",
|
||||
"save_failed": "Failed to save security and safety page configuration"
|
||||
}
|
||||
},
|
||||
"my": {
|
||||
"not_supported": "This redirect is not supported by your Home Assistant instance. Check the {link} for the supported redirects and the version they where introduced.",
|
||||
"component_not_loaded": "This redirect is not supported by your Home Assistant instance. You need the integration {integration} to use this redirect.",
|
||||
|
||||
@@ -1,417 +0,0 @@
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { ContextProvider } from "@lit/context";
|
||||
import { html, LitElement } from "lit";
|
||||
import type { PropertyValues } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import type { HassEntity } from "home-assistant-js-websocket";
|
||||
import {
|
||||
entitiesContext,
|
||||
internationalizationContext,
|
||||
statesContext,
|
||||
} from "../../../src/data/context";
|
||||
import {
|
||||
consumeEntityRegistryEntry,
|
||||
consumeEntityState,
|
||||
consumeEntityStates,
|
||||
consumeLocalize,
|
||||
} from "../../../src/common/decorators/consume-context-entry";
|
||||
import type { EntityRegistryDisplayEntry } from "../../../src/data/entity/entity_registry";
|
||||
import type { HomeAssistantInternationalization } from "../../../src/types";
|
||||
import type { LocalizeFunc } from "../../../src/common/translations/localize";
|
||||
|
||||
const makeEntity = (entityId: string, stateValue: string): HassEntity =>
|
||||
({
|
||||
entity_id: entityId,
|
||||
state: stateValue,
|
||||
attributes: {},
|
||||
last_changed: "2024-01-01T00:00:00Z",
|
||||
last_updated: "2024-01-01T00:00:00Z",
|
||||
context: { id: "", parent_id: null, user_id: null },
|
||||
}) as HassEntity;
|
||||
|
||||
const makeRegistryEntry = (
|
||||
entityId: string,
|
||||
name: string
|
||||
): EntityRegistryDisplayEntry => ({
|
||||
entity_id: entityId,
|
||||
name,
|
||||
labels: [],
|
||||
});
|
||||
|
||||
const makeI18n = (localize: LocalizeFunc): HomeAssistantInternationalization =>
|
||||
({ localize }) as HomeAssistantInternationalization;
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"test-consume-entity-state": TestConsumeEntityState;
|
||||
"test-consume-entity-states": TestConsumeEntityStates;
|
||||
"test-consume-registry-entry": TestConsumeRegistryEntry;
|
||||
"test-consume-localize": TestConsumeLocalize;
|
||||
"test-consume-localize-no-state": TestConsumeLocalizeNoState;
|
||||
}
|
||||
}
|
||||
|
||||
// --- Test consumers ----------------------------------------------------------
|
||||
|
||||
// Counts update cycles via `updated()` (not `render()`, which lint forbids
|
||||
// assigning to `this` in). One render() runs per update cycle, so this is the
|
||||
// render count. With the fix, an unrelated context change schedules no update,
|
||||
// so this counter does not advance.
|
||||
class RenderCounter extends LitElement {
|
||||
public renderCount = 0;
|
||||
|
||||
protected updated(changed: PropertyValues) {
|
||||
super.updated(changed);
|
||||
this.renderCount += 1;
|
||||
}
|
||||
}
|
||||
|
||||
@customElement("test-consume-entity-state")
|
||||
class TestConsumeEntityState extends RenderCounter {
|
||||
@property({ attribute: false }) public entityId = "light.kitchen";
|
||||
|
||||
@state()
|
||||
@consumeEntityState({ entityIdPath: ["entityId"] })
|
||||
public stateObj?: HassEntity;
|
||||
|
||||
protected render() {
|
||||
return html`${this.stateObj?.state ?? "none"}`;
|
||||
}
|
||||
}
|
||||
|
||||
@customElement("test-consume-entity-states")
|
||||
class TestConsumeEntityStates extends RenderCounter {
|
||||
@property({ attribute: false }) public entityIds: string[] = [
|
||||
"light.kitchen",
|
||||
];
|
||||
|
||||
@state()
|
||||
@consumeEntityStates({ entityIdPath: ["entityIds"] })
|
||||
public stateObjs?: Record<string, HassEntity>;
|
||||
|
||||
protected render() {
|
||||
return html`${Object.keys(this.stateObjs ?? {}).length}`;
|
||||
}
|
||||
}
|
||||
|
||||
@customElement("test-consume-registry-entry")
|
||||
class TestConsumeRegistryEntry extends RenderCounter {
|
||||
@property({ attribute: false }) public entityId = "light.kitchen";
|
||||
|
||||
@state()
|
||||
@consumeEntityRegistryEntry({ entityIdPath: ["entityId"] })
|
||||
public entry?: EntityRegistryDisplayEntry;
|
||||
|
||||
protected render() {
|
||||
return html`${this.entry?.name ?? "none"}`;
|
||||
}
|
||||
}
|
||||
|
||||
@customElement("test-consume-localize")
|
||||
class TestConsumeLocalize extends RenderCounter {
|
||||
@state()
|
||||
@consumeLocalize()
|
||||
public localize?: LocalizeFunc;
|
||||
|
||||
protected render() {
|
||||
return html`${this.localize ? "set" : "none"}`;
|
||||
}
|
||||
}
|
||||
|
||||
// Mirrors the ~10 call sites that use `@consumeLocalize()` WITHOUT `@state()`.
|
||||
@customElement("test-consume-localize-no-state")
|
||||
class TestConsumeLocalizeNoState extends RenderCounter {
|
||||
@consumeLocalize()
|
||||
public localize?: LocalizeFunc;
|
||||
|
||||
protected render() {
|
||||
return html`${this.localize ? "set" : "none"}`;
|
||||
}
|
||||
}
|
||||
|
||||
// --- Helpers -----------------------------------------------------------------
|
||||
|
||||
let host: HTMLDivElement | undefined;
|
||||
|
||||
afterEach(() => {
|
||||
host?.remove();
|
||||
host = undefined;
|
||||
});
|
||||
|
||||
const mount = async <T extends LitElement>(
|
||||
tag: string,
|
||||
context: any,
|
||||
initialValue: unknown
|
||||
): Promise<{ el: T; provider: ContextProvider<any> }> => {
|
||||
host = document.createElement("div");
|
||||
document.body.appendChild(host);
|
||||
const provider = new ContextProvider(host, {
|
||||
context,
|
||||
initialValue,
|
||||
});
|
||||
const el = document.createElement(tag) as T;
|
||||
// The consumer must be a connected DOM descendant of the provider host.
|
||||
host.appendChild(el);
|
||||
await el.updateComplete;
|
||||
return { el, provider };
|
||||
};
|
||||
|
||||
// --- Tests -------------------------------------------------------------------
|
||||
|
||||
describe("consumeEntityState", () => {
|
||||
it("renders on mount and reads the watched entity", async () => {
|
||||
const { el } = await mount<TestConsumeEntityState>(
|
||||
"test-consume-entity-state",
|
||||
statesContext,
|
||||
{
|
||||
"light.kitchen": makeEntity("light.kitchen", "on"),
|
||||
"light.bedroom": makeEntity("light.bedroom", "on"),
|
||||
}
|
||||
);
|
||||
expect(el.stateObj?.state).toBe("on");
|
||||
expect(el.renderCount).toBe(1);
|
||||
});
|
||||
|
||||
it("does NOT re-render when an unrelated entity changes", async () => {
|
||||
const kitchen = makeEntity("light.kitchen", "on");
|
||||
const { el, provider } = await mount<TestConsumeEntityState>(
|
||||
"test-consume-entity-state",
|
||||
statesContext,
|
||||
{ "light.kitchen": kitchen, "light.bedroom": makeEntity("b", "on") }
|
||||
);
|
||||
expect(el.renderCount).toBe(1);
|
||||
|
||||
// New states object, but the watched entity keeps the same reference.
|
||||
provider.setValue({
|
||||
"light.kitchen": kitchen,
|
||||
"light.bedroom": makeEntity("light.bedroom", "off"),
|
||||
});
|
||||
await el.updateComplete;
|
||||
expect(el.renderCount).toBe(1);
|
||||
});
|
||||
|
||||
it("re-renders when the watched entity changes", async () => {
|
||||
const kitchen = makeEntity("light.kitchen", "on");
|
||||
const { el, provider } = await mount<TestConsumeEntityState>(
|
||||
"test-consume-entity-state",
|
||||
statesContext,
|
||||
{ "light.kitchen": kitchen }
|
||||
);
|
||||
expect(el.renderCount).toBe(1);
|
||||
|
||||
provider.setValue({ "light.kitchen": makeEntity("light.kitchen", "off") });
|
||||
await el.updateComplete;
|
||||
expect(el.stateObj?.state).toBe("off");
|
||||
expect(el.renderCount).toBe(2);
|
||||
});
|
||||
|
||||
it("re-renders when the watched host property changes", async () => {
|
||||
const { el, provider } = await mount<TestConsumeEntityState>(
|
||||
"test-consume-entity-state",
|
||||
statesContext,
|
||||
{
|
||||
"light.kitchen": makeEntity("light.kitchen", "on"),
|
||||
"light.bedroom": makeEntity("light.bedroom", "off"),
|
||||
}
|
||||
);
|
||||
expect(el.stateObj?.state).toBe("on");
|
||||
|
||||
el.entityId = "light.bedroom";
|
||||
await el.updateComplete;
|
||||
expect(el.stateObj?.state).toBe("off");
|
||||
|
||||
// The provider is still wired, but churn that leaves the watched entity
|
||||
// untouched must not render.
|
||||
const renderCount = el.renderCount;
|
||||
provider.setValue({
|
||||
"light.kitchen": makeEntity("light.kitchen", "on"),
|
||||
"light.bedroom": el.stateObj!,
|
||||
});
|
||||
await el.updateComplete;
|
||||
expect(el.renderCount).toBe(renderCount);
|
||||
});
|
||||
|
||||
it("clears the value and re-renders when the watched entity is removed", async () => {
|
||||
const { el, provider } = await mount<TestConsumeEntityState>(
|
||||
"test-consume-entity-state",
|
||||
statesContext,
|
||||
{ "light.kitchen": makeEntity("light.kitchen", "on") }
|
||||
);
|
||||
expect(el.stateObj?.state).toBe("on");
|
||||
expect(el.renderCount).toBe(1);
|
||||
|
||||
provider.setValue({});
|
||||
await el.updateComplete;
|
||||
expect(el.stateObj).toBeUndefined();
|
||||
expect(el.renderCount).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ContextSubscriptionController lifecycle", () => {
|
||||
it("mounts without a provider: value stays undefined and renders once", async () => {
|
||||
host = document.createElement("div");
|
||||
document.body.appendChild(host);
|
||||
const el = document.createElement(
|
||||
"test-consume-entity-state"
|
||||
) as TestConsumeEntityState;
|
||||
host.appendChild(el);
|
||||
await el.updateComplete;
|
||||
|
||||
expect(el.stateObj).toBeUndefined();
|
||||
expect(el.renderCount).toBe(1);
|
||||
});
|
||||
|
||||
it("unsubscribes on disconnect and re-subscribes on reconnect", async () => {
|
||||
host = document.createElement("div");
|
||||
document.body.appendChild(host);
|
||||
const provider = new ContextProvider(host, {
|
||||
context: statesContext,
|
||||
initialValue: { "light.kitchen": makeEntity("light.kitchen", "on") },
|
||||
});
|
||||
const el = document.createElement(
|
||||
"test-consume-entity-state"
|
||||
) as TestConsumeEntityState;
|
||||
host.appendChild(el);
|
||||
await el.updateComplete;
|
||||
expect(el.stateObj?.state).toBe("on");
|
||||
|
||||
// Disconnect, then push an update — the disconnected element must not render.
|
||||
el.remove();
|
||||
const renderCount = el.renderCount;
|
||||
provider.setValue({ "light.kitchen": makeEntity("light.kitchen", "off") });
|
||||
await el.updateComplete;
|
||||
expect(el.renderCount).toBe(renderCount);
|
||||
|
||||
// Reconnect — it must re-subscribe and pick up the current value.
|
||||
host.appendChild(el);
|
||||
await el.updateComplete;
|
||||
expect(el.stateObj?.state).toBe("off");
|
||||
});
|
||||
});
|
||||
|
||||
describe("consumeEntityStates", () => {
|
||||
it("does NOT re-render when an entity outside the watched set changes", async () => {
|
||||
const kitchen = makeEntity("light.kitchen", "on");
|
||||
const { el, provider } = await mount<TestConsumeEntityStates>(
|
||||
"test-consume-entity-states",
|
||||
statesContext,
|
||||
{ "light.kitchen": kitchen, "light.bedroom": makeEntity("b", "on") }
|
||||
);
|
||||
expect(el.renderCount).toBe(1);
|
||||
|
||||
provider.setValue({
|
||||
"light.kitchen": kitchen,
|
||||
"light.bedroom": makeEntity("light.bedroom", "off"),
|
||||
});
|
||||
await el.updateComplete;
|
||||
expect(el.renderCount).toBe(1);
|
||||
});
|
||||
|
||||
it("re-renders when a watched entity changes", async () => {
|
||||
const kitchen = makeEntity("light.kitchen", "on");
|
||||
const { el, provider } = await mount<TestConsumeEntityStates>(
|
||||
"test-consume-entity-states",
|
||||
statesContext,
|
||||
{ "light.kitchen": kitchen }
|
||||
);
|
||||
expect(el.renderCount).toBe(1);
|
||||
|
||||
provider.setValue({ "light.kitchen": makeEntity("light.kitchen", "off") });
|
||||
await el.updateComplete;
|
||||
expect(el.renderCount).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("consumeEntityRegistryEntry", () => {
|
||||
it("does NOT re-render when an unrelated registry entry changes", async () => {
|
||||
const kitchen = makeRegistryEntry("light.kitchen", "Kitchen");
|
||||
const { el, provider } = await mount<TestConsumeRegistryEntry>(
|
||||
"test-consume-registry-entry",
|
||||
entitiesContext,
|
||||
{
|
||||
"light.kitchen": kitchen,
|
||||
"light.bedroom": makeRegistryEntry("light.bedroom", "Bedroom"),
|
||||
}
|
||||
);
|
||||
expect(el.entry?.name).toBe("Kitchen");
|
||||
expect(el.renderCount).toBe(1);
|
||||
|
||||
provider.setValue({
|
||||
"light.kitchen": kitchen,
|
||||
"light.bedroom": makeRegistryEntry("light.bedroom", "Bedroom 2"),
|
||||
});
|
||||
await el.updateComplete;
|
||||
expect(el.renderCount).toBe(1);
|
||||
});
|
||||
|
||||
it("re-renders when the watched registry entry changes", async () => {
|
||||
const { el, provider } = await mount<TestConsumeRegistryEntry>(
|
||||
"test-consume-registry-entry",
|
||||
entitiesContext,
|
||||
{
|
||||
"light.kitchen": makeRegistryEntry("light.kitchen", "Kitchen"),
|
||||
}
|
||||
);
|
||||
expect(el.renderCount).toBe(1);
|
||||
|
||||
provider.setValue({
|
||||
"light.kitchen": makeRegistryEntry("light.kitchen", "Kitchen renamed"),
|
||||
});
|
||||
await el.updateComplete;
|
||||
expect(el.entry?.name).toBe("Kitchen renamed");
|
||||
expect(el.renderCount).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("consumeLocalize", () => {
|
||||
const localizeA = (() => "A") as LocalizeFunc;
|
||||
const localizeB = (() => "B") as LocalizeFunc;
|
||||
|
||||
it("does NOT re-render when the i18n context changes but localize is identical", async () => {
|
||||
const { el, provider } = await mount<TestConsumeLocalize>(
|
||||
"test-consume-localize",
|
||||
internationalizationContext,
|
||||
makeI18n(localizeA)
|
||||
);
|
||||
expect(el.localize).toBe(localizeA);
|
||||
expect(el.renderCount).toBe(1);
|
||||
|
||||
// New i18n object, same localize reference (e.g. only locale changed).
|
||||
provider.setValue(makeI18n(localizeA));
|
||||
await el.updateComplete;
|
||||
expect(el.renderCount).toBe(1);
|
||||
});
|
||||
|
||||
it("re-renders when localize changes", async () => {
|
||||
const { el, provider } = await mount<TestConsumeLocalize>(
|
||||
"test-consume-localize",
|
||||
internationalizationContext,
|
||||
makeI18n(localizeA)
|
||||
);
|
||||
expect(el.renderCount).toBe(1);
|
||||
|
||||
provider.setValue(makeI18n(localizeB));
|
||||
await el.updateComplete;
|
||||
expect(el.localize).toBe(localizeB);
|
||||
expect(el.renderCount).toBe(2);
|
||||
});
|
||||
|
||||
it("works without @state(): updates only when localize changes", async () => {
|
||||
const { el, provider } = await mount<TestConsumeLocalizeNoState>(
|
||||
"test-consume-localize-no-state",
|
||||
internationalizationContext,
|
||||
makeI18n(localizeA)
|
||||
);
|
||||
expect(el.localize).toBe(localizeA);
|
||||
expect(el.renderCount).toBe(1);
|
||||
|
||||
provider.setValue(makeI18n(localizeA));
|
||||
await el.updateComplete;
|
||||
expect(el.renderCount).toBe(1);
|
||||
|
||||
provider.setValue(makeI18n(localizeB));
|
||||
await el.updateComplete;
|
||||
expect(el.localize).toBe(localizeB);
|
||||
expect(el.renderCount).toBe(2);
|
||||
});
|
||||
});
|
||||
@@ -344,6 +344,43 @@ test.describe("Lovelace dashboard", () => {
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Security panel
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test.describe("Security panel", () => {
|
||||
test("renders configured active security alerts", async ({ page }) => {
|
||||
await goToPanel(page, "/?scenario=security-alerts#/security");
|
||||
|
||||
await expect(page.locator("ha-panel-security")).toBeAttached({
|
||||
timeout: PANEL_TIMEOUT,
|
||||
});
|
||||
|
||||
const alertCard = page.locator("hui-security-alerts-card").first();
|
||||
await expect(alertCard).toBeAttached({ timeout: PANEL_TIMEOUT });
|
||||
|
||||
if (!(await alertCard.isVisible().catch(() => false))) {
|
||||
const activityTab = page.getByRole("radio", { name: "Activity" });
|
||||
if (await activityTab.isVisible().catch(() => false)) {
|
||||
await activityTab.dispatchEvent("click");
|
||||
}
|
||||
}
|
||||
|
||||
await expect(alertCard).toBeVisible({ timeout: QUICK_TIMEOUT });
|
||||
await expect(alertCard.locator("text=Front door")).toBeVisible({
|
||||
timeout: QUICK_TIMEOUT,
|
||||
});
|
||||
|
||||
await page.evaluate(() => {
|
||||
(window as any).__mockHass.mockEntities[
|
||||
"binary_sensor.front_door"
|
||||
].update({ state: "off" });
|
||||
});
|
||||
|
||||
await expect(alertCard).toBeHidden({ timeout: QUICK_TIMEOUT });
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// More-info dialog (light)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -29,6 +29,13 @@ export const e2eTestPanels: Panels = {
|
||||
config: null,
|
||||
url_path: "history",
|
||||
},
|
||||
security: {
|
||||
component_name: "security",
|
||||
icon: "mdi:shield-home",
|
||||
title: "security",
|
||||
config: null,
|
||||
url_path: "security",
|
||||
},
|
||||
config: {
|
||||
component_name: "config",
|
||||
icon: "mdi:cog",
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { ExtEntityRegistryEntry } from "../../../../../src/data/entity/entity_registry";
|
||||
import type { SecurityFrontendSystemData } from "../../../../../src/data/frontend";
|
||||
import type { MockHomeAssistant } from "../../../../../src/fake_data/provide_hass";
|
||||
|
||||
export type Scenario = (hass: MockHomeAssistant) => Promise<void> | void;
|
||||
@@ -89,6 +90,27 @@ const lightMoreInfoScenario: Scenario = async (hass) => {
|
||||
hass.mockWS("config/entity_registry/get", () => registryEntry);
|
||||
};
|
||||
|
||||
const securityAlertsScenario: Scenario = async (hass) => {
|
||||
const securityData: SecurityFrontendSystemData = {
|
||||
alert_entities: [{ entity: "binary_sensor.front_door" }],
|
||||
};
|
||||
|
||||
hass.addEntities([
|
||||
{
|
||||
entity_id: "binary_sensor.front_door",
|
||||
state: "on",
|
||||
attributes: {
|
||||
friendly_name: "Front door",
|
||||
device_class: "door",
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
hass.mockWS("frontend/get_system_data", (msg: { key: string }) => ({
|
||||
value: msg.key === "security" ? securityData : null,
|
||||
}));
|
||||
};
|
||||
|
||||
// ── Registry ──────────────────────────────────────────────────────────────
|
||||
|
||||
export const scenarios: Record<string, Scenario> = {
|
||||
@@ -97,4 +119,5 @@ export const scenarios: Record<string, Scenario> = {
|
||||
"dark-theme": darkThemeScenario,
|
||||
"custom-theme": customThemeScenario,
|
||||
"light-more-info": lightMoreInfoScenario,
|
||||
"security-alerts": securityAlertsScenario,
|
||||
};
|
||||
|
||||
@@ -80,7 +80,7 @@ const makeStatus = (
|
||||
remote_certificate_status: "ready",
|
||||
http_use_ssl: false,
|
||||
active_subscription: true,
|
||||
onboarding_postponed: false,
|
||||
is_onboarding_postponed: false,
|
||||
onboarding_completed: false,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
import type { HassEntity } from "home-assistant-js-websocket";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import type { SecurityAlertItem } from "../../../../../src/panels/security/strategies/security-alerts";
|
||||
import "../../../../../src/panels/lovelace/cards/security-alerts/hui-security-alerts-list";
|
||||
|
||||
interface TestSecurityAlertsList extends HTMLElement {
|
||||
updateComplete: Promise<boolean>;
|
||||
_alerts: SecurityAlertItem[];
|
||||
_formatters: {
|
||||
formatEntityState: (stateObj: HassEntity) => string;
|
||||
};
|
||||
}
|
||||
|
||||
const state = (): HassEntity => ({
|
||||
entity_id: "binary_sensor.window",
|
||||
state: "on",
|
||||
attributes: {
|
||||
device_class: "window",
|
||||
friendly_name: "Window",
|
||||
},
|
||||
last_changed: "2026-01-01T00:00:00Z",
|
||||
last_updated: "2026-01-01T00:00:00Z",
|
||||
context: { id: "", parent_id: null, user_id: null },
|
||||
});
|
||||
|
||||
const alert = (pulse: boolean, color?: string): SecurityAlertItem => {
|
||||
const stateObj = state();
|
||||
return {
|
||||
entityId: stateObj.entity_id,
|
||||
stateObj,
|
||||
pulse,
|
||||
color,
|
||||
};
|
||||
};
|
||||
|
||||
const createList = async (alerts: SecurityAlertItem[]) => {
|
||||
const element = document.createElement(
|
||||
"hui-security-alerts-list"
|
||||
) as unknown as TestSecurityAlertsList;
|
||||
element._alerts = alerts;
|
||||
element._formatters = { formatEntityState: () => "On" };
|
||||
document.body.appendChild(element);
|
||||
await element.updateComplete;
|
||||
return element;
|
||||
};
|
||||
|
||||
describe("hui-security-alerts-list", () => {
|
||||
afterEach(() => {
|
||||
document.body.replaceChildren();
|
||||
});
|
||||
|
||||
it("does not pulse when pulse is disabled", async () => {
|
||||
const element = await createList([alert(false)]);
|
||||
|
||||
const card = element.shadowRoot!.querySelector("ha-card")!;
|
||||
|
||||
expect(card.classList.contains("pulse")).toBe(false);
|
||||
expect(
|
||||
card.style.getPropertyValue("--ha-security-alert-static-opacity")
|
||||
).toBe("var(--ha-security-alert-pulse-opacity)");
|
||||
});
|
||||
|
||||
it("pulses when pulse is enabled", async () => {
|
||||
const element = await createList([alert(true)]);
|
||||
|
||||
const card = element.shadowRoot!.querySelector("ha-card")!;
|
||||
expect(card.classList.contains("pulse")).toBe(true);
|
||||
});
|
||||
|
||||
it("applies configured colors", async () => {
|
||||
const element = await createList([alert(true, "amber")]);
|
||||
|
||||
const card = element.shadowRoot!.querySelector("ha-card")!;
|
||||
|
||||
expect(card.classList.contains("warning")).toBe(false);
|
||||
expect(card.classList.contains("no-color")).toBe(false);
|
||||
expect(card.style.getPropertyValue("--ha-security-alert-color")).toBe(
|
||||
"var(--amber-color)"
|
||||
);
|
||||
});
|
||||
|
||||
it("does not apply a color when color is none", async () => {
|
||||
const element = await createList([alert(true, "none")]);
|
||||
|
||||
const card = element.shadowRoot!.querySelector("ha-card")!;
|
||||
|
||||
expect(card.classList.contains("warning")).toBe(false);
|
||||
expect(card.classList.contains("no-color")).toBe(true);
|
||||
expect(card.style.getPropertyValue("--ha-security-alert-color")).toBe("");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,302 @@
|
||||
import type { HassEntity } from "home-assistant-js-websocket";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
computeDefaultSecurityAlertVisibility,
|
||||
computeSecurityAlertEntityDefaultColor,
|
||||
computeSecurityAlertItems,
|
||||
isSecurityAlertEntity,
|
||||
type SecurityAlertHass,
|
||||
} from "../../../../../src/panels/security/strategies/security-alerts";
|
||||
|
||||
const state = (
|
||||
entityId: string,
|
||||
value: string,
|
||||
deviceClass: string | undefined,
|
||||
lastChanged: string
|
||||
): HassEntity => ({
|
||||
entity_id: entityId,
|
||||
state: value,
|
||||
attributes: {
|
||||
...(deviceClass ? { device_class: deviceClass } : {}),
|
||||
friendly_name: entityId,
|
||||
},
|
||||
last_changed: lastChanged,
|
||||
last_updated: lastChanged,
|
||||
context: { id: "", parent_id: null, user_id: null },
|
||||
});
|
||||
|
||||
const hass = (states: Record<string, HassEntity>): SecurityAlertHass => ({
|
||||
states,
|
||||
user: undefined,
|
||||
config: {
|
||||
time_zone: "UTC",
|
||||
} as SecurityAlertHass["config"],
|
||||
locale: {
|
||||
time_zone: "server",
|
||||
} as SecurityAlertHass["locale"],
|
||||
});
|
||||
|
||||
describe("computeDefaultSecurityAlertVisibility", () => {
|
||||
it("defaults alarm panels to triggered", () => {
|
||||
expect(
|
||||
computeDefaultSecurityAlertVisibility("alarm_control_panel.house")
|
||||
).toEqual([
|
||||
{
|
||||
condition: "state",
|
||||
entity: "alarm_control_panel.house",
|
||||
state: "triggered",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("defaults other entities to on", () => {
|
||||
expect(computeDefaultSecurityAlertVisibility("binary_sensor.leak")).toEqual(
|
||||
[
|
||||
{
|
||||
condition: "state",
|
||||
entity: "binary_sensor.leak",
|
||||
state: "on",
|
||||
},
|
||||
]
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("computeSecurityAlertEntityDefaultColor", () => {
|
||||
it("uses device class defaults independent of current state", () => {
|
||||
expect(
|
||||
computeSecurityAlertEntityDefaultColor(
|
||||
state(
|
||||
"binary_sensor.carbon_monoxide",
|
||||
"unavailable",
|
||||
"carbon_monoxide",
|
||||
"2026-01-01T00:00:00Z"
|
||||
)
|
||||
)
|
||||
).toBe("red");
|
||||
expect(
|
||||
computeSecurityAlertEntityDefaultColor(
|
||||
state(
|
||||
"binary_sensor.window",
|
||||
"unavailable",
|
||||
"window",
|
||||
"2026-01-01T00:00:00Z"
|
||||
)
|
||||
)
|
||||
).toBe("amber");
|
||||
expect(
|
||||
computeSecurityAlertEntityDefaultColor(
|
||||
state("camera.patio", "unavailable", undefined, "2026-01-01T00:00:00Z")
|
||||
)
|
||||
).toBe("blue");
|
||||
});
|
||||
});
|
||||
|
||||
describe("isSecurityAlertEntity", () => {
|
||||
it("includes entities that can be active alerts", () => {
|
||||
expect(
|
||||
isSecurityAlertEntity(
|
||||
state(
|
||||
"binary_sensor.dishwasher_leak",
|
||||
"off",
|
||||
"moisture",
|
||||
"2026-01-01T00:00:00Z"
|
||||
)
|
||||
)
|
||||
).toBe(true);
|
||||
expect(
|
||||
isSecurityAlertEntity(
|
||||
state("lock.front_door", "locked", undefined, "2026-01-01T00:00:00Z")
|
||||
)
|
||||
).toBe(true);
|
||||
expect(
|
||||
isSecurityAlertEntity(
|
||||
state("camera.patio", "idle", undefined, "2026-01-01T00:00:00Z")
|
||||
)
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("excludes entities that do not classify as alerts", () => {
|
||||
expect(
|
||||
isSecurityAlertEntity(
|
||||
state("binary_sensor.motion", "off", "motion", "2026-01-01T00:00:00Z")
|
||||
)
|
||||
).toBe(false);
|
||||
expect(
|
||||
isSecurityAlertEntity(
|
||||
state("light.kitchen", "on", undefined, "2026-01-01T00:00:00Z")
|
||||
)
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("computeSecurityAlertItems", () => {
|
||||
it("does not infer alerts without configured rows", () => {
|
||||
const states = {
|
||||
"binary_sensor.dishwasher_leak": state(
|
||||
"binary_sensor.dishwasher_leak",
|
||||
"on",
|
||||
"moisture",
|
||||
"2026-01-01T00:00:00Z"
|
||||
),
|
||||
};
|
||||
|
||||
expect(computeSecurityAlertItems(hass(states), [])).toEqual([]);
|
||||
});
|
||||
|
||||
it("shows configured entities when their default visibility matches", () => {
|
||||
const states = {
|
||||
"binary_sensor.dishwasher_leak": state(
|
||||
"binary_sensor.dishwasher_leak",
|
||||
"on",
|
||||
"moisture",
|
||||
"2026-01-01T00:00:00Z"
|
||||
),
|
||||
};
|
||||
|
||||
expect(
|
||||
computeSecurityAlertItems(hass(states), [
|
||||
{ entity: "binary_sensor.dishwasher_leak" },
|
||||
]).map((item) => item.entityId)
|
||||
).toEqual(["binary_sensor.dishwasher_leak"]);
|
||||
});
|
||||
|
||||
it("shows carbon monoxide sensors when active", () => {
|
||||
const states = {
|
||||
"binary_sensor.carbon_monoxide": state(
|
||||
"binary_sensor.carbon_monoxide",
|
||||
"on",
|
||||
"carbon_monoxide",
|
||||
"2026-01-01T00:00:00Z"
|
||||
),
|
||||
};
|
||||
|
||||
expect(
|
||||
computeSecurityAlertItems(hass(states), [
|
||||
{ entity: "binary_sensor.carbon_monoxide" },
|
||||
]).map((item) => item.entityId)
|
||||
).toEqual(["binary_sensor.carbon_monoxide"]);
|
||||
});
|
||||
|
||||
it("hides configured entities when their default visibility does not match", () => {
|
||||
const states = {
|
||||
"binary_sensor.dishwasher_leak": state(
|
||||
"binary_sensor.dishwasher_leak",
|
||||
"off",
|
||||
"moisture",
|
||||
"2026-01-01T00:00:00Z"
|
||||
),
|
||||
};
|
||||
|
||||
expect(
|
||||
computeSecurityAlertItems(hass(states), [
|
||||
{ entity: "binary_sensor.dishwasher_leak" },
|
||||
])
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it("uses custom visibility conditions", () => {
|
||||
const states = {
|
||||
"lock.front_door": state(
|
||||
"lock.front_door",
|
||||
"unlocked",
|
||||
undefined,
|
||||
"2026-01-01T00:00:00Z"
|
||||
),
|
||||
};
|
||||
|
||||
expect(
|
||||
computeSecurityAlertItems(hass(states), [
|
||||
{
|
||||
entity: "lock.front_door",
|
||||
visibility: [
|
||||
{
|
||||
condition: "state",
|
||||
entity: "lock.front_door",
|
||||
state: "unlocked",
|
||||
},
|
||||
],
|
||||
},
|
||||
]).map((item) => item.entityId)
|
||||
).toEqual(["lock.front_door"]);
|
||||
});
|
||||
|
||||
it("applies configured color and pulse", () => {
|
||||
const states = {
|
||||
"binary_sensor.window": state(
|
||||
"binary_sensor.window",
|
||||
"on",
|
||||
"window",
|
||||
"2026-01-01T00:00:00Z"
|
||||
),
|
||||
};
|
||||
|
||||
expect(
|
||||
computeSecurityAlertItems(hass(states), [
|
||||
{
|
||||
entity: "binary_sensor.window",
|
||||
color: "red",
|
||||
pulse: false,
|
||||
},
|
||||
])[0]
|
||||
).toMatchObject({ color: "red", pulse: false });
|
||||
});
|
||||
|
||||
it("uses the entity default color when color is not configured", () => {
|
||||
const states = {
|
||||
"binary_sensor.window": state(
|
||||
"binary_sensor.window",
|
||||
"on",
|
||||
"window",
|
||||
"2026-01-01T00:00:00Z"
|
||||
),
|
||||
};
|
||||
|
||||
expect(
|
||||
computeSecurityAlertItems(hass(states), [
|
||||
{ entity: "binary_sensor.window" },
|
||||
])[0]
|
||||
).toMatchObject({ color: "amber" });
|
||||
});
|
||||
|
||||
it("keeps no color as an explicit color choice", () => {
|
||||
const states = {
|
||||
"binary_sensor.window": state(
|
||||
"binary_sensor.window",
|
||||
"on",
|
||||
"window",
|
||||
"2026-01-01T00:00:00Z"
|
||||
),
|
||||
};
|
||||
|
||||
expect(
|
||||
computeSecurityAlertItems(hass(states), [
|
||||
{ entity: "binary_sensor.window", color: "none" },
|
||||
])[0]
|
||||
).toMatchObject({ color: "none" });
|
||||
});
|
||||
|
||||
it("keeps configured order", () => {
|
||||
const states = {
|
||||
"binary_sensor.window": state(
|
||||
"binary_sensor.window",
|
||||
"on",
|
||||
"window",
|
||||
"2026-01-01T00:02:00Z"
|
||||
),
|
||||
"binary_sensor.leak": state(
|
||||
"binary_sensor.leak",
|
||||
"on",
|
||||
"moisture",
|
||||
"2026-01-01T00:01:00Z"
|
||||
),
|
||||
};
|
||||
|
||||
expect(
|
||||
computeSecurityAlertItems(hass(states), [
|
||||
{ entity: "binary_sensor.window" },
|
||||
{ entity: "binary_sensor.leak" },
|
||||
]).map((item) => item.entityId)
|
||||
).toEqual(["binary_sensor.window", "binary_sensor.leak"]);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user