77 Commits
Author SHA1 Message Date
gisle d5cf30ea4b sensitize periodic light refresh in hue2 again.
continuous-integration/drone/push Build is passing
2022-02-19 20:43:38 +01:00
gisle 582bf3f46b decrease sensitivity in hue2 (should fix double press, regular check over-updating and massive update storms when regular checks find a lot of incongruences), and fix rare race condition
continuous-integration/drone/push Build is passing
2022-02-19 10:06:40 +01:00
gisle c72a7c3f43 fix xy color conversion using srgb instead of rgb.
continuous-integration/drone/push Build is passing
2022-02-18 17:01:37 +01:00
gisle 8a20bab709 reduce Hue2 'update storms'..
continuous-integration/drone/push Build is passing
2022-02-18 16:24:09 +01:00
gisle 27fd858c04 add device search.
continuous-integration/drone/push Build is passing
2022-02-17 21:58:03 +01:00
gisle d19a8e6ca0 add device rename and tweaks.
continuous-integration/drone/push Build is failing
2022-02-17 16:57:33 +01:00
gisle 107b29620d it has the trappings of a driver now, but essential features are missing: discovery and forgetting
continuous-integration/drone/push Build is passing
2022-02-15 22:31:51 +01:00
gisle 73e6cba770 add very partial hue2 driver wip.
continuous-integration/drone/push Build is passing
2022-02-14 22:03:41 +01:00
gisle 44bd327a71 add index for name in scene table to force uniqueness. 2022-02-14 22:03:28 +01:00
gisle cedd0d1d28 add support for invoking and assigning scenes by name.
continuous-integration/drone/push Build is passing
2022-02-12 11:36:23 +01:00
gisle 7471102413 add scene list subcommand.
continuous-integration/drone/push Build is passing
2022-02-10 20:46:05 +01:00
gisle bf4d000455 add info mode to lucy list command. 2022-02-10 20:38:10 +01:00
gisle 6947fc3461 add delete light endpoint and forget for Hue.
continuous-integration/drone/push Build is passing
2022-01-31 23:09:00 +01:00
gisle 600c90a614 undo group nonsense in hue bridge.
continuous-integration/drone/push Build is passing
2022-01-28 23:01:44 +01:00
Stian Fredrik Aune a08685c3cd Location, location, location.
continuous-integration/drone Build is passing
2022-01-21 19:26:06 +01:00
Stian Fredrik Aune 15536c6078 lucy factory - sensor support 2022-01-20 22:07:04 +01:00
Stian Fredrik Aune 2bb2154e5b SceneDimmer bug 2022-01-12 17:23:56 +01:00
Stian Fredrik Aune 8c2ef2dac3 factory command 2022-01-12 17:01:56 +01:00
Stian Fredrik Aune f4e584938b enable 'lucy handler delete all' 2022-01-08 21:02:35 +01:00
Stian Fredrik Aune 8c9b47aa1d Add temperature to API.
continuous-integration/drone/push Build is passing
2021-11-07 19:03:42 +01:00
Stian Fredrik Aune 5bd7a7f6b6 cleanup and fix
continuous-integration/drone/push Build is passing
2021-11-07 18:56:21 +01:00
Stian Fredrik Aune 88bd4dc073 Mill. Take 1.
continuous-integration/drone/push Build is failing
2021-11-07 18:38:32 +01:00
gisle 6443e0ecd3 any/all.scene=-1 now checks for no scene.
continuous-integration/drone/push Build is passing
2021-10-31 09:51:06 +01:00
gisle 147231e4fd add sensor scenes for Motion and Temperature.
continuous-integration/drone/push Build is passing
2021-10-30 14:38:14 +02:00
gisle b191455753 optimize Hue light updates by using temporary groups.
continuous-integration/drone/push Build is passing
2021-10-30 12:05:56 +02:00
gisle ba22187e6e feature-scenesystem (#1)
continuous-integration/drone/push Build is passing
Co-authored-by: Gisle Aune <dev@gisle.me>
Reviewed-on: #1
Co-authored-by: gisle <gisle@hidden-email>
Co-committed-by: gisle <gisle@hidden-email>
2021-10-06 18:13:16 +00:00
Stian Fredrik Aune d91bd472dd Merge branch 'asmodeus' of git.aiterp.net:lucifer/new-server into asmodeus
continuous-integration/drone/push Build is failing
continuous-integration/drone Build is passing
continuous-integration/drone/tag Build is passing
2021-10-03 19:10:06 +02:00
Stian Fredrik Aune 251687b85d Edit. 2021-10-03 19:10:02 +02:00
gisle ade49589a9 add another test and fix local.
continuous-integration/drone/push Build is failing
2021-10-03 18:35:04 +02:00
gisle ad2f912a6f change presence events, fix non-eq conditions.
continuous-integration/drone/push Build is failing
2021-10-02 17:34:44 +02:00
gisle 2abb4b1024 add time-of-day limitation to events.
continuous-integration/drone/push Build is failing
2021-10-02 14:43:15 +02:00
gisle b83673b2cc change nanoleaf default names to start at 1 instead of 0.
continuous-integration/drone/push Build is failing
2021-09-30 23:08:33 +02:00
gisle 6fe0d8b864 adapt Nanoleaf driver to Canvas.
continuous-integration/drone/push Build is failing
2021-09-30 23:07:11 +02:00
Stian Fredrik Aune c7d7e264ca Lucy handler add 2021-09-28 21:54:03 +02:00
gisle 0cd325e8db add SaveMode for device which can be 0 (All) or a bitmask for what to save. It did not improve performance
continuous-integration/drone/push Build is passing
2021-09-26 10:50:54 +02:00
Stian Fredrik Aune b1fa52e53f lucy handler delete
continuous-integration/drone/push Build is passing
2021-09-25 19:03:11 +02:00
Stian Fredrik Aune b28496fd6b less focus on oneshot
continuous-integration/drone/push Build is passing
2021-09-25 17:27:56 +02:00
Stian Fredrik Aune 35c3a387c9 lucy handler list
continuous-integration/drone/push Build is passing
2021-09-25 17:26:27 +02:00
Stian Fredrik Aune b7b6b06748 .
continuous-integration/drone/push Build is passing
2021-09-24 19:51:56 +02:00
gisle 27f8c6b91d fix not being able to setOneShot in event updates. 2021-09-24 18:05:51 +02:00
gisle 0846290047 >_> 2021-09-24 17:14:27 +02:00
gisle 9b9e5e9da8 add lucy config. 2021-09-24 17:12:27 +02:00
gisle 3c2aa7212d add replace of * to % in fetch. 2021-09-24 17:03:25 +02:00
gisle d6c38d647f fix cli app help. 2021-09-24 16:59:14 +02:00
gisle 06fa73b916 add beginning of CLI app and some backend fixes. 2021-09-24 16:58:24 +02:00
gisle 7a6ee3f87b add device search endpoint. >_> 2021-09-21 20:55:13 +02:00
gisle d623647e21 fix device fetch returning duplicate results if overlapping tags are supplied. 2021-09-21 17:54:10 +02:00
gisle 4fd49d0480 add filtering to tags. E.g. Hue,Namrom+Gisle will return all with either of the former AND the latter. - will do the opposite. 2021-09-21 17:51:28 +02:00
gisle 38d777404b fix 'all.*' conditions counting 'incapable' devices like sensors. 2021-09-20 22:37:41 +02:00
gisle 2f9bd3c007 fixed hue updating too often, and change logic for fetchbyreference to support multiple tags, ids or bridges. 2021-09-20 22:09:41 +02:00
gisle 56a0525b9d add event handler API and logic. 2021-09-20 21:05:24 +02:00
gisle 1cf8c019e5 add EventHandlerRepo (probably has bugs) 2021-09-20 18:31:32 +02:00
gisle b2c9cca400 finish EventHandler model for now. 2021-09-20 18:10:18 +02:00
gisle 4109d41bec fix bug with empty buttons array being mistaken as [], and add /api/devices/batch. 2021-09-18 18:01:16 +02:00
gisle 616adc14a4 cancel 2021-09-18 16:14:12 +02:00
gisle 26fcc1f314 small fixed, device repo and some device api 2021-09-18 16:08:57 +02:00
gisle 4e734c86a5 Merge branch 'asmodeus' of git.aiterp.net:lucifer/new-server into asmodeus 2021-07-12 20:26:55 +02:00
gisle 766c534fcb add HorribleBroadcast™ functionality to LifX driver. 2021-07-12 20:26:50 +02:00
Stian Fredrik Aune 79fc2a319c wip form 2021-07-03 23:24:09 +02:00
Stian Fredrik Aune e9bfbfbec7 wip: colors 2021-07-02 08:58:09 +02:00
Stian Fredrik Aune b2375e5864 wip colors 2021-06-13 12:47:08 +02:00
Stian Fredrik Aune 22c80a90a1 dialogs sorta work now 2021-06-10 21:48:09 +02:00
gisle 95ec918ed1 add lifx driver. It works with the bulb, but the code is a bit smelly. 2021-06-07 19:46:02 +02:00
gisle fff01befda fixed bug in hue driver where it would update either all or no bulbs. 2021-06-06 15:22:56 +02:00
Stian Fredrik Aune ea3037b03a started on icons 2021-06-05 18:31:21 +02:00
gisle 677d9aa9b1 add support for hue temp sensor, fix saturation not working for hue bulbs. 2021-06-04 09:57:21 +02:00
gisle 5cdb4f3cf5 fix hue light/sensor search and add a separate SensorPresenceEnding event. 2021-06-03 23:30:23 +02:00
gisle 74fb2c6b5a fix oops in bridgetest. 2021-05-29 16:58:44 +02:00
gisle 2aa2757828 add rest of hue driver functionality. 2021-05-29 16:29:10 +02:00
Stian Fredrik Aune 38d370894e webui stuff 2021-05-24 13:02:52 +02:00
Stian Fredrik Aune a2e719def9 Bridge events 2021-05-24 10:31:31 +02:00
gisle 883115bf8b Merge branch 'asmodeus' of git.aiterp.net:lucifer/new-server into asmodeus 2021-05-24 08:30:36 +02:00
gisle 954d6bd3e1 add state restore after Nanoleaf controller reboots. 2021-05-24 08:30:32 +02:00
Stian Fredrik Aune e1f38e3dcd color picker component 2021-05-23 23:51:31 +02:00
Stian Fredrik Aune f045bc0f4d color api 2021-05-23 22:22:15 +02:00
Stian Fredrik Aune e4078de845 driver in bridgetest 2021-05-23 15:08:37 +02:00
gisle d6fdb48023 mark Nanoleaf panels for update if updateEffect changes th effect. 2021-05-23 14:44:38 +02:00
123 changed files with 11949 additions and 12201 deletions
+31
View File
@@ -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
+7
View File
@@ -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
View File
@@ -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 {
+333
View File
@@ -0,0 +1,333 @@
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"
"strconv"
"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
}
var scene *models.Scene
if body.SceneName != "" {
scene, err = config.SceneRepository().FindName(ctxOf(c), body.SceneName)
if err != nil {
return nil, err
}
if body.DurationMS < 0 {
body.DurationMS = 0
}
body.StartTime = time.Now()
body.SceneID = scene.ID
} else {
scene, 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()
body.SceneName = scene.Name
}
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
}))
r.DELETE("/:fetch", handler(func(c *gin.Context) (interface{}, error) {
id, err := strconv.Atoi(c.Param("fetch"))
if err != nil {
return nil, err
}
device, err := config.DeviceRepository().Find(ctxOf(c), id)
if err != nil {
return nil, err
}
if c.Query("forgetmenot") != "true" {
bridge, err := config.BridgeRepository().Find(ctxOf(c), device.BridgeID)
if err != nil {
return nil, err
}
driver, err := config.DriverProvider().Provide(bridge.Driver)
if err != nil {
return nil, err
}
forgettableDriver, ok := driver.(models.ForgettableDriver)
if !ok {
return nil, models.ErrCannotForget
}
err = forgettableDriver.ForgetDevice(ctxOf(c), bridge, *device)
if err != nil {
return nil, err
}
}
err = config.DeviceRepository().Delete(ctxOf(c), device)
if err != nil {
return nil, err
}
return device, 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
}
+82
View File
@@ -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
}))
}
+97
View File
@@ -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
}))
}
+80
View File
@@ -0,0 +1,80 @@
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"
"regexp"
)
func Scenes(r gin.IRoutes) {
nonNumericRegex := regexp.MustCompile("[a-zA-Z ]")
findScene := func(c *gin.Context) (*models.Scene, error) {
param := c.Param("id")
if nonNumericRegex.MatchString(param) {
return config.SceneRepository().FindName(ctxOf(c), param)
} else {
return config.SceneRepository().Find(ctxOf(c), intParam(c, "id"))
}
}
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 findScene(c)
}))
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 := findScene(c)
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
}))
}
+7
View File
@@ -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 {
+161
View File
@@ -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,
}
+57
View File
@@ -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
}
+16
View File
@@ -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
}
+4
View File
@@ -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
+20 -4
View File
@@ -1,13 +1,29 @@
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/hue2"
"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.DTHue2: &hue2.Driver{},
models.DTLIFX: &lifx.Driver{},
models.DTMill: &mill.Driver{},
}
})
return dp return dp
} }
+18 -1
View File
@@ -1,17 +1,34 @@
package config package config
import ( import (
"log"
"os" "os"
"strconv" "strconv"
"time"
) )
func Location() *time.Location {
env, ok := os.LookupEnv("LUCIFER_LOCATION")
if !ok {
return time.UTC
}
loc, err := time.LoadLocation(env)
if err != nil {
log.Printf("Location lookup failed: %s (%s)", env, err.Error())
return time.UTC
}
return loc
}
func MySqlHost() string { return strEnv("LUCIFER_MYSQL_HOST") } func MySqlHost() string { return strEnv("LUCIFER_MYSQL_HOST") }
func MySqlPort() int { return intEnv("LUCIFER_MYSQL_PORT") } func MySqlPort() int { return intEnv("LUCIFER_MYSQL_PORT") }
func MySqlSchema() string { return strEnv("LUCIFER_MYSQL_SCHEMA") } func MySqlSchema() string { return strEnv("LUCIFER_MYSQL_SCHEMA") }
func MySqlUsername() string { return strEnv("LUCIFER_MYSQL_USERNAME") } func MySqlUsername() string { return strEnv("LUCIFER_MYSQL_USERNAME") }
func MySQlPassword() string { return strEnv("LUCIFER_MYSQL_PASSWORD") } func MySQlPassword() string { return strEnv("LUCIFER_MYSQL_PASSWORD") }
func ServerPort() int { return intEnv("LUCIFER_SERVER_PORT") } func ServerPort() int { return intEnv("LUCIFER_SERVER_PORT") }
func strEnv(key string) string { func strEnv(key string) string {
env, ok := os.LookupEnv(key) env, ok := os.LookupEnv(key)
+9 -19
View File
@@ -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()}
} }
+21
View File
@@ -1,24 +1,45 @@
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"
"git.aiterp.net/lucifer/new-server/models"
"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"))
models.TimeOfDayTimeZone = config.Location()
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())))
} }
+66
View File
@@ -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
View File
@@ -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
}
}
+62
View File
@@ -0,0 +1,62 @@
package handlerfactory
import (
"git.aiterp.net/lucifer/new-server/models"
"gopkg.in/yaml.v2"
"io"
"io/ioutil"
"strings"
)
type FactoryConfig struct {
Mode FCMOde `yaml:"mode"`
Target string `yaml:"target"`
SceneNames []string `yaml:"scenes"`
TriggerDeviceID string `yaml:"trigger_device_id"`
After []After `yaml:"after"`
From string `yaml:"from"`
To string `yaml:"to"`
}
type FCMOde string
var (
SceneDimmer FCMOde = "SceneDimmer"
MotionSensor FCMOde = "MotionSensor"
)
type After struct {
Minutes int `yaml:"minutes"`
NewState models.NewDeviceState `yaml:"new_state"`
}
func ParseConfigs(reader io.Reader) ([]FactoryConfig, error) {
readAll, err := ioutil.ReadAll(reader)
if err != nil {
return nil, err
}
splits := strings.Split(string(readAll), "\n---\n")
configs := make([]FactoryConfig, 0, 8)
for _, split := range splits {
var factoryConfig FactoryConfig
err := yaml.NewDecoder(strings.NewReader(split)).Decode(&factoryConfig)
if err != nil {
return nil, err
}
configs = append(configs, factoryConfig)
}
return configs, nil
}
func (f *FactoryConfig) FromTime() models.TimeOfDay {
tod, _ := models.ParseTimeOfDay(f.From)
return tod
}
func (f *FactoryConfig) ToTime() models.TimeOfDay {
tod, _ := models.ParseTimeOfDay(f.To)
return tod
}
+235
View File
@@ -0,0 +1,235 @@
package handlerfactory
import (
"fmt"
"git.aiterp.net/lucifer/new-server/models"
)
func CreateHandlers(
config FactoryConfig,
scenes []models.Scene,
existing []models.EventHandler,
) ([]models.EventHandler, []models.EventHandler) {
switch config.Mode {
case SceneDimmer:
return sceneDimmer(config, scenes, existing)
case MotionSensor:
return motionSensor(config, existing)
default:
return []models.EventHandler{}, []models.EventHandler{}
}
}
func sceneDimmer(
config FactoryConfig,
scenes []models.Scene,
existing []models.EventHandler,
) ([]models.EventHandler, []models.EventHandler) {
kind, refValue := models.ParseFetchString(config.Target)
toRemove := make([]models.EventHandler, 0, 8)
for _, old := range existing {
if old.Conditions["deviceId"].EQ == config.TriggerDeviceID {
toRemove = append(toRemove, old)
}
}
resolvedScenes := make([]models.Scene, 0, len(config.SceneNames))
for _, name := range config.SceneNames {
for _, scene := range scenes {
if name == scene.Name {
resolvedScenes = append(resolvedScenes, scene)
break
}
}
}
if len(scenes) < 2 {
return []models.EventHandler{}, []models.EventHandler{}
}
handlers := make([]models.EventHandler, 0, len(scenes)+4)
handlers = append(handlers, models.EventHandler{
EventName: "ButtonPressed",
Conditions: map[string]models.EventCondition{
"deviceId": {EQ: config.TriggerDeviceID},
"buttonName": {EQ: "On"},
},
OneShot: false,
Priority: 100,
TargetKind: kind,
TargetValue: refValue,
Actions: models.EventAction{SetPower: &myTrue},
From: config.FromTime(),
To: config.ToTime(),
})
handlers = append(handlers, models.EventHandler{
EventName: "ButtonPressed",
Conditions: map[string]models.EventCondition{
"deviceId": {EQ: config.TriggerDeviceID},
"buttonName": {EQ: "Off"},
},
OneShot: false,
Priority: 100,
TargetKind: kind,
TargetValue: refValue,
Actions: models.EventAction{SetPower: &myFalse},
From: config.FromTime(),
To: config.ToTime(),
})
for i, scene := range resolvedScenes {
if i > 0 {
handlers = append(handlers, models.EventHandler{
EventName: "ButtonPressed",
Conditions: map[string]models.EventCondition{
"deviceId": {EQ: config.TriggerDeviceID},
"buttonName": {EQ: "DimDown"},
"any.scene": {EQ: fmt.Sprintf("%d", scene.ID)},
},
OneShot: false,
Priority: 100,
TargetKind: kind,
TargetValue: refValue,
Actions: models.EventAction{SetScene: &models.DeviceSceneAssignment{
SceneID: resolvedScenes[i-1].ID,
Group: refValue,
DurationMS: 0,
}},
From: config.FromTime(),
To: config.ToTime(),
})
}
if i < len(resolvedScenes)-1 {
handlers = append(handlers, models.EventHandler{
EventName: "ButtonPressed",
Conditions: map[string]models.EventCondition{
"deviceId": {EQ: config.TriggerDeviceID},
"buttonName": {EQ: "DimUp"},
"any.scene": {EQ: fmt.Sprintf("%d", scene.ID)},
},
OneShot: false,
Priority: 100,
TargetKind: kind,
TargetValue: refValue,
Actions: models.EventAction{SetScene: &models.DeviceSceneAssignment{
SceneID: resolvedScenes[i+1].ID,
Group: refValue,
DurationMS: 0,
}},
From: config.FromTime(),
To: config.ToTime(),
})
}
}
handlers = append(handlers, models.EventHandler{
EventName: "ButtonPressed",
Conditions: map[string]models.EventCondition{
"deviceId": {EQ: config.TriggerDeviceID},
"buttonName": {EQ: "DimDown"},
},
OneShot: false,
Priority: 90,
TargetKind: kind,
TargetValue: refValue,
Actions: models.EventAction{SetScene: &models.DeviceSceneAssignment{
SceneID: resolvedScenes[len(resolvedScenes)-1].ID,
Group: refValue,
DurationMS: 0,
}},
From: config.FromTime(),
To: config.ToTime(),
})
handlers = append(handlers, models.EventHandler{
EventName: "ButtonPressed",
Conditions: map[string]models.EventCondition{
"deviceId": {EQ: config.TriggerDeviceID},
"buttonName": {EQ: "DimUp"},
},
OneShot: false,
Priority: 90,
TargetKind: kind,
TargetValue: refValue,
Actions: models.EventAction{SetScene: &models.DeviceSceneAssignment{
SceneID: resolvedScenes[0].ID,
Group: refValue,
DurationMS: 0,
}},
From: config.FromTime(),
To: config.ToTime(),
})
return optimizeLists(handlers, toRemove)
}
func motionSensor(
config FactoryConfig,
existing []models.EventHandler,
) ([]models.EventHandler, []models.EventHandler) {
kind, refValue := models.ParseFetchString(config.Target)
toRemove := make([]models.EventHandler, 0, 8)
for _, old := range existing {
if old.From != config.FromTime() || old.To != config.ToTime() {
continue
}
if old.Conditions["deviceId"].EQ == config.TriggerDeviceID {
toRemove = append(toRemove, old)
}
}
handlers := make([]models.EventHandler, 0, len(config.After))
for _, after := range config.After {
if after.Minutes == 0 {
handlers = append(handlers, models.EventHandler{
EventName: "SensorPresenceStarted",
Conditions: map[string]models.EventCondition{
"deviceId": {EQ: config.TriggerDeviceID},
},
OneShot: false,
Priority: 100,
TargetKind: kind,
TargetValue: refValue,
Actions: models.EventAction{
SetPower: after.NewState.Power,
SetColor: after.NewState.Color,
SetIntensity: after.NewState.Intensity,
SetTemperature: after.NewState.Temperature,
},
From: config.FromTime(),
To: config.ToTime(),
})
} else {
handlers = append(handlers, models.EventHandler{
EventName: "SensorPresenceEnded",
Conditions: map[string]models.EventCondition{
"deviceId": {EQ: config.TriggerDeviceID},
"minutesElapsed": {EQ: fmt.Sprintf("%d", after.Minutes)},
},
OneShot: false,
Priority: 100,
TargetKind: kind,
TargetValue: refValue,
Actions: models.EventAction{
SetPower: after.NewState.Power,
SetColor: after.NewState.Color,
SetIntensity: after.NewState.Intensity,
SetTemperature: after.NewState.Temperature,
},
From: config.FromTime(),
To: config.ToTime(),
})
}
}
return optimizeLists(handlers, toRemove)
}
var myTrue = true
var myFalse = false
+72
View File
@@ -0,0 +1,72 @@
package handlerfactory
import (
"git.aiterp.net/lucifer/new-server/models"
)
func optimizeLists(
add []models.EventHandler,
del []models.EventHandler,
) ([]models.EventHandler, []models.EventHandler) {
newAdd := make([]models.EventHandler, 0, len(add))
newDel := make([]models.EventHandler, 0, len(del))
OptimizeLoop1:
for _, addItem := range add {
for _, delItem := range del {
if checkDuplicate(&addItem, &delItem) {
continue OptimizeLoop1
}
}
newAdd = append(newAdd, addItem)
}
OptimizeLoop2:
for _, delItem := range del {
for _, addItem := range add {
if checkDuplicate(&addItem, &delItem) {
continue OptimizeLoop2
}
}
newDel = append(newDel, delItem)
}
return newAdd, newDel
}
func checkDuplicate(a *models.EventHandler, b *models.EventHandler) bool {
check := true
check = check && a.EventName == b.EventName
check = check && len(a.Conditions) == len(b.Conditions)
for k := range a.Conditions {
check = check && a.Conditions[k] == b.Conditions[k]
}
check = check && a.OneShot == b.OneShot
check = check && a.Priority == b.Priority
check = check && a.TargetKind == b.TargetKind
check = check && a.TargetValue == b.TargetValue
check = check && a.From == b.From
check = check && a.To == b.To
aPower := a.Actions.SetPower
bPower := b.Actions.SetPower
powerEq := (aPower == nil && bPower == nil) || (aPower != nil && bPower != nil && *aPower == *bPower)
aColor := a.Actions.SetColor
bColor := b.Actions.SetColor
colorEq := (aColor == nil && bColor == nil) || (aColor != nil && bColor != nil && *aColor == *bColor)
aInt := a.Actions.SetIntensity
bInt := b.Actions.SetIntensity
intEq := (aInt == nil && bInt == nil) || (aInt != nil && bInt != nil && *aInt == *bInt)
aTemp := a.Actions.SetTemperature
bTemp := b.Actions.SetTemperature
tempEq := (aTemp == nil && bTemp == nil) || (aTemp != nil && bTemp != nil && *aTemp == *bTemp)
check = check && powerEq && colorEq && intEq && tempEq
return check
}
-47
View File
@@ -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
}
}
}()
}
+345
View File
@@ -0,0 +1,345 @@
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
}
unwaitMap := make(map[int]bool)
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)
unwaitMap[device.BridgeID] = true
}
}
for bridgeID := range unwaitMap {
if p.waiting[bridgeID] != nil {
close(p.waiting[bridgeID])
p.waiting[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)
unwaitMap := make(map[int]bool)
for range ticker.C {
deleteList = deleteList[:0]
updatedList = updatedList[:0]
for key := range unwaitMap {
delete(unwaitMap, key)
}
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)
unwaitMap[device.BridgeID] = true
}
for bridgeID := range unwaitMap {
if p.waiting[bridgeID] != nil {
close(p.waiting[bridgeID])
p.waiting[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 := append(p.pending[id][:0:0], p.pending[id]...)
p.pending[id] = p.pending[id][: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
}
+225
View File
@@ -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
}
+95
View File
@@ -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
View File
@@ -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
View File
@@ -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)
} }
+277
View File
@@ -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] = &param.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
}
+31
View File
@@ -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
}
+75
View File
@@ -0,0 +1,75 @@
package main
import (
"context"
"fmt"
"git.aiterp.net/lucifer/new-server/app/client"
"git.aiterp.net/lucifer/new-server/app/services/handlerfactory"
"log"
"os"
)
func factoryCmd(
ctx context.Context,
c client.Client,
) {
cmd := parseCommand(os.Args[2:])
switch cmd.Name {
case "run":
fileName := cmd.Params.Get(0).String()
if fileName == nil {
log.Fatalln("Missing filename")
}
scenes, err := c.GetScenes(ctx)
if err != nil {
log.Fatalln(err.Error())
}
handlers, err := c.GetHandlers(ctx)
if err != nil {
log.Fatalln(err.Error())
}
file, err := os.Open(*fileName)
if err != nil {
log.Fatalln("Failed to open file:", err)
}
defer file.Close()
configs, err := handlerfactory.ParseConfigs(file)
if err != nil {
log.Fatalln("Failed to parse file:", err)
}
for _, config := range configs {
toAdd, toRemove := handlerfactory.CreateHandlers(config, scenes, handlers)
for _, removing := range toRemove {
_, err := c.DeleteHandler(ctx, removing.ID)
if err != nil {
log.Fatalln(err.Error())
}
log.Printf("Removed handler %d", removing.ID)
}
for _, adding := range toAdd {
res, err := c.AddHandler(ctx, adding)
if err != nil {
log.Fatalln(err.Error())
}
log.Printf("Added handler %d", res.ID)
}
}
default:
if cmd.Name != "help" {
log.Println("Unknown command:", cmd.Name)
}
_, _ = fmt.Fprintln(os.Stderr, helpString[1:])
}
}
+202
View File
@@ -0,0 +1,202 @@
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":
if cmd.Params.Get(0).StringOr("_not") == "all" {
handlers, err := c.GetHandlers(ctx)
if err != nil {
log.Fatalln(err)
}
for _, handler := range handlers {
_, err = c.DeleteHandler(ctx, handler.ID)
if err != nil {
log.Fatalln(err)
}
}
log.Printf("Deleted %d handlers", len(handlers))
} else {
id := cmd.Params.Get(0).Int()
if id == nil {
log.Fatalln("ID missing")
}
handler, err := c.DeleteHandler(ctx, *id)
if err != nil {
log.Fatalln(err)
}
WriteHandlerInfoTable(os.Stdout, []models.EventHandler{*handler})
}
default:
if cmd.Name != "help" {
log.Println("Unknown command:", cmd.Name)
}
_, _ = 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
model.Actions.SetTemperature = nds.Temperature
// 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
}
+71
View File
@@ -0,0 +1,71 @@
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 HANDLER FACTORY COMMANDS
factory run <file.yaml>
File example (use '---' to delimit multiple configs in one file):
mode: SceneDimmer
target: tag:Soverom
scenes:
- kveldslys
- landskap
- vasking
trigger_device_id: 14
---
mode: MotionSensor
target: tag:Bod
trigger_device_id: 36
after:
- minutes: 0
new_state:
power: true
color: hs:240,0.6
intensity: 0.8
- minutes: 1
new_state:
power: false
from: 08:00
to: 15:59:59
---
(more modes coming soon)
WARNING: Similar event handlers will be removed!
EVENT COMMANDS
run <event name> <*=S>
NOTE: You may have to surround arguments including < or > with quotation marks!
`
+157
View File
@@ -0,0 +1,157 @@
package main
import (
"encoding/json"
"fmt"
"git.aiterp.net/lucifer/new-server/models"
"io"
"sort"
"strings"
"time"
)
func WriteDeviceInfo(w io.Writer, devices []models.Device, fields []string) {
if len(fields) == 1 && fields[0] == "all" {
fields = allFields
} else {
fields = append(fields[:0:0], fields...)
for i, field := range fields {
if aliased, ok := fieldAliases[field]; ok {
fields[i] = aliased
}
}
}
length := 0
for _, field := range fields {
if mlFields[field] {
continue
}
if len(field) > length {
length = len(field)
}
}
for i, device := range devices {
if i > 0 {
fmt.Println()
}
for _, field := range fields {
if formatter, ok := fieldFormatters[field]; ok {
_, _ = fmt.Fprintf(w, "%-*s %s\n", length+1, field+":", formatter(&device))
} else {
_, _ = fmt.Fprintf(w, "%-*s (unknown field)\n", length+1, field+":")
}
}
}
}
var allFields = []string{
"id", "name", "icon", "bridgeId", "tags", "buttonNames",
"power", "color", "intensity", "temperature",
"internalId", "sceneAssignment", "userProperties", "driverProperties",
}
var mlFields = map[string]bool{
"sceneAssignment": true,
"userProperties": true,
"driverProperties": true,
}
var fieldFormatters = map[string]func(d *models.Device) string{
"id": func(d *models.Device) string { return fmt.Sprint(d.ID) },
"internalId": func(d *models.Device) string { return d.InternalID },
"name": func(d *models.Device) string { return d.Name },
"icon": func(d *models.Device) string { return d.Icon },
"bridgeId": func(d *models.Device) string { return fmt.Sprint(d.BridgeID) },
"tags": func(d *models.Device) string { return strings.Join(d.Tags, ", ") },
"buttonNames": func(d *models.Device) string { return strings.Join(d.ButtonNames, ", ") },
"power": func(d *models.Device) string { return fmt.Sprint(d.State.Power) },
"color": func(d *models.Device) string { return d.State.Color.String() },
"intensity": func(d *models.Device) string { return fmt.Sprint(d.State.Intensity) },
"temperature": func(d *models.Device) string { return fmt.Sprint(d.State.Temperature) },
"sceneAssignment": func(d *models.Device) string {
str := &strings.Builder{}
str.Grow(64)
for _, scene := range d.SceneAssignments {
_, _ = fmt.Fprintf(str, "\n - id: %d\n", scene.SceneID)
_, _ = fmt.Fprintf(str, " duration: %d\n", scene.DurationMS)
_, _ = fmt.Fprintf(str, " group: %s\n", scene.Group)
_, _ = fmt.Fprintf(str, " startTime: %s", scene.StartTime.Format(time.RFC3339))
}
if len(d.SceneAssignments) == 0 {
str.Reset()
str.WriteString("(none)")
}
return str.String()
},
"userProperties": func(d *models.Device) string {
str := &strings.Builder{}
str.Grow(64)
str.WriteString("\n")
keys := make([]string, 0, len(d.UserProperties))
for key := range d.UserProperties {
keys = append(keys, key)
}
sort.Strings(keys)
for _, key := range keys {
_, _ = fmt.Fprintf(str, " %s: %s\n", key, d.UserProperties[key])
}
if len(d.UserProperties) == 0 {
str.Reset()
str.WriteString("(none)")
}
return str.String()
},
"driverProperties": func(d *models.Device) string {
str := &strings.Builder{}
str.Grow(256)
str.WriteString("\n")
keys := make([]string, 0, len(d.DriverProperties))
for key := range d.DriverProperties {
keys = append(keys, key)
}
sort.Strings(keys)
for _, key := range keys {
j, _ := json.Marshal(d.DriverProperties[key])
_, _ = fmt.Fprintf(str, " %s: %s\n", key, string(j))
}
if len(d.DriverProperties) == 0 {
str.Reset()
str.WriteString("(none)")
}
return str.String()
},
"capabilities": func(d *models.Device) string {
capStr := make([]string, len(d.Capabilities))
for i := range d.Capabilities {
capStr[i] = string(d.Capabilities[i])
}
return strings.Join(capStr, ", ")
},
}
var fieldAliases = map[string]string{
"bridge": "bridgeId",
"buttons": "buttonNames",
"internal": "internalId",
"caps": "capabilities",
"cap": "capabilities",
"userprops": "userProperties",
"uprops": "userProperties",
"driverprops": "driverProperties",
"dprops": "driverProperties",
"internalid": "internalId",
"bridgeid": "bridgeId",
"buttonnames": "buttonNames",
"scene": "sceneAssignment",
}
+131
View File
@@ -0,0 +1,131 @@
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 info := cmd.Params.Get("info").String(); info != nil {
WriteDeviceInfo(os.Stdout, devices, strings.Split(*info, ","))
} 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 "factory":
factoryCmd(ctx, c)
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:])
}
}
+182
View File
@@ -0,0 +1,182 @@
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 "list":
{
scenes, err := c.GetScenes(ctx)
if err != nil {
log.Fatalln("Failed to load scenes:", err)
}
WriteSceneTable(os.Stdout, scenes)
}
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()
name := cmd.Params.Get(1).String()
if fetch == nil || (id == nil && name == nil) {
log.Println("Usage: lucy scene assign <fetch> <id|name> <group=S> <duration=I>")
}
assignment := models.DeviceSceneAssignment{
Group: cmd.Params.Get("group").StringOr(*fetch),
DurationMS: int64(cmd.Params.Get("duration").IntOr(0)),
}
if id != nil {
assignment.SceneID = *id
} else {
assignment.SceneName = *name
}
devices, err := c.AssignDevice(ctx, *fetch, cmd.Name == "push", assignment)
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
}
+173
View File
@@ -0,0 +1,173 @@
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 WriteSceneTable(w io.Writer, scenes []models.Scene) {
table := tablewriter.NewWriter(w)
table.SetHeader([]string{"ID", "NAME", "ROLES", "INTERVAL"})
table.SetReflowDuringAutoWrap(false)
for _, v := range scenes {
table.Append([]string{
fmt.Sprint(v.ID),
v.Name,
fmt.Sprint(len(v.Roles)),
fmt.Sprint(v.IntervalMS),
})
}
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()
}
+49
View File
@@ -0,0 +1,49 @@
package main
import (
"context"
"encoding/json"
"fmt"
"git.aiterp.net/lucifer/new-server/internal/drivers/hue2"
"git.aiterp.net/lucifer/new-server/models"
"log"
)
func main() {
client := hue2.NewClient("10.80.8.8", "o2XKGgmVUGNBghYFdLUCVuinOTMxFH4pHV9PuTbU")
bridge := hue2.NewBridge(client)
err := bridge.RefreshAll(context.Background())
if err != nil {
log.Fatalln(err)
}
j, _ := json.Marshal(bridge.GenerateDevices())
fmt.Println(string(j))
ch := make(chan models.Event)
go func() {
for event := range ch {
log.Println("EVENT", event.Name, event.Payload)
}
}()
for i, dev := range bridge.GenerateDevices() {
device := dev
switch device.InternalID {
case "6d5a45b0-ec69-4417-8588-717358b05086":
c, _ := models.ParseColorValue("xy:0.22,0.18")
device.State.Color = c
device.State.Intensity = 0.3
case "a71128f4-5295-4ae4-9fbc-5541abc8739b":
c, _ := models.ParseColorValue("k:6500")
device.State.Color = c
device.State.Intensity = 0.2
}
device.ID = i + 1
bridge.Update(device)
}
err = bridge.Run(context.Background(), ch)
log.Println(err)
}
+27
View File
@@ -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
+6 -2
View File
@@ -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
) )
+25 -5
View File
@@ -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=
+11
View File
@@ -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
+425
View File
@@ -0,0 +1,425 @@
package hue
import (
"bytes"
"context"
"encoding/json"
"fmt"
"git.aiterp.net/lucifer/new-server/models"
"golang.org/x/sync/errgroup"
"io"
"net"
"net/http"
"strconv"
"strings"
"sync"
"time"
)
type Bridge struct {
mu sync.Mutex
host string
token string
externalID int
lightStates []*hueLightState
sensorStates []*hueSensorState
quarantine map[string]time.Time
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 {
if time.Now().Before(b.quarantine[light.Uniqueid]) {
continue
}
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 {
if time.Now().Before(b.quarantine[sensor.UniqueID]) {
continue
}
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)
inputs := make([]LightStateInput, 0, 4)
eg, ctx := errgroup.WithContext(ctx)
b.mu.Lock()
for _, state := range b.lightStates {
if !state.stale {
continue
}
indices = append(indices, state.index)
inputs = append(inputs, state.input)
}
b.mu.Unlock()
if len(inputs) == 0 {
return nil
}
for i, input := range inputs {
iCopy := i
index := indices[i]
inputCopy := input
eg.Go(func() error {
err := b.putLightState(ctx, index, inputCopy)
if err != nil {
return err
}
b.lightStates[iCopy].stale = false
return nil
})
}
return eg.Wait()
}
func (b *Bridge) ForgetDevice(ctx context.Context, device models.Device) error {
// Find index
b.mu.Lock()
found := false
index := -1
for i, ls := range b.lightStates {
if ls.uniqueID == device.InternalID {
found = true
index = i
}
}
b.mu.Unlock()
if !found {
return models.ErrNotFound
}
// Delete light from bridge
err := b.deleteLight(ctx, index)
if err != nil {
return err
}
// Remove light state from local list. I don't know if the quarantine is necessary, but let's have it anyway.
b.mu.Lock()
for i, ls := range b.lightStates {
if ls.uniqueID == device.InternalID {
b.lightStates = append(b.lightStates[:i], b.lightStates[i+1:]...)
}
}
if b.quarantine == nil {
b.quarantine = make(map[string]time.Time, 1)
}
b.quarantine[device.InternalID] = time.Now().Add(time.Second * 30)
b.mu.Unlock()
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) deleteLight(ctx context.Context, index int) error {
return b.delete(ctx, fmt.Sprintf("lights/%d", index), 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,
}
+285
View File
@@ -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")
+419
View File
@@ -0,0 +1,419 @@
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 / 5)
slowTicker := time.NewTicker(time.Second / 2)
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) ForgetDevice(ctx context.Context, bridge models.Bridge, device models.Device) error {
b, err := d.ensureBridge(ctx, bridge)
if err != nil {
return err
}
return b.ForgetDevice(ctx, device)
}
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
}
+254
View File
@@ -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
}
+620
View File
@@ -0,0 +1,620 @@
package hue2
import (
"context"
"errors"
"fmt"
"git.aiterp.net/lucifer/new-server/models"
"golang.org/x/sync/errgroup"
"log"
"math"
"strings"
"sync"
"time"
)
type Bridge struct {
mu sync.Mutex
externalID int
client *Client
needsUpdate chan struct{}
devices map[string]models.Device
resources map[string]*ResourceData
}
func NewBridge(client *Client) *Bridge {
return &Bridge{
client: client,
needsUpdate: make(chan struct{}, 4),
devices: make(map[string]models.Device, 64),
resources: make(map[string]*ResourceData, 256),
}
}
func (b *Bridge) Run(ctx context.Context, eventCh chan<- models.Event) error {
log.Println(fmt.Sprintf("[Bridge %d]", b.externalID), "Connecting SSE...")
sse := b.client.SSE(ctx)
log.Println(fmt.Sprintf("[Bridge %d]", b.externalID), "Refreshing...")
err := b.RefreshAll(ctx)
if err != nil {
return err
}
log.Println(fmt.Sprintf("[Bridge %d]", b.externalID), "Running updates...")
updated, err := b.MakeCongruent(ctx)
if err != nil {
return err
}
log.Println(fmt.Sprintf("[Bridge %d]", b.externalID), "Updated", updated, "hue services (startup)")
lightRefreshTimer := time.NewTicker(time.Second * 5)
defer lightRefreshTimer.Stop()
motionTicker := time.NewTicker(time.Second * 10)
defer motionTicker.Stop()
absences := make(map[int]time.Time)
lastPress := make(map[string]time.Time)
lastUpdate := time.Now()
needFull := false
for {
select {
case <-ctx.Done():
return ctx.Err()
case <-lightRefreshTimer.C:
if time.Since(lastUpdate) < time.Second*2 {
continue
}
if needFull {
err := b.RefreshAll(ctx)
if err != nil {
return err
}
} else {
err := b.Refresh(ctx, "light")
if err != nil {
return err
}
}
updated, err := b.MakeCongruent(ctx)
if err != nil {
return err
}
if updated > 0 {
// Force this to cool for 30 seconds unless a manual update occurs.
if updated > 10 {
lastUpdate = time.Now().Add(time.Second * 15)
}
log.Println(fmt.Sprintf("[Bridge %d]", b.externalID), "Updated", updated, "hue services (regular check)")
}
case <-b.needsUpdate:
lastUpdate = time.Now()
updated, err := b.MakeCongruent(ctx)
if err != nil {
return err
}
if updated > 0 {
log.Println(fmt.Sprintf("[Bridge %d]", b.externalID), "Updated", updated, "hue services (publish)")
}
case <-motionTicker.C:
for id, absenceTime := range absences {
seconds := int(time.Since(absenceTime).Seconds())
if seconds < 10 || seconds%60 >= 10 {
continue
}
eventCh <- models.Event{
Name: models.ENSensorPresenceEnded,
Payload: map[string]string{
"deviceId": fmt.Sprint(id),
"minutesElapsed": fmt.Sprint(seconds / 60),
"secondsElapsed": fmt.Sprint(seconds),
"lastUpdated": fmt.Sprint(absenceTime.Unix()),
},
}
}
case data, ok := <-sse:
if !ok {
return errors.New("SSE Disconnected")
}
b.applyPatches(data.Data)
updated, err := b.MakeCongruent(ctx)
if err != nil {
return err
}
if updated > 0 {
log.Println(fmt.Sprintf("[Bridge %d]", b.externalID), "Updated", updated, "hue services (SSE)")
}
for _, patch := range data.Data {
if patch.Owner == nil {
continue
}
b.mu.Lock()
if b.resources[patch.Owner.ID] == nil {
needFull = true
}
b.mu.Unlock()
device, deviceOK := b.devices[patch.Owner.ID]
if !deviceOK || device.ID == 0 {
continue
}
if patch.Button != nil {
valid := false
if patch.Button.LastEvent == "initial_press" || patch.Button.LastEvent == "repeat" {
valid = true
} else if patch.Button.LastEvent == "long_release" {
valid = false
} else {
valid = data.CreationTime.Sub(lastPress[patch.ID]) >= time.Second*2
}
if valid {
lastPress[patch.ID] = data.CreationTime
b.mu.Lock()
owner := b.resources[patch.Owner.ID]
b.mu.Unlock()
if owner != nil {
index := owner.ServiceIndex("button", patch.ID)
if index != -1 {
eventCh <- models.Event{
Name: models.ENButtonPressed,
Payload: map[string]string{
"deviceId": fmt.Sprint(device.ID),
"hueButtonEvent": patch.Button.LastEvent,
"buttonIndex": fmt.Sprint(index),
"buttonName": device.ButtonNames[index],
},
}
}
}
}
}
if patch.Temperature != nil && patch.Temperature.Valid {
eventCh <- models.Event{
Name: models.ENSensorTemperature,
Payload: map[string]string{
"deviceId": fmt.Sprint(device.ID),
"deviceInternalId": patch.Owner.ID,
"temperature": fmt.Sprint(patch.Temperature.Temperature),
"lastUpdated": fmt.Sprint(data.CreationTime.Unix()),
},
}
}
if patch.Motion != nil && patch.Motion.Valid {
if patch.Motion.Motion {
eventCh <- models.Event{
Name: models.ENSensorPresenceStarted,
Payload: map[string]string{
"deviceId": fmt.Sprint(device.ID),
"deviceInternalId": patch.Owner.ID,
},
}
delete(absences, device.ID)
} else {
eventCh <- models.Event{
Name: models.ENSensorPresenceEnded,
Payload: map[string]string{
"deviceId": fmt.Sprint(device.ID),
"deviceInternalId": device.InternalID,
"minutesElapsed": "0",
"secondsElapsed": "0",
"lastUpdated": fmt.Sprint(data.CreationTime.Unix()),
},
}
absences[device.ID] = data.CreationTime
}
}
}
}
}
}
func (b *Bridge) Update(devices ...models.Device) {
b.mu.Lock()
for _, device := range devices {
b.devices[device.InternalID] = device
}
b.mu.Unlock()
select {
case b.needsUpdate <- struct{}{}:
default:
}
}
func (b *Bridge) MakeCongruent(ctx context.Context) (int, error) {
// Exhaust the channel to avoid more updates.
exhausted := false
for !exhausted {
select {
case <-b.needsUpdate:
default:
exhausted = true
}
}
b.mu.Lock()
dur := time.Millisecond * 100
updates := make(map[string]ResourceUpdate)
for _, device := range b.devices {
resource := b.resources[device.InternalID]
// Update device
if resource.Metadata.Name != device.Name {
name := device.Name
updates["device/"+resource.ID] = ResourceUpdate{
Name: &name,
}
}
// Update light
if lightID := resource.ServiceID("light"); lightID != nil {
light := b.resources[*lightID]
update := ResourceUpdate{TransitionDuration: &dur}
changed := false
lightsOut := light.Power != nil && !device.State.Power
if !lightsOut {
if light.ColorTemperature != nil && device.State.Color.IsKelvin() {
mirek := 1000000 / device.State.Color.Kelvin
if mirek < light.ColorTemperature.MirekSchema.MirekMinimum {
mirek = light.ColorTemperature.MirekSchema.MirekMinimum
}
if mirek > light.ColorTemperature.MirekSchema.MirekMaximum {
mirek = light.ColorTemperature.MirekSchema.MirekMaximum
}
if light.ColorTemperature.Mirek == nil || mirek != *light.ColorTemperature.Mirek {
update.Mirek = &mirek
changed = true
}
} else if xy, ok := device.State.Color.ToXY(); ok && light.Color != nil {
xy = light.Color.Gamut.Conform(xy).Round()
if xy.DistanceTo(light.Color.XY) > 0.0009 || (light.ColorTemperature != nil && light.ColorTemperature.Mirek != nil) {
update.ColorXY = &xy
changed = true
}
}
if light.Dimming != nil && math.Abs(light.Dimming.Brightness/100-device.State.Intensity) > 0.02 {
brightness := math.Abs(math.Min(device.State.Intensity*100, 100))
update.Brightness = &brightness
changed = true
}
}
if light.Power != nil && light.Power.On != device.State.Power {
update.Power = &device.State.Power
if device.State.Power {
brightness := math.Abs(math.Min(device.State.Intensity*100, 100))
update.Brightness = &brightness
}
changed = true
}
if changed {
updates["light/"+light.ID] = update
}
}
}
if len(updates) > 0 {
// Optimistically apply the updates to the states, so that the driver assumes they are set until
// proven otherwise by the SSE client.
newResources := make(map[string]*ResourceData, len(b.resources))
for key, value := range b.resources {
newResources[key] = value
}
for key, update := range updates {
id := strings.SplitN(key, "/", 2)[1]
newResources[id] = newResources[id].WithUpdate(update)
}
b.resources = newResources
}
b.mu.Unlock()
if len(updates) == 0 {
return 0, nil
}
eg, ctx := errgroup.WithContext(ctx)
for key := range updates {
update := updates[key]
split := strings.SplitN(key, "/", 2)
link := ResourceLink{Kind: split[0], ID: split[1]}
eg.Go(func() error {
return b.client.UpdateResource(ctx, link, update)
})
}
err := eg.Wait()
if err != nil {
// Try to restore light states.
_ = b.Refresh(ctx, "light")
return len(updates), err
}
return len(updates), nil
}
func (b *Bridge) GenerateDevices() []models.Device {
b.mu.Lock()
resources := b.resources
b.mu.Unlock()
devices := make([]models.Device, 0, 16)
for _, resource := range resources {
if resource.Type != "device" || strings.HasPrefix(resource.Metadata.Archetype, "bridge") {
continue
}
device := models.Device{
BridgeID: b.externalID,
InternalID: resource.ID,
Name: resource.Metadata.Name,
DriverProperties: map[string]interface{}{
"archetype": resource.Metadata.Archetype,
"name": resource.ProductData.ProductName,
"product": resource.ProductData,
"legacyId": resource.LegacyID,
},
}
// Set icon
if resource.ProductData.ProductName == "Hue dimmer switch" {
device.Icon = "switch"
} else if resource.ProductData.ProductName == "Hue motion sensor" {
device.Icon = "sensor"
} else {
device.Icon = "lightbulb"
}
buttonCount := 0
for _, ptr := range resource.Services {
switch ptr.Kind {
case "device_power":
{
device.DriverProperties["battery"] = resources[ptr.ID].PowerState
}
case "button":
{
buttonCount += 1
}
case "zigbee_connectivity":
{
device.DriverProperties["zigbee"] = resources[ptr.ID].Status
}
case "motion":
{
device.Capabilities = append(device.Capabilities, models.DCPresence)
}
case "temperature":
{
device.Capabilities = append(device.Capabilities, models.DCTemperatureSensor)
}
case "light":
{
light := resources[ptr.ID]
if light.Power != nil {
device.State.Power = light.Power.On
device.Capabilities = append(device.Capabilities, models.DCPower)
}
if light.Dimming != nil {
device.State.Intensity = light.Dimming.Brightness / 100
device.Capabilities = append(device.Capabilities, models.DCIntensity)
}
if light.ColorTemperature != nil {
if light.ColorTemperature.Mirek != nil {
device.State.Color = models.ColorValue{
Kelvin: int(1000000 / *light.ColorTemperature.Mirek),
}
}
device.Capabilities = append(device.Capabilities, models.DCColorKelvin)
device.DriverProperties["maxTemperature"] = 1000000 / light.ColorTemperature.MirekSchema.MirekMinimum
device.DriverProperties["minTemperature"] = 1000000 / light.ColorTemperature.MirekSchema.MirekMaximum
}
if light.Color != nil {
if device.State.Color.IsEmpty() {
device.State.Color = models.ColorValue{
XY: &light.Color.XY,
}
}
device.DriverProperties["colorGamut"] = light.Color.Gamut
device.DriverProperties["colorGamutType"] = light.Color.GamutType
device.Capabilities = append(device.Capabilities, models.DCColorHS, models.DCColorXY)
}
}
}
}
if buttonCount == 4 {
device.ButtonNames = []string{"On", "DimUp", "DimDown", "Off"}
} else if buttonCount == 1 {
device.ButtonNames = []string{"Button"}
} else {
for n := 1; n <= buttonCount; n++ {
device.ButtonNames = append(device.ButtonNames, fmt.Sprint("Button", n))
}
}
devices = append(devices, device)
}
return devices
}
func (b *Bridge) Refresh(ctx context.Context, kind string) error {
if kind == "device" {
// Device refresh requires the full deal as services are taken for granted.
return b.RefreshAll(ctx)
}
resources, err := b.client.Resources(ctx, kind)
if err != nil {
return err
}
b.mu.Lock()
oldResources := b.resources
b.mu.Unlock()
newResources := make(map[string]*ResourceData, len(b.resources))
for key, value := range oldResources {
if value.Type != kind {
newResources[key] = value
}
}
for i := range resources {
resource := resources[i]
newResources[resource.ID] = &resource
}
b.mu.Lock()
b.resources = newResources
b.mu.Unlock()
return nil
}
func (b *Bridge) RefreshAll(ctx context.Context) error {
allResources, err := b.client.AllResources(ctx)
if err != nil {
return err
}
resources := make(map[string]*ResourceData, len(allResources))
for i := range allResources {
resource := allResources[i]
resources[resource.ID] = &resource
}
b.mu.Lock()
b.resources = resources
b.mu.Unlock()
return nil
}
func (b *Bridge) applyPatches(patches []ResourceData) {
b.mu.Lock()
newResources := make(map[string]*ResourceData, len(b.resources))
for key, value := range b.resources {
newResources[key] = value
}
b.mu.Unlock()
for _, patch := range patches {
if res := newResources[patch.ID]; res != nil {
resCopy := *res
if patch.Power != nil && resCopy.Power != nil {
cp := *resCopy.Power
resCopy.Power = &cp
resCopy.Power.On = patch.Power.On
}
if patch.Color != nil && resCopy.Color != nil {
cp := *resCopy.Color
resCopy.Color = &cp
resCopy.Color.XY = patch.Color.XY
cp2 := *resCopy.ColorTemperature
resCopy.ColorTemperature = &cp2
resCopy.ColorTemperature.Mirek = nil
}
if patch.ColorTemperature != nil && resCopy.ColorTemperature != nil {
cp := *resCopy.ColorTemperature
resCopy.ColorTemperature = &cp
resCopy.ColorTemperature.Mirek = patch.ColorTemperature.Mirek
}
if patch.Dimming != nil && resCopy.Dimming != nil {
cp := *resCopy.Dimming
resCopy.Dimming = &cp
resCopy.Dimming.Brightness = patch.Dimming.Brightness
}
if patch.Dynamics != nil {
resCopy.Dynamics = patch.Dynamics
}
if patch.Alert != nil {
resCopy.Alert = patch.Alert
}
if patch.PowerState != nil {
resCopy.PowerState = patch.PowerState
}
if patch.Temperature != nil {
resCopy.Temperature = patch.Temperature
}
if patch.Status != nil {
resCopy.Status = patch.Status
}
resCopy.Metadata.Name = patch.Metadata.Name
newResources[patch.ID] = &resCopy
}
}
b.mu.Lock()
b.resources = newResources
b.mu.Unlock()
}
func (b *Bridge) SearchDevices(ctx context.Context, timeout time.Duration) ([]models.Device, error) {
// Record the current state.
b.mu.Lock()
before := b.resources
b.mu.Unlock()
// Spend half the time waiting for devices
// TODO: Wait for v2 endpoint
err := b.client.LegacyDiscover(ctx, "sensors")
if err != nil {
return nil, err
}
select {
case <-time.After(timeout / 1):
case <-ctx.Done():
return nil, ctx.Err()
}
// Spend half the time waiting for lights
// TODO: Wait for v2 endpoint
err = b.client.LegacyDiscover(ctx, "lights")
if err != nil {
return nil, err
}
select {
case <-time.After(timeout / 1):
case <-ctx.Done():
return nil, ctx.Err()
}
// Perform a full refresh.
err = b.RefreshAll(ctx)
if err != nil {
return nil, err
}
// Check for new devices
devices := b.GenerateDevices()
newDevices := make([]models.Device, 0)
for _, device := range devices {
if before[device.InternalID] == nil {
newDevices = append(newDevices, device)
}
}
// Return said devices.
return newDevices, nil
}
+358
View File
@@ -0,0 +1,358 @@
package hue2
import (
"bufio"
"bytes"
"context"
"crypto/tls"
"encoding/json"
"errors"
"fmt"
"git.aiterp.net/lucifer/new-server/models"
"io"
"log"
"net"
"net/http"
"strings"
"time"
)
func NewClient(host, token string) *Client {
ch := make(chan struct{}, 5)
for i := 0; i < 2; i++ {
ch <- struct{}{}
}
return &Client{
host: host,
token: token,
ch: ch,
}
}
type Client struct {
host string
token string
ch chan struct{}
}
func (c *Client) Register(ctx context.Context) (string, error) {
result := make([]CreateUserResponse, 0, 1)
err := c.post(ctx, "api/", CreateUserInput{DeviceType: "git.aiterp.net/lucifer"}, &result)
if err != nil {
return "", err
}
if len(result) == 0 || result[0].Error != nil {
select {
case <-ctx.Done():
return "", ctx.Err()
case <-time.After(time.Second):
return c.Register(ctx)
}
}
if result[0].Success == nil {
return "", models.ErrUnexpectedResponse
}
c.token = result[0].Success.Username
return result[0].Success.Username, nil
}
func (c *Client) AllResources(ctx context.Context) ([]ResourceData, error) {
res := struct {
Error interface{}
Data []ResourceData
}{}
err := c.get(ctx, "clip/v2/resource", &res)
if err != nil {
return nil, err
}
return res.Data, nil
}
func (c *Client) Resources(ctx context.Context, kind string) ([]ResourceData, error) {
res := struct {
Error interface{}
Data []ResourceData
}{}
err := c.get(ctx, "clip/v2/resource/"+kind, &res)
if err != nil {
return nil, err
}
return res.Data, nil
}
func (c *Client) UpdateResource(ctx context.Context, link ResourceLink, update ResourceUpdate) error {
return c.put(ctx, link.Path(), update, nil)
}
func (c *Client) LegacyDiscover(ctx context.Context, kind string) error {
return c.legacyPost(ctx, kind, nil, nil)
}
func (c *Client) SSE(ctx context.Context) <-chan SSEUpdate {
ch := make(chan SSEUpdate, 4)
go func() {
defer close(ch)
reader, err := c.getReader(ctx, "/eventstream/clip/v2", map[string]string{
"Accept": "text/event-stream",
})
if err != nil {
log.Println("SSE Connect error:", err)
return
}
defer reader.Close()
br := bufio.NewReader(reader)
for {
line, err := br.ReadString('\n')
if err != nil {
log.Println("SSE Read error:", err)
return
}
line = strings.Trim(line, "  \t\r\n")
kv := strings.SplitN(line, ": ", 2)
if len(kv) < 2 {
continue
}
switch kv[0] {
case "data":
var data []SSEUpdate
err := json.Unmarshal([]byte(kv[1]), &data)
if err != nil {
log.Println("Parsing SSE event failed:", err)
log.Println(" json:", kv[1])
return
}
for _, obj := range data {
ch <- obj
}
}
}
}()
return ch
}
func (c *Client) getReader(ctx context.Context, path string, headers map[string]string) (io.ReadCloser, error) {
select {
case <-ctx.Done():
return nil, ctx.Err()
case <-c.ch:
defer func() {
c.ch <- struct{}{}
}()
}
req, err := http.NewRequest("GET", fmt.Sprintf("https://%s/%s", c.host, path), nil)
if err != nil {
return nil, err
}
for key, value := range headers {
req.Header.Set(key, value)
}
req.Header.Set("hue-application-key", c.token)
client := httpClient
if headers["Accept"] == "text/event-stream" {
client = sseClient
}
res, err := client.Do(req.WithContext(ctx))
if err != nil {
return nil, err
}
if res.StatusCode != 200 {
_ = res.Body.Close()
return nil, errors.New(res.Status)
}
return res.Body, nil
}
func (c *Client) get(ctx context.Context, path string, target interface{}) error {
body, err := c.getReader(ctx, path, map[string]string{})
if err != nil {
return err
}
defer body.Close()
if target == nil {
return nil
}
return json.NewDecoder(body).Decode(&target)
}
func (c *Client) put(ctx context.Context, path string, body interface{}, target interface{}) error {
select {
case <-ctx.Done():
return ctx.Err()
case <-c.ch:
defer func() {
c.ch <- struct{}{}
}()
}
rb, err := reqBody(body)
if err != nil {
return err
}
req, err := http.NewRequest("PUT", fmt.Sprintf("https://%s/%s", c.host, path), rb)
if err != nil {
return err
}
req.Header.Set("hue-application-key", c.token)
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 (c *Client) post(ctx context.Context, path string, body interface{}, target interface{}) error {
select {
case <-ctx.Done():
return ctx.Err()
case <-c.ch:
defer func() {
c.ch <- struct{}{}
}()
}
rb, err := reqBody(body)
if err != nil {
return err
}
req, err := http.NewRequest("POST", fmt.Sprintf("https://%s/%s", c.host, path), rb)
if err != nil {
return err
}
if c.token != "" {
req.Header.Set("hue-application-key", c.token)
}
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 (c *Client) legacyPost(ctx context.Context, resource string, body interface{}, target interface{}) error {
select {
case <-ctx.Done():
return ctx.Err()
case <-c.ch:
defer func() {
c.ch <- struct{}{}
}()
}
rb, err := reqBody(body)
if err != nil {
return err
}
if c.token != "" {
resource = c.token + "/" + resource
}
req, err := http.NewRequest("POST", fmt.Sprintf("http://%s/api/%s", c.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 sseClient = &http.Client{
Transport: &http.Transport{
Proxy: http.ProxyFromEnvironment,
DialContext: (&net.Dialer{
Timeout: time.Hour * 1000000,
KeepAlive: 30 * time.Second,
}).DialContext,
MaxIdleConns: 5,
MaxIdleConnsPerHost: 1,
IdleConnTimeout: 0,
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
},
Timeout: time.Hour * 1000000,
}
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,
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
},
Timeout: time.Minute,
}
+280
View File
@@ -0,0 +1,280 @@
package hue2
import (
"encoding/json"
"encoding/xml"
"fmt"
"git.aiterp.net/lucifer/new-server/models"
"strings"
"time"
)
type DeviceData struct {
ID string `json:"id"`
LegacyID string `json:"id_v1"`
Metadata DeviceMetadata `json:"metadata"`
Type string `json:"type"`
ProductData DeviceProductData `json:"product_data"`
Services []ResourceLink `json:"services"`
}
type DeviceMetadata struct {
Archetype string `json:"archetype"`
Name string `json:"name"`
}
type DeviceProductData struct {
Certified bool `json:"certified"`
ManufacturerName string `json:"manufacturer_name"`
ModelID string `json:"model_id"`
ProductArchetype string `json:"product_archetype"`
ProductName string `json:"product_name"`
SoftwareVersion string `json:"software_version"`
}
type SSEUpdate struct {
CreationTime time.Time `json:"creationTime"`
ID string `json:"id"`
Type string `json:"type"`
Data []ResourceData `json:"data"`
}
type ResourceData struct {
ID string `json:"id"`
LegacyID string `json:"id_v1"`
Metadata DeviceMetadata `json:"metadata"`
Type string `json:"type"`
Mode *string `json:"mode"`
Owner *ResourceLink `json:"owner"`
ProductData *DeviceProductData `json:"product_data"`
Services []ResourceLink `json:"services"`
Button *SensorButton `json:"button"`
Power *LightPower `json:"on"`
Color *LightColor `json:"color"`
ColorTemperature *LightCT `json:"color_temperature"`
Dimming *LightDimming `json:"dimming"`
Dynamics *LightDynamics `json:"dynamics"`
Alert *LightAlert `json:"alert"`
PowerState *PowerState `json:"power_state"`
Temperature *SensorTemperature `json:"temperature"`
Motion *SensorMotion `json:"motion"`
Status *string `json:"status"`
}
func (res *ResourceData) ServiceID(kind string) *string {
for _, ptr := range res.Services {
if ptr.Kind == kind {
return &ptr.ID
}
}
return nil
}
func (res *ResourceData) ServiceIndex(kind string, id string) int {
idx := 0
for _, link := range res.Services {
if link.ID == id {
return idx
} else if link.Kind == kind {
idx += 1
}
}
return -1
}
func (res *ResourceData) WithUpdate(update ResourceUpdate) *ResourceData {
resCopy := *res
if update.Name != nil {
resCopy.Metadata.Name = *update.Name
}
if update.Power != nil {
cp := *resCopy.Power
resCopy.Power = &cp
resCopy.Power.On = *update.Power
}
if update.ColorXY != nil {
cp := *resCopy.Color
resCopy.Color = &cp
resCopy.Color.XY = *update.ColorXY
if resCopy.ColorTemperature != nil {
cp := *resCopy.ColorTemperature
resCopy.ColorTemperature = &cp
resCopy.ColorTemperature.Mirek = nil
}
}
if update.Mirek != nil {
cp := *resCopy.ColorTemperature
resCopy.ColorTemperature = &cp
mirek := *update.Mirek
resCopy.ColorTemperature.Mirek = &mirek
}
if update.Brightness != nil {
cp := *resCopy.Dimming
resCopy.Dimming = &cp
resCopy.Dimming.Brightness = *update.Brightness
}
return &resCopy
}
type SensorButton struct {
LastEvent string `json:"last_event"`
}
type SensorMotion struct {
Motion bool `json:"motion"`
Valid bool `json:"motion_valid"`
}
type SensorTemperature struct {
Temperature float64 `json:"temperature"`
Valid bool `json:"temperature_valid"`
}
type PowerState struct {
BatteryState string `json:"battery_state"`
BatteryLevel float64 `json:"battery_level"`
}
type LightPower struct {
On bool `json:"on"`
}
type LightDimming struct {
Brightness float64 `json:"brightness"`
}
type LightColor struct {
Gamut models.ColorGamut `json:"gamut"`
GamutType string `json:"gamut_type"`
XY models.ColorXY `json:"xy"`
}
type LightCT struct {
Mirek *int `json:"mirek"`
MirekSchema LightCTMirekSchema `json:"mirek_schema"`
MirekValid bool `json:"mirek_valid"`
}
type LightCTMirekSchema struct {
MirekMaximum int `json:"mirek_maximum"`
MirekMinimum int `json:"mirek_minimum"`
}
type LightDynamics struct {
Speed float64 `json:"speed"`
SpeedValid bool `json:"speed_valid"`
Status string `json:"status"`
StatusValues []string `json:"status_values"`
}
type LightAlert struct {
ActionValues []string `json:"action_values"`
}
type ResourceUpdate struct {
Name *string
Power *bool
ColorXY *models.ColorXY
Brightness *float64
Mirek *int
TransitionDuration *time.Duration
}
func (r ResourceUpdate) MarshalJSON() ([]byte, error) {
chunks := make([]string, 0, 4)
if r.Name != nil {
s, _ := json.Marshal(*r.Name)
chunks = append(chunks, fmt.Sprintf(`"metadata":{"name":%s}`, string(s)))
}
if r.Power != nil {
chunks = append(chunks, fmt.Sprintf(`"on":{"on":%v}`, *r.Power))
}
if r.ColorXY != nil {
chunks = append(chunks, fmt.Sprintf(`"color":{"xy":{"x":%f,"y":%f}}`, r.ColorXY.X, r.ColorXY.Y))
}
if r.Brightness != nil {
chunks = append(chunks, fmt.Sprintf(`"dimming":{"brightness":%f}`, *r.Brightness))
}
if r.Mirek != nil {
chunks = append(chunks, fmt.Sprintf(`"color_temperature":{"mirek":%d}`, *r.Mirek))
}
if r.TransitionDuration != nil {
chunks = append(chunks, fmt.Sprintf(`"dynamics":{"duration":%d}`, r.TransitionDuration.Truncate(time.Millisecond*100).Milliseconds()))
}
return []byte(fmt.Sprintf("{%s}", strings.Join(chunks, ","))), nil
}
type ResourceLink struct {
ID string `json:"rid"`
Kind string `json:"rtype"`
}
func (rl *ResourceLink) Path() string {
return fmt.Sprintf("/clip/v2/resource/%s/%s", rl.Kind, rl.ID)
}
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 DiscoveryEntry struct {
Id string `json:"id"`
InternalIPAddress string `json:"internalipaddress"`
}
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"`
}
+116
View File
@@ -0,0 +1,116 @@
package hue2
import (
"context"
"encoding/json"
"encoding/xml"
"fmt"
"git.aiterp.net/lucifer/new-server/models"
"net/http"
"sync"
"time"
)
type Driver struct {
mu sync.Mutex
bridges []*Bridge
}
func (d *Driver) SearchBridge(ctx context.Context, address, token 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.DTHue2,
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.DTHue2,
Address: address,
Token: "",
}
if !dryRun {
client := NewClient(address, "")
timeout, cancel := context.WithTimeout(ctx, time.Second*30)
defer cancel()
bridge.Token, err = client.Register(timeout)
if err != nil {
return nil, err
}
}
return []models.Bridge{bridge}, nil
}
func (d *Driver) SearchDevices(ctx context.Context, bridge models.Bridge, timeout time.Duration) ([]models.Device, error) {
return d.ensureBridge(bridge).SearchDevices(ctx, timeout)
}
func (d *Driver) ListDevices(_ context.Context, bridge models.Bridge) ([]models.Device, error) {
return d.ensureBridge(bridge).GenerateDevices(), nil
}
func (d *Driver) Publish(_ context.Context, bridge models.Bridge, devices []models.Device) error {
d.ensureBridge(bridge).Update(devices...)
return nil
}
func (d *Driver) Run(ctx context.Context, bridge models.Bridge, ch chan<- models.Event) error {
return d.ensureBridge(bridge).Run(ctx, ch)
}
func (d *Driver) ensureBridge(info models.Bridge) *Bridge {
d.mu.Lock()
for _, bridge := range d.bridges {
if bridge.client.host == info.Address {
d.mu.Unlock()
return bridge
}
}
bridge := NewBridge(NewClient(info.Address, info.Token))
bridge.externalID = info.ID
d.bridges = append(d.bridges, bridge)
d.mu.Unlock()
return bridge
}
+269
View File
@@ -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
}
+285
View File
@@ -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
}
+177
View File
@@ -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
}
+191
View File
@@ -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,
}
+417
View File
@@ -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
+88
View File
@@ -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
}
+240
View File
@@ -0,0 +1,240 @@
package mill
import (
"bytes"
"context"
"crypto/rand"
"crypto/sha1"
"encoding/json"
"fmt"
"git.aiterp.net/lucifer/new-server/models"
"io"
"log"
"net/http"
"strconv"
"sync"
"time"
)
type bridge struct {
mu sync.Mutex
luciferID int
username string
password string
token string
userId int
mustRefreshBy time.Time
luciferMillIDMap map[int]int
millLuciferIDMap map[int]int
}
func (b *bridge) listDevices(ctx context.Context) ([]models.Device, error) {
err := b.authenticate(ctx)
if err != nil {
return nil, err
}
var shlRes listHomeResBody
err = b.command(ctx, "selectHomeList", listHomeReqBody{}, &shlRes)
if err != nil {
return nil, err
}
devices := make([]millDevice, 0, 16)
for _, home := range shlRes.HomeList {
var gidRes listDeviceResBody
err = b.command(ctx, "getIndependentDevices", listDeviceReqBody{HomeID: home.HomeID}, &gidRes)
if err != nil {
return nil, err
}
devices = append(devices, gidRes.DeviceInfo...)
}
luciferDevices := make([]models.Device, len(devices), len(devices))
for i, device := range devices {
luciferDevices[i] = models.Device{
ID: b.millLuciferIDMap[device.DeviceID],
BridgeID: b.luciferID,
InternalID: fmt.Sprintf("%d", device.DeviceID),
Icon: "heater",
Name: device.DeviceName,
Capabilities: []models.DeviceCapability{models.DCTemperatureControl, models.DCPower},
ButtonNames: nil,
DriverProperties: map[string]interface{}{
"subDomain": fmt.Sprintf("%d", device.SubDomainID),
},
UserProperties: nil,
SceneAssignments: nil,
SceneState: nil,
State: models.DeviceState{
Power: device.PowerStatus > 0,
Temperature: device.HolidayTemp,
},
Tags: nil,
}
}
return luciferDevices, nil
}
func (b *bridge) pushStateChange(ctx context.Context, deviceModel models.Device) error {
b.mu.Lock()
if b.luciferMillIDMap == nil {
b.luciferMillIDMap = make(map[int]int, 4)
b.millLuciferIDMap = make(map[int]int, 4)
}
if b.luciferMillIDMap[deviceModel.ID] == 0 {
millID, _ := strconv.Atoi(deviceModel.InternalID)
b.luciferMillIDMap[deviceModel.ID] = millID
b.millLuciferIDMap[millID] = deviceModel.ID
}
b.mu.Unlock()
status := 0
if deviceModel.State.Power {
status = 1
}
powerReq := deviceControlReqBody{
SubDomain: deviceModel.DriverProperties["subDomain"].(string),
DeviceID: b.luciferMillIDMap[deviceModel.ID],
TestStatus: 1,
Status: status,
}
err := b.command(ctx, "deviceControl", powerReq, nil)
if err != nil {
return err
}
tempReq := changeInfoReqBody{
DeviceID: b.luciferMillIDMap[deviceModel.ID],
Value: deviceModel.State.Temperature,
TimeZoneNum: "+02:00",
Key: "holidayTemp",
}
err = b.command(ctx, "changeDeviceInfo", tempReq, nil)
if err != nil {
return err
}
return nil
}
func (b *bridge) command(ctx context.Context, command string, payload interface{}, target interface{}) error {
err := b.authenticate(ctx)
if err != nil {
return err
}
url := serviceEndpoint + command
method := "POST"
nonce := makeNonce()
timestamp := fmt.Sprintf("%d", time.Now().Unix())
timeout := "300"
h := sha1.New()
h.Write([]byte(timeout))
h.Write([]byte(timestamp))
h.Write([]byte(nonce))
h.Write([]byte(b.token))
signature := fmt.Sprintf("%x", h.Sum(nil))
body, err := json.Marshal(payload)
if err != nil {
return err
}
req, err := http.NewRequestWithContext(ctx, method, url, bytes.NewReader(body))
if err != nil {
return err
}
addDefaultHeaders(req)
req.Header.Add("X-Zc-Timestamp", timestamp)
req.Header.Add("X-Zc-Timeout", timeout)
req.Header.Add("X-Zc-Nonce", nonce)
req.Header.Add("X-Zc-User-Id", fmt.Sprintf("%d", b.userId))
req.Header.Add("X-Zc-User-Signature", signature)
req.Header.Add("X-Zc-Content-Length", fmt.Sprintf("%d", len(body)))
res, err := http.DefaultClient.Do(req)
if err != nil {
return models.ErrCannotForwardRequest
} else if res.StatusCode != 200 {
return models.ErrIncorrectToken
}
if target == nil {
return nil
}
err = json.NewDecoder(res.Body).Decode(&target)
if err != nil {
return models.ErrUnexpectedResponse
}
return nil
}
func (b *bridge) authenticate(ctx context.Context) error {
b.mu.Lock()
defer b.mu.Unlock()
if b.mustRefreshBy.Before(time.Now().Add(-1 * time.Minute)) {
body, err := json.Marshal(authReqBody{
Account: b.username,
Password: b.password,
})
if err != nil {
return models.ErrMissingToken
}
req, err := http.NewRequestWithContext(ctx, "POST", accountEndpoint + "login", bytes.NewReader(body))
if err != nil {
return models.ErrMissingToken
}
addDefaultHeaders(req)
res, err := http.DefaultClient.Do(req)
if err != nil {
return models.ErrCannotForwardRequest
} else if res.StatusCode != 200 {
return models.ErrIncorrectToken
}
var resBody authResBody
err = json.NewDecoder(res.Body).Decode(&resBody)
if err != nil {
return models.ErrBridgeSearchFailed
}
log.Printf("Mill: Authenticated as %s", resBody.NickName)
b.userId = resBody.UserID
b.token = resBody.Token
b.mustRefreshBy, err = time.ParseInLocation("2006-01-02 15:04:05", resBody.TokenExpire, location)
}
return nil
}
func makeNonce() string {
buf := make([]byte, 8)
_, _ = io.ReadFull(rand.Reader, buf)
return fmt.Sprintf("%x", buf)
}
func addDefaultHeaders(req *http.Request) {
req.Header.Add("Content-Type", "application/x-zc-object")
req.Header.Add("Connection", "Keep-Alive")
req.Header.Add("X-Zc-Major-Domain", "seanywell")
req.Header.Add("X-Zc-Msg-Name", "millService")
req.Header.Add("X-Zc-Sub-Domain", "milltype")
req.Header.Add("X-Zc-Seq-Id", "1")
req.Header.Add("X-Zc-Version", "1")
}
+100
View File
@@ -0,0 +1,100 @@
package mill
import (
"context"
"fmt"
"git.aiterp.net/lucifer/new-server/models"
"sync"
"time"
)
type Driver struct {
mu sync.Mutex
bridges map[int]*bridge
}
func (d *Driver) SearchBridge(ctx context.Context, address, token string, _ bool) ([]models.Bridge, error) {
bridgeData := models.Bridge{
Name: fmt.Sprintf("Mill account (%s)", address),
Driver: models.DTMill,
Address: address,
Token: token,
}
b, err := d.ensureBridge(bridgeData)
if err != nil {
return nil, err
}
err = b.authenticate(ctx)
if err != nil {
return nil, err
}
return []models.Bridge{bridgeData}, nil
}
func (d *Driver) SearchDevices(context.Context, models.Bridge, time.Duration) ([]models.Device, error) {
// You would have to configure devices with the Mill app, unfortunately.
return []models.Device{}, nil
}
func (d *Driver) ListDevices(ctx context.Context, bridge models.Bridge) ([]models.Device, error) {
b, err := d.ensureBridge(bridge)
if err != nil {
return nil, err
}
return b.listDevices(ctx)
}
func (d *Driver) Publish(ctx context.Context, bridge models.Bridge, devices []models.Device) error {
b, err := d.ensureBridge(bridge)
if err != nil {
return err
}
for _, device := range devices {
err = b.pushStateChange(ctx, device)
if err != nil {
return err
}
}
return nil
}
func (d *Driver) Run(ctx context.Context, _ models.Bridge, _ chan<- models.Event) error {
// TODO: Maybe do something with the thermostat on the device
for {
select {
case <-ctx.Done():
return ctx.Err()
}
}
}
func (d *Driver) ensureBridge(model models.Bridge) (*bridge, error) {
d.mu.Lock()
defer d.mu.Unlock()
if d.bridges == nil {
d.bridges = make(map[int]*bridge, 4)
}
if d.bridges[model.ID] == nil {
newBridge := &bridge{
luciferID: model.ID,
username: model.Address,
password: model.Token,
}
if model.ID <= 0 {
return newBridge, nil
}
d.bridges[model.ID] = newBridge
}
return d.bridges[model.ID], nil
}
+77
View File
@@ -0,0 +1,77 @@
package mill
import "time"
const accountEndpoint = "https://eurouter.ablecloud.cn:9005/zc-account/v1/"
const serviceEndpoint = "https://eurouter.ablecloud.cn:9005/millService/v1/"
type millHome struct {
HomeID int64 `json:"homeId"`
HomeName string `json:"homeName"`
}
type millDevice struct {
DeviceID int `json:"deviceId"`
DeviceName string `json:"deviceName"`
PowerStatus int `json:"powerStatus"`
HolidayTemp int `json:"holidayTemp"`
CurrentTemp float64 `json:"currentTemp"`
SubDomainID int `json:"subDomainId"`
}
type authReqBody struct {
Account string `json:"account"`
Password string `json:"password"`
}
type authResBody struct {
Token string `json:"token"`
UserID int `json:"userId"`
NickName string `json:"nickName"`
TokenExpire string `json:"tokenExpire"`
}
type listHomeReqBody struct{}
type listHomeResBody struct {
HomeList []millHome `json:"homeList"`
}
type listDeviceReqBody struct {
HomeID int64 `json:"homeId"`
}
type listDeviceResBody struct {
DeviceInfo []millDevice `json:"deviceInfo"`
}
type changeInfoReqBody struct {
HomeType int `json:"homeType"`
DeviceID int `json:"deviceId"`
Value int `json:"value"`
TimeZoneNum string `json:"timeZoneNum"`
Key string `json:"key"`
}
type deviceControlReqBody struct {
SubDomain string `json:"subDomain"`
DeviceID int `json:"deviceId"`
TestStatus int `json:"testStatus"`
Operation int `json:"operation"`
Status int `json:"status"`
WindStatus int `json:"windStatus"`
TempType int `json:"tempType"`
PowerLevel int `json:"powerLevel"`
}
var location *time.Location
func init() {
myLocation, err := time.LoadLocation("Europe/Oslo")
if err != nil {
panic(err.Error())
}
location = myLocation
}
+43 -13
View File
@@ -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",
+12
View File
@@ -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,
+1 -1
View File
@@ -17,7 +17,7 @@ type Driver struct {
// SearchBridge checks the bridge at the address. If it's not a dry-run, you must hold down the power button // 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
+14
View File
@@ -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
}
+4 -4
View File
@@ -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)
} }
+421
View File
@@ -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
}
+161
View File
@@ -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
}
+99
View File
@@ -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
}
+133
View File
@@ -0,0 +1,133 @@
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) FindName(ctx context.Context, name string) (*models.Scene, error) {
var scene sceneRecord
err := r.DBX.GetContext(ctx, &scene, "SELECT * FROM scene WHERE name = ?", name)
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
}
+4 -1
View File
@@ -21,7 +21,10 @@ type DriverKind string
var ( var (
DTHue DriverKind = "Hue" DTHue DriverKind = "Hue"
DTNanoLeaf DriverKind = "NanoLeaf" DTHue2 DriverKind = "Hue2"
DTNanoLeaf DriverKind = "Nanoleaf"
DTLIFX DriverKind = "LIFX"
DTMill DriverKind = "Mill"
) )
var ValidDriverKinds = []DriverKind{ var ValidDriverKinds = []DriverKind{
+26
View File
@@ -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()
}
}
+87 -21
View File
@@ -8,9 +8,14 @@ import (
) )
type ColorValue struct { type ColorValue struct {
Hue float64 `json:"h,omitempty"` // 0..360 Hue float64 `json:"h,omitempty"` // 0..360
Saturation float64 `json:"s,omitempty"` // 0..=1 Saturation float64 `json:"s,omitempty"` // 0..=1
Kelvin int `json:"kelvin,omitempty"` Kelvin int `json:"kelvin,omitempty"`
XY *ColorXY `json:"xy,omitempty"`
}
func (c *ColorValue) IsEmpty() bool {
return c.XY == nil && c.Kelvin == 0 && c.Saturation == 0 && c.Hue == 0
} }
func (c *ColorValue) IsHueSat() bool { func (c *ColorValue) IsHueSat() bool {
@@ -18,15 +23,35 @@ func (c *ColorValue) IsHueSat() bool {
} }
func (c *ColorValue) IsKelvin() bool { func (c *ColorValue) IsKelvin() bool {
return c.Kelvin > 0 return !c.IsXY() && c.Kelvin > 0
}
func (c *ColorValue) IsXY() bool {
return c.XY != nil
}
// ToXY converts the color to XY if possible. If the color already is XY, it returns
// a copy of its held value. There are no guarantees of conforming to a gamut, however.
func (c *ColorValue) ToXY() (xy ColorXY, ok bool) {
if c.XY != nil {
xy = *c.XY
ok = true
} else if c.Kelvin > 0 && c.Hue < 0.001 && c.Saturation <= 0.001 {
ok = false
} else {
xy = hsToXY(c.Hue, c.Saturation)
ok = true
}
return
} }
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) {
@@ -35,28 +60,69 @@ func ParseColorValue(raw string) (ColorValue, error) {
return ColorValue{}, ErrBadInput return ColorValue{}, ErrBadInput
} }
if tokens[0] == "kelvin" { switch tokens[0] {
parsedPart, err := strconv.Atoi(tokens[1]) case "kelvin", "k":
if err != nil { {
return ColorValue{}, ErrBadInput parsedPart, err := strconv.Atoi(tokens[1])
if err != nil {
return ColorValue{}, ErrBadInput
}
return ColorValue{Kelvin: parsedPart}, nil
} }
return ColorValue{Kelvin: parsedPart}, nil case "xy":
} {
parts := strings.Split(tokens[1], ",")
if len(parts) < 2 {
return ColorValue{}, ErrUnknownColorFormat
}
if tokens[0] == "hs" { x, err1 := strconv.ParseFloat(parts[0], 64)
parts := strings.Split(tokens[1], ",") y, err2 := strconv.ParseFloat(parts[1], 64)
if len(parts) < 2 { if err1 != nil || err2 != nil {
return ColorValue{}, ErrUnknownColorFormat return ColorValue{}, ErrBadInput
}
return ColorValue{XY: &ColorXY{X: x, Y: y}}, nil
} }
part1, err1 := strconv.ParseFloat(parts[0], 64) case "hs":
part2, err2 := strconv.ParseFloat(parts[1], 64) {
if err1 != nil || err2 != nil { parts := strings.Split(tokens[1], ",")
return ColorValue{}, ErrBadInput if len(parts) < 2 {
return ColorValue{}, ErrUnknownColorFormat
}
part1, err1 := strconv.ParseFloat(parts[0], 64)
part2, err2 := strconv.ParseFloat(parts[1], 64)
if err1 != nil || err2 != nil {
return ColorValue{}, ErrBadInput
}
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 case "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
+204
View File
@@ -0,0 +1,204 @@
package models
import (
"github.com/lucasb-eyer/go-colorful"
"math"
)
const eps = 0.0001
const epsSquare = eps * eps
type ColorGamut struct {
Red ColorXY `json:"red"`
Green ColorXY `json:"green"`
Blue ColorXY `json:"blue"`
}
func (cg *ColorGamut) side(x1, y1, x2, y2, x, y float64) float64 {
return (y2-y1)*(x-x1) + (-x2+x1)*(y-y1)
}
func (cg *ColorGamut) naiveContains(color ColorXY) bool {
x, y := color.X, color.Y
x1, y1 := cg.Red.X, cg.Red.Y
x2, y2 := cg.Green.X, cg.Green.Y
x3, y3 := cg.Blue.X, cg.Blue.Y
checkSide1 := cg.side(x1, y1, x2, y2, x, y) < 0
checkSide2 := cg.side(x2, y2, x3, y3, x, y) < 0
checkSide3 := cg.side(x3, y3, x1, y1, x, y) < 0
return checkSide1 && checkSide2 && checkSide3
}
func (cg *ColorGamut) getBounds() (xMin, xMax, yMin, yMax float64) {
x1, y1 := cg.Red.X, cg.Red.Y
x2, y2 := cg.Green.X, cg.Green.Y
x3, y3 := cg.Blue.X, cg.Blue.Y
xMin = math.Min(x1, math.Min(x2, x3)) - eps
xMax = math.Max(x1, math.Max(x2, x3)) + eps
yMin = math.Min(y1, math.Min(y2, y3)) - eps
yMax = math.Max(y1, math.Max(y2, y3)) + eps
return
}
func (cg *ColorGamut) isInBounds(color ColorXY) bool {
x, y := color.X, color.Y
xMin, xMax, yMin, yMax := cg.getBounds()
return !(x < xMin || xMax < x || y < yMin || yMax < y)
}
func (cg *ColorGamut) distanceSquarePointToSegment(x1, y1, x2, y2, x, y float64) float64 {
sqLength1 := (x2-x1)*(x2-x1) + (y2-y1)*(y2-y1)
dotProduct := ((x-x1)*(x2-x1) + (y-y1)*(y2-y1)) / sqLength1
if dotProduct < 0 {
return (x-x1)*(x-x1) + (y-y1)*(y-y1)
} else if dotProduct <= 1 {
sqLength2 := (x1-x)*(x1-x) + (y1-y)*(y1-y)
return sqLength2 - dotProduct*dotProduct*sqLength1
} else {
return (x-x2)*(x-x2) + (y-y2)*(y-y2)
}
}
func (cg *ColorGamut) atTheEdge(color ColorXY) bool {
x, y := color.X, color.Y
x1, y1 := cg.Red.X, cg.Red.Y
x2, y2 := cg.Green.X, cg.Green.Y
x3, y3 := cg.Blue.X, cg.Blue.Y
if cg.distanceSquarePointToSegment(x1, y1, x2, y2, x, y) <= epsSquare {
return true
}
if cg.distanceSquarePointToSegment(x2, y2, x3, y3, x, y) <= epsSquare {
return true
}
if cg.distanceSquarePointToSegment(x3, y3, x1, y1, x, y) <= epsSquare {
return true
}
return false
}
func (cg *ColorGamut) Contains(color ColorXY) bool {
if cg == nil {
return true
}
return cg.isInBounds(color) && (cg.naiveContains(color) || cg.atTheEdge(color))
}
func (cg *ColorGamut) Conform(color ColorXY) ColorXY {
if cg.Contains(color) {
return color
}
var best *ColorXY
xMin, xMax, yMin, yMax := cg.getBounds()
for x := xMin; x < xMax; x += 0.001 {
for y := yMin; y < yMax; y += 0.001 {
color2 := ColorXY{X: x, Y: y}
if cg.Contains(color2) {
if best == nil || color.DistanceTo(color2) < color.DistanceTo(*best) {
best = &color2
}
}
}
}
if best == nil {
centerX := (cg.Red.X + cg.Green.X + cg.Blue.X) / 3
centerY := (cg.Red.Y + cg.Green.Y + cg.Blue.Y) / 3
stepX := (centerX - color.X) / 5000
stepY := (centerY - color.Y) / 5000
for !cg.Contains(color) {
color.X += stepX
color.Y += stepY
}
return color
}
for x := best.X - 0.001; x < best.X+0.001; x += 0.0002 {
for y := best.Y - 0.001; y < best.Y+0.001; y += 0.0002 {
color2 := ColorXY{X: x, Y: y}
if cg.atTheEdge(color2) {
if best == nil || color.DistanceTo(color2) < color.DistanceTo(*best) {
best = &color2
}
}
}
}
for x := best.X - 0.0001; x < best.X+0.0001; x += 0.00003 {
for y := best.Y - 0.0001; y < best.Y+0.0001; y += 0.00003 {
color2 := ColorXY{X: x, Y: y}
if cg.atTheEdge(color2) {
if best == nil || color.DistanceTo(color2) < color.DistanceTo(*best) {
best = &color2
}
}
}
}
return *best
}
type ColorXY struct {
X float64 `json:"x"`
Y float64 `json:"y"`
}
func (xy ColorXY) DistanceTo(other ColorXY) float64 {
return math.Sqrt(math.Pow(xy.X-other.X, 2) + math.Pow(xy.Y-other.Y, 2))
}
func (xy ColorXY) Round() ColorXY {
return ColorXY{
X: math.Round(xy.X*10000) / 10000,
Y: math.Round(xy.Y*10000) / 10000,
}
}
func hsToXY(hue, sat float64) ColorXY {
c := colorful.Hsv(hue, sat, 1)
red255, green255, blue255 := c.RGB255()
red := float64(red255) / 255.0
green := float64(green255) / 255.0
blue := float64(blue255) / 255.0
return rgbToXY(red, green, blue)
}
func rgbToXY(red float64, green float64, blue float64) ColorXY {
x := red*0.649926 + green*0.103455 + blue*0.197109
y := red*0.234327 + green*0.743075 + blue*0.022598
z := green*0.053077 + blue*1.035763
return ColorXY{
X: x / (x + y + z),
Y: y / (x + y + z),
}
}
func screenRGBToXY(red, green, blue float64) ColorXY {
for _, component := range []*float64{&red, &green, &blue} {
if *component > 0.04045 {
*component = math.Pow((*component+0.055)/(1.055), 2.4)
} else {
*component /= 12.92
}
}
return rgbToXY(red, green, blue)
}
+173 -29
View File
@@ -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,61 @@ 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" DCColorXY DeviceCapability = "ColorXY"
DCTemperature DeviceCapability = "Temperature" DCButtons DeviceCapability = "Buttons"
DCPresence DeviceCapability = "Presence"
DCIntensity DeviceCapability = "Intensity"
DCTemperatureControl DeviceCapability = "TemperatureControl"
DCTemperatureSensor DeviceCapability = "TemperatureSensor"
) )
var Capabilities = []DeviceCapability{ var Capabilities = []DeviceCapability{
@@ -61,8 +99,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 +144,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 +190,70 @@ 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
}
if newState.Temperature != nil && d.HasCapability(DCTemperatureControl) {
d.State.Temperature = *newState.Temperature
}
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))
}
+5 -1
View File
@@ -10,9 +10,13 @@ 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
Run(ctx context.Context, bridge Bridge, ch chan<- Event) error Run(ctx context.Context, bridge Bridge, ch chan<- Event) error
} }
type ForgettableDriver interface {
ForgetDevice(ctx context.Context, bridge Bridge, device Device) error
}
+18
View File
@@ -8,8 +8,26 @@ 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 ErrCannotForget = errors.New("forget not supported on this bridge type")
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
View File
@@ -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
View File
@@ -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, ";")
}
+314
View File
@@ -0,0 +1,314 @@
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"`
SceneName string `json:"sceneName"`
Group string `json:"group"`
StartTime time.Time `json:"start"`
DurationMS int64 `json:"durationMs"`
}
type SceneRepository interface {
Find(ctx context.Context, id int) (*Scene, error)
FindName(ctx context.Context, name string) (*Scene, error)
FetchAll(ctx context.Context) ([]Scene, error)
Save(ctx context.Context, bridge *Scene) error
Delete(ctx context.Context, bridge *Scene) error
}
+79 -3
View File
@@ -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
}
}
+140
View File
@@ -0,0 +1,140 @@
package models
import (
"bytes"
"encoding/json"
"fmt"
"strconv"
"strings"
"time"
)
var TimeOfDayTimeZone = time.UTC
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().In(TimeOfDayTimeZone))
}
func TimeOfDayFromDate(date time.Time) TimeOfDay {
localDate := date.In(TimeOfDayTimeZone)
return NewTimeOfDay(localDate.Hour(), localDate.Minute(), localDate.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
}
+42
View File
@@ -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()
}
})
}
}
+63
View File
@@ -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
+26
View File
@@ -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
+57
View File
@@ -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
+40
View File
@@ -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
+42
View File
@@ -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
+3 -2
View File
@@ -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
+1 -1
View File
@@ -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)
); );
+17
View File
@@ -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
+16
View File
@@ -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
+21
View File
@@ -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
+17
View File
@@ -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
@@ -0,0 +1,9 @@
-- +goose Up
-- +goose StatementBegin
CREATE UNIQUE INDEX scene_name ON scene (name);
-- +goose StatementEnd
-- +goose Down
-- +goose StatementBegin
DROP INDEX IF EXISTS scene_name;
-- +goose StatementEnd
+2 -1
View File
@@ -1 +1,2 @@
node_modules/ node_modules/
yarn.error.log
+9 -1
View File
@@ -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
View File
@@ -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>&nbsp;</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>
); );
} }

Some files were not shown because too many files have changed in this diff Show More