feature-scenesystem (#1)
continuous-integration/drone/push Build is passing

Co-authored-by: Gisle Aune <dev@gisle.me>
Reviewed-on: #1
Co-authored-by: gisle <gisle@hidden-email>
Co-committed-by: gisle <gisle@hidden-email>
This commit was merged in pull request #1.
This commit is contained in:
2021-10-06 18:13:16 +00:00
co-authored by gisle
parent d91bd472dd
commit ba22187e6e
38 changed files with 1898 additions and 225 deletions
+3 -1
View File
@@ -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 {
+83 -11
View File
@@ -2,21 +2,25 @@ package models
import (
"context"
"math"
"strings"
"time"
)
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"`
SceneState *DeviceState `json:"sceneState"`
State DeviceState `json:"state"`
Tags []string `json:"tags"`
}
type DeviceUpdate struct {
@@ -37,6 +41,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"`
@@ -58,6 +69,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
}
@@ -178,3 +190,63 @@ 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{}
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))
}
+7
View File
@@ -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")
+29 -6
View File
@@ -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 {
+251
View File
@@ -0,0 +1,251 @@
package models
import (
"context"
"math"
"math/rand"
"sort"
"strings"
"time"
)
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 = "Static"
SERandom SceneEffect = "Random"
SEGradient SceneEffect = "Gradient"
SEWalkingGradient SceneEffect = "WalkingGradient"
SETransition SceneEffect = "Transition"
)
type SceneRole struct {
Effect SceneEffect `json:"effect"`
PowerMode ScenePowerMode `json:"powerMode"`
TargetKind ReferenceKind `json:"targetKind"`
TargetValue string `json:"targetValue"`
Interpolate bool `json:"interpolate"`
Relative bool `json:"relative"`
Order string `json:"order"`
States []NewDeviceState `json:"states"`
}
type SceneRunContext struct {
Index int
Length int
IntervalNumber int64
IntervalMax int64
}
func (d *SceneRunContext) PositionFacShifted() float64 {
shiftFac := float64(d.IntervalNumber%int64(d.Length)) / 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
}
switch r.TargetKind {
case RKTag, RKBridgeID, RKDeviceID, RKName, RKAll:
default:
return ErrBadInput
}
switch r.PowerMode {
case SPScene, SPDevice, SPBoth:
default:
return ErrSceneRoleUnknownPowerMode
}
switch r.Effect {
case SEStatic, SERandom, SEGradient, SEWalkingGradient, 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, c SceneRunContext) (newState NewDeviceState) {
switch r.Effect {
case SEStatic:
newState = r.States[0]
case SERandom:
newState = r.State(rand.Float64())
case SEGradient:
newState = r.State(c.PositionFac())
case SEWalkingGradient:
newState = r.State(c.PositionFacShifted())
case SETransition:
newState = r.State(c.IntervalFac())
}
if r.Relative {
newState = newState.RelativeTo(*device)
}
switch r.PowerMode {
case SPDevice:
newState.Power = nil
case SPScene:
// Do nothing
case SPBoth:
if newState.Power != nil {
powerIntersection := *newState.Power && device.State.Power
newState.Power = &powerIntersection
}
}
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.
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
// instance is created for the group.
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
}
+51 -1
View File
@@ -15,6 +15,56 @@ var (
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
}
func ParseFetchString(fetchStr string) (ReferenceKind, string) {
if strings.HasPrefix(fetchStr, "tag:") {
return RKTag, fetchStr[4:]
@@ -34,4 +84,4 @@ func ParseFetchString(fetchStr string) (ReferenceKind, string) {
return RKDeviceID, fetchStr
}
}
}