From 5156c6722653a5d4c8c03470f40384d05e81355d Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Tue, 23 Mar 2021 09:06:59 -0700 Subject: [PATCH] Refactor trace rendering (#8693) Co-authored-by: Bram Kragten --- src/components/trace/hat-trace.ts | 436 +++++++++++++++++++++--------- src/data/automation_debug.ts | 2 +- src/data/script.ts | 27 +- 3 files changed, 326 insertions(+), 139 deletions(-) diff --git a/src/components/trace/hat-trace.ts b/src/components/trace/hat-trace.ts index 76d671e93e..402b57b704 100644 --- a/src/components/trace/hat-trace.ts +++ b/src/components/trace/hat-trace.ts @@ -9,8 +9,8 @@ import { } from "lit-element"; import { formatDateTimeWithSeconds } from "../../common/datetime/format_date_time"; import { - ActionTrace, AutomationTraceExtended, + ChooseActionTrace, getDataFromPath, } from "../../data/automation_debug"; import { HomeAssistant } from "../../types"; @@ -24,16 +24,300 @@ import { mdiStopCircleOutline, } from "@mdi/js"; import { LogbookEntry } from "../../data/logbook"; -import { describeAction } from "../../data/script"; +import { getActionType } from "../../data/script"; import relativeTime from "../../common/datetime/relative_time"; const LOGBOOK_ENTRIES_BEFORE_FOLD = 2; const pathToName = (path: string) => path.split("/").join(" "); +/* eslint max-classes-per-file: "off" */ + // Report time entry when more than this time has passed const SIGNIFICANT_TIME_CHANGE = 5000; // 5 seconds +const isSignificantTimeChange = (a: Date, b: Date) => + Math.abs(b.getTime() - a.getTime()) > SIGNIFICANT_TIME_CHANGE; + +class RenderedTimeTracker { + private lastReportedTime: Date; + + constructor( + private hass: HomeAssistant, + private entries: TemplateResult[], + trace: AutomationTraceExtended + ) { + this.lastReportedTime = new Date(trace.timestamp.start); + } + + setLastReportedTime(date: Date) { + this.lastReportedTime = date; + } + + renderTime(from: Date, to: Date): void { + this.entries.push(html` + + ${relativeTime(from, this.hass.localize, { + compareTime: to, + includeTense: false, + })} + later + + `); + this.lastReportedTime = to; + } + + maybeRenderTime(timestamp: Date): boolean { + if (!isSignificantTimeChange(timestamp, this.lastReportedTime)) { + this.lastReportedTime = timestamp; + return false; + } + + this.renderTime(this.lastReportedTime, timestamp); + return true; + } +} + +class LogbookRenderer { + private curIndex: number; + + private pendingItems: Array<[Date, LogbookEntry]> = []; + + constructor( + private entries: TemplateResult[], + private timeTracker: RenderedTimeTracker, + private logbookEntries: LogbookEntry[] + ) { + // Skip the "automation got triggered item" + this.curIndex = + logbookEntries.length > 0 && logbookEntries[0].domain === "automation" + ? 1 + : 0; + } + + get curItem() { + return this.logbookEntries[this.curIndex]; + } + + get hasNext() { + return this.curIndex !== this.logbookEntries.length; + } + + maybeRenderItem() { + const logbookEntry = this.curItem; + this.curIndex++; + const entryDate = new Date(logbookEntry.when); + + if (this.pendingItems.length === 0) { + this.pendingItems.push([entryDate, logbookEntry]); + return; + } + + const previousEntryDate = this.pendingItems[ + this.pendingItems.length - 1 + ][0]; + + // If logbook entry is too long after the last one, + // add a time passed label + if (isSignificantTimeChange(previousEntryDate, entryDate)) { + this._renderLogbookEntries(); + this.timeTracker.renderTime(previousEntryDate, entryDate); + } + + this.pendingItems.push([entryDate, logbookEntry]); + } + + flush() { + if (this.pendingItems.length > 0) { + this._renderLogbookEntries(); + } + } + + private _renderLogbookEntries() { + this.timeTracker.maybeRenderTime(this.pendingItems[0][0]); + + const parts: TemplateResult[] = []; + + let i; + + for ( + i = 0; + i < Math.min(this.pendingItems.length, LOGBOOK_ENTRIES_BEFORE_FOLD); + i++ + ) { + parts.push(this._renderLogbookEntryHelper(this.pendingItems[i][1])); + } + + let moreItems: TemplateResult[] | undefined; + + // If we didn't render all items, push rest into `moreItems` + if (i < this.pendingItems.length) { + moreItems = []; + for (; i < this.pendingItems.length; i++) { + moreItems.push(this._renderLogbookEntryHelper(this.pendingItems[i][1])); + } + } + + this.entries.push(html` + + ${parts} + + `); + + // Clear rendered items. + this.timeTracker.setLastReportedTime( + this.pendingItems[this.pendingItems.length - 1][0] + ); + this.pendingItems = []; + } + + private _renderLogbookEntryHelper(entry: LogbookEntry) { + return html`${entry.name} (${entry.entity_id}) turned ${entry.state}
`; + } +} + +class ActionRenderer { + private curIndex = 0; + + private keys: string[]; + + constructor( + private entries: TemplateResult[], + private trace: AutomationTraceExtended, + private timeTracker: RenderedTimeTracker + ) { + this.keys = Object.keys(trace.action_trace); + } + + get curItem() { + return this._getItem(this.curIndex); + } + + get hasNext() { + return this.curIndex !== this.keys.length; + } + + renderItem() { + this.curIndex = this._renderItem(this.curIndex); + } + + private _getItem(index: number) { + return this.trace.action_trace[this.keys[index]]; + } + + private _renderItem( + index: number, + actionType?: ReturnType + ): number { + const value = this._getItem(index); + const timestamp = new Date(value[0].timestamp); + + this.timeTracker.maybeRenderTime(timestamp); + + const path = value[0].path; + let data; + try { + data = getDataFromPath(this.trace.config, path); + } catch (err) { + this.entries.push( + html`Unable to extract path ${path}. Download trace and report as bug` + ); + return index + 1; + } + + const isTopLevel = path.split("/").length === 2; + + if (!isTopLevel && !actionType) { + this._renderEntry(path.replace(/\//g, " ")); + return index + 1; + } + + if (!actionType) { + actionType = getActionType(data); + } + + if (actionType === "choose") { + return this._handleChoose(index); + } + + this._renderEntry(data.alias || actionType); + return index + 1; + } + + private _handleChoose(index: number): number { + // startLevel: choose root config + + // +1: 'default + // +2: executed sequence + + // +1: 'choose' + // +2: current choice + + // +3: 'conditions' + // +4: evaluated condition + + // +3: 'sequence' + // +4: executed sequence + + const startLevel = this.keys[index].split("/").length - 1; + + const chooseTrace = this._getItem(index)[0] as ChooseActionTrace; + const defaultExecuted = chooseTrace.result.choice === "default"; + + if (defaultExecuted) { + this._renderEntry(`Choose: Default action executed`); + } else { + this._renderEntry(`Choose: Choice ${chooseTrace.result.choice} executed`); + } + + let i; + + // Skip over conditions + for (i = index + 1; i < this.keys.length; i++) { + const parts = this.keys[i].split("/"); + + // We're done if no more sequence in current level + if (parts.length <= startLevel) { + return i; + } + + // We're going to skip all conditions + if (parts[startLevel + 3] === "sequence") { + break; + } + } + + // Render choice + for (; i < this.keys.length; i++) { + const path = this.keys[i]; + const parts = path.split("/"); + + // We're done if no more sequence in current level + if (parts.length <= startLevel) { + return i; + } + + // We know it's an action sequence, so force the type like that + // for rendering. + this._renderItem(i, getActionType(this._getDataFromPath(path))); + } + + return i; + } + + private _renderEntry(description: string) { + this.entries.push(html` + + ${description} + + `); + } + + private _getDataFromPath(path: string) { + return getDataFromPath(this.trace.config, path); + } +} + @customElement("hat-trace") export class HaAutomationTracer extends LitElement { @property({ attribute: false }) public hass!: HomeAssistant; @@ -77,84 +361,44 @@ export class HaAutomationTracer extends LitElement { } if (this.trace.action_trace && this.logbookEntries) { - const actionTraces = Object.values(this.trace.action_trace); - - let logbookIndex = 0; - let actionTraceIndex = 0; - let lastReportedTime = new Date(this.trace.timestamp.start); - - const maybeRenderTime = (nextItemTimestamp: Date) => { - if ( - nextItemTimestamp.getTime() - lastReportedTime.getTime() < - SIGNIFICANT_TIME_CHANGE - ) { - return; - } - - entries.push(html` - - ${relativeTime(lastReportedTime, this.hass.localize, { - compareTime: nextItemTimestamp, - includeTense: false, - })} - later - - `); - lastReportedTime = nextItemTimestamp; - }; - - let groupedLogbookItems: LogbookEntry[] = []; - - while ( - logbookIndex < this.logbookEntries.length && - actionTraceIndex < actionTraces.length - ) { - // Find next item. - - // Skip the "automation got triggered item" - if ( - logbookIndex === 0 && - this.logbookEntries[0].domain === "automation" - ) { - logbookIndex++; - continue; - } + const timeTracker = new RenderedTimeTracker( + this.hass, + entries, + this.trace + ); + const logbookRenderer = new LogbookRenderer( + entries, + timeTracker, + this.logbookEntries + ); + const actionRenderer = new ActionRenderer( + entries, + this.trace, + timeTracker + ); + while (logbookRenderer.hasNext && actionRenderer.hasNext) { // Find next item time-wise. - const logbookItem = this.logbookEntries[logbookIndex]; - const actionTrace = actionTraces[actionTraceIndex]; + const logbookItem = logbookRenderer.curItem; + const actionTrace = actionRenderer.curItem; const actionTimestamp = new Date(actionTrace[0].timestamp); if (new Date(logbookItem.when) > actionTimestamp) { - actionTraceIndex++; - if (groupedLogbookItems.length > 0) { - maybeRenderTime(new Date(groupedLogbookItems[0].when)); - entries.push(this._renderLogbookEntries(groupedLogbookItems)); - groupedLogbookItems = []; - } - maybeRenderTime(actionTimestamp); - entries.push(this._renderActionTrace(actionTrace)); + logbookRenderer.flush(); + actionRenderer.renderItem(); } else { - logbookIndex++; - groupedLogbookItems.push(logbookItem); + logbookRenderer.maybeRenderItem(); } } - while (logbookIndex < this.logbookEntries.length) { - groupedLogbookItems.push(this.logbookEntries[logbookIndex]); - logbookIndex++; + while (logbookRenderer.hasNext) { + logbookRenderer.maybeRenderItem(); } - if (groupedLogbookItems.length > 0) { - maybeRenderTime(new Date(groupedLogbookItems[0].when)); - entries.push(this._renderLogbookEntries(groupedLogbookItems)); - } + logbookRenderer.flush(); - while (actionTraceIndex < actionTraces.length) { - const trace = actionTraces[actionTraceIndex]; - maybeRenderTime(new Date(trace[0].timestamp)); - entries.push(this._renderActionTrace(trace)); - actionTraceIndex++; + while (actionRenderer.hasNext) { + actionRenderer.renderItem(); } } @@ -188,62 +432,6 @@ export class HaAutomationTracer extends LitElement { return html`${entries}`; } - private _renderLogbookEntryHelper(entry: LogbookEntry) { - return html`${entry.name} (${entry.entity_id}) turned ${entry.state}
`; - } - - private _renderLogbookEntries(entries: LogbookEntry[]) { - const parts: TemplateResult[] = []; - - let i; - - for ( - i = 0; - i < Math.min(entries.length, LOGBOOK_ENTRIES_BEFORE_FOLD); - i++ - ) { - parts.push(this._renderLogbookEntryHelper(entries[i])); - } - - let moreItems: TemplateResult[] | undefined; - - if (i < entries.length) { - moreItems = []; - for (i = 0; i < entries.length; i++) { - moreItems.push(this._renderLogbookEntryHelper(entries[i])); - } - } - - return html` - - ${parts} - - `; - } - - private _renderActionTrace(value: ActionTrace[]) { - const path = value[0].path; - let data; - try { - data = getDataFromPath(this.trace!.config, path); - } catch (err) { - return html`Unable to extract path ${path}. Download trace and report as - bug`; - } - - const description = - // Top-level we know it's an action - path.split("/").length === 2 - ? data.alias || describeAction(data, this.hass.localize) - : path.replace(/\//g, " "); - - return html` - - ${description} - - `; - } - static get styles(): CSSResult[] { return [ css` diff --git a/src/data/automation_debug.ts b/src/data/automation_debug.ts index a29b874556..f5fca26c84 100644 --- a/src/data/automation_debug.ts +++ b/src/data/automation_debug.ts @@ -27,7 +27,7 @@ export interface CallServiceActionTrace extends BaseTrace { } export interface ChooseActionTrace extends BaseTrace { - result: { choice: number }; + result: { choice: number | "default" }; } export interface ChooseChoiceActionTrace extends BaseTrace { diff --git a/src/data/script.ts b/src/data/script.ts index bce5274c2b..da1b8f525f 100644 --- a/src/data/script.ts +++ b/src/data/script.ts @@ -5,7 +5,6 @@ import { } from "home-assistant-js-websocket"; import { computeObjectId } from "../common/entity/compute_object_id"; import { navigate } from "../common/navigate"; -import { LocalizeFunc } from "../common/translations/localize"; import { HomeAssistant } from "../types"; import { Condition, Trigger } from "./automation"; @@ -165,40 +164,40 @@ export const getScriptEditorInitData = () => { return data; }; -export const describeAction = (action: Action, _localize: LocalizeFunc) => { +export const getActionType = (action: Action) => { // Check based on config_validation.py#determine_script_action if ("delay" in action) { - return "Delay"; + return "delay"; } if ("wait_template" in action) { - return "Wait"; + return "wait_template"; } if ("condition" in action) { - return "Check condition"; + return "check_condition"; } if ("event" in action) { - return "Fire event"; + return "fire_event"; } if ("device_id" in action) { - return "Run Device Action"; + return "device_action"; } if ("scene" in action) { - return "Activate a scene"; + return "activate_scene"; } if ("repeat" in action) { - return "Repeat an action multiple times"; + return "repeat"; } if ("choose" in action) { - return "Choose an action"; + return "choose"; } if ("wait_for_trigger" in action) { - return "Wait for a trigger"; + return "wait_for_trigger"; } if ("variables" in action) { - return "Define variables"; + return "variables"; } if ("service" in action) { - return "Call service"; + return "service"; } - return "Unknown action"; + return "unknown"; };