42 Commits
Author SHA1 Message Date
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
Stian Fredrik Aune 11ef2997c8 Merge branch 'asmodeus' of git.aiterp.net:lucifer/new-server into asmodeus 2021-05-23 14:00:51 +02:00
Stian Fredrik Aune 21157e4330 my shit 2021-05-23 14:00:42 +02:00
Stian Fredrik Aune f7f8989d20 my shit 2021-05-23 14:00:30 +02:00
90 changed files with 18642 additions and 11745 deletions
+40
View File
@@ -0,0 +1,40 @@
name: lucifer3
kind: pipeline
type: docker
steps:
- name: go-build
image: golang:1.17
commands:
- go test -v ./...
when:
event:
exclude:
- tag
- name: go-test
image: golang:1.17
commands:
- 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
.idea/
*.iml
/bridgetest
/server
/goose
/bridgetest.exe
/server.exe
/goose.exe
/10.24.4.1
+144
View File
@@ -0,0 +1,144 @@
package api
import (
"fmt"
"git.aiterp.net/lucifer/new-server/app/config"
"git.aiterp.net/lucifer/new-server/models"
"github.com/gin-gonic/gin"
"log"
"time"
)
func Bridges(r gin.IRoutes) {
r.GET("", handler(func(c *gin.Context) (interface{}, error) {
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) {
var body struct {
Driver models.DriverKind `json:"driver"`
Address string `json:"address"`
Token string `json:"token"`
ForceSave bool `json:"forceSave"`
DryRun bool `json:"dryRun"`
}
err := parseBody(c, &body)
if err != nil {
return nil, err
}
driver, err := config.DriverProvider().Provide(body.Driver)
if err != nil {
return nil, err
}
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.DryRun)
if err != nil {
return nil, err
}
if !body.DryRun {
for _, bridge := range bridges {
err := config.BridgeRepository().Save(ctxOf(c), &bridge)
if err != nil {
return nil, err
}
log.Printf("Saved new bridge: %s (%s)", bridge.Name, bridge.Address)
}
}
return bridges, nil
}))
r.PUT("/:id", handler(func(c *gin.Context) (interface{}, error) {
var body struct {
Name *string `json:"name"`
}
err := parseBody(c, &body)
if err != nil {
return nil, err
}
bridge, err := config.BridgeRepository().Find(ctxOf(c), intParam(c, "id"))
if err != nil {
return nil, err
}
if body.Name != nil {
bridge.Name = *body.Name
}
err = config.BridgeRepository().Save(ctxOf(c), &bridge)
if err != nil {
return nil, err
}
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) {
bridge, err := config.BridgeRepository().Find(ctxOf(c), intParam(c, "id"))
if err != nil {
return nil, err
}
err = config.BridgeRepository().Delete(ctxOf(c), &bridge)
if err != nil {
return nil, err
}
return bridge, nil
}))
}
+215
View File
@@ -0,0 +1,215 @@
package api
import (
"context"
"git.aiterp.net/lucifer/new-server/app/config"
"git.aiterp.net/lucifer/new-server/models"
"github.com/gin-gonic/gin"
"log"
"strconv"
"strings"
)
func fetchDevices(ctx context.Context, fetchStr string) ([]models.Device, error) {
if strings.HasPrefix(fetchStr, "tag:") {
return config.DeviceRepository().FetchByReference(ctx, models.RKTag, fetchStr[4:])
} else if strings.HasPrefix(fetchStr, "bridge:") {
return config.DeviceRepository().FetchByReference(ctx, models.RKBridgeID, fetchStr[7:])
} else if strings.HasPrefix(fetchStr, "id:") {
return config.DeviceRepository().FetchByReference(ctx, models.RKDeviceID, fetchStr[7:])
} else if strings.HasPrefix(fetchStr, "name:") {
return config.DeviceRepository().FetchByReference(ctx, models.RKName, fetchStr[7:])
}else if fetchStr == "all" {
return config.DeviceRepository().FetchByReference(ctx, models.RKAll, "")
} else {
_, err := strconv.Atoi(fetchStr)
if err != nil {
return config.DeviceRepository().FetchByReference(ctx, models.RKName, fetchStr)
}
return config.DeviceRepository().FetchByReference(ctx, models.RKDeviceID, fetchStr)
}
}
func Devices(r gin.IRoutes) {
r.GET("", handler(func(c *gin.Context) (interface{}, error) {
return config.DeviceRepository().FetchByReference(ctxOf(c), models.RKAll, "")
}))
r.GET("/:fetch", handler(func(c *gin.Context) (interface{}, error) {
return fetchDevices(ctxOf(c), c.Param("fetch"))
}))
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)
if err != nil {
log.Println("Failed to save device for state:", err)
continue
}
}
}()
return 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])
if err != nil {
log.Println("Failed to save device for state:", err)
continue
}
}
return 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
go func() {
for _, device := range devices {
err := config.DeviceRepository().Save(context.Background(), &device)
if err != nil {
log.Println("Failed to save device for state:", err)
continue
}
}
}()
return 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)
if err != nil {
return nil, err
}
}
return devices, nil
}))
}
+71
View File
@@ -0,0 +1,71 @@
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) {
var body models.EventHandlerUpdate
err := parseBody(c, &body)
if err != nil {
return nil, err
}
handler, err := config.EventHandlerRepository().FindByID(ctxOf(c), intParam(c, "id"))
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
}))
}
+27 -3
View File
@@ -1,13 +1,20 @@
package api
import (
"context"
"encoding/json"
"git.aiterp.net/lucifer/new-server/models"
"github.com/gin-gonic/gin"
"strconv"
)
var errorMap = map[error]int{
models.ErrInvalidName: 400,
models.ErrNotFound: 404,
models.ErrInvalidName: 400,
models.ErrBadInput: 400,
models.ErrBadColor: 400,
models.ErrInternal: 500,
models.ErrUnknownColorFormat: 400,
}
type response struct {
@@ -26,16 +33,29 @@ func handler(fun func(c *gin.Context) (interface{}, error)) gin.HandlerFunc {
}
c.JSON(errCode, response{
Code: errCode,
Code: errCode,
Message: err.Error(),
})
return
}
c.JSON(200, val)
c.JSON(200, response{
Code: 200,
Message: "success",
Data: val,
})
}
}
func intParam(c *gin.Context, key string) int {
i, err := strconv.Atoi(c.Param(key))
if err != nil {
return 0
}
return i
}
func parseBody(c *gin.Context, target interface{}) error {
err := json.NewDecoder(c.Request.Body).Decode(target)
if err != nil {
@@ -44,3 +64,7 @@ func parseBody(c *gin.Context, target interface{}) error {
return nil
}
func ctxOf(c *gin.Context) context.Context {
return c.Request.Context()
}
+136
View File
@@ -0,0 +1,136 @@
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) 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,
}
+2
View File
@@ -5,3 +5,5 @@ import "git.aiterp.net/lucifer/new-server/models"
var EventChannel = make(chan models.Event, 8)
var ChangeChannel = make(chan string, 16)
var PublishChannel = make(chan []models.Device, 32)
+3
View File
@@ -23,6 +23,9 @@ func DBX() *sqlx.DB {
MySqlPort(),
MySqlSchema(),
))
dbx.SetMaxIdleConns(50)
dbx.SetMaxOpenConns(100)
}
return dbx
+16 -4
View File
@@ -1,13 +1,25 @@
package config
import "git.aiterp.net/lucifer/new-server/models"
import (
"git.aiterp.net/lucifer/new-server/internal/drivers"
"git.aiterp.net/lucifer/new-server/internal/drivers/hue"
"git.aiterp.net/lucifer/new-server/internal/drivers/lifx"
"git.aiterp.net/lucifer/new-server/internal/drivers/nanoleaf"
"git.aiterp.net/lucifer/new-server/models"
"sync"
)
var dp models.DriverProvider
var dpOnce sync.Once
func DriverProvider() models.DriverProvider {
if dp == nil {
panic("not implemented yet")
}
dpOnce.Do(func() {
dp = drivers.DriverMap{
models.DTNanoLeaf: &nanoleaf.Driver{},
models.DTHue: &hue.Driver{},
models.DTLIFX: &lifx.Driver{},
}
})
return dp
}
+13 -15
View File
@@ -1,24 +1,22 @@
package config
import "git.aiterp.net/lucifer/new-server/models"
var (
dRepo models.DeviceRepository
ehRepo models.EventHandlerRepository
import (
"git.aiterp.net/lucifer/new-server/internal/mysql"
"git.aiterp.net/lucifer/new-server/models"
)
func DeviceRepository() models.DeviceRepository {
if dRepo == nil {
panic("panik")
}
func BridgeRepository() models.BridgeRepository {
return &mysql.BridgeRepo{DBX: DBX()}
}
return dRepo
func ColorPresetRepository() models.ColorPresetRepository {
return &mysql.ColorPresetRepo{DBX: DBX()}
}
func DeviceRepository() models.DeviceRepository {
return &mysql.DeviceRepo{DBX: DBX()}
}
func EventHandlerRepository() models.EventHandlerRepository {
if ehRepo == nil {
panic("panik")
}
return ehRepo
return &mysql.EventHandlerRepo{DBX: DBX()}
}
+8 -1
View File
@@ -11,13 +11,20 @@ import (
func StartServer() {
services.StartEventHandler()
services.StartPublisher()
services.ConnectToBridges()
services.CheckNewDevices()
gin.SetMode(gin.ReleaseMode)
ginny := gin.New()
apiGin := ginny.Group("/api")
api.Bridges(apiGin.Group("/bridges"))
api.Devices(apiGin.Group("/devices"))
api.ColorPresets(apiGin.Group("/color-presets"))
api.DriverKinds(apiGin.Group("/driver-kinds"))
api.Events(apiGin.Group("/events"))
api.EventHandlers(apiGin.Group("/event-handlers"))
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())))
}
+76
View File
@@ -0,0 +1,76 @@
package services
import (
"context"
"git.aiterp.net/lucifer/new-server/app/config"
"git.aiterp.net/lucifer/new-server/models"
"log"
"strconv"
"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()) {
savedDevices, err := config.DeviceRepository().FetchByReference(ctx, models.RKBridgeID, strconv.Itoa(bridge.ID))
if err != nil {
log.Println("Failed to fetch devices from db for refresh:", err)
}
err = driver.Publish(ctx, bridge, savedDevices)
if err != nil {
log.Println("Failed to publish devices from db before run:", err)
}
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
}
+153 -11
View File
@@ -2,37 +2,60 @@ package services
import (
"context"
"fmt"
"git.aiterp.net/lucifer/new-server/app/config"
"git.aiterp.net/lucifer/new-server/models"
"log"
"math"
"sort"
"strconv"
"strings"
"sync"
"time"
)
func StartEventHandler() {
config.EventChannel<-models.Event{Name: "LuciferStarted"}
config.EventChannel <- models.Event{Name: "LuciferStarted"}
go func() {
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() {
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 {
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 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") {
event.AddPayload("hour", time.Now().In(loc).Format("15"))
}
@@ -40,20 +63,40 @@ func handleEvent(event models.Event) {
event.AddPayload("minute", time.Now().In(loc).Format("04"))
}
if !X {
err := handleSpecial(event)
if err != nil {
log.Printf("Special event handler error (%s): %v", event.Name, err)
return
}
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)
if err != nil {
log.Printf("Error fetchin event halders: %d", err)
log.Printf("Error fetchin' event halders: %s", err)
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 {
if handler.EventName != event.Name {
continue
}
devices, err := config.DeviceRepository().FetchByReference(ctx, handler.TargetKind, handler.TargetValue)
if err != nil {
log.Printf("Error fetchin event halders: %d", err)
log.Printf("Error fetchin' event devices: %s", err)
return
}
@@ -61,7 +104,106 @@ func handleEvent(event models.Event) {
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
}
}
}
log.Println("Unhandled event: " + event.Name)
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
}
err = allDevices[i].SetState(newState)
if err != nil {
log.Println("Error updating state for device", device.ID, "err:", err)
}
}
config.PublishChannel <- allDevices
wg := sync.WaitGroup{}
for _, device := range allDevices {
wg.Add(1)
go func(device models.Device) {
err := config.DeviceRepository().Save(context.Background(), &device)
if err != nil {
log.Println("Failed to save device for state:", err)
}
wg.Done()
}(device)
}
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)
default:
return nil
}
}
+76
View File
@@ -0,0 +1,76 @@
package services
import (
"context"
"git.aiterp.net/lucifer/new-server/app/config"
"git.aiterp.net/lucifer/new-server/models"
"log"
"sync"
"time"
)
func StartPublisher() {
ctx := context.Background()
go func() {
for devices := range config.PublishChannel {
if len(devices) == 0 {
continue
}
lists := make(map[int][]models.Device, 4)
for _, device := range devices {
lists[device.BridgeID] = append(lists[device.BridgeID], device)
}
ctx, cancel := context.WithTimeout(ctx, time.Second * 30)
bridges, err := config.BridgeRepository().FetchAll(ctx)
if err != nil {
log.Println("Publishing error (1): " + err.Error())
cancel()
continue
}
wg := sync.WaitGroup{}
for _, devices := range lists {
wg.Add(1)
go func(devices []models.Device) {
defer wg.Done()
var bridge models.Bridge
for _, bridge2 := range bridges {
if bridge2.ID == devices[0].BridgeID {
bridge = bridge2
}
}
if bridge.ID == 0 {
log.Println("Unknown bridge")
}
bridge, err := config.BridgeRepository().Find(ctx, devices[0].BridgeID)
if err != nil {
log.Println("Publishing error (1): " + err.Error())
return
}
driver, err := config.DriverProvider().Provide(bridge.Driver)
if err != nil {
log.Println("Publishing error (2): " + err.Error())
return
}
err = driver.Publish(ctx, bridge, devices)
if err != nil {
log.Println("Publishing error (3): " + err.Error())
return
}
}(devices)
}
wg.Wait()
cancel()
}
}()
}
+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)
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
}
+58 -21
View File
@@ -3,9 +3,10 @@ package main
import (
"bufio"
"context"
"encoding/json"
"flag"
"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"
"log"
"os"
@@ -15,7 +16,7 @@ import (
"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 flagToken = flag.String("token", "", "The bridge's access token / api key / login")
var flagPair = flag.Bool("pair", false, "Try to pair with the bridge.")
@@ -25,8 +26,11 @@ var flagSearchTimeout = flag.Duration("search-timeout", time.Second*3, "Timeout
func main() {
flag.Parse()
// TODO: Select driver
driver := nanoleaf.Driver{}
// Find drivers
driver, err := config.DriverProvider().Provide(models.DriverKind(*flagDriver))
if err != nil {
log.Fatalln("Failed to find driver:", err)
}
// Find bridge
bridges, err := driver.SearchBridge(context.Background(), *flagAddress, !*flagPair)
@@ -43,6 +47,15 @@ func main() {
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
var devices []models.Device
if *flagSearch {
@@ -56,20 +69,14 @@ func main() {
log.Fatalln("Failed to list devices:", err)
}
}
idMap := make(map[string]int)
nextId := len(devices) + 1
for i := range devices {
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() {
reader := bufio.NewReader(os.Stdin)
@@ -83,6 +90,35 @@ func main() {
text, _ := reader.ReadString('\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, " ")
if len(tokens) < 4 {
continue
@@ -120,14 +156,15 @@ func main() {
if id == -1 || id == device.ID {
if (color.IsKelvin() && device.HasCapability(models.DCColorKelvin)) || (color.IsHueSat() && device.HasCapability(models.DCColorHS)) {
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)
}
}
}
+11 -3
View File
@@ -4,20 +4,28 @@ import (
"git.aiterp.net/lucifer/new-server/app/config"
"github.com/pressly/goose"
"log"
"os"
"time"
)
func main() {
db := config.DBX().DB
log.Println("Host: " + config.MySqlHost())
log.Println("Schema: " + config.MySqlUsername())
log.Printf("Target version: %s",time.Now().Format("20060102150405"))
log.Printf("Database: %s:%d/%s", config.MySqlHost(), config.MySqlPort(), config.MySqlSchema())
log.Printf("Authenticating as: %s", config.MySqlUsername())
err := goose.SetDialect("mysql")
if err != nil {
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 {
log.Fatal(err)
}
+183
View File
@@ -0,0 +1,183 @@
package main
import (
"log"
"strconv"
"strings"
)
type Param struct {
Index int
Key string
Value string
}
func (p *Param) String() *string {
if p == nil {
return nil
}
return &p.Value
}
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) 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})
}
}
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
}
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:] {
kv := strings.SplitN(arg, "=", 2)
if len(kv) == 2 {
cmd.Params = append(cmd.Params, Param{Index: -1, Key: kv[0], Value: kv[1]})
} 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
}
+18
View File
@@ -0,0 +1,18 @@
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 COMMANDS
run <event name> <*=S>
`
+121
View File
@@ -0,0 +1,121 @@
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"}
}
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(context.Background(), *fetchStr)
if err != nil {
log.Fatalln(err)
}
WriteDeviceStateTable(os.Stdout, devices)
}
case "set":
{
devices, err := c.PutDeviceState(context.Background(), cmd.Params.Get(0).StringOr("all"), models.NewDeviceState{
Power: cmd.Params.Get("power").Bool(),
Color: cmd.Params.Get("color").String(),
Intensity: cmd.Params.Get("intensity").Float(),
Temperature: cmd.Params.Get("temperature").Int(),
})
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(
context.Background(),
cmd.Params.Get(0).StringOr("all"),
addTags,
removeTags,
)
if err != nil {
log.Fatalln(err)
}
WriteDeviceInfoTable(os.Stdout, devices)
}
case "update":
{
devices, err := c.PutDevice(context.Background(), 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)
}
// 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(context.Background(), event)
if err != nil {
log.Fatalln(err)
}
}
default:
if cmd.Name != "help" {
log.Println("Unknown command:", cmd.Name)
}
_, _ = fmt.Fprintln(os.Stderr, helpString[1:])
}
}
+74
View File
@@ -0,0 +1,74 @@
package main
import (
"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)
for _, v := range devices {
powerStr := ""
if v.HasCapability(models.DCPower) {
powerStr = strconv.FormatBool(v.State.Power)
}
colorStr := ""
if v.HasCapability(models.DCColorHSK, models.DCColorHS, models.DCColorKelvin) {
colorStr = v.State.Color.String()
}
temperatureString := ""
if v.HasCapability(models.DCTemperatureControl, models.DCTemperatureSensor) {
temperatureString = strconv.FormatFloat(v.State.Temperature, 'f', -1, 64)
}
intensityString := ""
if v.HasCapability(models.DCIntensity) {
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()
}
+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
require (
github.com/Masterminds/squirrel v1.5.0
github.com/gin-gonic/gin v1.7.1
github.com/go-sql-driver/mysql v1.6.0
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/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/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
)
+19 -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.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
@@ -23,15 +25,21 @@ 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/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/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/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.10.2 h1:AqzbZs4ZoCBp+GtejcpCpcxM3zlSMx29dXbUSeVtJb8=
github.com/lib/pq v1.10.2/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
github.com/lib/pq v1.10.3 h1:v9QZf2Sn6AmjXtQeFpdoq/eaNtYP6IN+7lcrygsIAtg=
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/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
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-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 v2.0.3+incompatible h1:gXHsfypPkaMZrKbD5209QV9jbUTJKjyR5WD3HYQSd+U=
github.com/mattn/go-sqlite3 v2.0.3+incompatible/go.mod h1:FPy6KqzDD04eiIsT53CuJW3U88zkxoIYsOqkbpncsNc=
@@ -39,6 +47,8 @@ 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/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/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/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
@@ -46,6 +56,7 @@ github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZN
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/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.4.0 h1:2E4SXV/wtOkTonXsotYi4li6zVWxYlZuYNCXe9XRJyk=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
@@ -55,15 +66,18 @@ 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=
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-20210513164829-c07d793c2f9a h1:kr2P4QFmQr29mSLA43kwrOcgcReGTfbE9N577tCTuBc=
golang.org/x/crypto v0.0.0-20210513164829-c07d793c2f9a/go.mod h1:P+XmwS30IXTQdn5tA2iutPOUgjI07+tq3H3K9MVA1s8=
golang.org/x/crypto v0.0.0-20210915214749-c084706c2272 h1:3erb+vDS8lU1sxfDHF4/hhWyaXnhIaO+7RgL4fDZORA=
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-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-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-20201119102817-f84b799fce68 h1:nxC68pudNYkKU6jWhgrqdreuFiOQWj1Fs7T3VrH4Pjw=
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/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
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=
+318
View File
@@ -0,0 +1,318 @@
package hue
import (
"bytes"
"context"
"encoding/json"
"fmt"
"git.aiterp.net/lucifer/new-server/models"
"golang.org/x/sync/errgroup"
"io"
"net"
"net/http"
"strings"
"sync"
"time"
)
type Bridge struct {
mu sync.Mutex
host string
token string
externalID int
lightStates []*hueLightState
sensorStates []*hueSensorState
syncingPublish uint32
}
func (b *Bridge) Refresh(ctx context.Context) error {
lightMap, err := b.getLights(ctx)
if err != nil {
return err
}
b.mu.Lock()
for index, light := range lightMap {
var state *hueLightState
for _, existingState := range b.lightStates {
if existingState.index == index {
state = existingState
}
}
if state == nil {
state = &hueLightState{
index: index,
uniqueID: light.Uniqueid,
externalID: -1,
info: light,
}
b.lightStates = append(b.lightStates, state)
} else {
if light.Uniqueid != state.uniqueID {
state.uniqueID = light.Uniqueid
state.externalID = -1
}
}
state.CheckStaleness(light.State)
}
b.mu.Unlock()
sensorMap, err := b.getSensors(ctx)
if err != nil {
return err
}
b.mu.Lock()
for index, sensor := range sensorMap {
var state *hueSensorState
for _, existingState := range b.sensorStates {
if existingState.index == index {
state = existingState
}
}
if state == nil {
state = &hueSensorState{
index: index,
uniqueID: sensor.UniqueID,
externalID: -1,
}
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) 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) 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) 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) 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,
}
+197
View File
@@ -0,0 +1,197 @@
package hue
import (
"encoding/xml"
"errors"
)
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"`
}
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"`
}
var buttonNames = []string{"On", "DimUp", "DimDown", "Off"}
var errLinkButtonNotPressed = errors.New("link button not pressed")
+410
View File
@@ -0,0 +1,410 @@
package hue
import (
"context"
"encoding/json"
"encoding/xml"
"fmt"
"git.aiterp.net/lucifer/new-server/models"
"log"
"net/http"
"strconv"
"sync"
"sync/atomic"
"time"
)
type Driver struct {
mu sync.Mutex
bridges []*Bridge
}
func (d *Driver) SearchBridge(ctx context.Context, address string, dryRun bool) ([]models.Bridge, error) {
if address == "" {
if !dryRun {
return nil, models.ErrAddressOnlyDryRunnable
}
res, err := http.Get("https://discovery.meethue.com")
if err != nil {
return nil, err
}
defer res.Body.Close()
entries := make([]DiscoveryEntry, 0, 8)
err = json.NewDecoder(res.Body).Decode(&entries)
if err != nil {
return nil, err
}
bridges := make([]models.Bridge, 0, len(entries))
for _, entry := range entries {
bridges = append(bridges, models.Bridge{
ID: -1,
Name: entry.Id,
Driver: models.DTHue,
Address: entry.InternalIPAddress,
Token: "",
})
}
return bridges, nil
}
deviceInfo := BridgeDeviceInfo{}
res, err := http.Get(fmt.Sprintf("http://%s/description.xml", address))
if err != nil {
return nil, err
}
defer res.Body.Close()
err = xml.NewDecoder(res.Body).Decode(&deviceInfo)
if err != nil {
return nil, err
}
bridge := models.Bridge{
ID: -1,
Name: deviceInfo.Device.FriendlyName,
Driver: models.DTHue,
Address: address,
Token: "",
}
if !dryRun {
b := &Bridge{host: address}
timeout, cancel := context.WithTimeout(ctx, time.Second*30)
defer cancel()
ticker := time.NewTicker(time.Second)
defer ticker.Stop()
for range ticker.C {
token, err := b.getToken(timeout)
if err != nil {
if err == errLinkButtonNotPressed {
continue
}
return nil, err
}
bridge.Token = token
b.token = token
break
}
}
return []models.Bridge{bridge}, nil
}
func (d *Driver) SearchDevices(ctx context.Context, bridge models.Bridge, timeout time.Duration) ([]models.Device, error) {
b, err := d.ensureBridge(ctx, bridge)
if err != nil {
return nil, err
}
before, err := d.ListDevices(ctx, bridge)
if err != nil {
return nil, err
}
if timeout.Seconds() < 10 {
timeout = time.Second * 10
}
halfTime := timeout / 2
err = b.StartDiscovery(ctx, "sensors")
if err != nil {
return nil, err
}
select {
case <-time.After(halfTime):
case <-ctx.Done():
return nil, ctx.Err()
}
err = b.StartDiscovery(ctx, "lights")
if err != nil {
return nil, err
}
select {
case <-time.After(halfTime):
case <-ctx.Done():
return nil, ctx.Err()
}
err = b.Refresh(ctx)
if err != nil {
return nil, err
}
after, err := d.ListDevices(ctx, bridge)
if err != nil {
return nil, err
}
intersection := make([]models.Device, 0, 4)
for _, device := range after {
found := false
for _, device2 := range before {
if device2.InternalID == device.InternalID {
found = true
break
}
}
if !found {
intersection = append(intersection, device)
}
}
return intersection, nil
}
func (d *Driver) ListDevices(ctx context.Context, bridge models.Bridge) ([]models.Device, error) {
b, err := d.ensureBridge(ctx, bridge)
if err != nil {
return nil, err
}
devices := make([]models.Device, 0, 8)
lightMap, err := b.getLights(ctx)
if err != nil {
return nil, err
}
for _, lightInfo := range lightMap {
device := models.Device{
ID: -1,
BridgeID: b.externalID,
InternalID: lightInfo.Uniqueid,
Icon: "lightbulb",
Name: lightInfo.Name,
Capabilities: []models.DeviceCapability{
models.DCPower,
},
ButtonNames: nil,
DriverProperties: map[string]interface{}{
"modelId": lightInfo.Modelid,
"productName": lightInfo.Productname,
"swVersion": lightInfo.Swversion,
"hueLightType": lightInfo.Type,
},
UserProperties: nil,
State: models.DeviceState{},
Tags: nil,
}
hasDimming := false
hasCT := false
hasColor := false
switch lightInfo.Type {
case "On/off light":
// Always take DCPower for granted anyway.
case "Dimmable light":
hasDimming = true
case "Color temperature light":
hasDimming = true
hasCT = true
case "Color light":
hasDimming = true
hasColor = true
case "Extended color light":
hasDimming = true
hasColor = true
hasCT = true
}
ctrl := lightInfo.Capabilities.Control
if hasDimming {
device.Capabilities = append(device.Capabilities, models.DCIntensity)
}
if hasCT {
device.Capabilities = append(device.Capabilities, models.DCColorKelvin)
device.DriverProperties["minKelvin"] = 1000000 / ctrl.CT.Max
device.DriverProperties["maxKelvin"] = 1000000 / ctrl.CT.Min
}
if hasColor {
device.Capabilities = append(device.Capabilities, models.DCColorHS)
device.DriverProperties["gamutType"] = ctrl.Colorgamuttype
device.DriverProperties["gamutData"] = ctrl.Colorgamut
}
device.DriverProperties["maxLumen"] = strconv.Itoa(ctrl.Maxlumen)
devices = append(devices, device)
}
sensorMap, err := b.getSensors(ctx)
if err != nil {
return nil, err
}
for _, sensorInfo := range sensorMap {
device := models.Device{
ID: -1,
BridgeID: b.externalID,
InternalID: sensorInfo.UniqueID,
Name: sensorInfo.Name,
Capabilities: []models.DeviceCapability{},
ButtonNames: []string{},
DriverProperties: map[string]interface{}{
"modelId": sensorInfo.Modelid,
"productName": sensorInfo.Productname,
"swVersion": sensorInfo.Swversion,
"hueLightType": sensorInfo.Type,
},
UserProperties: nil,
State: models.DeviceState{},
Tags: nil,
}
switch sensorInfo.Type {
case "ZLLSwitch":
device.Capabilities = append(device.Capabilities, models.DCButtons)
device.ButtonNames = append(buttonNames[:0:0], buttonNames...)
device.Icon = "lightswitch"
case "ZLLPresence":
device.Capabilities = append(device.Capabilities, models.DCPresence)
device.Icon = "sensor"
case "ZLLTemperature":
device.Capabilities = append(device.Capabilities, models.DCTemperatureSensor)
device.Icon = "thermometer"
case "Daylight":
continue
}
devices = append(devices, device)
}
return devices, nil
}
func (d *Driver) Publish(ctx context.Context, bridge models.Bridge, devices []models.Device) error {
b, err := d.ensureBridge(ctx, bridge)
if err != nil {
return err
}
err = b.Refresh(ctx)
if err != nil {
return err
}
b.mu.Lock()
for _, device := range devices {
for _, state := range b.lightStates {
if device.InternalID == state.uniqueID {
state.externalID = device.ID
state.Update(device.State)
break
}
}
for _, state := range b.sensorStates {
if device.InternalID == state.uniqueID {
state.externalID = device.ID
break
}
}
}
b.mu.Unlock()
atomic.StoreUint32(&b.syncingPublish, 1)
defer atomic.StoreUint32(&b.syncingPublish, 0)
return b.SyncStale(ctx)
}
func (d *Driver) Run(ctx context.Context, bridge models.Bridge, ch chan<- models.Event) error {
b, err := d.ensureBridge(ctx, bridge)
if err != nil {
return err
}
fastTicker := time.NewTicker(time.Second / 10)
slowTicker := time.NewTicker(time.Second / 3)
selectedTicker := fastTicker
ticksUntilRefresh := 0
ticksSinceChange := 0
for {
select {
case <-selectedTicker.C:
if atomic.LoadUint32(&b.syncingPublish) == 1 {
continue
}
if ticksUntilRefresh <= 0 {
err := b.Refresh(ctx)
if err != nil {
return err
}
err = b.SyncStale(ctx)
if err != nil {
return err
}
ticksUntilRefresh = 60
}
events, err := b.SyncSensors(ctx)
if err != nil {
return err
}
for _, event := range events {
ch <- event
ticksSinceChange = 0
}
if ticksSinceChange > 30 {
selectedTicker = slowTicker
} else if ticksSinceChange == 0 {
selectedTicker = fastTicker
}
case <-ctx.Done():
return ctx.Err()
}
ticksUntilRefresh -= 1
ticksSinceChange += 1
}
}
func (d *Driver) ensureBridge(ctx context.Context, info models.Bridge) (*Bridge, error) {
d.mu.Lock()
for _, bridge := range d.bridges {
if bridge.host == info.Address {
d.mu.Unlock()
return bridge, nil
}
}
d.mu.Unlock()
bridge := &Bridge{
host: info.Address,
token: info.Token,
externalID: info.ID,
}
// If this call succeeds, then the token is ok.
lightMap, err := bridge.getLights(ctx)
if err != nil {
return nil, err
}
log.Printf("Found %d lights on bridge %d", len(lightMap), bridge.externalID)
// To avoid a potential duplicate, try looking for it again before inserting
d.mu.Lock()
for _, bridge := range d.bridges {
if bridge.host == info.Address {
d.mu.Unlock()
return bridge, nil
}
}
d.bridges = append(d.bridges, bridge)
d.mu.Unlock()
return bridge, nil
}
+206
View File
@@ -0,0 +1,206 @@
package hue
import (
"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) 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 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 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 bool
}
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
}
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 {
name := models.ENSensorPresenceStarted
if !newData.State.Presence {
name = models.ENSensorPresenceEnding
state.presenceCooldown = true
}
return &models.Event{
Name: name,
Payload: map[string]string{
"deviceId": strconv.Itoa(state.externalID),
"deviceInternalId": newData.UniqueID,
},
}
} else if state.presenceCooldown && !newData.State.Presence && time.Since(stateTime) > time.Minute {
state.presenceCooldown = false
return &models.Event{
Name: models.ENSensorPresenceEnded,
Payload: map[string]string{
"deviceId": strconv.Itoa(state.externalID),
"deviceInternalId": newData.UniqueID,
},
}
}
}
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,
},
}
}
}
return nil
}
func ptrBool(v bool) *bool {
return &v
}
func ptrInt(v int) *int {
return &v
}
+267
View File
@@ -0,0 +1,267 @@
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 {
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
+87
View File
@@ -0,0 +1,87 @@
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
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
}
+29 -9
View File
@@ -50,12 +50,12 @@ func (b *bridge) Devices() []models.Device {
models.DCButtons,
},
ButtonNames: []string{"Touch"},
DriverProperties: map[string]string{
"x": strconv.Itoa(panel.X),
"y": strconv.Itoa(panel.Y),
"o": strconv.Itoa(panel.O),
DriverProperties: map[string]interface{}{
"x": panel.X,
"y": panel.Y,
"o": panel.O,
"shapeType": shapeTypeMap[panel.ShapeType],
"shapeWidth": strconv.Itoa(shapeWidthMap[panel.ShapeType]),
"shapeWidth": shapeWidthMap[panel.ShapeType],
},
UserProperties: nil,
State: models.DeviceState{
@@ -195,9 +195,9 @@ func (b *bridge) Run(ctx context.Context, info models.Bridge, ch chan<- models.E
defer conn.Close()
// Notify connections and disconnections
ch <- models.Event{Name: "BridgeConnected", Payload: map[string]string{"bridgeId": strconv.Itoa(info.ID)}}
ch <- models.BridgeConnectedEvent(info)
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.
@@ -207,15 +207,29 @@ func (b *bridge) Run(ctx context.Context, info models.Bridge, ch chan<- models.E
go func() {
ticker := time.NewTicker(time.Second * 5)
hadErrors := false
for {
select {
case <-ticker.C:
case <-ctx2.Done():
}
err := b.updateEffect(ctx)
reqTimeout, cancel := context.WithTimeout(ctx2, time.Second*4)
err := b.updateEffect(reqTimeout)
cancel()
if err != nil {
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 +314,12 @@ func (b *bridge) updateEffect(ctx context.Context) error {
return models.ErrUnexpectedResponse
}
b.mu.Lock()
for _, panel := range b.panels {
panel.Stale = true
}
b.mu.Unlock()
return nil
}
@@ -373,7 +393,7 @@ func (b *bridge) runTouchListener(ctx context.Context, host, apiKey string, ch c
}
event := models.Event{
Name: "ButtonPressed",
Name: models.ENButtonPressed,
Payload: map[string]string{
"buttonIndex": "0",
"buttonName": "Touch",
+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
}
+79
View File
@@ -0,0 +1,79 @@
package mysql
import (
"context"
"git.aiterp.net/lucifer/new-server/models"
"github.com/jmoiron/sqlx"
)
type BridgeRepo struct {
DBX *sqlx.DB
}
func (b *BridgeRepo) Find(ctx context.Context, id int) (models.Bridge, error) {
var bridge models.Bridge
err := b.DBX.GetContext(ctx, &bridge, "SELECT * FROM bridge WHERE id = ?", id)
if err != nil {
return models.Bridge{}, dbErr(err)
}
return bridge, nil
}
func (b *BridgeRepo) FetchAll(ctx context.Context) ([]models.Bridge, error) {
bridges := make([]models.Bridge, 0, 8)
err := b.DBX.SelectContext(ctx, &bridges, "SELECT * FROM bridge")
if err != nil {
return nil, dbErr(err)
}
return bridges, nil
}
func (b *BridgeRepo) Save(ctx context.Context, bridge *models.Bridge) error {
if bridge.ID > 0 {
_, err := b.DBX.ExecContext(
ctx,
"UPDATE bridge SET name = ?, address = ?, token = ? WHERE id = ?",
bridge.Name, bridge.Address, bridge.Token, bridge.ID,
)
if err != nil {
return dbErr(err)
}
} else {
rs, err := b.DBX.ExecContext(
ctx,
"INSERT INTO bridge (name, driver, address, token) VALUES (?, ?, ?, ?)",
bridge.Name, bridge.Driver, bridge.Address, bridge.Token,
)
if err != nil {
return dbErr(err)
}
id, err := rs.LastInsertId()
if err != nil {
return dbErr(err)
}
bridge.ID = int(id)
}
return nil
}
func (b *BridgeRepo) Delete(ctx context.Context, bridge *models.Bridge) error {
_, err := b.DBX.ExecContext(ctx, "DELETE FROM bridge WHERE id = ?", bridge.ID)
if err != nil {
return dbErr(err)
}
_, err = b.DBX.ExecContext(ctx, "DELETE FROM device WHERE bridge_id = ?", bridge.ID)
if err != nil {
return dbErr(err)
}
bridge.ID = 0
return nil
}
+382
View File
@@ -0,0 +1,382 @@
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"`
}
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"`
}
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) Save(ctx context.Context, device *models.Device) error {
tx, err := r.DBX.Beginx()
if err != nil {
return dbErr(err)
}
defer tx.Rollback()
record := deviceRecord{
ID: device.ID,
BridgeID: device.BridgeID,
InternalID: device.InternalID,
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
WHERE id=:id
`, record)
if err != nil {
return dbErr(err)
}
// Let's just be lazy for now, optimize later if need be.
_, err = tx.ExecContext(ctx, "DELETE FROM device_tag WHERE device_id=?", record.ID)
if err != nil {
return dbErr(err)
}
_, 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)
device.ID = int(lastID)
}
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)
}
}
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)
}
}
_, err = tx.NamedExecContext(ctx, `
REPLACE INTO device_state(device_id, hue, saturation, kelvin, power, intensity)
VALUES (:device_id, :hue, :saturation, :kelvin, :power, :intensity)
`, 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,
})
if err != nil {
return dbErr(err)
}
return tx.Commit()
}
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,
ButtonNames: strings.Split(record.ButtonNames, ","),
DriverProperties: make(map[string]interface{}, 8),
UserProperties: make(map[string]string, 8),
Tags: make([]string, 0, 8),
}
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,
}
}
}
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
}
+146
View File
@@ -0,0 +1,146 @@
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"`
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,
}
if record.ID == 0 {
res, err := r.DBX.NamedExecContext(ctx, `
INSERT INTO event_handler (event_name, one_shot, priority, target_kind, target_value, conditions, actions)
VALUES (:event_name, :one_shot, :priority, :target_kind, :target_value, :conditions, :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,
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)
}
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
}
+18
View File
@@ -0,0 +1,18 @@
package mysql
import (
"database/sql"
"git.aiterp.net/lucifer/new-server/models"
"log"
)
func dbErr(err error) error {
if err == sql.ErrNoRows {
return models.ErrNotFound
} else if err != nil {
log.Printf("Internal error: %s", err.Error())
return models.ErrInternal
}
return nil
}
+16 -6
View File
@@ -1,18 +1,28 @@
package models
import "context"
type Bridge struct {
ID int `json:"id"`
Name string `json:"name"`
Driver DriverKind `json:"driver"`
Address string `json:"address"`
Token string `json:"token"`
ID int `json:"id" db:"id"`
Name string `json:"name" db:"name"`
Driver DriverKind `json:"driver" db:"driver"`
Address string `json:"address" db:"address"`
Token string `json:"-" db:"token"`
}
type BridgeRepository interface {
Find(ctx context.Context, id int) (Bridge, error)
FetchAll(ctx context.Context) ([]Bridge, error)
Save(ctx context.Context, bridge *Bridge) error
Delete(ctx context.Context, bridge *Bridge) error
}
type DriverKind string
var (
DTHue DriverKind = "Hue"
DTNanoLeaf DriverKind = "NanoLeaf"
DTNanoLeaf DriverKind = "Nanoleaf"
DTLIFX DriverKind = "LIFX"
)
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()
}
}
+23 -3
View File
@@ -23,10 +23,10 @@ func (c *ColorValue) IsKelvin() bool {
func (c *ColorValue) String() string {
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:%g,%g", c.Hue, c.Saturation)
}
func ParseColorValue(raw string) (ColorValue, error) {
@@ -35,7 +35,7 @@ func ParseColorValue(raw string) (ColorValue, error) {
return ColorValue{}, ErrBadInput
}
if tokens[0] == "kelvin" {
if tokens[0] == "kelvin" || tokens[0] == "k" {
parsedPart, err := strconv.Atoi(tokens[1])
if err != nil {
return ColorValue{}, ErrBadInput
@@ -59,5 +59,25 @@ func ParseColorValue(raw string) (ColorValue, error) {
return ColorValue{Hue: math.Mod(part1, 360), Saturation: math.Min(math.Max(part2, 0), 1)}, nil
}
if tokens[0] == "hsk" {
parts := strings.Split(tokens[1], ",")
if len(parts) < 3 {
return ColorValue{}, ErrUnknownColorFormat
}
part1, err1 := strconv.ParseFloat(parts[0], 64)
part2, err2 := strconv.ParseFloat(parts[1], 64)
part3, err3 := strconv.Atoi(parts[2])
if err1 != nil || err2 != nil || err3 != nil {
return ColorValue{}, ErrBadInput
}
return ColorValue{
Hue: math.Mod(part1, 360),
Saturation: math.Min(math.Max(part2, 0), 1),
Kelvin: part3,
}, nil
}
return ColorValue{}, ErrUnknownColorFormat
}
+84 -27
View File
@@ -6,17 +6,23 @@ import (
)
type Device struct {
ID int `json:"id"`
BridgeID int `json:"bridgeID"`
InternalID string `json:"internalId"`
Icon string `json:"icon"`
Name string `json:"name"`
Capabilities []DeviceCapability `json:"capabilities"`
ButtonNames []string `json:"buttonNames"`
DriverProperties map[string]string `json:"driverProperties"`
UserProperties map[string]string `json:"userProperties"`
State DeviceState `json:"state"`
Tags []string `json:"tags"`
ID int `json:"id"`
BridgeID int `json:"bridgeID"`
InternalID string `json:"internalId"`
Icon string `json:"icon"`
Name string `json:"name"`
Capabilities []DeviceCapability `json:"capabilities"`
ButtonNames []string `json:"buttonNames"`
DriverProperties map[string]interface{} `json:"driverProperties"`
UserProperties map[string]string `json:"userProperties"`
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
@@ -32,28 +38,40 @@ type DeviceState struct {
}
type NewDeviceState struct {
Power *bool `json:"power"`
Color *string `json:"color"`
Intensity int `json:"intensity"`
Temperature int `json:"temperature"`
Power *bool `json:"power"`
Color *string `json:"color"`
Intensity *float64 `json:"intensity"`
Temperature *int `json:"temperature"`
}
type DeviceCapability string
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)
Save(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 (
DCPower DeviceCapability = "Power"
DCColorHS DeviceCapability = "ColorHS"
DCColorKelvin DeviceCapability = "ColorKelvin"
DCButtons DeviceCapability = "Buttons"
DCIntensity DeviceCapability = "Intensity"
DCTemperature DeviceCapability = "Temperature"
DCPower DeviceCapability = "Power"
DCColorHS DeviceCapability = "ColorHS"
DCColorHSK DeviceCapability = "ColorHSK"
DCColorKelvin DeviceCapability = "ColorKelvin"
DCButtons DeviceCapability = "Buttons"
DCPresence DeviceCapability = "Presence"
DCIntensity DeviceCapability = "Intensity"
DCTemperatureControl DeviceCapability = "TemperatureControl"
DCTemperatureSensor DeviceCapability = "TemperatureSensor"
)
var Capabilities = []DeviceCapability{
@@ -61,8 +79,29 @@ var Capabilities = []DeviceCapability{
DCColorHS,
DCColorKelvin,
DCButtons,
DCPresence,
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 {
@@ -85,10 +124,24 @@ func (d *Device) Validate() error {
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) HasCapability(capabilities ...DeviceCapability) bool {
for _, c := range d.Capabilities {
if c == capacity {
return true
for _, c2 := range capabilities {
if c == c2 {
return true
}
}
}
@@ -106,10 +159,14 @@ func (d *Device) SetState(newState NewDeviceState) error {
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
}
}
if newState.Intensity != nil && d.HasCapability(DCIntensity) {
d.State.Intensity = *newState.Intensity
}
return nil
}
+11
View File
@@ -2,12 +2,23 @@ package models
import "errors"
var ErrNotFound = errors.New("not found")
var ErrInvalidName = errors.New("invalid name")
var ErrBadInput = errors.New("bad input")
var ErrBadColor = errors.New("bad color")
var ErrInternal = errors.New("internal")
var ErrUnknownColorFormat = errors.New("unknown color format")
var ErrUnknownDriver = errors.New("unknown driver")
var ErrMissingToken = errors.New("driver is missing 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 ErrBridgeSearchFailed = errors.New("bridge search failed")
var ErrAddressOnlyDryRunnable = errors.New("this address may only be used for a dry run")
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")
+26
View File
@@ -1,5 +1,7 @@
package models
import "strconv"
type Event struct {
Name string `json:"name"`
Payload map[string]string `json:"payload,omitempty"`
@@ -16,3 +18,27 @@ func (e *Event) AddPayload(key, value string) {
func (e *Event) HasPayload(key string) bool {
return e.Payload != nil && e.Payload[key] != ""
}
var (
ENBridgeConnected = "BridgeConnected"
ENBridgeDisconnected = "BridgeDisconnected"
ENButtonPressed = "ButtonPressed"
ENSensorPresenceStarted = "SensorPresenceStarted"
ENSensorPresenceEnding = "SensorPresenceEnding"
ENSensorPresenceEnded = "SensorPresenceEnded"
ENSensorTemperature = "SensorTemperature"
)
func BridgeConnectedEvent(bridge Bridge) Event {
e := Event{Name: ENBridgeConnected}
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
}
+121 -20
View File
@@ -11,15 +11,18 @@ type EventHandler struct {
ID int `json:"id"`
EventName string `json:"eventName"`
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"`
Actions EventAction `json:"actions"`
}
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)
Save(ctx context.Context, handler *EventHandler)
Delete(ctx context.Context, handler *EventHandler)
Save(ctx context.Context, handler *EventHandler) error
Delete(ctx context.Context, handler *EventHandler) error
}
type EventCondition struct {
@@ -30,13 +33,62 @@ type EventCondition struct {
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"`
}
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.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 {
if event.Name != h.EventName {
return false
}
for key, condition := range h.Conditions {
if !event.HasPayload(key) {
if !strings.ContainsRune(key, '.') && !event.HasPayload(key) {
return false
}
@@ -49,35 +101,58 @@ func (h *EventHandler) MatchesEvent(event Event, targets []Device) bool {
}
func (c *EventCondition) check(key, value string, targets []Device) bool {
any := strings.Index(key, "any.") == 0
all := strings.Index(key, "all.") == 0
if any || all && len(key) > 4 {
any := strings.HasPrefix(key, "any.")
all := strings.HasPrefix(key, "all.")
if any || all {
count := 0
total := 0
for _, target := range targets {
if c.checkDevice(key[4:], target) {
matches, skip := c.checkDevice(key[4:], target)
if skip {
continue
}
if matches {
count++
}
total++
}
return (any && count > 0) || (all && count == len(targets))
return (any && count > 0) || (all && count == total)
}
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 {
case "power":
return c.matches(strconv.FormatBool(device.State.Power))
case "color":
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))
}
if !device.HasCapability(DCPower) {
return false, true
}
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.FormatFloat(device.State.Temperature, 'f', -1, 64)), false
default:
return false, true
}
}
var numRegex = regexp.MustCompile("^{-[0-9].}+$")
@@ -119,3 +194,29 @@ func (c *EventCondition) matches(value string) bool {
return false
}
}
type EventAction struct {
SetPower *bool `json:"setPower"`
SetColor *string `json:"setColor"`
SetIntensity *float64 `json:"setIntensity"`
AddIntensity *float64 `json:"addIntensity"`
FireEvent *Event `json:"fireEvent"`
}
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
}
}
+5 -5
View File
@@ -3,9 +3,9 @@ package models
type ReferenceKind string
var (
RTDeviceID ReferenceKind = "DeviceID"
RTBridgeID ReferenceKind = "BridgeID"
RTTag ReferenceKind = "Tag"
RKDeviceID ReferenceKind = "DeviceID"
RKBridgeID ReferenceKind = "BridgeID"
RKTag ReferenceKind = "Tag"
RKAll ReferenceKind = "All"
RKName ReferenceKind = "Name"
)
+21
View File
@@ -0,0 +1,21 @@
-- +goose Up
-- +goose StatementBegin
CREATE TABLE device
(
id INT NOT NULL AUTO_INCREMENT,
bridge_id INT NOT NULL,
internal_id VARCHAR(255) NOT NULL,
icon VARCHAR(255) NOT NULL,
name VARCHAR(255) NOT NULL,
capabilities VARCHAR(255) NOT NULL,
button_names VARCHAR(255) NOT NULL,
PRIMARY KEY (id),
UNIQUE (bridge_id, internal_id)
);
-- +goose StatementEnd
-- +goose Down
-- +goose StatementBegin
DROP TABLE device;
-- +goose StatementEnd
@@ -0,0 +1,17 @@
-- +goose Up
-- +goose StatementBegin
CREATE TABLE device_property
(
device_id INT NOT NULL,
prop_key VARCHAR(255) NOT NULL,
prop_value VARCHAR(255) NOT NULL,
is_user TINYINT NOT NULL DEFAULT 0,
PRIMARY KEY (device_id, prop_key)
);
-- +goose StatementEnd
-- +goose Down
-- +goose StatementBegin
DROP TABLE device_property;
-- +goose StatementEnd
+20
View File
@@ -0,0 +1,20 @@
-- +goose Up
-- +goose StatementBegin
CREATE TABLE device_state
(
device_id INT NOT NULL,
hue DOUBLE NOT NULL,
saturation DOUBLE NOT NULL,
kelvin INT NOT NULL,
power TINYINT NOT NULL,
intensity DOUBLE NOT NULL,
PRIMARY KEY (device_id)
);
-- +goose StatementEnd
-- +goose Down
-- +goose StatementBegin
DROP TABLE device_state;
-- +goose StatementEnd
+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
+2 -1
View File
@@ -1 +1,2 @@
node_modules/
node_modules/
yarn.error.log
+56 -43
View File
@@ -1,43 +1,56 @@
{
"name": "webui",
"version": "0.1.0",
"private": true,
"dependencies": {
"@testing-library/jest-dom": "^5.11.4",
"@testing-library/react": "^11.1.0",
"@testing-library/user-event": "^12.1.10",
"@types/jest": "^26.0.15",
"@types/node": "^12.0.0",
"@types/react": "^17.0.0",
"@types/react-dom": "^17.0.0",
"react": "^17.0.2",
"react-dom": "^17.0.2",
"react-scripts": "4.0.3",
"typescript": "^4.1.2",
"web-vitals": "^1.0.1"
},
"scripts": {
"start": "react-scripts start",
"build": "react-scripts build",
"test": "react-scripts test",
"eject": "react-scripts eject"
},
"eslintConfig": {
"extends": [
"react-app",
"react-app/jest"
]
},
"browserslist": {
"production": [
">0.2%",
"not dead",
"not op_mini all"
],
"development": [
"last 1 chrome version",
"last 1 firefox version",
"last 1 safari version"
]
}
}
{
"name": "webui",
"version": "0.1.0",
"private": true,
"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/react": "^11.1.0",
"@testing-library/user-event": "^12.1.10",
"@types/jest": "^26.0.15",
"@types/node": "^12.0.0",
"@types/react": "^17.0.0",
"@types/react-dom": "^17.0.0",
"axios": "^0.21.1",
"hookrouter": "^1.2.5",
"kelvin-to-rgb": "^1.0.2",
"node-sass": "4.14.1",
"react": "^17.0.2",
"react-dom": "^17.0.2",
"react-modal": "^3.14.2",
"react-scripts": "4.0.3",
"react-semantic-ui-range": "^0.7.1",
"typescript": "^4.1.2",
"web-vitals": "^1.0.1"
},
"scripts": {
"start": "react-scripts start",
"build": "react-scripts build",
"test": "react-scripts test",
"eject": "react-scripts eject"
},
"eslintConfig": {
"extends": [
"react-app",
"react-app/jest"
]
},
"browserslist": {
"production": [
">0.2%",
"not dead",
"not op_mini all"
],
"development": [
"last 1 chrome version",
"last 1 firefox version",
"last 1 safari version"
]
},
"devDependencies": {
"@types/hookrouter": "^2.2.5",
"@types/react-modal": "^3.12.0"
}
}
-38
View File
@@ -1,38 +0,0 @@
.App {
text-align: center;
}
.App-logo {
height: 40vmin;
pointer-events: none;
}
@media (prefers-reduced-motion: no-preference) {
.App-logo {
animation: App-logo-spin infinite 20s linear;
}
}
.App-header {
background-color: #282c34;
min-height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
font-size: calc(10px + 2vmin);
color: white;
}
.App-link {
color: #61dafb;
}
@keyframes App-logo-spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
-9
View File
@@ -1,9 +0,0 @@
import React from 'react';
import { render, screen } from '@testing-library/react';
import App from './App';
test('renders learn react link', () => {
render(<App />);
const linkElement = screen.getByText(/learn react/i);
expect(linkElement).toBeInTheDocument();
});
+72 -26
View File
@@ -1,26 +1,72 @@
import React from 'react';
import logo from './logo.svg';
import './App.css';
function App() {
return (
<div className="App">
<header className="App-header">
<img src={logo} className="App-logo" alt="logo" />
<p>
Edit <code>src/App.tsx</code> and save to reload.
</p>
<a
className="App-link"
href="https://reactjs.org"
target="_blank"
rel="noopener noreferrer"
>
Learn React
</a>
</header>
</div>
);
}
export default App;
import React, {useContext, useLayoutEffect} from 'react';
import {HookRouter, navigate, usePath, useRoutes} from "hookrouter";
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 = {
"/": () => (
<Page>
<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 tabNames = ["Lucifer", "Enheter", "Oppsett"];
function App() {
return (
<DialogContextProvider>
<AppInContext/>
</DialogContextProvider>
);
}
function AppInContext() {
const route = useRoutes(routeObj);
const path = usePath();
const {dialog} = useContext(DialogContext);
useLayoutEffect(() => {
ReactModal.setAppElement("#root");
}, []);
return (
<div className="App">
<Tabs tabNames={tabNames}
index={routeList.indexOf(path)}
onChange={i => navigate(routeList[i])}
large boldIndex={0}
/>
{route || <Page>404</Page>}
{makeDialog(dialog)}
</div>
);
}
export default App;
+18
View File
@@ -0,0 +1,18 @@
import {ColorPreset} from "../models/Colors";
import {deleteRequest, getRequest, postRequest, putRequest} from "../helpers/rest";
export function listColorPresets(): Promise<ColorPreset[]> {
return getRequest("/color-presets");
}
export function addColorPreset(name: string, colorString: string) {
return postRequest("/color-presets", {name, colorString});
}
export function editColorPreset(id: number, name: string, colorString: string) {
return putRequest(`/color-presets/${id}`, {name, colorString});
}
export function deleteColorPreset(id: number) {
return deleteRequest(`/color-presets/${id}`);
}
+56
View File
@@ -0,0 +1,56 @@
import React, {createContext, useCallback, useEffect, useState} from "react";
import {ColorPreset} from "../models/Colors";
import {DataTarget} from "../models/Shared";
import {unimplemented} from "../helpers/misc";
import {listColorPresets} from "../actions/ColorPresets";
interface DataContextValue {
colorPresets: ColorPreset[]
ready(target?: DataTarget): boolean
refresh(target?: DataTarget): void
}
const DataContext = createContext<DataContextValue>({
colorPresets: [],
ready: unimplemented,
refresh: unimplemented,
});
export const DataContextProvider: React.FC = ({children}) => {
const [colorPresets, setColorPresets] = useState<ColorPreset[]>([]);
const [readyMap, setReadyMap] = useState<{ [key: string]: boolean }>({
[DataTarget.Default]: false,
[DataTarget.ColorPresets]: false,
});
const ready = useCallback((target: DataTarget = DataTarget.Default) => {
return readyMap[target] || false;
}, [readyMap]);
const refresh = useCallback((target: DataTarget = DataTarget.Default) => {
const refreshing: Promise<any>[] = [];
if ([DataTarget.Default, DataTarget.ColorPresets].includes(target)) {
refreshing.push(listColorPresets().then(setColorPresets));
}
Promise.all(refreshing).then(() => setReadyMap(prev => ({...prev, [target]: true})));
}, []);
useEffect(() => {
refresh();
}, [refresh]);
return (
<DataContext.Provider value={{
colorPresets,
ready, refresh,
}}>
{children}
</DataContext.Provider>
);
};
export default DataContext;
+41
View File
@@ -0,0 +1,41 @@
import React, {createContext, useCallback, useState} from "react";
import {DialogConfig, DialogType} from "../models/Dialog";
import {unimplemented} from "../helpers/misc";
import ColorDialog from "../dialogs/ColorDialog";
interface DialogContextValue {
dialog: DialogConfig,
setDialog: (dialog: DialogConfig) => void,
clearDialog: () => void,
}
const DialogContext = createContext<DialogContextValue>({
dialog: {type: DialogType.None},
setDialog: unimplemented,
clearDialog: unimplemented,
});
export const DialogContextProvider: React.FC = ({children}) => {
const [dialog, setDialog] = useState<DialogConfig>({type: DialogType.None});
const clearDialog = useCallback(() => {
setDialog({type: DialogType.None});
}, []);
return (
<DialogContext.Provider value={{dialog, setDialog, clearDialog}}>
{children}
</DialogContext.Provider>
);
};
export function makeDialog(dialog: DialogConfig) {
switch (dialog.type) {
case DialogType.None:
return undefined;
case DialogType.AddColor:
return <ColorDialog/>;
}
}
export default DialogContext;
+71
View File
@@ -0,0 +1,71 @@
import React, {useCallback, useContext, useEffect, useState} from "react";
import {ColorPreset} from "../models/Colors";
import DialogContext from "../contexts/DialogContext";
import {DialogType} from "../models/Dialog";
import {Dialog} from "../primitives/Layout";
import {LuciferIcon} from "../models/Icons";
import {HSColorPicker, Input, KelvinColorPicker} from "../primitives/Forms";
interface ColorDialogProps {
data?: ColorPreset
}
enum ColorType {
HueSat,
Kelvin,
HueSatKelvin,
}
const COLOR_TYPE_OPTIONS = [
{value: ColorType.HueSat, name: "Bare Nyanse (H) og metning (S)"},
{value: ColorType.Kelvin, name: "Bare temperatur (K)"},
{value: ColorType.HueSatKelvin, name: "Nyanse (H), metning (K) og kelvin (S)"},
];
export default function ColorDialog({data}: ColorDialogProps) {
const {setDialog, clearDialog} = useContext(DialogContext);
const [name, setName] = useState(data?.name || "");
const [hue, setHue] = useState(data?.value?.h || 0);
const [sat, setSat] = useState(data?.value?.s || 0);
const [kelvin, setKelvin] = useState(data?.value?.kelvin || 0);
const [colorType, setColorType] = useState(COLOR_TYPE_OPTIONS[0].value);
const [isInit, setIsInit] = useState(false);
const onSave = useCallback(() => {
setDialog({type: DialogType.None});
}, [setDialog]);
useEffect(() => {
if (!isInit && data) {
const v = data.value;
if (v.kelvin !== undefined && v.kelvin > 0) {
setColorType((v.h || v.s) ? ColorType.HueSatKelvin : ColorType.Kelvin);
}
}
if (!isInit) {
setIsInit(true);
}
}, [isInit, data, colorType]);
return (
<Dialog title={data ? "Endre farge" : "Ny farge"} buttons={[
{icon: LuciferIcon.Check, text: "Lagre", onClick: onSave},
{icon: LuciferIcon.Cross, text: "Avbryt", onClick: clearDialog},
]}>
<Input label="Navn" value={name} onChange={setName}/>
{colorType !== ColorType.Kelvin && (
<HSColorPicker h={hue} s={sat} onChange={(h, s) => {
setHue(h);
setSat(s);
}}/>
)}
{colorType !== ColorType.HueSat && (
<KelvinColorPicker kelvin={kelvin} onChange={setKelvin}/>
)}
</Dialog>
);
}
+37
View File
@@ -0,0 +1,37 @@
import {ColorValue} from "../models/Colors";
import iro from "@jaames/iro";
export function kelvinToColorValue(rawKelvin: number): ColorValue {
const kelvin = normalizeKelvin(rawKelvin);
return kelvinTable[kelvin];
}
export function kelvinToRgbHex(rawKelvin: number): string {
const kelvin = normalizeKelvin(rawKelvin);
const c = new iro.Color();
c.kelvin = kelvin;
return c.hexString;
}
function normalizeKelvin(kelvin: number): number {
if (kelvin < 1000) {
return 1000;
} else if (kelvin > 10000) {
return 10000;
} else {
return kelvin - (kelvin % 100);
}
}
const kelvinTable: {[key: number]: ColorValue} = {};
for (let kelvin = 1000; kelvin <= 10000; kelvin += 100) {
const c = new iro.Color();
c.kelvin = kelvin;
const [h, s] = [c.hsv.h, (c.hsv.s as number) / 100];
kelvinTable[kelvin] = {h, s, kelvin};
}
+3
View File
@@ -0,0 +1,3 @@
export function unimplemented<T>(): T {
throw new Error("unimplemented");
}
+81
View File
@@ -0,0 +1,81 @@
import Axios, {AxiosError, AxiosRequestConfig, AxiosResponse} from "axios";
interface Response<T> {
code: number
message: string
data: T
}
export const authError = new Error("unauthenticated");
export async function getRequest<T>(url: string): Promise<T> {
const cors = url.includes("//");
return sendRequest<T>({
method: "GET",
url: cors ? url : `/api${url}`,
responseType: "json",
}, cors);
}
export function postRequest<T>(url: string, data: object = {}): Promise<T> {
return sendRequest<T>({
method: "POST",
url: `/api${url}`,
responseType: "json",
data,
});
}
export function putRequest<T>(url: string, data: object): Promise<T> {
return sendRequest<T>({
method: "PUT",
url: `/api${url}`,
responseType: "json",
data,
});
}
export function deleteRequest<T>(url: string): Promise<T> {
return sendRequest<T>({
method: "DELETE",
url: `/api${url}`,
responseType: "json",
});
}
async function sendRequest<T>(config: AxiosRequestConfig, cors: boolean = false): Promise<T> {
try {
const res = await Axios(config);
return await handleResponse(res, cors);
} catch (e) {
if (e === authError) {
throw e;
}
return await handleResponse((e as AxiosError).response as AxiosResponse);
}
}
function handleResponse<T>(response: AxiosResponse, object: boolean = false): Promise<T> {
if (object) {
return Promise.resolve(response.data);
} else {
const obj: Response<T> | undefined = response?.data;
if (![200, 201].includes(response.status)) {
if (response.status === 403) {
return Promise.reject(authError);
} else {
return Promise.reject(obj?.message || "Ukjent feil");
}
}
if (!obj) {
return Promise.reject("Ingen data mottatt");
}
return Promise.resolve(obj.data);
}
}
+7 -13
View File
@@ -1,13 +1,7 @@
body {
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
code {
font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New',
monospace;
}
body, html {
margin: 0;
font-family: sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
padding: 0;
}
+20 -17
View File
@@ -1,17 +1,20 @@
import React from 'react';
import ReactDOM from 'react-dom';
import './index.css';
import App from './App';
import reportWebVitals from './reportWebVitals';
ReactDOM.render(
<React.StrictMode>
<App />
</React.StrictMode>,
document.getElementById('root')
);
// If you want to start measuring performance in your app, pass a function
// to log results (for example: reportWebVitals(console.log))
// or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals
reportWebVitals();
import React from 'react';
import ReactDOM from 'react-dom';
import './index.css';
import App from './App';
import reportWebVitals from "./reportWebVitals";
import {DataContextProvider} from "./contexts/DataContext";
ReactDOM.render(
<React.StrictMode>
<DataContextProvider>
<App/>
</DataContextProvider>
</React.StrictMode>,
document.getElementById('root')
);
// If you want to start measuring performance in your app, pass a function
// to log results (for example: reportWebVitals(console.log))
// or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals
reportWebVitals();
-1
View File
@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 841.9 595.3"><g fill="#61DAFB"><path d="M666.3 296.5c0-32.5-40.7-63.3-103.1-82.4 14.4-63.6 8-114.2-20.2-130.4-6.5-3.8-14.1-5.6-22.4-5.6v22.3c4.6 0 8.3.9 11.4 2.6 13.6 7.8 19.5 37.5 14.9 75.7-1.1 9.4-2.9 19.3-5.1 29.4-19.6-4.8-41-8.5-63.5-10.9-13.5-18.5-27.5-35.3-41.6-50 32.6-30.3 63.2-46.9 84-46.9V78c-27.5 0-63.5 19.6-99.9 53.6-36.4-33.8-72.4-53.2-99.9-53.2v22.3c20.7 0 51.4 16.5 84 46.6-14 14.7-28 31.4-41.3 49.9-22.6 2.4-44 6.1-63.6 11-2.3-10-4-19.7-5.2-29-4.7-38.2 1.1-67.9 14.6-75.8 3-1.8 6.9-2.6 11.5-2.6V78.5c-8.4 0-16 1.8-22.6 5.6-28.1 16.2-34.4 66.7-19.9 130.1-62.2 19.2-102.7 49.9-102.7 82.3 0 32.5 40.7 63.3 103.1 82.4-14.4 63.6-8 114.2 20.2 130.4 6.5 3.8 14.1 5.6 22.5 5.6 27.5 0 63.5-19.6 99.9-53.6 36.4 33.8 72.4 53.2 99.9 53.2 8.4 0 16-1.8 22.6-5.6 28.1-16.2 34.4-66.7 19.9-130.1 62-19.1 102.5-49.9 102.5-82.3zm-130.2-66.7c-3.7 12.9-8.3 26.2-13.5 39.5-4.1-8-8.4-16-13.1-24-4.6-8-9.5-15.8-14.4-23.4 14.2 2.1 27.9 4.7 41 7.9zm-45.8 106.5c-7.8 13.5-15.8 26.3-24.1 38.2-14.9 1.3-30 2-45.2 2-15.1 0-30.2-.7-45-1.9-8.3-11.9-16.4-24.6-24.2-38-7.6-13.1-14.5-26.4-20.8-39.8 6.2-13.4 13.2-26.8 20.7-39.9 7.8-13.5 15.8-26.3 24.1-38.2 14.9-1.3 30-2 45.2-2 15.1 0 30.2.7 45 1.9 8.3 11.9 16.4 24.6 24.2 38 7.6 13.1 14.5 26.4 20.8 39.8-6.3 13.4-13.2 26.8-20.7 39.9zm32.3-13c5.4 13.4 10 26.8 13.8 39.8-13.1 3.2-26.9 5.9-41.2 8 4.9-7.7 9.8-15.6 14.4-23.7 4.6-8 8.9-16.1 13-24.1zM421.2 430c-9.3-9.6-18.6-20.3-27.8-32 9 .4 18.2.7 27.5.7 9.4 0 18.7-.2 27.8-.7-9 11.7-18.3 22.4-27.5 32zm-74.4-58.9c-14.2-2.1-27.9-4.7-41-7.9 3.7-12.9 8.3-26.2 13.5-39.5 4.1 8 8.4 16 13.1 24 4.7 8 9.5 15.8 14.4 23.4zM420.7 163c9.3 9.6 18.6 20.3 27.8 32-9-.4-18.2-.7-27.5-.7-9.4 0-18.7.2-27.8.7 9-11.7 18.3-22.4 27.5-32zm-74 58.9c-4.9 7.7-9.8 15.6-14.4 23.7-4.6 8-8.9 16-13 24-5.4-13.4-10-26.8-13.8-39.8 13.1-3.1 26.9-5.8 41.2-7.9zm-90.5 125.2c-35.4-15.1-58.3-34.9-58.3-50.6 0-15.7 22.9-35.6 58.3-50.6 8.6-3.7 18-7 27.7-10.1 5.7 19.6 13.2 40 22.5 60.9-9.2 20.8-16.6 41.1-22.2 60.6-9.9-3.1-19.3-6.5-28-10.2zM310 490c-13.6-7.8-19.5-37.5-14.9-75.7 1.1-9.4 2.9-19.3 5.1-29.4 19.6 4.8 41 8.5 63.5 10.9 13.5 18.5 27.5 35.3 41.6 50-32.6 30.3-63.2 46.9-84 46.9-4.5-.1-8.3-1-11.3-2.7zm237.2-76.2c4.7 38.2-1.1 67.9-14.6 75.8-3 1.8-6.9 2.6-11.5 2.6-20.7 0-51.4-16.5-84-46.6 14-14.7 28-31.4 41.3-49.9 22.6-2.4 44-6.1 63.6-11 2.3 10.1 4.1 19.8 5.2 29.1zm38.5-66.7c-8.6 3.7-18 7-27.7 10.1-5.7-19.6-13.2-40-22.5-60.9 9.2-20.8 16.6-41.1 22.2-60.6 9.9 3.1 19.3 6.5 28.1 10.2 35.4 15.1 58.3 34.9 58.3 50.6-.1 15.7-23 35.6-58.4 50.6zM320.8 78.4z"/><circle cx="420.9" cy="296.5" r="45.7"/><path d="M520.5 78.1z"/></g></svg>

Before

Width:  |  Height:  |  Size: 2.6 KiB

+11
View File
@@ -0,0 +1,11 @@
export interface ColorValue {
h?: number
s?: number
kelvin?: number
}
export interface ColorPreset {
id: number
name: string
value: ColorValue
}
+9
View File
@@ -0,0 +1,9 @@
export enum DialogType {
None,
AddColor,
}
export type DialogConfig =
| { type: DialogType.None }
| { type: DialogType.AddColor }
;
+51
View File
@@ -0,0 +1,51 @@
import bridgeIcon from "@iconify-icons/mdi/bridge";
import checkIcon from "@iconify-icons/mdi/check";
import crossIcon from "@iconify-icons/mdi/window-close";
import cogIcon from "@iconify-icons/mdi/cog";
import hexagonIcon from "@iconify-icons/mdi/hexagon";
import hexagonGroupIcon from "@iconify-icons/mdi/hexagon-multiple";
import lightBulbIcon from "@iconify-icons/mdi/lightbulb";
import lightBulbGroupIcon from "@iconify-icons/mdi/lightbulb-group";
import lightSwitchIcon from '@iconify-icons/mdi/light-switch';
import routerIcon from "@iconify-icons/mdi/router-wireless";
import squareIcon from "@iconify-icons/mdi/square-rounded";
import triangleIcon from "@iconify-icons/mdi/triangle";
// To add a new icon, follow the instructions on this page:
// https://iconify.design/icon-sets/mdi/
export enum LuciferIcon {
Bridge = "Bridge",
Check = "Check",
Cog = "Cog",
Cross = "Cross",
Bulb = "Bulb",
BulbGroup = "BulbGroup",
Hexagon = "Hexagon",
HexagonGroup = "HexagonGroup",
Router = "Router",
Square = "Square",
Switch = "Switch",
Triangle = "Triangle",
}
const iconMap = {
[LuciferIcon.Bridge]: bridgeIcon,
[LuciferIcon.Check]: checkIcon,
[LuciferIcon.Cog]: cogIcon,
[LuciferIcon.Cross]: crossIcon,
[LuciferIcon.Bulb]: lightBulbIcon,
[LuciferIcon.BulbGroup]: lightBulbGroupIcon,
[LuciferIcon.Hexagon]: hexagonIcon,
[LuciferIcon.HexagonGroup]: hexagonGroupIcon,
[LuciferIcon.Router]: routerIcon,
[LuciferIcon.Square]: squareIcon,
[LuciferIcon.Switch]: lightSwitchIcon,
[LuciferIcon.Triangle]: triangleIcon,
}
export function toIconify(icon: LuciferIcon): object {
return iconMap[icon];
}
+4
View File
@@ -0,0 +1,4 @@
export enum DataTarget {
Default = "",
ColorPresets = "ColorPresets",
}
+26
View File
@@ -0,0 +1,26 @@
import React, {useContext, useEffect} from "react";
import {Page} from "../primitives/Layout";
import DataContext from "../contexts/DataContext";
import {IconElement} from "../primitives/Elements";
import DialogContext from "../contexts/DialogContext";
import {DialogType} from "../models/Dialog";
const SettingsPage: React.FC = () => {
const {setDialog} = useContext(DialogContext);
const {colorPresets} = useContext(DataContext);
useEffect(() => {
}, []);
return (
<Page title="Oppsett">
<h1>Definerte farger</h1>
{colorPresets.map(cp => (
<IconElement key={cp.id} color={cp.value} caption={cp.name}/>
))}
<button onClick={() => setDialog({type: DialogType.AddColor})}>Add</button>
</Page>
);
};
export default SettingsPage;
+17
View File
@@ -0,0 +1,17 @@
@import "../res/colors"
.Element
display: inline-block
padding: 0.2em 0.5ch
cursor: pointer
.Element:hover
background-color: $color-background-elevated
.ColorElement-block
vertical-align: text-top
display: inline-block
width: 1em
height: 1em
border-radius: 5px
margin-right: 0.5ch
+71
View File
@@ -0,0 +1,71 @@
import React, {CSSProperties, useMemo} from "react";
import {ColorValue} from "../models/Colors";
import {kelvinToRgbHex} from "../helpers/kelvin";
import {Icon} from "@iconify/react";
import "./Elements.sass";
import {LuciferIcon, toIconify} from "../models/Icons";
function Element({children}: React.PropsWithChildren<{}>) {
return (
<div className="Element">{children}</div>
)
}
interface IconProps {
icon?: LuciferIcon
color?: ColorValue | "hs-gradient" | "k-gradient"
}
export function IconBlock({icon, color}: IconProps) {
const style: CSSProperties = useMemo(() => {
const s: CSSProperties = {
backgroundClip: "text",
// @ts-ignore
textFillColor: "transparent",
};
if (color !== undefined) {
if (color === "hs-gradient") {
s.backgroundImage = "linear-gradient(to bottom right, red, yellow, green, blue, violet)";
} else if (color === "k-gradient") {
s.backgroundImage = "linear-gradient(to bottom right, #ff9329, #ffd6aa, #fffaf4, #fff, #c9e2ff, #40a3ff)";
} else if (color.h !== undefined) {
const hue = color.h;
const sat = Math.floor((color.s || 0) * 100);
s.color = `hsl(${hue}, ${sat}%, 50%)`;
if (color.kelvin) {
s.borderBottom = `1px solid ${kelvinToRgbHex(color.kelvin)}`;
}
} else if (color.kelvin !== undefined && color.kelvin > 0) {
s.color = kelvinToRgbHex(color.kelvin);
} else {
s.color = "white"
}
}
return s;
}, [color]);
return (
<Icon icon={toIconify(icon || LuciferIcon.Square)}
className="ColorElement-block"
style={style}
/>
);
}
interface IconElementProps extends IconProps {
caption: string
}
export function IconElement({caption, color, icon}: IconElementProps) {
return (
<Element>
<IconBlock icon={icon} color={color} />
{caption}
</Element>
);
}
+20
View File
@@ -0,0 +1,20 @@
@import "../res/colors"
.FormLine
padding: 0.5em 1ch
width: 100%
&:first-of-type
margin-top: 0
input, select
display: block
width: 100%
font: inherit
background-color: $color-background-elevated
color: $color-foreground
border: none
padding: 0.25em 0.5ch
&:focus, &:active
outline: 1px solid $color-foreground-elevated
+136
View File
@@ -0,0 +1,136 @@
import React, {PropsWithChildren, useCallback, useLayoutEffect, useMemo} from 'react';
import iro from "@jaames/iro";
import "./Forms.sass";
interface FormLineProps {
label: string
id?: string
}
function FormLine({label, id, children}: PropsWithChildren<FormLineProps>) {
return (
<div className="FormLine">
<label htmlFor={id}>{label}:</label>
{children}
</div>
);
}
interface InputProps {
label: string
value: string
onChange?: (s: string) => void,
onChangeNumeric?: (n: number) => void
numeric?: boolean
}
export function Input({label, numeric, onChange, onChangeNumeric, value}: InputProps) {
const id = useMemo(() => `input-${randomId()}`, []);
const actualOnChange = useCallback((newValue: string) => {
if (onChangeNumeric) {
onChangeNumeric(parseInt(newValue));
} else if (onChange) {
onChange(newValue);
}
}, [onChange, onChangeNumeric]);
return (
<FormLine label={label} id={id}>
<input id={id} value={value}
type={numeric ? "number" : "text"}
onChange={i => actualOnChange(i.target.value)}
/>
</FormLine>
)
}
interface ColorPickerProps {
label?: string
h: number
s: number
onChange: (h: number, s: number) => void
}
const randomId = () => Math.floor(Math.random() * 100000);
export const HSColorPicker: React.FC<ColorPickerProps> = ({h, s, onChange, label}) => {
const myId = useMemo(() => `color-picker-${randomId()}`, []);
useLayoutEffect(() => {
// @ts-ignore
const colorPicker = new iro.ColorPicker(`#${myId}`, {
color: {h, s: s * 100, v: 255},
layout: [
{
component: iro.ui.Wheel,
options: {}
}
],
});
colorPicker.on("input:end", (color: { hsv: { h: number, s: number } }) => {
onChange(color.hsv.h || 0, (color.hsv.s || 0) / 100);
});
return () => {
const elem = document.getElementById(myId);
if (elem === null) {
return;
}
elem.innerHTML = "";
};
}, [h, s, onChange, myId]);
return (
<FormLine label={label || "HS-farge"}>
<div id={myId} style={{margin: "0 auto"}}/>
</FormLine>
);
};
interface KelvinColorPickerProps {
label?: string
kelvin: number
onChange: (kelvin: number) => void
}
export const KelvinColorPicker: React.FC<KelvinColorPickerProps> = ({label, kelvin, onChange}) => {
const myId = useMemo(() => `color-picker-${randomId()}`, []);
useLayoutEffect(() => {
// @ts-ignore
const colorPicker = new iro.ColorPicker(`#${myId}`, {
color: {kelvin},
layout: [
{
component: iro.ui.Slider,
options: {
sliderType: "kelvin",
}
}
],
});
colorPicker.on("input:end", (color: iro.Color) => {
onChange(Math.floor(color.kelvin));
});
return () => {
const elem = document.getElementById(myId);
if (elem === null) {
return;
}
elem.innerHTML = "";
};
}, [kelvin, onChange, myId]);
return (
<FormLine label={label || "KV-farge"}>
<div title={`${kelvin}K`} id={myId} style={{margin: "0 auto"}}/>
</FormLine>
);
}
+91
View File
@@ -0,0 +1,91 @@
@import "../res/colors"
.Tabs-container
background-color: $color-background-elevated
&.Tabs-large
text-align: center
font-size: 175%
.Tabs-element
display: inline-block
padding: 0.25em 0.7ch
cursor: pointer
user-select: none
.Tabs-active
border-bottom: 1px solid $color-foreground
.Tabs-bold
font-family: 'Bitstream Vera Serif', 'Lucida Fax', serif
font-weight: 600
.Page
max-width: 800px
margin: 0.25em auto
.Dialog-overlay
background-color: rgba(0, 0, 0, 0.5)
position: fixed
left: 0
top: 0
right: 0
bottom: 0
overflow-y: scroll
.Dialog-main
max-width: 640px
margin: 4em auto
// background-color: #202020
color: #ddd
outline: none
box-shadow: 5px 10px 20px rgba(0, 0, 0, 0.5)
.Card
margin: 0
.Dialog-title
font-weight: 800
font-size: 200%
text-align: center
padding: 0.25em 0.5ch
.Dialog-body
margin-bottom: 0
.Dialog-footer
border-top: solid 1px #000
.Dialog-container
background-color: $color-background
.Dialog-header
font-size: 140%
font-weight: bold
text-align: center
padding: 0.1em 0
background-color: $color-background-elevated
border-bottom: solid 1px #000
.Dialog-footer
display: flex
flex-direction: row
button
flex-grow: 4
text-align: center
font: inherit
padding: 0.5em 1.5ch
background-color: $color-background-elevated
color: $color-foreground
border: none
cursor: pointer
border-bottom: solid 1px #000
&:hover, &:active
background-color: $color-background-elevated2
color: $color-foreground-elevated
&:not(:last-of-type)
border-right: solid 1px #000
+102
View File
@@ -0,0 +1,102 @@
import React, {PropsWithChildren, useCallback, useEffect} from "react";
import ReactModal from "react-modal";
import "./Layout.sass";
import {LuciferIcon} from "../models/Icons";
import {IconBlock} from "./Elements";
interface TabsProps {
tabNames: string[]
index: number
onChange: (newIndex: number) => void
boldIndex?: number
large?: boolean
}
export function Tabs({tabNames, index, onChange, large, boldIndex}: TabsProps) {
useEffect(() => {
if (index < 0) {
onChange(0);
} else if (tabNames.length > 0 && index >= tabNames.length) {
onChange(tabNames.length - 1);
}
}, [tabNames, index, onChange]);
const tabClass = useCallback((i: number) => {
const classes = ['Tabs-element'];
if (i === index) {
classes.push("Tabs-active");
}
if (i === boldIndex) {
classes.push("Tabs-bold");
}
return classes.join(" ");
}, [index, boldIndex])
return (
<div className={`Tabs-container ${large ? "Tabs-large" : ""}`}>
<div className="Tabs-inner">
{tabNames.map((name, i) => (
<div className={tabClass(i)}
onClick={() => onChange(i)}
>
{name}
</div>
))}
</div>
</div>
);
}
interface PageProps {
title?: string
}
export function Page({title, children}: PropsWithChildren<PageProps>) {
useEffect(() => {
window.document.title = title ? `${title} - Lucifer` : "Lucifer";
}, [title]);
return (
<div className="Page">
{children}
</div>
);
}
interface DialogProps {
title: string
buttons?: {
text: string
icon: LuciferIcon
onClick?: (() => void)
}[]
loading?: boolean
}
export function Dialog({title, buttons, children}: PropsWithChildren<DialogProps>) {
return (
<ReactModal isOpen={true}
className="Dialog-main"
overlayClassName="Dialog-overlay"
>
<div className="Dialog-container">
<div className="Dialog-header">
{title}
</div>
{children}
<div className="Dialog-footer">
{buttons?.map(b => (
<button key={b.text} onClick={b.onClick}>
<IconBlock icon={b.icon}/>
{" "}
{b.text}
</button>
))}
</div>
</div>
</ReactModal>
);
}
+15 -15
View File
@@ -1,15 +1,15 @@
import { ReportHandler } from 'web-vitals';
const reportWebVitals = (onPerfEntry?: ReportHandler) => {
if (onPerfEntry && onPerfEntry instanceof Function) {
import('web-vitals').then(({ getCLS, getFID, getFCP, getLCP, getTTFB }) => {
getCLS(onPerfEntry);
getFID(onPerfEntry);
getFCP(onPerfEntry);
getLCP(onPerfEntry);
getTTFB(onPerfEntry);
});
}
};
export default reportWebVitals;
import { ReportHandler } from 'web-vitals';
const reportWebVitals = (onPerfEntry?: ReportHandler) => {
if (onPerfEntry && onPerfEntry instanceof Function) {
import('web-vitals').then(({ getCLS, getFID, getFCP, getLCP, getTTFB }) => {
getCLS(onPerfEntry);
getFID(onPerfEntry);
getFCP(onPerfEntry);
getLCP(onPerfEntry);
getTTFB(onPerfEntry);
});
}
};
export default reportWebVitals;
+11
View File
@@ -0,0 +1,11 @@
$color-background: #111
$color-background-elevated: #222
$color-background-elevated2: #333
$color-foreground: rgb(204, 204, 204)
$color-foreground-elevated: #FFF
$color-foreground-dark: rgba(238, 238, 238, 0.05)
body, html
background-color: $color-background
color: $color-foreground
+8
View File
@@ -0,0 +1,8 @@
const proxy = require("http-proxy-middleware");
let config = {url: "http://127.0.0.1:9000/"};
module.exports = function(app) {
app.use(proxy('/api/ws', { target: config.url, ws: true }));
app.use(proxy('/api', { target: config.url, ws: false }));
};
+12049 -11447
View File
File diff suppressed because it is too large Load Diff