Browse Source

script editor

beelzebub
Gisle Aune 7 months ago
parent
commit
e06cf1ae34
  1. 6
      effects/animation.go
  2. 8
      effects/gradient.go
  3. 4
      effects/pattern.go
  4. 6
      effects/random.go
  5. 2
      effects/vrange.go
  6. 8
      frontend/src/lib/components/Checkbox.svelte
  7. 11
      frontend/src/lib/components/Icon.svelte
  8. 20
      frontend/src/lib/components/Modal.svelte
  9. 9
      frontend/src/lib/components/ModalSection.svelte
  10. 13
      frontend/src/lib/components/bforms/BFormColorOption.svelte
  11. 13
      frontend/src/lib/components/bforms/BFormOption.svelte
  12. 12
      frontend/src/lib/components/bforms/BFormParameter.svelte
  13. 3
      frontend/src/lib/components/scripting/ScriptAssignmentState.svelte
  14. 58
      frontend/src/lib/components/scripting/ScriptBlock.svelte
  15. 31
      frontend/src/lib/components/scripting/ScriptCondition.svelte
  16. 74
      frontend/src/lib/components/scripting/ScriptEffect.svelte
  17. 111
      frontend/src/lib/components/scripting/ScriptLine.svelte
  18. 19
      frontend/src/lib/components/scripting/ScriptLineBlock.svelte
  19. 38
      frontend/src/lib/components/scripting/ScriptMatch.svelte
  20. 18
      frontend/src/lib/components/scripting/ScriptSet.svelte
  21. 3
      frontend/src/lib/contexts/ModalContext.svelte
  22. 2
      frontend/src/lib/modals/DeviceModal.svelte
  23. 120
      frontend/src/lib/modals/ScriptModal.svelte
  24. 14
      frontend/src/lib/models/assignment.ts
  25. 13
      frontend/src/lib/models/command.ts
  26. 4
      frontend/src/lib/models/device.ts
  27. 146
      frontend/src/lib/models/script.ts
  28. 3
      frontend/src/lib/models/uistate.ts
  29. 12
      frontend/src/routes/+page.svelte
  30. 6
      services/httpapiv1/service.go
  31. 10
      services/mysqldb/mysqlgen/db.go
  32. 9
      services/mysqldb/mysqlgen/script.sql.go
  33. 3
      services/mysqldb/queries/script.sql
  34. 29
      services/mysqldb/service.go
  35. 6
      services/uistate/data.go

6
effects/animation.go

