mirror of
https://github.com/Matysh/houseplan-card
synced 2026-10-03 13:18:58 +00:00
feat: add contextual Zigbee topology hover
Issue: #54 User-Visible: yes
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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} в других пространствах"
|
||||
}
|
||||
@@ -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[] };
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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>`;
|
||||
}
|
||||
@@ -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 */ }
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user