Refactoring automation trace graphs (#8763)

Co-authored-by: Paulus Schoutsen <balloob@gmail.com>
Co-authored-by: Bram Kragten <mail@bramkragten.nl>
This commit is contained in:
Thomas Lovén 2021-03-31 15:09:00 +02:00 committed by GitHub
parent 20858db96d
commit e714f32737
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
10 changed files with 828 additions and 868 deletions

View File

@ -0,0 +1,2 @@
export const strStartsWith = (value: string, search: string) =>
value.substring(0, search.length) === search;

View File

@ -0,0 +1,187 @@
import { css, customElement, LitElement, property, svg } from "lit-element";
import { NODE_SIZE, SPACING } from "./hat-graph";
@customElement("hat-graph-node")
export class HatGraphNode extends LitElement {
@property() iconPath?: string;
@property({ reflect: true, type: Boolean }) disabled?: boolean;
@property({ reflect: true, type: Boolean }) graphstart?: boolean;
@property({ reflect: true, type: Boolean }) nofocus?: boolean;
@property({ reflect: true, type: Number }) badge?: number;
connectedCallback() {
super.connectedCallback();
if (!this.hasAttribute("tabindex") && !this.nofocus)
this.setAttribute("tabindex", "0");
}
updated() {
const svgEl = this.shadowRoot?.querySelector("svg");
if (!svgEl) {
return;
}
const bbox = svgEl.getBBox();
const extra_height = this.graphstart ? 2 : 1;
const extra_width = SPACING;
svgEl.setAttribute("width", `${bbox.width + extra_width}px`);
svgEl.setAttribute("height", `${bbox.height + extra_height}px`);
svgEl.setAttribute(
"viewBox",
`${Math.ceil(bbox.x - extra_width / 2)}
${Math.ceil(bbox.y - extra_height / 2)}
${bbox.width + extra_width}
${bbox.height + extra_height}`
);
}
render() {
return svg`
<svg
>
${
this.graphstart
? ``
: svg`
<path
class="connector"
d="
M 0 ${-SPACING - NODE_SIZE / 2}
L 0 0
"
line-caps="round"
/>
`
}
<g class="node">
<circle
cx="0"
cy="0"
r="${NODE_SIZE / 2}"
/>
${
this.badge
? svg`
<g class="number">
<circle
cx="8"
cy="${-NODE_SIZE / 2}"
r="8"
></circle>
<text
x="8"
y="${-NODE_SIZE / 2}"
text-anchor="middle"
alignment-baseline="middle"
>${this.badge > 9 ? "9+" : this.badge}</text>
</g>
`
: ""
}
<g
style="pointer-events: none"
transform="translate(${-12} ${-12})"
>
${this.iconPath ? svg`<path class="icon" d="${this.iconPath}"/>` : ""}
</g>
</g>
</svg>
`;
}
static get styles() {
return css`
:host {
display: flex;
flex-direction: column;
--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);
}
:host(.track) {
--stroke-clr: var(--track-clr);
--icon-clr: var(--default-icon-clr);
}
:host(.active) circle {
--stroke-clr: var(--active-clr);
--icon-clr: var(--default-icon-clr);
}
:host(:focus) {
outline: none;
}
:host(:hover) circle {
--stroke-clr: var(--hover-clr);
--icon-clr: var(--default-icon-clr);
}
:host([disabled]) circle {
stroke: var(--disabled-clr);
}
:host-context([disabled]) {
--stroke-clr: var(--disabled-clr);
}
:host([nofocus]):host-context(.active),
:host([nofocus]):host-context(:focus) {
--stroke-clr: var(--active-clr);
--icon-clr: var(--default-icon-clr);
}
circle,
path.connector {
stroke: var(--stroke-clr);
stroke-width: 2;
fill: none;
}
circle {
fill: var(--background-clr);
stroke: var(--circle-clr, var(--stroke-clr));
}
.number circle {
fill: var(--track-clr);
stroke: none;
stroke-width: 0;
}
.number text {
font-size: smaller;
}
path.icon {
fill: var(--icon-clr);
}
:host(.triggered) svg {
overflow: visible;
}
:host(.triggered) circle {
animation: glow 10s;
}
@keyframes glow {
0% {
filter: drop-shadow(0px 0px 5px rgba(var(--rgb-trigger-color), 0));
}
10% {
filter: drop-shadow(0px 0px 10px rgba(var(--rgb-trigger-color), 1));
}
100% {
filter: drop-shadow(0px 0px 5px rgba(var(--rgb-trigger-color), 0));
}
}
`;
}
}
declare global {
interface HTMLElementTagNameMap {
"hat-graph-node": HatGraphNode;
}
}

View File

@ -1,397 +1,218 @@
import {
LitElement,
html,
svg,
property,
customElement,
SVGTemplateResult,
css,
customElement,
html,
LitElement,
property,
svg,
} from "lit-element";
import { classMap } from "lit-html/directives/class-map";
const SIZE = 35;
const DIST = 20;
export const BRANCH_HEIGHT = 20;
export const SPACING = 10;
export const NODE_SIZE = 30;
type ValueOrArray<T> = T | ValueOrArray<T>[];
// Return value is undefined if it's an empty array
const extractFirstValue = <T>(val: ValueOrArray<T>): T | undefined =>
Array.isArray(val) ? extractFirstValue(val[0]) : val;
const extractLastValue = <T>(val: ValueOrArray<T>): T | undefined =>
Array.isArray(val) ? extractLastValue(val[val.length - 1]) : val;
const track_converter = {
fromAttribute: (value) => value.split(",").map((v) => parseInt(v)),
toAttribute: (value) =>
value instanceof Array ? value.join(",") : `${value}`,
};
export interface NodeInfo {
path: string;
config: any;
update?: (conf: any) => void;
}
export interface TreeNode {
icon: string;
number?: number;
end?: boolean;
nodeInfo?: NodeInfo;
children?: Array<ValueOrArray<TreeNode>>;
clickCallback?: () => void;
addCallback?: () => void;
isActive: boolean;
isTracked: boolean | undefined;
isNew?: boolean;
}
export function* bfsIterateTreeNodes(
nodeOrNodes: ValueOrArray<TreeNode>
): IterableIterator<TreeNode> {
if (Array.isArray(nodeOrNodes)) {
for (const node of nodeOrNodes) {
yield* bfsIterateTreeNodes(node);
}
return;
}
yield nodeOrNodes;
if (nodeOrNodes.children) {
yield* bfsIterateTreeNodes(nodeOrNodes.children);
}
}
interface RenderedTree {
svg: SVGTemplateResult[];
width: number;
interface BranchConfig {
x: number;
height: number;
// These are the parts rendered before/after this tree
previousPartTracked: boolean | undefined;
nextPartTracked: boolean | undefined;
// These are the parts inside the tree
firstNodeTracked: boolean | undefined;
lastNodeTracked: boolean | undefined;
start: boolean;
end: boolean;
}
@customElement("hat-graph")
class HatGraph extends LitElement {
@property() tree!: TreeNode[];
export class HatGraph extends LitElement {
@property({ type: Number }) _num_items = 0;
@property() finishedActive = false;
@property({ reflect: true, type: Boolean }) branching?: boolean;
@property() nodeSize = SIZE;
@property({ reflect: true, converter: track_converter })
track_start?: number[];
@property() nodeSeparation = DIST;
@property({ reflect: true, converter: track_converter }) track_end?: number[];
private _draw_node(x: number, y: number, node: TreeNode) {
return svg`
<circle
cx="${x}"
cy="${y + this.nodeSize / 2}"
r="${this.nodeSize / 2}"
class="node ${classMap({
active: node.isActive || false,
track: node.isTracked || false,
new: node.isNew || false,
click: !!node.clickCallback,
})}"
@click=${node.clickCallback}
/>
<g style="pointer-events: none" transform="translate(${x - 12} ${
y + this.nodeSize / 2 - 12
})">
${node.icon ? svg`<path d="${node.icon}"/>` : ""}
${
node.number
? svg`<g class="number"><circle r="8" cx="27" cy=0></circle>
<text x="27" y="1" text-anchor="middle" alignment-baseline="middle">${node.number}</text></g>`
: ""
}
</g>
`;
}
@property({ reflect: true, type: Boolean }) disabled?: boolean;
private _draw_new_node(x, y, node) {
return svg`
<circle
cx="${x}"
cy="${y + this.nodeSize / 4}"
r="${this.nodeSize / 4}"
class="newnode"
@click=${node.addCallback}
/>
`;
}
@property({ reflect: true, type: Boolean }) selected?: boolean;
private _draw_connector(x1, y1, x2, y2, track) {
return svg`
<line
class=${classMap({ track })}
x1=${x1}
y1=${y1}
x2=${x2}
y2=${y2}
/>
`;
}
@property({ reflect: true, type: Boolean }) short = false;
private _draw_tree(
tree: ValueOrArray<TreeNode>,
previousPartTracked: boolean | undefined,
nextPartTracked: boolean | undefined
): RenderedTree {
if (!tree) {
return {
svg: [],
width: 0,
height: 0,
previousPartTracked,
nextPartTracked,
firstNodeTracked: false,
lastNodeTracked: false,
};
}
if (!Array.isArray(tree)) {
return this._draw_tree_single(tree, previousPartTracked, nextPartTracked);
}
if (tree.length === 0) {
return {
svg: [],
width: 0,
height: 0,
previousPartTracked,
nextPartTracked,
firstNodeTracked: false,
lastNodeTracked: false,
};
}
return this._draw_tree_array(tree, previousPartTracked, nextPartTracked);
}
private _draw_tree_single(
tree: TreeNode,
previousPartTracked: boolean | undefined,
nextPartTracked: boolean | undefined
): RenderedTree {
let height = this.nodeSize;
let width = this.nodeSize;
const pieces: SVGTemplateResult[] = [];
let lastNodeTracked = tree.isTracked;
// These children are drawn in parallel to one another.
if (tree.children && tree.children.length > 0) {
lastNodeTracked = extractFirstValue(
tree.children[tree.children.length - 1]
)?.isTracked;
const childTrees: RenderedTree[] = [];
tree.children.forEach((child) => {
childTrees.push(
this._draw_tree(child, previousPartTracked, nextPartTracked)
);
});
height += childTrees.reduce((a, i) => Math.max(a, i.height), 0);
width =
childTrees.reduce((a, i) => a + i.width, 0) +
this.nodeSeparation * (tree.children.length - 1);
const offsets = childTrees.map(
((sum) => (value) => {
return sum + value.width + this.nodeSeparation;
})(0)
);
let bottomConnectors = false;
let prevOffset = 0;
for (const [idx, child] of childTrees.entries()) {
prevOffset += idx ? offsets[idx - 1] : 0;
const x = -width / 2 + prevOffset + child.width / 2;
// Draw top connectors
pieces.push(
this._draw_connector(
0,
this.nodeSize / 2,
x,
this.nodeSize + this.nodeSeparation,
child.previousPartTracked && child.firstNodeTracked
)
);
const endNode = extractLastValue(tree.children[idx])!;
if (endNode.end !== true) {
// Draw bottom fill
pieces.push(
this._draw_connector(
x,
this.nodeSeparation + child.height,
x,
this.nodeSeparation + height,
child.lastNodeTracked && child.nextPartTracked
)
);
// Draw bottom connectors
pieces.push(
this._draw_connector(
x,
this.nodeSeparation + height - 1,
0,
this.nodeSeparation +
height +
this.nodeSize / 2 +
this.nodeSeparation -
1,
child.lastNodeTracked && child.nextPartTracked
)
);
bottomConnectors = true;
}
// Draw child tree
pieces.push(svg`
<g class="a" transform="translate(${x} ${
this.nodeSize + this.nodeSeparation
})">
${child.svg}
</g>
`);
}
if (bottomConnectors) {
height += this.nodeSize + this.nodeSeparation;
}
}
if (tree.addCallback) {
pieces.push(
this._draw_connector(
0,
height,
0,
height + this.nodeSeparation,
tree.isTracked && nextPartTracked
)
);
pieces.push(this._draw_new_node(0, height + this.nodeSeparation, tree));
height += this.nodeSeparation + this.nodeSize / 2;
}
if (tree.end !== true) {
// Draw bottom connector
pieces.push(
this._draw_connector(
0,
height,
0,
height + this.nodeSeparation,
tree.isTracked && nextPartTracked
)
);
height += this.nodeSeparation;
}
// Draw the node itself
pieces.push(this._draw_node(0, 0, tree));
return {
svg: pieces,
width,
height,
previousPartTracked,
nextPartTracked,
firstNodeTracked: tree.isTracked,
lastNodeTracked,
};
}
private _draw_tree_array(
tree: ValueOrArray<TreeNode>[],
previousPartTracked: boolean | undefined,
nextPartTracked: boolean | undefined
): RenderedTree {
const pieces: SVGTemplateResult[] = [];
let height = 0;
// Render each entry while keeping track of the "track" variable.
const childTrees: RenderedTree[] = [];
let lastChildTracked: boolean | undefined = previousPartTracked;
tree.forEach((child, idx) => {
const lastNodeTracked = extractLastValue(child)?.isTracked;
const nextChildTracked =
idx < tree.length - 1
? extractFirstValue(tree[idx + 1])?.isTracked
: lastNodeTracked && nextPartTracked;
childTrees.push(
this._draw_tree(child, lastChildTracked, nextChildTracked)
);
lastChildTracked = lastNodeTracked;
});
const width = childTrees.reduce((a, i) => Math.max(a, i.width), 0);
for (const [_, node] of childTrees.entries()) {
pieces.push(svg`
<g class="b" transform="translate(0, ${height})">
${node.svg}
</g>
`);
height += node.height;
}
return {
svg: pieces,
width,
height,
previousPartTracked,
nextPartTracked,
firstNodeTracked: extractFirstValue(tree[0])?.isTracked,
lastNodeTracked: extractFirstValue(tree[tree.length - 1])?.isTracked,
};
async updateChildren() {
this._num_items = this.children.length;
}
render() {
const tree = this._draw_tree(
this.tree,
this.tree.length > 0 && this.tree[0].isTracked,
this.finishedActive
);
const branches: BranchConfig[] = [];
let total_width = 0;
let max_height = 0;
let min_height = Number.POSITIVE_INFINITY;
if (this.branching) {
for (const c of Array.from(this.children)) {
if (c.slot === "head") continue;
const rect = c.getBoundingClientRect();
branches.push({
x: rect.width / 2 + total_width,
height: rect.height,
start: c.getAttribute("graphStart") != null,
end: c.getAttribute("graphEnd") != null,
});
total_width += rect.width;
max_height = Math.max(max_height, rect.height);
min_height = Math.min(min_height, rect.height);
}
}
return html`
<svg width=${tree.width + 32} height=${tree.height + 64}>
<g transform="translate(${tree.width / 2 + 16} 16)">
${tree.svg}
</g>
</svg>
<slot name="head" @slotchange=${this.updateChildren}> </slot>
${this.branching
? svg`
<svg
id="top"
width="${total_width}"
height="${BRANCH_HEIGHT}"
>
${branches.map((branch, i) => {
if (branch.start) return "";
return svg`
<path
class="${classMap({
line: true,
track: this.track_start?.includes(i) ?? false,
})}"
id="${this.track_start?.includes(i) ? "track-start" : ""}"
index=${i}
d="
M ${total_width / 2} 0
L ${branch.x} ${BRANCH_HEIGHT}
"/>
`;
})}
<use xlink:href="#track-start" />
</svg>
`
: ""}
<div id="branches">
${this.branching
? svg`
<svg
id="lines"
width="${total_width}"
height="${max_height}"
>
${branches.map((branch, i) => {
if (branch.end) return "";
return svg`
<path
class="${classMap({
line: true,
track: this.track_end?.includes(i) ?? false,
})}"
index=${i}
d="
M ${branch.x} ${branch.height}
l 0 ${max_height - branch.height}
"/>
`;
})}
</svg>
`
: ""}
<slot @slotchange=${this.updateChildren}></slot>
</div>
${this.branching && !this.short
? svg`
<svg
id="bottom"
width="${total_width}"
height="${BRANCH_HEIGHT + SPACING}"
>
${branches.map((branch, i) => {
if (branch.end) return "";
return svg`
<path
class="${classMap({
line: true,
track: this.track_end?.includes(i) ?? false,
})}"
id="${this.track_end?.includes(i) ? "track-end" : ""}"
index=${i}
d="
M ${branch.x} 0
L ${branch.x} ${SPACING}
L ${total_width / 2} ${BRANCH_HEIGHT + SPACING}
"/>
`;
})}
<use xlink:href="#track-end" />
</svg>
`
: ""}
`;
}
static get styles() {
return css`
:host {
position: relative;
display: flex;
flex-direction: column;
align-items: center;
--stroke-clr: var(--stroke-color, var(--secondary-text-color));
--active-clr: var(--active-color, var(--primary-color));
--hover-clr: var(--hover-color, var(--primary-color));
--track-clr: var(--track-color, var(--accent-color));
--disabled-clr: var(--disabled-color, gray);
}
circle,
line {
:host(:focus) {
outline: none;
}
#branches {
position: relative;
display: flex;
flex-direction: column;
align-items: center;
}
:host([branching]) #branches {
flex-direction: row;
align-items: start;
}
:host([branching]) ::slotted(*) {
z-index: 1;
}
:host([branching]) ::slotted([slot="head"]) {
margin-bottom: ${-BRANCH_HEIGHT / 2}px;
}
#lines {
position: absolute;
}
path.line {
stroke: var(--stroke-clr);
stroke-width: 3px;
fill: white;
stroke-width: 2;
fill: none;
}
.click {
cursor: pointer;
}
.click:hover {
stroke: var(--hover-clr);
}
.track {
path.line.track {
stroke: var(--track-clr);
}
.active {
:host([disabled]) path.line {
stroke: var(--disabled-clr);
}
:host(.active) #top path.line {
stroke: var(--active-clr);
}
.number circle {
fill: var(--track-clr);
stroke: none;
}
.number text {
font-size: smaller;
:host(:focus) #top path.line {
stroke: var(--active-clr);
}
`;
}

View File

@ -7,13 +7,47 @@ import {
css,
} from "lit-element";
import "@material/mwc-icon-button/mwc-icon-button";
import memoizeOne from "memoize-one";
import { fireEvent } from "../../common/dom/fire_event";
import "../ha-svg-icon";
import { AutomationTraceExtended } from "../../data/trace";
import { bfsIterateTreeNodes, NodeInfo, TreeNode } from "./hat-graph";
import { ActionHandler } from "./script-to-graph";
import { mdiChevronDown, mdiChevronUp } from "@mdi/js";
import {
AutomationTraceExtended,
ChooseActionTraceStep,
ConditionTraceStep,
} from "../../data/trace";
import {
mdiAbTesting,
mdiArrowUp,
mdiAsterisk,
mdiCallSplit,
mdiCheckboxBlankOutline,
mdiCheckBoxOutline,
mdiChevronDown,
mdiChevronRight,
mdiChevronUp,
mdiClose,
mdiCodeBrackets,
mdiDevices,
mdiExclamation,
mdiRefresh,
mdiTimerOutline,
mdiTrafficLight,
} from "@mdi/js";
import "./hat-graph-node";
import { classMap } from "lit-html/directives/class-map";
import { NODE_SIZE, SPACING, NodeInfo } from "./hat-graph";
import { Condition, Trigger } from "../../data/automation";
import {
Action,
ChooseAction,
DelayAction,
DeviceAction,
EventAction,
RepeatAction,
SceneAction,
ServiceAction,
WaitAction,
WaitForTriggerAction,
} from "../../data/script";
declare global {
interface HASSDomEvents {
@ -27,27 +61,390 @@ class HatScriptGraph extends LitElement {
@property({ attribute: false }) public selected;
private getActionHandler = memoizeOne((trace: AutomationTraceExtended) => {
return new ActionHandler(
trace.config.action,
false,
undefined,
(nodeInfo) => {
// eslint-disable-next-line no-console
console.log(nodeInfo);
fireEvent(this, "graph-node-selected", nodeInfo);
this.requestUpdate();
},
this.selected,
this.trace
);
});
@property() trackedNodes: Record<string, any> = {};
private selectNode(config, path) {
return () => {
fireEvent(this, "graph-node-selected", { config, path });
};
}
private render_trigger(config: Trigger, i: number) {
const path = `trigger/${i}`;
const tracked = this.trace && path in this.trace.trace;
if (tracked) {
this.trackedNodes[path] = { config, path };
}
return html`
<hat-graph-node
graphStart
@focus=${this.selectNode(config, path)}
class=${classMap({
track: tracked,
active: this.selected === path,
})}
.iconPath=${mdiAsterisk}
tabindex=${tracked ? "0" : "-1"}
></hat-graph-node>
`;
}
private render_condition(config: Condition, i: number) {
const path = `condition/${i}`;
const trace = this.trace.trace[path] as ConditionTraceStep[] | undefined;
const track_path =
trace === undefined ? 0 : trace![0].result.result ? 1 : 2;
if (trace) {
this.trackedNodes[path] = { config, path };
}
return html`
<hat-graph
branching
@focus=${this.selectNode(config, path)}
class=${classMap({
track: track_path,
active: this.selected === path,
})}
.track_start=${[track_path]}
.track_end=${[track_path]}
tabindex=${trace === undefined ? "-1" : "0"}
short
>
<hat-graph-node
slot="head"
class=${classMap({
track: trace !== undefined,
})}
.iconPath=${mdiAbTesting}
nofocus
graphEnd
></hat-graph-node>
<div
style=${`width: ${NODE_SIZE + SPACING}px;`}
graphStart
graphEnd
></div>
<div></div>
<hat-graph-node
.iconPath=${mdiClose}
graphEnd
nofocus
class=${classMap({
track: track_path === 2,
})}
></hat-graph-node>
</hat-graph>
`;
}
private render_choose_node(config: ChooseAction, path: string) {
const trace = this.trace.trace[path] as ChooseActionTraceStep[] | undefined;
const trace_path = trace
? trace[0].result.choice === "default"
? [config.choose.length]
: [trace[0].result.choice]
: [];
return html`
<hat-graph
tabindex=${trace === undefined ? "-1" : "0"}
branching
.track_start=${trace_path}
.track_end=${trace_path}
@focus=${this.selectNode(config, path)}
class=${classMap({
track: trace !== undefined,
active: this.selected === path,
})}
>
<hat-graph-node
.iconPath=${mdiCallSplit}
class=${classMap({
track: trace !== undefined,
})}
slot="head"
nofocus
></hat-graph-node>
${config.choose.map((branch, i) => {
const branch_path = `${path}/choose/${i}`;
return html`
<hat-graph>
<hat-graph-node
.iconPath=${mdiCheckBoxOutline}
nofocus
class=${classMap({
track: trace !== undefined && trace[0].result.choice === i,
})}
></hat-graph-node>
${branch.sequence.map((action, j) =>
this.render_node(action, `${branch_path}/sequence/${j}`)
)}
</hat-graph>
`;
})}
<hat-graph>
<hat-graph-node
.iconPath=${mdiCheckboxBlankOutline}
nofocus
class=${classMap({
track:
trace !== undefined && trace[0].result.choice === "default",
})}
></hat-graph-node>
${config.default?.map((action, i) =>
this.render_node(action, `${path}/default/${i}`)
)}
</hat-graph>
</hat-graph>
`;
}
private render_condition_node(node: Condition, path: string) {
const trace: any = this.trace.trace[path];
const track_path = trace === undefined ? 0 : trace[0].result.result ? 1 : 2;
return html`
<hat-graph
branching
@focus=${this.selectNode(node, path)}
class=${classMap({
track: track_path,
active: this.selected === path,
})}
.track_start=${[track_path]}
.track_end=${[track_path]}
tabindex=${trace === undefined ? "-1" : "0"}
short
>
<hat-graph-node
slot="head"
class=${classMap({
track: trace,
})}
.iconPath=${mdiAbTesting}
nofocus
graphEnd
></hat-graph-node>
<div
style=${`width: ${NODE_SIZE + SPACING}px;`}
graphStart
graphEnd
></div>
<div></div>
<hat-graph-node
.iconPath=${mdiClose}
graphEnd
nofocus
class=${classMap({
track: track_path === 2,
})}
></hat-graph-node>
</hat-graph>
`;
}
private render_delay_node(node: DelayAction, path: string) {
return html`
<hat-graph-node
.iconPath=${mdiTimerOutline}
@focus=${this.selectNode(node, path)}
class=${classMap({
track: path in this.trace.trace,
active: this.selected === path,
})}
tabindex=${this.trace && path in this.trace.trace ? "0" : "-1"}
></hat-graph-node>
`;
}
private render_device_node(node: DeviceAction, path: string) {
return html`
<hat-graph-node
.iconPath=${mdiDevices}
@focus=${this.selectNode(node, path)}
class=${classMap({
track: path in this.trace.trace,
active: this.selected === path,
})}
tabindex=${this.trace && path in this.trace.trace ? "0" : "-1"}
></hat-graph-node>
`;
}
private render_event_node(node: EventAction, path: string) {
return html`
<hat-graph-node
.iconPath=${mdiExclamation}
@focus=${this.selectNode(node, path)}
class=${classMap({
track: path in this.trace.trace,
active: this.selected === path,
})}
tabindex=${this.trace && path in this.trace.trace ? "0" : "-1"}
></hat-graph-node>
`;
}
private render_repeat_node(node: RepeatAction, path: string) {
const trace: any = this.trace.trace[path];
const track_path = trace ? [0, 1] : [];
const repeats = this.trace?.trace[`${path}/repeat/sequence/0`]?.length;
return html`
<hat-graph
.track_start=${track_path}
.track_end=${track_path}
tabindex=${trace === undefined ? "-1" : "0"}
branching
@focus=${this.selectNode(node, path)}
class=${classMap({
track: path in this.trace.trace,
active: this.selected === path,
})}
>
<hat-graph-node
.iconPath=${mdiRefresh}
class=${classMap({
track: trace,
})}
slot="head"
nofocus
></hat-graph-node>
<hat-graph-node
.iconPath=${mdiArrowUp}
nofocus
class=${classMap({
track: track_path.includes(1),
})}
.badge=${repeats}
></hat-graph-node>
<hat-graph>
${node.repeat.sequence.map((action, i) =>
this.render_node(action, `${path}/repeat/sequence/${i}`)
)}
</hat-graph>
</hat-graph>
`;
}
private render_scene_node(node: SceneAction, path: string) {
return html`
<hat-graph-node
.iconPath=${mdiExclamation}
@focus=${this.selectNode(node, path)}
class=${classMap({
track: path in this.trace.trace,
active: this.selected === path,
})}
tabindex=${this.trace && path in this.trace.trace ? "0" : "-1"}
></hat-graph-node>
`;
}
private render_service_node(node: ServiceAction, path: string) {
return html`
<hat-graph-node
.iconPath=${mdiChevronRight}
@focus=${this.selectNode(node, path)}
class=${classMap({
track: path in this.trace.trace,
active: this.selected === path,
})}
tabindex=${this.trace && path in this.trace.trace ? "0" : "-1"}
></hat-graph-node>
`;
}
private render_wait_node(
node: WaitAction | WaitForTriggerAction,
path: string
) {
return html`
<hat-graph-node
.iconPath=${mdiTrafficLight}
@focus=${this.selectNode(node, path)}
class=${classMap({
track: path in this.trace.trace,
active: this.selected === path,
})}
tabindex=${this.trace && path in this.trace.trace ? "0" : "-1"}
></hat-graph-node>
`;
}
private render_other_node(node: Action, path: string) {
return html`
<hat-graph-node
.iconPath=${mdiCodeBrackets}
@focus=${this.selectNode(node, path)}
class=${classMap({
track: path in this.trace.trace,
active: this.selected === path,
})}
></hat-graph-node>
`;
}
private render_node(node: Action, path: string) {
const NODE_TYPES = {
choose: this.render_choose_node,
condition: this.render_condition_node,
delay: this.render_delay_node,
device_id: this.render_device_node,
event: this.render_event_node,
repeat: this.render_repeat_node,
scene: this.render_scene_node,
service: this.render_service_node,
wait_template: this.render_wait_node,
wait_for_trigger: this.render_wait_node,
other: this.render_other_node,
};
const type = Object.keys(NODE_TYPES).find((key) => key in node) || "other";
const nodeEl = NODE_TYPES[type].bind(this)(node, path);
if (this.trace && path in this.trace.trace) {
this.trackedNodes[path] = { config: node, path };
}
return nodeEl;
}
protected render() {
const actionHandler = this.getActionHandler(this.trace);
const paths = Object.keys(this.getTrackedNodes());
const paths = Object.keys(this.trackedNodes);
const manual_triggered = this.trace && "trigger" in this.trace.trace;
let track_path = manual_triggered ? undefined : [0];
const trigger_nodes = (Array.isArray(this.trace.config.trigger)
? this.trace.config.trigger
: [this.trace.config.trigger]
).map((trigger, i) => {
if (this.trace && `trigger/${i}` in this.trace.trace) {
track_path = [i];
}
return this.render_trigger(trigger, i);
});
return html`
<hat-graph .tree=${actionHandler.createGraph()}></hat-graph>
<hat-graph class="parent">
<div></div>
<hat-graph
branching
id="trigger"
.short=${trigger_nodes.length < 2}
.track_start=${track_path}
.track_end=${track_path}
>
${trigger_nodes}
</hat-graph>
<hat-graph id="condition">
${(!this.trace.config.condition ||
Array.isArray(this.trace.config.condition)
? this.trace.config.condition
: [this.trace.config.condition]
)?.map((condition, i) => this.render_condition(condition, i))}
</hat-graph>
${(Array.isArray(this.trace.config.action)
? this.trace.config.action
: [this.trace.config.action]
).map((action, i) => this.render_node(action, `action/${i}`))}
</hat-graph>
<div class="actions">
<mwc-icon-button
.disabled=${paths.length === 0 || paths[0] === this.selected}
@ -66,19 +463,11 @@ class HatScriptGraph extends LitElement {
`;
}
public selectPath(path: string) {
const actionHandler = this.getActionHandler(this.trace!);
let selected: NodeInfo | undefined;
for (const node of actionHandler.createGraph()) {
if (node.nodeInfo?.path === path) {
selected = node.nodeInfo;
break;
}
protected update(changedProps: PropertyValues<this>) {
if (changedProps.has("trace")) {
this.trackedNodes = {};
}
actionHandler.selected = selected?.path || "";
this.requestUpdate();
super.update(changedProps);
}
protected updated(changedProps: PropertyValues<this>) {
@ -93,22 +482,29 @@ class HatScriptGraph extends LitElement {
if (this.selected === "" || !(this.selected in paths)) {
// Find first tracked node with node info
for (const path of paths) {
if (tracked[path].nodeInfo) {
fireEvent(this, "graph-node-selected", tracked[path].nodeInfo);
if (tracked[path]) {
fireEvent(this, "graph-node-selected", tracked[path]);
break;
}
}
}
}
if (changedProps.has("selected")) {
this.getActionHandler(this.trace).selected = this.selected;
this.requestUpdate();
if (this.trace) {
const sortKeys = Object.keys(this.trace.trace);
const keys = Object.keys(this.trackedNodes).sort(
(a, b) => sortKeys.indexOf(a) - sortKeys.indexOf(b)
);
const sortedTrackedNodes = keys.reduce((obj, key) => {
obj[key] = this.trackedNodes[key];
return obj;
}, {});
this.trackedNodes = sortedTrackedNodes;
}
}
}
public getTrackedNodes() {
return this._getTrackedNodes(this.trace);
return this.trackedNodes;
}
public previousTrackedNode() {
@ -116,8 +512,8 @@ class HatScriptGraph extends LitElement {
const nodes = Object.keys(tracked);
for (let i = nodes.indexOf(this.selected) - 1; i >= 0; i--) {
if (tracked[nodes[i]].nodeInfo) {
fireEvent(this, "graph-node-selected", tracked[nodes[i]].nodeInfo);
if (tracked[nodes[i]]) {
fireEvent(this, "graph-node-selected", tracked[nodes[i]]);
break;
}
}
@ -127,25 +523,13 @@ class HatScriptGraph extends LitElement {
const tracked = this.getTrackedNodes();
const nodes = Object.keys(tracked);
for (let i = nodes.indexOf(this.selected) + 1; i < nodes.length; i++) {
if (tracked[nodes[i]].nodeInfo) {
fireEvent(this, "graph-node-selected", tracked[nodes[i]].nodeInfo);
if (tracked[nodes[i]]) {
fireEvent(this, "graph-node-selected", tracked[nodes[i]]);
break;
}
}
}
private _getTrackedNodes = memoizeOne((trace) => {
const tracked: Record<string, TreeNode> = {};
for (const node of bfsIterateTreeNodes(
this.getActionHandler(trace).createGraph()
)) {
if (node.isTracked && node.nodeInfo) {
tracked[node.nodeInfo.path] = node;
}
}
return tracked;
});
static get styles() {
return css`
:host {
@ -155,6 +539,9 @@ class HatScriptGraph extends LitElement {
display: flex;
flex-direction: column;
}
.parent {
margin-left: 8px;
}
`;
}
}

View File

@ -14,6 +14,7 @@ import {
ChooseActionTraceStep,
getDataFromPath,
TriggerTraceStep,
isTriggerPath,
} from "../../data/trace";
import { HomeAssistant } from "../../types";
import "./ha-timeline";
@ -218,7 +219,7 @@ class ActionRenderer {
): number {
const value = this._getItem(index);
if (value[0].path === "trigger") {
if (isTriggerPath(value[0].path)) {
return this._handleTrigger(index, value[0] as TriggerTraceStep);
}
@ -267,9 +268,12 @@ class ActionRenderer {
private _handleTrigger(index: number, triggerStep: TriggerTraceStep): number {
this._renderEntry(
"trigger",
`Triggered by the
${triggerStep.changed_variables.trigger.description} at
triggerStep.path,
`Triggered ${
triggerStep.path === "trigger"
? "manually"
: `by the ${triggerStep.changed_variables.trigger.description}`
} at
${formatDateTimeWithSeconds(
new Date(triggerStep.timestamp),
this.hass.locale

View File

@ -1,443 +0,0 @@
import {
mdiCallSplit,
mdiAbTesting,
mdiCheck,
mdiClose,
mdiChevronRight,
mdiExclamation,
mdiTimerOutline,
mdiTrafficLight,
mdiRefresh,
mdiArrowUp,
mdiCodeJson,
mdiCheckBoxOutline,
mdiCheckboxBlankOutline,
mdiAsterisk,
mdiDevices,
mdiFlare,
} from "@mdi/js";
import memoizeOne from "memoize-one";
import { Condition } from "../../data/automation";
import { Action, ChooseAction, RepeatAction } from "../../data/script";
import {
AutomationTraceExtended,
ChooseActionTraceStep,
ChooseChoiceActionTraceStep,
ConditionTraceStep,
} from "../../data/trace";
import { NodeInfo, TreeNode } from "./hat-graph";
const ICONS = {
new: mdiAsterisk,
device_id: mdiDevices,
service: mdiChevronRight,
condition: mdiAbTesting,
TRUE: mdiCheck,
FALSE: mdiClose,
delay: mdiTimerOutline,
wait_template: mdiTrafficLight,
event: mdiExclamation,
repeat: mdiRefresh,
repeatReturn: mdiArrowUp,
choose: mdiCallSplit,
chooseChoice: mdiCheckBoxOutline,
chooseDefault: mdiCheckboxBlankOutline,
YAML: mdiCodeJson,
};
const OPTIONS = [
"condition",
"delay",
"device_id",
"event",
"scene",
"service",
"wait_template",
"repeat",
"choose",
];
// eslint-disable-next-line @typescript-eslint/no-empty-interface
interface NoAction {}
// const cmpLists = (a: Array<unknown>, b: Array<unknown>) =>
// a.length === b.length && a.every((itm, idx) => b[idx] === itm);
const TRACE_ACTION_PREFIX = "action/";
export class ActionHandler {
public pathPrefix: string;
constructor(
public actions: Array<Action | NoAction>,
/**
* Do we allow adding new nodes
*/
private allowAdd: boolean,
/**
* Called when the data has changed
*/
private updateCallback?: (actions: ActionHandler["actions"]) => void,
/**
* Called when a node is clicked.
*/
private selectCallback?: (params: NodeInfo) => void,
public selected: string = "",
public trace?: AutomationTraceExtended,
pathPrefix?: string,
public end?: boolean
) {
if (pathPrefix !== undefined) {
this.pathPrefix = pathPrefix;
} else if (this.trace) {
this.pathPrefix = TRACE_ACTION_PREFIX;
} else {
this.pathPrefix = "";
}
}
createGraph(): TreeNode[] {
return this._createGraph(this.actions, this.selected, this.trace);
}
_createGraph = memoizeOne((_actions, _selected, _trace) =>
this._renderTraceHead().concat(
this.actions.map((action, idx) =>
this._createTreeNode(
idx,
action,
this.actions.length === idx + 1 &&
(this.end === undefined || this.end === true)
)
)
)
);
_renderTraceHead(): TreeNode[] {
if (this.pathPrefix !== TRACE_ACTION_PREFIX) {
return [];
}
const triggerNodeInfo = {
path: "trigger",
// Just all triggers for now.
config: this.trace!.config.trigger,
};
const nodes: TreeNode[] = [
{
icon: mdiFlare,
nodeInfo: triggerNodeInfo,
clickCallback: () => {
this._selectNode(triggerNodeInfo);
},
isActive: this.selected === "trigger",
isTracked: true,
},
];
if (this.trace!.config.condition) {
this.trace!.config.condition.forEach((condition, idx) =>
nodes.push(
this._createConditionNode(
"condition/",
idx,
condition,
!this.actions.length &&
this.trace!.config.condition!.length === idx + 1
)
)
);
}
return nodes;
}
_updateAction(idx: number, action) {
if (action === null) {
this.actions.splice(idx, 1);
} else {
this.actions[idx] = action;
}
if (this.updateCallback) this.updateCallback(this.actions);
}
_addAction(idx: number) {
this.actions.splice(idx, 0, {});
if (this.updateCallback) {
this.updateCallback(this.actions);
}
this._selectNode({
path: `${this.pathPrefix}${idx}`,
config: {},
update: (a) => this._updateAction(idx, a),
});
}
_selectNode(nodeInfo: NodeInfo) {
this.selected = nodeInfo.path;
if (this.selectCallback) {
this.selectCallback(nodeInfo);
}
}
_createTreeNode(idx: number, action, end: boolean): TreeNode {
let _type = "yaml";
if (Object.keys(action).length === 0) {
_type = "new";
} else {
_type = OPTIONS.find((option) => option in action) || "YAML";
}
let node: TreeNode;
if (_type in this.SPECIAL) {
node = this.SPECIAL[_type](idx, action, end);
} else {
const path = `${this.pathPrefix}${idx}`;
const nodeInfo: NodeInfo = {
path,
config: action,
update: (a) => this._updateAction(idx, a),
};
node = {
icon: ICONS[_type],
nodeInfo,
clickCallback: () => {
this._selectNode(nodeInfo);
},
isActive: path === this.selected,
isTracked: this.trace && path in this.trace.trace,
end,
};
}
if (this.allowAdd) {
node.addCallback = () => this._addAction(idx + 1);
}
if (_type === "new") {
node.isNew = true;
}
return node;
}
SPECIAL: Record<
string,
(idx: number, action: any, end: boolean) => TreeNode
> = {
condition: (idx, action: Condition, end: boolean): TreeNode =>
this._createConditionNode(this.pathPrefix, idx, action, end),
repeat: (idx, action: RepeatAction, end: boolean): TreeNode => {
let seq: Array<Action | NoAction> = action.repeat.sequence;
if (!seq || !seq.length) {
seq = [{}];
}
const path = `${this.pathPrefix}${idx}`;
const isTracked = this.trace && path in this.trace.trace;
const repeats =
this.trace && this.trace.trace[`${path}/repeat/sequence/0`]?.length;
const nodeInfo: NodeInfo = {
path,
config: action,
update: (conf) => this._updateAction(idx, conf),
};
return {
icon: ICONS.repeat,
number: repeats,
nodeInfo,
clickCallback: () => this._selectNode(nodeInfo),
isActive: path === this.selected,
isTracked,
end,
children: [
new ActionHandler(
seq,
this.allowAdd,
(a) => {
action.repeat.sequence = a as Action[];
this._updateAction(idx, action);
},
(params) => this._selectNode(params),
this.selected,
this.trace,
`${path}/repeat/sequence/`,
end
).createGraph(),
],
};
},
choose: (idx, action: ChooseAction, end: boolean): TreeNode => {
const choosePath = `${this.pathPrefix}${idx}`;
let choice: number | "default" | undefined;
if (this.trace?.trace && choosePath in this.trace.trace) {
const chooseResult = this.trace.trace[
choosePath
] as ChooseActionTraceStep[];
choice = chooseResult[0].result.choice;
}
const children = action.choose.map(
(b, choiceIdx): NonNullable<TreeNode["children"]> => {
// If we have a trace, highlight the chosen track here.
const choicePath = `${this.pathPrefix}${idx}/choose/${choiceIdx}`;
let chosen = false;
if (this.trace && choicePath in this.trace.trace) {
const choiceResult = this.trace.trace[
choicePath
] as ChooseChoiceActionTraceStep[];
chosen = choiceResult[0].result.result;
}
const choiceNodeInfo: NodeInfo = {
path: choicePath,
config: b,
update: (conf) => {
action.choose[choiceIdx] = conf;
this._updateAction(idx, action);
},
};
return [
{
icon: ICONS.chooseChoice,
nodeInfo: choiceNodeInfo,
clickCallback: () => this._selectNode(choiceNodeInfo),
isActive: choicePath === this.selected,
isTracked: chosen,
},
new ActionHandler(
b.sequence || [{}],
this.allowAdd,
(actions) => {
b.sequence = actions as Action[];
action.choose[choiceIdx] = b;
this._updateAction(idx, action);
},
(params) => {
this._selectNode(params);
},
this.selected,
this.trace,
`${this.pathPrefix}${idx}/choose/${choiceIdx}/sequence/`,
!chosen || end
).createGraph(),
];
}
);
if (action.default || this.allowAdd) {
const defaultConfig = action.default || [{}];
const updateDefault = (actions) => {
action.default = actions as Action[];
this._updateAction(idx, action);
};
const defaultPath = `${this.pathPrefix}${idx}/default`;
const defaultNodeInfo: NodeInfo = {
path: defaultPath,
config: defaultConfig,
update: updateDefault,
};
children.push([
{
icon: ICONS.chooseDefault,
nodeInfo: defaultNodeInfo,
clickCallback: () => this._selectNode(defaultNodeInfo),
isActive: defaultPath === this.selected,
isTracked: choice === "default",
},
new ActionHandler(
defaultConfig,
this.allowAdd,
updateDefault,
(params) => this._selectNode(params),
this.selected,
this.trace,
`${this.pathPrefix}${idx}/default/`,
choice !== "default" || end
).createGraph(),
]);
}
const chooseNodeInfo: NodeInfo = {
path: choosePath,
config: action,
update: (conf) => this._updateAction(idx, conf),
};
return {
icon: ICONS.choose,
nodeInfo: chooseNodeInfo,
end,
clickCallback: () => this._selectNode(chooseNodeInfo),
isActive: choosePath === this.selected,
isTracked: choice !== undefined,
children,
};
},
};
private _createConditionNode(
pathPrefix: string,
idx: number,
action: Condition,
end: boolean
): TreeNode {
const path = `${pathPrefix}${idx}`;
let result: boolean | undefined;
let isTracked = false;
if (this.trace && path in this.trace.trace) {
const conditionResult = this.trace.trace[path] as ConditionTraceStep[];
result = conditionResult[0].result.result;
isTracked = true;
}
const nodeInfo: NodeInfo = {
path,
config: action,
update: (conf) => this._updateAction(idx, conf),
};
const isActive = path === this.selected;
return {
icon: ICONS.condition,
nodeInfo,
clickCallback: () => this._selectNode(nodeInfo),
isActive,
isTracked,
end,
children: [
{
icon: ICONS.TRUE,
clickCallback: () => this._selectNode(nodeInfo),
isActive,
isTracked: result === true,
},
{
icon: ICONS.FALSE,
clickCallback: () => this._selectNode(nodeInfo),
isActive,
isTracked: result === false,
end: true,
},
],
};
}
}

View File

@ -1,3 +1,4 @@
import { strStartsWith } from "../common/string/starts-with";
import { HomeAssistant, Context } from "../types";
import { AutomationConfig } from "./automation";
@ -146,3 +147,11 @@ export const getDataFromPath = (
return result;
};
// It is 'trigger' if manually triggered by the user via UI
export const isTriggerPath = (path: string): boolean =>
path === "trigger" || strStartsWith(path, "trigger/");
export const getTriggerPathFromTrace = (
steps: Record<string, BaseTraceStep[]>
): string | undefined => Object.keys(steps).find((path) => isTriggerPath(path));

View File

@ -17,10 +17,7 @@ import {
} from "../../../../data/trace";
import "../../../../components/ha-icon-button";
import "../../../../components/ha-code-editor";
import type {
NodeInfo,
TreeNode,
} from "../../../../components/trace/hat-graph";
import type { NodeInfo } from "../../../../components/trace/hat-graph";
import { HomeAssistant } from "../../../../types";
import { formatDateTimeWithSeconds } from "../../../../common/datetime/format_date_time";
import { LogbookEntry } from "../../../../data/logbook";
@ -39,7 +36,7 @@ export class HaAutomationTracePathDetails extends LitElement {
@property() public logbookEntries!: LogbookEntry[];
@property() public trackedNodes!: Record<string, TreeNode>;
@property() public trackedNodes!: Record<string, any>;
@internalProperty() private _view:
| "config"

View File

@ -10,7 +10,6 @@ import {
import { AutomationTraceExtended } from "../../../../data/trace";
import { HomeAssistant } from "../../../../types";
import { LogbookEntry } from "../../../../data/logbook";
import { fireEvent } from "../../../../common/dom/fire_event";
import "../../../../components/trace/hat-trace-timeline";
import { NodeInfo } from "../../../../components/trace/hat-graph";
@ -32,16 +31,11 @@ export class HaAutomationTraceTimeline extends LitElement {
.logbookEntries=${this.logbookEntries}
.selectedPath=${this.selected.path}
allowPick
@value-changed=${this._timelinePathPicked}
>
</hat-trace-timeline>
`;
}
private _timelinePathPicked(ev) {
fireEvent(this, "value-changed", ev.detail);
}
static get styles(): CSSResult[] {
return [
css`

View File

@ -384,7 +384,9 @@ export class HaAutomationTrace extends LitElement {
const nodes = this.shadowRoot!.querySelector(
"hat-script-graph"
)!.getTrackedNodes();
this._selected = nodes[path]?.nodeInfo;
if (nodes[path]) {
this._selected = nodes[path];
}
}
static get styles(): CSSResult[] {