From 1c15116052d4777b789d1a114eb3cb5d782e23c3 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 21 Apr 2025 08:09:58 -1000 Subject: [PATCH] Add initial DHCP discovery panel (#25086) --- src/data/dhcp.ts | 83 +++++++++++++ src/data/integration.ts | 1 + .../device-detail/ha-device-info-card.ts | 16 ++- src/panels/config/ha-panel-config.ts | 5 + .../dhcp/dhcp-config-panel.ts | 111 ++++++++++++++++++ src/panels/my/ha-panel-my.ts | 4 + src/translations/en.json | 6 + 7 files changed, 223 insertions(+), 3 deletions(-) create mode 100644 src/data/dhcp.ts create mode 100644 src/panels/config/integrations/integration-panels/dhcp/dhcp-config-panel.ts diff --git a/src/data/dhcp.ts b/src/data/dhcp.ts new file mode 100644 index 0000000000..124545235a --- /dev/null +++ b/src/data/dhcp.ts @@ -0,0 +1,83 @@ +import { + createCollection, + type Connection, + type UnsubscribeFunc, +} from "home-assistant-js-websocket"; +import type { Store } from "home-assistant-js-websocket/dist/store"; +import type { DataTableRowData } from "../components/data-table/ha-data-table"; + +export interface DHCPDiscoveryData extends DataTableRowData { + mac_address: string; + hostname: string; + ip_address: string; +} + +interface DHCPRemoveDiscoveryData { + mac_address: string; +} + +interface DHCPSubscriptionMessage { + add?: DHCPDiscoveryData[]; + change?: DHCPDiscoveryData[]; + remove?: DHCPRemoveDiscoveryData[]; +} + +const subscribeDHCPDiscoveryUpdates = ( + conn: Connection, + store: Store +): Promise => + conn.subscribeMessage( + (event) => { + const data = [...(store.state || [])]; + if (event.add) { + for (const deviceData of event.add) { + const index = data.findIndex( + (d) => d.mac_address === deviceData.mac_address + ); + if (index === -1) { + data.push(deviceData); + } else { + data[index] = deviceData; + } + } + } + if (event.change) { + for (const deviceData of event.change) { + const index = data.findIndex( + (d) => d.mac_address === deviceData.mac_address + ); + if (index !== -1) { + data[index] = deviceData; + } + } + } + if (event.remove) { + for (const deviceData of event.remove) { + const index = data.findIndex( + (d) => d.mac_address === deviceData.mac_address + ); + if (index !== -1) { + data.splice(index, 1); + } + } + } + + store.setState(data, true); + }, + { + type: `dhcp/subscribe_discovery`, + } + ); + +export const subscribeDHCPDiscovery = ( + conn: Connection, + callbackFunction: (dhcpDiscoveryData: DHCPDiscoveryData[]) => void +) => + createCollection( + "_dhcpDiscoveryRows", + () => Promise.resolve([]), // empty array as initial state + + subscribeDHCPDiscoveryUpdates, + conn, + callbackFunction + ); diff --git a/src/data/integration.ts b/src/data/integration.ts index 75cb334060..38370183d6 100644 --- a/src/data/integration.ts +++ b/src/data/integration.ts @@ -6,6 +6,7 @@ import { debounce } from "../common/util/debounce"; export const integrationsWithPanel = { bluetooth: "config/bluetooth", + dhcp: "config/dhcp", matter: "config/matter", mqtt: "config/mqtt", thread: "config/thread", diff --git a/src/panels/config/devices/device-detail/ha-device-info-card.ts b/src/panels/config/devices/device-detail/ha-device-info-card.ts index 0602d6fa00..dba2bbfa66 100644 --- a/src/panels/config/devices/device-detail/ha-device-info-card.ts +++ b/src/panels/config/devices/device-detail/ha-device-info-card.ts @@ -8,6 +8,7 @@ import type { DeviceRegistryEntry } from "../../../../data/device_registry"; import { haStyle } from "../../../../resources/styles"; import type { HomeAssistant } from "../../../../types"; import { createSearchParam } from "../../../../common/url/search-params"; +import { isComponentLoaded } from "../../../../common/config/is_component_loaded"; @customElement("ha-device-info-card") export class HaDeviceCard extends LitElement { @@ -103,7 +104,8 @@ export class HaDeviceCard extends LitElement { ${this._getAddresses().map( ([type, value]) => html`
- ${type === "bluetooth" + ${type === "bluetooth" && + isComponentLoaded(this.hass, "bluetooth") ? html`${titleCase(type)} ${value.toUpperCase()}` - : html`${type === "mac" ? "MAC" : titleCase(type)}: - ${value.toUpperCase()}`} + : type === "mac" && isComponentLoaded(this.hass, "dhcp") + ? html`${titleCase(type)} + ${value.toUpperCase()}` + : html`${type === "mac" ? "MAC" : titleCase(type)}: + ${value.toUpperCase()}`}
` )} diff --git a/src/panels/config/ha-panel-config.ts b/src/panels/config/ha-panel-config.ts index b40b01d63c..586755bdac 100644 --- a/src/panels/config/ha-panel-config.ts +++ b/src/panels/config/ha-panel-config.ts @@ -555,6 +555,11 @@ class HaPanelConfig extends SubscribeMixin(HassRouterPage) { "./integrations/integration-panels/bluetooth/bluetooth-config-dashboard-router" ), }, + dhcp: { + tag: "dhcp-config-panel", + load: () => + import("./integrations/integration-panels/dhcp/dhcp-config-panel"), + }, application_credentials: { tag: "ha-config-application-credentials", load: () => diff --git a/src/panels/config/integrations/integration-panels/dhcp/dhcp-config-panel.ts b/src/panels/config/integrations/integration-panels/dhcp/dhcp-config-panel.ts new file mode 100644 index 0000000000..ea4c24a2aa --- /dev/null +++ b/src/panels/config/integrations/integration-panels/dhcp/dhcp-config-panel.ts @@ -0,0 +1,111 @@ +import type { CSSResultGroup, TemplateResult, PropertyValues } from "lit"; +import { html, LitElement } from "lit"; +import { customElement, property, state } from "lit/decorators"; +import memoizeOne from "memoize-one"; +import type { UnsubscribeFunc } from "home-assistant-js-websocket"; +import type { LocalizeFunc } from "../../../../../common/translations/localize"; +import type { DataTableColumnContainer } from "../../../../../components/data-table/ha-data-table"; +import { extractSearchParamsObject } from "../../../../../common/url/search-params"; +import "../../../../../components/ha-fab"; +import "../../../../../components/ha-icon-button"; +import "../../../../../layouts/hass-tabs-subpage-data-table"; +import { haStyle } from "../../../../../resources/styles"; +import type { HomeAssistant, Route } from "../../../../../types"; +import type { DHCPDiscoveryData } from "../../../../../data/dhcp"; +import { SubscribeMixin } from "../../../../../mixins/subscribe-mixin"; + +import { subscribeDHCPDiscovery } from "../../../../../data/dhcp"; + +@customElement("dhcp-config-panel") +export class DHCPConfigPanel extends SubscribeMixin(LitElement) { + @property({ attribute: false }) public hass!: HomeAssistant; + + @property({ attribute: false }) public route!: Route; + + @state() private _macAddress?: string; + + @property({ type: Boolean }) public narrow = false; + + @property({ attribute: "is-wide", type: Boolean }) public isWide = false; + + @state() private _data: DHCPDiscoveryData[] = []; + + public hassSubscribe(): UnsubscribeFunc[] { + return [ + subscribeDHCPDiscovery(this.hass.connection, (data) => { + this._data = data; + }), + ]; + } + + private _columns = memoizeOne( + (localize: LocalizeFunc): DataTableColumnContainer => { + const columns: DataTableColumnContainer = { + mac_address: { + title: localize("ui.panel.config.dhcp.mac_address"), + sortable: true, + filterable: true, + showNarrow: true, + main: true, + hideable: false, + moveable: false, + direction: "asc", + }, + hostname: { + title: localize("ui.panel.config.dhcp.hostname"), + filterable: true, + sortable: true, + }, + ip_address: { + title: localize("ui.panel.config.dhcp.ip_address"), + filterable: true, + sortable: true, + }, + }; + + return columns; + } + ); + + private _dataWithIds = memoizeOne((data) => + data.map((row) => ({ + ...row, + id: row.mac_address, + })) + ); + + protected willUpdate(changedProps: PropertyValues) { + super.willUpdate(changedProps); + + if (this.hasUpdated) { + return; + } + + const searchParams = extractSearchParamsObject(); + const macAddress = searchParams.mac_address; + if (macAddress) { + this._macAddress = macAddress.toUpperCase(); + } + } + + protected render(): TemplateResult { + return html` + + `; + } + + static styles: CSSResultGroup = haStyle; +} + +declare global { + interface HTMLElementTagNameMap { + "dhcp-config-panel": DHCPConfigPanel; + } +} diff --git a/src/panels/my/ha-panel-my.ts b/src/panels/my/ha-panel-my.ts index f8480455e4..8dbdd28859 100644 --- a/src/panels/my/ha-panel-my.ts +++ b/src/panels/my/ha-panel-my.ts @@ -110,6 +110,10 @@ export const getMyRedirects = (): Redirects => ({ component: "bluetooth", redirect: "/config/bluetooth", }, + config_dhcp: { + component: "dhcp", + redirect: "/config/dhcp", + }, config_energy: { component: "energy", redirect: "/config/energy/dashboard", diff --git a/src/translations/en.json b/src/translations/en.json index 8ffd5093eb..140929162b 100644 --- a/src/translations/en.json +++ b/src/translations/en.json @@ -5482,6 +5482,12 @@ "service_uuids": "Service UUIDs", "copy_to_clipboard": "[%key:ui::panel::config::automation::editor::copy_to_clipboard%]" }, + "dhcp": { + "title": "DHCP discovery", + "mac_address": "MAC Address", + "hostname": "Hostname", + "ip_address": "IP Address" + }, "thread": { "other_networks": "Other networks", "my_network": "Preferred network",