mirror of
https://github.com/home-assistant/frontend.git
synced 2026-07-19 09:35:51 +00:00
Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 69959f73a3 | |||
| 06b7836794 | |||
| 55ea661e31 | |||
| 23b9e97ee5 | |||
| f807806bed | |||
| 450a132fc7 | |||
| c2fdde7ad9 | |||
| 2b659d27b1 |
@@ -8,16 +8,33 @@ inputs:
|
||||
cache:
|
||||
description: Enable the yarn cache in setup-node
|
||||
default: "true"
|
||||
node-modules-cache:
|
||||
description: Restore the exact shared node_modules cache before installing
|
||||
default: "false"
|
||||
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
- name: Restore complete dependency tree
|
||||
id: dependency-cache
|
||||
if: inputs.node-modules-cache == 'true'
|
||||
continue-on-error: true
|
||||
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
with:
|
||||
path: |
|
||||
node_modules
|
||||
.yarn/install-state.gz
|
||||
key: >-
|
||||
node-modules-v1-${{ runner.os }}-${{ runner.arch }}-${{
|
||||
hashFiles('.nvmrc', 'package.json', 'yarn.lock', '.yarnrc.yml', '.yarn/releases/**', '.yarn/patches/**') }}
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version-file: ".nvmrc"
|
||||
cache: ${{ inputs.cache == 'true' && 'yarn' || '' }}
|
||||
cache: ${{ inputs.cache == 'true' && (inputs.node-modules-cache != 'true' || steps.dependency-cache.outputs.cache-hit != 'true') && 'yarn' || '' }}
|
||||
|
||||
- name: Install dependencies
|
||||
if: inputs.node-modules-cache != 'true' || steps.dependency-cache.outputs.cache-hit != 'true'
|
||||
shell: bash
|
||||
run: yarn install ${{ inputs.immutable == 'true' && '--immutable' || '' }}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
branches:
|
||||
- dev
|
||||
@@ -21,16 +22,56 @@ permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
lint:
|
||||
name: Lint and check format
|
||||
prepare-dependencies:
|
||||
name: Prepare dependencies
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out files from GitHub
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Check for complete dependency tree
|
||||
id: dependencies
|
||||
continue-on-error: true
|
||||
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
with:
|
||||
path: |
|
||||
node_modules
|
||||
.yarn/install-state.gz
|
||||
key: >-
|
||||
node-modules-v1-${{ runner.os }}-${{ runner.arch }}-${{
|
||||
hashFiles('.nvmrc', 'package.json', 'yarn.lock', '.yarnrc.yml', '.yarn/releases/**', '.yarn/patches/**') }}
|
||||
lookup-only: true
|
||||
- name: Setup Node and install
|
||||
if: steps.dependencies.outputs.cache-hit != 'true'
|
||||
uses: ./.github/actions/setup
|
||||
with:
|
||||
cache: false
|
||||
- name: Save complete dependency tree
|
||||
if: steps.dependencies.outputs.cache-hit != 'true'
|
||||
continue-on-error: true
|
||||
uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
with:
|
||||
path: |
|
||||
node_modules
|
||||
.yarn/install-state.gz
|
||||
key: >-
|
||||
node-modules-v1-${{ runner.os }}-${{ runner.arch }}-${{
|
||||
hashFiles('.nvmrc', 'package.json', 'yarn.lock', '.yarnrc.yml', '.yarn/releases/**', '.yarn/patches/**') }}
|
||||
|
||||
lint:
|
||||
name: Lint and check format
|
||||
needs: prepare-dependencies
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out files from GitHub
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Setup Node with shared dependencies
|
||||
uses: ./.github/actions/setup
|
||||
with:
|
||||
node-modules-cache: true
|
||||
- name: Check for duplicate dependencies
|
||||
run: yarn dedupe --check
|
||||
- name: Build resources
|
||||
@@ -63,14 +104,17 @@ jobs:
|
||||
run: yarn run lint:licenses
|
||||
test:
|
||||
name: Run tests
|
||||
needs: prepare-dependencies
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out files from GitHub
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Setup Node and install
|
||||
- name: Setup Node with shared dependencies
|
||||
uses: ./.github/actions/setup
|
||||
with:
|
||||
node-modules-cache: true
|
||||
- name: Build resources
|
||||
run: ./node_modules/.bin/gulp gen-icons-json build-translations build-locale-data
|
||||
env:
|
||||
@@ -80,6 +124,7 @@ jobs:
|
||||
build:
|
||||
name: Build frontend
|
||||
needs:
|
||||
- prepare-dependencies
|
||||
- lint
|
||||
- test
|
||||
runs-on: ubuntu-latest
|
||||
@@ -88,8 +133,10 @@ jobs:
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Setup Node and install
|
||||
- name: Setup Node with shared dependencies
|
||||
uses: ./.github/actions/setup
|
||||
with:
|
||||
node-modules-cache: true
|
||||
- name: Build Application
|
||||
uses: ./.github/actions/build
|
||||
with:
|
||||
|
||||
+11
-11
@@ -52,15 +52,15 @@
|
||||
"@codemirror/view": "6.43.6",
|
||||
"@date-fns/tz": "1.5.0",
|
||||
"@egjs/hammerjs": "2.0.17",
|
||||
"@formatjs/intl-datetimeformat": "7.5.0",
|
||||
"@formatjs/intl-displaynames": "7.3.12",
|
||||
"@formatjs/intl-durationformat": "0.10.17",
|
||||
"@formatjs/intl-getcanonicallocales": "3.2.11",
|
||||
"@formatjs/intl-listformat": "8.3.12",
|
||||
"@formatjs/intl-locale": "5.3.10",
|
||||
"@formatjs/intl-numberformat": "9.3.13",
|
||||
"@formatjs/intl-pluralrules": "6.3.12",
|
||||
"@formatjs/intl-relativetimeformat": "12.3.12",
|
||||
"@formatjs/intl-datetimeformat": "7.4.10",
|
||||
"@formatjs/intl-displaynames": "7.3.11",
|
||||
"@formatjs/intl-durationformat": "0.10.16",
|
||||
"@formatjs/intl-getcanonicallocales": "3.2.10",
|
||||
"@formatjs/intl-listformat": "8.3.11",
|
||||
"@formatjs/intl-locale": "5.3.9",
|
||||
"@formatjs/intl-numberformat": "9.3.12",
|
||||
"@formatjs/intl-pluralrules": "6.3.11",
|
||||
"@formatjs/intl-relativetimeformat": "12.3.11",
|
||||
"@fullcalendar/core": "6.1.21",
|
||||
"@fullcalendar/daygrid": "6.1.21",
|
||||
"@fullcalendar/interaction": "6.1.21",
|
||||
@@ -107,7 +107,7 @@
|
||||
"hls.js": "1.6.16",
|
||||
"home-assistant-js-websocket": "9.6.0",
|
||||
"idb-keyval": "6.3.0",
|
||||
"intl-messageformat": "11.2.11",
|
||||
"intl-messageformat": "11.2.10",
|
||||
"js-yaml": "5.2.1",
|
||||
"leaflet": "1.9.4",
|
||||
"leaflet-draw": "patch:leaflet-draw@npm%3A1.0.4#./.yarn/patches/leaflet-draw-npm-1.0.4-0ca0ebcf65.patch",
|
||||
@@ -173,7 +173,7 @@
|
||||
"babel-plugin-polyfill-corejs3": "1.0.0",
|
||||
"browserslist-useragent-regexp": "4.1.4",
|
||||
"del": "8.0.1",
|
||||
"eslint": "10.7.0",
|
||||
"eslint": "10.6.0",
|
||||
"eslint-config-prettier": "10.1.8",
|
||||
"eslint-import-resolver-webpack": "0.13.11",
|
||||
"eslint-plugin-import-x": "4.17.1",
|
||||
|
||||
@@ -4,7 +4,6 @@ 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";
|
||||
@@ -36,10 +35,6 @@ 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) {
|
||||
@@ -116,9 +111,6 @@ 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;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
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 {
|
||||
@@ -27,17 +26,6 @@ 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),
|
||||
@@ -54,7 +42,6 @@ declare global {
|
||||
core: CoreFrontendSystemData;
|
||||
home: HomeFrontendSystemData;
|
||||
energy: EnergyFrontendSystemData;
|
||||
security: SecurityFrontendSystemData;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
import { createContext } from "@lit/context";
|
||||
import type { SecurityAlertItem } from "../../../security/strategies/security-alerts";
|
||||
|
||||
export const securityAlertsContext =
|
||||
createContext<SecurityAlertItem[]>("security-alerts");
|
||||
@@ -1,140 +0,0 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -1,54 +0,0 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -1,160 +0,0 @@
|
||||
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,7 +3,6 @@ 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 {
|
||||
@@ -697,10 +696,6 @@ 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,8 +80,6 @@ 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"),
|
||||
|
||||
@@ -1,216 +0,0 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -1,521 +0,0 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
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,23 +1,14 @@
|
||||
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";
|
||||
@@ -38,12 +29,8 @@ 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
|
||||
@@ -63,7 +50,7 @@ class PanelSecurity extends LitElement {
|
||||
}
|
||||
|
||||
if (oldHass && this.hass) {
|
||||
// Refresh the generated view when registries or panels change.
|
||||
// If the entity registry changed, ask the user if they want to refresh the config
|
||||
if (
|
||||
oldHass.entities !== this.hass.entities ||
|
||||
oldHass.devices !== this.hass.devices ||
|
||||
@@ -87,25 +74,10 @@ class PanelSecurity extends LitElement {
|
||||
}
|
||||
|
||||
private async _setup() {
|
||||
this._loadConfigPromise = this._loadConfig();
|
||||
await this._loadConfigPromise;
|
||||
await this.hass.loadFragmentTranslation("lovelace");
|
||||
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
|
||||
@@ -122,12 +94,6 @@ 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`
|
||||
@@ -149,17 +115,8 @@ class PanelSecurity extends LitElement {
|
||||
}
|
||||
|
||||
private async _setLovelace() {
|
||||
if (this._loadConfigPromise) {
|
||||
await this._loadConfigPromise;
|
||||
}
|
||||
|
||||
const viewConfig = await generateLovelaceViewStrategy(
|
||||
{
|
||||
strategy: {
|
||||
...SECURITY_LOVELACE_VIEW_CONFIG.strategy,
|
||||
alert_entities: this._config.alert_entities,
|
||||
},
|
||||
},
|
||||
SECURITY_LOVELACE_VIEW_CONFIG,
|
||||
this.hass
|
||||
);
|
||||
|
||||
@@ -185,35 +142,6 @@ 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,
|
||||
|
||||
@@ -1,224 +0,0 @@
|
||||
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,4 +1,3 @@
|
||||
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";
|
||||
@@ -16,14 +15,12 @@ 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[] = [
|
||||
@@ -72,14 +69,6 @@ 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,
|
||||
@@ -143,7 +132,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);
|
||||
@@ -253,51 +242,37 @@ export class SecurityViewStrategy extends ReactiveElement {
|
||||
|
||||
const logbookEntityIds = [...entities, ...personEntities];
|
||||
|
||||
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,
|
||||
],
|
||||
});
|
||||
}
|
||||
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;
|
||||
|
||||
return {
|
||||
type: "sections",
|
||||
max_columns: 3,
|
||||
sections: sections,
|
||||
...(sidebarSections.length > 0 && {
|
||||
...(sidebarSection && {
|
||||
sidebar: {
|
||||
sections: sidebarSections,
|
||||
sections: [sidebarSection],
|
||||
content_label: hass.localize(
|
||||
"ui.panel.lovelace.strategy.security.devices"
|
||||
),
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
import { css } from "lit";
|
||||
|
||||
export const pulseOpacityAnimation = css`
|
||||
@keyframes pulse-opacity {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: var(--ha-pulse-opacity, 0.3);
|
||||
}
|
||||
}
|
||||
`;
|
||||
@@ -244,9 +244,6 @@
|
||||
"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",
|
||||
@@ -2577,24 +2574,6 @@
|
||||
"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.",
|
||||
|
||||
@@ -412,43 +412,6 @@ 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)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -109,13 +109,6 @@ export const e2eTestPanels: Record<string, E2ETestPanelInfo> = {
|
||||
url_path: "iframe",
|
||||
testSelector: "ha-panel-iframe",
|
||||
},
|
||||
security: {
|
||||
component_name: "security",
|
||||
icon: "mdi:shield-home",
|
||||
title: "security",
|
||||
config: null,
|
||||
url_path: "security",
|
||||
},
|
||||
config: {
|
||||
component_name: "config",
|
||||
icon: "mdi:cog",
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
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;
|
||||
@@ -90,27 +89,6 @@ 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> = {
|
||||
@@ -119,5 +97,4 @@ export const scenarios: Record<string, Scenario> = {
|
||||
"dark-theme": darkThemeScenario,
|
||||
"custom-theme": customThemeScenario,
|
||||
"light-more-info": lightMoreInfoScenario,
|
||||
"security-alerts": securityAlertsScenario,
|
||||
};
|
||||
|
||||
@@ -1,91 +0,0 @@
|
||||
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("");
|
||||
});
|
||||
});
|
||||
@@ -1,302 +0,0 @@
|
||||
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"]);
|
||||
});
|
||||
});
|
||||
@@ -2745,135 +2745,135 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@formatjs/bigdecimal@npm:0.2.7":
|
||||
version: 0.2.7
|
||||
resolution: "@formatjs/bigdecimal@npm:0.2.7"
|
||||
checksum: 10/e78fe804ed6805708b849fbd49006e981cfa736677bac29556f84bd6ca5271f848076f2f35de087197a559d434435bbfaecca2776563a5b46ecd6246bde7406d
|
||||
"@formatjs/bigdecimal@npm:0.2.6":
|
||||
version: 0.2.6
|
||||
resolution: "@formatjs/bigdecimal@npm:0.2.6"
|
||||
checksum: 10/5ef248f0feadeb1bceb9fa65ba36ccd1768a41ea6165bc58ae794564ebb09a67621d894fc94ab8b328cc7bba60e4c181847562c92b7cec5cd1208a4191fbbe67
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@formatjs/fast-memoize@npm:3.1.7":
|
||||
version: 3.1.7
|
||||
resolution: "@formatjs/fast-memoize@npm:3.1.7"
|
||||
checksum: 10/2d7ead48684539764ecb8a52178719169186595e830d9d6941eff12bec1ae31e20642a9cfd197007c48a8cc7e965bf61445ffcccc0fd77281341271d00e7b8fb
|
||||
"@formatjs/fast-memoize@npm:3.1.6":
|
||||
version: 3.1.6
|
||||
resolution: "@formatjs/fast-memoize@npm:3.1.6"
|
||||
checksum: 10/7dec3e82586d4c4889671c6081d7b0d87b2c229ba551d8328f96ae8ab58b2cd6056fc5d360c0b0c9688b7164f12ef517428016fbce15b950987a77005e481824
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@formatjs/icu-messageformat-parser@npm:3.5.14":
|
||||
version: 3.5.14
|
||||
resolution: "@formatjs/icu-messageformat-parser@npm:3.5.14"
|
||||
"@formatjs/icu-messageformat-parser@npm:3.5.13":
|
||||
version: 3.5.13
|
||||
resolution: "@formatjs/icu-messageformat-parser@npm:3.5.13"
|
||||
dependencies:
|
||||
"@formatjs/icu-skeleton-parser": "npm:2.1.11"
|
||||
checksum: 10/e842d8ec0c17c174da0eb562e161f5216b31cb83546bf94fd3dd6e4b6493f8c0c404b9fb47b6b8106a607a83d305386bc1fb44abbc70453cf467d261e39d09c1
|
||||
"@formatjs/icu-skeleton-parser": "npm:2.1.10"
|
||||
checksum: 10/b6451febdcfbe32571af5ae1c94cc560f6b3d815401321849c9dba229c460edf9ee4eca4c2d4da6dedefea416f2e3b10b4aa5507383e12b67042f941157e8045
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@formatjs/icu-skeleton-parser@npm:2.1.11":
|
||||
version: 2.1.11
|
||||
resolution: "@formatjs/icu-skeleton-parser@npm:2.1.11"
|
||||
checksum: 10/492f42bd4ad72b2cdb4fd75a07e3874aa78e88f346027058f74c58e3828378092784176c9f7c77f4b5baf58ffaa1a4e8f5efcb53143552def2990510d0ac3c16
|
||||
"@formatjs/icu-skeleton-parser@npm:2.1.10":
|
||||
version: 2.1.10
|
||||
resolution: "@formatjs/icu-skeleton-parser@npm:2.1.10"
|
||||
checksum: 10/ec30d106ce38de80f4128d0cdfac15699628652807695843254bf0d31650bd0dc4b57e48691d164556234494d59b2816e710fa12321234c85b803c5cda32bedf
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@formatjs/intl-datetimeformat@npm:7.5.0":
|
||||
version: 7.5.0
|
||||
resolution: "@formatjs/intl-datetimeformat@npm:7.5.0"
|
||||
"@formatjs/intl-datetimeformat@npm:7.4.10":
|
||||
version: 7.4.10
|
||||
resolution: "@formatjs/intl-datetimeformat@npm:7.4.10"
|
||||
dependencies:
|
||||
"@formatjs/bigdecimal": "npm:0.2.7"
|
||||
"@formatjs/intl-localematcher": "npm:0.8.12"
|
||||
checksum: 10/8f5bbcd2768609e466e5d03cd42880e62fa5c584ef57d716023a0f89ffff12036da41a248956cc1b3369b1af0155459464ac05d5f8fdfb42c0aa066d77ede815
|
||||
"@formatjs/bigdecimal": "npm:0.2.6"
|
||||
"@formatjs/intl-localematcher": "npm:0.8.11"
|
||||
checksum: 10/67fd55857555fe2651bc8ed5544fb036807d926542c92ccc07e475c14493d93a87d2cc805d04c812c6df68c9fdaeb4d64b399ac019f8c27940e2b872c9837618
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@formatjs/intl-displaynames@npm:7.3.12":
|
||||
version: 7.3.12
|
||||
resolution: "@formatjs/intl-displaynames@npm:7.3.12"
|
||||
"@formatjs/intl-displaynames@npm:7.3.11":
|
||||
version: 7.3.11
|
||||
resolution: "@formatjs/intl-displaynames@npm:7.3.11"
|
||||
dependencies:
|
||||
"@formatjs/intl-localematcher": "npm:0.8.12"
|
||||
checksum: 10/d802f9d44831fcb567efd7399b2f20da7872bf6cdf3cc8c5b38b188111fc8c38af704a79f55902caadd50edb8ebf2f830c473aecc5a93573ddbe1bcfba275793
|
||||
"@formatjs/intl-localematcher": "npm:0.8.11"
|
||||
checksum: 10/3278e430d7f2bbb0656f5cf0a76a1a539a572c58f0d2473e2a175bc927de7e3a4a01823c3c98a38dc2878fd05ca3a6ebe83afa94dbd254733fe9195377064347
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@formatjs/intl-durationformat@npm:0.10.17":
|
||||
version: 0.10.17
|
||||
resolution: "@formatjs/intl-durationformat@npm:0.10.17"
|
||||
"@formatjs/intl-durationformat@npm:0.10.16":
|
||||
version: 0.10.16
|
||||
resolution: "@formatjs/intl-durationformat@npm:0.10.16"
|
||||
dependencies:
|
||||
"@formatjs/bigdecimal": "npm:0.2.7"
|
||||
"@formatjs/intl-localematcher": "npm:0.8.12"
|
||||
checksum: 10/ee3210da7b946a80995b36609a360a55d63f4626e3f40223215d782082f1f8254c49af6145f397ea6324b370038095145c6ff51fda8712e7b611f8609e1ac2b3
|
||||
"@formatjs/bigdecimal": "npm:0.2.6"
|
||||
"@formatjs/intl-localematcher": "npm:0.8.11"
|
||||
checksum: 10/8d0f5bbf24528c6d8e65636502de64b13e12c71854935db8bf2ce6bcc5b6e0e19533d0e0178c1079b1dd52214b025369c98e297b0e8484fa8ce29c9f6408c953
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@formatjs/intl-getcanonicallocales@npm:3.2.11":
|
||||
version: 3.2.11
|
||||
resolution: "@formatjs/intl-getcanonicallocales@npm:3.2.11"
|
||||
checksum: 10/3132dba96c7cfe63787e126a91e620211a32fa6a623cb763f96102ff153845fb12486f378aa1d761736a7641adb19f5cc307156c8b7a40eedd00223b87c8d2a9
|
||||
"@formatjs/intl-getcanonicallocales@npm:3.2.10":
|
||||
version: 3.2.10
|
||||
resolution: "@formatjs/intl-getcanonicallocales@npm:3.2.10"
|
||||
checksum: 10/dbf704d141bd4efc4e2687bd745d1a847a7b94955c23d2f06fe26add8e5ab8ad6096168babad72f2b4568f1fbee32c1528082269273b750bed4bdd1dc5b5d396
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@formatjs/intl-listformat@npm:8.3.12":
|
||||
version: 8.3.12
|
||||
resolution: "@formatjs/intl-listformat@npm:8.3.12"
|
||||
"@formatjs/intl-listformat@npm:8.3.11":
|
||||
version: 8.3.11
|
||||
resolution: "@formatjs/intl-listformat@npm:8.3.11"
|
||||
dependencies:
|
||||
"@formatjs/intl-localematcher": "npm:0.8.12"
|
||||
checksum: 10/3ae640ab968b3051fd1b2d150d892a087d5a4feecb8bdf4a6a61726888996ee0b054de43e35548374d0a61d8577b5a48f23abfb9506c1560f88d64c0809691f1
|
||||
"@formatjs/intl-localematcher": "npm:0.8.11"
|
||||
checksum: 10/a08a74c22bb01871944cad91fcb87342e30f3f6792375af3028737794a56b2dec6b7506c8e2b9d0495f4eff50f4d63542fcf86116cd4fdf1d4d8b6838a54ac5c
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@formatjs/intl-locale@npm:5.3.10":
|
||||
version: 5.3.10
|
||||
resolution: "@formatjs/intl-locale@npm:5.3.10"
|
||||
"@formatjs/intl-locale@npm:5.3.9":
|
||||
version: 5.3.9
|
||||
resolution: "@formatjs/intl-locale@npm:5.3.9"
|
||||
dependencies:
|
||||
"@formatjs/intl-getcanonicallocales": "npm:3.2.11"
|
||||
"@formatjs/intl-supportedvaluesof": "npm:2.3.9"
|
||||
checksum: 10/f7d8be5ea145089b9daa56e24158f835ed0f3d67d3230b42c3a8a32f3dbdd98534236e9262d86f628288d09968fec32d1c2f5906ec1d5f38e692d436a4052255
|
||||
"@formatjs/intl-getcanonicallocales": "npm:3.2.10"
|
||||
"@formatjs/intl-supportedvaluesof": "npm:2.3.8"
|
||||
checksum: 10/e2af858ec3ff75611d5412a1e39abce882eab2cf13eb278e4cd1647481321823cc2303c74e86c38b95662e668f6900f9d666136debb2377231d5ddf0bdba3218
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@formatjs/intl-localematcher@npm:0.8.12":
|
||||
version: 0.8.12
|
||||
resolution: "@formatjs/intl-localematcher@npm:0.8.12"
|
||||
"@formatjs/intl-localematcher@npm:0.8.11":
|
||||
version: 0.8.11
|
||||
resolution: "@formatjs/intl-localematcher@npm:0.8.11"
|
||||
dependencies:
|
||||
"@formatjs/fast-memoize": "npm:3.1.7"
|
||||
checksum: 10/4f9ecb936ac76cb978460edacbb2704e9760f65203e2c17f96b8db605a3c344ecc7da2ac78c897316f519b033c623b39062ba720e23fe0c2162af7c761f765e6
|
||||
"@formatjs/fast-memoize": "npm:3.1.6"
|
||||
checksum: 10/482b819100997439b2c7295c8112b43c81f64083644cde7124ef1afecaa8fc663bc7b9820b6386d02b45872db7a943c3f1f43a4f78780fe52ed4873ef422e7dc
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@formatjs/intl-numberformat@npm:9.3.13":
|
||||
version: 9.3.13
|
||||
resolution: "@formatjs/intl-numberformat@npm:9.3.13"
|
||||
"@formatjs/intl-numberformat@npm:9.3.12":
|
||||
version: 9.3.12
|
||||
resolution: "@formatjs/intl-numberformat@npm:9.3.12"
|
||||
dependencies:
|
||||
"@formatjs/bigdecimal": "npm:0.2.7"
|
||||
"@formatjs/intl-localematcher": "npm:0.8.12"
|
||||
checksum: 10/59530d3aec0411a2dae8f2113829b969dceec3ec4a59032cc985ef1b9cebec6d406fbe4dd44ac69e905b4f3b3edb333d61f728a5610fc1e1e132e4bdcb2436aa
|
||||
"@formatjs/bigdecimal": "npm:0.2.6"
|
||||
"@formatjs/intl-localematcher": "npm:0.8.11"
|
||||
checksum: 10/6a9f3115136ce9f74fab520d5eeddb6a3f23d32492b004c9c6f108a19dd18e9ccaec04582569238f089b3028cc68783a2b75328b3ce38b13bf30d6267cb33b20
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@formatjs/intl-pluralrules@npm:6.3.12":
|
||||
version: 6.3.12
|
||||
resolution: "@formatjs/intl-pluralrules@npm:6.3.12"
|
||||
"@formatjs/intl-pluralrules@npm:6.3.11":
|
||||
version: 6.3.11
|
||||
resolution: "@formatjs/intl-pluralrules@npm:6.3.11"
|
||||
dependencies:
|
||||
"@formatjs/bigdecimal": "npm:0.2.7"
|
||||
"@formatjs/intl-localematcher": "npm:0.8.12"
|
||||
checksum: 10/23833b17822a648fc4e87dfe581733f303a8d1c4721a47e875994bcb56c5985c83ea01f0ab176557886fc6b2ef158ce50f9cafa1e1441badefddec0ea79c5303
|
||||
"@formatjs/bigdecimal": "npm:0.2.6"
|
||||
"@formatjs/intl-localematcher": "npm:0.8.11"
|
||||
checksum: 10/c5506c1a20f1aa7fdca24b6800243aec5cbacd5aa0de96d01b497fa9485f6affa7abf6b0d2ea04ebff0ec8a2d22a41913f4353b9fd9c6054899a9f168ef2ebd0
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@formatjs/intl-relativetimeformat@npm:12.3.12":
|
||||
version: 12.3.12
|
||||
resolution: "@formatjs/intl-relativetimeformat@npm:12.3.12"
|
||||
"@formatjs/intl-relativetimeformat@npm:12.3.11":
|
||||
version: 12.3.11
|
||||
resolution: "@formatjs/intl-relativetimeformat@npm:12.3.11"
|
||||
dependencies:
|
||||
"@formatjs/intl-localematcher": "npm:0.8.12"
|
||||
checksum: 10/c667ed62a13cbc8cd5d07f5dbc57a4d1b7e23c31f2aac0ef9e5f509feec014c5a2dfd77ac41a2c34fe537d6f1f6d8ebc8391a479a4294867bb4e980c7211f822
|
||||
"@formatjs/intl-localematcher": "npm:0.8.11"
|
||||
checksum: 10/ead23de113c9c28334ff2f8696e0f5ff0567f2b929def10d06e67dba2ca502967671abb1bb6ad06b200329e0215b469edd3c3ad9ed4259b313e97f2badfdab53
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@formatjs/intl-supportedvaluesof@npm:2.3.9":
|
||||
version: 2.3.9
|
||||
resolution: "@formatjs/intl-supportedvaluesof@npm:2.3.9"
|
||||
"@formatjs/intl-supportedvaluesof@npm:2.3.8":
|
||||
version: 2.3.8
|
||||
resolution: "@formatjs/intl-supportedvaluesof@npm:2.3.8"
|
||||
dependencies:
|
||||
"@formatjs/fast-memoize": "npm:3.1.7"
|
||||
checksum: 10/36254064dd8aab3cb2cbe31db88d751cb0c1d2b0739563ddd1736ed7bd8ec02881de385a69cc4ab7b691f670a3f11ec61a41ba678baa3d0967d87dc0b395cc7b
|
||||
"@formatjs/fast-memoize": "npm:3.1.6"
|
||||
checksum: 10/d9d4e4d5dda1c26c771d0f2746e1031f9695dee663fb2d2ef9f83794c1e8683de88caf6cdb9718022c592cc879c5c1afa7f72af4c77da9ce5e1d98b54267dfff
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
@@ -8681,9 +8681,9 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"eslint@npm:10.7.0":
|
||||
version: 10.7.0
|
||||
resolution: "eslint@npm:10.7.0"
|
||||
"eslint@npm:10.6.0":
|
||||
version: 10.6.0
|
||||
resolution: "eslint@npm:10.6.0"
|
||||
dependencies:
|
||||
"@eslint-community/eslint-utils": "npm:^4.8.0"
|
||||
"@eslint-community/regexpp": "npm:^4.12.2"
|
||||
@@ -8722,7 +8722,7 @@ __metadata:
|
||||
optional: true
|
||||
bin:
|
||||
eslint: bin/eslint.js
|
||||
checksum: 10/51cb70fbdd0de6586f0cb12625388faab18eb679cbdb523ecb631cae9f47fd9171d9ff02f570dc4d2e5700017908a81477511025ccb2a6603c5928fbfddcaebf
|
||||
checksum: 10/36d02cdbe37668e601f255917c605d10a5d06d278648dc72cec21fe23b7b60a53453241b32c382ba8107533cacba70aeec97581cc4f4bb591544b1ada2516335
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
@@ -9736,15 +9736,15 @@ __metadata:
|
||||
"@date-fns/tz": "npm:1.5.0"
|
||||
"@egjs/hammerjs": "npm:2.0.17"
|
||||
"@eslint/js": "npm:10.0.1"
|
||||
"@formatjs/intl-datetimeformat": "npm:7.5.0"
|
||||
"@formatjs/intl-displaynames": "npm:7.3.12"
|
||||
"@formatjs/intl-durationformat": "npm:0.10.17"
|
||||
"@formatjs/intl-getcanonicallocales": "npm:3.2.11"
|
||||
"@formatjs/intl-listformat": "npm:8.3.12"
|
||||
"@formatjs/intl-locale": "npm:5.3.10"
|
||||
"@formatjs/intl-numberformat": "npm:9.3.13"
|
||||
"@formatjs/intl-pluralrules": "npm:6.3.12"
|
||||
"@formatjs/intl-relativetimeformat": "npm:12.3.12"
|
||||
"@formatjs/intl-datetimeformat": "npm:7.4.10"
|
||||
"@formatjs/intl-displaynames": "npm:7.3.11"
|
||||
"@formatjs/intl-durationformat": "npm:0.10.16"
|
||||
"@formatjs/intl-getcanonicallocales": "npm:3.2.10"
|
||||
"@formatjs/intl-listformat": "npm:8.3.11"
|
||||
"@formatjs/intl-locale": "npm:5.3.9"
|
||||
"@formatjs/intl-numberformat": "npm:9.3.12"
|
||||
"@formatjs/intl-pluralrules": "npm:6.3.11"
|
||||
"@formatjs/intl-relativetimeformat": "npm:12.3.11"
|
||||
"@fullcalendar/core": "npm:6.1.21"
|
||||
"@fullcalendar/daygrid": "npm:6.1.21"
|
||||
"@fullcalendar/interaction": "npm:6.1.21"
|
||||
@@ -9814,7 +9814,7 @@ __metadata:
|
||||
dialog-polyfill: "npm:0.5.6"
|
||||
echarts: "npm:6.1.0"
|
||||
element-internals-polyfill: "npm:3.0.2"
|
||||
eslint: "npm:10.7.0"
|
||||
eslint: "npm:10.6.0"
|
||||
eslint-config-prettier: "npm:10.1.8"
|
||||
eslint-import-resolver-webpack: "npm:0.13.11"
|
||||
eslint-plugin-import-x: "npm:4.17.1"
|
||||
@@ -9838,7 +9838,7 @@ __metadata:
|
||||
html-minifier-terser: "npm:7.2.0"
|
||||
husky: "npm:9.1.7"
|
||||
idb-keyval: "npm:6.3.0"
|
||||
intl-messageformat: "npm:11.2.11"
|
||||
intl-messageformat: "npm:11.2.10"
|
||||
js-yaml: "npm:5.2.1"
|
||||
jsdom: "npm:29.1.1"
|
||||
jszip: "npm:3.10.1"
|
||||
@@ -10186,13 +10186,13 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"intl-messageformat@npm:11.2.11":
|
||||
version: 11.2.11
|
||||
resolution: "intl-messageformat@npm:11.2.11"
|
||||
"intl-messageformat@npm:11.2.10":
|
||||
version: 11.2.10
|
||||
resolution: "intl-messageformat@npm:11.2.10"
|
||||
dependencies:
|
||||
"@formatjs/fast-memoize": "npm:3.1.7"
|
||||
"@formatjs/icu-messageformat-parser": "npm:3.5.14"
|
||||
checksum: 10/5074494588d6c16fb2730234b3c8eb80799189c9c21485323b5c119c22d3a7a8b385868b46479b81c09ca350d82ea0eeaf82b5e9799911b9a1b8bddbb2c3ad1c
|
||||
"@formatjs/fast-memoize": "npm:3.1.6"
|
||||
"@formatjs/icu-messageformat-parser": "npm:3.5.13"
|
||||
checksum: 10/d3f751ebfe2015cf4a011afe5679398dfa7156c0bc2ccd76ba8c5aa4e82946bd663e750538c97a8b4c946a6d5f5d19fbb56b2e159d422c416e51bba9b10b2746
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
|
||||
Reference in New Issue
Block a user