Add security and climate panel

This commit is contained in:
Paul Bottein
2025-09-30 13:44:23 +02:00
parent dba42bb7b5
commit 5fe4656a86
7 changed files with 760 additions and 1 deletions

View File

@@ -33,6 +33,8 @@ const COMPONENTS = {
"media-browser": () =>
import("../panels/media-browser/ha-panel-media-browser"),
lights: () => import("../panels/lights/ha-panel-lights"),
security: () => import("../panels/security/ha-panel-security"),
climate: () => import("../panels/climate/ha-panel-climate"),
};
@customElement("partial-panel-resolver")

View File

@@ -0,0 +1,200 @@
import type { CSSResultGroup, PropertyValues, TemplateResult } from "lit";
import { LitElement, css, html, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { goBack } from "../../common/navigate";
import "../../components/ha-icon-button-arrow-prev";
import "../../components/ha-menu-button";
import type { LovelaceConfig } from "../../data/lovelace/config/types";
import { haStyle } from "../../resources/styles";
import type { HomeAssistant } from "../../types";
import type { Lovelace } from "../lovelace/types";
import "../lovelace/views/hui-view";
import "../lovelace/views/hui-view-container";
const CLIMATE_LOVELACE_CONFIG: LovelaceConfig = {
views: [
{
strategy: {
type: "climate",
},
},
],
};
@customElement("ha-panel-climate")
class PanelClimate extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ type: Boolean, reflect: true }) public narrow = false;
@state() private _viewIndex = 0;
@state() private _lovelace?: Lovelace;
@state() private _searchParms = new URLSearchParams(window.location.search);
public willUpdate(changedProps: PropertyValues) {
if (!this.hasUpdated) {
this.hass.loadFragmentTranslation("lovelace");
}
if (!changedProps.has("hass")) {
return;
}
const oldHass = changedProps.get("hass") as this["hass"];
if (oldHass?.locale !== this.hass.locale) {
this._setLovelace();
}
}
private _back(ev) {
ev.stopPropagation();
goBack();
}
protected render(): TemplateResult {
return html`
<div class="header">
<div class="toolbar">
${this._searchParms.has("historyBack")
? html`
<ha-icon-button-arrow-prev
@click=${this._back}
slot="navigationIcon"
></ha-icon-button-arrow-prev>
`
: html`
<ha-menu-button
slot="navigationIcon"
.hass=${this.hass}
.narrow=${this.narrow}
></ha-menu-button>
`}
${!this.narrow
? html`<div class="main-title">
${this.hass.localize("panel.climate")}
</div>`
: nothing}
</div>
</div>
<hui-view-container .hass=${this.hass}>
<hui-view
.hass=${this.hass}
.narrow=${this.narrow}
.lovelace=${this._lovelace}
.index=${this._viewIndex}
></hui-view>
</hui-view-container>
`;
}
private _setLovelace() {
this._lovelace = {
config: CLIMATE_LOVELACE_CONFIG,
rawConfig: CLIMATE_LOVELACE_CONFIG,
editMode: false,
urlPath: "climate",
mode: "generated",
locale: this.hass.locale,
enableFullEditMode: () => undefined,
saveConfig: async () => undefined,
deleteConfig: async () => undefined,
setEditMode: () => undefined,
showToast: () => undefined,
};
}
static get styles(): CSSResultGroup {
return [
haStyle,
css`
:host {
-ms-user-select: none;
-webkit-user-select: none;
-moz-user-select: none;
}
.header {
background-color: var(--app-header-background-color);
color: var(--app-header-text-color, white);
border-bottom: var(--app-header-border-bottom, none);
position: fixed;
top: 0;
width: calc(
var(--mdc-top-app-bar-width, 100%) - var(
--safe-area-inset-right,
0px
)
);
padding-top: var(--safe-area-inset-top);
z-index: 4;
transition: box-shadow 200ms linear;
display: flex;
flex-direction: row;
-webkit-backdrop-filter: var(--app-header-backdrop-filter, none);
backdrop-filter: var(--app-header-backdrop-filter, none);
padding-top: var(--safe-area-inset-top);
padding-right: var(--safe-area-inset-right);
}
:host([narrow]) .header {
width: calc(
var(--mdc-top-app-bar-width, 100%) - var(
--safe-area-inset-left,
0px
) - var(--safe-area-inset-right, 0px)
);
padding-left: var(--safe-area-inset-left);
}
:host([scrolled]) .header {
box-shadow: var(
--mdc-top-app-bar-fixed-box-shadow,
0px 2px 4px -1px rgba(0, 0, 0, 0.2),
0px 4px 5px 0px rgba(0, 0, 0, 0.14),
0px 1px 10px 0px rgba(0, 0, 0, 0.12)
);
}
.toolbar {
height: var(--header-height);
display: flex;
flex: 1;
align-items: center;
font-size: var(--ha-font-size-xl);
padding: 0px 12px;
font-weight: var(--ha-font-weight-normal);
box-sizing: border-box;
}
:host([narrow]) .toolbar {
padding: 0 4px;
}
.main-title {
margin: var(--margin-title);
line-height: var(--ha-line-height-normal);
flex-grow: 1;
}
hui-view-container {
position: relative;
display: flex;
min-height: 100vh;
box-sizing: border-box;
padding-top: calc(var(--header-height) + var(--safe-area-inset-top));
padding-right: var(--safe-area-inset-right);
padding-inline-end: var(--safe-area-inset-right);
padding-bottom: var(--safe-area-inset-bottom);
}
:host([narrow]) hui-view-container {
padding-left: var(--safe-area-inset-left);
padding-inline-start: var(--safe-area-inset-left);
}
hui-view {
flex: 1 1 100%;
max-width: 100%;
}
`,
];
}
}
declare global {
interface HTMLElementTagNameMap {
"ha-panel-climate": PanelClimate;
}
}