@ -7,9 +7,9 @@ import (
)
type Solid struct {
States []device.State `json:"states,omitempty"`
AnimationMS int64 `json:"animationMs,omitempty"`
Interleave int `json:"interleave,omitempty"`
States []device.State `json:"states"`
AnimationMS int64 `json:"animationMs"`
Interleave int `json:"interleave"`
}
func (e Solid) State(_, length, round int) device.State {

8
effects/gradient.go

@ -7,10 +7,10 @@ import (
)
type Gradient struct {
States []device.State `json:"states,omitempty"`
AnimationMS int64 `json:"animationMs,omitempty"`
Reverse bool `json:"reverse,omitempty"`
Interpolate bool `json:"interpolate,omitempty"`
States []device.State `json:"states"`
AnimationMS int64 `json:"animationMs"`
Reverse bool `json:"reverse"`
Interpolate bool `json:"interpolate"`
}
func (e Gradient) State(index, length, round int) device.State {

4
effects/pattern.go

@ -7,8 +7,8 @@ import (
)
type Pattern struct {
States []device.State `json:"states,omitempty"`
AnimationMS int64 `json:"animationMs,omitempty"`
States []device.State `json:"states"`
AnimationMS int64 `json:"animationMs"`
}
func (e Pattern) State(index, _, round int) device.State {

6
effects/random.go

@ -8,9 +8,9 @@ import (
)
type Random struct {
States []device.State `json:"states,omitempty"`
Interpolate bool `json:"interpolate,omitempty"`
AnimationMS int64 `json:"animationMs,omitempty"`
States []device.State `json:"states"`
Interpolate bool `json:"interpolate"`
AnimationMS int64 `json:"animationMs"`
}
func (e Random) State(_, _, _ int) device.State {

2
effects/vrange.go

@ -7,7 +7,7 @@ import (
)
type VRange struct {
States []device.State `json:"states,omitempty"`
States []device.State `json:"states"`
Variable string `json:"variable"`
Min float64 `json:"min"`
Max float64 `json:"max"`

8
frontend/src/lib/components/Checkbox.svelte

@ -4,12 +4,13 @@
import Icon, { type IconName } from "./Icon.svelte";
export let tabIndex: number | undefined | null = void(0);
export let checked = false;
export let checked: boolean | string | number;
export let centered = false;
export let disabled = false;
export let noLabel = false;
export let noBorder = false;
export let inline = false;
export let small = false;
export let icon: IconName = "check";
export let label = "(Missing label property)";
@ -35,6 +36,7 @@
class:centered
class:noLabel
class:inline
class:small
role="checkbox"
aria-checked={checked}
aria-disabled={disabled}
@ -72,6 +74,10 @@
margin-bottom: 0
font-size: 0.75em
&.small
padding-top: 0
font-size: 0.65em
div.checkbox.centered
margin: auto

11
frontend/src/lib/components/Icon.svelte

@ -55,7 +55,13 @@
import { faMasksTheater } from "@fortawesome/free-solid-svg-icons/faMasksTheater";
import { faTag } from "@fortawesome/free-solid-svg-icons/faTag";
import { faFilter } from "@fortawesome/free-solid-svg-icons/faFilter";
import { faSignature } from "@fortawesome/free-solid-svg-icons/faSignature";
import { faAsterisk } from "@fortawesome/free-solid-svg-icons/faAsterisk";
import { faPlay } from "@fortawesome/free-solid-svg-icons/faPlay";
import { faClockRotateLeft } from "@fortawesome/free-solid-svg-icons/faClockRotateLeft";
import { faNoteSticky } from "@fortawesome/free-solid-svg-icons/faNoteSticky";
const icons = {
"clock": faClock,
"thumbtack": faThumbtack,
@ -90,6 +96,11 @@
"masks_theater": faMasksTheater,
"tag": faTag,
"filter": faFilter,
"signature": faSignature,
"asterisk": faAsterisk,
"play": faPlay,
"clock_rotate_left": faClockRotateLeft,
"note_sticky": faNoteSticky,
};
export type IconName = keyof typeof icons;

20
frontend/src/lib/components/Modal.svelte

@ -6,6 +6,8 @@
export let submitText: string = "Submit";
export let cancelLabel: string = "Cancel";
export let wide: boolean = false;
export let ultrawide: boolean = false;
export let fullheight: boolean = false;
export let error: string | null = null;
export let closable: boolean = false;
export let disabled: boolean = false;
@ -28,7 +30,7 @@
{#if show}
<!-- svelte-ignore a11y-no-noninteractive-element-interactions -->
<div role="dialog" class="modal-background" on:keypress={onKeyPress}>
<div class="modal" class:wide class:nobody>
<div class="modal" class:wide class:ultrawide class:fullheight class:nobody>
<div class="header" class:nobody>
<div class="title" class:noclose={!closable}>{titleText}</div>
{#if (closable)}
@ -88,9 +90,13 @@
&.fullheight {
min-height: 100%
}
}
div.modal.wide {
max-width: 80ch;
&.wide {
max-width: 80ch;
}
&.ultrawide {
max-width: 120ch;
}
}
div.header {
@ -186,7 +192,7 @@
-moz-user-select: none;
}
div.modal :global(input:not(.custom)), div.modal :global(select), div.modal :global(textarea) {
div.modal :global(input:not(.custom)), div.modal :global(select:not(.custom)), div.modal :global(textarea) {
width: calc(100% - 2ch);
margin-bottom: 1em;
margin-top: 0.25em;
@ -198,12 +204,12 @@
resize: vertical;
padding: 0.5em 1ch;
}
div.modal :global(select) {
div.modal :global(select:not(.custom)) {
padding-left: 0.5ch;
padding: 0.45em 1ch;
width: 100%;
}
div.modal :global(select option), div.modal :global(select optgroup) {
div.modal :global(select:not(.custom) option), div.modal :global(select:not(.custom) optgroup) {
-webkit-appearance: none;
-moz-appearance: none;
appearance: none;

9
frontend/src/lib/components/ModalSection.svelte

@ -3,9 +3,12 @@
export let expanded: boolean;
export let title: string;
export let disableExpandToggle: boolean = false;
function toggleExpand() {
expanded = !expanded;
if (!disableExpandToggle) {
expanded = !expanded;
}
}
</script>
@ -13,7 +16,9 @@
<!-- svelte-ignore a11y-click-events-have-key-events -->
<div class="modal-section-header" on:click={toggleExpand}>
<div class="title">{title}</div>
<Icon block name="{expanded ? "chevron_down" : "chevron_right"}" />
{#if !disableExpandToggle}
<Icon block name="{expanded ? "chevron_down" : "chevron_right"}" />
{/if}
</div>
<div class="modal-section-body">
<slot></slot>

13
frontend/src/lib/components/bforms/BFormColorOption.svelte

@ -5,6 +5,7 @@
<script lang="ts">
import { rgbString, type Color, stringifyColor, parseColor } from "$lib/models/color";
import { hsToHsl, kToRgb, xyToRgb } from "$lib/utils/color";
import { onMount } from "svelte";
import ColorPicker, { selectedColorPicker } from "../ColorPicker.svelte";
import BFormOption from "./BFormOption.svelte";
import BFormParameter from "./BFormParameter.svelte";
@ -46,6 +47,18 @@
value = stringifyColor(color);
}
onMount(() => {
if (value !== null && $selectedColorPicker !== null) {
$selectedColorPicker = colorPickerId;
}
return () => {
if ($selectedColorPicker = colorPickerId) {
$selectedColorPicker = null;
}
}
})
let colorStr = "";
$: {
if (color !== null) {

13
frontend/src/lib/components/bforms/BFormOption.svelte

@ -17,14 +17,15 @@
export let icon: IconName = "check";
export let unclickable: boolean = false;
export let red: boolean = false;
export let alwaysOpen: boolean = false;
const enabled = writable(state === true);
$: $enabled = state === true;
$: $enabled = state === true || alwaysOpen;
setContext(ctxKey, { subscribe: enabled.subscribe });
</script>
<div class="bform-option" class:unclickable class:on={state === true} class:off={state === false} style="--color: {color}">
<div class="bform-option" class:red class:unclickable class:on={state === true} class:off={state === false} style="--color: {color}">
<Icon on:click block name={icon} />
<div class="bform-option-body">
<slot></slot>
@ -42,24 +43,24 @@
cursor: pointer
background: $color-main1
:global(.icon)
> :global(div > .icon)
padding: 0.1em 0.5ch
padding-top: 0.45em
color: $color-main2
&.off
background-color: $color-main2
:global(.icon)
> :global(div > .icon)
color: $color-main4
&.on
background-color: $color-main2
:global(.icon)
> :global(div > .icon)
color: var(--color)
&.red
background-color: $color-main2-red
:global(.icon)
> :global(div > .icon)
color: $color-main6-redder
&.unclickable

12
frontend/src/lib/components/bforms/BFormParameter.svelte

@ -1,9 +1,10 @@
<script lang="ts">
import { getBFormOptionEnabled } from "./BFormOption.svelte";
import Checkbox from "../Checkbox.svelte";
import { getBFormOptionEnabled } from "./BFormOption.svelte";
export let label: string;
export let type: "number" | "text" | "select";
export let value: number | string;
export let type: "number" | "text" | "select" | "checkbox";
export let value: number | string | boolean;
export let wide: boolean = false;
export let narrow: boolean = false;
export let narrower: boolean = false;
@ -23,6 +24,8 @@
<input class="custom" on:blur on:focus type="number" bind:value={value} {min} {max} {step} />
{:else if type === "text"}
<input class="custom" on:blur on:focus type="text" bind:value={value} />
{:else if type === "checkbox"}
<Checkbox small centered noLabel bind:checked={value} />
{:else if type === "select"}
<select class="custom" bind:value={value}>
{#each options as opt (opt.value)}
@ -84,10 +87,7 @@
&.wide
> input, > label, > select
text-align: left
width: 20rem
> label
padding-left: 0.2rch
&.narrow
> input, > label, > select

3
frontend/src/lib/components/scripting/ScriptAssignmentState.svelte

@ -1,6 +1,5 @@
<script lang="ts">
import type { State } from "$lib/models/device";
import type { ColorRGB } from '$lib/models/color';
import BFormLine from "../bforms/BFormLine.svelte";
import BFormTemperatureOption from "../bforms/BFormTemperatureOption.svelte";
import BFormColorOption from "../bforms/BFormColorOption.svelte";
@ -10,8 +9,6 @@
export let value: State;
export let deletable: boolean = false;
$: console.log(value);
</script>
<BFormLine>

58
frontend/src/lib/components/scripting/ScriptBlock.svelte

@ -0,0 +1,58 @@
<script lang="ts">
import { createEventDispatcher } from "svelte";
import Icon from "../Icon.svelte";
export let label: string;
export let add: boolean = false;
export let nopad: boolean = false;
const dispatch = createEventDispatcher();
function onAdd() {
dispatch("add");
}
</script>
<div class="block" class:nopad>
<div class="label">
<div class="text">{label}</div>
{#if add}
<!-- svelte-ignore a11y-click-events-have-key-events -->
<div class="add-button" on:click={onAdd}><Icon name="plus" /></div>
{/if}
</div>
<slot></slot>
</div>
<style lang="sass">
@import "$lib/css/colors.sass"
div.block
padding-left: 1ch
margin-left: 0.25ch
border-left: 2px solid $color-main2
margin-bottom: 0.5em
&.nopad
margin-left: 0
div.label
color: $color-main5
font-size: 0.666em
padding-top: 0.1em
padding-left: 0.3ch
display: flex
flex-direction: row
div.add-button
color: $color-green-dark
margin-top: 0.1em
margin-left: 0.5ch
font-size: 0.9em
line-height: 1em
cursor: pointer
&:hover
color: $color-green
</style>

31
frontend/src/lib/components/scripting/ScriptCondition.svelte

@ -0,0 +1,31 @@
<script lang="ts">
import type { ScriptCondition } from "$lib/models/script";
import BFormOption from "../bforms/BFormOption.svelte";
import BFormParameter from "../bforms/BFormParameter.svelte";
export let value: Required<ScriptCondition>
</script>
<BFormOption state unclickable icon="question">
<BFormParameter narrowest type="checkbox" bind:value={value.not} label="Not" />
<BFormParameter narrow type="select" bind:value={value.scope} label="Scope" options={[
{ label: "Match", value: "match" },
{ label: "Devices", value: "device" },
{ label: "Global", value: "global" },
]} />
<BFormParameter type="text" bind:value={value.key} label="Key" />
<BFormParameter narrow type="select" bind:value={value.op} label="Op" options={[
{ label: "==", value: "eq" },
{ label: "!==", value: "neq" },
{ label: ">", value: "gt" },
{ label: ">=", value: "gte" },
{ label: "<", value: "lt" },
{ label: "<=", value: "lte" },
{ label: "contains", value: "contains" },
{ label: "exists", value: "exists" },
{ label: "numerical", value: "numerical" },
]} />
{#if value.op !== "numerical" && value.op !== "exists"}
<BFormParameter type="text" bind:value={value.value} label="Value" />
{/if}
</BFormOption>

74
frontend/src/lib/components/scripting/ScriptEffect.svelte

@ -0,0 +1,74 @@
<script lang="ts">
import type { EffectRaw } from "$lib/models/assignment";
import BFormOption from "../bforms/BFormOption.svelte";
import BFormParameter from "../bforms/BFormParameter.svelte";
export let value: EffectRaw
</script>
<BFormOption unclickable state icon="play">
<BFormParameter
narrow label="Effect" type="select" bind:value={value.kind}
options={[
{ value:"solid", label:"Solid" },
{ value:"gradient", label:"Gradient" },
{ value:"pattern", label:"Pattern" },
{ value:"random", label:"Random" },
{ value:"vrange", label:"V. Range" },
]}
/>
</BFormOption>
{#if value.kind !== "manual" && value.kind !== "vrange"}
<BFormOption alwaysOpen unclickable state={value.animationMs > 0} icon="clock_rotate_left">
<BFormParameter
narrow label="Milliseconds" type="number"
min={0} max={10000000} step={100}
bind:value={value.animationMs}
/>
{#if value.kind === "solid"}
<BFormParameter
narrower label="Interpolate" type="number"
min={0} max={10} step={1}
bind:value={value.interleave}
/>
{/if}
{#if value.kind === "gradient"}
<BFormParameter
narrower label="Reverse" type="checkbox"
bind:value={value.reverse}
/>
{/if}
{#if value.kind === "gradient" || value.kind === "random"}
<BFormParameter
narrower label="Interpolate" type="checkbox"
bind:value={value.interpolate}
/>
{/if}
</BFormOption>
{/if}
{#if value.kind === "vrange"}
<BFormOption unclickable state icon="temperature_half">
<BFormParameter
label="Variable" type="select" bind:value={value.variable}
options={[
{ value:"motion.min", label:"Min Motion" },
{ value:"motion.max", label:"Max Motion" },
{ value:"motion.avg", label:"Avg Motion" },
{ value:"temperature.min", label:"Min Temperature" },
{ value:"temperature.max", label:"Max Temperature" },
{ value:"temperature.avg", label:"Avg Temperature" },
]}
/>
<BFormParameter
narrower label="Min" type="number"
min={0} max={1000} step={1}
bind:value={value.min}
/>
<BFormParameter
narrower label="Max" type="number"
min={0} max={1000} step={1}
bind:value={value.max}
/>
</BFormOption>
{/if}

111
frontend/src/lib/components/scripting/ScriptLine.svelte

@ -0,0 +1,111 @@
<script lang="ts" context="module">
const KIND_ICONS: Record<ScriptLineEditable["kind"], IconName> = {
"if": "chevron_right",
"select": "filter",
"assign": "swatch_book",
"set": "pen",
}
</script>
<script lang="ts">
import { BLANK_STATE, copyState } from "$lib/models/device";
import { toEditableScriptLine, type ScriptLineEditable } from "$lib/models/script";
import type { IconName } from "../Icon.svelte";
import BFormDeleteOption from "../bforms/BFormDeleteOption.svelte";
import BFormLine from "../bforms/BFormLine.svelte";
import BFormOption from "../bforms/BFormOption.svelte";
import BFormParameter from "../bforms/BFormParameter.svelte";
import ScriptAssignmentState from "./ScriptAssignmentState.svelte";
import ScriptBlock from "./ScriptBlock.svelte";
import ScriptCondition from "./ScriptCondition.svelte";
import ScriptEffect from "./ScriptEffect.svelte";
import ScriptLineBlock from "./ScriptLineBlock.svelte";
import ScriptMatch from "./ScriptMatch.svelte";
import ScriptSet from "./ScriptSet.svelte";
export let value: ScriptLineEditable;
function deleteState(index: number) {
value.effect.states = [
...value.effect.states.slice(0, index),
...value.effect.states.slice(index + 1),
];
if (value.effect.states.length === 0) {
value.effect.states = [{...BLANK_STATE}];
}
}
function addState() {
value.effect.states = [
...value.effect.states,
copyState(value.effect.states[value.effect.states.length - 1]),
];
}
function addThen() {
value.then = [
...value.then,
toEditableScriptLine({if: {
condition: { scope: "global", key: "", op: "eq" },
then: [],
else: [],
}})
]
}
function addElse() {
value.else = [
...value.else,
toEditableScriptLine({if: {
condition: { scope: "global", key: "", op: "eq" },
then: [],
else: [],
}})
]
}
</script>
<BFormLine>
<BFormOption unclickable state icon={KIND_ICONS[value.kind]}>
<BFormParameter
narrow
label="Op"
type="select"
bind:value={value.kind}
options={[
{label: "If", value: "if"},
{label: "Assign", value: "assign"},
{label: "Set", value: "set"},
{label: "Select", value: "select"},
]}
/>
</BFormOption>
{#if value.kind === "assign" || value.kind === "select"}
<ScriptMatch bind:matchKind={value.matchKind} bind:matchValue={value.matchValue} />
{/if}
{#if value.kind === "assign"}
<ScriptEffect bind:value={value.effect} />
{/if}
{#if value.kind === "if"}
<ScriptCondition bind:value={value.condition} />
{/if}
{#if value.kind === "set"}
<ScriptSet bind:key={value.key} bind:scope={value.scope} bind:value={value.value} />
{/if}
<BFormDeleteOption on:delete />
</BFormLine>
{#if value.kind === "assign"}
<ScriptBlock on:add={addState} add label="States">
{#each value.effect.states as state, i}
<ScriptAssignmentState bind:value={state} deletable on:delete={() => deleteState(i)} />
{/each}
</ScriptBlock>
{/if}
{#if value.kind === "select" || value.kind === "if"}
<ScriptLineBlock on:add={addThen} add label="Then" bind:value={value.then} />
{/if}
{#if value.kind === "if"}
<ScriptLineBlock on:add={addElse} add label="Else" bind:value={value.else} />
{/if}

19
frontend/src/lib/components/scripting/ScriptLineBlock.svelte

@ -0,0 +1,19 @@
<script lang="ts">
import type { ScriptLineEditable } from "$lib/models/script";
import ScriptBlock from "./ScriptBlock.svelte";
import ScriptLine from "./ScriptLine.svelte";
export let label: string;
export let value: ScriptLineEditable[];
export let add: boolean = false;
function deleteLine(i: number) {
value = [...value.slice(0, i), ...value.slice(i + 1)];
}
</script>
<ScriptBlock on:add add={add} label={label}>
{#each value as line, i}
<ScriptLine bind:value={line} on:delete={() => deleteLine(i)} />
{/each}
</ScriptBlock>

38
frontend/src/lib/components/scripting/ScriptMatch.svelte

@ -0,0 +1,38 @@
<script lang="ts" context="module">
const MATCH_ICONS: Record<"all" | "raw" | "name" | "role", IconName> = {
name: "signature",
raw: "cog",
role: "masks_theater",
all: "asterisk",
}
</script>
<script lang="ts">
import type { IconName } from "../Icon.svelte";
import BFormOption from "../bforms/BFormOption.svelte";
import BFormParameter from "../bforms/BFormParameter.svelte";
export let matchKind: "all" | "name" | "role" | "raw";
export let matchValue: string;
function toggleMatchKind() {
switch (matchKind) {
case "all": matchKind = "name"; break;
case "name": matchKind = "role"; break;
case "role": matchKind = "raw"; break;
case "raw": matchKind = "all"; break;
}
}
</script>
<BFormOption on:click={toggleMatchKind} state icon={MATCH_ICONS[matchKind]}>
{#if matchKind !== "all"}
<BFormParameter
wide={matchKind === "raw"}
type="text"
label="Match ({matchKind})"
bind:value={matchValue}
/>
{/if}
</BFormOption>

18
frontend/src/lib/components/scripting/ScriptSet.svelte

@ -0,0 +1,18 @@
<script lang="ts">
import BFormOption from "../bforms/BFormOption.svelte";
import BFormParameter from "../bforms/BFormParameter.svelte";
export let scope: string;
export let key: string;
export let value: string;
</script>
<BFormOption state unclickable icon="note_sticky">
<BFormParameter narrow type="select" bind:value={scope} label="Scope" options={[
{ label: "Match", value: "match" },
{ label: "Devices", value: "device" },
{ label: "Global", value: "global" },
]} />
<BFormParameter type="text" bind:value={key} label="Key" />
<BFormParameter type="text" bind:value={value} label="Value" />
</BFormOption>

3
frontend/src/lib/contexts/ModalContext.svelte

@ -10,8 +10,7 @@
export type ModalSelection =
| { kind: "closed" }
| { kind: "device.edit", op: DeviceEditOp }
| { kind: "script.edit", id: string | null, lines: ScriptLine[] }
| { kind: "script.execute", script: Script }
| { kind: "script.edit" }
export interface ModalContextState {
modal: Writable<ModalSelection>

2
frontend/src/lib/modals/DeviceModal.svelte

@ -141,8 +141,6 @@
disabled = true;
let shouldWait = false;
console.log("SOOBMIEET")
try {
if (enableRename && newName !== "") {
await runCommand({addAlias: { match, alias: `lucifer:name:${newName}` }});

120
frontend/src/lib/modals/ScriptModal.svelte

@ -0,0 +1,120 @@
<script lang="ts">
import { runCommand } from "$lib/client/lucifer";
import HSplit from "$lib/components/HSplit.svelte";
import HSplitPart from "$lib/components/HSplitPart.svelte";
import Modal from "$lib/components/Modal.svelte";
import ModalBody from "$lib/components/ModalBody.svelte";
import ModalSection from "$lib/components/ModalSection.svelte";
import ScriptLineBlock from "$lib/components/scripting/ScriptLineBlock.svelte";
import { getModalContext } from "$lib/contexts/ModalContext.svelte";
import { getSelectedContext } from "$lib/contexts/SelectContext.svelte";
import { getStateContext } from "$lib/contexts/StateContext.svelte";
import { toEditableScriptLine, type ScriptLineEditable, fromEditableScriptLine } from "$lib/models/script";
const { state } = getStateContext();
const { selectedMasks } = getSelectedContext();
const { modal } = getModalContext();
let show = false;
let disabled = false;
let selectedScript = "";
let runMatch = "";
let newScriptName = "New Script";
let current: ScriptLineEditable[] = [];
function onOpen() {
disabled = false;
current = [];
selectedScript = scriptNames?.[0] || "";
loadScript(selectedScript);
}
async function onSubmit() {
disabled = true;
try {
await runCommand({updateScript: { name: selectedScript || newScriptName, lines: current.map(fromEditableScriptLine) }});
if (runMatch !== "") {
await runCommand({executeScript: { name: selectedScript || newScriptName, match: runMatch, }});
}
await new Promise(resolve => setTimeout(resolve, 500));
selectedScript = selectedScript || newScriptName;
} catch(_) {}
disabled = false;
}
function loadScript(name: string) {
if (name === "") {
current = [];
} else {
current = $state?.scripts[name]?.map(toEditableScriptLine) || [];
}
}
function addLine() {
current = [
...current,
toEditableScriptLine({if: {
condition: { scope: "global", key: "", op: "eq" },
then: [],
else: [],
}})
]
}
$: if ($modal.kind === "script.edit") {
show = true;
onOpen();
} else {
show = false;
}
$: scriptNames = Object.keys($state?.scripts||{}).sort();
$: loadScript(selectedScript);
</script>
<form novalidate on:submit|preventDefault={onSubmit}>
<Modal
ultrawide closable show={show}
titleText="Script Editor"
disabled={disabled || (selectedScript === "" && current.length === 0)}
submitText={(current.length > 0 || selectedScript === "") ? "Save Script" : "Delete Script"}
>
<ModalBody>
<HSplit>
<HSplitPart weight={33.3}>
<label for="mask">Script</label>
<select bind:value={selectedScript}>
{#each scriptNames as scriptName}
<option value={scriptName}>{scriptName}</option>
{/each}
<option value="">New Script</option>
</select>
</HSplitPart>
<HSplitPart weight={33.3}>
{#if selectedScript === ""}
<label for="mask">New Script Name</label>
<input type="text" bind:value={newScriptName}>
{/if}
</HSplitPart>
<HSplitPart weight={33.4}>
{#if $selectedMasks.length > 0}
<label for="mask">Run on Save</label>
<select bind:value={runMatch}>
<option value="">Don't Run</option>
{#each $selectedMasks as option (option)}
<option value={option}>{option}</option>
{/each}
</select>
{/if}
</HSplitPart>
</HSplit>
<ModalSection expanded disableExpandToggle title="Edit Script">
<ScriptLineBlock add on:add={addLine} label="Script" bind:value={current} />
</ModalSection>
</ModalBody>
</Modal>
</form>

14
frontend/src/lib/models/assignment.ts

@ -23,26 +23,26 @@ export type Effect =
export function toEffectRaw(effect?: Effect): EffectRaw {
if (effect != null) {
if ("manual" in effect) {
return { kind: "manual", states: [effect.manual], animationMs: 0, reverse: false, interpolate: false, min: 0, max: 0, variable: "", interleave: 0 }
return { kind: "solid", states: [effect.manual], animationMs: 0, reverse: false, interpolate: true, min: 30, max: 300, variable: "motion.min", interleave: 0 }
}
if ("solid" in effect) {
return { kind: "solid", ...effect.solid, min: 0, max: 0, variable: "", interpolate: false, reverse: false}
return { kind: "solid", ...effect.solid, min: 30, max: 300, variable: "motion.min", interpolate: true, reverse: false}
}
if ("gradient" in effect) {
return { kind: "gradient", ...effect.gradient, min: 0, max: 0, variable: "", interleave: 0 }
return { kind: "gradient", ...effect.gradient, min: 30, max: 300, variable: "motion.min", interleave: 0 }
}
if ("pattern" in effect) {
return { kind: "pattern", ...effect.pattern, reverse: false, interpolate: false, min: 0, max: 0, variable: "", interleave: 0 }
return { kind: "pattern", ...effect.pattern, reverse: false, interpolate: true, min: 30, max: 300, variable: "motion.min", interleave: 0 }
}
if ("random" in effect) {
return { kind: "random", ...effect.random, reverse: false, min: 0, max: 0, variable: "", interleave: 0 }
return { kind: "random", ...effect.random, reverse: false, min: 30, max: 300, variable: "motion.min", interleave: 0 }
}
if ("vrange" in effect) {
return { kind: "vrange", ...effect.vrange, animationMs: 0, interpolate: false, reverse: false, interleave: 0 }
return { kind: "vrange", ...effect.vrange, animationMs: 0, interpolate: true, reverse: false, interleave: 0 }
}
}
return { kind: "manual", states: [{color: null, intensity: null, power: null, temperature: null}], animationMs: 0, reverse: false, interpolate: false, min: 0, max: 0, variable: "", interleave: 0 }
return { kind: "manual", states: [{color: null, intensity: null, power: null, temperature: null}], animationMs: 0, reverse: false, interpolate: false, min: 30, max: 300, variable: "motion.min", interleave: 0 }
}
export function fromEffectRaw(raw: EffectRaw): Effect {

13
frontend/src/lib/models/command.ts

@ -1,9 +1,22 @@
import type { AssignmentInput } from "./assignment"
import type { ScriptLine } from "./script"
export default interface CommandInput {
addAlias?: AddAliasCommand
removeAlias?: RemoveAliasComamnd
assign?: AssignmentInput
updateScript?: UpdateScriptCommand
executeScript?: ExecuteScriptCommand
}
export interface UpdateScriptCommand {
name: string
lines: ScriptLine[]
}
export interface ExecuteScriptCommand {
name: string
match: string
}
export interface AddAliasCommand {

4
frontend/src/lib/models/device.ts

@ -62,6 +62,10 @@ export const BLANK_STATE: State = (Object.seal||(v=>v))({
color: null,
});
export function copyState(state?: State | null): State {
return { ...(state || BLANK_STATE) };
}
export type DeviceEditOp = "none" | "assign" | "rename" | "change_icon" | "move_group" | "move_room" | "change_tags" | "change_roles";

146
frontend/src/lib/models/script.ts

@ -1,16 +1,31 @@
import type { Effect } from "./assignment"
import { toEffectRaw, type Effect, type EffectRaw, fromEffectRaw } from "./assignment"
export default interface Script {
name: string
lines: ScriptLine[]
}
export interface ScriptLine {
if: ScriptLineIf
assign: ScriptLineAssign
set: ScriptLineSet
type ScriptLineEditableData = ScriptLineIf & ScriptLineAssign & ScriptLineSet & ScriptLineSelect
export interface ScriptLineEditable {
kind: "if" | "assign" | "set" | "select"
condition: Required<ScriptCondition>
effect: EffectRaw
matchKind: "all" | "name" | "role" | "raw"
matchValue: string
scope: string
key: string
value: string
then: ScriptLineEditable[]
else: ScriptLineEditable[]
}
export type ScriptLine =
| { if: ScriptLineIf }
| { assign: ScriptLineAssign }
| { set: ScriptLineSet }
| { select: ScriptLineSelect }
export interface ScriptLineIf {
condition: ScriptCondition
then: ScriptLine[]
@ -35,3 +50,124 @@ export interface ScriptLineSet {
key: string
value: string
}
export interface ScriptLineAssign {
match: string
effect: Effect
}
export interface ScriptLineSelect {
match: string
then: ScriptLine[]
}
export function fromEditableScriptLine(line: ScriptLineEditable): ScriptLine {
switch (line.kind) {
case "if": return {
if: {
condition: line.condition,
then: line.then.map(fromEditableScriptLine),
else: line.else.map(fromEditableScriptLine),
}
};
case "assign": return {
assign: {
match: fromMatchKindValue(line),
effect: fromEffectRaw(line.effect),
}
}
case "select": return {
select: {
match: fromMatchKindValue(line),
then: line.then.map(fromEditableScriptLine),
}
}
case "set": return {
set: {
key: line.key,
scope: line.scope,
value: line.value,
}
}
}
}
export function toEditableScriptLine(line: ScriptLine): ScriptLineEditable {
const base = emptyEditable();
if ("assign" in line) {
base.kind = "assign";
base.effect = toEffectRaw(line.assign.effect);
Object.assign(base, toMatchKindValue(line.assign.match));
} else if ("if" in line) {
base.kind = "if";
base.condition = {
...line.if.condition,
not: line.if.condition.not || false,
value: line.if.condition.value || "",
};
base.then = line.if.then.map(toEditableScriptLine);
base.else = line.if.else.map(toEditableScriptLine);
} else if ("select" in line) {
base.kind = "select";
Object.assign(base, toMatchKindValue(line.select.match));
base.then = line.select.then.map(toEditableScriptLine);
} else if ("set" in line) {
base.kind = "set";
base.key = line.set.key
base.scope = line.set.scope
base.value = line.set.value
}
return base;
}
function toMatchKindValue(match: string): { matchKind: "all" | "name" | "role" | "raw", matchValue: string } {
if (match.startsWith("lucifer:name:")) {
return { matchKind: "name", matchValue: match.slice("lucifer:name:".length) };
} else if (match.startsWith("lucifer:role:")) {
return { matchKind: "role", matchValue: match.slice("lucifer:role:".length) };
} else if (match === "*") {
return { matchKind: "all", matchValue: "*" };
} else {
return { matchKind: "raw", matchValue: match }
}
}
function fromMatchKindValue({matchKind, matchValue }: { matchKind: "all" | "name" | "role" | "raw", matchValue: string }): string {
switch (matchKind) {
case "name": return `lucifer:name:${matchValue}`;
case "role": return `lucifer:role:${matchValue}`;
case "all": return "*";
case "raw": return matchValue;
}
}
function emptyEditable(): ScriptLineEditable {
return {
condition: {
key: "",
op: "add",
scope: "global",
not: false,
value: "",
},
effect: toEffectRaw({
solid: {
states: [
{color: null, intensity: null, power: null, temperature: null}
],
interleave: 0,
animationMs: 0,
}
}),
key: "",
kind: "assign",
matchKind: "name",
matchValue: "*",
scope: "match",
value: "",
then: [],
else: [],
}
}

3
frontend/src/lib/models/uistate.ts

@ -1,11 +1,12 @@
import type Assignment from "./assignment";
import type Device from "./device";
import type { ScriptLine } from "./script";
import type Script from "./script";
export default interface UIState {
devices: {[id: string]: Device}
assignments: {[id: string]: Assignment}
scripts: {[id: string]: Script}
scripts: {[id: string]: ScriptLine[]}
}
export interface UIStatePatch {

12
frontend/src/routes/+page.svelte

@ -6,16 +6,23 @@
import { getSelectedContext } from "$lib/contexts/SelectContext.svelte";
import { getStateContext } from "$lib/contexts/StateContext.svelte";
import DeviceModal from "$lib/modals/DeviceModal.svelte";
import ScriptModal from "$lib/modals/ScriptModal.svelte";
const {selectedList} = getSelectedContext();
const {roomList} = getStateContext();
const {modal} = getModalContext();
function handleKeyPress(e: KeyboardEvent) {
if ($modal.kind === "closed" && $selectedList.length && !e.ctrlKey && !e.shiftKey) {
if ($modal.kind === "closed" && !e.ctrlKey && !e.shiftKey) {
switch (e.key.toLocaleLowerCase()) {
case 'e':
modal.set({kind: "device.edit", op: "none"});
if ($selectedList.length) {
modal.set({kind: "device.edit", op: "none"});
e.preventDefault();
}
break;
case 'o':
modal.set({kind: "script.edit"});
e.preventDefault();
break;
}
@ -47,6 +54,7 @@
</div>
<DeviceModal />
<ScriptModal />
<style>
div.page {

6
services/httpapiv1/service.go

@ -177,7 +177,11 @@ func New(addr string) (lucifer3.Service, error) {
}
}
if patch.Script != nil {
statePatch.Scripts[patch.Script.Name] = svc.data.Scripts[patch.Script.Name]
if len(patch.Script.Lines) > 0 {
statePatch.Scripts[patch.Script.Name] = svc.data.Scripts[patch.Script.Name]
} else {
statePatch.Scripts[patch.Script.Name] = nil
}
}
}
svc.mu.Unlock()

10
services/mysqldb/mysqlgen/db.go

@ -48,6 +48,9 @@ func Prepare(ctx context.Context, db DBTX) (*Queries, error) {
if q.deleteDeviceInfoLikeStmt, err = db.PrepareContext(ctx, deleteDeviceInfoLike); err != nil {
return nil, fmt.Errorf("error preparing query DeleteDeviceInfoLike: %w", err)
}
if q.deleteScriptStmt, err = db.PrepareContext(ctx, deleteScript); err != nil {
return nil, fmt.Errorf("error preparing query DeleteScript: %w", err)
}
if q.deleteScriptTriggerStmt, err = db.PrepareContext(ctx, deleteScriptTrigger); err != nil {
return nil, fmt.Errorf("error preparing query DeleteScriptTrigger: %w", err)
}
@ -138,6 +141,11 @@ func (q *Queries) Close() error {
err = fmt.Errorf("error closing deleteDeviceInfoLikeStmt: %w", cerr)
}
}
if q.deleteScriptStmt != nil {
if cerr := q.deleteScriptStmt.Close(); cerr != nil {
err = fmt.Errorf("error closing deleteScriptStmt: %w", cerr)
}
}
if q.deleteScriptTriggerStmt != nil {
if cerr := q.deleteScriptTriggerStmt.Close(); cerr != nil {
err = fmt.Errorf("error closing deleteScriptTriggerStmt: %w", cerr)
@ -260,6 +268,7 @@ type Queries struct {
deleteDeviceInfoStmt *sql.Stmt
deleteDeviceInfoByIDStmt *sql.Stmt
deleteDeviceInfoLikeStmt *sql.Stmt
deleteScriptStmt *sql.Stmt
deleteScriptTriggerStmt *sql.Stmt
insertDeviceAliasStmt *sql.Stmt
listDeviceAliasesStmt *sql.Stmt
@ -289,6 +298,7 @@ func (q *Queries) WithTx(tx *sql.Tx) *Queries {
deleteDeviceInfoStmt: q.deleteDeviceInfoStmt,
deleteDeviceInfoByIDStmt: q.deleteDeviceInfoByIDStmt,
deleteDeviceInfoLikeStmt: q.deleteDeviceInfoLikeStmt,
deleteScriptStmt: q.deleteScriptStmt,
deleteScriptTriggerStmt: q.deleteScriptTriggerStmt,
insertDeviceAliasStmt: q.insertDeviceAliasStmt,
listDeviceAliasesStmt: q.listDeviceAliasesStmt,

9
services/mysqldb/mysqlgen/script.sql.go

@ -12,6 +12,15 @@ import (
"github.com/google/uuid"
)
const deleteScript = `-- name: DeleteScript :exec
DELETE FROM script WHERE name = ?
`
func (q *Queries) DeleteScript(ctx context.Context, name string) error {
_, err := q.exec(ctx, q.deleteScriptStmt, deleteScript, name)
return err
}
const deleteScriptTrigger = `-- name: DeleteScriptTrigger :exec
DELETE FROM script_trigger WHERE id = ?
`

3
services/mysqldb/queries/script.sql

@ -4,6 +4,9 @@ SELECT * FROM script;
-- name: SaveScript :exec
REPLACE INTO script (name, data) VALUES (?, ?);
-- name: DeleteScript :exec
DELETE FROM script WHERE name = ?;
-- name: ListScriptVariables :many
SELECT * FROM script_variable;

29
services/mysqldb/service.go

@ -434,16 +434,27 @@ func (d *database) HandleCommand(bus *lucifer3.EventBus, command lucifer3.Comman
case script.Update:
j, _ := json.Marshal(command.Lines)
err := q.SaveScript(timeout, mysqlgen.SaveScriptParams{
Name: command.Name,
Data: j,
})
if err != nil {
bus.RunEvent(events.Log{
Level: "error",
Code: "database_could_not_save_script",
Message: "Failed to save script: " + err.Error(),
if (len(command.Lines)) > 0 {
err := q.SaveScript(timeout, mysqlgen.SaveScriptParams{
Name: command.Name,
Data: j,
})
if err != nil {
bus.RunEvent(events.Log{
Level: "error",
Code: "database_could_not_save_script",
Message: "Failed to save script: " + err.Error(),
})
}
} else {
err := q.DeleteScript(timeout, command.Name)
if err != nil {
bus.RunEvent(events.Log{
Level: "error",
Code: "database_could_not_delete_script",
Message: "Failed to delete script: " + err.Error(),
})
}
}
}
}

6
services/uistate/data.go

@ -115,7 +115,11 @@ func (d *Data) WithPatch(patches ...Patch) Data {
}
if patch.Script != nil {
newData.Scripts[patch.Script.Name] = patch.Script.Lines
if len(patch.Script.Lines) > 0 {
newData.Scripts[patch.Script.Name] = patch.Script.Lines
} else {
delete(newData.Scripts, patch.Script.Name)
}
}
}

Loading…
Cancel
Save