diff --git a/src/data/mqtt.ts b/src/data/mqtt.ts index d91b32ce13..d00f9f2415 100644 --- a/src/data/mqtt.ts +++ b/src/data/mqtt.ts @@ -7,6 +7,31 @@ export interface MQTTMessage { retain: number; } +export interface MQTTTopicDebugInfo { + topic: string; + messages: MQTTMessage[]; +} + +export interface MQTTDiscoveryDebugInfo { + topic: string; + payload: string; +} + +export interface MQTTEntityDebugInfo { + entity_id: string; + discovery_data: MQTTDiscoveryDebugInfo; + subscriptions: MQTTTopicDebugInfo[]; +} + +export interface MQTTTriggerDebugInfo { + discovery_data: MQTTDiscoveryDebugInfo; +} + +export interface MQTTDeviceDebugInfo { + entities: MQTTEntityDebugInfo[]; + triggers: MQTTTriggerDebugInfo[]; +} + export const subscribeMQTTTopic = ( hass: HomeAssistant, topic: string, @@ -26,3 +51,12 @@ export const removeMQTTDeviceEntry = ( type: "mqtt/device/remove", device_id: deviceId, }); + +export const fetchMQTTDebugInfo = ( + hass: HomeAssistant, + deviceId: string +): Promise => + hass.callWS({ + type: "mqtt/device/debug_info", + device_id: deviceId, + }); diff --git a/src/dialogs/mqtt-device-debug-info-dialog/dialog-mqtt-device-debug-info.ts b/src/dialogs/mqtt-device-debug-info-dialog/dialog-mqtt-device-debug-info.ts new file mode 100644 index 0000000000..80ce1a52ef --- /dev/null +++ b/src/dialogs/mqtt-device-debug-info-dialog/dialog-mqtt-device-debug-info.ts @@ -0,0 +1,215 @@ +import { + LitElement, + css, + html, + CSSResult, + TemplateResult, + customElement, + property, +} from "lit-element"; +import "../../components/ha-dialog"; +import "../../components/ha-switch"; +import { computeDeviceName } from "../../data/device_registry"; +import { computeStateName } from "../../common/entity/compute_state_name"; +import { haStyleDialog } from "../../resources/styles"; +import type { HaSwitch } from "../../components/ha-switch"; +import { HomeAssistant } from "../../types"; +import { MQTTDeviceDebugInfoDialogParams } from "./show-dialog-mqtt-device-debug-info"; +import { MQTTDeviceDebugInfo, fetchMQTTDebugInfo } from "../../data/mqtt"; +import "./mqtt-messages"; +import "./mqtt-discovery-payload"; + +@customElement("dialog-mqtt-device-debug-info") +class DialogMQTTDeviceDebugInfo extends LitElement { + public hass!: HomeAssistant; + + @property() private _params?: MQTTDeviceDebugInfoDialogParams; + + @property() private _debugInfo?: MQTTDeviceDebugInfo; + + @property() private _showAsYaml = true; + + @property() private _showDeserialized = true; + + public async showDialog( + params: MQTTDeviceDebugInfoDialogParams + ): Promise { + this._params = params; + fetchMQTTDebugInfo(this.hass, params.device.id).then((results) => { + this._debugInfo = results; + }); + } + + protected render(): TemplateResult { + if (!this._params || !this._debugInfo) { + return html``; + } + + return html` + +

+ ${this.hass!.localize( + "ui.dialogs.mqtt_device_debug_info.payload_display" + )} +

+ + ${this.hass!.localize( + "ui.dialogs.mqtt_device_debug_info.deserialize" + )} + + + ${this.hass!.localize( + "ui.dialogs.mqtt_device_debug_info.show_as_yaml" + )} + +

+ ${this.hass!.localize("ui.dialogs.mqtt_device_debug_info.entities")} +

+
    + ${this._debugInfo.entities.length + ? this._renderEntities() + : html` + ${this.hass!.localize( + "ui.dialogs.mqtt_device_debug_info.no_entities" + )} + `} +
+

+ ${this.hass!.localize("ui.dialogs.mqtt_device_debug_info.triggers")} +

+
    + ${this._debugInfo.triggers.length + ? this._renderTriggers() + : html` + ${this.hass!.localize( + "ui.dialogs.mqtt_device_debug_info.no_triggers" + )} + `} +
+ + ${this.hass!.localize("ui.dialogs.generic.close")} + +
+ `; + } + + private _close(): void { + this._params = undefined; + this._debugInfo = undefined; + } + + private _showAsYamlChanged(ev: Event): void { + this._showAsYaml = (ev.target as HaSwitch).checked; + } + + private _showDeserializedChanged(ev: Event): void { + this._showDeserialized = (ev.target as HaSwitch).checked; + } + + private _renderEntities(): TemplateResult { + return html` + ${this._debugInfo!.entities.map( + (entity) => html` +
  • + '${computeStateName(this.hass.states[entity.entity_id])}' + (${entity.entity_id}) +
    MQTT discovery data: +
      +
    • + Topic: + ${entity.discovery_data.topic} +
    • +
    • + + +
    • +
    + Subscribed topics: +
      + ${entity.subscriptions.map( + (topic) => html` +
    • + ${topic.topic} + + +
    • + ` + )} +
    +
  • + ` + )} + `; + } + + private _renderTriggers(): TemplateResult { + return html` + ${this._debugInfo!.triggers.map( + (trigger) => html` +
  • + Discovery topic: + ${trigger.discovery_data.topic} + + +
  • + ` + )} + `; + } + + static get styles(): CSSResult[] { + return [ + haStyleDialog, + css` + ha-dialog { + --mdc-dialog-max-width: 95%; + --mdc-dialog-min-width: 640px; + } + ha-switch { + margin: 16px; + } + `, + ]; + } +} + +declare global { + interface HTMLElementTagNameMap { + "dialog-mqtt-device-debug-info": DialogMQTTDeviceDebugInfo; + } +} diff --git a/src/dialogs/mqtt-device-debug-info-dialog/mqtt-discovery-payload.ts b/src/dialogs/mqtt-device-debug-info-dialog/mqtt-discovery-payload.ts new file mode 100644 index 0000000000..b000f5499a --- /dev/null +++ b/src/dialogs/mqtt-device-debug-info-dialog/mqtt-discovery-payload.ts @@ -0,0 +1,49 @@ +import { + LitElement, + html, + TemplateResult, + customElement, + property, +} from "lit-element"; +import { safeDump } from "js-yaml"; + +@customElement("mqtt-discovery-payload") +class MQTTDiscoveryPayload extends LitElement { + @property() public payload!: object; + + @property() public showAsYaml = false; + + @property() public summary!: string; + + @property() private _open = false; + + protected render(): TemplateResult { + return html` +
    + + ${this.summary} + + ${this._open ? this._renderPayload() : ""} +
    + `; + } + + private _renderPayload(): TemplateResult { + const payload = this.payload; + return html` + ${this.showAsYaml + ? html`
    ${safeDump(payload)}
    ` + : html`
    ${JSON.stringify(payload, null, 2)}
    `} + `; + } + + private _handleToggle(ev) { + this._open = ev.target.open; + } +} + +declare global { + interface HTMLElementTagNameMap { + "mqtt-discovery-payload": MQTTDiscoveryPayload; + } +} diff --git a/src/dialogs/mqtt-device-debug-info-dialog/mqtt-messages.ts b/src/dialogs/mqtt-device-debug-info-dialog/mqtt-messages.ts new file mode 100644 index 0000000000..c3f3b799ef --- /dev/null +++ b/src/dialogs/mqtt-device-debug-info-dialog/mqtt-messages.ts @@ -0,0 +1,130 @@ +import { + LitElement, + html, + TemplateResult, + customElement, + property, +} from "lit-element"; +import { safeDump } from "js-yaml"; +import { MQTTMessage } from "../../data/mqtt"; + +@customElement("mqtt-messages") +class MQTTMessages extends LitElement { + @property() public messages!: MQTTMessage[]; + + @property() public showAsYaml = false; + + @property() public showDeserialized = false; + + @property() public subscribedTopic!: string; + + @property() public summary!: string; + + @property() private _open = false; + + @property() private _payloadsJson = new WeakMap(); + + @property() private _showTopic = false; + + protected firstUpdated(): void { + this.messages.forEach((message) => { + // If any message's topic differs from the subscribed topic, show topics + payload + if (this.subscribedTopic !== message.topic) { + this._showTopic = true; + } + }); + } + + protected render(): TemplateResult { + return html` +
    + + ${this.summary} + + ${this._open + ? html` +
      + ${this.messages.map( + (message) => html` +
    • + ${this._renderSingleMessage(message)} +
    • + ` + )} +
    + ` + : ""} +
    + `; + } + + private _renderSingleMessage(message): TemplateResult { + const topic = message.topic; + return this._showTopic + ? html` +
      +
    • + Topic: ${topic} +
    • +
    • + Payload: ${this._renderSinglePayload(message)} +
    • +
    + ` + : this._renderSinglePayload(message); + } + + private _renderSinglePayload(message): TemplateResult { + let json; + + if (this.showDeserialized) { + if (!this._payloadsJson.has(message)) { + json = this._tryParseJson(message.payload); + this._payloadsJson.set(message, json); + } else { + json = this._payloadsJson.get(message); + } + } + + return json + ? html` + ${this.showAsYaml + ? html`
    ${safeDump(json)}
    ` + : html`
    ${JSON.stringify(json, null, 2)}
    `} + ` + : html` ${message.payload} `; + } + + private _tryParseJson(payload) { + let jsonPayload = null; + let o = payload; + + // If the payload is a string, determine if the payload is valid JSON and if it + // is, assign the object representation to this._payloadJson. + if (typeof payload === "string") { + try { + o = JSON.parse(payload); + } catch (e) { + o = null; + } + } + // Handle non-exception-throwing cases: + // Neither JSON.parse(false) or JSON.parse(1234) throw errors, hence the type-checking, + // but... JSON.parse(null) returns null, and typeof null === "object", + // so we must check for that, too. Thankfully, null is falsey, so this suffices: + if (o && typeof o === "object") { + jsonPayload = o; + } + return jsonPayload; + } + + private _handleToggle(ev) { + this._open = ev.target.open; + } +} + +declare global { + interface HTMLElementTagNameMap { + "mqtt-messages": MQTTMessages; + } +} diff --git a/src/dialogs/mqtt-device-debug-info-dialog/show-dialog-mqtt-device-debug-info.ts b/src/dialogs/mqtt-device-debug-info-dialog/show-dialog-mqtt-device-debug-info.ts new file mode 100644 index 0000000000..92fe6554cc --- /dev/null +++ b/src/dialogs/mqtt-device-debug-info-dialog/show-dialog-mqtt-device-debug-info.ts @@ -0,0 +1,22 @@ +import { fireEvent } from "../../common/dom/fire_event"; +import { DeviceRegistryEntry } from "../../data/device_registry"; + +export interface MQTTDeviceDebugInfoDialogParams { + device: DeviceRegistryEntry; +} + +export const loadMQTTDeviceDebugInfoDialog = () => + import( + /* webpackChunkName: "dialog-mqtt-device-debug-info" */ "./dialog-mqtt-device-debug-info" + ); + +export const showMQTTDeviceDebugInfoDialog = ( + element: HTMLElement, + mqttDeviceInfoParams: MQTTDeviceDebugInfoDialogParams +): void => { + fireEvent(element, "show-dialog", { + dialogTag: "dialog-mqtt-device-debug-info", + dialogImport: loadMQTTDeviceDebugInfoDialog, + dialogParams: mqttDeviceInfoParams, + }); +}; diff --git a/src/panels/config/devices/device-detail/ha-device-card-mqtt.ts b/src/panels/config/devices/device-detail/ha-device-card-mqtt.ts index cce083ac15..c2d1e9e824 100644 --- a/src/panels/config/devices/device-detail/ha-device-card-mqtt.ts +++ b/src/panels/config/devices/device-detail/ha-device-card-mqtt.ts @@ -1,16 +1,17 @@ -import { - CSSResult, - customElement, - html, - LitElement, - property, - TemplateResult, -} from "lit-element"; import { DeviceRegistryEntry } from "../../../../data/device_registry"; import { removeMQTTDeviceEntry } from "../../../../data/mqtt"; +import { + LitElement, + html, + customElement, + property, + TemplateResult, + CSSResult, +} from "lit-element"; import { showConfirmationDialog } from "../../../../dialogs/generic/show-dialog-box"; -import { haStyle } from "../../../../resources/styles"; +import { showMQTTDeviceDebugInfoDialog } from "../../../../dialogs/mqtt-device-debug-info-dialog/show-dialog-mqtt-device-debug-info"; import { HomeAssistant } from "../../../../types"; +import { haStyle } from "../../../../resources/styles"; @customElement("ha-device-card-mqtt") export class HaDeviceCardMqtt extends LitElement { @@ -20,6 +21,9 @@ export class HaDeviceCardMqtt extends LitElement { protected render(): TemplateResult { return html` + + MQTT Info + ${this.hass.localize("ui.panel.config.devices.delete")} @@ -38,6 +42,11 @@ export class HaDeviceCardMqtt extends LitElement { await removeMQTTDeviceEntry(this.hass!, this.device.id); } + private async _showDebugInfo(): Promise { + const device = this.device; + await showMQTTDeviceDebugInfoDialog(this, { device }); + } + static get styles(): CSSResult { return haStyle; } diff --git a/src/translations/en.json b/src/translations/en.json index 9f9df680cc..26ddad1a5e 100755 --- a/src/translations/en.json +++ b/src/translations/en.json @@ -456,6 +456,17 @@ }, "domain_toggler": { "title": "Toggle Domains" + }, + "mqtt_device_debug_info": { + "title": "{device} debug info", + "deserialize": "Attempt to parse MQTT messages as JSON", + "entities": "Entities", + "no_entities": "No entities", + "no_triggers": "No triggers", + "payload_display": "Payload display", + "recent_messages": "{n} most recently received message(s)", + "show_as_yaml": "Show as YAML", + "triggers": "Triggers" } }, "duration": {