diff --git a/src/data/integration.ts b/src/data/integration.ts index 38370183d6..b2f071d843 100644 --- a/src/data/integration.ts +++ b/src/data/integration.ts @@ -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", }; diff --git a/src/data/zeroconf.ts b/src/data/zeroconf.ts new file mode 100644 index 0000000000..72bf97f910 --- /dev/null +++ b/src/data/zeroconf.ts @@ -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; + ip_addresses: string[]; +} + +interface ZeroconfRemoveDiscoveryData { + name: string; +} + +interface ZeroconfSubscriptionMessage { + add?: ZeroconfDiscoveryData[]; + change?: ZeroconfDiscoveryData[]; + remove?: ZeroconfRemoveDiscoveryData[]; +} + +const subscribeZeroconfDiscoveryUpdates = ( + 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.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( + "_zeroconfDiscoveryRows", + () => Promise.resolve([]), // empty array as initial state + + subscribeZeroconfDiscoveryUpdates, + conn, + callbackFunction + ); diff --git a/src/panels/config/ha-panel-config.ts b/src/panels/config/ha-panel-config.ts index 8afee3fb70..f7a7f6c9b3 100644 --- a/src/panels/config/ha-panel-config.ts +++ b/src/panels/config/ha-panel-config.ts @@ -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: () => diff --git a/src/panels/config/integrations/integration-panels/zeroconf/dialog-zeroconf-discovery-info.ts b/src/panels/config/integrations/integration-panels/zeroconf/dialog-zeroconf-discovery-info.ts new file mode 100644 index 0000000000..9af8a21eb5 --- /dev/null +++ b/src/panels/config/integrations/integration-panels/zeroconf/dialog-zeroconf-discovery-info.ts @@ -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 { + 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.zeroconf.name")}: + ${this._params.entry.name.slice( + 0, + -this._params.entry.type.length - 1 + )} +
+ ${this.hass.localize("ui.panel.config.zeroconf.type")}: + ${this._params.entry.type} +
+ ${this.hass.localize("ui.panel.config.zeroconf.port")}: + ${this._params.entry.port} +
+

+ +

${this.hass.localize("ui.panel.config.zeroconf.ip_addresses")}

+ + + ${this._params.entry.ip_addresses.map( + (ipAddress) => html` + + + + ` + )} + +
${ipAddress}
+ +

${this.hass.localize("ui.panel.config.zeroconf.properties")}

+ + + ${Object.entries(this._params.entry.properties).map( + ([key, value]) => html` + + + + + ` + )} + +
${key}${value}
+ + ${this.hass.localize( + "ui.panel.config.zeroconf.copy_to_clipboard" + )} +
+ `; + } +} + +declare global { + interface HTMLElementTagNameMap { + "dialog-zeroconf-device-info": DialogZeroconfDiscoveryInfo; + } +} diff --git a/src/panels/config/integrations/integration-panels/zeroconf/show-dialog-zeroconf-discovery-info.ts b/src/panels/config/integrations/integration-panels/zeroconf/show-dialog-zeroconf-discovery-info.ts new file mode 100644 index 0000000000..1a121b7e17 --- /dev/null +++ b/src/panels/config/integrations/integration-panels/zeroconf/show-dialog-zeroconf-discovery-info.ts @@ -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, + }); +}; diff --git a/src/panels/config/integrations/integration-panels/zeroconf/zeroconf-config-panel.ts b/src/panels/config/integrations/integration-panels/zeroconf/zeroconf-config-panel.ts new file mode 100644 index 0000000000..57ffabcdee --- /dev/null +++ b/src/panels/config/integrations/integration-panels/zeroconf/zeroconf-config-panel.ts @@ -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 = { + 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` + + `; + } + + private _handleRowClicked(ev: HASSDomEvent) { + 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; + } +} diff --git a/src/panels/config/network/ha-config-network-discovery.ts b/src/panels/config/network/ha-config-network-discovery.ts new file mode 100644 index 0000000000..4639e466b2 --- /dev/null +++ b/src/panels/config/network/ha-config-network-discovery.ts @@ -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` + +
+

+ ${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-section-network.ts b/src/panels/config/network/ha-config-section-network.ts index 1a18aca3b6..94a6680646 100644 --- a/src/panels/config/network/ha-config-section-network.ts +++ b/src/panels/config/network/ha-config-section-network.ts @@ -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 { : ""} + ${isComponentLoaded(this.hass, "zeroconf") + ? html`` + : ""} `; @@ -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; diff --git a/src/panels/my/ha-panel-my.ts b/src/panels/my/ha-panel-my.ts index 8dbdd28859..c06c94b10a 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_zeroconf: { + component: "zeroconf", + redirect: "/config/zeroconf", + }, devices: { redirect: "/config/devices/dashboard", }, diff --git a/src/translations/en.json b/src/translations/en.json index 1624b08491..f7b0b73bc1 100644 --- a/src/translations/en.json +++ b/src/translations/en.json @@ -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",