diff --git a/src/layouts/partial-panel-resolver.ts b/src/layouts/partial-panel-resolver.ts index 5dd07bd094..e573f456eb 100644 --- a/src/layouts/partial-panel-resolver.ts +++ b/src/layouts/partial-panel-resolver.ts @@ -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") diff --git a/src/panels/climate/ha-panel-climate.ts b/src/panels/climate/ha-panel-climate.ts new file mode 100644 index 0000000000..d2e7684785 --- /dev/null +++ b/src/panels/climate/ha-panel-climate.ts @@ -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` +
+
+ ${this._searchParms.has("historyBack") + ? html` + + ` + : html` + + `} + ${!this.narrow + ? html`
+ ${this.hass.localize("panel.climate")} +
` + : nothing} +
+
+ + + + + `; + } + + 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; + } +} diff --git a/src/panels/climate/strategies/climate-view-strategy.ts b/src/panels/climate/strategies/climate-view-strategy.ts new file mode 100644 index 0000000000..6d4ef66a32 --- /dev/null +++ b/src/panels/climate/strategies/climate-view-strategy.ts @@ -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 { + 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; + } +} diff --git a/src/panels/lovelace/strategies/get-strategy.ts b/src/panels/lovelace/strategies/get-strategy.ts index 4b968febd9..09a569a628 100644 --- a/src/panels/lovelace/strategies/get-strategy.ts +++ b/src/panels/lovelace/strategies/get-strategy.ts @@ -51,6 +51,8 @@ const STRATEGIES: Record> = { 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": () => diff --git a/src/panels/security/ha-panel-security.ts b/src/panels/security/ha-panel-security.ts new file mode 100644 index 0000000000..f44630659a --- /dev/null +++ b/src/panels/security/ha-panel-security.ts @@ -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` +
+
+ ${this._searchParms.has("historyBack") + ? html` + + ` + : html` + + `} + ${!this.narrow + ? html`
+ ${this.hass.localize("panel.security")} +
` + : nothing} +
+
+ + + + + `; + } + + 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; + } +} diff --git a/src/panels/security/strategies/security-view-strategy.ts b/src/panels/security/strategies/security-view-strategy.ts new file mode 100644 index 0000000000..a0b9579093 --- /dev/null +++ b/src/panels/security/strategies/security-view-strategy.ts @@ -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 { + 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; + } +} diff --git a/src/translations/en.json b/src/translations/en.json index ada639a17a..3de5e8114f 100644 --- a/src/translations/en.json +++ b/src/translations/en.json @@ -11,7 +11,9 @@ "developer_tools": "Developer tools", "media_browser": "Media", "profile": "Profile", - "lights": "Lights" + "lights": "Lights", + "security": "Security", + "climate": "Climate" }, "state": { "default": {