mirror of
https://github.com/home-assistant/frontend.git
synced 2025-07-19 15:26:36 +00:00
Bluetooth network visualization (#25512)
* Bluetooth network visualization * fix category symbol * Add translations * memoize bluetooth data * throttle data updates to 10s * handle proxies that appear as end devices too * fix tab highlighting * memoize fix
This commit is contained in:
parent
97e0217906
commit
07e5f53469
@ -3,7 +3,7 @@ import type { GraphSeriesOption } from "echarts/charts";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property, state, query } from "lit/decorators";
|
||||
import type { TopLevelFormatterParams } from "echarts/types/dist/shared";
|
||||
import { mdiGoogleCirclesGroup } from "@mdi/js";
|
||||
import { mdiFormatTextVariant, mdiGoogleCirclesGroup } from "@mdi/js";
|
||||
import memoizeOne from "memoize-one";
|
||||
import { listenMediaQuery } from "../../common/dom/media_query";
|
||||
import type { ECOption } from "../../resources/echarts";
|
||||
@ -42,6 +42,7 @@ export interface NetworkLink {
|
||||
type?: "solid" | "dashed" | "dotted";
|
||||
};
|
||||
symbolSize?: number | number[];
|
||||
symbol?: string;
|
||||
label?: {
|
||||
show?: boolean;
|
||||
formatter?: string;
|
||||
@ -52,7 +53,7 @@ export interface NetworkLink {
|
||||
export interface NetworkData {
|
||||
nodes: NetworkNode[];
|
||||
links: NetworkLink[];
|
||||
categories?: { name: string }[];
|
||||
categories?: { name: string; symbol: string }[];
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention, @typescript-eslint/consistent-type-imports
|
||||
@ -62,7 +63,7 @@ let GraphChart: typeof import("echarts/lib/chart/graph/install");
|
||||
export class HaNetworkGraph extends LitElement {
|
||||
public chart?: EChartsType;
|
||||
|
||||
@property({ attribute: false }) public data?: NetworkData;
|
||||
@property({ attribute: false }) public data!: NetworkData;
|
||||
|
||||
@property({ attribute: false }) public tooltipFormatter?: (
|
||||
params: TopLevelFormatterParams
|
||||
@ -74,6 +75,8 @@ export class HaNetworkGraph extends LitElement {
|
||||
|
||||
@state() private _physicsEnabled = true;
|
||||
|
||||
@state() private _showLabels = true;
|
||||
|
||||
private _listeners: (() => void)[] = [];
|
||||
|
||||
private _nodePositions: Record<string, { x: number; y: number }> = {};
|
||||
@ -117,7 +120,8 @@ export class HaNetworkGraph extends LitElement {
|
||||
.data=${this._getSeries(
|
||||
this.data,
|
||||
this._physicsEnabled,
|
||||
this._reducedMotion
|
||||
this._reducedMotion,
|
||||
this._showLabels
|
||||
)}
|
||||
.options=${this._createOptions(this.data?.categories)}
|
||||
height="100%"
|
||||
@ -133,6 +137,15 @@ export class HaNetworkGraph extends LitElement {
|
||||
"ui.panel.config.common.graph.toggle_physics"
|
||||
)}
|
||||
></ha-icon-button>
|
||||
<ha-icon-button
|
||||
slot="button"
|
||||
class=${this._showLabels ? "active" : "inactive"}
|
||||
.path=${mdiFormatTextVariant}
|
||||
@click=${this._toggleLabels}
|
||||
label=${this.hass.localize(
|
||||
"ui.panel.config.common.graph.toggle_labels"
|
||||
)}
|
||||
></ha-icon-button>
|
||||
</ha-chart-base>`;
|
||||
}
|
||||
|
||||
@ -145,7 +158,10 @@ export class HaNetworkGraph extends LitElement {
|
||||
},
|
||||
legend: {
|
||||
show: !!categories?.length,
|
||||
data: categories,
|
||||
data: categories?.map((category) => ({
|
||||
...category,
|
||||
icon: category.symbol,
|
||||
})),
|
||||
top: 8,
|
||||
},
|
||||
dataZoom: {
|
||||
@ -156,11 +172,12 @@ export class HaNetworkGraph extends LitElement {
|
||||
);
|
||||
|
||||
private _getSeries = memoizeOne(
|
||||
(data?: NetworkData, physicsEnabled?: boolean, reducedMotion?: boolean) => {
|
||||
if (!data) {
|
||||
return [];
|
||||
}
|
||||
|
||||
(
|
||||
data: NetworkData,
|
||||
physicsEnabled: boolean,
|
||||
reducedMotion: boolean,
|
||||
showLabels: boolean
|
||||
) => {
|
||||
const containerWidth = this.clientWidth;
|
||||
const containerHeight = this.clientHeight;
|
||||
return [
|
||||
@ -172,7 +189,7 @@ export class HaNetworkGraph extends LitElement {
|
||||
roam: true,
|
||||
selectedMode: "single",
|
||||
label: {
|
||||
show: true,
|
||||
show: showLabels,
|
||||
position: "right",
|
||||
},
|
||||
emphasis: {
|
||||
@ -182,7 +199,7 @@ export class HaNetworkGraph extends LitElement {
|
||||
repulsion: [400, 600],
|
||||
edgeLength: [200, 300],
|
||||
gravity: 0.1,
|
||||
layoutAnimation: !reducedMotion,
|
||||
layoutAnimation: !reducedMotion && data.nodes.length < 100,
|
||||
},
|
||||
edgeSymbol: ["none", "arrow"],
|
||||
edgeSymbolSize: 10,
|
||||
@ -251,6 +268,10 @@ export class HaNetworkGraph extends LitElement {
|
||||
this._physicsEnabled = !this._physicsEnabled;
|
||||
}
|
||||
|
||||
private _toggleLabels() {
|
||||
this._showLabels = !this._showLabels;
|
||||
}
|
||||
|
||||
static styles = css`
|
||||
:host {
|
||||
display: block;
|
||||
|
@ -27,6 +27,18 @@ import "../../../../../layouts/hass-tabs-subpage-data-table";
|
||||
import { haStyle } from "../../../../../resources/styles";
|
||||
import type { HomeAssistant, Route } from "../../../../../types";
|
||||
import { showBluetoothDeviceInfoDialog } from "./show-dialog-bluetooth-device-info";
|
||||
import type { PageNavigation } from "../../../../../layouts/hass-tabs-subpage";
|
||||
|
||||
export const bluetoothAdvertisementMonitorTabs: PageNavigation[] = [
|
||||
{
|
||||
translationKey: "ui.panel.config.bluetooth.advertisement_monitor",
|
||||
path: "advertisement-monitor",
|
||||
},
|
||||
{
|
||||
translationKey: "ui.panel.config.bluetooth.visualization",
|
||||
path: "visualization",
|
||||
},
|
||||
];
|
||||
|
||||
@customElement("bluetooth-advertisement-monitor")
|
||||
export class BluetoothAdvertisementMonitorPanel extends LitElement {
|
||||
@ -220,6 +232,7 @@ export class BluetoothAdvertisementMonitorPanel extends LitElement {
|
||||
@collapsed-changed=${this._handleCollapseChanged}
|
||||
filter=${this.address || ""}
|
||||
clickable
|
||||
.tabs=${bluetoothAdvertisementMonitorTabs}
|
||||
></hass-tabs-subpage-data-table>
|
||||
`;
|
||||
}
|
||||
|
@ -31,6 +31,10 @@ class BluetoothConfigDashboardRouter extends HassRouterPage {
|
||||
tag: "bluetooth-connection-monitor",
|
||||
load: () => import("./bluetooth-connection-monitor"),
|
||||
},
|
||||
visualization: {
|
||||
tag: "bluetooth-network-visualization",
|
||||
load: () => import("./bluetooth-network-visualization"),
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
|
@ -106,6 +106,13 @@ export class BluetoothConfigDashboard extends LitElement {
|
||||
)}
|
||||
</ha-button></a
|
||||
>
|
||||
<a href="/config/bluetooth/visualization"
|
||||
><ha-button>
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.bluetooth.visualization"
|
||||
)}
|
||||
</ha-button></a
|
||||
>
|
||||
</div>
|
||||
</ha-card>
|
||||
<ha-card
|
||||
@ -208,6 +215,10 @@ export class BluetoothConfigDashboard extends LitElement {
|
||||
ha-card {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.card-actions {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
}
|
||||
`,
|
||||
];
|
||||
}
|
||||
|
@ -0,0 +1,318 @@
|
||||
import { html, LitElement, css } from "lit";
|
||||
import type { CSSResultGroup } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import type { UnsubscribeFunc } from "home-assistant-js-websocket";
|
||||
import type {
|
||||
CallbackDataParams,
|
||||
TopLevelFormatterParams,
|
||||
} from "echarts/types/dist/shared";
|
||||
import memoizeOne from "memoize-one";
|
||||
import type { HomeAssistant, Route } from "../../../../../types";
|
||||
import "../../../../../components/chart/ha-network-graph";
|
||||
import type {
|
||||
NetworkData,
|
||||
NetworkNode,
|
||||
NetworkLink,
|
||||
} from "../../../../../components/chart/ha-network-graph";
|
||||
import type {
|
||||
BluetoothDeviceData,
|
||||
BluetoothScannersDetails,
|
||||
} from "../../../../../data/bluetooth";
|
||||
import {
|
||||
subscribeBluetoothAdvertisements,
|
||||
subscribeBluetoothScannersDetails,
|
||||
} from "../../../../../data/bluetooth";
|
||||
import type { DeviceRegistryEntry } from "../../../../../data/device_registry";
|
||||
import "../../../../../layouts/hass-subpage";
|
||||
import { colorVariables } from "../../../../../resources/theme/color.globals";
|
||||
import { navigate } from "../../../../../common/navigate";
|
||||
import { bluetoothAdvertisementMonitorTabs } from "./bluetooth-advertisement-monitor";
|
||||
import { relativeTime } from "../../../../../common/datetime/relative_time";
|
||||
import { throttle } from "../../../../../common/util/throttle";
|
||||
|
||||
const UPDATE_THROTTLE_TIME = 10000;
|
||||
|
||||
@customElement("bluetooth-network-visualization")
|
||||
export class BluetoothNetworkVisualization extends LitElement {
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
|
||||
@property({ type: Boolean, reflect: true }) public narrow = false;
|
||||
|
||||
@property({ attribute: "is-wide", type: Boolean }) public isWide = false;
|
||||
|
||||
@property({ attribute: false }) public route!: Route;
|
||||
|
||||
@state() private _data: BluetoothDeviceData[] = [];
|
||||
|
||||
@state() private _scanners: BluetoothScannersDetails = {};
|
||||
|
||||
@state() private _sourceDevices: Record<string, DeviceRegistryEntry> = {};
|
||||
|
||||
private _unsub_advertisements?: UnsubscribeFunc;
|
||||
|
||||
private _unsub_scanners?: UnsubscribeFunc;
|
||||
|
||||
private _throttledUpdateData = throttle((data: BluetoothDeviceData[]) => {
|
||||
this._data = data;
|
||||
}, UPDATE_THROTTLE_TIME);
|
||||
|
||||
public connectedCallback(): void {
|
||||
super.connectedCallback();
|
||||
if (this.hass) {
|
||||
this._unsub_advertisements = subscribeBluetoothAdvertisements(
|
||||
this.hass.connection,
|
||||
(data) => {
|
||||
if (!this._data.length) {
|
||||
this._data = data;
|
||||
} else {
|
||||
this._throttledUpdateData(data);
|
||||
}
|
||||
}
|
||||
);
|
||||
this._unsub_scanners = subscribeBluetoothScannersDetails(
|
||||
this.hass.connection,
|
||||
(scanners) => {
|
||||
this._scanners = scanners;
|
||||
}
|
||||
);
|
||||
|
||||
const devices = Object.values(this.hass.devices);
|
||||
const bluetoothDevices = devices.filter((device) =>
|
||||
device.connections.find((connection) => connection[0] === "bluetooth")
|
||||
);
|
||||
this._sourceDevices = Object.fromEntries(
|
||||
bluetoothDevices.map((device) => {
|
||||
const connection = device.connections.find(
|
||||
(c) => c[0] === "bluetooth"
|
||||
)!;
|
||||
return [connection[1], device];
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
public disconnectedCallback() {
|
||||
super.disconnectedCallback();
|
||||
if (this._unsub_advertisements) {
|
||||
this._unsub_advertisements();
|
||||
this._unsub_advertisements = undefined;
|
||||
}
|
||||
this._throttledUpdateData.cancel();
|
||||
if (this._unsub_scanners) {
|
||||
this._unsub_scanners();
|
||||
this._unsub_scanners = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
protected render() {
|
||||
return html`
|
||||
<hass-tabs-subpage
|
||||
.hass=${this.hass}
|
||||
.narrow=${this.narrow}
|
||||
.route=${this.route}
|
||||
header=${this.hass.localize("ui.panel.config.bluetooth.visualization")}
|
||||
.tabs=${bluetoothAdvertisementMonitorTabs}
|
||||
>
|
||||
<ha-network-graph
|
||||
.hass=${this.hass}
|
||||
.data=${this._formatNetworkData(this._data, this._scanners)}
|
||||
.tooltipFormatter=${this._tooltipFormatter}
|
||||
@chart-click=${this._handleChartClick}
|
||||
></ha-network-graph>
|
||||
</hass-tabs-subpage>
|
||||
`;
|
||||
}
|
||||
|
||||
private _formatNetworkData = memoizeOne(
|
||||
(
|
||||
data: BluetoothDeviceData[],
|
||||
scanners: BluetoothScannersDetails
|
||||
): NetworkData => {
|
||||
const categories = [
|
||||
{
|
||||
name: this.hass.localize("ui.panel.config.bluetooth.core"),
|
||||
symbol: "roundRect",
|
||||
itemStyle: {
|
||||
color: colorVariables["primary-color"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: this.hass.localize("ui.panel.config.bluetooth.scanners"),
|
||||
symbol: "circle",
|
||||
itemStyle: {
|
||||
color: colorVariables["cyan-color"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: this.hass.localize("ui.panel.config.bluetooth.known_devices"),
|
||||
symbol: "circle",
|
||||
itemStyle: {
|
||||
color: colorVariables["teal-color"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: this.hass.localize("ui.panel.config.bluetooth.unknown_devices"),
|
||||
symbol: "circle",
|
||||
itemStyle: {
|
||||
color: colorVariables["disabled-color"],
|
||||
},
|
||||
},
|
||||
];
|
||||
const nodes: NetworkNode[] = [
|
||||
{
|
||||
id: "ha",
|
||||
name: this.hass.localize("ui.panel.config.bluetooth.core"),
|
||||
category: 0,
|
||||
value: 4,
|
||||
symbol: "roundRect",
|
||||
symbolSize: 40,
|
||||
polarDistance: 0,
|
||||
},
|
||||
];
|
||||
const links: NetworkLink[] = [];
|
||||
Object.values(scanners).forEach((scanner) => {
|
||||
const scannerDevice = this._sourceDevices[scanner.source];
|
||||
nodes.push({
|
||||
id: scanner.source,
|
||||
name:
|
||||
scannerDevice?.name_by_user || scannerDevice?.name || scanner.name,
|
||||
category: 1,
|
||||
value: 5,
|
||||
symbol: "circle",
|
||||
symbolSize: 30,
|
||||
polarDistance: 0.25,
|
||||
});
|
||||
links.push({
|
||||
source: "ha",
|
||||
target: scanner.source,
|
||||
value: 0,
|
||||
symbol: "none",
|
||||
lineStyle: {
|
||||
width: 3,
|
||||
color: colorVariables["primary-color"],
|
||||
},
|
||||
});
|
||||
});
|
||||
data.forEach((node) => {
|
||||
if (scanners[node.address]) {
|
||||
// proxies sometimes appear as end devices too
|
||||
links.push({
|
||||
source: node.source,
|
||||
target: node.address,
|
||||
value: node.rssi,
|
||||
symbol: "none",
|
||||
lineStyle: {
|
||||
width: this._getLineWidth(node.rssi),
|
||||
color: colorVariables["primary-color"],
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
const device = this._sourceDevices[node.address];
|
||||
nodes.push({
|
||||
id: node.address,
|
||||
name: this._getBluetoothDeviceName(node.address),
|
||||
value: device ? 1 : 0,
|
||||
category: device ? 2 : 3,
|
||||
symbolSize: 20,
|
||||
});
|
||||
links.push({
|
||||
source: node.source,
|
||||
target: node.address,
|
||||
value: node.rssi,
|
||||
symbol: "none",
|
||||
lineStyle: {
|
||||
width: this._getLineWidth(node.rssi),
|
||||
color: device
|
||||
? colorVariables["primary-color"]
|
||||
: colorVariables["disabled-color"],
|
||||
},
|
||||
});
|
||||
});
|
||||
return { nodes, links, categories };
|
||||
}
|
||||
);
|
||||
|
||||
private _getBluetoothDeviceName(id: string): string {
|
||||
if (id === "ha") {
|
||||
return this.hass.localize("ui.panel.config.bluetooth.core");
|
||||
}
|
||||
if (this._sourceDevices[id]) {
|
||||
return (
|
||||
this._sourceDevices[id]?.name_by_user ||
|
||||
this._sourceDevices[id]?.name ||
|
||||
id
|
||||
);
|
||||
}
|
||||
if (this._scanners[id]) {
|
||||
return this._scanners[id]?.name || id;
|
||||
}
|
||||
return this._data.find((d) => d.address === id)?.name || id;
|
||||
}
|
||||
|
||||
private _getLineWidth(rssi: number): number {
|
||||
return rssi > -33 ? 3 : rssi > -66 ? 2 : 1;
|
||||
}
|
||||
|
||||
private _tooltipFormatter = (params: TopLevelFormatterParams): string => {
|
||||
const { dataType, data } = params as CallbackDataParams;
|
||||
let tooltipText = "";
|
||||
if (dataType === "edge") {
|
||||
const { source, target, value } = data as any;
|
||||
const sourceName = this._getBluetoothDeviceName(source);
|
||||
const targetName = this._getBluetoothDeviceName(target);
|
||||
tooltipText = `${sourceName} → ${targetName}`;
|
||||
if (source !== "ha") {
|
||||
tooltipText += ` <b>${this.hass.localize("ui.panel.config.bluetooth.rssi")}:</b> ${value}`;
|
||||
}
|
||||
} else {
|
||||
const { id: address } = data as any;
|
||||
const name = this._getBluetoothDeviceName(address);
|
||||
const btDevice = this._data.find((d) => d.address === address);
|
||||
if (btDevice) {
|
||||
tooltipText = `<b>${name}</b><br><b>${this.hass.localize("ui.panel.config.bluetooth.address")}:</b> ${address}<br><b>${this.hass.localize("ui.panel.config.bluetooth.rssi")}:</b> ${btDevice.rssi}<br><b>${this.hass.localize("ui.panel.config.bluetooth.source")}:</b> ${btDevice.source}<br><b>${this.hass.localize("ui.panel.config.bluetooth.updated")}:</b> ${relativeTime(new Date(btDevice.time * 1000), this.hass.locale)}`;
|
||||
} else {
|
||||
const device = this._sourceDevices[address];
|
||||
if (device) {
|
||||
tooltipText = `<b>${name}</b><br><b>${this.hass.localize("ui.panel.config.bluetooth.address")}:</b> ${address}`;
|
||||
if (device.area_id) {
|
||||
const area = this.hass.areas[device.area_id];
|
||||
if (area) {
|
||||
tooltipText += `<br><b>${this.hass.localize("ui.panel.config.bluetooth.area")}: </b>${area.name}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return tooltipText;
|
||||
};
|
||||
|
||||
private _handleChartClick(e: CustomEvent): void {
|
||||
if (
|
||||
e.detail.dataType === "node" &&
|
||||
e.detail.event.target.cursor === "pointer"
|
||||
) {
|
||||
const { id } = e.detail.data;
|
||||
const device = this._sourceDevices[id];
|
||||
if (device) {
|
||||
navigate(`/config/devices/device/${device.id}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static get styles(): CSSResultGroup {
|
||||
return [
|
||||
css`
|
||||
ha-network-graph {
|
||||
height: 100%;
|
||||
}
|
||||
`,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"bluetooth-network-visualization": BluetoothNetworkVisualization;
|
||||
}
|
||||
}
|
@ -54,9 +54,7 @@ export class ZHANetworkVisualizationPage extends LitElement {
|
||||
.narrow=${this.narrow}
|
||||
.isWide=${this.isWide}
|
||||
.route=${this.route}
|
||||
.header=${this.hass.localize(
|
||||
"ui.panel.config.zha.visualization.header"
|
||||
)}
|
||||
header=${this.hass.localize("ui.panel.config.zha.visualization.header")}
|
||||
>
|
||||
<ha-network-graph
|
||||
.hass=${this.hass}
|
||||
@ -164,16 +162,16 @@ export class ZHANetworkVisualizationPage extends LitElement {
|
||||
const categories = [
|
||||
{
|
||||
name: "Coordinator",
|
||||
icon: "roundRect",
|
||||
symbol: "roundRect",
|
||||
itemStyle: { color: primaryColor },
|
||||
},
|
||||
{ name: "Router", icon: "circle", itemStyle: { color: routerColor } },
|
||||
{ name: "Router", symbol: "circle", itemStyle: { color: routerColor } },
|
||||
{
|
||||
name: "End Device",
|
||||
icon: "circle",
|
||||
symbol: "circle",
|
||||
itemStyle: { color: endDeviceColor },
|
||||
},
|
||||
{ name: "Offline", icon: "circle", itemStyle: { color: offlineColor } },
|
||||
{ name: "Offline", symbol: "circle", itemStyle: { color: offlineColor } },
|
||||
];
|
||||
|
||||
// Create all the nodes and links
|
||||
@ -347,7 +345,7 @@ export class ZHANetworkVisualizationPage extends LitElement {
|
||||
}
|
||||
|
||||
private _getLQIWidth(lqi: number): number {
|
||||
return Math.max(1, Math.floor((lqi / 256) * 4));
|
||||
return lqi > 200 ? 3 : lqi > 100 ? 2 : 1;
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -119,6 +119,10 @@ export const getMyRedirects = (): Redirects => ({
|
||||
component: "bluetooth",
|
||||
redirect: "/config/bluetooth/connection-monitor",
|
||||
},
|
||||
bluetooth_visualization: {
|
||||
component: "bluetooth",
|
||||
redirect: "/config/bluetooth/visualization",
|
||||
},
|
||||
config_bluetooth: {
|
||||
component: "bluetooth",
|
||||
redirect: "/config/bluetooth",
|
||||
|
@ -2113,7 +2113,8 @@
|
||||
"hide_url": "Hide URL",
|
||||
"copy_link": "Copy link",
|
||||
"graph": {
|
||||
"toggle_physics": "Toggle physics"
|
||||
"toggle_physics": "Toggle physics",
|
||||
"toggle_labels": "Toggle labels"
|
||||
}
|
||||
},
|
||||
"updates": {
|
||||
@ -5521,6 +5522,7 @@
|
||||
"connection_slot_allocations_monitor": "Connection slot allocations monitor",
|
||||
"connection_slot_allocations_monitor_details": "The connection slot allocations monitor displays the (GATT) connection slot allocations for the adapter. This adapter supports up to {slots} simultaneous connections. Each remote Bluetooth device that requires an active connection will use one connection slot while the Bluetooth device is connecting or connected.",
|
||||
"connection_monitor": "Connection monitor",
|
||||
"visualization": "Visualization",
|
||||
"used_connection_slot_allocations": "Used connection slot allocations",
|
||||
"no_connections": "No active connections",
|
||||
"no_advertisements_found": "No matching Bluetooth advertisements found",
|
||||
@ -5538,7 +5540,12 @@
|
||||
"manufacturer_data": "Manufacturer data",
|
||||
"service_data": "Service data",
|
||||
"service_uuids": "Service UUIDs",
|
||||
"copy_to_clipboard": "[%key:ui::panel::config::automation::editor::copy_to_clipboard%]"
|
||||
"copy_to_clipboard": "[%key:ui::panel::config::automation::editor::copy_to_clipboard%]",
|
||||
"area": "Area",
|
||||
"core": "Home Assistant",
|
||||
"scanners": "Scanners",
|
||||
"known_devices": "Known devices",
|
||||
"unknown_devices": "Unknown devices"
|
||||
},
|
||||
"dhcp": {
|
||||
"title": "DHCP discovery",
|
||||
|
Loading…
x
Reference in New Issue
Block a user