View File

@@ -0,0 +1,189 @@
import { ReactiveElement } from "lit";
import { customElement } from "lit/decorators";
import {
findEntities,
generateEntityFilter,
type EntityFilter,
} from "../../../common/entity/entity_filter";
import type { LovelaceCardConfig } from "../../../data/lovelace/config/card";
import type { LovelaceSectionRawConfig } from "../../../data/lovelace/config/section";
import type { LovelaceViewConfig } from "../../../data/lovelace/config/view";
import type { HomeAssistant } from "../../../types";
import {
computeAreaTileCardConfig,
getAreas,
getFloors,
} from "../../lovelace/strategies/areas/helpers/areas-strategy-helper";
import { getHomeStructure } from "../../lovelace/strategies/home/helpers/home-structure";
export interface ClimateViewStrategyConfig {
type: "climate";
}
export const climateEntityFilters: EntityFilter[] = [
{ domain: "climate" },
{ domain: "humidifier" },
{ domain: "fan" },
{ domain: "binary_sensor", device_class: "heat" },
{ domain: "binary_sensor", device_class: "cold" },
{ domain: "sensor", device_class: "temperature" },
{ domain: "sensor", device_class: "humidity" },
{ domain: "sensor", device_class: "atmospheric_pressure" },
];
const processAreasForClimate = (
areaIds: string[],
hass: HomeAssistant,
entities: string[]
): LovelaceCardConfig[] => {
const cards: LovelaceCardConfig[] = [];
const computeTileCard = computeAreaTileCardConfig(hass, "", true);
for (const areaId of areaIds) {
const area = hass.areas[areaId];
if (!area) continue;
const areaFilter = generateEntityFilter(hass, {
area: area.area_id,
});
const areaClimateEntities = entities.filter(areaFilter);
const areaCards: LovelaceCardConfig[] = [];
// 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),
features: [{ type: "trend-graph" }],
});
}
const humidityEntityId = area.humidity_entity_id;
if (humidityEntityId && hass.states[humidityEntityId]) {
areaCards.push({
...computeTileCard(humidityEntityId),
features: [{ type: "trend-graph" }],
});
}
// Add other climate entities
for (const entityId of areaClimateEntities) {
// Skip if already added as temperature/humidity sensor
if (entityId === temperatureEntityId || entityId === humidityEntityId) {
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));
}
}
if (areaCards.length > 0) {
cards.push({
heading_style: "subtitle",
type: "heading",
heading: area.name,
});
cards.push(...areaCards);
}
}
return cards;
};
@customElement("climate-view-strategy")
export class ClimateViewStrategy extends ReactiveElement {
static async generate(
_config: ClimateViewStrategyConfig,
hass: HomeAssistant
): Promise<LovelaceViewConfig> {
const areas = getAreas(hass.areas);
const floors = getFloors(hass.floors);
const home = getHomeStructure(floors, areas);
const sections: LovelaceSectionRawConfig[] = [];
const allEntities = Object.keys(hass.states);
const climateFilters = climateEntityFilters.map((filter) =>
generateEntityFilter(hass, filter)
);
const entities = findEntities(allEntities, climateFilters);
const floorCount = home.floors.length + (home.areas.length ? 1 : 0);
// Process floors
for (const floorStructure of home.floors) {
const floorId = floorStructure.id;
const areaIds = floorStructure.areas;
const floor = hass.floors[floorId];
const section: LovelaceSectionRawConfig = {
type: "grid",
column_span: 2,
cards: [
{
type: "heading",
heading:
floorCount > 1
? floor.name
: hass.localize("ui.panel.lovelace.strategy.home.areas"),
},
],
};
const areaCards = processAreasForClimate(areaIds, hass, entities);
if (areaCards.length > 0) {
section.cards!.push(...areaCards);
sections.push(section);
}
}
// Process unassigned areas
if (home.areas.length > 0) {
const section: LovelaceSectionRawConfig = {
type: "grid",
column_span: 2,
cards: [
{
type: "heading",
heading:
floorCount > 1
? hass.localize("ui.panel.lovelace.strategy.home.other_areas")
: hass.localize("ui.panel.lovelace.strategy.home.areas"),
},
],
};
const areaCards = processAreasForClimate(home.areas, hass, entities);
if (areaCards.length > 0) {
section.cards!.push(...areaCards);
sections.push(section);
}
}
return {
type: "sections",
max_columns: 2,
sections: sections || [],
};
}
}
declare global {
interface HTMLElementTagNameMap {
"climate-view-strategy": ClimateViewStrategy;
}
}

