Add initial zeroconf panel (#25151)

* Add initial zeroconf panel
This commit is contained in:
J. Nick Koston 2025-04-26 00:14:47 -10:00 committed by GitHub
parent d75ea3bb8d
commit ee495a432f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 461 additions and 1 deletions

View File

@ -10,6 +10,7 @@ export const integrationsWithPanel = {
matter: "config/matter",
mqtt: "config/mqtt",
thread: "config/thread",
zeroconf: "config/zeroconf",
zha: "config/zha/dashboard",
zwave_js: "config/zwave_js/dashboard",
};

79
src/data/zeroconf.ts Normal file
View File

@ -0,0 +1,79 @@
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 ZeroconfDiscoveryData extends DataTableRowData {
name: string;
type: string;
port: number;
properties: Record<string, unknown>;
ip_addresses: string[];
}
interface ZeroconfRemoveDiscoveryData {
name: string;
}
interface ZeroconfSubscriptionMessage {
add?: ZeroconfDiscoveryData[];
change?: ZeroconfDiscoveryData[];
remove?: ZeroconfRemoveDiscoveryData[];
}
const subscribeZeroconfDiscoveryUpdates = (
conn: Connection,
store: Store<ZeroconfDiscoveryData[]>
): Promise<UnsubscribeFunc> =>
conn.subscribeMessage<ZeroconfSubscriptionMessage>(
(event) => {
const data = [...(store.state || [])];
if (event.add) {
for (const deviceData of event.add) {
const index = data.findIndex((d) => d.name === deviceData.name);
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.name === deviceData.name);
if (index !== -1) {
data[index] = deviceData;
}
}
}
if (event.remove) {
for (const deviceData of event.remove) {
const index = data.findIndex((d) => d.name === deviceData.name);
if (index !== -1) {
data.splice(index, 1);
}
}
}
store.setState(data, true);
},
{
type: `zeroconf/subscribe_discovery`,
}
);
export const subscribeZeroconfDiscovery = (
conn: Connection,
callbackFunction: (zeroconfDiscoveryData: ZeroconfDiscoveryData[]) => void
) =>
createCollection<ZeroconfDiscoveryData[]>(
"_zeroconfDiscoveryRows",
() => Promise.resolve<ZeroconfDiscoveryData[]>([]), // empty array as initial state
subscribeZeroconfDiscoveryUpdates,
conn,
callbackFunction
);

View File

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

View File

