diff --git a/src/panels/lovelace/cards/hui-home-summary-card.ts b/src/panels/lovelace/cards/hui-home-summary-card.ts index e7981dccbb..d53db76fe2 100644 --- a/src/panels/lovelace/cards/hui-home-summary-card.ts +++ b/src/panels/lovelace/cards/hui-home-summary-card.ts @@ -35,6 +35,7 @@ const COLORS: Record = { climate: "deep-orange", security: "blue-grey", media_players: "blue", + unassigned_devices: "grey", }; @customElement("hui-home-summary-card") diff --git a/src/panels/lovelace/strategies/get-strategy.ts b/src/panels/lovelace/strategies/get-strategy.ts index e08c358a7d..a1a9ae9ccb 100644 --- a/src/panels/lovelace/strategies/get-strategy.ts +++ b/src/panels/lovelace/strategies/get-strategy.ts @@ -52,6 +52,8 @@ const STRATEGIES: Record> = { "home-media-players": () => import("./home/home-media-players-view-strategy"), "home-area": () => import("./home/home-area-view-strategy"), + "home-unassigned-devices": () => + import("./home/home-unassigned-devices-view-strategy"), light: () => import("../../light/strategies/light-view-strategy"), security: () => import("../../security/strategies/security-view-strategy"), climate: () => import("../../climate/strategies/climate-view-strategy"), diff --git a/src/panels/lovelace/strategies/home/helpers/home-summaries.ts b/src/panels/lovelace/strategies/home/helpers/home-summaries.ts index 5f3c9557a1..a0654b0908 100644 --- a/src/panels/lovelace/strategies/home/helpers/home-summaries.ts +++ b/src/panels/lovelace/strategies/home/helpers/home-summaries.ts @@ -9,6 +9,7 @@ export const HOME_SUMMARIES = [ "climate", "security", "media_players", + "unassigned_devices", ] as const; export type HomeSummary = (typeof HOME_SUMMARIES)[number]; @@ -18,6 +19,7 @@ export const HOME_SUMMARIES_ICONS: Record = { climate: "mdi:home-thermometer", security: "mdi:security", media_players: "mdi:multimedia", + unassigned_devices: "mdi:shape", }; export const HOME_SUMMARIES_FILTERS: Record = { @@ -25,6 +27,19 @@ export const HOME_SUMMARIES_FILTERS: Record = { climate: climateEntityFilters, security: securityEntityFilters, media_players: [{ domain: "media_player", entity_category: "none" }], + unassigned_devices: [ + { + area: null, + hidden_platform: [ + "automation", + "script", + "hassio", + "backup", + "zone", + "person", + ], + }, + ], }; export const getSummaryLabel = ( diff --git a/src/panels/lovelace/strategies/home/home-dashboard-strategy.ts b/src/panels/lovelace/strategies/home/home-dashboard-strategy.ts index 0cc7e7df6a..6a7c2fa012 100644 --- a/src/panels/lovelace/strategies/home/home-dashboard-strategy.ts +++ b/src/panels/lovelace/strategies/home/home-dashboard-strategy.ts @@ -71,6 +71,16 @@ export class HomeDashboardStrategy extends ReactiveElement { icon: HOME_SUMMARIES_ICONS.media_players, } satisfies LovelaceViewRawConfig; + const unassignedDevicesView = { + title: getSummaryLabel(hass.localize, "unassigned_devices"), + path: "unassigned-devices", + subview: true, + strategy: { + type: "home-unassigned-devices", + }, + icon: HOME_SUMMARIES_ICONS.unassigned_devices, + } satisfies LovelaceViewRawConfig; + return { views: [ { @@ -83,6 +93,7 @@ export class HomeDashboardStrategy extends ReactiveElement { }, ...areaViews, mediaPlayersView, + unassignedDevicesView, ], }; } diff --git a/src/panels/lovelace/strategies/home/home-overview-view-strategy.ts b/src/panels/lovelace/strategies/home/home-overview-view-strategy.ts index 2d69f42957..bba5ef3a09 100644 --- a/src/panels/lovelace/strategies/home/home-overview-view-strategy.ts +++ b/src/panels/lovelace/strategies/home/home-overview-view-strategy.ts @@ -219,6 +219,19 @@ export class HomeOverviewViewStrategy extends ReactiveElement { columns: 12, }, } satisfies HomeSummaryCard), + { + type: "home-summary", + summary: "unassigned_devices", + vertical: true, + tap_action: { + action: "navigate", + navigation_path: "unassigned-devices", + }, + grid_options: { + rows: 2, + columns: 4, + }, + } satisfies HomeSummaryCard, ].filter(Boolean) as LovelaceCardConfig[]; const forYouSection: LovelaceSectionConfig = { @@ -285,6 +298,29 @@ export class HomeOverviewViewStrategy extends ReactiveElement { } } + const noAreaFilter = generateEntityFilter(hass, { + area: null, + }); + + const otherEntities = allEntities.filter(noAreaFilter); + + if (otherEntities.length > 0) { + widgetSection.cards!.push({ + type: "tile", + entity: otherEntities[0], + icon: "mdi:shape", + name: "Unassigned devices", + hide_state: true, + tap_action: { + action: "navigate", + navigation_path: "unassigned-devices", + }, + icon_tap_action: { + action: "none", + }, + }); + } + const sections = ( [ { diff --git a/src/panels/lovelace/strategies/home/home-unassigned-devices-view-strategy.ts b/src/panels/lovelace/strategies/home/home-unassigned-devices-view-strategy.ts new file mode 100644 index 0000000000..51573b70c7 --- /dev/null +++ b/src/panels/lovelace/strategies/home/home-unassigned-devices-view-strategy.ts @@ -0,0 +1,183 @@ +import { ReactiveElement } from "lit"; +import { customElement } from "lit/decorators"; +import { computeDeviceName } from "../../../../common/entity/compute_device_name"; +import { getEntityContext } from "../../../../common/entity/context/get_entity_context"; +import { + findEntities, + generateEntityFilter, +} from "../../../../common/entity/entity_filter"; +import { clamp } from "../../../../common/number/clamp"; +import type { LovelaceSectionRawConfig } from "../../../../data/lovelace/config/section"; +import type { LovelaceViewConfig } from "../../../../data/lovelace/config/view"; +import type { HomeAssistant } from "../../../../types"; +import type { HeadingCardConfig } from "../../cards/types"; +import { HOME_SUMMARIES_FILTERS } from "./helpers/home-summaries"; + +export interface HomeUnassignedDevicesViewStrategyConfig { + type: "home-unassigned-devices"; +} + +@customElement("home-unassigned-devices-view-strategy") +export class HomeUnassignedDevicesViewStrategy extends ReactiveElement { + static async generate( + _config: HomeUnassignedDevicesViewStrategyConfig, + hass: HomeAssistant + ): Promise { + const sections: LovelaceSectionRawConfig[] = []; + const allEntities = Object.keys(hass.states); + + const unassignedFilters = HOME_SUMMARIES_FILTERS.unassigned_devices.map( + (filter) => generateEntityFilter(hass, filter) + ); + + const unassignedEntities = findEntities(allEntities, unassignedFilters); + + const deviceSections: LovelaceSectionRawConfig[] = []; + + const entitiesByDevice: Record = {}; + const entitiesWithoutDevices: string[] = []; + for (const entityId of unassignedEntities) { + const stateObj = hass.states[entityId]; + if (!stateObj) continue; + const { device } = getEntityContext( + stateObj, + hass.entities, + hass.devices, + hass.areas, + hass.floors + ); + if (!device) { + entitiesWithoutDevices.push(entityId); + continue; + } + if (!(device.id in entitiesByDevice)) { + entitiesByDevice[device.id] = []; + } + entitiesByDevice[device.id].push(entityId); + } + + const otherDeviceEntities = Object.entries(entitiesByDevice).map( + ([deviceId, entities]) => ({ + device_id: deviceId, + entities: entities, + }) + ); + + if (entitiesWithoutDevices.length > 0) { + otherDeviceEntities.push({ + device_id: "unassigned", + entities: entitiesWithoutDevices, + }); + } + + const batteryFilter = generateEntityFilter(hass, { + domain: "sensor", + device_class: "battery", + }); + + const energyFilter = generateEntityFilter(hass, { + domain: "sensor", + device_class: ["energy", "power"], + }); + + const primaryFilter = generateEntityFilter(hass, { + entity_category: "none", + }); + + for (const deviceEntities of otherDeviceEntities) { + if (deviceEntities.entities.length === 0) continue; + + const batteryEntities = deviceEntities.entities.filter((e) => + batteryFilter(e) + ); + const entities = deviceEntities.entities.filter( + (e) => !batteryFilter(e) && !energyFilter(e) && primaryFilter(e) + ); + + if (entities.length === 0) { + continue; + } + + const deviceId = deviceEntities.device_id; + const device = hass.devices[deviceId]; + let heading = ""; + if (device) { + heading = + computeDeviceName(device) || + hass.localize("ui.panel.lovelace.strategy.home.unamed_device"); + } else { + heading = hass.localize("ui.panel.lovelace.strategy.home.others"); + } + + deviceSections.push({ + type: "grid", + cards: [ + { + type: "heading", + heading: heading, + tap_action: device + ? { + action: "navigate", + navigation_path: `/config/devices/device/${device.id}`, + } + : undefined, + badges: [ + ...batteryEntities.slice(0, 1).map((e) => ({ + entity: e, + type: "entity", + tap_action: { + action: "more-info", + }, + })), + ], + } satisfies HeadingCardConfig, + ...entities.map((e) => ({ + type: "tile", + entity: e, + name: { + type: "entity", + }, + })), + ], + }); + } + + if (deviceSections.length > 0) { + sections.push({ + type: "grid", + column_span: 3, + cards: [ + { + type: "heading", + heading_style: "subtitle", + heading: "", + } satisfies HeadingCardConfig, + ], + } satisfies LovelaceSectionRawConfig); + sections.push(...deviceSections); + } + + // Allow between 2 and 3 columns (the max should be set to define the width of the header) + const maxColumns = clamp(sections.length, 2, 3); + + // Take the full width if there is only one section to avoid narrow header on desktop + if (sections.length === 1) { + sections[0].column_span = 2; + } + + return { + type: "sections", + header: { + badges_position: "bottom", + }, + max_columns: maxColumns, + sections: sections, + }; + } +} + +declare global { + interface HTMLElementTagNameMap { + "home-unassigned-devices-view-strategy": HomeUnassignedDevicesViewStrategy; + } +} diff --git a/src/translations/en.json b/src/translations/en.json index 057620a1ed..b657ae466e 100644 --- a/src/translations/en.json +++ b/src/translations/en.json @@ -7152,7 +7152,8 @@ }, "home": { "summary_list": { - "media_players": "Media players" + "media_players": "Media players", + "unassigned_devices": "Unassigned devices" }, "welcome_user": "Welcome {user}", "summaries": "Summaries",