Browse Source

everything: Changing light colors works now.

login_bugfix
Gisle Aune 5 years ago
parent
commit
8c302ecbbe
  1. 21
      Dockerfile
  2. 56
      cmd/lucifer-server/main.go
  3. 78
      database/sqlite/group-repository.go
  4. 46
      database/sqlite/init.go
  5. 112
      database/sqlite/light-repository.go
  6. 2
      database/sqlite/user-repository.go
  7. 1
      go.mod
  8. 2
      go.sum
  9. 82
      internal/huecolor/convert.go
  10. 16
      internal/huecolor/convert_test.go
  11. 86
      internal/huecolor/gamut.go
  12. 17
      internal/huecolor/gamut_test.go
  13. 16
      light/driver.go
  14. 175
      light/hue/driver.go
  15. 60
      light/service.go
  16. 35
      models/group.go
  17. 102
      models/light.go

21
Dockerfile

@ -0,0 +1,21 @@
FROM golang:1.11 as build-server
WORKDIR /server
COPY . .
RUN go mod tidy
ENV CGO_ENABLED 1
RUN go build -ldflags "-w -s" ./cmd/lucifer-server/
FROM node:10.15.1-alpine as build-ui
WORKDIR /ui
COPY ./webui /ui
RUN npm install
RUN npm run build
FROM alpine:3.8
RUN apk add --no-cache sqlite
COPY /lib/libc.musl-x86_64.so.1 /lib64/ld-linux-x86-64.so.2
RUN apk remove sqlite
COPY --from=build-server /server/lucifer-server /usr/local/bin/
COPY --from=build-ui /ui/build /usr/local/share/lucifer-ui
CMD ["/usr/local/bin/lucifer-server"]

56
cmd/lucifer-server/main.go

@ -2,10 +2,16 @@ package main
import ( import (
"context" "context"
"crypto/rand"
"database/sql"
"encoding/hex"
"fmt"
"log" "log"
"net/http" "net/http"
"time"
"git.aiterp.net/lucifer/lucifer/light" "git.aiterp.net/lucifer/lucifer/light"
"git.aiterp.net/lucifer/lucifer/models"
"github.com/gorilla/mux" "github.com/gorilla/mux"
@ -29,6 +35,9 @@ func main() {
log.Fatalln("Failed to set up database:", err) log.Fatalln("Failed to set up database:", err)
} }
// Initialize
setupAdmin(sqlite.UserRepository, sqlite.GroupRepository)
// Services // Services
lightService := light.NewService(sqlite.BridgeRepository, sqlite.LightRepository) lightService := light.NewService(sqlite.BridgeRepository, sqlite.LightRepository)
@ -42,7 +51,54 @@ func main() {
// Background Tasks // Background Tasks
go lightService.SyncLoop(context.TODO()) go lightService.SyncLoop(context.TODO())
//go lightService.DiscoverLoop(context.TODO())
// TODO: Listen in another goroutine and have SIGINT/SIGTERM handlers with graceful shutdown. // TODO: Listen in another goroutine and have SIGINT/SIGTERM handlers with graceful shutdown.
http.ListenAndServe(conf.Server.Address, router) http.ListenAndServe(conf.Server.Address, router)
} }
func setupAdmin(users models.UserRepository, groups models.GroupRepository) {
ctx, cancel := context.WithTimeout(context.Background(), time.Second*15)
defer cancel()
admin, err := users.FindByName(ctx, "Admin")
if err != nil {
if err != sql.ErrNoRows {
log.Fatalln("Could not check for admin user:", err)
}
admin = models.User{Name: "Admin"}
admin.SetPassword("123456")
admin, err = users.Insert(ctx, admin)
if err != nil {
fmt.Println("Failed to insert admin username:", err)
}
}
buf := make([]byte, 16)
_, err = rand.Read(buf)
if err != nil {
log.Fatalln("Could not get random bytes:", err)
}
password := hex.EncodeToString(buf)
admin.SetPassword(password)
err = users.Update(ctx, admin)
if err != nil {
log.Println("Could not update admin password:", err)
} else {
log.Println("Administrator: Admin /", password)
}
groups.UpdatePermissions(ctx, models.GroupPermission{
UserID: admin.ID,
GroupID: 0,
Read: true,
Write: true,
Create: true,
Delete: true,
Manage: true,
})
}

78
database/sqlite/group-repository.go

@ -0,0 +1,78 @@
package sqlite
import (
"context"
"golang.org/x/sync/errgroup"
"git.aiterp.net/lucifer/lucifer/models"
)
type groupRepository struct{}
// GroupRepository is a sqlite datbase repository for the Group model.
var GroupRepository = &groupRepository{}
func (r *groupRepository) FindByID(ctx context.Context, id int) (models.Group, error) {
group := models.Group{}
err := db.GetContext(ctx, &group, "SELECT * FROM group WHERE id=?", id)
return group, err
}
func (r *groupRepository) FindByLight(ctx context.Context, light models.Light) (models.Group, error) {
group := models.Group{}
err := db.GetContext(ctx, &group, "SELECT g.id, g.name FROM `light` l JOIN `group` g ON g.id = l.group_id WHERE l.id = ?", light.ID)
return group, err
}
func (r *groupRepository) List(ctx context.Context) ([]models.Group, error) {
groups := make([]models.Group, 0, 16)
err := db.SelectContext(ctx, &groups, "SELECT * FROM group")
if err != nil {
return nil, err
}
eg, egCtx := errgroup.WithContext(ctx)
for i := range groups {
group := &groups[i]
eg.Go(func() error {
return db.SelectContext(egCtx, &group.Permissions, "SELECT * FROM group_permissions WHERE group_id=?", group.ID)
})
}
return groups, eg.Wait()
}
func (r *groupRepository) ListByUser(ctx context.Context, user models.User) ([]models.Group, error) {
panic("not implemented")
}
func (r *groupRepository) Insert(ctx context.Context, group models.Group) (models.Group, error) {
panic("not implemented")
}
func (r *groupRepository) Update(ctx context.Context, group models.Group) error {
_, err := db.NamedExecContext(ctx, "UPDATE group SET name=:name WHERE id=:id", group)
return err
}
func (r *groupRepository) UpdatePermissions(ctx context.Context, permission models.GroupPermission) error {
_, err := db.NamedExecContext(ctx, "REPLACE INTO group_permission(group_id, user_id, read, write, 'create', 'delete', manage) VALUES (:group_id, :user_id, :read, :write, :create, :delete, :manage)", permission)
return err
}
func (r *groupRepository) Remove(ctx context.Context, group models.Group) error {
_, err := db.NamedExecContext(ctx, "DELETE FROM group WHERE id=:id LIMIT 1", group)
return err
}
/*
SELECT g.id, g.name FROM group_permission AS p
JOIN 'group' AS g ON p.group_id=g.id
WHERE p.user_id=? AND p.read=true;
*/

