From 44716c84d121a0555b9211e30def759aac3da338 Mon Sep 17 00:00:00 2001 From: Gisle Aune Date: Sat, 25 Sep 2021 19:04:54 +0200 Subject: [PATCH] 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 +}