21 Commits
Author SHA1 Message Date
gisle bf903c1d71 fix crash in hue2 driver.
continuous-integration/drone/push Build is passing
2023-08-31 21:19:53 +02:00
gisle 19eb205ee3 try again.
continuous-integration/drone/push Build is passing
2023-08-31 20:37:54 +02:00
gisle edb45c74b6 Merge branch 'asmodeus' of git.aiterp.net:lucifer/new-server into asmodeus
continuous-integration/drone/push Build is passing
2023-08-31 20:25:59 +02:00
gisle c4b391db66 add forgetting devices to hue2 (untested yolo-deploy). 2023-08-31 20:25:26 +02:00
Stian Fredrik Aune 3bf8cbef2f Mill option for local client
continuous-integration/drone Build is passing
continuous-integration/drone/tag Build is passing
2023-02-12 22:18:17 +01:00
Stian Fredrik Aune 5db753e401 Fix Gen 3 support in mill
continuous-integration/drone/push Build is passing
2022-09-21 21:41:46 +02:00
gisle 2a750ae1f7 add lucy color command for conversions.
continuous-integration/drone/push Build is passing
2022-02-28 23:04:28 +01:00
gisle f1ed6da6d7 Squashed commit of the following:
continuous-integration/drone/push Build is passing
commit 62e7cb88f4
Author: Gisle Aune <dev@gisle.me>
Date:   Mon Feb 28 22:52:06 2022 +0100

    refactor color system to be separate from models, also move errors out of models.

commit df865574bf
Author: Gisle Aune <dev@gisle.me>
Date:   Mon Feb 21 19:27:42 2022 +0100

    rework color system and make it work with drivers.
