Add initial SSDP discovery panel (#25217)

* Add initial SSDP discovery panel
This commit is contained in:
J. Nick Koston 2025-04-29 21:46:56 +02:00 committed by GitHub
parent ab415188ba
commit bc582db7fc
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 523 additions and 78 deletions

View File

@ -9,6 +9,7 @@ export const integrationsWithPanel = {
dhcp: "config/dhcp", dhcp: "config/dhcp",
matter: "config/matter", matter: "config/matter",
mqtt: "config/mqtt", mqtt: "config/mqtt",
ssdp: "config/ssdp",
thread: "config/thread", thread: "config/thread",
zeroconf: "config/zeroconf", zeroconf: "config/zeroconf",
zha: "config/zha/dashboard", zha: "config/zha/dashboard",

98
src/data/ssdp.ts Normal file
View File

@ -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<string, unknown>;
ssdp_location: string | undefined;
ssdp_nt: string | undefined;
ssdp_udn: string | undefined;
ssdp_ext: string | undefined;
ssdp_server: string | undefined;
ssdp_headers: Record<string, unknown>;
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<SSDPDiscoveryData[]>
): Promise<UnsubscribeFunc> =>
conn.subscribeMessage<SSDPSubscriptionMessage>(
(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<SSDPDiscoveryData[]>(
"_ssdpDiscoveryRows",
() => Promise.resolve<SSDPDiscoveryData[]>([]), // empty array as initial state
subscribeSSDPDiscoveryUpdates,
conn,
callbackFunction
);

View File

@ -560,6 +560,11 @@ class HaPanelConfig extends SubscribeMixin(HassRouterPage) {
load: () => load: () =>
import("./integrations/integration-panels/dhcp/dhcp-config-panel"), import("./integrations/integration-panels/dhcp/dhcp-config-panel"),
}, },
ssdp: {
tag: "ssdp-config-panel",
load: () =>
import("./integrations/integration-panels/ssdp/ssdp-config-panel"),
},
zeroconf: { zeroconf: {
tag: "zeroconf-config-panel", tag: "zeroconf-config-panel",
load: () => load: () =>

View File

@ -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<void> {
this._params = params;
}
public closeDialog(): boolean {
this._params = undefined;
fireEvent(this, "dialog-closed", { dialog: this.localName });
return true;
}
private async _copyToClipboard(): Promise<void> {
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`
<ha-dialog
open
@closed=${this.closeDialog}
.heading=${createCloseHeading(
this.hass,
this.hass.localize("ui.panel.config.ssdp.discovery_information")
)}
>
<p>
<b>${this.hass.localize("ui.panel.config.ssdp.ssdp_st")}</b>:
${this._params.entry.ssdp_st} <br />
<b>${this.hass.localize("ui.panel.config.ssdp.ssdp_location")}</b>:
${this._params.entry.ssdp_location}
</p>
<h4>${this.hass.localize("ui.panel.config.ssdp.ssdp_headers")}</h4>
<table width="100%">
<tbody>
${Object.entries(this._params.entry.ssdp_headers).map(
([key, value]) => html`
<tr>
<td><b>${key}</b></td>
<td>${value}</td>
</tr>
`
)}
</tbody>
</table>
<h4>${this.hass.localize("ui.panel.config.ssdp.upnp")}</h4>
<table width="100%">
<tbody>
${Object.entries(this._params.entry.upnp).map(
([key, value]) => html`
<tr>
<td><b>${key}</b></td>
<td>${value}</td>
</tr>
`
)}
</tbody>
</table>
<ha-button slot="secondaryAction" @click=${this._copyToClipboard}>
${this.hass.localize("ui.panel.config.ssdp.copy_to_clipboard")}
</ha-button>
</ha-dialog>
`;
}
}
declare global {
interface HTMLElementTagNameMap {
"dialog-ssdp-device-info": DialogSSDPDiscoveryInfo;
}
}

View File

@ -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,
});
};

View File

@ -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<SSDPDiscoveryData> = {
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`
<hass-tabs-subpage-data-table
.hass=${this.hass}
.narrow=${this.narrow}
.route=${this.route}
.columns=${this._columns(this.hass.localize)}
.initialGroupColumn=${this._activeGrouping}
.initialCollapsedGroups=${this._activeCollapsed}
@grouping-changed=${this._handleGroupingChanged}
@collapsed-changed=${this._handleCollapseChanged}
.data=${this._dataWithIds(this._data)}
@row-click=${this._handleRowClicked}
clickable
></hass-tabs-subpage-data-table>
`;
}
private _handleRowClicked(ev: HASSDomEvent<RowClickedEvent>) {
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;
}
}

View File

