import { mdiAbTesting, mdiAlertOctagon, mdiArrowDecision, mdiArrowUp, mdiAsterisk, mdiCallMissed, mdiCallReceived, mdiCallSplit, mdiCheckboxBlankOutline, mdiCheckboxMarkedOutline, mdiChevronDown, mdiChevronRight, mdiChevronUp, mdiClose, mdiCloseOctagon, mdiCodeBrackets, mdiDevices, mdiExclamation, mdiRefresh, mdiShuffleDisabled, mdiTimerOutline, mdiTrafficLight, } from "@mdi/js"; import { css, html, LitElement, PropertyValues } from "lit"; import { customElement, property } from "lit/decorators"; import { fireEvent } from "../../common/dom/fire_event"; import { ensureArray } from "../../common/ensure-array"; import { Condition, Trigger } from "../../data/automation"; import { Action, ChooseAction, DelayAction, DeviceAction, EventAction, IfAction, ParallelAction, RepeatAction, SceneAction, ServiceAction, WaitAction, WaitForTriggerAction, } from "../../data/script"; import { ChooseActionTraceStep, ConditionTraceStep, IfActionTraceStep, StopActionTraceStep, TraceExtended, } from "../../data/trace"; import "../ha-icon-button"; import "./hat-graph-branch"; import { BRANCH_HEIGHT, NODE_SIZE, SPACING } from "./hat-graph-const"; import "./hat-graph-node"; import "./hat-graph-spacer"; export interface NodeInfo { path: string; config: any; } declare global { interface HASSDomEvents { "graph-node-selected": NodeInfo; } } @customElement("hat-script-graph") export class HatScriptGraph extends LitElement { @property({ attribute: false }) public trace!: TraceExtended; @property({ attribute: false }) public selected?: string; public renderedNodes: Record = {}; public trackedNodes: Record = {}; private selectNode(config, path) { return () => { fireEvent(this, "graph-node-selected", { config, path }); }; } private render_trigger(config: Trigger, i: number) { const path = `trigger/${i}`; const track = this.trace && path in this.trace.trace; this.renderedNodes[path] = { config, path }; if (track) { this.trackedNodes[path] = this.renderedNodes[path]; } return html` `; } private render_condition(config: Condition, i: number) { const path = `condition/${i}`; this.renderedNodes[path] = { config, path }; if (this.trace && path in this.trace.trace) { this.trackedNodes[path] = this.renderedNodes[path]; } return this.render_condition_node(config, path); } private typeRenderers = { condition: this.render_condition_node, delay: this.render_delay_node, event: this.render_event_node, scene: this.render_scene_node, service: this.render_service_node, wait_template: this.render_wait_node, wait_for_trigger: this.render_wait_node, repeat: this.render_repeat_node, choose: this.render_choose_node, device_id: this.render_device_node, if: this.render_if_node, stop: this.render_stop_node, parallel: this.render_parallel_node, other: this.render_other_node, }; private render_action_node(node: Action, path: string, graphStart = false) { const type = Object.keys(this.typeRenderers).find((key) => key in node) || "other"; this.renderedNodes[path] = { config: node, path }; if (this.trace && path in this.trace.trace) { this.trackedNodes[path] = this.renderedNodes[path]; } return this.typeRenderers[type].bind(this)(node, path, graphStart); } private render_choose_node( config: ChooseAction, path: string, graphStart = false ) { const trace = this.trace.trace[path] as ChooseActionTraceStep[] | undefined; const trace_path = trace ? trace.map((trc) => trc.result === undefined || trc.result.choice === "default" ? "default" : trc.result.choice ) : []; const track_default = trace_path.includes("default"); return html` ${config.choose ? ensureArray(config.choose)?.map((branch, i) => { const branch_path = `${path}/choose/${i}`; const track_this = trace_path.includes(i); this.renderedNodes[branch_path] = { config, path: branch_path }; if (track_this) { this.trackedNodes[branch_path] = this.renderedNodes[branch_path]; } return html`
${branch.sequence !== null ? ensureArray(branch.sequence).map((action, j) => this.render_action_node( action, `${branch_path}/sequence/${j}` ) ) : ""}
`; }) : ""}
${config.default !== null ? ensureArray(config.default)?.map((action, i) => this.render_action_node(action, `${path}/default/${i}`) ) : ""}
`; } private render_if_node(config: IfAction, path: string, graphStart = false) { const trace = this.trace.trace[path] as IfActionTraceStep[] | undefined; const result = trace?.[0].result?.choice; return html` ${config.else ? html`
${ensureArray(config.else).map((action, j) => this.render_action_node(action, `${path}/else/${j}`) )}
` : html``}
${ensureArray(config.then).map((action, j) => this.render_action_node(action, `${path}/then/${j}`) )}
`; } private render_condition_node( node: Condition, path: string, graphStart = false ) { const trace = this.trace.trace[path] as ConditionTraceStep[] | undefined; let track = false; let trackPass = false; let trackFailed = false; if (trace) { for (const trc of trace) { if (trc.result) { track = true; if (trc.result.result) { trackPass = true; } else { trackFailed = true; } } if (trackPass && trackFailed) { break; } } } return html`
`; } private render_delay_node( node: DelayAction, path: string, graphStart = false ) { return html` `; } private render_device_node( node: DeviceAction, path: string, graphStart = false ) { return html` `; } private render_event_node( node: EventAction, path: string, graphStart = false ) { return html` `; } private render_repeat_node( node: RepeatAction, path: string, graphStart = false ) { const trace: any = this.trace.trace[path]; const repeats = this.trace?.trace[`${path}/repeat/sequence/0`]?.length; return html` 1} ?active=${this.selected === path} nofocus .badge=${repeats > 1 ? repeats : undefined} >
${ensureArray(node.repeat.sequence).map((action, i) => this.render_action_node(action, `${path}/repeat/sequence/${i}`) )}
`; } private render_scene_node( node: SceneAction, path: string, graphStart = false ) { return html` `; } private render_service_node( node: ServiceAction, path: string, graphStart = false ) { return html` `; } private render_wait_node( node: WaitAction | WaitForTriggerAction, path: string, graphStart = false ) { return html` `; } private render_parallel_node( node: ParallelAction, path: string, graphStart = false ) { const trace: any = this.trace.trace[path]; return html` ${ensureArray(node.parallel).map((action, i) => this.render_action_node(action, `${path}/parallel/${i}/0`) )} `; } private render_stop_node(node: Action, path: string, graphStart = false) { const trace = this.trace.trace[path] as StopActionTraceStep[] | undefined; return html` `; } private render_other_node(node: Action, path: string, graphStart = false) { return html` `; } protected render() { const paths = Object.keys(this.trackedNodes); const trigger_nodes = "trigger" in this.trace.config ? ensureArray(this.trace.config.trigger).map((trigger, i) => this.render_trigger(trigger, i) ) : undefined; try { return html`
${trigger_nodes ? html` ${trigger_nodes} ` : ""} ${"condition" in this.trace.config ? html`${ensureArray(this.trace.config.condition)?.map( (condition, i) => this.render_condition(condition, i) )}` : ""} ${"action" in this.trace.config ? html`${ensureArray(this.trace.config.action).map((action, i) => this.render_action_node(action, `action/${i}`) )}` : ""} ${"sequence" in this.trace.config ? html`${ensureArray(this.trace.config.sequence).map((action, i) => this.render_action_node(action, `sequence/${i}`, i === 0) )}` : ""}
`; } catch (err: any) { if (__DEV__) { // eslint-disable-next-line no-console console.log("Error creating script graph:", err); } return html`
Error rendering graph. Please download trace and share with the developers.
`; } } public willUpdate(changedProps: PropertyValues) { super.willUpdate(changedProps); if (changedProps.has("trace")) { this.renderedNodes = {}; this.trackedNodes = {}; } } protected updated(changedProps: PropertyValues) { super.updated(changedProps); if (!changedProps.has("trace")) { return; } // If trace changed and we have no or an invalid selection, select first option. if (!this.selected || !(this.selected in this.trackedNodes)) { const firstNode = this.trackedNodes[Object.keys(this.trackedNodes)[0]]; if (firstNode) { fireEvent(this, "graph-node-selected", firstNode); } } if (this.trace) { const sortKeys = Object.keys(this.trace.trace); const keys = Object.keys(this.renderedNodes).sort( (a, b) => sortKeys.indexOf(a) - sortKeys.indexOf(b) ); const sortedTrackedNodes = {}; const sortedRenderedNodes = {}; for (const key of keys) { sortedRenderedNodes[key] = this.renderedNodes[key]; if (key in this.trackedNodes) { sortedTrackedNodes[key] = this.trackedNodes[key]; } } this.renderedNodes = sortedRenderedNodes; this.trackedNodes = sortedTrackedNodes; } } private _previousTrackedNode() { const nodes = Object.keys(this.trackedNodes); const prevIndex = nodes.indexOf(this.selected!) - 1; if (prevIndex >= 0) { fireEvent( this, "graph-node-selected", this.trackedNodes[nodes[prevIndex]] ); } } private _nextTrackedNode() { const nodes = Object.keys(this.trackedNodes); const nextIndex = nodes.indexOf(this.selected!) + 1; if (nextIndex < nodes.length) { fireEvent( this, "graph-node-selected", this.trackedNodes[nodes[nextIndex]] ); } } static get styles() { return css` :host { display: flex; --stroke-clr: var(--stroke-color, var(--secondary-text-color)); --active-clr: var(--active-color, var(--primary-color)); --track-clr: var(--track-color, var(--accent-color)); --hover-clr: var(--hover-color, var(--primary-color)); --disabled-clr: var(--disabled-color, var(--disabled-text-color)); --default-trigger-color: 3, 169, 244; --rgb-trigger-color: var(--trigger-color, var(--default-trigger-color)); --background-clr: var(--background-color, white); --default-icon-clr: var(--icon-color, black); --icon-clr: var(--stroke-clr); --hat-graph-spacing: ${SPACING}px; --hat-graph-node-size: ${NODE_SIZE}px; --hat-graph-branch-height: ${BRANCH_HEIGHT}px; } .graph-container { display: flex; flex-direction: column; align-items: center; } .actions { display: flex; flex-direction: column; } .parent { margin-left: 8px; margin-top: 16px; } .error { padding: 16px; max-width: 300px; } `; } } declare global { interface HTMLElementTagNameMap { "hat-script-graph": HatScriptGraph; } }