From bc582db7fce452272e111902f570738d40a39f01 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 29 Apr 2025 21:46:56 +0200 Subject: [PATCH] Add initial SSDP discovery panel (#25217) * Add initial SSDP discovery panel --- src/data/integration.ts | 1 + src/data/ssdp.ts | 98 +++++++++++++ src/panels/config/ha-panel-config.ts | 5 + .../ssdp/dialog-ssdp-discovery-info.ts | 103 ++++++++++++++ .../ssdp/show-dialog-ssdp-discovery-info.ts | 20 +++ .../ssdp/ssdp-config-panel.ts | 133 ++++++++++++++++++ .../network/ha-config-network-discovery.ts | 73 ---------- .../config/network/ha-config-network-ssdp.ts | 66 +++++++++ .../network/ha-config-network-zeroconf.ts | 70 +++++++++ .../network/ha-config-section-network.ts | 17 ++- src/panels/my/ha-panel-my.ts | 4 + src/translations/en.json | 11 ++ 12 files changed, 523 insertions(+), 78 deletions(-) create mode 100644 src/data/ssdp.ts create mode 100644 src/panels/config/integrations/integration-panels/ssdp/dialog-ssdp-discovery-info.ts create mode 100644 src/panels/config/integrations/integration-panels/ssdp/show-dialog-ssdp-discovery-info.ts create mode 100644 src/panels/config/integrations/integration-panels/ssdp/ssdp-config-panel.ts delete mode 100644 src/panels/config/network/ha-config-network-discovery.ts create mode 100644 src/panels/config/network/ha-config-network-ssdp.ts create mode 100644 src/panels/config/network/ha-config-network-zeroconf.ts diff --git a/src/data/integration.ts b/src/data/integration.ts index b2f071d843..cab4d464a7 100644 --- a/src/data/integration.ts +++ b/src/data/integration.ts @@ -9,6 +9,7 @@ export const integrationsWithPanel = { dhcp: "config/dhcp", matter: "config/matter", mqtt: "config/mqtt", + ssdp: "config/ssdp", thread: "config/thread", zeroconf: "config/zeroconf", zha: "config/zha/dashboard", diff --git a/src/data/ssdp.ts b/src/data/ssdp.ts new file mode 100644 index 0000000000..a256ed4330 --- /dev/null +++ b/src/data/ssdp.ts @@ -0,0 +1,98 @@ +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 SSDPDiscoveryData extends DataTableRowData { + ssdp_usn: string; + ssdp_st: string; + upnp: Record; + ssdp_location: string | undefined; + ssdp_nt: string | undefined; + ssdp_udn: string | undefined; + ssdp_ext: string | undefined; + ssdp_server: string | undefined; + ssdp_headers: Record; + ssdp_all_locations: string[]; + x_homeassistant_matching_domains: string[]; +} + +interface SSDPRemoveDiscoveryData { + ssdp_st: string; + ssdp_location: string | undefined; +} + +interface SSDPSubscriptionMessage { + add?: SSDPDiscoveryData[]; + change?: SSDPDiscoveryData[]; + remove?: SSDPRemoveDiscoveryData[]; +} + +const subscribeSSDPDiscoveryUpdates = ( + 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.ssdp_st === deviceData.ssdp_st && + d.ssdp_location === deviceData.ssdp_location + ); + 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.ssdp_st === deviceData.ssdp_st && + d.ssdp_location === deviceData.ssdp_location + ); + if (index !== -1) { + data[index] = deviceData; + } + } + } + if (event.remove) { + for (const deviceData of event.remove) { + const index = data.findIndex( + (d) => + d.ssdp_st === deviceData.ssdp_st && + d.ssdp_location === deviceData.ssdp_location + ); + if (index !== -1) { + data.splice(index, 1); + } + } + } + + store.setState(data, true); + }, + { + type: `ssdp/subscribe_discovery`, + } + ); + +export const subscribeSSDPDiscovery = ( + conn: Connection, + callbackFunction: (ssdpDiscoveryData: SSDPDiscoveryData[]) => void +) => + createCollection( + "_ssdpDiscoveryRows", + () => Promise.resolve([]), // empty array as initial state + + subscribeSSDPDiscoveryUpdates, + conn, + callbackFunction + ); diff --git a/src/panels/config/ha-panel-config.ts b/src/panels/config/ha-panel-config.ts index f7a7f6c9b3..f9e434965c 100644 --- a/src/panels/config/ha-panel-config.ts +++ b/src/panels/config/ha-panel-config.ts @@ -560,6 +560,11 @@ class HaPanelConfig extends SubscribeMixin(HassRouterPage) { load: () => import("./integrations/integration-panels/dhcp/dhcp-config-panel"), }, + ssdp: { + tag: "ssdp-config-panel", + load: () => + import("./integrations/integration-panels/ssdp/ssdp-config-panel"), + }, zeroconf: { tag: "zeroconf-config-panel", load: () => diff --git a/src/panels/config/integrations/integration-panels/ssdp/dialog-ssdp-discovery-info.ts b/src/panels/config/integrations/integration-panels/ssdp/dialog-ssdp-discovery-info.ts new file mode 100644 index 0000000000..313373cdac --- /dev/null +++ b/src/panels/config/integrations/integration-panels/ssdp/dialog-ssdp-discovery-info.ts @@ -0,0 +1,103 @@ +import type { TemplateResult } from "lit"; +import { html, LitElement, nothing } from "lit"; +import { customElement, property, state } from "lit/decorators"; +import { fireEvent } from "../../../../../common/dom/fire_event"; +import type { HassDialog } from "../../../../../dialogs/make-dialog-manager"; +import { createCloseHeading } from "../../../../../components/ha-dialog"; +import type { HomeAssistant } from "../../../../../types"; +import type { SSDPDiscoveryInfoDialogParams } from "./show-dialog-ssdp-discovery-info"; +import "../../../../../components/ha-button"; +import { showToast } from "../../../../../util/toast"; +import { copyToClipboard } from "../../../../../common/util/copy-clipboard"; + +@customElement("dialog-ssdp-device-info") +class DialogSSDPDiscoveryInfo extends LitElement implements HassDialog { + @property({ attribute: false }) public hass!: HomeAssistant; + + @state() private _params?: SSDPDiscoveryInfoDialogParams; + + public async showDialog( + params: SSDPDiscoveryInfoDialogParams + ): Promise { + this._params = params; + } + + public closeDialog(): boolean { + this._params = undefined; + fireEvent(this, "dialog-closed", { dialog: this.localName }); + return true; + } + + private async _copyToClipboard(): Promise { + if (!this._params) { + return; + } + + await copyToClipboard(JSON.stringify(this._params!.entry)); + showToast(this, { + message: this.hass.localize("ui.common.copied_clipboard"), + }); + } + + protected render(): TemplateResult | typeof nothing { + if (!this._params) { + return nothing; + } + + return html` + +

+ ${this.hass.localize("ui.panel.config.ssdp.ssdp_st")}: + ${this._params.entry.ssdp_st}
+ ${this.hass.localize("ui.panel.config.ssdp.ssdp_location")}: + ${this._params.entry.ssdp_location} +

+ +

${this.hass.localize("ui.panel.config.ssdp.ssdp_headers")}

+ + + ${Object.entries(this._params.entry.ssdp_headers).map( + ([key, value]) => html` + + + + + ` + )} + +
${key}${value}
+ +

${this.hass.localize("ui.panel.config.ssdp.upnp")}

+ + + ${Object.entries(this._params.entry.upnp).map( + ([key, value]) => html` + + + + + ` + )} + +
${key}${value}
+ + + ${this.hass.localize("ui.panel.config.ssdp.copy_to_clipboard")} + +
+ `; + } +} + +declare global { + interface HTMLElementTagNameMap { + "dialog-ssdp-device-info": DialogSSDPDiscoveryInfo; + } +} diff --git a/src/panels/config/integrations/integration-panels/ssdp/show-dialog-ssdp-discovery-info.ts b/src/panels/config/integrations/integration-panels/ssdp/show-dialog-ssdp-discovery-info.ts new file mode 100644 index 0000000000..ec0774650f --- /dev/null +++ b/src/panels/config/integrations/integration-panels/ssdp/show-dialog-ssdp-discovery-info.ts @@ -0,0 +1,20 @@ +import { fireEvent } from "../../../../../common/dom/fire_event"; +import type { SSDPDiscoveryData } from "../../../../../data/ssdp"; + +export interface SSDPDiscoveryInfoDialogParams { + entry: SSDPDiscoveryData; +} + +export const loadSSDPDiscoveryInfoDialog = () => + import("./dialog-ssdp-discovery-info"); + +export const showSSDPDiscoveryInfoDialog = ( + element: HTMLElement, + ssdpDiscoveryInfoDialogParams: SSDPDiscoveryInfoDialogParams +): void => { + fireEvent(element, "show-dialog", { + dialogTag: "dialog-ssdp-device-info", + dialogImport: loadSSDPDiscoveryInfoDialog, + dialogParams: ssdpDiscoveryInfoDialogParams, + }); +}; diff --git a/src/panels/config/integrations/integration-panels/ssdp/ssdp-config-panel.ts b/src/panels/config/integrations/integration-panels/ssdp/ssdp-config-panel.ts new file mode 100644 index 0000000000..f9703cac90 --- /dev/null +++ b/src/panels/config/integrations/integration-panels/ssdp/ssdp-config-panel.ts @@ -0,0 +1,133 @@ +import type { CSSResultGroup, TemplateResult } 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 { + RowClickedEvent, + DataTableColumnContainer, +} from "../../../../../components/data-table/ha-data-table"; +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 { SSDPDiscoveryData } from "../../../../../data/ssdp"; +import { SubscribeMixin } from "../../../../../mixins/subscribe-mixin"; +import { storage } from "../../../../../common/decorators/storage"; +import type { HASSDomEvent } from "../../../../../common/dom/fire_event"; +import { subscribeSSDPDiscovery } from "../../../../../data/ssdp"; +import { showSSDPDiscoveryInfoDialog } from "./show-dialog-ssdp-discovery-info"; + +@customElement("ssdp-config-panel") +export class SSDPConfigPanel extends SubscribeMixin(LitElement) { + @property({ attribute: false }) public hass!: HomeAssistant; + + @property({ attribute: false }) public route!: Route; + + @property({ type: Boolean }) public narrow = false; + + @property({ attribute: "is-wide", type: Boolean }) public isWide = false; + + @state() private _data: SSDPDiscoveryData[] = []; + + @storage({ + key: "ssdp-discovery-table-grouping", + state: false, + subscribe: false, + }) + private _activeGrouping?: string = "ssdp_location"; + + @storage({ + key: "ssdp-discovery-table-collapsed", + state: false, + subscribe: false, + }) + private _activeCollapsed: string[] = []; + + public hassSubscribe(): UnsubscribeFunc[] { + return [ + subscribeSSDPDiscovery(this.hass.connection, (data) => { + this._data = data; + }), + ]; + } + + private _columns = memoizeOne( + (localize: LocalizeFunc): DataTableColumnContainer => { + const columns: DataTableColumnContainer = { + ssdp_st: { + title: localize("ui.panel.config.ssdp.ssdp_st"), + sortable: true, + filterable: true, + showNarrow: true, + main: true, + hideable: false, + moveable: false, + direction: "asc", + }, + ssdp_location: { + title: localize("ui.panel.config.ssdp.ssdp_location"), + filterable: true, + sortable: true, + groupable: true, + }, + }; + + return columns; + } + ); + + private _dataWithIds = memoizeOne((data) => + data.map((row) => ({ + ...row, + id: [row.ssdp_st, row.ssdp_location].filter(Boolean).join("|"), + })) + ); + + protected render(): TemplateResult { + return html` + + `; + } + + private _handleRowClicked(ev: HASSDomEvent) { + const entry = this._data.find( + (ent) => + [ent.ssdp_st, ent.ssdp_location].filter(Boolean).join("|") === + ev.detail.id + ); + showSSDPDiscoveryInfoDialog(this, { + entry: entry!, + }); + } + + private _handleGroupingChanged(ev: CustomEvent) { + this._activeGrouping = ev.detail.value; + } + + private _handleCollapseChanged(ev: CustomEvent) { + this._activeCollapsed = ev.detail.value; + } + + static styles: CSSResultGroup = haStyle; +} + +declare global { + interface HTMLElementTagNameMap { + "ssdp-config-panel": SSDPConfigPanel; + } +} diff --git a/src/panels/config/network/ha-config-network-discovery.ts b/src/panels/config/network/ha-config-network-discovery.ts deleted file mode 100644 index 4639e466b2..0000000000 --- a/src/panels/config/network/ha-config-network-discovery.ts +++ /dev/null @@ -1,73 +0,0 @@ -import "@material/mwc-button/mwc-button"; -import type { CSSResultGroup } from "lit"; -import { css, html, LitElement } from "lit"; -import { customElement, property } from "lit/decorators"; -import { isComponentLoaded } from "../../../common/config/is_component_loaded"; -import "../../../components/ha-button"; -import "../../../components/ha-card"; -import { haStyle } from "../../../resources/styles"; -import type { HomeAssistant } from "../../../types"; - -@customElement("ha-config-network-discovery") -class ConfigNetworkDiscovery extends LitElement { - @property({ attribute: false }) public hass!: HomeAssistant; - - protected render() { - return isComponentLoaded(this.hass, "zeroconf") - ? html` - -
-

- ${this.hass.localize( - "ui.panel.config.network.discovery.zeroconf_info" - )} -

-
- -
- ` - : ""; - } - - static get styles(): CSSResultGroup { - return [ - haStyle, - css` - ha-settings-row { - padding: 0; - } - - .card-actions { - display: flex; - flex-direction: row-reverse; - justify-content: space-between; - align-items: center; - } - `, // row-reverse so we tab first to "save" - ]; - } -} - -declare global { - interface HTMLElementTagNameMap { - "ha-config-network-discovery": ConfigNetworkDiscovery; - } -} diff --git a/src/panels/config/network/ha-config-network-ssdp.ts b/src/panels/config/network/ha-config-network-ssdp.ts new file mode 100644 index 0000000000..bd7bf83568 --- /dev/null +++ b/src/panels/config/network/ha-config-network-ssdp.ts @@ -0,0 +1,66 @@ +import "@material/mwc-button/mwc-button"; +import type { CSSResultGroup } from "lit"; +import { css, html, LitElement } from "lit"; +import { customElement, property } from "lit/decorators"; +import "../../../components/ha-button"; +import "../../../components/ha-card"; +import { haStyle } from "../../../resources/styles"; +import type { HomeAssistant } from "../../../types"; + +@customElement("ha-config-network-ssdp") +class ConfigNetworkSSDP extends LitElement { + @property({ attribute: false }) public hass!: HomeAssistant; + + protected render() { + return html` + +
+

+ ${this.hass.localize("ui.panel.config.network.discovery.ssdp_info")} +

+
+ +
+ `; + } + + static get styles(): CSSResultGroup { + return [ + haStyle, + css` + ha-settings-row { + padding: 0; + } + + .card-actions { + display: flex; + flex-direction: row-reverse; + justify-content: space-between; + align-items: center; + } + `, // row-reverse so we tab first to "save" + ]; + } +} + +declare global { + interface HTMLElementTagNameMap { + "ha-config-network-ssdp": ConfigNetworkSSDP; + } +} diff --git a/src/panels/config/network/ha-config-network-zeroconf.ts b/src/panels/config/network/ha-config-network-zeroconf.ts new file mode 100644 index 0000000000..db97f0d019 --- /dev/null +++ b/src/panels/config/network/ha-config-network-zeroconf.ts @@ -0,0 +1,70 @@ +import "@material/mwc-button/mwc-button"; +import type { CSSResultGroup } from "lit"; +import { css, html, LitElement } from "lit"; +import { customElement, property } from "lit/decorators"; +import "../../../components/ha-button"; +import "../../../components/ha-card"; +import { haStyle } from "../../../resources/styles"; +import type { HomeAssistant } from "../../../types"; + +@customElement("ha-config-network-zeroconf") +class ConfigNetworkZeroconf extends LitElement { + @property({ attribute: false }) public hass!: HomeAssistant; + + protected render() { + return html` + +
+

+ ${this.hass.localize( + "ui.panel.config.network.discovery.zeroconf_info" + )} +

+
+ +
+ `; + } + + static get styles(): CSSResultGroup { + return [ + haStyle, + css` + ha-settings-row { + padding: 0; + } + + .card-actions { + display: flex; + flex-direction: row-reverse; + justify-content: space-between; + align-items: center; + } + `, // row-reverse so we tab first to "save" + ]; + } +} + +declare global { + interface HTMLElementTagNameMap { + "ha-config-network-zeroconf": ConfigNetworkZeroconf; + } +} diff --git a/src/panels/config/network/ha-config-section-network.ts b/src/panels/config/network/ha-config-section-network.ts index 94a6680646..4b5f6a2808 100644 --- a/src/panels/config/network/ha-config-section-network.ts +++ b/src/panels/config/network/ha-config-section-network.ts @@ -5,7 +5,8 @@ import { isComponentLoaded } from "../../../common/config/is_component_loaded"; import "../../../layouts/hass-subpage"; import type { HomeAssistant, Route } from "../../../types"; import "./ha-config-network"; -import "./ha-config-network-discovery"; +import "./ha-config-network-ssdp"; +import "./ha-config-network-zeroconf"; import "./ha-config-url-form"; import "./supervisor-hostname"; import "./supervisor-network"; @@ -36,10 +37,15 @@ class HaConfigSectionNetwork extends LitElement { : ""} - ${isComponentLoaded(this.hass, "zeroconf") - ? html`` + >` + : ""} + ${isComponentLoaded(this.hass, "zeroconf") + ? html`` : ""} @@ -56,7 +62,8 @@ class HaConfigSectionNetwork extends LitElement { supervisor-network, ha-config-url-form, ha-config-network, - ha-config-network-discovery { + ha-config-network-ssdp, + ha-config-network-zeroconf { display: block; margin: 0 auto; margin-bottom: 24px; diff --git a/src/panels/my/ha-panel-my.ts b/src/panels/my/ha-panel-my.ts index c06c94b10a..110e540fbb 100644 --- a/src/panels/my/ha-panel-my.ts +++ b/src/panels/my/ha-panel-my.ts @@ -118,6 +118,10 @@ export const getMyRedirects = (): Redirects => ({ component: "energy", redirect: "/config/energy/dashboard", }, + config_ssdp: { + component: "ssdp", + redirect: "/config/ssdp", + }, config_zeroconf: { component: "zeroconf", redirect: "/config/zeroconf", diff --git a/src/translations/en.json b/src/translations/en.json index 21b19c2a4c..98070c9fd4 100644 --- a/src/translations/en.json +++ b/src/translations/en.json @@ -5567,6 +5567,14 @@ "thread_network_info": "Thread network information", "thread_network_delete_credentials": "Delete Thread network credentials" }, + "ssdp": { + "ssdp_st": "Search Target (ST)", + "ssdp_location": "Device Description URL", + "ssdp_headers": "SSDP Headers", + "upnp": "Universal Plug and Play (UPnP)", + "discovery_information": "Discovery information", + "copy_to_clipboard": "Copy to clipboard" + }, "zeroconf": { "name": "Name", "type": "Type", @@ -6382,6 +6390,9 @@ } }, "discovery": { + "ssdp": "SSDP browser", + "ssdp_info": "The SSDP browser shows devices discovered by Home Assistant using SSDP/UPnP. Devices that Home Assistant has discovered will appear here.", + "ssdp_browser": "View SSDP browser", "zeroconf": "Zeroconf browser", "zeroconf_info": "The Zeroconf browser shows devices discovered by Home Assistant using mDNS. Only devices that Home Assistant is actively searching for will appear here.", "zeroconf_browser": "View Zeroconf browser"