From 44716c84d121a0555b9211e30def759aac3da338 Mon Sep 17 00:00:00 2001 From: Gisle Aune Date: Sat, 25 Sep 2021 19:04:54 +0200 Subject: [PATCH 1/8] scene system so far. --- app/api/util.go | 7 ++ app/services/scene/manager.go | 36 ++++++ app/services/scene/scene.go | 87 +++++++++++++++ models/device.go | 53 ++++++++- models/errors.go | 7 ++ models/scene.go | 204 ++++++++++++++++++++++++++++++++++ models/shared.go | 55 +++++++++ 7 files changed, 447 insertions(+), 2 deletions(-) create mode 100644 app/services/scene/manager.go create mode 100644 app/services/scene/scene.go create mode 100644 models/scene.go diff --git a/app/api/util.go b/app/api/util.go index 2387488..5cdece7 100644 --- a/app/api/util.go +++ b/app/api/util.go @@ -15,6 +15,13 @@ var errorMap = map[error]int{ models.ErrBadColor: 400, models.ErrInternal: 500, models.ErrUnknownColorFormat: 400, + + models.ErrSceneInvalidInterval: 400, + models.ErrSceneNoRoles: 400, + models.ErrSceneRoleNoStates: 400, + models.ErrSceneRoleUnsupportedOrdering: 422, + models.ErrSceneRoleUnknownEffect: 422, + models.ErrSceneRoleUnknownPowerMode: 422, } type response struct { diff --git a/app/services/scene/manager.go b/app/services/scene/manager.go new file mode 100644 index 0000000..3b2ac4c --- /dev/null +++ b/app/services/scene/manager.go @@ -0,0 +1,36 @@ +package scene + +import ( + "git.aiterp.net/lucifer/new-server/models" + "sync" +) + +type Manager struct { + mu sync.Mutex + assignments map[int]*Scene + scenes []*Scene +} + +func (mgr *Manager) UpdateDevice(device *models.Device) { + +} + +func (mgr *Manager) GetUnassigned(devices []models.Device) []models.Device { + mgr.mu.Lock() + defer mgr.mu.Unlock() + + res := make([]models.Device, 0, len(devices)) + for _, device := range devices { + if _, ok := mgr.assignments[device.ID]; !ok { + res = append(res, device) + } + } + + return res +} + +var globalManager = &Manager{} + +func Get() *Manager { + return globalManager +} diff --git a/app/services/scene/scene.go b/app/services/scene/scene.go new file mode 100644 index 0000000..17bb28c --- /dev/null +++ b/app/services/scene/scene.go @@ -0,0 +1,87 @@ +package scene + +import ( + "git.aiterp.net/lucifer/new-server/models" + "time" +) + +type Scene struct { + startTime time.Time + duration time.Duration + tag string + data models.Scene + roleList [][]models.Device + deviceMap map[int]*models.Device + roleMap map[int]int + changes map[int]models.NewDeviceState +} + +func (s *Scene) UpdateScene(scene models.Scene) { + s.data = scene + s.roleMap = make(map[int]int) + + for _, device := range s.deviceMap { + s.moveDevice(*device) + } +} + +func (s *Scene) UpsertDevice(device models.Device) { + s.deviceMap[device.ID] = &device + s.moveDevice(device) +} + +func (s *Scene) RemoveDevice(device models.Device) { + delete(s.deviceMap, device.ID) + delete(s.roleMap, device.ID) +} + +func (s *Scene) runRole(index int) { + +} + +func (s *Scene) moveDevice(device models.Device, run bool) { + oldRole, hasOldRole := s.roleMap[device.ID] + newRole := s.data.RoleIndex(&device) + + if hasOldRole && oldRole == newRole { + return + } + + if hasOldRole { + for i, device2 := range s.roleList[oldRole] { + if device2.ID == device.ID { + s.roleList[oldRole] = append(s.roleList[oldRole][:i], s.roleList[oldRole][i+1:]...) + break + } + } + + s.runRole(oldRole) + } + + s.roleMap[device.ID] = newRole + s.runRole(newRole) +} + +func (s *Scene) intervalNumber() int64 { + intervalDuration := time.Duration(s.data.IntervalMS) * time.Millisecond + + return int64(time.Since(s.startTime) / intervalDuration) +} + +func (s *Scene) intervalFac() float64 { + return clamp(float64(time.Since(s.startTime)) / float64(s.duration)) +} + +func (s *Scene) positionFac(i, len int) float64 { + return clamp(float64(i) / float64(len)) +} + +func clamp(fac float64) float64 { + if fac > 1.00 { + return 1.00 + } else if fac < 0.00 { + return 0.00 + } else { + return fac + } +} \ No newline at end of file diff --git a/models/device.go b/models/device.go index de51e88..e363034 100644 --- a/models/device.go +++ b/models/device.go @@ -3,6 +3,7 @@ package models import ( "context" "strings" + "time" ) type Device struct { @@ -20,8 +21,8 @@ type Device struct { } type DeviceUpdate struct { - Icon *string `json:"icon"` - Name *string `json:"name"` + Icon *string `json:"icon"` + Name *string `json:"name"` UserProperties map[string]*string `json:"userProperties"` } @@ -37,6 +38,13 @@ type DeviceState struct { Temperature float64 `json:"temperature"` } +type DeviceScene struct { + SceneID int `json:"sceneId"` + Time time.Time `json:"time"` + DurationMS int64 `json:"duration"` + Tag string `json:"tag"` +} + type NewDeviceState struct { Power *bool `json:"power"` Color *string `json:"color"` @@ -170,3 +178,44 @@ func (d *Device) SetState(newState NewDeviceState) error { return nil } + +func (s *NewDeviceState) Interpolate(other NewDeviceState, fac float64) NewDeviceState { + n := NewDeviceState{} + + if s.Power != nil && other.Power != nil { + if fac >= 0.5 { + n.Power = other.Power + } else { + n.Power = s.Power + } + } + + if s.Color != nil && other.Color != nil { + sc, err := ParseColorValue(*s.Color) + oc, err2 := ParseColorValue(*other.Color) + if err == nil && err2 == nil { + rc := ColorValue{} + rc.Hue = interpolateFloat(sc.Hue, oc.Hue, fac) + rc.Saturation = interpolateFloat(sc.Saturation, oc.Saturation, fac) + rc.Kelvin = interpolateInt(sc.Kelvin, oc.Kelvin, fac) + + rcStr := rc.String() + n.Color = &rcStr + } + } + + if s.Intensity != nil && other.Intensity != nil { + n.Intensity = new(float64) + *n.Intensity = interpolateFloat(*s.Intensity, *other.Intensity, fac) + } + + return n +} + +func interpolateFloat(a, b, fac float64) float64 { + return (a * (1 - fac)) + (b * fac) +} + +func interpolateInt(a, b int, fac float64) int { + return int((float64(a) * (1 - fac)) + (float64(b) * fac)) +} diff --git a/models/errors.go b/models/errors.go index 8566d9a..ee40e6b 100644 --- a/models/errors.go +++ b/models/errors.go @@ -22,3 +22,10 @@ var ErrInvalidPacketSize = errors.New("invalid packet size") var ErrReadTimeout = errors.New("read timeout") var ErrUnrecognizedPacketType = errors.New("packet type not recognized") var ErrBridgeRunningRequired = errors.New("this operation cannot be performed when bridge is not running") + +var ErrSceneInvalidInterval = errors.New("scene interval must be 0 (=disabled) or greater") +var ErrSceneNoRoles = errors.New("scene cannot have zero rules") +var ErrSceneRoleNoStates = errors.New("scene rule has no states") +var ErrSceneRoleUnsupportedOrdering = errors.New("scene rule has an unsupported ordering") +var ErrSceneRoleUnknownEffect = errors.New("scene rule has an unknown effect") +var ErrSceneRoleUnknownPowerMode = errors.New("scene rule has an unknown power mode") diff --git a/models/scene.go b/models/scene.go new file mode 100644 index 0000000..c2d5a29 --- /dev/null +++ b/models/scene.go @@ -0,0 +1,204 @@ +package models + +import ( + "math" + "math/rand" + "sort" + "strings" +) + +type Scene struct { + ID int `json:"id"` + Name string `json:"name"` + IntervalMS int64 `json:"interval"` + Roles []SceneRole `json:"roles"` +} + +func (s *Scene) Validate() error { + if s.IntervalMS < 0 { + return ErrSceneInvalidInterval + } + if len(s.Roles) == 0 { + return ErrSceneNoRoles + } + + for _, role := range s.Roles { + err := role.Validate() + if err != nil { + return err + } + } + + return nil +} + +func (s *Scene) Role(device *Device) *SceneRole { + index := s.RoleIndex(device) + if index == -1 { + return nil + } + + return &s.Roles[index] +} + +func (s *Scene) RoleIndex(device *Device) int { + for i, role := range s.Roles { + if role.TargetKind.Matches(device, role.TargetValue) { + return i + } + } + + return -1 +} + +type SceneEffect string + +const ( + SEStatic SceneEffect = "Plain" + SERandom SceneEffect = "Random" + SEGradient SceneEffect = "Gradient" + SETransition SceneEffect = "Transition" +) + +type SceneRole struct { + Effect SceneEffect `json:"effect"` + PowerMode ScenePowerMode `json:"overridePower"` + TargetKind ReferenceKind `json:"targetKind"` + TargetValue string `json:"targetValue"` + Interpolate bool `json:"interpolate"` + Order string `json:"order"` + States []NewDeviceState `json:"states"` +} + +func (r *SceneRole) Validate() error { + if len(r.States) == 0 { + return ErrSceneRoleNoStates + } + + switch r.TargetKind { + case RKTag, RKBridgeID, RKDeviceID, RKName, RKAll: + default: + return ErrBadInput + } + + switch r.PowerMode { + case SPScene, SPDevice, SPOverride: + default: + return ErrSceneRoleUnknownPowerMode + } + + switch r.Effect { + case SEStatic, SERandom, SEGradient, SETransition: + default: + return ErrSceneRoleUnknownEffect + } + + switch r.Order { + case "", "-name", "name", "+name", "-id", "id", "+id": + default: + return ErrSceneRoleUnsupportedOrdering + } + + return nil +} + +func (r *SceneRole) ApplyOrder(devices []Device) { + if r.Order == "" { + return + } + + desc := strings.HasPrefix(r.Order, "-") + + orderKey := r.Order + if desc || strings.HasPrefix(r.Order, "+") { + orderKey = orderKey[1:] + } + + switch orderKey { + case "id": + sort.Slice(devices, func(i, j int) bool { + if desc { + return devices[i].ID > devices[j].ID + } + + return devices[i].ID < devices[j].ID + }) + case "name": + sort.Slice(devices, func(i, j int) bool { + if desc { + return strings.Compare(devices[i].Name, devices[j].Name) < 0 + } + + return strings.Compare(devices[i].Name, devices[j].Name) > 0 + }) + } +} + +func (r *SceneRole) ApplyEffect(device *Device, intervalFac float64, positionFac float64) (newState NewDeviceState, deviceChanged bool) { + switch r.Effect { + case SEStatic: + newState = r.States[0] + case SERandom: + newState = r.State(rand.Float64()) + case SEGradient: + newState = r.State(positionFac) + case SETransition: + newState = r.State(intervalFac) + } + + switch r.PowerMode { + case SPDevice: + newState.Power = nil + case SPScene: + // Do nothing + case SPOverride: + if newState.Power != nil && device.State.Power != *newState.Power { + device.State.Power = *newState.Power + deviceChanged = true + } + } + + return +} + +func (r *SceneRole) State(fac float64) NewDeviceState { + if len(r.States) == 0 { + return NewDeviceState{} + } else if len(r.States) == 1 { + return r.States[0] + } + + if r.Interpolate { + if len(r.States) == 2 { + return r.States[0].Interpolate(r.States[1], fac) + } else { + pos := fac * float64(len(r.States)-1) + start := math.Floor(pos) + localFac := pos - start + + return r.States[int(start)].Interpolate(r.States[int(start)+1], localFac) + } + } else { + index := int(fac * float64(len(r.States))) + if index == len(r.States) { + index = len(r.States) - 1 + } + + return r.States[index] + } +} + +type ScenePowerMode string + +const ( + SPDevice ScenePowerMode = "Device" // Device state decides power. Scene state may only power off. + SPScene ScenePowerMode = "Scene" // Scene state decide power. + SPOverride ScenePowerMode = "Override" // Same as above, but Scene state set Device state' power. This is good for "wake up" scenes. +) + +type SceneAssignment struct { + Scene *Scene + Device *Device + Tag string +} + diff --git a/models/shared.go b/models/shared.go index f92a20f..39aced2 100644 --- a/models/shared.go +++ b/models/shared.go @@ -1,5 +1,10 @@ package models +import ( + "strconv" + "strings" +) + type ReferenceKind string var ( @@ -9,3 +14,53 @@ var ( RKAll ReferenceKind = "All" RKName ReferenceKind = "Name" ) + +func (rk ReferenceKind) Matches(device *Device, value string) bool { + switch rk { + case RKName: + if strings.HasPrefix(value, "*") { + return strings.HasSuffix(device.Name, value[1:]) + } else if strings.HasSuffix(value, "*") { + return strings.HasPrefix(device.Name, value[:len(value)-1]) + } else { + return device.Name == value + } + case RKDeviceID: + idStr := strconv.Itoa(device.ID) + for _, idStr2 := range strings.Split(value, ",") { + if idStr == idStr2 { + return true + } + } + case RKBridgeID: + idStr := strconv.Itoa(device.BridgeID) + for _, idStr2 := range strings.Split(value, ",") { + if idStr == idStr2 { + return true + } + } + case RKTag: + hadAny := false + for _, tag := range strings.Split(value, ",") { + if strings.HasPrefix(tag, "-") { + if device.HasTag(tag[1:]) { + return false + } + } else if strings.HasPrefix(tag, "+") { + if !device.HasTag(tag[1:]) { + return false + } + } else { + if device.HasTag(tag) { + hadAny = true + } + } + } + + return hadAny + case RKAll: + return true + } + + return false +} -- 2.30.2 From f87c69efc71c53852cb3ed7df3c18b9ef757300c Mon Sep 17 00:00:00 2001 From: Gisle Aune Date: Sun, 26 Sep 2021 16:20:02 +0200 Subject: [PATCH 2/8] this does not work, it needs massive changes. --- app/api/devices.go | 62 ++++- app/api/scenes.go | 37 +++ app/config/db.go | 5 +- app/config/repo.go | 4 + app/server.go | 5 + app/services/events.go | 25 ++- app/services/publish.go | 5 + app/services/scene/manager.go | 212 +++++++++++++++++- app/services/scene/scene.go | 132 +++++++++-- internal/mysql/devicerepo.go | 186 ++++++++------- internal/mysql/scenerepo.go | 118 ++++++++++ models/device.go | 24 +- models/scene.go | 65 +++++- scripts/20210926135923_scene.sql | 17 ++ .../20210926152011_device_sceneassignment.sql | 9 + 15 files changed, 763 insertions(+), 143 deletions(-) create mode 100644 app/api/scenes.go create mode 100644 internal/mysql/scenerepo.go create mode 100644 scripts/20210926135923_scene.sql create mode 100644 scripts/20210926152011_device_sceneassignment.sql diff --git a/app/api/devices.go b/app/api/devices.go index 4555772..1f0f1d3 100644 --- a/app/api/devices.go +++ b/app/api/devices.go @@ -3,11 +3,14 @@ package api import ( "context" "git.aiterp.net/lucifer/new-server/app/config" + "git.aiterp.net/lucifer/new-server/app/services/scene" "git.aiterp.net/lucifer/new-server/models" "github.com/gin-gonic/gin" + "golang.org/x/sync/errgroup" "log" "strconv" "strings" + "time" ) func fetchDevices(ctx context.Context, fetchStr string) ([]models.Device, error) { @@ -71,12 +74,15 @@ func Devices(r gin.IRoutes) { return nil, err } + _ = scene.GlobalManager().UpdateDevice(ctxOf(c), &devices[i], nil) + set[devices[i].ID] = true changed = append(changed, devices[i]) } } - config.PublishChannel <- changed + + config.PublishChannel <- scene.GlobalManager().FilterUnassigned(changed) go func() { for _, device := range changed { @@ -139,9 +145,11 @@ func Devices(r gin.IRoutes) { if err != nil { return nil, err } + + _ = scene.GlobalManager().UpdateDevice(ctxOf(c), &devices[i], nil) } - config.PublishChannel <- devices + config.PublishChannel <- scene.GlobalManager().FilterUnassigned(devices) go func() { for _, device := range devices { @@ -212,4 +220,52 @@ func Devices(r gin.IRoutes) { return devices, nil })) -} + + r.PUT("/:fetch/scene", handler(func(c *gin.Context) (interface{}, error) { + var body models.DeviceSceneAssignment + err := parseBody(c, &body) + if err != nil { + return nil, err + } + + devices, err := fetchDevices(ctxOf(c), c.Param("fetch")) + if err != nil { + return nil, err + } + if len(devices) == 0 { + return []models.Device{}, nil + } + + assignedScene, err := config.SceneRepository().Find(ctxOf(c), body.SceneID) + if err != nil { + return nil, err + } + if body.DurationMS < 0 { + body.DurationMS = 0 + } + body.StartTime = time.Now() + + for i := range devices { + devices[i].SceneAssignments = []models.DeviceSceneAssignment{body} + + err := scene.GlobalManager().UpdateDevice(ctxOf(c), &devices[i], assignedScene) + if err != nil { + return nil, err + } + } + + eg := errgroup.Group{} + for i := range devices { + eg.Go(func() error { + return config.DeviceRepository().Save(ctxOf(c), &devices[i], 0) + }) + } + + err = eg.Wait() + if err != nil { + return nil, err + } + + return devices, nil + })) +} \ No newline at end of file diff --git a/app/api/scenes.go b/app/api/scenes.go new file mode 100644 index 0000000..9d67ea7 --- /dev/null +++ b/app/api/scenes.go @@ -0,0 +1,37 @@ +package api + +import ( + "git.aiterp.net/lucifer/new-server/app/config" + "git.aiterp.net/lucifer/new-server/models" + "github.com/gin-gonic/gin" +) + +func Scenes(r gin.IRoutes) { + r.GET("", handler(func(c *gin.Context) (interface{}, error) { + return config.SceneRepository().FetchAll(ctxOf(c)) + })) + + r.GET("/:id", handler(func(c *gin.Context) (interface{}, error) { + return config.SceneRepository().Find(ctxOf(c), intParam(c, "id")) + })) + + r.POST("", handler(func(c *gin.Context) (interface{}, error) { + var body models.Scene + err := parseBody(c, &body) + if err != nil { + return nil, err + } + + err = body.Validate() + if err != nil { + return nil, err + } + + err = config.SceneRepository().Save(ctxOf(c), &body) + if err != nil { + return nil, err + } + + return body, nil + })) +} \ No newline at end of file diff --git a/app/config/db.go b/app/config/db.go index 9bb75c9..041fe33 100644 --- a/app/config/db.go +++ b/app/config/db.go @@ -24,8 +24,9 @@ func DBX() *sqlx.DB { MySqlSchema(), )) - dbx.SetMaxIdleConns(50) - dbx.SetMaxOpenConns(100) + dbx.SetMaxIdleConns(20) + dbx.SetMaxOpenConns(40) + dbx.SetConnMaxLifetime(0) } return dbx diff --git a/app/config/repo.go b/app/config/repo.go index d92455b..7216df8 100644 --- a/app/config/repo.go +++ b/app/config/repo.go @@ -20,3 +20,7 @@ func DeviceRepository() models.DeviceRepository { func EventHandlerRepository() models.EventHandlerRepository { return &mysql.EventHandlerRepo{DBX: DBX()} } + +func SceneRepository() models.SceneRepository { + return &mysql.SceneRepo{DBX: DBX()} +} diff --git a/app/server.go b/app/server.go index 6aaa75e..73a7028 100644 --- a/app/server.go +++ b/app/server.go @@ -1,10 +1,12 @@ package app import ( + "context" "fmt" "git.aiterp.net/lucifer/new-server/app/api" "git.aiterp.net/lucifer/new-server/app/config" "git.aiterp.net/lucifer/new-server/app/services" + "git.aiterp.net/lucifer/new-server/app/services/scene" "github.com/gin-gonic/gin" "log" ) @@ -15,6 +17,8 @@ func StartServer() { services.ConnectToBridges() services.CheckNewDevices() + go scene.GlobalManager().Run(context.Background()) + gin.SetMode(gin.ReleaseMode) ginny := gin.New() @@ -25,6 +29,7 @@ func StartServer() { api.DriverKinds(apiGin.Group("/driver-kinds")) api.Events(apiGin.Group("/events")) api.EventHandlers(apiGin.Group("/event-handlers")) + api.Scenes(apiGin.Group("/scenes")) log.Fatal(ginny.Run(fmt.Sprintf("0.0.0.0:%d", config.ServerPort()))) } diff --git a/app/services/events.go b/app/services/events.go index c1bac4d..ae5a869 100644 --- a/app/services/events.go +++ b/app/services/events.go @@ -4,6 +4,7 @@ import ( "context" "fmt" "git.aiterp.net/lucifer/new-server/app/config" + "git.aiterp.net/lucifer/new-server/app/services/scene" "git.aiterp.net/lucifer/new-server/models" "log" "math" @@ -150,21 +151,31 @@ func handleEvent(event models.Event) (responses []models.Event) { } } - config.PublishChannel <- allDevices + config.PublishChannel <- scene.GlobalManager().FilterUnassigned(allDevices) wg := sync.WaitGroup{} - for _, device := range allDevices { - wg.Add(1) - go func(device models.Device) { - err := config.DeviceRepository().Save(context.Background(), &device, models.SMState) + if len(allDevices) > 0 { + wg.Add(1) + go func() { + err := config.DeviceRepository().SaveMany(context.Background(), models.SMState, allDevices) if err != nil { - log.Println("Failed to save device for state:", err) + log.Println("Failed to save devices' state:", err) } wg.Done() - }(device) + }() + + for _, device := range allDevices { + go func(device models.Device) { + err := scene.GlobalManager().UpdateDevice(context.Background(), &device, nil) + if err != nil { + log.Println("Failed to update devices' scene in resposne to event:", err) + } + }(device) + } } + for _, handler := range deadHandlers { wg.Add(1) diff --git a/app/services/publish.go b/app/services/publish.go index 7fd9cf9..11c6ed1 100644 --- a/app/services/publish.go +++ b/app/services/publish.go @@ -47,6 +47,11 @@ func StartPublisher() { } if bridge.ID == 0 { log.Println("Unknown bridge") + return + } + + if bridge.Driver == models.DTLIFX { + return } bridge, err := config.BridgeRepository().Find(ctx, devices[0].BridgeID) diff --git a/app/services/scene/manager.go b/app/services/scene/manager.go index 3b2ac4c..e6eeb1f 100644 --- a/app/services/scene/manager.go +++ b/app/services/scene/manager.go @@ -1,27 +1,84 @@ package scene import ( + "context" + "git.aiterp.net/lucifer/new-server/app/config" "git.aiterp.net/lucifer/new-server/models" + "log" "sync" + "time" ) type Manager struct { mu sync.Mutex - assignments map[int]*Scene + allocations map[int]*Scene + devices map[int]*models.Device scenes []*Scene + + notifyCh chan struct{} } -func (mgr *Manager) UpdateDevice(device *models.Device) { +func (mgr *Manager) UpdateDevice(ctx context.Context, device *models.Device, data *models.Scene) error { + mgr.mu.Lock() + defer mgr.mu.Unlock() + + scene, err := mgr.findScene(ctx, device.SceneAssignments, data) + if err != nil { + return err + } + + if mgr.allocations[device.ID] != nil && mgr.allocations[device.ID] != scene { + mgr.allocations[device.ID].RemoveDevice(*device) + } + + if mgr.allocations[device.ID] != scene { + if mgr.allocations == nil { + mgr.allocations = make(map[int]*Scene, 8) + } + + mgr.allocations[device.ID] = scene + if scene != nil { + scene.due = true + } + } + + if scene != nil { + scene.UpsertDevice(*device) + + if mgr.devices == nil { + mgr.devices = make(map[int]*models.Device) + } + mgr.devices[device.ID] = device + } else { + config.PublishChannel <- []models.Device{*device} + } + + if mgr.notifyCh != nil { + close(mgr.notifyCh) + mgr.notifyCh = nil + } + + return nil } -func (mgr *Manager) GetUnassigned(devices []models.Device) []models.Device { +func (mgr *Manager) UpdateScene(data *models.Scene) { + mgr.mu.Lock() + for _, scene := range mgr.scenes { + if scene.data.ID == data.ID { + scene.UpdateScene(scene.data) + } + } + mgr.mu.Unlock() +} + +func (mgr *Manager) FilterUnassigned(devices []models.Device) []models.Device { mgr.mu.Lock() defer mgr.mu.Unlock() res := make([]models.Device, 0, len(devices)) for _, device := range devices { - if _, ok := mgr.assignments[device.ID]; !ok { + if mgr.allocations[device.ID] == nil { res = append(res, device) } } @@ -29,8 +86,153 @@ func (mgr *Manager) GetUnassigned(devices []models.Device) []models.Device { return res } +func (mgr *Manager) Run(ctx context.Context) { + devices, err := config.DeviceRepository().FetchByReference(ctx, models.RKAll, "") + if err == nil { + for _, device := range devices { + _ = mgr.UpdateDevice(ctx, &device, nil) + } + } + + ticker := time.NewTicker(time.Millisecond * 101) + defer ticker.Stop() + + mgr.mu.Lock() + mgr.notifyCh = make(chan struct{}) + mgr.mu.Unlock() + + for { + mgr.mu.Lock() + if mgr.notifyCh == nil { + mgr.notifyCh = make(chan struct{}) + } + notifyCh := mgr.notifyCh + mgr.mu.Unlock() + + select { + case <-ticker.C: + case <-notifyCh: + case <-ctx.Done(): + return + } + + timeout, cancel := context.WithTimeout(ctx, time.Millisecond*100) + + var updates []models.Device + mgr.mu.Lock() + for _, device := range mgr.devices { + if device == nil { + continue + } + + if mgr.reScene(timeout, device) { + updates = append(updates, *device) + } + } + + for _, scene := range mgr.scenes { + if scene.Due() { + scene.Run() + } + + updates = append(updates, scene.Flush()...) + } + mgr.mu.Unlock() + + cancel() + + if len(updates) > 0 { + log.Println("Updating", len(updates), "devices") + config.PublishChannel <- updates + } + } +} + +func (mgr *Manager) reScene(ctx context.Context, device *models.Device) bool { + scene, err := mgr.findScene(ctx, device.SceneAssignments, nil) + if err != nil { + return false + } + + if mgr.allocations[device.ID] != nil && mgr.allocations[device.ID] != scene { + mgr.allocations[device.ID].RemoveDevice(*device) + } + if mgr.allocations[device.ID] != scene { + if mgr.allocations == nil { + mgr.allocations = make(map[int]*Scene, 8) + } + + mgr.allocations[device.ID] = scene + + if scene == nil { + return true + } + + mgr.allocations[device.ID].UpsertDevice(*device) + } + + return false +} + +func (mgr *Manager) findScene(ctx context.Context, assignments []models.DeviceSceneAssignment, data *models.Scene) (*Scene, error) { + if len(assignments) == 0 { + return nil, nil + } + + var selected *models.DeviceSceneAssignment + for _, assignment := range assignments { + pos := int64(time.Since(assignment.StartTime) / time.Millisecond) + if assignment.DurationMS == 0 || (pos >= -1 && pos < assignment.DurationMS) { + selected = &assignment + } + } + if selected == nil { + return nil, nil + } + + for _, scene := range mgr.scenes { + if scene.group == selected.Group && scene.startTime.Equal(selected.StartTime) && selected.DurationMS == scene.duration.Milliseconds() { + return scene, nil + } + } + + if data == nil { + for i := range mgr.scenes { + if mgr.scenes[i].data.ID == selected.SceneID { + data = &mgr.scenes[i].data + break + } + } + } + + if data == nil { + var err error + data, err = config.SceneRepository().Find(ctx, selected.SceneID) + if err != nil { + return nil, err + } + } + + scene := &Scene{ + startTime: selected.StartTime, + duration: time.Duration(selected.DurationMS) * time.Millisecond, + lastRun: 0, + due: true, + group: selected.Group, + data: *data, + roleList: make([][]models.Device, len(data.Roles)), + deviceMap: make(map[int]*models.Device, 8), + roleMap: make(map[int]int, 8), + changes: make(map[int]models.NewDeviceState, 8), + } + + mgr.scenes = append(mgr.scenes, scene) + + return scene, nil +} + var globalManager = &Manager{} -func Get() *Manager { +func GlobalManager() *Manager { return globalManager } diff --git a/app/services/scene/scene.go b/app/services/scene/scene.go index 17bb28c..87d3fb0 100644 --- a/app/services/scene/scene.go +++ b/app/services/scene/scene.go @@ -1,14 +1,19 @@ package scene import ( + "context" + "git.aiterp.net/lucifer/new-server/app/config" "git.aiterp.net/lucifer/new-server/models" + "math" "time" ) type Scene struct { startTime time.Time duration time.Duration - tag string + lastRun int64 + due bool + group string data models.Scene roleList [][]models.Device deviceMap map[int]*models.Device @@ -21,32 +26,110 @@ func (s *Scene) UpdateScene(scene models.Scene) { s.roleMap = make(map[int]int) for _, device := range s.deviceMap { - s.moveDevice(*device) + s.moveDevice(*device, false) } + + for i, role := range s.data.Roles { + role.ApplyOrder(s.roleList[i]) + } + + s.Run() } func (s *Scene) UpsertDevice(device models.Device) { s.deviceMap[device.ID] = &device - s.moveDevice(device) + s.moveDevice(device, true) } func (s *Scene) RemoveDevice(device models.Device) { + roleIndex, hasRole := s.roleMap[device.ID] + delete(s.deviceMap, device.ID) delete(s.roleMap, device.ID) + + if hasRole { + s.runRole(roleIndex) + } +} + +func (s *Scene) Due() bool { + return s.due || s.intervalNumber() != s.lastRun +} + +func (s *Scene) Flush() []models.Device { + res := make([]models.Device, 0, 8) + for key, change := range s.changes { + if s.deviceMap[key] != nil { + device := *s.deviceMap[key] + err := device.SetState(change) + if err != nil { + continue + } + + res = append(res, device) + } + + delete(s.changes, key) + } + + return res +} + +func (s *Scene) Run() { + s.lastRun = s.intervalNumber() + s.due = false + + for index := range s.roleList { + s.runRole(index) + } } func (s *Scene) runRole(index int) { + role := s.data.Roles[index] + devices := s.roleList[index] + + intervalNumber := s.intervalNumber() + intervalMax := s.intervalMax() + + for i, device := range devices { + newState, changed := role.ApplyEffect(&device, models.SceneRunContext{ + Index: i, + Length: len(devices), + IntervalNumber: intervalNumber, + IntervalMax: intervalMax, + }) + + if !changed { + go func() { + _ = config.DeviceRepository().Save(context.Background(), &device, models.SMState) + }() + } + s.changes[device.ID] = newState + } } func (s *Scene) moveDevice(device models.Device, run bool) { oldRole, hasOldRole := s.roleMap[device.ID] newRole := s.data.RoleIndex(&device) + // If it doesn't move between roles, just update and reorder it. if hasOldRole && oldRole == newRole { + for i, device2 := range s.roleList[newRole] { + if device2.ID == device.ID { + s.roleList[newRole][i] = device + } + } + + if run { + s.data.Roles[newRole].ApplyOrder(s.roleList[newRole]) + s.runRole(newRole) + } + return } + // Take it out of the old role. if hasOldRole { for i, device2 := range s.roleList[oldRole] { if device2.ID == device.ID { @@ -55,33 +138,38 @@ func (s *Scene) moveDevice(device models.Device, run bool) { } } - s.runRole(oldRole) + if run { + s.runRole(oldRole) + } } - s.roleMap[device.ID] = newRole - s.runRole(newRole) + if newRole != -1 { + s.roleMap[device.ID] = newRole + s.roleList[newRole] = append(s.roleList[newRole], device) + + if run { + s.data.Roles[newRole].ApplyOrder(s.roleList[newRole]) + s.runRole(newRole) + } + } + + s.due = true } func (s *Scene) intervalNumber() int64 { intervalDuration := time.Duration(s.data.IntervalMS) * time.Millisecond + if intervalDuration == 0 { + return 0 + } - return int64(time.Since(s.startTime) / intervalDuration) + return int64(math.Round(float64(time.Since(s.startTime)) / float64(intervalDuration))) } -func (s *Scene) intervalFac() float64 { - return clamp(float64(time.Since(s.startTime)) / float64(s.duration)) -} +func (s *Scene) intervalMax() int64 { + intervalDuration := time.Duration(s.data.IntervalMS) * time.Millisecond + if intervalDuration == 0 { + return 1 + } -func (s *Scene) positionFac(i, len int) float64 { - return clamp(float64(i) / float64(len)) + return int64(s.duration / intervalDuration) } - -func clamp(fac float64) float64 { - if fac > 1.00 { - return 1.00 - } else if fac < 0.00 { - return 0.00 - } else { - return fac - } -} \ No newline at end of file diff --git a/internal/mysql/devicerepo.go b/internal/mysql/devicerepo.go index 1d6e373..9932a46 100644 --- a/internal/mysql/devicerepo.go +++ b/internal/mysql/devicerepo.go @@ -12,13 +12,14 @@ import ( ) type deviceRecord struct { - ID int `db:"id"` - BridgeID int `db:"bridge_id"` - InternalID string `db:"internal_id"` - Icon string `db:"icon"` - Name string `db:"name"` - Capabilities string `db:"capabilities"` - ButtonNames string `db:"button_names"` + ID int `db:"id"` + BridgeID int `db:"bridge_id"` + InternalID string `db:"internal_id"` + Icon string `db:"icon"` + Name string `db:"name"` + Capabilities string `db:"capabilities"` + ButtonNames string `db:"button_names"` + SceneAssignmentJSON json.RawMessage `db:"scene_assignments"` } type deviceStateRecord struct { @@ -117,25 +118,32 @@ func (r *DeviceRepo) FetchByReference(ctx context.Context, kind models.Reference return r.populate(ctx, records) } -func (r *DeviceRepo) Save(ctx context.Context, device *models.Device, mode models.SaveMode) error { +func (r *DeviceRepo) SaveMany(ctx context.Context, mode models.SaveMode, devices []models.Device) error { tx, err := r.DBX.Beginx() if err != nil { return dbErr(err) } defer tx.Rollback() - record := deviceRecord{ - ID: device.ID, - BridgeID: device.BridgeID, - InternalID: device.InternalID, - Icon: device.Icon, - Name: device.Name, - Capabilities: strings.Join(models.DeviceCapabilitiesToStrings(device.Capabilities), ","), - ButtonNames: strings.Join(device.ButtonNames, ","), - } + for i, device := range devices { + scenesJSON, err := json.Marshal(device.SceneAssignments) + if err != nil { + return err + } - if device.ID > 0 { - _, err := tx.NamedExecContext(ctx, ` + record := deviceRecord{ + ID: device.ID, + BridgeID: device.BridgeID, + InternalID: device.InternalID, + SceneAssignmentJSON: scenesJSON, + Icon: device.Icon, + Name: device.Name, + Capabilities: strings.Join(models.DeviceCapabilitiesToStrings(device.Capabilities), ","), + ButtonNames: strings.Join(device.ButtonNames, ","), + } + + if device.ID > 0 { + _, err := tx.NamedExecContext(ctx, ` UPDATE device SET internal_id = :internal_id, icon = :icon, @@ -144,98 +152,111 @@ func (r *DeviceRepo) Save(ctx context.Context, device *models.Device, mode model button_names = :button_names WHERE id=:id `, record) - if err != nil { - return dbErr(err) - } - - // Let's just be lazy for now, optimize later if need be. - if mode == 0 || mode&models.SMTags != 0 { - _, err = tx.ExecContext(ctx, "DELETE FROM device_tag WHERE device_id=?", record.ID) if err != nil { return dbErr(err) } - } - if mode == 0 || mode&models.SMProperties != 0 { - _, err = tx.ExecContext(ctx, "DELETE FROM device_property WHERE device_id=?", record.ID) - if err != nil { - return dbErr(err) + // Let's just be lazy for now, optimize later if need be. + if mode == 0 || mode&models.SMTags != 0 { + _, err = tx.ExecContext(ctx, "DELETE FROM device_tag WHERE device_id=?", record.ID) + if err != nil { + return dbErr(err) + } } - } - } else { - res, err := tx.NamedExecContext(ctx, ` + + if mode == 0 || mode&models.SMProperties != 0 { + _, err = tx.ExecContext(ctx, "DELETE FROM device_property WHERE device_id=?", record.ID) + if err != nil { + return dbErr(err) + } + } + } else { + res, err := tx.NamedExecContext(ctx, ` INSERT INTO device (bridge_id, internal_id, icon, name, capabilities, button_names) VALUES (:bridge_id, :internal_id, :icon, :name, :capabilities, :button_names) `, record) - if err != nil { - return dbErr(err) - } - - lastID, err := res.LastInsertId() - if err != nil { - return dbErr(err) - } - - record.ID = int(lastID) - device.ID = int(lastID) - } - - if mode == 0 || mode&models.SMTags != 0 { - for _, tag := range device.Tags { - _, err := tx.ExecContext(ctx, "INSERT INTO device_tag (device_id, tag_name) VALUES (?, ?)", record.ID, tag) if err != nil { return dbErr(err) } - } - } - if mode == 0 || mode&models.SMProperties != 0 { - for key, value := range device.UserProperties { - _, err := tx.ExecContext(ctx, "INSERT INTO device_property (device_id, prop_key, prop_value, is_user) VALUES (?, ?, ?, 1)", - record.ID, key, value, - ) + lastID, err := res.LastInsertId() if err != nil { return dbErr(err) } + + record.ID = int(lastID) + devices[i].ID = int(lastID) } - for key, value := range device.DriverProperties { - j, err := json.Marshal(value) - if err != nil { - // Eh, it'll get filled by the driver anyway - continue + if mode == 0 || mode&models.SMTags != 0 { + for _, tag := range device.Tags { + _, err := tx.ExecContext(ctx, "INSERT INTO device_tag (device_id, tag_name) VALUES (?, ?)", record.ID, tag) + if err != nil { + return dbErr(err) + } } + } - _, err = tx.ExecContext(ctx, "INSERT INTO device_property (device_id, prop_key, prop_value, is_user) VALUES (?, ?, ?, 0)", - record.ID, key, string(j), - ) - if err != nil { - // Return err here anyway, it might put the tx in a bad state to ignore it. - return dbErr(err) + if mode == 0 || mode&models.SMProperties != 0 { + for key, value := range device.UserProperties { + _, err := tx.ExecContext(ctx, "INSERT INTO device_property (device_id, prop_key, prop_value, is_user) VALUES (?, ?, ?, 1)", + record.ID, key, value, + ) + if err != nil { + return dbErr(err) + } + } + + for key, value := range device.DriverProperties { + j, err := json.Marshal(value) + if err != nil { + // Eh, it'll get filled by the driver anyway + continue + } + + _, err = tx.ExecContext(ctx, "INSERT INTO device_property (device_id, prop_key, prop_value, is_user) VALUES (?, ?, ?, 0)", + record.ID, key, string(j), + ) + if err != nil { + // Return err here anyway, it might put the tx in a bad state to ignore it. + return dbErr(err) + } } } - } - if mode == 0 || mode&models.SMState != 0 { - _, err = tx.NamedExecContext(ctx, ` + if mode == 0 || mode&models.SMState != 0 { + _, err = tx.NamedExecContext(ctx, ` REPLACE INTO device_state(device_id, hue, saturation, kelvin, power, intensity) VALUES (:device_id, :hue, :saturation, :kelvin, :power, :intensity) - `, deviceStateRecord{ - DeviceID: record.ID, - Hue: device.State.Color.Hue, - Saturation: device.State.Color.Saturation, - Kelvin: device.State.Color.Kelvin, - Power: device.State.Power, - Intensity: device.State.Intensity, - }) - if err != nil { - return dbErr(err) + `, deviceStateRecord{ + DeviceID: record.ID, + Hue: device.State.Color.Hue, + Saturation: device.State.Color.Saturation, + Kelvin: device.State.Color.Kelvin, + Power: device.State.Power, + Intensity: device.State.Intensity, + }) + if err != nil { + return dbErr(err) + } } } return tx.Commit() } +func (r *DeviceRepo) Save(ctx context.Context, device *models.Device, mode models.SaveMode) error { + devices := []models.Device{*device} + + err := r.SaveMany(ctx, mode, devices) + if err != nil { + return err + } + + *device = devices[0] + return nil +} + func (r *DeviceRepo) Delete(ctx context.Context, device *models.Device) error { _, err := r.DBX.ExecContext(ctx, "DELETE FROM device WHERE Id=?", device.ID) if err != nil { @@ -328,12 +349,15 @@ func (r *DeviceRepo) populate(ctx context.Context, records []deviceRecord) ([]mo InternalID: record.InternalID, Icon: record.Icon, Name: record.Name, + SceneAssignments: make([]models.DeviceSceneAssignment, 0, 4), ButtonNames: strings.Split(record.ButtonNames, ","), DriverProperties: make(map[string]interface{}, 8), UserProperties: make(map[string]string, 8), Tags: make([]string, 0, 8), } + _ = json.Unmarshal(record.SceneAssignmentJSON, &device.SceneAssignments) + if device.ButtonNames[0] == "" { device.ButtonNames = device.ButtonNames[:0] } diff --git a/internal/mysql/scenerepo.go b/internal/mysql/scenerepo.go new file mode 100644 index 0000000..7c27791 --- /dev/null +++ b/internal/mysql/scenerepo.go @@ -0,0 +1,118 @@ +package mysql + +import ( + "context" + "encoding/json" + "git.aiterp.net/lucifer/new-server/models" + "github.com/jmoiron/sqlx" +) + +type sceneRecord struct { + ID int `db:"id"` + Name string `db:"name"` + IntervalMS int64 `db:"interval_ms"` + RoleJSON json.RawMessage `db:"roles"` +} + +type SceneRepo struct { + DBX *sqlx.DB +} + +func (r *SceneRepo) Find(ctx context.Context, id int) (*models.Scene, error) { + var scene sceneRecord + err := r.DBX.GetContext(ctx, &scene, "SELECT * FROM scene WHERE id = ?", id) + if err != nil { + return nil, dbErr(err) + } + + return r.populateOne(&scene) +} + +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 { + return nil, dbErr(err) + } + + return r.populate(scenes) +} + +func (r *SceneRepo) Save(ctx context.Context, scene *models.Scene) error { + j, err := json.Marshal(scene.Roles) + if err != nil { + return err + } + + if scene.ID > 0 { + _, err := r.DBX.ExecContext( + ctx, + "UPDATE scene SET name = ?, interval_ms = ?, roles = ? WHERE id = ?", + scene.Name, scene.IntervalMS, j, scene.ID, + ) + + if err != nil { + return dbErr(err) + } + } else { + rs, err := r.DBX.ExecContext( + ctx, + "INSERT INTO scene (name, interval_ms, roles) VALUES (?, ?, ?)", + scene.Name, scene.IntervalMS, j, + ) + + if err != nil { + return dbErr(err) + } + + id, err := rs.LastInsertId() + if err != nil { + return dbErr(err) + } + + scene.ID = int(id) + } + + return nil +} + +func (r *SceneRepo) Delete(ctx context.Context, scene *models.Scene) error { + _, err := r.DBX.ExecContext(ctx, "DELETE FROM scene WHERE id = ?", scene.ID) + if err != nil { + return dbErr(err) + } + + scene.ID = 0 + return nil +} + +func (r *SceneRepo) populateOne(record *sceneRecord) (*models.Scene, error) { + records, err := r.populate([]sceneRecord{*record}) + if err != nil { + return nil, err + } + + return &records[0], nil +} + +func (r *SceneRepo) populate(records []sceneRecord) ([]models.Scene, error) { + res := make([]models.Scene, 0, len(records)) + + for _, record := range records { + scene := models.Scene{ + ID: record.ID, + Name: record.Name, + IntervalMS: record.IntervalMS, + Roles: make([]models.SceneRole, 0, 8), + } + + err := json.Unmarshal(record.RoleJSON, &scene.Roles) + if err != nil { + return nil, dbErr(err) + } + + res = append(res, scene) + } + + return res, nil +} diff --git a/models/device.go b/models/device.go index ab032f6..0ee9463 100644 --- a/models/device.go +++ b/models/device.go @@ -7,17 +7,18 @@ import ( ) type Device struct { - ID int `json:"id"` - BridgeID int `json:"bridgeID"` - InternalID string `json:"internalId"` - Icon string `json:"icon"` - Name string `json:"name"` - Capabilities []DeviceCapability `json:"capabilities"` - ButtonNames []string `json:"buttonNames"` - DriverProperties map[string]interface{} `json:"driverProperties"` - UserProperties map[string]string `json:"userProperties"` - State DeviceState `json:"state"` - Tags []string `json:"tags"` + ID int `json:"id"` + BridgeID int `json:"bridgeID"` + InternalID string `json:"internalId"` + Icon string `json:"icon"` + Name string `json:"name"` + Capabilities []DeviceCapability `json:"capabilities"` + ButtonNames []string `json:"buttonNames"` + DriverProperties map[string]interface{} `json:"driverProperties"` + UserProperties map[string]string `json:"userProperties"` + SceneAssignments []DeviceSceneAssignment `json:"sceneAssignments"` + State DeviceState `json:"state"` + Tags []string `json:"tags"` } type DeviceUpdate struct { @@ -66,6 +67,7 @@ type DeviceRepository interface { Find(ctx context.Context, id int) (*Device, error) FetchByReference(ctx context.Context, kind ReferenceKind, value string) ([]Device, error) Save(ctx context.Context, device *Device, mode SaveMode) error + SaveMany(ctx context.Context, mode SaveMode, devices []Device) error Delete(ctx context.Context, device *Device) error } diff --git a/models/scene.go b/models/scene.go index c2d5a29..37edfc3 100644 --- a/models/scene.go +++ b/models/scene.go @@ -1,10 +1,12 @@ package models import ( + "context" "math" "math/rand" "sort" "strings" + "time" ) type Scene struct { @@ -54,15 +56,16 @@ func (s *Scene) RoleIndex(device *Device) int { type SceneEffect string const ( - SEStatic SceneEffect = "Plain" - SERandom SceneEffect = "Random" - SEGradient SceneEffect = "Gradient" - SETransition SceneEffect = "Transition" + SEStatic SceneEffect = "Plain" + SERandom SceneEffect = "Random" + SEGradient SceneEffect = "Gradient" + SEWalkingGradient SceneEffect = "WalkingGradient" + SETransition SceneEffect = "Transition" ) type SceneRole struct { Effect SceneEffect `json:"effect"` - PowerMode ScenePowerMode `json:"overridePower"` + PowerMode ScenePowerMode `json:"powerMode"` TargetKind ReferenceKind `json:"targetKind"` TargetValue string `json:"targetValue"` Interpolate bool `json:"interpolate"` @@ -70,6 +73,34 @@ type SceneRole struct { States []NewDeviceState `json:"states"` } +type SceneRunContext struct { + Index int + Length int + IntervalNumber int64 + IntervalMax int64 +} + +func (d *SceneRunContext) PositionFacShifted() float64 { + shiftFac := float64((int64(d.Index)+d.IntervalNumber)%int64(d.Length)) * (1 / float64(d.Length)) + + return math.Mod(d.PositionFac()+shiftFac, 1) +} + +func (d *SceneRunContext) PositionFac() float64 { + return float64(d.Index) / float64(d.Length) +} + +func (d *SceneRunContext) IntervalFac() float64 { + fac := float64(d.IntervalNumber) / float64(d.IntervalMax) + if fac > 1 { + return 1 + } else if fac < 0 { + return 0 + } else { + return fac + } +} + func (r *SceneRole) Validate() error { if len(r.States) == 0 { return ErrSceneRoleNoStates @@ -134,16 +165,18 @@ func (r *SceneRole) ApplyOrder(devices []Device) { } } -func (r *SceneRole) ApplyEffect(device *Device, intervalFac float64, positionFac float64) (newState NewDeviceState, deviceChanged bool) { +func (r *SceneRole) ApplyEffect(device *Device, c SceneRunContext) (newState NewDeviceState, deviceChanged bool) { switch r.Effect { case SEStatic: newState = r.States[0] case SERandom: newState = r.State(rand.Float64()) case SEGradient: - newState = r.State(positionFac) + newState = r.State(c.PositionFac()) + case SEWalkingGradient: + newState = r.State(c.PositionFacShifted()) case SETransition: - newState = r.State(intervalFac) + newState = r.State(c.IntervalFac()) } switch r.PowerMode { @@ -196,9 +229,17 @@ const ( SPOverride ScenePowerMode = "Override" // Same as above, but Scene state set Device state' power. This is good for "wake up" scenes. ) -type SceneAssignment struct { - Scene *Scene - Device *Device - Tag string +// DeviceSceneAssignment is an entry on the device stack. +type DeviceSceneAssignment struct { + SceneID int `json:"sceneId"` + Group string `json:"group"` + StartTime time.Time `json:"start"` + DurationMS int64 `json:"durationMs"` } +type SceneRepository interface { + Find(ctx context.Context, id int) (*Scene, error) + FetchAll(ctx context.Context) ([]Scene, error) + Save(ctx context.Context, bridge *Scene) error + Delete(ctx context.Context, bridge *Scene) error +} diff --git a/scripts/20210926135923_scene.sql b/scripts/20210926135923_scene.sql new file mode 100644 index 0000000..de23be8 --- /dev/null +++ b/scripts/20210926135923_scene.sql @@ -0,0 +1,17 @@ +-- +goose Up +-- +goose StatementBegin +CREATE TABLE scene +( + id INT NOT NULL AUTO_INCREMENT, + name VARCHAR(255) NOT NULL, + interval_ms INT NOT NULL, + roles JSON NOT NULL, + + PRIMARY KEY (id) +); +-- +goose StatementEnd + +-- +goose Down +-- +goose StatementBegin +DROP TABLE scene; +-- +goose StatementEnd diff --git a/scripts/20210926152011_device_sceneassignment.sql b/scripts/20210926152011_device_sceneassignment.sql new file mode 100644 index 0000000..433c2d0 --- /dev/null +++ b/scripts/20210926152011_device_sceneassignment.sql @@ -0,0 +1,9 @@ +-- +goose Up +-- +goose StatementBegin +ALTER TABLE device ADD COLUMN scene_assignments JSON NOT NULL DEFAULT ('[]'); +-- +goose StatementEnd + +-- +goose Down +-- +goose StatementBegin +ALTER TABLE device DROP COLUMN IF EXISTS scene_assignments; +-- +goose StatementEnd -- 2.30.2 From ffa757f742b981f55ac5f98098c488b01d6e147c Mon Sep 17 00:00:00 2001 From: Gisle Aune Date: Sun, 26 Sep 2021 22:15:49 +0200 Subject: [PATCH 3/8] rewrite of publishing mechanism to make scenes first-class. --- app/services/publisher/scene.go | 116 ++++++++++++++++ app/services/scene/manager.go | 238 -------------------------------- app/services/scene/scene.go | 175 ----------------------- models/scene.go | 13 +- 4 files changed, 120 insertions(+), 422 deletions(-) create mode 100644 app/services/publisher/scene.go delete mode 100644 app/services/scene/manager.go delete mode 100644 app/services/scene/scene.go diff --git a/app/services/publisher/scene.go b/app/services/publisher/scene.go new file mode 100644 index 0000000..0b7a8ea --- /dev/null +++ b/app/services/publisher/scene.go @@ -0,0 +1,116 @@ +package publisher + +import ( + "git.aiterp.net/lucifer/new-server/models" + "time" +) + +type Scene struct { + data *models.Scene + group string + startTime time.Time + endTime time.Time + interval time.Duration + roleMap map[int]int + roleList map[int][]models.Device + + due bool +} + +func (s *Scene) UpdateScene(data models.Scene) { + +} + +func (s *Scene) UpsertDevice(device models.Device) { + if s.data == nil { + s.roleMap[device.ID] = -1 + s.roleList[-1] = append(s.roleList[-1], device) + return + } + + oldIndex, hasOldIndex := s.roleMap[device.ID] + newIndex := s.data.RoleIndex(&device) + + if hasOldIndex { + if oldIndex == newIndex { + for i, device2 := range s.roleList[newIndex] { + if device2.ID == device.ID { + s.roleList[newIndex][i] = device + s.data.Roles[oldIndex].ApplyOrder(s.roleList[oldIndex]) + break + } + } + + return + } else { + for i, device2 := range s.roleList[oldIndex] { + if device2.ID == device.ID { + s.roleList[oldIndex] = append(s.roleList[oldIndex][:i], s.roleList[oldIndex][i+1:]...) + break + } + } + } + } + + s.roleList[newIndex] = append(s.roleList[newIndex], device) + s.data.Roles[newIndex].ApplyOrder(s.roleList[newIndex]) +} + +func (s *Scene) RemoveDevice(device models.Device) { + roleIndex, hasRoleIndex := s.roleMap[device.ID] + if !hasRoleIndex { + return + } + + for i, device2 := range s.roleList[roleIndex] { + if device2.ID == device.ID { + s.roleList[roleIndex] = append(s.roleList[roleIndex][:i], s.roleList[roleIndex][i+1:]...) + break + } + } +} + +func (s *Scene) Run() []models.Device { + if s.data == nil { + return []models.Device{} + } + + intervalNumber := int64(0) + intervalMax := int64(1) + if s.interval > 0 { + intervalNumber = int64(time.Since(s.startTime) / s.interval) + + if !s.endTime.IsZero() { + intervalMax = int64(s.endTime.Sub(s.startTime) / s.interval) + } else { + intervalMax = intervalNumber + 1 + } + } + + updatedDevices := make([]models.Device, 0, 16) + for i, list := range s.roleList { + if i == -1 { + continue + } + + role := s.data.Roles[i] + + for j, device := range list { + newState := role.ApplyEffect(&device, models.SceneRunContext{ + Index: j, + Length: len(list), + IntervalNumber: intervalNumber, + IntervalMax: intervalMax, + }) + + err := device.SetState(newState) + if err != nil { + continue + } + + updatedDevices = append(updatedDevices, device) + } + } + + return updatedDevices +} diff --git a/app/services/scene/manager.go b/app/services/scene/manager.go deleted file mode 100644 index e6eeb1f..0000000 --- a/app/services/scene/manager.go +++ /dev/null @@ -1,238 +0,0 @@ -package scene - -import ( - "context" - "git.aiterp.net/lucifer/new-server/app/config" - "git.aiterp.net/lucifer/new-server/models" - "log" - "sync" - "time" -) - -type Manager struct { - mu sync.Mutex - allocations map[int]*Scene - devices map[int]*models.Device - scenes []*Scene - - notifyCh chan struct{} -} - -func (mgr *Manager) UpdateDevice(ctx context.Context, device *models.Device, data *models.Scene) error { - mgr.mu.Lock() - defer mgr.mu.Unlock() - - scene, err := mgr.findScene(ctx, device.SceneAssignments, data) - if err != nil { - return err - } - - if mgr.allocations[device.ID] != nil && mgr.allocations[device.ID] != scene { - mgr.allocations[device.ID].RemoveDevice(*device) - } - - if mgr.allocations[device.ID] != scene { - if mgr.allocations == nil { - mgr.allocations = make(map[int]*Scene, 8) - } - - mgr.allocations[device.ID] = scene - - if scene != nil { - scene.due = true - } - } - - if scene != nil { - scene.UpsertDevice(*device) - - if mgr.devices == nil { - mgr.devices = make(map[int]*models.Device) - } - mgr.devices[device.ID] = device - } else { - config.PublishChannel <- []models.Device{*device} - } - - if mgr.notifyCh != nil { - close(mgr.notifyCh) - mgr.notifyCh = nil - } - - return nil -} - -func (mgr *Manager) UpdateScene(data *models.Scene) { - mgr.mu.Lock() - for _, scene := range mgr.scenes { - if scene.data.ID == data.ID { - scene.UpdateScene(scene.data) - } - } - mgr.mu.Unlock() -} - -func (mgr *Manager) FilterUnassigned(devices []models.Device) []models.Device { - mgr.mu.Lock() - defer mgr.mu.Unlock() - - res := make([]models.Device, 0, len(devices)) - for _, device := range devices { - if mgr.allocations[device.ID] == nil { - res = append(res, device) - } - } - - return res -} - -func (mgr *Manager) Run(ctx context.Context) { - devices, err := config.DeviceRepository().FetchByReference(ctx, models.RKAll, "") - if err == nil { - for _, device := range devices { - _ = mgr.UpdateDevice(ctx, &device, nil) - } - } - - ticker := time.NewTicker(time.Millisecond * 101) - defer ticker.Stop() - - mgr.mu.Lock() - mgr.notifyCh = make(chan struct{}) - mgr.mu.Unlock() - - for { - mgr.mu.Lock() - if mgr.notifyCh == nil { - mgr.notifyCh = make(chan struct{}) - } - notifyCh := mgr.notifyCh - mgr.mu.Unlock() - - select { - case <-ticker.C: - case <-notifyCh: - case <-ctx.Done(): - return - } - - timeout, cancel := context.WithTimeout(ctx, time.Millisecond*100) - - var updates []models.Device - mgr.mu.Lock() - for _, device := range mgr.devices { - if device == nil { - continue - } - - if mgr.reScene(timeout, device) { - updates = append(updates, *device) - } - } - - for _, scene := range mgr.scenes { - if scene.Due() { - scene.Run() - } - - updates = append(updates, scene.Flush()...) - } - mgr.mu.Unlock() - - cancel() - - if len(updates) > 0 { - log.Println("Updating", len(updates), "devices") - config.PublishChannel <- updates - } - } -} - -func (mgr *Manager) reScene(ctx context.Context, device *models.Device) bool { - scene, err := mgr.findScene(ctx, device.SceneAssignments, nil) - if err != nil { - return false - } - - if mgr.allocations[device.ID] != nil && mgr.allocations[device.ID] != scene { - mgr.allocations[device.ID].RemoveDevice(*device) - } - if mgr.allocations[device.ID] != scene { - if mgr.allocations == nil { - mgr.allocations = make(map[int]*Scene, 8) - } - - mgr.allocations[device.ID] = scene - - if scene == nil { - return true - } - - mgr.allocations[device.ID].UpsertDevice(*device) - } - - return false -} - -func (mgr *Manager) findScene(ctx context.Context, assignments []models.DeviceSceneAssignment, data *models.Scene) (*Scene, error) { - if len(assignments) == 0 { - return nil, nil - } - - var selected *models.DeviceSceneAssignment - for _, assignment := range assignments { - pos := int64(time.Since(assignment.StartTime) / time.Millisecond) - if assignment.DurationMS == 0 || (pos >= -1 && pos < assignment.DurationMS) { - selected = &assignment - } - } - if selected == nil { - return nil, nil - } - - for _, scene := range mgr.scenes { - if scene.group == selected.Group && scene.startTime.Equal(selected.StartTime) && selected.DurationMS == scene.duration.Milliseconds() { - return scene, nil - } - } - - if data == nil { - for i := range mgr.scenes { - if mgr.scenes[i].data.ID == selected.SceneID { - data = &mgr.scenes[i].data - break - } - } - } - - if data == nil { - var err error - data, err = config.SceneRepository().Find(ctx, selected.SceneID) - if err != nil { - return nil, err - } - } - - scene := &Scene{ - startTime: selected.StartTime, - duration: time.Duration(selected.DurationMS) * time.Millisecond, - lastRun: 0, - due: true, - group: selected.Group, - data: *data, - roleList: make([][]models.Device, len(data.Roles)), - deviceMap: make(map[int]*models.Device, 8), - roleMap: make(map[int]int, 8), - changes: make(map[int]models.NewDeviceState, 8), - } - - mgr.scenes = append(mgr.scenes, scene) - - return scene, nil -} - -var globalManager = &Manager{} - -func GlobalManager() *Manager { - return globalManager -} diff --git a/app/services/scene/scene.go b/app/services/scene/scene.go deleted file mode 100644 index 87d3fb0..0000000 --- a/app/services/scene/scene.go +++ /dev/null @@ -1,175 +0,0 @@ -package scene - -import ( - "context" - "git.aiterp.net/lucifer/new-server/app/config" - "git.aiterp.net/lucifer/new-server/models" - "math" - "time" -) - -type Scene struct { - startTime time.Time - duration time.Duration - lastRun int64 - due bool - group string - data models.Scene - roleList [][]models.Device - deviceMap map[int]*models.Device - roleMap map[int]int - changes map[int]models.NewDeviceState -} - -func (s *Scene) UpdateScene(scene models.Scene) { - s.data = scene - s.roleMap = make(map[int]int) - - for _, device := range s.deviceMap { - s.moveDevice(*device, false) - } - - for i, role := range s.data.Roles { - role.ApplyOrder(s.roleList[i]) - } - - s.Run() -} - -func (s *Scene) UpsertDevice(device models.Device) { - s.deviceMap[device.ID] = &device - s.moveDevice(device, true) -} - -func (s *Scene) RemoveDevice(device models.Device) { - roleIndex, hasRole := s.roleMap[device.ID] - - delete(s.deviceMap, device.ID) - delete(s.roleMap, device.ID) - - if hasRole { - s.runRole(roleIndex) - } -} - -func (s *Scene) Due() bool { - return s.due || s.intervalNumber() != s.lastRun -} - -func (s *Scene) Flush() []models.Device { - res := make([]models.Device, 0, 8) - for key, change := range s.changes { - if s.deviceMap[key] != nil { - device := *s.deviceMap[key] - err := device.SetState(change) - if err != nil { - continue - } - - res = append(res, device) - } - - delete(s.changes, key) - } - - return res -} - -func (s *Scene) Run() { - s.lastRun = s.intervalNumber() - s.due = false - - for index := range s.roleList { - s.runRole(index) - } -} - -func (s *Scene) runRole(index int) { - role := s.data.Roles[index] - devices := s.roleList[index] - - intervalNumber := s.intervalNumber() - intervalMax := s.intervalMax() - - for i, device := range devices { - newState, changed := role.ApplyEffect(&device, models.SceneRunContext{ - Index: i, - Length: len(devices), - IntervalNumber: intervalNumber, - IntervalMax: intervalMax, - }) - - if !changed { - go func() { - _ = config.DeviceRepository().Save(context.Background(), &device, models.SMState) - }() - } - - s.changes[device.ID] = newState - } -} - -func (s *Scene) moveDevice(device models.Device, run bool) { - oldRole, hasOldRole := s.roleMap[device.ID] - newRole := s.data.RoleIndex(&device) - - // If it doesn't move between roles, just update and reorder it. - if hasOldRole && oldRole == newRole { - for i, device2 := range s.roleList[newRole] { - if device2.ID == device.ID { - s.roleList[newRole][i] = device - } - } - - if run { - s.data.Roles[newRole].ApplyOrder(s.roleList[newRole]) - s.runRole(newRole) - } - - return - } - - // Take it out of the old role. - if hasOldRole { - for i, device2 := range s.roleList[oldRole] { - if device2.ID == device.ID { - s.roleList[oldRole] = append(s.roleList[oldRole][:i], s.roleList[oldRole][i+1:]...) - break - } - } - - if run { - s.runRole(oldRole) - } - } - - if newRole != -1 { - s.roleMap[device.ID] = newRole - s.roleList[newRole] = append(s.roleList[newRole], device) - - if run { - s.data.Roles[newRole].ApplyOrder(s.roleList[newRole]) - s.runRole(newRole) - } - } - - s.due = true -} - -func (s *Scene) intervalNumber() int64 { - intervalDuration := time.Duration(s.data.IntervalMS) * time.Millisecond - if intervalDuration == 0 { - return 0 - } - - return int64(math.Round(float64(time.Since(s.startTime)) / float64(intervalDuration))) -} - -func (s *Scene) intervalMax() int64 { - intervalDuration := time.Duration(s.data.IntervalMS) * time.Millisecond - if intervalDuration == 0 { - return 1 - } - - return int64(s.duration / intervalDuration) -} diff --git a/models/scene.go b/models/scene.go index 37edfc3..a05e3a6 100644 --- a/models/scene.go +++ b/models/scene.go @@ -113,7 +113,7 @@ func (r *SceneRole) Validate() error { } switch r.PowerMode { - case SPScene, SPDevice, SPOverride: + case SPScene, SPDevice: default: return ErrSceneRoleUnknownPowerMode } @@ -165,7 +165,7 @@ func (r *SceneRole) ApplyOrder(devices []Device) { } } -func (r *SceneRole) ApplyEffect(device *Device, c SceneRunContext) (newState NewDeviceState, deviceChanged bool) { +func (r *SceneRole) ApplyEffect(device *Device, c SceneRunContext) (newState NewDeviceState) { switch r.Effect { case SEStatic: newState = r.States[0] @@ -184,11 +184,6 @@ func (r *SceneRole) ApplyEffect(device *Device, c SceneRunContext) (newState New newState.Power = nil case SPScene: // Do nothing - case SPOverride: - if newState.Power != nil && device.State.Power != *newState.Power { - device.State.Power = *newState.Power - deviceChanged = true - } } return @@ -226,10 +221,10 @@ type ScenePowerMode string const ( SPDevice ScenePowerMode = "Device" // Device state decides power. Scene state may only power off. SPScene ScenePowerMode = "Scene" // Scene state decide power. - SPOverride ScenePowerMode = "Override" // Same as above, but Scene state set Device state' power. This is good for "wake up" scenes. ) -// DeviceSceneAssignment is an entry on the device stack. +// DeviceSceneAssignment is an entry on the device stack. StartTime and DurationMS are only respected when the scene +// instance is created for the group. type DeviceSceneAssignment struct { SceneID int `json:"sceneId"` Group string `json:"group"` -- 2.30.2 From a3695a3ddd1e3a417d888a69a8c7a472bf7b5b8b Mon Sep 17 00:00:00 2001 From: Gisle Aune Date: Tue, 28 Sep 2021 21:57:28 +0200 Subject: [PATCH 4/8] some more work. --- app/services/publisher/scene.go | 19 +++++++++++++++++++ internal/drivers/lifx/bridge.go | 4 +++- internal/drivers/lifx/state.go | 1 + 3 files changed, 23 insertions(+), 1 deletion(-) diff --git a/app/services/publisher/scene.go b/app/services/publisher/scene.go index 0b7a8ea..98082ce 100644 --- a/app/services/publisher/scene.go +++ b/app/services/publisher/scene.go @@ -17,10 +17,25 @@ type Scene struct { due bool } +// UpdateScene updates the scene data and re-seats all devices. func (s *Scene) UpdateScene(data models.Scene) { + devices := make([]models.Device, 0, 16) + for ri, list := range s.roleList { + for _, device := range list { + devices = append(devices, device) + s.roleMap[device.ID] = -1 + } + } + + s.roleList = map[int][]models.Device {-1: devices} + + for _, device := range append(devices[:0:0], devices...) { + s.UpsertDevice(device) + } } +// UpsertDevice moves the device if neccesary and updates its state. func (s *Scene) UpsertDevice(device models.Device) { if s.data == nil { s.roleMap[device.ID] = -1 @@ -56,6 +71,7 @@ func (s *Scene) UpsertDevice(device models.Device) { s.data.Roles[newIndex].ApplyOrder(s.roleList[newIndex]) } +// RemoveDevice finds and remove a device. It's a noop if the device does not exist in this scene. func (s *Scene) RemoveDevice(device models.Device) { roleIndex, hasRoleIndex := s.roleMap[device.ID] if !hasRoleIndex { @@ -68,8 +84,11 @@ func (s *Scene) RemoveDevice(device models.Device) { break } } + + delete(s.roleMap, device.ID) } +// Run runs the scene func (s *Scene) Run() []models.Device { if s.data == nil { return []models.Device{} diff --git a/internal/drivers/lifx/bridge.go b/internal/drivers/lifx/bridge.go index cee191e..410ecd1 100644 --- a/internal/drivers/lifx/bridge.go +++ b/internal/drivers/lifx/bridge.go @@ -204,7 +204,9 @@ func (b *Bridge) Run(ctx context.Context, debug bool) error { b.checkAndUpdateState(state) } - if time.Since(state.discoveredTime) > time.Second*10 { + if time.Since(state.discoveredTime) > time.Second*10 && time.Since(state.fwSpamTime) > time.Second * 30 { + state.fwSpamTime = time.Now() + if state.firmware == nil { _, _ = client.Send(state.target, &GetHostFirmware{}) } diff --git a/internal/drivers/lifx/state.go b/internal/drivers/lifx/state.go index 864c8c9..bb0fac1 100644 --- a/internal/drivers/lifx/state.go +++ b/internal/drivers/lifx/state.go @@ -18,6 +18,7 @@ type State struct { lightStateTime time.Time requestTime time.Time updateTime time.Time + fwSpamTime time.Time acksPending []uint8 } -- 2.30.2 From 7b0028ee2b2c44e843fd2d56569d3ebe8ebecc98 Mon Sep 17 00:00:00 2001 From: Gisle Aune Date: Mon, 4 Oct 2021 20:13:57 +0200 Subject: [PATCH 5/8] new scene system is kind of working. I haven't tested changing between scenes and Transition effect yet, though. --- app/api/devices.go | 21 +-- app/server.go | 15 +- app/services/events.go | 12 +- app/services/publish.go | 81 -------- app/services/publisher/publisher.go | 275 ++++++++++++++++++++++++++++ app/services/publisher/scene.go | 80 +++++--- internal/mysql/devicerepo.go | 3 +- models/device.go | 20 ++ models/scene.go | 13 +- 9 files changed, 380 insertions(+), 140 deletions(-) delete mode 100644 app/services/publish.go create mode 100644 app/services/publisher/publisher.go diff --git a/app/api/devices.go b/app/api/devices.go index aa7a78f..0438514 100644 --- a/app/api/devices.go +++ b/app/api/devices.go @@ -3,7 +3,6 @@ package api import ( "context" "git.aiterp.net/lucifer/new-server/app/config" - "git.aiterp.net/lucifer/new-server/app/services/scene" "git.aiterp.net/lucifer/new-server/models" "github.com/gin-gonic/gin" "golang.org/x/sync/errgroup" @@ -56,14 +55,12 @@ func Devices(r gin.IRoutes) { return nil, err } - _ = scene.GlobalManager().UpdateDevice(ctxOf(c), &devices[i], nil) - set[devices[i].ID] = true changed = append(changed, devices[i]) } } - config.PublishChannel <- scene.GlobalManager().FilterUnassigned(changed) + config.PublishChannel <- changed go func() { for _, device := range changed { @@ -126,11 +123,9 @@ func Devices(r gin.IRoutes) { if err != nil { return nil, err } - - _ = scene.GlobalManager().UpdateDevice(ctxOf(c), &devices[i], nil) } - config.PublishChannel <- scene.GlobalManager().FilterUnassigned(devices) + config.PublishChannel <- devices go func() { for _, device := range devices { @@ -217,7 +212,7 @@ func Devices(r gin.IRoutes) { return []models.Device{}, nil } - assignedScene, err := config.SceneRepository().Find(ctxOf(c), body.SceneID) + _, err = config.SceneRepository().Find(ctxOf(c), body.SceneID) if err != nil { return nil, err } @@ -228,17 +223,15 @@ func Devices(r gin.IRoutes) { for i := range devices { devices[i].SceneAssignments = []models.DeviceSceneAssignment{body} - - err := scene.GlobalManager().UpdateDevice(ctxOf(c), &devices[i], assignedScene) - if err != nil { - return nil, err - } } + config.PublishChannel <- devices eg := errgroup.Group{} for i := range devices { + device := devices[i] + eg.Go(func() error { - return config.DeviceRepository().Save(ctxOf(c), &devices[i], 0) + return config.DeviceRepository().Save(ctxOf(c), &device, 0) }) } diff --git a/app/server.go b/app/server.go index 73a7028..ab32941 100644 --- a/app/server.go +++ b/app/server.go @@ -6,19 +6,26 @@ import ( "git.aiterp.net/lucifer/new-server/app/api" "git.aiterp.net/lucifer/new-server/app/config" "git.aiterp.net/lucifer/new-server/app/services" - "git.aiterp.net/lucifer/new-server/app/services/scene" + "git.aiterp.net/lucifer/new-server/app/services/publisher" "github.com/gin-gonic/gin" "log" + "time" ) func StartServer() { + setupCtx, cancel := context.WithTimeout(context.Background(), time.Second * 10) + defer cancel() + + err := publisher.Initialize(setupCtx) + if err != nil { + log.Fatalln("Publish init failed:", err) + return + } + services.StartEventHandler() - services.StartPublisher() services.ConnectToBridges() services.CheckNewDevices() - go scene.GlobalManager().Run(context.Background()) - gin.SetMode(gin.ReleaseMode) ginny := gin.New() diff --git a/app/services/events.go b/app/services/events.go index a1d258c..3079599 100644 --- a/app/services/events.go +++ b/app/services/events.go @@ -4,7 +4,6 @@ import ( "context" "fmt" "git.aiterp.net/lucifer/new-server/app/config" - "git.aiterp.net/lucifer/new-server/app/services/scene" "git.aiterp.net/lucifer/new-server/models" "log" "math" @@ -158,7 +157,7 @@ func handleEvent(event models.Event) (responses []models.Event) { } } - config.PublishChannel <- scene.GlobalManager().FilterUnassigned(allDevices) + config.PublishChannel <- allDevices wg := sync.WaitGroup{} @@ -172,15 +171,6 @@ func handleEvent(event models.Event) (responses []models.Event) { wg.Done() }() - - for _, device := range allDevices { - go func(device models.Device) { - err := scene.GlobalManager().UpdateDevice(context.Background(), &device, nil) - if err != nil { - log.Println("Failed to update devices' scene in resposne to event:", err) - } - }(device) - } } for _, handler := range deadHandlers { diff --git a/app/services/publish.go b/app/services/publish.go deleted file mode 100644 index 11c6ed1..0000000 --- a/app/services/publish.go +++ /dev/null @@ -1,81 +0,0 @@ -package services - -import ( - "context" - "git.aiterp.net/lucifer/new-server/app/config" - "git.aiterp.net/lucifer/new-server/models" - "log" - "sync" - "time" -) - -func StartPublisher() { - ctx := context.Background() - - go func() { - for devices := range config.PublishChannel { - if len(devices) == 0 { - continue - } - - lists := make(map[int][]models.Device, 4) - for _, device := range devices { - lists[device.BridgeID] = append(lists[device.BridgeID], device) - } - - ctx, cancel := context.WithTimeout(ctx, time.Second * 30) - - bridges, err := config.BridgeRepository().FetchAll(ctx) - if err != nil { - log.Println("Publishing error (1): " + err.Error()) - cancel() - continue - } - - wg := sync.WaitGroup{} - for _, devices := range lists { - wg.Add(1) - - go func(devices []models.Device) { - defer wg.Done() - - var bridge models.Bridge - for _, bridge2 := range bridges { - if bridge2.ID == devices[0].BridgeID { - bridge = bridge2 - } - } - if bridge.ID == 0 { - log.Println("Unknown bridge") - return - } - - if bridge.Driver == models.DTLIFX { - return - } - - bridge, err := config.BridgeRepository().Find(ctx, devices[0].BridgeID) - if err != nil { - log.Println("Publishing error (1): " + err.Error()) - return - } - - driver, err := config.DriverProvider().Provide(bridge.Driver) - if err != nil { - log.Println("Publishing error (2): " + err.Error()) - return - } - - err = driver.Publish(ctx, bridge, devices) - if err != nil { - log.Println("Publishing error (3): " + err.Error()) - return - } - }(devices) - } - - wg.Wait() - cancel() - } - }() -} diff --git a/app/services/publisher/publisher.go b/app/services/publisher/publisher.go new file mode 100644 index 0000000..74eedff --- /dev/null +++ b/app/services/publisher/publisher.go @@ -0,0 +1,275 @@ +package publisher + +import ( + "context" + "git.aiterp.net/lucifer/new-server/app/config" + "git.aiterp.net/lucifer/new-server/models" + "log" + "sync" + "time" +) + +type Publisher struct { + mu sync.Mutex + sceneData map[int]*models.Scene + scenes []*Scene + sceneAssignment map[int]*Scene + started map[int]bool + pending map[int][]models.Device + waiting map[int]chan struct{} +} + +func (p *Publisher) ReloadScenes(ctx context.Context) error { + scenes, err := config.SceneRepository().FetchAll(ctx) + if err != nil { + return err + } + + p.mu.Lock() + for _, scene := range scenes { + p.sceneData[scene.ID] = &scene + } + p.mu.Unlock() + + return nil +} + +func (p *Publisher) ReloadDevices(ctx context.Context) error { + devices, err := config.DeviceRepository().FetchByReference(ctx, models.RKAll, "") + if err != nil { + return err + } + + p.Publish(devices...) + + return nil +} + +func (p *Publisher) Publish(devices ...models.Device) { + if len(devices) == 0 { + return + } + + p.mu.Lock() + defer p.mu.Unlock() + + for _, device := range devices { + if !p.started[device.BridgeID] { + p.started[device.BridgeID] = true + go p.runBridge(device.BridgeID) + } + + p.reassignDevice(device) + + if p.sceneAssignment[device.ID] != nil { + p.sceneAssignment[device.ID].UpsertDevice(device) + } else { + p.pending[device.BridgeID] = append(p.pending[device.BridgeID], device) + if p.waiting[device.BridgeID] != nil { + close(p.waiting[device.BridgeID]) + p.waiting[device.BridgeID] = nil + } + } + } +} + +func (p *Publisher) PublishChannel(ch <-chan []models.Device) { + for list := range ch { + p.Publish(list...) + } +} + +func (p *Publisher) Run() { + ticker := time.NewTicker(time.Millisecond * 100) + deleteList := make([]int, 0, 8) + updatedList := make([]models.Device, 0, 16) + + for range ticker.C { + deleteList = deleteList[:0] + updatedList = updatedList[:0] + + p.mu.Lock() + for i, scene := range p.scenes { + if !scene.endTime.IsZero() && time.Now().After(scene.endTime) { + deleteList = append(deleteList, i-len(deleteList)) + updatedList = append(updatedList, scene.AllDevices()...) + + for _, device := range scene.AllDevices() { + p.sceneAssignment[device.ID] = nil + } + continue + } + + if scene.Due() { + updatedList = append(updatedList, scene.Run()...) + updatedList = append(updatedList, scene.UnaffectedDevices()...) + } + } + + for _, i := range deleteList { + p.scenes = append(p.scenes[:i], p.scenes[i+1:]...) + } + + for _, device := range updatedList { + if !p.started[device.BridgeID] { + p.started[device.BridgeID] = true + go p.runBridge(device.BridgeID) + } + + p.pending[device.BridgeID] = append(p.pending[device.BridgeID], device) + if p.waiting[device.BridgeID] != nil { + close(p.waiting[device.BridgeID]) + p.waiting[device.BridgeID] = nil + } + } + p.mu.Unlock() + } +} + +// reassignDevice re-evaluates the device's scene assignment config. It will return whether the scene changed, which +// should trigger an update. +func (p *Publisher) reassignDevice(device models.Device) bool { + var selectedAssignment *models.DeviceSceneAssignment + for _, assignment := range device.SceneAssignments { + duration := time.Duration(assignment.DurationMS) * time.Millisecond + if duration <= 0 || time.Now().Before(assignment.StartTime.Add(duration)) { + selectedAssignment = &assignment + } + } + + if selectedAssignment == nil { + if p.sceneAssignment[device.ID] != nil { + p.sceneAssignment[device.ID].RemoveDevice(device) + delete(p.sceneAssignment, device.ID) + + // Scene changed + return true + } + + // Stop here, no scene should be assigned. + return false + } + + if p.sceneAssignment[device.ID] != nil { + scene := p.sceneAssignment[device.ID] + if scene.data.ID == selectedAssignment.SceneID && scene.group == selectedAssignment.Group { + // Current assignment is good. + return false + } + + p.sceneAssignment[device.ID].RemoveDevice(device) + delete(p.sceneAssignment, device.ID) + } + + for _, scene := range p.scenes { + if scene.data.ID == selectedAssignment.SceneID && scene.group == selectedAssignment.Group { + p.sceneAssignment[device.ID] = scene + return true + } + } + + newScene := &Scene{ + data: p.sceneData[selectedAssignment.SceneID], + group: selectedAssignment.Group, + startTime: selectedAssignment.StartTime, + endTime: selectedAssignment.StartTime.Add(time.Duration(selectedAssignment.DurationMS) * time.Millisecond), + roleMap: make(map[int]int, 16), + roleList: make(map[int][]models.Device, 16), + due: true, + } + p.sceneAssignment[device.ID] = newScene + p.scenes = append(p.scenes, newScene) + + if selectedAssignment.DurationMS <= 0 { + newScene.endTime = time.Time{} + } + + return true +} + +func (p *Publisher) runBridge(id int) { + defer func() { + p.mu.Lock() + p.started[id] = false + p.mu.Unlock() + }() + + bridge, err := config.BridgeRepository().Find(context.Background(), id) + if err != nil { + log.Println("Failed to get bridge data:", err) + return + } + + driver, err := config.DriverProvider().Provide(bridge.Driver) + if err != nil { + log.Println("Failed to get bridge driver:", err) + log.Println("Maybe Lucifer needs to be updated.") + return + } + + devices := make(map[int]models.Device) + + for { + p.mu.Lock() + if len(p.pending[id]) == 0 { + if p.waiting[id] == nil { + p.waiting[id] = make(chan struct{}) + } + waitCh := p.waiting[id] + p.mu.Unlock() + <-waitCh + p.mu.Lock() + } + + updates := p.pending[id] + p.pending[id] = p.pending[id][:0:0] + p.mu.Unlock() + + // Only allow the latest update per device (this avoids slow bridges causing a backlog of cations). + for key := range devices { + delete(devices, key) + } + for _, update := range updates { + devices[update.ID] = update + } + updates = updates[:0] + for _, value := range devices { + updates = append(updates, value) + } + + err := driver.Publish(context.Background(), bridge, updates) + if err != nil { + log.Println("Failed to publish to driver:", err) + + p.mu.Lock() + p.pending[id] = append(updates, p.pending[id]...) + p.mu.Unlock() + return + } + } +} + +var publisher = Publisher{ + sceneData: make(map[int]*models.Scene), + scenes: make([]*Scene, 0, 16), + sceneAssignment: make(map[int]*Scene, 16), + started: make(map[int]bool), + pending: make(map[int][]models.Device), + waiting: make(map[int]chan struct{}), +} + +func Initialize(ctx context.Context) error { + err :=publisher.ReloadScenes(ctx) + if err != nil { + return err + } + err = publisher.ReloadDevices(ctx) + if err != nil { + return err + } + + go publisher.PublishChannel(config.PublishChannel) + go publisher.Run() + + return nil +} diff --git a/app/services/publisher/scene.go b/app/services/publisher/scene.go index 98082ce..2d12ba3 100644 --- a/app/services/publisher/scene.go +++ b/app/services/publisher/scene.go @@ -10,32 +10,34 @@ type Scene struct { group string startTime time.Time endTime time.Time - interval time.Duration roleMap map[int]int roleList map[int][]models.Device - due bool + due bool + lastInterval int64 } // UpdateScene updates the scene data and re-seats all devices. func (s *Scene) UpdateScene(data models.Scene) { devices := make([]models.Device, 0, 16) - for ri, list := range s.roleList { + // Collect all devices into the undefined role (-1) + for _, list := range s.roleList { for _, device := range list { devices = append(devices, device) s.roleMap[device.ID] = -1 } } + s.roleList = map[int][]models.Device{-1: devices} - s.roleList = map[int][]models.Device {-1: devices} - + // Update data and reset devices. + s.data = &data for _, device := range append(devices[:0:0], devices...) { s.UpsertDevice(device) } } -// UpsertDevice moves the device if neccesary and updates its state. +// UpsertDevice moves the device if necessary and updates its state. func (s *Scene) UpsertDevice(device models.Device) { if s.data == nil { s.roleMap[device.ID] = -1 @@ -46,29 +48,23 @@ func (s *Scene) UpsertDevice(device models.Device) { oldIndex, hasOldIndex := s.roleMap[device.ID] newIndex := s.data.RoleIndex(&device) - if hasOldIndex { - if oldIndex == newIndex { - for i, device2 := range s.roleList[newIndex] { - if device2.ID == device.ID { - s.roleList[newIndex][i] = device - s.data.Roles[oldIndex].ApplyOrder(s.roleList[oldIndex]) - break - } - } + s.roleMap[device.ID] = newIndex - return - } else { - for i, device2 := range s.roleList[oldIndex] { - if device2.ID == device.ID { - s.roleList[oldIndex] = append(s.roleList[oldIndex][:i], s.roleList[oldIndex][i+1:]...) - break - } + if hasOldIndex { + for i, device2 := range s.roleList[oldIndex] { + if device2.ID == device.ID { + s.roleList[oldIndex] = append(s.roleList[oldIndex][:i], s.roleList[oldIndex][i+1:]...) + break } } } + s.due = true + s.roleList[newIndex] = append(s.roleList[newIndex], device) - s.data.Roles[newIndex].ApplyOrder(s.roleList[newIndex]) + if newIndex != -1 { + s.data.Roles[newIndex].ApplyOrder(s.roleList[newIndex]) + } } // RemoveDevice finds and remove a device. It's a noop if the device does not exist in this scene. @@ -85,9 +81,37 @@ func (s *Scene) RemoveDevice(device models.Device) { } } + s.due = true + delete(s.roleMap, device.ID) } +func (s *Scene) Due() bool { + if s.due { + return true + } + + if s.data.IntervalMS > 0 { + interval := time.Duration(s.data.IntervalMS) * time.Millisecond + return int64(time.Since(s.startTime)/interval) != s.lastInterval + } + + return false +} + +func (s *Scene) UnaffectedDevices() []models.Device { + return append(s.roleList[-1][:0:0], s.roleList[-1]...) +} + +func (s *Scene) AllDevices() []models.Device { + res := make([]models.Device, 0, 16) + for _, list := range s.roleList { + res = append(res, list...) + } + + return res +} + // Run runs the scene func (s *Scene) Run() []models.Device { if s.data == nil { @@ -96,11 +120,12 @@ func (s *Scene) Run() []models.Device { intervalNumber := int64(0) intervalMax := int64(1) - if s.interval > 0 { - intervalNumber = int64(time.Since(s.startTime) / s.interval) + if s.data.IntervalMS > 0 { + interval := time.Duration(s.data.IntervalMS) * time.Millisecond + intervalNumber = int64(time.Since(s.startTime) / interval) if !s.endTime.IsZero() { - intervalMax = int64(s.endTime.Sub(s.startTime) / s.interval) + intervalMax = int64(s.endTime.Sub(s.startTime) / interval) } else { intervalMax = intervalNumber + 1 } @@ -131,5 +156,8 @@ func (s *Scene) Run() []models.Device { } } + s.due = false + s.lastInterval = intervalNumber + return updatedDevices } diff --git a/internal/mysql/devicerepo.go b/internal/mysql/devicerepo.go index 9932a46..2b17574 100644 --- a/internal/mysql/devicerepo.go +++ b/internal/mysql/devicerepo.go @@ -149,7 +149,8 @@ func (r *DeviceRepo) SaveMany(ctx context.Context, mode models.SaveMode, devices icon = :icon, name = :name, capabilities = :capabilities, - button_names = :button_names + button_names = :button_names, + scene_assignments = :scene_assignments WHERE id=:id `, record) if err != nil { diff --git a/models/device.go b/models/device.go index 0ee9463..bf1abec 100644 --- a/models/device.go +++ b/models/device.go @@ -2,6 +2,7 @@ package models import ( "context" + "math" "strings" "time" ) @@ -189,6 +190,25 @@ func (d *Device) SetState(newState NewDeviceState) error { return nil } +func (s *NewDeviceState) RelativeTo(device Device) NewDeviceState { + n := NewDeviceState{} + + if s.Intensity != nil { + intensity := device.State.Intensity * *s.Intensity + n.Intensity = &intensity + } + if s.Color != nil { + c, err := ParseColorValue(*s.Color) + if err == nil { + c.Hue = math.Mod(device.State.Color.Hue + c.Hue, 360) + c.Saturation *= device.State.Color.Saturation + c.Kelvin += device.State.Color.Kelvin + } + } + + return n +} + func (s *NewDeviceState) Interpolate(other NewDeviceState, fac float64) NewDeviceState { n := NewDeviceState{} diff --git a/models/scene.go b/models/scene.go index a05e3a6..0737836 100644 --- a/models/scene.go +++ b/models/scene.go @@ -69,6 +69,7 @@ type SceneRole struct { TargetKind ReferenceKind `json:"targetKind"` TargetValue string `json:"targetValue"` Interpolate bool `json:"interpolate"` + Relative bool `json:"relative"` Order string `json:"order"` States []NewDeviceState `json:"states"` } @@ -81,7 +82,7 @@ type SceneRunContext struct { } func (d *SceneRunContext) PositionFacShifted() float64 { - shiftFac := float64((int64(d.Index)+d.IntervalNumber)%int64(d.Length)) * (1 / float64(d.Length)) + shiftFac := float64(d.IntervalNumber%int64(d.Length)) / float64(d.Length) return math.Mod(d.PositionFac()+shiftFac, 1) } @@ -179,6 +180,10 @@ func (r *SceneRole) ApplyEffect(device *Device, c SceneRunContext) (newState New newState = r.State(c.IntervalFac()) } + if r.Relative { + newState = newState.RelativeTo(*device) + } + switch r.PowerMode { case SPDevice: newState.Power = nil @@ -196,6 +201,8 @@ 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) @@ -219,8 +226,8 @@ func (r *SceneRole) State(fac float64) NewDeviceState { type ScenePowerMode string const ( - SPDevice ScenePowerMode = "Device" // Device state decides power. Scene state may only power off. - SPScene ScenePowerMode = "Scene" // Scene state decide power. + SPDevice ScenePowerMode = "Device" // Device state decides power. Scene state may only power off. + SPScene ScenePowerMode = "Scene" // Scene state decide power. ) // DeviceSceneAssignment is an entry on the device stack. StartTime and DurationMS are only respected when the scene -- 2.30.2 From 1b7e495a594ff4af0ef0bcff1625ea4a1cf75ec6 Mon Sep 17 00:00:00 2001 From: Gisle Aune Date: Tue, 5 Oct 2021 20:11:53 +0200 Subject: [PATCH 6/8] more scene stuff. There's still no command and API to push scenes and unassign (aside from setting a temporary scene and waiting it out). --- app/api/scenes.go | 33 +++++- app/client/client.go | 10 ++ app/client/scene.go | 16 +++ app/services/publisher/publisher.go | 28 +++++- app/services/publisher/scene.go | 11 ++ cmd/lucy/main.go | 3 + cmd/lucy/scenecmd.go | 151 ++++++++++++++++++++++++++++ internal/drivers/nanoleaf/bridge.go | 2 +- internal/mysql/scenerepo.go | 5 + models/scene.go | 6 +- scene-examples/evening.yaml | 63 ++++++++++++ scene-examples/flash.yaml | 9 ++ scene-examples/late.yaml | 48 +++++++++ scene-examples/morning.yaml | 40 ++++++++ 14 files changed, 416 insertions(+), 9 deletions(-) create mode 100644 app/client/scene.go create mode 100644 cmd/lucy/scenecmd.go create mode 100644 scene-examples/evening.yaml create mode 100644 scene-examples/flash.yaml create mode 100644 scene-examples/late.yaml create mode 100644 scene-examples/morning.yaml 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 + -- 2.30.2 From def494ba456a5bafc32ef5f23dcf6573119f15d9 Mon Sep 17 00:00:00 2001 From: Gisle Aune Date: Wed, 6 Oct 2021 20:00:58 +0200 Subject: [PATCH 7/8] scene stuff is ready for prime time now. --- app/api/devices.go | 79 +++++++++++++++++++++-------- app/client/client.go | 19 ++++++- app/client/handler.go | 2 +- app/services/bridges.go | 10 ---- app/services/events.go | 9 ++++ app/services/publisher/publisher.go | 19 +++++-- app/services/publisher/scene.go | 26 +++++++--- cmd/lucy/command.go | 12 +++++ cmd/lucy/handlercmd.go | 10 +++- cmd/lucy/scenecmd.go | 18 ++++++- cmd/lucy/tables.go | 37 ++++++++++++-- models/colorvalue.go | 4 +- models/device.go | 3 +- models/eventhandler.go | 35 ++++++++++--- models/scene.go | 10 +++- scene-examples/evening.yaml | 6 +-- scene-examples/flash.yaml | 19 ++++++- scene-examples/late.yaml | 23 ++++++--- scene-examples/morning.yaml | 4 +- scene-examples/saturday.yaml | 42 +++++++++++++++ 20 files changed, 313 insertions(+), 74 deletions(-) create mode 100644 scene-examples/saturday.yaml diff --git a/app/api/devices.go b/app/api/devices.go index 0438514..c524b05 100644 --- a/app/api/devices.go +++ b/app/api/devices.go @@ -3,9 +3,9 @@ package api import ( "context" "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" - "golang.org/x/sync/errgroup" "log" "time" ) @@ -17,11 +17,21 @@ func fetchDevices(ctx context.Context, fetchStr string) ([]models.Device, error) func Devices(r gin.IRoutes) { r.GET("", handler(func(c *gin.Context) (interface{}, error) { - return config.DeviceRepository().FetchByReference(ctxOf(c), models.RKAll, "") + devices, err := config.DeviceRepository().FetchByReference(ctxOf(c), models.RKAll, "") + if err != nil { + return nil, err + } + + return withSceneState(devices), nil })) r.GET("/:fetch", handler(func(c *gin.Context) (interface{}, error) { - return fetchDevices(ctxOf(c), c.Param("fetch")) + devices, err := fetchDevices(ctxOf(c), c.Param("fetch")) + if err != nil { + return nil, err + } + + return withSceneState(devices), nil })) r.PUT("", handler(func(c *gin.Context) (interface{}, error) { @@ -72,7 +82,7 @@ func Devices(r gin.IRoutes) { } }() - return changed, nil + return withSceneState(changed), nil })) r.PUT("/:fetch", handler(func(c *gin.Context) (interface{}, error) { @@ -100,7 +110,7 @@ func Devices(r gin.IRoutes) { } } - return devices, nil + return withSceneState(devices), nil })) r.PUT("/:fetch/state", handler(func(c *gin.Context) (interface{}, error) { @@ -128,16 +138,13 @@ func Devices(r gin.IRoutes) { config.PublishChannel <- devices go func() { - for _, device := range devices { - err := config.DeviceRepository().Save(context.Background(), &device, models.SMState) - if err != nil { - log.Println("Failed to save device for state:", err) - continue - } + err = config.DeviceRepository().SaveMany(ctxOf(c), models.SMState, devices) + if err != nil { + log.Println("Failed to save devices states") } }() - return devices, nil + return withSceneState(devices), nil })) r.PUT("/:fetch/tags", handler(func(c *gin.Context) (interface{}, error) { @@ -194,7 +201,7 @@ func Devices(r gin.IRoutes) { } } - return devices, nil + return withSceneState(devices), nil })) r.PUT("/:fetch/scene", handler(func(c *gin.Context) (interface{}, error) { @@ -221,25 +228,53 @@ func Devices(r gin.IRoutes) { } body.StartTime = time.Now() + pushMode := c.Query("push") == "true" for i := range devices { - devices[i].SceneAssignments = []models.DeviceSceneAssignment{body} + if pushMode { + devices[i].SceneAssignments = append(devices[i].SceneAssignments, body) + } else { + devices[i].SceneAssignments = []models.DeviceSceneAssignment{body} + } } config.PublishChannel <- devices - eg := errgroup.Group{} - for i := range devices { - device := devices[i] + err = config.DeviceRepository().SaveMany(ctxOf(c), 0, devices) + if err != nil { + return nil, err + } - eg.Go(func() error { - return config.DeviceRepository().Save(ctxOf(c), &device, 0) - }) + return withSceneState(devices), nil + })) + + r.DELETE("/:fetch/scene", handler(func(c *gin.Context) (interface{}, error) { + devices, err := fetchDevices(ctxOf(c), c.Param("fetch")) + if err != nil { + return nil, err } + if len(devices) == 0 { + return []models.Device{}, nil + } + + for i := range devices { + devices[i].SceneAssignments = []models.DeviceSceneAssignment{} + } + config.PublishChannel <- devices - err = eg.Wait() + err = config.DeviceRepository().SaveMany(ctxOf(c), 0, devices) if err != nil { return nil, err } - return devices, nil + return withSceneState(devices), nil })) } + +func withSceneState(devices []models.Device) []models.Device { + res := make([]models.Device, 0, len(devices)) + for _, device := range devices { + device.SceneState = publisher.Global().SceneState(device.ID) + res = append(res, device) + } + + return res +} diff --git a/app/client/client.go b/app/client/client.go index 84b8dcb..c0bba26 100644 --- a/app/client/client.go +++ b/app/client/client.go @@ -60,9 +60,24 @@ 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) { +func (client *Client) AssignDevice(ctx context.Context, fetchStr string, push bool, assignment models.DeviceSceneAssignment) ([]models.Device, error) { + query := "" + if push { + query = "?push=true" + } + + devices := make([]models.Device, 0, 16) + err := client.Fetch(ctx, "PUT", "/api/devices/"+fetchStr+"/scene"+query, &devices, assignment) + if err != nil { + return nil, err + } + + return devices, nil +} + +func (client *Client) ClearDevice(ctx context.Context, fetchStr string) ([]models.Device, error) { devices := make([]models.Device, 0, 16) - err := client.Fetch(ctx, "PUT", "/api/devices/"+fetchStr+"/scene", &devices, assignment) + err := client.Fetch(ctx, "DELETE", "/api/devices/"+fetchStr+"/scene", &devices, nil) if err != nil { return nil, err } diff --git a/app/client/handler.go b/app/client/handler.go index 4ef960c..fdc0ab4 100644 --- a/app/client/handler.go +++ b/app/client/handler.go @@ -28,7 +28,7 @@ func (client *Client) GetHandler(ctx context.Context, id int) (*models.EventHand func (client *Client) PutHandler(ctx context.Context, handler *models.EventHandler) (*models.EventHandler, error) { var response models.EventHandler - err := client.Fetch(ctx, "PUT", fmt.Sprintf("/api/event-handlers/%d", handler.ID), &response, handler) + err := client.Fetch(ctx, "PUT", fmt.Sprintf("/api/event-handlers/%d?hard=true", handler.ID), &response, handler) if err != nil { return nil, err } diff --git a/app/services/bridges.go b/app/services/bridges.go index 14f82e1..ea4df28 100644 --- a/app/services/bridges.go +++ b/app/services/bridges.go @@ -5,7 +5,6 @@ import ( "git.aiterp.net/lucifer/new-server/app/config" "git.aiterp.net/lucifer/new-server/models" "log" - "strconv" "sync" "time" ) @@ -53,15 +52,6 @@ func runConnectToBridges() error { log.Printf("Running bridge \"%s\" (%d)", bridge.Name, bridge.ID) go func(bridge models.Bridge, cancel func()) { - savedDevices, err := config.DeviceRepository().FetchByReference(ctx, models.RKBridgeID, strconv.Itoa(bridge.ID)) - if err != nil { - log.Println("Failed to fetch devices from db for refresh:", err) - } - err = driver.Publish(ctx, bridge, savedDevices) - if err != nil { - log.Println("Failed to publish devices from db before run:", err) - } - err = driver.Run(ctx, bridge, config.EventChannel) log.Printf("Bridge \"%s\" (%d) stopped: %s", bridge.Name, bridge.ID, err) diff --git a/app/services/events.go b/app/services/events.go index 3079599..9e86300 100644 --- a/app/services/events.go +++ b/app/services/events.go @@ -155,6 +155,15 @@ func handleEvent(event models.Event) (responses []models.Event) { if err != nil { log.Println("Error updating state for device", device.ID, "err:", err) } + + if action.SetScene != nil { + action.SetScene.StartTime = time.Now() + allDevices[i].SceneAssignments = []models.DeviceSceneAssignment{*action.SetScene} + } + if action.PushScene != nil { + action.PushScene.StartTime = time.Now() + allDevices[i].SceneAssignments = append(allDevices[i].SceneAssignments, *action.PushScene) + } } config.PublishChannel <- allDevices diff --git a/app/services/publisher/publisher.go b/app/services/publisher/publisher.go index e181e34..2b3f604 100644 --- a/app/services/publisher/publisher.go +++ b/app/services/publisher/publisher.go @@ -19,6 +19,17 @@ type Publisher struct { waiting map[int]chan struct{} } +func (p *Publisher) SceneState(deviceID int) *models.DeviceState { + p.mu.Lock() + defer p.mu.Unlock() + + if s := p.sceneAssignment[deviceID]; s != nil { + return s.LastState(deviceID) + } + + return nil +} + func (p *Publisher) UpdateScene(data models.Scene) { p.mu.Lock() p.sceneData[data.ID] = &data @@ -127,7 +138,7 @@ func (p *Publisher) Run() { for _, i := range deleteList { p.scenes = append(p.scenes[:i], p.scenes[i+1:]...) } - + for _, device := range updatedList { if !p.started[device.BridgeID] { p.started[device.BridgeID] = true @@ -193,6 +204,7 @@ func (p *Publisher) reassignDevice(device models.Device) bool { endTime: selectedAssignment.StartTime.Add(time.Duration(selectedAssignment.DurationMS) * time.Millisecond), roleMap: make(map[int]int, 16), roleList: make(map[int][]models.Device, 16), + lastStates: make(map[int]models.DeviceState, 16), due: true, } p.sceneAssignment[device.ID] = newScene @@ -281,7 +293,7 @@ func Global() *Publisher { } func Initialize(ctx context.Context) error { - err :=publisher.ReloadScenes(ctx) + err := publisher.ReloadScenes(ctx) if err != nil { return err } @@ -290,8 +302,9 @@ func Initialize(ctx context.Context) error { return err } - go publisher.PublishChannel(config.PublishChannel) go publisher.Run() + time.Sleep(time.Millisecond * 50) + go publisher.PublishChannel(config.PublishChannel) return nil } diff --git a/app/services/publisher/scene.go b/app/services/publisher/scene.go index eea9cfa..e6d605d 100644 --- a/app/services/publisher/scene.go +++ b/app/services/publisher/scene.go @@ -6,12 +6,13 @@ import ( ) type Scene struct { - data *models.Scene - group string - startTime time.Time - endTime time.Time - roleMap map[int]int - roleList map[int][]models.Device + data *models.Scene + group string + startTime time.Time + endTime time.Time + roleMap map[int]int + roleList map[int][]models.Device + lastStates map[int]models.DeviceState due bool lastInterval int64 @@ -84,6 +85,7 @@ func (s *Scene) RemoveDevice(device models.Device) { s.due = true delete(s.roleMap, device.ID) + delete(s.lastStates, device.ID) } func (s *Scene) Empty() bool { @@ -96,7 +98,6 @@ func (s *Scene) Empty() bool { return true } - func (s *Scene) Due() bool { if s.due { return true @@ -163,6 +164,8 @@ func (s *Scene) Run() []models.Device { continue } + s.lastStates[device.ID] = device.State + updatedDevices = append(updatedDevices, device) } } @@ -172,3 +175,12 @@ func (s *Scene) Run() []models.Device { return updatedDevices } + +func (s *Scene) LastState(id int) *models.DeviceState { + lastState, ok := s.lastStates[id] + if !ok { + return nil + } + + return &lastState +} diff --git a/cmd/lucy/command.go b/cmd/lucy/command.go index 2648711..fe7aab1 100644 --- a/cmd/lucy/command.go +++ b/cmd/lucy/command.go @@ -5,6 +5,7 @@ import ( "log" "strconv" "strings" + "time" ) type Param struct { @@ -221,6 +222,17 @@ func (p Params) DeviceState(prefix string) models.NewDeviceState { } } +func (p Params) SceneAssignment(prefix string) *models.DeviceSceneAssignment { + if p.Get(prefix+"scene") == nil { + return nil + } + + return &models.DeviceSceneAssignment{ + SceneID: p.Get(prefix+"scene").IntOr(-1), + Group: p.Get(prefix+"scene.group").StringOr(time.Now().Format(time.RFC3339)), + DurationMS: int64(p.Get(prefix+"scene.duration").IntOr(0)), + } +} type Command struct { Name string diff --git a/cmd/lucy/handlercmd.go b/cmd/lucy/handlercmd.go index 9a65def..05a04d2 100644 --- a/cmd/lucy/handlercmd.go +++ b/cmd/lucy/handlercmd.go @@ -99,7 +99,7 @@ func handlerCmd( func applyCmdToHandler(model models.EventHandler, cmd Command) models.EventHandler { // Remove keys for _, elem := range cmd.Params.Strings(1) { - if elem[0] != '-' { + if !strings.HasPrefix(elem, "-") { continue } keyToRemove := elem[1:] @@ -128,6 +128,10 @@ func applyCmdToHandler(model models.EventHandler, cmd Command) models.EventHandl model.From = models.Never case "to": model.To = models.Never + case "set-scene": + model.Actions.SetScene = nil + case "push-scene": + model.Actions.PushScene = nil } } } @@ -173,5 +177,9 @@ func applyCmdToHandler(model models.EventHandler, cmd Command) models.EventHandl } } + // Scenes + model.Actions.SetScene = cmd.Params.SceneAssignment("set-") + model.Actions.PushScene = cmd.Params.SceneAssignment("push-") + return model } diff --git a/cmd/lucy/scenecmd.go b/cmd/lucy/scenecmd.go index 63675be..31139d4 100644 --- a/cmd/lucy/scenecmd.go +++ b/cmd/lucy/scenecmd.go @@ -79,7 +79,7 @@ func sceneCmd( } } - case "assign": + case "push", "assign": { fetch := cmd.Params.Get(0).String() id := cmd.Params.Get(1).Int() @@ -87,7 +87,7 @@ func sceneCmd( log.Println("Usage: lucy scene assign ") } - devices, err := c.AssignDevice(ctx, *fetch, models.DeviceSceneAssignment{ + devices, err := c.AssignDevice(ctx, *fetch, cmd.Name == "push", models.DeviceSceneAssignment{ SceneID: *id, Group: cmd.Params.Get("group").StringOr(*fetch), DurationMS: int64(cmd.Params.Get("duration").IntOr(0)), @@ -99,6 +99,20 @@ func sceneCmd( WriteDeviceInfoTable(os.Stdout, devices) } + + case "clear": + fetch := cmd.Params.Get(0).String() + if fetch == nil { + log.Println("Usage: lucy scene clear ") + } + + devices, err := c.ClearDevice(ctx, *fetch) + if err != nil { + log.Println("Could not clear devices:", err) + return + } + + WriteDeviceInfoTable(os.Stdout, devices) } } diff --git a/cmd/lucy/tables.go b/cmd/lucy/tables.go index cb3a15b..7808ca4 100644 --- a/cmd/lucy/tables.go +++ b/cmd/lucy/tables.go @@ -14,16 +14,32 @@ func WriteDeviceStateTable(w io.Writer, devices []models.Device) { table.SetHeader([]string{"ID", "NAME", "POWER", "COLOR", "INTENSITY", "TEMPERATURE"}) table.SetReflowDuringAutoWrap(true) + table.SetColumnAlignment([]int{ + tablewriter.ALIGN_RIGHT, + tablewriter.ALIGN_LEFT, + tablewriter.ALIGN_RIGHT, + tablewriter.ALIGN_LEFT, + tablewriter.ALIGN_RIGHT, + tablewriter.ALIGN_RIGHT, + }) for _, v := range devices { powerStr := "" if v.HasCapability(models.DCPower) { - powerStr = strconv.FormatBool(v.State.Power) + if v.SceneState != nil && v.SceneState.Power != v.State.Power { + powerStr = strconv.FormatBool(v.SceneState.Power) + "*" + } else { + powerStr = strconv.FormatBool(v.State.Power) + } } colorStr := "" if v.HasCapability(models.DCColorHSK, models.DCColorHS, models.DCColorKelvin) { - colorStr = v.State.Color.String() + if v.SceneState != nil && v.SceneState.Color.String() != v.State.Color.String() { + colorStr = v.SceneState.Color.String() + "*" + } else { + colorStr = v.State.Color.String() + } } temperatureString := "" @@ -33,7 +49,11 @@ func WriteDeviceStateTable(w io.Writer, devices []models.Device) { intensityString := "" if v.HasCapability(models.DCIntensity) { - intensityString = strconv.FormatFloat(v.State.Intensity, 'f', -1, 64) + if v.SceneState != nil && v.SceneState.Intensity != v.State.Intensity { + intensityString = strconv.FormatFloat(v.SceneState.Intensity, 'g', 2, 64) + "*" + } else { + intensityString = strconv.FormatFloat(v.State.Intensity, 'f', -1, 64) + } } table.Append([]string{ @@ -104,6 +124,17 @@ func WriteHandlerInfoTable(w io.Writer, handlers []models.EventHandler) { if h.Actions.FireEvent != nil { actionStr += fmt.Sprintf("fireEvent=%s ", (*h.Actions.FireEvent).Name) } + if h.Actions.FireEvent != nil { + actionStr += fmt.Sprintf("fireEvent=%s ", (*h.Actions.FireEvent).Name) + } + if h.Actions.SetScene != nil { + s := h.Actions.SetScene + actionStr += fmt.Sprintf("setScene=(id=%d,group=\"%s\",duration=%d) ", s.SceneID, s.Group, s.DurationMS) + } + if h.Actions.PushScene != nil { + s := h.Actions.PushScene + actionStr += fmt.Sprintf("pushScene=(id=%d,group=\"%s\",duration=%d) ", s.SceneID, s.Group, s.DurationMS) + } eventName := h.EventName if h.OneShot { diff --git a/models/colorvalue.go b/models/colorvalue.go index 0bfff3e..119e170 100644 --- a/models/colorvalue.go +++ b/models/colorvalue.go @@ -26,9 +26,11 @@ func (c *ColorValue) String() string { return fmt.Sprintf("k:%d", c.Kelvin) } - return fmt.Sprintf("hs:%g,%g", c.Hue, c.Saturation) + return fmt.Sprintf("hs:%.4g,%.3g", c.Hue, c.Saturation) } + + func ParseColorValue(raw string) (ColorValue, error) { tokens := strings.SplitN(raw, ":", 2) if len(tokens) != 2 { diff --git a/models/device.go b/models/device.go index bf1abec..aa76fe2 100644 --- a/models/device.go +++ b/models/device.go @@ -18,6 +18,7 @@ type Device struct { DriverProperties map[string]interface{} `json:"driverProperties"` UserProperties map[string]string `json:"userProperties"` SceneAssignments []DeviceSceneAssignment `json:"sceneAssignments"` + SceneState *DeviceState `json:"sceneState"` State DeviceState `json:"state"` Tags []string `json:"tags"` } @@ -200,7 +201,7 @@ func (s *NewDeviceState) RelativeTo(device Device) NewDeviceState { if s.Color != nil { c, err := ParseColorValue(*s.Color) if err == nil { - c.Hue = math.Mod(device.State.Color.Hue + c.Hue, 360) + c.Hue = math.Mod(device.State.Color.Hue+c.Hue, 360) c.Saturation *= device.State.Color.Saturation c.Kelvin += device.State.Color.Kelvin } diff --git a/models/eventhandler.go b/models/eventhandler.go index 31a2b7e..0a7ea4f 100644 --- a/models/eventhandler.go +++ b/models/eventhandler.go @@ -5,6 +5,7 @@ import ( "regexp" "strconv" "strings" + "time" ) type EventHandler struct { @@ -168,6 +169,20 @@ func (c *EventCondition) checkDevice(key string, device Device) (matches bool, s } return c.matches(strconv.FormatFloat(device.State.Temperature, 'f', -1, 64)), false + case "scene": + if len(device.SceneAssignments) == 0 { + return false, false + } + + sceneId := -1 + for _, assignment := range device.SceneAssignments { + duration := time.Duration(assignment.DurationMS) * time.Millisecond + if duration <= 0 || time.Now().Before(assignment.StartTime.Add(duration)) { + sceneId = assignment.SceneID + } + } + + return c.matches(strconv.Itoa(sceneId)), false default: return false, true } @@ -214,12 +229,14 @@ func (c *EventCondition) matches(value string) bool { } type EventAction struct { - SetPower *bool `json:"setPower"` - SetColor *string `json:"setColor"` - SetIntensity *float64 `json:"setIntensity"` - SetTemperature *int `json:"setTemperature"` - AddIntensity *float64 `json:"addIntensity"` - FireEvent *Event `json:"fireEvent"` + SetPower *bool `json:"setPower"` + SetColor *string `json:"setColor"` + SetIntensity *float64 `json:"setIntensity"` + SetTemperature *int `json:"setTemperature"` + AddIntensity *float64 `json:"addIntensity"` + FireEvent *Event `json:"fireEvent"` + SetScene *DeviceSceneAssignment `json:"setScene"` + PushScene *DeviceSceneAssignment `json:"pushScene"` } func (action *EventAction) Apply(other EventAction) { @@ -238,6 +255,12 @@ func (action *EventAction) Apply(other EventAction) { if action.FireEvent == nil { action.FireEvent = other.FireEvent } + if action.SetScene == nil { + action.SetScene = other.SetScene + } + if action.PushScene == nil { + action.PushScene = other.PushScene + } } func (c EventCondition) String() string { diff --git a/models/scene.go b/models/scene.go index 64a0c72..7dd215e 100644 --- a/models/scene.go +++ b/models/scene.go @@ -114,7 +114,7 @@ func (r *SceneRole) Validate() error { } switch r.PowerMode { - case SPScene, SPDevice: + case SPScene, SPDevice, SPBoth: default: return ErrSceneRoleUnknownPowerMode } @@ -188,7 +188,12 @@ func (r *SceneRole) ApplyEffect(device *Device, c SceneRunContext) (newState New case SPDevice: newState.Power = nil case SPScene: - // Do nothing + // Do nothing + case SPBoth: + if newState.Power != nil { + powerIntersection := *newState.Power && device.State.Power + newState.Power = &powerIntersection + } } return @@ -226,6 +231,7 @@ type ScenePowerMode string const ( SPDevice ScenePowerMode = "Device" // Device state decides power. Scene state may only power off. SPScene ScenePowerMode = "Scene" // Scene state decide power. + SPBoth ScenePowerMode = "Both" // Power is only on if both Device and Scene says it is. ) // DeviceSceneAssignment is an entry on the device stack. StartTime and DurationMS are only respected when the scene diff --git a/scene-examples/evening.yaml b/scene-examples/evening.yaml index a54d12f..825b83f 100644 --- a/scene-examples/evening.yaml +++ b/scene-examples/evening.yaml @@ -48,8 +48,8 @@ roles: relative: false order: +name states: - - color: 'hs:250,0.6' - intensity: 0.2 + - color: 'hs:250,0.7' + intensity: 0.25 - effect: Static power_mode: Device @@ -60,4 +60,4 @@ roles: order: +name states: - color: 'k:2000' - intensity: 0.25 + intensity: 0.3 diff --git a/scene-examples/flash.yaml b/scene-examples/flash.yaml index 94a916d..dc5ed51 100644 --- a/scene-examples/flash.yaml +++ b/scene-examples/flash.yaml @@ -1,9 +1,26 @@ name: Flash interval: 0 roles: + - effect: Static + power_mode: Device + target_kind: Tag + target_value: Nanoleaf + states: + - intensity: 1 + color: hs:220,0.1 + + - effect: Static + power_mode: Device + target_kind: Tag + target_value: Hue + states: + - intensity: 1 + color: k:6500 + - effect: Static power_mode: Device target_kind: All target_value: "" states: - - intensity: 1 \ No newline at end of file + - intensity: 1 + color: hs:0,0 diff --git a/scene-examples/late.yaml b/scene-examples/late.yaml index 8441033..c6d4613 100644 --- a/scene-examples/late.yaml +++ b/scene-examples/late.yaml @@ -1,5 +1,5 @@ name: Late -interval: 5000 +interval: 60000 roles: - effect: Random power_mode: Device @@ -11,11 +11,15 @@ roles: states: - color: 'hs:35,1' intensity: 0.08 - - color: 'hs:25,1' - intensity: 0.10 + - color: 'hs:35,1' + intensity: 0.085 + - color: 'hs:35,1' + intensity: 0.09 + - color: 'hs:35,1' + intensity: 0.12 - effect: Gradient - power_mode: Device + power_mode: Both target_kind: Tag target_value: Square interpolate: true @@ -24,8 +28,13 @@ roles: states: - color: 'hs:25,1' intensity: 0.05 + power: off + - color: 'hs:30,1' + intensity: 0.06 + power: off - color: 'hs:35,1' - intensity: 0.05 + intensity: 0.07 + power: on - effect: Static power_mode: Device @@ -35,8 +44,8 @@ roles: relative: false order: +name states: - - color: 'hs:25,1' - intensity: 0.05 + - color: 'k:2000' + intensity: 0.15 - effect: Static power_mode: Scene diff --git a/scene-examples/morning.yaml b/scene-examples/morning.yaml index 6a1901e..52432b4 100644 --- a/scene-examples/morning.yaml +++ b/scene-examples/morning.yaml @@ -9,10 +9,10 @@ roles: relative: false order: +name states: - - color: 'hs:220,0.5' - intensity: 0.50 - color: 'hs:220,0.4' intensity: 0.65 + - color: 'hs:220,0.5' + intensity: 0.50 - effect: Random power_mode: Device diff --git a/scene-examples/saturday.yaml b/scene-examples/saturday.yaml new file mode 100644 index 0000000..7ee87d4 --- /dev/null +++ b/scene-examples/saturday.yaml @@ -0,0 +1,42 @@ +name: Saturday +interval: 700 +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.25 + - color: 'hs:220,0.5' + intensity: 0.30 + + - 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.15 + - color: 'hs:220,0.55' + intensity: 0.16 + - color: 'hs:220,0.5' + intensity: 0.20 + + - effect: Static + power_mode: Device + target_kind: All + target_value: '' + interpolate: true + relative: false + order: +name + states: + - color: 'hs:250,0.7' + intensity: 0.3 + -- 2.30.2 From cdf51735036f8bff37289e9038f74975128ea332 Mon Sep 17 00:00:00 2001 From: Gisle Aune Date: Wed, 6 Oct 2021 20:10:57 +0200 Subject: [PATCH 8/8] fixed a rare hue driver crash on startup and crash if an event sets an invalid scene id. --- app/services/publisher/publisher.go | 5 +++++ internal/drivers/hue/state.go | 3 +++ 2 files changed, 8 insertions(+) diff --git a/app/services/publisher/publisher.go b/app/services/publisher/publisher.go index 2b3f604..2550e84 100644 --- a/app/services/publisher/publisher.go +++ b/app/services/publisher/publisher.go @@ -177,6 +177,11 @@ func (p *Publisher) reassignDevice(device models.Device) bool { // Stop here, no scene should be assigned. return false + } else { + if p.sceneData[selectedAssignment.SceneID] == nil { + // Freeze until scene becomes available. + return false + } } if p.sceneAssignment[device.ID] != nil { diff --git a/internal/drivers/hue/state.go b/internal/drivers/hue/state.go index 288eaa0..588f4ae 100644 --- a/internal/drivers/hue/state.go +++ b/internal/drivers/hue/state.go @@ -122,6 +122,9 @@ func (state *hueSensorState) Update(newData SensorData) *models.Event { if time.Since(stateTime) > time.Second*3 { return nil } + if state.prevData == nil { + return nil + } pe := state.prevData.State.ButtonEvent ce := newData.State.ButtonEvent -- 2.30.2