2022-02-28 22:52:25 +01:00
gisle d5cf30ea4b sensitize periodic light refresh in hue2 again.
continuous-integration/drone/push Build is passing
2022-02-19 20:43:38 +01:00
gisle 582bf3f46b decrease sensitivity in hue2 (should fix double press, regular check over-updating and massive update storms when regular checks find a lot of incongruences), and fix rare race condition
continuous-integration/drone/push Build is passing
2022-02-19 10:06:40 +01:00
gisle c72a7c3f43 fix xy color conversion using srgb instead of rgb.
continuous-integration/drone/push Build is passing
2022-02-18 17:01:37 +01:00
gisle 8a20bab709 reduce Hue2 'update storms'..
continuous-integration/drone/push Build is passing
2022-02-18 16:24:09 +01:00
gisle 27fd858c04 add device search.
continuous-integration/drone/push Build is passing
2022-02-17 21:58:03 +01:00
gisle d19a8e6ca0 add device rename and tweaks.
continuous-integration/drone/push Build is failing
2022-02-17 16:57:33 +01:00
gisle 107b29620d it has the trappings of a driver now, but essential features are missing: discovery and forgetting
continuous-integration/drone/push Build is passing
2022-02-15 22:31:51 +01:00
gisle 73e6cba770 add very partial hue2 driver wip.
continuous-integration/drone/push Build is passing
2022-02-14 22:03:41 +01:00
gisle 44bd327a71 add index for name in scene table to force uniqueness. 2022-02-14 22:03:28 +01:00
gisle cedd0d1d28 add support for invoking and assigning scenes by name.
continuous-integration/drone/push Build is passing
2022-02-12 11:36:23 +01:00
gisle 7471102413 add scene list subcommand.
continuous-integration/drone/push Build is passing
2022-02-10 20:46:05 +01:00
gisle bf4d000455 add info mode to lucy list command. 2022-02-10 20:38:10 +01:00
gisle 6947fc3461 add delete light endpoint and forget for Hue.
continuous-integration/drone/push Build is passing
2022-01-31 23:09:00 +01:00
50 changed files with 2806 additions and 257 deletions
+63 -7
View File
@@ -4,9 +4,11 @@ import (
"context"
"git.aiterp.net/lucifer/new-server/app/config"
"git.aiterp.net/lucifer/new-server/app/services/publisher"
"git.aiterp.net/lucifer/new-server/internal/lerrors"
"git.aiterp.net/lucifer/new-server/models"
"github.com/gin-gonic/gin"
"log"
"strconv"
"time"
)
@@ -217,14 +219,28 @@ func Devices(r gin.IRoutes) {
return []models.Device{}, nil
}
_, err = config.SceneRepository().Find(ctxOf(c), body.SceneID)
if err != nil {
return nil, err
var scene *models.Scene
if body.SceneName != "" {
scene, err = config.SceneRepository().FindName(ctxOf(c), body.SceneName)
if err != nil {
return nil, err
}
if body.DurationMS < 0 {
body.DurationMS = 0
}
body.StartTime = time.Now()
body.SceneID = scene.ID
} else {
scene, err = config.SceneRepository().Find(ctxOf(c), body.SceneID)
if err != nil {
return nil, err
}
if body.DurationMS < 0 {
body.DurationMS = 0
}
body.StartTime = time.Now()
body.SceneName = scene.Name
}
if body.DurationMS < 0 {
body.DurationMS = 0
}
body.StartTime = time.Now()
pushMode := c.Query("push") == "true"
for i := range devices {
@@ -265,6 +281,46 @@ func Devices(r gin.IRoutes) {
return withSceneState(devices), nil
}))
r.DELETE("/:fetch", handler(func(c *gin.Context) (interface{}, error) {
id, err := strconv.Atoi(c.Param("fetch"))
if err != nil {
return nil, err
}
device, err := config.DeviceRepository().Find(ctxOf(c), id)
if err != nil {
return nil, err
}
if c.Query("forgetmenot") != "true" {
bridge, err := config.BridgeRepository().Find(ctxOf(c), device.BridgeID)
if err != nil {
return nil, err
}
driver, err := config.DriverProvider().Provide(bridge.Driver)
if err != nil {
return nil, err
}
forgettableDriver, ok := driver.(models.ForgettableDriver)
if !ok {
return nil, lerrors.ErrCannotForget
}
err = forgettableDriver.ForgetDevice(ctxOf(c), bridge, *device)
if err != nil {
return nil, err
}
}
err = config.DeviceRepository().Delete(ctxOf(c), device)
if err != nil {
return nil, err
}
return device, nil
}))
}
func withSceneState(devices []models.Device) []models.Device {
+4 -3
View File
@@ -2,6 +2,7 @@ package api
import (
"git.aiterp.net/lucifer/new-server/app/config"
"git.aiterp.net/lucifer/new-server/internal/color"
"git.aiterp.net/lucifer/new-server/models"
"github.com/gin-gonic/gin"
)
@@ -25,13 +26,13 @@ func ColorPresets(r gin.IRoutes) {
return nil, err
}
newColor, err := models.ParseColorValue(body.ColorString)
newColor, err := color.Parse(body.ColorString)
if err != nil {
return nil, err
}
preset := models.ColorPreset{
Name: body.Name,
Name: body.Name,
Value: newColor,
}
@@ -64,7 +65,7 @@ func ColorPresets(r gin.IRoutes) {
}
if body.ColorString != nil {
newColor, err := models.ParseColorValue(*body.ColorString)
newColor, err := color.Parse(*body.ColorString)
if err != nil {
return nil, err
}
+14 -2
View File
@@ -5,15 +5,27 @@ import (
"git.aiterp.net/lucifer/new-server/app/services/publisher"
"git.aiterp.net/lucifer/new-server/models"
"github.com/gin-gonic/gin"
"regexp"
)
func Scenes(r gin.IRoutes) {
nonNumericRegex := regexp.MustCompile("[a-zA-Z ]")
findScene := func(c *gin.Context) (*models.Scene, error) {
param := c.Param("id")
if nonNumericRegex.MatchString(param) {
return config.SceneRepository().FindName(ctxOf(c), param)
} else {
return config.SceneRepository().Find(ctxOf(c), intParam(c, "id"))
}
}
r.GET("", handler(func(c *gin.Context) (interface{}, error) {
return config.SceneRepository().FetchAll(ctxOf(c))
}))
r.GET("/:id", handler(func(c *gin.Context) (interface{}, error) {
return config.SceneRepository().Find(ctxOf(c), intParam(c, "id"))
return findScene(c)
}))
r.POST("", handler(func(c *gin.Context) (interface{}, error) {
@@ -45,7 +57,7 @@ func Scenes(r gin.IRoutes) {
return nil, err
}
scene, err := config.SceneRepository().Find(ctxOf(c), intParam(c, "id"))
scene, err := findScene(c)
if err != nil {
return nil, err
}
+14 -14
View File
@@ -3,25 +3,25 @@ package api
import (
"context"
"encoding/json"
"git.aiterp.net/lucifer/new-server/models"
"git.aiterp.net/lucifer/new-server/internal/lerrors"
"github.com/gin-gonic/gin"
"strconv"
)
var errorMap = map[error]int{
models.ErrNotFound: 404,
models.ErrInvalidName: 400,
models.ErrBadInput: 400,
models.ErrBadColor: 400,
models.ErrInternal: 500,
models.ErrUnknownColorFormat: 400,
lerrors.ErrNotFound: 404,
lerrors.ErrInvalidName: 400,
lerrors.ErrBadInput: 400,
lerrors.ErrBadColor: 400,
lerrors.ErrInternal: 500,
lerrors.ErrUnknownColorFormat: 400,
models.ErrSceneInvalidInterval: 400,
models.ErrSceneNoRoles: 400,
models.ErrSceneRoleNoStates: 400,
models.ErrSceneRoleUnsupportedOrdering: 422,
models.ErrSceneRoleUnknownEffect: 422,
models.ErrSceneRoleUnknownPowerMode: 422,
lerrors.ErrSceneInvalidInterval: 400,
lerrors.ErrSceneNoRoles: 400,
lerrors.ErrSceneRoleNoStates: 400,
lerrors.ErrSceneRoleUnsupportedOrdering: 422,
lerrors.ErrSceneRoleUnknownEffect: 422,
lerrors.ErrSceneRoleUnknownPowerMode: 422,
}
type response struct {
@@ -66,7 +66,7 @@ func intParam(c *gin.Context, key string) int {
func parseBody(c *gin.Context, target interface{}) error {
err := json.NewDecoder(c.Request.Body).Decode(target)
if err != nil {
return models.ErrBadInput
return lerrors.ErrBadInput
}
return nil
+2
View File
@@ -3,6 +3,7 @@ package config
import (
"git.aiterp.net/lucifer/new-server/internal/drivers"
"git.aiterp.net/lucifer/new-server/internal/drivers/hue"
"git.aiterp.net/lucifer/new-server/internal/drivers/hue2"
"git.aiterp.net/lucifer/new-server/internal/drivers/lifx"
"git.aiterp.net/lucifer/new-server/internal/drivers/mill"
"git.aiterp.net/lucifer/new-server/internal/drivers/nanoleaf"
@@ -18,6 +19,7 @@ func DriverProvider() models.DriverProvider {
dp = drivers.DriverMap{
models.DTNanoLeaf: &nanoleaf.Driver{},
models.DTHue: &hue.Driver{},
models.DTHue2: &hue2.Driver{},
models.DTLIFX: &lifx.Driver{},
models.DTMill: &mill.Driver{},
}
+24 -9
View File
@@ -87,6 +87,8 @@ func (p *Publisher) Publish(devices ...models.Device) {
return
}
unwaitMap := make(map[int]bool)
p.mu.Lock()
defer p.mu.Unlock()
@@ -106,10 +108,14 @@ func (p *Publisher) Publish(devices ...models.Device) {
}
} else {
p.pending[device.BridgeID] = append(p.pending[device.BridgeID], device)
if p.waiting[device.BridgeID] != nil {
close(p.waiting[device.BridgeID])
p.waiting[device.BridgeID] = nil
}
unwaitMap[device.BridgeID] = true
}
}
for bridgeID := range unwaitMap {
if p.waiting[bridgeID] != nil {
close(p.waiting[bridgeID])
p.waiting[bridgeID] = nil
}
}
}
@@ -124,11 +130,16 @@ func (p *Publisher) Run() {
ticker := time.NewTicker(time.Millisecond * 100)
deleteList := make([]int, 0, 8)
updatedList := make([]models.Device, 0, 16)
unwaitMap := make(map[int]bool)
for range ticker.C {
deleteList = deleteList[:0]
updatedList = updatedList[:0]
for key := range unwaitMap {
delete(unwaitMap, key)
}
p.mu.Lock()
for i, scene := range p.scenes {
if (!scene.endTime.IsZero() && time.Now().After(scene.endTime)) || scene.Empty() {
@@ -160,9 +171,13 @@ func (p *Publisher) Run() {
}
p.pending[device.BridgeID] = append(p.pending[device.BridgeID], device)
if p.waiting[device.BridgeID] != nil {
close(p.waiting[device.BridgeID])
p.waiting[device.BridgeID] = nil
unwaitMap[device.BridgeID] = true
}
for bridgeID := range unwaitMap {
if p.waiting[bridgeID] != nil {
close(p.waiting[bridgeID])
p.waiting[bridgeID] = nil
}
}
p.mu.Unlock()
@@ -270,8 +285,8 @@ func (p *Publisher) runBridge(id int) {
p.mu.Lock()
}
updates := p.pending[id]
p.pending[id] = p.pending[id][:0:0]
updates := append(p.pending[id][:0:0], p.pending[id]...)
p.pending[id] = p.pending[id][:0]
p.mu.Unlock()
// Only allow the latest update per device (this avoids slow bridges causing a backlog of cations).
+2 -1
View File
@@ -7,6 +7,7 @@ import (
"flag"
"fmt"
"git.aiterp.net/lucifer/new-server/app/config"
"git.aiterp.net/lucifer/new-server/internal/color"
"git.aiterp.net/lucifer/new-server/models"
"log"
"os"
@@ -124,7 +125,7 @@ func main() {
continue
}
color, err := models.ParseColorValue(tokens[2])
color, err := color.Parse(tokens[2])
if err != nil {
_, _ = fmt.Fprintln(os.Stderr, "Invalid color:", err)
continue
+1 -1
View File
@@ -11,7 +11,7 @@ import (
func main() {
db := config.DBX().DB
log.Printf("Target version: %s",time.Now().Format("20060102150405"))
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())
+4
View File
@@ -36,6 +36,10 @@ EVENT HANDLER FACTORY COMMANDS
factory run <file.yaml>
MISC COMMANDS
color <color>
File example (use '---' to delimit multiple configs in one file):
mode: SceneDimmer
target: tag:Soverom
+157
View File
@@ -0,0 +1,157 @@
package main
import (
"encoding/json"
"fmt"
"git.aiterp.net/lucifer/new-server/models"
"io"
"sort"
"strings"
"time"
)
func WriteDeviceInfo(w io.Writer, devices []models.Device, fields []string) {
if len(fields) == 1 && fields[0] == "all" {
fields = allFields
} else {
fields = append(fields[:0:0], fields...)
for i, field := range fields {
if aliased, ok := fieldAliases[field]; ok {
fields[i] = aliased
}
}
}
length := 0
for _, field := range fields {
if mlFields[field] {
continue
}
if len(field) > length {
length = len(field)
}
}
for i, device := range devices {
if i > 0 {
fmt.Println()
}
for _, field := range fields {
if formatter, ok := fieldFormatters[field]; ok {
_, _ = fmt.Fprintf(w, "%-*s %s\n", length+1, field+":", formatter(&device))
} else {
_, _ = fmt.Fprintf(w, "%-*s (unknown field)\n", length+1, field+":")
}
}
}
}
var allFields = []string{
"id", "name", "icon", "bridgeId", "tags", "buttonNames",
"power", "color", "intensity", "temperature",
"internalId", "sceneAssignment", "userProperties", "driverProperties",
}
var mlFields = map[string]bool{
"sceneAssignment": true,
"userProperties": true,
"driverProperties": true,
}
var fieldFormatters = map[string]func(d *models.Device) string{
"id": func(d *models.Device) string { return fmt.Sprint(d.ID) },
"internalId": func(d *models.Device) string { return d.InternalID },
"name": func(d *models.Device) string { return d.Name },
"icon": func(d *models.Device) string { return d.Icon },
"bridgeId": func(d *models.Device) string { return fmt.Sprint(d.BridgeID) },
"tags": func(d *models.Device) string { return strings.Join(d.Tags, ", ") },
"buttonNames": func(d *models.Device) string { return strings.Join(d.ButtonNames, ", ") },
"power": func(d *models.Device) string { return fmt.Sprint(d.State.Power) },
"color": func(d *models.Device) string { return d.State.Color.String() },
"intensity": func(d *models.Device) string { return fmt.Sprint(d.State.Intensity) },
"temperature": func(d *models.Device) string { return fmt.Sprint(d.State.Temperature) },
"sceneAssignment": func(d *models.Device) string {
str := &strings.Builder{}
str.Grow(64)
for _, scene := range d.SceneAssignments {
_, _ = fmt.Fprintf(str, "\n - id: %d\n", scene.SceneID)
_, _ = fmt.Fprintf(str, " duration: %d\n", scene.DurationMS)
_, _ = fmt.Fprintf(str, " group: %s\n", scene.Group)
_, _ = fmt.Fprintf(str, " startTime: %s", scene.StartTime.Format(time.RFC3339))
}
if len(d.SceneAssignments) == 0 {
str.Reset()
str.WriteString("(none)")
}
return str.String()
},
"userProperties": func(d *models.Device) string {
str := &strings.Builder{}
str.Grow(64)
str.WriteString("\n")
keys := make([]string, 0, len(d.UserProperties))
for key := range d.UserProperties {
keys = append(keys, key)
}
sort.Strings(keys)
for _, key := range keys {
_, _ = fmt.Fprintf(str, " %s: %s\n", key, d.UserProperties[key])
}
if len(d.UserProperties) == 0 {
str.Reset()
str.WriteString("(none)")
}
return str.String()
},
"driverProperties": func(d *models.Device) string {
str := &strings.Builder{}
str.Grow(256)
str.WriteString("\n")
keys := make([]string, 0, len(d.DriverProperties))
for key := range d.DriverProperties {
keys = append(keys, key)
}
sort.Strings(keys)
for _, key := range keys {
j, _ := json.Marshal(d.DriverProperties[key])
_, _ = fmt.Fprintf(str, " %s: %s\n", key, string(j))
}
if len(d.DriverProperties) == 0 {
str.Reset()
str.WriteString("(none)")
}
return str.String()
},
"capabilities": func(d *models.Device) string {
capStr := make([]string, len(d.Capabilities))
for i := range d.Capabilities {
capStr[i] = string(d.Capabilities[i])
}
return strings.Join(capStr, ", ")
},
}
var fieldAliases = map[string]string{
"bridge": "bridgeId",
"buttons": "buttonNames",
"internal": "internalId",
"caps": "capabilities",
"cap": "capabilities",
"userprops": "userProperties",
"uprops": "userProperties",
"driverprops": "driverProperties",
"dprops": "driverProperties",
"internalid": "internalId",
"bridgeid": "bridgeId",
"buttonnames": "buttonNames",
"scene": "sceneAssignment",
}
+26 -2
View File
@@ -4,6 +4,7 @@ import (
"context"
"fmt"
"git.aiterp.net/lucifer/new-server/app/client"
"git.aiterp.net/lucifer/new-server/internal/color"
"git.aiterp.net/lucifer/new-server/models"
"log"
"os"
@@ -37,8 +38,8 @@ func main() {
log.Fatalln(err)
}
if cmd.Params.Get("info").StringOr("false") == "true" {
WriteDeviceInfoTable(os.Stdout, devices)
if info := cmd.Params.Get("info").String(); info != nil {
WriteDeviceInfo(os.Stdout, devices, strings.Split(*info, ","))
} else {
WriteDeviceStateTable(os.Stdout, devices)
}
@@ -92,6 +93,29 @@ func main() {
WriteDeviceInfoTable(os.Stdout, devices)
}
case "color":
{
col, err := color.Parse(cmd.Params.Get(0).StringOr("hs:0,0"))
if err != nil {
log.Fatalln(err)
}
if col.IsKelvin() {
fmt.Println(col.String())
}
if hs, ok := col.ToHS(); ok {
fmt.Println(hs.String())
}
if hsk, ok := col.ToHSK(); ok {
fmt.Println(hsk.String())
}
if xy, ok := col.ToXY(); ok {
fmt.Println(xy.String())
}
if rgb, ok := col.ToRGB(); ok {
fmt.Println(rgb.String())
}
}
case "factory":
factoryCmd(ctx, c)
+22 -5
View File
@@ -19,6 +19,16 @@ func sceneCmd(
cmd := parseCommand(os.Args[2:])
switch cmd.Name {
case "list":
{
scenes, err := c.GetScenes(ctx)
if err != nil {
log.Fatalln("Failed to load scenes:", err)
}
WriteSceneTable(os.Stdout, scenes)
}
case "create", "update":
{
fileName := cmd.Params.Get(0).String()
@@ -83,15 +93,22 @@ func sceneCmd(
{
fetch := cmd.Params.Get(0).String()
id := cmd.Params.Get(1).Int()
if fetch == nil || id == nil {
log.Println("Usage: lucy scene assign <fetch> <id> <group=S> <duration=I>")
name := cmd.Params.Get(1).String()
if fetch == nil || (id == nil && name == nil) {
log.Println("Usage: lucy scene assign <fetch> <id|name> <group=S> <duration=I>")
}
devices, err := c.AssignDevice(ctx, *fetch, cmd.Name == "push", models.DeviceSceneAssignment{
SceneID: *id,
assignment := models.DeviceSceneAssignment{
Group: cmd.Params.Get("group").StringOr(*fetch),
DurationMS: int64(cmd.Params.Get("duration").IntOr(0)),
})
}
if id != nil {
assignment.SceneID = *id
} else {
assignment.SceneName = *name
}
devices, err := c.AssignDevice(ctx, *fetch, cmd.Name == "push", assignment)
if err != nil {
log.Println("Could not assign devices:", err)
return
+18
View File
@@ -94,6 +94,24 @@ func WriteDeviceInfoTable(w io.Writer, devices []models.Device) {
table.Render()
}
func WriteSceneTable(w io.Writer, scenes []models.Scene) {
table := tablewriter.NewWriter(w)
table.SetHeader([]string{"ID", "NAME", "ROLES", "INTERVAL"})
table.SetReflowDuringAutoWrap(false)
for _, v := range scenes {
table.Append([]string{
fmt.Sprint(v.ID),
v.Name,
fmt.Sprint(len(v.Roles)),
fmt.Sprint(v.IntervalMS),
})
}
table.Render()
}
func WriteHandlerInfoTable(w io.Writer, handlers []models.EventHandler) {
table := tablewriter.NewWriter(w)
table.SetHeader([]string{"ID", "EVENT NAME", "PRIORITY", "CONDITIONS", "TARGET", "ACTION"})
+15
View File
@@ -0,0 +1,15 @@
package main
import (
"git.aiterp.net/lucifer/new-server/internal/color"
"log"
)
func main() {
cv, _ := color.Parse("xy:0.1944,0.0942")
hs, _ := cv.ToHS()
log.Println(cv.String())
log.Println(hs.String())
xy, _ := hs.ToXY()
log.Println(xy.String())
}
+369
View File
@@ -0,0 +1,369 @@
package color
import (
"fmt"
"git.aiterp.net/lucifer/new-server/internal/lerrors"
"github.com/lucasb-eyer/go-colorful"
"strconv"
"strings"
)
type Color struct {
RGB *RGB `json:"rgb,omitempty"`
HS *HueSat `json:"hs,omitempty"`
K *int `json:"k,omitempty"`
XY *XY `json:"xy,omitempty"`
}
func (col *Color) IsHueSat() bool {
return col.HS != nil
}
func (col *Color) IsHueSatKelvin() bool {
return col.HS != nil && col.K != nil
}
func (col *Color) IsKelvin() bool {
return col.K != nil
}
func (col *Color) IsEmpty() bool {
return *col == Color{}
}
func (col *Color) SetK(k int) {
*col = Color{K: &k}
}
func (col *Color) SetXY(xy XY) {
*col = Color{XY: &xy}
}
// ToRGB tries to copy the color to an RGB color. If it's already RGB, it will be plainly copied, but HS
// will be returned. If ok is false, then no copying has occurred and cv2 will be blank.
func (col *Color) ToRGB() (col2 Color, ok bool) {
if col.RGB != nil {
rgb := *col.RGB
col2 = Color{RGB: &rgb}
ok = true
} else if col.HS != nil {
rgb := col.HS.ToRGB()
col2 = Color{RGB: &rgb}
ok = true
} else if col.XY != nil {
rgb := col.XY.ToRGB()
col2 = Color{RGB: &rgb}
ok = true
}
return
}
func (col *Color) ToHS() (col2 Color, ok bool) {
if col.HS != nil {
hs := *col.HS
col2 = Color{HS: &hs}
ok = true
} else if col.RGB != nil {
hs := col.RGB.ToHS()
col2 = Color{HS: &hs}
ok = true
} else if col.XY != nil {
hs := col.XY.ToHS()
col2 = Color{HS: &hs}
ok = true
}
return
}
func (col *Color) ToHSK() (col2 Color, ok bool) {
k := 4000
if col.HS != nil {
hs := *col.HS
col2 = Color{HS: &hs}
if col.K != nil {
k = *col.K
}
col2.K = &k
ok = true
} else if col.RGB != nil {
hs := col.RGB.ToHS()
col2 = Color{HS: &hs}
col2.K = &k
ok = true
} else if col.XY != nil {
hs := col.XY.ToHS()
col2 = Color{HS: &hs}
col2.K = &k
ok = true
} else if col.K != nil {
k = *col.K
col2.HS = &HueSat{Hue: 0, Sat: 0}
col2.K = &k
ok = true
}
return
}
// ToXY tries to copy the color to an XY color.
func (col *Color) ToXY() (col2 Color, ok bool) {
if col.XY != nil {
xy := *col.XY
col2 = Color{XY: &xy}
ok = true
} else if col.HS != nil {
xy := col.HS.ToXY()
col2 = Color{XY: &xy}
ok = true
} else if col.RGB != nil {
xy := col.RGB.ToXY()
col2 = Color{XY: &xy}
ok = true
}
return
}
func (col *Color) Interpolate(other Color, fac float64) Color {
// Special case for kelvin values.
if col.IsKelvin() && other.IsKelvin() {
k1 := *col.K
k2 := *col.K
k3 := k1 + int(float64(k2-k1)*fac)
return Color{K: &k3}
}
// Get the colorful values.
cvCF := col.colorful()
otherCF := other.colorful()
// Blend and normalize
blended := cvCF.BlendLuv(otherCF, fac)
blendedHue, blendedSat, _ := blended.Hsv()
blendedHs := HueSat{Hue: blendedHue, Sat: blendedSat}
// Convert to the first's type
switch col.Kind() {
case "rgb":
rgb := blendedHs.ToRGB()
return Color{RGB: &rgb}
case "xy":
xy := blendedHs.ToXY()
return Color{XY: &xy}
default:
return Color{HS: &blendedHs}
}
}
func (col *Color) Kind() string {
switch {
case col.RGB != nil:
return "rgb"
case col.XY != nil:
return "xy"
case col.HS != nil && col.K != nil:
return "hsk"
case col.HS != nil:
return "hs"
case col.K != nil:
return "k"
default:
return ""
}
}
func (col *Color) String() string {
switch {
case col.RGB != nil:
return fmt.Sprintf("rgb:%.3f,%.3f,%.3f", col.RGB.Red, col.RGB.Green, col.RGB.Blue)
case col.XY != nil:
return fmt.Sprintf("xy:%.4f,%.4f", col.XY.X, col.XY.Y)
case col.HS != nil && col.K != nil:
return fmt.Sprintf("hsk:%.4f,%.3f,%d", col.HS.Hue, col.HS.Sat, *col.K)
case col.HS != nil:
return fmt.Sprintf("hs:%.4f,%.3f", col.HS.Hue, col.HS.Sat)
case col.K != nil:
return fmt.Sprintf("k:%d", *col.K)
default:
return ""
}
}
func (col *Color) colorful() colorful.Color {
switch {
case col.HS != nil:
return colorful.Hsv(col.HS.Hue, col.HS.Sat, 1)
case col.RGB != nil:
return colorful.Color{R: col.RGB.Red, G: col.RGB.Green, B: col.RGB.Blue}
case col.XY != nil:
return colorful.Xyy(col.XY.X, col.XY.Y, 0.5)
default:
return colorful.Color{R: 255, B: 255, G: 255}
}
}
func Parse(raw string) (col Color, err error) {
if raw == "" {
return
}
tokens := strings.SplitN(raw, ":", 2)
if len(tokens) != 2 {
err = lerrors.ErrBadInput
return
}
switch tokens[0] {
case "kelvin", "k":
{
parsedPart, err := strconv.Atoi(tokens[1])
if err != nil {
err = lerrors.ErrBadInput
break
}
col.K = &parsedPart
}
case "xy":
{
parts := strings.Split(tokens[1], ",")
if len(parts) < 2 {
err = lerrors.ErrUnknownColorFormat
return
}
x, err1 := strconv.ParseFloat(parts[0], 64)
y, err2 := strconv.ParseFloat(parts[1], 64)
if err1 != nil || err2 != nil {
err = lerrors.ErrBadInput
break
}
col.XY = &XY{x, y}
}
case "hs":
{
parts := strings.Split(tokens[1], ",")
if len(parts) < 2 {
err = lerrors.ErrUnknownColorFormat
return
}
part1, err1 := strconv.ParseFloat(parts[0], 64)
part2, err2 := strconv.ParseFloat(parts[1], 64)
if err1 != nil || err2 != nil {
err = lerrors.ErrBadInput
break
}
col.HS = &HueSat{Hue: part1, Sat: part2}
}
case "hsk":
{
parts := strings.Split(tokens[1], ",")
if len(parts) < 3 {
err = lerrors.ErrUnknownColorFormat
return
}
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 {
err = lerrors.ErrBadInput
break
}
col.HS = &HueSat{Hue: part1, Sat: part2}
col.K = &part3
}
case "rgb":
{
if strings.HasPrefix(tokens[1], "#") {
hex := tokens[1][1:]
if !validHex(hex) {
err = lerrors.ErrBadInput
break
}
if len(hex) == 6 {
col.RGB = &RGB{
Red: float64(hex2num(hex[0:2])) / 255.0,
Green: float64(hex2num(hex[2:4])) / 255.0,
Blue: float64(hex2num(hex[4:6])) / 255.0,
}
} else if len(hex) == 3 {
col.RGB = &RGB{
Red: float64(hex2digit(hex[0])) / 15.0,
Green: float64(hex2digit(hex[1])) / 15.0,
Blue: float64(hex2digit(hex[2])) / 15.0,
}
} else {
err = lerrors.ErrUnknownColorFormat
return
}
} else {
parts := strings.Split(tokens[1], ",")
if len(parts) < 3 {
err = lerrors.ErrUnknownColorFormat
return
}
part1, err1 := strconv.ParseFloat(parts[0], 64)
part2, err2 := strconv.ParseFloat(parts[1], 64)
part3, err3 := strconv.ParseFloat(parts[2], 64)
if err1 != nil || err2 != nil || err3 != nil {
err = lerrors.ErrBadInput
break
}
col.RGB = &RGB{Red: part1, Green: part2, Blue: part3}
}
normalizedRGB := col.RGB.ToHS().ToRGB()
col.RGB = &normalizedRGB
}
default:
err = lerrors.ErrUnknownColorFormat
}
return
}
func validHex(h string) bool {
for _, ch := range h {
if !((ch >= 'a' && ch <= 'f') || (ch >= '0' || ch <= '9')) {
return false
}
}
return true
}
func hex2num(s string) int {
v := 0
for _, h := range s {
v *= 16
v += hex2digit(byte(h))
}
return v
}
func hex2digit(h byte) int {
if h >= 'a' && h <= 'f' {
return 10 + int(h-'a')
} else {
return int(h - '0')
}
}
+17
View File
@@ -0,0 +1,17 @@
package color
import "github.com/lucasb-eyer/go-colorful"
type HueSat struct {
Hue float64 `json:"hue"`
Sat float64 `json:"sat"`
}
func (hs HueSat) ToXY() XY {
return hs.ToRGB().ToXY()
}
func (hs HueSat) ToRGB() RGB {
c := colorful.Hsv(hs.Hue, hs.Sat, 1)
return RGB{Red: c.R, Green: c.G, Blue: c.B}
}
+29
View File
@@ -0,0 +1,29 @@
package color
import "github.com/lucasb-eyer/go-colorful"
type RGB struct {
Red float64 `json:"red"`
Green float64 `json:"green"`
Blue float64 `json:"blue"`
}
func (rgb RGB) AtIntensity(intensity float64) RGB {
hue, sat, _ := colorful.Color{R: rgb.Red, G: rgb.Green, B: rgb.Blue}.Hsv()
hsv2 := colorful.Hsv(hue, sat, intensity)
return RGB{Red: hsv2.R, Green: hsv2.G, Blue: hsv2.B}
}
func (rgb RGB) ToHS() HueSat {
hue, sat, _ := colorful.Color{R: rgb.Red, G: rgb.Green, B: rgb.Blue}.Hsv()
return HueSat{Hue: hue, Sat: sat}
}
func (rgb RGB) ToXY() XY {
x, y, z := (colorful.Color{R: rgb.Red, G: rgb.Green, B: rgb.Blue}).Xyz()
return XY{
X: x / (x + y + z),
Y: y / (x + y + z),
}
}
+182
View File
@@ -0,0 +1,182 @@
package color
import (
"github.com/lucasb-eyer/go-colorful"
"math"
)
const eps = 0.0001
const epsSquare = eps * eps
type Gamut struct {
Red XY `json:"red"`
Green XY `json:"green"`
Blue XY `json:"blue"`
}
func (cg *Gamut) side(x1, y1, x2, y2, x, y float64) float64 {
return (y2-y1)*(x-x1) + (-x2+x1)*(y-y1)
}
func (cg *Gamut) naiveContains(color XY) bool {
x, y := color.X, color.Y
x1, y1 := cg.Red.X, cg.Red.Y
x2, y2 := cg.Green.X, cg.Green.Y
x3, y3 := cg.Blue.X, cg.Blue.Y
checkSide1 := cg.side(x1, y1, x2, y2, x, y) < 0
checkSide2 := cg.side(x2, y2, x3, y3, x, y) < 0
checkSide3 := cg.side(x3, y3, x1, y1, x, y) < 0
return checkSide1 && checkSide2 && checkSide3
}
func (cg *Gamut) getBounds() (xMin, xMax, yMin, yMax float64) {
x1, y1 := cg.Red.X, cg.Red.Y
x2, y2 := cg.Green.X, cg.Green.Y
x3, y3 := cg.Blue.X, cg.Blue.Y
xMin = math.Min(x1, math.Min(x2, x3)) - eps
xMax = math.Max(x1, math.Max(x2, x3)) + eps
yMin = math.Min(y1, math.Min(y2, y3)) - eps
yMax = math.Max(y1, math.Max(y2, y3)) + eps
return
}
func (cg *Gamut) isInBounds(color XY) bool {
x, y := color.X, color.Y
xMin, xMax, yMin, yMax := cg.getBounds()
return !(x < xMin || xMax < x || y < yMin || yMax < y)
}
func (cg *Gamut) distanceSquarePointToSegment(x1, y1, x2, y2, x, y float64) float64 {
sqLength1 := (x2-x1)*(x2-x1) + (y2-y1)*(y2-y1)
dotProduct := ((x-x1)*(x2-x1) + (y-y1)*(y2-y1)) / sqLength1
if dotProduct < 0 {
return (x-x1)*(x-x1) + (y-y1)*(y-y1)
} else if dotProduct <= 1 {
sqLength2 := (x1-x)*(x1-x) + (y1-y)*(y1-y)
return sqLength2 - dotProduct*dotProduct*sqLength1
} else {
return (x-x2)*(x-x2) + (y-y2)*(y-y2)
}
}
func (cg *Gamut) atTheEdge(color XY) bool {
x, y := color.X, color.Y
x1, y1 := cg.Red.X, cg.Red.Y
x2, y2 := cg.Green.X, cg.Green.Y
x3, y3 := cg.Blue.X, cg.Blue.Y
if cg.distanceSquarePointToSegment(x1, y1, x2, y2, x, y) <= epsSquare {
return true
}
if cg.distanceSquarePointToSegment(x2, y2, x3, y3, x, y) <= epsSquare {
return true
}
if cg.distanceSquarePointToSegment(x3, y3, x1, y1, x, y) <= epsSquare {
return true
}
return false
}
func (cg *Gamut) Contains(color XY) bool {
if cg == nil {
return true
}
return cg.isInBounds(color) && (cg.naiveContains(color) || cg.atTheEdge(color))
}
func (cg *Gamut) Conform(color XY) XY {
if cg.Contains(color) {
return color
}
var best *XY
xMin, xMax, yMin, yMax := cg.getBounds()
for x := xMin; x < xMax; x += 0.001 {
for y := yMin; y < yMax; y += 0.001 {
color2 := XY{X: x, Y: y}
if cg.Contains(color2) {
if best == nil || color.DistanceTo(color2) < color.DistanceTo(*best) {
best = &color2
}
}
}
}
if best == nil {
centerX := (cg.Red.X + cg.Green.X + cg.Blue.X) / 3
centerY := (cg.Red.Y + cg.Green.Y + cg.Blue.Y) / 3
stepX := (centerX - color.X) / 5000
stepY := (centerY - color.Y) / 5000
for !cg.Contains(color) {
color.X += stepX
color.Y += stepY
}
return color
}
for x := best.X - 0.001; x < best.X+0.001; x += 0.0002 {
for y := best.Y - 0.001; y < best.Y+0.001; y += 0.0002 {
color2 := XY{X: x, Y: y}
if cg.atTheEdge(color2) {
if best == nil || color.DistanceTo(color2) < color.DistanceTo(*best) {
best = &color2
}
}
}
}
for x := best.X - 0.0001; x < best.X+0.0001; x += 0.00003 {
for y := best.Y - 0.0001; y < best.Y+0.0001; y += 0.00003 {
color2 := XY{X: x, Y: y}
if cg.atTheEdge(color2) {
if best == nil || color.DistanceTo(color2) < color.DistanceTo(*best) {
best = &color2
}
}
}
}
return *best
}
type XY struct {
X float64 `json:"x"`
Y float64 `json:"y"`
}
func (xy XY) ToRGB() RGB {
h, s, _ := colorful.Xyy(xy.X, xy.Y, 0.5).Hsv()
c := colorful.Hsv(h, s, 1)
return RGB{Red: c.R, Green: c.G, Blue: c.B}
}
func (xy XY) ToHS() HueSat {
h, s, _ := colorful.Xyy(xy.X, xy.Y, 0.5).Hsv()
return HueSat{Hue: h, Sat: s}
}
func (xy XY) DistanceTo(other XY) float64 {
return math.Sqrt(math.Pow(xy.X-other.X, 2) + math.Pow(xy.Y-other.Y, 2))
}
func (xy XY) Round() XY {
return XY{
X: math.Round(xy.X*10000) / 10000,
Y: math.Round(xy.Y*10000) / 10000,
}
}
+53 -1
View File
@@ -5,6 +5,7 @@ import (
"context"
"encoding/json"
"fmt"
"git.aiterp.net/lucifer/new-server/internal/lerrors"
"git.aiterp.net/lucifer/new-server/models"
"golang.org/x/sync/errgroup"
"io"
@@ -23,6 +24,7 @@ type Bridge struct {
externalID int
lightStates []*hueLightState
sensorStates []*hueSensorState
quarantine map[string]time.Time
syncingPublish uint32
}
@@ -34,6 +36,10 @@ func (b *Bridge) Refresh(ctx context.Context) error {
b.mu.Lock()
for index, light := range lightMap {
if time.Now().Before(b.quarantine[light.Uniqueid]) {
continue
}
var state *hueLightState
for _, existingState := range b.lightStates {
if existingState.index == index {
@@ -68,6 +74,10 @@ func (b *Bridge) Refresh(ctx context.Context) error {
b.mu.Lock()
for index, sensor := range sensorMap {
if time.Now().Before(b.quarantine[sensor.UniqueID]) {
continue
}
var state *hueSensorState
for _, existingState := range b.sensorStates {
if existingState.index == index {
@@ -137,6 +147,44 @@ func (b *Bridge) SyncStale(ctx context.Context) error {
return eg.Wait()
}
func (b *Bridge) ForgetDevice(ctx context.Context, device models.Device) error {
// Find index
b.mu.Lock()
found := false
index := -1
for i, ls := range b.lightStates {
if ls.uniqueID == device.InternalID {
found = true
index = i
}
}
b.mu.Unlock()
if !found {
return lerrors.ErrNotFound
}
// Delete light from bridge
err := b.deleteLight(ctx, index)
if err != nil {
return err
}
// Remove light state from local list. I don't know if the quarantine is necessary, but let's have it anyway.
b.mu.Lock()
for i, ls := range b.lightStates {
if ls.uniqueID == device.InternalID {
b.lightStates = append(b.lightStates[:i], b.lightStates[i+1:]...)
}
}
if b.quarantine == nil {
b.quarantine = make(map[string]time.Time, 1)
}
b.quarantine[device.InternalID] = time.Now().Add(time.Second * 30)
b.mu.Unlock()
return nil
}
func (b *Bridge) SyncSensors(ctx context.Context) ([]models.Event, error) {
sensorMap, err := b.getSensors(ctx)
if err != nil {
@@ -175,6 +223,10 @@ func (b *Bridge) putGroupLightState(ctx context.Context, index int, input LightS
return b.put(ctx, fmt.Sprintf("groups/%d/action", index), input, nil)
}
func (b *Bridge) deleteLight(ctx context.Context, index int) error {
return b.delete(ctx, fmt.Sprintf("lights/%d", index), nil)
}
func (b *Bridge) getToken(ctx context.Context) (string, error) {
result := make([]CreateUserResponse, 0, 1)
err := b.post(ctx, "", CreateUserInput{DeviceType: "git.aiterp.net/lucifer"}, &result)
@@ -186,7 +238,7 @@ func (b *Bridge) getToken(ctx context.Context) (string, error) {
return "", errLinkButtonNotPressed
}
if result[0].Success == nil {
return "", models.ErrUnexpectedResponse
return "", lerrors.ErrUnexpectedResponse
}
return result[0].Success.Username, nil
+13 -3
View File
@@ -5,6 +5,7 @@ import (
"encoding/json"
"encoding/xml"
"fmt"
"git.aiterp.net/lucifer/new-server/internal/lerrors"
"git.aiterp.net/lucifer/new-server/models"
"log"
"net/http"
@@ -22,7 +23,7 @@ type Driver struct {
func (d *Driver) SearchBridge(ctx context.Context, address, _ string, dryRun bool) ([]models.Bridge, error) {
if address == "" {
if !dryRun {
return nil, models.ErrAddressOnlyDryRunnable
return nil, lerrors.ErrAddressOnlyDryRunnable
}
res, err := http.Get("https://discovery.meethue.com")
@@ -322,8 +323,8 @@ func (d *Driver) Run(ctx context.Context, bridge models.Bridge, ch chan<- models
return err
}
fastTicker := time.NewTicker(time.Second / 10)
slowTicker := time.NewTicker(time.Second / 3)
fastTicker := time.NewTicker(time.Second / 5)
slowTicker := time.NewTicker(time.Second / 2)
selectedTicker := fastTicker
ticksUntilRefresh := 0
ticksSinceChange := 0
@@ -372,6 +373,15 @@ func (d *Driver) Run(ctx context.Context, bridge models.Bridge, ch chan<- models
}
}
func (d *Driver) ForgetDevice(ctx context.Context, bridge models.Bridge, device models.Device) error {
b, err := d.ensureBridge(ctx, bridge)
if err != nil {
return err
}
return b.ForgetDevice(ctx, device)
}
func (d *Driver) ensureBridge(ctx context.Context, info models.Bridge) (*Bridge, error) {
d.mu.Lock()
for _, bridge := range d.bridges {
+4 -4
View File
@@ -40,7 +40,7 @@ func (s *hueLightState) Update(state models.DeviceState) {
if state.Power {
input.On = ptrBool(true)
if state.Color.IsKelvin() {
input.CT = ptrInt(1000000 / state.Color.Kelvin)
input.CT = ptrInt(1000000 / *state.Color.K)
if *input.CT < s.info.Capabilities.Control.CT.Min {
*input.CT = s.info.Capabilities.Control.CT.Min
}
@@ -51,13 +51,13 @@ func (s *hueLightState) Update(state models.DeviceState) {
if s.input.CT == nil || *s.input.CT != *input.CT {
s.stale = true
}
} else {
input.Hue = ptrInt(int(state.Color.Hue*(65536/360)) % 65536)
} else if color, ok := state.Color.ToHS(); ok {
input.Hue = ptrInt(int(color.HS.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))
input.Sat = ptrInt(int(color.HS.Sat * 255))
if *input.Sat > 254 {
*input.Sat = 254
}
+639
View File
@@ -0,0 +1,639 @@
package hue2
import (
"context"
"errors"
"fmt"
"git.aiterp.net/lucifer/new-server/internal/color"
"git.aiterp.net/lucifer/new-server/models"
"golang.org/x/sync/errgroup"
"log"
"math"
"strings"
"sync"
"time"
)
type Bridge struct {
mu sync.Mutex
externalID int
client *Client
needsUpdate chan struct{}
devices map[string]models.Device
resources map[string]*ResourceData
}
func NewBridge(client *Client) *Bridge {
return &Bridge{
client: client,
needsUpdate: make(chan struct{}, 4),
devices: make(map[string]models.Device, 64),
resources: make(map[string]*ResourceData, 256),
}
}
func (b *Bridge) Run(ctx context.Context, eventCh chan<- models.Event) error {
log.Println(fmt.Sprintf("[Bridge %d]", b.externalID), "Connecting SSE...")
sse := b.client.SSE(ctx)
log.Println(fmt.Sprintf("[Bridge %d]", b.externalID), "Refreshing...")
err := b.RefreshAll(ctx)
if err != nil {
return err
}
log.Println(fmt.Sprintf("[Bridge %d]", b.externalID), "Running updates...")
updated, err := b.MakeCongruent(ctx)
if err != nil {
return err
}
log.Println(fmt.Sprintf("[Bridge %d]", b.externalID), "Updated", updated, "hue services (startup)")
lightRefreshTimer := time.NewTicker(time.Second * 5)
defer lightRefreshTimer.Stop()
motionTicker := time.NewTicker(time.Second * 10)
defer motionTicker.Stop()
absences := make(map[int]time.Time)
lastPress := make(map[string]time.Time)
lastUpdate := time.Now()
needFull := false
for {
select {
case <-ctx.Done():
return ctx.Err()
case <-lightRefreshTimer.C:
if time.Since(lastUpdate) < time.Second*2 {
continue
}
if needFull {
err := b.RefreshAll(ctx)
if err != nil {
return err
}
} else {
err := b.Refresh(ctx, "light")
if err != nil {
return err
}
}
updated, err := b.MakeCongruent(ctx)
if err != nil {
return err
}
if updated > 0 {
// Force this to cool for 30 seconds unless a manual update occurs.
if updated > 10 {
lastUpdate = time.Now().Add(time.Second * 15)
}
log.Println(fmt.Sprintf("[Bridge %d]", b.externalID), "Updated", updated, "hue services (regular check)")
}
case <-b.needsUpdate:
lastUpdate = time.Now()
updated, err := b.MakeCongruent(ctx)
if err != nil {
return err
}
if updated > 0 {
log.Println(fmt.Sprintf("[Bridge %d]", b.externalID), "Updated", updated, "hue services (publish)")
}
case <-motionTicker.C:
for id, absenceTime := range absences {
seconds := int(time.Since(absenceTime).Seconds())
if seconds < 10 || seconds%60 >= 10 {
continue
}
eventCh <- models.Event{
Name: models.ENSensorPresenceEnded,
Payload: map[string]string{
"deviceId": fmt.Sprint(id),
"minutesElapsed": fmt.Sprint(seconds / 60),
"secondsElapsed": fmt.Sprint(seconds),
"lastUpdated": fmt.Sprint(absenceTime.Unix()),
},
}
}
case data, ok := <-sse:
if !ok {
return errors.New("SSE Disconnected")
}
b.applyPatches(data.Data)
updated, err := b.MakeCongruent(ctx)
if err != nil {
return err
}
if updated > 0 {
log.Println(fmt.Sprintf("[Bridge %d]", b.externalID), "Updated", updated, "hue services (SSE)")
}
for _, patch := range data.Data {
if patch.Owner == nil {
continue
}
b.mu.Lock()
if b.resources[patch.Owner.ID] == nil {
needFull = true
}
b.mu.Unlock()
device, deviceOK := b.devices[patch.Owner.ID]
if !deviceOK || device.ID == 0 {
continue
}
if patch.Button != nil {
valid := false
if patch.Button.LastEvent == "initial_press" || patch.Button.LastEvent == "repeat" {
valid = true
} else if patch.Button.LastEvent == "long_release" {
valid = false
} else {
valid = data.CreationTime.Sub(lastPress[patch.ID]) >= time.Second*2
}
if valid {
lastPress[patch.ID] = data.CreationTime
b.mu.Lock()
owner := b.resources[patch.Owner.ID]
b.mu.Unlock()
if owner != nil {
index := owner.ServiceIndex("button", patch.ID)
if index != -1 {
eventCh <- models.Event{
Name: models.ENButtonPressed,
Payload: map[string]string{
"deviceId": fmt.Sprint(device.ID),
"hueButtonEvent": patch.Button.LastEvent,
"buttonIndex": fmt.Sprint(index),
"buttonName": device.ButtonNames[index],
},
}
}
}
}
}
if patch.Temperature != nil && patch.Temperature.Valid {
eventCh <- models.Event{
Name: models.ENSensorTemperature,
Payload: map[string]string{
"deviceId": fmt.Sprint(device.ID),
"deviceInternalId": patch.Owner.ID,
"temperature": fmt.Sprint(patch.Temperature.Temperature),
"lastUpdated": fmt.Sprint(data.CreationTime.Unix()),
},
}
}
if patch.Motion != nil && patch.Motion.Valid {
if patch.Motion.Motion {
eventCh <- models.Event{
Name: models.ENSensorPresenceStarted,
Payload: map[string]string{
"deviceId": fmt.Sprint(device.ID),
"deviceInternalId": patch.Owner.ID,
},
}
delete(absences, device.ID)
} else {
eventCh <- models.Event{
Name: models.ENSensorPresenceEnded,
Payload: map[string]string{
"deviceId": fmt.Sprint(device.ID),
"deviceInternalId": device.InternalID,
"minutesElapsed": "0",
"secondsElapsed": "0",
"lastUpdated": fmt.Sprint(data.CreationTime.Unix()),
},
}
absences[device.ID] = data.CreationTime
}
}
}
}
}
}
func (b *Bridge) Update(devices ...models.Device) {
b.mu.Lock()
for _, device := range devices {
b.devices[device.InternalID] = device
}
b.mu.Unlock()
select {
case b.needsUpdate <- struct{}{}:
default:
}
}
func (b *Bridge) MakeCongruent(ctx context.Context) (int, error) {
// Exhaust the channel to avoid more updates.
exhausted := false
for !exhausted {
select {
case <-b.needsUpdate:
default:
exhausted = true
}
}
b.mu.Lock()
dur := time.Millisecond * 100
updates := make(map[string]ResourceUpdate)
for _, device := range b.devices {
resource := b.resources[device.InternalID]
if resource == nil {
continue
}
// Update device
if resource.Metadata.Name != device.Name {
name := device.Name
updates["device/"+resource.ID] = ResourceUpdate{
Name: &name,
}
}
// Update light
if lightID := resource.ServiceID("light"); lightID != nil {
light := b.resources[*lightID]
update := ResourceUpdate{TransitionDuration: &dur}
changed := false
lightsOut := light.Power != nil && !device.State.Power
if !lightsOut {
if light.ColorTemperature != nil && device.State.Color.IsKelvin() {
mirek := 1000000 / *device.State.Color.K
if mirek < light.ColorTemperature.MirekSchema.MirekMinimum {
mirek = light.ColorTemperature.MirekSchema.MirekMinimum
}
if mirek > light.ColorTemperature.MirekSchema.MirekMaximum {
mirek = light.ColorTemperature.MirekSchema.MirekMaximum
}
if light.ColorTemperature.Mirek == nil || mirek != *light.ColorTemperature.Mirek {
update.Mirek = &mirek
changed = true
}
} else if xyColor, ok := device.State.Color.ToXY(); ok && light.Color != nil {
xy := light.Color.Gamut.Conform(*xyColor.XY).Round()
if xy.DistanceTo(light.Color.XY) > 0.0009 || (light.ColorTemperature != nil && light.ColorTemperature.Mirek != nil) {
update.ColorXY = &xy
changed = true
}
}
if light.Dimming != nil && math.Abs(light.Dimming.Brightness/100-device.State.Intensity) > 0.02 {
brightness := math.Abs(math.Min(device.State.Intensity*100, 100))
update.Brightness = &brightness
changed = true
}
}
if light.Power != nil && light.Power.On != device.State.Power {
update.Power = &device.State.Power
if device.State.Power {
brightness := math.Abs(math.Min(device.State.Intensity*100, 100))
update.Brightness = &brightness
}
changed = true
}
if changed {
updates["light/"+light.ID] = update
}
}
}
if len(updates) > 0 {
// Optimistically apply the updates to the states, so that the driver assumes they are set until
// proven otherwise by the SSE client.
newResources := make(map[string]*ResourceData, len(b.resources))
for key, value := range b.resources {
newResources[key] = value
}
for key, update := range updates {
id := strings.SplitN(key, "/", 2)[1]
newResources[id] = newResources[id].WithUpdate(update)
}
b.resources = newResources
}
b.mu.Unlock()
if len(updates) == 0 {
return 0, nil
}
eg, ctx := errgroup.WithContext(ctx)
for key := range updates {
update := updates[key]
split := strings.SplitN(key, "/", 2)
link := ResourceLink{Kind: split[0], ID: split[1]}
eg.Go(func() error {
return b.client.UpdateResource(ctx, link, update)
})
}
err := eg.Wait()
if err != nil {
// Try to restore light states.
_ = b.Refresh(ctx, "light")
return len(updates), err
}
return len(updates), nil
}
func (b *Bridge) GenerateDevices() []models.Device {
b.mu.Lock()
resources := b.resources
b.mu.Unlock()
devices := make([]models.Device, 0, 16)
for _, resource := range resources {
if resource.Type != "device" || strings.HasPrefix(resource.Metadata.Archetype, "bridge") {
continue
}
device := models.Device{
BridgeID: b.externalID,
InternalID: resource.ID,
Name: resource.Metadata.Name,
DriverProperties: map[string]interface{}{
"archetype": resource.Metadata.Archetype,
"name": resource.ProductData.ProductName,
"product": resource.ProductData,
"legacyId": resource.LegacyID,
},
}
// Set icon
if resource.ProductData.ProductName == "Hue dimmer switch" {
device.Icon = "switch"
} else if resource.ProductData.ProductName == "Hue motion sensor" {
device.Icon = "sensor"
} else {
device.Icon = "lightbulb"
}
buttonCount := 0
for _, ptr := range resource.Services {
switch ptr.Kind {
case "device_power":
{
device.DriverProperties["battery"] = resources[ptr.ID].PowerState
}
case "button":
{
buttonCount += 1
}
case "zigbee_connectivity":
{
device.DriverProperties["zigbee"] = resources[ptr.ID].Status
}
case "motion":
{
device.Capabilities = append(device.Capabilities, models.DCPresence)
}
case "temperature":
{
device.Capabilities = append(device.Capabilities, models.DCTemperatureSensor)
}
case "light":
{
light := resources[ptr.ID]
if light.Power != nil {
device.State.Power = light.Power.On
device.Capabilities = append(device.Capabilities, models.DCPower)
}
if light.Dimming != nil {
device.State.Intensity = light.Dimming.Brightness / 100
device.Capabilities = append(device.Capabilities, models.DCIntensity)
}
if light.ColorTemperature != nil {
if light.ColorTemperature.Mirek != nil {
device.State.Color.SetK(1000000 / *light.ColorTemperature.Mirek)
}
device.Capabilities = append(device.Capabilities, models.DCColorKelvin)
device.DriverProperties["maxTemperature"] = 1000000 / light.ColorTemperature.MirekSchema.MirekMinimum
device.DriverProperties["minTemperature"] = 1000000 / light.ColorTemperature.MirekSchema.MirekMaximum
}
if light.Color != nil {
if device.State.Color.IsEmpty() {
device.State.Color = color.Color{
XY: &light.Color.XY,
}
}
device.DriverProperties["colorGamut"] = light.Color.Gamut
device.DriverProperties["colorGamutType"] = light.Color.GamutType
device.Capabilities = append(device.Capabilities, models.DCColorHS, models.DCColorXY)
}
}
}
}
if buttonCount == 4 {
device.ButtonNames = []string{"On", "DimUp", "DimDown", "Off"}
} else if buttonCount == 1 {
device.ButtonNames = []string{"Button"}
} else {
for n := 1; n <= buttonCount; n++ {
device.ButtonNames = append(device.ButtonNames, fmt.Sprint("Button", n))
}
}
devices = append(devices, device)
}
return devices
}
func (b *Bridge) Refresh(ctx context.Context, kind string) error {
if kind == "device" {
// Device refresh requires the full deal as services are taken for granted.
return b.RefreshAll(ctx)
}
resources, err := b.client.Resources(ctx, kind)
if err != nil {
return err
}
b.mu.Lock()
oldResources := b.resources
b.mu.Unlock()
newResources := make(map[string]*ResourceData, len(b.resources))
for key, value := range oldResources {
if value.Type != kind {
newResources[key] = value
}
}
for i := range resources {
resource := resources[i]
newResources[resource.ID] = &resource
}
b.mu.Lock()
b.resources = newResources
b.mu.Unlock()
return nil
}
func (b *Bridge) RefreshAll(ctx context.Context) error {
allResources, err := b.client.AllResources(ctx)
if err != nil {
return err
}
resources := make(map[string]*ResourceData, len(allResources))
for i := range allResources {
resource := allResources[i]
resources[resource.ID] = &resource
}
b.mu.Lock()
b.resources = resources
b.mu.Unlock()
return nil
}
func (b *Bridge) applyPatches(patches []ResourceData) {
b.mu.Lock()
newResources := make(map[string]*ResourceData, len(b.resources))
for key, value := range b.resources {
newResources[key] = value
}
b.mu.Unlock()
for _, patch := range patches {
if res := newResources[patch.ID]; res != nil {
resCopy := *res
if patch.Power != nil && resCopy.Power != nil {
cp := *resCopy.Power
resCopy.Power = &cp
resCopy.Power.On = patch.Power.On
}
if patch.Color != nil && resCopy.Color != nil {
cp := *resCopy.Color
resCopy.Color = &cp
resCopy.Color.XY = patch.Color.XY
if resCopy.ColorTemperature != nil {
cp2 := *resCopy.ColorTemperature
resCopy.ColorTemperature = &cp2
resCopy.ColorTemperature.Mirek = nil
}
}
if patch.ColorTemperature != nil && resCopy.ColorTemperature != nil {
cp := *resCopy.ColorTemperature
resCopy.ColorTemperature = &cp
resCopy.ColorTemperature.Mirek = patch.ColorTemperature.Mirek
}
if patch.Dimming != nil && resCopy.Dimming != nil {
cp := *resCopy.Dimming
resCopy.Dimming = &cp
resCopy.Dimming.Brightness = patch.Dimming.Brightness
}
if patch.Dynamics != nil {
resCopy.Dynamics = patch.Dynamics
}
if patch.Alert != nil {
resCopy.Alert = patch.Alert
}
if patch.PowerState != nil {
resCopy.PowerState = patch.PowerState
}
if patch.Temperature != nil {
resCopy.Temperature = patch.Temperature
}
if patch.Status != nil {
resCopy.Status = patch.Status
}
resCopy.Metadata.Name = patch.Metadata.Name
newResources[patch.ID] = &resCopy
}
}
b.mu.Lock()
b.resources = newResources
b.mu.Unlock()
}
func (b *Bridge) SearchDevices(ctx context.Context, timeout time.Duration) ([]models.Device, error) {
// Record the current state.
b.mu.Lock()
before := b.resources
b.mu.Unlock()
// Spend half the time waiting for devices
// TODO: Wait for v2 endpoint
err := b.client.LegacyDiscover(ctx, "sensors")
if err != nil {
return nil, err
}
select {
case <-time.After(timeout / 1):
case <-ctx.Done():
return nil, ctx.Err()
}
// Spend half the time waiting for lights
// TODO: Wait for v2 endpoint
err = b.client.LegacyDiscover(ctx, "lights")
if err != nil {
return nil, err
}
select {
case <-time.After(timeout / 1):
case <-ctx.Done():
return nil, ctx.Err()
}
// Perform a full refresh.
err = b.RefreshAll(ctx)
if err != nil {
return nil, err
}
// Check for new devices
devices := b.GenerateDevices()
newDevices := make([]models.Device, 0)
for _, device := range devices {
if before[device.InternalID] == nil {
newDevices = append(newDevices, device)
}
}
// Return said devices.
return newDevices, nil
}
func (b *Bridge) Forget(ctx context.Context, device models.Device) error {
b.mu.Lock()
resource := b.resources[device.InternalID]
b.mu.Unlock()
if resource == nil {
return errors.New("resource not found")
}
return b.client.DeleteResource(ctx, ResourceLink{
ID: device.InternalID,
Kind: "device",
})
}
+424
View File
@@ -0,0 +1,424 @@
package hue2
import (
"bufio"
"bytes"
"context"
"crypto/tls"
"encoding/json"
"errors"
"fmt"
"git.aiterp.net/lucifer/new-server/internal/lerrors"
"io"
"log"
"net"
"net/http"
"strings"
"time"
)
func NewClient(host, token string) *Client {
ch := make(chan struct{}, 5)
for i := 0; i < 2; i++ {
ch <- struct{}{}
}
return &Client{
host: host,
token: token,
ch: ch,
}
}
type Client struct {
host string
token string
ch chan struct{}
}
func (c *Client) Register(ctx context.Context) (string, error) {
result := make([]CreateUserResponse, 0, 1)
err := c.post(ctx, "api/", CreateUserInput{DeviceType: "git.aiterp.net/lucifer"}, &result)
if err != nil {
return "", err
}
if len(result) == 0 || result[0].Error != nil {
select {
case <-ctx.Done():
return "", ctx.Err()
case <-time.After(time.Second):
return c.Register(ctx)
}
}
if result[0].Success == nil {
return "", lerrors.ErrUnexpectedResponse
}
c.token = result[0].Success.Username
return result[0].Success.Username, nil
}
func (c *Client) AllResources(ctx context.Context) ([]ResourceData, error) {
res := struct {
Error interface{}
Data []ResourceData
}{}
err := c.get(ctx, "clip/v2/resource", &res)
if err != nil {
return nil, err
}
return res.Data, nil
}
func (c *Client) Resources(ctx context.Context, kind string) ([]ResourceData, error) {
res := struct {
Error interface{}
Data []ResourceData
}{}
err := c.get(ctx, "clip/v2/resource/"+kind, &res)
if err != nil {
return nil, err
}
return res.Data, nil
}
func (c *Client) UpdateResource(ctx context.Context, link ResourceLink, update ResourceUpdate) error {
return c.put(ctx, link.Path(), update, nil)
}
func (c *Client) DeleteResource(ctx context.Context, link ResourceLink) error {
return c.delete(ctx, link.Path(), nil)
}
func (c *Client) LegacyDiscover(ctx context.Context, kind string) error {
return c.legacyPost(ctx, kind, nil, nil)
}
func (c *Client) SSE(ctx context.Context) <-chan SSEUpdate {
ch := make(chan SSEUpdate, 4)
go func() {
defer close(ch)
reader, err := c.getReader(ctx, "/eventstream/clip/v2", map[string]string{
"Accept": "text/event-stream",
})
if err != nil {
log.Println("SSE Connect error:", err)
return
}
defer reader.Close()
br := bufio.NewReader(reader)
for {
line, err := br.ReadString('\n')
if err != nil {
log.Println("SSE Read error:", err)
return
}
line = strings.Trim(line, "  \t\r\n")
kv := strings.SplitN(line, ": ", 2)
if len(kv) < 2 {
continue
}
switch kv[0] {
case "data":
var data []SSEUpdate
err := json.Unmarshal([]byte(kv[1]), &data)
if err != nil {
log.Println("Parsing SSE event failed:", err)
log.Println(" json:", kv[1])
return
}
for _, obj := range data {
ch <- obj
}
}
}
}()
return ch
}
func (c *Client) getReader(ctx context.Context, path string, headers map[string]string) (io.ReadCloser, error) {
select {
case <-ctx.Done():
return nil, ctx.Err()
case <-c.ch:
defer func() {
c.ch <- struct{}{}
}()
}
req, err := http.NewRequest("GET", fmt.Sprintf("https://%s/%s", c.host, path), nil)
if err != nil {
return nil, err
}
for key, value := range headers {
req.Header.Set(key, value)
}
req.Header.Set("hue-application-key", c.token)
client := httpClient
if headers["Accept"] == "text/event-stream" {
client = sseClient
}
res, err := client.Do(req.WithContext(ctx))
if err != nil {
return nil, err
}
if res.StatusCode != 200 {
_ = res.Body.Close()
return nil, errors.New(res.Status)
}
return res.Body, nil
}
func (c *Client) get(ctx context.Context, path string, target interface{}) error {
body, err := c.getReader(ctx, path, map[string]string{})
if err != nil {
return err
}
defer body.Close()
if target == nil {
return nil
}
return json.NewDecoder(body).Decode(&target)
}
func (c *Client) put(ctx context.Context, path string, body interface{}, target interface{}) error {
select {
case <-ctx.Done():
return ctx.Err()
case <-c.ch:
defer func() {
c.ch <- struct{}{}
}()
}
rb, err := reqBody(body)
if err != nil {
return err
}
req, err := http.NewRequest("PUT", fmt.Sprintf("https://%s/%s", c.host, path), rb)
if err != nil {
return err
}
req.Header.Set("hue-application-key", c.token)
res, err := httpClient.Do(req.WithContext(ctx))
if err != nil {
return err
}
defer res.Body.Close()
if target == nil {
return nil
}
return json.NewDecoder(res.Body).Decode(&target)
}
func (c *Client) post(ctx context.Context, path string, body interface{}, target interface{}) error {
select {
case <-ctx.Done():
return ctx.Err()
case <-c.ch:
defer func() {
c.ch <- struct{}{}
}()
}
rb, err := reqBody(body)
if err != nil {
return err
}
req, err := http.NewRequest("POST", fmt.Sprintf("https://%s/%s", c.host, path), rb)
if err != nil {
return err
}
if c.token != "" {
req.Header.Set("hue-application-key", c.token)
}
res, err := httpClient.Do(req.WithContext(ctx))
if err != nil {
return err
}
defer res.Body.Close()
if target == nil {
return nil
}
return json.NewDecoder(res.Body).Decode(&target)
}
func (c *Client) delete(ctx context.Context, path string, target interface{}) error {
select {
case <-ctx.Done():
return ctx.Err()
case <-c.ch:
defer func() {
c.ch <- struct{}{}
}()
}
req, err := http.NewRequest("PUT", fmt.Sprintf("https://%s/%s", c.host, path), nil)
if err != nil {
return err
}
req.Header.Set("hue-application-key", c.token)
res, err := httpClient.Do(req.WithContext(ctx))
if err != nil {
return err
}
defer res.Body.Close()
if target == nil {
return nil
}
return json.NewDecoder(res.Body).Decode(&target)
}
func (c *Client) legacyPost(ctx context.Context, resource string, body interface{}, target interface{}) error {
select {
case <-ctx.Done():
return ctx.Err()
case <-c.ch:
defer func() {
c.ch <- struct{}{}
}()
}
rb, err := reqBody(body)
if err != nil {
return err
}
if c.token != "" {
resource = c.token + "/" + resource
}
req, err := http.NewRequest("POST", fmt.Sprintf("http://%s/api/%s", c.host, resource), rb)
if err != nil {
return err
}
res, err := httpClient.Do(req.WithContext(ctx))
if err != nil {
return err
}
defer res.Body.Close()
if target == nil {
return nil
}
return json.NewDecoder(res.Body).Decode(target)
}
func (c *Client) legacyDelete(ctx context.Context, resource string, target interface{}) error {
select {
case <-ctx.Done():
return ctx.Err()
case <-c.ch:
defer func() {
c.ch <- struct{}{}
}()
}
if c.token != "" {
resource = c.token + "/" + resource
}
req, err := http.NewRequest("DELETE", fmt.Sprintf("http://%s/api/%s", c.host, resource), nil)
if err != nil {
return err
}
res, err := httpClient.Do(req.WithContext(ctx))
if err != nil {
return err
}
defer res.Body.Close()
if target == nil {
return nil
}
return json.NewDecoder(res.Body).Decode(target)
}
func reqBody(body interface{}) (io.Reader, error) {
if body == nil {
return nil, nil
}
switch v := body.(type) {
case []byte:
return bytes.NewReader(v), nil
case string:
return strings.NewReader(v), nil
case io.Reader:
return v, nil
default:
jsonData, err := json.Marshal(v)
if err != nil {
return nil, err
}
return bytes.NewReader(jsonData), nil
}
}
var sseClient = &http.Client{
Transport: &http.Transport{
Proxy: http.ProxyFromEnvironment,
DialContext: (&net.Dialer{
Timeout: time.Hour * 1000000,
KeepAlive: 30 * time.Second,
}).DialContext,
MaxIdleConns: 5,
MaxIdleConnsPerHost: 1,
IdleConnTimeout: 0,
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
},
Timeout: time.Hour * 1000000,
}
var httpClient = &http.Client{
Transport: &http.Transport{
Proxy: http.ProxyFromEnvironment,
DialContext: (&net.Dialer{
Timeout: 30 * time.Second,
KeepAlive: 30 * time.Second,
}).DialContext,
MaxIdleConns: 256,
MaxIdleConnsPerHost: 16,
IdleConnTimeout: 10 * time.Minute,
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
},
Timeout: time.Minute,
}
+280
View File
@@ -0,0 +1,280 @@
package hue2
import (
"encoding/json"
"encoding/xml"
"fmt"
"git.aiterp.net/lucifer/new-server/internal/color"
"strings"
"time"
)
type DeviceData struct {
ID string `json:"id"`
LegacyID string `json:"id_v1"`
Metadata DeviceMetadata `json:"metadata"`
Type string `json:"type"`
ProductData DeviceProductData `json:"product_data"`
Services []ResourceLink `json:"services"`
}
type DeviceMetadata struct {
Archetype string `json:"archetype"`
Name string `json:"name"`
}
type DeviceProductData struct {
Certified bool `json:"certified"`
ManufacturerName string `json:"manufacturer_name"`
ModelID string `json:"model_id"`
ProductArchetype string `json:"product_archetype"`
ProductName string `json:"product_name"`
SoftwareVersion string `json:"software_version"`
}
type SSEUpdate struct {
CreationTime time.Time `json:"creationTime"`
ID string `json:"id"`
Type string `json:"type"`
Data []ResourceData `json:"data"`
}
type ResourceData struct {
ID string `json:"id"`
LegacyID string `json:"id_v1"`
Metadata DeviceMetadata `json:"metadata"`
Type string `json:"type"`
Mode *string `json:"mode"`
Owner *ResourceLink `json:"owner"`
ProductData *DeviceProductData `json:"product_data"`
Services []ResourceLink `json:"services"`
Button *SensorButton `json:"button"`
Power *LightPower `json:"on"`
Color *LightColor `json:"color"`
ColorTemperature *LightCT `json:"color_temperature"`
Dimming *LightDimming `json:"dimming"`
Dynamics *LightDynamics `json:"dynamics"`
Alert *LightAlert `json:"alert"`
PowerState *PowerState `json:"power_state"`
Temperature *SensorTemperature `json:"temperature"`
Motion *SensorMotion `json:"motion"`
Status *string `json:"status"`
}
func (res *ResourceData) ServiceID(kind string) *string {
for _, ptr := range res.Services {
if ptr.Kind == kind {
return &ptr.ID
}
}
return nil
}
func (res *ResourceData) ServiceIndex(kind string, id string) int {
idx := 0
for _, link := range res.Services {
if link.ID == id {
return idx
} else if link.Kind == kind {
idx += 1
}
}
return -1
}
func (res *ResourceData) WithUpdate(update ResourceUpdate) *ResourceData {
resCopy := *res
if update.Name != nil {
resCopy.Metadata.Name = *update.Name
}
if update.Power != nil {
cp := *resCopy.Power
resCopy.Power = &cp
resCopy.Power.On = *update.Power
}
if update.ColorXY != nil {
cp := *resCopy.Color
resCopy.Color = &cp
resCopy.Color.XY = *update.ColorXY
if resCopy.ColorTemperature != nil {
cp := *resCopy.ColorTemperature
resCopy.ColorTemperature = &cp
resCopy.ColorTemperature.Mirek = nil
}
}
if update.Mirek != nil {
cp := *resCopy.ColorTemperature
resCopy.ColorTemperature = &cp
mirek := *update.Mirek
resCopy.ColorTemperature.Mirek = &mirek
}
if update.Brightness != nil {
cp := *resCopy.Dimming
resCopy.Dimming = &cp
resCopy.Dimming.Brightness = *update.Brightness
}
return &resCopy
}
type SensorButton struct {
LastEvent string `json:"last_event"`
}
type SensorMotion struct {
Motion bool `json:"motion"`
Valid bool `json:"motion_valid"`
}
type SensorTemperature struct {
Temperature float64 `json:"temperature"`
Valid bool `json:"temperature_valid"`
}
type PowerState struct {
BatteryState string `json:"battery_state"`
BatteryLevel float64 `json:"battery_level"`
}
type LightPower struct {
On bool `json:"on"`
}
type LightDimming struct {
Brightness float64 `json:"brightness"`
}
type LightColor struct {
Gamut color.Gamut `json:"gamut"`
GamutType string `json:"gamut_type"`
XY color.XY `json:"xy"`
}
type LightCT struct {
Mirek *int `json:"mirek"`
MirekSchema LightCTMirekSchema `json:"mirek_schema"`
MirekValid bool `json:"mirek_valid"`
}
type LightCTMirekSchema struct {
MirekMaximum int `json:"mirek_maximum"`
MirekMinimum int `json:"mirek_minimum"`
}
type LightDynamics struct {
Speed float64 `json:"speed"`
SpeedValid bool `json:"speed_valid"`
Status string `json:"status"`
StatusValues []string `json:"status_values"`
}
type LightAlert struct {
ActionValues []string `json:"action_values"`
}
type ResourceUpdate struct {
Name *string
Power *bool
ColorXY *color.XY
Brightness *float64
Mirek *int
TransitionDuration *time.Duration
}
func (r ResourceUpdate) MarshalJSON() ([]byte, error) {
chunks := make([]string, 0, 4)
if r.Name != nil {
s, _ := json.Marshal(*r.Name)
chunks = append(chunks, fmt.Sprintf(`"metadata":{"name":%s}`, string(s)))
}
if r.Power != nil {
chunks = append(chunks, fmt.Sprintf(`"on":{"on":%v}`, *r.Power))
}
if r.ColorXY != nil {
chunks = append(chunks, fmt.Sprintf(`"color":{"xy":{"x":%f,"y":%f}}`, r.ColorXY.X, r.ColorXY.Y))
}
if r.Brightness != nil {
chunks = append(chunks, fmt.Sprintf(`"dimming":{"brightness":%f}`, *r.Brightness))
}
if r.Mirek != nil {
chunks = append(chunks, fmt.Sprintf(`"color_temperature":{"mirek":%d}`, *r.Mirek))
}
if r.TransitionDuration != nil {
chunks = append(chunks, fmt.Sprintf(`"dynamics":{"duration":%d}`, r.TransitionDuration.Truncate(time.Millisecond*100).Milliseconds()))
}
return []byte(fmt.Sprintf("{%s}", strings.Join(chunks, ","))), nil
}
type ResourceLink struct {
ID string `json:"rid"`
Kind string `json:"rtype"`
}
func (rl *ResourceLink) Path() string {
return fmt.Sprintf("/clip/v2/resource/%s/%s", rl.Kind, rl.ID)
}
type CreateUserInput struct {
DeviceType string `json:"devicetype"`
}
type CreateUserResponse struct {
Success *struct {
Username string `json:"username"`
} `json:"success"`
Error *struct {
Type int `json:"type"`
Address string `json:"address"`
Description string `json:"description"`
} `json:"error"`
}
type DiscoveryEntry struct {
Id string `json:"id"`
InternalIPAddress string `json:"internalipaddress"`
}
type BridgeDeviceInfo struct {
XMLName xml.Name `xml:"root"`
Text string `xml:",chardata"`
Xmlns string `xml:"xmlns,attr"`
SpecVersion struct {
Text string `xml:",chardata"`
Major string `xml:"major"`
Minor string `xml:"minor"`
} `xml:"specVersion"`
URLBase string `xml:"URLBase"`
Device struct {
Text string `xml:",chardata"`
DeviceType string `xml:"deviceType"`
FriendlyName string `xml:"friendlyName"`
Manufacturer string `xml:"manufacturer"`
ManufacturerURL string `xml:"manufacturerURL"`
ModelDescription string `xml:"modelDescription"`
ModelName string `xml:"modelName"`
ModelNumber string `xml:"modelNumber"`
ModelURL string `xml:"modelURL"`
SerialNumber string `xml:"serialNumber"`
UDN string `xml:"UDN"`
PresentationURL string `xml:"presentationURL"`
IconList struct {
Text string `xml:",chardata"`
Icon struct {
Text string `xml:",chardata"`
Mimetype string `xml:"mimetype"`
Height string `xml:"height"`
Width string `xml:"width"`
Depth string `xml:"depth"`
URL string `xml:"url"`
} `xml:"icon"`
} `xml:"iconList"`
} `xml:"device"`
}
+121
View File
@@ -0,0 +1,121 @@
package hue2
import (
"context"
"encoding/json"
"encoding/xml"
"fmt"
"git.aiterp.net/lucifer/new-server/internal/lerrors"
"git.aiterp.net/lucifer/new-server/models"
"net/http"
"sync"
"time"
)
type Driver struct {
mu sync.Mutex
bridges []*Bridge
}
func (d *Driver) SearchBridge(ctx context.Context, address, token string, dryRun bool) ([]models.Bridge, error) {
if address == "" {
if !dryRun {
return nil, lerrors.ErrAddressOnlyDryRunnable
}
res, err := http.Get("https://discovery.meethue.com")
if err != nil {
return nil, err
}
defer res.Body.Close()
entries := make([]DiscoveryEntry, 0, 8)
err = json.NewDecoder(res.Body).Decode(&entries)
if err != nil {
return nil, err
}
bridges := make([]models.Bridge, 0, len(entries))
for _, entry := range entries {
bridges = append(bridges, models.Bridge{
ID: -1,
Name: entry.Id,
Driver: models.DTHue2,
Address: entry.InternalIPAddress,
Token: "",
})
}
return bridges, nil
}
deviceInfo := BridgeDeviceInfo{}
res, err := http.Get(fmt.Sprintf("http://%s/description.xml", address))
if err != nil {
return nil, err
}
defer res.Body.Close()
err = xml.NewDecoder(res.Body).Decode(&deviceInfo)
if err != nil {
return nil, err
}
bridge := models.Bridge{
ID: -1,
Name: deviceInfo.Device.FriendlyName,
Driver: models.DTHue2,
Address: address,
Token: "",
}
if !dryRun {
client := NewClient(address, "")
timeout, cancel := context.WithTimeout(ctx, time.Second*30)
defer cancel()
bridge.Token, err = client.Register(timeout)
if err != nil {
return nil, err
}
}
return []models.Bridge{bridge}, nil
}
func (d *Driver) SearchDevices(ctx context.Context, bridge models.Bridge, timeout time.Duration) ([]models.Device, error) {
return d.ensureBridge(bridge).SearchDevices(ctx, timeout)
}
func (d *Driver) ListDevices(_ context.Context, bridge models.Bridge) ([]models.Device, error) {
return d.ensureBridge(bridge).GenerateDevices(), nil
}
func (d *Driver) Publish(_ context.Context, bridge models.Bridge, devices []models.Device) error {
d.ensureBridge(bridge).Update(devices...)
return nil
}
func (d *Driver) Run(ctx context.Context, bridge models.Bridge, ch chan<- models.Event) error {
return d.ensureBridge(bridge).Run(ctx, ch)
}
func (d *Driver) ForgetDevice(ctx context.Context, bridge models.Bridge, device models.Device) error {
return d.ensureBridge(bridge).Forget(ctx, device)
}
func (d *Driver) ensureBridge(info models.Bridge) *Bridge {
d.mu.Lock()
for _, bridge := range d.bridges {
if bridge.client.host == info.Address {
d.mu.Unlock()
return bridge
}
}
bridge := NewBridge(NewClient(info.Address, info.Token))
bridge.externalID = info.ID
d.bridges = append(d.bridges, bridge)
d.mu.Unlock()
return bridge
}
+12 -8
View File
@@ -2,6 +2,8 @@ package lifx
import (
"context"
"git.aiterp.net/lucifer/new-server/internal/color"
"git.aiterp.net/lucifer/new-server/internal/lerrors"
"git.aiterp.net/lucifer/new-server/models"
"log"
"sync"
@@ -20,7 +22,7 @@ type Bridge struct {
func (b *Bridge) StartSearch(ctx context.Context) error {
c := b.getClient()
if c == nil {
return models.ErrBridgeRunningRequired
return lerrors.ErrBridgeRunningRequired
}
_, err := c.HorribleBroadcast(ctx, &GetService{})
@@ -129,9 +131,9 @@ func (b *Bridge) Run(ctx context.Context, debug bool) error {
for {
target, seq, payload, err := client.Recv(time.Millisecond * 200)
if err == models.ErrInvalidPacketSize || err == models.ErrPayloadTooShort || err == models.ErrUnrecognizedPacketType {
if err == lerrors.ErrInvalidPacketSize || err == lerrors.ErrPayloadTooShort || err == lerrors.ErrUnrecognizedPacketType {
log.Println("LIFX udp socket received something weird:", err)
} else if err != nil && err != models.ErrReadTimeout {
} else if err != nil && err != lerrors.ErrReadTimeout {
if ctx.Err() != nil {
return ctx.Err()
}
@@ -168,10 +170,12 @@ func (b *Bridge) Run(ctx context.Context, debug bool) error {
if state.deviceState == nil {
state.deviceState = &models.DeviceState{
Power: p.On,
Color: models.ColorValue{
Hue: p.Hue,
Saturation: p.Sat,
Kelvin: p.Kelvin,
Color: color.Color{
HS: &color.HueSat{
Hue: p.Hue,
Sat: p.Sat,
},
K: &p.Kelvin,
},
Intensity: p.Bri,
}
@@ -204,7 +208,7 @@ func (b *Bridge) Run(ctx context.Context, debug bool) error {
b.checkAndUpdateState(state)
}
if time.Since(state.discoveredTime) > time.Second*10 && time.Since(state.fwSpamTime) > time.Second * 30 {
if time.Since(state.discoveredTime) > time.Second*10 && time.Since(state.fwSpamTime) > time.Second*30 {
state.fwSpamTime = time.Now()
if state.firmware == nil {
+3 -3
View File
@@ -4,7 +4,7 @@ import (
"context"
"encoding/binary"
"errors"
"git.aiterp.net/lucifer/new-server/models"
"git.aiterp.net/lucifer/new-server/internal/lerrors"
"log"
"math/rand"
"net"
@@ -215,7 +215,7 @@ func (c *Client) Recv(timeout time.Duration) (target string, seq uint8, payload
}
if err != nil {
if netErr, ok := err.(*net.OpError); ok && netErr.Timeout() {
err = models.ErrReadTimeout
err = lerrors.ErrReadTimeout
return
}
@@ -224,7 +224,7 @@ func (c *Client) Recv(timeout time.Duration) (target string, seq uint8, payload
packet := Packet(c.buf[:n])
if n < 2 || packet.Size() != n && packet.Protocol() != 1024 {
err = models.ErrInvalidAddress
err = lerrors.ErrInvalidAddress
return
}
+3 -3
View File
@@ -3,7 +3,7 @@ package lifx
import (
"encoding/binary"
"fmt"
"git.aiterp.net/lucifer/new-server/models"
"git.aiterp.net/lucifer/new-server/internal/lerrors"
"log"
"net"
)
@@ -60,7 +60,7 @@ func (p *Packet) SetTarget(v string) error {
return err
}
if len(addr) != 6 {
return models.ErrInvalidAddress
return lerrors.ErrInvalidAddress
}
copy((*p)[8:], addr)
@@ -133,7 +133,7 @@ func (p *Packet) Payload() (res Payload, err error) {
res = &SetLightPower{}
err = res.Decode((*p)[36:])
default:
err = models.ErrUnrecognizedPacketType
err = lerrors.ErrUnrecognizedPacketType
}
if err != nil {
+7 -7
View File
@@ -3,7 +3,7 @@ package lifx
import (
"encoding/binary"
"fmt"
"git.aiterp.net/lucifer/new-server/models"
"git.aiterp.net/lucifer/new-server/internal/lerrors"
"math"
"time"
)
@@ -82,7 +82,7 @@ type StateHostFirmware struct {
func (p *StateHostFirmware) Decode(data []byte) error {
if len(data) < 20 {
return models.ErrPayloadTooShort
return lerrors.ErrPayloadTooShort
}
ts := int64(binary.LittleEndian.Uint64(data[0:8]))
@@ -123,7 +123,7 @@ type StateVersion struct {
func (p *StateVersion) Decode(data []byte) error {
if len(data) < 8 {
return models.ErrPayloadTooShort
return lerrors.ErrPayloadTooShort
}
p.Vendor = binary.LittleEndian.Uint32(data[0:4])
@@ -207,7 +207,7 @@ type SetColor struct {
func (p *SetColor) Decode(data []byte) error {
if len(data) < 13 {
return models.ErrPayloadTooShort
return lerrors.ErrPayloadTooShort
}
hue := binary.LittleEndian.Uint16(data[1:3])
@@ -263,7 +263,7 @@ type SetLightPower struct {
func (p *SetLightPower) Decode(data []byte) error {
if len(data) < 6 {
return models.ErrPayloadTooShort
return lerrors.ErrPayloadTooShort
}
level := binary.LittleEndian.Uint16(data[0:2])
@@ -314,7 +314,7 @@ func (p *StateService) String() string {
func (p *StateService) Decode(data []byte) error {
if len(data) < 5 {
return models.ErrPayloadTooShort
return lerrors.ErrPayloadTooShort
}
p.Service = int(data[0])
@@ -357,7 +357,7 @@ func (p *LightState) String() string {
func (p *LightState) Decode(data []byte) error {
if len(data) < 52 {
return models.ErrPayloadTooShort
return lerrors.ErrPayloadTooShort
}
hue := binary.LittleEndian.Uint16(data[0:2])
+18 -8
View File
@@ -1,6 +1,7 @@
package lifx
import (
"git.aiterp.net/lucifer/new-server/internal/color"
"git.aiterp.net/lucifer/new-server/models"
"math"
"time"
@@ -36,17 +37,21 @@ func (s *State) generateUpdate() []Payload {
return results
}
c := s.deviceState.Color
c, ok := s.deviceState.Color.ToHSK()
if !ok {
c, _ = color.Parse("hsk:0,0,4000")
}
l := s.lightState
di := s.deviceState.Intensity
k := c.Kelvin
k := *c.K
if k == 0 {
k = 4000
}
if !equalish(c.Hue, l.Hue) || !equalish(c.Saturation, l.Sat) || !equalish(di, l.Bri) || k != l.Kelvin {
if !equalish(c.HS.Hue, l.Hue) || !equalish(c.HS.Sat, l.Sat) || !equalish(di, l.Bri) || k != l.Kelvin {
results = append(results, &SetColor{
Hue: c.Hue,
Sat: c.Saturation,
Hue: c.HS.Hue,
Sat: c.HS.Sat,
Bri: di,
Kelvin: k,
TransitionTime: time.Millisecond * 150,
@@ -72,11 +77,16 @@ func (s *State) handleAck(seq uint8) {
prevLabel = s.lightState.Label
}
c, ok := s.deviceState.Color.ToHSK()
if !ok {
c, _ = color.Parse("hsk:0,0,4000")
}
s.lightState = &LightState{
Hue: s.deviceState.Color.Hue,
Sat: s.deviceState.Color.Saturation,
Hue: c.HS.Hue,
Sat: c.HS.Sat,
Bri: s.deviceState.Intensity,
Kelvin: s.deviceState.Color.Kelvin,
Kelvin: *c.K,
On: s.deviceState.Power,
Label: prevLabel,
}
+103 -22
View File
@@ -6,7 +6,9 @@ import (
"crypto/rand"
"crypto/sha1"
"encoding/json"
"errors"
"fmt"
"git.aiterp.net/lucifer/new-server/internal/lerrors"
"git.aiterp.net/lucifer/new-server/models"
"io"
"log"
@@ -82,11 +84,16 @@ func (b *bridge) listDevices(ctx context.Context) ([]models.Device, error) {
}
func (b *bridge) pushStateChange(ctx context.Context, deviceModel models.Device) error {
if ip, ok := deviceModel.DriverProperties["ip"].(string); ok {
return b.pushWifiStateChange(ctx, ip, deviceModel.State.Temperature)
}
b.mu.Lock()
if b.luciferMillIDMap == nil {
b.luciferMillIDMap = make(map[int]int, 4)
b.millLuciferIDMap = make(map[int]int, 4)
}
subDomain := deviceModel.DriverProperties["subDomain"].(string)
if b.luciferMillIDMap[deviceModel.ID] == 0 {
millID, _ := strconv.Atoi(deviceModel.InternalID)
@@ -99,28 +106,90 @@ func (b *bridge) pushStateChange(ctx context.Context, deviceModel models.Device)
if deviceModel.State.Power {
status = 1
}
powerReq := deviceControlReqBody{
SubDomain: deviceModel.DriverProperties["subDomain"].(string),
DeviceID: b.luciferMillIDMap[deviceModel.ID],
TestStatus: 1,
Status: status,
if subDomainIsGen2(subDomain) {
powerReq := deviceControlReqBody{
SubDomain: subDomain,
DeviceID: b.luciferMillIDMap[deviceModel.ID],
TestStatus: 1,
Status: status,
}
err := b.command(ctx, "deviceControl", powerReq, nil)
if err != nil {
return err
}
tempReq := changeInfoReqBody{
DeviceID: b.luciferMillIDMap[deviceModel.ID],
Value: deviceModel.State.Temperature,
TimeZoneNum: "+02:00",
Key: "holidayTemp",
}
err = b.command(ctx, "changeDeviceInfo", tempReq, nil)
if err != nil {
return err
}
} else {
sd, _ := strconv.Atoi(subDomain)
tempReq := deviceControlGen3Body{
Operation: "SINGLE_CONTROL",
Status: status,
SubDomain: sd,
DeviceId: b.luciferMillIDMap[deviceModel.ID],
HoldTemp: deviceModel.State.Temperature,
}
err := b.command(ctx, "deviceControlGen3ForApp", tempReq, nil)
if err != nil {
return err
}
powerReq := deviceControlGen3Body{
Operation: "SWITCH",
Status: status,
SubDomain: sd,
DeviceId: b.luciferMillIDMap[deviceModel.ID],
}
err = b.command(ctx, "deviceControlGen3ForApp", powerReq, nil)
if err != nil {
return err
}
}
err := b.command(ctx, "deviceControl", powerReq, nil)
return nil
}
func (b *bridge) pushWifiStateChange(ctx context.Context, ip string, temperature int) error {
b.mu.Lock()
defer b.mu.Unlock()
data, err := json.Marshal(wifiSetTemperatureBody{
Type: "Normal",
Value: temperature,
})
if err != nil {
return err
}
tempReq := changeInfoReqBody{
DeviceID: b.luciferMillIDMap[deviceModel.ID],
Value: deviceModel.State.Temperature,
TimeZoneNum: "+02:00",
Key: "holidayTemp",
}
err = b.command(ctx, "changeDeviceInfo", tempReq, nil)
req, err := http.NewRequestWithContext(
ctx,
"POST",
fmt.Sprintf("http://%s/set-temperature", ip),
bytes.NewReader(data),
)
if err != nil {
return err
}
res, err := http.DefaultClient.Do(req)
if err != nil {
return err
}
if res.StatusCode > 299 {
return errors.New("mill: negative response from " + ip)
}
return nil
}
@@ -163,9 +232,9 @@ func (b *bridge) command(ctx context.Context, command string, payload interface{
res, err := http.DefaultClient.Do(req)
if err != nil {
return models.ErrCannotForwardRequest
return lerrors.ErrCannotForwardRequest
} else if res.StatusCode != 200 {
return models.ErrIncorrectToken
return lerrors.ErrIncorrectToken
}
if target == nil {
@@ -174,7 +243,7 @@ func (b *bridge) command(ctx context.Context, command string, payload interface{
err = json.NewDecoder(res.Body).Decode(&target)
if err != nil {
return models.ErrUnexpectedResponse
return lerrors.ErrUnexpectedResponse
}
return nil
@@ -190,27 +259,27 @@ func (b *bridge) authenticate(ctx context.Context) error {
Password: b.password,
})
if err != nil {
return models.ErrMissingToken
return lerrors.ErrMissingToken
}
req, err := http.NewRequestWithContext(ctx, "POST", accountEndpoint + "login", bytes.NewReader(body))
req, err := http.NewRequestWithContext(ctx, "POST", accountEndpoint+"login", bytes.NewReader(body))
if err != nil {
return models.ErrMissingToken
return lerrors.ErrMissingToken
}
addDefaultHeaders(req)
res, err := http.DefaultClient.Do(req)
if err != nil {
return models.ErrCannotForwardRequest
return lerrors.ErrCannotForwardRequest
} else if res.StatusCode != 200 {
return models.ErrIncorrectToken
return lerrors.ErrIncorrectToken
}
var resBody authResBody
err = json.NewDecoder(res.Body).Decode(&resBody)
if err != nil {
return models.ErrBridgeSearchFailed
return lerrors.ErrBridgeSearchFailed
}
log.Printf("Mill: Authenticated as %s", resBody.NickName)
@@ -238,3 +307,15 @@ func addDefaultHeaders(req *http.Request) {
req.Header.Add("X-Zc-Seq-Id", "1")
req.Header.Add("X-Zc-Version", "1")
}
var gen2subDomains = []string{"863", "5316", "5317", "5332", "5333", "6933"}
func subDomainIsGen2(subDomain string) bool {
for _, gen2sd := range gen2subDomains {
if subDomain == gen2sd {
return true
}
}
return false
}
+13
View File
@@ -65,6 +65,19 @@ type deviceControlReqBody struct {
PowerLevel int `json:"powerLevel"`
}
type deviceControlGen3Body struct {
Operation string `json:"operation"`
Status int `json:"status"`
SubDomain int `json:"subDomain"`
DeviceId int `json:"deviceId"`
HoldTemp int `json:"holdTemp,omitempty"`
}
type wifiSetTemperatureBody struct {
Type string `json:"type"`
Value int `json:"value"`
}
var location *time.Location
func init() {
+26 -12
View File
@@ -5,12 +5,13 @@ import (
"context"
"encoding/json"
"fmt"
"git.aiterp.net/lucifer/new-server/internal/color"
"git.aiterp.net/lucifer/new-server/internal/lerrors"
"git.aiterp.net/lucifer/new-server/models"
"github.com/lucasb-eyer/go-colorful"
"io"
"io/ioutil"
"log"
"math"
"net"
"net/http"
"strconv"
@@ -31,11 +32,12 @@ type bridge struct {
func (b *bridge) Devices() []models.Device {
results := make([]models.Device, 0, len(b.panels))
for i, panel := range b.panels {
// Find normalized RGB and intensity
red := float64(panel.ColorRGBA[0]) / 255.0
green := float64(panel.ColorRGBA[1]) / 255.0
blue := float64(panel.ColorRGBA[2]) / 255.0
hue, sat, value := colorful.LinearRgb(red, green, blue).Hsv()
hue, sat, value := colorful.Color{R: red, G: green, B: blue}.Hsv()
rgb := colorful.Hsv(hue, sat, 1)
shapeType, shapeTypeOK := shapeTypeMap[panel.ShapeType]
if !shapeTypeOK {
@@ -70,10 +72,11 @@ func (b *bridge) Devices() []models.Device {
UserProperties: nil,
State: models.DeviceState{
Power: panel.ColorRGBA[3] == 0,
Color: models.ColorValue{
Hue: math.Mod(hue, 360),
Saturation: sat,
},
Color: color.Color{RGB: &color.RGB{
Red: rgb.R,
Green: rgb.G,
Blue: rgb.B,
}},
Intensity: value,
Temperature: 0,
},
@@ -139,9 +142,9 @@ func (b *bridge) Overview(ctx context.Context) (*Overview, error) {
switch res.StatusCode {
case 400, 403, 500, 503:
return nil, models.ErrUnexpectedResponse
return nil, lerrors.ErrUnexpectedResponse
case 401:
return nil, models.ErrIncorrectToken
return nil, lerrors.ErrIncorrectToken
}
overview := Overview{}
@@ -172,9 +175,20 @@ func (b *bridge) Update(devices []models.Device) {
for _, panel := range b.panels {
if panel.ID == uint16(id) {
if device.State.Power {
color := colorful.Hsv(device.State.Color.Hue, device.State.Color.Saturation, device.State.Intensity)
rgbColor, ok := device.State.Color.ToRGB()
if !ok {
newColor := [4]byte{255, 255, 255, 255}
if newColor != panel.ColorRGBA {
panel.update(newColor, time.Now().Add(time.Millisecond*220))
}
red, green, blue := color.RGB255()
continue
}
rgb := rgbColor.RGB.AtIntensity(device.State.Intensity)
red := byte(rgb.Red * 255.9)
green := byte(rgb.Green * 255.9)
blue := byte(rgb.Blue * 255.9)
newColor := [4]byte{red, green, blue, 255}
if newColor != panel.ColorRGBA {
panel.update(newColor, time.Now().Add(time.Millisecond*220))
@@ -321,7 +335,7 @@ func (b *bridge) updateEffect(ctx context.Context) error {
defer res.Body.Close()
if res.StatusCode != 204 {
return models.ErrUnexpectedResponse
return lerrors.ErrUnexpectedResponse
}
b.mu.Lock()
+3 -2
View File
@@ -4,6 +4,7 @@ import (
"context"
"encoding/json"
"fmt"
"git.aiterp.net/lucifer/new-server/internal/lerrors"
"git.aiterp.net/lucifer/new-server/models"
"net/http"
"sync"
@@ -30,7 +31,7 @@ func (d *Driver) SearchBridge(ctx context.Context, address, _ string, dryRun boo
return nil, err
}
if deviceInfo.ModelNumber == "" {
return nil, models.ErrUnexpectedResponse
return nil, lerrors.ErrUnexpectedResponse
}
token := ""
@@ -47,7 +48,7 @@ func (d *Driver) SearchBridge(ctx context.Context, address, _ string, dryRun boo
defer res.Body.Close()
if res.StatusCode != 200 {
return nil, models.ErrBridgeSearchFailed
return nil, lerrors.ErrBridgeSearchFailed
}
tokenResponse := TokenResponse{}
+5 -3
View File
@@ -1,14 +1,16 @@
package drivers
import "git.aiterp.net/lucifer/new-server/models"
import (
"git.aiterp.net/lucifer/new-server/internal/lerrors"
"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 nil, lerrors.ErrUnknownDriver
}
return m[kind], nil
}
@@ -1,4 +1,4 @@
package models
package lerrors
import "errors"
@@ -16,6 +16,7 @@ var ErrUnexpectedResponse = errors.New("driver api returned unexpected response
var ErrBridgeSearchFailed = errors.New("bridge search failed")
var ErrAddressOnlyDryRunnable = errors.New("this address may only be used for a dry run")
var ErrCannotForwardRequest = errors.New("driver is not able to forward requests")
var ErrCannotForget = errors.New("forget not supported on this bridge type")
var ErrInvalidAddress = errors.New("invalid mac address")
var ErrPayloadTooShort = errors.New("payload too short")
+13 -12
View File
@@ -4,6 +4,7 @@ import (
"context"
"database/sql"
"encoding/json"
"git.aiterp.net/lucifer/new-server/internal/color"
"git.aiterp.net/lucifer/new-server/models"
sq "github.com/Masterminds/squirrel"
"github.com/jmoiron/sqlx"
@@ -30,6 +31,7 @@ type deviceStateRecord struct {
Power bool `db:"power"`
Intensity float64 `db:"intensity"`
Temperature int `db:"temperature"`
Color string `db:"color"`
}
type devicePropertyRecord struct {
@@ -228,13 +230,14 @@ func (r *DeviceRepo) SaveMany(ctx context.Context, mode models.SaveMode, devices
if mode == 0 || mode&models.SMState != 0 {
_, err = tx.NamedExecContext(ctx, `
REPLACE INTO device_state(device_id, hue, saturation, kelvin, power, intensity, temperature)
VALUES (:device_id, :hue, :saturation, :kelvin, :power, :intensity, :temperature)
REPLACE INTO device_state(device_id, hue, saturation, kelvin, power, intensity, color, temperature)
VALUES (:device_id, :hue, :saturation, :kelvin, :power, :intensity, :color, :temperature)
`, deviceStateRecord{
DeviceID: record.ID,
Hue: device.State.Color.Hue,
Saturation: device.State.Color.Saturation,
Kelvin: device.State.Color.Kelvin,
Hue: 40,
Saturation: 0,
Kelvin: 0,
Color: device.State.Color.String(),
Power: device.State.Power,
Intensity: device.State.Intensity,
Temperature: device.State.Temperature,
@@ -373,14 +376,12 @@ func (r *DeviceRepo) populate(ctx context.Context, records []deviceRecord) ([]mo
for _, state := range states {
if state.DeviceID == record.ID {
color, _ := color.Parse(state.Color)
device.State = models.DeviceState{
Power: state.Power,
Color: models.ColorValue{
Hue: state.Hue,
Saturation: state.Saturation,
Kelvin: state.Kelvin,
},
Intensity: state.Intensity,
Power: state.Power,
Color: color,
Intensity: state.Intensity,
Temperature: state.Temperature,
}
}
+11 -11
View File
@@ -2,6 +2,7 @@ package mysql
import (
"context"
"git.aiterp.net/lucifer/new-server/internal/color"
"git.aiterp.net/lucifer/new-server/models"
"github.com/jmoiron/sqlx"
)
@@ -12,6 +13,7 @@ type presetRecord struct {
Hue float64 `db:"hue"`
Saturation float64 `db:"saturation"`
Kelvin int `db:"kelvin"`
Value string `db:"value"`
}
type ColorPresetRepo struct {
@@ -42,8 +44,8 @@ func (c *ColorPresetRepo) Save(ctx context.Context, preset *models.ColorPreset)
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,
"UPDATE color_preset SET name = ?, value = ? WHERE id = ?",
preset.Name, preset.Value.String(), preset.ID,
)
if err != nil {
@@ -52,8 +54,8 @@ func (c *ColorPresetRepo) Save(ctx context.Context, preset *models.ColorPreset)
} 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,
"INSERT INTO color_preset (name, value, hue, saturation, kelvin) VALUES (?, ?, 0, 0, 0)",
preset.Name, preset.Value.String(),
)
if err != nil {
@@ -84,14 +86,12 @@ func (c *ColorPresetRepo) Delete(ctx context.Context, preset *models.ColorPreset
func (c *ColorPresetRepo) fromRecords(records ...presetRecord) []models.ColorPreset {
newList := make([]models.ColorPreset, len(records), len(records))
for i, record := range records {
color, _ := color.Parse(record.Value)
newList[i] = models.ColorPreset{
ID: record.ID,
Name: record.Name,
Value: models.ColorValue{
Hue: record.Hue,
Saturation: record.Saturation,
Kelvin: record.Kelvin,
},
ID: record.ID,
Name: record.Name,
Value: color,
}
}
+10
View File
@@ -29,6 +29,16 @@ func (r *SceneRepo) Find(ctx context.Context, id int) (*models.Scene, error) {
return r.populateOne(&scene)
}
func (r *SceneRepo) FindName(ctx context.Context, name string) (*models.Scene, error) {
var scene sceneRecord
err := r.DBX.GetContext(ctx, &scene, "SELECT * FROM scene WHERE name = ?", name)
if err != nil {
return nil, dbErr(err)
}
return r.populateOne(&scene)
}
func (r *SceneRepo) FetchAll(ctx context.Context) ([]models.Scene, error) {
scenes := make([]sceneRecord, 0, 8)
err := r.DBX.SelectContext(ctx, &scenes, "SELECT * FROM scene")
+3 -3
View File
@@ -2,16 +2,16 @@ package mysql
import (
"database/sql"
"git.aiterp.net/lucifer/new-server/models"
"git.aiterp.net/lucifer/new-server/internal/lerrors"
"log"
)
func dbErr(err error) error {
if err == sql.ErrNoRows {
return models.ErrNotFound
return lerrors.ErrNotFound
} else if err != nil {
log.Printf("Internal error: %s", err.Error())
return models.ErrInternal
return lerrors.ErrInternal
}
return nil
+1
View File
@@ -21,6 +21,7 @@ type DriverKind string
var (
DTHue DriverKind = "Hue"
DTHue2 DriverKind = "Hue2"
DTNanoLeaf DriverKind = "Nanoleaf"
DTLIFX DriverKind = "LIFX"
DTMill DriverKind = "Mill"
+4 -3
View File
@@ -2,13 +2,14 @@ package models
import (
"context"
"git.aiterp.net/lucifer/new-server/internal/color"
"strings"
)
type ColorPreset struct {
ID int `json:"id"`
Name string `json:"name"`
Value ColorValue `json:"value"`
ID int `json:"id"`
Name string `json:"name"`
Value color.Color `json:"value"`
}
type ColorPresetRepository interface {
-85
View File
@@ -1,85 +0,0 @@
package models
import (
"fmt"
"math"
"strconv"
"strings"
)
type ColorValue struct {
Hue float64 `json:"h,omitempty"` // 0..360
Saturation float64 `json:"s,omitempty"` // 0..=1
Kelvin int `json:"kelvin,omitempty"`
}
func (c *ColorValue) IsHueSat() bool {
return !c.IsKelvin()
}
func (c *ColorValue) IsKelvin() bool {
return c.Kelvin > 0
}
func (c *ColorValue) String() string {
if c.Kelvin > 0 {
return fmt.Sprintf("k:%d", c.Kelvin)
}
return fmt.Sprintf("hs:%.4g,%.3g", c.Hue, c.Saturation)
}
func ParseColorValue(raw string) (ColorValue, error) {
tokens := strings.SplitN(raw, ":", 2)
if len(tokens) != 2 {
return ColorValue{}, ErrBadInput
}
if tokens[0] == "kelvin" || tokens[0] == "k" {
parsedPart, err := strconv.Atoi(tokens[1])
if err != nil {
return ColorValue{}, ErrBadInput
}
return ColorValue{Kelvin: parsedPart}, nil
}
if tokens[0] == "hs" {
parts := strings.Split(tokens[1], ",")
if len(parts) < 2 {
return ColorValue{}, ErrUnknownColorFormat
}
part1, err1 := strconv.ParseFloat(parts[0], 64)
part2, err2 := strconv.ParseFloat(parts[1], 64)
if err1 != nil || err2 != nil {
return ColorValue{}, ErrBadInput
}
return ColorValue{Hue: math.Mod(part1, 360), Saturation: math.Min(math.Max(part2, 0), 1)}, nil
}
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
}
+22 -15
View File
@@ -2,6 +2,8 @@ package models
import (
"context"
"git.aiterp.net/lucifer/new-server/internal/color"
"git.aiterp.net/lucifer/new-server/internal/lerrors"
"strings"
"time"
)
@@ -34,10 +36,10 @@ type DeviceUpdate struct {
// - Intensity: e.g. brightness, range 0..=1
// - Temperature: e.g. for thermostats
type DeviceState struct {
Power bool `json:"power"`
Color ColorValue `json:"color,omitempty"`
Intensity float64 `json:"intensity,omitempty"`
Temperature int `json:"temperature"`
Power bool `json:"power"`
Color color.Color `json:"color,omitempty"`
Intensity float64 `json:"intensity,omitempty"`
Temperature int `json:"temperature"`
}
type DeviceScene struct {
@@ -83,9 +85,11 @@ func DeviceCapabilitiesToStrings(caps []DeviceCapability) []string {
var (
DCPower DeviceCapability = "Power"
DCColorHS DeviceCapability = "ColorHS"
DCColorHS DeviceCapability = "HueSat"
DCColorHSK DeviceCapability = "ColorHSK"
DCColorKelvin DeviceCapability = "ColorKelvin"
DCColorXY DeviceCapability = "XY"
DCColorRGB DeviceCapability = "RGB"
DCButtons DeviceCapability = "Buttons"
DCPresence DeviceCapability = "Presence"
DCIntensity DeviceCapability = "Intensity"
@@ -126,7 +130,7 @@ func (d *Device) ApplyUpdate(update DeviceUpdate) {
func (d *Device) Validate() error {
d.Name = strings.Trim(d.Name, " \t\n ")
if d.Name == "" {
return ErrInvalidName
return lerrors.ErrInvalidName
}
newCaps := make([]DeviceCapability, 0, len(d.Capabilities))
@@ -184,12 +188,19 @@ func (d *Device) SetState(newState NewDeviceState) error {
}
if newState.Color != nil {
parsed, err := ParseColorValue(*newState.Color)
parsed, err := color.Parse(*newState.Color)
if err != nil {
return err
}
if (parsed.IsKelvin() && d.HasCapability(DCColorKelvin, DCColorHSK)) || (parsed.IsHueSat() && d.HasCapability(DCColorHS)) {
switch {
case (parsed.RGB != nil || parsed.XY != nil || parsed.HS != nil) || d.HasCapability(DCColorHS, DCColorXY, DCColorRGB):
d.State.Color = parsed
case parsed.K != nil && d.HasCapability(DCColorKelvin, DCColorHSK):
if !d.HasCapability(DCColorKelvin) {
d.State.Color.HS = &color.HueSat{Hue: 0, Sat: 0}
}
d.State.Color = parsed
}
}
@@ -228,14 +239,10 @@ func (s *NewDeviceState) Interpolate(other NewDeviceState, fac float64) NewDevic
}
if s.Color != nil && other.Color != nil {
sc, err := ParseColorValue(*s.Color)
oc, err2 := ParseColorValue(*other.Color)
sc, err := color.Parse(*s.Color)
oc, err2 := color.Parse(*other.Color)
if err == nil && err2 == nil {
rc := ColorValue{}
rc.Hue = interpolateFloat(sc.Hue, oc.Hue, fac)
rc.Saturation = interpolateFloat(sc.Saturation, oc.Saturation, fac)
rc.Kelvin = interpolateInt(sc.Kelvin, oc.Kelvin, fac)
rc := sc.Interpolate(oc, fac)
rcStr := rc.String()
n.Color = &rcStr
}
+4
View File
@@ -16,3 +16,7 @@ type Driver interface {
Publish(ctx context.Context, bridge Bridge, devices []Device) error
Run(ctx context.Context, bridge Bridge, ch chan<- Event) error
}
type ForgettableDriver interface {
ForgetDevice(ctx context.Context, bridge Bridge, device Device) error
}
+10 -7
View File
@@ -2,6 +2,7 @@ package models
import (
"context"
"git.aiterp.net/lucifer/new-server/internal/lerrors"
"math"
"math/rand"
"sort"
@@ -18,10 +19,10 @@ type Scene struct {
func (s *Scene) Validate() error {
if s.IntervalMS < 0 {
return ErrSceneInvalidInterval
return lerrors.ErrSceneInvalidInterval
}
if len(s.Roles) == 0 {
return ErrSceneNoRoles
return lerrors.ErrSceneNoRoles
}
for _, role := range s.Roles {
@@ -118,31 +119,31 @@ func (d *SceneRunContext) IntervalFac() float64 {
func (r *SceneRole) Validate() error {
if len(r.States) == 0 {
return ErrSceneRoleNoStates
return lerrors.ErrSceneRoleNoStates
}
switch r.TargetKind {
case RKTag, RKBridgeID, RKDeviceID, RKName, RKAll:
default:
return ErrBadInput
return lerrors.ErrBadInput
}
switch r.PowerMode {
case SPScene, SPDevice, SPBoth:
default:
return ErrSceneRoleUnknownPowerMode
return lerrors.ErrSceneRoleUnknownPowerMode
}
switch r.Effect {
case SEStatic, SERandom, SEGradient, SEWalkingGradient, SETransition, SEMotion, SETemperature:
default:
return ErrSceneRoleUnknownEffect
return lerrors.ErrSceneRoleUnknownEffect
}
switch r.Order {
case "", "-name", "name", "+name", "-id", "id", "+id":
default:
return ErrSceneRoleUnsupportedOrdering
return lerrors.ErrSceneRoleUnsupportedOrdering
}
return nil
@@ -299,6 +300,7 @@ const (
// instance is created for the group.
type DeviceSceneAssignment struct {
SceneID int `json:"sceneId"`
SceneName string `json:"sceneName"`
Group string `json:"group"`
StartTime time.Time `json:"start"`
DurationMS int64 `json:"durationMs"`
@@ -306,6 +308,7 @@ type DeviceSceneAssignment struct {
type SceneRepository interface {
Find(ctx context.Context, id int) (*Scene, error)
FindName(ctx context.Context, name string) (*Scene, error)
FetchAll(ctx context.Context) ([]Scene, error)
Save(ctx context.Context, bridge *Scene) error
Delete(ctx context.Context, bridge *Scene) error
@@ -0,0 +1,9 @@
-- +goose Up
-- +goose StatementBegin
CREATE UNIQUE INDEX scene_name ON scene (name);
-- +goose StatementEnd
-- +goose Down
-- +goose StatementBegin
DROP INDEX IF EXISTS scene_name;
-- +goose StatementEnd
@@ -0,0 +1,9 @@
-- +goose Up
-- +goose StatementBegin
TRUNCATE color_preset;
-- +goose StatementEnd
-- +goose Down
-- +goose StatementBegin
SELECT 'down SQL query';
-- +goose StatementEnd
@@ -0,0 +1,9 @@
-- +goose Up
-- +goose StatementBegin
ALTER TABLE color_preset ADD COLUMN value VARCHAR(255) NOT NULL DEFAULT 'hs:0,0';
-- +goose StatementEnd
-- +goose Down
-- +goose StatementBegin
ALTER TABLE color_preset DROP COLUMN value;
-- +goose StatementEnd
@@ -0,0 +1,9 @@
-- +goose Up
-- +goose StatementBegin
ALTER TABLE device_state ADD COLUMN color VARCHAR(255) NOT NULL DEFAULT 'hs:0,0';
-- +goose StatementEnd
-- +goose Down
-- +goose StatementBegin
ALTER TABLE device_state DROP COLUMN IF EXISTS color;
-- +goose StatementEnd