diff --git a/app/api/scenes.go b/app/api/scenes.go index 9d67ea7..44c2f1f 100644 --- a/app/api/scenes.go +++ b/app/api/scenes.go @@ -2,6 +2,7 @@ package api import ( "git.aiterp.net/lucifer/new-server/app/config" + "git.aiterp.net/lucifer/new-server/app/services/publisher" "git.aiterp.net/lucifer/new-server/models" "github.com/gin-gonic/gin" ) @@ -32,6 +33,36 @@ func Scenes(r gin.IRoutes) { return nil, err } + publisher.Global().UpdateScene(body) + + return body, nil + })) + + r.PUT("/:id", handler(func(c *gin.Context) (interface{}, error) { + var body models.Scene + err := parseBody(c, &body) + if err != nil { + return nil, err + } + + scene, err := config.SceneRepository().Find(ctxOf(c), intParam(c, "id")) + if err != nil { + return nil, err + } + + body.ID = scene.ID + err = body.Validate() + if err != nil { + return nil, err + } + + err = config.SceneRepository().Save(ctxOf(c), &body) + if err != nil { + return nil, err + } + + publisher.Global().UpdateScene(body) + return body, nil })) -} \ No newline at end of file +} diff --git a/app/client/client.go b/app/client/client.go index 45c2f6c..84b8dcb 100644 --- a/app/client/client.go +++ b/app/client/client.go @@ -60,6 +60,16 @@ func (client *Client) PutDeviceTags(ctx context.Context, fetchStr string, addTag return devices, nil } +func (client *Client) AssignDevice(ctx context.Context, fetchStr string, assignment models.DeviceSceneAssignment) ([]models.Device, error) { + devices := make([]models.Device, 0, 16) + err := client.Fetch(ctx, "PUT", "/api/devices/"+fetchStr+"/scene", &devices, assignment) + if err != nil { + return nil, err + } + + return devices, nil +} + func (client *Client) FireEvent(ctx context.Context, event models.Event) error { err := client.Fetch(ctx, "POST", "/api/events", nil, event) if err != nil { diff --git a/app/client/scene.go b/app/client/scene.go new file mode 100644 index 0000000..6eda620 --- /dev/null +++ b/app/client/scene.go @@ -0,0 +1,16 @@ +package client + +import ( + "context" + "git.aiterp.net/lucifer/new-server/models" +) + +func (client *Client) GetScenes(ctx context.Context) ([]models.Scene, error) { + scenes := make([]models.Scene, 0, 16) + err := client.Fetch(ctx, "GET", "/api/scenes", &scenes, nil) + if err != nil { + return nil, err + } + + return scenes, nil +} \ No newline at end of file diff --git a/app/services/publisher/publisher.go b/app/services/publisher/publisher.go index 74eedff..e181e34 100644 --- a/app/services/publisher/publisher.go +++ b/app/services/publisher/publisher.go @@ -19,6 +19,18 @@ type Publisher struct { waiting map[int]chan struct{} } +func (p *Publisher) UpdateScene(data models.Scene) { + p.mu.Lock() + p.sceneData[data.ID] = &data + + for _, scene := range p.scenes { + if scene.data.ID == data.ID { + scene.UpdateScene(data) + } + } + p.mu.Unlock() +} + func (p *Publisher) ReloadScenes(ctx context.Context) error { scenes, err := config.SceneRepository().FetchAll(ctx) if err != nil { @@ -26,8 +38,12 @@ func (p *Publisher) ReloadScenes(ctx context.Context) error { } p.mu.Lock() - for _, scene := range scenes { - p.sceneData[scene.ID] = &scene + for i, scene := range scenes { + p.sceneData[scene.ID] = &scenes[i] + } + + for _, scene := range p.scenes { + scene.UpdateScene(*p.sceneData[scene.data.ID]) } p.mu.Unlock() @@ -90,10 +106,12 @@ func (p *Publisher) Run() { p.mu.Lock() for i, scene := range p.scenes { - if !scene.endTime.IsZero() && time.Now().After(scene.endTime) { + if (!scene.endTime.IsZero() && time.Now().After(scene.endTime)) || scene.Empty() { deleteList = append(deleteList, i-len(deleteList)) updatedList = append(updatedList, scene.AllDevices()...) + log.Printf("Removing scene instance for %s (%d)", scene.data.Name, scene.data.ID) + for _, device := range scene.AllDevices() { p.sceneAssignment[device.ID] = nil } @@ -258,6 +276,10 @@ var publisher = Publisher{ waiting: make(map[int]chan struct{}), } +func Global() *Publisher { + return &publisher +} + func Initialize(ctx context.Context) error { err :=publisher.ReloadScenes(ctx) if err != nil { diff --git a/app/services/publisher/scene.go b/app/services/publisher/scene.go index 2d12ba3..eea9cfa 100644 --- a/app/services/publisher/scene.go +++ b/app/services/publisher/scene.go @@ -86,6 +86,17 @@ func (s *Scene) RemoveDevice(device models.Device) { delete(s.roleMap, device.ID) } +func (s *Scene) Empty() bool { + for _, list := range s.roleList { + if len(list) > 0 { + return false + } + } + + return true +} + + func (s *Scene) Due() bool { if s.due { return true diff --git a/cmd/lucy/main.go b/cmd/lucy/main.go index adc8fa8..0e74490 100644 --- a/cmd/lucy/main.go +++ b/cmd/lucy/main.go @@ -96,6 +96,9 @@ func main() { case "handler": handlerCmd(ctx, c) + case "scene": + sceneCmd(ctx, c) + // EVENT case "run": { diff --git a/cmd/lucy/scenecmd.go b/cmd/lucy/scenecmd.go new file mode 100644 index 0000000..63675be --- /dev/null +++ b/cmd/lucy/scenecmd.go @@ -0,0 +1,151 @@ +package main + +import ( + "context" + "git.aiterp.net/lucifer/new-server/app/client" + "git.aiterp.net/lucifer/new-server/models" + "gopkg.in/yaml.v2" + "log" + "os" + "strconv" + "strings" + "unicode" +) + +func sceneCmd( + ctx context.Context, + c client.Client, +) { + cmd := parseCommand(os.Args[2:]) + + switch cmd.Name { + case "create", "update": + { + fileName := cmd.Params.Get(0).String() + if fileName == nil { + log.Fatalln("Missing filename") + } + + file, err := os.Open(*fileName) + if err != nil { + log.Fatalln("Failed to open file:", err) + } + defer file.Close() + + yamlData := make(map[string]interface{}) + err = yaml.NewDecoder(file).Decode(yamlData) + if err != nil { + log.Fatalln("Failed to decode file:", err) + return + } + + yamlData = camelCasify(yamlData) + name, nameOk := yamlData["name"] + if !nameOk { + log.Fatalln("Missing name in yaml data.") + } + + var scene models.Scene + if cmd.Name == "create" { + err := c.Fetch(ctx, "POST", "/api/scenes", &scene, yamlData) + if err != nil { + log.Fatalln("Failed to create scene:", err) + return + } + } else { + scenes, err := c.GetScenes(ctx) + if err != nil { + log.Fatalln("Failed to fetch existing scenes:", err) + return + } + + id := -1 + for _, scene := range scenes { + if scene.Name == name { + id = scene.ID + break + } + } + if id == -1 { + log.Fatalln("Could not find scene with name", name) + return + } + + err = c.Fetch(ctx, "PUT", "/api/scenes/"+strconv.Itoa(id), &scene, yamlData) + if err != nil { + log.Fatalln("Failed to update scene:", err) + return + } + } + } + + case "assign": + { + fetch := cmd.Params.Get(0).String() + id := cmd.Params.Get(1).Int() + if fetch == nil || id == nil { + log.Println("Usage: lucy scene assign ") + } + + devices, err := c.AssignDevice(ctx, *fetch, models.DeviceSceneAssignment{ + SceneID: *id, + Group: cmd.Params.Get("group").StringOr(*fetch), + DurationMS: int64(cmd.Params.Get("duration").IntOr(0)), + }) + if err != nil { + log.Println("Could not assign devices:", err) + return + } + + WriteDeviceInfoTable(os.Stdout, devices) + } + } +} + +func camelCasify(m map[string]interface{}) map[string]interface{} { + m2 := make(map[string]interface{}, len(m)) + for key, value := range m { + b := strings.Builder{} + snake := false + for _, ch := range key { + if ch == '_' { + snake = true + } else if snake { + b.WriteRune(unicode.ToUpper(ch)) + snake = false + } else { + b.WriteRune(ch) + } + } + + switch value := value.(type) { + case []interface{}: + valueCopy := make([]interface{}, len(value)) + for i, elem := range value { + switch elem := elem.(type) { + case map[interface{}]interface{}: + m3 := make(map[string]interface{}) + for k, v := range elem { + if kStr, ok := k.(string); ok { + m3[kStr] = v + } + } + + valueCopy[i] = camelCasify(m3) + case map[string]interface{}: + valueCopy[i] = camelCasify(elem) + default: + valueCopy[i] = elem + } + } + + m2[b.String()] = valueCopy + case map[string]interface{}: + m2[b.String()] = camelCasify(value) + default: + m2[b.String()] = value + } + } + + return m2 +} diff --git a/internal/drivers/nanoleaf/bridge.go b/internal/drivers/nanoleaf/bridge.go index 2c91727..ce5d916 100644 --- a/internal/drivers/nanoleaf/bridge.go +++ b/internal/drivers/nanoleaf/bridge.go @@ -177,7 +177,7 @@ func (b *bridge) Update(devices []models.Device) { red, green, blue := color.RGB255() newColor := [4]byte{red, green, blue, 255} if newColor != panel.ColorRGBA { - panel.update(newColor, time.Now().Add(time.Millisecond*120)) + panel.update(newColor, time.Now().Add(time.Millisecond*220)) } } else { panel.update([4]byte{0, 0, 0, 0}, time.Now()) diff --git a/internal/mysql/scenerepo.go b/internal/mysql/scenerepo.go index 7c27791..b8db256 100644 --- a/internal/mysql/scenerepo.go +++ b/internal/mysql/scenerepo.go @@ -2,6 +2,7 @@ package mysql import ( "context" + "database/sql" "encoding/json" "git.aiterp.net/lucifer/new-server/models" "github.com/jmoiron/sqlx" @@ -32,6 +33,10 @@ func (r *SceneRepo) FetchAll(ctx context.Context) ([]models.Scene, error) { scenes := make([]sceneRecord, 0, 8) err := r.DBX.SelectContext(ctx, &scenes, "SELECT * FROM scene") if err != nil { + if err == sql.ErrNoRows { + return []models.Scene{}, nil + } + return nil, dbErr(err) } diff --git a/models/scene.go b/models/scene.go index 0737836..64a0c72 100644 --- a/models/scene.go +++ b/models/scene.go @@ -56,7 +56,7 @@ func (s *Scene) RoleIndex(device *Device) int { type SceneEffect string const ( - SEStatic SceneEffect = "Plain" + SEStatic SceneEffect = "Static" SERandom SceneEffect = "Random" SEGradient SceneEffect = "Gradient" SEWalkingGradient SceneEffect = "WalkingGradient" @@ -120,7 +120,7 @@ func (r *SceneRole) Validate() error { } switch r.Effect { - case SEStatic, SERandom, SEGradient, SETransition: + case SEStatic, SERandom, SEGradient, SEWalkingGradient, SETransition: default: return ErrSceneRoleUnknownEffect } @@ -201,8 +201,6 @@ func (r *SceneRole) State(fac float64) NewDeviceState { return r.States[0] } - - if r.Interpolate { if len(r.States) == 2 { return r.States[0].Interpolate(r.States[1], fac) diff --git a/scene-examples/evening.yaml b/scene-examples/evening.yaml new file mode 100644 index 0000000..a54d12f --- /dev/null +++ b/scene-examples/evening.yaml @@ -0,0 +1,63 @@ +name: Evening +interval: 1500 +roles: + - effect: WalkingGradient + power_mode: Device + target_kind: Tag + target_value: Hexagon + interpolate: true + relative: false + order: -name + states: + - color: 'hs:30,1' + intensity: 0.20 + - color: 'hs:30,1' + intensity: 0.15 + + - effect: Gradient + power_mode: Device + target_kind: Tag + target_value: SquareLeft + interpolate: true + relative: false + order: +name + states: + - color: 'hs:25,1' + intensity: 0.075 + - color: 'hs:50,1' + intensity: 0.15 + + - effect: Random + power_mode: Device + target_kind: Tag + target_value: SquareRight + interpolate: true + relative: false + order: +name + states: + - color: 'hs:220,0.7' + intensity: 0.22 + - color: 'hs:220,0.6' + intensity: 0.25 + + - effect: Static + power_mode: Device + target_kind: Tag + target_value: Accent + interpolate: true + relative: false + order: +name + states: + - color: 'hs:250,0.6' + intensity: 0.2 + + - effect: Static + power_mode: Device + target_kind: All + target_value: "" + interpolate: true + relative: false + order: +name + states: + - color: 'k:2000' + intensity: 0.25 diff --git a/scene-examples/flash.yaml b/scene-examples/flash.yaml new file mode 100644 index 0000000..94a916d --- /dev/null +++ b/scene-examples/flash.yaml @@ -0,0 +1,9 @@ +name: Flash +interval: 0 +roles: + - effect: Static + power_mode: Device + target_kind: All + target_value: "" + states: + - intensity: 1 \ No newline at end of file diff --git a/scene-examples/late.yaml b/scene-examples/late.yaml new file mode 100644 index 0000000..8441033 --- /dev/null +++ b/scene-examples/late.yaml @@ -0,0 +1,48 @@ +name: Late +interval: 5000 +roles: + - effect: Random + power_mode: Device + target_kind: Tag + target_value: Hexagon + interpolate: true + relative: false + order: +name + states: + - color: 'hs:35,1' + intensity: 0.08 + - color: 'hs:25,1' + intensity: 0.10 + + - effect: Gradient + power_mode: Device + target_kind: Tag + target_value: Square + interpolate: true + relative: false + order: +name + states: + - color: 'hs:25,1' + intensity: 0.05 + - color: 'hs:35,1' + intensity: 0.05 + + - effect: Static + power_mode: Device + target_kind: Tag + target_value: Nightstand + interpolate: true + relative: false + order: +name + states: + - color: 'hs:25,1' + intensity: 0.05 + + - effect: Static + power_mode: Scene + target_kind: All + target_value: '' + interpolate: true + relative: false + states: + - power: false diff --git a/scene-examples/morning.yaml b/scene-examples/morning.yaml new file mode 100644 index 0000000..6a1901e --- /dev/null +++ b/scene-examples/morning.yaml @@ -0,0 +1,40 @@ +name: Morning +interval: 1000 +roles: + - effect: WalkingGradient + power_mode: Device + target_kind: Tag + target_value: Hexagon + interpolate: true + relative: false + order: +name + states: + - color: 'hs:220,0.5' + intensity: 0.50 + - color: 'hs:220,0.4' + intensity: 0.65 + + - effect: Random + power_mode: Device + target_kind: Tag + target_value: Square + interpolate: true + relative: false + order: +name + states: + - color: 'hs:220,0.6' + intensity: 0.30 + - color: 'hs:220,0.5' + intensity: 0.35 + + - effect: Static + power_mode: Device + target_kind: All + target_value: '' + interpolate: true + relative: false + order: +name + states: + - color: 'hs:250,0.6' + intensity: 0.3 +