@ -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`
<ha-card
outlined
header=${this.hass.localize(
"ui.panel.config.network.discovery.zeroconf"
)}
>
<div class="card-content">
<p>
${this.hass.localize(
"ui.panel.config.network.discovery.zeroconf_info"
)}
</p>
</div>
<div class="card-actions">
<a
href="/config/zeroconf"
aria-label=${this.hass.localize(
"ui.panel.config.network.discovery.zeroconf_browser"
)}
>
<ha-button>
${this.hass.localize(
"ui.panel.config.network.discovery.zeroconf_browser"
)}
</ha-button>
</a>
</div>
</ha-card>
`
: "";
}
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;
}
}

View File

@ -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`
<ha-card
outlined
header=${this.hass.localize("ui.panel.config.network.discovery.ssdp")}
>
<div class="card-content">
<p>
${this.hass.localize("ui.panel.config.network.discovery.ssdp_info")}
</p>
</div>
<div class="card-actions">
<a
href="/config/ssdp"
aria-label=${this.hass.localize(
"ui.panel.config.network.discovery.ssdp_browser"
)}
>
<ha-button>
${this.hass.localize(
"ui.panel.config.network.discovery.ssdp_browser"
)}
</ha-button>
</a>
</div>
</ha-card>
`;
}
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;
}
}

View File

@ -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`
<ha-card
outlined
header=${this.hass.localize(
"ui.panel.config.network.discovery.zeroconf"
)}
>
<div class="card-content">
<p>
${this.hass.localize(
"ui.panel.config.network.discovery.zeroconf_info"
)}
</p>
</div>
<div class="card-actions">
<a
href="/config/zeroconf"
aria-label=${this.hass.localize(
"ui.panel.config.network.discovery.zeroconf_browser"
)}
>
<ha-button>
${this.hass.localize(
"ui.panel.config.network.discovery.zeroconf_browser"
)}
</ha-button>
</a>
</div>
</ha-card>
`;
}
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;
}
}

View File

@ -5,7 +5,8 @@ import { isComponentLoaded } from "../../../common/config/is_component_loaded";
import "../../../layouts/hass-subpage"; import "../../../layouts/hass-subpage";
import type { HomeAssistant, Route } from "../../../types"; import type { HomeAssistant, Route } from "../../../types";
import "./ha-config-network"; 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 "./ha-config-url-form";
import "./supervisor-hostname"; import "./supervisor-hostname";
import "./supervisor-network"; import "./supervisor-network";
@ -36,10 +37,15 @@ class HaConfigSectionNetwork extends LitElement {
: ""} : ""}
<ha-config-url-form .hass=${this.hass}></ha-config-url-form> <ha-config-url-form .hass=${this.hass}></ha-config-url-form>
<ha-config-network .hass=${this.hass}></ha-config-network> <ha-config-network .hass=${this.hass}></ha-config-network>
${isComponentLoaded(this.hass, "zeroconf") ${isComponentLoaded(this.hass, "ssdp")
? html`<ha-config-network-discovery ? html`<ha-config-network-ssdp
.hass=${this.hass} .hass=${this.hass}
></ha-config-network-discovery>` ></ha-config-network-ssdp>`
: ""}
${isComponentLoaded(this.hass, "zeroconf")
? html`<ha-config-network-zeroconf
.hass=${this.hass}
></ha-config-network-zeroconf>`
: ""} : ""}
</div> </div>
</hass-subpage> </hass-subpage>
@ -56,7 +62,8 @@ class HaConfigSectionNetwork extends LitElement {
supervisor-network, supervisor-network,
ha-config-url-form, ha-config-url-form,
ha-config-network, ha-config-network,
ha-config-network-discovery { ha-config-network-ssdp,
ha-config-network-zeroconf {
display: block; display: block;
margin: 0 auto; margin: 0 auto;
margin-bottom: 24px; margin-bottom: 24px;

View File

@ -118,6 +118,10 @@ export const getMyRedirects = (): Redirects => ({
component: "energy", component: "energy",
redirect: "/config/energy/dashboard", redirect: "/config/energy/dashboard",
}, },
config_ssdp: {
component: "ssdp",
redirect: "/config/ssdp",
},
config_zeroconf: { config_zeroconf: {
component: "zeroconf", component: "zeroconf",
redirect: "/config/zeroconf", redirect: "/config/zeroconf",

View File

@ -5567,6 +5567,14 @@
"thread_network_info": "Thread network information", "thread_network_info": "Thread network information",
"thread_network_delete_credentials": "Delete Thread network credentials" "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": { "zeroconf": {
"name": "Name", "name": "Name",
"type": "Type", "type": "Type",
@ -6382,6 +6390,9 @@
} }
}, },
"discovery": { "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": "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_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" "zeroconf_browser": "View Zeroconf browser"