Browse Source

websocket backend + frontend patching.

beelzebub
Gisle Aune 8 months ago
parent
commit
64bb737569
  1. 685
      frontend/package-lock.json
  2. 1
      frontend/src/lib/client/lucifer.ts
  3. 0
      frontend/src/lib/components/DeviceIcon.svelte
  4. 28
      frontend/src/lib/components/Lamp.svelte
  5. 103
      frontend/src/lib/contexts/StateContext.svelte
  6. 15
      frontend/src/lib/models/assignment.ts
  7. 3
      frontend/src/lib/models/device.ts
  8. 37
      frontend/src/lib/models/script.ts
  9. 10
      frontend/src/lib/models/uistate.ts
  10. 26
      frontend/src/routes/+page.svelte
  11. 1
      go.mod
  12. 2
      go.sum
  13. 56
      services/httpapiv1/service.go
  14. 4
      services/hue/bridge.go
  15. 20
      services/uistate/data.go
  16. 4
      services/uistate/patch.go
  17. 34
      services/uistate/service.go

685
frontend/package-lock.json
File diff suppressed because it is too large
View File

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

@ -21,3 +21,4 @@ export default async function fetchLucifer<T>(path: string, init?: RequestInit):
export async function fetchUIState(): Promise<UIState> {
return fetchLucifer("state");
}

0
frontend/src/lib/components/Icon.svelte → frontend/src/lib/components/DeviceIcon.svelte

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