View File

@@ -51,6 +51,8 @@ const STRATEGIES: Record<LovelaceStrategyConfigType, Record<string, any>> = {
import("./home/home-media-players-view-strategy"),
"home-area": () => import("./home/home-area-view-strategy"),
lights: () => import("../../lights/strategies/lights-view-strategy"),
security: () => import("../../security/strategies/security-view-strategy"),
climate: () => import("../../climate/strategies/climate-view-strategy"),
},
section: {
"common-controls": () =>

View File

@@ -0,0 +1,200 @@
import type { CSSResultGroup, PropertyValues, TemplateResult } from "lit";
import { LitElement, css, html, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { goBack } from "../../common/navigate";
import "../../components/ha-icon-button-arrow-prev";
import "../../components/ha-menu-button";
import type { LovelaceConfig } from "../../data/lovelace/config/types";
import { haStyle } from "../../resources/styles";
import type { HomeAssistant } from "../../types";
import type { Lovelace } from "../lovelace/types";
import "../lovelace/views/hui-view";
import "../lovelace/views/hui-view-container";
const SECURITY_LOVELACE_CONFIG: LovelaceConfig = {
views: [
{
strategy: {
type: "security",
},
},
],
};
@customElement("ha-panel-security")
class PanelSecurity extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ type: Boolean, reflect: true }) public narrow = false;
@state() private _viewIndex = 0;
@state() private _lovelace?: Lovelace;
@state() private _searchParms = new URLSearchParams(window.location.search);
public willUpdate(changedProps: PropertyValues) {
if (!this.hasUpdated) {
this.hass.loadFragmentTranslation("lovelace");
}
if (!changedProps.has("hass")) {
return;
}
const oldHass = changedProps.get("hass") as this["hass"];
if (oldHass?.locale !== this.hass.locale) {
this._setLovelace();
}
}
private _back(ev) {
ev.stopPropagation();
goBack();
}
protected render(): TemplateResult {
return html`
<div class="header">
<div class="toolbar">
${this._searchParms.has("historyBack")
? html`
<ha-icon-button-arrow-prev
@click=${this._back}
slot="navigationIcon"
></ha-icon-button-arrow-prev>
`
: html`
<ha-menu-button
slot="navigationIcon"
.hass=${this.hass}
.narrow=${this.narrow}
></ha-menu-button>
`}
${!this.narrow
? html`<div class="main-title">
${this.hass.localize("panel.security")}
</div>`
: nothing}
</div>
</div>
<hui-view-container .hass=${this.hass}>
<hui-view
.hass=${this.hass}
.narrow=${this.narrow}
.lovelace=${this._lovelace}
.index=${this._viewIndex}
></hui-view>
</hui-view-container>
`;
}
private _setLovelace() {
this._lovelace = {
config: SECURITY_LOVELACE_CONFIG,
rawConfig: SECURITY_LOVELACE_CONFIG,
editMode: false,
urlPath: "security",
mode: "generated",
locale: this.hass.locale,
enableFullEditMode: () => undefined,
saveConfig: async () => undefined,
deleteConfig: async () => undefined,
setEditMode: () => undefined,
showToast: () => undefined,
};
}
static get styles(): CSSResultGroup {
return [
haStyle,
css`
:host {
-ms-user-select: none;
-webkit-user-select: none;
-moz-user-select: none;
}
.header {
background-color: var(--app-header-background-color);
color: var(--app-header-text-color, white);
border-bottom: var(--app-header-border-bottom, none);
position: fixed;
top: 0;
width: calc(
var(--mdc-top-app-bar-width, 100%) - var(
--safe-area-inset-right,
0px
)
);
padding-top: var(--safe-area-inset-top);
z-index: 4;
transition: box-shadow 200ms linear;
display: flex;
flex-direction: row;
-webkit-backdrop-filter: var(--app-header-backdrop-filter, none);
backdrop-filter: var(--app-header-backdrop-filter, none);
padding-top: var(--safe-area-inset-top);
padding-right: var(--safe-area-inset-right);
}
:host([narrow]) .header {
width: calc(
var(--mdc-top-app-bar-width, 100%) - var(
--safe-area-inset-left,
0px
) - var(--safe-area-inset-right, 0px)
);
padding-left: var(--safe-area-inset-left);
}
:host([scrolled]) .header {
box-shadow: var(
--mdc-top-app-bar-fixed-box-shadow,
0px 2px 4px -1px rgba(0, 0, 0, 0.2),
0px 4px 5px 0px rgba(0, 0, 0, 0.14),
0px 1px 10px 0px rgba(0, 0, 0, 0.12)
);
}
.toolbar {
height: var(--header-height);
display: flex;
flex: 1;
align-items: center;
font-size: var(--ha-font-size-xl);
padding: 0px 12px;
font-weight: var(--ha-font-weight-normal);
box-sizing: border-box;
}
:host([narrow]) .toolbar {
padding: 0 4px;
}
.main-title {
margin: var(--margin-title);
line-height: var(--ha-line-height-normal);
flex-grow: 1;
}
hui-view-container {
position: relative;
display: flex;
min-height: 100vh;
box-sizing: border-box;
padding-top: calc(var(--header-height) + var(--safe-area-inset-top));
padding-right: var(--safe-area-inset-right);
padding-inline-end: var(--safe-area-inset-right);
padding-bottom: var(--safe-area-inset-bottom);
}
:host([narrow]) hui-view-container {
padding-left: var(--safe-area-inset-left);
padding-inline-start: var(--safe-area-inset-left);
}
hui-view {
flex: 1 1 100%;
max-width: 100%;
}
`,
];
}
}
declare global {
interface HTMLElementTagNameMap {
"ha-panel-security": PanelSecurity;
}
}

