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:
@@ -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
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user