@ -0,0 +1,112 @@
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 { ZeroconfDiscoveryInfoDialogParams } from "./show-dialog-zeroconf-discovery-info";
import "../../../../../components/ha-button";
import { showToast } from "../../../../../util/toast";
import { copyToClipboard } from "../../../../../common/util/copy-clipboard";
@customElement("dialog-zeroconf-device-info")
class DialogZeroconfDiscoveryInfo extends LitElement implements HassDialog {
@property({ attribute: false }) public hass!: HomeAssistant;
@state() private _params?: ZeroconfDiscoveryInfoDialogParams;
public async showDialog(
params: ZeroconfDiscoveryInfoDialogParams
): 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.zeroconf.discovery_information")
)}
>
<p>
<b>${this.hass.localize("ui.panel.config.zeroconf.name")}</b>:
${this._params.entry.name.slice(
0,
-this._params.entry.type.length - 1
)}
<br />
<b>${this.hass.localize("ui.panel.config.zeroconf.type")}</b>:
${this._params.entry.type}
<br />
<b>${this.hass.localize("ui.panel.config.zeroconf.port")}</b>:
${this._params.entry.port}
<br />
</p>
<h4>${this.hass.localize("ui.panel.config.zeroconf.ip_addresses")}</h4>
<table width="100%">
<tbody>
${this._params.entry.ip_addresses.map(
(ipAddress) => html`
<tr>
<td>${ipAddress}</td>
</tr>
`
)}
</tbody>
</table>
<h4>${this.hass.localize("ui.panel.config.zeroconf.properties")}</h4>
<table width="100%">
<tbody>
${Object.entries(this._params.entry.properties).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.zeroconf.copy_to_clipboard"
)}</ha-button
>
</ha-dialog>
`;
}
}
declare global {
interface HTMLElementTagNameMap {
"dialog-zeroconf-device-info": DialogZeroconfDiscoveryInfo;
}
}

View File

@ -0,0 +1,20 @@
import { fireEvent } from "../../../../../common/dom/fire_event";
import type { ZeroconfDiscoveryData } from "../../../../../data/zeroconf";
export interface ZeroconfDiscoveryInfoDialogParams {
entry: ZeroconfDiscoveryData;
}
export const loadZeroconfDiscoveryInfoDialog = () =>
import("./dialog-zeroconf-discovery-info");
export const showZeroconfDiscoveryInfoDialog = (
element: HTMLElement,
zeroconfDiscoveryInfoDialogParams: ZeroconfDiscoveryInfoDialogParams
): void => {
fireEvent(element, "show-dialog", {
dialogTag: "dialog-zeroconf-device-info",
dialogImport: loadZeroconfDiscoveryInfoDialog,
dialogParams: zeroconfDiscoveryInfoDialogParams,
});
};

View File

@ -0,0 +1,143 @@
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 { ZeroconfDiscoveryData } from "../../../../../data/zeroconf";
import { SubscribeMixin } from "../../../../../mixins/subscribe-mixin";
import { storage } from "../../../../../common/decorators/storage";
import type { HASSDomEvent } from "../../../../../common/dom/fire_event";
import { subscribeZeroconfDiscovery } from "../../../../../data/zeroconf";
import { showZeroconfDiscoveryInfoDialog } from "./show-dialog-zeroconf-discovery-info";
@customElement("zeroconf-config-panel")
export class ZeroconfConfigPanel 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: ZeroconfDiscoveryData[] = [];
@storage({
key: "zeroconf-discovery-table-grouping",
state: false,
subscribe: false,
})
private _activeGrouping?: string = "type";
@storage({
key: "zeroconf-discovery-table-collapsed",
state: false,
subscribe: false,
})
private _activeCollapsed: string[] = [];
public hassSubscribe(): UnsubscribeFunc[] {
return [
subscribeZeroconfDiscovery(this.hass.connection, (data) => {
this._data = data;
}),
];
}
private _columns = memoizeOne(
(localize: LocalizeFunc): DataTableColumnContainer => {
const columns: DataTableColumnContainer<ZeroconfDiscoveryData> = {
name: {
title: localize("ui.panel.config.zeroconf.name"),
sortable: true,
filterable: true,
showNarrow: true,
main: true,
hideable: false,
moveable: false,
direction: "asc",
template: (data) =>
html`${data.name.slice(0, -data.type.length - 1)}`,
},
type: {
title: localize("ui.panel.config.zeroconf.type"),
filterable: true,
sortable: true,
groupable: true,
},
ip_addresses: {
title: localize("ui.panel.config.zeroconf.ip_addresses"),
showNarrow: true,
filterable: true,
sortable: false,
template: (data) => html`${data.ip_addresses.join(", ")}`,
},
port: {
title: localize("ui.panel.config.zeroconf.port"),
filterable: true,
sortable: true,
},
};
return columns;
}
);
private _dataWithIds = memoizeOne((data) =>
data.map((row) => ({
...row,
id: row.name,
}))
);
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.name === ev.detail.id);
showZeroconfDiscoveryInfoDialog(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 {
"zeroconf-config-panel": ZeroconfConfigPanel;
}
}

View File

@ -0,0 +1,73 @@
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

@ -5,6 +5,7 @@ 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-url-form";
import "./supervisor-hostname";
import "./supervisor-network";
@ -35,6 +36,11 @@ class HaConfigSectionNetwork extends LitElement {
: ""}
<ha-config-url-form .hass=${this.hass}></ha-config-url-form>
<ha-config-network .hass=${this.hass}></ha-config-network>
${isComponentLoaded(this.hass, "zeroconf")
? html`<ha-config-network-discovery
.hass=${this.hass}
></ha-config-network-discovery>`
: ""}
</div>
</hass-subpage>
`;
@ -49,7 +55,8 @@ class HaConfigSectionNetwork extends LitElement {
supervisor-hostname,
supervisor-network,
ha-config-url-form,
ha-config-network {
ha-config-network,
ha-config-network-discovery {
display: block;
margin: 0 auto;
margin-bottom: 24px;

View File

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

View File

@ -5542,6 +5542,15 @@
"thread_network_info": "Thread network information",
"thread_network_delete_credentials": "Delete Thread network credentials"
},
"zeroconf": {
"name": "Name",
"type": "Type",
"port": "Port",
"ip_addresses": "IP Addresses",
"properties": "Properties",
"discovery_information": "Discovery information",
"copy_to_clipboard": "Copy to clipboard"
},
"zha": {
"common": {
"clusters": "Clusters",
@ -6347,6 +6356,11 @@
"failed_to_set_hostname": "Setting hostname failed"
}
},
"discovery": {
"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"
},
"network_adapter": "Network adapter",
"network_adapter_info": "Configure which network adapters integrations will use. Currently this setting only affects multicast traffic. A restart is required for these settings to apply.",
"ip_information": "IP Information",