Gisle Aune
2 years ago
14 changed files with 583 additions and 21 deletions
-
1.gitignore
-
17bus.go
-
70cmd/lucifer4-server/main.go
-
13go.mod
-
29go.sum
-
16internal/color/color.go
-
66internal/gentools/update.go
-
112services/httpapiv1/service.go
-
2services/hue/bridge.go
-
1services/mysqldb/mysqlgen/device.sql.go
-
3services/mysqldb/queries/device.sql
-
118services/uistate/data.go
-
51services/uistate/patch.go
-
105services/uistate/service.go
@ -1,2 +1,3 @@ |
|||||
.idea |
.idea |
||||
.vscode |
.vscode |
||||
|
.env |
@ -0,0 +1,70 @@ |
|||||
|
package main |
||||
|
|
||||
|
import ( |
||||
|
lucifer3 "git.aiterp.net/lucifer3/server" |
||||
|
"git.aiterp.net/lucifer3/server/events" |
||||
|
"git.aiterp.net/lucifer3/server/services" |
||||
|
"git.aiterp.net/lucifer3/server/services/httpapiv1" |
||||
|
"git.aiterp.net/lucifer3/server/services/hue" |
||||
|
"git.aiterp.net/lucifer3/server/services/mysqldb" |
||||
|
"git.aiterp.net/lucifer3/server/services/nanoleaf" |
||||
|
"git.aiterp.net/lucifer3/server/services/uistate" |
||||
|
"log" |
||||
|
"os" |
||||
|
"strconv" |
||||
|
"time" |
||||
|
) |
||||
|
|
||||
|
func main() { |
||||
|
bus := lucifer3.EventBus{} |
||||
|
|
||||
|
resolver := services.NewResolver() |
||||
|
sceneMap := services.NewSceneMap(resolver) |
||||
|
|
||||
|
database, err := mysqldb.Connect( |
||||
|
env("LUCIFER4_DB_HOST"), |
||||
|
envInt("LUCIFER4_DB_PORT"), |
||||
|
env("LUCIFER4_DB_USER"), |
||||
|
env("LUCIFER4_DB_PASSWORD"), |
||||
|
env("LUCIFER4_DB_SCHEMA"), |
||||
|
) |
||||
|
if err != nil { |
||||
|
log.Fatalln("Database failed", err) |
||||
|
} |
||||
|
|
||||
|
httpAPI, err := httpapiv1.New(env("LUCIFER4_HTTP_LISTEN")) |
||||
|
if err != nil { |
||||
|
log.Fatalln("HTTP Listen failed", err) |
||||
|
} |
||||
|
|
||||
|
bus.JoinPrivileged(resolver) |
||||
|
bus.JoinPrivileged(sceneMap) |
||||
|
bus.Join(services.NewEffectEnforcer(resolver, sceneMap)) |
||||
|
bus.Join(nanoleaf.NewService()) |
||||
|
bus.Join(hue.NewService()) |
||||
|
bus.Join(uistate.NewService()) |
||||
|
bus.Join(database) |
||||
|
bus.Join(httpAPI) |
||||
|
|
||||
|
bus.RunEvent(events.Started{}) |
||||
|
|
||||
|
time.Sleep(time.Hour * 16) |
||||
|
} |
||||
|
|
||||
|
func env(key string) string { |
||||
|
value := os.Getenv(key) |
||||
|
if value == "" { |
||||
|
log.Fatalln("Expected env:", key) |
||||
|
} |
||||
|
|
||||
|
return value |
||||
|
} |
||||
|
|
||||
|
func envInt(key string) int { |
||||
|
value, err := strconv.Atoi(os.Getenv(key)) |
||||
|
if err != nil { |
||||
|
log.Fatalln("Expected numeric env:", key) |
||||
|
} |
||||
|
|
||||
|
return value |
||||
|
} |
@ -0,0 +1,66 @@ |
|||||
|
package gentools |
||||
|
|
||||
|
func ApplyUpdate[T any](dst *T, value *T) { |
||||
|
if value != nil { |
||||
|
*dst = *value |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
func ApplyUpdatePtr[T any](dst **T, value *T) { |
||||
|
if value != nil { |
||||
|
*dst = ShallowCopy(value) |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
func ApplyUpdateSlice[T any](dst *[]T, value []T) { |
||||
|
if value != nil { |
||||
|
*dst = append([]T{}, value...) |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
func ApplyUpdateNonZero[T comparable](dst *T, value *T) { |
||||
|
var zero T |
||||
|
if value != nil && *value != zero { |
||||
|
*dst = *value |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
func ApplyUpdateNilZero[T comparable](dst **T, value *T) { |
||||
|
if value != nil { |
||||
|
var zero T |
||||
|
|
||||
|
if *value == zero { |
||||
|
*dst = nil |
||||
|
} else { |
||||
|
valueCopy := *value |
||||
|
*dst = &valueCopy |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
func ApplyMapUpdate[K comparable, V any](dst *map[K]V, src map[K]*V) { |
||||
|
if *dst == nil { |
||||
|
dst = &map[K]V{} |
||||
|
} |
||||
|
for key, value := range src { |
||||
|
if value != nil { |
||||
|
(*dst)[key] = *value |
||||
|
} else { |
||||
|
delete(*dst, key) |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
func ApplyUpdateMapNilZero[K comparable, V comparable](dst *map[K]V, src map[K]V) { |
||||
|
var zero V |
||||
|
if *dst == nil { |
||||
|
dst = &map[K]V{} |
||||
|
} |
||||
|
for key, value := range src { |
||||
|
if value != zero { |
||||
|
(*dst)[key] = value |
||||
|
} else { |
||||
|
delete(*dst, key) |
||||
|
} |
||||
|
} |
||||
|
} |
@ -0,0 +1,112 @@ |
|||||
|
package httpapiv1 |
||||
|
|
||||
|
import ( |
||||
|
lucifer3 "git.aiterp.net/lucifer3/server" |
||||
|
"git.aiterp.net/lucifer3/server/commands" |
||||
|
"git.aiterp.net/lucifer3/server/effects" |
||||
|
"git.aiterp.net/lucifer3/server/events" |
||||
|
"git.aiterp.net/lucifer3/server/services/uistate" |
||||
|
"github.com/labstack/echo/v4" |
||||
|
"log" |
||||
|
"net" |
||||
|
"sync" |
||||
|
) |
||||
|
|
||||
|
func New(addr string) (lucifer3.Service, error) { |
||||
|
svc := &service{} |
||||
|
|
||||
|
e := echo.New() |
||||
|
e.GET("/state", func(c echo.Context) error { |
||||
|
svc.mu.Lock() |
||||
|
data := svc.data |
||||
|
svc.mu.Unlock() |
||||
|
|
||||
|
return c.JSON(200, data) |
||||
|
}) |
||||
|
|
||||
|
e.POST("/command", func(c echo.Context) error { |
||||
|
var input commandInput |
||||
|
err := c.Bind(&input) |
||||
|
if err != nil { |
||||
|
return err |
||||
|
} |
||||
|
|
||||
|
svc.mu.Lock() |
||||
|
bus := svc.bus |
||||
|
svc.mu.Unlock() |
||||
|
if bus == nil { |
||||
|
return c.String(413, "Waiting for bus") |
||||
|
} |
||||
|
|
||||
|
switch { |
||||
|
case input.Assign != nil: |
||||
|
bus.RunCommand(commands.Assign{ |
||||
|
ID: nil, |
||||
|
Match: input.Assign.Match, |
||||
|
Effect: input.Assign.Effect.Effect, |
||||
|
}) |
||||
|
case input.PairDevice != nil: |
||||
|
bus.RunCommand(*input.PairDevice) |
||||
|
case input.ForgetDevice != nil: |
||||
|
bus.RunCommand(*input.ForgetDevice) |
||||
|
case input.SearchDevices != nil: |
||||
|
bus.RunCommand(*input.SearchDevices) |
||||
|
default: |
||||
|
return c.String(400, "No supported command found in input") |
||||
|
} |
||||
|
|
||||
|
return c.JSON(200, input) |
||||
|
}) |
||||
|
|
||||
|
listener, err := net.Listen("tcp", addr) |
||||
|
if err != nil { |
||||
|
return nil, err |
||||
|
} |
||||
|
e.Listener = listener |
||||
|
|
||||
|
go func() { |
||||
|
err := e.Start(addr) |
||||
|
if err != nil { |
||||
|
log.Fatalln("Failed to listen to webserver") |
||||
|
} |
||||
|
}() |
||||
|
|
||||
|
return svc, nil |
||||
|
} |
||||
|
|
||||
|
type service struct { |
||||
|
mu sync.Mutex |
||||
|
data uistate.Data |
||||
|
bus *lucifer3.EventBus |
||||
|
} |
||||
|
|
||||
|
func (s *service) Active() bool { |
||||
|
return true |
||||
|
} |
||||
|
|
||||
|
func (s *service) HandleEvent(bus *lucifer3.EventBus, event lucifer3.Event) { |
||||
|
switch event := event.(type) { |
||||
|
case events.Started: |
||||
|
s.mu.Lock() |
||||
|
s.bus = bus |
||||
|
s.mu.Unlock() |
||||
|
case uistate.Patch: |
||||
|
s.mu.Lock() |
||||
|
s.data = s.data.WithPatch(event) |
||||
|
s.mu.Unlock() |
||||
|
|
||||
|
// TODO: Broadcast websockets
|
||||
|
} |
||||
|
} |
||||
|
|
||||
|
type commandInput struct { |
||||
|
Assign *assignInput `json:"assign,omitempty"` |
||||
|
PairDevice *commands.PairDevice `json:"pairDevice,omitempty"` |
||||
|
SearchDevices *commands.SearchDevices `json:"searchDevices,omitempty"` |
||||
|
ForgetDevice *commands.ForgetDevice `json:"forgetDevice,omitempty"` |
||||
|
} |
||||
|
|
||||
|
type assignInput struct { |
||||
|
Match string `json:"match"` |
||||
|
Effect effects.Serializable `json:"effect"` |
||||
|
} |
@ -0,0 +1,118 @@ |
|||||
|
package uistate |
||||
|
|
||||
|
import ( |
||||
|
"git.aiterp.net/lucifer3/server/device" |
||||
|
"git.aiterp.net/lucifer3/server/effects" |
||||
|
"git.aiterp.net/lucifer3/server/events" |
||||
|
"git.aiterp.net/lucifer3/server/internal/gentools" |
||||
|
"github.com/google/uuid" |
||||
|
) |
||||
|
|
||||
|
type Data struct { |
||||
|
Devices map[string]Device `json:"devices"` |
||||
|
Assignments map[uuid.UUID]Assignment `json:"assignments"` |
||||
|
} |
||||
|
|
||||
|
func (d *Data) WithPatch(patches ...Patch) Data { |
||||
|
newData := d.Copy() |
||||
|
for _, patch := range patches { |
||||
|
if patch.Device != nil { |
||||
|
pd := d.ensureDevice(patch.Device.ID) |
||||
|
|
||||
|
gentools.ApplyUpdateSlice(&pd.Aliases, patch.Device.SetAliases) |
||||
|
gentools.ApplyUpdate(&pd.Name, patch.Device.Name) |
||||
|
gentools.ApplyUpdatePtr(&pd.HWState, patch.Device.HWState) |
||||
|
gentools.ApplyUpdatePtr(&pd.HWMetadata, patch.Device.HWMetadata) |
||||
|
gentools.ApplyUpdatePtr(&pd.DesiredState, patch.Device.DesiredState) |
||||
|
gentools.ApplyUpdatePtr(&pd.Assignment, patch.Device.Assignment) |
||||
|
|
||||
|
if patch.Device.AddAlias != nil { |
||||
|
pd.Aliases = append(pd.Aliases[:0:0], pd.Aliases...) |
||||
|
pd.Aliases = append(pd.Aliases, *patch.Device.AddAlias) |
||||
|
} |
||||
|
if patch.Device.RemoveAlias != nil { |
||||
|
for i, alias := range pd.Aliases { |
||||
|
if alias == *patch.Device.RemoveAlias { |
||||
|
pd.Aliases = append(pd.Aliases[:0:0], pd.Aliases...) |
||||
|
pd.Aliases = append(pd.Aliases[:i], pd.Aliases[i+1:]...) |
||||
|
break |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
if patch.Device.ClearAssignment { |
||||
|
pd.Assignment = nil |
||||
|
} |
||||
|
|
||||
|
if patch.Device.Delete { |
||||
|
delete(newData.Devices, pd.ID) |
||||
|
} else { |
||||
|
newData.Devices[pd.ID] = pd |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
if patch.Assignment != nil { |
||||
|
pa := d.ensureAssignment(patch.Assignment.ID) |
||||
|
gentools.ApplyUpdatePtr(&pa.Effect, patch.Assignment.Effect) |
||||
|
if patch.Assignment.AddDeviceID != nil { |
||||
|
pa.DeviceIDs = append(pa.DeviceIDs[:0:0], pa.DeviceIDs...) |
||||
|
pa.DeviceIDs = append(pa.DeviceIDs, *patch.Assignment.AddDeviceID) |
||||
|
} |
||||
|
if patch.Assignment.RemoveDeviceID != nil { |
||||
|
for i, id := range pa.DeviceIDs { |
||||
|
if id == *patch.Assignment.RemoveDeviceID { |
||||
|
pa.DeviceIDs = append(pa.DeviceIDs[:0:0], pa.DeviceIDs...) |
||||
|
pa.DeviceIDs[i] = "" |
||||
|
break |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
if patch.Assignment.Delete { |
||||
|
delete(newData.Assignments, pa.ID) |
||||
|
} else { |
||||
|
newData.Assignments[pa.ID] = pa |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
return newData |
||||
|
} |
||||
|
|
||||
|
func (d *Data) Copy() Data { |
||||
|
return Data{ |
||||
|
Devices: gentools.CopyMap(d.Devices), |
||||
|
Assignments: gentools.CopyMap(d.Assignments), |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
func (d *Data) ensureDevice(id string) Device { |
||||
|
if device, ok := d.Devices[id]; ok { |
||||
|
return device |
||||
|
} else { |
||||
|
return Device{ID: id} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
func (d *Data) ensureAssignment(id uuid.UUID) Assignment { |
||||
|
if assignment, ok := d.Assignments[id]; ok { |
||||
|
return assignment |
||||
|
} else { |
||||
|
return Assignment{ID: id} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
type Device struct { |
||||
|
ID string `json:"id"` |
||||
|
Name string `json:"name"` |
||||
|
HWMetadata *events.HardwareMetadata `json:"hwMetadata"` |
||||
|
HWState *events.HardwareState `json:"hwState"` |
||||
|
DesiredState *device.State `json:"desiredState"` |
||||
|
Aliases []string `json:"aliases"` |
||||
|
Assignment *uuid.UUID `json:"assignment"` |
||||
|
} |
||||
|
|
||||
|
type Assignment struct { |
||||
|
ID uuid.UUID `json:"id"` |
||||
|
DeviceIDs []string `json:"deviceIds"` |
||||
|
Effect *effects.Serializable `json:"effect"` |
||||
|
} |
@ -0,0 +1,51 @@ |
|||||
|
package uistate |
||||
|
|
||||
|
import ( |
||||
|
"fmt" |
||||
|
"git.aiterp.net/lucifer3/server/device" |
||||
|
"git.aiterp.net/lucifer3/server/effects" |
||||
|
"git.aiterp.net/lucifer3/server/events" |
||||
|
"github.com/google/uuid" |
||||
|
) |
||||
|
|
||||
|
type Patch struct { |
||||
|
Assignment *AssignmentPatch `json:"assignment,omitempty"` |
||||
|
Device *DevicePatch `json:"device,omitempty"` |
||||
|
} |
||||
|
|
||||
|
func (e Patch) EventDescription() string { |
||||
|
if e.Device != nil { |
||||
|
switch { |
||||
|
case e.Device.DesiredState != nil: |
||||
|
return fmt.Sprintf("uistate.Patch(device=%s, desired state)", e.Device.ID) |
||||
|
default: |
||||
|
return fmt.Sprintf("uistate.Patch(device=%s)", e.Device.ID) |
||||
|
} |
||||
|
} else if e.Assignment != nil { |
||||
|
return fmt.Sprintf("uistate.Patch(assignment=%s)", e.Assignment.ID) |
||||
|
} else { |
||||
|
return "uistate.Patch" |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
type DevicePatch struct { |
||||
|
ID string `json:"id,omitempty"` |
||||
|
Name *string `json:"name,omitempty"` |
||||
|
HWMetadata *events.HardwareMetadata `json:"hwMetadata,omitempty"` |
||||
|
HWState *events.HardwareState `json:"hwState,omitempty"` |
||||
|
DesiredState *device.State `json:"desiredState,omitempty"` |
||||
|
SetAliases []string `json:"setAliases,omitempty"` |
||||
|
AddAlias *string `json:"addAlias,omitempty"` |
||||
|
RemoveAlias *string `json:"removeAlias,omitempty"` |
||||
|
Assignment *uuid.UUID `json:"assignment,omitempty"` |
||||
|
ClearAssignment bool `json:"clearAssignment,omitempty"` |
||||
|
Delete bool `json:"delete,omitempty"` |
||||
|
} |
||||
|
|
||||
|
type AssignmentPatch struct { |
||||
|
ID uuid.UUID `json:"id"` |
||||
|
AddDeviceID *string `json:"addDeviceId"` |
||||
|
RemoveDeviceID *string `json:"removeDeviceId"` |
||||
|
Effect *effects.Serializable `json:"effect"` |
||||
|
Delete bool `json:"delete"` |
||||
|
} |
@ -0,0 +1,105 @@ |
|||||
|
package uistate |
||||
|
|
||||
|
import ( |
||||
|
lucifer3 "git.aiterp.net/lucifer3/server" |
||||
|
"git.aiterp.net/lucifer3/server/commands" |
||||
|
"git.aiterp.net/lucifer3/server/effects" |
||||
|
"git.aiterp.net/lucifer3/server/events" |
||||
|
"git.aiterp.net/lucifer3/server/internal/gentools" |
||||
|
"sync" |
||||
|
) |
||||
|
|
||||
|
func NewService() lucifer3.ActiveService { |
||||
|
return &service{} |
||||
|
} |
||||
|
|
||||
|
type service struct { |
||||
|
mu sync.Mutex |
||||
|
data Data |
||||
|
listener []chan Patch |
||||
|
} |
||||
|
|
||||
|
func (s *service) Active() bool { |
||||
|
return true |
||||
|
} |
||||
|
|
||||
|
func (s *service) HandleCommand(bus *lucifer3.EventBus, command lucifer3.Command) { |
||||
|
var patches []Patch |
||||
|
|
||||
|
switch command := command.(type) { |
||||
|
case commands.SetState: |
||||
|
patches = []Patch{{Device: &DevicePatch{ID: command.ID, DesiredState: &command.State}}} |
||||
|
case commands.SetStateBatch: |
||||
|
for id, state := range command { |
||||
|
patches = []Patch{{Device: &DevicePatch{ID: id, DesiredState: gentools.ShallowCopy(&state)}}} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
if len(patches) > 0 { |
||||
|
s.mu.Lock() |
||||
|
s.data = s.data.WithPatch(patches...) |
||||
|
s.mu.Unlock() |
||||
|
|
||||
|
for _, patch := range patches { |
||||
|
bus.RunEvent(patch) |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
func (s *service) HandleEvent(bus *lucifer3.EventBus, event lucifer3.Event) { |
||||
|
var patches []Patch |
||||
|
|
||||
|
switch event := event.(type) { |
||||
|
case events.AliasAdded: |
||||
|
patches = []Patch{{Device: &DevicePatch{ID: event.ID, AddAlias: &event.Alias}}} |
||||
|
case events.AliasRemoved: |
||||
|
patches = []Patch{{Device: &DevicePatch{ID: event.ID, RemoveAlias: &event.Alias}}} |
||||
|
case events.HardwareState: |
||||
|
patches = []Patch{{Device: &DevicePatch{ID: event.ID, HWState: &event}}} |
||||
|
case events.HardwareMetadata: |
||||
|
patches = []Patch{{Device: &DevicePatch{ID: event.ID, HWMetadata: &event}}} |
||||
|
case events.AssignmentCreated: |
||||
|
patches = []Patch{{Assignment: &AssignmentPatch{ |
||||
|
ID: event.ID, |
||||
|
Effect: &effects.Serializable{Effect: event.Effect}, |
||||
|
}}} |
||||
|
case events.AssignmentRemoved: |
||||
|
patches = []Patch{{Assignment: &AssignmentPatch{ |
||||
|
ID: event.ID, |
||||
|
Delete: true, |
||||
|
}}} |
||||
|
case events.DeviceAssigned: |
||||
|
// Un-assign from current assignment (if any)
|
||||
|
if d, ok := s.data.Devices[event.DeviceID]; ok && d.Assignment != nil { |
||||
|
patches = append(patches, Patch{Assignment: &AssignmentPatch{ |
||||
|
ID: *d.Assignment, |
||||
|
RemoveDeviceID: &d.ID, |
||||
|
}}) |
||||
|
} |
||||
|
|
||||
|
// Assign to current assignment (if it's not cleared)
|
||||
|
if event.AssignmentID != nil { |
||||
|
patches = append(patches, Patch{Assignment: &AssignmentPatch{ |
||||
|
ID: *event.AssignmentID, |
||||
|
AddDeviceID: &event.DeviceID, |
||||
|
}}) |
||||
|
} |
||||
|
|
||||
|
// Always set the assignment
|
||||
|
patches = append(patches, Patch{Device: &DevicePatch{ |
||||
|
ID: event.DeviceID, |
||||
|
Assignment: gentools.ShallowCopy(event.AssignmentID), |
||||
|
ClearAssignment: event.AssignmentID == nil, |
||||
|
}}) |
||||
|
} |
||||
|
|
||||
|
if len(patches) > 0 { |
||||
|
s.mu.Lock() |
||||
|
s.data = s.data.WithPatch(patches...) |
||||
|
s.mu.Unlock() |
||||
|
|
||||
|
for _, patch := range patches { |
||||
|
bus.RunEvent(patch) |
||||
|
} |
||||
|
} |
||||
|
} |
Write
Preview
Loading…
Cancel
Save
Reference in new issue