mirror of
https://github.com/home-assistant/frontend.git
synced 2026-11-05 04:25:06 +00:00
Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c84f7870e5 | ||
|
|
34fcf666da | ||
|
|
bc5b0056a7 |
No files matched your search
@@ -163,9 +163,21 @@ export const findEntities = (
|
||||
return results;
|
||||
};
|
||||
|
||||
export const excludeEntities = (
|
||||
entityIds: string[],
|
||||
excludedEntityIds?: string[]
|
||||
): string[] => {
|
||||
if (!excludedEntityIds?.length) {
|
||||
return entityIds;
|
||||
}
|
||||
const excluded = new Set(excludedEntityIds);
|
||||
return entityIds.filter((entityId) => !excluded.has(entityId));
|
||||
};
|
||||
|
||||
export const findFilteredEntities = (
|
||||
sources: EntityFilterSources,
|
||||
filters: EntityFilter[]
|
||||
filters: EntityFilter[],
|
||||
excludedEntityIds?: string[]
|
||||
): string[] => {
|
||||
const filterFuncs = filters.map((filter) =>
|
||||
generateEntityFilter(
|
||||
@@ -177,5 +189,8 @@ export const findFilteredEntities = (
|
||||
filter
|
||||
)
|
||||
);
|
||||
return findEntities(Object.keys(sources.states), filterFuncs);
|
||||
return excludeEntities(
|
||||
findEntities(Object.keys(sources.states), filterFuncs),
|
||||
excludedEntityIds
|
||||
);
|
||||
};
|
||||
@@ -62,6 +62,10 @@ export class HaControlSelect extends LitElement {
|
||||
});
|
||||
}
|
||||
|
||||
public override focus(): void {
|
||||
this._focusOption(this._tabbableIndex);
|
||||
}
|
||||
|
||||
private _handleBlur(ev: FocusEvent) {
|
||||
// Only reset if focus is leaving the entire component
|
||||
if (!this.contains(ev.relatedTarget as Node)) {
|
||||
|
||||
@@ -49,6 +49,15 @@ export interface SecurityAlertEntityConfig {
|
||||
export interface SecurityFrontendSystemData {
|
||||
alert_entities?: SecurityAlertEntityConfig[];
|
||||
favorite_entities?: string[];
|
||||
hidden_entities?: string[];
|
||||
}
|
||||
|
||||
export interface MaintenanceFrontendSystemData {
|
||||
hidden_entities?: string[];
|
||||
}
|
||||
|
||||
export interface ClimateFrontendSystemData {
|
||||
hidden_entities?: string[];
|
||||
}
|
||||
|
||||
export interface EnergyFrontendSystemData {
|
||||
@@ -69,6 +78,8 @@ declare global {
|
||||
home: HomeFrontendSystemData;
|
||||
energy: EnergyFrontendSystemData;
|
||||
security: SecurityFrontendSystemData;
|
||||
maintenance: MaintenanceFrontendSystemData;
|
||||
climate: ClimateFrontendSystemData;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -145,6 +156,32 @@ export const saveFrontendSystemData = async <
|
||||
value,
|
||||
});
|
||||
|
||||
const pendingSystemDataSaves = new Map<ValidSystemDataKey, Promise<void>>();
|
||||
|
||||
export const queueFrontendSystemDataSave = <
|
||||
SystemDataKey extends ValidSystemDataKey,
|
||||
>(
|
||||
conn: Connection,
|
||||
key: SystemDataKey,
|
||||
value: FrontendSystemData[SystemDataKey]
|
||||
): Promise<void> => {
|
||||
const save = (pendingSystemDataSaves.get(key) ?? Promise.resolve())
|
||||
.catch(() => undefined)
|
||||
.then(() => saveFrontendSystemData(conn, key, value));
|
||||
pendingSystemDataSaves.set(key, save);
|
||||
return save;
|
||||
};
|
||||
|
||||
export const fetchFrontendSystemDataAfterSaves = async <
|
||||
SystemDataKey extends ValidSystemDataKey,
|
||||
>(
|
||||
conn: Connection,
|
||||
key: SystemDataKey
|
||||
): Promise<FrontendSystemData[SystemDataKey] | null> => {
|
||||
await pendingSystemDataSaves.get(key)?.catch(() => undefined);
|
||||
return fetchFrontendSystemData(conn, key);
|
||||
};
|
||||
|
||||
export const subscribeFrontendSystemData = <
|
||||
SystemDataKey extends ValidSystemDataKey,
|
||||
>(
|
||||
|
||||
@@ -1,18 +1,28 @@
|
||||
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 type { HASSDomEvent } from "../../common/dom/fire_event";
|
||||
import { debounce } from "../../common/util/debounce";
|
||||
import { deepEqual } from "../../common/util/deep-equal";
|
||||
import "../../components/ha-button";
|
||||
import "../../components/ha-icon-button";
|
||||
import "../../components/ha-top-app-bar-fixed";
|
||||
import {
|
||||
fetchFrontendSystemDataAfterSaves,
|
||||
queueFrontendSystemDataSave,
|
||||
type ClimateFrontendSystemData,
|
||||
} from "../../data/frontend";
|
||||
import type { LovelaceStrategyViewConfig } from "../../data/lovelace/config/view";
|
||||
import { ChildPanelReady } from "../../layouts/panel-ready";
|
||||
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 "../lovelace/views/hui-view";
|
||||
import "../lovelace/views/hui-view-background";
|
||||
import "../lovelace/views/hui-view-container";
|
||||
import "../../components/ha-top-app-bar-fixed";
|
||||
|
||||
const CLIMATE_LOVELACE_VIEW_CONFIG: LovelaceStrategyViewConfig = {
|
||||
strategy: {
|
||||
@@ -30,10 +40,20 @@ class PanelClimate extends LitElement {
|
||||
|
||||
@state() private _lovelace?: Lovelace;
|
||||
|
||||
@state() private _config?: ClimateFrontendSystemData;
|
||||
|
||||
@state() private _editVisibility = false;
|
||||
|
||||
@state() private _searchParams = new URLSearchParams(window.location.search);
|
||||
|
||||
private _childPanelReady?: ChildPanelReady;
|
||||
|
||||
private _loadConfigPromise?: Promise<void>;
|
||||
|
||||
private _loadConfigRevision = 0;
|
||||
|
||||
private _setLovelaceRevision = 0;
|
||||
|
||||
public willUpdate(changedProps: PropertyValues<this>) {
|
||||
super.willUpdate(changedProps);
|
||||
// Initial setup
|
||||
@@ -47,13 +67,22 @@ class PanelClimate extends LitElement {
|
||||
}
|
||||
|
||||
const oldHass = changedProps.get("hass") as this["hass"];
|
||||
if (
|
||||
oldHass &&
|
||||
this.hass.config.state === "RUNNING" &&
|
||||
oldHass.config.state !== "RUNNING"
|
||||
) {
|
||||
this._setup();
|
||||
return;
|
||||
}
|
||||
|
||||
if (oldHass && oldHass.localize !== this.hass.localize) {
|
||||
this._setLovelace();
|
||||
return;
|
||||
}
|
||||
|
||||
if (oldHass && this.hass) {
|
||||
// If the entity registry changed, ask the user if they want to refresh the config
|
||||
// Refresh the generated view when registries or panels change.
|
||||
if (
|
||||
oldHass.entities !== this.hass.entities ||
|
||||
oldHass.devices !== this.hass.devices ||
|
||||
@@ -63,24 +92,46 @@ class PanelClimate extends LitElement {
|
||||
) {
|
||||
if (this.hass.config.state === "RUNNING") {
|
||||
this._debounceRegistriesChanged();
|
||||
return;
|
||||
}
|
||||
}
|
||||
// If ha started, refresh the config
|
||||
if (
|
||||
this.hass.config.state === "RUNNING" &&
|
||||
oldHass.config.state !== "RUNNING"
|
||||
) {
|
||||
this._setLovelace();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async _setup() {
|
||||
await this.hass.loadFragmentTranslation("lovelace");
|
||||
this._loadConfigPromise = this._loadConfig();
|
||||
await Promise.all([
|
||||
this.hass.loadFragmentTranslation("lovelace"),
|
||||
this._loadConfigPromise,
|
||||
]);
|
||||
this._setLovelace();
|
||||
}
|
||||
|
||||
private async _loadConfig() {
|
||||
const revision = ++this._loadConfigRevision;
|
||||
this._config = undefined;
|
||||
try {
|
||||
const data = await fetchFrontendSystemDataAfterSaves(
|
||||
this.hass.connection,
|
||||
"climate"
|
||||
);
|
||||
if (revision !== this._loadConfigRevision) {
|
||||
return;
|
||||
}
|
||||
this._config = data || {};
|
||||
} catch (err) {
|
||||
if (revision !== this._loadConfigRevision) {
|
||||
return;
|
||||
}
|
||||
// eslint-disable-next-line no-console
|
||||
console.error("Failed to load climate configuration:", err);
|
||||
showToast(this, {
|
||||
message: this.hass.localize("ui.panel.climate.editor.load_failed"),
|
||||
duration: 0,
|
||||
dismissable: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private _debounceRegistriesChanged = debounce(
|
||||
() => this._registriesChanged(),
|
||||
200
|
||||
@@ -97,6 +148,27 @@ class PanelClimate extends LitElement {
|
||||
.backButton=${this._searchParams.has("historyBack")}
|
||||
>
|
||||
<div slot="title">${this.hass.localize("panel.climate")}</div>
|
||||
${
|
||||
!this.hass.user?.is_admin
|
||||
? nothing
|
||||
: this._editVisibility
|
||||
? html`<ha-button
|
||||
slot="actionItems"
|
||||
appearance="filled"
|
||||
size="s"
|
||||
@click=${this._stopEditVisibility}
|
||||
>
|
||||
${this.hass.localize("ui.panel.lovelace.menu.exit_edit_mode")}
|
||||
</ha-button>`
|
||||
: this._config
|
||||
? html`<ha-icon-button
|
||||
slot="actionItems"
|
||||
.path=${mdiPencil}
|
||||
.label=${this.hass.localize("ui.panel.climate.editor.title")}
|
||||
@click=${this._startEditVisibility}
|
||||
></ha-icon-button>`
|
||||
: nothing
|
||||
}
|
||||
${
|
||||
this._lovelace
|
||||
? html`
|
||||
@@ -108,6 +180,7 @@ class PanelClimate extends LitElement {
|
||||
.narrow=${this.narrow}
|
||||
.lovelace=${this._lovelace}
|
||||
.index=${this._viewIndex}
|
||||
@entity-visibility-changed=${this._visibilityChanged}
|
||||
></hui-view
|
||||
></hui-view-container>
|
||||
`
|
||||
@@ -118,11 +191,26 @@ class PanelClimate extends LitElement {
|
||||
}
|
||||
|
||||
private async _setLovelace() {
|
||||
const revision = ++this._setLovelaceRevision;
|
||||
if (this._loadConfigPromise) {
|
||||
await this._loadConfigPromise;
|
||||
}
|
||||
|
||||
const viewConfig = await generateLovelaceViewStrategy(
|
||||
CLIMATE_LOVELACE_VIEW_CONFIG,
|
||||
{
|
||||
strategy: {
|
||||
...CLIMATE_LOVELACE_VIEW_CONFIG.strategy,
|
||||
hidden_entities: this._config?.hidden_entities,
|
||||
edit_visibility: this._editVisibility || undefined,
|
||||
},
|
||||
},
|
||||
this.hass
|
||||
);
|
||||
|
||||
if (revision !== this._setLovelaceRevision) {
|
||||
return;
|
||||
}
|
||||
|
||||
const config = { views: [viewConfig] };
|
||||
const rawConfig = { views: [CLIMATE_LOVELACE_VIEW_CONFIG] };
|
||||
|
||||
@@ -146,6 +234,61 @@ class PanelClimate extends LitElement {
|
||||
};
|
||||
}
|
||||
|
||||
private _startEditVisibility = () => {
|
||||
this._editVisibility = true;
|
||||
this._setLovelace();
|
||||
};
|
||||
|
||||
private _stopEditVisibility = () => {
|
||||
this._editVisibility = false;
|
||||
if (!this._config) {
|
||||
this._loadConfigPromise = this._loadConfig();
|
||||
}
|
||||
this._setLovelace();
|
||||
};
|
||||
|
||||
private _visibilityChanged = (
|
||||
ev: HASSDomEvent<HASSDomEvents["entity-visibility-changed"]>
|
||||
) => {
|
||||
if (!this._config) {
|
||||
return;
|
||||
}
|
||||
const { entity, hidden } = ev.detail;
|
||||
const hiddenEntities = (this._config.hidden_entities ?? []).filter(
|
||||
(entityId) => entityId !== entity
|
||||
);
|
||||
if (hidden) {
|
||||
hiddenEntities.push(entity);
|
||||
}
|
||||
this._saveConfig({
|
||||
...this._config,
|
||||
hidden_entities: hiddenEntities.length ? hiddenEntities : undefined,
|
||||
});
|
||||
};
|
||||
|
||||
private _saveConfig(config: ClimateFrontendSystemData): Promise<void> {
|
||||
this._config = config;
|
||||
this._setLovelace();
|
||||
return queueFrontendSystemDataSave(
|
||||
this.hass.connection,
|
||||
"climate",
|
||||
config
|
||||
).catch(async (err) => {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error("Failed to save climate configuration:", err);
|
||||
showToast(this, {
|
||||
message: this.hass.localize("ui.panel.climate.editor.save_failed"),
|
||||
duration: 0,
|
||||
dismissable: true,
|
||||
});
|
||||
if (config !== this._config) {
|
||||
return;
|
||||
}
|
||||
await this._loadConfig();
|
||||
await this._setLovelace();
|
||||
});
|
||||
}
|
||||
|
||||
static get styles(): CSSResultGroup {
|
||||
return [
|
||||
haStyle,
|
||||
|
||||
@@ -2,9 +2,11 @@ import { ReactiveElement } from "lit";
|
||||
import { customElement } from "lit/decorators";
|
||||
import { getAreasFloorHierarchy } from "../../../common/areas/areas-floor-hierarchy";
|
||||
import {
|
||||
findEntities,
|
||||
excludeEntities,
|
||||
findFilteredEntities,
|
||||
generateEntityFilter,
|
||||
type EntityFilter,
|
||||
type EntityFilterSources,
|
||||
} from "../../../common/entity/entity_filter";
|
||||
import { floorDefaultIcon } from "../../../components/ha-floor-icon";
|
||||
import type { LovelaceCardConfig } from "../../../data/lovelace/config/card";
|
||||
@@ -12,9 +14,15 @@ import type { LovelaceSectionRawConfig } from "../../../data/lovelace/config/sec
|
||||
import type { LovelaceViewConfig } from "../../../data/lovelace/config/view";
|
||||
import type { HomeAssistant } from "../../../types";
|
||||
import { computeAreaTileCardConfig } from "../../lovelace/strategies/areas/helpers/areas-strategy-helper";
|
||||
import {
|
||||
wrapEntityVisibility,
|
||||
type ComputeEntityCard,
|
||||
} from "../../lovelace/strategies/helpers/entity-visibility";
|
||||
|
||||
export interface ClimateViewStrategyConfig {
|
||||
type: "climate";
|
||||
hidden_entities?: string[];
|
||||
edit_visibility?: boolean;
|
||||
}
|
||||
|
||||
export const climateEntityFilters: EntityFilter[] = [
|
||||
@@ -42,40 +50,45 @@ export const climateEntityFilters: EntityFilter[] = [
|
||||
},
|
||||
];
|
||||
|
||||
export const hasClimateEntities = (hass: HomeAssistant): boolean => {
|
||||
const hasAreaSensor = Object.values(hass.areas).some(
|
||||
(area) =>
|
||||
(area.temperature_entity_id && hass.states[area.temperature_entity_id]) ||
|
||||
(area.humidity_entity_id && hass.states[area.humidity_entity_id])
|
||||
);
|
||||
export const findClimateEntities = (
|
||||
sources: EntityFilterSources,
|
||||
hiddenEntities?: string[]
|
||||
): string[] =>
|
||||
findFilteredEntities(sources, climateEntityFilters, hiddenEntities);
|
||||
|
||||
if (hasAreaSensor) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const entityIds = Object.keys(hass.states);
|
||||
|
||||
return climateEntityFilters.some((filter) =>
|
||||
entityIds.some(
|
||||
generateEntityFilter(
|
||||
hass.states,
|
||||
hass.entities,
|
||||
hass.devices,
|
||||
hass.areas,
|
||||
hass.floors,
|
||||
filter
|
||||
export const findClimateAreaSensors = (
|
||||
sources: EntityFilterSources,
|
||||
hiddenEntities?: string[]
|
||||
): string[] =>
|
||||
excludeEntities(
|
||||
Object.values(sources.areas).flatMap((area) =>
|
||||
[area.temperature_entity_id, area.humidity_entity_id].filter(
|
||||
(entityId): entityId is string =>
|
||||
!!entityId && entityId in sources.states
|
||||
)
|
||||
)
|
||||
),
|
||||
hiddenEntities
|
||||
);
|
||||
};
|
||||
|
||||
export const hasClimateEntities = (
|
||||
hass: HomeAssistant,
|
||||
hiddenEntities?: string[]
|
||||
): boolean =>
|
||||
findClimateAreaSensors(hass, hiddenEntities).length > 0 ||
|
||||
findClimateEntities(hass, hiddenEntities).length > 0;
|
||||
|
||||
interface ClimateCardOptions {
|
||||
entities: string[];
|
||||
showAreaSensor: (entityId: string) => boolean;
|
||||
computeCard: ComputeEntityCard;
|
||||
}
|
||||
|
||||
const processAreasForClimate = (
|
||||
areaIds: string[],
|
||||
hass: HomeAssistant,
|
||||
entities: string[]
|
||||
{ entities, showAreaSensor, computeCard }: ClimateCardOptions
|
||||
): LovelaceCardConfig[] => {
|
||||
const cards: LovelaceCardConfig[] = [];
|
||||
const computeTileCard = computeAreaTileCardConfig(hass, true);
|
||||
|
||||
for (const areaId of areaIds) {
|
||||
const area = hass.areas[areaId];
|
||||
@@ -96,25 +109,21 @@ const processAreasForClimate = (
|
||||
|
||||
// Add temperature and humidity sensors with trend graphs for areas
|
||||
const temperatureEntityId = area.temperature_entity_id;
|
||||
if (temperatureEntityId && hass.states[temperatureEntityId]) {
|
||||
areaCards.push({
|
||||
...computeTileCard(temperatureEntityId),
|
||||
name:
|
||||
hass.localize("component.sensor.entity_component.temperature.name") ||
|
||||
"Temperature",
|
||||
features: [{ type: "trend-graph" }],
|
||||
});
|
||||
if (
|
||||
temperatureEntityId &&
|
||||
hass.states[temperatureEntityId] &&
|
||||
showAreaSensor(temperatureEntityId)
|
||||
) {
|
||||
areaCards.push(computeCard(temperatureEntityId));
|
||||
}
|
||||
|
||||
const humidityEntityId = area.humidity_entity_id;
|
||||
if (humidityEntityId && hass.states[humidityEntityId]) {
|
||||
areaCards.push({
|
||||
...computeTileCard(humidityEntityId),
|
||||
name:
|
||||
hass.localize("component.sensor.entity_component.humidity.name") ||
|
||||
"Humidity",
|
||||
features: [{ type: "trend-graph" }],
|
||||
});
|
||||
if (
|
||||
humidityEntityId &&
|
||||
hass.states[humidityEntityId] &&
|
||||
showAreaSensor(humidityEntityId)
|
||||
) {
|
||||
areaCards.push(computeCard(humidityEntityId));
|
||||
}
|
||||
|
||||
// Add other climate entities
|
||||
@@ -124,18 +133,7 @@ const processAreasForClimate = (
|
||||
continue;
|
||||
}
|
||||
|
||||
const state = hass.states[entityId];
|
||||
if (
|
||||
state?.attributes.device_class === "temperature" ||
|
||||
state?.attributes.device_class === "humidity"
|
||||
) {
|
||||
areaCards.push({
|
||||
...computeTileCard(entityId),
|
||||
features: [{ type: "trend-graph" }],
|
||||
});
|
||||
} else {
|
||||
areaCards.push(computeTileCard(entityId));
|
||||
}
|
||||
areaCards.push(computeCard(entityId));
|
||||
}
|
||||
|
||||
if (areaCards.length > 0) {
|
||||
@@ -159,7 +157,7 @@ const processAreasForClimate = (
|
||||
|
||||
const processUnassignedEntities = (
|
||||
hass: HomeAssistant,
|
||||
entities: string[]
|
||||
{ entities, computeCard }: ClimateCardOptions
|
||||
): LovelaceCardConfig[] => {
|
||||
const unassignedFilter = generateEntityFilter(
|
||||
hass.states,
|
||||
@@ -173,10 +171,9 @@ const processUnassignedEntities = (
|
||||
);
|
||||
const unassignedEntities = entities.filter(unassignedFilter);
|
||||
const areaCards: LovelaceCardConfig[] = [];
|
||||
const computeTileCard = computeAreaTileCardConfig(hass, true);
|
||||
|
||||
for (const entityId of unassignedEntities) {
|
||||
areaCards.push(computeTileCard(entityId));
|
||||
areaCards.push(computeCard(entityId));
|
||||
}
|
||||
|
||||
return areaCards;
|
||||
@@ -185,7 +182,7 @@ const processUnassignedEntities = (
|
||||
@customElement("climate-view-strategy")
|
||||
export class ClimateViewStrategy extends ReactiveElement {
|
||||
static async generate(
|
||||
_config: ClimateViewStrategyConfig,
|
||||
config: ClimateViewStrategyConfig,
|
||||
hass: HomeAssistant
|
||||
): Promise<LovelaceViewConfig> {
|
||||
const areas = Object.values(hass.areas);
|
||||
@@ -194,20 +191,41 @@ export class ClimateViewStrategy extends ReactiveElement {
|
||||
|
||||
const sections: LovelaceSectionRawConfig[] = [];
|
||||
|
||||
const allEntities = Object.keys(hass.states);
|
||||
|
||||
const climateFilters = climateEntityFilters.map((filter) =>
|
||||
generateEntityFilter(
|
||||
hass.states,
|
||||
hass.entities,
|
||||
hass.devices,
|
||||
hass.areas,
|
||||
hass.floors,
|
||||
filter
|
||||
)
|
||||
const hiddenEntities = new Set(config.hidden_entities);
|
||||
const editVisibility = Boolean(config.edit_visibility);
|
||||
const entities = findClimateEntities(
|
||||
hass,
|
||||
editVisibility ? undefined : config.hidden_entities
|
||||
);
|
||||
|
||||
const entities = findEntities(allEntities, climateFilters);
|
||||
const areaSensorIds = new Set(findClimateAreaSensors(hass));
|
||||
const computeTileCard = computeAreaTileCardConfig(hass, true);
|
||||
const computeClimateCard = (entityId: string): LovelaceCardConfig => {
|
||||
const tileCard = computeTileCard(entityId);
|
||||
const deviceClass = hass.states[entityId]?.attributes.device_class;
|
||||
if (areaSensorIds.has(entityId)) {
|
||||
return {
|
||||
...tileCard,
|
||||
name:
|
||||
hass.localize(
|
||||
`component.sensor.entity_component.${deviceClass}.name`
|
||||
) || undefined,
|
||||
features: [{ type: "trend-graph" }],
|
||||
};
|
||||
}
|
||||
if (deviceClass === "temperature" || deviceClass === "humidity") {
|
||||
return { ...tileCard, features: [{ type: "trend-graph" }] };
|
||||
}
|
||||
return tileCard;
|
||||
};
|
||||
const cardOptions: ClimateCardOptions = {
|
||||
entities,
|
||||
showAreaSensor: (entityId) =>
|
||||
editVisibility || !hiddenEntities.has(entityId),
|
||||
computeCard: editVisibility
|
||||
? wrapEntityVisibility(hiddenEntities, computeClimateCard)
|
||||
: computeClimateCard,
|
||||
};
|
||||
|
||||
const floorCount =
|
||||
hierarchy.floors.length + (hierarchy.areas.length ? 1 : 0);
|
||||
@@ -233,7 +251,7 @@ export class ClimateViewStrategy extends ReactiveElement {
|
||||
],
|
||||
};
|
||||
|
||||
const areaCards = processAreasForClimate(areaIds, hass, entities);
|
||||
const areaCards = processAreasForClimate(areaIds, hass, cardOptions);
|
||||
|
||||
if (areaCards.length > 0) {
|
||||
section.cards!.push(...areaCards);
|
||||
@@ -257,7 +275,11 @@ export class ClimateViewStrategy extends ReactiveElement {
|
||||
],
|
||||
};
|
||||
|
||||
const areaCards = processAreasForClimate(hierarchy.areas, hass, entities);
|
||||
const areaCards = processAreasForClimate(
|
||||
hierarchy.areas,
|
||||
hass,
|
||||
cardOptions
|
||||
);
|
||||
|
||||
if (areaCards.length > 0) {
|
||||
section.cards!.push(...areaCards);
|
||||
@@ -266,7 +288,7 @@ export class ClimateViewStrategy extends ReactiveElement {
|
||||
}
|
||||
|
||||
// Process unassigned entities
|
||||
const unassignedCards = processUnassignedEntities(hass, entities);
|
||||
const unassignedCards = processUnassignedEntities(hass, cardOptions);
|
||||
|
||||
if (unassignedCards.length > 0) {
|
||||
const section: LovelaceSectionRawConfig = {
|
||||
|
||||
@@ -16,7 +16,9 @@ import {
|
||||
fetchFrontendSystemData,
|
||||
saveFrontendSystemData,
|
||||
subscribeFrontendSystemData,
|
||||
type ClimateFrontendSystemData,
|
||||
type HomeFrontendSystemData,
|
||||
type MaintenanceFrontendSystemData,
|
||||
type SecurityFrontendSystemData,
|
||||
} from "../../data/frontend";
|
||||
import type { LovelaceDashboardStrategyConfig } from "../../data/lovelace/config/types";
|
||||
@@ -54,6 +56,10 @@ class PanelHome extends SubscribeMixin(LitElement) {
|
||||
|
||||
@state() private _securityConfig: SecurityFrontendSystemData = {};
|
||||
|
||||
@state() private _maintenanceConfig: MaintenanceFrontendSystemData = {};
|
||||
|
||||
@state() private _climateConfig: ClimateFrontendSystemData = {};
|
||||
|
||||
@state() private _extraActionItems?: ExtraActionItem[];
|
||||
|
||||
@query(".banner") private _banner?: HTMLElement;
|
||||
@@ -62,6 +68,10 @@ class PanelHome extends SubscribeMixin(LitElement) {
|
||||
|
||||
private _securityConfigRevision = 0;
|
||||
|
||||
private _maintenanceConfigRevision = 0;
|
||||
|
||||
private _climateConfigRevision = 0;
|
||||
|
||||
public hassSubscribe() {
|
||||
return [
|
||||
subscribeFrontendSystemData(
|
||||
@@ -79,6 +89,36 @@ class PanelHome extends SubscribeMixin(LitElement) {
|
||||
}
|
||||
}
|
||||
),
|
||||
subscribeFrontendSystemData(
|
||||
this.hass.connection,
|
||||
"maintenance",
|
||||
({ value }) => {
|
||||
this._maintenanceConfigRevision++;
|
||||
const config = value || {};
|
||||
if (deepEqual(config, this._maintenanceConfig)) {
|
||||
return;
|
||||
}
|
||||
this._maintenanceConfig = config;
|
||||
if (this.hasUpdated) {
|
||||
this._setLovelace();
|
||||
}
|
||||
}
|
||||
),
|
||||
subscribeFrontendSystemData(
|
||||
this.hass.connection,
|
||||
"climate",
|
||||
({ value }) => {
|
||||
this._climateConfigRevision++;
|
||||
const config = value || {};
|
||||
if (deepEqual(config, this._climateConfig)) {
|
||||
return;
|
||||
}
|
||||
this._climateConfig = config;
|
||||
if (this.hasUpdated) {
|
||||
this._setLovelace();
|
||||
}
|
||||
}
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -179,12 +219,21 @@ class PanelHome extends SubscribeMixin(LitElement) {
|
||||
|
||||
private async _loadConfig() {
|
||||
const securityConfigRevision = this._securityConfigRevision;
|
||||
const [translationsResult, homeResult, securityResult] =
|
||||
await Promise.allSettled([
|
||||
this.hass.loadFragmentTranslation("lovelace"),
|
||||
fetchFrontendSystemData(this.hass.connection, "home"),
|
||||
fetchFrontendSystemData(this.hass.connection, "security"),
|
||||
]);
|
||||
const maintenanceConfigRevision = this._maintenanceConfigRevision;
|
||||
const climateConfigRevision = this._climateConfigRevision;
|
||||
const [
|
||||
translationsResult,
|
||||
homeResult,
|
||||
securityResult,
|
||||
maintenanceResult,
|
||||
climateResult,
|
||||
] = await Promise.allSettled([
|
||||
this.hass.loadFragmentTranslation("lovelace"),
|
||||
fetchFrontendSystemData(this.hass.connection, "home"),
|
||||
fetchFrontendSystemData(this.hass.connection, "security"),
|
||||
fetchFrontendSystemData(this.hass.connection, "maintenance"),
|
||||
fetchFrontendSystemData(this.hass.connection, "climate"),
|
||||
]);
|
||||
|
||||
if (translationsResult.status === "rejected") {
|
||||
// eslint-disable-next-line no-console
|
||||
@@ -214,6 +263,32 @@ class PanelHome extends SubscribeMixin(LitElement) {
|
||||
} else if (securityConfigRevision === this._securityConfigRevision) {
|
||||
this._securityConfig = securityResult.value || {};
|
||||
}
|
||||
|
||||
if (maintenanceResult.status === "rejected") {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error(
|
||||
"Failed to load maintenance configuration:",
|
||||
maintenanceResult.reason
|
||||
);
|
||||
if (maintenanceConfigRevision === this._maintenanceConfigRevision) {
|
||||
this._maintenanceConfig = {};
|
||||
}
|
||||
} else if (maintenanceConfigRevision === this._maintenanceConfigRevision) {
|
||||
this._maintenanceConfig = maintenanceResult.value || {};
|
||||
}
|
||||
|
||||
if (climateResult.status === "rejected") {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error(
|
||||
"Failed to load climate configuration:",
|
||||
climateResult.reason
|
||||
);
|
||||
if (climateConfigRevision === this._climateConfigRevision) {
|
||||
this._climateConfig = {};
|
||||
}
|
||||
} else if (climateConfigRevision === this._climateConfigRevision) {
|
||||
this._climateConfig = climateResult.value || {};
|
||||
}
|
||||
}
|
||||
|
||||
private _debounceRegenerateStrategy = debounce(
|
||||
@@ -400,6 +475,9 @@ class PanelHome extends SubscribeMixin(LitElement) {
|
||||
strategy: {
|
||||
type: "home",
|
||||
alert_entities: this._securityConfig.alert_entities,
|
||||
security_hidden_entities: this._securityConfig.hidden_entities,
|
||||
maintenance_hidden_entities: this._maintenanceConfig.hidden_entities,
|
||||
climate_hidden_entities: this._climateConfig.hidden_entities,
|
||||
favorite_entities: this._config?.favorite_entities,
|
||||
home_panel: true,
|
||||
hide_welcome_message: this._config?.hide_welcome_message,
|
||||
|
||||
@@ -0,0 +1,481 @@
|
||||
import {
|
||||
mdiBell,
|
||||
mdiBellOutline,
|
||||
mdiBellRing,
|
||||
mdiEye,
|
||||
mdiEyeOff,
|
||||
mdiStar,
|
||||
mdiStarOutline,
|
||||
} from "@mdi/js";
|
||||
import type { CSSResultGroup, PropertyValues } from "lit";
|
||||
import { LitElement, css, html, nothing } from "lit";
|
||||
import { customElement, property, query, state } from "lit/decorators";
|
||||
import { classMap } from "lit/directives/class-map";
|
||||
import { ifDefined } from "lit/directives/if-defined";
|
||||
import memoizeOne from "memoize-one";
|
||||
import { fireEvent } from "../../../common/dom/fire_event";
|
||||
import "../../../components/ha-control-select";
|
||||
import type { ControlSelectOption } from "../../../components/ha-control-select";
|
||||
import "../../../components/ha-svg-icon";
|
||||
import type { SecurityAlertSeverity } from "../../../data/frontend";
|
||||
import type { LocalizeFunc } from "../../../common/translations/localize";
|
||||
import type { HomeAssistant, ValueChangedEvent } from "../../../types";
|
||||
import "./hui-card";
|
||||
import type { HuiCard } from "./hui-card";
|
||||
import type { LovelaceCard, LovelaceGridOptions } from "../types";
|
||||
import { createEntityNotFoundWarning } from "../components/hui-warning";
|
||||
import type {
|
||||
EntityVisibilityAlertLevel,
|
||||
EntityVisibilityCardConfig,
|
||||
} from "./types";
|
||||
|
||||
const ALERT_LEVELS: EntityVisibilityAlertLevel[] = ["none", "warning", "alert"];
|
||||
|
||||
type EntityVisibilityControl = "hidden" | "favorite" | "alert";
|
||||
|
||||
interface PendingFocus {
|
||||
key: string;
|
||||
control: EntityVisibilityControl;
|
||||
source: HuiEntityVisibilityCard;
|
||||
}
|
||||
|
||||
let pendingFocus: PendingFocus | undefined;
|
||||
|
||||
const computeFocusKey = (
|
||||
entity: string,
|
||||
onPage: boolean,
|
||||
alertSelect: boolean
|
||||
): string => `${entity}|${onPage}|${alertSelect}`;
|
||||
|
||||
export const clearEntityVisibilityFocus = (): void => {
|
||||
pendingFocus = undefined;
|
||||
};
|
||||
|
||||
const ALERT_ICONS: Record<EntityVisibilityAlertLevel, string> = {
|
||||
none: mdiBellOutline,
|
||||
warning: mdiBell,
|
||||
alert: mdiBellRing,
|
||||
};
|
||||
|
||||
@customElement("hui-entity-visibility-card")
|
||||
export class HuiEntityVisibilityCard
|
||||
extends LitElement
|
||||
implements LovelaceCard
|
||||
{
|
||||
@property({ attribute: false }) public hass?: HomeAssistant;
|
||||
|
||||
@property({ type: Boolean }) public preview = false;
|
||||
|
||||
@state() private _config?: EntityVisibilityCardConfig;
|
||||
|
||||
@query(".control") private _hiddenControl?: HTMLButtonElement;
|
||||
|
||||
@query(".favorite-control") private _favoriteControl?: HTMLButtonElement;
|
||||
|
||||
@query(".alert-control") private _alertControl?: HTMLElement;
|
||||
|
||||
private _card?: HuiCard;
|
||||
|
||||
public setConfig(config: EntityVisibilityCardConfig): void {
|
||||
if (!config.entity) {
|
||||
throw new Error("No entity configured");
|
||||
}
|
||||
if (!config.card) {
|
||||
throw new Error("No card configured");
|
||||
}
|
||||
this._config = config;
|
||||
this._card = document.createElement("hui-card");
|
||||
this._card.hass = this.hass;
|
||||
this._card.preview = this.preview;
|
||||
this._card.config = config.card;
|
||||
this._card.inert = true;
|
||||
this._card.load();
|
||||
}
|
||||
|
||||
public getCardSize(): number | Promise<number> {
|
||||
return this._card?.getCardSize() ?? 1;
|
||||
}
|
||||
|
||||
public getGridOptions(): LovelaceGridOptions {
|
||||
return this._card?.getGridOptions() ?? {};
|
||||
}
|
||||
|
||||
protected willUpdate(changedProps: PropertyValues<this>): void {
|
||||
super.willUpdate(changedProps);
|
||||
if (!this._card) {
|
||||
return;
|
||||
}
|
||||
if (changedProps.has("hass")) {
|
||||
this._card.hass = this.hass;
|
||||
}
|
||||
if (changedProps.has("preview")) {
|
||||
this._card.preview = this.preview;
|
||||
}
|
||||
}
|
||||
|
||||
protected updated(changedProps: PropertyValues<this>): void {
|
||||
super.updated(changedProps);
|
||||
if (
|
||||
!this._config ||
|
||||
!pendingFocus ||
|
||||
pendingFocus.source === this ||
|
||||
pendingFocus.key !== this._focusKey()
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const { control } = pendingFocus;
|
||||
pendingFocus = undefined;
|
||||
const element =
|
||||
control === "hidden"
|
||||
? this._hiddenControl
|
||||
: control === "favorite"
|
||||
? this._favoriteControl
|
||||
: this._alertControl;
|
||||
element?.focus();
|
||||
}
|
||||
|
||||
protected render() {
|
||||
if (!this._config || !this.hass || !this._card) {
|
||||
return nothing;
|
||||
}
|
||||
const { localize } = this.hass;
|
||||
const canHide = this._config.hidden !== undefined;
|
||||
const alertSelect = Boolean(this._config.alert_select);
|
||||
const hidden = Boolean(this._config.hidden);
|
||||
const favorite = this._config.favorite;
|
||||
const alert = this._config.alert;
|
||||
const stateObj = this.hass.states[this._config.entity];
|
||||
const name = stateObj
|
||||
? this.hass.formatEntityName(stateObj)
|
||||
: this._config.entity;
|
||||
const alertLabel =
|
||||
alert !== undefined
|
||||
? localize("ui.card.entity-visibility.alert_level", {
|
||||
label: name,
|
||||
level: localize(`ui.card.entity-visibility.alert_levels.${alert}`),
|
||||
})
|
||||
: undefined;
|
||||
return html`
|
||||
<div class="wrapper" ?hidden-entity=${hidden}>
|
||||
${
|
||||
stateObj
|
||||
? this._card
|
||||
: html`
|
||||
<hui-warning>
|
||||
<div class="missing">
|
||||
<span class="missing-entity">${this._config.entity}</span>
|
||||
<span>
|
||||
${createEntityNotFoundWarning(localize, this.hass.config)}
|
||||
</span>
|
||||
</div>
|
||||
</hui-warning>
|
||||
`
|
||||
}
|
||||
${
|
||||
canHide
|
||||
? html`
|
||||
<button
|
||||
class="control"
|
||||
aria-label=${localize(
|
||||
`ui.components.items-display-editor.${hidden ? "show" : "hide"}`,
|
||||
{ label: name }
|
||||
)}
|
||||
@click=${this._toggleHidden}
|
||||
></button>
|
||||
`
|
||||
: nothing
|
||||
}
|
||||
<div class="actions">
|
||||
${
|
||||
alert === undefined
|
||||
? nothing
|
||||
: !alertSelect
|
||||
? html`
|
||||
<button
|
||||
class=${classMap({
|
||||
action: true,
|
||||
"alert-control": true,
|
||||
[alert]: true,
|
||||
})}
|
||||
aria-label=${ifDefined(alertLabel)}
|
||||
@click=${this._cycleAlert}
|
||||
>
|
||||
<ha-svg-icon .path=${ALERT_ICONS[alert]}></ha-svg-icon>
|
||||
</button>
|
||||
`
|
||||
: html`
|
||||
<ha-control-select
|
||||
class=${classMap({ "alert-control": true, [alert]: true })}
|
||||
hide-option-label
|
||||
.label=${localize(
|
||||
"ui.card.entity-visibility.alert_level_group",
|
||||
{ label: name }
|
||||
)}
|
||||
.options=${this._alertOptions(localize)}
|
||||
.value=${alert}
|
||||
@value-changed=${this._alertSelected}
|
||||
></ha-control-select>
|
||||
`
|
||||
}
|
||||
${
|
||||
favorite !== undefined
|
||||
? html`
|
||||
<button
|
||||
class=${classMap({
|
||||
action: true,
|
||||
"favorite-control": true,
|
||||
favorite,
|
||||
})}
|
||||
aria-label=${localize(
|
||||
`ui.card.entity-visibility.${favorite ? "remove_favorite" : "add_favorite"}`,
|
||||
{ label: name }
|
||||
)}
|
||||
@click=${this._toggleFavorite}
|
||||
>
|
||||
<ha-svg-icon
|
||||
.path=${favorite ? mdiStar : mdiStarOutline}
|
||||
></ha-svg-icon>
|
||||
</button>
|
||||
`
|
||||
: nothing
|
||||
}
|
||||
${
|
||||
canHide
|
||||
? html`
|
||||
<ha-svg-icon
|
||||
class="eye"
|
||||
.path=${hidden ? mdiEyeOff : mdiEye}
|
||||
></ha-svg-icon>
|
||||
`
|
||||
: nothing
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private _focusKey(): string {
|
||||
const config = this._config!;
|
||||
return computeFocusKey(
|
||||
config.entity,
|
||||
config.hidden !== undefined,
|
||||
Boolean(config.alert_select)
|
||||
);
|
||||
}
|
||||
|
||||
private _rememberFocus(
|
||||
control: EntityVisibilityControl,
|
||||
entryRemoved = false
|
||||
): void {
|
||||
if (!this.shadowRoot?.activeElement) {
|
||||
return;
|
||||
}
|
||||
const config = this._config!;
|
||||
pendingFocus = {
|
||||
key: !entryRemoved
|
||||
? this._focusKey()
|
||||
: config.neighbor_entity
|
||||
? computeFocusKey(
|
||||
config.neighbor_entity,
|
||||
false,
|
||||
Boolean(config.alert_select)
|
||||
)
|
||||
: computeFocusKey(config.entity, true, false),
|
||||
control,
|
||||
source: this,
|
||||
};
|
||||
}
|
||||
|
||||
private _toggleHidden(): void {
|
||||
if (this._config?.hidden === undefined) {
|
||||
return;
|
||||
}
|
||||
this._rememberFocus("hidden");
|
||||
fireEvent(this, "entity-visibility-changed", {
|
||||
entity: this._config.entity,
|
||||
hidden: !this._config.hidden,
|
||||
});
|
||||
}
|
||||
|
||||
private _toggleFavorite(ev: Event): void {
|
||||
ev.stopPropagation();
|
||||
if (!this._config) {
|
||||
return;
|
||||
}
|
||||
const { favorite, hidden, alert_select: alertSelect } = this._config;
|
||||
this._rememberFocus(
|
||||
"favorite",
|
||||
Boolean(favorite) && hidden === undefined && !alertSelect
|
||||
);
|
||||
fireEvent(this, "entity-favorite-changed", {
|
||||
entity: this._config.entity,
|
||||
favorite: !this._config.favorite,
|
||||
});
|
||||
}
|
||||
|
||||
private _alertOptions = memoizeOne(
|
||||
(localize: LocalizeFunc): ControlSelectOption[] =>
|
||||
ALERT_LEVELS.map((level) => ({
|
||||
value: level,
|
||||
path: ALERT_ICONS[level],
|
||||
ariaLabel: localize(`ui.card.entity-visibility.alert_levels.${level}`),
|
||||
}))
|
||||
);
|
||||
|
||||
private _alertSelected(
|
||||
ev: ValueChangedEvent<EntityVisibilityAlertLevel>
|
||||
): void {
|
||||
ev.stopPropagation();
|
||||
if (!this._config?.alert || ev.detail.value === this._config.alert) {
|
||||
return;
|
||||
}
|
||||
this._rememberFocus("alert", ev.detail.value === "none");
|
||||
fireEvent(this, "entity-alert-changed", {
|
||||
entity: this._config.entity,
|
||||
severity: ev.detail.value === "none" ? undefined : ev.detail.value,
|
||||
});
|
||||
}
|
||||
|
||||
private _cycleAlert(ev: Event): void {
|
||||
ev.stopPropagation();
|
||||
if (!this._config?.alert) {
|
||||
return;
|
||||
}
|
||||
const next =
|
||||
ALERT_LEVELS[
|
||||
(ALERT_LEVELS.indexOf(this._config.alert) + 1) % ALERT_LEVELS.length
|
||||
];
|
||||
this._rememberFocus("alert");
|
||||
fireEvent(this, "entity-alert-changed", {
|
||||
entity: this._config.entity,
|
||||
severity: next === "none" ? undefined : next,
|
||||
});
|
||||
}
|
||||
|
||||
static styles: CSSResultGroup = css`
|
||||
.wrapper {
|
||||
position: relative;
|
||||
height: 100%;
|
||||
border-radius: var(--ha-card-border-radius, var(--ha-border-radius-lg));
|
||||
}
|
||||
hui-card,
|
||||
hui-warning {
|
||||
display: block;
|
||||
height: 100%;
|
||||
}
|
||||
.missing {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
}
|
||||
.missing-entity {
|
||||
font-weight: var(--ha-font-weight-medium);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.wrapper[hidden-entity] hui-card {
|
||||
opacity: 0.8;
|
||||
}
|
||||
.wrapper[hidden-entity]::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border: 2px dotted var(--secondary-text-color);
|
||||
border-radius: inherit;
|
||||
pointer-events: none;
|
||||
}
|
||||
.control {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 1;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border: none;
|
||||
background: none;
|
||||
border-radius: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
.control:focus-visible {
|
||||
outline: 2px solid var(--primary-color);
|
||||
}
|
||||
.actions {
|
||||
position: absolute;
|
||||
bottom: var(--ha-space-2);
|
||||
right: var(--ha-space-2);
|
||||
inset-inline-end: var(--ha-space-2);
|
||||
inset-inline-start: auto;
|
||||
z-index: 2;
|
||||
display: flex;
|
||||
gap: var(--ha-space-1);
|
||||
pointer-events: none;
|
||||
}
|
||||
.action {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border: none;
|
||||
background: none;
|
||||
cursor: pointer;
|
||||
pointer-events: auto;
|
||||
color: var(--primary-text-color);
|
||||
}
|
||||
.action:focus-visible {
|
||||
outline: 2px solid var(--primary-color);
|
||||
border-radius: var(--ha-border-radius-circle);
|
||||
}
|
||||
.action.favorite {
|
||||
color: var(--primary-color);
|
||||
}
|
||||
.action.warning {
|
||||
color: var(--warning-color);
|
||||
}
|
||||
.action.alert {
|
||||
color: var(--error-color);
|
||||
}
|
||||
ha-control-select {
|
||||
pointer-events: auto;
|
||||
width: 96px;
|
||||
--control-select-thickness: 28px;
|
||||
--control-select-padding: 2px;
|
||||
--control-select-border-radius: var(--ha-border-radius-pill);
|
||||
--control-select-button-border-radius: var(--ha-border-radius-pill);
|
||||
--control-select-background: var(--secondary-background-color);
|
||||
--control-select-background-opacity: 1;
|
||||
--control-select-color: var(--secondary-text-color);
|
||||
--mdc-icon-size: 16px;
|
||||
border-radius: var(--ha-border-radius-pill);
|
||||
box-shadow: var(--ha-box-shadow-s);
|
||||
}
|
||||
ha-control-select.warning {
|
||||
--control-select-color: var(--warning-color);
|
||||
}
|
||||
ha-control-select.alert {
|
||||
--control-select-color: var(--error-color);
|
||||
}
|
||||
ha-svg-icon {
|
||||
display: flex;
|
||||
padding: var(--ha-space-1);
|
||||
border-radius: var(--ha-border-radius-circle);
|
||||
background: var(--secondary-background-color);
|
||||
box-shadow: var(--ha-box-shadow-s);
|
||||
--mdc-icon-size: 20px;
|
||||
}
|
||||
.eye {
|
||||
color: var(--primary-text-color);
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"hui-entity-visibility-card": HuiEntityVisibilityCard;
|
||||
}
|
||||
interface HASSDomEvents {
|
||||
"entity-visibility-changed": { entity: string; hidden: boolean };
|
||||
"entity-favorite-changed": { entity: string; favorite: boolean };
|
||||
"entity-alert-changed": {
|
||||
entity: string;
|
||||
severity: SecurityAlertSeverity | undefined;
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -191,12 +191,16 @@ export class HuiHomeSummaryCard extends LitElement implements LovelaceCard {
|
||||
}
|
||||
case "climate": {
|
||||
// Min/Max temperature of the areas
|
||||
const hiddenEntities = new Set(this._config.hidden_entities);
|
||||
const areaSensors = Object.values(areas)
|
||||
.map((area) => area.temperature_entity_id)
|
||||
.filter(Boolean);
|
||||
.filter(
|
||||
(entityId): entityId is string =>
|
||||
!!entityId && !hiddenEntities.has(entityId)
|
||||
);
|
||||
|
||||
const sensorsValues = areaSensors
|
||||
.map((entityId) => parseFloat(states[entityId!]?.state) || NaN)
|
||||
.map((entityId) => parseFloat(states[entityId]?.state) || NaN)
|
||||
.filter((value) => !isNaN(value));
|
||||
|
||||
if (sensorsValues.length === 0) {
|
||||
@@ -233,13 +237,10 @@ export class HuiHomeSummaryCard extends LitElement implements LovelaceCard {
|
||||
}
|
||||
case "security": {
|
||||
// Alarm and lock status
|
||||
const securityEntities = findSecurityEntities({
|
||||
states,
|
||||
entities,
|
||||
devices,
|
||||
areas,
|
||||
floors,
|
||||
});
|
||||
const securityEntities = findSecurityEntities(
|
||||
{ states, entities, devices, areas, floors },
|
||||
this._config.hidden_entities
|
||||
);
|
||||
|
||||
const locks = securityEntities.filter((entityId) => {
|
||||
const domain = computeDomain(entityId);
|
||||
@@ -322,13 +323,10 @@ export class HuiHomeSummaryCard extends LitElement implements LovelaceCard {
|
||||
: localize("ui.card.home-summary.no_media_playing");
|
||||
}
|
||||
case "maintenance": {
|
||||
const maintenanceEntities = findMaintenanceEntities({
|
||||
states,
|
||||
entities,
|
||||
devices,
|
||||
areas,
|
||||
floors,
|
||||
});
|
||||
const maintenanceEntities = findMaintenanceEntities(
|
||||
{ states, entities, devices, areas, floors },
|
||||
this._config.hidden_entities
|
||||
);
|
||||
|
||||
const lowBatteryEntities = filterLowBatteryEntities(
|
||||
states,
|
||||
|
||||
@@ -4,7 +4,10 @@ import type { MapStyleConfig } from "../../../common/map/map-styles";
|
||||
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 {
|
||||
SecurityAlertEntityConfig,
|
||||
SecurityAlertSeverity,
|
||||
} from "../../../data/frontend";
|
||||
import type { ActionConfig } from "../../../data/lovelace/config/action";
|
||||
import type { LovelaceCardConfig } from "../../../data/lovelace/config/card";
|
||||
import type {
|
||||
@@ -71,6 +74,18 @@ export interface ConditionalCardConfig extends LovelaceCardConfig {
|
||||
conditions: VisibilityCondition[];
|
||||
}
|
||||
|
||||
export type EntityVisibilityAlertLevel = "none" | SecurityAlertSeverity;
|
||||
|
||||
export interface EntityVisibilityCardConfig extends LovelaceCardConfig {
|
||||
entity: string;
|
||||
hidden?: boolean;
|
||||
favorite?: boolean;
|
||||
alert?: EntityVisibilityAlertLevel;
|
||||
alert_select?: boolean;
|
||||
neighbor_entity?: string;
|
||||
card: LovelaceCardConfig;
|
||||
}
|
||||
|
||||
export interface EmptyStateButtonConfig {
|
||||
text: string;
|
||||
icon?: string;
|
||||
@@ -751,6 +766,7 @@ export interface HeadingCardConfig extends LovelaceCardConfig {
|
||||
export interface HomeSummaryCard extends LovelaceCardConfig {
|
||||
summary: HomeSummary;
|
||||
alert_entities?: SecurityAlertEntityConfig[];
|
||||
hidden_entities?: string[];
|
||||
vertical?: boolean;
|
||||
tap_action?: ActionConfig;
|
||||
hold_action?: ActionConfig;
|
||||
|
||||
@@ -38,6 +38,7 @@ const LAZY_LOAD_TYPES = {
|
||||
area: () => import("../cards/hui-area-card"),
|
||||
calendar: () => import("../cards/hui-calendar-card"),
|
||||
conditional: () => import("../cards/hui-conditional-card"),
|
||||
"entity-visibility": () => import("../cards/hui-entity-visibility-card"),
|
||||
"empty-state": () => import("../cards/hui-empty-state-card"),
|
||||
"energy-compare": () => import("../cards/energy/hui-energy-compare-card"),
|
||||
"energy-carbon-consumed-gauge": () =>
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import type { LovelaceCardConfig } from "../../../../data/lovelace/config/card";
|
||||
import type { EntityVisibilityCardConfig } from "../../cards/types";
|
||||
|
||||
export type ComputeEntityCard = (entityId: string) => LovelaceCardConfig;
|
||||
|
||||
export const wrapEntityVisibility =
|
||||
(
|
||||
hiddenEntities: Set<string>,
|
||||
computeCard: ComputeEntityCard,
|
||||
computeExtras?: (
|
||||
entityId: string
|
||||
) => Pick<EntityVisibilityCardConfig, "favorite" | "alert">
|
||||
) =>
|
||||
(entityId: string): EntityVisibilityCardConfig => ({
|
||||
type: "entity-visibility",
|
||||
entity: entityId,
|
||||
hidden: hiddenEntities.has(entityId),
|
||||
...computeExtras?.(entityId),
|
||||
card: computeCard(entityId),
|
||||
});
|
||||
@@ -3,6 +3,7 @@ import { customElement } from "lit/decorators";
|
||||
import { computeDeviceName } from "../../../../common/entity/compute_device_name";
|
||||
import { getEntityContext } from "../../../../common/entity/context/get_entity_context";
|
||||
import {
|
||||
excludeEntities,
|
||||
findEntities,
|
||||
generateEntityFilter,
|
||||
} from "../../../../common/entity/entity_filter";
|
||||
@@ -29,6 +30,9 @@ import {
|
||||
export interface HomeAreaViewStrategyConfig {
|
||||
type: "home-area";
|
||||
area?: string;
|
||||
security_hidden_entities?: string[];
|
||||
maintenance_hidden_entities?: string[];
|
||||
climate_hidden_entities?: string[];
|
||||
home_panel?: boolean;
|
||||
}
|
||||
|
||||
@@ -91,6 +95,12 @@ export class HomeAreaViewStrategy extends ReactiveElement {
|
||||
const allEntities = Object.keys(hass.states);
|
||||
const areaEntities = allEntities.filter(areaFilter);
|
||||
|
||||
const hiddenEntities: Partial<Record<HomeSummary, string[]>> = {
|
||||
security: config.security_hidden_entities,
|
||||
maintenance: config.maintenance_hidden_entities,
|
||||
climate: config.climate_hidden_entities,
|
||||
};
|
||||
|
||||
const entitiesBySummary = HOME_SUMMARIES.reduce(
|
||||
(acc, summary) => {
|
||||
const summariesFilters = HOME_SUMMARIES_FILTERS[summary];
|
||||
@@ -104,7 +114,10 @@ export class HomeAreaViewStrategy extends ReactiveElement {
|
||||
filter
|
||||
)
|
||||
);
|
||||
acc[summary] = findEntities(areaEntities, filterFunctions);
|
||||
acc[summary] = excludeEntities(
|
||||
findEntities(areaEntities, filterFunctions),
|
||||
hiddenEntities[summary]
|
||||
);
|
||||
return acc;
|
||||
},
|
||||
{} as Record<HomeSummary, string[]>
|
||||
|
||||
@@ -21,6 +21,9 @@ import type { HomeOverviewViewStrategyConfig } from "./home-overview-view-strate
|
||||
export interface HomeDashboardStrategyConfig {
|
||||
type: "home";
|
||||
alert_entities?: SecurityAlertEntityConfig[];
|
||||
security_hidden_entities?: string[];
|
||||
maintenance_hidden_entities?: string[];
|
||||
climate_hidden_entities?: string[];
|
||||
favorite_entities?: string[];
|
||||
home_panel?: boolean;
|
||||
hide_welcome_message?: boolean;
|
||||
@@ -72,6 +75,9 @@ export class HomeDashboardStrategy extends ReactiveElement {
|
||||
strategy: {
|
||||
type: "home-area",
|
||||
area: area.area_id,
|
||||
security_hidden_entities: config.security_hidden_entities,
|
||||
maintenance_hidden_entities: config.maintenance_hidden_entities,
|
||||
climate_hidden_entities: config.climate_hidden_entities,
|
||||
home_panel: config.home_panel,
|
||||
} satisfies HomeAreaViewStrategyConfig,
|
||||
};
|
||||
@@ -106,6 +112,9 @@ export class HomeDashboardStrategy extends ReactiveElement {
|
||||
strategy: {
|
||||
type: "home-overview",
|
||||
alert_entities: config.alert_entities,
|
||||
security_hidden_entities: config.security_hidden_entities,
|
||||
maintenance_hidden_entities: config.maintenance_hidden_entities,
|
||||
climate_hidden_entities: config.climate_hidden_entities,
|
||||
favorite_entities: config.favorite_entities,
|
||||
home_panel: config.home_panel,
|
||||
hide_welcome_message: config.hide_welcome_message,
|
||||
|
||||
@@ -57,6 +57,9 @@ import { OTHER_DEVICES_FILTERS } from "./helpers/other-devices-filters";
|
||||
export interface HomeOverviewViewStrategyConfig {
|
||||
type: "home-overview";
|
||||
alert_entities?: SecurityAlertEntityConfig[];
|
||||
security_hidden_entities?: string[];
|
||||
maintenance_hidden_entities?: string[];
|
||||
climate_hidden_entities?: string[];
|
||||
favorite_entities?: string[];
|
||||
home_panel?: boolean;
|
||||
hide_welcome_message?: boolean;
|
||||
@@ -360,11 +363,17 @@ export class HomeOverviewViewStrategy extends ReactiveElement {
|
||||
hass.panels.light && findEntities(allEntities, lightsFilters).length > 0;
|
||||
const hasMediaPlayers =
|
||||
findEntities(allEntities, mediaPlayerFilter).length > 0;
|
||||
const hasClimate = hass.panels.climate && hasClimateEntities(hass);
|
||||
const hasClimate =
|
||||
hass.panels.climate &&
|
||||
hasClimateEntities(hass, config.climate_hidden_entities);
|
||||
const hasSecurity =
|
||||
hass.panels.security && findSecurityEntities(hass).length > 0;
|
||||
hass.panels.security &&
|
||||
(findSecurityEntities(hass, config.security_hidden_entities).length > 0 ||
|
||||
Boolean(config.alert_entities?.length));
|
||||
const hasMaintenance =
|
||||
hass.panels.maintenance && findMaintenanceEntities(hass).length > 0;
|
||||
hass.panels.maintenance &&
|
||||
findMaintenanceEntities(hass, config.maintenance_hidden_entities).length >
|
||||
0;
|
||||
|
||||
const alertEntities = config.alert_entities ?? [];
|
||||
const alertSeverityEntities = filterSecurityAlertEntities(
|
||||
@@ -423,6 +432,9 @@ export class HomeOverviewViewStrategy extends ReactiveElement {
|
||||
? ({
|
||||
type: "home-summary",
|
||||
summary: "climate",
|
||||
hidden_entities: config.climate_hidden_entities?.length
|
||||
? config.climate_hidden_entities
|
||||
: undefined,
|
||||
tap_action: {
|
||||
action: "navigate",
|
||||
navigation_path: "/climate?historyBack=1",
|
||||
@@ -444,6 +456,9 @@ export class HomeOverviewViewStrategy extends ReactiveElement {
|
||||
if (alertEntities.length) {
|
||||
card.alert_entities = alertEntities;
|
||||
}
|
||||
if (config.security_hidden_entities?.length) {
|
||||
card.hidden_entities = config.security_hidden_entities;
|
||||
}
|
||||
return card;
|
||||
},
|
||||
media_players: () =>
|
||||
@@ -462,6 +477,9 @@ export class HomeOverviewViewStrategy extends ReactiveElement {
|
||||
? ({
|
||||
type: "home-summary",
|
||||
summary: "maintenance",
|
||||
hidden_entities: config.maintenance_hidden_entities?.length
|
||||
? config.maintenance_hidden_entities
|
||||
: undefined,
|
||||
tap_action: {
|
||||
action: "navigate",
|
||||
navigation_path: config.home_panel
|
||||
|
||||
@@ -1,13 +1,23 @@
|
||||
import { mdiPencil } from "@mdi/js";
|
||||
import type { CSSResultGroup, PropertyValues } from "lit";
|
||||
import { LitElement, css, html, nothing } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import type { HASSDomEvent } from "../../common/dom/fire_event";
|
||||
import { debounce } from "../../common/util/debounce";
|
||||
import { deepEqual } from "../../common/util/deep-equal";
|
||||
import "../../components/ha-button";
|
||||
import "../../components/ha-icon-button";
|
||||
import "../../components/ha-top-app-bar-fixed";
|
||||
import {
|
||||
fetchFrontendSystemDataAfterSaves,
|
||||
queueFrontendSystemDataSave,
|
||||
type MaintenanceFrontendSystemData,
|
||||
} from "../../data/frontend";
|
||||
import type { LovelaceStrategyViewConfig } from "../../data/lovelace/config/view";
|
||||
import { ChildPanelReady } from "../../layouts/panel-ready";
|
||||
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 "../lovelace/views/hui-view";
|
||||
@@ -30,10 +40,20 @@ class PanelMaintenance extends LitElement {
|
||||
|
||||
@state() private _lovelace?: Lovelace;
|
||||
|
||||
@state() private _config?: MaintenanceFrontendSystemData;
|
||||
|
||||
@state() private _editVisibility = false;
|
||||
|
||||
@state() private _searchParams = new URLSearchParams(window.location.search);
|
||||
|
||||
private _childPanelReady?: ChildPanelReady;
|
||||
|
||||
private _loadConfigPromise?: Promise<void>;
|
||||
|
||||
private _loadConfigRevision = 0;
|
||||
|
||||
private _setLovelaceRevision = 0;
|
||||
|
||||
public willUpdate(changedProps: PropertyValues<this>) {
|
||||
super.willUpdate(changedProps);
|
||||
// Initial setup
|
||||
@@ -47,13 +67,22 @@ class PanelMaintenance extends LitElement {
|
||||
}
|
||||
|
||||
const oldHass = changedProps.get("hass") as this["hass"];
|
||||
if (
|
||||
oldHass &&
|
||||
this.hass.config.state === "RUNNING" &&
|
||||
oldHass.config.state !== "RUNNING"
|
||||
) {
|
||||
this._setup();
|
||||
return;
|
||||
}
|
||||
|
||||
if (oldHass && oldHass.localize !== this.hass.localize) {
|
||||
this._setLovelace();
|
||||
return;
|
||||
}
|
||||
|
||||
if (oldHass && this.hass) {
|
||||
// If the entity registry changed, ask the user if they want to refresh the config
|
||||
// Refresh the generated view when registries or panels change.
|
||||
if (
|
||||
oldHass.entities !== this.hass.entities ||
|
||||
oldHass.devices !== this.hass.devices ||
|
||||
@@ -63,24 +92,46 @@ class PanelMaintenance extends LitElement {
|
||||
) {
|
||||
if (this.hass.config.state === "RUNNING") {
|
||||
this._debounceRegistriesChanged();
|
||||
return;
|
||||
}
|
||||
}
|
||||
// If ha started, refresh the config
|
||||
if (
|
||||
this.hass.config.state === "RUNNING" &&
|
||||
oldHass.config.state !== "RUNNING"
|
||||
) {
|
||||
this._setLovelace();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async _setup() {
|
||||
await this.hass.loadFragmentTranslation("lovelace");
|
||||
this._loadConfigPromise = this._loadConfig();
|
||||
await Promise.all([
|
||||
this.hass.loadFragmentTranslation("lovelace"),
|
||||
this._loadConfigPromise,
|
||||
]);
|
||||
this._setLovelace();
|
||||
}
|
||||
|
||||
private async _loadConfig() {
|
||||
const revision = ++this._loadConfigRevision;
|
||||
this._config = undefined;
|
||||
try {
|
||||
const data = await fetchFrontendSystemDataAfterSaves(
|
||||
this.hass.connection,
|
||||
"maintenance"
|
||||
);
|
||||
if (revision !== this._loadConfigRevision) {
|
||||
return;
|
||||
}
|
||||
this._config = data || {};
|
||||
} catch (err) {
|
||||
if (revision !== this._loadConfigRevision) {
|
||||
return;
|
||||
}
|
||||
// eslint-disable-next-line no-console
|
||||
console.error("Failed to load maintenance configuration:", err);
|
||||
showToast(this, {
|
||||
message: this.hass.localize("ui.panel.maintenance.editor.load_failed"),
|
||||
duration: 0,
|
||||
dismissable: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private _debounceRegistriesChanged = debounce(
|
||||
() => this._registriesChanged(),
|
||||
200
|
||||
@@ -97,6 +148,27 @@ class PanelMaintenance extends LitElement {
|
||||
.backButton=${this._searchParams.has("historyBack")}
|
||||
>
|
||||
<div slot="title">${this.hass.localize("panel.maintenance")}</div>
|
||||
${
|
||||
!this.hass.user?.is_admin
|
||||
? nothing
|
||||
: this._editVisibility
|
||||
? html`<ha-button
|
||||
slot="actionItems"
|
||||
appearance="filled"
|
||||
size="s"
|
||||
@click=${this._stopEditVisibility}
|
||||
>
|
||||
${this.hass.localize("ui.panel.lovelace.menu.exit_edit_mode")}
|
||||
</ha-button>`
|
||||
: this._config
|
||||
? html`<ha-icon-button
|
||||
slot="actionItems"
|
||||
.path=${mdiPencil}
|
||||
.label=${this.hass.localize("ui.panel.maintenance.editor.title")}
|
||||
@click=${this._startEditVisibility}
|
||||
></ha-icon-button>`
|
||||
: nothing
|
||||
}
|
||||
${
|
||||
this._lovelace
|
||||
? html`
|
||||
@@ -108,6 +180,7 @@ class PanelMaintenance extends LitElement {
|
||||
.narrow=${this.narrow}
|
||||
.lovelace=${this._lovelace}
|
||||
.index=${this._viewIndex}
|
||||
@entity-visibility-changed=${this._visibilityChanged}
|
||||
></hui-view>
|
||||
</hui-view-container>
|
||||
`
|
||||
@@ -118,11 +191,26 @@ class PanelMaintenance extends LitElement {
|
||||
}
|
||||
|
||||
private async _setLovelace() {
|
||||
const revision = ++this._setLovelaceRevision;
|
||||
if (this._loadConfigPromise) {
|
||||
await this._loadConfigPromise;
|
||||
}
|
||||
|
||||
const viewConfig = await generateLovelaceViewStrategy(
|
||||
MAINTENANCE_LOVELACE_VIEW_CONFIG,
|
||||
{
|
||||
strategy: {
|
||||
...MAINTENANCE_LOVELACE_VIEW_CONFIG.strategy,
|
||||
hidden_entities: this._config?.hidden_entities,
|
||||
edit_visibility: this._editVisibility || undefined,
|
||||
},
|
||||
},
|
||||
this.hass
|
||||
);
|
||||
|
||||
if (revision !== this._setLovelaceRevision) {
|
||||
return;
|
||||
}
|
||||
|
||||
const config = { views: [viewConfig] };
|
||||
const rawConfig = { views: [MAINTENANCE_LOVELACE_VIEW_CONFIG] };
|
||||
|
||||
@@ -146,6 +234,61 @@ class PanelMaintenance extends LitElement {
|
||||
};
|
||||
}
|
||||
|
||||
private _startEditVisibility = () => {
|
||||
this._editVisibility = true;
|
||||
this._setLovelace();
|
||||
};
|
||||
|
||||
private _stopEditVisibility = () => {
|
||||
this._editVisibility = false;
|
||||
if (!this._config) {
|
||||
this._loadConfigPromise = this._loadConfig();
|
||||
}
|
||||
this._setLovelace();
|
||||
};
|
||||
|
||||
private _visibilityChanged = (
|
||||
ev: HASSDomEvent<HASSDomEvents["entity-visibility-changed"]>
|
||||
) => {
|
||||
if (!this._config) {
|
||||
return;
|
||||
}
|
||||
const { entity, hidden } = ev.detail;
|
||||
const hiddenEntities = (this._config.hidden_entities ?? []).filter(
|
||||
(entityId) => entityId !== entity
|
||||
);
|
||||
if (hidden) {
|
||||
hiddenEntities.push(entity);
|
||||
}
|
||||
this._saveConfig({
|
||||
...this._config,
|
||||
hidden_entities: hiddenEntities.length ? hiddenEntities : undefined,
|
||||
});
|
||||
};
|
||||
|
||||
private _saveConfig(config: MaintenanceFrontendSystemData): Promise<void> {
|
||||
this._config = config;
|
||||
this._setLovelace();
|
||||
return queueFrontendSystemDataSave(
|
||||
this.hass.connection,
|
||||
"maintenance",
|
||||
config
|
||||
).catch(async (err) => {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error("Failed to save maintenance configuration:", err);
|
||||
showToast(this, {
|
||||
message: this.hass.localize("ui.panel.maintenance.editor.save_failed"),
|
||||
duration: 0,
|
||||
dismissable: true,
|
||||
});
|
||||
if (config !== this._config) {
|
||||
return;
|
||||
}
|
||||
await this._loadConfig();
|
||||
await this._setLovelace();
|
||||
});
|
||||
}
|
||||
|
||||
static get styles(): CSSResultGroup {
|
||||
return [
|
||||
haStyle,
|
||||
|
||||
@@ -14,6 +14,10 @@ import type { LovelaceSectionRawConfig } from "../../../data/lovelace/config/sec
|
||||
import type { LovelaceViewConfig } from "../../../data/lovelace/config/view";
|
||||
import type { HomeAssistant } from "../../../types";
|
||||
import type { TileCardConfig } from "../../lovelace/cards/types";
|
||||
import {
|
||||
wrapEntityVisibility,
|
||||
type ComputeEntityCard,
|
||||
} from "../../lovelace/strategies/helpers/entity-visibility";
|
||||
import { BINARY_STATE_ON } from "../../../common/const";
|
||||
import { computeDomain } from "../../../common/entity/compute_domain";
|
||||
import { getDeviceEntityDisplayLookup } from "../../../data/device/device_registry";
|
||||
@@ -21,6 +25,8 @@ import { findBatteryChargingEntity } from "../../../data/entity/entity_registry"
|
||||
|
||||
export interface MaintenanceViewStrategyConfig {
|
||||
type: "maintenance";
|
||||
hidden_entities?: string[];
|
||||
edit_visibility?: boolean;
|
||||
}
|
||||
|
||||
export const maintenanceEntityFilters: EntityFilter[] = [
|
||||
@@ -35,8 +41,10 @@ export const maintenanceEntityFilters: EntityFilter[] = [
|
||||
];
|
||||
|
||||
export const findMaintenanceEntities = (
|
||||
sources: EntityFilterSources
|
||||
): string[] => findFilteredEntities(sources, maintenanceEntityFilters);
|
||||
sources: EntityFilterSources,
|
||||
hiddenEntities?: string[]
|
||||
): string[] =>
|
||||
findFilteredEntities(sources, maintenanceEntityFilters, hiddenEntities);
|
||||
|
||||
const LOW_BATTERY_THRESHOLD = 20;
|
||||
|
||||
@@ -119,7 +127,8 @@ const computeMaintenanceTileCard = (
|
||||
const processAreasForBattery = (
|
||||
areaIds: string[],
|
||||
hass: HomeAssistant,
|
||||
entities: string[]
|
||||
entities: string[],
|
||||
computeCard: ComputeEntityCard
|
||||
): LovelaceCardConfig[] => {
|
||||
const cards: LovelaceCardConfig[] = [];
|
||||
|
||||
@@ -141,9 +150,7 @@ const processAreasForBattery = (
|
||||
const areaCards: LovelaceCardConfig[] = [];
|
||||
|
||||
for (const entityId of areaBatteryEntities) {
|
||||
areaCards.push(
|
||||
computeMaintenanceTileCard(hass.states, hass.entities, entityId)
|
||||
);
|
||||
areaCards.push(computeCard(entityId));
|
||||
}
|
||||
|
||||
if (areaCards.length > 0) {
|
||||
@@ -167,7 +174,8 @@ const processAreasForBattery = (
|
||||
|
||||
const processUnassignedEntities = (
|
||||
hass: HomeAssistant,
|
||||
entities: string[]
|
||||
entities: string[],
|
||||
computeCard: ComputeEntityCard
|
||||
): LovelaceCardConfig[] => {
|
||||
const unassignedFilter = generateEntityFilter(
|
||||
hass.states,
|
||||
@@ -183,9 +191,7 @@ const processUnassignedEntities = (
|
||||
const cards: LovelaceCardConfig[] = [];
|
||||
|
||||
for (const entityId of unassignedEntities) {
|
||||
cards.push(
|
||||
computeMaintenanceTileCard(hass.states, hass.entities, entityId)
|
||||
);
|
||||
cards.push(computeCard(entityId));
|
||||
}
|
||||
|
||||
return cards;
|
||||
@@ -194,7 +200,7 @@ const processUnassignedEntities = (
|
||||
@customElement("maintenance-view-strategy")
|
||||
export class MaintenanceViewStrategy extends ReactiveElement {
|
||||
static async generate(
|
||||
_config: MaintenanceViewStrategyConfig,
|
||||
config: MaintenanceViewStrategyConfig,
|
||||
hass: HomeAssistant
|
||||
): Promise<LovelaceViewConfig> {
|
||||
const areas = Object.values(hass.areas);
|
||||
@@ -203,7 +209,16 @@ export class MaintenanceViewStrategy extends ReactiveElement {
|
||||
|
||||
const sections: LovelaceSectionRawConfig[] = [];
|
||||
|
||||
const entities = findMaintenanceEntities(hass);
|
||||
const entities = findMaintenanceEntities(
|
||||
hass,
|
||||
config.edit_visibility ? undefined : config.hidden_entities
|
||||
);
|
||||
|
||||
const computeTileCard = (entityId: string) =>
|
||||
computeMaintenanceTileCard(hass.states, hass.entities, entityId);
|
||||
const computeCard: ComputeEntityCard = config.edit_visibility
|
||||
? wrapEntityVisibility(new Set(config.hidden_entities), computeTileCard)
|
||||
: computeTileCard;
|
||||
|
||||
const floorCount =
|
||||
hierarchy.floors.length + (hierarchy.areas.length ? 1 : 0);
|
||||
@@ -229,7 +244,12 @@ export class MaintenanceViewStrategy extends ReactiveElement {
|
||||
],
|
||||
};
|
||||
|
||||
const areaCards = processAreasForBattery(areaIds, hass, entities);
|
||||
const areaCards = processAreasForBattery(
|
||||
areaIds,
|
||||
hass,
|
||||
entities,
|
||||
computeCard
|
||||
);
|
||||
|
||||
if (areaCards.length > 0) {
|
||||
section.cards!.push(...areaCards);
|
||||
@@ -255,7 +275,12 @@ export class MaintenanceViewStrategy extends ReactiveElement {
|
||||
],
|
||||
};
|
||||
|
||||
const areaCards = processAreasForBattery(hierarchy.areas, hass, entities);
|
||||
const areaCards = processAreasForBattery(
|
||||
hierarchy.areas,
|
||||
hass,
|
||||
entities,
|
||||
computeCard
|
||||
);
|
||||
|
||||
if (areaCards.length > 0) {
|
||||
section.cards!.push(...areaCards);
|
||||
@@ -264,7 +289,11 @@ export class MaintenanceViewStrategy extends ReactiveElement {
|
||||
}
|
||||
|
||||
// Process unassigned entities
|
||||
const unassignedCards = processUnassignedEntities(hass, entities);
|
||||
const unassignedCards = processUnassignedEntities(
|
||||
hass,
|
||||
entities,
|
||||
computeCard
|
||||
);
|
||||
|
||||
if (unassignedCards.length > 0) {
|
||||
const section: LovelaceSectionRawConfig = {
|
||||
|
||||
@@ -1,286 +0,0 @@
|
||||
import type { ContextType } from "@lit/context";
|
||||
import { mdiDelete, mdiDragHorizontalVariant } from "@mdi/js";
|
||||
import { css, html, LitElement, nothing, type PropertyValues } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import { repeat } from "lit/directives/repeat";
|
||||
import { consume } from "../../../common/decorators/consume";
|
||||
import { ALERT_DOMAINS } from "../../../common/entity/alert_active";
|
||||
import { computeEntityPickerDisplay } from "../../../common/entity/compute_entity_name_display";
|
||||
import {
|
||||
fireEvent,
|
||||
type HASSDomCurrentTargetEvent,
|
||||
type HASSDomEvent,
|
||||
} from "../../../common/dom/fire_event";
|
||||
import type { HaEntityPicker } from "../../../components/entity/ha-entity-picker";
|
||||
import type { HaDropdownSelectEvent } from "../../../components/ha-dropdown";
|
||||
import type {
|
||||
SecurityAlertEntityConfig,
|
||||
SecurityAlertSeverity,
|
||||
} from "../../../data/frontend";
|
||||
import {
|
||||
internationalizationContext,
|
||||
registriesContext,
|
||||
statesContext,
|
||||
} from "../../../data/context";
|
||||
import "../../../components/entity/ha-entity-picker";
|
||||
import "../../../components/entity/state-badge";
|
||||
import "../../../components/ha-control-select-menu";
|
||||
import "../../../components/ha-icon-button";
|
||||
import "../../../components/ha-settings-row";
|
||||
import "../../../components/ha-sortable";
|
||||
import "../../../components/ha-svg-icon";
|
||||
import { computeDefaultSecurityAlertSeverity } from "../strategies/security-alerts";
|
||||
import {
|
||||
createSecurityEntityFilter,
|
||||
type SecurityEntityContext,
|
||||
} from "../security-entity-filter";
|
||||
import type { ValueChangedEvent } from "../../../types";
|
||||
|
||||
type SecurityEditorContext = SecurityEntityContext & {
|
||||
language: ContextType<typeof internationalizationContext>["language"];
|
||||
translationMetadata: ContextType<
|
||||
typeof internationalizationContext
|
||||
>["translationMetadata"];
|
||||
};
|
||||
|
||||
@customElement("security-alerts-editor")
|
||||
export class SecurityAlertsEditor extends LitElement {
|
||||
@property({ attribute: false })
|
||||
public alertEntities: SecurityAlertEntityConfig[] = [];
|
||||
|
||||
@state()
|
||||
@consume({ context: statesContext, subscribe: true })
|
||||
private _states!: ContextType<typeof statesContext>;
|
||||
|
||||
@state()
|
||||
@consume({ context: registriesContext, subscribe: true })
|
||||
private _registries!: ContextType<typeof registriesContext>;
|
||||
|
||||
@state()
|
||||
@consume({ context: internationalizationContext, subscribe: true })
|
||||
private _i18n!: ContextType<typeof internationalizationContext>;
|
||||
|
||||
private _entityContext?: SecurityEditorContext;
|
||||
|
||||
protected willUpdate(changedProps: PropertyValues): void {
|
||||
super.willUpdate(changedProps);
|
||||
if (
|
||||
changedProps.has("_states") ||
|
||||
changedProps.has("_registries") ||
|
||||
changedProps.has("_i18n")
|
||||
) {
|
||||
this._entityContext = {
|
||||
states: this._states,
|
||||
...this._registries,
|
||||
language: this._i18n.language,
|
||||
translationMetadata: this._i18n.translationMetadata,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
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._i18n.localize(
|
||||
"ui.panel.security.editor.add_alert_entity"
|
||||
)}
|
||||
.excludeEntities=${this.alertEntities.map(({ entity }) => entity)}
|
||||
.includeDomains=${ALERT_DOMAINS}
|
||||
.entityFilter=${this._entityFilter}
|
||||
@value-changed=${this._add}
|
||||
></ha-entity-picker>
|
||||
`;
|
||||
}
|
||||
|
||||
private _renderAlertEntity(
|
||||
alertEntity: SecurityAlertEntityConfig,
|
||||
index: number
|
||||
) {
|
||||
const stateObj = this._states[alertEntity.entity];
|
||||
const { primary, secondary } =
|
||||
stateObj && this._entityContext
|
||||
? computeEntityPickerDisplay(this._entityContext, stateObj)
|
||||
: { primary: alertEntity.entity, secondary: undefined };
|
||||
const severity =
|
||||
alertEntity.severity ?? computeDefaultSecurityAlertSeverity(stateObj);
|
||||
|
||||
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-control-select-menu
|
||||
show-arrow
|
||||
hide-label
|
||||
.label=${this._i18n.localize(
|
||||
"ui.panel.security.editor.severity.label"
|
||||
)}
|
||||
.value=${severity}
|
||||
.options=${(["alert", "warning"] as const).map((option) => ({
|
||||
value: option,
|
||||
label: this._i18n.localize(
|
||||
`ui.panel.security.editor.severity.${option}`
|
||||
),
|
||||
}))}
|
||||
data-index=${index}
|
||||
@wa-select=${this._severityChanged}
|
||||
></ha-control-select-menu>
|
||||
<ha-icon-button
|
||||
.path=${mdiDelete}
|
||||
.label=${this._i18n.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 _entityFilter = createSecurityEntityFilter(() => this._entityContext);
|
||||
|
||||
private _getIndex(
|
||||
ev: HASSDomCurrentTargetEvent<HTMLElement>
|
||||
): number | undefined {
|
||||
const index = Number(ev.currentTarget.dataset.index);
|
||||
return Number.isInteger(index) ? index : undefined;
|
||||
}
|
||||
|
||||
private _removeClicked(ev: HASSDomCurrentTargetEvent<HTMLElement>): void {
|
||||
ev.stopPropagation();
|
||||
const index = this._getIndex(ev);
|
||||
if (index !== undefined) {
|
||||
const next = [...this.alertEntities];
|
||||
next.splice(index, 1);
|
||||
this._changed(next);
|
||||
}
|
||||
}
|
||||
|
||||
private _severityChanged(
|
||||
ev: HaDropdownSelectEvent<SecurityAlertSeverity> &
|
||||
HASSDomCurrentTargetEvent<HTMLElement>
|
||||
): void {
|
||||
ev.stopPropagation();
|
||||
const index = this._getIndex(ev);
|
||||
if (index === undefined) {
|
||||
return;
|
||||
}
|
||||
const next = [...this.alertEntities];
|
||||
next[index] = { ...next[index], severity: ev.detail.item.value };
|
||||
this._changed(next);
|
||||
}
|
||||
|
||||
private _add(
|
||||
ev: ValueChangedEvent<string | undefined> &
|
||||
HASSDomCurrentTargetEvent<HaEntityPicker>
|
||||
): void {
|
||||
ev.stopPropagation();
|
||||
const entity = ev.detail.value;
|
||||
if (!entity) return;
|
||||
|
||||
ev.currentTarget.value = "";
|
||||
|
||||
if (
|
||||
this.alertEntities.some((alertEntity) => alertEntity.entity === entity)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
this._changed([...this.alertEntities, { 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;
|
||||
}
|
||||
ha-control-select-menu {
|
||||
width: 118px;
|
||||
--control-select-menu-height: 42px;
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"security-alerts-editor": SecurityAlertsEditor;
|
||||
}
|
||||
}
|
||||
@@ -1,237 +0,0 @@
|
||||
import type { ContextType } from "@lit/context";
|
||||
import { css, html, LitElement, nothing, type PropertyValues } from "lit";
|
||||
import { customElement, state } from "lit/decorators";
|
||||
import { consume } from "../../../common/decorators/consume";
|
||||
import "../../../components/ha-button";
|
||||
import "../../../components/ha-dialog";
|
||||
import "../../../components/ha-dialog-footer";
|
||||
import "../../../components/ha-expansion-panel";
|
||||
import "../../../components/ha-icon";
|
||||
import type { SecurityFrontendSystemData } from "../../../data/frontend";
|
||||
import {
|
||||
internationalizationContext,
|
||||
registriesContext,
|
||||
statesContext,
|
||||
} from "../../../data/context";
|
||||
import { DialogMixin } from "../../../dialogs/dialog-mixin";
|
||||
import { DirtyStateProviderMixin } from "../../../mixins/dirty-state-provider-mixin";
|
||||
import { haStyleDialog } from "../../../resources/styles";
|
||||
import type { ValueChangedEvent } from "../../../types";
|
||||
import "../../../components/entity/ha-favorites-editor";
|
||||
import "../components/security-alerts-editor";
|
||||
import {
|
||||
createSecurityEntityFilter,
|
||||
type SecurityEntityContext,
|
||||
} from "../security-entity-filter";
|
||||
import type { EditSecurityDialogParams } from "./show-dialog-edit-security";
|
||||
|
||||
@customElement("dialog-edit-security")
|
||||
export class DialogEditSecurity extends DirtyStateProviderMixin<SecurityFrontendSystemData>()(
|
||||
DialogMixin<EditSecurityDialogParams>(LitElement)
|
||||
) {
|
||||
@state() private _state?: SecurityFrontendSystemData;
|
||||
|
||||
@state() private _submitting = false;
|
||||
|
||||
@state()
|
||||
@consume({ context: internationalizationContext, subscribe: true })
|
||||
private _i18n!: ContextType<typeof internationalizationContext>;
|
||||
|
||||
@state()
|
||||
@consume({ context: statesContext, subscribe: true })
|
||||
private _states!: ContextType<typeof statesContext>;
|
||||
|
||||
@state()
|
||||
@consume({ context: registriesContext, subscribe: true })
|
||||
private _registries!: ContextType<typeof registriesContext>;
|
||||
|
||||
private _entityContext?: SecurityEntityContext;
|
||||
|
||||
protected willUpdate(changedProps: PropertyValues): void {
|
||||
super.willUpdate(changedProps);
|
||||
if (changedProps.has("_states") || changedProps.has("_registries")) {
|
||||
this._entityContext = {
|
||||
states: this._states,
|
||||
...this._registries,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
public connectedCallback(): void {
|
||||
super.connectedCallback();
|
||||
if (!this.params) {
|
||||
return;
|
||||
}
|
||||
this._state = {
|
||||
...this.params.config,
|
||||
favorite_entities: this.params.config.favorite_entities
|
||||
? [...this.params.config.favorite_entities]
|
||||
: [],
|
||||
alert_entities: this.params.config.alert_entities
|
||||
? [...this.params.config.alert_entities]
|
||||
: [],
|
||||
};
|
||||
this._initDirtyTracking({ type: "deep" }, this._state);
|
||||
}
|
||||
|
||||
protected render() {
|
||||
if (!this.params || !this._state) {
|
||||
return nothing;
|
||||
}
|
||||
return html`
|
||||
<ha-dialog
|
||||
open
|
||||
width="medium"
|
||||
.headerTitle=${this._i18n.localize("ui.panel.security.editor.title")}
|
||||
.headerSubtitle=${this._i18n.localize(
|
||||
"ui.panel.security.editor.description"
|
||||
)}
|
||||
.preventScrimClose=${this.isDirtyState}
|
||||
>
|
||||
${this._renderMainEditor()}
|
||||
|
||||
<ha-dialog-footer slot="footer">
|
||||
<ha-button
|
||||
autofocus
|
||||
appearance="plain"
|
||||
slot="secondaryAction"
|
||||
@click=${this.closeDialog}
|
||||
.disabled=${this._submitting}
|
||||
>
|
||||
${this._i18n.localize("ui.common.cancel")}
|
||||
</ha-button>
|
||||
<ha-button
|
||||
slot="primaryAction"
|
||||
@click=${this._save}
|
||||
.disabled=${this._submitting || !this.isDirtyState}
|
||||
>
|
||||
${this._i18n.localize("ui.common.save")}
|
||||
</ha-button>
|
||||
</ha-dialog-footer>
|
||||
</ha-dialog>
|
||||
`;
|
||||
}
|
||||
|
||||
private _renderMainEditor() {
|
||||
return html`
|
||||
<ha-expansion-panel
|
||||
outlined
|
||||
expanded
|
||||
no-collapse
|
||||
.header=${this._i18n.localize(
|
||||
"ui.panel.security.editor.favorite_entities"
|
||||
)}
|
||||
.secondary=${this._i18n.localize(
|
||||
"ui.panel.security.editor.favorite_entities_description"
|
||||
)}
|
||||
>
|
||||
<ha-icon slot="leading-icon" icon="mdi:star-outline"></ha-icon>
|
||||
<div class="expansion-content">
|
||||
<ha-favorites-editor
|
||||
.favorites=${this._state?.favorite_entities ?? []}
|
||||
.entityFilter=${this._entityFilter}
|
||||
.addButtonLabel=${this._i18n.localize(
|
||||
"ui.panel.security.editor.add_favorite_entity"
|
||||
)}
|
||||
@value-changed=${this._favoriteEntitiesChanged}
|
||||
></ha-favorites-editor>
|
||||
</div>
|
||||
</ha-expansion-panel>
|
||||
<ha-expansion-panel
|
||||
outlined
|
||||
expanded
|
||||
no-collapse
|
||||
.header=${this._i18n.localize(
|
||||
"ui.panel.security.editor.active_alert_entities"
|
||||
)}
|
||||
.secondary=${this._i18n.localize(
|
||||
"ui.panel.security.editor.active_alert_entities_description"
|
||||
)}
|
||||
>
|
||||
<ha-icon slot="leading-icon" icon="mdi:shield-alert-outline"></ha-icon>
|
||||
<div class="expansion-content">
|
||||
<security-alerts-editor
|
||||
.alertEntities=${this._state?.alert_entities ?? []}
|
||||
@value-changed=${this._alertEntitiesChanged}
|
||||
></security-alerts-editor>
|
||||
</div>
|
||||
</ha-expansion-panel>
|
||||
`;
|
||||
}
|
||||
|
||||
private _alertEntitiesChanged(
|
||||
ev: ValueChangedEvent<SecurityFrontendSystemData["alert_entities"]>
|
||||
): void {
|
||||
this._state = {
|
||||
...this._state,
|
||||
alert_entities: ev.detail.value,
|
||||
};
|
||||
this._updateDirtyState(this._state);
|
||||
}
|
||||
|
||||
private _favoriteEntitiesChanged(
|
||||
ev: ValueChangedEvent<SecurityFrontendSystemData["favorite_entities"]>
|
||||
): void {
|
||||
this._state = {
|
||||
...this._state,
|
||||
favorite_entities: ev.detail.value,
|
||||
};
|
||||
this._updateDirtyState(this._state);
|
||||
}
|
||||
|
||||
private _entityFilter = createSecurityEntityFilter(() => this._entityContext);
|
||||
|
||||
private async _save(): Promise<void> {
|
||||
if (!this.params || !this._state) return;
|
||||
|
||||
this._submitting = true;
|
||||
|
||||
try {
|
||||
await this.params.saveConfig({
|
||||
...this.params.config,
|
||||
favorite_entities: this._state.favorite_entities?.length
|
||||
? this._state.favorite_entities
|
||||
: undefined,
|
||||
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-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);
|
||||
}
|
||||
|
||||
ha-expansion-panel + ha-expansion-panel {
|
||||
margin-top: var(--ha-space-4);
|
||||
}
|
||||
|
||||
.expansion-content {
|
||||
padding: 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,
|
||||
});
|
||||
};
|
||||
@@ -2,13 +2,15 @@ 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 type { HASSDomEvent } from "../../common/dom/fire_event";
|
||||
import { debounce } from "../../common/util/debounce";
|
||||
import { deepEqual } from "../../common/util/deep-equal";
|
||||
import "../../components/ha-button";
|
||||
import "../../components/ha-icon-button";
|
||||
import "../../components/ha-top-app-bar-fixed";
|
||||
import {
|
||||
fetchFrontendSystemData,
|
||||
saveFrontendSystemData,
|
||||
fetchFrontendSystemDataAfterSaves,
|
||||
queueFrontendSystemDataSave,
|
||||
type SecurityFrontendSystemData,
|
||||
} from "../../data/frontend";
|
||||
import type { LovelaceStrategyViewConfig } from "../../data/lovelace/config/view";
|
||||
@@ -18,7 +20,7 @@ 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 { clearEntityVisibilityFocus } from "../lovelace/cards/hui-entity-visibility-card";
|
||||
import "../lovelace/views/hui-view";
|
||||
import "../lovelace/views/hui-view-container";
|
||||
import "../lovelace/views/hui-view-background";
|
||||
@@ -41,6 +43,8 @@ class PanelSecurity extends LitElement {
|
||||
|
||||
@state() private _config?: SecurityFrontendSystemData;
|
||||
|
||||
@state() private _editVisibility = false;
|
||||
|
||||
@state() private _searchParms = new URLSearchParams(window.location.search);
|
||||
|
||||
private _childPanelReady?: ChildPanelReady;
|
||||
@@ -49,6 +53,8 @@ class PanelSecurity extends LitElement {
|
||||
|
||||
private _loadConfigRevision = 0;
|
||||
|
||||
private _setLovelaceRevision = 0;
|
||||
|
||||
public willUpdate(changedProps: PropertyValues<this>) {
|
||||
super.willUpdate(changedProps);
|
||||
// Initial setup
|
||||
@@ -105,7 +111,7 @@ class PanelSecurity extends LitElement {
|
||||
const revision = ++this._loadConfigRevision;
|
||||
this._config = undefined;
|
||||
try {
|
||||
const data = await fetchFrontendSystemData(
|
||||
const data = await fetchFrontendSystemDataAfterSaves(
|
||||
this.hass.connection,
|
||||
"security"
|
||||
);
|
||||
@@ -144,14 +150,25 @@ class PanelSecurity extends LitElement {
|
||||
>
|
||||
<div slot="title">${this.hass.localize("panel.security")}</div>
|
||||
${
|
||||
this.hass.user?.is_admin && this._config
|
||||
? html`<ha-icon-button
|
||||
slot="actionItems"
|
||||
.path=${mdiPencil}
|
||||
.label=${this.hass.localize("ui.panel.security.editor.title")}
|
||||
@click=${this._editSecurity}
|
||||
></ha-icon-button>`
|
||||
: nothing
|
||||
!this.hass.user?.is_admin
|
||||
? nothing
|
||||
: this._editVisibility
|
||||
? html`<ha-button
|
||||
slot="actionItems"
|
||||
appearance="filled"
|
||||
size="s"
|
||||
@click=${this._stopEditVisibility}
|
||||
>
|
||||
${this.hass.localize("ui.panel.lovelace.menu.exit_edit_mode")}
|
||||
</ha-button>`
|
||||
: this._config
|
||||
? html`<ha-icon-button
|
||||
slot="actionItems"
|
||||
.path=${mdiPencil}
|
||||
.label=${this.hass.localize("ui.panel.security.editor.title")}
|
||||
@click=${this._startEditVisibility}
|
||||
></ha-icon-button>`
|
||||
: nothing
|
||||
}
|
||||
${
|
||||
this._lovelace
|
||||
@@ -164,6 +181,9 @@ class PanelSecurity extends LitElement {
|
||||
.narrow=${this.narrow}
|
||||
.lovelace=${this._lovelace}
|
||||
.index=${this._viewIndex}
|
||||
@entity-visibility-changed=${this._visibilityChanged}
|
||||
@entity-favorite-changed=${this._favoriteChanged}
|
||||
@entity-alert-changed=${this._alertChanged}
|
||||
></hui-view
|
||||
></hui-view-container>
|
||||
`
|
||||
@@ -174,6 +194,7 @@ class PanelSecurity extends LitElement {
|
||||
}
|
||||
|
||||
private async _setLovelace() {
|
||||
const revision = ++this._setLovelaceRevision;
|
||||
if (this._loadConfigPromise) {
|
||||
await this._loadConfigPromise;
|
||||
}
|
||||
@@ -184,11 +205,17 @@ class PanelSecurity extends LitElement {
|
||||
...SECURITY_LOVELACE_VIEW_CONFIG.strategy,
|
||||
alert_entities: this._config?.alert_entities,
|
||||
favorite_entities: this._config?.favorite_entities,
|
||||
hidden_entities: this._config?.hidden_entities,
|
||||
edit_visibility: this._editVisibility || undefined,
|
||||
},
|
||||
},
|
||||
this.hass
|
||||
);
|
||||
|
||||
if (revision !== this._setLovelaceRevision) {
|
||||
return;
|
||||
}
|
||||
|
||||
const config = { views: [viewConfig] };
|
||||
const rawConfig = { views: [SECURITY_LOVELACE_VIEW_CONFIG] };
|
||||
|
||||
@@ -212,23 +239,89 @@ class PanelSecurity extends LitElement {
|
||||
};
|
||||
}
|
||||
|
||||
private _editSecurity = () => {
|
||||
if (!this.hass.user?.is_admin || !this._config) {
|
||||
private _startEditVisibility = () => {
|
||||
clearEntityVisibilityFocus();
|
||||
this._editVisibility = true;
|
||||
this._setLovelace();
|
||||
};
|
||||
|
||||
private _stopEditVisibility = () => {
|
||||
this._editVisibility = false;
|
||||
if (!this._config) {
|
||||
this._loadConfigPromise = this._loadConfig();
|
||||
}
|
||||
this._setLovelace();
|
||||
};
|
||||
|
||||
private _visibilityChanged = (
|
||||
ev: HASSDomEvent<HASSDomEvents["entity-visibility-changed"]>
|
||||
) => {
|
||||
if (!this._config) {
|
||||
return;
|
||||
}
|
||||
showEditSecurityDialog(this, {
|
||||
config: this._config,
|
||||
saveConfig: async (config) => {
|
||||
await this._saveConfig(config);
|
||||
},
|
||||
const { entity, hidden } = ev.detail;
|
||||
const hiddenEntities = (this._config.hidden_entities ?? []).filter(
|
||||
(entityId) => entityId !== entity
|
||||
);
|
||||
if (hidden) {
|
||||
hiddenEntities.push(entity);
|
||||
}
|
||||
this._saveConfig({
|
||||
...this._config,
|
||||
hidden_entities: hiddenEntities.length ? hiddenEntities : undefined,
|
||||
});
|
||||
};
|
||||
|
||||
private async _saveConfig(config: SecurityFrontendSystemData): Promise<void> {
|
||||
try {
|
||||
await saveFrontendSystemData(this.hass.connection, "security", config);
|
||||
this._config = config;
|
||||
} catch (err) {
|
||||
private _favoriteChanged = (
|
||||
ev: HASSDomEvent<HASSDomEvents["entity-favorite-changed"]>
|
||||
) => {
|
||||
if (!this._config) {
|
||||
return;
|
||||
}
|
||||
const { entity, favorite } = ev.detail;
|
||||
const favoriteEntities = (this._config.favorite_entities ?? []).filter(
|
||||
(entityId) => entityId !== entity
|
||||
);
|
||||
if (favorite) {
|
||||
favoriteEntities.push(entity);
|
||||
}
|
||||
this._saveConfig({
|
||||
...this._config,
|
||||
favorite_entities: favoriteEntities.length ? favoriteEntities : undefined,
|
||||
});
|
||||
};
|
||||
|
||||
private _alertChanged = (
|
||||
ev: HASSDomEvent<HASSDomEvents["entity-alert-changed"]>
|
||||
) => {
|
||||
if (!this._config) {
|
||||
return;
|
||||
}
|
||||
const { entity, severity } = ev.detail;
|
||||
const current = this._config.alert_entities ?? [];
|
||||
const alertEntities = !severity
|
||||
? current.filter((alertEntity) => alertEntity.entity !== entity)
|
||||
: current.some((alertEntity) => alertEntity.entity === entity)
|
||||
? current.map((alertEntity) =>
|
||||
alertEntity.entity === entity
|
||||
? { ...alertEntity, severity }
|
||||
: alertEntity
|
||||
)
|
||||
: [...current, { entity, severity }];
|
||||
this._saveConfig({
|
||||
...this._config,
|
||||
alert_entities: alertEntities.length ? alertEntities : undefined,
|
||||
});
|
||||
};
|
||||
|
||||
private _saveConfig(config: SecurityFrontendSystemData): Promise<void> {
|
||||
this._config = config;
|
||||
this._setLovelace();
|
||||
return queueFrontendSystemDataSave(
|
||||
this.hass.connection,
|
||||
"security",
|
||||
config
|
||||
).catch(async (err) => {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error("Failed to save security configuration:", err);
|
||||
showToast(this, {
|
||||
@@ -236,12 +329,12 @@ class PanelSecurity extends LitElement {
|
||||
duration: 0,
|
||||
dismissable: true,
|
||||
});
|
||||
throw err;
|
||||
}
|
||||
showToast(this, {
|
||||
message: this.hass.localize("ui.common.successfully_saved"),
|
||||
if (config !== this._config) {
|
||||
return;
|
||||
}
|
||||
await this._loadConfig();
|
||||
await this._setLovelace();
|
||||
});
|
||||
await this._setLovelace();
|
||||
}
|
||||
|
||||
static get styles(): CSSResultGroup {
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
import type { ContextType } from "@lit/context";
|
||||
import type { HassEntity } from "home-assistant-js-websocket";
|
||||
import type { registriesContext, statesContext } from "../../data/context";
|
||||
import { isSecurityPanelEntity } from "./strategies/security-view-strategy";
|
||||
|
||||
export type SecurityEntityContext = ContextType<typeof registriesContext> & {
|
||||
states: ContextType<typeof statesContext>;
|
||||
};
|
||||
|
||||
export const createSecurityEntityFilter =
|
||||
(getContext: () => SecurityEntityContext | undefined) =>
|
||||
(entity: HassEntity): boolean => {
|
||||
const context = getContext();
|
||||
return context ? isSecurityPanelEntity(context, entity) : false;
|
||||
};
|
||||
@@ -97,6 +97,10 @@ export const filterSecurityAlertEntities = (
|
||||
) === severity
|
||||
);
|
||||
|
||||
export const computeSecurityAlertColor = (
|
||||
severity: SecurityAlertSeverity
|
||||
): string => (severity === "alert" ? "red" : "amber");
|
||||
|
||||
export const computeSecurityAlertCardConfig = (
|
||||
stateObj: HassEntity | undefined,
|
||||
alertEntity: SecurityAlertEntityConfig
|
||||
@@ -105,7 +109,7 @@ export const computeSecurityAlertCardConfig = (
|
||||
return {
|
||||
type: "alert",
|
||||
entity: alertEntity.entity,
|
||||
alert_color: severity === "alert" ? "red" : "amber",
|
||||
alert_color: computeSecurityAlertColor(severity),
|
||||
visibility: computeDefaultSecurityAlertVisibility(alertEntity.entity),
|
||||
};
|
||||
};
|
||||
@@ -21,15 +21,28 @@ import type {
|
||||
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 type {
|
||||
EntityVisibilityCardConfig,
|
||||
LogbookCardConfig,
|
||||
} from "../../lovelace/cards/types";
|
||||
import { computeAreaTileCardConfig } from "../../lovelace/strategies/areas/helpers/areas-strategy-helper";
|
||||
import {
|
||||
wrapEntityVisibility,
|
||||
type ComputeEntityCard,
|
||||
} from "../../lovelace/strategies/helpers/entity-visibility";
|
||||
import { computeFavoriteCardConfig } from "../../lovelace/strategies/helpers/favorite-cards";
|
||||
import { computeSecurityAlertCardConfig } from "./security-alerts";
|
||||
import {
|
||||
computeSecurityAlertCardConfig,
|
||||
computeSecurityAlertColor,
|
||||
resolveSecurityAlertSeverity,
|
||||
} from "./security-alerts";
|
||||
|
||||
export interface SecurityViewStrategyConfig {
|
||||
type: "security";
|
||||
alert_entities?: SecurityAlertEntityConfig[];
|
||||
favorite_entities?: string[];
|
||||
hidden_entities?: string[];
|
||||
edit_visibility?: boolean;
|
||||
}
|
||||
|
||||
export const securityEntityFilters: EntityFilter[] = [
|
||||
@@ -79,8 +92,11 @@ export const securityEntityFilters: EntityFilter[] = [
|
||||
},
|
||||
];
|
||||
|
||||
export const findSecurityEntities = (sources: EntityFilterSources): string[] =>
|
||||
findFilteredEntities(sources, securityEntityFilters);
|
||||
export const findSecurityEntities = (
|
||||
sources: EntityFilterSources,
|
||||
hiddenEntities?: string[]
|
||||
): string[] =>
|
||||
findFilteredEntities(sources, securityEntityFilters, hiddenEntities);
|
||||
|
||||
export const isSecurityPanelEntity = (
|
||||
hass: EntityFilterSources,
|
||||
@@ -100,7 +116,8 @@ export const isSecurityPanelEntity = (
|
||||
const processAreasForSecurity = (
|
||||
areaIds: string[],
|
||||
hass: HomeAssistant,
|
||||
entities: string[]
|
||||
entities: string[],
|
||||
computeCard: ComputeEntityCard
|
||||
): LovelaceCardConfig[] => {
|
||||
const cards: LovelaceCardConfig[] = [];
|
||||
|
||||
@@ -121,10 +138,8 @@ const processAreasForSecurity = (
|
||||
const areaSecurityEntities = entities.filter(areaFilter);
|
||||
const areaCards: LovelaceCardConfig[] = [];
|
||||
|
||||
const computeTileCard = computeAreaTileCardConfig(hass, false);
|
||||
|
||||
for (const entityId of areaSecurityEntities) {
|
||||
areaCards.push(computeTileCard(entityId));
|
||||
areaCards.push(computeCard(entityId));
|
||||
}
|
||||
|
||||
if (areaCards.length > 0) {
|
||||
@@ -148,7 +163,8 @@ const processAreasForSecurity = (
|
||||
|
||||
const processUnassignedEntities = (
|
||||
hass: HomeAssistant,
|
||||
entities: string[]
|
||||
entities: string[],
|
||||
computeCard: ComputeEntityCard
|
||||
): LovelaceCardConfig[] => {
|
||||
const unassignedFilter = generateEntityFilter(
|
||||
hass.states,
|
||||
@@ -162,10 +178,9 @@ const processUnassignedEntities = (
|
||||
);
|
||||
const unassignedLights = entities.filter(unassignedFilter);
|
||||
const areaCards: LovelaceCardConfig[] = [];
|
||||
const computeTileCard = computeAreaTileCardConfig(hass, false);
|
||||
|
||||
for (const entityId of unassignedLights) {
|
||||
areaCards.push(computeTileCard(entityId));
|
||||
areaCards.push(computeCard(entityId));
|
||||
}
|
||||
|
||||
return areaCards;
|
||||
@@ -183,14 +198,60 @@ export class SecurityViewStrategy extends ReactiveElement {
|
||||
|
||||
const sections: LovelaceSectionRawConfig[] = [];
|
||||
|
||||
const entities = findSecurityEntities(hass);
|
||||
|
||||
const favoriteEntities = (config.favorite_entities ?? []).filter(
|
||||
(entityId) =>
|
||||
hass.states[entityId] &&
|
||||
isSecurityPanelEntity(hass, hass.states[entityId])
|
||||
const editVisibility = Boolean(config.edit_visibility);
|
||||
const entities = findSecurityEntities(
|
||||
hass,
|
||||
editVisibility ? undefined : config.hidden_entities
|
||||
);
|
||||
|
||||
const favoriteEntities = editVisibility
|
||||
? (config.favorite_entities ?? [])
|
||||
: (config.favorite_entities ?? []).filter(
|
||||
(entityId) =>
|
||||
hass.states[entityId] &&
|
||||
isSecurityPanelEntity(hass, hass.states[entityId])
|
||||
);
|
||||
|
||||
const favoriteSet = new Set(config.favorite_entities);
|
||||
const alertEntities = new Map(
|
||||
(config.alert_entities ?? []).map((alertEntity) => [
|
||||
alertEntity.entity,
|
||||
alertEntity,
|
||||
])
|
||||
);
|
||||
const computeEditExtras = (
|
||||
entityId: string
|
||||
): Pick<EntityVisibilityCardConfig, "favorite" | "alert"> => {
|
||||
const alertEntity = alertEntities.get(entityId);
|
||||
return {
|
||||
favorite: favoriteSet.has(entityId),
|
||||
alert: ALERT_DOMAINS.includes(computeDomain(entityId))
|
||||
? alertEntity
|
||||
? resolveSecurityAlertSeverity(alertEntity, hass.states[entityId])
|
||||
: "none"
|
||||
: undefined,
|
||||
};
|
||||
};
|
||||
const wrapCard = (computeCard: ComputeEntityCard): ComputeEntityCard =>
|
||||
editVisibility
|
||||
? wrapEntityVisibility(
|
||||
new Set(config.hidden_entities),
|
||||
computeCard,
|
||||
computeEditExtras
|
||||
)
|
||||
: computeCard;
|
||||
const computeFavoriteCard = editVisibility
|
||||
? (entityId: string, index: number): EntityVisibilityCardConfig => ({
|
||||
type: "entity-visibility",
|
||||
entity: entityId,
|
||||
neighbor_entity:
|
||||
favoriteEntities[index + 1] ?? favoriteEntities[index - 1],
|
||||
...computeEditExtras(entityId),
|
||||
card: computeFavoriteCardConfig(entityId),
|
||||
})
|
||||
: computeFavoriteCardConfig;
|
||||
const computeCard = wrapCard(computeAreaTileCardConfig(hass, false));
|
||||
|
||||
if (favoriteEntities.length > 0) {
|
||||
sections.push({
|
||||
type: "grid",
|
||||
@@ -203,7 +264,7 @@ export class SecurityViewStrategy extends ReactiveElement {
|
||||
),
|
||||
heading_style: "title",
|
||||
},
|
||||
...favoriteEntities.map(computeFavoriteCardConfig),
|
||||
...favoriteEntities.map(computeFavoriteCard),
|
||||
],
|
||||
});
|
||||
}
|
||||
@@ -232,7 +293,12 @@ export class SecurityViewStrategy extends ReactiveElement {
|
||||
],
|
||||
};
|
||||
|
||||
const areaCards = processAreasForSecurity(areaIds, hass, entities);
|
||||
const areaCards = processAreasForSecurity(
|
||||
areaIds,
|
||||
hass,
|
||||
entities,
|
||||
computeCard
|
||||
);
|
||||
|
||||
if (areaCards.length > 0) {
|
||||
section.cards!.push(...areaCards);
|
||||
@@ -259,7 +325,8 @@ export class SecurityViewStrategy extends ReactiveElement {
|
||||
const areaCards = processAreasForSecurity(
|
||||
hierarchy.areas,
|
||||
hass,
|
||||
entities
|
||||
entities,
|
||||
computeCard
|
||||
);
|
||||
|
||||
if (areaCards.length > 0) {
|
||||
@@ -269,7 +336,11 @@ export class SecurityViewStrategy extends ReactiveElement {
|
||||
}
|
||||
|
||||
// Process unassigned entities
|
||||
const unassignedCards = processUnassignedEntities(hass, entities);
|
||||
const unassignedCards = processUnassignedEntities(
|
||||
hass,
|
||||
entities,
|
||||
computeCard
|
||||
);
|
||||
|
||||
if (unassignedCards.length > 0) {
|
||||
const section: LovelaceSectionRawConfig = {
|
||||
@@ -303,18 +374,60 @@ export class SecurityViewStrategy extends ReactiveElement {
|
||||
|
||||
const sidebarSections: LovelaceSectionConfig[] = [];
|
||||
|
||||
const alertCards = config.alert_entities
|
||||
?.filter((alertEntity) =>
|
||||
ALERT_DOMAINS.includes(computeDomain(alertEntity.entity))
|
||||
)
|
||||
.map((alertEntity) =>
|
||||
computeSecurityAlertCardConfig(
|
||||
hass.states[alertEntity.entity],
|
||||
alertEntity
|
||||
)
|
||||
);
|
||||
const alertEntityConfigs = (config.alert_entities ?? []).filter(
|
||||
(alertEntity) => ALERT_DOMAINS.includes(computeDomain(alertEntity.entity))
|
||||
);
|
||||
|
||||
if (alertCards?.length) {
|
||||
const alertCards = editVisibility
|
||||
? undefined
|
||||
: alertEntityConfigs.map((alertEntity) =>
|
||||
computeSecurityAlertCardConfig(
|
||||
hass.states[alertEntity.entity],
|
||||
alertEntity
|
||||
)
|
||||
);
|
||||
|
||||
if (editVisibility && alertEntityConfigs.length) {
|
||||
sidebarSections.push({
|
||||
type: "grid",
|
||||
cards: [
|
||||
{
|
||||
type: "heading",
|
||||
heading: hass.localize(
|
||||
"ui.panel.lovelace.strategy.security.active_alerts"
|
||||
),
|
||||
heading_style: "title",
|
||||
},
|
||||
...alertEntityConfigs.map(
|
||||
(alertEntity, index): EntityVisibilityCardConfig => {
|
||||
const stateObj = hass.states[alertEntity.entity];
|
||||
const severity = resolveSecurityAlertSeverity(
|
||||
alertEntity,
|
||||
stateObj
|
||||
);
|
||||
const color = computeSecurityAlertColor(severity);
|
||||
return {
|
||||
type: "entity-visibility",
|
||||
entity: alertEntity.entity,
|
||||
favorite: favoriteSet.has(alertEntity.entity),
|
||||
alert: severity,
|
||||
alert_select: true,
|
||||
neighbor_entity: (
|
||||
alertEntityConfigs[index + 1] ?? alertEntityConfigs[index - 1]
|
||||
)?.entity,
|
||||
grid_options: { columns: 12 },
|
||||
card: {
|
||||
type: "alert",
|
||||
entity: alertEntity.entity,
|
||||
alert_color: color,
|
||||
clear_color: color,
|
||||
},
|
||||
};
|
||||
}
|
||||
),
|
||||
] satisfies LovelaceCardConfig[],
|
||||
});
|
||||
} else if (alertCards?.length) {
|
||||
sidebarSections.push({
|
||||
type: "grid",
|
||||
visibility: [
|
||||
|
||||
+26
-13
@@ -230,6 +230,17 @@
|
||||
"logbook": {
|
||||
"device_not_found": "{count, plural,\n one {A device used by this card no longer exists.}\n other {# devices used by this card no longer exist.}\n}"
|
||||
},
|
||||
"entity-visibility": {
|
||||
"add_favorite": "Add {label} to favorites",
|
||||
"remove_favorite": "Remove {label} from favorites",
|
||||
"alert_level": "Alert level for {label}: {level}",
|
||||
"alert_level_group": "Alert level for {label}",
|
||||
"alert_levels": {
|
||||
"none": "None",
|
||||
"warning": "Warning",
|
||||
"alert": "Alert"
|
||||
}
|
||||
},
|
||||
"home-summary": {
|
||||
"all_lights_off": "All off",
|
||||
"count_active_alerts": "{count} {count, plural,\n one {active alert}\n other {active alerts}\n}",
|
||||
@@ -2915,23 +2926,25 @@
|
||||
},
|
||||
"security": {
|
||||
"editor": {
|
||||
"title": "Edit security and safety page",
|
||||
"description": "Configure your security and safety display preferences.",
|
||||
"favorite_entities": "[%key:ui::panel::lovelace::editor::strategy::home::favorite_entities%]",
|
||||
"favorite_entities_description": "Pin entities to the top of the page.",
|
||||
"add_favorite_entity": "[%key:ui::panel::lovelace::editor::strategy::home::add_favorite_entity%]",
|
||||
"active_alert_entities": "Active alert entities",
|
||||
"active_alert_entities_description": "Display any entities that require attention.",
|
||||
"add_alert_entity": "Add entity",
|
||||
"severity": {
|
||||
"label": "Severity",
|
||||
"alert": "Alert",
|
||||
"warning": "Warning"
|
||||
},
|
||||
"title": "Edit visible entities",
|
||||
"load_failed": "Failed to load security and safety page configuration",
|
||||
"save_failed": "Failed to save security and safety page configuration"
|
||||
}
|
||||
},
|
||||
"climate": {
|
||||
"editor": {
|
||||
"title": "Edit visible entities",
|
||||
"load_failed": "Failed to load climate page configuration",
|
||||
"save_failed": "Failed to save climate page configuration"
|
||||
}
|
||||
},
|
||||
"maintenance": {
|
||||
"editor": {
|
||||
"title": "Edit visible entities",
|
||||
"load_failed": "Failed to load maintenance page configuration",
|
||||
"save_failed": "Failed to save maintenance 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.",
|
||||
|
||||
@@ -710,15 +710,23 @@ describe("generateEntityFilter", () => {
|
||||
});
|
||||
|
||||
describe("findFilteredEntities", () => {
|
||||
it("should match entities against any of the filters", () => {
|
||||
const entities = findFilteredEntities(mockHass, [
|
||||
{ domain: "light" },
|
||||
{ domain: "switch" },
|
||||
]);
|
||||
it("should match any filter and drop excluded entities", () => {
|
||||
const entities = findFilteredEntities(
|
||||
mockHass,
|
||||
[{ domain: "light" }, { domain: "switch" }],
|
||||
["light.bedroom"]
|
||||
);
|
||||
|
||||
expect(entities).toContain("light.living_room");
|
||||
expect(entities).toContain("switch.kitchen");
|
||||
expect(entities).not.toContain("light.bedroom");
|
||||
expect(entities).not.toContain("sensor.temperature");
|
||||
});
|
||||
|
||||
it("should return every match when nothing is excluded", () => {
|
||||
const entities = findFilteredEntities(mockHass, [{ domain: "light" }]);
|
||||
|
||||
expect(entities).toContain("light.living_room");
|
||||
expect(entities).toContain("light.bedroom");
|
||||
expect(entities).toContain("switch.kitchen");
|
||||
expect(entities).not.toContain("sensor.temperature");
|
||||
});
|
||||
});
|
||||
@@ -1,70 +0,0 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { SecurityFrontendSystemData } from "../../../../src/data/frontend";
|
||||
import type { EditSecurityDialogParams } from "../../../../src/panels/security/dialogs/show-dialog-edit-security";
|
||||
import type { HomeAssistantInternationalization } from "../../../../src/types";
|
||||
import "../../../../src/panels/security/dialogs/dialog-edit-security";
|
||||
import { createMockHass } from "../../../fixtures/hass";
|
||||
|
||||
interface TestEditSecurityDialog extends HTMLElement {
|
||||
params: EditSecurityDialogParams;
|
||||
_i18n: HomeAssistantInternationalization;
|
||||
isDirtyState: boolean;
|
||||
connectedCallback(): void;
|
||||
disconnectedCallback(): void;
|
||||
performUpdate(): void;
|
||||
}
|
||||
|
||||
const alertEntitiesChanged = (
|
||||
dialog: TestEditSecurityDialog,
|
||||
ev: CustomEvent
|
||||
) =>
|
||||
(
|
||||
dialog as unknown as Record<
|
||||
"_alertEntitiesChanged",
|
||||
(event: CustomEvent) => void
|
||||
>
|
||||
)["_alertEntitiesChanged"](ev);
|
||||
|
||||
describe("dialog-edit-security", () => {
|
||||
const createDialog = (config: SecurityFrontendSystemData = {}) => {
|
||||
const hass = createMockHass();
|
||||
const dialog = document.createElement(
|
||||
"dialog-edit-security"
|
||||
) as unknown as TestEditSecurityDialog;
|
||||
dialog._i18n = hass;
|
||||
dialog.params = {
|
||||
config,
|
||||
saveConfig: vi.fn(),
|
||||
};
|
||||
dialog.performUpdate = vi.fn();
|
||||
dialog.connectedCallback();
|
||||
return dialog;
|
||||
};
|
||||
|
||||
it("becomes clean after nested configuration is restored", () => {
|
||||
const alertEntities = [
|
||||
{ entity: "binary_sensor.window", severity: "warning" as const },
|
||||
];
|
||||
const dialog = createDialog({ alert_entities: alertEntities });
|
||||
|
||||
alertEntitiesChanged(
|
||||
dialog,
|
||||
new CustomEvent("value-changed", {
|
||||
detail: {
|
||||
value: [{ ...alertEntities[0], severity: "alert" }],
|
||||
},
|
||||
})
|
||||
);
|
||||
expect(dialog.isDirtyState).toBe(true);
|
||||
|
||||
alertEntitiesChanged(
|
||||
dialog,
|
||||
new CustomEvent("value-changed", {
|
||||
detail: { value: [{ ...alertEntities[0] }] },
|
||||
})
|
||||
);
|
||||
expect(dialog.isDirtyState).toBe(false);
|
||||
|
||||
dialog.disconnectedCallback();
|
||||
});
|
||||
});
|
||||
@@ -1,10 +1,51 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { LovelaceCardConfig } from "../../../../src/data/lovelace/config/card";
|
||||
import type { LovelaceSectionRawConfig } from "../../../../src/data/lovelace/config/section";
|
||||
import { isStrategySection } from "../../../../src/data/lovelace/config/section";
|
||||
import {
|
||||
isSecurityPanelEntity,
|
||||
SecurityViewStrategy,
|
||||
} from "../../../../src/panels/security/strategies/security-view-strategy";
|
||||
import { createMockEntityState, createMockHass } from "../../../fixtures/hass";
|
||||
|
||||
const createSecurityHass = () => {
|
||||
const hass = createMockHass();
|
||||
hass.config = { ...hass.config, components: [] };
|
||||
for (const [entityId, deviceClass] of [
|
||||
["binary_sensor.front_door", "door"],
|
||||
["binary_sensor.window", "window"],
|
||||
]) {
|
||||
hass.states[entityId] = createMockEntityState(entityId, "off", {
|
||||
device_class: deviceClass,
|
||||
});
|
||||
}
|
||||
hass.states["sensor.temperature"] = createMockEntityState(
|
||||
"sensor.temperature",
|
||||
"21"
|
||||
);
|
||||
return hass;
|
||||
};
|
||||
|
||||
const cardsOf = (section?: LovelaceSectionRawConfig): LovelaceCardConfig[] =>
|
||||
!section || isStrategySection(section)
|
||||
? []
|
||||
: (section.cards ?? []).filter((card) => card.type !== "heading");
|
||||
|
||||
const sectionWithHeading = (
|
||||
sections: LovelaceSectionRawConfig[] | undefined,
|
||||
heading: string
|
||||
) =>
|
||||
sections?.find(
|
||||
(section) =>
|
||||
!isStrategySection(section) &&
|
||||
section.cards?.some(
|
||||
(card) => card.type === "heading" && card.heading === heading
|
||||
)
|
||||
);
|
||||
|
||||
const FAVORITES = "ui.panel.lovelace.strategy.security.favorites";
|
||||
const ACTIVE_ALERTS = "ui.panel.lovelace.strategy.security.active_alerts";
|
||||
|
||||
describe("security-view-strategy", () => {
|
||||
it("includes glass break binary sensors", () => {
|
||||
const hass = createMockHass();
|
||||
@@ -68,4 +109,152 @@ describe("security-view-strategy", () => {
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("drops hidden entities from the tiles but keeps their favorite and alert entries", async () => {
|
||||
const view = await SecurityViewStrategy.generate(
|
||||
{
|
||||
type: "security",
|
||||
hidden_entities: ["binary_sensor.front_door"],
|
||||
favorite_entities: ["binary_sensor.front_door"],
|
||||
alert_entities: [{ entity: "binary_sensor.front_door" }],
|
||||
},
|
||||
createSecurityHass()
|
||||
);
|
||||
|
||||
const favorites = sectionWithHeading(view.sections, FAVORITES);
|
||||
const tiles = view.sections!.filter((section) => section !== favorites);
|
||||
|
||||
expect(tiles.flatMap(cardsOf).map((card) => card.entity)).toEqual([
|
||||
"binary_sensor.window",
|
||||
]);
|
||||
expect(cardsOf(favorites).map((card) => card.entity)).toEqual([
|
||||
"binary_sensor.front_door",
|
||||
]);
|
||||
expect(
|
||||
cardsOf(sectionWithHeading(view.sidebar?.sections, ACTIVE_ALERTS)).map(
|
||||
(card) => card.entity
|
||||
)
|
||||
).toEqual(["binary_sensor.front_door"]);
|
||||
});
|
||||
|
||||
it("lists deleted and non-matching favorites only in edit mode", async () => {
|
||||
const config = {
|
||||
type: "security" as const,
|
||||
favorite_entities: [
|
||||
"binary_sensor.front_door",
|
||||
"binary_sensor.gone",
|
||||
"sensor.temperature",
|
||||
],
|
||||
};
|
||||
|
||||
const view = await SecurityViewStrategy.generate(
|
||||
config,
|
||||
createSecurityHass()
|
||||
);
|
||||
expect(
|
||||
cardsOf(sectionWithHeading(view.sections, FAVORITES)).map(
|
||||
(card) => card.entity
|
||||
)
|
||||
).toEqual(["binary_sensor.front_door"]);
|
||||
|
||||
const editView = await SecurityViewStrategy.generate(
|
||||
{ ...config, edit_visibility: true },
|
||||
createSecurityHass()
|
||||
);
|
||||
expect(cardsOf(sectionWithHeading(editView.sections, FAVORITES))).toEqual([
|
||||
{
|
||||
type: "entity-visibility",
|
||||
entity: "binary_sensor.front_door",
|
||||
neighbor_entity: "binary_sensor.gone",
|
||||
favorite: true,
|
||||
alert: "none",
|
||||
card: expect.objectContaining({ entity: "binary_sensor.front_door" }),
|
||||
},
|
||||
{
|
||||
type: "entity-visibility",
|
||||
entity: "binary_sensor.gone",
|
||||
neighbor_entity: "sensor.temperature",
|
||||
favorite: true,
|
||||
alert: "none",
|
||||
card: expect.objectContaining({ entity: "binary_sensor.gone" }),
|
||||
},
|
||||
{
|
||||
type: "entity-visibility",
|
||||
entity: "sensor.temperature",
|
||||
neighbor_entity: "binary_sensor.gone",
|
||||
favorite: true,
|
||||
alert: undefined,
|
||||
card: expect.objectContaining({ entity: "sensor.temperature" }),
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps hidden tiles and lists every configured alert in edit mode", async () => {
|
||||
const view = await SecurityViewStrategy.generate(
|
||||
{
|
||||
type: "security",
|
||||
edit_visibility: true,
|
||||
hidden_entities: ["binary_sensor.front_door"],
|
||||
alert_entities: [
|
||||
{ entity: "binary_sensor.front_door", severity: "alert" },
|
||||
{ entity: "binary_sensor.gone" },
|
||||
],
|
||||
},
|
||||
createSecurityHass()
|
||||
);
|
||||
|
||||
expect(view.sections!.flatMap(cardsOf)).toEqual([
|
||||
{
|
||||
type: "entity-visibility",
|
||||
entity: "binary_sensor.front_door",
|
||||
hidden: true,
|
||||
favorite: false,
|
||||
alert: "alert",
|
||||
card: expect.objectContaining({ entity: "binary_sensor.front_door" }),
|
||||
},
|
||||
{
|
||||
type: "entity-visibility",
|
||||
entity: "binary_sensor.window",
|
||||
hidden: false,
|
||||
favorite: false,
|
||||
alert: "none",
|
||||
card: expect.objectContaining({ entity: "binary_sensor.window" }),
|
||||
},
|
||||
]);
|
||||
|
||||
const alerts = sectionWithHeading(view.sidebar?.sections, ACTIVE_ALERTS);
|
||||
expect(alerts?.visibility).toBeUndefined();
|
||||
expect(cardsOf(alerts)).toEqual([
|
||||
{
|
||||
type: "entity-visibility",
|
||||
entity: "binary_sensor.front_door",
|
||||
favorite: false,
|
||||
alert: "alert",
|
||||
alert_select: true,
|
||||
neighbor_entity: "binary_sensor.gone",
|
||||
grid_options: { columns: 12 },
|
||||
card: {
|
||||
type: "alert",
|
||||
entity: "binary_sensor.front_door",
|
||||
alert_color: "red",
|
||||
clear_color: "red",
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "entity-visibility",
|
||||
entity: "binary_sensor.gone",
|
||||
favorite: false,
|
||||
alert: "warning",
|
||||
alert_select: true,
|
||||
neighbor_entity: "binary_sensor.front_door",
|
||||
grid_options: { columns: 12 },
|
||||
card: {
|
||||
type: "alert",
|
||||
entity: "binary_sensor.gone",
|
||||
alert_color: "amber",
|
||||
clear_color: "amber",
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user