Browse Source

moar stuff.

beelzebub
Gisle Aune 1 year ago
parent
commit
2188d489ea
  1. 43
      effects/animation.go
  2. 4
      effects/gradient.go
  3. 5
      effects/serializable.go
  4. 3
      effects/vrange.go
  5. 101
      frontend/package-lock.json
  6. 5
      frontend/package.json
  7. 14
      frontend/src/lib/client/lucifer.ts
  8. 294
      frontend/src/lib/components/AssignmentState.svelte
  9. 31
      frontend/src/lib/components/Button.svelte
  10. 117
      frontend/src/lib/components/Checkbox.svelte
  11. 22
      frontend/src/lib/components/DeviceIcon.svelte
  12. 43
      frontend/src/lib/components/DeviceIconSelector.svelte
  13. 18
      frontend/src/lib/components/HSplit.svelte
  14. 18
      frontend/src/lib/components/HSplitPart.svelte
  15. 97
      frontend/src/lib/components/Icon.svelte
  16. 45
      frontend/src/lib/components/Lamp.svelte
  17. 32
      frontend/src/lib/components/Modal.svelte
  18. 15
      frontend/src/lib/components/ModalBody.svelte
  19. 59
      frontend/src/lib/components/ModalSection.svelte
  20. 92
      frontend/src/lib/components/TagInput.svelte
  21. 0
      frontend/src/lib/components/icons/shape_hexagon.svg
  22. 0
      frontend/src/lib/components/icons/shape_square.svg
  23. 0
      frontend/src/lib/components/icons/shape_triangle.svg
  24. 3
      frontend/src/lib/contexts/ModalContext.svelte
  25. 8
      frontend/src/lib/contexts/SelectContext.svelte
  26. 40
      frontend/src/lib/contexts/StateContext.svelte
  27. 4
      frontend/src/lib/css/colors.sass
  28. 345
      frontend/src/lib/modals/DeviceModal.svelte
  29. 54
      frontend/src/lib/models/assignment.ts
  30. 17
      frontend/src/lib/models/color.ts
  31. 17
      frontend/src/lib/models/command.ts
  32. 10
      frontend/src/lib/models/device.ts
  33. 2
      frontend/src/lib/models/uistate.ts
  34. 2
      frontend/src/routes/+layout.svelte
  35. 1
      frontend/src/routes/+layout.ts
  36. 44
      frontend/src/routes/+page.svelte
  37. 2
      frontend/svelte.config.js
  38. 17
      services/effectenforcer/service.go
  39. 19
      services/httpapiv1/service.go
  40. 32
      services/hue/bridge.go
  41. 9
      services/mysqldb/migrations/20230909214407_script_trigger_column_name.sql
  42. 1
      services/mysqldb/mysqlgen/models.go
  43. 9
      services/mysqldb/mysqlgen/script.sql.go
  44. 4
      services/mysqldb/queries/script.sql
  45. 4
      services/mysqldb/service.go
  46. 18
      services/nanoleaf/data.go
  47. 1
      services/script/trigger.go
  48. 10
      services/uistate/data.go

43
effects/animation.go

@ -0,0 +1,43 @@
package effects
import (
"fmt"
"git.aiterp.net/lucifer3/server/device"
"time"
)
type Solid struct {
States []device.State `json:"states,omitempty"`
AnimationMS int64 `json:"animationMs,omitempty"`
Interleave int `json:"interleave,omitempty"`
}
func (e Solid) State(_, length, round int) device.State {
if len(e.States) == 0 {
return device.State{}
}
if len(e.States) == 1 {
return e.States[0]
}
interleave := e.Interleave + 1
if interleave < 1 {
interleave = 1
}
if interleave > 1 {
calcIndex := round % (len(e.States) * interleave)
return gradientState(append(e.States, e.States[0]), e.Interleave != 0, calcIndex, len(e.States)*interleave+1)
} else {
calcIndex := round % len(e.States)
return gradientState(e.States, e.Interleave != 0, calcIndex, len(e.States)*interleave)
}
}
func (e Solid) Frequency() time.Duration {
return time.Duration(e.AnimationMS) * time.Millisecond
}
func (e Solid) EffectDescription() string {
return fmt.Sprintf("Solid(states:%s, anim:%dms, interleave:%d)", statesDescription(e.States), e.AnimationMS, e.Interleave)
}

4
effects/gradient.go