46
database/sqlite/init.go

@ -1,6 +1,7 @@
package sqlite package sqlite
import ( import (
"log"
"strings" "strings"
"github.com/jmoiron/sqlx" "github.com/jmoiron/sqlx"
@ -31,9 +32,16 @@ func Initialize(filename string) (err error) {
} }
tableDefs := strings.Split(tableDefStr, ";") tableDefs := strings.Split(tableDefStr, ";")
for _, tableDef := range tableDefs {
_, err := db.Exec(strings.Trim(tableDef, "\n  "))
for i, tableDef := range tableDefs {
query := strings.Trim(tableDef, "\n  ")
if len(query) < 2 {
continue
}
_, err := db.Exec(query)
if err != nil { if err != nil {
log.Printf("Statement %d failed", i)
db.Close() db.Close()
db = nil db = nil
return err return err
@ -70,8 +78,36 @@ CREATE TABLE IF NOT EXISTS "light" (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
bridge_id INTEGER NOT NULL, bridge_id INTEGER NOT NULL,
internal_id VARCHAR(255) NOT NULL, internal_id VARCHAR(255) NOT NULL,
group_id INTEGER NOT NULL,
name VARCHAR(255) NOT NULL, name VARCHAR(255) NOT NULL,
'on' BOOLEAN NOT NULL,
color VARCHAR(255) NOT NULL
)
enabled BOOLEAN NOT NULL,
color VARCHAR(255) NOT NULL,
brightness INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS "group" (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name VARCHAR(255) NOT NULL
);
CREATE TABLE IF NOT EXISTS "group_permission" (
group_id INT NOT NULL,
user_id INT NOT NULL,
read BOOLEAN NOT NULL,
write BOOLEAN NOT NULL,
'create' BOOLEAN NOT NULL,
'delete' BOOLEAN NOT NULL,
manage BOOLEAN NOT NULL,
PRIMARY KEY (group_id, user_id)
);
CREATE TABLE IF NOT EXISTS "group_light" (
group_id INT NOT NULL,
light_id INT NOT NULL,
PRIMARY KEY (group_id, light_id)
);
REPLACE INTO "group" (id, name) VALUES (0, "Lonely Lights");
` `

112
database/sqlite/light-repository.go

@ -11,119 +11,43 @@ type lightRepository struct{}
// LightRepository is a sqlite datbase repository for the Light model. // LightRepository is a sqlite datbase repository for the Light model.
var LightRepository = &lightRepository{} var LightRepository = &lightRepository{}
type structScanner interface {
StructScan(v interface{}) error
}
func scanLight(row structScanner, light *models.Light) error {
scannedLight := struct {
ID int `db:"id"`
BridgeID int `db:"bridge_id"`
InternalID string `db:"internal_id"`
Name string `db:"name"`
On bool `db:"on"`
Color string `db:"color"`
}{}
if err := row.StructScan(&scannedLight); err != nil {
return err
}
light.ID = scannedLight.ID
light.BridgeID = scannedLight.BridgeID
light.InternalID = scannedLight.InternalID
light.Name = scannedLight.Name
light.On = scannedLight.On
return light.Color.Parse(scannedLight.Color)
}
func (r *lightRepository) FindByID(ctx context.Context, id int) (models.Light, error) { func (r *lightRepository) FindByID(ctx context.Context, id int) (models.Light, error) {
row := db.QueryRowxContext(ctx, "SELECT * FROM light WHERE id=?", id)
if err := row.Err(); err != nil {
return models.Light{}, err
}
light := models.Light{} light := models.Light{}
if err := scanLight(row, &light); err != nil {
return models.Light{}, err
}
err := db.GetContext(ctx, &light, "SELECT * FROM light WHERE id=?", id)
return light, nil
return light, err
} }
func (r *lightRepository) FindByInternalID(ctx context.Context, internalID string) (models.Light, error) { func (r *lightRepository) FindByInternalID(ctx context.Context, internalID string) (models.Light, error) {
row := db.QueryRowxContext(ctx, "SELECT * FROM light WHERE internal_id=?", internalID)
if err := row.Err(); err != nil {
return models.Light{}, err
}
light := models.Light{} light := models.Light{}
if err := scanLight(row, &light); err != nil {
return models.Light{}, err
}
err := db.GetContext(ctx, &light, "SELECT * FROM light WHERE internal_id=?", internalID)
return light, nil
return light, err
} }
func (r *lightRepository) List(ctx context.Context) ([]models.Light, error) { func (r *lightRepository) List(ctx context.Context) ([]models.Light, error) {
res, err := db.QueryxContext(ctx, "SELECT * FROM light")
if err != nil {
return nil, err
} else if err := res.Err(); err != nil {
return nil, err
}
lights := make([]models.Light, 0, 64) lights := make([]models.Light, 0, 64)
for res.Next() {
light := models.Light{}
if err := scanLight(res, &light); err != nil {
return nil, err
}
lights = append(lights, light)
}
err := db.SelectContext(ctx, &lights, "SELECT * FROM light")
return lights, nil
return lights, err
} }
func (r *lightRepository) ListByBridge(ctx context.Context, bridge models.Bridge) ([]models.Light, error) { func (r *lightRepository) ListByBridge(ctx context.Context, bridge models.Bridge) ([]models.Light, error) {
res, err := db.QueryxContext(ctx, "SELECT * FROM light WHERE bridge_id=?", bridge.ID)
if err != nil {
return nil, err
} else if err := res.Err(); err != nil {
return nil, err
}
lights := make([]models.Light, 0, 64) lights := make([]models.Light, 0, 64)
for res.Next() {
light := models.Light{}
if err := scanLight(res, &light); err != nil {
return nil, err
}
err := db.SelectContext(ctx, &lights, "SELECT * FROM light WHERE bridge_id=?", bridge.ID)
lights = append(lights, light)
}
return lights, err
}
return lights, nil
func (r *lightRepository) ListByGroup(ctx context.Context, group models.Group) ([]models.Light, error) {
lights := make([]models.Light, 0, 64)
err := db.SelectContext(ctx, &lights, "SELECT * FROM light WHERE group_id=?", group.ID)
return lights, err
} }
func (r *lightRepository) Insert(ctx context.Context, light models.Light) (models.Light, error) { func (r *lightRepository) Insert(ctx context.Context, light models.Light) (models.Light, error) {
data := struct {
BridgeID int `db:"bridge_id"`
InternalID string `db:"internal_id"`
Name string `db:"name"`
On bool `db:"on"`
Color string `db:"color"`
}{
BridgeID: light.BridgeID,
InternalID: light.InternalID,
Name: light.Name,
On: light.On,
Color: light.Color.String(),
}
res, err := db.NamedExecContext(ctx, "INSERT INTO light (bridge_id, internal_id, name, `on`, color) VALUES(:bridge_id, :internal_id, :name, :on, :color)", data)
res, err := db.NamedExecContext(ctx, "INSERT INTO light (bridge_id, internal_id, group_id, name, enabled, color, brightness) VALUES(:bridge_id, :internal_id, :group_id, :name, :enabled, :color, :brightness)", light)
if err != nil { if err != nil {
return models.Light{}, err return models.Light{}, err
} }
@ -138,9 +62,11 @@ func (r *lightRepository) Insert(ctx context.Context, light models.Light) (model
} }
func (r *lightRepository) Update(ctx context.Context, light models.Light) error { func (r *lightRepository) Update(ctx context.Context, light models.Light) error {
panic("not implemented")
_, err := db.NamedExecContext(ctx, "UPDATE light SET group_id=:group_id, name=:name, enabled=:enabled, color=:color, brightness=:brightness WHERE id=:id", light)
return err
} }
func (r *lightRepository) Remove(ctx context.Context, light models.Light) error { func (r *lightRepository) Remove(ctx context.Context, light models.Light) error {
panic("not implemented")
_, err := db.NamedExecContext(ctx, "DELETE FROM light WHERE id=:id", light)
return err
} }

2
database/sqlite/user-repository.go

@ -76,7 +76,7 @@ func (repo *userRepository) Insert(ctx context.Context, user models.User) (model
} }
func (repo *userRepository) Update(ctx context.Context, user models.User) error { func (repo *userRepository) Update(ctx context.Context, user models.User) error {
_, err := db.NamedExecContext(ctx, "UPDATE user SET name=:name AND hash=:hash WHERE id=:id", user)
_, err := db.NamedExecContext(ctx, "UPDATE user SET hash=:hash WHERE id=:id", user)
if err != nil { if err != nil {
return err return err
} }

1
go.mod

@ -3,7 +3,6 @@ module git.aiterp.net/lucifer/lucifer
require ( require (
github.com/collinux/GoHue v0.0.0-20181229002551-d259041d5eb8 // indirect github.com/collinux/GoHue v0.0.0-20181229002551-d259041d5eb8 // indirect
github.com/collinux/gohue v0.0.0-20181229002551-d259041d5eb8 github.com/collinux/gohue v0.0.0-20181229002551-d259041d5eb8
github.com/gerow/go-color v0.0.0-20140219113758-125d37f527f1
github.com/gorilla/context v1.1.1 // indirect github.com/gorilla/context v1.1.1 // indirect
github.com/gorilla/mux v1.6.2 github.com/gorilla/mux v1.6.2
github.com/jmoiron/sqlx v1.2.0 github.com/jmoiron/sqlx v1.2.0

2
go.sum

@ -2,8 +2,6 @@ github.com/collinux/GoHue v0.0.0-20181229002551-d259041d5eb8 h1:NOPuu1sMqBVC3iyl
github.com/collinux/GoHue v0.0.0-20181229002551-d259041d5eb8/go.mod h1:ex2AEcUvgoeoE/uknn0ZUExwGhBcH9jwA5heqv2xq6w= github.com/collinux/GoHue v0.0.0-20181229002551-d259041d5eb8/go.mod h1:ex2AEcUvgoeoE/uknn0ZUExwGhBcH9jwA5heqv2xq6w=
github.com/collinux/gohue v0.0.0-20181229002551-d259041d5eb8 h1:Qhd31xZ6GUL0nEaXYP4nXOn8J6l9jqa6xEyp70qfjZE= github.com/collinux/gohue v0.0.0-20181229002551-d259041d5eb8 h1:Qhd31xZ6GUL0nEaXYP4nXOn8J6l9jqa6xEyp70qfjZE=
github.com/collinux/gohue v0.0.0-20181229002551-d259041d5eb8/go.mod h1:HFm7vkh/1EJQ9ymYsKUQtK7JlG3om1r61wMAHtl+bxw= github.com/collinux/gohue v0.0.0-20181229002551-d259041d5eb8/go.mod h1:HFm7vkh/1EJQ9ymYsKUQtK7JlG3om1r61wMAHtl+bxw=
github.com/gerow/go-color v0.0.0-20140219113758-125d37f527f1 h1:DSA78HTfGC442ChonW9NdWuH5rsfJjTwsYwfIZhYjFo=
github.com/gerow/go-color v0.0.0-20140219113758-125d37f527f1/go.mod h1:uN90NshmoiEU0ECs3cPdEg3wshS8kG9Zez9RmYPuL5A=
github.com/go-sql-driver/mysql v1.4.0 h1:7LxgVwFb2hIQtMm87NdgAVfXjnt4OePseqT1tKx+opk= github.com/go-sql-driver/mysql v1.4.0 h1:7LxgVwFb2hIQtMm87NdgAVfXjnt4OePseqT1tKx+opk=
github.com/go-sql-driver/mysql v1.4.0/go.mod h1:zAC/RDZ24gD3HViQzih4MyKcchzm+sOG5ZlKdlhCg5w= github.com/go-sql-driver/mysql v1.4.0/go.mod h1:zAC/RDZ24gD3HViQzih4MyKcchzm+sOG5ZlKdlhCg5w=
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=

82
internal/huecolor/convert.go

@ -0,0 +1,82 @@
package huecolor
import (
"math"
)
// ConvertXY converts the RGB value to XY and brightness and snaps it to the color gamut.
func ConvertXY(r, g, b float64) (x, y float64) {
x, y, z := convertXYZ(correctGamma(r, g, b))
return x / (x + y + z), y / (x + y + z)
}
// ConvertRGB converts back the values for ConvertXY to RGB. This does not gurantee that
// `ConvertRGB(ConvertXY(r, g, b))` returns the same `r`, `g` and `b` because of Gamut
// clamping.
func ConvertRGB(x, y, bri float64) (r, g, b float64) {
z := (1 - x - y)
y2 := bri
x2 := (y2 / y) * x
z2 := (y2 / y) * z
r = x2*1.4628067 - y2*0.1840623 - z2*0.2743606
g = -x2*0.5217933 + y2*1.4472381 + z2*0.0677227
b = x2*0.0349342 - y2*0.0968930 + z2*1.2884099
return reverseGamma(r, g, b)
}
// convertXYZ converts the RGB values to XYZ using the Wide RGB D65 conversion formula.
func convertXYZ(r, g, b float64) (x, y, z float64) {
return r*0.649926 + g*0.103455 + b*0.197109,
r*0.234327 + g*0.743075 + b*0.022598,
r*0.0000000 + g*0.053077 + b*1.035763
}
// correctGamma applies a gamma correction to the RGB values, which makes the color
// more vivid and more the like the color displayed on the screen of your device.
func correctGamma(r, g, b float64) (r2, g2, b2 float64) {
if r > 0.04045 {
r2 = math.Pow((r+0.055)/1.055, 2.4)
} else {
r2 = r / 12.92
}
if g > 0.04045 {
g2 = math.Pow((g+0.055)/1.055, 2.4)
} else {
g2 = g / 12.92
}
if b > 0.04045 {
b2 = math.Pow((b+0.055)/1.055, 2.4)
} else {
b2 = b / 12.92
}
return
}
// reverseGamma applies a reverse gamma correction to the RGB values to make them
func reverseGamma(r, g, b float64) (r2, g2, b2 float64) {
if r >= 0.0031308 {
r2 = 1.055*math.Pow(r, 1/2.4) - 0.055
} else {
r2 = r * 12.92
}
if g >= 0.0031308 {
g2 = 1.055*math.Pow(g, 1/2.4) - 0.055
} else {
g2 = g * 12.92
}
if b >= 0.0031308 {
b2 = 1.055*math.Pow(b, 1/2.4) - 0.055
} else {
b2 = b * 12.92
}
return
}

16
internal/huecolor/convert_test.go

@ -0,0 +1,16 @@
package huecolor_test
import (
"testing"
"git.aiterp.net/lucifer/lucifer/internal/huecolor"
)
func BenchmarkConvert(b *testing.B) {
for n := 0; n < b.N; n++ {
x, y := huecolor.ConvertXY(0.96, 0.04, 0.07)
if x < 0 && y < 0 {
b.Fail()
}
}
}

86
internal/huecolor/gamut.go

@ -0,0 +1,86 @@
package huecolor
// NOT IN USE. Gamuts just made things worse.
import "math"
type gamutPoint [2]float64
// A ColorGamut is a subset of a color space, and is used to constrain colors.
type ColorGamut struct {
R gamutPoint
G gamutPoint
B gamutPoint
}
var (
// HueGamut is the recommended gamut for hue bulbs
HueGamut = ColorGamut{gamutPoint{0.675, 0.322}, gamutPoint{0.4091, 0.518}, gamutPoint{0.167, 0.04}}
// FancyGamut is for LivingColors, Iris, and other fancy bulbs
FancyGamut = ColorGamut{gamutPoint{0.704, 0.296}, gamutPoint{0.2151, 0.7106}, gamutPoint{0.138, 0.08}}
// DefaultGamut is for non-Hue stuff.
DefaultGamut = ColorGamut{gamutPoint{0.9961, 0.0001}, gamutPoint{0.0001, 0.9961}, gamutPoint{0.0001, 0.0001}}
)
// Closest gets the closest point on the color gamut's edges. This function will always return a point
// on an edge, even if `(x, y)` is inside of it.
func (g *ColorGamut) Closest(x, y float64) (x2, y2 float64) {
p := gamutPoint{x, y}
pRG := closestToLine(g.R, g.G, p)
pGB := closestToLine(g.G, g.B, p)
pBR := closestToLine(g.B, g.R, p)
dRG := distance(p, pRG)
dGB := distance(p, pGB)
dBR := distance(p, pBR)
closest := pRG
closestDistance := dRG
if dGB < closestDistance {
closest = pGB
closestDistance = dGB
}
if dBR < closestDistance {
closest = pBR
closestDistance = dBR
}
return closest[0], closest[1]
}
// Contains retunrs true if the gamut contains the color.
func (g *ColorGamut) Contains(x, y float64) bool {
pt := gamutPoint{x, y}
b1 := sign(pt, g.R, g.G) < 0
b2 := sign(pt, g.G, g.B) < 0
b3 := sign(pt, g.B, g.R) < 0
return ((b1 == b2) && (b2 == b3))
}
func distance(p1, p2 gamutPoint) float64 {
dx := p1[0] - p2[0]
dy := p1[1] - p2[1]
return math.Sqrt(dx*dx + dy*dy)
}
func closestToLine(a, b, p gamutPoint) gamutPoint {
ap := gamutPoint{p[0] - a[0], p[1] - a[1]}
ab := gamutPoint{b[0] - a[0], b[1] - a[1]}
t := (ap[0]*ab[0] + ap[1]*ab[1]) / (ab[0]*ab[0] + ap[0]*ap[0])
if t < 0 {
t = 0
} else if t > 1 {
t = 1
}
return gamutPoint{a[0] + t*ab[0], a[1] + t*ab[1]}
}
func sign(p1, p2, p3 gamutPoint) float64 {
return (p1[0]-p3[0])*(p2[1]-p3[1]) - (p2[0]-p3[0])*(p1[1]-p3[1])
}

17
internal/huecolor/gamut_test.go

@ -0,0 +1,17 @@
package huecolor_test
import (
"testing"
"git.aiterp.net/lucifer/lucifer/internal/huecolor"
)
func TestGamut(t *testing.T) {
if !huecolor.DefaultGamut.Contains(0.1, 0.1) {
t.Error(1)
}
if huecolor.DefaultGamut.Contains(0, 0) {
t.Error(2)
}
}

16
light/driver.go

@ -10,17 +10,23 @@ var drivers = make(map[string]Driver)
// A Driver that communicates with an underlying lighting system. // A Driver that communicates with an underlying lighting system.
type Driver interface { type Driver interface {
// Apply applies all changed lights, which are lights that differ from what DiscoverLights returned.
// Apply applies all changed lights, which are lights that differ from what Lights returned.
Apply(ctx context.Context, bridge models.Bridge, lights ...models.Light) error Apply(ctx context.Context, bridge models.Bridge, lights ...models.Light) error
// DiscoverLights lists all available lights. The `ID` field will the -1.
DiscoverLights(ctx context.Context, bridge models.Bridge) ([]models.Light, error)
// Lights lists all available lights. The `ID` field will the -1.
Lights(ctx context.Context, bridge models.Bridge) ([]models.Light, error)
// DiscoverBridges lists all available bridges.
DiscoverBridges(ctx context.Context) ([]models.Bridge, error)
// Bridges lists all available bridges.
Bridges(ctx context.Context) ([]models.Bridge, error)
// Connect connects to a bridge, returning the bridge with the API Key. // Connect connects to a bridge, returning the bridge with the API Key.
Connect(ctx context.Context, bridge models.Bridge) (models.Bridge, error) Connect(ctx context.Context, bridge models.Bridge) (models.Bridge, error)
// DiscoverLights asks the bridge to start a search for new lights.
DiscoverLights(ctx context.Context, bridge models.Bridge) error
// ChangedLights returns a subset of the list describing which lights to update.
ChangedLights(ctx context.Context, bridge models.Bridge, lights ...models.Light) ([]models.Light, error)
} }
// RegisterDriver registers a driver. This must happen in init() functions. // RegisterDriver registers a driver. This must happen in init() functions.

175
light/hue/driver.go

@ -2,21 +2,40 @@ package hue
import ( import (
"context" "context"
"encoding/json"
"errors" "errors"
"fmt"
"log" "log"
"sync" "sync"
"time" "time"
"git.aiterp.net/lucifer/lucifer/internal/huecolor"
"git.aiterp.net/lucifer/lucifer/light" "git.aiterp.net/lucifer/lucifer/light"
"git.aiterp.net/lucifer/lucifer/models" "git.aiterp.net/lucifer/lucifer/models"
gohue "github.com/collinux/gohue" gohue "github.com/collinux/gohue"
"golang.org/x/sync/errgroup" "golang.org/x/sync/errgroup"
) )
const (
// FlagUseXY applies a more aggressive mode change via xy to make TradFri bulbs work.
FlagUseXY = 1
)
type xyBri struct {
XY [2]float32
Bri uint8
}
func colorKey(light models.Light) string {
return fmt.Sprintf("%s.%s.%d", light.InternalID, light.Color, light.Brightness)
}
// A driver is a driver for Phillips Hue lights. // A driver is a driver for Phillips Hue lights.
type driver struct { type driver struct {
mutex sync.Mutex mutex sync.Mutex
bridges map[int]*gohue.Bridge bridges map[int]*gohue.Bridge
colors map[string]xyBri
} }
func (d *driver) Apply(ctx context.Context, bridge models.Bridge, lights ...models.Light) error { func (d *driver) Apply(ctx context.Context, bridge models.Bridge, lights ...models.Light) error {
@ -30,7 +49,7 @@ func (d *driver) Apply(ctx context.Context, bridge models.Bridge, lights ...mode
return err return err
} }
eg, egCtx := errgroup.WithContext(ctx)
eg, _ := errgroup.WithContext(ctx)
for _, hueLight := range hueLights { for _, hueLight := range hueLights {
if !hueLight.State.Reachable { if !hueLight.State.Reachable {
@ -46,18 +65,38 @@ func (d *driver) Apply(ctx context.Context, bridge models.Bridge, lights ...mode
hl := hueLight hl := hueLight
eg.Go(func() error { eg.Go(func() error {
select {
case <-egCtx.Done():
return egCtx.Err()
default:
if !light.On {
return hl.SetState(gohue.LightState{
On: false,
})
} }
return hl.SetState(gohue.LightState{
x, y, bri, err := d.calcColor(light, hl)
if err != nil {
return err
}
log.Printf("Updating light (id: %d, rgb: %s, xy: [%f, %f], bri: %d)", light.ID, light.Color, x, y, bri)
err = hl.SetState(gohue.LightState{
On: light.On, On: light.On,
Hue: light.Color.Hue,
Sat: light.Color.Sat,
Bri: light.Color.Bri,
XY: &[2]float32{float32(x), float32(y)},
Bri: bri,
}) })
if err != nil {
return err
}
hl2, err := hueBridge.GetLightByIndex(hl.Index)
if err != nil {
return err
}
d.mutex.Lock()
d.colors[colorKey(light)] = xyBri{XY: hl2.State.XY, Bri: hl2.State.Bri}
d.mutex.Unlock()
return nil
}) })
break break
@ -67,7 +106,16 @@ func (d *driver) Apply(ctx context.Context, bridge models.Bridge, lights ...mode
return eg.Wait() return eg.Wait()
} }
func (d *driver) DiscoverLights(ctx context.Context, bridge models.Bridge) ([]models.Light, error) {
func (d *driver) DiscoverLights(ctx context.Context, bridge models.Bridge) error {
hueBridge, err := d.getBridge(bridge)
if err != nil {
return err
}
return hueBridge.FindNewLights()
}
func (d *driver) Lights(ctx context.Context, bridge models.Bridge) ([]models.Light, error) {
hueBridge, err := d.getBridge(bridge) hueBridge, err := d.getBridge(bridge)
if err != nil { if err != nil {
return nil, err return nil, err
@ -80,31 +128,32 @@ func (d *driver) DiscoverLights(ctx context.Context, bridge models.Bridge) ([]mo
lights := make([]models.Light, 0, len(hueLights)) lights := make([]models.Light, 0, len(hueLights))
for _, hueLight := range hueLights { for _, hueLight := range hueLights {
lights = append(lights, models.Light{
r, g, b := huecolor.ConvertRGB(float64(hueLight.State.XY[0]), float64(hueLight.State.XY[1]), float64(hueLight.State.Bri)/255)
light := models.Light{
ID: -1, ID: -1,
Name: hueLight.Name, Name: hueLight.Name,
BridgeID: bridge.ID, BridgeID: bridge.ID,
InternalID: hueLight.UniqueID, InternalID: hueLight.UniqueID,
On: hueLight.State.On, On: hueLight.State.On,
Color: models.LightColor{
Hue: hueLight.State.Hue,
Sat: hueLight.State.Saturation,
Bri: hueLight.State.Bri,
},
})
}
light.SetColorRGBf(r, g, b)
light.Brightness = hueLight.State.Bri
lights = append(lights, light)
} }
return lights, nil return lights, nil
} }
func (d *driver) DiscoverBridges(ctx context.Context) ([]models.Bridge, error) {
func (d *driver) Bridges(ctx context.Context) ([]models.Bridge, error) {
panic("not implemented") panic("not implemented")
} }
func (d *driver) Connect(ctx context.Context, bridge models.Bridge) (models.Bridge, error) { func (d *driver) Connect(ctx context.Context, bridge models.Bridge) (models.Bridge, error) {
hueBridge, err := gohue.NewBridge(bridge.Addr) hueBridge, err := gohue.NewBridge(bridge.Addr)
if err != nil { if err != nil {
log.Fatalln(err)
return models.Bridge{}, err
} }
// Make 30 attempts (30 seconds) // Make 30 attempts (30 seconds)
@ -129,6 +178,84 @@ func (d *driver) Connect(ctx context.Context, bridge models.Bridge) (models.Brid
return models.Bridge{}, errors.New("Failed to create bridge") return models.Bridge{}, errors.New("Failed to create bridge")
} }
func (d *driver) ChangedLights(ctx context.Context, bridge models.Bridge, lights ...models.Light) ([]models.Light, error) {
hueBridge, err := d.getBridge(bridge)
if err != nil {
return nil, err
}
hueLights, err := hueBridge.GetAllLights()
if err != nil {
return nil, err
}
subset := make([]models.Light, 0, len(lights))
for _, hueLight := range hueLights {
for _, light := range lights {
if hueLight.UniqueID != light.InternalID {
continue
}
d.mutex.Lock()
c, cOk := d.colors[colorKey(light)]
d.mutex.Unlock()
if !cOk || c.Bri != hueLight.State.Bri || diff(c.XY[0], hueLight.State.XY[0]) > 0.064 || diff(c.XY[1], hueLight.State.XY[1]) > 0.064 {
subset = append(subset, light)
}
break
}
}
return subset, nil
}
func (d *driver) calcColor(light models.Light, hueLight gohue.Light) (x, y float64, bri uint8, err error) {
r, g, b, err := light.ColorRGBf()
if err != nil {
return
}
x, y = huecolor.ConvertXY(r, g, b)
bri = light.Brightness
if bri < 1 {
bri = 1
} else if bri > 254 {
bri = 254
}
return
}
func (d *driver) getRawState(hueLight gohue.Light) (map[string]interface{}, error) {
data := struct {
State map[string]interface{} `json:"state"`
}{
State: make(map[string]interface{}, 16),
}
uri := fmt.Sprintf("/api/%s/lights/%d/", hueLight.Bridge.Username, hueLight.Index)
_, reader, err := hueLight.Bridge.Get(uri)
if err != nil {
return nil, err
}
err = json.NewDecoder(reader).Decode(&data)
return data.State, err
}
func (d *driver) setState(hueLight gohue.Light, key string, value interface{}) error {
m := make(map[string]interface{}, 1)
m[key] = value
uri := fmt.Sprintf("/api/%s/lights/%d/state", hueLight.Bridge.Username, hueLight.Index)
_, _, err := hueLight.Bridge.Put(uri, m)
return err
}
func (d *driver) getBridge(bridge models.Bridge) (*gohue.Bridge, error) { func (d *driver) getBridge(bridge models.Bridge) (*gohue.Bridge, error) {
d.mutex.Lock() d.mutex.Lock()
defer d.mutex.Unlock() defer d.mutex.Unlock()
@ -160,9 +287,19 @@ func (d *driver) getBridge(bridge models.Bridge) (*gohue.Bridge, error) {
return hueBridge, nil return hueBridge, nil
} }
func diff(a, b float32) float32 {
diff := a - b
if diff < 0 {
return -diff
}
return diff
}
func init() { func init() {
driver := &driver{ driver := &driver{
bridges: make(map[int]*gohue.Bridge, 16), bridges: make(map[int]*gohue.Bridge, 16),
colors: make(map[string]xyBri, 128),
} }
light.RegisterDriver("hue", driver) light.RegisterDriver("hue", driver)

60
light/service.go

@ -52,7 +52,7 @@ func (s *Service) SyncLights(ctx context.Context, bridge models.Bridge) error {
return ErrUnknownDriver return ErrUnknownDriver
} }
bridgeLights, err := d.DiscoverLights(ctx, bridge)
bridgeLights, err := d.Lights(ctx, bridge)
if err != nil { if err != nil {
return err return err
} }
@ -62,29 +62,31 @@ func (s *Service) SyncLights(ctx context.Context, bridge models.Bridge) error {
return err return err
} }
// Sync with matching db light if it exists.
changedLights := make([]models.Light, 0, len(dbLights))
LightLoop:
// Add unknown lights
for _, bridgeLight := range bridgeLights { for _, bridgeLight := range bridgeLights {
found := false
for _, dbLight := range dbLights { for _, dbLight := range dbLights {
if dbLight.InternalID == bridgeLight.InternalID { if dbLight.InternalID == bridgeLight.InternalID {
if !dbLight.Color.Equals(bridgeLight.Color) || dbLight.On != bridgeLight.On {
changedLights = append(changedLights, dbLight)
}
continue LightLoop
found = true
break
} }
} }
// Add unknown lights if it doesn't exist in the databse. // Add unknown lights if it doesn't exist in the databse.
log.Println("Adding unknown light", bridgeLight.InternalID)
_, err := s.lights.Insert(ctx, bridgeLight)
if err != nil {
return err
if !found {
log.Println("Adding unknown light", bridgeLight.InternalID)
_, err := s.lights.Insert(ctx, bridgeLight)
if err != nil {
return err
}
} }
} }
changedLights, err := d.ChangedLights(ctx, bridge, dbLights...)
if err != nil {
return err
}
if len(changedLights) > 0 { if len(changedLights) > 0 {
err := d.Apply(ctx, bridge, changedLights...) err := d.Apply(ctx, bridge, changedLights...)
if err != nil { if err != nil {
@ -98,7 +100,7 @@ LightLoop:
// SyncLoop runs synclight on all bridges twice every second until the context is // SyncLoop runs synclight on all bridges twice every second until the context is
// done. // done.
func (s *Service) SyncLoop(ctx context.Context) { func (s *Service) SyncLoop(ctx context.Context) {
interval := time.NewTicker(time.Second / 2)
interval := time.NewTicker(time.Millisecond * 2500)
for { for {
select { select {
@ -125,6 +127,34 @@ func (s *Service) SyncLoop(ctx context.Context) {
} }
} }
// DiscoverLoop discovers stuff.
func (s *Service) DiscoverLoop(ctx context.Context) {
for {
bridges, err := s.Bridges(ctx)
if err != nil {
log.Println("Failed to get bridges:", err)
continue
}
for _, bridge := range bridges {
d, ok := drivers[bridge.Driver]
if !ok {
continue
}
log.Println("Searcing on bridge", bridge.Name)
err := d.DiscoverLights(ctx, bridge)
if err != nil {
log.Println("Failed to discover lights:", err)
continue
}
time.Sleep(time.Second * 60)
}
}
}
// Bridge gets a bridge by ID. // Bridge gets a bridge by ID.
func (s *Service) Bridge(ctx context.Context, id int) (models.Bridge, error) { func (s *Service) Bridge(ctx context.Context, id int) (models.Bridge, error) {
return s.bridges.FindByID(ctx, id) return s.bridges.FindByID(ctx, id)

35
models/group.go

@ -0,0 +1,35 @@
package models
import "context"
// A Group is a collection of lights that relates to one another. The API should
// allow all lights within it.
type Group struct {
ID int `json:"id" db:"id"`
Name string `json:"name" db:"name"`
Permissions []GroupPermission `json:"permissions" db:"-"`
}
// A GroupPermission is a permission for a user in a group.
type GroupPermission struct {
GroupID int `json:"group_id" db:"group_id"`
UserID int `json:"user_id" db:"user_id"`
Read bool `json:"read" db:"read"`
Write bool `json:"write" db:"write"`
Create bool `json:"create" db:"create"`
Delete bool `json:"delete" db:"delete"`
Manage bool `json:"manage" db:"manage"`
}
// GroupRepository is an interface for all database operations
// the Group model makes.
type GroupRepository interface {
FindByID(ctx context.Context, id int) (Group, error)
FindByLight(ctx context.Context, light Light) (Group, error)
List(ctx context.Context) ([]Group, error)
ListByUser(ctx context.Context, user User) ([]Group, error)
UpdatePermissions(ctx context.Context, permission GroupPermission) error
Insert(ctx context.Context, group Group) (Group, error)
Update(ctx context.Context, group Group) error
Remove(ctx context.Context, group Group) error
}

102
models/light.go

@ -2,92 +2,57 @@ package models
import ( import (
"context" "context"
"encoding/hex"
"errors" "errors"
"fmt"
"strconv"
"strings"
goColor "github.com/gerow/go-color"
) )
// ErrMalformedColor is returned by light.ColorRGBf when the color value is invalid.
var ErrMalformedColor = errors.New("Malformed color in light")
// A Light represents a bulb. // A Light represents a bulb.
type Light struct { type Light struct {
ID int `json:"id"`
BridgeID int `json:"bridgeId"`
InternalID string `json:"-"`
Name string `json:"name"`
On bool `json:"on"`
Color LightColor `json:"color"`
}
// LightColor represent a HSB color.
type LightColor struct {
Hue uint16
Sat uint8
Bri uint8
ID int `json:"id" db:"id"`
BridgeID int `json:"bridgeId" db:"bridge_id"`
GroupID int `json:"groupId" db:"group_id"`
InternalID string `json:"internalId" db:"internal_id"`
Name string `json:"name" db:"name"`
On bool `json:"on" db:"enabled"`
Color string `json:"color" db:"color"`
Brightness uint8 `json:"brightness" db:"brightness"`
} }
// SetRGB sets the light color's RGB.
func (color *LightColor) SetRGB(r, g, b uint8) {
rgb := goColor.RGB{
R: float64(r) / 255,
G: float64(g) / 255,
B: float64(b) / 255,
}
hsl := rgb.ToHSL()
color.Hue = uint16(hsl.H * 65535)
color.Sat = uint8(hsl.S * 255)
color.Bri = uint8(hsl.L * 255)
// SetColorRGB sets the color with an RGB value.
func (light *Light) SetColorRGB(r, g, b uint8) {
light.Color = hex.EncodeToString([]byte{r, g, b})
} }
// String prints the color as HSB.
func (color *LightColor) String() string {
return fmt.Sprintf("%d,%d,%d", color.Hue, color.Sat, color.Bri)
// SetColorRGBf sets the color with an RGB value as floats between `0.0` and `1.0`.
func (light *Light) SetColorRGBf(r, g, b float64) {
light.SetColorRGB(uint8(r*255), uint8(g*255), uint8(b*255))
} }
// Equals returns true if all the light's values are the same.
func (color *LightColor) Equals(other LightColor) bool {
return color.Hue == other.Hue && color.Sat == other.Sat && color.Bri == other.Bri
}
// ColorRGB gets the RGB values.
func (light *Light) ColorRGB() (r, g, b uint8, err error) {
// Parse parses three comma separated number.
func (color *LightColor) Parse(src string) error {
split := strings.SplitN(src, ",", 3)
if len(split) < 3 {
return errors.New("H,S,V format is incomplete")
if light.Color == "" {
return 0, 0, 0, nil
} }
hue, err := strconv.ParseUint(split[0], 10, 16)
if err != nil {
return err
}
sat, err := strconv.ParseUint(split[1], 10, 16)
if err != nil {
return err
}
bri, err := strconv.ParseUint(split[2], 10, 16)
if err != nil {
return err
bytes, err := hex.DecodeString(light.Color)
if err != nil || len(bytes) != 3 {
return 0, 0, 0, ErrMalformedColor
} }
color.Hue = uint16(hue)
color.Sat = uint8(sat)
color.Bri = uint8(bri)
return bytes[0], bytes[1], bytes[2], nil
}
// Hue doesn't like 0 and 255 for Sat and Bri.
if color.Sat < 1 {
color.Sat = 1
} else if color.Sat > 254 {
color.Sat = 254
}
if color.Bri < 1 {
color.Bri = 1
} else if color.Bri > 254 {
color.Bri = 254
// ColorRGBf returns the float values of the RGB color.
func (light *Light) ColorRGBf() (r, g, b float64, err error) {
r8, g8, b8, err := light.ColorRGB()
if err != nil {
return 0, 0, 0, err
} }
return nil
return float64(r8) / 255, float64(g8) / 255, float64(b8) / 255, nil
} }
// LightRepository is an interface for all database operations // LightRepository is an interface for all database operations
@ -97,6 +62,7 @@ type LightRepository interface {
FindByInternalID(ctx context.Context, internalID string) (Light, error) FindByInternalID(ctx context.Context, internalID string) (Light, error)
List(ctx context.Context) ([]Light, error) List(ctx context.Context) ([]Light, error)
ListByBridge(ctx context.Context, bridge Bridge) ([]Light, error) ListByBridge(ctx context.Context, bridge Bridge) ([]Light, error)
ListByGroup(ctx context.Context, group Group) ([]Light, error)
Insert(ctx context.Context, light Light) (Light, error) Insert(ctx context.Context, light Light) (Light, error)
Update(ctx context.Context, light Light) error Update(ctx context.Context, light Light) error
Remove(ctx context.Context, light Light) error Remove(ctx context.Context, light Light) error

Loading…
Cancel
Save