diff --git a/index.html b/index.html index 29a38c2..ab4ca97 100644 --- a/index.html +++ b/index.html @@ -2,8 +2,6 @@ Script graph demo - - @@ -36,18 +34,34 @@
-
-

Selected node code

- - - - +
+
+

Selected node code

+ + +
Path:
+ + +

+
+ + + +
Condition A
+
Condition B
+
Condition C
+
+
+

Trace

+ + +
-
+

Full script code

- + - +
diff --git a/package-lock.json b/package-lock.json index 13109e2..f16003d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -70,6 +70,11 @@ "resolved": "https://registry.npmjs.org/@vanillawc/wc-codemirror/-/wc-codemirror-1.8.10.tgz", "integrity": "sha512-UKMD/UOpF1uRl29nlwvwQqSMBqsl+uDWYlGx82wIYbIBJkeqPrSo1Ez1rGi9jc1CL7/XwUr7u+l/kTwZAEkrEg==" }, + "argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==" + }, "async": { "version": "2.6.3", "resolved": "https://registry.npmjs.org/async/-/async-2.6.3.tgz", @@ -238,6 +243,14 @@ "integrity": "sha1-Mlj7afeMFNW4FdZkM2tM/7ZEFZE=", "dev": true }, + "js-yaml": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.0.0.tgz", + "integrity": "sha512-pqon0s+4ScYUvX30wxQi3PogGFAlUyH0awepWvwkj4jD4v+ova3RiYw8bmA6x2rDrEaj8i/oWKoRxpVNW+Re8Q==", + "requires": { + "argparse": "^2.0.1" + } + }, "lit-element": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/lit-element/-/lit-element-2.3.1.tgz", diff --git a/package.json b/package.json index faf1ef4..93b115d 100644 --- a/package.json +++ b/package.json @@ -22,6 +22,7 @@ "dependencies": { "@mdi/js": "^5.5.55", "@vanillawc/wc-codemirror": "^1.8.10", + "js-yaml": "^4.0.0", "lit-element": "^2.3.1" } } diff --git a/src/demo-config.js b/src/demo-config.js index 27b812d..6c99e47 100644 --- a/src/demo-config.js +++ b/src/demo-config.js @@ -1,107 +1,90 @@ -export const demoConfig = [ - { - condition: "state", - entity_id: "binary_sensor.dark_outside", - state: "on", - }, - { - choose: [ - { - conditions: [ - { - condition: "state", - entity_id: "binary_sensor.door_open", - state: "on", - }, - ], - sequence: [ - { - service: "light.turn_on", - entity_id: "light.outdoors", - } - ], - }, - { - conditions: [ - { - condition: "state", - entity_id: "input_select.time_of_day", - state: "night", - }, - ], - sequence: [ - { - service: "light.turn_off", - entity_id: "light.outdoors", - } - ], - }, - ], - default: [ - ], - }, -] +export const demoTrace = { + "trigger/2": {}, + "condition/0": {}, + "condition/1": {}, + "condition/2": {}, + "action/0": {}, + "action/1": { result: true }, + "action/2": {}, + "action/3": {}, + "action/4": {}, + "action/5": { repeats: 1 }, + "action/5/sequence/0": {}, + "action/5/sequence/1": {}, + "action/6": {}, + "action/6/": {}, + "action/6/choose/2": {}, + "action/6/choose/2/sequence/0": {}, + "action/6/choose/2/sequence/1": {}, +}; -export const demoConfig2 = [ - { service: "light.turn_on", - data: { - entity_id: "group.bedroom", - brightness: 100, +export const demoConfig = { + trigger: ["trigger1", "trigger2", "trigger3"], + condition: ["a", "b", "c"], + action: [ + { + service: "light.turn_on", + data: { + entity_id: "group.bedroom", + brightness: 100, + }, }, - }, - { condition: "state", - entity_id: "device_tracker.paulus", - state: "home", - }, - { delay: "01:00", - }, - { wait_template: "{{ is_state(media_player.floor', 'stop') }}", - }, - { event: "LOGBOOK_ENTRY", - event_data: { - name: "Paulus", - message: "is waking up", + { + condition: "state", entity_id: "device_tracker.paulus", - domain: "light", + state: "home", }, - }, - { repeat: { - count: "5", - sequence: [ - { delay: 2 - }, - { service: "light.toggle", - data: { - entity_id: "light.bed_light", + { + delay: "01:00", + }, + { + wait_template: "{{ is_state(media_player.floor', 'stop') }}", + }, + { + event: "LOGBOOK_ENTRY", + event_data: { + name: "Paulus", + message: "is waking up", + entity_id: "device_tracker.paulus", + domain: "light", + }, + }, + { + repeat: { + count: "5", + sequence: [ + { + delay: 2, }, + { + service: "light.toggle", + data: { + entity_id: "light.bed_light", + }, + }, + ], + }, + }, + { + choose: [ + { + conditions: [], + sequence: [ + { service: "test" }, + { service: "test" }, + { service: "test" }, + ], + }, + { + conditions: [], + sequence: [{ service: "test" }], + }, + { + conditions: [], + sequence: [{ service: "test" }, { service: "test" }], }, ], + default: [{ service: "test" }, { service: "test" }, { service: "test" }], }, - }, - { choose: [ - { conditions: [], - sequence: [ - { service: "test"}, - { service: "test"}, - { service: "test"}, - ], - }, - { conditions: [], - sequence: [ - { service: "test"}, - ], - }, - { conditions: [], - sequence: [ - { service: "test"}, - { service: "test"}, - ], - }, - ], - default: [ - { service: "test"}, - { service: "test"}, - { service: "test"}, - ], - }, -]; + ], +}; diff --git a/src/hat-graph/hat-graph-node.ts b/src/hat-graph/hat-graph-node.ts index 5a01e69..bf8606f 100644 --- a/src/hat-graph/hat-graph-node.ts +++ b/src/hat-graph/hat-graph-node.ts @@ -1,29 +1,35 @@ import { css, LitElement, property, svg } from "lit-element"; -const NODE_SIZE = 24; -const VERTICAL_SPACING = 10; +const NODE_SIZE = 30; +const SPACING = 10; export class HatGraphNode extends LitElement { - @property() iconPath?; - @property({ reflect: true }) marked?; - @property({ reflect: true }) selected?; - @property({ reflect: true }) disabled?; + @property() iconPath?: string; + @property({ reflect: true, type: Boolean }) marked?: boolean; + @property({ reflect: true, type: Boolean }) selected?: boolean; + @property({ reflect: true, type: Boolean }) disabled?: boolean; + @property({ reflect: true, type: Boolean }) graphstart?: boolean; + @property({ reflect: true, type: Boolean }) nofocus?: boolean; connectedCallback() { super.connectedCallback(); - if (!this.hasAttribute("tabindex")) this.setAttribute("tabindex", "0"); - this.addEventListener("focusin", () => this.setAttribute("selected", "")); - this.addEventListener("focusout", () => this.removeAttribute("selected")); + if (!this.hasAttribute("tabindex") && !this.nofocus) + this.setAttribute("tabindex", "0"); } updated() { const svg = this.shadowRoot.querySelector("svg"); const bbox = svg.getBBox(); - svg.setAttribute("width", `${bbox.width + 2}px`); - svg.setAttribute("height", `${bbox.height + 1}px`); + const extra_height = this.graphstart ? 2 : 1; + const extra_width = SPACING; + svg.setAttribute("width", `${bbox.width + extra_width}px`); + svg.setAttribute("height", `${bbox.height + extra_height}px`); svg.setAttribute( "viewBox", - `${bbox.x - 1} ${bbox.y} ${bbox.width + 2} ${bbox.height + 1}` + `${Math.ceil(bbox.x - extra_width / 2)} + ${Math.ceil(bbox.y - extra_height / 2)} + ${bbox.width + extra_width} + ${bbox.height + extra_height}` ); } @@ -31,17 +37,21 @@ export class HatGraphNode extends LitElement { return svg` - - + ${ + this.graphstart + ? `` + : svg` + + ` + } + value.split(",").map((v) => parseInt(v)), + toAttribute: (value, type) => + value instanceof Array ? value.join(",") : `${value}`, +}; + export class HatGraph extends LitElement { @property() _num_items = 0; - @property({ reflect: true }) branching?; - @property({ reflect: true }) mark_start?; - @property({ reflect: true }) mark_end?; - @property({ reflect: true }) disabled?; + @property({ reflect: true, type: Boolean }) branching?: boolean; + @property({ reflect: true, converter: mark_converter }) mark_start?: number[]; + @property({ reflect: true, converter: mark_converter }) mark_end?: number[]; + @property({ reflect: true, type: Boolean }) disabled?: boolean; + @property({ reflect: true, type: Boolean }) selected?: boolean; async updateChildren() { this._num_items = this.children.length; @@ -21,15 +28,15 @@ export class HatGraph extends LitElement { let total_width = 0; let max_height = 0; let min_height = Number.POSITIVE_INFINITY; - if (this.branching !== undefined) { + 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("graph-start") != null, - end: c.getAttribute("graph-end") != null, + start: c.getAttribute("graphStart") != null, + end: c.getAttribute("graphEnd") != null, }); total_width += rect.width; max_height = Math.max(max_height, rect.height); @@ -39,7 +46,7 @@ export class HatGraph extends LitElement { return html` - ${this.branching !== undefined + ${this.branching ? svg` - ${this.branching !== undefined + ${this.branching ? svg` + graph.setAttribute("tabindex", "0"); + + let marked_branch; + if (trace && path in trace) { + marked_branch = trace[path].result ? "0" : "1"; + graph.setAttribute("mark_start", marked_branch); + graph.setAttribute("mark_end", marked_branch); + } + + graph.addEventListener("focus", () => { graph.dispatchEvent( - new CustomEvent("node-selected", { detail: { config }, bubbles: true }) + new CustomEvent("node-selected", { + detail: { path, config, node: graph }, + bubbles: true, + }) ); + }); render( html` - + - + `, graph @@ -83,31 +116,73 @@ function makeConditionNode(config) { return graph; } -function makeChooseNode(config) { +function makeChooseNode( + config: AutomationActionConfig, + path: string, + trace?: AutomationTrace +) { const graph = document.createElement("hat-graph") as HatGraph; graph.branching = true; + graph.setAttribute("tabindex", "0"); - const focused = () => + graph.addEventListener("focus", () => { graph.dispatchEvent( - new CustomEvent("node-selected", { detail: { config }, bubbles: true }) + new CustomEvent("node-selected", { + detail: { path, config, node: graph }, + bubbles: true, + }) ); + }); + + if (trace && path in trace) { + for (const [i, _] of Object.entries(config.choose)) { + if (`${path}/choose/${i}` in trace) { + graph.setAttribute("mark_start", `${i}`); + console.log(config.choose[i], config.choose[i].length); + if ( + `${path}/choose/${i}/sequence/${ + config.choose[i].sequence.length - 1 + }` in trace + ) + graph.setAttribute("mark_end", `${i}`); + break; + } + } + if (`${path}/default` in trace) { + graph.setAttribute("mark_start", `${config.choose.length}`); + if (`${path}/default/${config.default.length - 1}` in trace) + graph.setAttribute("mark_end", `${config.choose.length}`); + } + } render( html` - + - ${config.choose?.map((branch) => { + ${config.choose?.map((branch, i) => { + const pth = `${path}/choose/${i}`; const head = document.createElement("hat-graph-node") as HatGraphNode; head.iconPath = mdiCheckBoxOutline; - head.addEventListener("focus", focused); - - return makeGraph(branch.sequence, head); + head.setAttribute("nofocus", ""); + if (trace && pth in trace) { + head.setAttribute("marked", ""); + } + return makeGraph(branch.sequence, `${pth}/sequence`, head, trace); })} ${(() => { + const pth = `${path}/default`; const head = document.createElement("hat-graph-node") as HatGraphNode; head.iconPath = mdiCheckboxBlankOutline; - head.addEventListener("focus", focused); - return makeGraph(config.default, head); + head.setAttribute("nofocus", ""); + if (trace && pth in trace) { + head.setAttribute("marked", ""); + } + return makeGraph(config.default, pth, head, trace); })()} `, graph @@ -115,49 +190,87 @@ function makeChooseNode(config) { return graph; } -function makeRepeatNode(config) { +function makeRepeatNode( + config: AutomationActionConfig, + path: string, + trace?: AutomationTrace +) { const graph = document.createElement("hat-graph") as HatGraph; graph.branching = true; + graph.setAttribute("tabindex", "0"); - const focused = () => + if (trace && path in trace) { + const marks = trace[path].repeats ? "0,1" : "1"; + graph.setAttribute("mark_start", marks); + if (`${path}/sequence/${config.repeat.sequence.length - 1}` in trace) + graph.setAttribute("mark_end", marks); + } + + graph.addEventListener("focus", () => { graph.dispatchEvent( - new CustomEvent("node-selected", { detail: { config }, bubbles: true }) + new CustomEvent("node-selected", { + detail: { path, config, node: graph }, + bubbles: true, + }) ); + }); render( html` - ${makeGraph(config.repeat.sequence)} + + ${makeGraph(config.repeat.sequence, `${path}/sequence`, undefined, trace)} `, graph ); return graph; } -function makeNode(config) { +function makeNode( + config: AutomationActionConfig, + path: string, + trace?: AutomationTrace +) { const type = OPTIONS.find((key) => key in config) || "yaml"; if (type in SPECIAL_NODES) { - return SPECIAL_NODES[type](config); + return SPECIAL_NODES[type](config, path, trace); } const node = document.createElement("hat-graph-node") as HatGraphNode; node.iconPath = ICONS[type]; + if (trace && path in trace) { + node.setAttribute("marked", ""); + } node.addEventListener("focus", (ev) => { node.dispatchEvent( - new CustomEvent("node-selected", { detail: { config }, bubbles: true }) + new CustomEvent("node-selected", { + detail: { path, config, node }, + bubbles: true, + }) ); }); return node; } -export function makeGraph(nodes, head = undefined) { +export function makeGraph( + nodes: AutomationActionConfig[], + path: string, + head = undefined, + trace?: AutomationTrace +) { const graph = document.createElement("hat-graph") as HatGraph; if (head) { @@ -166,7 +279,7 @@ export function makeGraph(nodes, head = undefined) { } for (const [i, nodeConfig] of nodes.entries()) { - const node = makeNode(nodeConfig); + const node = makeNode(nodeConfig, `${path}/${i}`, trace); node.addEventListener("update-node", (ev) => { ev.stopPropagation(); @@ -207,3 +320,108 @@ export function makeGraph(nodes, head = undefined) { return graph; } + +export function automationGraph( + automation: AutomationConfig, + trace: AutomationTrace = {} +) { + const graph = document.createElement("hat-graph"); + + const selected = (config, path: string) => { + return (ev) => { + const node = ev.target; + graph.dispatchEvent( + new CustomEvent("node-selected", { + detail: { path, config, node }, + bubbles: true, + }) + ); + }; + }; + + let marked_trigger = []; + // This part finds which trigger trace to colorize + for (const [i, trigger] of Object.entries(automation.trigger)) { + if (trace && `trigger/${i}` in trace) { + marked_trigger.push(i); + } + + // This would be a real ws event listener, I guess. + // Inspired by the animations when tags are scanned. + // Could be added to wait-for-trigger nodes and stuff too + document.addEventListener("event-triggered", (ev: CustomEvent) => { + if (ev.detail.trigger === trigger) { + const el = graph.querySelector( + `#trigger hat-graph-node:nth-of-type(${parseInt(i) + 1})` + ) as any; + if (el) { + if (el._timeout) { + clearTimeout(el._timeout); + el._timeout = undefined; + el.classList.remove("triggered"); + void el.offsetWidth; + } + el.classList.add("triggered"); + + el._timeout = window.setTimeout(() => { + el.classList.remove("triggered"); + el._timeout = undefined; + }, 10000); + } + } + }); + } + + for (const [i, condition] of Object.entries(automation.condition)) { + document.addEventListener("states-changed", (ev: CustomEvent) => { + const el = graph.querySelector( + `#condition hat-graph-node:nth-of-type(${parseInt(i) + 1})` + ) as any; + console.log(ev.detail); + if (ev.detail.state[condition]) { + el.iconPath = mdiCheck; + } else { + el.iconPath = mdiClose; + } + }); + } + + render( + html` + + ${automation.trigger.map((trigger, i) => { + const path = `trigger/${i}`; + return html` + + `; + })} + + + + ${automation.condition.map((condition, i) => { + const path = `condition/${i}`; + return html` + + `; + })} + + + + ${automation.action.map((action, i) => { + return makeNode(action, `action/${i}`, trace); + })} + + `, + graph + ); + return graph; +} diff --git a/src/hat-graph/types.ts b/src/hat-graph/types.ts new file mode 100644 index 0000000..304a626 --- /dev/null +++ b/src/hat-graph/types.ts @@ -0,0 +1,13 @@ +export interface AutomationConfig { + trigger: string[]; + condition: string[]; + action: AutomationActionConfig[]; +} + +export interface AutomationActionConfig { + [key: string]: any; +} + +export interface AutomationTrace { + [key: string]: any; +} diff --git a/src/main.js b/src/main.js index 56bc87b..700f974 100644 --- a/src/main.js +++ b/src/main.js @@ -1,26 +1,44 @@ -import { demoConfig2 } from "./demo-config"; +import { demoTrace, demoConfig } from "./demo-config"; import "@vanillawc/wc-codemirror"; -import { makeGraph } from "./hat-graph/make-graph"; +import { default as jsyaml } from "js-yaml"; + +import { automationGraph } from "./hat-graph/make-graph"; +import { html } from "lit-element"; +import { render } from "lit-html"; +import { mdiBowTie } from "@mdi/js"; +let selectedNode; function nodeSelected(ev) { - const code = document.querySelector("#snippet"); - code.value = jsyaml.safeDump(ev.detail.config); + if (selectedNode) { + selectedNode.removeAttribute("selected"); + } + ev.detail.node.setAttribute("selected", ""); + selectedNode = ev.detail.node; + + const snippet = document.querySelector("#snippet"); + snippet.querySelector("span").innerHTML = ev.detail.path; + const code = snippet.querySelector("wc-codemirror"); + code.value = jsyaml.dump(ev.detail.config); code.currentNode = ev.target; } -function rebuildGraph(config) { +function rebuildGraph(config = undefined) { const graphCard = document.querySelector("#graph"); + if (!config) { + const codeEl = document.querySelector("#fullcode wc-codemirror"); + config = jsyaml.load(codeEl.value); + } + while (graphCard.firstChild) graphCard.removeChild(graphCard.firstChild); - const graph = makeGraph(config); + const traceEl = document.querySelector("#trace wc-codemirror"); + const trace = jsyaml.load(traceEl.value); + + const graph = automationGraph(config, trace); + graph.addEventListener("node-selected", nodeSelected); - graph.addEventListener("update-node", (ev) => { - const code = document.querySelector("#fullcode"); - code.value = jsyaml.safeDump(ev.detail.config); - window.setTimeout(() => rebuildGraph(ev.detail.config), 100); - }); graphCard.appendChild(graph); } @@ -29,7 +47,7 @@ function setup_snippet_editor() { document.querySelector("#saveSnippet").onclick = () => { const code = document.querySelector("#snippet"); if (code.currentNode) { - code.currentNode.updateNode(jsyaml.safeLoad(code.value)); + code.currentNode.updateNode(jsyaml.load(code.value)); } }; document.querySelector("#deleteSnippet").onclick = () => { @@ -40,21 +58,70 @@ function setup_snippet_editor() { }; } -function setup() { - setup_snippet_editor(); +function setup_trace_editor() { + const src = demoTrace; + const trace = document.querySelector("#trace"); + window.setTimeout( + () => (trace.querySelector("wc-codemirror").value = jsyaml.dump(src)), + 100 + ); + trace.querySelector("button").addEventListener("click", () => rebuildGraph()); +} - let src = demoConfig2; - const fullcode = document.querySelector("#fullcode"); - fullcode.mode = "yaml"; - window.setTimeout(() => (fullcode.value = jsyaml.safeDump(src)), 100); +function setup_fullcode_editor() { + const src = demoConfig; + const code = document.querySelector("#fullcode"); - rebuildGraph(src); + window.setTimeout( + () => (code.querySelector("wc-codemirror").value = jsyaml.dump(src)), + 100 + ); - const updateButton = document.querySelector("#updateButton"); - updateButton.addEventListener("click", () => { - src = jsyaml.safeLoad(fullcode.value); - rebuildGraph(src); + code.querySelector("button").addEventListener("click", () => rebuildGraph()); +} + +function setup_trigger_buttons() { + const btn = document.querySelector("#triggers"); + btn.querySelectorAll("button").forEach((b, i) => { + b.addEventListener("click", () => { + console.log(i, "clicked"); + btn.dispatchEvent( + new CustomEvent("event-triggered", { + detail: { trigger: `trigger${i + 1}` }, + bubbles: true, + }) + ); + }); + }); + + btn.querySelectorAll("input").forEach((c, i) => { + c.addEventListener("change", () => { + const condition = ["a", "b", "c"][i]; + console.log(condition, "changed"); + btn.dispatchEvent( + new CustomEvent("states-changed", { + detail: { + state: { + a: btn.querySelector("#a").checked, + b: btn.querySelector("#b").checked, + c: btn.querySelector("#c").checked, + }, + }, + bubbles: true, + }) + ); + }); }); } +function setup() { + setup_snippet_editor(); + + setup_fullcode_editor(); + setup_trace_editor(); + setup_trigger_buttons(); + + window.setTimeout(() => rebuildGraph(), 300); +} + setup();