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 |
|||
.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