View File

@@ -0,0 +1,164 @@
import { ReactiveElement } from "lit";
import { customElement } from "lit/decorators";
import {
findEntities,
generateEntityFilter,
type EntityFilter,
} from "../../../common/entity/entity_filter";
import type { LovelaceCardConfig } from "../../../data/lovelace/config/card";
import type { LovelaceSectionRawConfig } from "../../../data/lovelace/config/section";
import type { LovelaceViewConfig } from "../../../data/lovelace/config/view";
import type { HomeAssistant } from "../../../types";
import {
computeAreaTileCardConfig,
getAreas,
getFloors,
} from "../../lovelace/strategies/areas/helpers/areas-strategy-helper";
import { getHomeStructure } from "../../lovelace/strategies/home/helpers/home-structure";
export interface SecurityViewStrategyConfig {
type: "security";
}
export const securityEntityFilters: EntityFilter[] = [
{ domain: "binary_sensor", device_class: "door" },
{ domain: "binary_sensor", device_class: "garage_door" },
{ domain: "binary_sensor", device_class: "lock" },
{ domain: "binary_sensor", device_class: "opening" },
{ domain: "binary_sensor", device_class: "window" },
{ domain: "binary_sensor", device_class: "motion" },
{ domain: "binary_sensor", device_class: "occupancy" },
{ domain: "binary_sensor", device_class: "presence" },
{ domain: "binary_sensor", device_class: "safety" },
{ domain: "binary_sensor", device_class: "smoke" },
{ domain: "binary_sensor", device_class: "gas" },
{ domain: "binary_sensor", device_class: "problem" },
{ domain: "binary_sensor", device_class: "tamper" },
{ domain: "lock" },
{ domain: "alarm_control_panel" },
{ domain: "camera" },
];
const processAreasForSecurity = (
areaIds: string[],
hass: HomeAssistant,
entities: string[]
): LovelaceCardConfig[] => {
const cards: LovelaceCardConfig[] = [];
for (const areaId of areaIds) {
const area = hass.areas[areaId];
if (!area) continue;
const areaFilter = generateEntityFilter(hass, {
area: area.area_id,
});
const areaSecurityEntities = entities.filter(areaFilter);
const areaCards: LovelaceCardConfig[] = [];
const computeTileCard = computeAreaTileCardConfig(hass, "", false);
for (const entityId of areaSecurityEntities) {
areaCards.push(computeTileCard(entityId));
}
if (areaCards.length > 0) {
cards.push({
heading_style: "subtitle",
type: "heading",
heading: area.name,
});
cards.push(...areaCards);
}
}
return cards;
};
@customElement("security-view-strategy")
export class SecurityViewStrategy extends ReactiveElement {
static async generate(
_config: SecurityViewStrategyConfig,
hass: HomeAssistant
): Promise<LovelaceViewConfig> {
const areas = getAreas(hass.areas);
const floors = getFloors(hass.floors);
const home = getHomeStructure(floors, areas);
const sections: LovelaceSectionRawConfig[] = [];
const allEntities = Object.keys(hass.states);
const securityFilters = securityEntityFilters.map((filter) =>
generateEntityFilter(hass, filter)
);
const entities = findEntities(allEntities, securityFilters);
const floorCount = home.floors.length + (home.areas.length ? 1 : 0);
// Process floors
for (const floorStructure of home.floors) {
const floorId = floorStructure.id;
const areaIds = floorStructure.areas;
const floor = hass.floors[floorId];
const section: LovelaceSectionRawConfig = {
type: "grid",
column_span: 2,
cards: [
{
type: "heading",
heading:
floorCount > 1
? floor.name
: hass.localize("ui.panel.lovelace.strategy.home.areas"),
},
],
};
const areaCards = processAreasForSecurity(areaIds, hass, entities);
if (areaCards.length > 0) {
section.cards!.push(...areaCards);
sections.push(section);
}
}
// Process unassigned areas
if (home.areas.length > 0) {
const section: LovelaceSectionRawConfig = {
type: "grid",
column_span: 2,
cards: [
{
type: "heading",
heading:
floorCount > 1
? hass.localize("ui.panel.lovelace.strategy.home.other_areas")
: hass.localize("ui.panel.lovelace.strategy.home.areas"),
},
],
};
const areaCards = processAreasForSecurity(home.areas, hass, entities);
if (areaCards.length > 0) {
section.cards!.push(...areaCards);
sections.push(section);
}
}
return {
type: "sections",
max_columns: 2,
sections: sections || [],
};
}
}
declare global {
interface HTMLElementTagNameMap {
"security-view-strategy": SecurityViewStrategy;
}
}

View File

@@ -11,7 +11,9 @@
"developer_tools": "Developer tools",
"media_browser": "Media",
"profile": "Profile",
"lights": "Lights"
"lights": "Lights",
"security": "Security",
"climate": "Climate"
},
"state": {
"default": {