11 Commits
Author SHA1 Message Date
Stian Fredrik Aune a08685c3cd Location, location, location.
continuous-integration/drone Build is passing
2022-01-21 19:26:06 +01:00
Stian Fredrik Aune 15536c6078 lucy factory - sensor support 2022-01-20 22:07:04 +01:00
Stian Fredrik Aune 2bb2154e5b SceneDimmer bug 2022-01-12 17:23:56 +01:00
Stian Fredrik Aune 8c2ef2dac3 factory command 2022-01-12 17:01:56 +01:00
Stian Fredrik Aune f4e584938b enable 'lucy handler delete all' 2022-01-08 21:02:35 +01:00
Stian Fredrik Aune 8c9b47aa1d Add temperature to API.
continuous-integration/drone/push Build is passing
2021-11-07 19:03:42 +01:00
Stian Fredrik Aune 5bd7a7f6b6 cleanup and fix
continuous-integration/drone/push Build is passing
2021-11-07 18:56:21 +01:00
Stian Fredrik Aune 88bd4dc073 Mill. Take 1.
continuous-integration/drone/push Build is failing
2021-11-07 18:38:32 +01:00
gisle 6443e0ecd3 any/all.scene=-1 now checks for no scene.
continuous-integration/drone/push Build is passing
2021-10-31 09:51:06 +01:00
gisle 147231e4fd add sensor scenes for Motion and Temperature.
continuous-integration/drone/push Build is passing
2021-10-30 14:38:14 +02:00
gisle b191455753 optimize Hue light updates by using temporary groups.
continuous-integration/drone/push Build is passing
2021-10-30 12:05:56 +02:00
37 changed files with 1420 additions and 99 deletions
-9
View File
@@ -4,15 +4,6 @@ kind: pipeline
type: docker
steps:
- name: go-build
image: golang:1.17
commands:
- go test -v ./...
when:
event:
exclude:
- tag
- name: go-test
image: golang:1.17
commands:
+1 -1
View File
@@ -52,7 +52,7 @@ func Bridges(r gin.IRoutes) {
return []models.Bridge{bridge}, nil
}
bridges, err := driver.SearchBridge(ctxOf(c), body.Address, body.DryRun)
bridges, err := driver.SearchBridge(ctxOf(c), body.Address, body.Token, body.DryRun)
if err != nil {
return nil, err
}
+4 -6
View File
@@ -137,12 +137,10 @@ func Devices(r gin.IRoutes) {
config.PublishChannel <- devices
go func() {
err = config.DeviceRepository().SaveMany(ctxOf(c), models.SMState, devices)
if err != nil {
log.Println("Failed to save devices states")
}
}()
err = config.DeviceRepository().SaveMany(ctxOf(c), models.SMState, devices)
if err != nil {
log.Println("Failed to save devices states")
}
return withSceneState(devices), nil
}))
+2
View File
@@ -4,6 +4,7 @@ import (
"git.aiterp.net/lucifer/new-server/internal/drivers"
"git.aiterp.net/lucifer/new-server/internal/drivers/hue"
"git.aiterp.net/lucifer/new-server/internal/drivers/lifx"
"git.aiterp.net/lucifer/new-server/internal/drivers/mill"
"git.aiterp.net/lucifer/new-server/internal/drivers/nanoleaf"
"git.aiterp.net/lucifer/new-server/models"
"sync"
@@ -18,6 +19,7 @@ func DriverProvider() models.DriverProvider {
models.DTNanoLeaf: &nanoleaf.Driver{},
models.DTHue: &hue.Driver{},
models.DTLIFX: &lifx.Driver{},
models.DTMill: &mill.Driver{},
}
})
+18 -1
View File
@@ -1,17 +1,34 @@
package config
import (
"log"
"os"
"strconv"
"time"
)
func Location() *time.Location {
env, ok := os.LookupEnv("LUCIFER_LOCATION")
if !ok {
return time.UTC
}
loc, err := time.LoadLocation(env)
if err != nil {
log.Printf("Location lookup failed: %s (%s)", env, err.Error())
return time.UTC
}
return loc
}
func MySqlHost() string { return strEnv("LUCIFER_MYSQL_HOST") }
func MySqlPort() int { return intEnv("LUCIFER_MYSQL_PORT") }
func MySqlSchema() string { return strEnv("LUCIFER_MYSQL_SCHEMA") }
func MySqlUsername() string { return strEnv("LUCIFER_MYSQL_USERNAME") }
func MySQlPassword() string { return strEnv("LUCIFER_MYSQL_PASSWORD") }
func ServerPort() int { return intEnv("LUCIFER_SERVER_PORT") }
func ServerPort() int { return intEnv("LUCIFER_SERVER_PORT") }
func strEnv(key string) string {
env, ok := os.LookupEnv(key)
+4 -1
View File
@@ -7,13 +7,14 @@ import (
"git.aiterp.net/lucifer/new-server/app/config"
"git.aiterp.net/lucifer/new-server/app/services"
"git.aiterp.net/lucifer/new-server/app/services/publisher"
"git.aiterp.net/lucifer/new-server/models"
"github.com/gin-gonic/gin"
"log"
"time"
)
func StartServer() {
setupCtx, cancel := context.WithTimeout(context.Background(), time.Second * 10)
setupCtx, cancel := context.WithTimeout(context.Background(), time.Second*10)
defer cancel()
err := publisher.Initialize(setupCtx)
@@ -38,5 +39,7 @@ func StartServer() {
api.EventHandlers(apiGin.Group("/event-handlers"))
api.Scenes(apiGin.Group("/scenes"))
models.TimeOfDayTimeZone = config.Location()
log.Fatal(ginny.Run(fmt.Sprintf("0.0.0.0:%d", config.ServerPort())))
}
+25 -2
View File
@@ -4,6 +4,7 @@ import (
"context"
"fmt"
"git.aiterp.net/lucifer/new-server/app/config"
"git.aiterp.net/lucifer/new-server/app/services/publisher"
"git.aiterp.net/lucifer/new-server/models"
"log"
"math"
@@ -66,7 +67,6 @@ func handleEvent(event models.Event) (responses []models.Event) {
err := handleSpecial(event)
if err != nil {
log.Printf("Special event handler error (%s): %v", event.Name, err)
return
}
paramStrings := make([]string, 0, 8)
@@ -220,7 +220,30 @@ func handleSpecial(event models.Event) error {
}
return driver.Publish(ctx, bridge, devices)
case models.ENSensorPresenceStarted, models.ENSensorPresenceEnded, models.ENSensorTemperature:
id, _ := strconv.Atoi(event.Payload["deviceId"])
updateTime, _ := strconv.ParseInt(event.Payload["lastUpdated"], 10, 64)
state := models.SceneSensor{
ID: id,
UpdateTime: time.Unix(updateTime, 0),
}
switch event.Name {
case models.ENSensorPresenceStarted:
presence := true
state.Presence = &presence
case models.ENSensorPresenceEnded:
presence := false
state.Presence = &presence
case models.ENSensorTemperature:
temperature, _ := strconv.ParseFloat(event.Payload["temperature"], 64)
state.Temperature = &temperature
}
publisher.Global().UpdateSensor(state)
return nil
default:
return nil
}
}
}
+62
View File
@@ -0,0 +1,62 @@
package handlerfactory
import (
"git.aiterp.net/lucifer/new-server/models"
"gopkg.in/yaml.v2"
"io"
"io/ioutil"
"strings"
)
type FactoryConfig struct {
Mode FCMOde `yaml:"mode"`
Target string `yaml:"target"`
SceneNames []string `yaml:"scenes"`
TriggerDeviceID string `yaml:"trigger_device_id"`
After []After `yaml:"after"`
From string `yaml:"from"`
To string `yaml:"to"`
}
type FCMOde string
var (
SceneDimmer FCMOde = "SceneDimmer"
MotionSensor FCMOde = "MotionSensor"
)
type After struct {
Minutes int `yaml:"minutes"`
NewState models.NewDeviceState `yaml:"new_state"`
}
func ParseConfigs(reader io.Reader) ([]FactoryConfig, error) {
readAll, err := ioutil.ReadAll(reader)
if err != nil {
return nil, err
}
splits := strings.Split(string(readAll), "\n---\n")
configs := make([]FactoryConfig, 0, 8)
for _, split := range splits {
var factoryConfig FactoryConfig
err := yaml.NewDecoder(strings.NewReader(split)).Decode(&factoryConfig)
if err != nil {
return nil, err
}
configs = append(configs, factoryConfig)
}
return configs, nil
}
func (f *FactoryConfig) FromTime() models.TimeOfDay {
tod, _ := models.ParseTimeOfDay(f.From)
return tod
}
func (f *FactoryConfig) ToTime() models.TimeOfDay {
tod, _ := models.ParseTimeOfDay(f.To)
return tod
}
+235
View File
@@ -0,0 +1,235 @@
package handlerfactory
import (
"fmt"
"git.aiterp.net/lucifer/new-server/models"
)
func CreateHandlers(
config FactoryConfig,
scenes []models.Scene,
existing []models.EventHandler,
) ([]models.EventHandler, []models.EventHandler) {
switch config.Mode {
case SceneDimmer:
return sceneDimmer(config, scenes, existing)
case MotionSensor:
return motionSensor(config, existing)
default:
return []models.EventHandler{}, []models.EventHandler{}
}
}
func sceneDimmer(
config FactoryConfig,
scenes []models.Scene,
existing []models.EventHandler,
) ([]models.EventHandler, []models.EventHandler) {
kind, refValue := models.ParseFetchString(config.Target)
toRemove := make([]models.EventHandler, 0, 8)
for _, old := range existing {
if old.Conditions["deviceId"].EQ == config.TriggerDeviceID {
toRemove = append(toRemove, old)
}
}
resolvedScenes := make([]models.Scene, 0, len(config.SceneNames))
for _, name := range config.SceneNames {
for _, scene := range scenes {
if name == scene.Name {
resolvedScenes = append(resolvedScenes, scene)
break
}
}
}
if len(scenes) < 2 {
return []models.EventHandler{}, []models.EventHandler{}
}
handlers := make([]models.EventHandler, 0, len(scenes)+4)
handlers = append(handlers, models.EventHandler{
EventName: "ButtonPressed",
Conditions: map[string]models.EventCondition{
"deviceId": {EQ: config.TriggerDeviceID},
"buttonName": {EQ: "On"},
},
OneShot: false,
Priority: 100,
TargetKind: kind,
TargetValue: refValue,
Actions: models.EventAction{SetPower: &myTrue},
From: config.FromTime(),
To: config.ToTime(),
})
handlers = append(handlers, models.EventHandler{
EventName: "ButtonPressed",
Conditions: map[string]models.EventCondition{
"deviceId": {EQ: config.TriggerDeviceID},
"buttonName": {EQ: "Off"},
},
OneShot: false,
Priority: 100,
TargetKind: kind,
TargetValue: refValue,
Actions: models.EventAction{SetPower: &myFalse},
From: config.FromTime(),
To: config.ToTime(),
})
for i, scene := range resolvedScenes {
if i > 0 {
handlers = append(handlers, models.EventHandler{
EventName: "ButtonPressed",
Conditions: map[string]models.EventCondition{
"deviceId": {EQ: config.TriggerDeviceID},
"buttonName": {EQ: "DimDown"},
"any.scene": {EQ: fmt.Sprintf("%d", scene.ID)},
},
OneShot: false,
Priority: 100,
TargetKind: kind,
TargetValue: refValue,
Actions: models.EventAction{SetScene: &models.DeviceSceneAssignment{
SceneID: resolvedScenes[i-1].ID,
Group: refValue,
DurationMS: 0,
}},
From: config.FromTime(),
To: config.ToTime(),
})
}
if i < len(resolvedScenes)-1 {
handlers = append(handlers, models.EventHandler{
EventName: "ButtonPressed",
Conditions: map[string]models.EventCondition{
"deviceId": {EQ: config.TriggerDeviceID},
"buttonName": {EQ: "DimUp"},
"any.scene": {EQ: fmt.Sprintf("%d", scene.ID)},
},
OneShot: false,
Priority: 100,
TargetKind: kind,
TargetValue: refValue,
Actions: models.EventAction{SetScene: &models.DeviceSceneAssignment{
SceneID: resolvedScenes[i+1].ID,
Group: refValue,
DurationMS: 0,
}},
From: config.FromTime(),
To: config.ToTime(),
})
}
}
handlers = append(handlers, models.EventHandler{
EventName: "ButtonPressed",
Conditions: map[string]models.EventCondition{
"deviceId": {EQ: config.TriggerDeviceID},
"buttonName": {EQ: "DimDown"},
},
OneShot: false,
Priority: 90,
TargetKind: kind,
TargetValue: refValue,
Actions: models.EventAction{SetScene: &models.DeviceSceneAssignment{
SceneID: resolvedScenes[len(resolvedScenes)-1].ID,
Group: refValue,
DurationMS: 0,
}},
From: config.FromTime(),
To: config.ToTime(),
})
handlers = append(handlers, models.EventHandler{
EventName: "ButtonPressed",
Conditions: map[string]models.EventCondition{
"deviceId": {EQ: config.TriggerDeviceID},
"buttonName": {EQ: "DimUp"},
},
OneShot: false,
Priority: 90,
TargetKind: kind,
TargetValue: refValue,
Actions: models.EventAction{SetScene: &models.DeviceSceneAssignment{
SceneID: resolvedScenes[0].ID,
Group: refValue,
DurationMS: 0,
}},
From: config.FromTime(),
To: config.ToTime(),
})
return optimizeLists(handlers, toRemove)
}
func motionSensor(
config FactoryConfig,
existing []models.EventHandler,
) ([]models.EventHandler, []models.EventHandler) {
kind, refValue := models.ParseFetchString(config.Target)
toRemove := make([]models.EventHandler, 0, 8)
for _, old := range existing {
if old.From != config.FromTime() || old.To != config.ToTime() {
continue
}
if old.Conditions["deviceId"].EQ == config.TriggerDeviceID {
toRemove = append(toRemove, old)
}
}
handlers := make([]models.EventHandler, 0, len(config.After))
for _, after := range config.After {
if after.Minutes == 0 {
handlers = append(handlers, models.EventHandler{
EventName: "SensorPresenceStarted",
Conditions: map[string]models.EventCondition{
"deviceId": {EQ: config.TriggerDeviceID},
},
OneShot: false,
Priority: 100,
TargetKind: kind,
TargetValue: refValue,
Actions: models.EventAction{
SetPower: after.NewState.Power,
SetColor: after.NewState.Color,
SetIntensity: after.NewState.Intensity,
SetTemperature: after.NewState.Temperature,
},
From: config.FromTime(),
To: config.ToTime(),
})
} else {
handlers = append(handlers, models.EventHandler{
EventName: "SensorPresenceEnded",
Conditions: map[string]models.EventCondition{
"deviceId": {EQ: config.TriggerDeviceID},
"minutesElapsed": {EQ: fmt.Sprintf("%d", after.Minutes)},
},
OneShot: false,
Priority: 100,
TargetKind: kind,
TargetValue: refValue,
Actions: models.EventAction{
SetPower: after.NewState.Power,
SetColor: after.NewState.Color,
SetIntensity: after.NewState.Intensity,
SetTemperature: after.NewState.Temperature,
},
From: config.FromTime(),
To: config.ToTime(),
})
}
}
return optimizeLists(handlers, toRemove)
}
var myTrue = true
var myFalse = false
+72
View File
@@ -0,0 +1,72 @@
package handlerfactory
import (
"git.aiterp.net/lucifer/new-server/models"
)
func optimizeLists(
add []models.EventHandler,
del []models.EventHandler,
) ([]models.EventHandler, []models.EventHandler) {
newAdd := make([]models.EventHandler, 0, len(add))
newDel := make([]models.EventHandler, 0, len(del))
OptimizeLoop1:
for _, addItem := range add {
for _, delItem := range del {
if checkDuplicate(&addItem, &delItem) {
continue OptimizeLoop1
}
}
newAdd = append(newAdd, addItem)
}
OptimizeLoop2:
for _, delItem := range del {
for _, addItem := range add {
if checkDuplicate(&addItem, &delItem) {
continue OptimizeLoop2
}
}
newDel = append(newDel, delItem)
}
return newAdd, newDel
}
func checkDuplicate(a *models.EventHandler, b *models.EventHandler) bool {
check := true
check = check && a.EventName == b.EventName
check = check && len(a.Conditions) == len(b.Conditions)
for k := range a.Conditions {
check = check && a.Conditions[k] == b.Conditions[k]
}
check = check && a.OneShot == b.OneShot
check = check && a.Priority == b.Priority
check = check && a.TargetKind == b.TargetKind
check = check && a.TargetValue == b.TargetValue
check = check && a.From == b.From
check = check && a.To == b.To
aPower := a.Actions.SetPower
bPower := b.Actions.SetPower
powerEq := (aPower == nil && bPower == nil) || (aPower != nil && bPower != nil && *aPower == *bPower)
aColor := a.Actions.SetColor
bColor := b.Actions.SetColor
colorEq := (aColor == nil && bColor == nil) || (aColor != nil && bColor != nil && *aColor == *bColor)
aInt := a.Actions.SetIntensity
bInt := b.Actions.SetIntensity
intEq := (aInt == nil && bInt == nil) || (aInt != nil && bInt != nil && *aInt == *bInt)
aTemp := a.Actions.SetTemperature
bTemp := b.Actions.SetTemperature
tempEq := (aTemp == nil && bTemp == nil) || (aTemp != nil && bTemp != nil && *aTemp == *bTemp)
check = check && powerEq && colorEq && intEq && tempEq
return check
}
+15
View File
@@ -17,6 +17,7 @@ type Publisher struct {
started map[int]bool
pending map[int][]models.Device
waiting map[int]chan struct{}
sensorCache map[int]models.SceneSensor
}
func (p *Publisher) SceneState(deviceID int) *models.DeviceState {
@@ -42,6 +43,15 @@ func (p *Publisher) UpdateScene(data models.Scene) {
p.mu.Unlock()
}
func (p *Publisher) UpdateSensor(data models.SceneSensor) {
p.mu.Lock()
if assignment := p.sceneAssignment[data.ID]; assignment != nil {
assignment.UpdateSensor(data)
}
p.sensorCache[data.ID] = data
p.mu.Unlock()
}
func (p *Publisher) ReloadScenes(ctx context.Context) error {
scenes, err := config.SceneRepository().FetchAll(ctx)
if err != nil {
@@ -90,6 +100,10 @@ func (p *Publisher) Publish(devices ...models.Device) {
if p.sceneAssignment[device.ID] != nil {
p.sceneAssignment[device.ID].UpsertDevice(device)
if cache, ok := p.sensorCache[device.ID]; ok {
p.sceneAssignment[device.ID].UpdateSensor(cache)
}
} else {
p.pending[device.BridgeID] = append(p.pending[device.BridgeID], device)
if p.waiting[device.BridgeID] != nil {
@@ -291,6 +305,7 @@ var publisher = Publisher{
started: make(map[int]bool),
pending: make(map[int][]models.Device),
waiting: make(map[int]chan struct{}),
sensorCache: make(map[int]models.SceneSensor),
}
func Global() *Publisher {
+41 -2
View File
@@ -13,11 +13,25 @@ type Scene struct {
roleMap map[int]int
roleList map[int][]models.Device
lastStates map[int]models.DeviceState
sensors []models.SceneSensor
due bool
lastInterval int64
}
func (s *Scene) UpdateSensor(data models.SceneSensor) {
s.due = true
for i, sensor := range s.sensors {
if sensor.ID == data.ID {
s.sensors[i] = data
return
}
}
s.sensors = append(s.sensors, data)
}
// UpdateScene updates the scene data and re-seats all devices.
func (s *Scene) UpdateScene(data models.Scene) {
devices := make([]models.Device, 0, 16)
@@ -86,6 +100,13 @@ func (s *Scene) RemoveDevice(device models.Device) {
delete(s.roleMap, device.ID)
delete(s.lastStates, device.ID)
for i, sensor := range s.sensors {
if sensor.ID == device.ID {
s.sensors = append(s.sensors[:i], s.sensors[i+1:]...)
break
}
}
}
func (s *Scene) Empty() bool {
@@ -130,6 +151,7 @@ func (s *Scene) Run() []models.Device {
return []models.Device{}
}
currentTime := time.Now()
intervalNumber := int64(0)
intervalMax := int64(1)
if s.data.IntervalMS > 0 {
@@ -149,14 +171,31 @@ func (s *Scene) Run() []models.Device {
continue
}
sensorCount := 0
for _, device := range list {
if device.IsOnlySensor() {
sensorCount += 1
}
}
role := s.data.Roles[i]
jSkip := 0
for j, device := range list {
if device.IsOnlySensor() {
updatedDevices = append(updatedDevices, device)
jSkip += 1
continue
}
newState := role.ApplyEffect(&device, models.SceneRunContext{
Index: j,
Length: len(list),
CurrentTime: currentTime,
Index: j - jSkip,
Length: len(list) - sensorCount,
IntervalNumber: intervalNumber,
IntervalMax: intervalMax,
Sensors: s.sensors,
})
err := device.SetState(newState)
+1 -1
View File
@@ -33,7 +33,7 @@ func main() {
}
// Find bridge
bridges, err := driver.SearchBridge(context.Background(), *flagAddress, !*flagPair)
bridges, err := driver.SearchBridge(context.Background(), *flagAddress, *flagToken, !*flagPair)
if err != nil {
log.Fatalln("Failed to search bridge:", err)
}
+75
View File
@@ -0,0 +1,75 @@
package main
import (
"context"
"fmt"
"git.aiterp.net/lucifer/new-server/app/client"
"git.aiterp.net/lucifer/new-server/app/services/handlerfactory"
"log"
"os"
)
func factoryCmd(
ctx context.Context,
c client.Client,
) {
cmd := parseCommand(os.Args[2:])
switch cmd.Name {
case "run":
fileName := cmd.Params.Get(0).String()
if fileName == nil {
log.Fatalln("Missing filename")
}
scenes, err := c.GetScenes(ctx)
if err != nil {
log.Fatalln(err.Error())
}
handlers, err := c.GetHandlers(ctx)
if err != nil {
log.Fatalln(err.Error())
}
file, err := os.Open(*fileName)
if err != nil {
log.Fatalln("Failed to open file:", err)
}
defer file.Close()
configs, err := handlerfactory.ParseConfigs(file)
if err != nil {
log.Fatalln("Failed to parse file:", err)
}
for _, config := range configs {
toAdd, toRemove := handlerfactory.CreateHandlers(config, scenes, handlers)
for _, removing := range toRemove {
_, err := c.DeleteHandler(ctx, removing.ID)
if err != nil {
log.Fatalln(err.Error())
}
log.Printf("Removed handler %d", removing.ID)
}
for _, adding := range toAdd {
res, err := c.AddHandler(ctx, adding)
if err != nil {
log.Fatalln(err.Error())
}
log.Printf("Added handler %d", res.ID)
}
}
default:
if cmd.Name != "help" {
log.Println("Unknown command:", cmd.Name)
}
_, _ = fmt.Fprintln(os.Stderr, helpString[1:])
}
}
+26 -9
View File
@@ -76,17 +76,33 @@ func handlerCmd(
WriteHandlerInfoTable(os.Stdout, []models.EventHandler{*returned})
case "delete":
id := cmd.Params.Get(0).Int()
if id == nil {
log.Fatalln("ID missing")
}
if cmd.Params.Get(0).StringOr("_not") == "all" {
handlers, err := c.GetHandlers(ctx)
if err != nil {
log.Fatalln(err)
}
handler, err := c.DeleteHandler(ctx, *id)
if err != nil {
log.Fatalln(err)
}
for _, handler := range handlers {
_, err = c.DeleteHandler(ctx, handler.ID)
if err != nil {
log.Fatalln(err)
}
}
WriteHandlerInfoTable(os.Stdout, []models.EventHandler{*handler})
log.Printf("Deleted %d handlers", len(handlers))
} else {
id := cmd.Params.Get(0).Int()
if id == nil {
log.Fatalln("ID missing")
}
handler, err := c.DeleteHandler(ctx, *id)
if err != nil {
log.Fatalln(err)
}
WriteHandlerInfoTable(os.Stdout, []models.EventHandler{*handler})
}
default:
if cmd.Name != "help" {
log.Println("Unknown command:", cmd.Name)
@@ -157,6 +173,7 @@ func applyCmdToHandler(model models.EventHandler, cmd Command) models.EventHandl
model.Actions.SetPower = nds.Power
model.Actions.SetColor = nds.Color
model.Actions.SetIntensity = nds.Intensity
model.Actions.SetTemperature = nds.Temperature
// Add intensity
model.Actions.AddIntensity = cmd.Params.Get("add-intensity").Float()
+32
View File
@@ -32,6 +32,38 @@ EVENT HANDLER COMMANDS
<-fire> <-fire.payload.*>
handler delete <id>
EVENT HANDLER FACTORY COMMANDS
factory run <file.yaml>
File example (use '---' to delimit multiple configs in one file):
mode: SceneDimmer
target: tag:Soverom
scenes:
- kveldslys
- landskap
- vasking
trigger_device_id: 14
---
mode: MotionSensor
target: tag:Bod
trigger_device_id: 36
after:
- minutes: 0
new_state:
power: true
color: hs:240,0.6
intensity: 0.8
- minutes: 1
new_state:
power: false
from: 08:00
to: 15:59:59
---
(more modes coming soon)
WARNING: Similar event handlers will be removed!
EVENT COMMANDS
run <event name> <*=S>
+5 -3
View File
@@ -82,8 +82,8 @@ func main() {
case "update":
{
devices, err := c.PutDevice(ctx, cmd.Params.Get(0).StringOr("all"), models.DeviceUpdate{
Name: cmd.Params.Get("name").String(),
Icon: cmd.Params.Get("icon").String(),
Name: cmd.Params.Get("name").String(),
Icon: cmd.Params.Get("icon").String(),
UserProperties: cmd.Params.Subset("prop.").StringPtrMap(),
})
if err != nil {
@@ -93,6 +93,9 @@ func main() {
WriteDeviceInfoTable(os.Stdout, devices)
}
case "factory":
factoryCmd(ctx, c)
case "handler":
handlerCmd(ctx, c)
@@ -126,4 +129,3 @@ func main() {
_, _ = fmt.Fprintln(os.Stderr, helpString[1:])
}
}
+1 -1
View File
@@ -44,7 +44,7 @@ func WriteDeviceStateTable(w io.Writer, devices []models.Device) {
temperatureString := ""
if v.HasCapability(models.DCTemperatureControl, models.DCTemperatureSensor) {
temperatureString = strconv.FormatFloat(v.State.Temperature, 'f', -1, 64)
temperatureString = fmt.Sprintf("%d", v.State.Temperature)
}
intensityString := ""
+133 -15
View File
@@ -6,10 +6,11 @@ import (
"encoding/json"
"fmt"
"git.aiterp.net/lucifer/new-server/models"
"golang.org/x/sync/errgroup"
"io"
"log"
"net"
"net/http"
"strconv"
"strings"
"sync"
"time"
@@ -97,17 +98,17 @@ func (b *Bridge) Refresh(ctx context.Context) error {
func (b *Bridge) SyncStale(ctx context.Context) error {
indices := make([]int, 0, 4)
arrayIndices := make([]int, 0, 4)
inputs := make([]LightStateInput, 0, 4)
eg, ctx := errgroup.WithContext(ctx)
b.mu.Lock()
for _, state := range b.lightStates {
for i, state := range b.lightStates {
if !state.stale {
continue
}
indices = append(indices, state.index)
arrayIndices = append(arrayIndices, i)
inputs = append(inputs, state.input)
}
b.mu.Unlock()
@@ -116,24 +117,85 @@ func (b *Bridge) SyncStale(ctx context.Context) error {
return nil
}
for i, input := range inputs {
iCopy := i
index := indices[i]
inputCopy := input
groups := make([]*syncGroup, 0, 4)
for i := range inputs {
input := inputs[i]
eg.Go(func() error {
err := b.putLightState(ctx, index, inputCopy)
found := false
for _, group := range groups {
if group.State.Equal(input) {
group.Indexes = append(group.Indexes, indices[i])
group.ArrayIndexes = append(group.ArrayIndexes, arrayIndices[i])
found = true
}
}
if !found {
groups = append(groups, &syncGroup{
GroupIndex: -1,
State: input,
Indexes: []int{indices[i]},
ArrayIndexes: []int{arrayIndices[i]},
})
}
}
groupMap, err := b.getGroups(ctx)
if err != nil {
return err
}
for id, data := range groupMap {
for _, group := range groups {
if group.Matches(&data) {
group.GroupIndex = id
break
}
}
}
for _, group := range groups {
if group.GroupIndex == -1 {
data := GroupData{
Name: "lucifer_auto_group",
Lights: []string{},
}
for _, idx := range group.Indexes {
data.Lights = append(data.Lights, strconv.Itoa(idx))
}
id, err := b.postGroup(ctx, data)
if err != nil {
return err
}
b.lightStates[iCopy].stale = false
return nil
})
group.GroupIndex = id
}
}
return eg.Wait()
log.Println("Updating", len(inputs), "lights on Hue bridge", b.externalID, "in", len(groups), "groups")
for _, group := range groups {
err := b.putGroupLightState(ctx, group.GroupIndex, group.State)
if err != nil {
return err
}
b.mu.Lock()
for _, arrayIndex := range group.ArrayIndexes {
b.lightStates[arrayIndex].stale = false
}
b.mu.Unlock()
if groupMap[group.GroupIndex].Name == "lucifer_auto_group" {
err := b.deleteGroup(ctx, group.GroupIndex)
if err != nil {
log.Println("Could not delete temporary group", err)
}
}
}
return nil
}
func (b *Bridge) SyncSensors(ctx context.Context) ([]models.Event, error) {
@@ -170,6 +232,10 @@ func (b *Bridge) putLightState(ctx context.Context, index int, input LightStateI
return b.put(ctx, fmt.Sprintf("lights/%d/state", index), input, nil)
}
func (b *Bridge) putGroupLightState(ctx context.Context, index int, input LightStateInput) error {
return b.put(ctx, fmt.Sprintf("groups/%d/action", index), input, nil)
}
func (b *Bridge) getToken(ctx context.Context) (string, error) {
result := make([]CreateUserResponse, 0, 1)
err := b.post(ctx, "", CreateUserInput{DeviceType: "git.aiterp.net/lucifer"}, &result)
@@ -207,6 +273,34 @@ func (b *Bridge) getSensors(ctx context.Context) (map[int]SensorData, error) {
return result, nil
}
func (b *Bridge) getGroups(ctx context.Context) (map[int]GroupData, error) {
result := make(map[int]GroupData, 16)
err := b.get(ctx, "groups", &result)
if err != nil {
return nil, err
}
return result, nil
}
func (b *Bridge) postGroup(ctx context.Context, input GroupData) (int, error) {
var res []struct {
Success struct {
ID string `json:"id"`
} `json:"success"`
}
err := b.post(ctx, "groups", input, &res)
id, _ := strconv.Atoi(res[0].Success.ID)
return id, err
}
func (b *Bridge) deleteGroup(ctx context.Context, index int) error {
return b.delete(ctx, "groups/"+strconv.Itoa(index), nil)
}
func (b *Bridge) get(ctx context.Context, resource string, target interface{}) error {
if b.token != "" {
resource = b.token + "/" + resource
@@ -226,6 +320,30 @@ func (b *Bridge) get(ctx context.Context, resource string, target interface{}) e
return json.NewDecoder(res.Body).Decode(target)
}
func (b *Bridge) delete(ctx context.Context, resource string, target interface{}) error {
if b.token != "" {
resource = b.token + "/" + resource
}
req, err := http.NewRequest("DELETE", fmt.Sprintf("http://%s/api/%s", b.host, resource), nil)
if err != nil {
return err
}
res, err := httpClient.Do(req.WithContext(ctx))
if err != nil {
return err
}
defer res.Body.Close()
if target == nil {
return nil
}
return json.NewDecoder(res.Body).Decode(target)
}
func (b *Bridge) post(ctx context.Context, resource string, body interface{}, target interface{}) error {
rb, err := reqBody(body)
if err != nil {
+88
View File
@@ -3,8 +3,14 @@ package hue
import (
"encoding/xml"
"errors"
"strconv"
)
type GroupData struct {
Name string `json:"name"`
Lights []string `json:"lights"`
}
type DiscoveryEntry struct {
Id string `json:"id"`
InternalIPAddress string `json:"internalipaddress"`
@@ -83,6 +89,58 @@ type LightStateInput struct {
TransitionTime *int `json:"transitiontime,omitempty"`
}
func (input *LightStateInput) Equal(other LightStateInput) bool {
if (input.On != nil) != (other.On != nil) {
return false
} else if input.On != nil && *input.On != *other.On {
return false
}
if (input.Bri != nil) != (other.Bri != nil) {
return false
} else if input.Bri != nil && *input.Bri != *other.Bri {
return false
}
if (input.Hue != nil) != (other.Hue != nil) {
return false
} else if input.Hue != nil && *input.Hue != *other.Hue {
return false
}
if (input.Sat != nil) != (other.Sat != nil) {
return false
} else if input.Sat != nil && *input.Sat != *other.Sat {
return false
}
if (input.Effect != nil) != (other.Effect != nil) {
return false
} else if input.Effect != nil && *input.Effect != *other.Effect {
return false
}
if (input.XY != nil) != (other.XY != nil) {
return false
} else if input.XY != nil && *input.XY != *other.XY {
return false
}
if (input.CT != nil) != (other.CT != nil) {
return false
} else if input.CT != nil && *input.CT != *other.CT {
return false
}
if (input.Alert != nil) != (other.Alert != nil) {
return false
} else if input.Alert != nil && *input.Alert != *other.Alert {
return false
}
return true
}
type LightData struct {
State LightState `json:"state"`
Type string `json:"type"`
@@ -192,6 +250,36 @@ type BridgeDeviceInfo struct {
} `xml:"device"`
}
type syncGroup struct {
GroupIndex int
State LightStateInput
Indexes []int
ArrayIndexes []int
}
func (sg *syncGroup) Matches(group *GroupData) bool {
if len(sg.Indexes) != len(group.Lights) {
return false
}
for _, idx := range sg.Indexes {
found := false
for _, idx2 := range group.Lights {
n := strconv.Itoa(idx)
if n == idx2 {
found = true
break
}
}
if !found {
return false
}
}
return true
}
var buttonNames = []string{"On", "DimUp", "DimDown", "Off"}
var errLinkButtonNotPressed = errors.New("link button not pressed")
+1 -1
View File
@@ -19,7 +19,7 @@ type Driver struct {
bridges []*Bridge
}
func (d *Driver) SearchBridge(ctx context.Context, address string, dryRun bool) ([]models.Bridge, error) {
func (d *Driver) SearchBridge(ctx context.Context, address, _ string, dryRun bool) ([]models.Bridge, error) {
if address == "" {
if !dryRun {
return nil, models.ErrAddressOnlyDryRunnable
+36 -1
View File
@@ -1,6 +1,7 @@
package hue
import (
"encoding/json"
"git.aiterp.net/lucifer/new-server/models"
"strconv"
"time"
@@ -16,12 +17,37 @@ type hueLightState struct {
stale bool
}
func (s *hueLightState) MarshalJSON() ([]byte, error) {
return json.Marshal(struct {
Index int `json:"index"`
UniqueID string `json:"uniqueId"`
ExternalID int `json:"externalId"`
Info LightData `json:"info"`
Input LightStateInput `json:"input"`
Stale bool `json:"stale"`
}{
Index: s.index,
UniqueID: s.uniqueID,
ExternalID: s.externalID,
Info: s.info,
Input: s.input,
Stale: s.stale,
})
}
func (s *hueLightState) Update(state models.DeviceState) {
input := LightStateInput{}
if state.Power {
input.On = ptrBool(true)
if state.Color.IsKelvin() {
input.CT = ptrInt(1000000 / state.Color.Kelvin)
if *input.CT < s.info.Capabilities.Control.CT.Min {
*input.CT = s.info.Capabilities.Control.CT.Min
}
if *input.CT > s.info.Capabilities.Control.CT.Max {
*input.CT = s.info.Capabilities.Control.CT.Max
}
if s.input.CT == nil || *s.input.CT != *input.CT {
s.stale = true
}
@@ -67,6 +93,13 @@ func (s *hueLightState) Update(state models.DeviceState) {
}
func (s *hueLightState) CheckStaleness(state LightState) {
if s.input.On == nil || state.On != *s.input.On {
s.stale = true
}
if !state.On {
return
}
if state.ColorMode == "xy" {
s.stale = true
if s.input.CT == nil && s.input.Hue == nil {
@@ -80,7 +113,7 @@ func (s *hueLightState) CheckStaleness(state LightState) {
s.stale = true
}
} else {
if s.input.Hue == nil || state.Hue != *s.input.Hue || s.input.Sat == nil || state.Sat == *s.input.Sat {
if s.input.Hue == nil || state.Hue != *s.input.Hue || s.input.Sat == nil || state.Sat != *s.input.Sat {
s.stale = true
}
}
@@ -186,6 +219,7 @@ func (state *hueSensorState) Update(newData SensorData) *models.Event {
"deviceInternalId": newData.UniqueID,
"minutesElapsed": strconv.Itoa(state.presenceCooldown - 1),
"secondsElapsed": strconv.Itoa((state.presenceCooldown - 1) * 60),
"lastUpdated": strconv.FormatInt(stateTime.Unix(), 10),
},
}
}
@@ -202,6 +236,7 @@ func (state *hueSensorState) Update(newData SensorData) *models.Event {
"temperature": strconv.FormatFloat(float64(newData.State.Temperature)/100, 'f', 2, 64),
"deviceId": strconv.Itoa(state.externalID),
"deviceInternalId": newData.UniqueID,
"lastUpdated": strconv.FormatInt(stateTime.Unix(), 10),
},
}
}
+1 -1
View File
@@ -14,7 +14,7 @@ type Driver struct {
bridges []*Bridge
}
func (d *Driver) SearchBridge(ctx context.Context, address string, _ bool) ([]models.Bridge, error) {
func (d *Driver) SearchBridge(ctx context.Context, address, _ string, _ bool) ([]models.Bridge, error) {
if address == "" {
ifaces, err := net.Interfaces()
if err != nil {
+240
View File
@@ -0,0 +1,240 @@
package mill
import (
"bytes"
"context"
"crypto/rand"
"crypto/sha1"
"encoding/json"
"fmt"
"git.aiterp.net/lucifer/new-server/models"
"io"
"log"
"net/http"
"strconv"
"sync"
"time"
)
type bridge struct {
mu sync.Mutex
luciferID int
username string
password string
token string
userId int
mustRefreshBy time.Time
luciferMillIDMap map[int]int
millLuciferIDMap map[int]int
}
func (b *bridge) listDevices(ctx context.Context) ([]models.Device, error) {
err := b.authenticate(ctx)
if err != nil {
return nil, err
}
var shlRes listHomeResBody
err = b.command(ctx, "selectHomeList", listHomeReqBody{}, &shlRes)
if err != nil {
return nil, err
}
devices := make([]millDevice, 0, 16)
for _, home := range shlRes.HomeList {
var gidRes listDeviceResBody
err = b.command(ctx, "getIndependentDevices", listDeviceReqBody{HomeID: home.HomeID}, &gidRes)
if err != nil {
return nil, err
}
devices = append(devices, gidRes.DeviceInfo...)
}
luciferDevices := make([]models.Device, len(devices), len(devices))
for i, device := range devices {
luciferDevices[i] = models.Device{
ID: b.millLuciferIDMap[device.DeviceID],
BridgeID: b.luciferID,
InternalID: fmt.Sprintf("%d", device.DeviceID),
Icon: "heater",
Name: device.DeviceName,
Capabilities: []models.DeviceCapability{models.DCTemperatureControl, models.DCPower},
ButtonNames: nil,
DriverProperties: map[string]interface{}{
"subDomain": fmt.Sprintf("%d", device.SubDomainID),
},
UserProperties: nil,
SceneAssignments: nil,
SceneState: nil,
State: models.DeviceState{
Power: device.PowerStatus > 0,
Temperature: device.HolidayTemp,
},
Tags: nil,
}
}
return luciferDevices, nil
}
func (b *bridge) pushStateChange(ctx context.Context, deviceModel models.Device) error {
b.mu.Lock()
if b.luciferMillIDMap == nil {
b.luciferMillIDMap = make(map[int]int, 4)
b.millLuciferIDMap = make(map[int]int, 4)
}
if b.luciferMillIDMap[deviceModel.ID] == 0 {
millID, _ := strconv.Atoi(deviceModel.InternalID)
b.luciferMillIDMap[deviceModel.ID] = millID
b.millLuciferIDMap[millID] = deviceModel.ID
}
b.mu.Unlock()
status := 0
if deviceModel.State.Power {
status = 1
}
powerReq := deviceControlReqBody{
SubDomain: deviceModel.DriverProperties["subDomain"].(string),
DeviceID: b.luciferMillIDMap[deviceModel.ID],
TestStatus: 1,
Status: status,
}
err := b.command(ctx, "deviceControl", powerReq, nil)
if err != nil {
return err
}
tempReq := changeInfoReqBody{
DeviceID: b.luciferMillIDMap[deviceModel.ID],
Value: deviceModel.State.Temperature,
TimeZoneNum: "+02:00",
Key: "holidayTemp",
}
err = b.command(ctx, "changeDeviceInfo", tempReq, nil)
if err != nil {
return err
}
return nil
}
func (b *bridge) command(ctx context.Context, command string, payload interface{}, target interface{}) error {
err := b.authenticate(ctx)
if err != nil {
return err
}
url := serviceEndpoint + command
method := "POST"
nonce := makeNonce()
timestamp := fmt.Sprintf("%d", time.Now().Unix())
timeout := "300"
h := sha1.New()
h.Write([]byte(timeout))
h.Write([]byte(timestamp))
h.Write([]byte(nonce))
h.Write([]byte(b.token))
signature := fmt.Sprintf("%x", h.Sum(nil))
body, err := json.Marshal(payload)
if err != nil {
return err
}
req, err := http.NewRequestWithContext(ctx, method, url, bytes.NewReader(body))
if err != nil {
return err
}
addDefaultHeaders(req)
req.Header.Add("X-Zc-Timestamp", timestamp)
req.Header.Add("X-Zc-Timeout", timeout)
req.Header.Add("X-Zc-Nonce", nonce)
req.Header.Add("X-Zc-User-Id", fmt.Sprintf("%d", b.userId))
req.Header.Add("X-Zc-User-Signature", signature)
req.Header.Add("X-Zc-Content-Length", fmt.Sprintf("%d", len(body)))
res, err := http.DefaultClient.Do(req)
if err != nil {
return models.ErrCannotForwardRequest
} else if res.StatusCode != 200 {
return models.ErrIncorrectToken
}
if target == nil {
return nil
}
err = json.NewDecoder(res.Body).Decode(&target)
if err != nil {
return models.ErrUnexpectedResponse
}
return nil
}
func (b *bridge) authenticate(ctx context.Context) error {
b.mu.Lock()
defer b.mu.Unlock()
if b.mustRefreshBy.Before(time.Now().Add(-1 * time.Minute)) {
body, err := json.Marshal(authReqBody{
Account: b.username,
Password: b.password,
})
if err != nil {
return models.ErrMissingToken
}
req, err := http.NewRequestWithContext(ctx, "POST", accountEndpoint + "login", bytes.NewReader(body))
if err != nil {
return models.ErrMissingToken
}
addDefaultHeaders(req)
res, err := http.DefaultClient.Do(req)
if err != nil {
return models.ErrCannotForwardRequest
} else if res.StatusCode != 200 {
return models.ErrIncorrectToken
}
var resBody authResBody
err = json.NewDecoder(res.Body).Decode(&resBody)
if err != nil {
return models.ErrBridgeSearchFailed
}
log.Printf("Mill: Authenticated as %s", resBody.NickName)
b.userId = resBody.UserID
b.token = resBody.Token
b.mustRefreshBy, err = time.ParseInLocation("2006-01-02 15:04:05", resBody.TokenExpire, location)
}
return nil
}
func makeNonce() string {
buf := make([]byte, 8)
_, _ = io.ReadFull(rand.Reader, buf)
return fmt.Sprintf("%x", buf)
}
func addDefaultHeaders(req *http.Request) {
req.Header.Add("Content-Type", "application/x-zc-object")
req.Header.Add("Connection", "Keep-Alive")
req.Header.Add("X-Zc-Major-Domain", "seanywell")
req.Header.Add("X-Zc-Msg-Name", "millService")
req.Header.Add("X-Zc-Sub-Domain", "milltype")
req.Header.Add("X-Zc-Seq-Id", "1")
req.Header.Add("X-Zc-Version", "1")
}
+100
View File
@@ -0,0 +1,100 @@
package mill
import (
"context"
"fmt"
"git.aiterp.net/lucifer/new-server/models"
"sync"
"time"
)
type Driver struct {
mu sync.Mutex
bridges map[int]*bridge
}
func (d *Driver) SearchBridge(ctx context.Context, address, token string, _ bool) ([]models.Bridge, error) {
bridgeData := models.Bridge{
Name: fmt.Sprintf("Mill account (%s)", address),
Driver: models.DTMill,
Address: address,
Token: token,
}
b, err := d.ensureBridge(bridgeData)
if err != nil {
return nil, err
}
err = b.authenticate(ctx)
if err != nil {
return nil, err
}
return []models.Bridge{bridgeData}, nil
}
func (d *Driver) SearchDevices(context.Context, models.Bridge, time.Duration) ([]models.Device, error) {
// You would have to configure devices with the Mill app, unfortunately.
return []models.Device{}, nil
}
func (d *Driver) ListDevices(ctx context.Context, bridge models.Bridge) ([]models.Device, error) {
b, err := d.ensureBridge(bridge)
if err != nil {
return nil, err
}
return b.listDevices(ctx)
}
func (d *Driver) Publish(ctx context.Context, bridge models.Bridge, devices []models.Device) error {
b, err := d.ensureBridge(bridge)
if err != nil {
return err
}
for _, device := range devices {
err = b.pushStateChange(ctx, device)
if err != nil {
return err
}
}
return nil
}
func (d *Driver) Run(ctx context.Context, _ models.Bridge, _ chan<- models.Event) error {
// TODO: Maybe do something with the thermostat on the device
for {
select {
case <-ctx.Done():
return ctx.Err()
}
}
}
func (d *Driver) ensureBridge(model models.Bridge) (*bridge, error) {
d.mu.Lock()
defer d.mu.Unlock()
if d.bridges == nil {
d.bridges = make(map[int]*bridge, 4)
}
if d.bridges[model.ID] == nil {
newBridge := &bridge{
luciferID: model.ID,
username: model.Address,
password: model.Token,
}
if model.ID <= 0 {
return newBridge, nil
}
d.bridges[model.ID] = newBridge
}
return d.bridges[model.ID], nil
}
+77
View File
@@ -0,0 +1,77 @@
package mill
import "time"
const accountEndpoint = "https://eurouter.ablecloud.cn:9005/zc-account/v1/"
const serviceEndpoint = "https://eurouter.ablecloud.cn:9005/millService/v1/"
type millHome struct {
HomeID int64 `json:"homeId"`
HomeName string `json:"homeName"`
}
type millDevice struct {
DeviceID int `json:"deviceId"`
DeviceName string `json:"deviceName"`
PowerStatus int `json:"powerStatus"`
HolidayTemp int `json:"holidayTemp"`
CurrentTemp float64 `json:"currentTemp"`
SubDomainID int `json:"subDomainId"`
}
type authReqBody struct {
Account string `json:"account"`
Password string `json:"password"`
}
type authResBody struct {
Token string `json:"token"`
UserID int `json:"userId"`
NickName string `json:"nickName"`
TokenExpire string `json:"tokenExpire"`
}
type listHomeReqBody struct{}
type listHomeResBody struct {
HomeList []millHome `json:"homeList"`
}
type listDeviceReqBody struct {
HomeID int64 `json:"homeId"`
}
type listDeviceResBody struct {
DeviceInfo []millDevice `json:"deviceInfo"`
}
type changeInfoReqBody struct {
HomeType int `json:"homeType"`
DeviceID int `json:"deviceId"`
Value int `json:"value"`
TimeZoneNum string `json:"timeZoneNum"`
Key string `json:"key"`
}
type deviceControlReqBody struct {
SubDomain string `json:"subDomain"`
DeviceID int `json:"deviceId"`
TestStatus int `json:"testStatus"`
Operation int `json:"operation"`
Status int `json:"status"`
WindStatus int `json:"windStatus"`
TempType int `json:"tempType"`
PowerLevel int `json:"powerLevel"`
}
var location *time.Location
func init() {
myLocation, err := time.LoadLocation("Europe/Oslo")
if err != nil {
panic(err.Error())
}
location = myLocation
}
+1 -1
View File
@@ -17,7 +17,7 @@ type Driver struct {
// SearchBridge checks the bridge at the address. If it's not a dry-run, you must hold down the power button
// before calling this function and wait for the pattern.
func (d *Driver) SearchBridge(ctx context.Context, address string, dryRun bool) ([]models.Bridge, error) {
func (d *Driver) SearchBridge(ctx context.Context, address, _ string, dryRun bool) ([]models.Bridge, error) {
res, err := http.Get(fmt.Sprintf("http://%s/device_info", address))
if err != nil {
return nil, err
+17 -14
View File
@@ -23,12 +23,13 @@ type deviceRecord struct {
}
type deviceStateRecord struct {
DeviceID int `db:"device_id"`
Hue float64 `db:"hue"`
Saturation float64 `db:"saturation"`
Kelvin int `db:"kelvin"`
Power bool `db:"power"`
Intensity float64 `db:"intensity"`
DeviceID int `db:"device_id"`
Hue float64 `db:"hue"`
Saturation float64 `db:"saturation"`
Kelvin int `db:"kelvin"`
Power bool `db:"power"`
Intensity float64 `db:"intensity"`
Temperature int `db:"temperature"`
}
type devicePropertyRecord struct {
@@ -227,15 +228,16 @@ func (r *DeviceRepo) SaveMany(ctx context.Context, mode models.SaveMode, devices
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)
REPLACE INTO device_state(device_id, hue, saturation, kelvin, power, intensity, temperature)
VALUES (:device_id, :hue, :saturation, :kelvin, :power, :intensity, :temperature)
`, 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,
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,
Temperature: device.State.Temperature,
})
if err != nil {
return dbErr(err)
@@ -379,6 +381,7 @@ func (r *DeviceRepo) populate(ctx context.Context, records []deviceRecord) ([]mo
Kelvin: state.Kelvin,
},
Intensity: state.Intensity,
Temperature: state.Temperature,
}
}
}
+1
View File
@@ -23,6 +23,7 @@ var (
DTHue DriverKind = "Hue"
DTNanoLeaf DriverKind = "Nanoleaf"
DTLIFX DriverKind = "LIFX"
DTMill DriverKind = "Mill"
)
var ValidDriverKinds = []DriverKind{
+16 -10
View File
@@ -2,7 +2,6 @@ package models
import (
"context"
"math"
"strings"
"time"
)
@@ -38,7 +37,7 @@ type DeviceState struct {
Power bool `json:"power"`
Color ColorValue `json:"color,omitempty"`
Intensity float64 `json:"intensity,omitempty"`
Temperature float64 `json:"temperature"`
Temperature int `json:"temperature"`
}
type DeviceScene struct {
@@ -156,6 +155,17 @@ func (d *Device) HasTag(tags ...string) bool {
return false
}
func (d *Device) IsOnlySensor() bool {
return !d.HasCapability(
DCPower,
DCColorHS,
DCColorHSK,
DCColorKelvin,
DCIntensity,
DCTemperatureControl,
)
}
func (d *Device) HasCapability(capabilities ...DeviceCapability) bool {
for _, c := range d.Capabilities {
for _, c2 := range capabilities {
@@ -188,6 +198,10 @@ func (d *Device) SetState(newState NewDeviceState) error {
d.State.Intensity = *newState.Intensity
}
if newState.Temperature != nil && d.HasCapability(DCTemperatureControl) {
d.State.Temperature = *newState.Temperature
}
return nil
}
@@ -198,14 +212,6 @@ func (s *NewDeviceState) RelativeTo(device Device) NewDeviceState {
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
}
+1 -1
View File
@@ -10,7 +10,7 @@ type DriverProvider interface {
}
type Driver interface {
SearchBridge(ctx context.Context, address string, dryRun bool) ([]Bridge, error)
SearchBridge(ctx context.Context, address, token string, dryRun bool) ([]Bridge, error)
SearchDevices(ctx context.Context, bridge Bridge, timeout time.Duration) ([]Device, error)
ListDevices(ctx context.Context, bridge Bridge) ([]Device, error)
Publish(ctx context.Context, bridge Bridge, devices []Device) error
+1
View File
@@ -15,6 +15,7 @@ var ErrIncorrectToken = errors.New("driver is not accepting authentication infor
var ErrUnexpectedResponse = errors.New("driver api returned unexpected response (wrong driver selected?)")
var ErrBridgeSearchFailed = errors.New("bridge search failed")
var ErrAddressOnlyDryRunnable = errors.New("this address may only be used for a dry run")
var ErrCannotForwardRequest = errors.New("driver is not able to forward requests")
var ErrInvalidAddress = errors.New("invalid mac address")
var ErrPayloadTooShort = errors.New("payload too short")
-1
View File
@@ -24,7 +24,6 @@ var (
ENBridgeDisconnected = "BridgeDisconnected"
ENButtonPressed = "ButtonPressed"
ENSensorPresenceStarted = "SensorPresenceStarted"
ENSensorPresenceEnding = "SensorPresenceEnding"
ENSensorPresenceEnded = "SensorPresenceEnded"
ENSensorTemperature = "SensorTemperature"
)
+1 -5
View File
@@ -168,12 +168,8 @@ func (c *EventCondition) checkDevice(key string, device Device) (matches bool, s
return false, true
}
return c.matches(strconv.FormatFloat(device.State.Temperature, 'f', -1, 64)), false
return c.matches(strconv.Itoa(device.State.Temperature)), 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
+71 -10
View File
@@ -61,24 +61,38 @@ const (
SEGradient SceneEffect = "Gradient"
SEWalkingGradient SceneEffect = "WalkingGradient"
SETransition SceneEffect = "Transition"
SEMotion SceneEffect = "Motion"
SETemperature SceneEffect = "Temperature"
)
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"`
Effect SceneEffect `json:"effect"`
MotionSeconds float64 `json:"motionSeconds"`
MinTemperature int `json:"minTemperature"`
MaxTemperature int `json:"maxTemperature"`
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 {
CurrentTime time.Time
Index int
Length int
IntervalNumber int64
IntervalMax int64
Sensors []SceneSensor
}
type SceneSensor struct {
ID int
UpdateTime time.Time
Temperature *float64
Presence *bool
}
func (d *SceneRunContext) PositionFacShifted() float64 {
@@ -120,7 +134,7 @@ func (r *SceneRole) Validate() error {
}
switch r.Effect {
case SEStatic, SERandom, SEGradient, SEWalkingGradient, SETransition:
case SEStatic, SERandom, SEGradient, SEWalkingGradient, SETransition, SEMotion, SETemperature:
default:
return ErrSceneRoleUnknownEffect
}
@@ -178,6 +192,53 @@ func (r *SceneRole) ApplyEffect(device *Device, c SceneRunContext) (newState New
newState = r.State(c.PositionFacShifted())
case SETransition:
newState = r.State(c.IntervalFac())
case SEMotion:
presence := false
absenceTime := time.Time{}
for _, sensors := range c.Sensors {
if sensors.Presence == nil {
continue
}
if *sensors.Presence {
presence = true
} else {
if sensors.UpdateTime.After(absenceTime) {
absenceTime = sensors.UpdateTime
}
}
}
if presence {
newState = r.State(0.0)
} else {
fac := c.CurrentTime.Sub(absenceTime).Seconds() / r.MotionSeconds
if fac < 0 {
fac = 0
} else if fac > 1 {
fac = 1
}
newState = r.State(fac)
}
case SETemperature:
avg := 0.0
count := 0
for _, sensor := range c.Sensors {
if sensor.Temperature == nil {
continue
}
if count == 0 {
avg = *sensor.Temperature
count = 1
} else {
avg = ((avg * float64(count)) + *sensor.Temperature) / float64(count+1)
count += 1
}
}
}
if r.Relative {
@@ -188,7 +249,7 @@ 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
+5 -3
View File
@@ -9,6 +9,8 @@ import (
"time"
)
var TimeOfDayTimeZone = time.UTC
type TimeOfDay int
func (t *TimeOfDay) UnmarshalJSON(v []byte) error {
@@ -29,7 +31,6 @@ func (t *TimeOfDay) UnmarshalJSON(v []byte) error {
}
}
t2, err := ParseTimeOfDay(str)
if err != nil {
return err
@@ -82,11 +83,12 @@ func (t TimeOfDay) IsBetween(from TimeOfDay, to TimeOfDay) bool {
}
func CurrentTimeOfDay() TimeOfDay {
return TimeOfDayFromDate(time.Now().Local())
return TimeOfDayFromDate(time.Now().In(TimeOfDayTimeZone))
}
func TimeOfDayFromDate(date time.Time) TimeOfDay {
return NewTimeOfDay(date.Hour(), date.Minute(), date.Second())
localDate := date.In(TimeOfDayTimeZone)
return NewTimeOfDay(localDate.Hour(), localDate.Minute(), localDate.Second())
}
func NewTimeOfDay(hours, minutes, seconds int) TimeOfDay {
@@ -0,0 +1,11 @@
-- +goose Up
-- +goose StatementBegin
ALTER TABLE device_state
ADD COLUMN temperature INT NOT NULL DEFAULT 0;
-- +goose StatementEnd
-- +goose Down
-- +goose StatementBegin
ALTER TABLE device_state
DROP COLUMN temperature;
-- +goose StatementEnd