diff --git a/.gitignore b/.gitignore index 706fd07..317b1fa 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ .idea .vscode +.env diff --git a/bus.go b/bus.go index fa78b8e..afc05d5 100644 --- a/bus.go +++ b/bus.go @@ -2,9 +2,7 @@ package lucifer3 import ( "fmt" - "strings" "sync" - "sync/atomic" ) type ServiceKey struct{} @@ -65,20 +63,7 @@ func (b *EventBus) JoinPrivileged(service ActiveService) { } func (b *EventBus) RunCommand(command Command) { - if cd := command.CommandDescription(); !strings.HasPrefix(cd, "SetState") { - if setStates := atomic.LoadInt32(&b.setStates); setStates > 0 { - fmt.Println("[INFO]", setStates, "SetStates commands hidden.") - atomic.AddInt32(&b.setStates, -setStates) - } - - fmt.Println("[COMMAND]", cd) - } else { - if atomic.AddInt32(&b.setStates, 1) >= 1000 { - fmt.Println("[INFO] 1000 SetStates commands hidden.") - atomic.AddInt32(&b.setStates, -1000) - } - } - + fmt.Println("[COMMAND]", command.CommandDescription()) b.send(serviceMessage{command: command}) } diff --git a/cmd/lucifer4-server/main.go b/cmd/lucifer4-server/main.go new file mode 100644 index 0000000..1d63665 --- /dev/null +++ b/cmd/lucifer4-server/main.go @@ -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 +} diff --git a/go.mod b/go.mod index e5f8598..8416cfc 100644 --- a/go.mod +++ b/go.mod @@ -17,9 +17,16 @@ require ( github.com/dustin/go-coap v0.0.0-20170214053734-ddcc80675fa4 // indirect github.com/eriklupander/dtls v0.0.0-20190304211642-b36018226359 // indirect github.com/golang/protobuf v1.3.1 // indirect - golang.org/x/net v0.0.0-20190311183353-d8887717615a // indirect - golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8 // indirect - golang.org/x/text v0.3.0 // indirect + github.com/labstack/echo/v4 v4.10.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 + github.com/valyala/bytebufferpool v1.0.0 // indirect + github.com/valyala/fasttemplate v1.2.2 // indirect + golang.org/x/crypto v0.2.0 // indirect + golang.org/x/net v0.4.0 // indirect + golang.org/x/sys v0.3.0 // indirect + golang.org/x/text v0.5.0 // indirect google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8 // indirect google.golang.org/grpc v1.21.1 // indirect ) diff --git a/go.sum b/go.sum index dd203aa..468a0fc 100644 --- a/go.sum +++ b/go.sum @@ -33,9 +33,19 @@ github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T github.com/kisielk/errcheck v1.2.0/go.mod h1:/BMXB+zMLi60iA8Vv6Ksmxu/1UDYcXs4uQLJ+jE2L00= github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= +github.com/labstack/echo/v4 v4.10.0 h1:5CiyngihEO4HXsz3vVsJn7f8xAlWwRr3aY6Ih280ZKA= +github.com/labstack/echo/v4 v4.10.0/go.mod h1:S/T/5fy/GigaXnHTkh0ZGe4LpkkQysvRjFMSUTkDRNQ= +github.com/labstack/gommon v0.4.0 h1:y7cvthEAEbU0yHOf4axH8ZG2NH8knB9iNSoTO8dyIk8= +github.com/labstack/gommon v0.4.0/go.mod h1:uW6kP17uPlLJsD3ijUYn3/M5bAxtlZhMI6m3MFxTMTM= github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= github.com/magiconair/properties v1.8.0/go.mod h1:PppfXfuXeibc/6YijjN8zIbojt8czPbwD3XqdrwzmxQ= +github.com/mattn/go-colorable v0.1.11/go.mod h1:u5H1YNBxpqRaxsYJYSkiCWKzEfiAb1Gb520KVy5xxl4= +github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= +github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= +github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94= +github.com/mattn/go-isatty v0.0.16 h1:bq3VjFmv/sOjHtdEhmkEV4x1AJtvUvOJ2PFAZ5+peKQ= +github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= github.com/mitchellh/mapstructure v1.0.0/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y= github.com/pelletier/go-toml v1.2.0/go.mod h1:5z9KED0ma1S8pY6P1sdut58dfprrGBbd/94hg7ilaic= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= @@ -54,10 +64,20 @@ github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+ github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk= +github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= +github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= +github.com/valyala/fasttemplate v1.2.1/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ= +github.com/valyala/fasttemplate v1.2.2 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQD0Loo= +github.com/valyala/fasttemplate v1.2.2/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.2.0 h1:BRXPfhNivWL5Yq0BGQ39a2sW6t44aODpfxkWjYdzewE= +golang.org/x/crypto v0.2.0/go.mod h1:hebNnKkNXi2UzZN1eVRvBB7co0a+JxK6XbPiWVs/3J4= golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= golang.org/x/net v0.0.0-20190311183353-d8887717615a h1:oWX7TPOiFAMXLq8o0ikBYfCJVlRHBcsciT5bXOrH628= golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.4.0 h1:Q5QPcMlvfxFTAPV0+07Xz/MpK9NTXu2VDUuy0FeMfaU= +golang.org/x/net v0.4.0/go.mod h1:MBQ8lrhLObU/6UmLb4fmbmk5OcyYmqtbGd/9yIeKjEE= golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.1.0 h1:wsuoTGHzEhffawBOhz5CYhcrV4IdKZbEyZjBMuTp12o= @@ -65,10 +85,18 @@ golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sys v0.0.0-20180906133057-8cf3aee42992/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20211103235746-7861aae1554b/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8 h1:0A+M6Uqn+Eje4kHMK80dtF3JCXC4ykBgQG4Fe06QRhQ= golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.3.0 h1:w8ZOecv6NaNa/zC8944JTU3vz4u6Lagfk4RPQxv92NQ= +golang.org/x/sys v0.3.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/text v0.3.0 h1:g61tztE5qeGQ89tm6NTjjM9VPIm088od1l6aSorWRWg= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.5.0 h1:OLmvp0KP+FVG99Ct/qFiL/Fhk4zp4QQnZ7b2U+5piUM= +golang.org/x/text v0.5.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/tools v0.0.0-20181030221726-6c7e314b6563/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= @@ -81,4 +109,5 @@ gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8 gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c h1:dUUwHk2QECo/6vqA44rthZ8ie2QXMNeKRTHCNY2nXvo= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= diff --git a/internal/color/color.go b/internal/color/color.go index 11176fd..2544119 100644 --- a/internal/color/color.go +++ b/internal/color/color.go @@ -1,6 +1,7 @@ package color import ( + "encoding/json" "fmt" "github.com/lucasb-eyer/go-colorful" "math" @@ -15,6 +16,21 @@ type Color struct { XY *XY `json:"xy,omitempty"` } +func (col *Color) MarshalJSON() ([]byte, error) { + return json.Marshal(col.String()) +} + +func (col *Color) UnmarshalJSON(bytes []byte) error { + var s string + err := json.Unmarshal(bytes, &s) + if err != nil { + return err + } + + *col, err = Parse(s) + return err +} + func (col *Color) IsHueSat() bool { return col.HS != nil } diff --git a/internal/gentools/update.go b/internal/gentools/update.go new file mode 100644 index 0000000..f610da9 --- /dev/null +++ b/internal/gentools/update.go @@ -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) + } + } +} diff --git a/services/httpapiv1/service.go b/services/httpapiv1/service.go new file mode 100644 index 0000000..b864b49 --- /dev/null +++ b/services/httpapiv1/service.go @@ -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"` +} diff --git a/services/hue/bridge.go b/services/hue/bridge.go index 3006d36..0820444 100644 --- a/services/hue/bridge.go +++ b/services/hue/bridge.go @@ -292,7 +292,7 @@ func (b *Bridge) makeCongruentLoop(ctx context.Context) { } // Make sure this loop doesn't spam too hard - rateLimit := time.After(time.Second / 10) + rateLimit := time.After(time.Second / 15) // Take states b.mu.Lock() diff --git a/services/mysqldb/mysqlgen/device.sql.go b/services/mysqldb/mysqlgen/device.sql.go index 8e0fb6b..8adcbcb 100644 --- a/services/mysqldb/mysqlgen/device.sql.go +++ b/services/mysqldb/mysqlgen/device.sql.go @@ -116,6 +116,7 @@ func (q *Queries) DeleteDeviceInfoLike(ctx context.Context, id string) error { const insertDeviceAlias = `-- name: InsertDeviceAlias :exec INSERT INTO device_alias (id, alias) VALUES (?, ?) +ON DUPLICATE KEY UPDATE alias=alias ` type InsertDeviceAliasParams struct { diff --git a/services/mysqldb/queries/device.sql b/services/mysqldb/queries/device.sql index 184db05..251c7d0 100644 --- a/services/mysqldb/queries/device.sql +++ b/services/mysqldb/queries/device.sql @@ -17,7 +17,8 @@ FROM device_alias; -- name: InsertDeviceAlias :exec INSERT INTO device_alias (id, alias) -VALUES (?, ?); +VALUES (?, ?) +ON DUPLICATE KEY UPDATE alias=alias; -- name: ReplaceDeviceAuth :exec REPLACE INTO device_auth (id, api_key, extras) diff --git a/services/uistate/data.go b/services/uistate/data.go new file mode 100644 index 0000000..32c6d56 --- /dev/null +++ b/services/uistate/data.go @@ -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"` +} diff --git a/services/uistate/patch.go b/services/uistate/patch.go new file mode 100644 index 0000000..06b1a24 --- /dev/null +++ b/services/uistate/patch.go @@ -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"` +} diff --git a/services/uistate/service.go b/services/uistate/service.go new file mode 100644 index 0000000..0126c1a --- /dev/null +++ b/services/uistate/service.go @@ -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) + } + } +}