mirror of
https://github.com/home-assistant/frontend.git
synced 2025-07-27 11:16:35 +00:00
parent
d75ea3bb8d
commit
ee495a432f
@ -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
79
src/data/zeroconf.ts
Normal 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
|
||||
);
|
@ -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: () =>
|
||||
|
@ -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;
|
||||
}
|
||||
}
|
@ -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,
|
||||
});
|
||||
};
|
@ -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;
|
||||
}
|
||||
}
|
73
src/panels/config/network/ha-config-network-discovery.ts
Normal file
73
src/panels/config/network/ha-config-network-discovery.ts
Normal 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;
|
||||
}
|
||||
}
|
@ -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;
|
||||
|
@ -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",
|
||||
},
|
||||
|
@ -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",
|
||||
|
Loading…
x
Reference in New Issue
Block a user