Compare commits

...

2 Commits

Author SHA1 Message Date
Paul Bottein
55c8fbc2b9 Use unassigned device summary card 2025-12-11 17:37:38 +01:00
Paul Bottein
3a5175781d Create unassigned devices panel 2025-12-11 16:21:59 +01:00
9 changed files with 447 additions and 3 deletions

View File

@@ -20,6 +20,7 @@ import { showAlertDialog } from "../dialogs/generic/show-dialog-box";
import { showAreaRegistryDetailDialog } from "../panels/config/areas/show-dialog-area-registry-detail";
import type { HomeAssistant, ValueChangedEvent } from "../types";
import type { HaDevicePickerDeviceFilterFunc } from "./device/ha-device-picker";
import "./ha-button";
import "./ha-combo-box-item";
import "./ha-generic-picker";
import type { HaGenericPicker } from "./ha-generic-picker";
@@ -95,6 +96,9 @@ export class HaAreaPicker extends LitElement {
@property({ attribute: "add-button-label" }) public addButtonLabel?: string;
@property({ type: Boolean, attribute: "button-style" })
public buttonStyle = false;
@query("ha-generic-picker") private _picker?: HaGenericPicker;
public async open() {
@@ -390,10 +394,26 @@ export class HaAreaPicker extends LitElement {
)}
@value-changed=${this._valueChanged}
>
${this.buttonStyle
? html`<ha-button
slot="field"
.disabled=${this.disabled}
@click=${this._openPicker}
appearance="plain"
size="small"
>
${placeholder}
</ha-button>`
: nothing}
</ha-generic-picker>
`;
}
private _openPicker(ev: Event) {
ev.stopPropagation();
this._picker?.open();
}
private _valueChanged(ev: ValueChangedEvent<string>) {
ev.stopPropagation();
const value = ev.detail.value;

View File

@@ -0,0 +1,15 @@
import type { DeviceRegistryEntry } from "./device_registry";
export const filterUnassignedDevices = (
devices: Record<string, DeviceRegistryEntry>
): DeviceRegistryEntry[] =>
Object.values(devices).filter(
(device) =>
device.area_id === null &&
device.disabled_by === null &&
device.entry_type !== "service"
);
export const countUnassignedDevices = (
devices: Record<string, DeviceRegistryEntry>
): number => filterUnassignedDevices(devices).length;

View File

@@ -795,7 +795,7 @@ export class HaConfigDeviceDashboard extends SubscribeMixin(LitElement) {
.narrow=${this.narrow}
.backPath=${this._searchParms.has("historyBack")
? undefined
: "/config"}
: "/config/devices"}
.tabs=${configSections.devices}
.route=${this.route}
.searchLabel=${this.hass.localize(

View File

@@ -0,0 +1,367 @@
import { consume } from "@lit/context";
import type { CSSResultGroup, TemplateResult } from "lit";
import { LitElement, css, html, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import { storage } from "../../../common/decorators/storage";
import type { HASSDomEvent } from "../../../common/dom/fire_event";
import { computeDeviceNameDisplay } from "../../../common/entity/compute_device_name";
import { navigate } from "../../../common/navigate";
import type { LocalizeFunc } from "../../../common/translations/localize";
import type {
DataTableColumnContainer,
RowClickedEvent,
SortingChangedEvent,
} from "../../../components/data-table/ha-data-table";
import "../../../components/ha-area-picker";
import type { ConfigEntry } from "../../../data/config_entries";
import { sortConfigEntries } from "../../../data/config_entries";
import { fullEntitiesContext } from "../../../data/context";
import type { DeviceEntityLookup } from "../../../data/device/device_registry";
import { updateDeviceRegistryEntry } from "../../../data/device/device_registry";
import { filterUnassignedDevices } from "../../../data/device/unassigned_devices";
import type { EntityRegistryEntry } from "../../../data/entity/entity_registry";
import type { IntegrationManifest } from "../../../data/integration";
import { showAlertDialog } from "../../../dialogs/generic/show-dialog-box";
import "../../../layouts/hass-tabs-subpage-data-table";
import type { PageNavigation } from "../../../layouts/hass-tabs-subpage";
import { haStyle } from "../../../resources/styles";
import type { HomeAssistant, Route } from "../../../types";
import { brandsUrl } from "../../../util/brands-url";
const TABS: PageNavigation[] = [
{
path: "/config/devices/unassigned",
translationKey: "ui.panel.config.devices.unassigned.caption",
},
];
@customElement("ha-config-devices-unassigned")
export class HaConfigDevicesUnassigned extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ type: Boolean }) public narrow = false;
@property({ attribute: "is-wide", type: Boolean }) public isWide = false;
@property({ attribute: false }) public entries!: ConfigEntry[];
@state()
@consume({ context: fullEntitiesContext, subscribe: true })
entities!: EntityRegistryEntry[];
@property({ attribute: false }) public manifests!: IntegrationManifest[];
@property({ attribute: false }) public route!: Route;
@state() private _searchParms = new URLSearchParams(window.location.search);
@state()
@storage({
storage: "sessionStorage",
key: "devices-unassigned-table-search",
state: true,
subscribe: false,
})
private _filter = "";
@storage({
key: "devices-unassigned-table-sort",
state: false,
subscribe: false,
})
private _activeSorting?: SortingChangedEvent;
@storage({
key: "devices-unassigned-table-grouping",
state: false,
subscribe: false,
})
private _activeGrouping?: string;
@storage({
key: "devices-unassigned-table-collapsed",
state: false,
subscribe: false,
})
private _activeCollapsed?: string;
@storage({
key: "devices-unassigned-table-column-order",
state: false,
subscribe: false,
})
private _activeColumnOrder?: string[];
@storage({
key: "devices-unassigned-table-hidden-columns",
state: false,
subscribe: false,
})
private _activeHiddenColumns?: string[];
private _unassignedDevices = memoizeOne(
(
devices: HomeAssistant["devices"],
entries: ConfigEntry[],
entities: EntityRegistryEntry[],
localize: LocalizeFunc
) => {
const deviceEntityLookup: DeviceEntityLookup<EntityRegistryEntry> = {};
for (const entity of entities) {
if (!entity.device_id) {
continue;
}
if (!(entity.device_id in deviceEntityLookup)) {
deviceEntityLookup[entity.device_id] = [];
}
deviceEntityLookup[entity.device_id].push(entity);
}
const entryLookup: Record<string, ConfigEntry> = {};
for (const entry of entries) {
entryLookup[entry.entry_id] = entry;
}
const unassignedDevices = filterUnassignedDevices(devices);
return unassignedDevices.map((device) => {
const deviceEntries = sortConfigEntries(
device.config_entries
.filter((entId) => entId in entryLookup)
.map((entId) => entryLookup[entId]),
device.primary_config_entry
);
return {
...device,
name: computeDeviceNameDisplay(
device,
this.hass,
deviceEntityLookup[device.id]
),
model:
device.model ||
`<${localize("ui.panel.config.devices.data_table.unknown")}>`,
manufacturer:
device.manufacturer ||
`<${localize("ui.panel.config.devices.data_table.unknown")}>`,
integration: deviceEntries.length
? deviceEntries
.map(
(entry) =>
localize(`component.${entry.domain}.title`) || entry.domain
)
.join(", ")
: this.hass.localize(
"ui.panel.config.devices.data_table.no_integration"
),
domains: deviceEntries.map((entry) => entry.domain),
};
});
}
);
private _columns = memoizeOne((localize: LocalizeFunc, narrow: boolean) => {
type DeviceItem = ReturnType<typeof this._unassignedDevices>[number];
const columns: DataTableColumnContainer<DeviceItem> = {
icon: {
title: "",
label: localize("ui.panel.config.devices.data_table.icon"),
type: "icon",
moveable: false,
showNarrow: true,
template: (device) =>
device.domains.length
? html`<img
alt=""
crossorigin="anonymous"
referrerpolicy="no-referrer"
src=${brandsUrl({
domain: device.domains[0],
type: "icon",
darkOptimized: this.hass.themes?.darkMode,
})}
/>`
: "",
},
name: {
title: localize("ui.panel.config.devices.data_table.device"),
main: true,
sortable: true,
filterable: true,
direction: "asc",
flex: 2,
minWidth: "150px",
},
integration: {
title: localize("ui.panel.config.devices.data_table.integration"),
sortable: true,
filterable: true,
groupable: true,
minWidth: "120px",
},
manufacturer: {
title: localize("ui.panel.config.devices.data_table.manufacturer"),
sortable: true,
filterable: true,
groupable: true,
minWidth: "120px",
defaultHidden: narrow,
},
model: {
title: localize("ui.panel.config.devices.data_table.model"),
sortable: true,
filterable: true,
minWidth: "120px",
defaultHidden: narrow,
},
assign: {
title: "",
label: localize("ui.panel.config.devices.unassigned.assign"),
type: "overflow-menu",
moveable: false,
showNarrow: true,
minWidth: "150px",
template: (device) => html`
<ha-area-picker
.hass=${this.hass}
data-device-id=${device.id}
.placeholder=${localize(
"ui.panel.config.devices.unassigned.assign"
)}
no-add
button-style
@value-changed=${this._assignArea}
></ha-area-picker>
`,
},
};
return columns;
});
protected render(): TemplateResult {
if (!this.hass || !this.entries || !this.entities) {
return nothing as unknown as TemplateResult;
}
const devicesOutput = this._unassignedDevices(
this.hass.devices,
this.entries,
this.entities,
this.hass.localize
);
return html`
<hass-tabs-subpage-data-table
.hass=${this.hass}
.narrow=${this.narrow}
.backPath=${this._searchParms.has("historyBack")
? undefined
: "/config/devices/dashboard"}
.tabs=${TABS}
.route=${this.route}
.searchLabel=${this.hass.localize(
"ui.panel.config.devices.unassigned.search",
{ number: devicesOutput.length }
)}
.columns=${this._columns(this.hass.localize, this.narrow)}
.data=${devicesOutput}
.filter=${this._filter}
.initialGroupColumn=${this._activeGrouping}
.initialCollapsedGroups=${this._activeCollapsed}
.initialSorting=${this._activeSorting}
.columnOrder=${this._activeColumnOrder}
.hiddenColumns=${this._activeHiddenColumns}
@columns-changed=${this._handleColumnsChanged}
@search-changed=${this._handleSearchChange}
@sorting-changed=${this._handleSortingChanged}
@grouping-changed=${this._handleGroupingChanged}
@collapsed-changed=${this._handleCollapseChanged}
@row-click=${this._handleRowClicked}
clickable
class=${this.narrow ? "narrow" : ""}
.noDataText=${this.hass.localize(
"ui.panel.config.devices.unassigned.no_devices"
)}
>
</hass-tabs-subpage-data-table>
`;
}
private _handleRowClicked(ev: HASSDomEvent<RowClickedEvent>) {
const deviceId = ev.detail.id;
navigate(`/config/devices/device/${deviceId}`);
}
private _handleSearchChange(ev: CustomEvent) {
this._filter = ev.detail.value;
}
private _assignArea = async (ev: CustomEvent) => {
const areaPicker = ev.currentTarget as any;
const deviceId = areaPicker.dataset.deviceId;
const areaId = ev.detail.value;
if (!areaId || !deviceId) {
return;
}
// Reset the picker
areaPicker.value = undefined;
try {
await updateDeviceRegistryEntry(this.hass, deviceId, {
area_id: areaId,
});
} catch (err: any) {
showAlertDialog(this, {
text: err.message || "Unknown error",
});
}
};
private _handleSortingChanged(ev: CustomEvent) {
this._activeSorting = ev.detail;
}
private _handleGroupingChanged(ev: CustomEvent) {
this._activeGrouping = ev.detail.value;
}
private _handleCollapseChanged(ev: CustomEvent) {
this._activeCollapsed = ev.detail.value;
}
private _handleColumnsChanged(ev: CustomEvent) {
this._activeColumnOrder = ev.detail.columnOrder;
this._activeHiddenColumns = ev.detail.hiddenColumns;
}
static get styles(): CSSResultGroup {
return [
css`
:host {
display: block;
}
hass-tabs-subpage-data-table {
--data-table-row-height: 60px;
}
hass-tabs-subpage-data-table.narrow {
--data-table-row-height: 72px;
}
`,
haStyle,
];
}
}
declare global {
interface HTMLElementTagNameMap {
"ha-config-devices-unassigned": HaConfigDevicesUnassigned;
}
}

View File

@@ -8,6 +8,7 @@ import { HassRouterPage } from "../../../layouts/hass-router-page";
import type { HomeAssistant } from "../../../types";
import "./ha-config-device-page";
import "./ha-config-devices-dashboard";
import "./ha-config-devices-unassigned";
@customElement("ha-config-devices")
class HaConfigDevices extends HassRouterPage {
@@ -26,6 +27,10 @@ class HaConfigDevices extends HassRouterPage {
tag: "ha-config-devices-dashboard",
cache: true,
},
unassigned: {
tag: "ha-config-devices-unassigned",
cache: true,
},
device: {
tag: "ha-config-device-page",
},

View File

@@ -15,6 +15,7 @@ import "../../../components/ha-icon";
import "../../../components/ha-ripple";
import "../../../components/tile/ha-tile-icon";
import "../../../components/tile/ha-tile-info";
import { countUnassignedDevices } from "../../../data/device/unassigned_devices";
import type { ActionHandlerEvent } from "../../../data/lovelace/action_handler";
import "../../../state-display/state-display";
import type { HomeAssistant } from "../../../types";
@@ -35,6 +36,7 @@ const COLORS: Record<HomeSummary, string> = {
climate: "deep-orange",
security: "blue-grey",
media_players: "blue",
unassigned_devices: "grey",
};
@customElement("hui-home-summary-card")
@@ -214,6 +216,13 @@ export class HuiHomeSummaryCard extends LitElement implements LovelaceCard {
})
: this.hass.localize("ui.card.home-summary.no_media_playing");
}
case "unassigned_devices": {
const count = countUnassignedDevices(this.hass.devices);
return this.hass.localize(
"ui.card.home-summary.count_unassigned_devices",
{ count }
);
}
}
return "";
}

View File

@@ -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<HomeSummary, string> = {
climate: "mdi:home-thermometer",
security: "mdi:security",
media_players: "mdi:multimedia",
unassigned_devices: "mdi:devices",
};
export const HOME_SUMMARIES_FILTERS: Record<HomeSummary, EntityFilter[]> = {
@@ -25,6 +27,7 @@ export const HOME_SUMMARIES_FILTERS: Record<HomeSummary, EntityFilter[]> = {
climate: climateEntityFilters,
security: securityEntityFilters,
media_players: [{ domain: "media_player", entity_category: "none" }],
unassigned_devices: [], // Device-based, not entity-based
};
export const getSummaryLabel = (

View File

@@ -24,6 +24,7 @@ import type {
WeatherForecastCardConfig,
} from "../../cards/types";
import type { CommonControlSectionStrategyConfig } from "../usage_prediction/common-controls-section-strategy";
import { countUnassignedDevices } from "../../../../data/device/unassigned_devices";
import { HOME_SUMMARIES_FILTERS } from "./helpers/home-summaries";
import type { Condition } from "../../common/validate-condition";
@@ -170,6 +171,10 @@ export class HomeOverviewViewStrategy extends ReactiveElement {
const hasClimate = findEntities(allEntities, climateFilters).length > 0;
const hasSecurity = findEntities(allEntities, securityFilters).length > 0;
const unassignedDevicesCount = countUnassignedDevices(hass.devices);
const hasUnassignedDevices =
hass.user?.is_admin && unassignedDevicesCount > 0;
const summaryCards: LovelaceCardConfig[] = [
hasLights &&
({
@@ -219,6 +224,18 @@ export class HomeOverviewViewStrategy extends ReactiveElement {
columns: 12,
},
} satisfies HomeSummaryCard),
hasUnassignedDevices &&
({
type: "home-summary",
summary: "unassigned_devices",
tap_action: {
action: "navigate",
navigation_path: "/config/devices/unassigned?historyBack=1",
},
grid_options: {
columns: 12,
},
} satisfies HomeSummaryCard),
].filter(Boolean) as LovelaceCardConfig[];
const forYouSection: LovelaceSectionConfig = {

View File

@@ -212,7 +212,8 @@
"count_alarms_disarmed": "{count} {count, plural,\n one {disarmed}\n other {disarmed}\n}",
"all_secure": "All secure",
"no_media_playing": "No media playing",
"count_media_playing": "{count} {count, plural,\n one {playing}\n other {playing}\n}"
"count_media_playing": "{count} {count, plural,\n one {playing}\n other {playing}\n}",
"count_unassigned_devices": "{count} {count, plural,\n one {device}\n other {devices}\n}"
},
"media_player": {
"source": "Source",
@@ -5494,6 +5495,12 @@
}
}
},
"unassigned": {
"caption": "Unassigned devices",
"search": "Search {number} unassigned {number, plural,\n one {device}\n other {devices}\n}",
"no_devices": "All devices are assigned to areas",
"assign": "Assign to area"
},
"esphome": {
"show_encryption_key": "Show encryption key",
"encryption_key_title": "ESPHome Encryption Key",
@@ -7148,7 +7155,8 @@
},
"home": {
"summary_list": {
"media_players": "Media players"
"media_players": "Media players",
"unassigned_devices": "Unassigned devices"
},
"welcome_user": "Welcome {user}",
"summaries": "Summaries",