@ -8,8 +8,8 @@ import (
type Gradient struct {
States []device.State `json:"states,omitempty"`
AnimationMS int64 `json:"AnimationMs,omitempty"`
Reverse bool `json:"backward,omitempty"`
AnimationMS int64 `json:"animationMs,omitempty"`
Reverse bool `json:"reverse,omitempty"`
Interpolate bool `json:"interpolate,omitempty"`
}

5
effects/serializable.go

@ -8,6 +8,7 @@ import (
type serializedEffect struct {
Manual *Manual `json:"manual,omitempty"`
Solid *Solid `json:"solid,omitempty"`
Gradient *Gradient `json:"gradient,omitempty"`
Pattern *Pattern `json:"pattern,omitempty"`
Random *Random `json:"random,omitempty"`
@ -28,6 +29,8 @@ func (s *Serializable) UnmarshalJSON(raw []byte) error {
switch {
case value.Manual != nil:
s.Effect = *value.Manual
case value.Solid != nil:
s.Effect = *value.Solid
case value.Gradient != nil:
s.Effect = *value.Gradient
case value.Pattern != nil:
@ -47,6 +50,8 @@ func (s *Serializable) MarshalJSON() ([]byte, error) {
switch effect := s.Effect.(type) {
case Manual:
return json.Marshal(serializedEffect{Manual: &effect})
case Solid:
return json.Marshal(serializedEffect{Solid: &effect})
case Gradient:
return json.Marshal(serializedEffect{Gradient: &effect})
case Pattern:

3
effects/vrange.go

@ -11,6 +11,7 @@ type VRange struct {
Variable string `json:"variable"`
Min float64 `json:"min"`
Max float64 `json:"max"`
Interpolate bool `json:"interpolate"`
}
func (e VRange) VariableName() string {
@ -24,7 +25,7 @@ func (e VRange) VariableState(_, _ int, value float64) device.State {
return e.States[len(e.States)-1]
}
return gradientStateFactor(e.States, true, (value-e.Min)/(e.Max-e.Min))
return gradientStateFactor(e.States, e.Interpolate, (value-e.Min)/(e.Max-e.Min))
}
func (e VRange) State(_, _, _ int) device.State {

101
frontend/package-lock.json

@ -8,19 +8,24 @@
"name": "frontend",
"version": "0.0.1",
"devDependencies": {
"@fortawesome/free-solid-svg-icons": "^6.4.2",
"@sveltejs/adapter-auto": "^1.0.0",
"@sveltejs/adapter-static": "^2.0.3",
"@sveltejs/kit": "^1.0.0",
"@types/svelte-range-slider-pips": "^2.0.0",
"@typescript-eslint/eslint-plugin": "^5.45.0",
"@typescript-eslint/parser": "^5.45.0",
"eslint": "^8.28.0",
"eslint-config-prettier": "^8.5.0",
"eslint-plugin-svelte3": "^4.0.0",
"fa-svelte": "^3.1.0",
"node-sass": "^8.0.0",
"prettier": "^2.8.0",
"prettier-plugin-svelte": "^2.8.1",
"sass": "^1.57.1",
"svelte": "^3.54.0",
"svelte-check": "^2.9.2",
"svelte-range-slider-pips": "^2.2.2",
"tslib": "^2.4.1",
"typescript": "^4.9.3",
"vite": "^4.0.0"
@ -507,6 +512,29 @@
"url": "https://opencollective.com/eslint"
}
},
"node_modules/@fortawesome/fontawesome-common-types": {
"version": "6.4.2",
"resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-common-types/-/fontawesome-common-types-6.4.2.tgz",
"integrity": "sha512-1DgP7f+XQIJbLFCTX1V2QnxVmpLdKdzzo2k8EmvDOePfchaIGQ9eCHj2up3/jNEbZuBqel5OxiaOJf37TWauRA==",
"dev": true,
"hasInstallScript": true,
"engines": {
"node": ">=6"
}
},
"node_modules/@fortawesome/free-solid-svg-icons": {
"version": "6.4.2",
"resolved": "https://registry.npmjs.org/@fortawesome/free-solid-svg-icons/-/free-solid-svg-icons-6.4.2.tgz",
"integrity": "sha512-sYwXurXUEQS32fZz9hVCUUv/xu49PEJEyUOsA51l6PU/qVgfbTb2glsTEaJngVVT8VqBATRIdh7XVgV1JF1LkA==",
"dev": true,
"hasInstallScript": true,
"dependencies": {
"@fortawesome/fontawesome-common-types": "6.4.2"
},
"engines": {
"node": ">=6"
}
},
"node_modules/@gar/promisify": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/@gar/promisify/-/promisify-1.1.3.tgz",
@ -663,6 +691,15 @@
"@sveltejs/kit": "^1.0.0"
}
},
"node_modules/@sveltejs/adapter-static": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/@sveltejs/adapter-static/-/adapter-static-2.0.3.tgz",
"integrity": "sha512-VUqTfXsxYGugCpMqQv1U0LIdbR3S5nBkMMDmpjGVJyM6Q2jHVMFtdWJCkeHMySc6mZxJ+0eZK3T7IgmUCDrcUQ==",
"dev": true,
"peerDependencies": {
"@sveltejs/kit": "^1.5.0"
}
},
"node_modules/@sveltejs/kit": {
"version": "1.24.0",
"resolved": "https://registry.npmjs.org/@sveltejs/kit/-/kit-1.24.0.tgz",
@ -794,6 +831,15 @@
"integrity": "sha512-21cFJr9z3g5dW8B0CVI9g2O9beqaThGQ6ZFBqHfwhzLDKUxaqTIy3vnfah/UPkfOiF2pLq+tGz+W8RyCskuslw==",
"dev": true
},
"node_modules/@types/svelte-range-slider-pips": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/@types/svelte-range-slider-pips/-/svelte-range-slider-pips-2.0.0.tgz",
"integrity": "sha512-+Yq3xwyI0ViGZKdrsNpM8Wqa0wCnSBKc8NcRfYGiu22kVJ3Xr0gfiZATWMzZ2mryt/tQR8QgH2B782ogrZS1aw==",
"dev": true,
"dependencies": {
"svelte": "^3.0.0"
}
},
"node_modules/@typescript-eslint/eslint-plugin": {
"version": "5.48.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-5.48.0.tgz",
@ -1946,6 +1992,12 @@
"node": ">=0.10.0"
}
},
"node_modules/fa-svelte": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/fa-svelte/-/fa-svelte-3.1.0.tgz",
"integrity": "sha512-RqBOWwt7sc+ta9GFjbu5GOwKFRzn3rMPPSqvSGpIwsfVnpMjiI5ttv84lwNsCMEYI6/lu/iH21HUcE3TLz8RGQ==",
"dev": true
},
"node_modules/fast-deep-equal": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
@ -4482,6 +4534,12 @@
"sourcemap-codec": "^1.4.8"
}
},
"node_modules/svelte-range-slider-pips": {
"version": "2.2.2",
"resolved": "https://registry.npmjs.org/svelte-range-slider-pips/-/svelte-range-slider-pips-2.2.2.tgz",
"integrity": "sha512-SsiIBpCkBnqvfaHLeIDolTfZlc8m7y93MhlxPKQ90YMZnE/rAxj+IoCrd7LTL/nXA2SCla4IJutSzfwdOtJddA==",
"dev": true
},
"node_modules/tar": {
"version": "6.1.13",
"resolved": "https://registry.npmjs.org/tar/-/tar-6.1.13.tgz",
@ -5145,6 +5203,21 @@
"strip-json-comments": "^3.1.1"
}
},
"@fortawesome/fontawesome-common-types": {
"version": "6.4.2",
"resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-common-types/-/fontawesome-common-types-6.4.2.tgz",
"integrity": "sha512-1DgP7f+XQIJbLFCTX1V2QnxVmpLdKdzzo2k8EmvDOePfchaIGQ9eCHj2up3/jNEbZuBqel5OxiaOJf37TWauRA==",
"dev": true
},
"@fortawesome/free-solid-svg-icons": {
"version": "6.4.2",
"resolved": "https://registry.npmjs.org/@fortawesome/free-solid-svg-icons/-/free-solid-svg-icons-6.4.2.tgz",
"integrity": "sha512-sYwXurXUEQS32fZz9hVCUUv/xu49PEJEyUOsA51l6PU/qVgfbTb2glsTEaJngVVT8VqBATRIdh7XVgV1JF1LkA==",
"dev": true,
"requires": {
"@fortawesome/fontawesome-common-types": "6.4.2"
}
},
"@gar/promisify": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/@gar/promisify/-/promisify-1.1.3.tgz",
@ -5265,6 +5338,13 @@
"import-meta-resolve": "^2.2.0"
}
},
"@sveltejs/adapter-static": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/@sveltejs/adapter-static/-/adapter-static-2.0.3.tgz",
"integrity": "sha512-VUqTfXsxYGugCpMqQv1U0LIdbR3S5nBkMMDmpjGVJyM6Q2jHVMFtdWJCkeHMySc6mZxJ+0eZK3T7IgmUCDrcUQ==",
"dev": true,
"requires": {}
},
"@sveltejs/kit": {
"version": "1.24.0",
"resolved": "https://registry.npmjs.org/@sveltejs/kit/-/kit-1.24.0.tgz",
@ -5367,6 +5447,15 @@
"integrity": "sha512-21cFJr9z3g5dW8B0CVI9g2O9beqaThGQ6ZFBqHfwhzLDKUxaqTIy3vnfah/UPkfOiF2pLq+tGz+W8RyCskuslw==",
"dev": true
},
"@types/svelte-range-slider-pips": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/@types/svelte-range-slider-pips/-/svelte-range-slider-pips-2.0.0.tgz",
"integrity": "sha512-+Yq3xwyI0ViGZKdrsNpM8Wqa0wCnSBKc8NcRfYGiu22kVJ3Xr0gfiZATWMzZ2mryt/tQR8QgH2B782ogrZS1aw==",
"dev": true,
"requires": {
"svelte": "^3.0.0"
}
},
"@typescript-eslint/eslint-plugin": {
"version": "5.48.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-5.48.0.tgz",
@ -6194,6 +6283,12 @@
"integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==",
"dev": true
},
"fa-svelte": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/fa-svelte/-/fa-svelte-3.1.0.tgz",
"integrity": "sha512-RqBOWwt7sc+ta9GFjbu5GOwKFRzn3rMPPSqvSGpIwsfVnpMjiI5ttv84lwNsCMEYI6/lu/iH21HUcE3TLz8RGQ==",
"dev": true
},
"fast-deep-equal": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
@ -8061,6 +8156,12 @@
}
}
},
"svelte-range-slider-pips": {
"version": "2.2.2",
"resolved": "https://registry.npmjs.org/svelte-range-slider-pips/-/svelte-range-slider-pips-2.2.2.tgz",
"integrity": "sha512-SsiIBpCkBnqvfaHLeIDolTfZlc8m7y93MhlxPKQ90YMZnE/rAxj+IoCrd7LTL/nXA2SCla4IJutSzfwdOtJddA==",
"dev": true
},
"tar": {
"version": "6.1.13",
"resolved": "https://registry.npmjs.org/tar/-/tar-6.1.13.tgz",

5
frontend/package.json

@ -12,19 +12,24 @@
"format": "prettier --plugin-search-dir . --write ."
},
"devDependencies": {
"@fortawesome/free-solid-svg-icons": "^6.4.2",
"@sveltejs/adapter-auto": "^1.0.0",
"@sveltejs/adapter-static": "^2.0.3",
"@sveltejs/kit": "^1.0.0",
"@types/svelte-range-slider-pips": "^2.0.0",
"@typescript-eslint/eslint-plugin": "^5.45.0",
"@typescript-eslint/parser": "^5.45.0",
"eslint": "^8.28.0",
"eslint-config-prettier": "^8.5.0",
"eslint-plugin-svelte3": "^4.0.0",
"fa-svelte": "^3.1.0",
"node-sass": "^8.0.0",
"prettier": "^2.8.0",
"prettier-plugin-svelte": "^2.8.1",
"sass": "^1.57.1",
"svelte": "^3.54.0",
"svelte-check": "^2.9.2",
"svelte-range-slider-pips": "^2.2.2",
"tslib": "^2.4.1",
"typescript": "^4.9.3",
"vite": "^4.0.0"

14
frontend/src/lib/client/lucifer.ts

@ -1,3 +1,5 @@
import type { Color } from "$lib/models/color";
import type CommandInput from "$lib/models/command";
import type UIState from "$lib/models/uistate";
export default async function fetchLucifer<T>(path: string, init?: RequestInit): Promise<T> {
@ -21,3 +23,15 @@ export async function fetchUIState(): Promise<UIState> {
return fetchLucifer("state");
}
export async function fetchColor(color: string): Promise<Color> {
return fetchLucifer("color/"+color);
}
export async function runCommand(command: CommandInput): Promise<CommandInput> {
return fetchLucifer("command", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(command),
})
}

294
frontend/src/lib/components/AssignmentState.svelte

@ -0,0 +1,294 @@
<script lang="ts" context="module">
const cache: Record<string, ColorRGB> = {};
function getColor(color: string) {
if (cache.hasOwnProperty(color)) {
return Promise.resolve(cache[color]);
}
return fetchColor(color).then(r => {
cache[color] = r.rgb || {red: 255, green: 255, blue: 255};
return cache[color];
}).catch(err => {
delete cache[color];
throw err;
});
}
</script>
<script lang="ts">
import type { State } from "$lib/models/device";
import Icon from "./Icon.svelte";
import type { ColorRGB } from '$lib/models/color';
import { fetchColor } from '$lib/client/lucifer';
import { createEventDispatcher } from "svelte";
export let value: State;
export let deletable: boolean = false;
const dispatch = createEventDispatcher()
function togglePower() {
switch (value.power) {
case null: value.power = true; break;
case true: value.power = false; break;
case false: value.power = null; break;
}
}
function toggleIntensity() {
if (value.intensity === null) {
value.intensity = 0.5;
} else {
value.intensity = null;
}
}
function toggleTemperature() {
if (value.temperature === null) {
value.temperature = 20;
} else {
value.temperature = null;
}
}
function toggleColor() {
switch (value.color?.split(":")[0]) {
case undefined: value.color = "k:2750"; break;
case "k": value.color = "hs:180,0.5"; break;
case "hs": value.color = "rgb:1.000,0.800,0.066"; break;
case "rgb": value.color = "xy:0.2000,0.2000"; break;
case "xy": value.color = null; break;
}
}
function computeColorInputs(color: string | null) {
const kind = color?.split(":")[0];
const values = color?.split(":")[1]?.split(",").map(v => parseFloat(v)) || [0,0];
switch (kind) {
case undefined: colorKind = "null"; break;
case "k": colorKind = "k"; colorX = values[0]; colorY = values[1]||0; break;
case "hs": colorKind = "hs"; colorX = values[0]; colorY = values[1]||0; break;
case "xy": colorKind = "xy"; colorX = values[0]; colorY = values[1]||0; break;
case "rgb": colorKind = "rgb"; colorX = values[0]; colorY = values[1]||0, colorZ = values[2]||0; break;
}
computedColor = color;
}
function updateColor(kind: string, x: number, y: number, z: number) {
x = x || 0;
y = y || 0;
z = z || 0;
switch (kind) {
case "k": value.color = `k:${x.toFixed(0)}`; break;
case "xy": value.color = `xy:${x.toFixed(4)},${y.toFixed(4)}`; break;
case "hs": value.color = `hs:${x.toFixed(0)},${y.toFixed(3)}`; break;
case "rgb": value.color = `rgb:${x.toFixed(3)},${y.toFixed(3)},${z.toFixed(3)}`; break;
}
computedColor = value.color;
}
let intensityColor = "none";
$: intensityColor = value.intensity !== null ? "off" : "none";
let temperatureColor = "none";
$: temperatureColor = value.temperature !== null ? "off" : "none";
let powerColor = "";
$: if (value.power != null) {
powerColor = value.power ? "on" : "off"
} else {
powerColor = "none"
}
let computedColor: string | null = "";
let colorButton = "none";
let colorKind = "null";
let colorX = 0;
let colorY = 0;
let colorZ = 0;
$: if (value.color !== computedColor) {
computeColorInputs(value.color);
}
$: updateColor(colorKind, colorX, colorY, colorZ);
let timeout: NodeJS.Timeout | null = null;
let rgb = "";
$: if (value.color !== null) {
let before = value.color;
if (timeout !== null) {
clearTimeout(timeout);
}
timeout = setTimeout(() => {
if (value.color !== null) {
getColor(value.color).then(v => {
if (value.color === before) {
rgb = `rgb(${v.red*255}, ${v.green*255}, ${v.blue*255})`;
}
});
}
}, 50)
} else {
rgb = "hsl(240, 8%, 21%)";
}
</script>
<!-- svelte-ignore a11y-click-events-have-key-events -->
<div class="assignment-state">
<div class="option {powerColor}"><Icon on:click={togglePower} block name="power" /></div>
<div class="option {intensityColor}">
<Icon on:click={toggleIntensity} block name="cirlce_notch" />
{#if value.intensity != null}
<input class="custom" type="number" min={0} max={1} step={0.01} bind:value={value.intensity} />
{/if}
</div>
<div class="option {temperatureColor}">
<Icon on:click={toggleTemperature} block name="temperature_half" />
{#if value.temperature != null}
<input class="custom" type="number" min={10} max={40} step={0.5} bind:value={value.temperature} />
{/if}
</div>
<div class="option {colorButton}" style="--color: {rgb}">
<Icon on:click={toggleColor} block name="palette" />
{#if colorKind !== "null"}
{#if colorKind === "k"}
<div class="color-input">
<label for="color_x">Kelvin</label>
<input class="custom" name="color_x" type="number" min={1000} max={12000} step={10} bind:value={colorX} />
</div>
{/if}
{#if colorKind === "hs"}
<div class="color-input">
<label for="color_x">Hue</label>
<input class="custom" name="color_x" type="number" bind:value={colorX} />
</div>
<div class="color-input">
<label for="color_y">Sat</label>
<input class="custom" name="color_y" type="number" min={0} max={1} step={0.01} bind:value={colorY} />
</div>
{/if}
{#if colorKind === "xy"}
<div class="color-input">
<label for="color_x">X</label>
<input class="custom" name="color_x" type="number" min={0} max={1} step={0.0001} bind:value={colorX} />
</div>
<div class="color-input">
<label for="color_y">Y</label>
<input class="custom" name="color_y" type="number" min={0} max={1} step={0.0001} bind:value={colorY} />
</div>
{/if}
{#if colorKind === "rgb"}
<div class="color-input">
<label class="short" for="color_x">Red</label>
<input class="custom short" name="color_x" type="number" min={0} max={1} step={0.001} bind:value={colorX} />
</div>
<div class="color-input">
<label class="short" for="color_y">Green</label>
<input class="custom short" name="color_y" type="number" min={0} max={1} step={0.001} bind:value={colorY} />
</div>
<div class="color-input">
<label class="short" for="color_z">Blue</label>
<input class="custom short" name="color_z" type="number" min={0} max={1} step={0.001} bind:value={colorZ} />
</div>
{/if}
{/if}
</div>
{#if deletable}
<div class="option red">
<Icon on:click={() => dispatch("delete")} block name="trash" />
</div>
{/if}
</div>
<style lang="sass">
@import "$lib/css/colors.sass"
div.assignment-state
display: flex
user-select: none
font-size: 1rem
width: 100%
flex-wrap: wrap
@media screen and (max-width: 749px)
font-size: 0.66rem
> div.option
box-shadow: 1px 1px 1px #000
display: flex
margin: 0.25em 0.25ch
cursor: pointer
:global(.icon)
padding: 0.1em 0.5ch
padding-top: 0.35em
color: var(--color)
input, :global(div.rangeSlider)
width: 4rem
font-size: 0.9rem
padding-left: 0
background: $color-main1
outline: none
border: none
text-align: center
font-size: 1rem
color: $color-main9
&::-webkit-inner-spin-button
-webkit-appearance: none
margin: 0
moz-appearance: textfield
&:focus
color: $color-main13
> div.color-input
display: flex
flex-direction: column
> label
font-size: 0.5em
color: $color-main5
text-align: center
margin: 0
line-height: 1em
margin-top: 0.15rem !important
padding-top: 0
margin-bottom: -0.2em
width: 2.5rem
&.short
width: 2.2rem
> input
width: 2.5rem
font-size: 0.8em
background: none
&.short
width: 2.2rem
&.none
background: $color-main1
color: $color-main2
&.off
background-color: $color-main2
color: $color-main6
&.on
background-color: $color-main2
color: #00FF00
&.red
background-color: $color-main2-red
color: $color-main6-redder
</style>

31
frontend/src/lib/components/Button.svelte

@ -0,0 +1,31 @@
<script lang="ts">
export let red: boolean = false;
export let icon: boolean = false;
</script>
<!-- svelte-ignore a11y-click-events-have-key-events -->
<div class:red class:icon class="button" on:click><slot></slot></div>
<style lang="sass">
@import "$lib/css/colors.sass"
div.button
box-shadow: 1px 1px 1px #000
margin: 0.25em 0.25ch
cursor: pointer
user-select: none
font-size: 1rem
background-color: $color-main2
color: $color-main6
> :global(.icon)
padding: 0.1em 0.5ch
padding-top: 0.35em
&.icon
width: 2.8ch
&.red
background-color: $color-main2-red
color: $color-main6-redder
</style>

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

@ -0,0 +1,117 @@
<script lang="ts">
import { createEventDispatcher } from "svelte";
import Icon, { type IconName } from "./Icon.svelte";
export let tabIndex: number | undefined | null = void(0);
export let checked = false;
export let centered = false;
export let disabled = false;
export let noLabel = false;
export let noBorder = false;
export let inline = false;
export let icon: IconName = "check";
export let label = "(Missing label property)";
const dispatch = createEventDispatcher();
function handleClick() {
if (!disabled) {
checked = !checked;
dispatch('check', { checked });
}
}
function handlePress(e: KeyboardEvent) {
if (!disabled && ["enter", " ", " ", "c"].includes(e.key?.toLowerCase())) {
checked = !checked;
dispatch('check', { checked });
}
}
</script>
<div
class="checkbox"
class:centered
class:noLabel
class:inline
role="checkbox"
aria-checked={checked}
aria-disabled={disabled}
tabindex={tabIndex}
on:keypress={handlePress}
on:click={handleClick}
>
<div class="box" class:noBorder class:disabled class:checked class:unchecked={!checked}>
<Icon name={icon} />
</div>
{#if !noLabel}
<div class="label">{label}</div>
{/if}
</div>
<style lang="sass">
@import "$lib/css/colors"
div.checkbox
display: flex
flex-direction: row
flex-shrink: 0
margin-top: 0.5em
margin-bottom: 1em
-webkit-user-select: none
-moz-user-select: none
user-select: none
transition: 250ms
padding: 0.1em
font-size: 0.9em
&.noLabel
display: block
margin-top: 0.35em
margin-bottom: 0
font-size: 0.75em
div.checkbox.centered
margin: auto
:global(div.checkbox + div.checkbox)
margin-top: -0.75em
div.checkbox.inline
margin-top: 0.25rem
margin-bottom: 0.25rem
margin-right: 1rch
div.box
cursor: pointer
border: 0.5px solid
padding: 0.05em 0.3ch
padding-top: 0.2em
line-height: 1
background-color: $color-mainhalf
color: $color-mainhalf
border-color: $color-main5
font-size: 0.9em
&.noBorder
color: $color-main2
&.checked
color: $color-main9
background-color: $color-main4
&.disabled
color: $color-main1
&.checked
color: $color-main5
background-color: $color-main1
&.noBorder
border: none
background: none
div.label
margin: auto 0
margin-left: 1ch
-webkit-user-select: none
-moz-user-select: none
user-select: none
</style>

22
frontend/src/lib/components/DeviceIcon.svelte

@ -12,11 +12,11 @@
import hue_adore_tube from "./icons/hue_adore_tube.svg?raw";
import hue_playbar from "./icons/hue_playbar.svg?raw";
import hue_go from "./icons/hue_go.svg?raw";
import square from "./icons/square.svg?raw";
import hexagon from "./icons/hexagon.svg?raw";
import triangle from "./icons/triangle.svg?raw";
import shape_square from "./icons/shape_square.svg?raw";
import shape_hexagon from "./icons/shape_hexagon.svg?raw";
import shape_triangle from "./icons/shape_triangle.svg?raw";
export const iconMap = Object.seal({
export const deviceIconMap = Object.seal({
generic_lamp,
generic_boob,
generic_ball,
@ -29,13 +29,15 @@
hue_lightbulb_gu10,
hue_adore_tube,
hue_playbar,
square,
hexagon,
triangle,
shape_square,
shape_hexagon,
shape_triangle,
hue_go,
});
export type IconName = keyof typeof iconMap;
export const deviceIconList = Object.seal(Object.keys(deviceIconMap).sort()) as DeviceIconName[];
export type DeviceIconName = keyof typeof deviceIconMap;
</script>
<script lang="ts">
@ -44,7 +46,7 @@
export let brightColor: ColorRGB;
export let darkColor: ColorRGB;
export let name: IconName;
export let name: DeviceIconName;
let gandalf: string;
let voldemort: string;
@ -52,7 +54,7 @@
$: voldemort = rgbToHex(darkColor);
let icon: string;
$: icon = iconMap[name] || iconMap.generic_lamp;
$: icon = deviceIconMap[name] || deviceIconMap.generic_lamp;
let dataUrl: string;
$: {

43
frontend/src/lib/components/DeviceIconSelector.svelte

@ -0,0 +1,43 @@
<script lang="ts">
import { rgb, type ColorRGB } from "$lib/models/color";
import DeviceIcon, { deviceIconList, type DeviceIconName } from "./DeviceIcon.svelte";
import { DARK_COLOR, DARK_COLOR_SELECTED } from "./Lamp.svelte";
export let value: DeviceIconName;
</script>
<div class="device-icon-selector">
{#each deviceIconList as deviceIconName (deviceIconName)}
<!-- svelte-ignore a11y-click-events-have-key-events -->
<div class="item" class:selected={value === deviceIconName} on:click={() => { value = deviceIconName }}>
<DeviceIcon
darkColor={ value === deviceIconName ? DARK_COLOR_SELECTED : DARK_COLOR }
brightColor={ value === deviceIconName ? rgb(0.517, 0.537, 1.000) : DARK_COLOR }
name={deviceIconName}
/>
</div>
{/each}
</div>
<style lang="sass">
@import "$lib/css/colors.sass"
div.device-icon-selector
display: flex
flex-direction: row
flex-wrap: wrap
> div.item
font-size: 2em
padding: 0.3rem
padding-bottom: 0
padding-top: 0.5rem
box-sizing: border-box
border: 2px solid $color-mainhalf
&.selected
border: 2px solid $color-main1
&:hover
background-color: $color-mainquarter
</style>

18
frontend/src/lib/components/HSplit.svelte

@ -0,0 +1,18 @@
<script lang="ts">
export let reverse: boolean = false
</script>
<div class="hsplit" class:reverse><slot></slot></div>
<style lang="sass">
div.hsplit
display: flex
flex-direction: row
flex-basis: 10
&.reverse
flex-direction: row-reverse
@media screen and (max-width: 500px)
display: block
</style>

18
frontend/src/lib/components/HSplitPart.svelte

@ -0,0 +1,18 @@
<script lang="ts">
export let weight = 1;
export let left = false;
export let right = false;
</script>
<div class="hsplit-part" class:right class:left style="flex: {weight}"><slot></slot></div>
<style lang="sass">
div.hsplit-part
box-sizing: border-box
padding: 0 0.5ch
&.left
padding-left: 0
&.right
padding-right: 0
</style>

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

@ -0,0 +1,97 @@
<script lang="ts">
import Icon from "fa-svelte";
export let name: IconName = "question";
export let block: boolean = false;
export let marginAutio: boolean = false;
</script>
<!-- svelte-ignore a11y-click-events-have-key-events -->
{#if block}
<div on:click class:marginAutio>
<Icon class="icon" icon={icons[name] || icons.question} />
</div>
{:else}
<Icon on:click class="icon" icon={icons[name] || icons.question} />
{/if}
<style>
div.marginAutio {
margin: auto;
}
</style>
<script lang="ts" context="module">
import { faQuestion } from "@fortawesome/free-solid-svg-icons/faQuestion";
import { faPlus } from "@fortawesome/free-solid-svg-icons/faPlus";
import { faPen } from "@fortawesome/free-solid-svg-icons/faPen";
import { faArchive } from "@fortawesome/free-solid-svg-icons/faArchive";
import { faCheck } from "@fortawesome/free-solid-svg-icons/faCheck";
import { faCog } from "@fortawesome/free-solid-svg-icons/faCog";
import { faLink } from "@fortawesome/free-solid-svg-icons/faLink";
import { faStar } from "@fortawesome/free-solid-svg-icons/faStar";
import { faTimes } from "@fortawesome/free-solid-svg-icons/faTimes";
import { faSpinner } from "@fortawesome/free-solid-svg-icons/faSpinner";
import { faHourglass } from "@fortawesome/free-solid-svg-icons/faHourglass";
import { faCalendar } from "@fortawesome/free-solid-svg-icons/faCalendar";
import { faExpand } from "@fortawesome/free-solid-svg-icons/faExpand";
import { faSearch } from "@fortawesome/free-solid-svg-icons/faSearch";
import { faClock } from "@fortawesome/free-solid-svg-icons/faClock";
import { faThumbtack } from "@fortawesome/free-solid-svg-icons/faThumbtack";
import { faHistory } from "@fortawesome/free-solid-svg-icons/faHistory";
import { faLightbulb } from "@fortawesome/free-solid-svg-icons/faLightbulb";
import { faChevronRight } from "@fortawesome/free-solid-svg-icons/faChevronRight";
import { faChevronDown } from "@fortawesome/free-solid-svg-icons/faChevronDown";
import { faTrash } from "@fortawesome/free-solid-svg-icons/faTrash";
import { faCheckToSlot } from "@fortawesome/free-solid-svg-icons/faCheckToSlot";
import { faEye } from "@fortawesome/free-solid-svg-icons/faEye";
import { faList } from "@fortawesome/free-solid-svg-icons/faList";
import { faPowerOff } from "@fortawesome/free-solid-svg-icons/faPowerOff";
import { faCircleNotch } from "@fortawesome/free-solid-svg-icons/faCircleNotch";
import { faTemperatureHalf } from "@fortawesome/free-solid-svg-icons/faTemperatureHalf";
import { faPalette } from "@fortawesome/free-solid-svg-icons/faPalette";
import { faSwatchbook } from "@fortawesome/free-solid-svg-icons/faSwatchbook";
import { faCircleDot } from "@fortawesome/free-solid-svg-icons/faCircleDot";
import { faMasksTheater } from "@fortawesome/free-solid-svg-icons/faMasksTheater";
import { faTag } from "@fortawesome/free-solid-svg-icons/faTag";
const icons = {
"clock": faClock,
"thumbtack": faThumbtack,
"history": faHistory,
"question": faQuestion,
"plus": faPlus,
"pen": faPen,
"archive": faArchive,
"check": faCheck,
"cog": faCog,
"link": faLink,
"star": faStar,
"times": faTimes,
"lightbulb": faLightbulb,
"spinner": faSpinner,
"hourglass": faHourglass,
"calendar": faCalendar,
"expand": faExpand,
"search": faSearch,
"chevron_right": faChevronRight,
"chevron_down": faChevronDown,
"trash": faTrash,
"check_slot": faCheckToSlot,
"eye": faEye,
"list": faList,
"power": faPowerOff,
"cirlce_notch": faCircleNotch,
"temperature_half": faTemperatureHalf,
"palette": faPalette,
"swatch_book": faSwatchbook,
"circle_dot": faCircleDot,
"masks_theater": faMasksTheater,
"tag": faTag,
};
export type IconName = keyof typeof icons;
export const iconNames = Object.keys(icons).sort() as IconName[];
export const DEFAULT_ICON: IconName = "question";
</script>

45
frontend/src/lib/components/Lamp.svelte

@ -9,6 +9,7 @@
import { SupportFlags } from "$lib/models/device";
import { rgb, type ColorRGB } from "../models/color";
import DeviceIcon from "./DeviceIcon.svelte";
import Icon from "./Icon.svelte";
export let device: Device;
export let compact: boolean = false;
@ -25,6 +26,9 @@
let barColor: ColorRGB | null;
let barFraction: number | null;
let roundboiText: string | null;
let roles: string[];
let tags: string[];
let hasRoleOrTag: boolean;
$: {
// TODO: Fix device.name on the backend
const nameAlias = device.aliases?.find(a => a.startsWith("lucifer:name:"));
@ -34,6 +38,10 @@
deviceTitle = "";
}
roles = device.aliases?.filter(a => a.startsWith("lucifer:role:")).map(a => a.slice("lucifer:role:".length));
tags = device.aliases?.filter(a => a.startsWith("lucifer:tag:")).map(a => a.slice("lucifer:tag:".length));
hasRoleOrTag = !!(roles.length || tags.length)
barFraction = null;
barColor = null;
iconColor = null;
@ -114,7 +122,25 @@
</DeviceIcon>
{/if}
</div>
<div class="title">{displayTitle}</div>
<div class="title">
<div class="name" class:hasRoleOrTag>{displayTitle}</div>
{#if hasRoleOrTag}
<div class="tag-list">
{#each roles as role}
<div class="tag">
<Icon block name="masks_theater" />
<div class="tag-name">{role}</div>
</div>
{/each}
{#each tags as tag}
<div class="tag">
<Icon block name="tag" />
<div class="tag-name">{tag}</div>
</div>
{/each}
</div>
{/if}
</div>
</div>
<div class="flatboi">
{#if barColor != null && barFraction != null}
@ -183,6 +209,23 @@
margin-right: 1ch
height: 1.6em
> div.name.hasRoleOrTag
margin-top: -0.275em
> div.tag-list
display: flex
flex-direction: row
font-size: 0.5em
> div.tag
display: flex
flex-direction: row
padding: 0 0.5ch
padding-right: 1ch
div.tag-name
padding-left: 0.5ch
> div.flatboi
height: 0.2em

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

@ -2,8 +2,8 @@
import { getModalContext, type ModalSelection } from "$lib/contexts/ModalContext.svelte";
export let show: boolean = false;
export let verb: string = "Submit";
export let noun: string = "Form";
export let titleText: string = "Edit Form";
export let submitText: string = "Submit";
export let cancelLabel: string = "Cancel";
export let wide: boolean = false;
export let error: string | null = null;
@ -30,7 +30,7 @@
<div role="dialog" class="modal-background" on:keypress={onKeyPress}>
<div class="modal" class:wide class:nobody>
<div class="header" class:nobody>
<div class="title" class:noclose={!closable}>{verb} {noun}</div>
<div class="title" class:noclose={!closable}>{titleText}</div>
{#if (closable)}
<div class="x">
<!-- svelte-ignore a11y-click-events-have-key-events -->
@ -46,7 +46,7 @@
<slot></slot>
</div>
<div class="button-row" class:nobody>
<button disabled={disabled} type="submit">{verb} {noun}</button>
<button disabled={disabled} type="submit">{submitText}</button>
<slot name="secondary-button-1"></slot>
<slot name="secondary-button-2"></slot>
<button disabled={!closable} on:click|preventDefault={onClose}>{cancelLabel}</button>
@ -84,6 +84,10 @@
&.nobody {
background: none;
}
&.fullheight {
min-height: 100%
}
}
div.modal.wide {
max-width: 80ch;
@ -182,7 +186,7 @@
-moz-user-select: none;
}
div.modal :global(input), div.modal :global(select), div.modal :global(textarea) {
div.modal :global(input:not(.custom)), div.modal :global(select), div.modal :global(textarea) {
width: calc(100% - 2ch);
margin-bottom: 1em;
margin-top: 0.25em;
@ -206,7 +210,7 @@
padding: 0.5em 0;
}
div.modal :global(input)::placeholder {
div.modal :global(input:not(.custom))::placeholder {
opacity: 0.5;
}
div.modal :global(select:disabled) {
@ -214,7 +218,7 @@
opacity: 1;
color: #789;
}
div.modal :global(input:disabled) {
div.modal :global(input:not(.custom):disabled) {
background: #1a1c1f;
color: #789;
}
@ -229,22 +233,22 @@
color: #aaa;
}
div.modal :global(input.nolast) {
div.modal :global(input:not(.custom).nolast) {
margin-bottom: 0.5em;
}
div.modal :global(input[type="checkbox"]) {
div.modal :global(input:not(.custom)[type="checkbox"]) {
width: initial;
display: inline-block;
}
div.modal :global(input[type="checkbox"] + label) {
div.modal :global(input:not(.custom)[type="checkbox"] + label) {
width: initial;
display: inline-block;
padding: 0;
margin: 0;
}
div.modal :global(input:focus), div.modal :global(select:focus), div.modal :global(textarea:focus) {
div.modal :global(input:not(.custom):focus), div.modal :global(select:focus), div.modal :global(textarea:focus) {
background: #121418;
color: $color-main9;
border: none;
@ -256,13 +260,13 @@
font-size: 0.9em;
}
div.modal :global(input::-webkit-outer-spin-button),
div.modal :global(input::-webkit-inner-spin-button) {
div.modal :global(input:not(.custom)::-webkit-outer-spin-button),
div.modal :global(input:not(.custom)::-webkit-inner-spin-button) {
-webkit-appearance: none;
margin: 0;
}
div.modal :global(input[type=number]) {
div.modal :global(input:not(.custom)[type=number]) {
appearance: textfield;
-moz-appearance: textfield;
}

15
frontend/src/lib/components/ModalBody.svelte

@ -0,0 +1,15 @@
<div class="modal-body"><slot></slot></div>
<style lang="sass">
div.modal-body
padding: 1em
padding-left: 0
flex: 1
@media screen and (max-width: 749px)
padding-left: 1em
padding: 0 1em
&:first-of-type
padding-left: 1em
</style>

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

@ -0,0 +1,59 @@
<script lang="ts">
import Icon from "./Icon.svelte";
export let expanded: boolean;
export let title: string;
function toggleExpand() {
expanded = !expanded;
}
</script>
<div class="modal-section" class:expanded>
<!-- 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"}" />
</div>
<div class="modal-section-body">
<slot></slot>
</div>
</div>
<style lang="sass">
@import "$lib/css/colors.sass"
div.modal-section
margin-left: -1.8ch
margin-right: -1.8ch
margin-bottom: 0.5em
> div.modal-section-body
display: none
padding: 0.25em 2.3ch
> div.modal-section-header
display: flex
flex-direction: row
user-select: none
padding: 0.5ch 1.6ch
padding-right: 1.8ch
color: $color-main4
> div.title
margin-left: 0.5ch
font-size: 0.9em
color: $color-main5
margin-right: auto
&.expanded
background-color: $color-mainhalf
> div.modal-section-body
display: block
> div.modal-section-header
color: $color-main5
> div.title
color: $color-main9
</style>

92
frontend/src/lib/components/TagInput.svelte

@ -0,0 +1,92 @@
<script lang="ts">
import Icon from "./Icon.svelte";
export let value: string[];
export let exclaimMode = false;
let nextTag = "";
function onKey(ev: KeyboardEvent) {
if ((ev.metaKey||ev.ctrlKey) && nextTag === "" && ev.key === "Backspace") {
value = value.slice(0, -1);
}
if ((ev.metaKey||ev.ctrlKey) && nextTag !== "" && ev.key === "Enter") {
value = [...value, nextTag];
nextTag = "";
ev.preventDefault();
}
}
$: while (nextTag.includes(",")) {
const newTags = nextTag.split(",").map(t => t.trim());
value = [...value, ...newTags.slice(0, -1)];
nextTag = newTags[newTags.length - 1];
}
$: value = (value||[]).filter((e, i) => !value.slice(0, i).includes(e));
$: if (exclaimMode) {
value = (value||[]).filter((e, i) => (
e.startsWith("!")
? !value.slice(i+1).includes(e.slice(1))
: !value.slice(i+1).includes("!"+e)
));
}
</script>
<div class="tag-input">
{#each value as tag (tag)}
<div class="tag">
<span>{tag}</span>
<span class="comma">,</span>
<!-- svelte-ignore a11y-click-events-have-key-events -->
<span class="x" on:click={() => value = value.filter(v => v !== tag)}>
<Icon name="times" />
</span>
</div>
{/each}
<input placeholder="(type , or press Ctrl+Enter to add)" on:keyup={onKey} bind:value={nextTag} />
</div>
<style lang="sass">
@import "$lib/css/colors.sass"
div.tag-input
width: calc(100% - 2ch)
margin-bottom: 1em
margin-top: 0.20em
min-height: 2em
background: $color-mainhalf
color: $color-main8
border: none
outline: none
resize: vertical
padding: 0.5em 1ch
display: flex
flex-direction: row
flex-wrap: wrap
> div.tag
margin: 0.25em 0.5ch
background-color: $color-main1
padding: 0.25em 1ch
border-radius: 0.25em
span.x
font-size: 0.75em
line-height: 1em
user-select: none
cursor: pointer
&:hover
color: $color-main12
span.comma
font-size: 0
> input
margin-bottom: 0.125em
</style>

0
frontend/src/lib/components/icons/hexagon.svg → frontend/src/lib/components/icons/shape_hexagon.svg

0
frontend/src/lib/components/icons/square.svg → frontend/src/lib/components/icons/shape_square.svg

0
frontend/src/lib/components/icons/triangle.svg → frontend/src/lib/components/icons/shape_triangle.svg

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

@ -1,6 +1,5 @@
<script lang="ts" context="module">
import type { DeviceEditOp } from "$lib/models/device";
import type Device from "$lib/models/device";
import type Script from "$lib/models/script";
import type { ScriptLine } from "$lib/models/script";
import { getContext, setContext } from "svelte";
@ -10,7 +9,7 @@
export type ModalSelection =
| { kind: "closed" }
| { kind: "device.edit", images: Device[], op: DeviceEditOp }
| { kind: "device.edit", op: DeviceEditOp }
| { kind: "script.edit", id: string | null, lines: ScriptLine[] }
| { kind: "script.execute", script: Script }

8
frontend/src/lib/contexts/SelectContext.svelte

@ -61,10 +61,6 @@
const firstDevice = $deviceList.find(d => $selectedMap[d.id]);
const nextMasks: string[] = [];
if (!$deviceList.find(d => !$selectedMap[d.id])) {
nextMasks.push("*");
}
if (firstDevice != null) {
// Common aliases first
for (const alias of firstDevice.aliases) {
@ -78,10 +74,6 @@
const isSelected = $selectedMap[device.id] || false;
const hasAlias = device.aliases.includes(alias);
if (alias === "lucifer:group:Squares") {
console.log(device.name, isSelected, hasAlias);
}
if (isSelected !== hasAlias) {
qualified = false;
break

40
frontend/src/lib/contexts/StateContext.svelte

@ -1,6 +1,7 @@
<script lang="ts" context="module">
import { fetchUIState } from "$lib/client/lucifer";
import type { IconName } from "$lib/components/DeviceIcon.svelte";
import type { DeviceIconName } from "$lib/components/DeviceIcon.svelte";
import type Assignment from "$lib/models/assignment";
import type Device from "$lib/models/device";
import type { UIStatePatch } from "$lib/models/uistate";
import type UIState from "$lib/models/uistate";
@ -14,6 +15,7 @@
state: Readable<UIState>
error: Readable<string | null>
deviceList: Readable<Device[]>
assignmentList: Readable<Assignment[]>
roomList: Readable<{name: string, groups: {name: string, devices: Device[]}[], devices: Device[]}[]>
}
@ -42,6 +44,17 @@
.sort((a,b) => a.name.localeCompare(b.name));
});
const assignmentList = derived(state, state => {
if (state == null) {
return [];
}
return Object.keys(state.assignments)
.map(k => state.assignments[k])
.sort((a,b) => a.id.localeCompare(b.id));
});
const roomList = derived(state, state => {
const roomMap: Record<string, Device[]> = {};
const groupMap: Record<string, string> = {};
@ -129,7 +142,7 @@
patch.name = patch.addAlias.slice("lucifer:name:".length)
}
if (patch.addAlias.startsWith("lucifer:icon:")) {
patch.icon = patch.addAlias.slice("lucifer:icon:".length) as IconName
patch.icon = patch.addAlias.slice("lucifer:icon:".length) as DeviceIconName
}
if (exclExisting) {
@ -170,6 +183,28 @@
const assignments = {...s.assignments};
delete assignments[patch.assignment.id];
s = {...s, assignments};
} else if (patch.assignment.addDeviceId) {
s = {
...s,
assignments: {
...s.assignments,
[patch.assignment.id]: {
...s.assignments[patch.assignment.id],
deviceIds: [...s.assignments[patch.assignment.id].deviceIds || [], patch.assignment.addDeviceId],
}
}
}
} else if (patch.assignment.removeDeviceId) {
s = {
...s,
assignments: {
...s.assignments,
[patch.assignment.id]: {
...s.assignments[patch.assignment.id],
deviceIds: s.assignments[patch.assignment.id].deviceIds.map(id => id === patch.assignment.removeDeviceId ? "" : id),
}
}
}
} else {
s = {
...s,
@ -257,6 +292,7 @@
error: { subscribe: error.subscribe },
state: { subscribe: state.subscribe },
deviceList,
assignmentList,
roomList,
});
</script>

4
frontend/src/lib/css/colors.sass

@ -1,15 +1,19 @@
$color-maindark: hsl(240, 8%, 3.5%)
$color-main0: hsl(240, 8%, 7%)
$color-main0-transparent: hsla(240, 8%, 10%, 0.7)
$color-mainquarter: hsl(240, 8%, 8.75%)
$color-mainhalf: hsl(240, 8%, 10.5%)
$color-main1: hsl(240, 8%, 14%)
$color-main1-transparent: hsla(240, 8%, 17%, 0.7)
$color-main2: hsl(240, 8%, 21%)
$color-main2-transparent: hsla(240, 8%, 24%, 0.7)
$color-main2-red: hsl(0, 16%, 21%)
$color-main3: hsl(240, 8%, 28%)
$color-main4: hsl(240, 8%, 35%)
$color-main5: hsl(240, 8%, 42%)
$color-main6: hsl(240, 8%, 49%)
$color-main6-red: hsl(0, 16%, 49%)
$color-main6-redder: hsl(0, 64%, 49%)
$color-main7: hsl(240, 8%, 56%) // Default
$color-main8: hsl(240, 8%, 63%)
$color-main9: hsl(240, 8%, 70%)

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

@ -0,0 +1,345 @@
<script lang="ts">
import { runCommand } from "$lib/client/lucifer";
import AssignmentState from "$lib/components/AssignmentState.svelte";
import Button from "$lib/components/Button.svelte";
import Checkbox from "$lib/components/Checkbox.svelte";
import type { DeviceIconName } from "$lib/components/DeviceIcon.svelte";
import DeviceIconSelector from "$lib/components/DeviceIconSelector.svelte";
import HSplit from "$lib/components/HSplit.svelte";
import HSplitPart from "$lib/components/HSplitPart.svelte";
import Icon from "$lib/components/Icon.svelte";
import Modal from "$lib/components/Modal.svelte";
import ModalBody from "$lib/components/ModalBody.svelte";
import ModalSection from "$lib/components/ModalSection.svelte";
import TagInput from "$lib/components/TagInput.svelte";
import { getModalContext } from "$lib/contexts/ModalContext.svelte";
import { getSelectedContext } from "$lib/contexts/SelectContext.svelte";
import { getStateContext } from "$lib/contexts/StateContext.svelte";
import { toEffectRaw, type EffectRaw, fromEffectRaw } from "$lib/models/assignment";
import type { DeviceEditOp } from "$lib/models/device";
import { iconName } from "@fortawesome/free-solid-svg-icons/faQuestion";
const { modal } = getModalContext();
const { selectedMasks, selectedMap, selectedList } = getSelectedContext();
const { deviceList, assignmentList } = getStateContext();
let show: boolean = false;
let match: string = "";
let disabled: boolean = false;
let enableRename: boolean = false;
let newName: string = "";
let enableRoom: boolean = false;
let newRoom: string = "";
let customRoom: string = "";
let enableGroup: boolean = false;
let newGroup: string = "";
let customGroup: string = "";
let enableAssign: boolean = false;
let newEffect: EffectRaw = toEffectRaw(undefined);
let enableIcon: boolean = false;
let newIcon: DeviceIconName = "generic_ball";
let enableTag: boolean = false;
let newTags: string[] = [];
let oldTags: string[] = [];
let enableRole: boolean = false;
let newRoles: string[] = [];
let oldRoles: string[] = [];
function setupModal(op: DeviceEditOp) {
show = true;
enableRename = (op === "rename");
enableAssign = (op === "assign");
enableIcon = (op === "change_icon");
enableRoom = (op === "move_room");
enableGroup = (op === "move_group");
enableTag = (op === "change_tags");
enableRole = (op === "change_roles");
const firstDevice = $deviceList.find(d => $selectedMap[d.id]);
newName = firstDevice?.name || "";
newIcon = firstDevice?.icon || "generic_ball";
newEffect = toEffectRaw(undefined);
newRoom = "";
newGroup = "";
reloadTagsAndRoles();
for (const device of $deviceList) {
if (!$selectedMap[device.id]) {
continue;
}
if (newRoom == "") {
const roomAlias = device.aliases.find(a => a.startsWith("lucifer:room:"))?.slice("lucifer:room:".length);
if (roomAlias != null) {
newRoom = roomAlias;
}
}
if (newGroup == "") {
const groupAlias = device.aliases.find(a => a.startsWith("lucifer:group:"))?.slice("lucifer:group:".length);
if (groupAlias != null) {
newGroup = groupAlias;
}
}
}
let mostPopularEffect = 0;
for (const assignment of $assignmentList) {
const selectedCount = assignment.deviceIds?.filter(id => $selectedMap[id]).length || 0;
if (selectedCount > mostPopularEffect) {
newEffect = toEffectRaw(assignment.effect);
mostPopularEffect = selectedCount;
}
}
}
function closeModal() {
show = false;
match = "";
}
function addEffectState() {
if (newEffect.states.length > 0) {
newEffect.states = [...newEffect.states, {...newEffect.states[newEffect.states.length - 1]}];
} else {
newEffect.states = [...newEffect.states, {color: null, intensity: null, power: null, temperature: null}]
}
}
function removeEffectState(i: number) {
newEffect.states = [...newEffect.states.slice(0, i), ...newEffect.states.slice(i+1)];
}
function reloadTagsAndRoles() {
const firstDevice = $deviceList.find(d => $selectedMap[d.id]);
newTags = firstDevice?.aliases
.filter(a => a.startsWith("lucifer:tag:"))
.filter(a => !$deviceList.filter(d => $selectedMap[d.id]).find(d => !d.aliases.includes(a)))
.map(a => a.slice("lucifer:tag:".length)) || [];
newRoles = firstDevice?.aliases
.filter(a => a.startsWith("lucifer:role:"))
.filter(a => !$deviceList.filter(d => $selectedMap[d.id]).find(d => !d.aliases.includes(a)))
.map(a => a.slice("lucifer:role:".length)) || [];
oldTags = [...newTags];
oldRoles = [...newRoles];
}
async function onSubmit() {
disabled = true;
let shouldWait = false;
console.log("SOOBMIEET")
try {
if (enableRename && newName !== "") {
await runCommand({addAlias: { match, alias: `lucifer:name:${newName}` }});
enableRename = false;
shouldWait = match.startsWith("lucifer:name:");
}
if (enableAssign) {
await runCommand({assign: { match, effect: fromEffectRaw(newEffect) }});
}
if (enableRoom) {
await runCommand({addAlias: { match, alias: `lucifer:room:${newRoom || customRoom}` }});
enableRoom = false;
shouldWait = match.startsWith("lucifer:room:");
newRoom = newRoom || customRoom;
}
if (enableGroup) {
await runCommand({addAlias: { match, alias: `lucifer:group:${newGroup || customGroup}` }});
enableGroup = false;
shouldWait = match.startsWith("lucifer:group:");
newGroup = newGroup || customGroup;
}
if (enableIcon) {
await runCommand({addAlias: { match, alias: `lucifer:icon:${newIcon}` }});
enableIcon = false;
shouldWait = match.startsWith("lucifer:icon:");
}
if (enableTag) {
const removeTags = oldTags.filter(ot => !newTags.includes(ot));
for (const removeTag of removeTags) {
await runCommand({removeAlias: { match, alias: `lucifer:tag:${removeTag}` }});
}
const addTags = newTags.filter(nt => !oldTags.includes(nt));
for (const addTag of addTags) {
await runCommand({addAlias: { match, alias: `lucifer:tag:${addTag}` }});
}
shouldWait = removeTags.length > 0 || addTags.length > 0;
}
if (enableRole) {
const removeRoles = oldRoles.filter(or => !newRoles.includes(or));
for (const removeRole of removeRoles) {
await runCommand({removeAlias: { match, alias: `lucifer:role:${removeRole}` }});
}
const addRoles = newRoles.filter(nr => !oldRoles.includes(nr));
for (const addRole of addRoles) {
await runCommand({addAlias: { match, alias: `lucifer:role:${addRole}` }});
}
shouldWait = removeRoles.length > 0 || addRoles.length > 0;
}
if (shouldWait) {
await new Promise(resolve => setTimeout(resolve, 1000))
}
} catch (err) {}
reloadTagsAndRoles();
disabled = false;
}
let roomOptions: string[] = [];
$: roomOptions = $deviceList.flatMap(d => d.aliases)
.filter(k => k.startsWith("lucifer:room:"))
.sort()
.filter((v, i, a) => v !== a[i-1])
.map(r => r.slice("lucifer:room:".length));
let groupOptions: string[] = [];
$: groupOptions = $deviceList.flatMap(d => d.aliases)
.filter(k => k.startsWith("lucifer:group:"))
.sort()
.filter((v, i, a) => v !== a[i-1])
.map(r => r.slice("lucifer:group:".length));
$: {
if ($modal.kind === "device.edit") {
setupModal($modal.op);
} else {
closeModal();
}
}
$: if (!$selectedMasks.includes(match)) {
match = $selectedMasks[0];
}
</script>
<form novalidate on:submit|preventDefault={onSubmit}>
<Modal wide disabled={disabled} closable show={show} titleText="Device Editor" submitText="Save Changes">
<ModalBody>
<label for="mask">Selection</label>
<select bind:value={match}>
{#each $selectedMasks as option (option)}
<option value={option}>{option}</option>
{/each}
</select>
<ModalSection bind:expanded={enableRename} title="Rename">
<label for="name">New Name</label>
<input type="text" name="name" bind:value={newName} />
</ModalSection>
<ModalSection bind:expanded={enableRoom} title="Change Room">
<label for="newRoom">Select Room</label>
<select bind:value={newRoom}>
{#each roomOptions as roomOption}
<option value={roomOption}>{roomOption}</option>
{/each}
<option value="">Create Room</option>
</select>
{#if newRoom == ""}
<label for="customRoom">New Room</label>
<input type="text" name="customRoom" bind:value={customRoom} />
{/if}
</ModalSection>
<ModalSection bind:expanded={enableGroup} title="Change Group">
<label for="newGroup">Select Group</label>
<select bind:value={newGroup}>
{#each groupOptions as groupOption}
<option value={groupOption}>{groupOption}</option>
{/each}
<option value="">Create Group</option>
</select>
{#if newGroup == ""}
<label for="customGroup">New Group</label>
<input type="text" name="customGroup" bind:value={customGroup} />
{/if}
</ModalSection>
<ModalSection bind:expanded={enableIcon} title="Change Icon">
<label for="icon">New Icon</label>
<DeviceIconSelector bind:value={newIcon} />
</ModalSection>
<ModalSection bind:expanded={enableTag} title="Change Tags">
<label for="icon">Tags</label>
<TagInput bind:value={newTags} />
</ModalSection>
<ModalSection bind:expanded={enableRole} title="Change Roles">
<label for="icon">Roles</label>
<TagInput bind:value={newRoles} />
</ModalSection>
<ModalSection bind:expanded={enableAssign} title="Assign">
<HSplit reverse>
<HSplitPart>
<label for="states">Effect</label>
<select bind:value={newEffect.kind}>
<option value="gradient">Gradient</option>
<option value="pattern">Pattern</option>
<option value="random">Random</option>
<option value="solid">Solid</option>
<option value="vrange">Variable Range</option>
</select>
{#if newEffect.kind !== "manual" && newEffect.kind !== "vrange"}
<label for="animationMs">Interval (ms)</label>
<input type="number" name="animationMs" min=0 max=10000 step=100 bind:value={newEffect.animationMs} />
{/if}
{#if newEffect.kind === "solid"}
<label for="interleave">Interleave</label>
<input type="number" name="interleave" min=0 step=1 bind:value={newEffect.interleave} />
{/if}
{#if newEffect.kind === "vrange"}
<label for="states">Variable</label>
<select bind:value={newEffect.variable}>
<option value="motion.min">Motion Min (Seconds)</option>
<option value="motion.avg">Motion Avg (Seconds)</option>
<option value="motion.max">Motion Max (Seconds)</option>
<option value="temperature.min">Temperature Min (Celcius)</option>
<option value="temperature.avg">Temperature Avg (Celcius)</option>
<option value="temperature.max">Temperature Max (Celcius)</option>
</select>
<HSplit>
<HSplitPart left>
<label for="min">Min</label>
<input type="number" name="min" min=0 step=1 bind:value={newEffect.min} />
</HSplitPart>
<HSplitPart right>
<label for="max">Max</label>
<input type="number" name="max" min=0 step=1 bind:value={newEffect.max} />
</HSplitPart>
</HSplit>
{/if}
{#if ["gradient", "random", "vrange"].includes(newEffect.kind)}
<label for="states">Options</label>
<Checkbox bind:checked={newEffect.interpolate} label="Interpolate" />
{#if (newEffect.kind === "gradient")}
<Checkbox bind:checked={newEffect.reverse} label="Reverse" />
{/if}
{/if}
</HSplitPart>
<HSplitPart weight={1.0}>
<label for="states">States</label>
{#each newEffect.states as state, i }
<AssignmentState deletable bind:value={state} on:delete={() => removeEffectState(i)} />
{/each}
<Button on:click={addEffectState} icon><Icon name="plus" /></Button>
</HSplitPart>
</HSplit>
</ModalSection>
</ModalBody>
</Modal>
</form>

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

@ -7,9 +7,63 @@ export default interface Assignment {
variables: Record<string, number>
}
export interface AssignmentInput {
match: string
effect: Effect
}
export type Effect =
| { manual: State }
| { solid: { states: State[], animationMs: number, interleave: number } }
| { gradient: { states: State[], animationMs: number, reverse: boolean, interpolate: boolean } }
| { pattern: { states: State[], animationMs: number } }
| { random: { states: State[], animationMs: number, interpolate: boolean } }
| { vrange: { states: State[], variable: string, min: number, max: number } }
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 }
}
if ("solid" in effect) {
return { kind: "solid", ...effect.solid, min: 0, max: 0, variable: "", interpolate: false, reverse: false}
}
if ("gradient" in effect) {
return { kind: "gradient", ...effect.gradient, min: 0, max: 0, variable: "", interleave: 0 }
}
if ("pattern" in effect) {
return { kind: "pattern", ...effect.pattern, reverse: false, interpolate: false, min: 0, max: 0, variable: "", interleave: 0 }
}
if ("random" in effect) {
return { kind: "random", ...effect.random, reverse: false, min: 0, max: 0, variable: "", interleave: 0 }
}
if ("vrange" in effect) {
return { kind: "vrange", ...effect.vrange, animationMs: 0, interpolate: false, 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 }
}
export function fromEffectRaw(raw: EffectRaw): Effect {
switch (raw.kind) {
case "manual": return { manual: raw.states[0] };
case "solid": return { solid: {...raw} };
case "gradient": return { gradient: {...raw} };
case "pattern": return { pattern: {...raw} };
case "random": return { random: {...raw} };
case "vrange": return { vrange: {...raw} };
}
}
export interface EffectRaw {
kind: "manual" | "solid" | "gradient" | "pattern" | "random" | "vrange"
states: State[]
animationMs: number
reverse: boolean
interpolate: boolean
interleave: number
variable: string
min: number
max: number
}

17
frontend/src/lib/models/color.ts

@ -4,6 +4,23 @@ export interface ColorRGB {
blue: number
}
export interface ColorXY {
x: number,
y: number
}
export interface ColorHS {
hue: number,
sat: number,
}
export interface Color {
rgb?: ColorRGB
xy?: ColorXY
hs?: ColorHS
k?: number
}
export enum ColorFlags {
CFlagXY = 1 << 0,
CFlagRGB = 1 << 1,

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

@ -0,0 +1,17 @@
import type { AssignmentInput } from "./assignment"
export default interface CommandInput {
addAlias?: AddAliasCommand
removeAlias?: RemoveAliasComamnd
assign?: AssignmentInput
}
export interface AddAliasCommand {
match: string
alias: string
}
export interface RemoveAliasComamnd {
match: string
alias: string
}

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

@ -1,10 +1,10 @@
import type { IconName } from "$lib/components/DeviceIcon.svelte"
import type { DeviceIconName } from "$lib/components/DeviceIcon.svelte"
import type { ColorFlags, ColorRGB } from "./color"
export default interface Device {
id: string
name: string
icon: IconName
icon: DeviceIconName
hwMetadata: HardwareMetadata | null
hwState: HardwareState | null
desiredColorRgb: ColorRGB | null
@ -33,7 +33,7 @@ export interface HardwareState {
export interface HardwareMetadata {
firmwareVersion?: string
icon?: IconName
icon?: DeviceIconName
}
export interface State {
@ -62,4 +62,6 @@ export const BLANK_STATE: State = (Object.seal||(v=>v))({
color: null,
});
export type DeviceEditOp = "assign" | "rename" | "change_icon" | "move_group"
export type DeviceEditOp = "none" | "assign" | "rename" | "change_icon" | "move_group" | "move_room" | "change_tags" | "change_roles";

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

@ -10,6 +10,6 @@ export default interface UIState {
export interface UIStatePatch {
device: Partial<Device> & { id: string, delete?: boolean, addAlias?: string, removeAlias?: string }
assignment: Partial<Assignment> & { id: string, delete?: boolean }
assignment: Partial<Assignment> & { id: string, delete?: boolean, addDeviceId?: string, removeDeviceId?: string }
script: Partial<Script> & { id: string, delete?: boolean }
}

2
frontend/src/routes/+layout.svelte

@ -1,6 +1,6 @@
<script lang="ts">
import ModalContext from "$lib/contexts/ModalContext.svelte";
import SelectContext from "$lib/contexts/SelectContext.svelte";
import SelectContext from "$lib/contexts/SelectContext.svelte";
import StateContext from "$lib/contexts/StateContext.svelte";
</script>

1
frontend/src/routes/+layout.ts

@ -0,0 +1 @@
export const prerender = true;

44
frontend/src/routes/+page.svelte

@ -1,17 +1,49 @@
<script lang="ts">
import AssignmentState from "$lib/components/AssignmentState.svelte";
import Lamp from "$lib/components/Lamp.svelte";
import MetaLamp from "$lib/components/MetaLamp.svelte";
import RoomHeader from "$lib/components/RoomHeader.svelte";
import { getModalContext } from "$lib/contexts/ModalContext.svelte";
import { getSelectedContext } from "$lib/contexts/SelectContext.svelte";
import { getStateContext } from "$lib/contexts/StateContext.svelte";
import DeviceModal from "$lib/modals/DeviceModal.svelte";
const {roomList} = getStateContext();
const {selectedMasks} = getSelectedContext();
const {modal} = getModalContext();
function handleKeyPress(e: KeyboardEvent) {
if ($modal.kind === "closed" && e.shiftKey) {
switch (e.key.toLocaleLowerCase()) {
case 'a':
modal.set({kind: "device.edit", op: "assign"});
e.preventDefault();
break;
case 'r':
modal.set({kind: "device.edit", op: "rename"});
e.preventDefault();
break;
case 'i':
modal.set({kind: "device.edit", op: "change_icon"});
e.preventDefault();
break;
case 'e':
modal.set({kind: "device.edit", op: "none"});
e.preventDefault();
break;
}
}
}
</script>
<svelte:body on:keypress={handleKeyPress} />
<div class="page">
{#each $roomList as room (room.name)}
<RoomHeader devices={room.devices} name={room.name} />
<div class="room">
<RoomHeader
devices={[...room.devices, ...room.groups.flatMap(g => g.devices)]}
name={room.name}
/>
<div class="devices">
{#each room.devices as device (device.id) }
<Lamp device={device} />
@ -22,9 +54,11 @@
<MetaLamp name={group.name} devices={group.devices} />
{/each}
</div>
</div>
{/each}
</div>
<pre>{JSON.stringify($selectedMasks, null, 4)}</pre>
<DeviceModal />
<style>
div.page {
@ -35,6 +69,10 @@
font-size: 1.33rem;
}
div.room {
margin-bottom: 1rem;
}
div.devices {
display: flex;
flex-direction: row;

2
frontend/svelte.config.js

@ -1,4 +1,4 @@
import adapter from '@sveltejs/adapter-auto';
import adapter from '@sveltejs/adapter-static';
import { vitePreprocess } from '@sveltejs/kit/vite';
/** @type {import('@sveltejs/kit').Config} */

17
services/effectenforcer/service.go

@ -21,6 +21,7 @@ func NewService(resolver device.Resolver) lucifer3.ActiveService {
supportFlags: make(map[string]device.SupportFlags),
colorFlags: make(map[string]device.ColorFlags),
ctRanges: make(map[string]*[2]int),
temperatures: make(map[string]float64),
motions: make(map[string]float64),
}
@ -41,6 +42,7 @@ type effectEnforcer struct {
list []*effectEnforcerRun
index map[string]*effectEnforcerRun
ctRanges map[string]*[2]int
}
func (s *effectEnforcer) Active() bool {
@ -53,6 +55,7 @@ func (s *effectEnforcer) HandleEvent(bus *lucifer3.EventBus, event lucifer3.Even
s.mu.Lock()
colorFlags := s.colorFlags
supportFlags := s.supportFlags
ctRanges := s.ctRanges
s.mu.Unlock()
colorFlags = gentools.CopyMap(colorFlags)
@ -60,9 +63,15 @@ func (s *effectEnforcer) HandleEvent(bus *lucifer3.EventBus, event lucifer3.Even
colorFlags[event.ID] = event.ColorFlags
supportFlags[event.ID] = event.SupportFlags
if event.ColorKelvinRange != nil {
ctRanges = gentools.CopyMap(ctRanges)
ctRanges[event.ID] = event.ColorKelvinRange
}
s.mu.Lock()
s.colorFlags = colorFlags
s.supportFlags = supportFlags
s.ctRanges = ctRanges
s.mu.Unlock()
case events.DeviceReady:
@ -237,6 +246,7 @@ func (s *effectEnforcer) runLoop(bus *lucifer3.EventBus) {
s.mu.Lock()
colorFlags := s.colorFlags
supportFlags := s.supportFlags
ctRanges := s.ctRanges
for i, run := range s.list {
if run.dead {
@ -307,11 +317,18 @@ func (s *effectEnforcer) runLoop(bus *lucifer3.EventBus) {
state.Color = nil
} else if state.Color != nil {
cf := colorFlags[id]
invalid := (state.Color.K != nil && !cf.HasAll(device.CFlagKelvin)) ||
(state.Color.XY != nil && !cf.HasAll(device.CFlagXY)) ||
(state.Color.RGB != nil && !cf.HasAll(device.CFlagRGB)) ||
(state.Color.HS != nil && !cf.HasAll(device.CFlagHS))
if state.Color.K != nil && cf.IsColor() && ctRanges[id] != nil {
if *state.Color.K < ctRanges[id][0] || *state.Color.K >= ctRanges[id][1] {
invalid = true
}
}
if invalid {
var converted color.Color
var ok bool

19
services/httpapiv1/service.go

@ -5,6 +5,7 @@ import (
"git.aiterp.net/lucifer3/server/commands"
"git.aiterp.net/lucifer3/server/effects"
"git.aiterp.net/lucifer3/server/events"
"git.aiterp.net/lucifer3/server/internal/color"
"git.aiterp.net/lucifer3/server/services/script"
"git.aiterp.net/lucifer3/server/services/uistate"
"github.com/google/uuid"
@ -38,6 +39,24 @@ func New(addr string) (lucifer3.Service, error) {
e.HidePort = true
e.Use(middleware.CORS())
e.GET("/color/:value", func(c echo.Context) error {
col, err := color.Parse(c.Param("value"))
if err != nil {
return c.String(400, err.Error())
}
rgb, _ := col.ToRGB()
xy, _ := col.ToXY()
hs, _ := col.ToHS()
return c.JSON(200, color.Color{
K: col.K,
RGB: rgb.RGB,
HS: hs.HS,
XY: xy.XY,
})
})
e.GET("/state", func(c echo.Context) error {
svc.mu.Lock()
data := svc.data

32
services/hue/bridge.go

@ -18,6 +18,7 @@ import (
func NewBridge(host string, client *Client) *Bridge {
return &Bridge{
mu: sync.Mutex{},
client: client,
host: host,
ctx: context.Background(),
@ -26,6 +27,7 @@ func NewBridge(host string, client *Client) *Bridge {
activeStates: map[string]device.State{},
desiredStates: map[string]device.State{},
colorFlags: map[string]device.ColorFlags{},
reachable: nil,
hasSeen: map[string]bool{},
triggerCongruenceCheckCh: make(chan struct{}, 2),
}
@ -46,6 +48,7 @@ type Bridge struct {
reachable map[string]bool
hasSeen map[string]bool
lastMotion map[string]time.Time
lastPresence map[string]bool
lastButton map[string]time.Time
triggerCongruenceCheckCh chan struct{}
@ -115,12 +118,14 @@ func (b *Bridge) RefreshAll() ([]lucifer3.Event, error) {
hasSeen := b.hasSeen
reachable := b.reachable
lastMotion := b.lastMotion
lastPresence := b.lastPresence
b.mu.Unlock()
oldHasSeen := hasSeen
hasSeen = gentools.CopyMap(hasSeen)
reachable = gentools.CopyMap(reachable)
lastMotion = gentools.CopyMap(lastMotion)
lastPresence = gentools.CopyMap(lastPresence)
colorFlags := make(map[string]device.ColorFlags)
activeStates := make(map[string]device.State)
@ -144,7 +149,9 @@ func (b *Bridge) RefreshAll() ([]lucifer3.Event, error) {
})
lastMotion[b.fullId(*res)] = time.Now()
lastPresence[b.fullId(*res)] = true
} else {
if lastMotion[b.fullId(*res)].IsZero() {
extraEvents = append(extraEvents, events.MotionSensed{
ID: b.fullId(*res),
SecondsSince: 301,
@ -152,6 +159,8 @@ func (b *Bridge) RefreshAll() ([]lucifer3.Event, error) {
lastMotion[b.fullId(*res)] = time.Now().Add(-time.Millisecond * 301)
}
lastPresence[b.fullId(*res)] = false
}
}
}
@ -180,6 +189,7 @@ func (b *Bridge) RefreshAll() ([]lucifer3.Event, error) {
b.activeStates = activeStates
b.reachable = reachable
b.lastMotion = lastMotion
b.lastPresence = lastPresence
b.mu.Unlock()
return append(newEvents, extraEvents...), nil
@ -193,6 +203,7 @@ func (b *Bridge) ApplyPatches(date time.Time, resources []ResourceData) (eventLi
colorFlags := b.colorFlags
lastMotion := b.lastMotion
lastButton := b.lastButton
lastPresence := b.lastPresence
b.mu.Unlock()
mapCopy := gentools.CopyMap(resourceMap)
@ -201,6 +212,7 @@ func (b *Bridge) ApplyPatches(date time.Time, resources []ResourceData) (eventLi
colorFlagsCopy := gentools.CopyMap(colorFlags)
lastMotionCopy := gentools.CopyMap(lastMotion)
lastButtonCopy := gentools.CopyMap(lastButton)
lastPresenceCopy := gentools.CopyMap(lastPresence)
for _, resource := range resources {
if mapCopy[resource.ID] != nil {
@ -218,6 +230,8 @@ func (b *Bridge) ApplyPatches(date time.Time, resources []ResourceData) (eventLi
}
for _, resource := range resources {
fullID := b.fullId(resource)
if resource.Owner != nil && resource.Owner.Kind == "device" {
if parent, ok := mapCopy[resource.Owner.ID]; ok && !strings.HasPrefix(parent.Metadata.Archetype, "bridge") {
hwState, _ := parent.GenerateEvent(b.host, mapCopy)
@ -231,18 +245,24 @@ func (b *Bridge) ApplyPatches(date time.Time, resources []ResourceData) (eventLi
if resource.Temperature != nil {
eventList = append(eventList, events.TemperatureChanged{
ID: b.fullId(resource),
ID: fullID,
Temperature: resource.Temperature.Temperature,
})
}
if resource.Motion != nil {
lastMotionCopy[fullID] = time.Now()
if resource.Motion.Motion {
eventList = append(eventList, events.MotionSensed{
ID: b.fullId(resource),
ID: fullID,
SecondsSince: 0,
})
lastMotionCopy[b.fullId(resource)] = time.Now()
lastMotion[fullID] = time.Now()
lastPresenceCopy[fullID] = true
} else if lastPresenceCopy[fullID] {
lastMotion[fullID] = time.Now()
lastPresenceCopy[fullID] = false
}
}
if resource.Button != nil {
@ -285,6 +305,7 @@ func (b *Bridge) ApplyPatches(date time.Time, resources []ResourceData) (eventLi
b.colorFlags = colorFlagsCopy
b.lastMotion = lastMotionCopy
b.lastButton = lastButtonCopy
b.lastPresence = lastPresenceCopy
b.mu.Unlock()
return
@ -382,9 +403,14 @@ func (b *Bridge) Run(ctx context.Context, bus *lucifer3.EventBus) interface{} {
case <-quickStep.C:
b.mu.Lock()
lastMotion := b.lastMotion
lastPresence := b.lastPresence
b.mu.Unlock()
for id, value := range lastMotion {
if lastPresence[id] {
continue
}
since := time.Since(value)
sinceMod := since % (time.Second * 30)
if (since > time.Second*20) && (sinceMod >= time.Second*27 || sinceMod <= time.Second*3) {

9
services/mysqldb/migrations/20230909214407_script_trigger_column_name.sql

@ -0,0 +1,9 @@
-- +goose Up
-- +goose StatementBegin
ALTER TABLE script_trigger ADD COLUMN name VARCHAR(255) NOT NULL DEFAULT '';
-- +goose StatementEnd
-- +goose Down
-- +goose StatementBegin
ALTER TABLE script_trigger DROP COLUMN IF EXISTS name;
-- +goose StatementEnd

1
services/mysqldb/mysqlgen/models.go

@ -49,6 +49,7 @@ type ScriptTrigger struct {
ScriptName string
ScriptPre json.RawMessage
ScriptPost json.RawMessage
Name string
}
type ScriptVariable struct {

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

@ -22,7 +22,7 @@ func (q *Queries) DeleteScriptTrigger(ctx context.Context, id uuid.UUID) error {
}
const listScriptTriggers = `-- name: ListScriptTriggers :many
SELECT id, event, device_match, parameter, script_target, script_name, script_pre, script_post FROM script_trigger
SELECT id, event, device_match, parameter, script_target, script_name, script_pre, script_post, name FROM script_trigger
`
func (q *Queries) ListScriptTriggers(ctx context.Context) ([]ScriptTrigger, error) {
@ -43,6 +43,7 @@ func (q *Queries) ListScriptTriggers(ctx context.Context) ([]ScriptTrigger, erro
&i.ScriptName,
&i.ScriptPre,
&i.ScriptPost,
&i.Name,
); err != nil {
return nil, err
}
@ -112,12 +113,13 @@ func (q *Queries) ListScripts(ctx context.Context) ([]Script, error) {
}
const replaceScriptTrigger = `-- name: ReplaceScriptTrigger :exec
REPLACE INTO script_trigger (id, event, device_match, parameter, script_target, script_name, script_pre, script_post)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
REPLACE INTO script_trigger (id, name, event, device_match, parameter, script_target, script_name, script_pre, script_post)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
`
type ReplaceScriptTriggerParams struct {
ID uuid.UUID
Name string
Event string
DeviceMatch string
Parameter string
@ -130,6 +132,7 @@ type ReplaceScriptTriggerParams struct {
func (q *Queries) ReplaceScriptTrigger(ctx context.Context, arg ReplaceScriptTriggerParams) error {
_, err := q.exec(ctx, q.replaceScriptTriggerStmt, replaceScriptTrigger,
arg.ID,
arg.Name,
arg.Event,
arg.DeviceMatch,
arg.Parameter,

4
services/mysqldb/queries/script.sql

@ -14,8 +14,8 @@ REPLACE INTO script_variable (scope, name, value) VALUES (?, ?, ?);
SELECT * FROM script_trigger;
-- name: ReplaceScriptTrigger :exec
REPLACE INTO script_trigger (id, event, device_match, parameter, script_target, script_name, script_pre, script_post)
VALUES (?, ?, ?, ?, ?, ?, ?, ?);
REPLACE INTO script_trigger (id, name, event, device_match, parameter, script_target, script_name, script_pre, script_post)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?);
-- name: DeleteScriptTrigger :exec
DELETE FROM script_trigger WHERE id = ?;

4
services/mysqldb/service.go

@ -34,7 +34,7 @@ func (d *database) HandleEvent(bus *lucifer3.EventBus, event lucifer3.Event) {
switch event := event.(type) {
case events.Started:
timeout, cancel := context.WithTimeout(context.Background(), time.Second*10)
timeout, cancel := context.WithTimeout(context.Background(), time.Second*90)
defer cancel()
// Fetch all aliases first
@ -185,6 +185,7 @@ func (d *database) HandleEvent(bus *lucifer3.EventBus, event lucifer3.Event) {
for _, trig := range scriptTriggers {
bus.RunCommand(script.UpdateTrigger{
ID: trig.ID,
Name: trig.Name,
Event: script.TriggerEvent(trig.Event),
DeviceMatch: trig.DeviceMatch,
Parameter: trig.Parameter,
@ -389,6 +390,7 @@ func (d *database) HandleCommand(bus *lucifer3.EventBus, command lucifer3.Comman
ScriptName: command.ScriptName,
ScriptPre: toJSON(command.ScriptPre),
ScriptPost: toJSON(command.ScriptPost),
Name: command.Name,
})
if err != nil {
bus.RunEvent(events.Log{

18
services/nanoleaf/data.go

@ -141,15 +141,15 @@ var shapeTypeMap = map[int]string{
}
var shapeIconMap = map[int]string{
0: "triangle",
1: "triangle",
2: "square",
3: "square",
4: "square",
7: "hexagon",
8: "triangle",
9: "triangle",
12: "hexagon",
0: "shape_triangle",
1: "shape_triangle",
2: "shape_square",
3: "shape_square",
4: "shape_square",
7: "shape_hexagon",
8: "shape_triangle",
9: "shape_triangle",
12: "shape_hexagon",
}
var shapeWidthMap = map[int]int{

1
services/script/trigger.go

@ -20,6 +20,7 @@ const (
type Trigger struct {
ID uuid.UUID `json:"id"`
Name string `json:"name"`
Event TriggerEvent `json:"event"`
DeviceMatch string `json:"deviceMatch"`
Parameter string `json:"parameter"`

10
services/uistate/data.go

@ -53,7 +53,15 @@ func (d *Data) WithPatch(patches ...Patch) Data {
}
if patch.Device.HWMetadata != nil {
if pd.Icon == "" {
hasIconAlias := false
for _, a := range pd.Aliases {
if strings.HasPrefix(a, "lucifer:icon") {
hasIconAlias = true
break
}
}
if !hasIconAlias {
pd.Icon = patch.Device.HWMetadata.Icon
}
}

Loading…
Cancel
Save