mirror of
https://github.com/home-assistant/frontend.git
synced 2026-11-05 20:44:32 +00:00
Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a8ef9c0a15 | ||
|
|
fbd68cb997 | ||
|
|
64bb47aee5 | ||
|
|
ffd7428a5b | ||
|
|
b0305e3ceb | ||
|
|
a1355e5116 | ||
|
|
ae4366020c | ||
|
|
7f73d29b2f | ||
|
|
ebf453ccc7 | ||
|
|
a0d9afe695 |
No files matched your search
+3
-3
@@ -40,7 +40,7 @@
|
||||
"license": "Apache-2.0",
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "8.0.10",
|
||||
"@babel/runtime": "8.1.0",
|
||||
"@braintree/sanitize-url": "7.1.2",
|
||||
"@codemirror/autocomplete": "6.20.4",
|
||||
"@codemirror/commands": "6.11.1",
|
||||
@@ -80,7 +80,7 @@
|
||||
"@swc/helpers": "0.5.23",
|
||||
"@tsparticles/engine": "4.5.0",
|
||||
"@tsparticles/preset-links": "4.5.0",
|
||||
"@versatiles/style": "7.1.0",
|
||||
"@versatiles/style": "8.0.0",
|
||||
"@vibrant/color": "4.0.4",
|
||||
"@vvo/tzdb": "6.202.0",
|
||||
"@webcomponents/scoped-custom-element-registry": "0.0.10",
|
||||
@@ -137,7 +137,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@ampproject/remapping": "2.3.0",
|
||||
"@babel/core": "8.0.10",
|
||||
"@babel/core": "8.1.0",
|
||||
"@babel/helper-compilation-targets": "8.0.7",
|
||||
"@babel/helper-define-polyfill-provider": "1.0.0",
|
||||
"@babel/plugin-transform-class-properties": "8.0.1",
|
||||
|
||||
Generated
+358
-358
File diff suppressed because it is too large.
Load diff
@@ -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
|
||||
);
|
||||
};
|
||||
+13
-2
@@ -16,6 +16,13 @@ declare global {
|
||||
export interface NavigateOptions {
|
||||
replace?: boolean;
|
||||
data?: Record<string, unknown>;
|
||||
/**
|
||||
* Leave open dialogs open. For a replace that only canonicalizes the current
|
||||
* URL, like a router filling in its default page: a dialog restored from the
|
||||
* URL, such as a more-info deep link, may already be open at that point, and
|
||||
* the entry keeps its state so back still closes it.
|
||||
*/
|
||||
keepDialogs?: boolean;
|
||||
}
|
||||
|
||||
// max time to wait for dialogs to close before navigating
|
||||
@@ -196,7 +203,8 @@ const buildHistoryState = (
|
||||
};
|
||||
|
||||
const performNavigation = async (path: string, options?: NavigateOptions) => {
|
||||
const canProceed = await ensureDialogsClosed(Date.now());
|
||||
const keepDialogs = options?.keepDialogs || false;
|
||||
const canProceed = await (keepDialogs || ensureDialogsClosed(Date.now()));
|
||||
if (!canProceed) {
|
||||
return false;
|
||||
}
|
||||
@@ -215,7 +223,10 @@ const performNavigation = async (path: string, options?: NavigateOptions) => {
|
||||
|
||||
const { history } = mainWindow;
|
||||
|
||||
if (replace) {
|
||||
if (replace && keepDialogs) {
|
||||
// Same entry under its canonical URL: its state, dialog included, stays.
|
||||
history.replaceState(history.state, "", path);
|
||||
} else if (replace) {
|
||||
// A replaced entry keeps its predecessor, so it keeps `from`.
|
||||
const { root, from } = history.state ?? {};
|
||||
const data = root ? { root: true } : options?.data;
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -390,3 +390,32 @@ export const getAutomaticEntityIds = (
|
||||
type: "config/entity_registry/get_automatic_entity_ids",
|
||||
entity_ids,
|
||||
});
|
||||
|
||||
/**
|
||||
* Set the area of entities and regenerate their entity IDs, which can include
|
||||
* the area. Returns the entity ID of each entity after the change.
|
||||
*/
|
||||
export const updateEntitiesArea = async (
|
||||
hass: HomeAssistant,
|
||||
entityIds: string[],
|
||||
areaId: string
|
||||
): Promise<Record<string, string>> => {
|
||||
await Promise.all(
|
||||
entityIds.map((entityId) =>
|
||||
updateEntityRegistryEntry(hass, entityId, { area_id: areaId })
|
||||
)
|
||||
);
|
||||
const newEntityIds = await getAutomaticEntityIds(hass, entityIds);
|
||||
const result: Record<string, string> = {};
|
||||
for (const entityId of entityIds) {
|
||||
const newEntityId = newEntityIds[entityId];
|
||||
if (newEntityId) {
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
await updateEntityRegistryEntry(hass, entityId, {
|
||||
new_entity_id: newEntityId,
|
||||
});
|
||||
}
|
||||
result[entityId] = newEntityId || entityId;
|
||||
}
|
||||
return result;
|
||||
};
|
||||
@@ -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,
|
||||
>(
|
||||
|
||||
@@ -481,6 +481,7 @@ class DataEntryFlowDialog extends DirtyStateProviderMixin<
|
||||
.flowConfig=${this._params.flowConfig}
|
||||
.step=${this._step}
|
||||
.hass=${this.hass}
|
||||
.manifest=${this._params.manifest}
|
||||
.navigateToResult=${
|
||||
this._params.navigateToResult ?? false
|
||||
}
|
||||
|
||||
@@ -20,13 +20,18 @@ import type { DeviceRegistryEntry } from "../../data/device/device_registry";
|
||||
import { updateDeviceRegistryEntry } from "../../data/device/device_registry";
|
||||
import {
|
||||
getAutomaticEntityIds,
|
||||
getExtendedEntityRegistryEntries,
|
||||
updateEntitiesArea,
|
||||
updateEntityRegistryEntry,
|
||||
type EntityRegistryDisplayEntry,
|
||||
type ExtEntityRegistryEntry,
|
||||
} from "../../data/entity/entity_registry";
|
||||
import { domainToName } from "../../data/integration";
|
||||
import { domainToName, type IntegrationManifest } from "../../data/integration";
|
||||
import { findRelated } from "../../data/search";
|
||||
import type { HomeAssistant, ValueChangedEvent } from "../../types";
|
||||
import { brandsUrl } from "../../util/brands-url";
|
||||
import { showAlertDialog } from "../generic/show-dialog-box";
|
||||
import { showMoreInfoDialog } from "../more-info/show-ha-more-info-dialog";
|
||||
import { showVoiceAssistantSetupDialog } from "../voice-assistant-setup/show-voice-assistant-setup-dialog";
|
||||
import type { FlowConfig } from "./show-dialog-data-entry-flow";
|
||||
import { configFlowContentStyles } from "./styles";
|
||||
@@ -35,6 +40,28 @@ interface DeviceTarget {
|
||||
device: string;
|
||||
}
|
||||
|
||||
interface HelperResult {
|
||||
deviceId?: string;
|
||||
entityId?: string;
|
||||
}
|
||||
|
||||
const computeHelperResult = (
|
||||
entities: ExtEntityRegistryEntry[]
|
||||
): HelperResult | undefined => {
|
||||
const deviceIds = [
|
||||
...new Set(
|
||||
entities.flatMap((entity) => (entity.device_id ? [entity.device_id] : []))
|
||||
),
|
||||
];
|
||||
if (deviceIds.length === 1) {
|
||||
return { deviceId: deviceIds[0] };
|
||||
}
|
||||
if (!deviceIds.length && entities.length === 1) {
|
||||
return { entityId: entities[0].entity_id };
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
@customElement("step-flow-create-entry")
|
||||
class StepFlowCreateEntry extends LitElement {
|
||||
@property({ attribute: false }) public flowConfig!: FlowConfig;
|
||||
@@ -46,10 +73,18 @@ class StepFlowCreateEntry extends LitElement {
|
||||
|
||||
@property({ attribute: false }) public devices!: DeviceRegistryEntry[];
|
||||
|
||||
@property({ attribute: false }) public manifest?: IntegrationManifest | null;
|
||||
|
||||
private _domains: Record<string, string> = {};
|
||||
|
||||
public navigateToResult = false;
|
||||
|
||||
private _helperEntitiesLookup?: Promise<ExtEntityRegistryEntry[]>;
|
||||
|
||||
@state() private _helperEntities?: ExtEntityRegistryEntry[];
|
||||
|
||||
@state() private _helperArea?: string;
|
||||
|
||||
@state() private _deviceUpdate: Record<
|
||||
string,
|
||||
{ name?: string; area?: string }
|
||||
@@ -73,6 +108,18 @@ class StepFlowCreateEntry extends LitElement {
|
||||
protected firstUpdated(changedProps: PropertyValues<this>) {
|
||||
super.firstUpdated(changedProps);
|
||||
this._loadDomains();
|
||||
if (
|
||||
this.flowConfig.flowType === "config_flow" &&
|
||||
this.step.result &&
|
||||
this.manifest?.integration_type === "helper"
|
||||
) {
|
||||
this._helperEntitiesLookup = this._findHelperEntities(
|
||||
this.step.result.entry_id
|
||||
);
|
||||
this._helperEntitiesLookup.then((entities) => {
|
||||
this._helperEntities = entities;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
protected willUpdate(changedProps: PropertyValues<this>) {
|
||||
@@ -133,11 +180,23 @@ class StepFlowCreateEntry extends LitElement {
|
||||
? nothing
|
||||
: this.devices.length === 0
|
||||
? html`<p>
|
||||
${localize(
|
||||
"ui.panel.config.integrations.config_flow.created_config",
|
||||
{ name: this.step.title }
|
||||
)}
|
||||
</p>`
|
||||
${localize(
|
||||
"ui.panel.config.integrations.config_flow.created_config",
|
||||
{ name: html`<b>${this.step.title}</b>` }
|
||||
)}
|
||||
</p>
|
||||
${
|
||||
this._entitiesWithoutDevice(this._helperEntities).length
|
||||
? html`<ha-area-picker
|
||||
.value=${this._helperArea}
|
||||
.disabled=${this._loading}
|
||||
.helper=${localize(
|
||||
"ui.panel.config.integrations.config_flow.helper_area"
|
||||
)}
|
||||
@value-changed=${this._helperAreaPicked}
|
||||
></ha-area-picker>`
|
||||
: nothing
|
||||
}`
|
||||
: html`
|
||||
<div class="devices">
|
||||
${this.devices.map(
|
||||
@@ -214,9 +273,11 @@ class StepFlowCreateEntry extends LitElement {
|
||||
|
||||
protected updated(changedProps: PropertyValues): void {
|
||||
super.updated(changedProps);
|
||||
if (changedProps.has("_deviceUpdate")) {
|
||||
if (changedProps.has("_deviceUpdate") || changedProps.has("_helperArea")) {
|
||||
fireEvent(this, "flow-step-footer-state-changed", {
|
||||
hasPendingUpdates: Object.keys(this._deviceUpdate).length > 0,
|
||||
hasPendingUpdates:
|
||||
Object.keys(this._deviceUpdate).length > 0 ||
|
||||
this._helperArea !== undefined,
|
||||
});
|
||||
}
|
||||
if (changedProps.has("_loading")) {
|
||||
@@ -226,6 +287,11 @@ class StepFlowCreateEntry extends LitElement {
|
||||
}
|
||||
}
|
||||
|
||||
private _entitiesWithoutDevice = memoizeOne(
|
||||
(entities?: ExtEntityRegistryEntry[]): ExtEntityRegistryEntry[] =>
|
||||
(entities ?? []).filter((entity) => !entity.device_id)
|
||||
);
|
||||
|
||||
private async _loadDomains() {
|
||||
const entries = await getConfigEntries(this.hass);
|
||||
this._domains = Object.fromEntries(
|
||||
@@ -290,16 +356,95 @@ class StepFlowCreateEntry extends LitElement {
|
||||
await Promise.allSettled(entityUpdates);
|
||||
}
|
||||
|
||||
fireEvent(this, "flow-update", { step: undefined });
|
||||
if (this.step.result && this.navigateToResult) {
|
||||
if (this.devices.length === 1) {
|
||||
navigate(`/config/devices/device/${this.devices[0].id}`);
|
||||
} else {
|
||||
navigate(
|
||||
`/config/integrations/integration/${this.step.result.domain}#config_entry=${this.step.result.entry_id}`
|
||||
);
|
||||
}
|
||||
// Helpers link their entities to an existing device instead of creating one
|
||||
let helperEntities =
|
||||
this._helperEntitiesLookup && !this.devices.length
|
||||
? await this._helperEntitiesLookup
|
||||
: [];
|
||||
if (this._helperArea) {
|
||||
helperEntities = await this._saveHelperArea(
|
||||
helperEntities,
|
||||
this._helperArea
|
||||
);
|
||||
}
|
||||
|
||||
const entry = this.navigateToResult ? this.step.result : undefined;
|
||||
const helperResult = entry
|
||||
? computeHelperResult(helperEntities)
|
||||
: undefined;
|
||||
|
||||
// The dialog was closed while waiting
|
||||
if (!this.isConnected) {
|
||||
return;
|
||||
}
|
||||
fireEvent(this, "flow-update", { step: undefined });
|
||||
if (!entry) {
|
||||
return;
|
||||
}
|
||||
const deviceId =
|
||||
this.devices.length === 1 ? this.devices[0].id : helperResult?.deviceId;
|
||||
if (deviceId) {
|
||||
navigate(`/config/devices/device/${deviceId}`);
|
||||
} else if (helperResult?.entityId) {
|
||||
showMoreInfoDialog(this, { entityId: helperResult.entityId });
|
||||
} else {
|
||||
navigate(
|
||||
`/config/integrations/integration/${entry.domain}#config_entry=${entry.entry_id}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private async _findHelperEntities(
|
||||
entryId: string
|
||||
): Promise<ExtEntityRegistryEntry[]> {
|
||||
try {
|
||||
const { entity: entityIds = [] } = await findRelated(
|
||||
this.hass,
|
||||
"config_entry",
|
||||
entryId
|
||||
);
|
||||
if (!entityIds.length) {
|
||||
return [];
|
||||
}
|
||||
return Object.values(
|
||||
await getExtendedEntityRegistryEntries(this.hass, entityIds)
|
||||
).filter(
|
||||
(entity): entity is ExtEntityRegistryEntry =>
|
||||
entity?.config_entry_id === entryId
|
||||
);
|
||||
} catch (_err) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
private async _saveHelperArea(
|
||||
entities: ExtEntityRegistryEntry[],
|
||||
areaId: string
|
||||
): Promise<ExtEntityRegistryEntry[]> {
|
||||
try {
|
||||
const newEntityIds = await updateEntitiesArea(
|
||||
this.hass,
|
||||
this._entitiesWithoutDevice(entities).map((entity) => entity.entity_id),
|
||||
areaId
|
||||
);
|
||||
return entities.map((entity) =>
|
||||
entity.entity_id in newEntityIds
|
||||
? { ...entity, entity_id: newEntityIds[entity.entity_id] }
|
||||
: entity
|
||||
);
|
||||
} catch (err: unknown) {
|
||||
showAlertDialog(this, {
|
||||
text: this.hass.localize(
|
||||
"ui.panel.config.integrations.config_flow.error_saving_entity",
|
||||
{ error: (err as { message?: string })?.message ?? "Unknown error" }
|
||||
),
|
||||
});
|
||||
return entities;
|
||||
}
|
||||
}
|
||||
|
||||
private _helperAreaPicked(ev: ValueChangedEvent<string | undefined>): void {
|
||||
this._helperArea = ev.detail.value || undefined;
|
||||
}
|
||||
|
||||
public async finish(): Promise<void> {
|
||||
|
||||
@@ -104,6 +104,7 @@ export class HassRouterPage extends ReactiveElement {
|
||||
const queryParams = window.location.search;
|
||||
navigate(`${route.prefix}/${defaultPage}${queryParams}`, {
|
||||
replace: true,
|
||||
keepDialogs: true,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -134,6 +135,7 @@ export class HassRouterPage extends ReactiveElement {
|
||||
if (route) {
|
||||
navigate(`${route.prefix}/${result}${location.search}`, {
|
||||
replace: true,
|
||||
keepDialogs: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -189,7 +189,7 @@ class PartialPanelResolver extends HassRouterPage {
|
||||
const { pathname, search, hash } = mainWindow.location;
|
||||
navigate(
|
||||
`${pathname.replace(/^\/hacs/, "/marketplace")}${search}${hash}`,
|
||||
{ replace: true }
|
||||
{ replace: true, keepDialogs: true }
|
||||
);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
`
|
||||
@@ -117,16 +190,31 @@ class PanelClimate extends LitElement {
|
||||
`;
|
||||
}
|
||||
|
||||
private async _setLovelace() {
|
||||
private async _setLovelace(force = false) {
|
||||
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] };
|
||||
|
||||
if (deepEqual(config, this._lovelace?.config)) {
|
||||
if (!force && deepEqual(config, this._lovelace?.config)) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -146,6 +234,60 @@ 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;
|
||||
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(true);
|
||||
});
|
||||
}
|
||||
|
||||
static get styles(): CSSResultGroup {
|
||||
return [
|
||||
haStyle,
|
||||
|
||||
@@ -2,9 +2,10 @@ import { ReactiveElement } from "lit";
|
||||
import { customElement } from "lit/decorators";
|
||||
import { getAreasFloorHierarchy } from "../../../common/areas/areas-floor-hierarchy";
|
||||
import {
|
||||
findEntities,
|
||||
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 +13,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 +49,53 @@ 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;
|
||||
type ClimateAreaSensorKind = "temperature" | "humidity";
|
||||
|
||||
export const findClimateAreaSensors = (
|
||||
sources: EntityFilterSources,
|
||||
hiddenEntities?: string[]
|
||||
): Map<string, ClimateAreaSensorKind> => {
|
||||
const hidden = new Set(hiddenEntities);
|
||||
const sensors = new Map<string, ClimateAreaSensorKind>();
|
||||
for (const area of Object.values(sources.areas)) {
|
||||
const slots = [
|
||||
[area.temperature_entity_id, "temperature"],
|
||||
[area.humidity_entity_id, "humidity"],
|
||||
] as const;
|
||||
for (const [entityId, kind] of slots) {
|
||||
if (entityId && entityId in sources.states && !hidden.has(entityId)) {
|
||||
sensors.set(entityId, kind);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const entityIds = Object.keys(hass.states);
|
||||
|
||||
return climateEntityFilters.some((filter) =>
|
||||
entityIds.some(
|
||||
generateEntityFilter(
|
||||
hass.states,
|
||||
hass.entities,
|
||||
hass.devices,
|
||||
hass.areas,
|
||||
hass.floors,
|
||||
filter
|
||||
)
|
||||
)
|
||||
);
|
||||
return sensors;
|
||||
};
|
||||
|
||||
export const hasClimateEntities = (
|
||||
hass: HomeAssistant,
|
||||
hiddenEntities?: string[]
|
||||
): boolean =>
|
||||
findClimateAreaSensors(hass, hiddenEntities).size > 0 ||
|
||||
findClimateEntities(hass, hiddenEntities).length > 0;
|
||||
|
||||
interface ClimateCardOptions {
|
||||
entities: string[];
|
||||
areaSensors: Map<string, ClimateAreaSensorKind>;
|
||||
computeCard: ComputeEntityCard;
|
||||
}
|
||||
|
||||
const processAreasForClimate = (
|
||||
areaIds: string[],
|
||||
hass: HomeAssistant,
|
||||
entities: string[]
|
||||
{ entities, areaSensors, computeCard }: ClimateCardOptions
|
||||
): LovelaceCardConfig[] => {
|
||||
const cards: LovelaceCardConfig[] = [];
|
||||
const computeTileCard = computeAreaTileCardConfig(hass, true);
|
||||
|
||||
for (const areaId of areaIds) {
|
||||
const area = hass.areas[areaId];
|
||||
@@ -96,25 +116,13 @@ 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 && areaSensors.has(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 && areaSensors.has(humidityEntityId)) {
|
||||
areaCards.push(computeCard(humidityEntityId));
|
||||
}
|
||||
|
||||
// Add other climate entities
|
||||
@@ -124,18 +132,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 +156,7 @@ const processAreasForClimate = (
|
||||
|
||||
const processUnassignedEntities = (
|
||||
hass: HomeAssistant,
|
||||
entities: string[]
|
||||
{ entities, computeCard }: ClimateCardOptions
|
||||
): LovelaceCardConfig[] => {
|
||||
const unassignedFilter = generateEntityFilter(
|
||||
hass.states,
|
||||
@@ -173,10 +170,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 +181,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 +190,40 @@ 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 areaSensors = findClimateAreaSensors(
|
||||
hass,
|
||||
editVisibility ? undefined : config.hidden_entities
|
||||
);
|
||||
const computeTileCard = computeAreaTileCardConfig(hass, true);
|
||||
const computeClimateCard = (entityId: string): LovelaceCardConfig => {
|
||||
const tileCard = computeTileCard(entityId);
|
||||
const areaSensor = areaSensors.get(entityId);
|
||||
if (!areaSensor) {
|
||||
return tileCard;
|
||||
}
|
||||
return {
|
||||
...tileCard,
|
||||
name:
|
||||
hass.localize(
|
||||
`component.sensor.entity_component.${areaSensor}.name`
|
||||
) || undefined,
|
||||
features: [{ type: "trend-graph" }],
|
||||
};
|
||||
};
|
||||
const cardOptions: ClimateCardOptions = {
|
||||
entities,
|
||||
areaSensors,
|
||||
computeCard: editVisibility
|
||||
? wrapEntityVisibility(hiddenEntities, computeClimateCard)
|
||||
: computeClimateCard,
|
||||
};
|
||||
|
||||
const floorCount =
|
||||
hierarchy.floors.length + (hierarchy.areas.length ? 1 : 0);
|
||||
@@ -233,7 +249,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 +273,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 +286,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 = {
|
||||
|
||||
@@ -13,6 +13,7 @@ import type {
|
||||
import { fireEvent } from "../../../common/dom/fire_event";
|
||||
import { stopPropagation } from "../../../common/dom/stop_propagation";
|
||||
import { stringCompare } from "../../../common/string/compare";
|
||||
import "../../../components/ha-area-picker";
|
||||
import "../../../components/ha-button";
|
||||
import "../../../components/ha-dialog";
|
||||
import "../../../components/ha-dialog-footer";
|
||||
@@ -28,6 +29,12 @@ import type { HaListSelectable } from "../../../components/list/ha-list-selectab
|
||||
import { DirtyStateProviderMixin } from "../../../mixins/dirty-state-provider-mixin";
|
||||
import { getConfigFlowHandlers } from "../../../data/config_flow";
|
||||
import { createCounter } from "../../../data/counter";
|
||||
import {
|
||||
fetchEntityRegistry,
|
||||
getExtendedEntityRegistryEntry,
|
||||
updateEntitiesArea,
|
||||
type EntityRegistryEntry,
|
||||
} from "../../../data/entity/entity_registry";
|
||||
import { createInputBoolean } from "../../../data/input_boolean";
|
||||
import { createInputButton } from "../../../data/input_button";
|
||||
import { createInputDateTime } from "../../../data/input_datetime";
|
||||
@@ -43,8 +50,9 @@ import { createSchedule } from "../../../data/schedule";
|
||||
import { createTimer } from "../../../data/timer";
|
||||
import { showConfigFlowDialog } from "../../../dialogs/config-flow/show-dialog-config-flow";
|
||||
import { showAlertDialog } from "../../../dialogs/generic/show-dialog-box";
|
||||
import { showMoreInfoDialog } from "../../../dialogs/more-info/show-ha-more-info-dialog";
|
||||
import { haStyleDialog, haStyleScrollbar } from "../../../resources/styles";
|
||||
import type { HomeAssistant } from "../../../types";
|
||||
import type { HomeAssistant, ValueChangedEvent } from "../../../types";
|
||||
import { brandsUrl } from "../../../util/brands-url";
|
||||
import type { Helper, HelperDomain, HelperFormItem } from "./const";
|
||||
import { isHelperDomain } from "./const";
|
||||
@@ -110,12 +118,14 @@ const HELPERS: HelperCreators = {
|
||||
|
||||
@customElement("dialog-helper-detail")
|
||||
export class DialogHelperDetail extends DirtyStateProviderMixin<
|
||||
HelperFormItem | undefined
|
||||
{ item?: HelperFormItem; area?: string } | undefined
|
||||
>()(LitElement) {
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
|
||||
@state() private _item?: HelperFormItem;
|
||||
|
||||
@state() private _area?: string;
|
||||
|
||||
@state() private _open = false;
|
||||
|
||||
@state() private _domain?: string;
|
||||
@@ -137,6 +147,7 @@ export class DialogHelperDetail extends DirtyStateProviderMixin<
|
||||
startFlowHandler: string;
|
||||
manifest: IntegrationManifest;
|
||||
dialogClosedCallback?: ShowDialogHelperDetailParams["dialogClosedCallback"];
|
||||
navigateToResult?: boolean;
|
||||
};
|
||||
|
||||
private _params?: ShowDialogHelperDetailParams;
|
||||
@@ -145,11 +156,15 @@ export class DialogHelperDetail extends DirtyStateProviderMixin<
|
||||
this._params = params;
|
||||
this._domain = params.domain;
|
||||
this._item = params.item;
|
||||
this._area = undefined;
|
||||
if (this._domain && this._domain in HELPERS) {
|
||||
this._loading = true;
|
||||
try {
|
||||
await HELPERS[this._domain].import();
|
||||
this._initDirtyTracking({ type: "deep" }, undefined);
|
||||
this._initDirtyTracking(
|
||||
{ type: "deep" },
|
||||
{ item: this._item, area: undefined }
|
||||
);
|
||||
} finally {
|
||||
this._loading = false;
|
||||
}
|
||||
@@ -185,6 +200,7 @@ export class DialogHelperDetail extends DirtyStateProviderMixin<
|
||||
startFlowHandler: pendingConfigFlow.startFlowHandler,
|
||||
manifest: pendingConfigFlow.manifest,
|
||||
dialogClosedCallback: pendingConfigFlow.dialogClosedCallback,
|
||||
navigateToResult: pendingConfigFlow.navigateToResult,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -206,6 +222,11 @@ export class DialogHelperDetail extends DirtyStateProviderMixin<
|
||||
new: true,
|
||||
autofocus: true,
|
||||
})}
|
||||
<ha-area-picker
|
||||
.value=${this._area}
|
||||
.disabled=${this._submitting}
|
||||
@value-changed=${this._areaChanged}
|
||||
></ha-area-picker>
|
||||
</div>
|
||||
`;
|
||||
footer = html`
|
||||
@@ -380,28 +401,45 @@ export class DialogHelperDetail extends DirtyStateProviderMixin<
|
||||
|
||||
private _valueChanged(ev: CustomEvent): void {
|
||||
this._item = ev.detail.value;
|
||||
this._updateDirtyState(this._item);
|
||||
this._updateDirtyState({ item: this._item, area: this._area });
|
||||
}
|
||||
|
||||
private async _createItem(): Promise<void> {
|
||||
if (!this._domain || !this._item) {
|
||||
return;
|
||||
}
|
||||
const domain = this._domain;
|
||||
const params = this._params;
|
||||
const area = this._area;
|
||||
this._submitting = true;
|
||||
this._error = "";
|
||||
try {
|
||||
const createdEntity = await HELPERS[this._domain].create(
|
||||
this.hass,
|
||||
this._item
|
||||
);
|
||||
if (this._params?.dialogClosedCallback && createdEntity.id) {
|
||||
this._params.dialogClosedCallback({
|
||||
const createdEntity = await HELPERS[domain].create(this.hass, this._item);
|
||||
// The dialog was closed or reopened while waiting
|
||||
if (this._params !== params) {
|
||||
return;
|
||||
}
|
||||
let entityId: string | undefined;
|
||||
if (createdEntity.id && (area || params?.navigateToResult)) {
|
||||
entityId = await this._findEntityId(domain, createdEntity.id);
|
||||
if (area) {
|
||||
entityId = await this._saveArea(entityId, area);
|
||||
}
|
||||
if (this._params !== params) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (params?.dialogClosedCallback && createdEntity.id) {
|
||||
params.dialogClosedCallback({
|
||||
flowFinished: true,
|
||||
entityId: `${this._domain}.${createdEntity.id}`,
|
||||
entityId: entityId ?? `${domain}.${createdEntity.id}`,
|
||||
});
|
||||
}
|
||||
this._markDirtyStateClean();
|
||||
this.closeDialog();
|
||||
if (params?.navigateToResult && entityId) {
|
||||
showMoreInfoDialog(this, { entityId });
|
||||
}
|
||||
} catch (err: any) {
|
||||
this._error = err.message || "Unknown error";
|
||||
} finally {
|
||||
@@ -409,6 +447,57 @@ export class DialogHelperDetail extends DirtyStateProviderMixin<
|
||||
}
|
||||
}
|
||||
|
||||
private async _findEntityId(
|
||||
domain: string,
|
||||
uniqueId: string
|
||||
): Promise<string | undefined> {
|
||||
const isCreated = (entry: EntityRegistryEntry) =>
|
||||
entry.platform === domain && entry.unique_id === uniqueId;
|
||||
try {
|
||||
// The entity ID usually matches the helper ID, unless it was taken
|
||||
const entry = await getExtendedEntityRegistryEntry(
|
||||
this.hass,
|
||||
`${domain}.${uniqueId}`
|
||||
).catch(() => undefined);
|
||||
if (entry && isCreated(entry)) {
|
||||
return entry.entity_id;
|
||||
}
|
||||
return (await fetchEntityRegistry(this.hass.connection)).find(isCreated)
|
||||
?.entity_id;
|
||||
} catch (_err) {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
private async _saveArea(
|
||||
entityId: string | undefined,
|
||||
areaId: string
|
||||
): Promise<string | undefined> {
|
||||
let error = "Entity not found";
|
||||
if (entityId) {
|
||||
try {
|
||||
return (await updateEntitiesArea(this.hass, [entityId], areaId))[
|
||||
entityId
|
||||
];
|
||||
} catch (err: unknown) {
|
||||
error = (err as { message?: string })?.message ?? "Unknown error";
|
||||
}
|
||||
}
|
||||
showAlertDialog(this, {
|
||||
text: this.hass.localize(
|
||||
"ui.panel.config.integrations.config_flow.error_saving_entity",
|
||||
{ error }
|
||||
),
|
||||
});
|
||||
return entityId;
|
||||
}
|
||||
|
||||
private _areaChanged(ev: ValueChangedEvent<string | undefined>): void {
|
||||
ev.stopPropagation();
|
||||
this._area = ev.detail.value || undefined;
|
||||
this._updateDirtyState({ item: this._item, area: this._area });
|
||||
}
|
||||
|
||||
private async _domainPicked(
|
||||
ev: HASSDomEvent<number> & HASSDomCurrentTargetEvent<HaListSelectable>
|
||||
): Promise<void> {
|
||||
@@ -435,7 +524,10 @@ export class DialogHelperDetail extends DirtyStateProviderMixin<
|
||||
try {
|
||||
await HELPERS[domain].import();
|
||||
this._domain = domain;
|
||||
this._initDirtyTracking({ type: "deep" }, undefined);
|
||||
this._initDirtyTracking(
|
||||
{ type: "deep" },
|
||||
{ item: this._item, area: undefined }
|
||||
);
|
||||
} finally {
|
||||
this._loading = false;
|
||||
}
|
||||
@@ -444,6 +536,7 @@ export class DialogHelperDetail extends DirtyStateProviderMixin<
|
||||
startFlowHandler: domain,
|
||||
manifest: await fetchIntegrationManifest(this.hass, domain),
|
||||
dialogClosedCallback: this._params?.dialogClosedCallback,
|
||||
navigateToResult: this._params?.navigateToResult,
|
||||
};
|
||||
this.closeDialog();
|
||||
}
|
||||
@@ -452,6 +545,7 @@ export class DialogHelperDetail extends DirtyStateProviderMixin<
|
||||
private async _goBack() {
|
||||
this._domain = undefined;
|
||||
this._item = undefined;
|
||||
this._area = undefined;
|
||||
this._error = undefined;
|
||||
await this.updateComplete;
|
||||
await this._focusSearchInput();
|
||||
@@ -490,6 +584,10 @@ export class DialogHelperDetail extends DirtyStateProviderMixin<
|
||||
.form {
|
||||
padding: var(--ha-space-6);
|
||||
}
|
||||
.form ha-area-picker {
|
||||
display: block;
|
||||
margin-top: var(--ha-space-4);
|
||||
}
|
||||
ha-input-search {
|
||||
margin: 0 var(--ha-space-4) var(--ha-space-3);
|
||||
}
|
||||
|
||||
@@ -1300,6 +1300,7 @@ ${rejected
|
||||
if (isHelperDomain(domain)) {
|
||||
showHelperDetailDialog(this, {
|
||||
domain,
|
||||
navigateToResult: true,
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -1343,6 +1344,7 @@ ${rejected
|
||||
showConfigFlowDialog(this, {
|
||||
startFlowHandler: domain,
|
||||
manifest: await fetchIntegrationManifest(this.hass, domain),
|
||||
navigateToResult: true,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1568,11 +1570,12 @@ ${rejected
|
||||
{ name: config.name }
|
||||
),
|
||||
},
|
||||
navigateToResult: true,
|
||||
});
|
||||
}
|
||||
|
||||
private _createHelper() {
|
||||
showHelperDetailDialog(this, {});
|
||||
showHelperDetailDialog(this, { navigateToResult: true });
|
||||
}
|
||||
|
||||
private _bulkCreateCategory = () => {
|
||||
|
||||
@@ -11,6 +11,7 @@ export interface ShowDialogHelperDetailParams {
|
||||
entryId?: string;
|
||||
entityId?: string;
|
||||
}) => void;
|
||||
navigateToResult?: boolean;
|
||||
}
|
||||
|
||||
export const showHelperDetailDialog = (
|
||||
|
||||
@@ -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,525 @@
|
||||
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 type { HASSDomEvent } from "../../../common/dom/fire_event";
|
||||
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 = "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(".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() ?? {};
|
||||
}
|
||||
|
||||
public connectedCallback(): void {
|
||||
super.connectedCallback();
|
||||
window.addEventListener(
|
||||
"entity-visibility-changed",
|
||||
this._entityVisibilityChanged
|
||||
);
|
||||
}
|
||||
|
||||
public disconnectedCallback(): void {
|
||||
super.disconnectedCallback();
|
||||
window.removeEventListener(
|
||||
"entity-visibility-changed",
|
||||
this._entityVisibilityChanged
|
||||
);
|
||||
}
|
||||
|
||||
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 === "favorite" ? this._favoriteControl : this._alertControl;
|
||||
element?.focus();
|
||||
this._reportLostFocus(element);
|
||||
}
|
||||
|
||||
private async _reportLostFocus(element?: HTMLElement): Promise<void> {
|
||||
if (element instanceof LitElement) {
|
||||
await element.updateComplete;
|
||||
}
|
||||
if (this.shadowRoot?.activeElement !== element) {
|
||||
fireEvent(this, "entity-visibility-focus-lost");
|
||||
}
|
||||
}
|
||||
|
||||
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!;
|
||||
const key = !entryRemoved
|
||||
? this._focusKey()
|
||||
: config.neighbor_entity
|
||||
? computeFocusKey(
|
||||
config.neighbor_entity,
|
||||
false,
|
||||
Boolean(config.alert_select)
|
||||
)
|
||||
: config.fallback_entity
|
||||
? computeFocusKey(config.fallback_entity, true, false)
|
||||
: undefined;
|
||||
if (!key) {
|
||||
fireEvent(this, "entity-visibility-focus-lost");
|
||||
return;
|
||||
}
|
||||
pendingFocus = { key, control, source: this };
|
||||
}
|
||||
|
||||
private _entityVisibilityChanged = (
|
||||
ev: HASSDomEvent<HASSDomEvents["entity-visibility-changed"]>
|
||||
) => {
|
||||
if (
|
||||
this._config?.hidden === undefined ||
|
||||
ev.detail.entity !== this._config.entity ||
|
||||
ev.detail.hidden === this._config.hidden
|
||||
) {
|
||||
return;
|
||||
}
|
||||
this._config = { ...this._config, hidden: ev.detail.hidden };
|
||||
};
|
||||
|
||||
private _toggleHidden(): void {
|
||||
if (this._config?.hidden === undefined) {
|
||||
return;
|
||||
}
|
||||
const hidden = !this._config.hidden;
|
||||
this._config = { ...this._config, hidden };
|
||||
fireEvent(this, "entity-visibility-changed", {
|
||||
entity: this._config.entity,
|
||||
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 GlobalEventHandlersEventMap {
|
||||
"entity-visibility-changed": HASSDomEvent<
|
||||
HASSDomEvents["entity-visibility-changed"]
|
||||
>;
|
||||
}
|
||||
interface HASSDomEvents {
|
||||
"entity-visibility-changed": { entity: string; hidden: boolean };
|
||||
"entity-visibility-focus-lost": undefined;
|
||||
"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,19 @@ 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;
|
||||
fallback_entity?: string;
|
||||
card: LovelaceCardConfig;
|
||||
}
|
||||
|
||||
export interface EmptyStateButtonConfig {
|
||||
text: string;
|
||||
icon?: string;
|
||||
@@ -751,6 +767,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": () =>
|
||||
|
||||
@@ -291,6 +291,12 @@ export class LovelacePanel extends LitElement {
|
||||
this._fetchConfig(true);
|
||||
}
|
||||
|
||||
private _redirectToHome() {
|
||||
// Keeps the query and any open dialog: a more-info deep link may be
|
||||
// restored from the URL around this redirect.
|
||||
navigate(`/home${location.search}`, { replace: true, keepDialogs: true });
|
||||
}
|
||||
|
||||
private async _fetchConfig(forceDiskRefresh: boolean) {
|
||||
this._loading = true;
|
||||
|
||||
@@ -300,7 +306,7 @@ export class LovelacePanel extends LitElement {
|
||||
|
||||
// If no mode, redirect to /home as there is no "lovelace" dashboard
|
||||
if (!confMode) {
|
||||
navigate("/home", { replace: true });
|
||||
this._redirectToHome();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -361,7 +367,7 @@ export class LovelacePanel extends LitElement {
|
||||
const dashboards = await fetchDashboards(this.hass!);
|
||||
const dashboard = dashboards.find((d) => d.url_path === "lovelace");
|
||||
if (!dashboard) {
|
||||
navigate("/home", { replace: true });
|
||||
this._redirectToHome();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,6 +32,7 @@ import { UndoRedoController } from "../../common/controllers/undo-redo-controlle
|
||||
import { fireEvent } from "../../common/dom/fire_event";
|
||||
import type { HASSDomEvent } from "../../common/dom/fire_event";
|
||||
import { ctrlOrCmdLabel } from "../../common/keyboard/ctrl-or-cmd";
|
||||
import type { NavigateOptions } from "../../common/navigate";
|
||||
import { goBack, navigate, replaceCurrentUrl } from "../../common/navigate";
|
||||
import type { LocalizeKeys } from "../../common/translations/localize";
|
||||
import { constructUrlCurrentPath } from "../../common/url/construct-url";
|
||||
@@ -831,7 +832,12 @@ class HUIRoot extends KeyboardShortcutMixin(LitElement) {
|
||||
|
||||
if (!viewPath && views.length) {
|
||||
newSelectView = views.findIndex(this._isVisible);
|
||||
this._navigateToView(views[newSelectView].path || newSelectView, true);
|
||||
// Filling in the default view keeps the entry as is: a dialog restored
|
||||
// from the URL, like a more-info deep link, can already be open.
|
||||
this._navigateToView(views[newSelectView].path || newSelectView, {
|
||||
replace: true,
|
||||
keepDialogs: true,
|
||||
});
|
||||
} else if (viewPath === "hass-unused-entities") {
|
||||
newSelectView = "hass-unused-entities";
|
||||
} else if (viewPath) {
|
||||
@@ -865,10 +871,9 @@ class HUIRoot extends KeyboardShortcutMixin(LitElement) {
|
||||
viewPath === "hass-unused-entities"
|
||||
) {
|
||||
newSelectView = views.findIndex(this._isVisible);
|
||||
this._navigateToView(
|
||||
views[newSelectView].path || newSelectView,
|
||||
true
|
||||
);
|
||||
this._navigateToView(views[newSelectView].path || newSelectView, {
|
||||
replace: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1197,7 +1202,7 @@ class HUIRoot extends KeyboardShortcutMixin(LitElement) {
|
||||
});
|
||||
}
|
||||
|
||||
private _navigateToView(path: string | number, replace?: boolean) {
|
||||
private _navigateToView(path: string | number, options?: NavigateOptions) {
|
||||
if (this.embedded) {
|
||||
fireEvent(this, "view-navigate", { path: String(path) });
|
||||
|
||||
@@ -1208,7 +1213,7 @@ class HUIRoot extends KeyboardShortcutMixin(LitElement) {
|
||||
|
||||
const currentUrl = `${location.pathname}${location.search}`;
|
||||
if (currentUrl !== url) {
|
||||
navigate(url, { replace });
|
||||
navigate(url, options);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1278,7 +1283,7 @@ class HUIRoot extends KeyboardShortcutMixin(LitElement) {
|
||||
newCurView = indexAfterMove(curView, oldIndex, newIndex);
|
||||
// Views without a path are addressed by their index in the URL
|
||||
if (newCurView !== curView && !this.config.views[curView].path) {
|
||||
this._navigateToView(newCurView, true);
|
||||
this._navigateToView(newCurView, { replace: true });
|
||||
}
|
||||
}
|
||||
lovelace.saveConfig(moveView(lovelace.config, oldIndex, newIndex));
|
||||
|
||||
@@ -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,8 @@ import {
|
||||
export interface HomeAreaViewStrategyConfig {
|
||||
type: "home-area";
|
||||
area?: string;
|
||||
security_hidden_entities?: string[];
|
||||
climate_hidden_entities?: string[];
|
||||
home_panel?: boolean;
|
||||
}
|
||||
|
||||
@@ -91,6 +94,11 @@ 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,
|
||||
climate: config.climate_hidden_entities,
|
||||
};
|
||||
|
||||
const entitiesBySummary = HOME_SUMMARIES.reduce(
|
||||
(acc, summary) => {
|
||||
const summariesFilters = HOME_SUMMARIES_FILTERS[summary];
|
||||
@@ -104,7 +112,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,8 @@ export class HomeDashboardStrategy extends ReactiveElement {
|
||||
strategy: {
|
||||
type: "home-area",
|
||||
area: area.area_id,
|
||||
security_hidden_entities: config.security_hidden_entities,
|
||||
climate_hidden_entities: config.climate_hidden_entities,
|
||||
home_panel: config.home_panel,
|
||||
} satisfies HomeAreaViewStrategyConfig,
|
||||
};
|
||||
@@ -106,6 +111,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,47 @@ 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;
|
||||
}
|
||||
this._editVisibility = false;
|
||||
// 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 +149,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 +181,7 @@ class PanelMaintenance extends LitElement {
|
||||
.narrow=${this.narrow}
|
||||
.lovelace=${this._lovelace}
|
||||
.index=${this._viewIndex}
|
||||
@entity-visibility-changed=${this._visibilityChanged}
|
||||
></hui-view>
|
||||
</hui-view-container>
|
||||
`
|
||||
@@ -117,16 +191,31 @@ class PanelMaintenance extends LitElement {
|
||||
`;
|
||||
}
|
||||
|
||||
private async _setLovelace() {
|
||||
private async _setLovelace(force = false) {
|
||||
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] };
|
||||
|
||||
if (deepEqual(config, this._lovelace?.config)) {
|
||||
if (!force && deepEqual(config, this._lovelace?.config)) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -146,6 +235,60 @@ 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;
|
||||
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(true);
|
||||
});
|
||||
}
|
||||
|
||||
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,
|
||||
});
|
||||
};
|
||||
@@ -1,14 +1,17 @@
|
||||
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 { customElement, property, query, 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 type { HaButton } from "../../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 +21,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,14 +44,20 @@ class PanelSecurity extends LitElement {
|
||||
|
||||
@state() private _config?: SecurityFrontendSystemData;
|
||||
|
||||
@state() private _editVisibility = false;
|
||||
|
||||
@state() private _searchParms = new URLSearchParams(window.location.search);
|
||||
|
||||
@query("ha-button") private _exitEditButton?: HaButton;
|
||||
|
||||
private _childPanelReady?: ChildPanelReady;
|
||||
|
||||
private _loadConfigPromise?: Promise<void>;
|
||||
|
||||
private _loadConfigRevision = 0;
|
||||
|
||||
private _setLovelaceRevision = 0;
|
||||
|
||||
public willUpdate(changedProps: PropertyValues<this>) {
|
||||
super.willUpdate(changedProps);
|
||||
// Initial setup
|
||||
@@ -105,7 +114,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 +153,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 +184,10 @@ 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}
|
||||
@entity-visibility-focus-lost=${this._focusLost}
|
||||
></hui-view
|
||||
></hui-view-container>
|
||||
`
|
||||
@@ -173,7 +197,8 @@ class PanelSecurity extends LitElement {
|
||||
`;
|
||||
}
|
||||
|
||||
private async _setLovelace() {
|
||||
private async _setLovelace(force = false) {
|
||||
const revision = ++this._setLovelaceRevision;
|
||||
if (this._loadConfigPromise) {
|
||||
await this._loadConfigPromise;
|
||||
}
|
||||
@@ -184,15 +209,21 @@ 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] };
|
||||
|
||||
if (deepEqual(config, this._lovelace?.config)) {
|
||||
if (!force && deepEqual(config, this._lovelace?.config)) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -212,23 +243,94 @@ 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 _focusLost = () => {
|
||||
this._exitEditButton?.focus();
|
||||
};
|
||||
|
||||
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,
|
||||
});
|
||||
this._setLovelace();
|
||||
};
|
||||
|
||||
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,
|
||||
});
|
||||
this._setLovelace();
|
||||
};
|
||||
|
||||
private _saveConfig(config: SecurityFrontendSystemData): Promise<void> {
|
||||
this._config = config;
|
||||
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 +338,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(true);
|
||||
});
|
||||
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),
|
||||
};
|
||||
};
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
import { computeStateDomain } from "../../../common/entity/compute_state_domain";
|
||||
import { floorDefaultIcon } from "../../../components/ha-floor-icon";
|
||||
import type { LovelaceCardConfig } from "../../../data/lovelace/config/card";
|
||||
import { isStrategySection } from "../../../data/lovelace/config/section";
|
||||
import type {
|
||||
LovelaceSectionConfig,
|
||||
LovelaceSectionRawConfig,
|
||||
@@ -21,15 +22,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 +93,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 +117,8 @@ export const isSecurityPanelEntity = (
|
||||
const processAreasForSecurity = (
|
||||
areaIds: string[],
|
||||
hass: HomeAssistant,
|
||||
entities: string[]
|
||||
entities: string[],
|
||||
computeCard: ComputeEntityCard
|
||||
): LovelaceCardConfig[] => {
|
||||
const cards: LovelaceCardConfig[] = [];
|
||||
|
||||
@@ -121,10 +139,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 +164,8 @@ const processAreasForSecurity = (
|
||||
|
||||
const processUnassignedEntities = (
|
||||
hass: HomeAssistant,
|
||||
entities: string[]
|
||||
entities: string[],
|
||||
computeCard: ComputeEntityCard
|
||||
): LovelaceCardConfig[] => {
|
||||
const unassignedFilter = generateEntityFilter(
|
||||
hass.states,
|
||||
@@ -162,10 +179,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,30 +199,50 @@ 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 pageEntities = new Set(entities);
|
||||
|
||||
if (favoriteEntities.length > 0) {
|
||||
sections.push({
|
||||
type: "grid",
|
||||
column_span: 2,
|
||||
cards: [
|
||||
{
|
||||
type: "heading",
|
||||
heading: hass.localize(
|
||||
"ui.panel.lovelace.strategy.security.favorites"
|
||||
),
|
||||
heading_style: "title",
|
||||
},
|
||||
...favoriteEntities.map(computeFavoriteCardConfig),
|
||||
],
|
||||
});
|
||||
}
|
||||
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 computeCard = wrapCard(computeAreaTileCardConfig(hass, false));
|
||||
|
||||
const floorCount =
|
||||
hierarchy.floors.length + (hierarchy.areas.length ? 1 : 0);
|
||||
@@ -232,7 +268,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 +300,8 @@ export class SecurityViewStrategy extends ReactiveElement {
|
||||
const areaCards = processAreasForSecurity(
|
||||
hierarchy.areas,
|
||||
hass,
|
||||
entities
|
||||
entities,
|
||||
computeCard
|
||||
);
|
||||
|
||||
if (areaCards.length > 0) {
|
||||
@@ -269,7 +311,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 = {
|
||||
@@ -279,7 +325,7 @@ export class SecurityViewStrategy extends ReactiveElement {
|
||||
{
|
||||
type: "heading",
|
||||
heading:
|
||||
sections.length > 0
|
||||
sections.length > 0 || favoriteEntities.length > 0
|
||||
? hass.localize(
|
||||
"ui.panel.lovelace.strategy.security.other_devices"
|
||||
)
|
||||
@@ -291,6 +337,41 @@ export class SecurityViewStrategy extends ReactiveElement {
|
||||
sections.push(section);
|
||||
}
|
||||
|
||||
if (favoriteEntities.length > 0) {
|
||||
const firstPageEntity = editVisibility
|
||||
? sections
|
||||
.flatMap((section) =>
|
||||
isStrategySection(section) ? [] : (section.cards ?? [])
|
||||
)
|
||||
.find((card) => card.type === "entity-visibility")?.entity
|
||||
: undefined;
|
||||
const computeFavoriteCard = editVisibility
|
||||
? (entityId: string, index: number): EntityVisibilityCardConfig => ({
|
||||
type: "entity-visibility",
|
||||
entity: entityId,
|
||||
neighbor_entity:
|
||||
favoriteEntities[index + 1] ?? favoriteEntities[index - 1],
|
||||
fallback_entity: firstPageEntity,
|
||||
...computeEditExtras(entityId),
|
||||
card: computeFavoriteCardConfig(entityId),
|
||||
})
|
||||
: computeFavoriteCardConfig;
|
||||
sections.unshift({
|
||||
type: "grid",
|
||||
column_span: 2,
|
||||
cards: [
|
||||
{
|
||||
type: "heading",
|
||||
heading: hass.localize(
|
||||
"ui.panel.lovelace.strategy.security.favorites"
|
||||
),
|
||||
heading_style: "title",
|
||||
},
|
||||
...favoriteEntities.map(computeFavoriteCard),
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
// Build sidebar with activity log
|
||||
const hasLogbook = isComponentLoaded(hass.config, "logbook");
|
||||
|
||||
@@ -303,18 +384,63 @@ 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,
|
||||
fallback_entity: pageEntities.has(alertEntity.entity)
|
||||
? alertEntity.entity
|
||||
: undefined,
|
||||
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: [
|
||||
|
||||
+27
-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}",
|
||||
@@ -2918,23 +2929,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.",
|
||||
@@ -7974,6 +7987,7 @@
|
||||
"error_saving_device": "Error updating device: {error}",
|
||||
"error_saving_entity": "Error updating entity: {error}",
|
||||
"created_config": "Created configuration for {name}.",
|
||||
"helper_area": "This helper isn't part of a device, so you can add it to an area.",
|
||||
"external_step": {
|
||||
"description": "This step requires you to visit an external website to be completed.",
|
||||
"open_site": "Open website"
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -12,10 +12,15 @@ import {
|
||||
registerUnsavedChangesGuard,
|
||||
unregisterUnsavedChangesGuard,
|
||||
} from "../../src/common/navigate";
|
||||
import { closeAllDialogs } from "../../src/dialogs/make-dialog-manager";
|
||||
|
||||
// navigate() closes open dialogs before touching history.
|
||||
// navigate() closes open dialogs before touching history. A closed dialog
|
||||
// drops its state from the entry, which is what navigate() waits for.
|
||||
vi.mock("../../src/dialogs/make-dialog-manager", () => ({
|
||||
closeAllDialogs: vi.fn(async () => true),
|
||||
closeAllDialogs: vi.fn(async () => {
|
||||
window.history.replaceState(null, "");
|
||||
return true;
|
||||
}),
|
||||
}));
|
||||
|
||||
// Fabricates a raw entry like a document load, which navigate() never produces.
|
||||
@@ -56,6 +61,7 @@ const trackDeferredGuard = (isDirty: () => boolean = () => true) => {
|
||||
|
||||
afterEach(() => {
|
||||
registeredGuards.splice(0).forEach(unregisterUnsavedChangesGuard);
|
||||
vi.mocked(closeAllDialogs).mockClear();
|
||||
});
|
||||
|
||||
describe("navigate", () => {
|
||||
@@ -97,6 +103,41 @@ describe("navigate", () => {
|
||||
expect(window.history.state).toBeNull();
|
||||
expect(canGoBack()).toBe(false);
|
||||
});
|
||||
|
||||
it("closes an open dialog before replacing the entry", async () => {
|
||||
setEntry("/home", { dialog: "ha-more-info-dialog" });
|
||||
|
||||
await navigate("/home/overview", { replace: true });
|
||||
|
||||
expect(closeAllDialogs).toHaveBeenCalledOnce();
|
||||
expect(window.location.pathname).toEqual("/home/overview");
|
||||
expect(window.history.state).toBeNull();
|
||||
});
|
||||
|
||||
it("keeps an open dialog and the entry state when asked to", async () => {
|
||||
setEntry("/home", { dialog: "ha-more-info-dialog", from: "/" });
|
||||
|
||||
await navigate("/home/overview", { replace: true, keepDialogs: true });
|
||||
|
||||
expect(closeAllDialogs).not.toHaveBeenCalled();
|
||||
expect(window.location.pathname).toEqual("/home/overview");
|
||||
expect(window.history.state).toEqual({
|
||||
dialog: "ha-more-info-dialog",
|
||||
from: "/",
|
||||
});
|
||||
});
|
||||
|
||||
it("does not drop a navigation right after one that keeps dialogs", async () => {
|
||||
setEntry("/home");
|
||||
|
||||
const results = await Promise.all([
|
||||
navigate("/home/overview", { replace: true, keepDialogs: true }),
|
||||
navigate("/config"),
|
||||
]);
|
||||
|
||||
expect(results).toEqual([true, true]);
|
||||
expect(window.location.pathname).toEqual("/config");
|
||||
});
|
||||
});
|
||||
|
||||
describe("unsaved changes guard", () => {
|
||||
|
||||
@@ -45,7 +45,7 @@ describe("partial-panel-resolver", () => {
|
||||
// The Marketplace loads its translations for the path it is at
|
||||
expect(navigate).toHaveBeenCalledWith(
|
||||
"/marketplace/repository/1?category=theme#top",
|
||||
{ replace: true }
|
||||
{ replace: true, keepDialogs: true }
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -280,4 +280,71 @@ describe("MaintenanceViewStrategy.generate", () => {
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
const createBatteryHass = () =>
|
||||
createMockHass({
|
||||
"sensor.car_target_charge": createMockEntityState(
|
||||
"sensor.car_target_charge",
|
||||
"80",
|
||||
{ device_class: "battery" }
|
||||
),
|
||||
"sensor.remote_battery": createMockEntityState(
|
||||
"sensor.remote_battery",
|
||||
"80",
|
||||
{ device_class: "battery" }
|
||||
),
|
||||
});
|
||||
|
||||
it("leaves hidden entities out of the view", async () => {
|
||||
const view = await MaintenanceViewStrategy.generate(
|
||||
{ type: "maintenance", hidden_entities: ["sensor.car_target_charge"] },
|
||||
createBatteryHass()
|
||||
);
|
||||
|
||||
const section = view.sections?.[0] as LovelaceSectionConfig;
|
||||
|
||||
expect(section.cards?.slice(1)).toEqual([
|
||||
{
|
||||
type: "tile",
|
||||
entity: "sensor.remote_battery",
|
||||
name: { type: "entity" },
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps hidden entities as hidden visibility wrappers while editing", async () => {
|
||||
const view = await MaintenanceViewStrategy.generate(
|
||||
{
|
||||
type: "maintenance",
|
||||
hidden_entities: ["sensor.car_target_charge"],
|
||||
edit_visibility: true,
|
||||
},
|
||||
createBatteryHass()
|
||||
);
|
||||
|
||||
const section = view.sections?.[0] as LovelaceSectionConfig;
|
||||
|
||||
expect(section.cards?.slice(1)).toEqual([
|
||||
{
|
||||
type: "entity-visibility",
|
||||
entity: "sensor.car_target_charge",
|
||||
hidden: true,
|
||||
card: {
|
||||
type: "tile",
|
||||
entity: "sensor.car_target_charge",
|
||||
name: { type: "entity" },
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "entity-visibility",
|
||||
entity: "sensor.remote_battery",
|
||||
hidden: false,
|
||||
card: {
|
||||
type: "tile",
|
||||
entity: "sensor.remote_battery",
|
||||
name: { type: "entity" },
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -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,157 @@ 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",
|
||||
fallback_entity: "binary_sensor.front_door",
|
||||
favorite: true,
|
||||
alert: "none",
|
||||
card: expect.objectContaining({ entity: "binary_sensor.front_door" }),
|
||||
},
|
||||
{
|
||||
type: "entity-visibility",
|
||||
entity: "binary_sensor.gone",
|
||||
neighbor_entity: "sensor.temperature",
|
||||
fallback_entity: "binary_sensor.front_door",
|
||||
favorite: true,
|
||||
alert: "none",
|
||||
card: expect.objectContaining({ entity: "binary_sensor.gone" }),
|
||||
},
|
||||
{
|
||||
type: "entity-visibility",
|
||||
entity: "sensor.temperature",
|
||||
neighbor_entity: "binary_sensor.gone",
|
||||
fallback_entity: "binary_sensor.front_door",
|
||||
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",
|
||||
fallback_entity: "binary_sensor.front_door",
|
||||
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",
|
||||
fallback_entity: undefined,
|
||||
grid_options: { columns: 12 },
|
||||
card: {
|
||||
type: "alert",
|
||||
entity: "binary_sensor.gone",
|
||||
alert_color: "amber",
|
||||
clear_color: "amber",
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user