feat: add contextual Zigbee topology hover

Issue: #54
User-Visible: yes
This commit is contained in:
Sergey Matyunin
2026-09-04 14:40:09 +03:00
parent e5587aed7c
commit 85702afc0c
56 changed files with 2481 additions and 564 deletions
+3 -3
View File
@@ -306,7 +306,7 @@ import {
type RenderDeviceSnapshot,
} from './render-device-snapshot';
import { deviceFaceStyle, deviceThemeClass, renderDeviceFace } from './device-face';
import { effectiveDeviceBaseSize } from './device-marker-geometry';
import { effectiveDeviceBaseSize } from './device-marker-geometry'; import { renderZigbeeTopologyOverlay } from './zigbee-topology-overlay-bridge';
import {
ModeTransitionController, viewportFromViewBox,
type HouseplanMode, type ModeTransitionState, type ModeVisualState, type ModeViewBox,
@@ -2074,7 +2074,7 @@ export class HouseplanCard extends LitElement {
colors: FillColors; glowRadius: number; bgColor: string | null;
/** sun on the plan (docs/SUN.md) */
northDeg: number | null; bgMode: 'static' | 'daynight'; sunRays: boolean;
showRoomTooltip: boolean;
showRoomTooltip: boolean; zigbeeTopology: import('./zigbee-topology-settings').ZigbeeTopologySettings;
busy: boolean;
} | null = null;
private _supportDialog: SupportDialogState | null = null;
@@ -11813,7 +11813,7 @@ export class HouseplanCard extends LitElement {
<svg class="iso-walls-svg" viewBox="${view.x} ${view.y} ${view.w} ${view.h}"
preserveAspectRatio="xMidYMid meet" aria-hidden="true" pointer-events="none">
${this._renderIsoWalls(isoLayers, isoPanels)}
</svg>` : nothing}
</svg>` : nothing}${renderZigbeeTopologyOverlay({ hass: this.hass, settings: this._settings, devices: this._renderDevices, registry: this._haRegistry, currentSpace: space.id, viewKey: view, view: this._mode === 'view', kiosk: this._kiosk })}
${''/* docs/CANVAS.md §6: an icon is a percentage of the PLAN and
scales with it when you zoom — the behaviour the card always
had, restored by the owner. `iconCqw` is `iconPct * iconUnit
+9 -10
View File
@@ -17,7 +17,7 @@ import type { HpConfirmRequest } from './danger-confirm';
import './hp-color-opacity';
import type { ColorPickerLabels } from './hp-color-opacity';
import './hp-help';
import './hp-device-preview';
import './hp-device-preview'; import './hp-zigbee-topology-settings';
import {
EXCLUDED_DOMAINS, DEFAULT_ICON_RULES, compileIconRules, isValidPattern, iconFor,
type IconRule, type CompiledIconRule,
@@ -250,7 +250,7 @@ import { enqueueSerializedWrite, optimisticAttempt, rollbackOptimistic, type Opt
import { applyCalibrationProposal, saveAutomaticCalibration, saveManualCalibration, saveVacuumMatrix,
type CalibrationProposal, type VacuumFit } from './vacuum-calibration-write';
import { hasTranslation, langOf, t, type I18nKey } from './i18n';
import { supportT, type SupportI18nKey } from './i18n/support';
import { supportT, type SupportI18nKey } from './i18n/support'; import { writeZigbeeTopologySettings, zigbeeTopologySettingsOf, type ZigbeeTopologySettings } from './zigbee-topology-settings';
import {
newSupportDialogState,
supportApiCompatible,
@@ -1118,8 +1118,8 @@ export interface HouseplanEditorHostPort {
_sentPos: Map<string, DeviceLayout[string] | null>;
_serverCfg: ServerConfig | null;
_serverStorage: boolean;
_settings: { exclude_integrations?: string[]; group_lights?: boolean; show_all?: boolean; filter_seeded?: boolean; icon_rules?: { pattern: string; icon: string; }[]; show_room_tooltip?: boolean; };
_settingsDialog: { colors: FillColors; glowRadius: number; bgColor: string | null; northDeg: number | null; bgMode: "static" | "daynight"; sunRays: boolean; showRoomTooltip: boolean; busy: boolean; } | null;
_settings: { exclude_integrations?: string[]; group_lights?: boolean; show_all?: boolean; filter_seeded?: boolean; icon_rules?: { pattern: string; icon: string; }[]; show_room_tooltip?: boolean; zigbee_topology?: { enabled?: boolean; z2m_base_topics?: string[] }; };
_settingsDialog: { colors: FillColors; glowRadius: number; bgColor: string | null; northDeg: number | null; bgMode: "static" | "daynight"; sunRays: boolean; showRoomTooltip: boolean; zigbeeTopology: ZigbeeTopologySettings; busy: boolean; } | null;
_supportDialog: SupportDialogState | null;
_showAll: boolean;
_showHidden: boolean;
@@ -9067,8 +9067,7 @@ public _openSettingsDialog = (): void => {
northDeg: northDegOf(this.host._settings, {}),
bgMode: bgModeOf(this.host._settings, {}),
sunRays: sunRaysOn(this.host._settings, {}),
showRoomTooltip: showRoomTooltipOf(this.host._settings),
busy: false,
showRoomTooltip: showRoomTooltipOf(this.host._settings), zigbeeTopology: zigbeeTopologySettingsOf(this.host._settings), busy: false,
};
};
@@ -10265,7 +10264,7 @@ public _updateDecorStyle(next: DecorStyle): void {
try {
const cfg = this.host._serverCfg!;
const isDefault = JSON.stringify(d.colors) === JSON.stringify(DEFAULT_FILL_COLORS);
const settings: any = { ...cfg.settings };
let settings: any = { ...cfg.settings };
if (isDefault) delete settings.fill_colors;
else settings.fill_colors = d.colors;
const cm = this.host._imperial ? d.glowRadius * 30.48 : d.glowRadius * 100;
@@ -10281,7 +10280,7 @@ public _updateDecorStyle(next: DecorStyle): void {
if (d.sunRays) settings.sun_rays = true;
else delete settings.sun_rays;
if (d.showRoomTooltip) delete settings.show_room_tooltip;
else settings.show_room_tooltip = false;
else settings.show_room_tooltip = false; settings = writeZigbeeTopologySettings(settings, d.zigbeeTopology);
// Old configs may still contain this accepted field, but weather no
// longer affects sunlight; saving general settings cleans it up.
delete settings.weather_entity;
@@ -10594,7 +10593,7 @@ public _renderSettingsDialog(): TemplateResult {
<span>${supportT(
langOf(this.host.hass, this.host._config?.language), 'gs.show_room_tooltip',
)}</span>
</label>
</label><hp-zigbee-topology-settings .hass=${this.host.hass} .value=${this.host._settingsDialog!.zigbeeTopology} .savedEnabled=${zigbeeTopologySettingsOf(this.host._settings).enabled} .devices=${this.host._devices} .registry=${this.host._haRegistry} @hp-topology-settings-change=${(event: CustomEvent<ZigbeeTopologySettings>) => (this.host._settingsDialog = { ...this.host._settingsDialog!, zigbeeTopology: event.detail })}></hp-zigbee-topology-settings>
<label class="dispsection">${this.host._t('gs.light_group')}</label>
${this._renderColorRow('light_on', 'gs.light_on')}
${this._renderColorRow('light_off', 'gs.light_off')}
@@ -10720,7 +10719,7 @@ public _renderSettingsDialog(): TemplateResult {
</div>
<div class="row" slot="footer">
<button class="btn ghost" @click=${() =>
(this.host._settingsDialog = { ...this.host._settingsDialog!, colors: JSON.parse(JSON.stringify(DEFAULT_FILL_COLORS)), glowRadius: this.host._imperial ? 9.8 : 3, bgColor: null, northDeg: null, bgMode: 'daynight', sunRays: false, showRoomTooltip: true })}>
(this.host._settingsDialog = { ...this.host._settingsDialog!, colors: JSON.parse(JSON.stringify(DEFAULT_FILL_COLORS)), glowRadius: this.host._imperial ? 9.8 : 3, bgColor: null, northDeg: null, bgMode: 'daynight', sunRays: false, showRoomTooltip: true, zigbeeTopology: { enabled: false, z2mBaseTopics: [] } })}>
${this.host._t('gs.reset')}
</button>
<span class="spacer"></span>
+203
View File
@@ -0,0 +1,203 @@
import { LitElement, css, html, svg, nothing, type PropertyValues } from 'lit';
import { langOf } from './i18n';
import { lqiColor } from './logic';
import { topologyT } from './i18n/topology';
import {
mapTopologies, resolveMappedTopologyHover, type ZigbeeHoverResolution, type ZigbeeMappedTopology,
} from './zigbee-topology';
import {
subscribeZigbeeTopology, zigbeeTopologyRuntimeSnapshot,
type ZigbeeTopologyRuntimeSnapshot,
} from './zigbee-topology-runtime';
import type { HaRegistrySnapshot } from './ha-binding-status';
import type { DevItem } from './types';
const EMPTY_RUNTIME: ZigbeeTopologyRuntimeSnapshot = { revision: 0, topologies: [], states: {} };
const EMPTY_HOVER: ZigbeeHoverResolution = { lines: [], remoteCount: 0, omittedCount: 0 };
export class HpZigbeeTopologyOverlay extends LitElement {
static properties = {
hass: { attribute: false },
devices: { attribute: false },
registry: { attribute: false },
currentSpace: { type: String, attribute: 'current-space' },
viewKey: { attribute: false },
};
hass: any;
devices: readonly DevItem[] = [];
registry!: HaRegistrySnapshot;
currentSpace = '';
viewKey: unknown;
private _runtime = EMPTY_RUNTIME;
private _hovered = '';
private _release?: () => void;
private _parent?: HTMLElement;
private _hoverGateObserver?: MutationObserver;
private _mappedMemo?: { runtime: ZigbeeTopologyRuntimeSnapshot; devices: readonly DevItem[];
registry: HaRegistrySnapshot; mapped: ZigbeeMappedTopology[] };
static styles = css`
:host { position: absolute; inset: 0; z-index: 5; display: block; pointer-events: none; }
svg { position: absolute; inset: 0; width: 100%; height: 100%; overflow: visible; }
line { vector-effect: non-scaling-stroke; stroke-linecap: round; }
.halo {
position: absolute; width: calc(var(--device-base-size, 4cqw) * 1.22);
height: calc(var(--device-base-size, 4cqw) * 1.22); transform: translate(-50%, -50%);
box-sizing: border-box; border: 2px solid rgba(120, 190, 220, .82); border-radius: 50%;
box-shadow: 0 0 0 4px rgba(120, 190, 220, .16);
}
.remote {
position: absolute; transform: translate(12px, calc(-100% - 12px));
border: 1px solid rgba(255,255,255,.7); border-radius: 999px;
padding: 3px 7px; color: #fff; background: rgba(28,31,36,.88);
box-shadow: 0 2px 7px rgba(0,0,0,.28); white-space: nowrap;
font: 600 11px/1.2 system-ui, sans-serif;
}
@media (forced-colors: active) {
line { stroke: Highlight !important; }
.halo { border-color: Highlight; box-shadow: none; }
.remote { color: CanvasText; background: Canvas; border-color: CanvasText; }
}
`;
connectedCallback(): void {
super.connectedCallback();
const root = this.getRootNode() as ShadowRoot;
if (root.host && typeof MutationObserver !== 'undefined') {
this._hoverGateObserver = new MutationObserver(() => {
if (!root.host.hasAttribute('data-pointer-hover')) this._clear();
});
this._hoverGateObserver.observe(root.host, { attributes: true, attributeFilter: ['data-pointer-hover'] });
}
queueMicrotask(() => this._connectParent());
}
disconnectedCallback(): void {
this._release?.();
this._release = undefined;
this._disconnectParent();
this._hoverGateObserver?.disconnect();
this._hoverGateObserver = undefined;
this._hovered = '';
super.disconnectedCallback();
}
protected updated(changed: PropertyValues<this>): void {
if (changed.has('hass')) {
this._release?.();
this._runtime = zigbeeTopologyRuntimeSnapshot(this.hass);
this._mappedMemo = undefined;
this._release = subscribeZigbeeTopology(this.hass, () => {
this._runtime = zigbeeTopologyRuntimeSnapshot(this.hass);
this._mappedMemo = undefined;
this.requestUpdate();
});
}
if (changed.has('currentSpace') || changed.has('devices') || changed.has('registry')) {
this._mappedMemo = undefined;
if (!this.devices.some((item) => item.id === this._hovered && item.space === this.currentSpace)) {
this._hovered = '';
}
}
}
private _connectParent(): void {
const parent = this.parentElement;
if (!parent || parent === this._parent) return;
this._disconnectParent();
this._parent = parent;
parent.addEventListener('pointerover', this._pointerOver);
parent.addEventListener('pointerout', this._pointerOut);
parent.addEventListener('pointerdown', this._pointerDown, true);
}
private _disconnectParent(): void {
this._parent?.removeEventListener('pointerover', this._pointerOver);
this._parent?.removeEventListener('pointerout', this._pointerOut);
this._parent?.removeEventListener('pointerdown', this._pointerDown, true);
this._parent = undefined;
}
private _deviceFromEvent(event: Event): HTMLElement | null {
for (const value of event.composedPath()) {
if (value instanceof HTMLElement && value.dataset.hp === 'device') return value;
}
return null;
}
private _mouseAllowed(event: PointerEvent): boolean {
const root = this.getRootNode() as ShadowRoot;
return event.pointerType === 'mouse' && root.host?.hasAttribute?.('data-pointer-hover');
}
private _pointerOver = (event: PointerEvent): void => {
if (!this._mouseAllowed(event)) { this._clear(); return; }
const device = this._deviceFromEvent(event);
const id = device?.dataset.id || '';
if (id && id !== this._hovered) { this._hovered = id; this.requestUpdate(); }
};
private _pointerOut = (event: PointerEvent): void => {
const from = this._deviceFromEvent(event);
if (!from || from.dataset.id !== this._hovered) return;
const related = event.relatedTarget instanceof Element
? event.relatedTarget.closest<HTMLElement>('[data-hp="device"]') : null;
if (related?.dataset.id === this._hovered) return;
this._clear();
};
private _pointerDown = (event: PointerEvent): void => {
if (event.pointerType !== 'mouse') this._clear();
};
private _clear(): void {
if (!this._hovered) return;
this._hovered = '';
this.requestUpdate();
}
private _position(id: string): { x: number; y: number; width: number; height: number } | null {
const marker = [...(this._parent?.querySelectorAll<HTMLElement>('.dev[data-id]') || [])]
.find((item) => item.dataset.id === id);
if (!marker) return null;
const x = Number.parseFloat(marker.style.left);
const y = Number.parseFloat(marker.style.top);
const rect = marker.getBoundingClientRect();
return Number.isFinite(x) && Number.isFinite(y)
? { x, y, width: rect.width, height: rect.height } : null;
}
protected render() {
if (!this._hovered || !this.registry || !this._runtime.topologies.length) return nothing;
const memo = this._mappedMemo;
const mapped = memo && memo.runtime === this._runtime && memo.devices === this.devices
&& memo.registry === this.registry ? memo.mapped : mapTopologies(this._runtime.topologies, this.devices, this.registry);
if (!memo || mapped !== memo.mapped) this._mappedMemo = { runtime: this._runtime, devices: this.devices, registry: this.registry, mapped };
const hover = resolveMappedTopologyHover(mapped, this.currentSpace, this._hovered);
const origin = this._position(this._hovered);
if (!origin) return nothing;
const lines = hover.lines.map((line) => ({ ...line, point: this._position(line.neighborMarkerId) }))
.filter((line) => !!line.point);
return html`
${lines.length ? svg`<svg viewBox="0 0 100 100" preserveAspectRatio="none"
aria-hidden="true" data-hp="zigbee-topology-lines">
${lines.map((line) => svg`<line x1=${origin.x} y1=${origin.y}
x2=${line.point!.x} y2=${line.point!.y}
stroke=${line.lqi === undefined ? 'rgba(145,155,165,.85)' : lqiColor(line.lqi)}
stroke-width=${line.lqi === undefined ? 2 : 2.2}
stroke-dasharray=${line.lqi === undefined ? '5 5' : nothing} opacity=".9"></line>`)}
</svg>` : nothing}
${lines.map((line) => html`<div class="halo" data-hp="zigbee-topology-neighbor"
data-id=${line.neighborMarkerId} style="left:${line.point!.x}%;top:${line.point!.y}%;width:${line.point!.width * 1.22}px;height:${line.point!.height * 1.22}px"></div>`)}
${hover.remoteCount ? html`<div class="remote" data-hp="zigbee-topology-remote"
style="left:${origin.x}%;top:${origin.y}%">${topologyT(
langOf(this.hass), 'remote_count', { n: hover.remoteCount },
)}</div>` : nothing}
`;
}
}
if (!customElements.get('hp-zigbee-topology-overlay')) {
customElements.define('hp-zigbee-topology-overlay', HpZigbeeTopologyOverlay);
}
+208
View File
@@ -0,0 +1,208 @@
import { LitElement, css, html, nothing, type PropertyValues } from 'lit';
import { langOf } from './i18n';
import { topologyT, type TopologyI18nKey } from './i18n/topology';
import {
normalizeZ2mBaseTopic, type ZigbeeTopologySettings,
} from './zigbee-topology-settings';
import { mapTopologyNodes, TOPOLOGY_STALE_MS } from './zigbee-topology';
import type { DevItem } from './types';
import type { HaRegistrySnapshot } from './ha-binding-status';
import type {
ZigbeeProviderState, ZigbeeTopologyRuntimeSnapshot,
} from './zigbee-topology-runtime';
const EMPTY_RUNTIME: ZigbeeTopologyRuntimeSnapshot = { revision: 0, topologies: [], states: {} };
export class HpZigbeeTopologySettings extends LitElement {
static properties = {
hass: { attribute: false },
value: { attribute: false },
savedEnabled: { type: Boolean, attribute: 'saved-enabled' },
devices: { attribute: false },
registry: { attribute: false },
};
hass: any;
value: ZigbeeTopologySettings = { enabled: false, z2mBaseTopics: [] };
savedEnabled = false;
devices: readonly DevItem[] = [];
registry?: HaRegistrySnapshot;
private _runtime: typeof import('./zigbee-topology-runtime') | null = null;
private _snapshot = EMPTY_RUNTIME;
private _release?: () => void;
private _topicText = 'zigbee2mqtt';
private _invalidTopic = false;
static styles = css`
:host { display: block; color: inherit; font: inherit; }
.section { margin-top: 18px; font-weight: 700; }
.toggle { display: flex; align-items: center; gap: 10px; margin-top: 10px; }
.hint, .status { color: var(--secondary-text-color, #9aa0aa); font-size: 12px; line-height: 1.45; }
.hint { margin-top: 6px; }
.providers { display: grid; gap: 14px; margin-top: 12px; padding-left: 34px; }
.provider { display: grid; gap: 7px; }
.provider-title { font-weight: 650; }
.actions { display: flex; align-items: center; flex-wrap: wrap; gap: 8px; }
textarea {
box-sizing: border-box; width: 100%; min-height: 58px; resize: vertical;
border: 1px solid var(--divider-color, #666); border-radius: 8px; padding: 8px;
color: var(--primary-text-color, inherit); background: var(--card-background-color, #202126);
font: inherit;
}
button {
display: inline-flex; align-items: center; gap: 6px; min-height: 36px;
border: 1px solid var(--divider-color, #666); border-radius: 9px; padding: 7px 11px;
color: inherit; background: transparent; cursor: pointer; font: inherit; font-weight: 600;
}
button:disabled, textarea:disabled { opacity: .5; cursor: default; }
.warning { color: var(--warning-color, #d89300); font-size: 12px; line-height: 1.45; }
`;
disconnectedCallback(): void {
this._release?.();
this._release = undefined;
super.disconnectedCallback();
}
protected updated(changed: PropertyValues<this>): void {
if (changed.has('hass')) {
this._release?.();
this._release = undefined;
this._runtime = null;
this._snapshot = EMPTY_RUNTIME;
}
if (this.savedEnabled && this._admin) void this._ensureRuntime();
}
protected willUpdate(changed: PropertyValues<this>): void {
if (changed.has('value')) {
this._topicText = (this.value.z2mBaseTopics.length ? this.value.z2mBaseTopics : ['zigbee2mqtt']).join('\n');
this._invalidTopic = false;
}
}
private get _admin(): boolean { return this.hass?.user?.is_admin === true; }
private _t(key: TopologyI18nKey, vars?: Record<string, string | number>): string {
return topologyT(langOf(this.hass), key, vars);
}
private async _ensureRuntime(): Promise<typeof import('./zigbee-topology-runtime')> {
if (!this._runtime) {
this._runtime = await import('./zigbee-topology-runtime');
this._snapshot = this._runtime.zigbeeTopologyRuntimeSnapshot(this.hass);
this._release = this._runtime.subscribeZigbeeTopology(this.hass, () => {
this._snapshot = this._runtime!.zigbeeTopologyRuntimeSnapshot(this.hass);
this.requestUpdate();
});
this.requestUpdate();
}
return this._runtime;
}
private _emit(value: ZigbeeTopologySettings): void {
if (!this._admin) return;
this.dispatchEvent(new CustomEvent('hp-topology-settings-change', {
detail: value, bubbles: true, composed: true,
}));
}
private _topics(): string[] {
return this.value.z2mBaseTopics.length ? this.value.z2mBaseTopics : ['zigbee2mqtt'];
}
private _editTopics(raw: string): void {
this._topicText = raw;
const values = raw.split(/[\r\n,]+/).map((value) => value.trim()).filter(Boolean);
const topics = values.map(normalizeZ2mBaseTopic).filter(Boolean) as string[];
this._invalidTopic = topics.length !== values.length;
if (this._invalidTopic) { this.requestUpdate(); return; }
this._emit({ ...this.value, z2mBaseTopics: [...new Set(topics)].slice(0, 8) });
}
private _status(key: string): string {
const current = this._snapshot.states[key];
if (!current) return this._t('status_idle');
if (current.phase === 'loading') return this._t('status_loading');
if (current.phase === 'error') return this._t((`error_${current.error || 'provider'}`) as TopologyI18nKey);
if (current.phase !== 'ready' || !current.obtainedAt) return this._t('status_idle');
const time = new Date(current.obtainedAt).toLocaleTimeString(langOf(this.hass), {
hour: '2-digit', minute: '2-digit',
});
const topology = this._snapshot.topologies.find((item) =>
(key === 'zha' ? item.provider === 'zha' : `z2m:${item.instanceId}` === key));
const mappingPartial = !!topology && !!this.registry
&& mapTopologyNodes(topology, this.devices, this.registry).warnings.length > 0;
if (current.partial || mappingPartial) return this._t('status_partial', { time });
if (topology && !topology.links.length) return this._t('status_no_links', { time });
return this._t(Date.now() - current.obtainedAt > TOPOLOGY_STALE_MS ? 'status_stale' : 'status_ready', { time });
}
private _busy(key: string): boolean {
return this._snapshot.states[key]?.phase === 'loading';
}
private async _readZha(): Promise<void> {
if (!this.savedEnabled || !this._admin) return;
(await this._ensureRuntime()).readZhaTopology(this.hass);
}
private async _refreshZ2m(topic: string): Promise<void> {
if (!this.savedEnabled || !this._admin) return;
(await this._ensureRuntime()).refreshZ2mTopology(this.hass, topic);
}
protected render() {
const admin = this._admin;
const enabled = this.value.enabled;
const mayLoad = admin && this.savedEnabled;
const topics = this._topics();
return html`
<div class="section">${this._t('title')}</div>
<label class="toggle">
${customElements.get('ha-switch')
? html`<ha-switch .checked=${enabled} .disabled=${!admin}
@change=${(event: Event) => this._emit({ ...this.value,
enabled: !!(event.target as HTMLInputElement).checked })}></ha-switch>`
: html`<input type="checkbox" .checked=${enabled} ?disabled=${!admin}
@change=${(event: Event) => this._emit({ ...this.value,
enabled: (event.target as HTMLInputElement).checked })} />`}
<span>${this._t('toggle')}</span>
</label>
<div class="hint">${this._t('hint')}</div>
${!admin ? html`<div class="hint">${this._t('admin_only')}</div>` : nothing}
${enabled ? html`<div class="providers">
${!this.savedEnabled ? html`<div class="hint">${this._t('save_first')}</div>` : nothing}
<div class="provider">
<div class="provider-title">${this._t('zha')}</div>
<div class="hint">${this._t('zha_hint')}</div>
<div class="actions">
<button ?disabled=${!mayLoad || this._busy('zha')} @click=${this._readZha}>
<ha-icon icon="mdi:access-point-network"></ha-icon>${this._t('zha_read')}
</button>
<span class="status">${this._status('zha')}</span>
</div>
</div>
<div class="provider">
<div class="provider-title">${this._t('z2m')}</div>
<label class="hint" for="z2m-topics">${this._t('z2m_topics')}</label>
<textarea id="z2m-topics" ?disabled=${!admin}
.value=${this._topicText} @input=${(event: Event) =>
this._editTopics((event.target as HTMLTextAreaElement).value)}></textarea>
${this._invalidTopic ? html`<div class="warning">${this._t('error_invalid_topic')}</div>` : nothing}
<div class="warning">${this._t('z2m_warning')}</div>
${topics.map((topic) => html`<div class="actions">
<button ?disabled=${!mayLoad || this._invalidTopic || this._busy(`z2m:${topic}`)}
@click=${() => this._refreshZ2m(topic)}>
<ha-icon icon="mdi:refresh"></ha-icon>${this._t('z2m_update')} · ${topic}
</button>
<span class="status">${this._status(`z2m:${topic}`)}</span>
</div>`)}
</div>
</div>` : nothing}
`;
}
}
if (!customElements.get('hp-zigbee-topology-settings')) {
customElements.define('hp-zigbee-topology-settings', HpZigbeeTopologySettings);
}
+16
View File
@@ -0,0 +1,16 @@
import { subst } from '../logic';
import type { Lang } from './registry';
import de from './topology/de.json' with { type: 'json' };
import en from './topology/en.json' with { type: 'json' };
import fr from './topology/fr.json' with { type: 'json' };
import ru from './topology/ru.json' with { type: 'json' };
export type TopologyI18nKey = keyof typeof en;
const DICTIONARIES: Record<Lang, Record<TopologyI18nKey, string>> = { en, ru, de, fr };
export function topologyT(
lang: Lang, key: TopologyI18nKey, vars?: Record<string, string | number>,
): string {
return subst(DICTIONARIES[lang]?.[key] ?? en[key] ?? key, vars);
}
+27
View File
@@ -0,0 +1,27 @@
{
"title": "Zigbee-Verbindungen",
"toggle": "Zigbee-Verbindungen beim Darüberfahren über ein Gerät anzeigen",
"hint": "Es werden nur beobachtete direkte Nachbarn angezeigt. Beim Darüberfahren werden keine Daten abgerufen.",
"admin_only": "Topologiedaten sind nur für Home-Assistant-Administratoren verfügbar.",
"save_first": "Speichere diese Einstellung, bevor Topologiedaten geladen werden.",
"zha": "ZHA",
"zha_read": "ZHA-Daten lesen",
"zha_hint": "Liest den ZHA-Topologiecache; es wird kein Funkscan gestartet.",
"z2m": "Zigbee2MQTT",
"z2m_topics": "Basistopics (eines pro Zeile)",
"z2m_update": "Karte aktualisieren",
"z2m_warning": "Ein Netzwerkscan kann 10 Sekunden bis 2 Minuten dauern und Zigbee vorübergehend verlangsamen.",
"status_idle": "Daten nicht geladen",
"status_loading": "Wird geladen…",
"status_ready": "Empfangen: {time}",
"status_stale": "Empfangen: {time} · veraltet",
"status_partial": "Empfangen: {time} · einige Knoten fehlen",
"status_no_links": "Empfangen: {time} · keine beobachteten Verbindungen gefunden",
"error_permission": "Administratorrechte sind erforderlich.",
"error_unsupported": "Dieser Anbieter oder die Home-Assistant-API ist nicht verfügbar.",
"error_timeout": "Der Anbieter hat nicht rechtzeitig geantwortet.",
"error_invalid_topic": "Prüfe das Zigbee2MQTT-Basistopic.",
"error_invalid_payload": "Der Anbieter hat nicht unterstützte Topologiedaten geliefert.",
"error_provider": "Topologiedaten konnten nicht geladen werden.",
"remote_count": "+{n} in anderen Bereichen"
}
+27
View File
@@ -0,0 +1,27 @@
{
"title": "Zigbee links",
"toggle": "Show Zigbee links when hovering over a device",
"hint": "Shows observed direct neighbours only. Nothing is fetched while you hover.",
"admin_only": "Topology data is available to Home Assistant administrators only.",
"save_first": "Save this setting before loading topology data.",
"zha": "ZHA",
"zha_read": "Read ZHA data",
"zha_hint": "Reads ZHA's cached topology; it does not start a radio scan.",
"z2m": "Zigbee2MQTT",
"z2m_topics": "Base topics (one per line)",
"z2m_update": "Update map",
"z2m_warning": "A network-map scan can take 10 seconds to 2 minutes and may temporarily reduce Zigbee responsiveness.",
"status_idle": "Data not loaded",
"status_loading": "Loading…",
"status_ready": "Received {time}",
"status_stale": "Received {time} · stale",
"status_partial": "Received {time} · some nodes were omitted",
"status_no_links": "Received {time} · no observed links found",
"error_permission": "Administrator permission is required.",
"error_unsupported": "This provider or Home Assistant API is unavailable.",
"error_timeout": "The provider did not respond in time.",
"error_invalid_topic": "Check the Zigbee2MQTT base topic.",
"error_invalid_payload": "The provider returned unsupported topology data.",
"error_provider": "Topology data could not be loaded.",
"remote_count": "+{n} in other spaces"
}
+27
View File
@@ -0,0 +1,27 @@
{
"title": "Liens Zigbee",
"toggle": "Afficher les liens Zigbee au survol d’un appareil",
"hint": "Seuls les voisins directs observés sont affichés. Le survol ne lance aucune requête.",
"admin_only": "Les données de topologie sont réservées aux administrateurs Home Assistant.",
"save_first": "Enregistrez ce réglage avant de charger les données de topologie.",
"zha": "ZHA",
"zha_read": "Lire les données ZHA",
"zha_hint": "Lit la topologie en cache de ZHA sans lancer d’analyse radio.",
"z2m": "Zigbee2MQTT",
"z2m_topics": "Topics de base (un par ligne)",
"z2m_update": "Actualiser la carte",
"z2m_warning": "L’analyse de la carte réseau peut durer de 10 secondes à 2 minutes et réduire temporairement la réactivité de Zigbee.",
"status_idle": "Données non chargées",
"status_loading": "Chargement…",
"status_ready": "Reçu : {time}",
"status_stale": "Reçu : {time} · obsolète",
"status_partial": "Reçu : {time} · certains nœuds sont omis",
"status_no_links": "Reçu : {time} · aucun lien observé trouvé",
"error_permission": "Les droits administrateur sont requis.",
"error_unsupported": "Ce fournisseur ou l’API Home Assistant n’est pas disponible.",
"error_timeout": "Le fournisseur n’a pas répondu à temps.",
"error_invalid_topic": "Vérifiez le topic de base Zigbee2MQTT.",
"error_invalid_payload": "Le fournisseur a renvoyé des données de topologie non prises en charge.",
"error_provider": "Impossible de charger les données de topologie.",
"remote_count": "+{n} dans d’autres espaces"
}
+27
View File
@@ -0,0 +1,27 @@
{
"title": "Связи Zigbee",
"toggle": "Показывать связи Zigbee при наведении на устройство",
"hint": "Показываются только наблюдаемые прямые соседи. Наведение ничего не запрашивает.",
"admin_only": "Данные топологии доступны только администраторам Home Assistant.",
"save_first": "Сохраните настройку перед загрузкой данных топологии.",
"zha": "ZHA",
"zha_read": "Прочитать данные ZHA",
"zha_hint": "Читает сохранённую топологию ZHA и не запускает радио-сканирование.",
"z2m": "Zigbee2MQTT",
"z2m_topics": "Базовые топики (по одному в строке)",
"z2m_update": "Обновить карту",
"z2m_warning": "Сканирование карты сети занимает от 10 секунд до 2 минут и временно может снизить отзывчивость Zigbee.",
"status_idle": "Данные не загружены",
"status_loading": "Загрузка…",
"status_ready": "Получено: {time}",
"status_stale": "Получено: {time} · данные устарели",
"status_partial": "Получено: {time} · часть узлов не показана",
"status_no_links": "Получено: {time} · наблюдаемые связи не найдены",
"error_permission": "Нужны права администратора.",
"error_unsupported": "Провайдер или API Home Assistant недоступен.",
"error_timeout": "Провайдер не ответил вовремя.",
"error_invalid_topic": "Проверьте базовый топик Zigbee2MQTT.",
"error_invalid_payload": "Провайдер вернул неподдерживаемые данные топологии.",
"error_provider": "Не удалось загрузить данные топологии.",
"remote_count": "+{n} в других пространствах"
}
+2
View File
@@ -261,6 +261,8 @@ export interface ServerConfig {
icon_rules?: { pattern: string; icon: string }[];
/** Only an explicit false hides the room hover information window. */
show_room_tooltip?: boolean;
/** Opt-in, admin-only contextual Zigbee diagnostics (#54). */
zigbee_topology?: { enabled?: boolean; z2m_base_topics?: string[] };
};
}
+21
View File
@@ -0,0 +1,21 @@
import { html, nothing, type TemplateResult } from 'lit';
import { zigbeeTopologySettingsOf } from './zigbee-topology-settings';
import type { HaRegistrySnapshot } from './ha-binding-status';
import type { DevItem } from './types';
export function renderZigbeeTopologyOverlay(input: {
hass: any;
settings: unknown;
devices: readonly DevItem[];
registry: HaRegistrySnapshot;
currentSpace: string;
viewKey: unknown;
view: boolean;
kiosk: boolean;
}): TemplateResult | typeof nothing {
if (!input.view || input.kiosk || input.hass?.user?.is_admin !== true
|| !zigbeeTopologySettingsOf(input.settings).enabled) return nothing;
void import('./hp-zigbee-topology-overlay');
return html`<hp-zigbee-topology-overlay .hass=${input.hass} .devices=${input.devices}
.registry=${input.registry} .currentSpace=${input.currentSpace} .viewKey=${input.viewKey}></hp-zigbee-topology-overlay>`;
}
+207
View File
@@ -0,0 +1,207 @@
import { normalizeZ2mTopology, normalizeZhaTopology, type ZigbeeTopology } from './zigbee-topology';
import { normalizeZ2mBaseTopic } from './zigbee-topology-settings';
export type ZigbeeTopologyErrorCode =
| 'permission'
| 'unsupported'
| 'timeout'
| 'invalid_topic'
| 'invalid_payload'
| 'provider';
export type ZigbeeProviderState = {
phase: 'idle' | 'loading' | 'ready' | 'error';
obtainedAt?: number;
partial?: boolean;
error?: ZigbeeTopologyErrorCode;
};
export interface ZigbeeTopologyRuntimeSnapshot {
revision: number;
topologies: ZigbeeTopology[];
states: Record<string, ZigbeeProviderState>;
}
type Cache = ZigbeeTopologyRuntimeSnapshot & {
listeners: Set<() => void>;
inflight: Map<string, Promise<void>>;
};
const caches = new WeakMap<object, Cache>();
function keyOf(hass: any): object | null {
const key = hass?.connection || hass;
return key && (typeof key === 'object' || typeof key === 'function') ? key : null;
}
function cacheOf(hass: any): Cache | null {
const key = keyOf(hass);
if (!key) return null;
let cache = caches.get(key);
if (!cache) {
cache = { revision: 0, topologies: [], states: {}, listeners: new Set(), inflight: new Map() };
caches.set(key, cache);
}
return cache;
}
function notify(cache: Cache): void {
cache.revision++;
for (const listener of cache.listeners) listener();
}
function state(cache: Cache, key: string, value: ZigbeeProviderState): void {
cache.states = { ...cache.states, [key]: value };
notify(cache);
}
function store(cache: Cache, topology: ZigbeeTopology): void {
cache.topologies = [
...cache.topologies.filter((item) => !(item.provider === topology.provider
&& item.instanceId === topology.instanceId)),
topology,
];
state(cache, topology.provider === 'zha' ? 'zha' : `z2m:${topology.instanceId}`, {
phase: 'ready', obtainedAt: topology.obtainedAt, partial: topology.warnings.length > 0,
});
}
function errorCode(error: unknown): ZigbeeTopologyErrorCode {
const code = (error as { code?: unknown } | null)?.code;
if (code === 'permission' || code === 'unsupported' || code === 'timeout'
|| code === 'invalid_topic' || code === 'invalid_payload') return code;
const message = String((error as any)?.message || '').toLowerCase();
if (message.includes('unauthor') || message.includes('permission')) return 'permission';
if (message.includes('unknown_command') || message.includes('not found')) return 'unsupported';
return 'provider';
}
function fail(code: ZigbeeTopologyErrorCode): Error & { code: ZigbeeTopologyErrorCode } {
return Object.assign(new Error(code), { code });
}
function run(cache: Cache, key: string, task: () => Promise<ZigbeeTopology>): Promise<void> {
const existing = cache.inflight.get(key);
if (existing) return existing;
state(cache, key, { phase: 'loading' });
const promise = task().then((topology) => {
if (!topology.nodes.length && topology.warnings.some((item) => item.code === 'invalid_payload')) {
throw fail('invalid_payload');
}
store(cache, topology);
}).catch((error) => {
state(cache, key, { phase: 'error', error: errorCode(error) });
}).finally(() => cache.inflight.delete(key));
cache.inflight.set(key, promise);
return promise;
}
export function zigbeeTopologyRuntimeSnapshot(hass: any): ZigbeeTopologyRuntimeSnapshot {
const cache = cacheOf(hass);
return cache
? { revision: cache.revision, topologies: cache.topologies, states: cache.states }
: { revision: 0, topologies: [], states: {} };
}
export function subscribeZigbeeTopology(hass: any, listener: () => void): () => void {
const cache = cacheOf(hass);
if (!cache) return () => undefined;
cache.listeners.add(listener);
return () => cache.listeners.delete(listener);
}
function requireAdmin(hass: any): void {
if (hass?.user?.is_admin !== true) throw fail('permission');
}
/** Read the cached ZHA graph. This must never call zha/topology/update. */
export function readZhaTopology(hass: any): Promise<void> {
const cache = cacheOf(hass);
if (!cache) return Promise.resolve();
return run(cache, 'zha', async () => {
requireAdmin(hass);
if (typeof hass?.callWS !== 'function') throw fail('unsupported');
return normalizeZhaTopology(await hass.callWS({ type: 'zha/devices' }));
});
}
function parseMessage(message: any): any {
const raw = message?.payload ?? message;
if (typeof raw !== 'string') return raw;
try { return JSON.parse(raw); } catch { return null; }
}
function transactionOf(value: any): string | null {
const transaction = value?.transaction ?? value?.data?.transaction;
return typeof transaction === 'string' || typeof transaction === 'number'
? String(transaction) : null;
}
function randomTransaction(): string {
const cryptoObj = globalThis.crypto;
if (typeof cryptoObj?.randomUUID === 'function') return `houseplan-${cryptoObj.randomUUID()}`;
return `houseplan-${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`;
}
async function withTimeout<T>(promise: Promise<T>, ms: number): Promise<T> {
let id: ReturnType<typeof globalThis.setTimeout> | undefined;
try {
return await Promise.race([
promise,
new Promise<never>((_, reject) => {
id = globalThis.setTimeout(() => reject(fail('timeout')), Math.max(1, ms));
}),
]);
} finally {
if (id !== undefined) globalThis.clearTimeout(id);
}
}
/** Explicit, completion-aware Z2M raw network-map request through HA MQTT. */
export function refreshZ2mTopology(
hass: any, baseTopic: string, timeoutMs = 150_000,
): Promise<void> {
const cache = cacheOf(hass);
if (!cache) return Promise.resolve();
const topic = normalizeZ2mBaseTopic(baseTopic);
const cacheKey = `z2m:${topic || String(baseTopic)}`;
return run(cache, cacheKey, async () => {
requireAdmin(hass);
if (!topic) throw fail('invalid_topic');
const subscribe = hass?.connection?.subscribeMessage;
if (typeof subscribe !== 'function' || typeof hass?.callService !== 'function') throw fail('unsupported');
const transaction = randomTransaction();
const deadline = Date.now() + Math.max(1, timeoutMs);
let infoResolve: (() => void) | null = null;
let responseResolve: ((value: any) => void) | null = null;
const info = new Promise<void>((resolve) => { infoResolve = resolve; });
const response = new Promise<any>((resolve) => { responseResolve = resolve; });
const unsubscribers: Array<() => void> = [];
try {
const unsubInfo = await subscribe.call(hass.connection, (message: any) => {
if (message?.retain === true && parseMessage(message)) infoResolve?.();
}, { type: 'mqtt/subscribe', topic: `${topic}/bridge/info` });
if (typeof unsubInfo === 'function') unsubscribers.push(unsubInfo);
const unsubResponse = await subscribe.call(hass.connection, (message: any) => {
if (message?.retain === true) return;
const value = parseMessage(message);
if (value && transactionOf(value) === transaction) responseResolve?.(value);
}, { type: 'mqtt/subscribe', topic: `${topic}/bridge/response/networkmap` });
if (typeof unsubResponse === 'function') unsubscribers.push(unsubResponse);
await withTimeout(info, Math.min(4000, Math.max(1, deadline - Date.now())));
await hass.callService('mqtt', 'publish', {
topic: `${topic}/bridge/request/networkmap`,
payload: JSON.stringify({ type: 'raw', routes: false, transaction }),
qos: 0,
retain: false,
});
const value = await withTimeout(response, deadline - Date.now());
if (value?.status && value.status !== 'ok') throw fail('provider');
return normalizeZ2mTopology(value, topic);
} finally {
for (const unsubscribe of unsubscribers) {
try { unsubscribe(); } catch { /* cleanup is best effort */ }
}
}
});
}
+45
View File
@@ -0,0 +1,45 @@
export interface ZigbeeTopologySettings {
enabled: boolean;
z2mBaseTopics: string[];
}
const EMPTY_SETTINGS: ZigbeeTopologySettings = Object.freeze({
enabled: false,
z2mBaseTopics: Object.freeze([]) as unknown as string[],
});
/** MQTT topic names are exact paths. Wildcards/control characters are unsafe here. */
export function normalizeZ2mBaseTopic(value: unknown): string | null {
if (typeof value !== 'string') return null;
const topic = value.trim().replace(/^\/+|\/+$/g, '').replace(/\/{2,}/g, '/');
if (!topic || topic.length > 180 || /[#+\u0000-\u001f\u007f]/.test(topic)) return null;
return topic;
}
export function zigbeeTopologySettingsOf(settings: unknown): ZigbeeTopologySettings {
const raw = (settings as { zigbee_topology?: unknown } | null | undefined)?.zigbee_topology;
if (!raw || typeof raw !== 'object') return EMPTY_SETTINGS;
const source = raw as { enabled?: unknown; z2m_base_topics?: unknown; z2mBaseTopics?: unknown };
const values = Array.isArray(source.z2m_base_topics)
? source.z2m_base_topics : Array.isArray(source.z2mBaseTopics) ? source.z2mBaseTopics : [];
const seen = new Set<string>();
const z2mBaseTopics: string[] = [];
for (const value of values) {
const topic = normalizeZ2mBaseTopic(value);
if (!topic || seen.has(topic)) continue;
seen.add(topic);
z2mBaseTopics.push(topic);
if (z2mBaseTopics.length === 8) break;
}
return { enabled: source.enabled === true, z2mBaseTopics };
}
export function writeZigbeeTopologySettings(
settings: Record<string, unknown>, value: ZigbeeTopologySettings,
): Record<string, unknown> {
const next = { ...settings };
const topics = value.z2mBaseTopics.map(normalizeZ2mBaseTopic).filter(Boolean) as string[];
if (!value.enabled && !topics.length) delete next.zigbee_topology;
else next.zigbee_topology = { enabled: value.enabled === true, z2m_base_topics: [...new Set(topics)].slice(0, 8) };
return next;
}
+316
View File
@@ -0,0 +1,316 @@
import type { DevItem } from './types';
import type { HaRegistrySnapshot } from './ha-binding-status';
import { normalizeZ2mBaseTopic } from './zigbee-topology-settings';
export type ZigbeeProvider = 'zha' | 'z2m';
export type ZigbeeRole = 'coordinator' | 'router' | 'end' | 'unknown';
export interface ZigbeeDirectionalObservation {
lqi?: number;
relationship?: string;
activeRoute?: boolean;
}
export interface ZigbeeTopologyNode {
key: string;
ieee: string;
deviceId?: string;
role: ZigbeeRole;
available?: boolean;
}
export interface ZigbeeTopologyLink {
a: string;
b: string;
aToB?: ZigbeeDirectionalObservation;
bToA?: ZigbeeDirectionalObservation;
}
export type ZigbeeTopologyWarningCode =
| 'invalid_payload'
| 'duplicate_link'
| 'self_link'
| 'unmatched_device'
| 'ambiguous_placement'
| 'provider_scan_failure';
export interface ZigbeeTopologyWarning {
code: ZigbeeTopologyWarningCode;
nodeKey?: string;
}
export interface ZigbeeTopology {
provider: ZigbeeProvider;
instanceId: string;
obtainedAt: number;
freshness: 'provider-cache' | 'fresh-scan';
nodes: ZigbeeTopologyNode[];
links: ZigbeeTopologyLink[];
warnings: ZigbeeTopologyWarning[];
}
export interface ZigbeeHoverLine {
neighborMarkerId: string;
lqi?: number;
}
export interface ZigbeeHoverResolution {
lines: ZigbeeHoverLine[];
remoteCount: number;
omittedCount: number;
}
export const TOPOLOGY_STALE_MS = 5 * 60 * 1000;
export const TOPOLOGY_MAX_PAYLOAD_BYTES = 2 * 1024 * 1024;
export const TOPOLOGY_MAX_NODES = 1000;
export const TOPOLOGY_MAX_LINKS = 6000;
export function normalizeIeee(value: unknown): string | null {
if (typeof value !== 'string' && typeof value !== 'number') return null;
let text = String(value).trim().toLowerCase();
if (/^0x[0-9a-f]{16}$/.test(text)) text = text.slice(2);
else text = text.replace(/[:-]/g, '');
return /^[0-9a-f]{16}$/.test(text) ? text : null;
}
function nodeKey(provider: ZigbeeProvider, instanceId: string, ieee: string): string {
return `${provider}:${instanceId}:${ieee}`;
}
function roleOf(value: unknown): ZigbeeRole {
const role = String(value || '').toLowerCase().replace(/[ _-]/g, '');
if (role.includes('coordinator')) return 'coordinator';
if (role.includes('router')) return 'router';
if (role.includes('enddevice') || role === 'end') return 'end';
return 'unknown';
}
function lqiOf(value: unknown): number | undefined {
const n = Number(value);
return Number.isFinite(n) && n >= 0 && n <= 255 ? Math.round(n) : undefined;
}
function observation(value: any): ZigbeeDirectionalObservation {
const lqi = lqiOf(value?.lqi ?? value?.linkquality ?? value?.link_quality);
const relationship = typeof value?.relationship === 'string'
? value.relationship.slice(0, 40) : undefined;
return { ...(lqi === undefined ? {} : { lqi }), ...(relationship ? { relationship } : {}) };
}
function boundedArray(value: unknown, max: number): any[] | null {
return Array.isArray(value) && value.length <= max ? value : null;
}
function safePayload(value: unknown): boolean {
if (typeof value === 'string') return value.length <= TOPOLOGY_MAX_PAYLOAD_BYTES;
try { return JSON.stringify(value).length <= TOPOLOGY_MAX_PAYLOAD_BYTES; } catch { return false; }
}
function pushDirectionalLink(
links: Map<string, ZigbeeTopologyLink>, warnings: ZigbeeTopologyWarning[],
from: string, to: string, value: any,
): void {
if (from === to) { warnings.push({ code: 'self_link', nodeKey: from }); return; }
const forward = from < to;
const pair = forward ? `${from}|${to}` : `${to}|${from}`;
const current = links.get(pair) || { a: forward ? from : to, b: forward ? to : from };
const field = forward ? 'aToB' : 'bToA';
if (current[field]) warnings.push({ code: 'duplicate_link', nodeKey: from });
else current[field] = observation(value);
links.set(pair, current);
}
/** Normalize cached ZHA neighbors. Route destinations deliberately do not create edges. */
export function normalizeZhaTopology(payload: unknown, now = Date.now()): ZigbeeTopology {
const warnings: ZigbeeTopologyWarning[] = [];
const rows = safePayload(payload) ? boundedArray(payload, TOPOLOGY_MAX_NODES) : null;
if (!rows) return { provider: 'zha', instanceId: 'zha', obtainedAt: now,
freshness: 'provider-cache', nodes: [], links: [], warnings: [{ code: 'invalid_payload' }] };
const nodes = new Map<string, ZigbeeTopologyNode>();
const links = new Map<string, ZigbeeTopologyLink>();
for (const row of rows) {
const ieee = normalizeIeee(row?.ieee ?? row?.ieee_address);
if (!ieee) { warnings.push({ code: 'invalid_payload' }); continue; }
const key = nodeKey('zha', 'zha', ieee);
nodes.set(key, {
key, ieee,
...(typeof row?.device_reg_id === 'string' ? { deviceId: row.device_reg_id } : {}),
role: roleOf(row?.device_type ?? row?.type),
...(typeof row?.available === 'boolean' ? { available: row.available } : {}),
});
}
for (const row of rows) {
const ieee = normalizeIeee(row?.ieee ?? row?.ieee_address);
if (!ieee) continue;
const from = nodeKey('zha', 'zha', ieee);
const neighbors = boundedArray(row?.neighbors, TOPOLOGY_MAX_LINKS);
if (!neighbors) { if (row?.neighbors != null) warnings.push({ code: 'invalid_payload', nodeKey: from }); continue; }
for (const neighbor of neighbors) {
if (links.size >= TOPOLOGY_MAX_LINKS) { warnings.push({ code: 'invalid_payload' }); break; }
const otherIeee = normalizeIeee(neighbor?.ieee ?? neighbor?.ieee_address);
if (!otherIeee) { warnings.push({ code: 'invalid_payload', nodeKey: from }); continue; }
const to = nodeKey('zha', 'zha', otherIeee);
if (!nodes.has(to)) nodes.set(to, { key: to, ieee: otherIeee, role: roleOf(neighbor?.device_type) });
pushDirectionalLink(links, warnings, from, to, neighbor);
}
}
return { provider: 'zha', instanceId: 'zha', obtainedAt: now,
freshness: 'provider-cache', nodes: [...nodes.values()], links: [...links.values()], warnings };
}
function z2mValue(payload: any): any {
let value = payload;
if (typeof value === 'string') {
if (value.length > TOPOLOGY_MAX_PAYLOAD_BYTES) return null;
try { value = JSON.parse(value); } catch { return null; }
}
value = value?.data?.value ?? value?.value ?? value?.data ?? value;
if (typeof value === 'string') {
if (value.length > TOPOLOGY_MAX_PAYLOAD_BYTES) return null;
try { value = JSON.parse(value); } catch { return null; }
}
return value;
}
function endpointIeee(value: any, nodeById: Map<string, string>): string | null {
const direct = normalizeIeee(value?.ieee_address ?? value?.ieee ?? value);
if (direct) return direct;
const id = value?.id ?? value?.network_address ?? value;
return id == null ? null : nodeById.get(String(id)) || null;
}
/** Normalize one explicit Zigbee2MQTT raw network-map response. */
export function normalizeZ2mTopology(payload: unknown, baseTopic: string, now = Date.now()): ZigbeeTopology {
const warnings: ZigbeeTopologyWarning[] = [];
const topic = normalizeZ2mBaseTopic(baseTopic) || 'invalid';
const raw = safePayload(payload) ? z2mValue(payload) : null;
const rows = boundedArray(raw?.nodes, TOPOLOGY_MAX_NODES);
const rawLinks = boundedArray(raw?.links, TOPOLOGY_MAX_LINKS);
if (!rows || !rawLinks) return { provider: 'z2m', instanceId: topic, obtainedAt: now,
freshness: 'fresh-scan', nodes: [], links: [], warnings: [{ code: 'invalid_payload' }] };
const nodes = new Map<string, ZigbeeTopologyNode>();
const nodeById = new Map<string, string>();
for (const row of rows) {
const ieee = normalizeIeee(row?.ieee_address ?? row?.ieee);
if (!ieee) { warnings.push({ code: 'invalid_payload' }); continue; }
const key = nodeKey('z2m', topic, ieee);
nodes.set(key, { key, ieee, role: roleOf(row?.type ?? row?.device_type),
...(typeof row?.failed === 'boolean' ? { available: !row.failed } : {}) });
for (const id of [row?.id, row?.network_address]) if (id != null) nodeById.set(String(id), ieee);
}
const links = new Map<string, ZigbeeTopologyLink>();
for (const row of rawLinks) {
const fromIeee = endpointIeee(row?.source, nodeById);
const toIeee = endpointIeee(row?.target, nodeById);
if (!fromIeee || !toIeee) { warnings.push({ code: 'invalid_payload' }); continue; }
const from = nodeKey('z2m', topic, fromIeee);
const to = nodeKey('z2m', topic, toIeee);
if (!nodes.has(from)) nodes.set(from, { key: from, ieee: fromIeee, role: 'unknown' });
if (!nodes.has(to)) nodes.set(to, { key: to, ieee: toIeee, role: 'unknown' });
pushDirectionalLink(links, warnings, from, to, row);
}
return { provider: 'z2m', instanceId: topic, obtainedAt: now,
freshness: 'fresh-scan', nodes: [...nodes.values()], links: [...links.values()], warnings };
}
function ieeeFromRegistryIdentifier(value: unknown): string | null {
if (typeof value !== 'string') return null;
const direct = normalizeIeee(value);
if (direct) return direct;
const match = value.toLowerCase().match(/^zigbee2mqtt(?:_bridge)?_(.+)$/);
return match ? normalizeIeee(match[1]) : null;
}
function deviceIdsForNode(node: ZigbeeTopologyNode, registry: HaRegistrySnapshot): string[] {
if (node.deviceId && registry.devices[node.deviceId]) return [node.deviceId];
const ids = new Set<string>();
for (const [id, device] of Object.entries<any>(registry.devices)) {
const identifiers = Array.isArray(device?.identifiers) ? device.identifiers : [];
if (identifiers.some((pair: any) => Array.isArray(pair)
&& ieeeFromRegistryIdentifier(pair[1]) === node.ieee)) ids.add(id);
}
if (ids.size) return [...ids];
for (const entity of Object.values<any>(registry.entities)) {
if (!entity?.device_id || typeof entity?.unique_id !== 'string') continue;
if (ieeeFromRegistryIdentifier(entity.unique_id) === node.ieee) ids.add(entity.device_id);
}
return [...ids];
}
function drawable(device: DevItem): boolean {
return !device.hidden && !device.virtual && device.bindingStatus?.kind !== 'ha_disabled'
&& device.bindingStatus?.kind !== 'orphaned' && device.bindingStatus?.kind !== 'unverified';
}
export type ZigbeeNodePlacement = { markerId: string; space: string };
export type ZigbeeMappedTopology = { topology: ZigbeeTopology; placements: Map<string, ZigbeeNodePlacement> };
/** Exact registry binding only; names, models and friendly names are never identities. */
export function mapTopologyNodes(
topology: ZigbeeTopology, devices: readonly DevItem[], registry: HaRegistrySnapshot,
): { placements: Map<string, ZigbeeNodePlacement>; warnings: ZigbeeTopologyWarning[] } {
const placements = new Map<string, ZigbeeNodePlacement>();
const warnings: ZigbeeTopologyWarning[] = [];
for (const node of topology.nodes) {
if (node.available === false) { warnings.push({ code: 'provider_scan_failure', nodeKey: node.key }); continue; }
const deviceIds = deviceIdsForNode(node, registry);
if (deviceIds.length !== 1) { warnings.push({ code: 'unmatched_device', nodeKey: node.key }); continue; }
const deviceId = deviceIds[0];
let candidates = devices.filter((item) => drawable(item)
&& item.bindingKind === 'device' && item.bindingRef === deviceId);
if (!candidates.length) candidates = devices.filter((item) => drawable(item)
&& item.bindingKind === 'entity' && !!item.bindingRef
&& registry.entities[item.bindingRef]?.device_id === deviceId);
if (candidates.length === 1) placements.set(node.key, {
markerId: candidates[0].id, space: candidates[0].space,
});
else warnings.push({ code: candidates.length > 1 ? 'ambiguous_placement' : 'unmatched_device', nodeKey: node.key });
}
return { placements, warnings };
}
export function mapTopologies(
topologies: readonly ZigbeeTopology[], devices: readonly DevItem[], registry: HaRegistrySnapshot,
): ZigbeeMappedTopology[] {
return topologies.map((topology) => ({
topology, placements: mapTopologyNodes(topology, devices, registry).placements,
}));
}
export function resolveMappedTopologyHover(
mappedTopologies: readonly ZigbeeMappedTopology[], currentSpace: string, hoveredMarkerId: string,
): ZigbeeHoverResolution {
const lines = new Map<string, ZigbeeHoverLine>();
const remote = new Set<string>();
let omittedCount = 0;
for (const { topology, placements } of mappedTopologies) {
const hoveredNodes = new Set([...placements]
.filter(([, placement]) => placement.markerId === hoveredMarkerId && placement.space === currentSpace)
.map(([key]) => key));
if (!hoveredNodes.size) continue;
for (const link of topology.links) {
const fromA = hoveredNodes.has(link.a);
const fromB = hoveredNodes.has(link.b);
if (!fromA && !fromB) continue;
const other = placements.get(fromA ? link.b : link.a);
if (!other || other.markerId === hoveredMarkerId) { omittedCount++; continue; }
if (other.space !== currentSpace) { remote.add(other.markerId); continue; }
const obs = fromA ? link.aToB : link.bToA;
const existing = lines.get(other.markerId);
if (!existing || (existing.lqi === undefined && obs?.lqi !== undefined)) {
lines.set(other.markerId, { neighborMarkerId: other.markerId, lqi: obs?.lqi });
}
}
}
return { lines: [...lines.values()], remoteCount: remote.size, omittedCount };
}
/** Resolve only the edges incident to the marker currently under the mouse. */
export function resolveTopologyHover(
topologies: readonly ZigbeeTopology[], devices: readonly DevItem[],
registry: HaRegistrySnapshot, currentSpace: string, hoveredMarkerId: string,
): ZigbeeHoverResolution {
return resolveMappedTopologyHover(
mapTopologies(topologies, devices, registry), currentSpace, hoveredMarkerId,
);
}