Compare commits
57
Commits
11ef2997c8
...
3.4.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5bd7a7f6b6 | ||
|
|
88bd4dc073 | ||
|
|
6443e0ecd3 | ||
|
|
147231e4fd | ||
|
|
b191455753 | ||
|
|
ba22187e6e | ||
|
|
d91bd472dd | ||
|
|
251687b85d | ||
|
|
ade49589a9 | ||
|
|
ad2f912a6f | ||
|
|
2abb4b1024 | ||
|
|
b83673b2cc | ||
|
|
6fe0d8b864 | ||
|
|
c7d7e264ca | ||
|
|
0cd325e8db | ||
|
|
b1fa52e53f | ||
|
|
b28496fd6b | ||
|
|
35c3a387c9 | ||
|
|
b7b6b06748 | ||
|
|
27f8c6b91d | ||
|
|
0846290047 | ||
|
|
9b9e5e9da8 | ||
|
|
3c2aa7212d | ||
|
|
d6c38d647f | ||
|
|
06fa73b916 | ||
|
|
7a6ee3f87b | ||
|
|
d623647e21 | ||
|
|
4fd49d0480 | ||
|
|
38d777404b | ||
|
|
2f9bd3c007 | ||
|
|
56a0525b9d | ||
|
|
1cf8c019e5 | ||
|
|
b2c9cca400 | ||
|
|
4109d41bec | ||
|
|
616adc14a4 | ||
|
|
26fcc1f314 | ||
|
|
4e734c86a5 | ||
|
|
766c534fcb | ||
|
|
79fc2a319c | ||
|
|
e9bfbfbec7 | ||
|
|
b2375e5864 | ||
|
|
22c80a90a1 | ||
|
|
95ec918ed1 | ||
|
|
fff01befda | ||
|
|
ea3037b03a | ||
|
|
677d9aa9b1 | ||
|
|
5cdb4f3cf5 | ||
|
|
74fb2c6b5a | ||
|
|
2aa2757828 | ||
|
|
38d370894e | ||
|
|
a2e719def9 | ||
|
|
883115bf8b | ||
|
|
954d6bd3e1 | ||
|
|
e1f38e3dcd | ||
|
|
f045bc0f4d | ||
|
|
e4078de845 | ||
|
|
d6fdb48023 |
+31
@@ -0,0 +1,31 @@
|
|||||||
|
name: lucifer3
|
||||||
|
|
||||||
|
kind: pipeline
|
||||||
|
type: docker
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: go-test
|
||||||
|
image: golang:1.17
|
||||||
|
commands:
|
||||||
|
- go test -v ./...
|
||||||
|
when:
|
||||||
|
event:
|
||||||
|
exclude:
|
||||||
|
- tag
|
||||||
|
|
||||||
|
- name: docker-backend-tag
|
||||||
|
image: plugins/docker
|
||||||
|
settings:
|
||||||
|
auto_tag: true
|
||||||
|
username:
|
||||||
|
from_secret: docker_username
|
||||||
|
password:
|
||||||
|
from_secret: docker_password
|
||||||
|
repo: r.vmaple.dev/lucifer/backend
|
||||||
|
registry: r.vmaple.dev
|
||||||
|
dockerfile: docker/backend/Dockerfile
|
||||||
|
when:
|
||||||
|
event:
|
||||||
|
- tag
|
||||||
|
|
||||||
|
|
||||||
@@ -2,3 +2,10 @@
|
|||||||
.env
|
.env
|
||||||
.idea/
|
.idea/
|
||||||
*.iml
|
*.iml
|
||||||
|
/bridgetest
|
||||||
|
/server
|
||||||
|
/goose
|
||||||
|
/bridgetest.exe
|
||||||
|
/server.exe
|
||||||
|
/goose.exe
|
||||||
|
/10.24.4.1
|
||||||
+59
-4
@@ -1,10 +1,12 @@
|
|||||||
package api
|
package api
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
"git.aiterp.net/lucifer/new-server/app/config"
|
"git.aiterp.net/lucifer/new-server/app/config"
|
||||||
"git.aiterp.net/lucifer/new-server/models"
|
"git.aiterp.net/lucifer/new-server/models"
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
"log"
|
"log"
|
||||||
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
func Bridges(r gin.IRoutes) {
|
func Bridges(r gin.IRoutes) {
|
||||||
@@ -12,11 +14,17 @@ func Bridges(r gin.IRoutes) {
|
|||||||
return config.BridgeRepository().FetchAll(ctxOf(c))
|
return config.BridgeRepository().FetchAll(ctxOf(c))
|
||||||
}))
|
}))
|
||||||
|
|
||||||
|
r.GET("/:id", handler(func(c *gin.Context) (interface{}, error) {
|
||||||
|
return config.BridgeRepository().Find(ctxOf(c), intParam(c, "id"))
|
||||||
|
}))
|
||||||
|
|
||||||
r.POST("", handler(func(c *gin.Context) (interface{}, error) {
|
r.POST("", handler(func(c *gin.Context) (interface{}, error) {
|
||||||
var body struct {
|
var body struct {
|
||||||
Driver models.DriverKind `json:"driver"`
|
Driver models.DriverKind `json:"driver"`
|
||||||
Address string `json:"address"`
|
Address string `json:"address"`
|
||||||
DryRun bool `json:"dryRun"`
|
Token string `json:"token"`
|
||||||
|
ForceSave bool `json:"forceSave"`
|
||||||
|
DryRun bool `json:"dryRun"`
|
||||||
}
|
}
|
||||||
err := parseBody(c, &body)
|
err := parseBody(c, &body)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -28,7 +36,23 @@ func Bridges(r gin.IRoutes) {
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
bridges, err := driver.SearchBridge(ctxOf(c), body.Address, body.DryRun)
|
if body.ForceSave {
|
||||||
|
bridge := models.Bridge{
|
||||||
|
Name: fmt.Sprintf("Manually saved %s bridge (%s)", body.Driver, body.Address),
|
||||||
|
Driver: body.Driver,
|
||||||
|
Address: body.Address,
|
||||||
|
Token: body.Token,
|
||||||
|
}
|
||||||
|
|
||||||
|
err := config.BridgeRepository().Save(ctxOf(c), &bridge)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return []models.Bridge{bridge}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
bridges, err := driver.SearchBridge(ctxOf(c), body.Address, body.Token, body.DryRun)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -73,6 +97,37 @@ func Bridges(r gin.IRoutes) {
|
|||||||
return bridge, nil
|
return bridge, nil
|
||||||
}))
|
}))
|
||||||
|
|
||||||
|
r.PUT("/:id/search", handler(func(c *gin.Context) (interface{}, error) {
|
||||||
|
var body struct {
|
||||||
|
TimeoutMS int64 `json:"timeoutMs"`
|
||||||
|
}
|
||||||
|
err := parseBody(c, &body)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if body.TimeoutMS <= 0 {
|
||||||
|
body.TimeoutMS = 30000
|
||||||
|
}
|
||||||
|
|
||||||
|
bridge, err := config.BridgeRepository().Find(ctxOf(c), intParam(c, "id"))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
driver, err := config.DriverProvider().Provide(bridge.Driver)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
devices, err := driver.SearchDevices(ctxOf(c), bridge, time.Millisecond * time.Duration(body.TimeoutMS))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return devices, nil
|
||||||
|
}))
|
||||||
|
|
||||||
r.DELETE("/:id", handler(func(c *gin.Context) (interface{}, error) {
|
r.DELETE("/:id", handler(func(c *gin.Context) (interface{}, error) {
|
||||||
bridge, err := config.BridgeRepository().Find(ctxOf(c), intParam(c, "id"))
|
bridge, err := config.BridgeRepository().Find(ctxOf(c), intParam(c, "id"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -0,0 +1,278 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"git.aiterp.net/lucifer/new-server/app/config"
|
||||||
|
"git.aiterp.net/lucifer/new-server/app/services/publisher"
|
||||||
|
"git.aiterp.net/lucifer/new-server/models"
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"log"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func fetchDevices(ctx context.Context, fetchStr string) ([]models.Device, error) {
|
||||||
|
kind, value := models.ParseFetchString(fetchStr)
|
||||||
|
return config.DeviceRepository().FetchByReference(ctx, kind, value)
|
||||||
|
}
|
||||||
|
|
||||||
|
func Devices(r gin.IRoutes) {
|
||||||
|
r.GET("", handler(func(c *gin.Context) (interface{}, error) {
|
||||||
|
devices, err := config.DeviceRepository().FetchByReference(ctxOf(c), models.RKAll, "")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return withSceneState(devices), nil
|
||||||
|
}))
|
||||||
|
|
||||||
|
r.GET("/:fetch", handler(func(c *gin.Context) (interface{}, error) {
|
||||||
|
devices, err := fetchDevices(ctxOf(c), c.Param("fetch"))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return withSceneState(devices), nil
|
||||||
|
}))
|
||||||
|
|
||||||
|
r.PUT("", handler(func(c *gin.Context) (interface{}, error) {
|
||||||
|
var body []struct {
|
||||||
|
Fetch string `json:"fetch"`
|
||||||
|
SetState models.NewDeviceState `json:"setState"`
|
||||||
|
}
|
||||||
|
err := parseBody(c, &body)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
set := make(map[int]bool)
|
||||||
|
changed := make([]models.Device, 0, 64)
|
||||||
|
for _, job := range body {
|
||||||
|
devices, err := fetchDevices(ctxOf(c), job.Fetch)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if len(devices) == 0 {
|
||||||
|
return []models.Device{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
for i := range devices {
|
||||||
|
if set[devices[i].ID] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
err := devices[i].SetState(job.SetState)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
set[devices[i].ID] = true
|
||||||
|
changed = append(changed, devices[i])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
config.PublishChannel <- changed
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
for _, device := range changed {
|
||||||
|
err := config.DeviceRepository().Save(context.Background(), &device, models.SMState)
|
||||||
|
if err != nil {
|
||||||
|
log.Println("Failed to save device for state:", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
return withSceneState(changed), nil
|
||||||
|
}))
|
||||||
|
|
||||||
|
r.PUT("/:fetch", handler(func(c *gin.Context) (interface{}, error) {
|
||||||
|
update := models.DeviceUpdate{}
|
||||||
|
err := parseBody(c, &update)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
devices, err := fetchDevices(ctxOf(c), c.Param("fetch"))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if len(devices) == 0 {
|
||||||
|
return []models.Device{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
for i := range devices {
|
||||||
|
devices[i].ApplyUpdate(update)
|
||||||
|
|
||||||
|
err := config.DeviceRepository().Save(context.Background(), &devices[i], models.SMProperties)
|
||||||
|
if err != nil {
|
||||||
|
log.Println("Failed to save device for state:", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return withSceneState(devices), nil
|
||||||
|
}))
|
||||||
|
|
||||||
|
r.PUT("/:fetch/state", handler(func(c *gin.Context) (interface{}, error) {
|
||||||
|
state := models.NewDeviceState{}
|
||||||
|
err := parseBody(c, &state)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
devices, err := fetchDevices(ctxOf(c), c.Param("fetch"))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if len(devices) == 0 {
|
||||||
|
return []models.Device{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
for i := range devices {
|
||||||
|
err := devices[i].SetState(state)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
config.PublishChannel <- devices
|
||||||
|
|
||||||
|
err = config.DeviceRepository().SaveMany(ctxOf(c), models.SMState, devices)
|
||||||
|
if err != nil {
|
||||||
|
log.Println("Failed to save devices states")
|
||||||
|
}
|
||||||
|
|
||||||
|
return withSceneState(devices), nil
|
||||||
|
}))
|
||||||
|
|
||||||
|
r.PUT("/:fetch/tags", handler(func(c *gin.Context) (interface{}, error) {
|
||||||
|
var body struct {
|
||||||
|
Add []string `json:"add"`
|
||||||
|
Remove []string `json:"remove"`
|
||||||
|
}
|
||||||
|
err := parseBody(c, &body)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
devices, err := fetchDevices(ctxOf(c), c.Param("fetch"))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if len(devices) == 0 {
|
||||||
|
return []models.Device{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
for i := range devices {
|
||||||
|
device := &devices[i]
|
||||||
|
|
||||||
|
for _, tag := range body.Add {
|
||||||
|
found := false
|
||||||
|
for _, tag2 := range device.Tags {
|
||||||
|
if tag == tag2 {
|
||||||
|
found = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !found {
|
||||||
|
device.Tags = append(device.Tags, tag)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, tag := range body.Remove {
|
||||||
|
index := -1
|
||||||
|
for i, tag2 := range device.Tags {
|
||||||
|
if tag == tag2 {
|
||||||
|
index = i
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if index == -1 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
device.Tags = append(device.Tags[:index], device.Tags[index+1:]...)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = config.DeviceRepository().Save(ctxOf(c), device, models.SMTags)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return withSceneState(devices), nil
|
||||||
|
}))
|
||||||
|
|
||||||
|
r.PUT("/:fetch/scene", handler(func(c *gin.Context) (interface{}, error) {
|
||||||
|
var body models.DeviceSceneAssignment
|
||||||
|
err := parseBody(c, &body)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
devices, err := fetchDevices(ctxOf(c), c.Param("fetch"))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if len(devices) == 0 {
|
||||||
|
return []models.Device{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = config.SceneRepository().Find(ctxOf(c), body.SceneID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if body.DurationMS < 0 {
|
||||||
|
body.DurationMS = 0
|
||||||
|
}
|
||||||
|
body.StartTime = time.Now()
|
||||||
|
|
||||||
|
pushMode := c.Query("push") == "true"
|
||||||
|
for i := range devices {
|
||||||
|
if pushMode {
|
||||||
|
devices[i].SceneAssignments = append(devices[i].SceneAssignments, body)
|
||||||
|
} else {
|
||||||
|
devices[i].SceneAssignments = []models.DeviceSceneAssignment{body}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
config.PublishChannel <- devices
|
||||||
|
|
||||||
|
err = config.DeviceRepository().SaveMany(ctxOf(c), 0, devices)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return withSceneState(devices), nil
|
||||||
|
}))
|
||||||
|
|
||||||
|
r.DELETE("/:fetch/scene", handler(func(c *gin.Context) (interface{}, error) {
|
||||||
|
devices, err := fetchDevices(ctxOf(c), c.Param("fetch"))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if len(devices) == 0 {
|
||||||
|
return []models.Device{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
for i := range devices {
|
||||||
|
devices[i].SceneAssignments = []models.DeviceSceneAssignment{}
|
||||||
|
}
|
||||||
|
config.PublishChannel <- devices
|
||||||
|
|
||||||
|
err = config.DeviceRepository().SaveMany(ctxOf(c), 0, devices)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return withSceneState(devices), nil
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
func withSceneState(devices []models.Device) []models.Device {
|
||||||
|
res := make([]models.Device, 0, len(devices))
|
||||||
|
for _, device := range devices {
|
||||||
|
device.SceneState = publisher.Global().SceneState(device.ID)
|
||||||
|
res = append(res, device)
|
||||||
|
}
|
||||||
|
|
||||||
|
return res
|
||||||
|
}
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"git.aiterp.net/lucifer/new-server/app/config"
|
||||||
|
"git.aiterp.net/lucifer/new-server/models"
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
func EventHandlers(r gin.IRoutes) {
|
||||||
|
r.GET("", handler(func(c *gin.Context) (interface{}, error) {
|
||||||
|
return config.EventHandlerRepository().FetchAll(ctxOf(c))
|
||||||
|
}))
|
||||||
|
|
||||||
|
r.GET("/:id", handler(func(c *gin.Context) (interface{}, error) {
|
||||||
|
return config.EventHandlerRepository().FindByID(ctxOf(c), intParam(c, "id"))
|
||||||
|
}))
|
||||||
|
|
||||||
|
r.POST("", handler(func(c *gin.Context) (interface{}, error) {
|
||||||
|
var body models.EventHandler
|
||||||
|
err := parseBody(c, &body)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if body.Conditions == nil {
|
||||||
|
body.Conditions = make(map[string]models.EventCondition)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = config.EventHandlerRepository().Save(ctxOf(c), &body)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return body, nil
|
||||||
|
}))
|
||||||
|
|
||||||
|
r.PUT("/:id", handler(func(c *gin.Context) (interface{}, error) {
|
||||||
|
|
||||||
|
|
||||||
|
handler, err := config.EventHandlerRepository().FindByID(ctxOf(c), intParam(c, "id"))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if c.Query("hard") == "true" {
|
||||||
|
var body models.EventHandler
|
||||||
|
err := parseBody(c, &body)
|
||||||
|
if err != nil || body.ID != intParam(c, "id") {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
handler = &body
|
||||||
|
} else {
|
||||||
|
var body models.EventHandlerUpdate
|
||||||
|
err := parseBody(c, &body)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
handler.ApplyUpdate(body)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = config.EventHandlerRepository().Save(ctxOf(c), handler)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return handler, nil
|
||||||
|
}))
|
||||||
|
|
||||||
|
r.DELETE("/:id", handler(func(c *gin.Context) (interface{}, error) {
|
||||||
|
handler, err := config.EventHandlerRepository().FindByID(ctxOf(c), intParam(c, "id"))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
err = config.EventHandlerRepository().Delete(ctxOf(c), handler)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return handler, nil
|
||||||
|
}))
|
||||||
|
}
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"git.aiterp.net/lucifer/new-server/app/config"
|
||||||
|
"git.aiterp.net/lucifer/new-server/models"
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
func ColorPresets(r gin.IRoutes) {
|
||||||
|
r.GET("", handler(func(c *gin.Context) (interface{}, error) {
|
||||||
|
return config.ColorPresetRepository().FetchAll(ctxOf(c))
|
||||||
|
}))
|
||||||
|
|
||||||
|
r.GET("/:id", handler(func(c *gin.Context) (interface{}, error) {
|
||||||
|
return config.ColorPresetRepository().Find(ctxOf(c), intParam(c, "id"))
|
||||||
|
}))
|
||||||
|
|
||||||
|
r.POST("", handler(func(c *gin.Context) (interface{}, error) {
|
||||||
|
var body struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
ColorString string `json:"colorString"`
|
||||||
|
}
|
||||||
|
err := parseBody(c, &body)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
newColor, err := models.ParseColorValue(body.ColorString)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
preset := models.ColorPreset{
|
||||||
|
Name: body.Name,
|
||||||
|
Value: newColor,
|
||||||
|
}
|
||||||
|
|
||||||
|
preset.Validate()
|
||||||
|
err = config.ColorPresetRepository().Save(ctxOf(c), &preset)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return preset, nil
|
||||||
|
}))
|
||||||
|
|
||||||
|
r.PUT("/:id", handler(func(c *gin.Context) (interface{}, error) {
|
||||||
|
var body struct {
|
||||||
|
Name *string `json:"name"`
|
||||||
|
ColorString *string `json:"colorString"`
|
||||||
|
}
|
||||||
|
err := parseBody(c, &body)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
preset, err := config.ColorPresetRepository().Find(ctxOf(c), intParam(c, "id"))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if body.Name != nil {
|
||||||
|
preset.Name = *body.Name
|
||||||
|
}
|
||||||
|
|
||||||
|
if body.ColorString != nil {
|
||||||
|
newColor, err := models.ParseColorValue(*body.ColorString)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
preset.Value = newColor
|
||||||
|
}
|
||||||
|
|
||||||
|
preset.Validate()
|
||||||
|
err = config.ColorPresetRepository().Save(ctxOf(c), &preset)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return preset, nil
|
||||||
|
}))
|
||||||
|
|
||||||
|
r.DELETE("/:id", handler(func(c *gin.Context) (interface{}, error) {
|
||||||
|
preset, err := config.ColorPresetRepository().Find(ctxOf(c), intParam(c, "id"))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
err = config.ColorPresetRepository().Delete(ctxOf(c), &preset)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return preset, nil
|
||||||
|
}))
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"git.aiterp.net/lucifer/new-server/app/config"
|
||||||
|
"git.aiterp.net/lucifer/new-server/app/services/publisher"
|
||||||
|
"git.aiterp.net/lucifer/new-server/models"
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
func Scenes(r gin.IRoutes) {
|
||||||
|
r.GET("", handler(func(c *gin.Context) (interface{}, error) {
|
||||||
|
return config.SceneRepository().FetchAll(ctxOf(c))
|
||||||
|
}))
|
||||||
|
|
||||||
|
r.GET("/:id", handler(func(c *gin.Context) (interface{}, error) {
|
||||||
|
return config.SceneRepository().Find(ctxOf(c), intParam(c, "id"))
|
||||||
|
}))
|
||||||
|
|
||||||
|
r.POST("", handler(func(c *gin.Context) (interface{}, error) {
|
||||||
|
var body models.Scene
|
||||||
|
err := parseBody(c, &body)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
err = body.Validate()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
err = config.SceneRepository().Save(ctxOf(c), &body)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
publisher.Global().UpdateScene(body)
|
||||||
|
|
||||||
|
return body, nil
|
||||||
|
}))
|
||||||
|
|
||||||
|
r.PUT("/:id", handler(func(c *gin.Context) (interface{}, error) {
|
||||||
|
var body models.Scene
|
||||||
|
err := parseBody(c, &body)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
scene, err := config.SceneRepository().Find(ctxOf(c), intParam(c, "id"))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
body.ID = scene.ID
|
||||||
|
err = body.Validate()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
err = config.SceneRepository().Save(ctxOf(c), &body)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
publisher.Global().UpdateScene(body)
|
||||||
|
|
||||||
|
return body, nil
|
||||||
|
}))
|
||||||
|
}
|
||||||
@@ -15,6 +15,13 @@ var errorMap = map[error]int{
|
|||||||
models.ErrBadColor: 400,
|
models.ErrBadColor: 400,
|
||||||
models.ErrInternal: 500,
|
models.ErrInternal: 500,
|
||||||
models.ErrUnknownColorFormat: 400,
|
models.ErrUnknownColorFormat: 400,
|
||||||
|
|
||||||
|
models.ErrSceneInvalidInterval: 400,
|
||||||
|
models.ErrSceneNoRoles: 400,
|
||||||
|
models.ErrSceneRoleNoStates: 400,
|
||||||
|
models.ErrSceneRoleUnsupportedOrdering: 422,
|
||||||
|
models.ErrSceneRoleUnknownEffect: 422,
|
||||||
|
models.ErrSceneRoleUnknownPowerMode: 422,
|
||||||
}
|
}
|
||||||
|
|
||||||
type response struct {
|
type response struct {
|
||||||
|
|||||||
@@ -0,0 +1,161 @@
|
|||||||
|
package client
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"git.aiterp.net/lucifer/new-server/models"
|
||||||
|
"io"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Client struct {
|
||||||
|
APIRoot string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (client *Client) GetDevices(ctx context.Context, fetchStr string) ([]models.Device, error) {
|
||||||
|
devices := make([]models.Device, 0, 16)
|
||||||
|
err := client.Fetch(ctx, "GET", "/api/devices/"+fetchStr, &devices, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return devices, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (client *Client) PutDevice(ctx context.Context, fetchStr string, update models.DeviceUpdate) ([]models.Device, error) {
|
||||||
|
devices := make([]models.Device, 0, 16)
|
||||||
|
err := client.Fetch(ctx, "PUT", "/api/devices/"+fetchStr, &devices, update)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return devices, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (client *Client) PutDeviceState(ctx context.Context, fetchStr string, update models.NewDeviceState) ([]models.Device, error) {
|
||||||
|
devices := make([]models.Device, 0, 16)
|
||||||
|
err := client.Fetch(ctx, "PUT", "/api/devices/"+fetchStr+"/state", &devices, update)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return devices, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (client *Client) PutDeviceTags(ctx context.Context, fetchStr string, addTags []string, removeTags []string) ([]models.Device, error) {
|
||||||
|
devices := make([]models.Device, 0, 16)
|
||||||
|
err := client.Fetch(ctx, "PUT", "/api/devices/"+fetchStr+"/tags", &devices, map[string][]string{
|
||||||
|
"add": addTags,
|
||||||
|
"remove": removeTags,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return devices, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (client *Client) AssignDevice(ctx context.Context, fetchStr string, push bool, assignment models.DeviceSceneAssignment) ([]models.Device, error) {
|
||||||
|
query := ""
|
||||||
|
if push {
|
||||||
|
query = "?push=true"
|
||||||
|
}
|
||||||
|
|
||||||
|
devices := make([]models.Device, 0, 16)
|
||||||
|
err := client.Fetch(ctx, "PUT", "/api/devices/"+fetchStr+"/scene"+query, &devices, assignment)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return devices, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (client *Client) ClearDevice(ctx context.Context, fetchStr string) ([]models.Device, error) {
|
||||||
|
devices := make([]models.Device, 0, 16)
|
||||||
|
err := client.Fetch(ctx, "DELETE", "/api/devices/"+fetchStr+"/scene", &devices, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return devices, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (client *Client) FireEvent(ctx context.Context, event models.Event) error {
|
||||||
|
err := client.Fetch(ctx, "POST", "/api/events", nil, event)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (client *Client) Fetch(ctx context.Context, method string, path string, dst interface{}, body interface{}) error {
|
||||||
|
var reqBody io.ReadWriter
|
||||||
|
if body != nil && method != "GET" {
|
||||||
|
reqBody = bytes.NewBuffer(make([]byte, 0, 512))
|
||||||
|
|
||||||
|
err := json.NewEncoder(reqBody).Encode(body)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
req, err := http.NewRequest(method, client.APIRoot+path, reqBody)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
res, err := httpClient.Do(req.WithContext(ctx))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer res.Body.Close()
|
||||||
|
|
||||||
|
if !strings.HasPrefix(res.Header.Get("Content-Type"), "application/json") {
|
||||||
|
return fmt.Errorf("%s: %s", path, res.Status)
|
||||||
|
}
|
||||||
|
|
||||||
|
var resJson struct {
|
||||||
|
Code int `json:"code"`
|
||||||
|
Message *string `json:"message"`
|
||||||
|
Data json.RawMessage `json:"data"`
|
||||||
|
}
|
||||||
|
err = json.NewDecoder(res.Body).Decode(&resJson)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if resJson.Code != 200 {
|
||||||
|
msg := ""
|
||||||
|
if resJson.Message != nil {
|
||||||
|
msg = *resJson.Message
|
||||||
|
}
|
||||||
|
|
||||||
|
return fmt.Errorf("%d: %s", resJson.Code, msg)
|
||||||
|
}
|
||||||
|
|
||||||
|
if dst == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return json.Unmarshal(resJson.Data, dst)
|
||||||
|
}
|
||||||
|
|
||||||
|
var httpClient = &http.Client{
|
||||||
|
Transport: &http.Transport{
|
||||||
|
Proxy: http.ProxyFromEnvironment,
|
||||||
|
DialContext: (&net.Dialer{
|
||||||
|
Timeout: 30 * time.Second,
|
||||||
|
KeepAlive: 30 * time.Second,
|
||||||
|
}).DialContext,
|
||||||
|
MaxIdleConns: 16,
|
||||||
|
MaxIdleConnsPerHost: 16,
|
||||||
|
IdleConnTimeout: time.Minute,
|
||||||
|
},
|
||||||
|
Timeout: time.Minute,
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
package client
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"git.aiterp.net/lucifer/new-server/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (client *Client) GetHandlers(ctx context.Context) ([]models.EventHandler, error) {
|
||||||
|
handlers := make([]models.EventHandler, 0, 16)
|
||||||
|
err := client.Fetch(ctx, "GET", "/api/event-handlers", &handlers, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return handlers, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (client *Client) GetHandler(ctx context.Context, id int) (*models.EventHandler, error) {
|
||||||
|
var handler models.EventHandler
|
||||||
|
err := client.Fetch(ctx, "GET", fmt.Sprintf("/api/event-handlers/%d", id), &handler, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return &handler, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (client *Client) PutHandler(ctx context.Context, handler *models.EventHandler) (*models.EventHandler, error) {
|
||||||
|
var response models.EventHandler
|
||||||
|
err := client.Fetch(ctx, "PUT", fmt.Sprintf("/api/event-handlers/%d?hard=true", handler.ID), &response, handler)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return &response, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (client *Client) AddHandler(ctx context.Context, handler models.EventHandler) (*models.EventHandler, error) {
|
||||||
|
var result models.EventHandler
|
||||||
|
err := client.Fetch(ctx, "POST", "/api/event-handlers", &result, handler)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return &result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (client *Client) DeleteHandler(ctx context.Context, id int) (*models.EventHandler, error) {
|
||||||
|
var handler models.EventHandler
|
||||||
|
err := client.Fetch(ctx, "DELETE", fmt.Sprintf("/api/event-handlers/%d", id), &handler, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return &handler, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
package client
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"git.aiterp.net/lucifer/new-server/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (client *Client) GetScenes(ctx context.Context) ([]models.Scene, error) {
|
||||||
|
scenes := make([]models.Scene, 0, 16)
|
||||||
|
err := client.Fetch(ctx, "GET", "/api/scenes", &scenes, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return scenes, nil
|
||||||
|
}
|
||||||
@@ -23,6 +23,10 @@ func DBX() *sqlx.DB {
|
|||||||
MySqlPort(),
|
MySqlPort(),
|
||||||
MySqlSchema(),
|
MySqlSchema(),
|
||||||
))
|
))
|
||||||
|
|
||||||
|
dbx.SetMaxIdleConns(20)
|
||||||
|
dbx.SetMaxOpenConns(40)
|
||||||
|
dbx.SetConnMaxLifetime(0)
|
||||||
}
|
}
|
||||||
|
|
||||||
return dbx
|
return dbx
|
||||||
|
|||||||
+18
-4
@@ -1,13 +1,27 @@
|
|||||||
package config
|
package config
|
||||||
|
|
||||||
import "git.aiterp.net/lucifer/new-server/models"
|
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"
|
||||||
|
)
|
||||||
|
|
||||||
var dp models.DriverProvider
|
var dp models.DriverProvider
|
||||||
|
var dpOnce sync.Once
|
||||||
|
|
||||||
func DriverProvider() models.DriverProvider {
|
func DriverProvider() models.DriverProvider {
|
||||||
if dp == nil {
|
dpOnce.Do(func() {
|
||||||
panic("not implemented yet")
|
dp = drivers.DriverMap{
|
||||||
}
|
models.DTNanoLeaf: &nanoleaf.Driver{},
|
||||||
|
models.DTHue: &hue.Driver{},
|
||||||
|
models.DTLIFX: &lifx.Driver{},
|
||||||
|
models.DTMill: &mill.Driver{},
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
return dp
|
return dp
|
||||||
}
|
}
|
||||||
|
|||||||
+9
-19
@@ -5,32 +5,22 @@ import (
|
|||||||
"git.aiterp.net/lucifer/new-server/models"
|
"git.aiterp.net/lucifer/new-server/models"
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
|
||||||
bRepo models.BridgeRepository
|
|
||||||
dRepo models.DeviceRepository
|
|
||||||
ehRepo models.EventHandlerRepository
|
|
||||||
)
|
|
||||||
|
|
||||||
func BridgeRepository() models.BridgeRepository {
|
func BridgeRepository() models.BridgeRepository {
|
||||||
if bRepo == nil {
|
return &mysql.BridgeRepo{DBX: DBX()}
|
||||||
bRepo = &mysql.BridgeRepo{DBX: DBX()}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
return bRepo
|
func ColorPresetRepository() models.ColorPresetRepository {
|
||||||
|
return &mysql.ColorPresetRepo{DBX: DBX()}
|
||||||
}
|
}
|
||||||
|
|
||||||
func DeviceRepository() models.DeviceRepository {
|
func DeviceRepository() models.DeviceRepository {
|
||||||
if dRepo == nil {
|
return &mysql.DeviceRepo{DBX: DBX()}
|
||||||
panic("panik")
|
|
||||||
}
|
|
||||||
|
|
||||||
return dRepo
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func EventHandlerRepository() models.EventHandlerRepository {
|
func EventHandlerRepository() models.EventHandlerRepository {
|
||||||
if ehRepo == nil {
|
return &mysql.EventHandlerRepo{DBX: DBX()}
|
||||||
panic("panik")
|
}
|
||||||
}
|
|
||||||
|
|
||||||
return ehRepo
|
func SceneRepository() models.SceneRepository {
|
||||||
|
return &mysql.SceneRepo{DBX: DBX()}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,24 +1,42 @@
|
|||||||
package app
|
package app
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"git.aiterp.net/lucifer/new-server/app/api"
|
"git.aiterp.net/lucifer/new-server/app/api"
|
||||||
"git.aiterp.net/lucifer/new-server/app/config"
|
"git.aiterp.net/lucifer/new-server/app/config"
|
||||||
"git.aiterp.net/lucifer/new-server/app/services"
|
"git.aiterp.net/lucifer/new-server/app/services"
|
||||||
|
"git.aiterp.net/lucifer/new-server/app/services/publisher"
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
"log"
|
"log"
|
||||||
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
func StartServer() {
|
func StartServer() {
|
||||||
|
setupCtx, cancel := context.WithTimeout(context.Background(), time.Second * 10)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
err := publisher.Initialize(setupCtx)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalln("Publish init failed:", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
services.StartEventHandler()
|
services.StartEventHandler()
|
||||||
|
services.ConnectToBridges()
|
||||||
|
services.CheckNewDevices()
|
||||||
|
|
||||||
gin.SetMode(gin.ReleaseMode)
|
gin.SetMode(gin.ReleaseMode)
|
||||||
ginny := gin.New()
|
ginny := gin.New()
|
||||||
|
|
||||||
apiGin := ginny.Group("/api")
|
apiGin := ginny.Group("/api")
|
||||||
api.Bridges(apiGin.Group("/bridges"))
|
api.Bridges(apiGin.Group("/bridges"))
|
||||||
|
api.Devices(apiGin.Group("/devices"))
|
||||||
|
api.ColorPresets(apiGin.Group("/color-presets"))
|
||||||
api.DriverKinds(apiGin.Group("/driver-kinds"))
|
api.DriverKinds(apiGin.Group("/driver-kinds"))
|
||||||
api.Events(apiGin.Group("/events"))
|
api.Events(apiGin.Group("/events"))
|
||||||
|
api.EventHandlers(apiGin.Group("/event-handlers"))
|
||||||
|
api.Scenes(apiGin.Group("/scenes"))
|
||||||
|
|
||||||
log.Fatal(ginny.Run(fmt.Sprintf("0.0.0.0:%d", config.ServerPort())))
|
log.Fatal(ginny.Run(fmt.Sprintf("0.0.0.0:%d", config.ServerPort())))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,66 @@
|
|||||||
|
package services
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"git.aiterp.net/lucifer/new-server/app/config"
|
||||||
|
"git.aiterp.net/lucifer/new-server/models"
|
||||||
|
"log"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
var cancelMap = make(map[int]context.CancelFunc, 8)
|
||||||
|
var cancelMutex sync.Mutex
|
||||||
|
|
||||||
|
func ConnectToBridges() {
|
||||||
|
go func() {
|
||||||
|
for {
|
||||||
|
err := runConnectToBridges()
|
||||||
|
if err != nil {
|
||||||
|
log.Println("Bridge connection error: ", err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
time.Sleep(15 * time.Second)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
func runConnectToBridges() error {
|
||||||
|
bridges, err := config.BridgeRepository().FetchAll(context.Background())
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, bridge := range bridges {
|
||||||
|
cancelMutex.Lock()
|
||||||
|
isRunning := cancelMap[bridge.ID] != nil
|
||||||
|
cancelMutex.Unlock()
|
||||||
|
if isRunning {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
driver, err := config.DriverProvider().Provide(bridge.Driver)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
cancelMutex.Lock()
|
||||||
|
cancelMap[bridge.ID] = cancel
|
||||||
|
cancelMutex.Unlock()
|
||||||
|
|
||||||
|
log.Printf("Running bridge \"%s\" (%d)", bridge.Name, bridge.ID)
|
||||||
|
|
||||||
|
go func(bridge models.Bridge, cancel func()) {
|
||||||
|
err = driver.Run(ctx, bridge, config.EventChannel)
|
||||||
|
log.Printf("Bridge \"%s\" (%d) stopped: %s", bridge.Name, bridge.ID, err)
|
||||||
|
|
||||||
|
cancelMutex.Lock()
|
||||||
|
cancel()
|
||||||
|
cancelMap[bridge.ID] = nil
|
||||||
|
cancelMutex.Unlock()
|
||||||
|
}(bridge, cancel)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
+195
-12
@@ -2,37 +2,61 @@ package services
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"fmt"
|
||||||
"git.aiterp.net/lucifer/new-server/app/config"
|
"git.aiterp.net/lucifer/new-server/app/config"
|
||||||
|
"git.aiterp.net/lucifer/new-server/app/services/publisher"
|
||||||
"git.aiterp.net/lucifer/new-server/models"
|
"git.aiterp.net/lucifer/new-server/models"
|
||||||
"log"
|
"log"
|
||||||
|
"math"
|
||||||
|
"sort"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
func StartEventHandler() {
|
func StartEventHandler() {
|
||||||
config.EventChannel<-models.Event{Name: "LuciferStarted"}
|
config.EventChannel <- models.Event{Name: "LuciferStarted"}
|
||||||
|
|
||||||
go func() {
|
go func() {
|
||||||
for event := range config.EventChannel {
|
for event := range config.EventChannel {
|
||||||
handleEvent(event)
|
responses := handleEvent(event)
|
||||||
|
|
||||||
|
for _, response := range responses {
|
||||||
|
handleEvent(response)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
|
||||||
// Dispatch an HourChanged event at every hour
|
// Generate TimeChanged event
|
||||||
go func() {
|
go func() {
|
||||||
drift := time.Now().Add(time.Minute).Truncate(time.Minute).Sub(time.Now())
|
drift := time.Now().Add(time.Minute).Truncate(time.Minute).Sub(time.Now())
|
||||||
time.Sleep(drift + time.Millisecond * 5)
|
time.Sleep(drift + time.Millisecond*5)
|
||||||
|
|
||||||
for range time.NewTicker(time.Minute).C {
|
for range time.NewTicker(time.Minute).C {
|
||||||
config.EventChannel <- models.Event{Name: "TimeChanged"}
|
config.EventChannel <- models.Event{Name: "TimeChanged", Payload: map[string]string{
|
||||||
|
"weekdayName": time.Now().In(loc).Format("Monday"),
|
||||||
|
"month": time.Now().In(loc).Format("01"),
|
||||||
|
"year": time.Now().In(loc).Format("2006"),
|
||||||
|
}}
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
}
|
}
|
||||||
|
|
||||||
var loc, _ = time.LoadLocation("Europe/Oslo")
|
var loc, _ = time.LoadLocation("Europe/Oslo")
|
||||||
var ctx = context.Background()
|
var ctx = context.Background()
|
||||||
var X = false
|
|
||||||
|
|
||||||
func handleEvent(event models.Event) {
|
func handleEvent(event models.Event) (responses []models.Event) {
|
||||||
|
var deadHandlers []models.EventHandler
|
||||||
|
|
||||||
|
startTime := time.Now()
|
||||||
|
defer func() {
|
||||||
|
duration := time.Since(startTime)
|
||||||
|
if duration > time.Millisecond*250 {
|
||||||
|
log.Println(event.Name, "took long to handle:", duration)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
if !event.HasPayload("hour") {
|
if !event.HasPayload("hour") {
|
||||||
event.AddPayload("hour", time.Now().In(loc).Format("15"))
|
event.AddPayload("hour", time.Now().In(loc).Format("15"))
|
||||||
}
|
}
|
||||||
@@ -40,21 +64,42 @@ func handleEvent(event models.Event) {
|
|||||||
event.AddPayload("minute", time.Now().In(loc).Format("04"))
|
event.AddPayload("minute", time.Now().In(loc).Format("04"))
|
||||||
}
|
}
|
||||||
|
|
||||||
if !X {
|
err := handleSpecial(event)
|
||||||
log.Println("Unhandled event: " + event.Name)
|
if err != nil {
|
||||||
return
|
log.Printf("Special event handler error (%s): %v", event.Name, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
paramStrings := make([]string, 0, 8)
|
||||||
|
for key, value := range event.Payload {
|
||||||
|
paramStrings = append(paramStrings, fmt.Sprintf("%s=%s", key, value))
|
||||||
|
}
|
||||||
|
sort.Strings(paramStrings)
|
||||||
|
|
||||||
|
log.Printf("Event %s(%s)", event.Name, strings.Join(paramStrings, ", "))
|
||||||
|
|
||||||
handlers, err := config.EventHandlerRepository().FetchAll(ctx)
|
handlers, err := config.EventHandlerRepository().FetchAll(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("Error fetchin event halders: %d", err)
|
log.Printf("Error fetchin' event halders: %s", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
allDevices := make([]models.Device, 0, 16)
|
||||||
|
actions := make(map[int]models.EventAction, 16)
|
||||||
|
priorities := make(map[int]int, 16)
|
||||||
|
highestPriority := 0
|
||||||
|
var prioritizedEvent *models.Event
|
||||||
|
|
||||||
for _, handler := range handlers {
|
for _, handler := range handlers {
|
||||||
|
if handler.EventName != event.Name {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !handler.IsWithinTimeRange() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
devices, err := config.DeviceRepository().FetchByReference(ctx, handler.TargetKind, handler.TargetValue)
|
devices, err := config.DeviceRepository().FetchByReference(ctx, handler.TargetKind, handler.TargetValue)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("Error fetchin event halders: %d", err)
|
log.Printf("Error fetchin' event devices: %s", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -62,5 +107,143 @@ func handleEvent(event models.Event) {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if handler.OneShot {
|
||||||
|
deadHandlers = append(deadHandlers, handler)
|
||||||
|
}
|
||||||
|
|
||||||
|
if handler.Priority > highestPriority {
|
||||||
|
highestPriority = handler.Priority
|
||||||
|
prioritizedEvent = handler.Actions.FireEvent
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, device := range devices {
|
||||||
|
_, ok := actions[device.ID]
|
||||||
|
if !ok {
|
||||||
|
allDevices = append(allDevices, device)
|
||||||
|
actions[device.ID] = handler.Actions
|
||||||
|
priorities[device.ID] = handler.Priority
|
||||||
|
} else if handler.Priority > priorities[device.ID] {
|
||||||
|
actions[device.ID] = handler.Actions
|
||||||
|
priorities[device.ID] = handler.Priority
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if prioritizedEvent != nil {
|
||||||
|
responses = append(responses, *prioritizedEvent)
|
||||||
|
}
|
||||||
|
|
||||||
|
for i, device := range allDevices {
|
||||||
|
action := actions[device.ID]
|
||||||
|
newState := models.NewDeviceState{}
|
||||||
|
|
||||||
|
newState.Color = action.SetColor
|
||||||
|
newState.Power = action.SetPower
|
||||||
|
|
||||||
|
if action.SetIntensity != nil {
|
||||||
|
newState.Intensity = action.SetIntensity
|
||||||
|
} else if action.AddIntensity != nil {
|
||||||
|
newIntensity := math.Max(0, math.Min(1, device.State.Intensity+*action.AddIntensity))
|
||||||
|
newState.Intensity = &newIntensity
|
||||||
|
}
|
||||||
|
|
||||||
|
if action.SetTemperature != nil {
|
||||||
|
newState.Temperature = action.SetTemperature
|
||||||
|
}
|
||||||
|
|
||||||
|
err = allDevices[i].SetState(newState)
|
||||||
|
if err != nil {
|
||||||
|
log.Println("Error updating state for device", device.ID, "err:", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if action.SetScene != nil {
|
||||||
|
action.SetScene.StartTime = time.Now()
|
||||||
|
allDevices[i].SceneAssignments = []models.DeviceSceneAssignment{*action.SetScene}
|
||||||
|
}
|
||||||
|
if action.PushScene != nil {
|
||||||
|
action.PushScene.StartTime = time.Now()
|
||||||
|
allDevices[i].SceneAssignments = append(allDevices[i].SceneAssignments, *action.PushScene)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
config.PublishChannel <- allDevices
|
||||||
|
|
||||||
|
wg := sync.WaitGroup{}
|
||||||
|
|
||||||
|
if len(allDevices) > 0 {
|
||||||
|
wg.Add(1)
|
||||||
|
go func() {
|
||||||
|
err := config.DeviceRepository().SaveMany(context.Background(), models.SMState, allDevices)
|
||||||
|
if err != nil {
|
||||||
|
log.Println("Failed to save devices' state:", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
wg.Done()
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, handler := range deadHandlers {
|
||||||
|
wg.Add(1)
|
||||||
|
|
||||||
|
go func(handler models.EventHandler) {
|
||||||
|
err := config.EventHandlerRepository().Delete(context.Background(), &handler)
|
||||||
|
if err != nil {
|
||||||
|
log.Println("Failed to delete spent one-shot event handler:", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
wg.Done()
|
||||||
|
}(handler)
|
||||||
|
}
|
||||||
|
|
||||||
|
wg.Wait()
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleSpecial(event models.Event) error {
|
||||||
|
switch event.Name {
|
||||||
|
case models.ENBridgeConnected:
|
||||||
|
bridgeId, _ := strconv.Atoi(event.Payload["bridgeId"])
|
||||||
|
bridge, err := config.BridgeRepository().Find(ctx, bridgeId)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
devices, err := config.DeviceRepository().FetchByReference(ctx, models.RKBridgeID, event.Payload["bridgeId"])
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
driver, err := config.DriverProvider().Provide(bridge.Driver)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,47 +0,0 @@
|
|||||||
package services
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"git.aiterp.net/lucifer/new-server/app/config"
|
|
||||||
"git.aiterp.net/lucifer/new-server/models"
|
|
||||||
"log"
|
|
||||||
)
|
|
||||||
|
|
||||||
func StartPublisher() {
|
|
||||||
ctx := context.Background()
|
|
||||||
|
|
||||||
go func() {
|
|
||||||
for devices := range config.PublishChannel {
|
|
||||||
if len(devices) == 0 {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
// Emergency solution! Please avoid!
|
|
||||||
// Send devices not belonging to the first channel separately
|
|
||||||
bridgeID := devices[0].BridgeID
|
|
||||||
for _, device := range devices {
|
|
||||||
if device.BridgeID != bridgeID {
|
|
||||||
config.PublishChannel<-[]models.Device{device}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
bridge, err := config.BridgeRepository().Find(ctx, devices[0].BridgeID)
|
|
||||||
if err != nil {
|
|
||||||
log.Println("Publishing error (1): " + err.Error())
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
driver, err := config.DriverProvider().Provide(bridge.Driver)
|
|
||||||
if err != nil {
|
|
||||||
log.Println("Publishing error (2): " + err.Error())
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
err = driver.Publish(ctx, bridge, devices)
|
|
||||||
if err != nil {
|
|
||||||
log.Println("Publishing error (3): " + err.Error())
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,330 @@
|
|||||||
|
package publisher
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"git.aiterp.net/lucifer/new-server/app/config"
|
||||||
|
"git.aiterp.net/lucifer/new-server/models"
|
||||||
|
"log"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Publisher struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
sceneData map[int]*models.Scene
|
||||||
|
scenes []*Scene
|
||||||
|
sceneAssignment map[int]*Scene
|
||||||
|
started map[int]bool
|
||||||
|
pending map[int][]models.Device
|
||||||
|
waiting map[int]chan struct{}
|
||||||
|
sensorCache map[int]models.SceneSensor
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Publisher) SceneState(deviceID int) *models.DeviceState {
|
||||||
|
p.mu.Lock()
|
||||||
|
defer p.mu.Unlock()
|
||||||
|
|
||||||
|
if s := p.sceneAssignment[deviceID]; s != nil {
|
||||||
|
return s.LastState(deviceID)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Publisher) UpdateScene(data models.Scene) {
|
||||||
|
p.mu.Lock()
|
||||||
|
p.sceneData[data.ID] = &data
|
||||||
|
|
||||||
|
for _, scene := range p.scenes {
|
||||||
|
if scene.data.ID == data.ID {
|
||||||
|
scene.UpdateScene(data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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 {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
p.mu.Lock()
|
||||||
|
for i, scene := range scenes {
|
||||||
|
p.sceneData[scene.ID] = &scenes[i]
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, scene := range p.scenes {
|
||||||
|
scene.UpdateScene(*p.sceneData[scene.data.ID])
|
||||||
|
}
|
||||||
|
p.mu.Unlock()
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Publisher) ReloadDevices(ctx context.Context) error {
|
||||||
|
devices, err := config.DeviceRepository().FetchByReference(ctx, models.RKAll, "")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
p.Publish(devices...)
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Publisher) Publish(devices ...models.Device) {
|
||||||
|
if len(devices) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
p.mu.Lock()
|
||||||
|
defer p.mu.Unlock()
|
||||||
|
|
||||||
|
for _, device := range devices {
|
||||||
|
if !p.started[device.BridgeID] {
|
||||||
|
p.started[device.BridgeID] = true
|
||||||
|
go p.runBridge(device.BridgeID)
|
||||||
|
}
|
||||||
|
|
||||||
|
p.reassignDevice(device)
|
||||||
|
|
||||||
|
if p.sceneAssignment[device.ID] != nil {
|
||||||
|
p.sceneAssignment[device.ID].UpsertDevice(device)
|
||||||
|
|
||||||
|
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 {
|
||||||
|
close(p.waiting[device.BridgeID])
|
||||||
|
p.waiting[device.BridgeID] = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Publisher) PublishChannel(ch <-chan []models.Device) {
|
||||||
|
for list := range ch {
|
||||||
|
p.Publish(list...)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Publisher) Run() {
|
||||||
|
ticker := time.NewTicker(time.Millisecond * 100)
|
||||||
|
deleteList := make([]int, 0, 8)
|
||||||
|
updatedList := make([]models.Device, 0, 16)
|
||||||
|
|
||||||
|
for range ticker.C {
|
||||||
|
deleteList = deleteList[:0]
|
||||||
|
updatedList = updatedList[:0]
|
||||||
|
|
||||||
|
p.mu.Lock()
|
||||||
|
for i, scene := range p.scenes {
|
||||||
|
if (!scene.endTime.IsZero() && time.Now().After(scene.endTime)) || scene.Empty() {
|
||||||
|
deleteList = append(deleteList, i-len(deleteList))
|
||||||
|
updatedList = append(updatedList, scene.AllDevices()...)
|
||||||
|
|
||||||
|
log.Printf("Removing scene instance for %s (%d)", scene.data.Name, scene.data.ID)
|
||||||
|
|
||||||
|
for _, device := range scene.AllDevices() {
|
||||||
|
p.sceneAssignment[device.ID] = nil
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if scene.Due() {
|
||||||
|
updatedList = append(updatedList, scene.Run()...)
|
||||||
|
updatedList = append(updatedList, scene.UnaffectedDevices()...)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, i := range deleteList {
|
||||||
|
p.scenes = append(p.scenes[:i], p.scenes[i+1:]...)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, device := range updatedList {
|
||||||
|
if !p.started[device.BridgeID] {
|
||||||
|
p.started[device.BridgeID] = true
|
||||||
|
go p.runBridge(device.BridgeID)
|
||||||
|
}
|
||||||
|
|
||||||
|
p.pending[device.BridgeID] = append(p.pending[device.BridgeID], device)
|
||||||
|
if p.waiting[device.BridgeID] != nil {
|
||||||
|
close(p.waiting[device.BridgeID])
|
||||||
|
p.waiting[device.BridgeID] = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
p.mu.Unlock()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// reassignDevice re-evaluates the device's scene assignment config. It will return whether the scene changed, which
|
||||||
|
// should trigger an update.
|
||||||
|
func (p *Publisher) reassignDevice(device models.Device) bool {
|
||||||
|
var selectedAssignment *models.DeviceSceneAssignment
|
||||||
|
for _, assignment := range device.SceneAssignments {
|
||||||
|
duration := time.Duration(assignment.DurationMS) * time.Millisecond
|
||||||
|
if duration <= 0 || time.Now().Before(assignment.StartTime.Add(duration)) {
|
||||||
|
selectedAssignment = &assignment
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if selectedAssignment == nil {
|
||||||
|
if p.sceneAssignment[device.ID] != nil {
|
||||||
|
p.sceneAssignment[device.ID].RemoveDevice(device)
|
||||||
|
delete(p.sceneAssignment, device.ID)
|
||||||
|
|
||||||
|
// Scene changed
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stop here, no scene should be assigned.
|
||||||
|
return false
|
||||||
|
} else {
|
||||||
|
if p.sceneData[selectedAssignment.SceneID] == nil {
|
||||||
|
// Freeze until scene becomes available.
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if p.sceneAssignment[device.ID] != nil {
|
||||||
|
scene := p.sceneAssignment[device.ID]
|
||||||
|
if scene.data.ID == selectedAssignment.SceneID && scene.group == selectedAssignment.Group {
|
||||||
|
// Current assignment is good.
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
p.sceneAssignment[device.ID].RemoveDevice(device)
|
||||||
|
delete(p.sceneAssignment, device.ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, scene := range p.scenes {
|
||||||
|
if scene.data.ID == selectedAssignment.SceneID && scene.group == selectedAssignment.Group {
|
||||||
|
p.sceneAssignment[device.ID] = scene
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
newScene := &Scene{
|
||||||
|
data: p.sceneData[selectedAssignment.SceneID],
|
||||||
|
group: selectedAssignment.Group,
|
||||||
|
startTime: selectedAssignment.StartTime,
|
||||||
|
endTime: selectedAssignment.StartTime.Add(time.Duration(selectedAssignment.DurationMS) * time.Millisecond),
|
||||||
|
roleMap: make(map[int]int, 16),
|
||||||
|
roleList: make(map[int][]models.Device, 16),
|
||||||
|
lastStates: make(map[int]models.DeviceState, 16),
|
||||||
|
due: true,
|
||||||
|
}
|
||||||
|
p.sceneAssignment[device.ID] = newScene
|
||||||
|
p.scenes = append(p.scenes, newScene)
|
||||||
|
|
||||||
|
if selectedAssignment.DurationMS <= 0 {
|
||||||
|
newScene.endTime = time.Time{}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Publisher) runBridge(id int) {
|
||||||
|
defer func() {
|
||||||
|
p.mu.Lock()
|
||||||
|
p.started[id] = false
|
||||||
|
p.mu.Unlock()
|
||||||
|
}()
|
||||||
|
|
||||||
|
bridge, err := config.BridgeRepository().Find(context.Background(), id)
|
||||||
|
if err != nil {
|
||||||
|
log.Println("Failed to get bridge data:", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
driver, err := config.DriverProvider().Provide(bridge.Driver)
|
||||||
|
if err != nil {
|
||||||
|
log.Println("Failed to get bridge driver:", err)
|
||||||
|
log.Println("Maybe Lucifer needs to be updated.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
devices := make(map[int]models.Device)
|
||||||
|
|
||||||
|
for {
|
||||||
|
p.mu.Lock()
|
||||||
|
if len(p.pending[id]) == 0 {
|
||||||
|
if p.waiting[id] == nil {
|
||||||
|
p.waiting[id] = make(chan struct{})
|
||||||
|
}
|
||||||
|
waitCh := p.waiting[id]
|
||||||
|
p.mu.Unlock()
|
||||||
|
<-waitCh
|
||||||
|
p.mu.Lock()
|
||||||
|
}
|
||||||
|
|
||||||
|
updates := p.pending[id]
|
||||||
|
p.pending[id] = p.pending[id][:0:0]
|
||||||
|
p.mu.Unlock()
|
||||||
|
|
||||||
|
// Only allow the latest update per device (this avoids slow bridges causing a backlog of cations).
|
||||||
|
for key := range devices {
|
||||||
|
delete(devices, key)
|
||||||
|
}
|
||||||
|
for _, update := range updates {
|
||||||
|
devices[update.ID] = update
|
||||||
|
}
|
||||||
|
updates = updates[:0]
|
||||||
|
for _, value := range devices {
|
||||||
|
updates = append(updates, value)
|
||||||
|
}
|
||||||
|
|
||||||
|
err := driver.Publish(context.Background(), bridge, updates)
|
||||||
|
if err != nil {
|
||||||
|
log.Println("Failed to publish to driver:", err)
|
||||||
|
|
||||||
|
p.mu.Lock()
|
||||||
|
p.pending[id] = append(updates, p.pending[id]...)
|
||||||
|
p.mu.Unlock()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var publisher = Publisher{
|
||||||
|
sceneData: make(map[int]*models.Scene),
|
||||||
|
scenes: make([]*Scene, 0, 16),
|
||||||
|
sceneAssignment: make(map[int]*Scene, 16),
|
||||||
|
started: make(map[int]bool),
|
||||||
|
pending: make(map[int][]models.Device),
|
||||||
|
waiting: make(map[int]chan struct{}),
|
||||||
|
sensorCache: make(map[int]models.SceneSensor),
|
||||||
|
}
|
||||||
|
|
||||||
|
func Global() *Publisher {
|
||||||
|
return &publisher
|
||||||
|
}
|
||||||
|
|
||||||
|
func Initialize(ctx context.Context) error {
|
||||||
|
err := publisher.ReloadScenes(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
err = publisher.ReloadDevices(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
go publisher.Run()
|
||||||
|
time.Sleep(time.Millisecond * 50)
|
||||||
|
go publisher.PublishChannel(config.PublishChannel)
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,225 @@
|
|||||||
|
package publisher
|
||||||
|
|
||||||
|
import (
|
||||||
|
"git.aiterp.net/lucifer/new-server/models"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Scene struct {
|
||||||
|
data *models.Scene
|
||||||
|
group string
|
||||||
|
startTime time.Time
|
||||||
|
endTime time.Time
|
||||||
|
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)
|
||||||
|
|
||||||
|
// Collect all devices into the undefined role (-1)
|
||||||
|
for _, list := range s.roleList {
|
||||||
|
for _, device := range list {
|
||||||
|
devices = append(devices, device)
|
||||||
|
s.roleMap[device.ID] = -1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
s.roleList = map[int][]models.Device{-1: devices}
|
||||||
|
|
||||||
|
// Update data and reset devices.
|
||||||
|
s.data = &data
|
||||||
|
for _, device := range append(devices[:0:0], devices...) {
|
||||||
|
s.UpsertDevice(device)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpsertDevice moves the device if necessary and updates its state.
|
||||||
|
func (s *Scene) UpsertDevice(device models.Device) {
|
||||||
|
if s.data == nil {
|
||||||
|
s.roleMap[device.ID] = -1
|
||||||
|
s.roleList[-1] = append(s.roleList[-1], device)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
oldIndex, hasOldIndex := s.roleMap[device.ID]
|
||||||
|
newIndex := s.data.RoleIndex(&device)
|
||||||
|
|
||||||
|
s.roleMap[device.ID] = newIndex
|
||||||
|
|
||||||
|
if hasOldIndex {
|
||||||
|
for i, device2 := range s.roleList[oldIndex] {
|
||||||
|
if device2.ID == device.ID {
|
||||||
|
s.roleList[oldIndex] = append(s.roleList[oldIndex][:i], s.roleList[oldIndex][i+1:]...)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
s.due = true
|
||||||
|
|
||||||
|
s.roleList[newIndex] = append(s.roleList[newIndex], device)
|
||||||
|
if newIndex != -1 {
|
||||||
|
s.data.Roles[newIndex].ApplyOrder(s.roleList[newIndex])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// RemoveDevice finds and remove a device. It's a noop if the device does not exist in this scene.
|
||||||
|
func (s *Scene) RemoveDevice(device models.Device) {
|
||||||
|
roleIndex, hasRoleIndex := s.roleMap[device.ID]
|
||||||
|
if !hasRoleIndex {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
for i, device2 := range s.roleList[roleIndex] {
|
||||||
|
if device2.ID == device.ID {
|
||||||
|
s.roleList[roleIndex] = append(s.roleList[roleIndex][:i], s.roleList[roleIndex][i+1:]...)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
s.due = true
|
||||||
|
|
||||||
|
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 {
|
||||||
|
for _, list := range s.roleList {
|
||||||
|
if len(list) > 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Scene) Due() bool {
|
||||||
|
if s.due {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
if s.data.IntervalMS > 0 {
|
||||||
|
interval := time.Duration(s.data.IntervalMS) * time.Millisecond
|
||||||
|
return int64(time.Since(s.startTime)/interval) != s.lastInterval
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Scene) UnaffectedDevices() []models.Device {
|
||||||
|
return append(s.roleList[-1][:0:0], s.roleList[-1]...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Scene) AllDevices() []models.Device {
|
||||||
|
res := make([]models.Device, 0, 16)
|
||||||
|
for _, list := range s.roleList {
|
||||||
|
res = append(res, list...)
|
||||||
|
}
|
||||||
|
|
||||||
|
return res
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run runs the scene
|
||||||
|
func (s *Scene) Run() []models.Device {
|
||||||
|
if s.data == nil {
|
||||||
|
return []models.Device{}
|
||||||
|
}
|
||||||
|
|
||||||
|
currentTime := time.Now()
|
||||||
|
intervalNumber := int64(0)
|
||||||
|
intervalMax := int64(1)
|
||||||
|
if s.data.IntervalMS > 0 {
|
||||||
|
interval := time.Duration(s.data.IntervalMS) * time.Millisecond
|
||||||
|
intervalNumber = int64(time.Since(s.startTime) / interval)
|
||||||
|
|
||||||
|
if !s.endTime.IsZero() {
|
||||||
|
intervalMax = int64(s.endTime.Sub(s.startTime) / interval)
|
||||||
|
} else {
|
||||||
|
intervalMax = intervalNumber + 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
updatedDevices := make([]models.Device, 0, 16)
|
||||||
|
for i, list := range s.roleList {
|
||||||
|
if i == -1 {
|
||||||
|
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{
|
||||||
|
CurrentTime: currentTime,
|
||||||
|
Index: j - jSkip,
|
||||||
|
Length: len(list) - sensorCount,
|
||||||
|
IntervalNumber: intervalNumber,
|
||||||
|
IntervalMax: intervalMax,
|
||||||
|
Sensors: s.sensors,
|
||||||
|
})
|
||||||
|
|
||||||
|
err := device.SetState(newState)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
s.lastStates[device.ID] = device.State
|
||||||
|
|
||||||
|
updatedDevices = append(updatedDevices, device)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
s.due = false
|
||||||
|
s.lastInterval = intervalNumber
|
||||||
|
|
||||||
|
return updatedDevices
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Scene) LastState(id int) *models.DeviceState {
|
||||||
|
lastState, ok := s.lastStates[id]
|
||||||
|
if !ok {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return &lastState
|
||||||
|
}
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
package services
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"git.aiterp.net/lucifer/new-server/app/config"
|
||||||
|
"git.aiterp.net/lucifer/new-server/models"
|
||||||
|
"log"
|
||||||
|
"strconv"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func CheckNewDevices() {
|
||||||
|
go func() {
|
||||||
|
// Wait a bit before the first to let bridges connect.
|
||||||
|
time.Sleep(time.Second * 5)
|
||||||
|
err := checkNewDevices()
|
||||||
|
if err != nil {
|
||||||
|
log.Println("Failed to sync lights:", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
for range time.NewTicker(time.Second * 30).C {
|
||||||
|
err := checkNewDevices()
|
||||||
|
if err != nil {
|
||||||
|
log.Println("Failed to sync lights:", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
func checkNewDevices() error {
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), time.Second*27)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
bridges, err := config.BridgeRepository().FetchAll(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, bridge := range bridges {
|
||||||
|
driver, err := config.DriverProvider().Provide(bridge.Driver)
|
||||||
|
if err != nil {
|
||||||
|
log.Println("Unknown/unsupported driver:", bridge.Driver)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
savedDevices, err := config.DeviceRepository().FetchByReference(ctx, models.RKBridgeID, strconv.Itoa(bridge.ID))
|
||||||
|
if err != nil {
|
||||||
|
log.Println("Failed to list devices from db:", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
driverDevices, err := driver.ListDevices(ctx, bridge)
|
||||||
|
if err != nil {
|
||||||
|
log.Println("Failed to list devices from driver:", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
foundNewDevices := false
|
||||||
|
SaveLoop:
|
||||||
|
for _, driverDevice := range driverDevices {
|
||||||
|
for _, savedDevice := range savedDevices {
|
||||||
|
if savedDevice.InternalID == driverDevice.InternalID {
|
||||||
|
continue SaveLoop
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Println("Saving new device", driverDevice.InternalID)
|
||||||
|
|
||||||
|
err := config.DeviceRepository().Save(ctx, &driverDevice, models.SMState|models.SMProperties|models.SMTags)
|
||||||
|
if err != nil {
|
||||||
|
log.Println("Failed to save device:", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
foundNewDevices = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// If new devices were found, publish them so that the driver can be set up.
|
||||||
|
if foundNewDevices {
|
||||||
|
savedDevices, err := config.DeviceRepository().FetchByReference(ctx, models.RKBridgeID, strconv.Itoa(bridge.ID))
|
||||||
|
if err != nil {
|
||||||
|
log.Println("Failed to fetch devices from db second time:", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
err = driver.Publish(ctx, bridge, savedDevices)
|
||||||
|
if err != nil {
|
||||||
|
log.Println("Failed to list devices from db:", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
+59
-22
@@ -3,9 +3,10 @@ package main
|
|||||||
import (
|
import (
|
||||||
"bufio"
|
"bufio"
|
||||||
"context"
|
"context"
|
||||||
|
"encoding/json"
|
||||||
"flag"
|
"flag"
|
||||||
"fmt"
|
"fmt"
|
||||||
"git.aiterp.net/lucifer/new-server/internal/drivers/nanoleaf"
|
"git.aiterp.net/lucifer/new-server/app/config"
|
||||||
"git.aiterp.net/lucifer/new-server/models"
|
"git.aiterp.net/lucifer/new-server/models"
|
||||||
"log"
|
"log"
|
||||||
"os"
|
"os"
|
||||||
@@ -15,7 +16,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
var flagDriver = flag.String("driver", "Nanoleaf", "The bridge driver to use")
|
var flagDriver = flag.String("driver", string(models.DTNanoLeaf), "The bridge driver to use")
|
||||||
var flagAddress = flag.String("address", "127.0.0.1", "The bridge's address")
|
var flagAddress = flag.String("address", "127.0.0.1", "The bridge's address")
|
||||||
var flagToken = flag.String("token", "", "The bridge's access token / api key / login")
|
var flagToken = flag.String("token", "", "The bridge's access token / api key / login")
|
||||||
var flagPair = flag.Bool("pair", false, "Try to pair with the bridge.")
|
var flagPair = flag.Bool("pair", false, "Try to pair with the bridge.")
|
||||||
@@ -25,11 +26,14 @@ var flagSearchTimeout = flag.Duration("search-timeout", time.Second*3, "Timeout
|
|||||||
func main() {
|
func main() {
|
||||||
flag.Parse()
|
flag.Parse()
|
||||||
|
|
||||||
// TODO: Select driver
|
// Find drivers
|
||||||
driver := nanoleaf.Driver{}
|
driver, err := config.DriverProvider().Provide(models.DriverKind(*flagDriver))
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalln("Failed to find driver:", err)
|
||||||
|
}
|
||||||
|
|
||||||
// Find bridge
|
// Find bridge
|
||||||
bridges, err := driver.SearchBridge(context.Background(), *flagAddress, !*flagPair)
|
bridges, err := driver.SearchBridge(context.Background(), *flagAddress, *flagToken, !*flagPair)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatalln("Failed to search bridge:", err)
|
log.Fatalln("Failed to search bridge:", err)
|
||||||
}
|
}
|
||||||
@@ -43,6 +47,15 @@ func main() {
|
|||||||
log.Println("New token:", bridge.Token)
|
log.Println("New token:", bridge.Token)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ch := config.EventChannel
|
||||||
|
go func() {
|
||||||
|
err := driver.Run(context.Background(), bridge, ch)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalln("Run bridge stopped:", err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
time.Sleep(time.Second)
|
||||||
|
|
||||||
// List devices
|
// List devices
|
||||||
var devices []models.Device
|
var devices []models.Device
|
||||||
if *flagSearch {
|
if *flagSearch {
|
||||||
@@ -56,20 +69,14 @@ func main() {
|
|||||||
log.Fatalln("Failed to list devices:", err)
|
log.Fatalln("Failed to list devices:", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
idMap := make(map[string]int)
|
||||||
|
nextId := len(devices) + 1
|
||||||
for i := range devices {
|
for i := range devices {
|
||||||
devices[i].ID = i + 1
|
devices[i].ID = i + 1
|
||||||
|
idMap[devices[i].InternalID] = i + 1
|
||||||
}
|
}
|
||||||
|
|
||||||
_ = driver.Publish(context.Background(), bridge, devices)
|
|
||||||
|
|
||||||
ch := make(chan models.Event)
|
|
||||||
go func() {
|
|
||||||
err := driver.Run(context.Background(), bridge, ch)
|
|
||||||
if err != nil {
|
|
||||||
log.Fatalln("Run bridge stopped:", err)
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
|
|
||||||
go func() {
|
go func() {
|
||||||
reader := bufio.NewReader(os.Stdin)
|
reader := bufio.NewReader(os.Stdin)
|
||||||
|
|
||||||
@@ -83,6 +90,35 @@ func main() {
|
|||||||
text, _ := reader.ReadString('\n')
|
text, _ := reader.ReadString('\n')
|
||||||
text = strings.Trim(text, "\t \r\n")
|
text = strings.Trim(text, "\t \r\n")
|
||||||
|
|
||||||
|
if text == "search" {
|
||||||
|
_, _ = driver.SearchDevices(context.Background(), bridge, time.Second)
|
||||||
|
}
|
||||||
|
if text == "list" || text == "search" || text == "json" {
|
||||||
|
devices, err = driver.ListDevices(context.Background(), bridge)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalln("Failed to list devices:", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
for i, device := range devices {
|
||||||
|
if extId, ok := idMap[device.InternalID]; ok {
|
||||||
|
devices[i].ID = extId
|
||||||
|
} else {
|
||||||
|
idMap[device.InternalID] = nextId
|
||||||
|
devices[i].ID = nextId
|
||||||
|
|
||||||
|
nextId += 1
|
||||||
|
}
|
||||||
|
|
||||||
|
if text != "json" {
|
||||||
|
_, _ = fmt.Fprintf(os.Stderr, "Device: %d - %s %+v\n", device.ID, device.InternalID, device.Capabilities)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if text == "json" {
|
||||||
|
j, _ := json.MarshalIndent(devices, "", " ")
|
||||||
|
fmt.Println(string(j))
|
||||||
|
}
|
||||||
|
|
||||||
tokens := strings.Split(text, " ")
|
tokens := strings.Split(text, " ")
|
||||||
if len(tokens) < 4 {
|
if len(tokens) < 4 {
|
||||||
continue
|
continue
|
||||||
@@ -120,14 +156,15 @@ func main() {
|
|||||||
if id == -1 || id == device.ID {
|
if id == -1 || id == device.ID {
|
||||||
if (color.IsKelvin() && device.HasCapability(models.DCColorKelvin)) || (color.IsHueSat() && device.HasCapability(models.DCColorHS)) {
|
if (color.IsKelvin() && device.HasCapability(models.DCColorKelvin)) || (color.IsHueSat() && device.HasCapability(models.DCColorHS)) {
|
||||||
device.State.Color = color
|
device.State.Color = color
|
||||||
if device.HasCapability(models.DCPower) {
|
|
||||||
device.State.Power = power
|
|
||||||
}
|
|
||||||
if device.HasCapability(models.DCIntensity) {
|
|
||||||
device.State.Intensity = intensity
|
|
||||||
}
|
|
||||||
updatedDevices = append(updatedDevices, device)
|
|
||||||
}
|
}
|
||||||
|
if device.HasCapability(models.DCPower) {
|
||||||
|
device.State.Power = power
|
||||||
|
}
|
||||||
|
if device.HasCapability(models.DCIntensity) {
|
||||||
|
device.State.Intensity = intensity
|
||||||
|
}
|
||||||
|
|
||||||
|
updatedDevices = append(updatedDevices, device)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+9
-1
@@ -4,11 +4,14 @@ import (
|
|||||||
"git.aiterp.net/lucifer/new-server/app/config"
|
"git.aiterp.net/lucifer/new-server/app/config"
|
||||||
"github.com/pressly/goose"
|
"github.com/pressly/goose"
|
||||||
"log"
|
"log"
|
||||||
|
"os"
|
||||||
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
db := config.DBX().DB
|
db := config.DBX().DB
|
||||||
|
|
||||||
|
log.Printf("Target version: %s",time.Now().Format("20060102150405"))
|
||||||
log.Printf("Database: %s:%d/%s", config.MySqlHost(), config.MySqlPort(), config.MySqlSchema())
|
log.Printf("Database: %s:%d/%s", config.MySqlHost(), config.MySqlPort(), config.MySqlSchema())
|
||||||
log.Printf("Authenticating as: %s", config.MySqlUsername())
|
log.Printf("Authenticating as: %s", config.MySqlUsername())
|
||||||
|
|
||||||
@@ -17,7 +20,12 @@ func main() {
|
|||||||
log.Fatal(err)
|
log.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
err = goose.Run("up", db, "./scripts")
|
cmd := os.Getenv("GOOSE_COMMAND")
|
||||||
|
if cmd == "" {
|
||||||
|
cmd = "up"
|
||||||
|
}
|
||||||
|
|
||||||
|
err = goose.Run(cmd, db, "./scripts")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatal(err)
|
log.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,277 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"git.aiterp.net/lucifer/new-server/models"
|
||||||
|
"log"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Param struct {
|
||||||
|
Index int
|
||||||
|
Key string
|
||||||
|
Value string
|
||||||
|
Operator string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Param) String() *string {
|
||||||
|
if p == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return &p.Value
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Param) TimeOfDay() *models.TimeOfDay {
|
||||||
|
if p == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
tod, err := models.ParseTimeOfDay(p.Value)
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return &tod
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Param) TimeOfDayOr(fallback models.TimeOfDay) models.TimeOfDay {
|
||||||
|
tod := p.TimeOfDay()
|
||||||
|
if tod == nil {
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
return *tod
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Param) StringOr(fallback string) string {
|
||||||
|
if p == nil {
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
return p.Value
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Param) Int() *int {
|
||||||
|
if p == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
n, err := strconv.Atoi(p.Value)
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return &n
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Param) IntOr(other int) int {
|
||||||
|
val := p.Int()
|
||||||
|
if val == nil {
|
||||||
|
return other
|
||||||
|
}
|
||||||
|
|
||||||
|
return *val
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Param) Float() *float64 {
|
||||||
|
if p == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
n, err := strconv.ParseFloat(p.Value, 64)
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return &n
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Param) Bool() *bool {
|
||||||
|
if p == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
v := strings.ToLower(p.Value)
|
||||||
|
if v == "yes" || v == "true" || v == "on" {
|
||||||
|
r := true
|
||||||
|
return &r
|
||||||
|
} else if v == "no" || v == "false" || v == "off" {
|
||||||
|
r := false
|
||||||
|
return &r
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type Params []Param
|
||||||
|
|
||||||
|
func (p Params) Get(key interface{}) *Param {
|
||||||
|
switch key := key.(type) {
|
||||||
|
case string:
|
||||||
|
for _, p := range p {
|
||||||
|
if p.Key == key {
|
||||||
|
return &p
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case int:
|
||||||
|
for _, p := range p {
|
||||||
|
if p.Index == key {
|
||||||
|
return &p
|
||||||
|
}
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
log.Panicf("Incorrect key type %T", key)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p Params) Subset(prefix string) Params {
|
||||||
|
if len(p) == 0 {
|
||||||
|
return Params{}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(prefix) > 0 && !strings.HasSuffix(prefix, ".") {
|
||||||
|
prefix += "."
|
||||||
|
}
|
||||||
|
|
||||||
|
res := make(Params, 0, len(p))
|
||||||
|
|
||||||
|
for _, param := range p {
|
||||||
|
if param.Index == -1 && strings.HasPrefix(param.Key, prefix) {
|
||||||
|
res = append(res, Param{Index: -1, Key: param.Key[len(prefix):], Value: param.Value, Operator: param.Operator})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return res
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p Params) StringMap() map[string]string {
|
||||||
|
res := make(map[string]string)
|
||||||
|
for _, param := range p {
|
||||||
|
if param.Index == -1 {
|
||||||
|
res[param.Key] = param.Value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return res
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p Params) StringPtrMap() map[string]*string {
|
||||||
|
res := make(map[string]*string)
|
||||||
|
for _, param := range p {
|
||||||
|
if param.Index == -1 {
|
||||||
|
if param.Value == "NULL" {
|
||||||
|
res[param.Key] = nil
|
||||||
|
} else {
|
||||||
|
res[param.Key] = ¶m.Value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return res
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p Params) Strings(minIndex int) []string {
|
||||||
|
res := make([]string, 0, len(p))
|
||||||
|
for _, param := range p {
|
||||||
|
if param.Index >= minIndex {
|
||||||
|
res = append(res, param.Value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return res
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p Params) EventConditions() map[string]models.EventCondition {
|
||||||
|
ecMap := make(map[string]models.EventCondition, len(p))
|
||||||
|
|
||||||
|
for _, param := range p {
|
||||||
|
element, ok := ecMap[param.Key]
|
||||||
|
if !ok {
|
||||||
|
element = models.EventCondition{}
|
||||||
|
}
|
||||||
|
|
||||||
|
switch param.Operator {
|
||||||
|
case "<":
|
||||||
|
element.LT = param.Value
|
||||||
|
case "<=":
|
||||||
|
element.LTE = param.Value
|
||||||
|
case ">=":
|
||||||
|
element.GTE = param.Value
|
||||||
|
case ">":
|
||||||
|
element.GT = param.Value
|
||||||
|
default:
|
||||||
|
element.EQ = param.Value
|
||||||
|
}
|
||||||
|
|
||||||
|
ecMap[param.Key] = element
|
||||||
|
}
|
||||||
|
|
||||||
|
return ecMap
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p Params) DeviceState(prefix string) models.NewDeviceState {
|
||||||
|
return models.NewDeviceState{
|
||||||
|
Power: p.Get(prefix + "power").Bool(),
|
||||||
|
Color: p.Get(prefix + "color").String(),
|
||||||
|
Intensity: p.Get(prefix + "intensity").Float(),
|
||||||
|
Temperature: p.Get(prefix + "temperature").Int(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p Params) SceneAssignment(prefix string) *models.DeviceSceneAssignment {
|
||||||
|
if p.Get(prefix+"scene") == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return &models.DeviceSceneAssignment{
|
||||||
|
SceneID: p.Get(prefix+"scene").IntOr(-1),
|
||||||
|
Group: p.Get(prefix+"scene.group").StringOr(time.Now().Format(time.RFC3339)),
|
||||||
|
DurationMS: int64(p.Get(prefix+"scene.duration").IntOr(0)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type Command struct {
|
||||||
|
Name string
|
||||||
|
Params Params
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseCommand(args []string) Command {
|
||||||
|
if len(args) == 0 {
|
||||||
|
return Command{Name: "help"}
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd := Command{
|
||||||
|
Name: args[0],
|
||||||
|
Params: make(Params, 0, len(args)-1),
|
||||||
|
}
|
||||||
|
|
||||||
|
nextIndex := 0
|
||||||
|
for _, arg := range args[1:] {
|
||||||
|
kvle := strings.SplitN(arg, "<=", 2)
|
||||||
|
kvge := strings.SplitN(arg, ">=", 2)
|
||||||
|
kvl := strings.SplitN(arg, "<", 2)
|
||||||
|
kvg := strings.SplitN(arg, ">", 2)
|
||||||
|
kve := strings.SplitN(arg, "=", 2)
|
||||||
|
|
||||||
|
if len(kvle) == 2 {
|
||||||
|
cmd.Params = append(cmd.Params, Param{Index: -1, Key: kvle[0], Value: kvle[1], Operator: "<="})
|
||||||
|
} else if len(kvge) == 2 {
|
||||||
|
cmd.Params = append(cmd.Params, Param{Index: -1, Key: kvge[0], Value: kvge[1], Operator: ">="})
|
||||||
|
} else if len(kvl) == 2 {
|
||||||
|
cmd.Params = append(cmd.Params, Param{Index: -1, Key: kvl[0], Value: kvl[1], Operator: "<"})
|
||||||
|
} else if len(kvg) == 2 {
|
||||||
|
cmd.Params = append(cmd.Params, Param{Index: -1, Key: kvg[0], Value: kvg[1], Operator: ">"})
|
||||||
|
} else if len(kve) == 2 {
|
||||||
|
cmd.Params = append(cmd.Params, Param{Index: -1, Key: kve[0], Value: kve[1], Operator: "="})
|
||||||
|
} else {
|
||||||
|
cmd.Params = append(cmd.Params, Param{Index: nextIndex, Value: arg})
|
||||||
|
nextIndex += 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"gopkg.in/yaml.v2"
|
||||||
|
"os"
|
||||||
|
"path"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Config struct {
|
||||||
|
Remote string `yaml:"remote"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadConfig() (*Config, error) {
|
||||||
|
homedir, err := os.UserHomeDir()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
f, err := os.Open(path.Join(homedir, ".config/lucy.yaml"))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg := Config{}
|
||||||
|
err = yaml.NewDecoder(f).Decode(&cfg)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return &cfg, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,185 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"git.aiterp.net/lucifer/new-server/app/client"
|
||||||
|
"git.aiterp.net/lucifer/new-server/models"
|
||||||
|
"log"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
func handlerCmd(
|
||||||
|
ctx context.Context,
|
||||||
|
c client.Client,
|
||||||
|
) {
|
||||||
|
cmd := parseCommand(os.Args[2:])
|
||||||
|
|
||||||
|
switch cmd.Name {
|
||||||
|
case "list":
|
||||||
|
handlers, err := c.GetHandlers(ctx)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalln(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
WriteHandlerInfoTable(os.Stdout, handlers)
|
||||||
|
case "add":
|
||||||
|
model := models.EventHandler{}
|
||||||
|
|
||||||
|
// Event name
|
||||||
|
model.EventName = cmd.Params.Get(0).StringOr("")
|
||||||
|
if len(model.EventName) == 0 {
|
||||||
|
log.Fatalln("Event name needed!")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Search
|
||||||
|
fetchStr := cmd.Params.Get(1).String()
|
||||||
|
if fetchStr == nil {
|
||||||
|
log.Fatalln("Search filter needed!")
|
||||||
|
}
|
||||||
|
model.TargetKind, model.TargetValue = models.ParseFetchString(*fetchStr)
|
||||||
|
model = applyCmdToHandler(model, cmd)
|
||||||
|
|
||||||
|
newHandler, err := c.AddHandler(ctx, model)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalln(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
WriteHandlerInfoTable(os.Stdout, []models.EventHandler{*newHandler})
|
||||||
|
case "edit":
|
||||||
|
id := cmd.Params.Get(0).IntOr(0)
|
||||||
|
if id == 0 {
|
||||||
|
log.Fatalln("ID missing")
|
||||||
|
}
|
||||||
|
|
||||||
|
model, err := c.GetHandler(ctx, id)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalln(err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
eventStr := cmd.Params.Get("event").String()
|
||||||
|
if eventStr != nil {
|
||||||
|
model.EventName = *eventStr
|
||||||
|
}
|
||||||
|
|
||||||
|
fetchStr := cmd.Params.Get("search").String()
|
||||||
|
if fetchStr != nil {
|
||||||
|
model.TargetKind, model.TargetValue = models.ParseFetchString(*fetchStr)
|
||||||
|
}
|
||||||
|
|
||||||
|
modified := applyCmdToHandler(*model, cmd)
|
||||||
|
returned, err := c.PutHandler(ctx, &modified)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalln(err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
WriteHandlerInfoTable(os.Stdout, []models.EventHandler{*returned})
|
||||||
|
case "delete":
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, _ = fmt.Fprintln(os.Stderr, helpString[1:])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func applyCmdToHandler(model models.EventHandler, cmd Command) models.EventHandler {
|
||||||
|
// Remove keys
|
||||||
|
for _, elem := range cmd.Params.Strings(1) {
|
||||||
|
if !strings.HasPrefix(elem, "-") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
keyToRemove := elem[1:]
|
||||||
|
|
||||||
|
if strings.HasPrefix(keyToRemove, "conditions.") {
|
||||||
|
condKeyToRemove := keyToRemove[11:]
|
||||||
|
delete(model.Conditions, condKeyToRemove)
|
||||||
|
} else if strings.HasPrefix(keyToRemove, "fire.payload.") && model.Actions.FireEvent != nil {
|
||||||
|
firePayloadKeyToRemove := keyToRemove[13:]
|
||||||
|
delete(model.Actions.FireEvent.Payload, firePayloadKeyToRemove)
|
||||||
|
} else {
|
||||||
|
switch keyToRemove {
|
||||||
|
case "set-power":
|
||||||
|
model.Actions.SetPower = nil
|
||||||
|
case "set-color":
|
||||||
|
model.Actions.SetColor = nil
|
||||||
|
case "set-intensity":
|
||||||
|
model.Actions.SetIntensity = nil
|
||||||
|
case "add-intensity":
|
||||||
|
model.Actions.AddIntensity = nil
|
||||||
|
case "fire":
|
||||||
|
fallthrough
|
||||||
|
case "fire.name":
|
||||||
|
model.Actions.FireEvent = nil
|
||||||
|
case "from":
|
||||||
|
model.From = models.Never
|
||||||
|
case "to":
|
||||||
|
model.To = models.Never
|
||||||
|
case "set-scene":
|
||||||
|
model.Actions.SetScene = nil
|
||||||
|
case "push-scene":
|
||||||
|
model.Actions.PushScene = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// From / To
|
||||||
|
model.From = cmd.Params.Get("from").TimeOfDayOr(models.Never)
|
||||||
|
model.To = cmd.Params.Get("to").TimeOfDayOr(models.Never)
|
||||||
|
if model.From.IsNever() != model.To.IsNever() {
|
||||||
|
log.Fatalln("from and to must be specified together!")
|
||||||
|
}
|
||||||
|
|
||||||
|
// One shot
|
||||||
|
model.OneShot = cmd.Params.Get("one-shot").StringOr("false") == "true"
|
||||||
|
|
||||||
|
// Priority
|
||||||
|
model.Priority = cmd.Params.Get("priority").IntOr(100)
|
||||||
|
|
||||||
|
// Conditions
|
||||||
|
model.Conditions = cmd.Params.Subset("conditions").EventConditions()
|
||||||
|
|
||||||
|
// Power, Color, Intensity, Temperature
|
||||||
|
nds := cmd.Params.DeviceState("set-")
|
||||||
|
model.Actions.SetPower = nds.Power
|
||||||
|
model.Actions.SetColor = nds.Color
|
||||||
|
model.Actions.SetIntensity = nds.Intensity
|
||||||
|
|
||||||
|
// Add intensity
|
||||||
|
model.Actions.AddIntensity = cmd.Params.Get("add-intensity").Float()
|
||||||
|
|
||||||
|
// Fire
|
||||||
|
fireParams := cmd.Params.Subset("fire")
|
||||||
|
fireName := fireParams.Get("name").String()
|
||||||
|
if fireName != nil {
|
||||||
|
firePayloadParams := fireParams.Subset("payload")
|
||||||
|
|
||||||
|
model.Actions.FireEvent = &models.Event{
|
||||||
|
Name: *fireName,
|
||||||
|
Payload: make(map[string]string, len(firePayloadParams)),
|
||||||
|
}
|
||||||
|
|
||||||
|
for key, value := range firePayloadParams.StringMap() {
|
||||||
|
model.Actions.FireEvent.Payload[key] = value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Scenes
|
||||||
|
model.Actions.SetScene = cmd.Params.SceneAssignment("set-")
|
||||||
|
model.Actions.PushScene = cmd.Params.SceneAssignment("push-")
|
||||||
|
|
||||||
|
return model
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
const helpString = `
|
||||||
|
lucy – lucifer CLI
|
||||||
|
|
||||||
|
EXAMPLES
|
||||||
|
lucy set tag:Hexagon color=hs:35,1 intensity=0.3
|
||||||
|
lucy run SetProfile name=evening
|
||||||
|
|
||||||
|
DEVICE COMMANDS
|
||||||
|
list <search>
|
||||||
|
set <search> <power=B> <color=C> <intensity=F> <temperature=N>
|
||||||
|
tag <search> <[+/-]tag-name>
|
||||||
|
update <search> <name=S> <icon=S> <prop.*=S/NULL>
|
||||||
|
|
||||||
|
EVENT HANDLER COMMANDS
|
||||||
|
handler list
|
||||||
|
handler add <event> <search> \
|
||||||
|
<one-shot=B> \
|
||||||
|
<priority=N> \
|
||||||
|
<from=TOD to=TOD> \
|
||||||
|
<conditions.[any.*|all.*|*][<|<=|=|=>|>]*> \
|
||||||
|
<set-power=B> <set-color=C> <set-intensity=F> <set-temperature=N> \
|
||||||
|
<add-intensity=F> \
|
||||||
|
<fire.name=S> <fire.payload.*=S>
|
||||||
|
handler edit <id> \
|
||||||
|
(...all arguments from 'add', appending conditions and fire payload matchers...) \
|
||||||
|
<event=S> <search=S>
|
||||||
|
<-from> <-to> \
|
||||||
|
<-set-power> <-set-color> <-set-intensity> <-set-temperature> \
|
||||||
|
<-conditions.*> \
|
||||||
|
<-fire> <-fire.payload.*>
|
||||||
|
handler delete <id>
|
||||||
|
|
||||||
|
EVENT COMMANDS
|
||||||
|
run <event name> <*=S>
|
||||||
|
|
||||||
|
NOTE: You may have to surround arguments including < or > with quotation marks!
|
||||||
|
`
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"git.aiterp.net/lucifer/new-server/app/client"
|
||||||
|
"git.aiterp.net/lucifer/new-server/models"
|
||||||
|
"log"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
cfg, err := loadConfig()
|
||||||
|
if err != nil {
|
||||||
|
log.Println("Config at $HOME/.config/lucy.yaml failed:", err)
|
||||||
|
cfg = &Config{Remote: "http://localhost:8000"}
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
c := client.Client{APIRoot: cfg.Remote}
|
||||||
|
cmd := parseCommand(os.Args[1:])
|
||||||
|
|
||||||
|
switch cmd.Name {
|
||||||
|
// DEVICE
|
||||||
|
case "list":
|
||||||
|
{
|
||||||
|
fetchStr := cmd.Params.Get(0).String()
|
||||||
|
if fetchStr == nil {
|
||||||
|
s := "all"
|
||||||
|
fetchStr = &s
|
||||||
|
}
|
||||||
|
|
||||||
|
devices, err := c.GetDevices(ctx, *fetchStr)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalln(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if cmd.Params.Get("info").StringOr("false") == "true" {
|
||||||
|
WriteDeviceInfoTable(os.Stdout, devices)
|
||||||
|
} else {
|
||||||
|
WriteDeviceStateTable(os.Stdout, devices)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case "set":
|
||||||
|
{
|
||||||
|
devices, err := c.PutDeviceState(ctx, cmd.Params.Get(0).StringOr("all"), cmd.Params.DeviceState(""))
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalln(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
WriteDeviceStateTable(os.Stdout, devices)
|
||||||
|
}
|
||||||
|
case "tag":
|
||||||
|
{
|
||||||
|
var addTags []string
|
||||||
|
var removeTags []string
|
||||||
|
|
||||||
|
for _, tag := range cmd.Params.Strings(1) {
|
||||||
|
if strings.HasPrefix(tag, "+") {
|
||||||
|
addTags = append(addTags, tag[1:])
|
||||||
|
} else if strings.HasPrefix(tag, "-") {
|
||||||
|
removeTags = append(removeTags, tag[1:])
|
||||||
|
} else {
|
||||||
|
addTags = append(addTags, tag)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
devices, err := c.PutDeviceTags(
|
||||||
|
ctx,
|
||||||
|
cmd.Params.Get(0).StringOr("all"),
|
||||||
|
addTags,
|
||||||
|
removeTags,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalln(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
WriteDeviceInfoTable(os.Stdout, devices)
|
||||||
|
}
|
||||||
|
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(),
|
||||||
|
UserProperties: cmd.Params.Subset("prop.").StringPtrMap(),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalln(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
WriteDeviceInfoTable(os.Stdout, devices)
|
||||||
|
}
|
||||||
|
|
||||||
|
case "handler":
|
||||||
|
handlerCmd(ctx, c)
|
||||||
|
|
||||||
|
case "scene":
|
||||||
|
sceneCmd(ctx, c)
|
||||||
|
|
||||||
|
// EVENT
|
||||||
|
case "run":
|
||||||
|
{
|
||||||
|
name := cmd.Params.Get(0).String()
|
||||||
|
if name == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
event := models.Event{
|
||||||
|
Name: *name,
|
||||||
|
Payload: cmd.Params.StringMap(),
|
||||||
|
}
|
||||||
|
|
||||||
|
err := c.FireEvent(ctx, event)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalln(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
default:
|
||||||
|
if cmd.Name != "help" {
|
||||||
|
log.Println("Unknown command:", cmd.Name)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, _ = fmt.Fprintln(os.Stderr, helpString[1:])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,165 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"git.aiterp.net/lucifer/new-server/app/client"
|
||||||
|
"git.aiterp.net/lucifer/new-server/models"
|
||||||
|
"gopkg.in/yaml.v2"
|
||||||
|
"log"
|
||||||
|
"os"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"unicode"
|
||||||
|
)
|
||||||
|
|
||||||
|
func sceneCmd(
|
||||||
|
ctx context.Context,
|
||||||
|
c client.Client,
|
||||||
|
) {
|
||||||
|
cmd := parseCommand(os.Args[2:])
|
||||||
|
|
||||||
|
switch cmd.Name {
|
||||||
|
case "create", "update":
|
||||||
|
{
|
||||||
|
fileName := cmd.Params.Get(0).String()
|
||||||
|
if fileName == nil {
|
||||||
|
log.Fatalln("Missing filename")
|
||||||
|
}
|
||||||
|
|
||||||
|
file, err := os.Open(*fileName)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalln("Failed to open file:", err)
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
|
|
||||||
|
yamlData := make(map[string]interface{})
|
||||||
|
err = yaml.NewDecoder(file).Decode(yamlData)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalln("Failed to decode file:", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
yamlData = camelCasify(yamlData)
|
||||||
|
name, nameOk := yamlData["name"]
|
||||||
|
if !nameOk {
|
||||||
|
log.Fatalln("Missing name in yaml data.")
|
||||||
|
}
|
||||||
|
|
||||||
|
var scene models.Scene
|
||||||
|
if cmd.Name == "create" {
|
||||||
|
err := c.Fetch(ctx, "POST", "/api/scenes", &scene, yamlData)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalln("Failed to create scene:", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
scenes, err := c.GetScenes(ctx)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalln("Failed to fetch existing scenes:", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
id := -1
|
||||||
|
for _, scene := range scenes {
|
||||||
|
if scene.Name == name {
|
||||||
|
id = scene.ID
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if id == -1 {
|
||||||
|
log.Fatalln("Could not find scene with name", name)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
err = c.Fetch(ctx, "PUT", "/api/scenes/"+strconv.Itoa(id), &scene, yamlData)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalln("Failed to update scene:", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
case "push", "assign":
|
||||||
|
{
|
||||||
|
fetch := cmd.Params.Get(0).String()
|
||||||
|
id := cmd.Params.Get(1).Int()
|
||||||
|
if fetch == nil || id == nil {
|
||||||
|
log.Println("Usage: lucy scene assign <fetch> <id> <group=S> <duration=I>")
|
||||||
|
}
|
||||||
|
|
||||||
|
devices, err := c.AssignDevice(ctx, *fetch, cmd.Name == "push", models.DeviceSceneAssignment{
|
||||||
|
SceneID: *id,
|
||||||
|
Group: cmd.Params.Get("group").StringOr(*fetch),
|
||||||
|
DurationMS: int64(cmd.Params.Get("duration").IntOr(0)),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
log.Println("Could not assign devices:", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
WriteDeviceInfoTable(os.Stdout, devices)
|
||||||
|
}
|
||||||
|
|
||||||
|
case "clear":
|
||||||
|
fetch := cmd.Params.Get(0).String()
|
||||||
|
if fetch == nil {
|
||||||
|
log.Println("Usage: lucy scene clear <fetch>")
|
||||||
|
}
|
||||||
|
|
||||||
|
devices, err := c.ClearDevice(ctx, *fetch)
|
||||||
|
if err != nil {
|
||||||
|
log.Println("Could not clear devices:", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
WriteDeviceInfoTable(os.Stdout, devices)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func camelCasify(m map[string]interface{}) map[string]interface{} {
|
||||||
|
m2 := make(map[string]interface{}, len(m))
|
||||||
|
for key, value := range m {
|
||||||
|
b := strings.Builder{}
|
||||||
|
snake := false
|
||||||
|
for _, ch := range key {
|
||||||
|
if ch == '_' {
|
||||||
|
snake = true
|
||||||
|
} else if snake {
|
||||||
|
b.WriteRune(unicode.ToUpper(ch))
|
||||||
|
snake = false
|
||||||
|
} else {
|
||||||
|
b.WriteRune(ch)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
switch value := value.(type) {
|
||||||
|
case []interface{}:
|
||||||
|
valueCopy := make([]interface{}, len(value))
|
||||||
|
for i, elem := range value {
|
||||||
|
switch elem := elem.(type) {
|
||||||
|
case map[interface{}]interface{}:
|
||||||
|
m3 := make(map[string]interface{})
|
||||||
|
for k, v := range elem {
|
||||||
|
if kStr, ok := k.(string); ok {
|
||||||
|
m3[kStr] = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
valueCopy[i] = camelCasify(m3)
|
||||||
|
case map[string]interface{}:
|
||||||
|
valueCopy[i] = camelCasify(elem)
|
||||||
|
default:
|
||||||
|
valueCopy[i] = elem
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
m2[b.String()] = valueCopy
|
||||||
|
case map[string]interface{}:
|
||||||
|
m2[b.String()] = camelCasify(value)
|
||||||
|
default:
|
||||||
|
m2[b.String()] = value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return m2
|
||||||
|
}
|
||||||
@@ -0,0 +1,155 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"git.aiterp.net/lucifer/new-server/models"
|
||||||
|
"github.com/olekukonko/tablewriter"
|
||||||
|
"io"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
func WriteDeviceStateTable(w io.Writer, devices []models.Device) {
|
||||||
|
table := tablewriter.NewWriter(w)
|
||||||
|
table.SetHeader([]string{"ID", "NAME", "POWER", "COLOR", "INTENSITY", "TEMPERATURE"})
|
||||||
|
|
||||||
|
table.SetReflowDuringAutoWrap(true)
|
||||||
|
table.SetColumnAlignment([]int{
|
||||||
|
tablewriter.ALIGN_RIGHT,
|
||||||
|
tablewriter.ALIGN_LEFT,
|
||||||
|
tablewriter.ALIGN_RIGHT,
|
||||||
|
tablewriter.ALIGN_LEFT,
|
||||||
|
tablewriter.ALIGN_RIGHT,
|
||||||
|
tablewriter.ALIGN_RIGHT,
|
||||||
|
})
|
||||||
|
|
||||||
|
for _, v := range devices {
|
||||||
|
powerStr := ""
|
||||||
|
if v.HasCapability(models.DCPower) {
|
||||||
|
if v.SceneState != nil && v.SceneState.Power != v.State.Power {
|
||||||
|
powerStr = strconv.FormatBool(v.SceneState.Power) + "*"
|
||||||
|
} else {
|
||||||
|
powerStr = strconv.FormatBool(v.State.Power)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
colorStr := ""
|
||||||
|
if v.HasCapability(models.DCColorHSK, models.DCColorHS, models.DCColorKelvin) {
|
||||||
|
if v.SceneState != nil && v.SceneState.Color.String() != v.State.Color.String() {
|
||||||
|
colorStr = v.SceneState.Color.String() + "*"
|
||||||
|
} else {
|
||||||
|
colorStr = v.State.Color.String()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
temperatureString := ""
|
||||||
|
if v.HasCapability(models.DCTemperatureControl, models.DCTemperatureSensor) {
|
||||||
|
temperatureString = fmt.Sprintf("%d", v.State.Temperature)
|
||||||
|
}
|
||||||
|
|
||||||
|
intensityString := ""
|
||||||
|
if v.HasCapability(models.DCIntensity) {
|
||||||
|
if v.SceneState != nil && v.SceneState.Intensity != v.State.Intensity {
|
||||||
|
intensityString = strconv.FormatFloat(v.SceneState.Intensity, 'g', 2, 64) + "*"
|
||||||
|
} else {
|
||||||
|
intensityString = strconv.FormatFloat(v.State.Intensity, 'f', -1, 64)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
table.Append([]string{
|
||||||
|
strconv.Itoa(v.ID),
|
||||||
|
v.Name,
|
||||||
|
powerStr,
|
||||||
|
colorStr,
|
||||||
|
intensityString,
|
||||||
|
temperatureString,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
table.Render()
|
||||||
|
}
|
||||||
|
|
||||||
|
func WriteDeviceInfoTable(w io.Writer, devices []models.Device) {
|
||||||
|
table := tablewriter.NewWriter(w)
|
||||||
|
table.SetHeader([]string{"ID", "NAME", "ICON", "BUTTONS", "TAGS", "USER PROPERTIES"})
|
||||||
|
|
||||||
|
table.SetReflowDuringAutoWrap(false)
|
||||||
|
|
||||||
|
for _, v := range devices {
|
||||||
|
propStr := ""
|
||||||
|
for key, value := range v.UserProperties {
|
||||||
|
propStr += key + "=" + value + " "
|
||||||
|
}
|
||||||
|
|
||||||
|
table.Append([]string{
|
||||||
|
strconv.Itoa(v.ID),
|
||||||
|
v.Name,
|
||||||
|
v.Icon,
|
||||||
|
strings.Join(v.ButtonNames, ","),
|
||||||
|
strings.Join(v.Tags, ","),
|
||||||
|
propStr,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
table.Render()
|
||||||
|
}
|
||||||
|
|
||||||
|
func WriteHandlerInfoTable(w io.Writer, handlers []models.EventHandler) {
|
||||||
|
table := tablewriter.NewWriter(w)
|
||||||
|
table.SetHeader([]string{"ID", "EVENT NAME", "PRIORITY", "CONDITIONS", "TARGET", "ACTION"})
|
||||||
|
table.SetReflowDuringAutoWrap(false)
|
||||||
|
|
||||||
|
for _, h := range handlers {
|
||||||
|
condStr := ""
|
||||||
|
for key, value := range h.Conditions {
|
||||||
|
condStr += fmt.Sprintf("%s=%s ", key, value)
|
||||||
|
}
|
||||||
|
|
||||||
|
actionStr := ""
|
||||||
|
if h.Actions.SetPower != nil {
|
||||||
|
actionStr += fmt.Sprintf("setPower=%t ", *h.Actions.SetPower)
|
||||||
|
}
|
||||||
|
if h.Actions.SetColor != nil {
|
||||||
|
actionStr += fmt.Sprintf("setColor=%s ", *h.Actions.SetColor)
|
||||||
|
}
|
||||||
|
if h.Actions.SetIntensity != nil {
|
||||||
|
actionStr += fmt.Sprintf("setIntensity=%.02f ", *h.Actions.SetIntensity)
|
||||||
|
}
|
||||||
|
if h.Actions.SetTemperature != nil {
|
||||||
|
actionStr += fmt.Sprintf("setTemperature=%d ", *h.Actions.SetTemperature)
|
||||||
|
}
|
||||||
|
if h.Actions.AddIntensity != nil {
|
||||||
|
actionStr += fmt.Sprintf("addIntensity=%.02f ", *h.Actions.AddIntensity)
|
||||||
|
}
|
||||||
|
if h.Actions.FireEvent != nil {
|
||||||
|
actionStr += fmt.Sprintf("fireEvent=%s ", (*h.Actions.FireEvent).Name)
|
||||||
|
}
|
||||||
|
if h.Actions.FireEvent != nil {
|
||||||
|
actionStr += fmt.Sprintf("fireEvent=%s ", (*h.Actions.FireEvent).Name)
|
||||||
|
}
|
||||||
|
if h.Actions.SetScene != nil {
|
||||||
|
s := h.Actions.SetScene
|
||||||
|
actionStr += fmt.Sprintf("setScene=(id=%d,group=\"%s\",duration=%d) ", s.SceneID, s.Group, s.DurationMS)
|
||||||
|
}
|
||||||
|
if h.Actions.PushScene != nil {
|
||||||
|
s := h.Actions.PushScene
|
||||||
|
actionStr += fmt.Sprintf("pushScene=(id=%d,group=\"%s\",duration=%d) ", s.SceneID, s.Group, s.DurationMS)
|
||||||
|
}
|
||||||
|
|
||||||
|
eventName := h.EventName
|
||||||
|
if h.OneShot {
|
||||||
|
eventName += " (one shot)"
|
||||||
|
}
|
||||||
|
|
||||||
|
table.Append([]string{
|
||||||
|
strconv.Itoa(h.ID),
|
||||||
|
eventName,
|
||||||
|
strconv.Itoa(h.Priority),
|
||||||
|
condStr,
|
||||||
|
fmt.Sprintf("%s:%s", h.TargetKind, h.TargetValue),
|
||||||
|
actionStr,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
table.Render()
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
#####################
|
||||||
|
## 1. Build server ##
|
||||||
|
#####################
|
||||||
|
FROM golang:1.17 AS build-server
|
||||||
|
|
||||||
|
WORKDIR /project
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
ENV CGO_ENABLED 0
|
||||||
|
|
||||||
|
RUN go mod download
|
||||||
|
RUN go build -ldflags "-w -s" -o /build/lucifer cmd/server/main.go
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
###################
|
||||||
|
## 2. Run server ##
|
||||||
|
###################
|
||||||
|
FROM alpine:3.14.2
|
||||||
|
|
||||||
|
# Add results from previous builds
|
||||||
|
COPY --from=build-server /build/lucifer /usr/bin/lucifer
|
||||||
|
|
||||||
|
RUN apk add --no-cache tzdata
|
||||||
|
|
||||||
|
# Run server
|
||||||
|
CMD lucifer
|
||||||
@@ -3,13 +3,17 @@ module git.aiterp.net/lucifer/new-server
|
|||||||
go 1.16
|
go 1.16
|
||||||
|
|
||||||
require (
|
require (
|
||||||
|
github.com/Masterminds/squirrel v1.5.0
|
||||||
github.com/gin-gonic/gin v1.7.1
|
github.com/gin-gonic/gin v1.7.1
|
||||||
github.com/go-sql-driver/mysql v1.6.0
|
github.com/go-sql-driver/mysql v1.6.0
|
||||||
github.com/jmoiron/sqlx v1.3.4
|
github.com/jmoiron/sqlx v1.3.4
|
||||||
github.com/lib/pq v1.10.2 // indirect
|
github.com/lib/pq v1.10.3 // indirect
|
||||||
github.com/lucasb-eyer/go-colorful v1.2.0
|
github.com/lucasb-eyer/go-colorful v1.2.0
|
||||||
github.com/mattn/go-sqlite3 v2.0.3+incompatible // indirect
|
github.com/mattn/go-sqlite3 v2.0.3+incompatible // indirect
|
||||||
|
github.com/olekukonko/tablewriter v0.0.5
|
||||||
github.com/pkg/errors v0.9.1 // indirect
|
github.com/pkg/errors v0.9.1 // indirect
|
||||||
github.com/pressly/goose v2.7.0+incompatible
|
github.com/pressly/goose v2.7.0+incompatible
|
||||||
golang.org/x/crypto v0.0.0-20210513164829-c07d793c2f9a // indirect
|
golang.org/x/crypto v0.0.0-20210915214749-c084706c2272 // indirect
|
||||||
|
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c
|
||||||
|
gopkg.in/yaml.v2 v2.2.8
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
github.com/Masterminds/squirrel v1.5.0 h1:JukIZisrUXadA9pl3rMkjhiamxiB0cXiu+HGp/Y8cY8=
|
||||||
|
github.com/Masterminds/squirrel v1.5.0/go.mod h1:NNaOrjSoIDfDA40n7sr2tPNZRfjzjA400rg+riTZj10=
|
||||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
@@ -18,20 +20,27 @@ github.com/go-sql-driver/mysql v1.6.0 h1:BCTh4TKNUYmOmMUcQ3IipzF5prigylS7XXjEkfC
|
|||||||
github.com/go-sql-driver/mysql v1.6.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LBy8hT2VhHyBg=
|
github.com/go-sql-driver/mysql v1.6.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LBy8hT2VhHyBg=
|
||||||
github.com/golang/protobuf v1.3.3 h1:gyjaxf+svBWX08ZjK86iN9geUJF0H6gp2IRKX6Nf6/I=
|
github.com/golang/protobuf v1.3.3 h1:gyjaxf+svBWX08ZjK86iN9geUJF0H6gp2IRKX6Nf6/I=
|
||||||
github.com/golang/protobuf v1.3.3/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw=
|
github.com/golang/protobuf v1.3.3/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw=
|
||||||
|
github.com/google/gofuzz v1.0.0 h1:A8PeW59pxE9IoFRqBp37U+mSNaQoZ46F1f0f863XSXw=
|
||||||
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||||
github.com/jmoiron/sqlx v1.3.4 h1:wv+0IJZfL5z0uZoUjlpKgHkgaFSYD+r9CfrXjEXsO7w=
|
github.com/jmoiron/sqlx v1.3.4 h1:wv+0IJZfL5z0uZoUjlpKgHkgaFSYD+r9CfrXjEXsO7w=
|
||||||
github.com/jmoiron/sqlx v1.3.4/go.mod h1:2BljVx/86SuTyjE+aPYlHCTNvZrnJXghYGpNiXLBMCQ=
|
github.com/jmoiron/sqlx v1.3.4/go.mod h1:2BljVx/86SuTyjE+aPYlHCTNvZrnJXghYGpNiXLBMCQ=
|
||||||
github.com/json-iterator/go v1.1.9 h1:9yzud/Ht36ygwatGx56VwCZtlI/2AD15T1X2sjSuGns=
|
github.com/json-iterator/go v1.1.9 h1:9yzud/Ht36ygwatGx56VwCZtlI/2AD15T1X2sjSuGns=
|
||||||
github.com/json-iterator/go v1.1.9/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4=
|
github.com/json-iterator/go v1.1.9/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4=
|
||||||
|
github.com/lann/builder v0.0.0-20180802200727-47ae307949d0 h1:SOEGU9fKiNWd/HOJuq6+3iTQz8KNCLtVX6idSoTLdUw=
|
||||||
|
github.com/lann/builder v0.0.0-20180802200727-47ae307949d0/go.mod h1:dXGbAdH5GtBTC4WfIxhKZfyBF/HBFgRZSWwZ9g/He9o=
|
||||||
|
github.com/lann/ps v0.0.0-20150810152359-62de8c46ede0 h1:P6pPBnrTSX3DEVR4fDembhRWSsG5rVo6hYhAB/ADZrk=
|
||||||
|
github.com/lann/ps v0.0.0-20150810152359-62de8c46ede0/go.mod h1:vmVJ0l/dxyfGW6FmdpVm2joNMFikkuWg0EoCKLGUMNw=
|
||||||
github.com/leodido/go-urn v1.2.0 h1:hpXL4XnriNwQ/ABnpepYM/1vCLWNDfUNts8dX3xTG6Y=
|
github.com/leodido/go-urn v1.2.0 h1:hpXL4XnriNwQ/ABnpepYM/1vCLWNDfUNts8dX3xTG6Y=
|
||||||
github.com/leodido/go-urn v1.2.0/go.mod h1:+8+nEpDfqqsY+g338gtMEUOtuK+4dEMhiQEgxpxOKII=
|
github.com/leodido/go-urn v1.2.0/go.mod h1:+8+nEpDfqqsY+g338gtMEUOtuK+4dEMhiQEgxpxOKII=
|
||||||
github.com/lib/pq v1.2.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo=
|
github.com/lib/pq v1.2.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo=
|
||||||
github.com/lib/pq v1.10.2 h1:AqzbZs4ZoCBp+GtejcpCpcxM3zlSMx29dXbUSeVtJb8=
|
github.com/lib/pq v1.10.3 h1:v9QZf2Sn6AmjXtQeFpdoq/eaNtYP6IN+7lcrygsIAtg=
|
||||||
github.com/lib/pq v1.10.2/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
|
github.com/lib/pq v1.10.3/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
|
||||||
github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
|
github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
|
||||||
github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
|
github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
|
||||||
github.com/mattn/go-isatty v0.0.12 h1:wuysRhFDzyxgEmMf5xjvJ2M9dZoWAXNNr5LSBS7uHXY=
|
github.com/mattn/go-isatty v0.0.12 h1:wuysRhFDzyxgEmMf5xjvJ2M9dZoWAXNNr5LSBS7uHXY=
|
||||||
github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU=
|
github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU=
|
||||||
|
github.com/mattn/go-runewidth v0.0.9 h1:Lm995f3rfxdpd6TSmuVCHVb/QhupuXlYr8sCI/QdE+0=
|
||||||
|
github.com/mattn/go-runewidth v0.0.9/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI=
|
||||||
github.com/mattn/go-sqlite3 v1.14.6/go.mod h1:NyWgC/yNuGj7Q9rpYnZvas74GogHl5/Z4A/KQRfk6bU=
|
github.com/mattn/go-sqlite3 v1.14.6/go.mod h1:NyWgC/yNuGj7Q9rpYnZvas74GogHl5/Z4A/KQRfk6bU=
|
||||||
github.com/mattn/go-sqlite3 v2.0.3+incompatible h1:gXHsfypPkaMZrKbD5209QV9jbUTJKjyR5WD3HYQSd+U=
|
github.com/mattn/go-sqlite3 v2.0.3+incompatible h1:gXHsfypPkaMZrKbD5209QV9jbUTJKjyR5WD3HYQSd+U=
|
||||||
github.com/mattn/go-sqlite3 v2.0.3+incompatible/go.mod h1:FPy6KqzDD04eiIsT53CuJW3U88zkxoIYsOqkbpncsNc=
|
github.com/mattn/go-sqlite3 v2.0.3+incompatible/go.mod h1:FPy6KqzDD04eiIsT53CuJW3U88zkxoIYsOqkbpncsNc=
|
||||||
@@ -39,13 +48,17 @@ github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421 h1:ZqeYNhU3OH
|
|||||||
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||||
github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742 h1:Esafd1046DLDQ0W1YjYsBW+p8U2u7vzgW2SQVmlNazg=
|
github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742 h1:Esafd1046DLDQ0W1YjYsBW+p8U2u7vzgW2SQVmlNazg=
|
||||||
github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0=
|
github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0=
|
||||||
|
github.com/olekukonko/tablewriter v0.0.5 h1:P2Ga83D34wi1o9J6Wh1mRuqd4mF/x/lgBS7N7AbDhec=
|
||||||
|
github.com/olekukonko/tablewriter v0.0.5/go.mod h1:hPp6KlRPjbx+hW8ykQs1w3UBbZlj6HuIJcUGPhkA7kY=
|
||||||
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
github.com/pressly/goose v2.7.0+incompatible h1:PWejVEv07LCerQEzMMeAtjuyCKbyprZ/LBa6K5P0OCQ=
|
github.com/pressly/goose v2.7.0+incompatible h1:PWejVEv07LCerQEzMMeAtjuyCKbyprZ/LBa6K5P0OCQ=
|
||||||
github.com/pressly/goose v2.7.0+incompatible/go.mod h1:m+QHWCqxR3k8D9l7qfzuC/djtlfzxr34mozWDYEu1z8=
|
github.com/pressly/goose v2.7.0+incompatible/go.mod h1:m+QHWCqxR3k8D9l7qfzuC/djtlfzxr34mozWDYEu1z8=
|
||||||
|
github.com/stretchr/objx v0.1.0 h1:4G4v2dO3VZwixGIRoQ5Lfboy6nUhCyYzaqnIAPPhYs4=
|
||||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||||
|
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
|
||||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||||
github.com/stretchr/testify v1.4.0 h1:2E4SXV/wtOkTonXsotYi4li6zVWxYlZuYNCXe9XRJyk=
|
github.com/stretchr/testify v1.4.0 h1:2E4SXV/wtOkTonXsotYi4li6zVWxYlZuYNCXe9XRJyk=
|
||||||
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
|
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
|
||||||
@@ -55,19 +68,26 @@ github.com/ugorji/go/codec v1.1.7 h1:2SvQaVZ1ouYrrKKwoSk2pzd4A9evlKJb9oTL+OaLUSs
|
|||||||
github.com/ugorji/go/codec v1.1.7/go.mod h1:Ax+UKWsSmolVDwsd+7N3ZtXu+yMGCf907BLYF3GoBXY=
|
github.com/ugorji/go/codec v1.1.7/go.mod h1:Ax+UKWsSmolVDwsd+7N3ZtXu+yMGCf907BLYF3GoBXY=
|
||||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||||
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||||
golang.org/x/crypto v0.0.0-20210513164829-c07d793c2f9a h1:kr2P4QFmQr29mSLA43kwrOcgcReGTfbE9N577tCTuBc=
|
golang.org/x/crypto v0.0.0-20210915214749-c084706c2272 h1:3erb+vDS8lU1sxfDHF4/hhWyaXnhIaO+7RgL4fDZORA=
|
||||||
golang.org/x/crypto v0.0.0-20210513164829-c07d793c2f9a/go.mod h1:P+XmwS30IXTQdn5tA2iutPOUgjI07+tq3H3K9MVA1s8=
|
golang.org/x/crypto v0.0.0-20210915214749-c084706c2272/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||||
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||||
|
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110 h1:qWPm9rbaAMKs8Bq/9LRpbMqxWRVUAQwMI9fVrssnTfw=
|
||||||
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||||
|
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c h1:5KslGYwFpkhGh+Q16bwMP3cOontH8FOep7tGV86Y7SQ=
|
||||||
|
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||||
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68 h1:nxC68pudNYkKU6jWhgrqdreuFiOQWj1Fs7T3VrH4Pjw=
|
|
||||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1 h1:SrN+KX8Art/Sf4HNj6Zcz06G7VEz+7w9tdXTPOZ7+l4=
|
||||||
|
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1 h1:v+OssWQX+hTHEmOBgwxdZxK4zHq3yOs8F9J7mk0PY8E=
|
||||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||||
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
|
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
|
||||||
|
golang.org/x/text v0.3.3 h1:cokOdA+Jmi5PJGXLlLllQSgYigAEfHXJAERHVMaCc2k=
|
||||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||||
|
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e h1:FDhOuMEY4JVRztM/gsbk+IKUQ8kj74bxZrgw87eMMVc=
|
||||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
|||||||
Executable
+11
@@ -0,0 +1,11 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
printf "Building lucy...\n"
|
||||||
|
go mod download || exit 1
|
||||||
|
go build -ldflags "-w -s" -o lucy ./cmd/lucy || exit 1
|
||||||
|
|
||||||
|
printf "Removing old lucy...\n"
|
||||||
|
sudo rm /usr/local/bin/lucy > /dev/null 2>&1
|
||||||
|
|
||||||
|
printf "Installing...\n"
|
||||||
|
sudo mv ./lucy /usr/local/bin/lucy
|
||||||
@@ -0,0 +1,437 @@
|
|||||||
|
package hue
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"git.aiterp.net/lucifer/new-server/models"
|
||||||
|
"io"
|
||||||
|
"log"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Bridge struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
host string
|
||||||
|
token string
|
||||||
|
externalID int
|
||||||
|
lightStates []*hueLightState
|
||||||
|
sensorStates []*hueSensorState
|
||||||
|
syncingPublish uint32
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *Bridge) Refresh(ctx context.Context) error {
|
||||||
|
lightMap, err := b.getLights(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
b.mu.Lock()
|
||||||
|
for index, light := range lightMap {
|
||||||
|
var state *hueLightState
|
||||||
|
for _, existingState := range b.lightStates {
|
||||||
|
if existingState.index == index {
|
||||||
|
state = existingState
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if state == nil {
|
||||||
|
state = &hueLightState{
|
||||||
|
index: index,
|
||||||
|
uniqueID: light.Uniqueid,
|
||||||
|
externalID: -1,
|
||||||
|
info: light,
|
||||||
|
}
|
||||||
|
|
||||||
|
b.lightStates = append(b.lightStates, state)
|
||||||
|
} else {
|
||||||
|
if light.Uniqueid != state.uniqueID {
|
||||||
|
state.uniqueID = light.Uniqueid
|
||||||
|
state.externalID = -1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
state.CheckStaleness(light.State)
|
||||||
|
}
|
||||||
|
b.mu.Unlock()
|
||||||
|
|
||||||
|
sensorMap, err := b.getSensors(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
b.mu.Lock()
|
||||||
|
for index, sensor := range sensorMap {
|
||||||
|
var state *hueSensorState
|
||||||
|
for _, existingState := range b.sensorStates {
|
||||||
|
if existingState.index == index {
|
||||||
|
state = existingState
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if state == nil {
|
||||||
|
state = &hueSensorState{
|
||||||
|
index: index,
|
||||||
|
uniqueID: sensor.UniqueID,
|
||||||
|
externalID: -1,
|
||||||
|
presenceCooldown: -2,
|
||||||
|
}
|
||||||
|
|
||||||
|
b.sensorStates = append(b.sensorStates, state)
|
||||||
|
} else {
|
||||||
|
if sensor.UniqueID != state.uniqueID {
|
||||||
|
state.uniqueID = sensor.UniqueID
|
||||||
|
state.externalID = -1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
b.mu.Unlock()
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *Bridge) SyncStale(ctx context.Context) error {
|
||||||
|
indices := make([]int, 0, 4)
|
||||||
|
arrayIndices := make([]int, 0, 4)
|
||||||
|
inputs := make([]LightStateInput, 0, 4)
|
||||||
|
|
||||||
|
b.mu.Lock()
|
||||||
|
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()
|
||||||
|
|
||||||
|
if len(inputs) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
groups := make([]*syncGroup, 0, 4)
|
||||||
|
for i := range inputs {
|
||||||
|
input := inputs[i]
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
group.GroupIndex = id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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) {
|
||||||
|
sensorMap, err := b.getSensors(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var events []models.Event
|
||||||
|
|
||||||
|
b.mu.Lock()
|
||||||
|
for idx, sensorData := range sensorMap {
|
||||||
|
for _, state := range b.sensorStates {
|
||||||
|
if idx == state.index {
|
||||||
|
event := state.Update(sensorData)
|
||||||
|
if event != nil {
|
||||||
|
events = append(events, *event)
|
||||||
|
}
|
||||||
|
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
b.mu.Unlock()
|
||||||
|
|
||||||
|
return events, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *Bridge) StartDiscovery(ctx context.Context, model string) error {
|
||||||
|
return b.post(ctx, model, nil, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *Bridge) putLightState(ctx context.Context, index int, input LightStateInput) error {
|
||||||
|
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)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(result) == 0 || result[0].Error != nil {
|
||||||
|
return "", errLinkButtonNotPressed
|
||||||
|
}
|
||||||
|
if result[0].Success == nil {
|
||||||
|
return "", models.ErrUnexpectedResponse
|
||||||
|
}
|
||||||
|
|
||||||
|
return result[0].Success.Username, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *Bridge) getLights(ctx context.Context) (map[int]LightData, error) {
|
||||||
|
result := make(map[int]LightData, 16)
|
||||||
|
err := b.get(ctx, "lights", &result)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *Bridge) getSensors(ctx context.Context) (map[int]SensorData, error) {
|
||||||
|
result := make(map[int]SensorData, 16)
|
||||||
|
err := b.get(ctx, "sensors", &result)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
req, err := http.NewRequest("GET", 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()
|
||||||
|
|
||||||
|
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 {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if b.token != "" {
|
||||||
|
resource = b.token + "/" + resource
|
||||||
|
}
|
||||||
|
|
||||||
|
req, err := http.NewRequest("POST", fmt.Sprintf("http://%s/api/%s", b.host, resource), rb)
|
||||||
|
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) put(ctx context.Context, resource string, body interface{}, target interface{}) error {
|
||||||
|
rb, err := reqBody(body)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if b.token != "" {
|
||||||
|
resource = b.token + "/" + resource
|
||||||
|
}
|
||||||
|
|
||||||
|
req, err := http.NewRequest("PUT", fmt.Sprintf("http://%s/api/%s", b.host, resource), rb)
|
||||||
|
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 reqBody(body interface{}) (io.Reader, error) {
|
||||||
|
if body == nil {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
switch v := body.(type) {
|
||||||
|
case []byte:
|
||||||
|
return bytes.NewReader(v), nil
|
||||||
|
case string:
|
||||||
|
return strings.NewReader(v), nil
|
||||||
|
case io.Reader:
|
||||||
|
return v, nil
|
||||||
|
default:
|
||||||
|
jsonData, err := json.Marshal(v)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return bytes.NewReader(jsonData), nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var httpClient = &http.Client{
|
||||||
|
Transport: &http.Transport{
|
||||||
|
Proxy: http.ProxyFromEnvironment,
|
||||||
|
DialContext: (&net.Dialer{
|
||||||
|
Timeout: 30 * time.Second,
|
||||||
|
KeepAlive: 30 * time.Second,
|
||||||
|
}).DialContext,
|
||||||
|
MaxIdleConns: 256,
|
||||||
|
MaxIdleConnsPerHost: 16,
|
||||||
|
IdleConnTimeout: 10 * time.Minute,
|
||||||
|
},
|
||||||
|
Timeout: time.Minute,
|
||||||
|
}
|
||||||
@@ -0,0 +1,285 @@
|
|||||||
|
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"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type CreateUserInput struct {
|
||||||
|
DeviceType string `json:"devicetype"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type CreateUserResponse struct {
|
||||||
|
Success *struct {
|
||||||
|
Username string `json:"username"`
|
||||||
|
} `json:"success"`
|
||||||
|
Error *struct {
|
||||||
|
Type int `json:"type"`
|
||||||
|
Address string `json:"address"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
} `json:"error"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Overview struct {
|
||||||
|
Lights map[int]LightData `json:"lights"`
|
||||||
|
Config BridgeConfig `json:"config"`
|
||||||
|
Sensors map[int]SensorData `json:"sensors"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type BridgeConfig struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
ZigbeeChannel int `json:"zigbeechannel"`
|
||||||
|
BridgeID string `json:"bridgeid"`
|
||||||
|
Mac string `json:"mac"`
|
||||||
|
DHCP bool `json:"dhcp"`
|
||||||
|
IPAddress string `json:"ipaddress"`
|
||||||
|
NetMask string `json:"netmask"`
|
||||||
|
Gateway string `json:"gateway"`
|
||||||
|
ProxyAddress string `json:"proxyaddress"`
|
||||||
|
ProxyPort int `json:"proxyport"`
|
||||||
|
UTC string `json:"UTC"`
|
||||||
|
LocalTime string `json:"localtime"`
|
||||||
|
TimeZone string `json:"timezone"`
|
||||||
|
ModelID string `json:"modelid"`
|
||||||
|
DataStoreVersion string `json:"datastoreversion"`
|
||||||
|
SWVersion string `json:"swversion"`
|
||||||
|
APIVersion string `json:"apiversion"`
|
||||||
|
LinkButton bool `json:"linkbutton"`
|
||||||
|
PortalServices bool `json:"portalservices"`
|
||||||
|
PortalConnection string `json:"portalconnection"`
|
||||||
|
FactoryNew bool `json:"factorynew"`
|
||||||
|
StarterKitID string `json:"starterkitid"`
|
||||||
|
Whitelist map[string]WhitelistEntry `json:"whitelist"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type LightState struct {
|
||||||
|
On bool `json:"on"`
|
||||||
|
Bri int `json:"bri"`
|
||||||
|
Hue int `json:"hue"`
|
||||||
|
Sat int `json:"sat"`
|
||||||
|
Effect string `json:"effect"`
|
||||||
|
XY []float64 `json:"xy"`
|
||||||
|
CT int `json:"ct"`
|
||||||
|
Alert string `json:"alert"`
|
||||||
|
ColorMode string `json:"colormode"`
|
||||||
|
Mode string `json:"mode"`
|
||||||
|
Reachable bool `json:"reachable"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type LightStateInput struct {
|
||||||
|
On *bool `json:"on,omitempty"`
|
||||||
|
Bri *int `json:"bri,omitempty"`
|
||||||
|
Hue *int `json:"hue,omitempty"`
|
||||||
|
Sat *int `json:"sat,omitempty"`
|
||||||
|
Effect *string `json:"effect,omitempty"`
|
||||||
|
XY *[2]float64 `json:"xy,omitempty"`
|
||||||
|
CT *int `json:"ct,omitempty"`
|
||||||
|
Alert *string `json:"alert,omitempty"`
|
||||||
|
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"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Modelid string `json:"modelid"`
|
||||||
|
Manufacturername string `json:"manufacturername"`
|
||||||
|
Productname string `json:"productname"`
|
||||||
|
Capabilities struct {
|
||||||
|
Certified bool `json:"certified"`
|
||||||
|
Control struct {
|
||||||
|
Mindimlevel int `json:"mindimlevel"`
|
||||||
|
Maxlumen int `json:"maxlumen"`
|
||||||
|
Colorgamuttype string `json:"colorgamuttype"`
|
||||||
|
Colorgamut [][]float64 `json:"colorgamut"`
|
||||||
|
CT struct {
|
||||||
|
Min int `json:"min"`
|
||||||
|
Max int `json:"max"`
|
||||||
|
} `json:"ct"`
|
||||||
|
} `json:"control"`
|
||||||
|
Streaming struct {
|
||||||
|
Renderer bool `json:"renderer"`
|
||||||
|
Proxy bool `json:"proxy"`
|
||||||
|
} `json:"streaming"`
|
||||||
|
} `json:"capabilities"`
|
||||||
|
Config struct {
|
||||||
|
Archetype string `json:"archetype"`
|
||||||
|
Function string `json:"function"`
|
||||||
|
Direction string `json:"direction"`
|
||||||
|
Startup struct {
|
||||||
|
Mode string `json:"mode"`
|
||||||
|
Configured bool `json:"configured"`
|
||||||
|
} `json:"startup"`
|
||||||
|
} `json:"config"`
|
||||||
|
Swupdate struct {
|
||||||
|
State string `json:"state"`
|
||||||
|
Lastinstall string `json:"lastinstall"`
|
||||||
|
} `json:"swupdate"`
|
||||||
|
Uniqueid string `json:"uniqueid"`
|
||||||
|
Swversion string `json:"swversion"`
|
||||||
|
Swconfigid string `json:"swconfigid"`
|
||||||
|
Productid string `json:"productid"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type SensorData struct {
|
||||||
|
State struct {
|
||||||
|
Daylight interface{} `json:"daylight"`
|
||||||
|
ButtonEvent int `json:"buttonevent"`
|
||||||
|
LastUpdated string `json:"lastupdated"`
|
||||||
|
Presence bool `json:"presence"`
|
||||||
|
Temperature int
|
||||||
|
} `json:"state"`
|
||||||
|
Config struct {
|
||||||
|
On bool `json:"on"`
|
||||||
|
Configured bool `json:"configured"`
|
||||||
|
Sunriseoffset int `json:"sunriseoffset"`
|
||||||
|
Sunsetoffset int `json:"sunsetoffset"`
|
||||||
|
} `json:"config"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Type string `json:"type"`
|
||||||
|
Modelid string `json:"modelid"`
|
||||||
|
Manufacturername string `json:"manufacturername"`
|
||||||
|
Productname string `json:"productname"`
|
||||||
|
Swversion string `json:"swversion"`
|
||||||
|
UniqueID string `json:"uniqueid"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type WhitelistEntry struct {
|
||||||
|
LastUseDate string `json:"last use date"`
|
||||||
|
CreateDate string `json:"create date"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type BridgeDeviceInfo struct {
|
||||||
|
XMLName xml.Name `xml:"root"`
|
||||||
|
Text string `xml:",chardata"`
|
||||||
|
Xmlns string `xml:"xmlns,attr"`
|
||||||
|
SpecVersion struct {
|
||||||
|
Text string `xml:",chardata"`
|
||||||
|
Major string `xml:"major"`
|
||||||
|
Minor string `xml:"minor"`
|
||||||
|
} `xml:"specVersion"`
|
||||||
|
URLBase string `xml:"URLBase"`
|
||||||
|
Device struct {
|
||||||
|
Text string `xml:",chardata"`
|
||||||
|
DeviceType string `xml:"deviceType"`
|
||||||
|
FriendlyName string `xml:"friendlyName"`
|
||||||
|
Manufacturer string `xml:"manufacturer"`
|
||||||
|
ManufacturerURL string `xml:"manufacturerURL"`
|
||||||
|
ModelDescription string `xml:"modelDescription"`
|
||||||
|
ModelName string `xml:"modelName"`
|
||||||
|
ModelNumber string `xml:"modelNumber"`
|
||||||
|
ModelURL string `xml:"modelURL"`
|
||||||
|
SerialNumber string `xml:"serialNumber"`
|
||||||
|
UDN string `xml:"UDN"`
|
||||||
|
PresentationURL string `xml:"presentationURL"`
|
||||||
|
IconList struct {
|
||||||
|
Text string `xml:",chardata"`
|
||||||
|
Icon struct {
|
||||||
|
Text string `xml:",chardata"`
|
||||||
|
Mimetype string `xml:"mimetype"`
|
||||||
|
Height string `xml:"height"`
|
||||||
|
Width string `xml:"width"`
|
||||||
|
Depth string `xml:"depth"`
|
||||||
|
URL string `xml:"url"`
|
||||||
|
} `xml:"icon"`
|
||||||
|
} `xml:"iconList"`
|
||||||
|
} `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")
|
||||||
@@ -0,0 +1,410 @@
|
|||||||
|
package hue
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"encoding/xml"
|
||||||
|
"fmt"
|
||||||
|
"git.aiterp.net/lucifer/new-server/models"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Driver struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
bridges []*Bridge
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *Driver) SearchBridge(ctx context.Context, address, _ string, dryRun bool) ([]models.Bridge, error) {
|
||||||
|
if address == "" {
|
||||||
|
if !dryRun {
|
||||||
|
return nil, models.ErrAddressOnlyDryRunnable
|
||||||
|
}
|
||||||
|
|
||||||
|
res, err := http.Get("https://discovery.meethue.com")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer res.Body.Close()
|
||||||
|
|
||||||
|
entries := make([]DiscoveryEntry, 0, 8)
|
||||||
|
err = json.NewDecoder(res.Body).Decode(&entries)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
bridges := make([]models.Bridge, 0, len(entries))
|
||||||
|
for _, entry := range entries {
|
||||||
|
bridges = append(bridges, models.Bridge{
|
||||||
|
ID: -1,
|
||||||
|
Name: entry.Id,
|
||||||
|
Driver: models.DTHue,
|
||||||
|
Address: entry.InternalIPAddress,
|
||||||
|
Token: "",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return bridges, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
deviceInfo := BridgeDeviceInfo{}
|
||||||
|
res, err := http.Get(fmt.Sprintf("http://%s/description.xml", address))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer res.Body.Close()
|
||||||
|
err = xml.NewDecoder(res.Body).Decode(&deviceInfo)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
bridge := models.Bridge{
|
||||||
|
ID: -1,
|
||||||
|
Name: deviceInfo.Device.FriendlyName,
|
||||||
|
Driver: models.DTHue,
|
||||||
|
Address: address,
|
||||||
|
Token: "",
|
||||||
|
}
|
||||||
|
|
||||||
|
if !dryRun {
|
||||||
|
b := &Bridge{host: address}
|
||||||
|
|
||||||
|
timeout, cancel := context.WithTimeout(ctx, time.Second*30)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
ticker := time.NewTicker(time.Second)
|
||||||
|
defer ticker.Stop()
|
||||||
|
|
||||||
|
for range ticker.C {
|
||||||
|
token, err := b.getToken(timeout)
|
||||||
|
if err != nil {
|
||||||
|
if err == errLinkButtonNotPressed {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
bridge.Token = token
|
||||||
|
b.token = token
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return []models.Bridge{bridge}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *Driver) SearchDevices(ctx context.Context, bridge models.Bridge, timeout time.Duration) ([]models.Device, error) {
|
||||||
|
b, err := d.ensureBridge(ctx, bridge)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
before, err := d.ListDevices(ctx, bridge)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if timeout.Seconds() < 10 {
|
||||||
|
timeout = time.Second * 10
|
||||||
|
}
|
||||||
|
halfTime := timeout / 2
|
||||||
|
|
||||||
|
err = b.StartDiscovery(ctx, "sensors")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case <-time.After(halfTime):
|
||||||
|
case <-ctx.Done():
|
||||||
|
return nil, ctx.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
err = b.StartDiscovery(ctx, "lights")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case <-time.After(halfTime):
|
||||||
|
case <-ctx.Done():
|
||||||
|
return nil, ctx.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
err = b.Refresh(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
after, err := d.ListDevices(ctx, bridge)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
intersection := make([]models.Device, 0, 4)
|
||||||
|
for _, device := range after {
|
||||||
|
found := false
|
||||||
|
for _, device2 := range before {
|
||||||
|
if device2.InternalID == device.InternalID {
|
||||||
|
found = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !found {
|
||||||
|
intersection = append(intersection, device)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return intersection, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *Driver) ListDevices(ctx context.Context, bridge models.Bridge) ([]models.Device, error) {
|
||||||
|
b, err := d.ensureBridge(ctx, bridge)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
devices := make([]models.Device, 0, 8)
|
||||||
|
|
||||||
|
lightMap, err := b.getLights(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
for _, lightInfo := range lightMap {
|
||||||
|
device := models.Device{
|
||||||
|
ID: -1,
|
||||||
|
BridgeID: b.externalID,
|
||||||
|
InternalID: lightInfo.Uniqueid,
|
||||||
|
Icon: "lightbulb",
|
||||||
|
Name: lightInfo.Name,
|
||||||
|
Capabilities: []models.DeviceCapability{
|
||||||
|
models.DCPower,
|
||||||
|
},
|
||||||
|
ButtonNames: nil,
|
||||||
|
DriverProperties: map[string]interface{}{
|
||||||
|
"modelId": lightInfo.Modelid,
|
||||||
|
"productName": lightInfo.Productname,
|
||||||
|
"swVersion": lightInfo.Swversion,
|
||||||
|
"hueLightType": lightInfo.Type,
|
||||||
|
},
|
||||||
|
UserProperties: nil,
|
||||||
|
State: models.DeviceState{},
|
||||||
|
Tags: nil,
|
||||||
|
}
|
||||||
|
|
||||||
|
hasDimming := false
|
||||||
|
hasCT := false
|
||||||
|
hasColor := false
|
||||||
|
|
||||||
|
switch lightInfo.Type {
|
||||||
|
case "On/off light":
|
||||||
|
// Always take DCPower for granted anyway.
|
||||||
|
case "Dimmable light":
|
||||||
|
hasDimming = true
|
||||||
|
case "Color temperature light":
|
||||||
|
hasDimming = true
|
||||||
|
hasCT = true
|
||||||
|
case "Color light":
|
||||||
|
hasDimming = true
|
||||||
|
hasColor = true
|
||||||
|
case "Extended color light":
|
||||||
|
hasDimming = true
|
||||||
|
hasColor = true
|
||||||
|
hasCT = true
|
||||||
|
}
|
||||||
|
|
||||||
|
ctrl := lightInfo.Capabilities.Control
|
||||||
|
if hasDimming {
|
||||||
|
device.Capabilities = append(device.Capabilities, models.DCIntensity)
|
||||||
|
}
|
||||||
|
if hasCT {
|
||||||
|
device.Capabilities = append(device.Capabilities, models.DCColorKelvin)
|
||||||
|
device.DriverProperties["minKelvin"] = 1000000 / ctrl.CT.Max
|
||||||
|
device.DriverProperties["maxKelvin"] = 1000000 / ctrl.CT.Min
|
||||||
|
}
|
||||||
|
if hasColor {
|
||||||
|
device.Capabilities = append(device.Capabilities, models.DCColorHS)
|
||||||
|
device.DriverProperties["gamutType"] = ctrl.Colorgamuttype
|
||||||
|
device.DriverProperties["gamutData"] = ctrl.Colorgamut
|
||||||
|
}
|
||||||
|
device.DriverProperties["maxLumen"] = strconv.Itoa(ctrl.Maxlumen)
|
||||||
|
|
||||||
|
devices = append(devices, device)
|
||||||
|
}
|
||||||
|
|
||||||
|
sensorMap, err := b.getSensors(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
for _, sensorInfo := range sensorMap {
|
||||||
|
device := models.Device{
|
||||||
|
ID: -1,
|
||||||
|
BridgeID: b.externalID,
|
||||||
|
InternalID: sensorInfo.UniqueID,
|
||||||
|
Name: sensorInfo.Name,
|
||||||
|
Capabilities: []models.DeviceCapability{},
|
||||||
|
ButtonNames: []string{},
|
||||||
|
DriverProperties: map[string]interface{}{
|
||||||
|
"modelId": sensorInfo.Modelid,
|
||||||
|
"productName": sensorInfo.Productname,
|
||||||
|
"swVersion": sensorInfo.Swversion,
|
||||||
|
"hueLightType": sensorInfo.Type,
|
||||||
|
},
|
||||||
|
UserProperties: nil,
|
||||||
|
State: models.DeviceState{},
|
||||||
|
Tags: nil,
|
||||||
|
}
|
||||||
|
|
||||||
|
switch sensorInfo.Type {
|
||||||
|
case "ZLLSwitch":
|
||||||
|
device.Capabilities = append(device.Capabilities, models.DCButtons)
|
||||||
|
device.ButtonNames = append(buttonNames[:0:0], buttonNames...)
|
||||||
|
device.Icon = "lightswitch"
|
||||||
|
case "ZLLPresence":
|
||||||
|
device.Capabilities = append(device.Capabilities, models.DCPresence)
|
||||||
|
device.Icon = "sensor"
|
||||||
|
case "ZLLTemperature":
|
||||||
|
device.Capabilities = append(device.Capabilities, models.DCTemperatureSensor)
|
||||||
|
device.Icon = "thermometer"
|
||||||
|
case "Daylight":
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
devices = append(devices, device)
|
||||||
|
}
|
||||||
|
|
||||||
|
return devices, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *Driver) Publish(ctx context.Context, bridge models.Bridge, devices []models.Device) error {
|
||||||
|
b, err := d.ensureBridge(ctx, bridge)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
err = b.Refresh(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
b.mu.Lock()
|
||||||
|
for _, device := range devices {
|
||||||
|
for _, state := range b.lightStates {
|
||||||
|
if device.InternalID == state.uniqueID {
|
||||||
|
state.externalID = device.ID
|
||||||
|
state.Update(device.State)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, state := range b.sensorStates {
|
||||||
|
if device.InternalID == state.uniqueID {
|
||||||
|
state.externalID = device.ID
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
b.mu.Unlock()
|
||||||
|
|
||||||
|
atomic.StoreUint32(&b.syncingPublish, 1)
|
||||||
|
defer atomic.StoreUint32(&b.syncingPublish, 0)
|
||||||
|
|
||||||
|
return b.SyncStale(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *Driver) Run(ctx context.Context, bridge models.Bridge, ch chan<- models.Event) error {
|
||||||
|
b, err := d.ensureBridge(ctx, bridge)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
fastTicker := time.NewTicker(time.Second / 10)
|
||||||
|
slowTicker := time.NewTicker(time.Second / 3)
|
||||||
|
selectedTicker := fastTicker
|
||||||
|
ticksUntilRefresh := 0
|
||||||
|
ticksSinceChange := 0
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-selectedTicker.C:
|
||||||
|
if atomic.LoadUint32(&b.syncingPublish) == 1 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if ticksUntilRefresh <= 0 {
|
||||||
|
err := b.Refresh(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
err = b.SyncStale(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
ticksUntilRefresh = 60
|
||||||
|
}
|
||||||
|
|
||||||
|
events, err := b.SyncSensors(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, event := range events {
|
||||||
|
ch <- event
|
||||||
|
ticksSinceChange = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
if ticksSinceChange > 30 {
|
||||||
|
selectedTicker = slowTicker
|
||||||
|
} else if ticksSinceChange == 0 {
|
||||||
|
selectedTicker = fastTicker
|
||||||
|
}
|
||||||
|
case <-ctx.Done():
|
||||||
|
return ctx.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
ticksUntilRefresh -= 1
|
||||||
|
ticksSinceChange += 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *Driver) ensureBridge(ctx context.Context, info models.Bridge) (*Bridge, error) {
|
||||||
|
d.mu.Lock()
|
||||||
|
for _, bridge := range d.bridges {
|
||||||
|
if bridge.host == info.Address {
|
||||||
|
d.mu.Unlock()
|
||||||
|
return bridge, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
d.mu.Unlock()
|
||||||
|
|
||||||
|
bridge := &Bridge{
|
||||||
|
host: info.Address,
|
||||||
|
token: info.Token,
|
||||||
|
externalID: info.ID,
|
||||||
|
}
|
||||||
|
|
||||||
|
// If this call succeeds, then the token is ok.
|
||||||
|
lightMap, err := bridge.getLights(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
log.Printf("Found %d lights on bridge %d", len(lightMap), bridge.externalID)
|
||||||
|
|
||||||
|
// To avoid a potential duplicate, try looking for it again before inserting
|
||||||
|
d.mu.Lock()
|
||||||
|
for _, bridge := range d.bridges {
|
||||||
|
if bridge.host == info.Address {
|
||||||
|
d.mu.Unlock()
|
||||||
|
return bridge, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
d.bridges = append(d.bridges, bridge)
|
||||||
|
d.mu.Unlock()
|
||||||
|
|
||||||
|
return bridge, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,254 @@
|
|||||||
|
package hue
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"git.aiterp.net/lucifer/new-server/models"
|
||||||
|
"strconv"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type hueLightState struct {
|
||||||
|
index int
|
||||||
|
uniqueID string
|
||||||
|
externalID int
|
||||||
|
info LightData
|
||||||
|
input LightStateInput
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
input.Hue = ptrInt(int(state.Color.Hue*(65536/360)) % 65536)
|
||||||
|
if s.input.Hue == nil || *s.input.Hue != *input.Hue {
|
||||||
|
s.stale = true
|
||||||
|
}
|
||||||
|
|
||||||
|
input.Sat = ptrInt(int(state.Color.Saturation * 255))
|
||||||
|
if *input.Sat > 254 {
|
||||||
|
*input.Sat = 254
|
||||||
|
}
|
||||||
|
if *input.Sat < 0 {
|
||||||
|
*input.Sat = 0
|
||||||
|
}
|
||||||
|
if s.input.Sat == nil || *s.input.Sat != *input.Sat {
|
||||||
|
s.stale = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
input.Bri = ptrInt(int(state.Intensity * 255))
|
||||||
|
if *input.Bri > 254 {
|
||||||
|
*input.Bri = 254
|
||||||
|
} else if *input.Bri < 0 {
|
||||||
|
*input.Bri = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
if s.input.Bri == nil || *s.input.Bri != *input.Bri {
|
||||||
|
s.stale = true
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
input.On = ptrBool(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
if s.input.On == nil || *s.input.On != *input.On {
|
||||||
|
s.stale = true
|
||||||
|
}
|
||||||
|
|
||||||
|
input.TransitionTime = ptrInt(1)
|
||||||
|
|
||||||
|
s.input = input
|
||||||
|
}
|
||||||
|
|
||||||
|
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 {
|
||||||
|
s.input.Hue = ptrInt(state.Hue)
|
||||||
|
s.input.Sat = ptrInt(state.Sat)
|
||||||
|
s.input.Bri = ptrInt(state.Bri)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
} else if state.ColorMode == "ct" {
|
||||||
|
if s.input.CT == nil || state.CT != *s.input.CT {
|
||||||
|
s.stale = true
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if s.input.Hue == nil || state.Hue != *s.input.Hue || s.input.Sat == nil || state.Sat != *s.input.Sat {
|
||||||
|
s.stale = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type hueSensorState struct {
|
||||||
|
index int
|
||||||
|
externalID int
|
||||||
|
uniqueID string
|
||||||
|
prevData *SensorData
|
||||||
|
prevTime time.Time
|
||||||
|
presenceCooldown int
|
||||||
|
}
|
||||||
|
|
||||||
|
func (state *hueSensorState) Update(newData SensorData) *models.Event {
|
||||||
|
stateTime, err := time.ParseInLocation("2006-01-02T15:04:05", newData.State.LastUpdated, time.UTC)
|
||||||
|
if err != nil {
|
||||||
|
// Invalid time is probably "none".
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
defer func() {
|
||||||
|
state.prevData = &newData
|
||||||
|
state.prevTime = stateTime
|
||||||
|
}()
|
||||||
|
|
||||||
|
if state.uniqueID != newData.UniqueID {
|
||||||
|
state.uniqueID = newData.UniqueID
|
||||||
|
}
|
||||||
|
|
||||||
|
if state.prevData != nil && newData.Type != state.prevData.Type {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
switch newData.Type {
|
||||||
|
case "ZLLSwitch":
|
||||||
|
{
|
||||||
|
// Ignore old events.
|
||||||
|
if time.Since(stateTime) > time.Second*3 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if state.prevData == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
pe := state.prevData.State.ButtonEvent
|
||||||
|
ce := newData.State.ButtonEvent
|
||||||
|
|
||||||
|
td := stateTime.Sub(state.prevTime) >= time.Second
|
||||||
|
pIdx := (pe / 1000) - 1
|
||||||
|
cIdx := (ce / 1000) - 1
|
||||||
|
pBtn := pe % 1000 / 2 // 0 = pressed, 1 = released
|
||||||
|
cBtn := ce % 1000 / 2 // 0 = pressed, 1 = released
|
||||||
|
|
||||||
|
if pIdx == cIdx && cBtn == 1 && pBtn == 0 {
|
||||||
|
// Do not allow 4002 after 4000.
|
||||||
|
// 4000 after 4002 is fine, though.
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
if cBtn != pBtn || cIdx != pIdx || td {
|
||||||
|
return &models.Event{
|
||||||
|
Name: models.ENButtonPressed,
|
||||||
|
Payload: map[string]string{
|
||||||
|
"buttonIndex": strconv.Itoa(cIdx),
|
||||||
|
"buttonName": buttonNames[cIdx],
|
||||||
|
"deviceId": strconv.Itoa(state.externalID),
|
||||||
|
"hueButtonEvent": strconv.Itoa(ce),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case "ZLLPresence":
|
||||||
|
{
|
||||||
|
if state.prevData != nil && state.prevData.State.Presence != newData.State.Presence {
|
||||||
|
if newData.State.Presence {
|
||||||
|
state.presenceCooldown = -1
|
||||||
|
|
||||||
|
return &models.Event{
|
||||||
|
Name: models.ENSensorPresenceStarted,
|
||||||
|
Payload: map[string]string{
|
||||||
|
"deviceId": strconv.Itoa(state.externalID),
|
||||||
|
"deviceInternalId": newData.UniqueID,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
state.presenceCooldown = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if state.presenceCooldown == -2 {
|
||||||
|
state.presenceCooldown = int(time.Since(stateTime) / time.Minute)
|
||||||
|
}
|
||||||
|
|
||||||
|
nextEventWait := time.Minute * time.Duration(state.presenceCooldown)
|
||||||
|
if state.presenceCooldown != -1 && !newData.State.Presence && time.Since(stateTime) > nextEventWait {
|
||||||
|
state.presenceCooldown += 1
|
||||||
|
|
||||||
|
return &models.Event{
|
||||||
|
Name: models.ENSensorPresenceEnded,
|
||||||
|
Payload: map[string]string{
|
||||||
|
"deviceId": strconv.Itoa(state.externalID),
|
||||||
|
"deviceInternalId": newData.UniqueID,
|
||||||
|
"minutesElapsed": strconv.Itoa(state.presenceCooldown - 1),
|
||||||
|
"secondsElapsed": strconv.Itoa((state.presenceCooldown - 1) * 60),
|
||||||
|
"lastUpdated": strconv.FormatInt(stateTime.Unix(), 10),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case "ZLLTemperature":
|
||||||
|
if time.Since(stateTime) > (time.Minute * 15) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if !state.prevTime.Equal(stateTime) {
|
||||||
|
return &models.Event{
|
||||||
|
Name: models.ENSensorTemperature,
|
||||||
|
Payload: map[string]string{
|
||||||
|
"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),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func ptrBool(v bool) *bool {
|
||||||
|
return &v
|
||||||
|
}
|
||||||
|
|
||||||
|
func ptrInt(v int) *int {
|
||||||
|
return &v
|
||||||
|
}
|
||||||
@@ -0,0 +1,269 @@
|
|||||||
|
package lifx
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"git.aiterp.net/lucifer/new-server/models"
|
||||||
|
"log"
|
||||||
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Bridge struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
externalID int
|
||||||
|
ip string
|
||||||
|
states []*State
|
||||||
|
client *Client
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *Bridge) StartSearch(ctx context.Context) error {
|
||||||
|
c := b.getClient()
|
||||||
|
if c == nil {
|
||||||
|
return models.ErrBridgeRunningRequired
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := c.HorribleBroadcast(ctx, &GetService{})
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *Bridge) Publish(devices []models.Device) {
|
||||||
|
b.mu.Lock()
|
||||||
|
for _, device := range devices {
|
||||||
|
state, _ := b.ensureState(device.InternalID)
|
||||||
|
|
||||||
|
state.deviceState = new(models.DeviceState)
|
||||||
|
*state.deviceState = device.State
|
||||||
|
|
||||||
|
b.checkAndUpdateState(state)
|
||||||
|
|
||||||
|
state.externalId = device.ID
|
||||||
|
}
|
||||||
|
b.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *Bridge) Devices() []models.Device {
|
||||||
|
devices := make([]models.Device, 0, 8)
|
||||||
|
|
||||||
|
b.mu.Lock()
|
||||||
|
for _, state := range b.states {
|
||||||
|
if state.lightState == nil || state.firmware == nil || state.version == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
deviceState := models.DeviceState{}
|
||||||
|
if state.deviceState != nil {
|
||||||
|
deviceState = *state.deviceState
|
||||||
|
}
|
||||||
|
|
||||||
|
device := models.Device{
|
||||||
|
ID: state.externalId,
|
||||||
|
BridgeID: b.externalID,
|
||||||
|
InternalID: state.target,
|
||||||
|
Icon: "lightbulb",
|
||||||
|
Name: state.lightState.Label,
|
||||||
|
Capabilities: []models.DeviceCapability{models.DCPower, models.DCIntensity},
|
||||||
|
DriverProperties: make(map[string]interface{}),
|
||||||
|
State: deviceState,
|
||||||
|
}
|
||||||
|
|
||||||
|
device.DriverProperties["lifxVendorId"] = state.version.Vendor
|
||||||
|
device.DriverProperties["lifxProductId"] = state.version.Product
|
||||||
|
device.DriverProperties["lifxFirmwareMajor"] = state.firmware.Major
|
||||||
|
device.DriverProperties["lifxFirmwareMinor"] = state.firmware.Minor
|
||||||
|
device.DriverProperties["lifxFirmwareBuild"] = state.firmware.BuildTime
|
||||||
|
|
||||||
|
product := findProduct(state.version.Vendor, state.version.Product)
|
||||||
|
if product != nil {
|
||||||
|
device.DriverProperties["productName"] = product.Name
|
||||||
|
|
||||||
|
if product.Features.Color {
|
||||||
|
device.Capabilities = append(device.Capabilities, models.DCColorHS)
|
||||||
|
}
|
||||||
|
if len(product.Features.TemperatureRange) >= 2 {
|
||||||
|
device.Capabilities = append(device.Capabilities, models.DCColorHSK, models.DCColorKelvin)
|
||||||
|
device.DriverProperties["minKelvin"] = product.Features.TemperatureRange[0]
|
||||||
|
device.DriverProperties["maxKelvin"] = product.Features.TemperatureRange[1]
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, upgrade := range product.Upgrades {
|
||||||
|
if state.firmware.Major > upgrade.Major || (state.firmware.Major >= upgrade.Major && state.firmware.Minor >= upgrade.Minor) {
|
||||||
|
if upgrade.Features.TemperatureRange != nil {
|
||||||
|
device.DriverProperties["minKelvin"] = upgrade.Features.TemperatureRange[0]
|
||||||
|
device.DriverProperties["maxKelvin"] = upgrade.Features.TemperatureRange[1]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
devices = append(devices, device)
|
||||||
|
}
|
||||||
|
b.mu.Unlock()
|
||||||
|
|
||||||
|
return devices
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *Bridge) Run(ctx context.Context, debug bool) error {
|
||||||
|
client, err := createClient(ctx, b.ip, debug)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
b.mu.Lock()
|
||||||
|
b.client = client
|
||||||
|
b.mu.Unlock()
|
||||||
|
defer func() {
|
||||||
|
b.mu.Lock()
|
||||||
|
if b.client == client {
|
||||||
|
b.client = nil
|
||||||
|
}
|
||||||
|
b.mu.Unlock()
|
||||||
|
}()
|
||||||
|
|
||||||
|
lastSearchTime := time.Now()
|
||||||
|
lastServiceTime := time.Time{}
|
||||||
|
_, err = client.Send("", &GetService{})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
for {
|
||||||
|
target, seq, payload, err := client.Recv(time.Millisecond * 200)
|
||||||
|
if err == models.ErrInvalidPacketSize || err == models.ErrPayloadTooShort || err == models.ErrUnrecognizedPacketType {
|
||||||
|
log.Println("LIFX udp socket received something weird:", err)
|
||||||
|
} else if err != nil && err != models.ErrReadTimeout {
|
||||||
|
if ctx.Err() != nil {
|
||||||
|
return ctx.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if payload != nil {
|
||||||
|
b.mu.Lock()
|
||||||
|
state, _ := b.ensureState(target)
|
||||||
|
b.mu.Unlock()
|
||||||
|
|
||||||
|
switch p := payload.(type) {
|
||||||
|
case *StateService:
|
||||||
|
if p.Service == 1 {
|
||||||
|
// Throw these messages to the wind. UDP errors will eventually be caught.
|
||||||
|
|
||||||
|
// Get the version only if it's missing. It should never change anyway.
|
||||||
|
b.mu.Lock()
|
||||||
|
if state.version == nil {
|
||||||
|
_, _ = client.Send(target, &GetVersion{})
|
||||||
|
}
|
||||||
|
b.mu.Unlock()
|
||||||
|
|
||||||
|
_, _ = client.Send(target, &GetHostFirmware{})
|
||||||
|
|
||||||
|
lastServiceTime = time.Now()
|
||||||
|
}
|
||||||
|
case *LightState:
|
||||||
|
b.mu.Lock()
|
||||||
|
state.lightState = p
|
||||||
|
state.lightStateTime = time.Now()
|
||||||
|
|
||||||
|
if state.deviceState == nil {
|
||||||
|
state.deviceState = &models.DeviceState{
|
||||||
|
Power: p.On,
|
||||||
|
Color: models.ColorValue{
|
||||||
|
Hue: p.Hue,
|
||||||
|
Saturation: p.Sat,
|
||||||
|
Kelvin: p.Kelvin,
|
||||||
|
},
|
||||||
|
Intensity: p.Bri,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
b.checkAndUpdateState(state)
|
||||||
|
b.mu.Unlock()
|
||||||
|
case *StateHostFirmware:
|
||||||
|
b.mu.Lock()
|
||||||
|
state.firmware = p
|
||||||
|
b.mu.Unlock()
|
||||||
|
case *StateVersion:
|
||||||
|
b.mu.Lock()
|
||||||
|
state.version = p
|
||||||
|
b.mu.Unlock()
|
||||||
|
case *Acknowledgement:
|
||||||
|
b.mu.Lock()
|
||||||
|
state.handleAck(seq)
|
||||||
|
b.mu.Unlock()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
b.mu.Lock()
|
||||||
|
for _, state := range b.states {
|
||||||
|
if time.Since(state.lightStateTime) > time.Second*10 && time.Since(state.requestTime) > time.Second*3 {
|
||||||
|
state.requestTime = time.Now()
|
||||||
|
_, _ = client.Send(state.target, &GetColor{})
|
||||||
|
} else if len(state.acksPending) > 0 && time.Since(state.updateTime) > time.Second {
|
||||||
|
state.requestTime = time.Now()
|
||||||
|
b.checkAndUpdateState(state)
|
||||||
|
}
|
||||||
|
|
||||||
|
if time.Since(state.discoveredTime) > time.Second*10 && time.Since(state.fwSpamTime) > time.Second * 30 {
|
||||||
|
state.fwSpamTime = time.Now()
|
||||||
|
|
||||||
|
if state.firmware == nil {
|
||||||
|
_, _ = client.Send(state.target, &GetHostFirmware{})
|
||||||
|
}
|
||||||
|
if state.version == nil {
|
||||||
|
_, _ = client.Send(state.target, &GetVersion{})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
b.mu.Unlock()
|
||||||
|
|
||||||
|
if atomic.LoadUint32(&client.isHorrible) == 0 && time.Since(lastServiceTime) > time.Second*30 && time.Since(lastSearchTime) > time.Second*3 {
|
||||||
|
lastSearchTime = time.Now()
|
||||||
|
_, err = client.Send("", &GetService{})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *Bridge) checkAndUpdateState(state *State) {
|
||||||
|
state.acksPending = state.acksPending[:0]
|
||||||
|
|
||||||
|
updatePayloads := state.generateUpdate()
|
||||||
|
for _, updatePayload := range updatePayloads {
|
||||||
|
seq, err := b.client.Send(state.target, updatePayload)
|
||||||
|
if err != nil {
|
||||||
|
log.Println("Error sending updates to", state.externalId, state.target, "err:", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
state.updateTime = time.Now()
|
||||||
|
state.acksPending = append(state.acksPending, seq)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *Bridge) ensureState(target string) (*State, bool) {
|
||||||
|
for _, state := range b.states {
|
||||||
|
if state.target == target {
|
||||||
|
return state, false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
state := &State{
|
||||||
|
target: target,
|
||||||
|
discoveredTime: time.Now(),
|
||||||
|
}
|
||||||
|
|
||||||
|
b.states = append(b.states, state)
|
||||||
|
|
||||||
|
return state, true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *Bridge) getClient() *Client {
|
||||||
|
b.mu.Lock()
|
||||||
|
client := b.client
|
||||||
|
b.mu.Unlock()
|
||||||
|
|
||||||
|
return client
|
||||||
|
}
|
||||||
@@ -0,0 +1,285 @@
|
|||||||
|
package lifx
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/binary"
|
||||||
|
"errors"
|
||||||
|
"git.aiterp.net/lucifer/new-server/models"
|
||||||
|
"log"
|
||||||
|
"math/rand"
|
||||||
|
"net"
|
||||||
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Client struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
seq uint8
|
||||||
|
buf []byte
|
||||||
|
lastN int
|
||||||
|
|
||||||
|
conn *net.UDPConn
|
||||||
|
source uint32
|
||||||
|
debug bool
|
||||||
|
|
||||||
|
isHorrible uint32
|
||||||
|
|
||||||
|
addrMap map[string]*net.UDPAddr
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send sends the payload to the hardware addr specified. If it cannot find a mapped
|
||||||
|
// IP address from a past message, it will be broadcast and may be slow to use. If the
|
||||||
|
// address is left blank, the package is marked as tagged and broadcast.
|
||||||
|
//
|
||||||
|
// ErrInvalidAddress can be returned by this, and indicates a badly configured device.
|
||||||
|
// It should probably be logged.
|
||||||
|
func (c *Client) Send(addr string, payload Payload) (seq uint8, err error) {
|
||||||
|
c.mu.Lock()
|
||||||
|
seq = c.seq
|
||||||
|
c.seq += 1
|
||||||
|
c.mu.Unlock()
|
||||||
|
|
||||||
|
packet := createPacket()
|
||||||
|
packet.SetSource(c.source)
|
||||||
|
packet.SetSequence(seq)
|
||||||
|
|
||||||
|
sendAddr := &net.UDPAddr{IP: net.IPv4(255, 255, 255, 255), Port: 56700}
|
||||||
|
if addr == "" {
|
||||||
|
packet.SetTagged(true)
|
||||||
|
|
||||||
|
if c.debug {
|
||||||
|
log.Println("Broadcasting", payload, "seq", seq)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
err = packet.SetTarget(addr)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.mu.Lock()
|
||||||
|
if udpAddr, ok := c.addrMap[addr]; ok {
|
||||||
|
sendAddr = udpAddr
|
||||||
|
}
|
||||||
|
c.mu.Unlock()
|
||||||
|
|
||||||
|
if c.debug {
|
||||||
|
log.Println("Sending", payload, "to", addr, "seq", seq)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
packet.SetPayload(payload)
|
||||||
|
|
||||||
|
_, err = c.conn.WriteToUDP(packet, sendAddr)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// HorribleBroadcast "broadcasts" by blasting every IP address in the space with a tagged packet, because LIFX is LIFX.
|
||||||
|
func (c *Client) HorribleBroadcast(ctx context.Context, payload Payload) (seq uint8, err error) {
|
||||||
|
c.mu.Lock()
|
||||||
|
seq = c.seq
|
||||||
|
c.seq += 1
|
||||||
|
c.mu.Unlock()
|
||||||
|
|
||||||
|
defer atomic.StoreUint32(&c.isHorrible, 0)
|
||||||
|
|
||||||
|
packet := createPacket()
|
||||||
|
packet.SetSource(c.source)
|
||||||
|
packet.SetSequence(seq)
|
||||||
|
packet.SetPayload(payload)
|
||||||
|
packet.SetTagged(true)
|
||||||
|
|
||||||
|
ifaces, err := net.Interfaces()
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
connIP := c.conn.LocalAddr().(*net.UDPAddr).IP
|
||||||
|
|
||||||
|
var minIPNum uint32
|
||||||
|
var maxIPNum uint32
|
||||||
|
for _, iface := range ifaces {
|
||||||
|
addrs, err := iface.Addrs()
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, addr := range addrs {
|
||||||
|
naddr, ok := addr.(*net.IPNet)
|
||||||
|
if !ok || !naddr.Contains(connIP) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
networkAddress := naddr.IP.Mask(naddr.Mask)
|
||||||
|
cidr, bits := naddr.Mask.Size()
|
||||||
|
minIPNum = binary.BigEndian.Uint32(networkAddress) + 1
|
||||||
|
maxIPNum = minIPNum + (1 << (bits - cidr)) - 2
|
||||||
|
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if minIPNum == maxIPNum {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
myIPNum := binary.BigEndian.Uint32(connIP)
|
||||||
|
|
||||||
|
if c.debug {
|
||||||
|
ipBuf := make(net.IP, 8)
|
||||||
|
binary.BigEndian.PutUint32(ipBuf, minIPNum)
|
||||||
|
binary.BigEndian.PutUint32(ipBuf[4:], maxIPNum-1)
|
||||||
|
|
||||||
|
log.Println("Sending tagged", payload, "to range", ipBuf[:4], "-", ipBuf[4:])
|
||||||
|
|
||||||
|
defer func() {
|
||||||
|
log.Println("Completed sending tagged", payload, "to range", ipBuf[:4], "-", ipBuf[4:])
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
for ipNum := minIPNum; ipNum < maxIPNum; ipNum++ {
|
||||||
|
if ipNum == myIPNum {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
atomic.StoreUint32(&c.isHorrible, 1)
|
||||||
|
|
||||||
|
ipBuf := make(net.IP, 4)
|
||||||
|
binary.BigEndian.PutUint32(ipBuf, ipNum)
|
||||||
|
addr := &net.UDPAddr{IP: ipBuf, Port: 56700}
|
||||||
|
|
||||||
|
_, err = c.conn.WriteToUDP(packet, addr)
|
||||||
|
if err != nil {
|
||||||
|
if c.debug {
|
||||||
|
log.Println("HorribleBroadcast™ aborted by error.")
|
||||||
|
}
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if ipNum%256 == 0 {
|
||||||
|
if c.debug {
|
||||||
|
log.Println("Progress:", (ipNum-minIPNum)+1, "of", maxIPNum-minIPNum)
|
||||||
|
}
|
||||||
|
|
||||||
|
time.Sleep(time.Millisecond * 100)
|
||||||
|
}
|
||||||
|
|
||||||
|
if ctx.Err() != nil {
|
||||||
|
if c.debug {
|
||||||
|
log.Println("HorribleBroadcast™ aborted at call site.")
|
||||||
|
}
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// LastPacket gets the last read packet. This data is valid until the next call to Recv. The Packet may
|
||||||
|
// not be valid, however!
|
||||||
|
func (c *Client) LastPacket() Packet {
|
||||||
|
if c.lastN < 36 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.buf[:c.lastN]
|
||||||
|
}
|
||||||
|
|
||||||
|
// Recv reads a message from the UDP socket. The data returned is decoded and will always be valid.
|
||||||
|
//
|
||||||
|
// However, these should be handled specifically:
|
||||||
|
// - ErrPayloadTooShort, ErrInvalidPacketSize: Garbage was received, please ignore.
|
||||||
|
// - ErrUnrecognizedPacketType: Log these and see what's up
|
||||||
|
// - ErrReadTimeout: The connection gets a 50ms read deadline. It should be used to do other things than wait
|
||||||
|
func (c *Client) Recv(timeout time.Duration) (target string, seq uint8, payload Payload, err error) {
|
||||||
|
if c.buf == nil {
|
||||||
|
c.buf = make([]byte, 2048)
|
||||||
|
}
|
||||||
|
|
||||||
|
if timeout > 0 {
|
||||||
|
err = c.conn.SetReadDeadline(time.Now().Add(timeout))
|
||||||
|
} else {
|
||||||
|
err = c.conn.SetReadDeadline(time.Time{})
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
n, addr, err := c.conn.ReadFromUDP(c.buf)
|
||||||
|
if n > 0 {
|
||||||
|
c.lastN = n
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
if netErr, ok := err.(*net.OpError); ok && netErr.Timeout() {
|
||||||
|
err = models.ErrReadTimeout
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
packet := Packet(c.buf[:n])
|
||||||
|
if n < 2 || packet.Size() != n && packet.Protocol() != 1024 {
|
||||||
|
err = models.ErrInvalidAddress
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
seq = packet.Sequence()
|
||||||
|
target = packet.Target().String()
|
||||||
|
|
||||||
|
payload, err = packet.Payload()
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Learn the IP address from state service or ack messages.
|
||||||
|
if service, ok := payload.(*StateService); ok && service.Service == 1 {
|
||||||
|
c.mu.Lock()
|
||||||
|
if c.addrMap == nil {
|
||||||
|
c.addrMap = make(map[string]*net.UDPAddr)
|
||||||
|
}
|
||||||
|
c.addrMap[packet.Target().String()] = addr
|
||||||
|
c.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
if c.debug {
|
||||||
|
log.Println("Received", payload, "from", target, "seq", seq)
|
||||||
|
}
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// createClient creates a client that will last as long as the context.
|
||||||
|
func createClient(ctx context.Context, bindAddr string, debug bool) (*Client, error) {
|
||||||
|
addr := net.ParseIP(bindAddr)
|
||||||
|
if addr == nil {
|
||||||
|
return nil, errors.New("invalid addr")
|
||||||
|
}
|
||||||
|
|
||||||
|
source := uint32(rand.Uint64())
|
||||||
|
if source < 2 {
|
||||||
|
source = 2
|
||||||
|
}
|
||||||
|
|
||||||
|
conn, err := net.ListenUDP("udp", &net.UDPAddr{
|
||||||
|
IP: addr,
|
||||||
|
Port: 0,
|
||||||
|
Zone: "",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
_ = conn.SetWriteBuffer(2048 * 1024)
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
<-ctx.Done()
|
||||||
|
_ = conn.Close()
|
||||||
|
}()
|
||||||
|
|
||||||
|
return &Client{conn: conn, source: source, debug: debug}, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,177 @@
|
|||||||
|
package lifx
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"git.aiterp.net/lucifer/new-server/models"
|
||||||
|
"net"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Driver struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
bridges []*Bridge
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *Driver) SearchBridge(ctx context.Context, address, _ string, _ bool) ([]models.Bridge, error) {
|
||||||
|
if address == "" {
|
||||||
|
ifaces, err := net.Interfaces()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
bridges := make([]models.Bridge, 0, len(ifaces))
|
||||||
|
for _, iface := range ifaces {
|
||||||
|
if iface.Name == "lo" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
addrs, err := iface.Addrs()
|
||||||
|
if err != nil || len(addrs) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, addr := range addrs {
|
||||||
|
bridges = append(bridges, models.Bridge{
|
||||||
|
ID: -1,
|
||||||
|
Name: fmt.Sprintf("%s (%s)", iface.Name, addr),
|
||||||
|
Driver: models.DTLIFX,
|
||||||
|
Address: addr.String(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return bridges, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx2, cancel := context.WithCancel(ctx)
|
||||||
|
defer cancel()
|
||||||
|
_, err := createClient(ctx2, address, false)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return []models.Bridge{{
|
||||||
|
ID: 0,
|
||||||
|
Name: "Your network card",
|
||||||
|
Driver: models.DTLIFX,
|
||||||
|
Address: address,
|
||||||
|
Token: "",
|
||||||
|
}}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *Driver) SearchDevices(ctx context.Context, bridge models.Bridge, timeout time.Duration) ([]models.Device, error) {
|
||||||
|
b, err := d.ensureBridge(ctx, bridge)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
before, err := d.ListDevices(ctx, bridge)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
err = b.StartSearch(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if timeout < time.Second/10 {
|
||||||
|
timeout = time.Second / 10
|
||||||
|
}
|
||||||
|
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return nil, ctx.Err()
|
||||||
|
case <-time.After(timeout):
|
||||||
|
}
|
||||||
|
|
||||||
|
after, err := d.ListDevices(ctx, bridge)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
intersection := make([]models.Device, 0, 4)
|
||||||
|
for _, device := range after {
|
||||||
|
found := false
|
||||||
|
for _, device2 := range before {
|
||||||
|
if device2.InternalID == device.InternalID {
|
||||||
|
found = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !found {
|
||||||
|
intersection = append(intersection, device)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return intersection, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *Driver) ListDevices(ctx context.Context, bridge models.Bridge) ([]models.Device, error) {
|
||||||
|
b, err := d.ensureBridge(ctx, bridge)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return b.Devices(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *Driver) Publish(ctx context.Context, bridge models.Bridge, devices []models.Device) error {
|
||||||
|
b, err := d.ensureBridge(ctx, bridge)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
b.Publish(devices)
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *Driver) Run(ctx context.Context, bridge models.Bridge, ch chan<- models.Event) error {
|
||||||
|
b, err := d.ensureBridge(ctx, bridge)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return b.Run(ctx, bridge.Token == "debug")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *Driver) ensureBridge(ctx context.Context, info models.Bridge) (*Bridge, error) {
|
||||||
|
d.mu.Lock()
|
||||||
|
for _, bridge := range d.bridges {
|
||||||
|
if bridge.ip == info.Address {
|
||||||
|
d.mu.Unlock()
|
||||||
|
return bridge, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
d.mu.Unlock()
|
||||||
|
|
||||||
|
bridge := &Bridge{
|
||||||
|
ip: info.Address,
|
||||||
|
externalID: info.ID,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to create a short-lived client to make sure it works.
|
||||||
|
ctx2, cancel := context.WithCancel(ctx)
|
||||||
|
defer cancel()
|
||||||
|
_, err := createClient(ctx2, info.Address, info.Token == "debug")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// To avoid a potential duplicate, try looking for it again before inserting
|
||||||
|
d.mu.Lock()
|
||||||
|
for _, bridge := range d.bridges {
|
||||||
|
if bridge.ip == info.Address {
|
||||||
|
d.mu.Unlock()
|
||||||
|
return bridge, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
d.bridges = append(d.bridges, bridge)
|
||||||
|
d.mu.Unlock()
|
||||||
|
|
||||||
|
return bridge, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,191 @@
|
|||||||
|
package lifx
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/binary"
|
||||||
|
"fmt"
|
||||||
|
"git.aiterp.net/lucifer/new-server/models"
|
||||||
|
"log"
|
||||||
|
"net"
|
||||||
|
)
|
||||||
|
|
||||||
|
func createPacket() Packet {
|
||||||
|
p := make(Packet, 36)
|
||||||
|
p.Reset()
|
||||||
|
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
|
||||||
|
type Packet []byte
|
||||||
|
|
||||||
|
func (p *Packet) Reset() {
|
||||||
|
p.ensureHeader()
|
||||||
|
copy(*p, blankHeader)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Packet) Size() int {
|
||||||
|
return int(binary.LittleEndian.Uint16((*p)[0:2]))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Packet) Protocol() int {
|
||||||
|
return int(binary.LittleEndian.Uint16((*p)[2:3]) & 0b00001111_11111111)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Packet) SetTagged(v bool) {
|
||||||
|
if v {
|
||||||
|
(*p)[3] |= uint8(0b00100000)
|
||||||
|
} else {
|
||||||
|
(*p)[3] &= ^uint8(0b00100000)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Packet) Source() uint32 {
|
||||||
|
return binary.LittleEndian.Uint32((*p)[4:8])
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Packet) Target() net.HardwareAddr {
|
||||||
|
return net.HardwareAddr((*p)[8:14])
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Packet) SetSource(v uint32) {
|
||||||
|
binary.LittleEndian.PutUint32((*p)[4:8], v)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Packet) CopyTargetFrom(other Packet) {
|
||||||
|
copy((*p)[8:14], other[8:14])
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Packet) SetTarget(v string) error {
|
||||||
|
addr, err := net.ParseMAC(v)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if len(addr) != 6 {
|
||||||
|
return models.ErrInvalidAddress
|
||||||
|
}
|
||||||
|
|
||||||
|
copy((*p)[8:], addr)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Packet) SetResRequired(v bool) {
|
||||||
|
if v {
|
||||||
|
(*p)[22] |= uint8(0b00000001)
|
||||||
|
} else {
|
||||||
|
(*p)[22] &= ^uint8(0b00000001)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Packet) SetAckRequired(v bool) {
|
||||||
|
if v {
|
||||||
|
(*p)[22] |= uint8(0b00000010)
|
||||||
|
} else {
|
||||||
|
(*p)[22] &= ^uint8(0b00000010)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Packet) Sequence() uint8 {
|
||||||
|
return (*p)[23]
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Packet) SetSequence(v uint8) {
|
||||||
|
(*p)[23] = v
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Packet) PacketType() uint16 {
|
||||||
|
return binary.LittleEndian.Uint16((*p)[32:34])
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Packet) SetRawPayload(packetType uint16, data []byte) {
|
||||||
|
p.ensureSize(36 + len(data))
|
||||||
|
copy((*p)[36:], data)
|
||||||
|
binary.LittleEndian.PutUint16(*p, uint16(36+len(data)))
|
||||||
|
binary.LittleEndian.PutUint16((*p)[32:34], packetType)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Packet) Payload() (res Payload, err error) {
|
||||||
|
switch p.PacketType() {
|
||||||
|
case 2:
|
||||||
|
res = &GetService{}
|
||||||
|
case 3:
|
||||||
|
res = &StateService{}
|
||||||
|
err = res.Decode((*p)[36:])
|
||||||
|
case 14:
|
||||||
|
res = &GetHostFirmware{}
|
||||||
|
case 15:
|
||||||
|
res = &StateHostFirmware{}
|
||||||
|
err = res.Decode((*p)[36:])
|
||||||
|
case 32:
|
||||||
|
res = &GetVersion{}
|
||||||
|
case 33:
|
||||||
|
res = &StateVersion{}
|
||||||
|
err = res.Decode((*p)[36:])
|
||||||
|
case 45:
|
||||||
|
res = &Acknowledgement{}
|
||||||
|
case 101:
|
||||||
|
res = &GetColor{}
|
||||||
|
case 102:
|
||||||
|
res = &SetColor{}
|
||||||
|
err = res.Decode((*p)[36:])
|
||||||
|
case 107:
|
||||||
|
res = &LightState{}
|
||||||
|
err = res.Decode((*p)[36:])
|
||||||
|
case 117:
|
||||||
|
res = &SetLightPower{}
|
||||||
|
err = res.Decode((*p)[36:])
|
||||||
|
default:
|
||||||
|
err = models.ErrUnrecognizedPacketType
|
||||||
|
}
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
res = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if res != nil && res.PacketType() != p.PacketType() {
|
||||||
|
log.Println("BUG Incorrect packet type used in Packet.Payload")
|
||||||
|
log.Println("BUG Payload Type:", p.PacketType())
|
||||||
|
log.Println("BUG Panic Packet Type:", res.PacketType())
|
||||||
|
log.Println("BUG Panic Payload:", res.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Packet) SetPayload(payload Payload) {
|
||||||
|
ackRequired, resRequired := payload.Flags()
|
||||||
|
|
||||||
|
p.SetRawPayload(payload.PacketType(), payload.Encode())
|
||||||
|
p.SetAckRequired(ackRequired)
|
||||||
|
p.SetResRequired(resRequired)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Packet) ensureHeader() {
|
||||||
|
p.ensureSize(36)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Packet) ensureSize(size int) {
|
||||||
|
if len(*p) < size {
|
||||||
|
newData := make([]byte, size)
|
||||||
|
copy(newData, *p)
|
||||||
|
*p = newData
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type Payload interface {
|
||||||
|
fmt.Stringer
|
||||||
|
Decode(data []byte) error
|
||||||
|
Encode() []byte
|
||||||
|
PacketType() uint16
|
||||||
|
Flags() (ackRequired bool, resRequired bool)
|
||||||
|
}
|
||||||
|
|
||||||
|
var blankHeader = []byte{
|
||||||
|
0x24, 0x00, 0x00, 0x14,
|
||||||
|
0x00, 0x00, 0x00, 0x00,
|
||||||
|
0x00, 0x00, 0x00, 0x00,
|
||||||
|
0x00, 0x00, 0x00, 0x00,
|
||||||
|
0x00, 0x00, 0x00, 0x00,
|
||||||
|
0x00, 0x00, 0x00, 0x00,
|
||||||
|
0x00, 0x00, 0x00, 0x00,
|
||||||
|
0x00, 0x00, 0x00, 0x00,
|
||||||
|
0x00, 0x00, 0x00, 0x00,
|
||||||
|
}
|
||||||
@@ -0,0 +1,417 @@
|
|||||||
|
package lifx
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/binary"
|
||||||
|
"fmt"
|
||||||
|
"git.aiterp.net/lucifer/new-server/models"
|
||||||
|
"math"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type GetService struct{}
|
||||||
|
|
||||||
|
func (p *GetService) Decode([]byte) error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *GetService) Encode() []byte {
|
||||||
|
return []byte{}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *GetService) PacketType() uint16 {
|
||||||
|
return 2
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *GetService) Flags() (ackRequired bool, resRequired bool) {
|
||||||
|
return false, true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *GetService) String() string {
|
||||||
|
return "GetService()"
|
||||||
|
}
|
||||||
|
|
||||||
|
type GetVersion struct{}
|
||||||
|
|
||||||
|
func (p *GetVersion) Decode([]byte) error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *GetVersion) Encode() []byte {
|
||||||
|
return []byte{}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *GetVersion) PacketType() uint16 {
|
||||||
|
return 32
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *GetVersion) Flags() (ackRequired bool, resRequired bool) {
|
||||||
|
return false, true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *GetVersion) String() string {
|
||||||
|
return "GetVersion()"
|
||||||
|
}
|
||||||
|
|
||||||
|
type GetHostFirmware struct{}
|
||||||
|
|
||||||
|
func (p *GetHostFirmware) Decode([]byte) error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *GetHostFirmware) Encode() []byte {
|
||||||
|
return []byte{}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *GetHostFirmware) PacketType() uint16 {
|
||||||
|
return 14
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *GetHostFirmware) Flags() (ackRequired bool, resRequired bool) {
|
||||||
|
return false, true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *GetHostFirmware) String() string {
|
||||||
|
return "GetHostFirmware()"
|
||||||
|
}
|
||||||
|
|
||||||
|
type StateHostFirmware struct {
|
||||||
|
BuildTime time.Time
|
||||||
|
Major uint16
|
||||||
|
Minor uint16
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *StateHostFirmware) Decode(data []byte) error {
|
||||||
|
if len(data) < 20 {
|
||||||
|
return models.ErrPayloadTooShort
|
||||||
|
}
|
||||||
|
|
||||||
|
ts := int64(binary.LittleEndian.Uint64(data[0:8]))
|
||||||
|
|
||||||
|
p.BuildTime = time.Unix(ts/1000000000, ts%1000000000)
|
||||||
|
p.Major = binary.LittleEndian.Uint16(data[16:18])
|
||||||
|
p.Minor = binary.LittleEndian.Uint16(data[18:20])
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *StateHostFirmware) Encode() []byte {
|
||||||
|
var data [20]byte
|
||||||
|
|
||||||
|
binary.LittleEndian.PutUint64(data[0:8], uint64(p.BuildTime.UnixNano()))
|
||||||
|
binary.LittleEndian.PutUint16(data[16:18], p.Major)
|
||||||
|
binary.LittleEndian.PutUint16(data[18:20], p.Minor)
|
||||||
|
|
||||||
|
return data[:]
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *StateHostFirmware) PacketType() uint16 {
|
||||||
|
return 15
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *StateHostFirmware) Flags() (ackRequired bool, resRequired bool) {
|
||||||
|
return false, false
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *StateHostFirmware) String() string {
|
||||||
|
return fmt.Sprintf("StateHostFirmware(major=%d, minor=%d, build=%s)", p.Major, p.Minor, p.BuildTime.Format(time.RFC3339))
|
||||||
|
}
|
||||||
|
|
||||||
|
type StateVersion struct {
|
||||||
|
Vendor uint32
|
||||||
|
Product uint32
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *StateVersion) Decode(data []byte) error {
|
||||||
|
if len(data) < 8 {
|
||||||
|
return models.ErrPayloadTooShort
|
||||||
|
}
|
||||||
|
|
||||||
|
p.Vendor = binary.LittleEndian.Uint32(data[0:4])
|
||||||
|
p.Product = binary.LittleEndian.Uint32(data[4:8])
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *StateVersion) Encode() []byte {
|
||||||
|
var data [12]byte
|
||||||
|
|
||||||
|
binary.LittleEndian.PutUint32(data[0:4], p.Vendor)
|
||||||
|
binary.LittleEndian.PutUint32(data[4:8], p.Product)
|
||||||
|
|
||||||
|
return data[:]
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *StateVersion) PacketType() uint16 {
|
||||||
|
return 33
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *StateVersion) Flags() (ackRequired bool, resRequired bool) {
|
||||||
|
return false, false
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *StateVersion) String() string {
|
||||||
|
return fmt.Sprintf("StateVersion(vendor=%d, product=%d)", p.Vendor, p.Product)
|
||||||
|
}
|
||||||
|
|
||||||
|
type GetColor struct{}
|
||||||
|
|
||||||
|
func (p *GetColor) Decode([]byte) error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *GetColor) Encode() []byte {
|
||||||
|
return []byte{}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *GetColor) PacketType() uint16 {
|
||||||
|
return 101
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *GetColor) Flags() (ackRequired bool, resRequired bool) {
|
||||||
|
return false, true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *GetColor) String() string {
|
||||||
|
return "GetColor()"
|
||||||
|
}
|
||||||
|
|
||||||
|
type Acknowledgement struct{}
|
||||||
|
|
||||||
|
func (p *Acknowledgement) Decode([]byte) error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Acknowledgement) Encode() []byte {
|
||||||
|
return []byte{}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Acknowledgement) PacketType() uint16 {
|
||||||
|
return 45
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Acknowledgement) Flags() (ackRequired bool, resRequired bool) {
|
||||||
|
return false, true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Acknowledgement) String() string {
|
||||||
|
return "Acknowledgement()"
|
||||||
|
}
|
||||||
|
|
||||||
|
type SetColor struct {
|
||||||
|
Hue float64
|
||||||
|
Sat float64
|
||||||
|
Bri float64
|
||||||
|
Kelvin int
|
||||||
|
TransitionTime time.Duration
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *SetColor) Decode(data []byte) error {
|
||||||
|
if len(data) < 13 {
|
||||||
|
return models.ErrPayloadTooShort
|
||||||
|
}
|
||||||
|
|
||||||
|
hue := binary.LittleEndian.Uint16(data[1:3])
|
||||||
|
sat := binary.LittleEndian.Uint16(data[3:5])
|
||||||
|
bri := binary.LittleEndian.Uint16(data[5:7])
|
||||||
|
kelvin := binary.LittleEndian.Uint16(data[7:9])
|
||||||
|
transitionMs := binary.LittleEndian.Uint32(data[9:13])
|
||||||
|
|
||||||
|
p.Hue = float64(hue) / (65536 / 360)
|
||||||
|
p.Sat = float64(sat) / 65535
|
||||||
|
p.Bri = float64(bri) / 65535
|
||||||
|
p.Kelvin = int(kelvin)
|
||||||
|
p.TransitionTime = time.Duration(transitionMs) * time.Millisecond
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *SetColor) Encode() []byte {
|
||||||
|
var data [13]byte
|
||||||
|
|
||||||
|
data[0] = 0x00 // reserved
|
||||||
|
binary.LittleEndian.PutUint16(data[1:3], uint16(math.Mod(p.Hue, 360)*(65536/360)))
|
||||||
|
binary.LittleEndian.PutUint16(data[3:5], uint16(p.Sat*65535))
|
||||||
|
binary.LittleEndian.PutUint16(data[5:7], uint16(p.Bri*65535))
|
||||||
|
binary.LittleEndian.PutUint16(data[7:9], uint16(p.Kelvin))
|
||||||
|
binary.LittleEndian.PutUint32(data[9:13], uint32(p.TransitionTime.Milliseconds()))
|
||||||
|
|
||||||
|
return data[:]
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *SetColor) PacketType() uint16 {
|
||||||
|
return 102
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *SetColor) Flags() (ackRequired bool, resRequired bool) {
|
||||||
|
return true, false
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *SetColor) String() string {
|
||||||
|
return fmt.Sprintf("SetColor(hsvk={%f, %f, %f, %d}, ttime=%s)",
|
||||||
|
p.Hue,
|
||||||
|
p.Sat,
|
||||||
|
p.Bri,
|
||||||
|
p.Kelvin,
|
||||||
|
p.TransitionTime,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
type SetLightPower struct {
|
||||||
|
On bool
|
||||||
|
TransitionTime time.Duration
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *SetLightPower) Decode(data []byte) error {
|
||||||
|
if len(data) < 6 {
|
||||||
|
return models.ErrPayloadTooShort
|
||||||
|
}
|
||||||
|
|
||||||
|
level := binary.LittleEndian.Uint16(data[0:2])
|
||||||
|
transitionMs := binary.LittleEndian.Uint32(data[2:6])
|
||||||
|
|
||||||
|
p.On = level > 0
|
||||||
|
p.TransitionTime = time.Duration(transitionMs) * time.Millisecond
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *SetLightPower) Encode() []byte {
|
||||||
|
var data [6]byte
|
||||||
|
|
||||||
|
if p.On {
|
||||||
|
data[0], data[1] = 0xff, 0xff
|
||||||
|
} else {
|
||||||
|
data[0], data[1] = 0x00, 0x00
|
||||||
|
}
|
||||||
|
binary.LittleEndian.PutUint32(data[2:6], uint32(p.TransitionTime.Milliseconds()))
|
||||||
|
|
||||||
|
return data[:]
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *SetLightPower) PacketType() uint16 {
|
||||||
|
return 117
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *SetLightPower) Flags() (ackRequired bool, resRequired bool) {
|
||||||
|
return true, false
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *SetLightPower) String() string {
|
||||||
|
return fmt.Sprintf("SetLightPower(on=%t, ttime=%s)",
|
||||||
|
p.On,
|
||||||
|
p.TransitionTime,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
type StateService struct {
|
||||||
|
Service int
|
||||||
|
Port int
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *StateService) String() string {
|
||||||
|
return fmt.Sprintf("StateService(service=%d, port=%d)", p.Service, p.Port)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *StateService) Decode(data []byte) error {
|
||||||
|
if len(data) < 5 {
|
||||||
|
return models.ErrPayloadTooShort
|
||||||
|
}
|
||||||
|
|
||||||
|
p.Service = int(data[0])
|
||||||
|
p.Port = int(binary.LittleEndian.Uint32(data[1:5]))
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *StateService) Encode() []byte {
|
||||||
|
var data [5]byte
|
||||||
|
data[0] = uint8(p.Service)
|
||||||
|
binary.LittleEndian.PutUint32(data[1:5], uint32(p.Port))
|
||||||
|
|
||||||
|
return data[:]
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *StateService) PacketType() uint16 {
|
||||||
|
return 3
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *StateService) Flags() (ackRequired bool, resRequired bool) {
|
||||||
|
return false, false
|
||||||
|
}
|
||||||
|
|
||||||
|
type LightState struct {
|
||||||
|
Hue float64
|
||||||
|
Sat float64
|
||||||
|
Bri float64
|
||||||
|
Kelvin int
|
||||||
|
On bool
|
||||||
|
Label string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *LightState) String() string {
|
||||||
|
return fmt.Sprintf("LightState(hsvk=(%f, %f, %f, %d), on=%t, label=%+v)",
|
||||||
|
p.Hue, p.Sat, p.Bri, p.Kelvin,
|
||||||
|
p.On, p.Label,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *LightState) Decode(data []byte) error {
|
||||||
|
if len(data) < 52 {
|
||||||
|
return models.ErrPayloadTooShort
|
||||||
|
}
|
||||||
|
|
||||||
|
hue := binary.LittleEndian.Uint16(data[0:2])
|
||||||
|
sat := binary.LittleEndian.Uint16(data[2:4])
|
||||||
|
bri := binary.LittleEndian.Uint16(data[4:6])
|
||||||
|
kelvin := binary.LittleEndian.Uint16(data[6:8])
|
||||||
|
power := binary.LittleEndian.Uint16(data[10:12])
|
||||||
|
|
||||||
|
p.Hue = float64(hue) / (65536 / 360)
|
||||||
|
p.Sat = float64(sat) / 65535
|
||||||
|
p.Bri = float64(bri) / 65535
|
||||||
|
p.Kelvin = int(kelvin)
|
||||||
|
p.On = power > 32767
|
||||||
|
|
||||||
|
p.Label = ""
|
||||||
|
for i, v := range data[14:46] {
|
||||||
|
if v == 0 {
|
||||||
|
p.Label = string(data[14 : 14+i])
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *LightState) Encode() []byte {
|
||||||
|
var data [52]byte
|
||||||
|
|
||||||
|
power := uint16(0)
|
||||||
|
if p.On {
|
||||||
|
power = 0xffff
|
||||||
|
}
|
||||||
|
|
||||||
|
binary.LittleEndian.PutUint16(data[0:2], uint16(math.Mod(p.Hue, 360)*(65536/360)))
|
||||||
|
binary.LittleEndian.PutUint16(data[2:4], uint16(p.Sat*65535))
|
||||||
|
binary.LittleEndian.PutUint16(data[4:6], uint16(p.Bri*65535))
|
||||||
|
binary.LittleEndian.PutUint16(data[6:8], uint16(p.Kelvin))
|
||||||
|
binary.LittleEndian.PutUint16(data[10:12], power)
|
||||||
|
|
||||||
|
label := p.Label
|
||||||
|
labelBytes := []byte(label)
|
||||||
|
for len(labelBytes) > 31 {
|
||||||
|
label = label[:len(label)-1]
|
||||||
|
labelBytes = []byte(label)
|
||||||
|
}
|
||||||
|
copy(data[14:], labelBytes)
|
||||||
|
|
||||||
|
return data[:]
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *LightState) PacketType() uint16 {
|
||||||
|
return 107
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *LightState) Flags() (ackRequired bool, resRequired bool) {
|
||||||
|
return false, false
|
||||||
|
}
|
||||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,88 @@
|
|||||||
|
package lifx
|
||||||
|
|
||||||
|
import (
|
||||||
|
"git.aiterp.net/lucifer/new-server/models"
|
||||||
|
"math"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type State struct {
|
||||||
|
target string
|
||||||
|
externalId int
|
||||||
|
lightState *LightState
|
||||||
|
firmware *StateHostFirmware
|
||||||
|
version *StateVersion
|
||||||
|
|
||||||
|
deviceState *models.DeviceState
|
||||||
|
discoveredTime time.Time
|
||||||
|
lightStateTime time.Time
|
||||||
|
requestTime time.Time
|
||||||
|
updateTime time.Time
|
||||||
|
fwSpamTime time.Time
|
||||||
|
acksPending []uint8
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *State) generateUpdate() []Payload {
|
||||||
|
if s.deviceState == nil || s.lightState == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
results := make([]Payload, 0, 0)
|
||||||
|
if s.deviceState.Power != s.lightState.On {
|
||||||
|
results = append(results, &SetLightPower{On: s.deviceState.Power, TransitionTime: time.Millisecond * 100})
|
||||||
|
}
|
||||||
|
|
||||||
|
if !s.deviceState.Power {
|
||||||
|
return results
|
||||||
|
}
|
||||||
|
|
||||||
|
c := s.deviceState.Color
|
||||||
|
l := s.lightState
|
||||||
|
di := s.deviceState.Intensity
|
||||||
|
k := c.Kelvin
|
||||||
|
if k == 0 {
|
||||||
|
k = 4000
|
||||||
|
}
|
||||||
|
if !equalish(c.Hue, l.Hue) || !equalish(c.Saturation, l.Sat) || !equalish(di, l.Bri) || k != l.Kelvin {
|
||||||
|
results = append(results, &SetColor{
|
||||||
|
Hue: c.Hue,
|
||||||
|
Sat: c.Saturation,
|
||||||
|
Bri: di,
|
||||||
|
Kelvin: k,
|
||||||
|
TransitionTime: time.Millisecond * 150,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return results
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *State) handleAck(seq uint8) {
|
||||||
|
for i, pendingAck := range s.acksPending {
|
||||||
|
if pendingAck == seq {
|
||||||
|
s.acksPending = append(s.acksPending[:i], s.acksPending[i+1:]...)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(s.acksPending) == 0 {
|
||||||
|
s.lightStateTime = time.Now()
|
||||||
|
|
||||||
|
prevLabel := ""
|
||||||
|
if s.lightState != nil {
|
||||||
|
prevLabel = s.lightState.Label
|
||||||
|
}
|
||||||
|
|
||||||
|
s.lightState = &LightState{
|
||||||
|
Hue: s.deviceState.Color.Hue,
|
||||||
|
Sat: s.deviceState.Color.Saturation,
|
||||||
|
Bri: s.deviceState.Intensity,
|
||||||
|
Kelvin: s.deviceState.Color.Kelvin,
|
||||||
|
On: s.deviceState.Power,
|
||||||
|
Label: prevLabel,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func equalish(a, b float64) bool {
|
||||||
|
return math.Abs(a-b) < 0.005
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -37,12 +37,22 @@ func (b *bridge) Devices() []models.Device {
|
|||||||
|
|
||||||
hue, sat, value := colorful.LinearRgb(red, green, blue).Hsv()
|
hue, sat, value := colorful.LinearRgb(red, green, blue).Hsv()
|
||||||
|
|
||||||
|
shapeType, shapeTypeOK := shapeTypeMap[panel.ShapeType]
|
||||||
|
if !shapeTypeOK {
|
||||||
|
shapeType = "NanoLeaf"
|
||||||
|
}
|
||||||
|
|
||||||
|
shapeIcon, shapeIconOK := shapeIconMap[panel.ShapeType]
|
||||||
|
if !shapeIconOK {
|
||||||
|
shapeIcon = "lightbulb"
|
||||||
|
}
|
||||||
|
|
||||||
results = append(results, models.Device{
|
results = append(results, models.Device{
|
||||||
ID: -1,
|
ID: -1,
|
||||||
BridgeID: b.externalID,
|
BridgeID: b.externalID,
|
||||||
InternalID: strconv.Itoa(int(panel.ID)),
|
InternalID: strconv.Itoa(int(panel.ID)),
|
||||||
Icon: "hexagon",
|
Icon: shapeIcon,
|
||||||
Name: fmt.Sprintf("Hexagon %d", i),
|
Name: fmt.Sprintf("%s %d", shapeType, i+1),
|
||||||
Capabilities: []models.DeviceCapability{
|
Capabilities: []models.DeviceCapability{
|
||||||
models.DCPower,
|
models.DCPower,
|
||||||
models.DCColorHS,
|
models.DCColorHS,
|
||||||
@@ -50,12 +60,12 @@ func (b *bridge) Devices() []models.Device {
|
|||||||
models.DCButtons,
|
models.DCButtons,
|
||||||
},
|
},
|
||||||
ButtonNames: []string{"Touch"},
|
ButtonNames: []string{"Touch"},
|
||||||
DriverProperties: map[string]string{
|
DriverProperties: map[string]interface{}{
|
||||||
"x": strconv.Itoa(panel.X),
|
"x": panel.X,
|
||||||
"y": strconv.Itoa(panel.Y),
|
"y": panel.Y,
|
||||||
"o": strconv.Itoa(panel.O),
|
"o": panel.O,
|
||||||
"shapeType": shapeTypeMap[panel.ShapeType],
|
"shapeType": shapeTypeMap[panel.ShapeType],
|
||||||
"shapeWidth": strconv.Itoa(shapeWidthMap[panel.ShapeType]),
|
"shapeWidth": shapeWidthMap[panel.ShapeType],
|
||||||
},
|
},
|
||||||
UserProperties: nil,
|
UserProperties: nil,
|
||||||
State: models.DeviceState{
|
State: models.DeviceState{
|
||||||
@@ -83,7 +93,7 @@ func (b *bridge) Refresh(ctx context.Context) error {
|
|||||||
b.mu.Lock()
|
b.mu.Lock()
|
||||||
PanelLoop:
|
PanelLoop:
|
||||||
for _, panelInfo := range overview.PanelLayout.Data.PositionData {
|
for _, panelInfo := range overview.PanelLayout.Data.PositionData {
|
||||||
if panelInfo.PanelID == 0 {
|
if shapeTypeMap[panelInfo.ShapeType] == "Shapes Controller" {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -167,7 +177,7 @@ func (b *bridge) Update(devices []models.Device) {
|
|||||||
red, green, blue := color.RGB255()
|
red, green, blue := color.RGB255()
|
||||||
newColor := [4]byte{red, green, blue, 255}
|
newColor := [4]byte{red, green, blue, 255}
|
||||||
if newColor != panel.ColorRGBA {
|
if newColor != panel.ColorRGBA {
|
||||||
panel.update(newColor, time.Now().Add(time.Millisecond*120))
|
panel.update(newColor, time.Now().Add(time.Millisecond*220))
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
panel.update([4]byte{0, 0, 0, 0}, time.Now())
|
panel.update([4]byte{0, 0, 0, 0}, time.Now())
|
||||||
@@ -195,9 +205,9 @@ func (b *bridge) Run(ctx context.Context, info models.Bridge, ch chan<- models.E
|
|||||||
defer conn.Close()
|
defer conn.Close()
|
||||||
|
|
||||||
// Notify connections and disconnections
|
// Notify connections and disconnections
|
||||||
ch <- models.Event{Name: "BridgeConnected", Payload: map[string]string{"bridgeId": strconv.Itoa(info.ID)}}
|
ch <- models.BridgeConnectedEvent(info)
|
||||||
defer func() {
|
defer func() {
|
||||||
ch <- models.Event{Name: "BridgeDisconnected", Payload: map[string]string{"bridgeId": strconv.Itoa(info.ID)}}
|
ch <- models.BridgeDisconnectedEvent(info)
|
||||||
}()
|
}()
|
||||||
|
|
||||||
// Start touch listener. This one should go down together with this one, though, so it needs a new context.
|
// Start touch listener. This one should go down together with this one, though, so it needs a new context.
|
||||||
@@ -207,15 +217,29 @@ func (b *bridge) Run(ctx context.Context, info models.Bridge, ch chan<- models.E
|
|||||||
|
|
||||||
go func() {
|
go func() {
|
||||||
ticker := time.NewTicker(time.Second * 5)
|
ticker := time.NewTicker(time.Second * 5)
|
||||||
|
hadErrors := false
|
||||||
for {
|
for {
|
||||||
select {
|
select {
|
||||||
case <-ticker.C:
|
case <-ticker.C:
|
||||||
case <-ctx2.Done():
|
case <-ctx2.Done():
|
||||||
}
|
}
|
||||||
|
|
||||||
err := b.updateEffect(ctx)
|
reqTimeout, cancel := context.WithTimeout(ctx2, time.Second*4)
|
||||||
|
err := b.updateEffect(reqTimeout)
|
||||||
|
cancel()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Println("Failed to update effects:", err, "This error is non-fatal, and it will be retried shortly.")
|
log.Println("Failed to update effects:", err, "This error is non-fatal, and it will be retried shortly.")
|
||||||
|
hadErrors = true
|
||||||
|
} else if hadErrors {
|
||||||
|
b.mu.Lock()
|
||||||
|
for _, panel := range b.panels {
|
||||||
|
panel.Stale = true
|
||||||
|
panel.SlowUpdates = 3
|
||||||
|
panel.TicksUntilSlowUpdate = 10
|
||||||
|
}
|
||||||
|
b.mu.Unlock()
|
||||||
|
|
||||||
|
hadErrors = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
@@ -300,6 +324,12 @@ func (b *bridge) updateEffect(ctx context.Context) error {
|
|||||||
return models.ErrUnexpectedResponse
|
return models.ErrUnexpectedResponse
|
||||||
}
|
}
|
||||||
|
|
||||||
|
b.mu.Lock()
|
||||||
|
for _, panel := range b.panels {
|
||||||
|
panel.Stale = true
|
||||||
|
}
|
||||||
|
b.mu.Unlock()
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -373,7 +403,7 @@ func (b *bridge) runTouchListener(ctx context.Context, host, apiKey string, ch c
|
|||||||
}
|
}
|
||||||
|
|
||||||
event := models.Event{
|
event := models.Event{
|
||||||
Name: "ButtonPressed",
|
Name: models.ENButtonPressed,
|
||||||
Payload: map[string]string{
|
Payload: map[string]string{
|
||||||
"buttonIndex": "0",
|
"buttonIndex": "0",
|
||||||
"buttonName": "Touch",
|
"buttonName": "Touch",
|
||||||
|
|||||||
@@ -135,6 +135,18 @@ var shapeTypeMap = map[int]string{
|
|||||||
12: "Shapes Controller",
|
12: "Shapes Controller",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var shapeIconMap = map[int]string{
|
||||||
|
0: "triangle",
|
||||||
|
1: "rhythm",
|
||||||
|
2: "Square",
|
||||||
|
3: "square",
|
||||||
|
4: "square",
|
||||||
|
7: "hexagon",
|
||||||
|
8: "triangle",
|
||||||
|
9: "triangle-small",
|
||||||
|
12: "hexagon",
|
||||||
|
}
|
||||||
|
|
||||||
var shapeWidthMap = map[int]int{
|
var shapeWidthMap = map[int]int{
|
||||||
0: 150,
|
0: 150,
|
||||||
1: -1,
|
1: -1,
|
||||||
|
|||||||
@@ -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
|
// 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.
|
// 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))
|
res, err := http.Get(fmt.Sprintf("http://%s/device_info", address))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
package drivers
|
||||||
|
|
||||||
|
import "git.aiterp.net/lucifer/new-server/models"
|
||||||
|
|
||||||
|
type DriverMap map[models.DriverKind]models.Driver
|
||||||
|
|
||||||
|
func (m DriverMap) Provide(kind models.DriverKind) (models.Driver, error) {
|
||||||
|
if m[kind] == nil {
|
||||||
|
return nil, models.ErrUnknownDriver
|
||||||
|
}
|
||||||
|
|
||||||
|
return m[kind], nil
|
||||||
|
}
|
||||||
|
|
||||||
@@ -11,10 +11,10 @@ type BridgeRepo struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (b *BridgeRepo) Find(ctx context.Context, id int) (models.Bridge, error) {
|
func (b *BridgeRepo) Find(ctx context.Context, id int) (models.Bridge, error) {
|
||||||
var bridge models.Bridge
|
var bridge models.Bridge
|
||||||
err := b.DBX.GetContext(ctx, &bridge, "SELECT * FROM bridge WHERE id = ?", id)
|
err := b.DBX.GetContext(ctx, &bridge, "SELECT * FROM bridge WHERE id = ?", id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return models.Bridge{}, dbErr(err)
|
return models.Bridge{}, dbErr(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return bridge, nil
|
return bridge, nil
|
||||||
@@ -22,7 +22,7 @@ func (b *BridgeRepo) Find(ctx context.Context, id int) (models.Bridge, error) {
|
|||||||
|
|
||||||
func (b *BridgeRepo) FetchAll(ctx context.Context) ([]models.Bridge, error) {
|
func (b *BridgeRepo) FetchAll(ctx context.Context) ([]models.Bridge, error) {
|
||||||
bridges := make([]models.Bridge, 0, 8)
|
bridges := make([]models.Bridge, 0, 8)
|
||||||
err := b.DBX.GetContext(ctx, bridges, "SELECT * FROM bridge")
|
err := b.DBX.SelectContext(ctx, &bridges, "SELECT * FROM bridge")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, dbErr(err)
|
return nil, dbErr(err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,421 @@
|
|||||||
|
package mysql
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"encoding/json"
|
||||||
|
"git.aiterp.net/lucifer/new-server/models"
|
||||||
|
sq "github.com/Masterminds/squirrel"
|
||||||
|
"github.com/jmoiron/sqlx"
|
||||||
|
"log"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
type deviceRecord struct {
|
||||||
|
ID int `db:"id"`
|
||||||
|
BridgeID int `db:"bridge_id"`
|
||||||
|
InternalID string `db:"internal_id"`
|
||||||
|
Icon string `db:"icon"`
|
||||||
|
Name string `db:"name"`
|
||||||
|
Capabilities string `db:"capabilities"`
|
||||||
|
ButtonNames string `db:"button_names"`
|
||||||
|
SceneAssignmentJSON json.RawMessage `db:"scene_assignments"`
|
||||||
|
}
|
||||||
|
|
||||||
|
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"`
|
||||||
|
Temperature int `db:"temperature"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type devicePropertyRecord struct {
|
||||||
|
DeviceID int `db:"device_id"`
|
||||||
|
Key string `db:"prop_key"`
|
||||||
|
Value string `db:"prop_value"`
|
||||||
|
IsUser bool `db:"is_user"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type deviceTagRecord struct {
|
||||||
|
DeviceID int `db:"device_id"`
|
||||||
|
TagName string `db:"tag_name"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type DeviceRepo struct {
|
||||||
|
DBX *sqlx.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *DeviceRepo) Find(ctx context.Context, id int) (*models.Device, error) {
|
||||||
|
var device deviceRecord
|
||||||
|
err := r.DBX.GetContext(ctx, &device, "SELECT * FROM device WHERE id = ?", id)
|
||||||
|
if err != nil {
|
||||||
|
return nil, dbErr(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return r.populateOne(ctx, device)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *DeviceRepo) FetchByReference(ctx context.Context, kind models.ReferenceKind, value string) ([]models.Device, error) {
|
||||||
|
q := sq.Select("device.*").From("device")
|
||||||
|
|
||||||
|
var requiredTags []string
|
||||||
|
var unwantedTags []string
|
||||||
|
|
||||||
|
switch kind {
|
||||||
|
case models.RKDeviceID:
|
||||||
|
q = q.Where(sq.Eq{"id": strings.Split(value, ",")})
|
||||||
|
case models.RKBridgeID:
|
||||||
|
q = q.Where(sq.Eq{"bridge_id": strings.Split(value, ",")})
|
||||||
|
case models.RKName:
|
||||||
|
value = strings.ReplaceAll(value, "*", "%")
|
||||||
|
q = q.Where(sq.Like{"name": value})
|
||||||
|
case models.RKTag:
|
||||||
|
allTags := strings.Split(strings.ReplaceAll(strings.ReplaceAll(value, "-", ",-"), "+", ",+"), ",")
|
||||||
|
optionalTags := make([]string, 0, len(allTags))
|
||||||
|
|
||||||
|
for _, tag := range allTags {
|
||||||
|
if tag == "" || tag == "+" || tag == "-" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if strings.HasPrefix(tag, "+") {
|
||||||
|
requiredTags = append(requiredTags, tag[1:])
|
||||||
|
} else if strings.HasPrefix(tag, "-") {
|
||||||
|
unwantedTags = append(unwantedTags, tag[1:])
|
||||||
|
} else {
|
||||||
|
optionalTags = append(optionalTags, tag)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
q = q.Join("device_tag dt ON device.id=dt.device_id").Where(sq.Eq{"dt.tag_name": optionalTags})
|
||||||
|
case models.RKAll:
|
||||||
|
default:
|
||||||
|
log.Println("Unknown reference kind used for device fetch:", kind)
|
||||||
|
return []models.Device{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
query, args, err := q.OrderBy("name", "id").ToSql()
|
||||||
|
if err != nil {
|
||||||
|
if err == sql.ErrNoRows {
|
||||||
|
return []models.Device{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil, dbErr(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
records := make([]deviceRecord, 0, 8)
|
||||||
|
err = r.DBX.SelectContext(ctx, &records, query, args...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, dbErr(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(requiredTags) > 0 || len(unwantedTags) > 0 {
|
||||||
|
return r.populateFiltered(ctx, records, requiredTags, unwantedTags)
|
||||||
|
}
|
||||||
|
|
||||||
|
return r.populate(ctx, records)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *DeviceRepo) SaveMany(ctx context.Context, mode models.SaveMode, devices []models.Device) error {
|
||||||
|
tx, err := r.DBX.Beginx()
|
||||||
|
if err != nil {
|
||||||
|
return dbErr(err)
|
||||||
|
}
|
||||||
|
defer tx.Rollback()
|
||||||
|
|
||||||
|
for i, device := range devices {
|
||||||
|
scenesJSON, err := json.Marshal(device.SceneAssignments)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
record := deviceRecord{
|
||||||
|
ID: device.ID,
|
||||||
|
BridgeID: device.BridgeID,
|
||||||
|
InternalID: device.InternalID,
|
||||||
|
SceneAssignmentJSON: scenesJSON,
|
||||||
|
Icon: device.Icon,
|
||||||
|
Name: device.Name,
|
||||||
|
Capabilities: strings.Join(models.DeviceCapabilitiesToStrings(device.Capabilities), ","),
|
||||||
|
ButtonNames: strings.Join(device.ButtonNames, ","),
|
||||||
|
}
|
||||||
|
|
||||||
|
if device.ID > 0 {
|
||||||
|
_, err := tx.NamedExecContext(ctx, `
|
||||||
|
UPDATE device SET
|
||||||
|
internal_id = :internal_id,
|
||||||
|
icon = :icon,
|
||||||
|
name = :name,
|
||||||
|
capabilities = :capabilities,
|
||||||
|
button_names = :button_names,
|
||||||
|
scene_assignments = :scene_assignments
|
||||||
|
WHERE id=:id
|
||||||
|
`, record)
|
||||||
|
if err != nil {
|
||||||
|
return dbErr(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Let's just be lazy for now, optimize later if need be.
|
||||||
|
if mode == 0 || mode&models.SMTags != 0 {
|
||||||
|
_, err = tx.ExecContext(ctx, "DELETE FROM device_tag WHERE device_id=?", record.ID)
|
||||||
|
if err != nil {
|
||||||
|
return dbErr(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if mode == 0 || mode&models.SMProperties != 0 {
|
||||||
|
_, err = tx.ExecContext(ctx, "DELETE FROM device_property WHERE device_id=?", record.ID)
|
||||||
|
if err != nil {
|
||||||
|
return dbErr(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
res, err := tx.NamedExecContext(ctx, `
|
||||||
|
INSERT INTO device (bridge_id, internal_id, icon, name, capabilities, button_names)
|
||||||
|
VALUES (:bridge_id, :internal_id, :icon, :name, :capabilities, :button_names)
|
||||||
|
`, record)
|
||||||
|
if err != nil {
|
||||||
|
return dbErr(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
lastID, err := res.LastInsertId()
|
||||||
|
if err != nil {
|
||||||
|
return dbErr(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
record.ID = int(lastID)
|
||||||
|
devices[i].ID = int(lastID)
|
||||||
|
}
|
||||||
|
|
||||||
|
if mode == 0 || mode&models.SMTags != 0 {
|
||||||
|
for _, tag := range device.Tags {
|
||||||
|
_, err := tx.ExecContext(ctx, "INSERT INTO device_tag (device_id, tag_name) VALUES (?, ?)", record.ID, tag)
|
||||||
|
if err != nil {
|
||||||
|
return dbErr(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if mode == 0 || mode&models.SMProperties != 0 {
|
||||||
|
for key, value := range device.UserProperties {
|
||||||
|
_, err := tx.ExecContext(ctx, "INSERT INTO device_property (device_id, prop_key, prop_value, is_user) VALUES (?, ?, ?, 1)",
|
||||||
|
record.ID, key, value,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return dbErr(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for key, value := range device.DriverProperties {
|
||||||
|
j, err := json.Marshal(value)
|
||||||
|
if err != nil {
|
||||||
|
// Eh, it'll get filled by the driver anyway
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = tx.ExecContext(ctx, "INSERT INTO device_property (device_id, prop_key, prop_value, is_user) VALUES (?, ?, ?, 0)",
|
||||||
|
record.ID, key, string(j),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
// Return err here anyway, it might put the tx in a bad state to ignore it.
|
||||||
|
return dbErr(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if mode == 0 || mode&models.SMState != 0 {
|
||||||
|
_, err = tx.NamedExecContext(ctx, `
|
||||||
|
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,
|
||||||
|
Temperature: device.State.Temperature,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return dbErr(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return tx.Commit()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *DeviceRepo) Save(ctx context.Context, device *models.Device, mode models.SaveMode) error {
|
||||||
|
devices := []models.Device{*device}
|
||||||
|
|
||||||
|
err := r.SaveMany(ctx, mode, devices)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
*device = devices[0]
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *DeviceRepo) Delete(ctx context.Context, device *models.Device) error {
|
||||||
|
_, err := r.DBX.ExecContext(ctx, "DELETE FROM device WHERE Id=?", device.ID)
|
||||||
|
if err != nil {
|
||||||
|
return dbErr(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *DeviceRepo) populateOne(ctx context.Context, record deviceRecord) (*models.Device, error) {
|
||||||
|
records, err := r.populate(ctx, []deviceRecord{record})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return &records[0], nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *DeviceRepo) populateFiltered(ctx context.Context, records []deviceRecord, requiredTags []string, unwantedTags []string) ([]models.Device, error) {
|
||||||
|
devices, err := r.populate(ctx, records)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
filteredDevices := make([]models.Device, 0, len(devices))
|
||||||
|
|
||||||
|
for _, device := range devices {
|
||||||
|
if device.HasTag(unwantedTags...) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(requiredTags) == 0 || device.HasTag(requiredTags...) {
|
||||||
|
filteredDevices = append(filteredDevices, device)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return filteredDevices, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *DeviceRepo) populate(ctx context.Context, records []deviceRecord) ([]models.Device, error) {
|
||||||
|
if len(records) == 0 {
|
||||||
|
return []models.Device{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
ids := make([]int, 0, len(records))
|
||||||
|
for _, record := range records {
|
||||||
|
ids = append(ids, record.ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
tagsQuery, tagsArgs, err := sq.Select("*").From("device_tag").Where(sq.Eq{"device_id": ids}).ToSql()
|
||||||
|
if err != nil {
|
||||||
|
return nil, dbErr(err)
|
||||||
|
}
|
||||||
|
propsQuery, propsArgs, err := sq.Select("*").From("device_property").Where(sq.Eq{"device_id": ids}).ToSql()
|
||||||
|
if err != nil {
|
||||||
|
return nil, dbErr(err)
|
||||||
|
}
|
||||||
|
stateQuery, stateArgs, err := sq.Select("*").From("device_state").Where(sq.Eq{"device_id": ids}).ToSql()
|
||||||
|
if err != nil {
|
||||||
|
return nil, dbErr(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
states := make([]deviceStateRecord, 0, len(records))
|
||||||
|
props := make([]devicePropertyRecord, 0, len(records)*8)
|
||||||
|
tags := make([]deviceTagRecord, 0, len(records)*4)
|
||||||
|
|
||||||
|
err = r.DBX.SelectContext(ctx, &states, stateQuery, stateArgs...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, dbErr(err)
|
||||||
|
}
|
||||||
|
err = r.DBX.SelectContext(ctx, &props, propsQuery, propsArgs...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, dbErr(err)
|
||||||
|
}
|
||||||
|
err = r.DBX.SelectContext(ctx, &tags, tagsQuery, tagsArgs...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, dbErr(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
hasAdded := make(map[int]bool, len(records))
|
||||||
|
devices := make([]models.Device, 0, len(records))
|
||||||
|
for _, record := range records {
|
||||||
|
if hasAdded[record.ID] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
device := models.Device{
|
||||||
|
ID: record.ID,
|
||||||
|
BridgeID: record.BridgeID,
|
||||||
|
InternalID: record.InternalID,
|
||||||
|
Icon: record.Icon,
|
||||||
|
Name: record.Name,
|
||||||
|
SceneAssignments: make([]models.DeviceSceneAssignment, 0, 4),
|
||||||
|
ButtonNames: strings.Split(record.ButtonNames, ","),
|
||||||
|
DriverProperties: make(map[string]interface{}, 8),
|
||||||
|
UserProperties: make(map[string]string, 8),
|
||||||
|
Tags: make([]string, 0, 8),
|
||||||
|
}
|
||||||
|
|
||||||
|
_ = json.Unmarshal(record.SceneAssignmentJSON, &device.SceneAssignments)
|
||||||
|
|
||||||
|
if device.ButtonNames[0] == "" {
|
||||||
|
device.ButtonNames = device.ButtonNames[:0]
|
||||||
|
}
|
||||||
|
|
||||||
|
caps := make([]models.DeviceCapability, 0, 16)
|
||||||
|
for _, capStr := range strings.Split(record.Capabilities, ",") {
|
||||||
|
caps = append(caps, models.DeviceCapability(capStr))
|
||||||
|
}
|
||||||
|
device.Capabilities = caps
|
||||||
|
|
||||||
|
for _, state := range states {
|
||||||
|
if state.DeviceID == record.ID {
|
||||||
|
device.State = models.DeviceState{
|
||||||
|
Power: state.Power,
|
||||||
|
Color: models.ColorValue{
|
||||||
|
Hue: state.Hue,
|
||||||
|
Saturation: state.Saturation,
|
||||||
|
Kelvin: state.Kelvin,
|
||||||
|
},
|
||||||
|
Intensity: state.Intensity,
|
||||||
|
Temperature: state.Temperature,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
driverProps := make(map[string]json.RawMessage, 8)
|
||||||
|
for _, prop := range props {
|
||||||
|
if prop.DeviceID == record.ID {
|
||||||
|
if prop.IsUser {
|
||||||
|
device.UserProperties[prop.Key] = prop.Value
|
||||||
|
} else {
|
||||||
|
driverProps[prop.Key] = json.RawMessage(prop.Value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(driverProps) > 0 {
|
||||||
|
j, err := json.Marshal(driverProps)
|
||||||
|
if err != nil {
|
||||||
|
return nil, dbErr(err)
|
||||||
|
}
|
||||||
|
err = json.Unmarshal(j, &device.DriverProperties)
|
||||||
|
if err != nil {
|
||||||
|
return nil, dbErr(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tag := range tags {
|
||||||
|
if tag.DeviceID == record.ID {
|
||||||
|
device.Tags = append(device.Tags, tag.TagName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
hasAdded[record.ID] = true
|
||||||
|
devices = append(devices, device)
|
||||||
|
}
|
||||||
|
|
||||||
|
return devices, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,161 @@
|
|||||||
|
package mysql
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"git.aiterp.net/lucifer/new-server/models"
|
||||||
|
"github.com/jmoiron/sqlx"
|
||||||
|
)
|
||||||
|
|
||||||
|
type eventHandlerRecord struct {
|
||||||
|
ID int `db:"id"`
|
||||||
|
EventName string `db:"event_name"`
|
||||||
|
OneShot bool `db:"one_shot"`
|
||||||
|
Priority int `db:"priority"`
|
||||||
|
TargetKind string `db:"target_kind"`
|
||||||
|
TargetValue string `db:"target_value"`
|
||||||
|
From *string `db:"from_tod"`
|
||||||
|
To *string `db:"to_tod"`
|
||||||
|
ConditionsJSON json.RawMessage `db:"conditions"`
|
||||||
|
ActionsJSON json.RawMessage `db:"actions"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type EventHandlerRepo struct {
|
||||||
|
DBX *sqlx.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *EventHandlerRepo) FindByID(ctx context.Context, id int) (*models.EventHandler, error) {
|
||||||
|
var record eventHandlerRecord
|
||||||
|
err := r.DBX.GetContext(ctx, &record, "SELECT * FROM event_handler WHERE id = ?", id)
|
||||||
|
if err != nil {
|
||||||
|
return nil, dbErr(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return r.populateOne(&record)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *EventHandlerRepo) FetchAll(ctx context.Context) ([]models.EventHandler, error) {
|
||||||
|
var records []eventHandlerRecord
|
||||||
|
err := r.DBX.SelectContext(ctx, &records, "SELECT * FROM event_handler ORDER BY priority DESC, id")
|
||||||
|
if err != nil {
|
||||||
|
return nil, dbErr(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return r.populate(records)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *EventHandlerRepo) Save(ctx context.Context, handler *models.EventHandler) error {
|
||||||
|
conditionsJson, err := json.Marshal(handler.Conditions)
|
||||||
|
if err != nil {
|
||||||
|
return dbErr(err)
|
||||||
|
}
|
||||||
|
actionsJson, err := json.Marshal(handler.Actions)
|
||||||
|
if err != nil {
|
||||||
|
return dbErr(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
record := eventHandlerRecord{
|
||||||
|
ID: handler.ID,
|
||||||
|
EventName: handler.EventName,
|
||||||
|
OneShot: handler.OneShot,
|
||||||
|
Priority: handler.Priority,
|
||||||
|
TargetKind: string(handler.TargetKind),
|
||||||
|
TargetValue: handler.TargetValue,
|
||||||
|
ConditionsJSON: conditionsJson,
|
||||||
|
ActionsJSON: actionsJson,
|
||||||
|
From: handler.From.StringPtr(),
|
||||||
|
To: handler.To.StringPtr(),
|
||||||
|
}
|
||||||
|
|
||||||
|
if record.ID == 0 {
|
||||||
|
res, err := r.DBX.NamedExecContext(ctx, `
|
||||||
|
INSERT INTO event_handler (event_name, one_shot, priority, target_kind, target_value, conditions, from_tod, to_tod, actions)
|
||||||
|
VALUES (:event_name, :one_shot, :priority, :target_kind, :target_value, :conditions, :from_tod, :to_tod, :actions);
|
||||||
|
`, record)
|
||||||
|
if err != nil {
|
||||||
|
return dbErr(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
id, err := res.LastInsertId()
|
||||||
|
if err != nil {
|
||||||
|
return dbErr(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
handler.ID = int(id)
|
||||||
|
} else {
|
||||||
|
_, err := r.DBX.NamedExecContext(ctx, `
|
||||||
|
UPDATE event_handler SET
|
||||||
|
event_name = :event_name,
|
||||||
|
one_shot = :one_shot,
|
||||||
|
priority = :priority,
|
||||||
|
target_kind = :target_kind,
|
||||||
|
target_value = :target_value,
|
||||||
|
conditions = :conditions,
|
||||||
|
from_tod = :from_tod,
|
||||||
|
to_tod = :to_tod,
|
||||||
|
actions = :actions
|
||||||
|
WHERE id = :id
|
||||||
|
`, record)
|
||||||
|
if err != nil {
|
||||||
|
return dbErr(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *EventHandlerRepo) Delete(ctx context.Context, handler *models.EventHandler) error {
|
||||||
|
_, err := r.DBX.ExecContext(ctx, "DELETE FROM event_handler WHERE id=?", handler.ID)
|
||||||
|
if err != nil {
|
||||||
|
return dbErr(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *EventHandlerRepo) populateOne(record *eventHandlerRecord) (*models.EventHandler, error) {
|
||||||
|
records, err := r.populate([]eventHandlerRecord{*record})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return &records[0], nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *EventHandlerRepo) populate(records []eventHandlerRecord) ([]models.EventHandler, error) {
|
||||||
|
res := make([]models.EventHandler, 0, len(records))
|
||||||
|
|
||||||
|
for _, record := range records {
|
||||||
|
handler := models.EventHandler{
|
||||||
|
ID: record.ID,
|
||||||
|
EventName: record.EventName,
|
||||||
|
OneShot: record.OneShot,
|
||||||
|
Priority: record.Priority,
|
||||||
|
TargetKind: models.ReferenceKind(record.TargetKind),
|
||||||
|
TargetValue: record.TargetValue,
|
||||||
|
Conditions: make(map[string]models.EventCondition),
|
||||||
|
}
|
||||||
|
|
||||||
|
err := json.Unmarshal(record.ConditionsJSON, &handler.Conditions)
|
||||||
|
if err != nil {
|
||||||
|
return nil, dbErr(err)
|
||||||
|
}
|
||||||
|
err = json.Unmarshal(record.ActionsJSON, &handler.Actions)
|
||||||
|
if err != nil {
|
||||||
|
return nil, dbErr(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
handler.From, err = models.ParseTimeOfDayPtr(record.From)
|
||||||
|
if err != nil {
|
||||||
|
return nil, dbErr(err)
|
||||||
|
}
|
||||||
|
handler.To, err = models.ParseTimeOfDayPtr(record.To)
|
||||||
|
if err != nil {
|
||||||
|
return nil, dbErr(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
res = append(res, handler)
|
||||||
|
}
|
||||||
|
|
||||||
|
return res, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
package mysql
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"git.aiterp.net/lucifer/new-server/models"
|
||||||
|
"github.com/jmoiron/sqlx"
|
||||||
|
)
|
||||||
|
|
||||||
|
type presetRecord struct {
|
||||||
|
ID int `db:"id"`
|
||||||
|
Name string `db:"name"`
|
||||||
|
Hue float64 `db:"hue"`
|
||||||
|
Saturation float64 `db:"saturation"`
|
||||||
|
Kelvin int `db:"kelvin"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ColorPresetRepo struct {
|
||||||
|
DBX *sqlx.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *ColorPresetRepo) Find(ctx context.Context, id int) (models.ColorPreset, error) {
|
||||||
|
var record presetRecord
|
||||||
|
err := c.DBX.GetContext(ctx, &record, "SELECT * FROM color_preset WHERE id = ?", id)
|
||||||
|
if err != nil {
|
||||||
|
return models.ColorPreset{}, dbErr(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.fromRecords(record)[0], nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *ColorPresetRepo) FetchAll(ctx context.Context) ([]models.ColorPreset, error) {
|
||||||
|
records := make([]presetRecord, 0, 16)
|
||||||
|
err := c.DBX.SelectContext(ctx, &records, "SELECT * FROM color_preset")
|
||||||
|
if err != nil {
|
||||||
|
return nil, dbErr(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.fromRecords(records...), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *ColorPresetRepo) Save(ctx context.Context, preset *models.ColorPreset) error {
|
||||||
|
if preset.ID > 0 {
|
||||||
|
_, err := c.DBX.ExecContext(
|
||||||
|
ctx,
|
||||||
|
"UPDATE color_preset SET name = ?, hue = ?, saturation = ?, kelvin = ? WHERE id = ?",
|
||||||
|
preset.Name, preset.Value.Hue, preset.Value.Saturation, preset.Value.Kelvin, preset.ID,
|
||||||
|
)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return dbErr(err)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
rs, err := c.DBX.ExecContext(
|
||||||
|
ctx,
|
||||||
|
"INSERT INTO color_preset (name, hue, saturation, kelvin) VALUES (?, ?, ?, ?)",
|
||||||
|
preset.Name, preset.Value.Hue, preset.Value.Saturation, preset.Value.Kelvin,
|
||||||
|
)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return dbErr(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
id, err := rs.LastInsertId()
|
||||||
|
if err != nil {
|
||||||
|
return dbErr(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
preset.ID = int(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *ColorPresetRepo) Delete(ctx context.Context, preset *models.ColorPreset) error {
|
||||||
|
_, err := c.DBX.ExecContext(ctx, "DELETE FROM color_preset WHERE id = ?", preset.ID)
|
||||||
|
if err != nil {
|
||||||
|
return dbErr(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
preset.ID = 0
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *ColorPresetRepo) fromRecords(records ...presetRecord) []models.ColorPreset {
|
||||||
|
newList := make([]models.ColorPreset, len(records), len(records))
|
||||||
|
for i, record := range records {
|
||||||
|
newList[i] = models.ColorPreset{
|
||||||
|
ID: record.ID,
|
||||||
|
Name: record.Name,
|
||||||
|
Value: models.ColorValue{
|
||||||
|
Hue: record.Hue,
|
||||||
|
Saturation: record.Saturation,
|
||||||
|
Kelvin: record.Kelvin,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return newList
|
||||||
|
}
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
package mysql
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"encoding/json"
|
||||||
|
"git.aiterp.net/lucifer/new-server/models"
|
||||||
|
"github.com/jmoiron/sqlx"
|
||||||
|
)
|
||||||
|
|
||||||
|
type sceneRecord struct {
|
||||||
|
ID int `db:"id"`
|
||||||
|
Name string `db:"name"`
|
||||||
|
IntervalMS int64 `db:"interval_ms"`
|
||||||
|
RoleJSON json.RawMessage `db:"roles"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type SceneRepo struct {
|
||||||
|
DBX *sqlx.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *SceneRepo) Find(ctx context.Context, id int) (*models.Scene, error) {
|
||||||
|
var scene sceneRecord
|
||||||
|
err := r.DBX.GetContext(ctx, &scene, "SELECT * FROM scene WHERE id = ?", id)
|
||||||
|
if err != nil {
|
||||||
|
return nil, dbErr(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return r.populateOne(&scene)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *SceneRepo) FetchAll(ctx context.Context) ([]models.Scene, error) {
|
||||||
|
scenes := make([]sceneRecord, 0, 8)
|
||||||
|
err := r.DBX.SelectContext(ctx, &scenes, "SELECT * FROM scene")
|
||||||
|
if err != nil {
|
||||||
|
if err == sql.ErrNoRows {
|
||||||
|
return []models.Scene{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil, dbErr(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return r.populate(scenes)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *SceneRepo) Save(ctx context.Context, scene *models.Scene) error {
|
||||||
|
j, err := json.Marshal(scene.Roles)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if scene.ID > 0 {
|
||||||
|
_, err := r.DBX.ExecContext(
|
||||||
|
ctx,
|
||||||
|
"UPDATE scene SET name = ?, interval_ms = ?, roles = ? WHERE id = ?",
|
||||||
|
scene.Name, scene.IntervalMS, j, scene.ID,
|
||||||
|
)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return dbErr(err)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
rs, err := r.DBX.ExecContext(
|
||||||
|
ctx,
|
||||||
|
"INSERT INTO scene (name, interval_ms, roles) VALUES (?, ?, ?)",
|
||||||
|
scene.Name, scene.IntervalMS, j,
|
||||||
|
)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return dbErr(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
id, err := rs.LastInsertId()
|
||||||
|
if err != nil {
|
||||||
|
return dbErr(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
scene.ID = int(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *SceneRepo) Delete(ctx context.Context, scene *models.Scene) error {
|
||||||
|
_, err := r.DBX.ExecContext(ctx, "DELETE FROM scene WHERE id = ?", scene.ID)
|
||||||
|
if err != nil {
|
||||||
|
return dbErr(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
scene.ID = 0
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *SceneRepo) populateOne(record *sceneRecord) (*models.Scene, error) {
|
||||||
|
records, err := r.populate([]sceneRecord{*record})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return &records[0], nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *SceneRepo) populate(records []sceneRecord) ([]models.Scene, error) {
|
||||||
|
res := make([]models.Scene, 0, len(records))
|
||||||
|
|
||||||
|
for _, record := range records {
|
||||||
|
scene := models.Scene{
|
||||||
|
ID: record.ID,
|
||||||
|
Name: record.Name,
|
||||||
|
IntervalMS: record.IntervalMS,
|
||||||
|
Roles: make([]models.SceneRole, 0, 8),
|
||||||
|
}
|
||||||
|
|
||||||
|
err := json.Unmarshal(record.RoleJSON, &scene.Roles)
|
||||||
|
if err != nil {
|
||||||
|
return nil, dbErr(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
res = append(res, scene)
|
||||||
|
}
|
||||||
|
|
||||||
|
return res, nil
|
||||||
|
}
|
||||||
+3
-1
@@ -21,7 +21,9 @@ type DriverKind string
|
|||||||
|
|
||||||
var (
|
var (
|
||||||
DTHue DriverKind = "Hue"
|
DTHue DriverKind = "Hue"
|
||||||
DTNanoLeaf DriverKind = "NanoLeaf"
|
DTNanoLeaf DriverKind = "Nanoleaf"
|
||||||
|
DTLIFX DriverKind = "LIFX"
|
||||||
|
DTMill DriverKind = "Mill"
|
||||||
)
|
)
|
||||||
|
|
||||||
var ValidDriverKinds = []DriverKind{
|
var ValidDriverKinds = []DriverKind{
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
package models
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ColorPreset struct {
|
||||||
|
ID int `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Value ColorValue `json:"value"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ColorPresetRepository interface {
|
||||||
|
Find(ctx context.Context, id int) (ColorPreset, error)
|
||||||
|
FetchAll(ctx context.Context) ([]ColorPreset, error)
|
||||||
|
Save(ctx context.Context, preset *ColorPreset) error
|
||||||
|
Delete(ctx context.Context, preset *ColorPreset) error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *ColorPreset) Validate() {
|
||||||
|
c.Name = strings.Trim(c.Name, " \t\n")
|
||||||
|
if len(c.Name) == 0 {
|
||||||
|
c.Name = c.Value.String()
|
||||||
|
}
|
||||||
|
}
|
||||||
+25
-3
@@ -23,19 +23,21 @@ func (c *ColorValue) IsKelvin() bool {
|
|||||||
|
|
||||||
func (c *ColorValue) String() string {
|
func (c *ColorValue) String() string {
|
||||||
if c.Kelvin > 0 {
|
if c.Kelvin > 0 {
|
||||||
return fmt.Sprintf("kelvin:%d", c.Kelvin)
|
return fmt.Sprintf("k:%d", c.Kelvin)
|
||||||
}
|
}
|
||||||
|
|
||||||
return fmt.Sprintf("hsv:%f,%f", c.Hue, c.Saturation)
|
return fmt.Sprintf("hs:%.4g,%.3g", c.Hue, c.Saturation)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
func ParseColorValue(raw string) (ColorValue, error) {
|
func ParseColorValue(raw string) (ColorValue, error) {
|
||||||
tokens := strings.SplitN(raw, ":", 2)
|
tokens := strings.SplitN(raw, ":", 2)
|
||||||
if len(tokens) != 2 {
|
if len(tokens) != 2 {
|
||||||
return ColorValue{}, ErrBadInput
|
return ColorValue{}, ErrBadInput
|
||||||
}
|
}
|
||||||
|
|
||||||
if tokens[0] == "kelvin" {
|
if tokens[0] == "kelvin" || tokens[0] == "k" {
|
||||||
parsedPart, err := strconv.Atoi(tokens[1])
|
parsedPart, err := strconv.Atoi(tokens[1])
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return ColorValue{}, ErrBadInput
|
return ColorValue{}, ErrBadInput
|
||||||
@@ -59,5 +61,25 @@ func ParseColorValue(raw string) (ColorValue, error) {
|
|||||||
return ColorValue{Hue: math.Mod(part1, 360), Saturation: math.Min(math.Max(part2, 0), 1)}, nil
|
return ColorValue{Hue: math.Mod(part1, 360), Saturation: math.Min(math.Max(part2, 0), 1)}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if tokens[0] == "hsk" {
|
||||||
|
parts := strings.Split(tokens[1], ",")
|
||||||
|
if len(parts) < 3 {
|
||||||
|
return ColorValue{}, ErrUnknownColorFormat
|
||||||
|
}
|
||||||
|
|
||||||
|
part1, err1 := strconv.ParseFloat(parts[0], 64)
|
||||||
|
part2, err2 := strconv.ParseFloat(parts[1], 64)
|
||||||
|
part3, err3 := strconv.Atoi(parts[2])
|
||||||
|
if err1 != nil || err2 != nil || err3 != nil {
|
||||||
|
return ColorValue{}, ErrBadInput
|
||||||
|
}
|
||||||
|
|
||||||
|
return ColorValue{
|
||||||
|
Hue: math.Mod(part1, 360),
|
||||||
|
Saturation: math.Min(math.Max(part2, 0), 1),
|
||||||
|
Kelvin: part3,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
return ColorValue{}, ErrUnknownColorFormat
|
return ColorValue{}, ErrUnknownColorFormat
|
||||||
}
|
}
|
||||||
|
|||||||
+168
-29
@@ -3,20 +3,29 @@ package models
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Device struct {
|
type Device struct {
|
||||||
ID int `json:"id"`
|
ID int `json:"id"`
|
||||||
BridgeID int `json:"bridgeID"`
|
BridgeID int `json:"bridgeID"`
|
||||||
InternalID string `json:"internalId"`
|
InternalID string `json:"internalId"`
|
||||||
Icon string `json:"icon"`
|
Icon string `json:"icon"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Capabilities []DeviceCapability `json:"capabilities"`
|
Capabilities []DeviceCapability `json:"capabilities"`
|
||||||
ButtonNames []string `json:"buttonNames"`
|
ButtonNames []string `json:"buttonNames"`
|
||||||
DriverProperties map[string]string `json:"driverProperties"`
|
DriverProperties map[string]interface{} `json:"driverProperties"`
|
||||||
UserProperties map[string]string `json:"userProperties"`
|
UserProperties map[string]string `json:"userProperties"`
|
||||||
State DeviceState `json:"state"`
|
SceneAssignments []DeviceSceneAssignment `json:"sceneAssignments"`
|
||||||
Tags []string `json:"tags"`
|
SceneState *DeviceState `json:"sceneState"`
|
||||||
|
State DeviceState `json:"state"`
|
||||||
|
Tags []string `json:"tags"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type DeviceUpdate struct {
|
||||||
|
Icon *string `json:"icon"`
|
||||||
|
Name *string `json:"name"`
|
||||||
|
UserProperties map[string]*string `json:"userProperties"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// DeviceState contains optional state values that
|
// DeviceState contains optional state values that
|
||||||
@@ -28,32 +37,60 @@ type DeviceState struct {
|
|||||||
Power bool `json:"power"`
|
Power bool `json:"power"`
|
||||||
Color ColorValue `json:"color,omitempty"`
|
Color ColorValue `json:"color,omitempty"`
|
||||||
Intensity float64 `json:"intensity,omitempty"`
|
Intensity float64 `json:"intensity,omitempty"`
|
||||||
Temperature float64 `json:"temperature"`
|
Temperature int `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 {
|
type NewDeviceState struct {
|
||||||
Power *bool `json:"power"`
|
Power *bool `json:"power"`
|
||||||
Color *string `json:"color"`
|
Color *string `json:"color"`
|
||||||
Intensity int `json:"intensity"`
|
Intensity *float64 `json:"intensity"`
|
||||||
Temperature int `json:"temperature"`
|
Temperature *int `json:"temperature"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type DeviceCapability string
|
type DeviceCapability string
|
||||||
|
|
||||||
|
type SaveMode int
|
||||||
|
|
||||||
|
const (
|
||||||
|
SMState SaveMode = 1
|
||||||
|
SMProperties SaveMode = 2
|
||||||
|
SMTags SaveMode = 4
|
||||||
|
)
|
||||||
|
|
||||||
type DeviceRepository interface {
|
type DeviceRepository interface {
|
||||||
FindByID(ctx context.Context, id int) (*Device, error)
|
Find(ctx context.Context, id int) (*Device, error)
|
||||||
FetchByReference(ctx context.Context, kind ReferenceKind, value string) ([]Device, error)
|
FetchByReference(ctx context.Context, kind ReferenceKind, value string) ([]Device, error)
|
||||||
Save(ctx context.Context, device *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
|
Delete(ctx context.Context, device *Device) error
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func DeviceCapabilitiesToStrings(caps []DeviceCapability) []string {
|
||||||
|
res := make([]string, 0, len(caps))
|
||||||
|
for _, cap := range caps {
|
||||||
|
res = append(res, string(cap))
|
||||||
|
}
|
||||||
|
|
||||||
|
return res
|
||||||
|
}
|
||||||
|
|
||||||
var (
|
var (
|
||||||
DCPower DeviceCapability = "Power"
|
DCPower DeviceCapability = "Power"
|
||||||
DCColorHS DeviceCapability = "ColorHS"
|
DCColorHS DeviceCapability = "ColorHS"
|
||||||
DCColorKelvin DeviceCapability = "ColorKelvin"
|
DCColorHSK DeviceCapability = "ColorHSK"
|
||||||
DCButtons DeviceCapability = "Buttons"
|
DCColorKelvin DeviceCapability = "ColorKelvin"
|
||||||
DCIntensity DeviceCapability = "Intensity"
|
DCButtons DeviceCapability = "Buttons"
|
||||||
DCTemperature DeviceCapability = "Temperature"
|
DCPresence DeviceCapability = "Presence"
|
||||||
|
DCIntensity DeviceCapability = "Intensity"
|
||||||
|
DCTemperatureControl DeviceCapability = "TemperatureControl"
|
||||||
|
DCTemperatureSensor DeviceCapability = "TemperatureSensor"
|
||||||
)
|
)
|
||||||
|
|
||||||
var Capabilities = []DeviceCapability{
|
var Capabilities = []DeviceCapability{
|
||||||
@@ -61,8 +98,29 @@ var Capabilities = []DeviceCapability{
|
|||||||
DCColorHS,
|
DCColorHS,
|
||||||
DCColorKelvin,
|
DCColorKelvin,
|
||||||
DCButtons,
|
DCButtons,
|
||||||
|
DCPresence,
|
||||||
DCIntensity,
|
DCIntensity,
|
||||||
DCTemperature,
|
DCTemperatureControl,
|
||||||
|
DCTemperatureSensor,
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *Device) ApplyUpdate(update DeviceUpdate) {
|
||||||
|
if update.Name != nil {
|
||||||
|
d.Name = *update.Name
|
||||||
|
}
|
||||||
|
if update.Icon != nil {
|
||||||
|
d.Icon = *update.Icon
|
||||||
|
}
|
||||||
|
if d.UserProperties == nil {
|
||||||
|
d.UserProperties = make(map[string]string)
|
||||||
|
}
|
||||||
|
for key, value := range update.UserProperties {
|
||||||
|
if value != nil {
|
||||||
|
d.UserProperties[key] = *value
|
||||||
|
} else {
|
||||||
|
delete(d.UserProperties, key)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (d *Device) Validate() error {
|
func (d *Device) Validate() error {
|
||||||
@@ -85,10 +143,35 @@ func (d *Device) Validate() error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (d *Device) HasCapability(capacity DeviceCapability) bool {
|
func (d *Device) HasTag(tags ...string) bool {
|
||||||
|
for _, c := range d.Tags {
|
||||||
|
for _, c2 := range tags {
|
||||||
|
if c == c2 {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 _, c := range d.Capabilities {
|
||||||
if c == capacity {
|
for _, c2 := range capabilities {
|
||||||
return true
|
if c == c2 {
|
||||||
|
return true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -106,10 +189,66 @@ func (d *Device) SetState(newState NewDeviceState) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
if (parsed.IsKelvin() && d.HasCapability(DCColorKelvin)) || (parsed.IsHueSat() && d.HasCapability(DCColorHS)) {
|
if (parsed.IsKelvin() && d.HasCapability(DCColorKelvin, DCColorHSK)) || (parsed.IsHueSat() && d.HasCapability(DCColorHS)) {
|
||||||
d.State.Color = parsed
|
d.State.Color = parsed
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if newState.Intensity != nil && d.HasCapability(DCIntensity) {
|
||||||
|
d.State.Intensity = *newState.Intensity
|
||||||
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *NewDeviceState) RelativeTo(device Device) NewDeviceState {
|
||||||
|
n := NewDeviceState{}
|
||||||
|
|
||||||
|
if s.Intensity != nil {
|
||||||
|
intensity := device.State.Intensity * *s.Intensity
|
||||||
|
n.Intensity = &intensity
|
||||||
|
}
|
||||||
|
|
||||||
|
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))
|
||||||
|
}
|
||||||
|
|||||||
+1
-1
@@ -10,7 +10,7 @@ type DriverProvider interface {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type Driver 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)
|
SearchDevices(ctx context.Context, bridge Bridge, timeout time.Duration) ([]Device, error)
|
||||||
ListDevices(ctx context.Context, bridge Bridge) ([]Device, error)
|
ListDevices(ctx context.Context, bridge Bridge) ([]Device, error)
|
||||||
Publish(ctx context.Context, bridge Bridge, devices []Device) error
|
Publish(ctx context.Context, bridge Bridge, devices []Device) error
|
||||||
|
|||||||
@@ -8,8 +8,25 @@ var ErrBadInput = errors.New("bad input")
|
|||||||
var ErrBadColor = errors.New("bad color")
|
var ErrBadColor = errors.New("bad color")
|
||||||
var ErrInternal = errors.New("internal")
|
var ErrInternal = errors.New("internal")
|
||||||
var ErrUnknownColorFormat = errors.New("unknown color format")
|
var ErrUnknownColorFormat = errors.New("unknown color format")
|
||||||
|
var ErrUnknownDriver = errors.New("unknown driver")
|
||||||
|
|
||||||
var ErrMissingToken = errors.New("driver is missing authentication information")
|
var ErrMissingToken = errors.New("driver is missing authentication information")
|
||||||
var ErrIncorrectToken = errors.New("driver is not accepting authentication information")
|
var ErrIncorrectToken = errors.New("driver is not accepting authentication information")
|
||||||
var ErrUnexpectedResponse = errors.New("driver api returned unexpected response (wrong driver selected?)")
|
var ErrUnexpectedResponse = errors.New("driver api returned unexpected response (wrong driver selected?)")
|
||||||
var ErrBridgeSearchFailed = errors.New("bridge search failed")
|
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")
|
||||||
|
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")
|
||||||
|
|||||||
+18
-2
@@ -19,9 +19,25 @@ func (e *Event) HasPayload(key string) bool {
|
|||||||
return e.Payload != nil && e.Payload[key] != ""
|
return e.Payload != nil && e.Payload[key] != ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
ENBridgeConnected = "BridgeConnected"
|
||||||
|
ENBridgeDisconnected = "BridgeDisconnected"
|
||||||
|
ENButtonPressed = "ButtonPressed"
|
||||||
|
ENSensorPresenceStarted = "SensorPresenceStarted"
|
||||||
|
ENSensorPresenceEnded = "SensorPresenceEnded"
|
||||||
|
ENSensorTemperature = "SensorTemperature"
|
||||||
|
)
|
||||||
|
|
||||||
func BridgeConnectedEvent(bridge Bridge) Event {
|
func BridgeConnectedEvent(bridge Bridge) Event {
|
||||||
e := Event{Name: "BridgeConnected"}
|
e := Event{Name: ENBridgeConnected}
|
||||||
e.AddPayload("id", strconv.Itoa(bridge.ID))
|
e.AddPayload("bridgeId", strconv.Itoa(bridge.ID))
|
||||||
|
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
|
||||||
|
func BridgeDisconnectedEvent(bridge Bridge) Event {
|
||||||
|
e := Event{Name: ENBridgeDisconnected}
|
||||||
|
e.AddPayload("bridgeId", strconv.Itoa(bridge.ID))
|
||||||
|
|
||||||
return e
|
return e
|
||||||
}
|
}
|
||||||
|
|||||||
+183
-23
@@ -5,21 +5,27 @@ import (
|
|||||||
"regexp"
|
"regexp"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
type EventHandler struct {
|
type EventHandler struct {
|
||||||
ID int `json:"id"`
|
ID int `json:"id"`
|
||||||
EventName string `json:"eventName"`
|
EventName string `json:"eventName"`
|
||||||
Conditions map[string]EventCondition `json:"conditions"`
|
Conditions map[string]EventCondition `json:"conditions"`
|
||||||
TargetKind ReferenceKind `json:"targetType"`
|
OneShot bool `json:"oneShot"`
|
||||||
|
Priority int `json:"priority"`
|
||||||
|
TargetKind ReferenceKind `json:"targetKind"`
|
||||||
TargetValue string `json:"targetValue"`
|
TargetValue string `json:"targetValue"`
|
||||||
|
Actions EventAction `json:"actions"`
|
||||||
|
From TimeOfDay `json:"from"`
|
||||||
|
To TimeOfDay `json:"to"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type EventHandlerRepository interface {
|
type EventHandlerRepository interface {
|
||||||
FindByID(ctx context.Context, id int) (EventHandler, error)
|
FindByID(ctx context.Context, id int) (*EventHandler, error)
|
||||||
FetchAll(ctx context.Context) ([]EventHandler, error)
|
FetchAll(ctx context.Context) ([]EventHandler, error)
|
||||||
Save(ctx context.Context, handler *EventHandler)
|
Save(ctx context.Context, handler *EventHandler) error
|
||||||
Delete(ctx context.Context, handler *EventHandler)
|
Delete(ctx context.Context, handler *EventHandler) error
|
||||||
}
|
}
|
||||||
|
|
||||||
type EventCondition struct {
|
type EventCondition struct {
|
||||||
@@ -30,13 +36,70 @@ type EventCondition struct {
|
|||||||
LTE string `json:"lte,omitempty"`
|
LTE string `json:"lte,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type EventHandlerUpdate struct {
|
||||||
|
SetEventName *string `json:"setEventName"`
|
||||||
|
SetPriority *int `json:"setPriority"`
|
||||||
|
SetTargetKind *ReferenceKind `json:"setTargetKind"`
|
||||||
|
SetTargetValue *string `json:"setTargetValue"`
|
||||||
|
SetOneShot *bool `json:"setOneShot"`
|
||||||
|
SetConditions map[string]EventCondition `json:"setConditions"`
|
||||||
|
PatchConditions map[string]*EventCondition `json:"patchConditions"`
|
||||||
|
SetActions *EventAction `json:"setActions"`
|
||||||
|
SetFrom *TimeOfDay `json:"setFrom"`
|
||||||
|
SetTo *TimeOfDay `json:"setTo"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *EventHandler) ApplyUpdate(update EventHandlerUpdate) {
|
||||||
|
if update.SetEventName != nil {
|
||||||
|
h.EventName = *update.SetEventName
|
||||||
|
}
|
||||||
|
if update.SetPriority != nil {
|
||||||
|
h.Priority = *update.SetPriority
|
||||||
|
}
|
||||||
|
if update.SetTargetKind != nil {
|
||||||
|
h.TargetKind = *update.SetTargetKind
|
||||||
|
}
|
||||||
|
if update.SetTargetValue != nil {
|
||||||
|
h.TargetValue = *update.SetTargetValue
|
||||||
|
}
|
||||||
|
if update.SetActions != nil {
|
||||||
|
h.Actions = *update.SetActions
|
||||||
|
}
|
||||||
|
if update.SetOneShot != nil {
|
||||||
|
h.OneShot = *update.SetOneShot
|
||||||
|
}
|
||||||
|
if update.SetFrom != nil {
|
||||||
|
h.From = *update.SetFrom
|
||||||
|
}
|
||||||
|
if update.SetFrom != nil {
|
||||||
|
h.To = *update.SetFrom
|
||||||
|
}
|
||||||
|
|
||||||
|
if update.SetConditions != nil {
|
||||||
|
h.Conditions = update.SetConditions
|
||||||
|
}
|
||||||
|
if update.PatchConditions != nil {
|
||||||
|
if h.Conditions == nil {
|
||||||
|
h.Conditions = make(map[string]EventCondition)
|
||||||
|
}
|
||||||
|
|
||||||
|
for key, value := range update.PatchConditions {
|
||||||
|
if value == nil {
|
||||||
|
delete(h.Conditions, key)
|
||||||
|
} else {
|
||||||
|
h.Conditions[key] = *value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func (h *EventHandler) MatchesEvent(event Event, targets []Device) bool {
|
func (h *EventHandler) MatchesEvent(event Event, targets []Device) bool {
|
||||||
if event.Name != h.EventName {
|
if event.Name != h.EventName {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
for key, condition := range h.Conditions {
|
for key, condition := range h.Conditions {
|
||||||
if !event.HasPayload(key) {
|
if !strings.ContainsRune(key, '.') && !event.HasPayload(key) {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -48,43 +111,84 @@ func (h *EventHandler) MatchesEvent(event Event, targets []Device) bool {
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (h *EventHandler) IsWithinTimeRange() bool {
|
||||||
|
if h.From.IsNever() || h.To.IsNever() {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
return CurrentTimeOfDay().IsBetween(h.From, h.To)
|
||||||
|
}
|
||||||
|
|
||||||
func (c *EventCondition) check(key, value string, targets []Device) bool {
|
func (c *EventCondition) check(key, value string, targets []Device) bool {
|
||||||
any := strings.Index(key, "any.") == 0
|
any := strings.HasPrefix(key, "any.")
|
||||||
all := strings.Index(key, "all.") == 0
|
all := strings.HasPrefix(key, "all.")
|
||||||
if any || all && len(key) > 4 {
|
if any || all {
|
||||||
count := 0
|
count := 0
|
||||||
|
total := 0
|
||||||
for _, target := range targets {
|
for _, target := range targets {
|
||||||
if c.checkDevice(key[4:], target) {
|
matches, skip := c.checkDevice(key[4:], target)
|
||||||
|
if skip {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if matches {
|
||||||
count++
|
count++
|
||||||
}
|
}
|
||||||
|
total++
|
||||||
}
|
}
|
||||||
|
|
||||||
return (any && count > 0) || (all && count == len(targets))
|
return (any && count > 0) || (all && count == total)
|
||||||
}
|
}
|
||||||
|
|
||||||
return c.matches(value)
|
return c.matches(value)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *EventCondition) checkDevice(key string, device Device) bool {
|
func (c *EventCondition) checkDevice(key string, device Device) (matches bool, skip bool) {
|
||||||
switch key {
|
switch key {
|
||||||
case "power":
|
case "power":
|
||||||
return c.matches(strconv.FormatBool(device.State.Power))
|
if !device.HasCapability(DCPower) {
|
||||||
case "color":
|
return false, true
|
||||||
return c.matches(device.State.Color.String())
|
}
|
||||||
case "intensity":
|
|
||||||
return c.matches(strconv.FormatFloat(device.State.Intensity, 'f', -1, 64))
|
|
||||||
case "temperature":
|
|
||||||
return c.matches(strconv.FormatFloat(device.State.Temperature, 'f', -1, 64))
|
|
||||||
}
|
|
||||||
|
|
||||||
return false
|
return c.matches(strconv.FormatBool(device.State.Power)), false
|
||||||
|
case "color":
|
||||||
|
if !device.HasCapability(DCColorKelvin, DCColorHS, DCColorHSK) {
|
||||||
|
return false, true
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.matches(device.State.Color.String()), false
|
||||||
|
case "intensity":
|
||||||
|
if !device.HasCapability(DCIntensity) {
|
||||||
|
return false, true
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.matches(strconv.FormatFloat(device.State.Intensity, 'f', -1, 64)), false
|
||||||
|
case "temperature":
|
||||||
|
if !device.HasCapability(DCTemperatureControl, DCTemperatureSensor) {
|
||||||
|
return false, true
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.matches(strconv.Itoa(device.State.Temperature)), false
|
||||||
|
case "scene":
|
||||||
|
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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var numRegex = regexp.MustCompile("^{-[0-9].}+$")
|
var numRegex = regexp.MustCompile("^-*[0-9]+(.[0-9]+)*$")
|
||||||
|
|
||||||
func (c *EventCondition) matches(value string) bool {
|
func (c *EventCondition) matches(value string) bool {
|
||||||
if numRegex.MatchString(value) {
|
if numRegex.MatchString(value) {
|
||||||
numValue, _ := strconv.ParseFloat(c.LT, 64)
|
numValue, _ := strconv.ParseFloat(value, 64)
|
||||||
stillAlive := true
|
stillAlive := true
|
||||||
|
|
||||||
if c.LT != "" {
|
if c.LT != "" {
|
||||||
@@ -104,7 +208,7 @@ func (c *EventCondition) matches(value string) bool {
|
|||||||
|
|
||||||
if stillAlive && c.GTE != "" {
|
if stillAlive && c.GTE != "" {
|
||||||
gte, _ := strconv.ParseFloat(c.GTE, 64)
|
gte, _ := strconv.ParseFloat(c.GTE, 64)
|
||||||
stillAlive = numValue == gte
|
stillAlive = numValue >= gte
|
||||||
}
|
}
|
||||||
|
|
||||||
if stillAlive && c.GT != "" {
|
if stillAlive && c.GT != "" {
|
||||||
@@ -119,3 +223,59 @@ func (c *EventCondition) matches(value string) bool {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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"`
|
||||||
|
SetScene *DeviceSceneAssignment `json:"setScene"`
|
||||||
|
PushScene *DeviceSceneAssignment `json:"pushScene"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (action *EventAction) Apply(other EventAction) {
|
||||||
|
if action.SetPower == nil {
|
||||||
|
action.SetPower = other.SetPower
|
||||||
|
}
|
||||||
|
if action.SetColor == nil {
|
||||||
|
action.SetColor = other.SetColor
|
||||||
|
}
|
||||||
|
if action.SetIntensity == nil && other.SetIntensity != nil {
|
||||||
|
action.SetIntensity = other.SetIntensity
|
||||||
|
}
|
||||||
|
if action.AddIntensity == nil && action.SetIntensity == nil {
|
||||||
|
action.AddIntensity = other.AddIntensity
|
||||||
|
}
|
||||||
|
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 {
|
||||||
|
str := make([]string, 0, 5)
|
||||||
|
if len(c.LT) > 0 {
|
||||||
|
str = append(str, "lt:"+c.LT)
|
||||||
|
}
|
||||||
|
if len(c.LTE) > 0 {
|
||||||
|
str = append(str, "lte:"+c.LTE)
|
||||||
|
}
|
||||||
|
if len(c.EQ) > 0 {
|
||||||
|
str = append(str, c.EQ)
|
||||||
|
}
|
||||||
|
if len(c.GTE) > 0 {
|
||||||
|
str = append(str, "gte:"+c.GTE)
|
||||||
|
}
|
||||||
|
if len(c.GT) > 0 {
|
||||||
|
str = append(str, "gte:"+c.GT)
|
||||||
|
}
|
||||||
|
|
||||||
|
return strings.Join(str, ";")
|
||||||
|
}
|
||||||
|
|||||||
+312
@@ -0,0 +1,312 @@
|
|||||||
|
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"
|
||||||
|
SEMotion SceneEffect = "Motion"
|
||||||
|
SETemperature SceneEffect = "Temperature"
|
||||||
|
)
|
||||||
|
|
||||||
|
type SceneRole struct {
|
||||||
|
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 {
|
||||||
|
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, SEMotion, SETemperature:
|
||||||
|
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())
|
||||||
|
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 {
|
||||||
|
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
|
||||||
|
}
|
||||||
+79
-3
@@ -1,11 +1,87 @@
|
|||||||
package models
|
package models
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
type ReferenceKind string
|
type ReferenceKind string
|
||||||
|
|
||||||
var (
|
var (
|
||||||
RTDeviceID ReferenceKind = "DeviceID"
|
RKDeviceID ReferenceKind = "DeviceID"
|
||||||
RTBridgeID ReferenceKind = "BridgeID"
|
RKBridgeID ReferenceKind = "BridgeID"
|
||||||
RTTag ReferenceKind = "Tag"
|
RKTag ReferenceKind = "Tag"
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
func ParseFetchString(fetchStr string) (ReferenceKind, string) {
|
||||||
|
if strings.HasPrefix(fetchStr, "tag:") {
|
||||||
|
return RKTag, fetchStr[4:]
|
||||||
|
} else if strings.HasPrefix(fetchStr, "bridge:") {
|
||||||
|
return RKBridgeID, fetchStr[7:]
|
||||||
|
} else if strings.HasPrefix(fetchStr, "id:") {
|
||||||
|
return RKDeviceID, fetchStr[7:]
|
||||||
|
} else if strings.HasPrefix(fetchStr, "name:") {
|
||||||
|
return RKName, fetchStr[7:]
|
||||||
|
} else if fetchStr == "all" {
|
||||||
|
return RKAll, ""
|
||||||
|
} else {
|
||||||
|
_, err := strconv.Atoi(fetchStr)
|
||||||
|
if err != nil {
|
||||||
|
return RKName, fetchStr
|
||||||
|
}
|
||||||
|
|
||||||
|
return RKDeviceID, fetchStr
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,138 @@
|
|||||||
|
package models
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type TimeOfDay int
|
||||||
|
|
||||||
|
func (t *TimeOfDay) UnmarshalJSON(v []byte) error {
|
||||||
|
if bytes.Equal(v, []byte("null")) {
|
||||||
|
*t = -1
|
||||||
|
}
|
||||||
|
|
||||||
|
var str string
|
||||||
|
err := json.Unmarshal(v, &str)
|
||||||
|
if err != nil {
|
||||||
|
var n int
|
||||||
|
err = json.Unmarshal(v, &n)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if n < -1 && n >= 86400 {
|
||||||
|
return fmt.Errorf("value outside range 0..86400: %d", n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
t2, err := ParseTimeOfDay(str)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
*t = t2
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t TimeOfDay) MarshalJSON() ([]byte, error) {
|
||||||
|
if t.IsNever() {
|
||||||
|
return []byte("null"), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return json.Marshal(t.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
const Never = TimeOfDay(-1)
|
||||||
|
|
||||||
|
func (t *TimeOfDay) String() string {
|
||||||
|
if *t < 0 {
|
||||||
|
return "n/a"
|
||||||
|
}
|
||||||
|
|
||||||
|
return fmt.Sprintf("%02d:%02d:%02d", *t/3600, (*t/60)%60, *t%60)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *TimeOfDay) StringPtr() *string {
|
||||||
|
if *t < 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
s := t.String()
|
||||||
|
return &s
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t TimeOfDay) IsNever() bool {
|
||||||
|
return t == Never
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t TimeOfDay) IsBetween(from TimeOfDay, to TimeOfDay) bool {
|
||||||
|
if from == to {
|
||||||
|
return t == from
|
||||||
|
} else if from > to {
|
||||||
|
return t >= from || t <= to
|
||||||
|
} else {
|
||||||
|
return t >= from && t <= to
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func CurrentTimeOfDay() TimeOfDay {
|
||||||
|
return TimeOfDayFromDate(time.Now().Local())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TimeOfDayFromDate(date time.Time) TimeOfDay {
|
||||||
|
return NewTimeOfDay(date.Hour(), date.Minute(), date.Second())
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewTimeOfDay(hours, minutes, seconds int) TimeOfDay {
|
||||||
|
return TimeOfDay(((hours % 24) * 3600) + ((minutes % 60) * 60) + seconds)
|
||||||
|
}
|
||||||
|
|
||||||
|
func ParseTimeOfDayPtr(str *string) (TimeOfDay, error) {
|
||||||
|
if str == nil {
|
||||||
|
return Never, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return ParseTimeOfDay(*str)
|
||||||
|
}
|
||||||
|
|
||||||
|
func ParseTimeOfDay(str string) (TimeOfDay, error) {
|
||||||
|
if str == "" || str == "n/a" || str == "N/A" {
|
||||||
|
return -1, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
split := strings.SplitN(str, ":", 4)
|
||||||
|
res := 0
|
||||||
|
|
||||||
|
n, err := strconv.Atoi(split[0])
|
||||||
|
if err != nil {
|
||||||
|
return Never, err
|
||||||
|
}
|
||||||
|
res += n * 3600
|
||||||
|
|
||||||
|
if len(split) >= 2 {
|
||||||
|
n, err := strconv.Atoi(split[1])
|
||||||
|
if err != nil {
|
||||||
|
return Never, err
|
||||||
|
}
|
||||||
|
res += n * 60
|
||||||
|
}
|
||||||
|
if len(split) >= 3 {
|
||||||
|
n, err := strconv.Atoi(split[2])
|
||||||
|
if err != nil {
|
||||||
|
return Never, err
|
||||||
|
}
|
||||||
|
res += n
|
||||||
|
}
|
||||||
|
|
||||||
|
if res >= 86400 || res < 0 {
|
||||||
|
return Never, fmt.Errorf("invalid time of day string %s (=%d)", str, res)
|
||||||
|
}
|
||||||
|
|
||||||
|
return TimeOfDay(res), nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
package models
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func tod(s string) TimeOfDay {
|
||||||
|
tod, err := ParseTimeOfDay(s)
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return tod
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTimeOfDay_IsBetween(t *testing.T) {
|
||||||
|
table := []struct{
|
||||||
|
Value TimeOfDay
|
||||||
|
From TimeOfDay
|
||||||
|
To TimeOfDay
|
||||||
|
Expected bool
|
||||||
|
}{
|
||||||
|
{ tod("16:13:11"), tod("07"), tod("21:30"), true },
|
||||||
|
{ tod("16:13:31"), tod("21:30"), tod("07"), false },
|
||||||
|
{ tod("23:15:32"), tod("21:30"), tod("07"), true },
|
||||||
|
{ tod("04:13:57"), tod("21:30"), tod("07"), true },
|
||||||
|
{ tod("16:14:43"), tod("15:30"), tod("16:00"), false },
|
||||||
|
{ tod("16:14:43"), tod("15:30"), tod("16:15"), true },
|
||||||
|
{ tod("08:45:43"), tod("07:00:00"), tod("21:30:00"), true },
|
||||||
|
{ tod("07:00:00"), tod("07:00:00"), tod("21:30:00"), true },
|
||||||
|
}
|
||||||
|
|
||||||
|
for i, row := range table {
|
||||||
|
t.Run(fmt.Sprintf("row_%d", i), func (t *testing.T) {
|
||||||
|
if row.Value.IsBetween(row.From, row.To) != row.Expected {
|
||||||
|
t.Log(row.Value, row.From, row.To)
|
||||||
|
t.Fail()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
name: Evening
|
||||||
|
interval: 1500
|
||||||
|
roles:
|
||||||
|
- effect: WalkingGradient
|
||||||
|
power_mode: Device
|
||||||
|
target_kind: Tag
|
||||||
|
target_value: Hexagon
|
||||||
|
interpolate: true
|
||||||
|
relative: false
|
||||||
|
order: -name
|
||||||
|
states:
|
||||||
|
- color: 'hs:30,1'
|
||||||
|
intensity: 0.20
|
||||||
|
- color: 'hs:30,1'
|
||||||
|
intensity: 0.15
|
||||||
|
|
||||||
|
- effect: Gradient
|
||||||
|
power_mode: Device
|
||||||
|
target_kind: Tag
|
||||||
|
target_value: SquareLeft
|
||||||
|
interpolate: true
|
||||||
|
relative: false
|
||||||
|
order: +name
|
||||||
|
states:
|
||||||
|
- color: 'hs:25,1'
|
||||||
|
intensity: 0.075
|
||||||
|
- color: 'hs:50,1'
|
||||||
|
intensity: 0.15
|
||||||
|
|
||||||
|
- effect: Random
|
||||||
|
power_mode: Device
|
||||||
|
target_kind: Tag
|
||||||
|
target_value: SquareRight
|
||||||
|
interpolate: true
|
||||||
|
relative: false
|
||||||
|
order: +name
|
||||||
|
states:
|
||||||
|
- color: 'hs:220,0.7'
|
||||||
|
intensity: 0.22
|
||||||
|
- color: 'hs:220,0.6'
|
||||||
|
intensity: 0.25
|
||||||
|
|
||||||
|
- effect: Static
|
||||||
|
power_mode: Device
|
||||||
|
target_kind: Tag
|
||||||
|
target_value: Accent
|
||||||
|
interpolate: true
|
||||||
|
relative: false
|
||||||
|
order: +name
|
||||||
|
states:
|
||||||
|
- color: 'hs:250,0.7'
|
||||||
|
intensity: 0.25
|
||||||
|
|
||||||
|
- effect: Static
|
||||||
|
power_mode: Device
|
||||||
|
target_kind: All
|
||||||
|
target_value: ""
|
||||||
|
interpolate: true
|
||||||
|
relative: false
|
||||||
|
order: +name
|
||||||
|
states:
|
||||||
|
- color: 'k:2000'
|
||||||
|
intensity: 0.3
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
name: Flash
|
||||||
|
interval: 0
|
||||||
|
roles:
|
||||||
|
- effect: Static
|
||||||
|
power_mode: Device
|
||||||
|
target_kind: Tag
|
||||||
|
target_value: Nanoleaf
|
||||||
|
states:
|
||||||
|
- intensity: 1
|
||||||
|
color: hs:220,0.1
|
||||||
|
|
||||||
|
- effect: Static
|
||||||
|
power_mode: Device
|
||||||
|
target_kind: Tag
|
||||||
|
target_value: Hue
|
||||||
|
states:
|
||||||
|
- intensity: 1
|
||||||
|
color: k:6500
|
||||||
|
|
||||||
|
- effect: Static
|
||||||
|
power_mode: Device
|
||||||
|
target_kind: All
|
||||||
|
target_value: ""
|
||||||
|
states:
|
||||||
|
- intensity: 1
|
||||||
|
color: hs:0,0
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
name: Late
|
||||||
|
interval: 60000
|
||||||
|
roles:
|
||||||
|
- effect: Random
|
||||||
|
power_mode: Device
|
||||||
|
target_kind: Tag
|
||||||
|
target_value: Hexagon
|
||||||
|
interpolate: true
|
||||||
|
relative: false
|
||||||
|
order: +name
|
||||||
|
states:
|
||||||
|
- color: 'hs:35,1'
|
||||||
|
intensity: 0.08
|
||||||
|
- color: 'hs:35,1'
|
||||||
|
intensity: 0.085
|
||||||
|
- color: 'hs:35,1'
|
||||||
|
intensity: 0.09
|
||||||
|
- color: 'hs:35,1'
|
||||||
|
intensity: 0.12
|
||||||
|
|
||||||
|
- effect: Gradient
|
||||||
|
power_mode: Both
|
||||||
|
target_kind: Tag
|
||||||
|
target_value: Square
|
||||||
|
interpolate: true
|
||||||
|
relative: false
|
||||||
|
order: +name
|
||||||
|
states:
|
||||||
|
- color: 'hs:25,1'
|
||||||
|
intensity: 0.05
|
||||||
|
power: off
|
||||||
|
- color: 'hs:30,1'
|
||||||
|
intensity: 0.06
|
||||||
|
power: off
|
||||||
|
- color: 'hs:35,1'
|
||||||
|
intensity: 0.07
|
||||||
|
power: on
|
||||||
|
|
||||||
|
- effect: Static
|
||||||
|
power_mode: Device
|
||||||
|
target_kind: Tag
|
||||||
|
target_value: Nightstand
|
||||||
|
interpolate: true
|
||||||
|
relative: false
|
||||||
|
order: +name
|
||||||
|
states:
|
||||||
|
- color: 'k:2000'
|
||||||
|
intensity: 0.15
|
||||||
|
|
||||||
|
- effect: Static
|
||||||
|
power_mode: Scene
|
||||||
|
target_kind: All
|
||||||
|
target_value: ''
|
||||||
|
interpolate: true
|
||||||
|
relative: false
|
||||||
|
states:
|
||||||
|
- power: false
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
name: Morning
|
||||||
|
interval: 1000
|
||||||
|
roles:
|
||||||
|
- effect: WalkingGradient
|
||||||
|
power_mode: Device
|
||||||
|
target_kind: Tag
|
||||||
|
target_value: Hexagon
|
||||||
|
interpolate: true
|
||||||
|
relative: false
|
||||||
|
order: +name
|
||||||
|
states:
|
||||||
|
- color: 'hs:220,0.4'
|
||||||
|
intensity: 0.65
|
||||||
|
- color: 'hs:220,0.5'
|
||||||
|
intensity: 0.50
|
||||||
|
|
||||||
|
- effect: Random
|
||||||
|
power_mode: Device
|
||||||
|
target_kind: Tag
|
||||||
|
target_value: Square
|
||||||
|
interpolate: true
|
||||||
|
relative: false
|
||||||
|
order: +name
|
||||||
|
states:
|
||||||
|
- color: 'hs:220,0.6'
|
||||||
|
intensity: 0.30
|
||||||
|
- color: 'hs:220,0.5'
|
||||||
|
intensity: 0.35
|
||||||
|
|
||||||
|
- effect: Static
|
||||||
|
power_mode: Device
|
||||||
|
target_kind: All
|
||||||
|
target_value: ''
|
||||||
|
interpolate: true
|
||||||
|
relative: false
|
||||||
|
order: +name
|
||||||
|
states:
|
||||||
|
- color: 'hs:250,0.6'
|
||||||
|
intensity: 0.3
|
||||||
|
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
name: Saturday
|
||||||
|
interval: 700
|
||||||
|
roles:
|
||||||
|
- effect: WalkingGradient
|
||||||
|
power_mode: Device
|
||||||
|
target_kind: Tag
|
||||||
|
target_value: Hexagon
|
||||||
|
interpolate: true
|
||||||
|
relative: false
|
||||||
|
order: +name
|
||||||
|
states:
|
||||||
|
- color: 'hs:220,0.5'
|
||||||
|
intensity: 0.25
|
||||||
|
- color: 'hs:220,0.5'
|
||||||
|
intensity: 0.30
|
||||||
|
|
||||||
|
- effect: Random
|
||||||
|
power_mode: Device
|
||||||
|
target_kind: Tag
|
||||||
|
target_value: Square
|
||||||
|
interpolate: true
|
||||||
|
relative: false
|
||||||
|
order: +name
|
||||||
|
states:
|
||||||
|
- color: 'hs:220,0.6'
|
||||||
|
intensity: 0.15
|
||||||
|
- color: 'hs:220,0.55'
|
||||||
|
intensity: 0.16
|
||||||
|
- color: 'hs:220,0.5'
|
||||||
|
intensity: 0.20
|
||||||
|
|
||||||
|
- effect: Static
|
||||||
|
power_mode: Device
|
||||||
|
target_kind: All
|
||||||
|
target_value: ''
|
||||||
|
interpolate: true
|
||||||
|
relative: false
|
||||||
|
order: +name
|
||||||
|
states:
|
||||||
|
- color: 'hs:250,0.7'
|
||||||
|
intensity: 0.3
|
||||||
|
|
||||||
@@ -5,12 +5,13 @@ CREATE TABLE device
|
|||||||
id INT NOT NULL AUTO_INCREMENT,
|
id INT NOT NULL AUTO_INCREMENT,
|
||||||
bridge_id INT NOT NULL,
|
bridge_id INT NOT NULL,
|
||||||
internal_id VARCHAR(255) NOT NULL,
|
internal_id VARCHAR(255) NOT NULL,
|
||||||
icon CHAR NOT NULL,
|
icon VARCHAR(255) NOT NULL,
|
||||||
name VARCHAR(255) NOT NULL,
|
name VARCHAR(255) NOT NULL,
|
||||||
capabilities VARCHAR(255) NOT NULL,
|
capabilities VARCHAR(255) NOT NULL,
|
||||||
button_names VARCHAR(255) NOT NULL,
|
button_names VARCHAR(255) NOT NULL,
|
||||||
|
|
||||||
PRIMARY KEY (id)
|
PRIMARY KEY (id),
|
||||||
|
UNIQUE (bridge_id, internal_id)
|
||||||
);
|
);
|
||||||
-- +goose StatementEnd
|
-- +goose StatementEnd
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ CREATE TABLE device_state
|
|||||||
saturation DOUBLE NOT NULL,
|
saturation DOUBLE NOT NULL,
|
||||||
kelvin INT NOT NULL,
|
kelvin INT NOT NULL,
|
||||||
power TINYINT NOT NULL,
|
power TINYINT NOT NULL,
|
||||||
intensity INT NOT NULL,
|
intensity DOUBLE NOT NULL,
|
||||||
|
|
||||||
PRIMARY KEY (device_id)
|
PRIMARY KEY (device_id)
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
-- +goose Up
|
||||||
|
-- +goose StatementBegin
|
||||||
|
CREATE TABLE color_preset
|
||||||
|
(
|
||||||
|
id INT NOT NULL AUTO_INCREMENT,
|
||||||
|
hue DOUBLE NOT NULL,
|
||||||
|
saturation DOUBLE NOT NULL,
|
||||||
|
kelvin INT NOT NULL,
|
||||||
|
|
||||||
|
PRIMARY KEY (id)
|
||||||
|
);
|
||||||
|
-- +goose StatementEnd
|
||||||
|
|
||||||
|
-- +goose Down
|
||||||
|
-- +goose StatementBegin
|
||||||
|
DROP TABLE color_preset;
|
||||||
|
-- +goose StatementEnd
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
-- +goose Up
|
||||||
|
-- +goose StatementBegin
|
||||||
|
ALTER TABLE color_preset
|
||||||
|
ADD COLUMN name VARCHAR(255) NOT NULL DEFAULT 'Unnamed';
|
||||||
|
-- +goose StatementEnd
|
||||||
|
|
||||||
|
-- +goose Down
|
||||||
|
-- +goose StatementBegin
|
||||||
|
ALTER TABLE color_preset
|
||||||
|
DROP COLUMN name;
|
||||||
|
-- +goose StatementEnd
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
-- +goose Up
|
||||||
|
-- +goose StatementBegin
|
||||||
|
CREATE TABLE device_tag
|
||||||
|
(
|
||||||
|
device_id INT NOT NULL,
|
||||||
|
tag_name VARCHAR(255) NOT NULL,
|
||||||
|
|
||||||
|
PRIMARY KEY (device_id, tag_name),
|
||||||
|
INDEX (tag_name)
|
||||||
|
);
|
||||||
|
-- +goose StatementEnd
|
||||||
|
|
||||||
|
-- +goose Down
|
||||||
|
-- +goose StatementBegin
|
||||||
|
DROP TABLE device_tag;
|
||||||
|
-- +goose StatementEnd
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
-- +goose Up
|
||||||
|
-- +goose StatementBegin
|
||||||
|
CREATE TABLE event_handler
|
||||||
|
(
|
||||||
|
id INT NOT NULL AUTO_INCREMENT,
|
||||||
|
event_name VARCHAR(255) NOT NULL,
|
||||||
|
one_shot TINYINT NOT NULL DEFAULT 0,
|
||||||
|
priority INT NOT NULL DEFAULT 0,
|
||||||
|
target_kind VARCHAR(255) NOT NULL,
|
||||||
|
target_value VARCHAR(255) NOT NULL,
|
||||||
|
conditions JSON NOT NULL,
|
||||||
|
actions JSON NOT NULL,
|
||||||
|
|
||||||
|
PRIMARY KEY (id)
|
||||||
|
);
|
||||||
|
-- +goose StatementEnd
|
||||||
|
|
||||||
|
-- +goose Down
|
||||||
|
-- +goose StatementBegin
|
||||||
|
DROP TABLE event_handler;
|
||||||
|
-- +goose StatementEnd
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
-- +goose Up
|
||||||
|
-- +goose StatementBegin
|
||||||
|
CREATE TABLE scene
|
||||||
|
(
|
||||||
|
id INT NOT NULL AUTO_INCREMENT,
|
||||||
|
name VARCHAR(255) NOT NULL,
|
||||||
|
interval_ms INT NOT NULL,
|
||||||
|
roles JSON NOT NULL,
|
||||||
|
|
||||||
|
PRIMARY KEY (id)
|
||||||
|
);
|
||||||
|
-- +goose StatementEnd
|
||||||
|
|
||||||
|
-- +goose Down
|
||||||
|
-- +goose StatementBegin
|
||||||
|
DROP TABLE scene;
|
||||||
|
-- +goose StatementEnd
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
-- +goose Up
|
||||||
|
-- +goose StatementBegin
|
||||||
|
ALTER TABLE device ADD COLUMN scene_assignments JSON NOT NULL DEFAULT ('[]');
|
||||||
|
-- +goose StatementEnd
|
||||||
|
|
||||||
|
-- +goose Down
|
||||||
|
-- +goose StatementBegin
|
||||||
|
ALTER TABLE device DROP COLUMN IF EXISTS scene_assignments;
|
||||||
|
-- +goose StatementEnd
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
-- +goose Up
|
||||||
|
-- +goose StatementBegin
|
||||||
|
ALTER TABLE event_handler
|
||||||
|
ADD COLUMN from_tod VARCHAR(255),
|
||||||
|
ADD COLUMN to_tod VARCHAR(255);
|
||||||
|
-- +goose StatementEnd
|
||||||
|
|
||||||
|
-- +goose Down
|
||||||
|
-- +goose StatementBegin
|
||||||
|
ALTER TABLE event_handler
|
||||||
|
DROP COLUMN IF EXISTS from_tod,
|
||||||
|
DROP COLUMN IF EXISTS to_tod;
|
||||||
|
-- +goose StatementEnd
|
||||||
@@ -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
|
||||||
@@ -1 +1,2 @@
|
|||||||
node_modules/
|
node_modules/
|
||||||
|
yarn.error.log
|
||||||
|
|||||||
+9
-1
@@ -3,6 +3,9 @@
|
|||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@iconify-icons/mdi": "^1.1.15",
|
||||||
|
"@iconify/react": "^1.1.4",
|
||||||
|
"@jaames/iro": "^5.5.1",
|
||||||
"@testing-library/jest-dom": "^5.11.4",
|
"@testing-library/jest-dom": "^5.11.4",
|
||||||
"@testing-library/react": "^11.1.0",
|
"@testing-library/react": "^11.1.0",
|
||||||
"@testing-library/user-event": "^12.1.10",
|
"@testing-library/user-event": "^12.1.10",
|
||||||
@@ -10,11 +13,15 @@
|
|||||||
"@types/node": "^12.0.0",
|
"@types/node": "^12.0.0",
|
||||||
"@types/react": "^17.0.0",
|
"@types/react": "^17.0.0",
|
||||||
"@types/react-dom": "^17.0.0",
|
"@types/react-dom": "^17.0.0",
|
||||||
|
"axios": "^0.21.1",
|
||||||
"hookrouter": "^1.2.5",
|
"hookrouter": "^1.2.5",
|
||||||
|
"kelvin-to-rgb": "^1.0.2",
|
||||||
"node-sass": "4.14.1",
|
"node-sass": "4.14.1",
|
||||||
"react": "^17.0.2",
|
"react": "^17.0.2",
|
||||||
"react-dom": "^17.0.2",
|
"react-dom": "^17.0.2",
|
||||||
|
"react-modal": "^3.14.2",
|
||||||
"react-scripts": "4.0.3",
|
"react-scripts": "4.0.3",
|
||||||
|
"react-semantic-ui-range": "^0.7.1",
|
||||||
"typescript": "^4.1.2",
|
"typescript": "^4.1.2",
|
||||||
"web-vitals": "^1.0.1"
|
"web-vitals": "^1.0.1"
|
||||||
},
|
},
|
||||||
@@ -43,6 +50,7 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/hookrouter": "^2.2.5"
|
"@types/hookrouter": "^2.2.5",
|
||||||
|
"@types/react-modal": "^3.12.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+48
-7
@@ -1,11 +1,38 @@
|
|||||||
import React from 'react';
|
import React, {useContext, useLayoutEffect} from 'react';
|
||||||
import {HookRouter, navigate, usePath, useRoutes} from "hookrouter";
|
import {HookRouter, navigate, usePath, useRoutes} from "hookrouter";
|
||||||
import {Tabs} from "./primitives/Layout";
|
import {Page, Tabs} from "./primitives/Layout";
|
||||||
|
import {IconElement} from "./primitives/Elements";
|
||||||
|
import SettingsPage from "./pages/SettingsPage";
|
||||||
|
import {LuciferIcon} from "./models/Icons";
|
||||||
|
import ReactModal from "react-modal";
|
||||||
|
import DialogContext, {DialogContextProvider, makeDialog} from "./contexts/DialogContext";
|
||||||
|
|
||||||
const routeObj: HookRouter.RouteObject = {
|
const routeObj: HookRouter.RouteObject = {
|
||||||
"/": () => <div>1</div>,
|
"/": () => (
|
||||||
"/devices": () => <div>2</div>,
|
<Page>
|
||||||
"/settings": () => <div>3</div>,
|
<h1> </h1>
|
||||||
|
<h1>Enheter</h1>
|
||||||
|
</Page>
|
||||||
|
),
|
||||||
|
"/devices": () => (
|
||||||
|
<div>
|
||||||
|
<IconElement icon={LuciferIcon.Hexagon} caption="Grønn" color={{h: 125, s: 0.5}}/>
|
||||||
|
<IconElement icon={LuciferIcon.Square} caption="Kelvin 1k" color={{kelvin: 1000}}/>
|
||||||
|
<IconElement icon={LuciferIcon.Bulb} caption="Kelvin 2k" color={{kelvin: 2000}}/>
|
||||||
|
<IconElement icon={LuciferIcon.BulbGroup} caption="Kelvin 3k" color={{kelvin: 3000}}/>
|
||||||
|
<IconElement icon={LuciferIcon.Bridge} caption="Kelvin 4k" color={{kelvin: 4000}}/>
|
||||||
|
<IconElement icon={LuciferIcon.Cog} caption="Kelvin 5k" color={{kelvin: 5000}}/>
|
||||||
|
<IconElement icon={LuciferIcon.Switch} caption="Kelvin 6k" color={{kelvin: 6000}}/>
|
||||||
|
<IconElement icon={LuciferIcon.Triangle} caption="Kelvin 7k" color={{kelvin: 7000}}/>
|
||||||
|
<IconElement icon={LuciferIcon.HexagonGroup} caption="Kelvin 8k" color={{kelvin: 8000}}/>
|
||||||
|
<IconElement icon={LuciferIcon.Router} caption="Kelvin 9k" color={{kelvin: 9000}}/>
|
||||||
|
<IconElement icon={LuciferIcon.Check} caption="Kelvin 10k" color={{kelvin: 10000}}/>
|
||||||
|
<IconElement icon={LuciferIcon.Cross} caption="Kelvin 10k" color={{kelvin: 10000}}/>
|
||||||
|
<IconElement caption="Mer" color="hs-gradient"/>
|
||||||
|
<IconElement caption="Mer" color="k-gradient"/>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
"/settings": () => <SettingsPage/>,
|
||||||
}
|
}
|
||||||
|
|
||||||
const routeList = ["/", "/devices", "/settings"];
|
const routeList = ["/", "/devices", "/settings"];
|
||||||
@@ -13,17 +40,31 @@ const routeList = ["/", "/devices", "/settings"];
|
|||||||
const tabNames = ["Lucifer", "Enheter", "Oppsett"];
|
const tabNames = ["Lucifer", "Enheter", "Oppsett"];
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
|
return (
|
||||||
|
<DialogContextProvider>
|
||||||
|
<AppInContext/>
|
||||||
|
</DialogContextProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AppInContext() {
|
||||||
const route = useRoutes(routeObj);
|
const route = useRoutes(routeObj);
|
||||||
const path = usePath();
|
const path = usePath();
|
||||||
|
const {dialog} = useContext(DialogContext);
|
||||||
|
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
ReactModal.setAppElement("#root");
|
||||||
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="App">
|
<div className="App">
|
||||||
<Tabs tabNames={tabNames}
|
<Tabs tabNames={tabNames}
|
||||||
index={routeList.indexOf(path)}
|
index={routeList.indexOf(path)}
|
||||||
onChange={i => navigate(routeList[i])}
|
onChange={i => navigate(routeList[i])}
|
||||||
boldIndex={0}
|
large boldIndex={0}
|
||||||
/>
|
/>
|
||||||
{route || <div>B</div>}
|
{route || <Page>404</Page>}
|
||||||
|
{makeDialog(dialog)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import {ColorPreset} from "../models/Colors";
|
||||||
|
import {deleteRequest, getRequest, postRequest, putRequest} from "../helpers/rest";
|
||||||
|
|
||||||
|
export function listColorPresets(): Promise<ColorPreset[]> {
|
||||||
|
return getRequest("/color-presets");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function addColorPreset(name: string, colorString: string) {
|
||||||
|
return postRequest("/color-presets", {name, colorString});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function editColorPreset(id: number, name: string, colorString: string) {
|
||||||
|
return putRequest(`/color-presets/${id}`, {name, colorString});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deleteColorPreset(id: number) {
|
||||||
|
return deleteRequest(`/color-presets/${id}`);
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import React, {createContext, useCallback, useEffect, useState} from "react";
|
||||||
|
import {ColorPreset} from "../models/Colors";
|
||||||
|
import {DataTarget} from "../models/Shared";
|
||||||
|
import {unimplemented} from "../helpers/misc";
|
||||||
|
import {listColorPresets} from "../actions/ColorPresets";
|
||||||
|
|
||||||
|
interface DataContextValue {
|
||||||
|
colorPresets: ColorPreset[]
|
||||||
|
|
||||||
|
ready(target?: DataTarget): boolean
|
||||||
|
|
||||||
|
refresh(target?: DataTarget): void
|
||||||
|
}
|
||||||
|
|
||||||
|
const DataContext = createContext<DataContextValue>({
|
||||||
|
colorPresets: [],
|
||||||
|
ready: unimplemented,
|
||||||
|
refresh: unimplemented,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const DataContextProvider: React.FC = ({children}) => {
|
||||||
|
const [colorPresets, setColorPresets] = useState<ColorPreset[]>([]);
|
||||||
|
|
||||||
|
const [readyMap, setReadyMap] = useState<{ [key: string]: boolean }>({
|
||||||
|
[DataTarget.Default]: false,
|
||||||
|
[DataTarget.ColorPresets]: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const ready = useCallback((target: DataTarget = DataTarget.Default) => {
|
||||||
|
return readyMap[target] || false;
|
||||||
|
}, [readyMap]);
|
||||||
|
|
||||||
|
const refresh = useCallback((target: DataTarget = DataTarget.Default) => {
|
||||||
|
const refreshing: Promise<any>[] = [];
|
||||||
|
if ([DataTarget.Default, DataTarget.ColorPresets].includes(target)) {
|
||||||
|
refreshing.push(listColorPresets().then(setColorPresets));
|
||||||
|
}
|
||||||
|
|
||||||
|
Promise.all(refreshing).then(() => setReadyMap(prev => ({...prev, [target]: true})));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
refresh();
|
||||||
|
}, [refresh]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DataContext.Provider value={{
|
||||||
|
colorPresets,
|
||||||
|
ready, refresh,
|
||||||
|
}}>
|
||||||
|
{children}
|
||||||
|
</DataContext.Provider>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default DataContext;
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import React, {createContext, useCallback, useState} from "react";
|
||||||
|
import {DialogConfig, DialogType} from "../models/Dialog";
|
||||||
|
import {unimplemented} from "../helpers/misc";
|
||||||
|
import ColorDialog from "../dialogs/ColorDialog";
|
||||||
|
|
||||||
|
interface DialogContextValue {
|
||||||
|
dialog: DialogConfig,
|
||||||
|
setDialog: (dialog: DialogConfig) => void,
|
||||||
|
clearDialog: () => void,
|
||||||
|
}
|
||||||
|
|
||||||
|
const DialogContext = createContext<DialogContextValue>({
|
||||||
|
dialog: {type: DialogType.None},
|
||||||
|
setDialog: unimplemented,
|
||||||
|
clearDialog: unimplemented,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const DialogContextProvider: React.FC = ({children}) => {
|
||||||
|
const [dialog, setDialog] = useState<DialogConfig>({type: DialogType.None});
|
||||||
|
|
||||||
|
const clearDialog = useCallback(() => {
|
||||||
|
setDialog({type: DialogType.None});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DialogContext.Provider value={{dialog, setDialog, clearDialog}}>
|
||||||
|
{children}
|
||||||
|
</DialogContext.Provider>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export function makeDialog(dialog: DialogConfig) {
|
||||||
|
switch (dialog.type) {
|
||||||
|
case DialogType.None:
|
||||||
|
return undefined;
|
||||||
|
case DialogType.AddColor:
|
||||||
|
return <ColorDialog/>;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default DialogContext;
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
import React, {useCallback, useContext, useEffect, useState} from "react";
|
||||||
|
import {ColorPreset} from "../models/Colors";
|
||||||
|
import DialogContext from "../contexts/DialogContext";
|
||||||
|
import {DialogType} from "../models/Dialog";
|
||||||
|
import {Dialog} from "../primitives/Layout";
|
||||||
|
import {LuciferIcon} from "../models/Icons";
|
||||||
|
import {HSColorPicker, Input, KelvinColorPicker} from "../primitives/Forms";
|
||||||
|
|
||||||
|
interface ColorDialogProps {
|
||||||
|
data?: ColorPreset
|
||||||
|
}
|
||||||
|
|
||||||
|
enum ColorType {
|
||||||
|
HueSat,
|
||||||
|
Kelvin,
|
||||||
|
HueSatKelvin,
|
||||||
|
}
|
||||||
|
|
||||||
|
const COLOR_TYPE_OPTIONS = [
|
||||||
|
{value: ColorType.HueSat, name: "Bare Nyanse (H) og metning (S)"},
|
||||||
|
{value: ColorType.Kelvin, name: "Bare temperatur (K)"},
|
||||||
|
{value: ColorType.HueSatKelvin, name: "Nyanse (H), metning (K) og kelvin (S)"},
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function ColorDialog({data}: ColorDialogProps) {
|
||||||
|
const {setDialog, clearDialog} = useContext(DialogContext);
|
||||||
|
|
||||||
|
const [name, setName] = useState(data?.name || "");
|
||||||
|
const [hue, setHue] = useState(data?.value?.h || 0);
|
||||||
|
const [sat, setSat] = useState(data?.value?.s || 0);
|
||||||
|
const [kelvin, setKelvin] = useState(data?.value?.kelvin || 0);
|
||||||
|
|
||||||
|
const [colorType, setColorType] = useState(COLOR_TYPE_OPTIONS[0].value);
|
||||||
|
const [isInit, setIsInit] = useState(false);
|
||||||
|
|
||||||
|
const onSave = useCallback(() => {
|
||||||
|
setDialog({type: DialogType.None});
|
||||||
|
}, [setDialog]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isInit && data) {
|
||||||
|
const v = data.value;
|
||||||
|
|
||||||
|
if (v.kelvin !== undefined && v.kelvin > 0) {
|
||||||
|
setColorType((v.h || v.s) ? ColorType.HueSatKelvin : ColorType.Kelvin);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isInit) {
|
||||||
|
setIsInit(true);
|
||||||
|
}
|
||||||
|
}, [isInit, data, colorType]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog title={data ? "Endre farge" : "Ny farge"} buttons={[
|
||||||
|
{icon: LuciferIcon.Check, text: "Lagre", onClick: onSave},
|
||||||
|
{icon: LuciferIcon.Cross, text: "Avbryt", onClick: clearDialog},
|
||||||
|
]}>
|
||||||
|
<Input label="Navn" value={name} onChange={setName}/>
|
||||||
|
{colorType !== ColorType.Kelvin && (
|
||||||
|
<HSColorPicker h={hue} s={sat} onChange={(h, s) => {
|
||||||
|
setHue(h);
|
||||||
|
setSat(s);
|
||||||
|
}}/>
|
||||||
|
)}
|
||||||
|
{colorType !== ColorType.HueSat && (
|
||||||
|
<KelvinColorPicker kelvin={kelvin} onChange={setKelvin}/>
|
||||||
|
)}
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import {ColorValue} from "../models/Colors";
|
||||||
|
import iro from "@jaames/iro";
|
||||||
|
|
||||||
|
export function kelvinToColorValue(rawKelvin: number): ColorValue {
|
||||||
|
const kelvin = normalizeKelvin(rawKelvin);
|
||||||
|
|
||||||
|
return kelvinTable[kelvin];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function kelvinToRgbHex(rawKelvin: number): string {
|
||||||
|
const kelvin = normalizeKelvin(rawKelvin);
|
||||||
|
|
||||||
|
const c = new iro.Color();
|
||||||
|
c.kelvin = kelvin;
|
||||||
|
|
||||||
|
return c.hexString;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeKelvin(kelvin: number): number {
|
||||||
|
if (kelvin < 1000) {
|
||||||
|
return 1000;
|
||||||
|
} else if (kelvin > 10000) {
|
||||||
|
return 10000;
|
||||||
|
} else {
|
||||||
|
return kelvin - (kelvin % 100);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const kelvinTable: {[key: number]: ColorValue} = {};
|
||||||
|
for (let kelvin = 1000; kelvin <= 10000; kelvin += 100) {
|
||||||
|
const c = new iro.Color();
|
||||||
|
c.kelvin = kelvin;
|
||||||
|
|
||||||
|
const [h, s] = [c.hsv.h, (c.hsv.s as number) / 100];
|
||||||
|
|
||||||
|
kelvinTable[kelvin] = {h, s, kelvin};
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
export function unimplemented<T>(): T {
|
||||||
|
throw new Error("unimplemented");
|
||||||
|
}
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
import Axios, {AxiosError, AxiosRequestConfig, AxiosResponse} from "axios";
|
||||||
|
|
||||||
|
interface Response<T> {
|
||||||
|
code: number
|
||||||
|
message: string
|
||||||
|
data: T
|
||||||
|
}
|
||||||
|
|
||||||
|
export const authError = new Error("unauthenticated");
|
||||||
|
|
||||||
|
export async function getRequest<T>(url: string): Promise<T> {
|
||||||
|
const cors = url.includes("//");
|
||||||
|
|
||||||
|
return sendRequest<T>({
|
||||||
|
method: "GET",
|
||||||
|
url: cors ? url : `/api${url}`,
|
||||||
|
responseType: "json",
|
||||||
|
}, cors);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function postRequest<T>(url: string, data: object = {}): Promise<T> {
|
||||||
|
return sendRequest<T>({
|
||||||
|
method: "POST",
|
||||||
|
url: `/api${url}`,
|
||||||
|
responseType: "json",
|
||||||
|
data,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function putRequest<T>(url: string, data: object): Promise<T> {
|
||||||
|
return sendRequest<T>({
|
||||||
|
method: "PUT",
|
||||||
|
url: `/api${url}`,
|
||||||
|
responseType: "json",
|
||||||
|
data,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deleteRequest<T>(url: string): Promise<T> {
|
||||||
|
return sendRequest<T>({
|
||||||
|
method: "DELETE",
|
||||||
|
url: `/api${url}`,
|
||||||
|
responseType: "json",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sendRequest<T>(config: AxiosRequestConfig, cors: boolean = false): Promise<T> {
|
||||||
|
try {
|
||||||
|
const res = await Axios(config);
|
||||||
|
|
||||||
|
return await handleResponse(res, cors);
|
||||||
|
} catch (e) {
|
||||||
|
if (e === authError) {
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
|
||||||
|
return await handleResponse((e as AxiosError).response as AxiosResponse);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleResponse<T>(response: AxiosResponse, object: boolean = false): Promise<T> {
|
||||||
|
if (object) {
|
||||||
|
return Promise.resolve(response.data);
|
||||||
|
} else {
|
||||||
|
const obj: Response<T> | undefined = response?.data;
|
||||||
|
|
||||||
|
if (![200, 201].includes(response.status)) {
|
||||||
|
if (response.status === 403) {
|
||||||
|
return Promise.reject(authError);
|
||||||
|
} else {
|
||||||
|
return Promise.reject(obj?.message || "Ukjent feil");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!obj) {
|
||||||
|
return Promise.reject("Ingen data mottatt");
|
||||||
|
}
|
||||||
|
|
||||||
|
return Promise.resolve(obj.data);
|
||||||
|
}
|
||||||
|
}
|
||||||
+4
-1
@@ -3,10 +3,13 @@ import ReactDOM from 'react-dom';
|
|||||||
import './index.css';
|
import './index.css';
|
||||||
import App from './App';
|
import App from './App';
|
||||||
import reportWebVitals from "./reportWebVitals";
|
import reportWebVitals from "./reportWebVitals";
|
||||||
|
import {DataContextProvider} from "./contexts/DataContext";
|
||||||
|
|
||||||
ReactDOM.render(
|
ReactDOM.render(
|
||||||
<React.StrictMode>
|
<React.StrictMode>
|
||||||
<App />
|
<DataContextProvider>
|
||||||
|
<App/>
|
||||||
|
</DataContextProvider>
|
||||||
</React.StrictMode>,
|
</React.StrictMode>,
|
||||||
document.getElementById('root')
|
document.getElementById('root')
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
export interface ColorValue {
|
||||||
|
h?: number
|
||||||
|
s?: number
|
||||||
|
kelvin?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ColorPreset {
|
||||||
|
id: number
|
||||||
|
name: string
|
||||||
|
value: ColorValue
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
export enum DialogType {
|
||||||
|
None,
|
||||||
|
AddColor,
|
||||||
|
}
|
||||||
|
|
||||||
|
export type DialogConfig =
|
||||||
|
| { type: DialogType.None }
|
||||||
|
| { type: DialogType.AddColor }
|
||||||
|
;
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import bridgeIcon from "@iconify-icons/mdi/bridge";
|
||||||
|
import checkIcon from "@iconify-icons/mdi/check";
|
||||||
|
import crossIcon from "@iconify-icons/mdi/window-close";
|
||||||
|
import cogIcon from "@iconify-icons/mdi/cog";
|
||||||
|
import hexagonIcon from "@iconify-icons/mdi/hexagon";
|
||||||
|
import hexagonGroupIcon from "@iconify-icons/mdi/hexagon-multiple";
|
||||||
|
import lightBulbIcon from "@iconify-icons/mdi/lightbulb";
|
||||||
|
import lightBulbGroupIcon from "@iconify-icons/mdi/lightbulb-group";
|
||||||
|
import lightSwitchIcon from '@iconify-icons/mdi/light-switch';
|
||||||
|
import routerIcon from "@iconify-icons/mdi/router-wireless";
|
||||||
|
import squareIcon from "@iconify-icons/mdi/square-rounded";
|
||||||
|
import triangleIcon from "@iconify-icons/mdi/triangle";
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
// To add a new icon, follow the instructions on this page:
|
||||||
|
// https://iconify.design/icon-sets/mdi/
|
||||||
|
|
||||||
|
export enum LuciferIcon {
|
||||||
|
Bridge = "Bridge",
|
||||||
|
Check = "Check",
|
||||||
|
Cog = "Cog",
|
||||||
|
Cross = "Cross",
|
||||||
|
Bulb = "Bulb",
|
||||||
|
BulbGroup = "BulbGroup",
|
||||||
|
Hexagon = "Hexagon",
|
||||||
|
HexagonGroup = "HexagonGroup",
|
||||||
|
Router = "Router",
|
||||||
|
Square = "Square",
|
||||||
|
Switch = "Switch",
|
||||||
|
Triangle = "Triangle",
|
||||||
|
}
|
||||||
|
|
||||||
|
const iconMap = {
|
||||||
|
[LuciferIcon.Bridge]: bridgeIcon,
|
||||||
|
[LuciferIcon.Check]: checkIcon,
|
||||||
|
[LuciferIcon.Cog]: cogIcon,
|
||||||
|
[LuciferIcon.Cross]: crossIcon,
|
||||||
|
[LuciferIcon.Bulb]: lightBulbIcon,
|
||||||
|
[LuciferIcon.BulbGroup]: lightBulbGroupIcon,
|
||||||
|
[LuciferIcon.Hexagon]: hexagonIcon,
|
||||||
|
[LuciferIcon.HexagonGroup]: hexagonGroupIcon,
|
||||||
|
[LuciferIcon.Router]: routerIcon,
|
||||||
|
[LuciferIcon.Square]: squareIcon,
|
||||||
|
[LuciferIcon.Switch]: lightSwitchIcon,
|
||||||
|
[LuciferIcon.Triangle]: triangleIcon,
|
||||||
|
}
|
||||||
|
|
||||||
|
export function toIconify(icon: LuciferIcon): object {
|
||||||
|
return iconMap[icon];
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
export enum DataTarget {
|
||||||
|
Default = "",
|
||||||
|
ColorPresets = "ColorPresets",
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import React, {useContext, useEffect} from "react";
|
||||||
|
import {Page} from "../primitives/Layout";
|
||||||
|
import DataContext from "../contexts/DataContext";
|
||||||
|
import {IconElement} from "../primitives/Elements";
|
||||||
|
import DialogContext from "../contexts/DialogContext";
|
||||||
|
import {DialogType} from "../models/Dialog";
|
||||||
|
|
||||||
|
const SettingsPage: React.FC = () => {
|
||||||
|
const {setDialog} = useContext(DialogContext);
|
||||||
|
const {colorPresets} = useContext(DataContext);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Page title="Oppsett">
|
||||||
|
<h1>Definerte farger</h1>
|
||||||
|
{colorPresets.map(cp => (
|
||||||
|
<IconElement key={cp.id} color={cp.value} caption={cp.name}/>
|
||||||
|
))}
|
||||||
|
<button onClick={() => setDialog({type: DialogType.AddColor})}>Add</button>
|
||||||
|
</Page>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default SettingsPage;
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user