diff --git a/src/components/ha-area-picker.ts b/src/components/ha-area-picker.ts
index 6a779119b0..47d630d236 100644
--- a/src/components/ha-area-picker.ts
+++ b/src/components/ha-area-picker.ts
@@ -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`
+ ${placeholder}
+ `
+ : nothing}
`;
}
+ private _openPicker(ev: Event) {
+ ev.stopPropagation();
+ this._picker?.open();
+ }
+
private _valueChanged(ev: ValueChangedEvent) {
ev.stopPropagation();
const value = ev.detail.value;
diff --git a/src/panels/config/devices/ha-config-devices-dashboard.ts b/src/panels/config/devices/ha-config-devices-dashboard.ts
index 13a4b70d48..8241e56a53 100644
--- a/src/panels/config/devices/ha-config-devices-dashboard.ts
+++ b/src/panels/config/devices/ha-config-devices-dashboard.ts
@@ -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(
diff --git a/src/panels/config/devices/ha-config-devices-unassigned.ts b/src/panels/config/devices/ha-config-devices-unassigned.ts
new file mode 100644
index 0000000000..53db616dfc
--- /dev/null
+++ b/src/panels/config/devices/ha-config-devices-unassigned.ts
@@ -0,0 +1,365 @@
+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 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()
+ @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 = {};
+ 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 = {};
+ for (const entry of entries) {
+ entryLookup[entry.entry_id] = entry;
+ }
+
+ // Filter to only unassigned and enabled devices
+ const unassignedDevices = Object.values(devices).filter(
+ (device) => device.area_id === null && device.disabled_by === null
+ );
+
+ 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[number];
+
+ const columns: DataTableColumnContainer = {
+ icon: {
+ title: "",
+ label: localize("ui.panel.config.devices.data_table.icon"),
+ type: "icon",
+ moveable: false,
+ showNarrow: true,
+ template: (device) =>
+ device.domains.length
+ ? html`
`
+ : "",
+ },
+ 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`
+
+ `,
+ },
+ };
+
+ 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`
+
+
+ `;
+ }
+
+ private _handleRowClicked(ev: HASSDomEvent) {
+ 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;
+ }
+}
diff --git a/src/panels/config/devices/ha-config-devices.ts b/src/panels/config/devices/ha-config-devices.ts
index 9532d2ba48..119696ca74 100644
--- a/src/panels/config/devices/ha-config-devices.ts
+++ b/src/panels/config/devices/ha-config-devices.ts
@@ -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",
},
diff --git a/src/translations/en.json b/src/translations/en.json
index 736384f565..047524481f 100644
--- a/src/translations/en.json
+++ b/src/translations/en.json
@@ -5494,6 +5494,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",