@ -8,7 +8,7 @@
import type Device from "$lib/models/device";
import { SupportFlags } from "$lib/models/device";
import { rgb, type ColorRGB } from "../models/color";
import Icon from "./Icon.svelte";
import DeviceIcon from "./DeviceIcon.svelte";
export let device: Device;
@ -22,18 +22,18 @@
let deviceTitle: string;
let iconColor: ColorRGB | null;
let barColor: ColorRGB | null;
let barPercentage: number | null;
let barFraction: number | null;
let roundboiText: string | null;
$: {
// TODO: Fix device.name on the backend
const nameAlias = device.aliases.find(a => a.startsWith("lucifer:name:"));
const nameAlias = device.aliases?.find(a => a.startsWith("lucifer:name:"));
if (nameAlias != null) {
deviceTitle = nameAlias.slice("lucifer:name:".length);
} else {
deviceTitle = "";
}
barPercentage = null;
barFraction = null;
barColor = null;
iconColor = null;
@ -46,29 +46,29 @@
}
if (sflags & SupportFlags.Color) {
iconColor = device.activeColorRgb;
iconColor = device.desiredColorRgb;
} else {
iconColor = null;
}
if (sflags & SupportFlags.SensorPresence) {
barColor = rgb(0.5,0.5,0.5);
barPercentage = Math.max(0, (300 - (device.sensors.lastMotion||300))/300) * 100;
barFraction = Math.max(0, (300 - (device.sensors.lastMotion!=null?device.sensors.lastMotion:300))/300);
}
if (sflags & SupportFlags.Temperature) {
barColor = rgb(1.000,0.2,0.2);
barPercentage = Math.min(1, Math.max(0, (device.desiredState.temperature||0) - 5) / 35) * 100;
barFraction = Math.min(1, Math.max(0, (device.desiredState.temperature||0) - 5) / 35) * 1;
}
if (sflags & SupportFlags.Intensity) {
if (sflags & SupportFlags.Color) {
barColor = device.activeColorRgb;
barColor = device.desiredColorRgb;
} else {
barColor = rgb(1.000,0.671,0.355);
}
barPercentage = device.desiredState.intensity;
barFraction = device.desiredState.intensity;
}
if (sflags & SupportFlags.SensorTemperature && !!device.sensors.temperature) {
@ -101,20 +101,20 @@
<div class="row">
<div class="row-icon">
{#if iconColor != null}
<Icon name={device.hwMetadata?.icon||"generic_ball"} brightColor={iconColor} darkColor={darkColor} />
<DeviceIcon name={device.hwMetadata?.icon||"generic_ball"} brightColor={iconColor} darkColor={darkColor} />
{:else}
<Icon name={device.hwMetadata?.icon||"generic_ball"} brightColor={darkColor} darkColor={darkColor}>
<DeviceIcon name={device.hwMetadata?.icon||"generic_ball"} brightColor={darkColor} darkColor={darkColor}>
{#if !!roundboiText}
<div class="roundboi-text">{roundboiText}</div>
{/if}
</Icon>
</DeviceIcon>
{/if}
</div>
<div class="title">{displayTitle}</div>
</div>
<div class="flatboi">
{#if barColor != null && barPercentage != null}
<div style="width: {barPercentage*100}%; background-color: rgb({barColor.red*255},{barColor.green*255},{barColor.blue*255})" class="flatboi2"></div>
{#if barColor != null && barFraction != null}
<div style="width: {barFraction*100}%; background-color: rgb({barColor.red*255},{barColor.green*255},{barColor.blue*255})" class="flatboi2"></div>
{/if}
</div>
</div>

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

@ -1,6 +1,7 @@
<script lang="ts" context="module">
import { fetchUIState } from "$lib/client/lucifer";
import type Device from "$lib/models/device";
import type { UIStatePatch } from "$lib/models/uistate";
import type UIState from "$lib/models/uistate";
import { getContext, onMount, setContext } from "svelte";
import { derived, writable, type Readable } from "svelte/store";
@ -9,7 +10,7 @@
export interface StateContextData {
reload(): Promise<void>
state: Readable<UIState | null>
state: Readable<UIState>
error: Readable<string | null>
deviceList: Readable<Device[]>
}
@ -20,7 +21,11 @@
</script>
<script lang="ts">
const state = writable<UIState | null>(null);
const state = writable<UIState>({
assignments: {},
devices: {},
script: {},
});
const error = writable<string | null>(null);
const deviceList = derived(state, state => {
@ -33,6 +38,8 @@
.sort((a,b) => a.id.localeCompare(b.id));
})
async function reload() {
error.set(null);
@ -44,7 +51,97 @@
}
}
onMount(() => { reload(); });
async function connectSocket() {
let url = `ws://${window.location.host}/subscribe`;
if (import.meta.env.VITE_LUCIFER4_BACKEND_URL != null) {
url = import.meta.env.VITE_LUCIFER4_BACKEND_URL.replace("http", "ws") +"/subscribe";
}
const socket = new WebSocket(url);
socket.onmessage = (msg) => {
const patch: UIStatePatch = JSON.parse(msg.data);
state.update(s => {
if (patch.device) {
if (patch.device.delete) {
const devices = {...s.devices};
delete devices[patch.device.id];
return {...s, devices};
} else {
return {
...s,
devices: {
...s.devices,
[patch.device.id]: {
...s.devices[patch.device.id],
...patch.device,
}
}
}
}
}
if (patch.assignment) {
if (patch.assignment.delete) {
const assignments = {...s.assignments};
delete assignments[patch.assignment.id];
return {...s, assignments};
} else {
return {
...s,
assignments: {
...s.assignments,
[patch.assignment.id]: {
...s.assignments[patch.assignment.id],
...patch.assignment,
}
}
}
}
}
if (patch.script) {
if (patch.script.delete) {
const scripts = {...s.scripts};
delete scripts[patch.script.id];
return {...s, scripts};
} else {
return {
...s,
scripts: {
...s.scripts,
[patch.script.id]: {
...s.scripts[patch.script.id],
...patch.script,
}
}
}
}
}
return s;
})
}
socket.onerror = err => {
console.warn("Socket failed:", err);
socket.close();
}
socket.onclose = () => {
setTimeout(() => connectSocket(), 3000);
}
}
onMount(() => {
reload();
const interval = setInterval(reload, 60000);
connectSocket();
return () => clearInterval(interval);
});
setContext<StateContextData>(ctxKey, {
reload,

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

@ -0,0 +1,15 @@
import type { State } from "./device"
export default interface Assignment {
id: string
deviceIds: string[]
effect: Effect
variables: Record<string, number>
}
export type Effect =
| { manual: State }
| { 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 } }

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

@ -1,4 +1,4 @@
import type { IconName } from "$lib/components/Icon.svelte"
import type { IconName } from "$lib/components/DeviceIcon.svelte"
import type { ColorFlags, ColorRGB } from "./color"
export default interface Device {
@ -6,6 +6,7 @@ export default interface Device {
name: string
hwMetadata: HardwareMetadata | null
hwState: HardwareState | null
desiredColorRgb: ColorRGB | null
activeColorRgb: ColorRGB | null
desiredState: State,
aliases: string[],

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

@ -0,0 +1,37 @@
import type { Effect } from "./assignment"
export default interface Script {
name: string
lines: ScriptLine[]
}
export interface ScriptLine {
if: ScriptLineIf
assign: ScriptLineAssign
set: ScriptLineSet
}
export interface ScriptLineIf {
condition: ScriptCondition
then: ScriptLine[]
else: ScriptLine[]
}
export interface ScriptCondition {
scope: string
key: string
op: string
value?: string
not?: boolean
}
export interface ScriptLineAssign {
match: string
effect: Effect
}
export interface ScriptLineSet {
scope: string
key: string
value: string
}

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

@ -1,5 +1,15 @@
import type Assignment from "./assignment";
import type Device from "./device";
import type Script from "./script";
export default interface UIState {
devices: {[id: string]: Device}
assignments: {[id: string]: Assignment}
scripts: {[id: string]: Script}
}
export interface UIStatePatch {
device: Partial<Device> & { id: string, delete?: boolean }
assignment: Partial<Assignment> & { id: string, delete?: boolean }
script: Partial<Script> & { id: string, delete?: boolean }
}

26
frontend/src/routes/+page.svelte

@ -1,5 +1,5 @@
<script lang="ts">
import Icon from "$lib/components/Icon.svelte";
import DeviceIcon from "$lib/components/DeviceIcon.svelte";
import Lamp, { DARK_COLOR } from "$lib/components/Lamp.svelte";
import Toolbar from "$lib/components/Toolbar.svelte";
import { getStateContext } from "$lib/contexts/StateContext.svelte";
@ -14,18 +14,18 @@ import Lamp, { DARK_COLOR } from "$lib/components/Lamp.svelte";
{/each}
</div>
<div class="page" style="font-size: 8em">
<Icon name="generic_ball" brightColor={rgb(0.517,0.537,1.000)} darkColor={DARK_COLOR} />
<Icon name="generic_boob" brightColor={rgb(0.517,0.537,1.000)} darkColor={DARK_COLOR} />
<Icon name="generic_lamp" brightColor={rgb(0.667,0.750,1.000)} darkColor={DARK_COLOR} />
<Icon name="generic_strip" brightColor={rgb(0.667,0.750,1.000)} darkColor={DARK_COLOR} />
<Icon name="hue_adore_tube" brightColor={rgb(1,1,0)} darkColor={DARK_COLOR} />
<Icon name="hue_signe" brightColor={rgb(0.300,1.000,0.300)} darkColor={DARK_COLOR} />
<Icon name="hue_dimmerswitch" brightColor={rgb(0.517,0.537,1.000)} darkColor={DARK_COLOR} />
<Icon name="hue_playbar" brightColor={rgb(1,0,1)} darkColor={DARK_COLOR} />
<Icon name="hue_motionsensor" brightColor={rgb(1,1,1)} darkColor={DARK_COLOR} />
<Icon name="hue_lightbulb_e27" brightColor={rgb(1,1,1)} darkColor={DARK_COLOR} />
<Icon name="hue_lightbulb_e14" brightColor={rgb(1,1,1)} darkColor={DARK_COLOR} />
<Icon name="hue_lightbulb_gu10" brightColor={rgb(1,1,1)} darkColor={DARK_COLOR} />
<DeviceIcon name="generic_ball" brightColor={rgb(0.517,0.537,1.000)} darkColor={DARK_COLOR} />
<DeviceIcon name="generic_boob" brightColor={rgb(0.517,0.537,1.000)} darkColor={DARK_COLOR} />
<DeviceIcon name="generic_lamp" brightColor={rgb(0.667,0.750,1.000)} darkColor={DARK_COLOR} />
<DeviceIcon name="generic_strip" brightColor={rgb(0.667,0.750,1.000)} darkColor={DARK_COLOR} />
<DeviceIcon name="hue_adore_tube" brightColor={rgb(1,1,0)} darkColor={DARK_COLOR} />
<DeviceIcon name="hue_signe" brightColor={rgb(0.300,1.000,0.300)} darkColor={DARK_COLOR} />
<DeviceIcon name="hue_dimmerswitch" brightColor={rgb(0.517,0.537,1.000)} darkColor={DARK_COLOR} />
<DeviceIcon name="hue_playbar" brightColor={rgb(1,0,1)} darkColor={DARK_COLOR} />
<DeviceIcon name="hue_motionsensor" brightColor={rgb(1,1,1)} darkColor={DARK_COLOR} />
<DeviceIcon name="hue_lightbulb_e27" brightColor={rgb(1,1,1)} darkColor={DARK_COLOR} />
<DeviceIcon name="hue_lightbulb_e14" brightColor={rgb(1,1,1)} darkColor={DARK_COLOR} />
<DeviceIcon name="hue_lightbulb_gu10" brightColor={rgb(1,1,1)} darkColor={DARK_COLOR} />
</div>
<Toolbar />

1
go.mod

@ -22,6 +22,7 @@ require (
github.com/eriklupander/dtls v0.0.0-20190304211642-b36018226359 // indirect
github.com/golang-jwt/jwt v3.2.2+incompatible // indirect
github.com/golang/protobuf v1.3.1 // indirect
github.com/gorilla/websocket v1.5.0 // indirect
github.com/labstack/gommon v0.4.0 // indirect
github.com/mattn/go-colorable v0.1.13 // indirect
github.com/mattn/go-isatty v0.0.16 // indirect

2
go.sum

@ -30,6 +30,8 @@ github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5y
github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M=
github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I=
github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/websocket v1.5.0 h1:PPwGk2jz7EePpoHN/+ClbZu8SPxiqlu12wZP/3sWmnc=
github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/grpc-ecosystem/go-grpc-middleware v1.0.0/go.mod h1:FiyG127CGDf3tlThmgyCl78X/SZQqEOJBCDaAfeWzPs=
github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ=
github.com/kisielk/errcheck v1.2.0/go.mod h1:/BMXB+zMLi60iA8Vv6Ksmxu/1UDYcXs4uQLJ+jE2L00=

56
services/httpapiv1/service.go

@ -8,10 +8,12 @@ import (
"git.aiterp.net/lucifer3/server/services/script"
"git.aiterp.net/lucifer3/server/services/uistate"
"github.com/google/uuid"
"github.com/gorilla/websocket"
"github.com/labstack/echo/v4"
"github.com/labstack/echo/v4/middleware"
"log"
"net"
"net/http"
"sync"
)
@ -24,6 +26,11 @@ var zeroUUID = uuid.UUID{
func New(addr string) (lucifer3.Service, error) {
svc := &service{}
upgrader := websocket.Upgrader{
CheckOrigin: func(r *http.Request) bool {
return true
},
}
e := echo.New()
e.HideBanner = true
@ -38,6 +45,28 @@ func New(addr string) (lucifer3.Service, error) {
return c.JSON(200, data)
})
e.GET("/subscribe", func(c echo.Context) error {
ws, err := upgrader.Upgrade(c.Response(), c.Request(), nil)
if err != nil {
return err
}
sub := make(chan uistate.Patch, 64)
svc.addSub(sub)
defer svc.removeSub(sub)
defer ws.Close()
for patch := range sub {
err := ws.WriteJSON(patch)
if err != nil {
break
}
}
return nil
})
e.POST("/command", func(c echo.Context) error {
var input commandInput
err := c.Bind(&input)
@ -115,6 +144,7 @@ type service struct {
mu sync.Mutex
data uistate.Data
bus *lucifer3.EventBus
subs []chan uistate.Patch
}
func (s *service) Active() bool {
@ -130,10 +160,34 @@ func (s *service) HandleEvent(bus *lucifer3.EventBus, event lucifer3.Event) {
case uistate.Patch:
s.mu.Lock()
s.data = s.data.WithPatch(event)
subs := s.subs
s.mu.Unlock()
// TODO: Broadcast websockets
for _, sub := range subs {
select {
case sub <- event:
default:
}
}
}
}
func (s *service) addSub(ch chan uistate.Patch) {
s.mu.Lock()
s.subs = append(s.subs, ch)
s.mu.Unlock()
}
func (s *service) removeSub(ch chan uistate.Patch) {
s.mu.Lock()
s.subs = append([]chan uistate.Patch{}, s.subs...)
for i, sub := range s.subs {
if sub == ch {
s.subs = append(s.subs[:i], s.subs[i+1:]...)
break
}
}
s.mu.Unlock()
}
type commandInput struct {

4
services/hue/bridge.go

@ -155,7 +155,7 @@ func (b *Bridge) RefreshAll() ([]lucifer3.Event, error) {
}
}
if res.Type == "device" {
if res.Type == "device" && !strings.HasPrefix(res.Metadata.Archetype, "bridge") {
hwState, hwEvent := res.GenerateEvent(b.host, resources)
if hwState.SupportFlags == 0 {
continue
@ -219,7 +219,7 @@ func (b *Bridge) ApplyPatches(date time.Time, resources []ResourceData) (eventLi
for _, resource := range resources {
if resource.Owner != nil && resource.Owner.Kind == "device" {
if parent, ok := mapCopy[resource.Owner.ID]; ok {
if parent, ok := mapCopy[resource.Owner.ID]; ok && !strings.HasPrefix(parent.Metadata.Archetype, "bridge") {
hwState, _ := parent.GenerateEvent(b.host, mapCopy)
eventList = append(eventList, hwState)

20
services/uistate/data.go

@ -29,6 +29,7 @@ func (d *Data) WithPatch(patches ...Patch) Data {
gentools.ApplyUpdatePtr(&pd.DesiredState, patch.Device.DesiredState)
gentools.ApplyUpdatePtr(&pd.Assignment, patch.Device.Assignment)
gentools.ApplyUpdatePtr(&pd.ActiveColorRGB, patch.Device.ActiveColorRGB)
gentools.ApplyUpdatePtr(&pd.DesiredColorRGB, patch.Device.DesiredColorRGB)
if patch.Device.AddAlias != nil {
pd.Aliases = append(pd.Aliases[:0:0], pd.Aliases...)
@ -125,15 +126,16 @@ func (d *Data) ensureAssignment(id uuid.UUID) Assignment {
}
type Device struct {
ID string `json:"id"`
Name string `json:"name"`
HWMetadata *events.HardwareMetadata `json:"hwMetadata"`
HWState *events.HardwareState `json:"hwState"`
ActiveColorRGB *color.RGB `json:"activeColorRgb"`
DesiredState *device.State `json:"desiredState"`
Aliases []string `json:"aliases"`
Assignment *uuid.UUID `json:"assignment"`
Sensors DeviceSensors `json:"sensors"`
ID string `json:"id"`
Name string `json:"name"`
HWMetadata *events.HardwareMetadata `json:"hwMetadata"`
HWState *events.HardwareState `json:"hwState"`
DesiredColorRGB *color.RGB `json:"desiredColorRgb"`
ActiveColorRGB *color.RGB `json:"activeColorRgb"`
DesiredState *device.State `json:"desiredState"`
Aliases []string `json:"aliases"`
Assignment *uuid.UUID `json:"assignment"`
Sensors DeviceSensors `json:"sensors"`
}
type DeviceSensors struct {

4
services/uistate/patch.go

@ -38,6 +38,9 @@ func (e Patch) EventDescription() string {
case e.Device.ActiveColorRGB != nil:
col := color.Color{RGB: e.Device.ActiveColorRGB}
return fmt.Sprintf("uistate.Patch(device=%s, activeColorRgb=%s)", e.Device.ID, col.String())
case e.Device.DesiredColorRGB != nil:
col := color.Color{RGB: e.Device.DesiredColorRGB}
return fmt.Sprintf("uistate.Patch(device=%s, desiredColorRgb=%s)", e.Device.ID, col.String())
default:
return fmt.Sprintf("uistate.Patch(device=%s, ...other)", e.Device.ID)
}
@ -61,6 +64,7 @@ type DevicePatch struct {
RemoveAlias *string `json:"removeAlias,omitempty"`
Assignment *uuid.UUID `json:"assignment,omitempty"`
ClearAssignment bool `json:"clearAssignment,omitempty"`
DesiredColorRGB *color.RGB `json:"desiredColorRgb,omitempty"`
ActiveColorRGB *color.RGB `json:"activeColorRgb,omitempty"`
ClearActiveColorRGB bool `json:"clearActiveColorRGB,omitempty"`
Sensors *DeviceSensors `json:"sensors,omitempty"`

34
services/uistate/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/internal/gentools"
"git.aiterp.net/lucifer3/server/services/script"
"github.com/google/uuid"
@ -36,11 +37,29 @@ func (s *service) HandleCommand(bus *lucifer3.EventBus, command lucifer3.Command
switch command := command.(type) {
case commands.SetState:
patches = []Patch{{Device: &DevicePatch{ID: command.ID, DesiredState: &command.State}}}
rgb := color.Color{}
if command.State.Color != nil {
rgb, _ = command.State.Color.ToRGB()
}
patches = []Patch{{Device: &DevicePatch{
ID: command.ID,
DesiredState: &command.State,
DesiredColorRGB: rgb.RGB,
}}}
case commands.SetStateBatch:
for id, state := range command {
rgb := color.Color{}
if state.Color != nil {
rgb, _ = state.Color.ToRGB()
}
patches = append(patches, Patch{
Device: &DevicePatch{ID: id, DesiredState: gentools.ShallowCopy(&state)},
Device: &DevicePatch{
ID: id,
DesiredState: gentools.ShallowCopy(&state),
DesiredColorRGB: rgb.RGB,
},
})
}
case script.Update:
@ -129,10 +148,13 @@ func (s *service) HandleEvent(bus *lucifer3.EventBus, event lucifer3.Event) {
}})
case events.AssignmentVariables:
// Always set the assignment
patches = append(patches, Patch{Assignment: &AssignmentPatch{
ID: event.ID,
Variables: event.Map,
}})
if len(event.Map) > 0 {
patches = append(patches, Patch{Assignment: &AssignmentPatch{
ID: event.ID,
Variables: event.Map,
}})
}
}
if len(patches) > 0 {

Loading…
Cancel
Save