From 8c302ecbbe91a65b8a11c7c5551f2881446914aa Mon Sep 17 00:00:00 2001 From: Gisle Aune Date: Mon, 4 Feb 2019 20:11:51 +0100 Subject: [PATCH] everything: Changing light colors works now. --- Dockerfile | 21 ++++ cmd/lucifer-server/main.go | 56 +++++++++ database/sqlite/group-repository.go | 78 +++++++++++++ database/sqlite/init.go | 46 +++++++- database/sqlite/light-repository.go | 112 +++--------------- database/sqlite/user-repository.go | 2 +- go.mod | 1 - go.sum | 2 - internal/huecolor/convert.go | 82 +++++++++++++ internal/huecolor/convert_test.go | 16 +++ internal/huecolor/gamut.go | 86 ++++++++++++++ internal/huecolor/gamut_test.go | 17 +++ light/driver.go | 16 ++- light/hue/driver.go | 175 +++++++++++++++++++++++++--- light/service.go | 60 +++++++--- models/group.go | 35 ++++++ models/light.go | 102 ++++++---------- 17 files changed, 698 insertions(+), 209 deletions(-) create mode 100644 Dockerfile create mode 100644 database/sqlite/group-repository.go create mode 100644 internal/huecolor/convert.go create mode 100644 internal/huecolor/convert_test.go create mode 100644 internal/huecolor/gamut.go create mode 100644 internal/huecolor/gamut_test.go create mode 100644 models/group.go diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..d55f059 --- /dev/null +++ b/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"] \ No newline at end of file diff --git a/cmd/lucifer-server/main.go b/cmd/lucifer-server/main.go index a4490da..c8484b4 100644 --- a/cmd/lucifer-server/main.go +++ b/cmd/lucifer-server/main.go @@ -2,10 +2,16 @@ package main import ( "context" + "crypto/rand" + "database/sql" + "encoding/hex" + "fmt" "log" "net/http" + "time" "git.aiterp.net/lucifer/lucifer/light" + "git.aiterp.net/lucifer/lucifer/models" "github.com/gorilla/mux" @@ -29,6 +35,9 @@ func main() { log.Fatalln("Failed to set up database:", err) } + // Initialize + setupAdmin(sqlite.UserRepository, sqlite.GroupRepository) + // Services lightService := light.NewService(sqlite.BridgeRepository, sqlite.LightRepository) @@ -42,7 +51,54 @@ func main() { // Background Tasks go lightService.SyncLoop(context.TODO()) + //go lightService.DiscoverLoop(context.TODO()) // TODO: Listen in another goroutine and have SIGINT/SIGTERM handlers with graceful shutdown. 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, + }) +} diff --git a/database/sqlite/group-repository.go b/database/sqlite/group-repository.go new file mode 100644 index 0000000..5b6e1aa --- /dev/null +++ b/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; + +*/ diff --git a/database/sqlite/init.go b/database/sqlite/init.go index ff200ed..1949ae7 100644 --- a/database/sqlite/init.go +++ b/database/sqlite/init.go @@ -1,6 +1,7 @@ package sqlite import ( + "log" "strings" "github.com/jmoiron/sqlx" @@ -31,9 +32,16 @@ func Initialize(filename string) (err error) { } 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 { + log.Printf("Statement %d failed", i) + db.Close() db = nil return err @@ -70,8 +78,36 @@ CREATE TABLE IF NOT EXISTS "light" ( id INTEGER PRIMARY KEY AUTOINCREMENT, bridge_id INTEGER NOT NULL, internal_id VARCHAR(255) NOT NULL, + group_id INTEGER 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"); ` diff --git a/database/sqlite/light-repository.go b/database/sqlite/light-repository.go index 831bd18..e84a5e0 100644 --- a/database/sqlite/light-repository.go +++ b/database/sqlite/light-repository.go @@ -11,119 +11,43 @@ type lightRepository struct{} // LightRepository is a sqlite datbase repository for the Light model. 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) { - row := db.QueryRowxContext(ctx, "SELECT * FROM light WHERE id=?", id) - if err := row.Err(); err != nil { - return models.Light{}, err - } - 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) { - row := db.QueryRowxContext(ctx, "SELECT * FROM light WHERE internal_id=?", internalID) - if err := row.Err(); err != nil { - return models.Light{}, err - } - 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) { - 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) - 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) { - 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) - 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) { - 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 { 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 { - 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 { - panic("not implemented") + _, err := db.NamedExecContext(ctx, "DELETE FROM light WHERE id=:id", light) + return err } diff --git a/database/sqlite/user-repository.go b/database/sqlite/user-repository.go index 211b044..46bf5ee 100644 --- a/database/sqlite/user-repository.go +++ b/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 { - _, 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 { return err } diff --git a/go.mod b/go.mod index be32d0b..125f108 100644 --- a/go.mod +++ b/go.mod @@ -3,7 +3,6 @@ module git.aiterp.net/lucifer/lucifer require ( github.com/collinux/GoHue v0.0.0-20181229002551-d259041d5eb8 // indirect 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/mux v1.6.2 github.com/jmoiron/sqlx v1.2.0 diff --git a/go.sum b/go.sum index 45c001c..8207f6b 100644 --- a/go.sum +++ b/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 h1:Qhd31xZ6GUL0nEaXYP4nXOn8J6l9jqa6xEyp70qfjZE= 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/go.mod h1:zAC/RDZ24gD3HViQzih4MyKcchzm+sOG5ZlKdlhCg5w= github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= diff --git a/internal/huecolor/convert.go b/internal/huecolor/convert.go new file mode 100644 index 0000000..e15afca --- /dev/null +++ b/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 +} diff --git a/internal/huecolor/convert_test.go b/internal/huecolor/convert_test.go new file mode 100644 index 0000000..c4e6a1b --- /dev/null +++ b/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() + } + } +} diff --git a/internal/huecolor/gamut.go b/internal/huecolor/gamut.go new file mode 100644 index 0000000..36afa41 --- /dev/null +++ b/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]) +} diff --git a/internal/huecolor/gamut_test.go b/internal/huecolor/gamut_test.go new file mode 100644 index 0000000..8cda2a0 --- /dev/null +++ b/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) + } +} diff --git a/light/driver.go b/light/driver.go index e0a3a45..9df45d9 100644 --- a/light/driver.go +++ b/light/driver.go @@ -10,17 +10,23 @@ var drivers = make(map[string]Driver) // A Driver that communicates with an underlying lighting system. 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 - // 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(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. diff --git a/light/hue/driver.go b/light/hue/driver.go index ab7586f..f8eba9d 100644 --- a/light/hue/driver.go +++ b/light/hue/driver.go @@ -2,21 +2,40 @@ package hue import ( "context" + "encoding/json" "errors" + "fmt" "log" "sync" "time" + "git.aiterp.net/lucifer/lucifer/internal/huecolor" + "git.aiterp.net/lucifer/lucifer/light" "git.aiterp.net/lucifer/lucifer/models" gohue "github.com/collinux/gohue" "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. type driver struct { mutex sync.Mutex bridges map[int]*gohue.Bridge + colors map[string]xyBri } 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 } - eg, egCtx := errgroup.WithContext(ctx) + eg, _ := errgroup.WithContext(ctx) for _, hueLight := range hueLights { if !hueLight.State.Reachable { @@ -46,18 +65,38 @@ func (d *driver) Apply(ctx context.Context, bridge models.Bridge, lights ...mode hl := hueLight 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, - 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 @@ -67,7 +106,16 @@ func (d *driver) Apply(ctx context.Context, bridge models.Bridge, lights ...mode 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) if err != nil { 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)) 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, Name: hueLight.Name, BridgeID: bridge.ID, InternalID: hueLight.UniqueID, 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 } -func (d *driver) DiscoverBridges(ctx context.Context) ([]models.Bridge, error) { +func (d *driver) Bridges(ctx context.Context) ([]models.Bridge, error) { panic("not implemented") } func (d *driver) Connect(ctx context.Context, bridge models.Bridge) (models.Bridge, error) { hueBridge, err := gohue.NewBridge(bridge.Addr) if err != nil { - log.Fatalln(err) + return models.Bridge{}, err } // 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") } +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) { d.mutex.Lock() defer d.mutex.Unlock() @@ -160,9 +287,19 @@ func (d *driver) getBridge(bridge models.Bridge) (*gohue.Bridge, error) { return hueBridge, nil } +func diff(a, b float32) float32 { + diff := a - b + if diff < 0 { + return -diff + } + + return diff +} + func init() { driver := &driver{ bridges: make(map[int]*gohue.Bridge, 16), + colors: make(map[string]xyBri, 128), } light.RegisterDriver("hue", driver) diff --git a/light/service.go b/light/service.go index b86d44c..664757b 100644 --- a/light/service.go +++ b/light/service.go @@ -52,7 +52,7 @@ func (s *Service) SyncLights(ctx context.Context, bridge models.Bridge) error { return ErrUnknownDriver } - bridgeLights, err := d.DiscoverLights(ctx, bridge) + bridgeLights, err := d.Lights(ctx, bridge) if err != nil { return err } @@ -62,29 +62,31 @@ func (s *Service) SyncLights(ctx context.Context, bridge models.Bridge) error { 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 { + found := false for _, dbLight := range dbLights { 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. - 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 { err := d.Apply(ctx, bridge, changedLights...) if err != nil { @@ -98,7 +100,7 @@ LightLoop: // SyncLoop runs synclight on all bridges twice every second until the context is // done. func (s *Service) SyncLoop(ctx context.Context) { - interval := time.NewTicker(time.Second / 2) + interval := time.NewTicker(time.Millisecond * 2500) for { 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. func (s *Service) Bridge(ctx context.Context, id int) (models.Bridge, error) { return s.bridges.FindByID(ctx, id) diff --git a/models/group.go b/models/group.go new file mode 100644 index 0000000..53c7b34 --- /dev/null +++ b/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 +} diff --git a/models/light.go b/models/light.go index e1d7a66..fa211ca 100644 --- a/models/light.go +++ b/models/light.go @@ -2,92 +2,57 @@ package models import ( "context" + "encoding/hex" "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. 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 @@ -97,6 +62,7 @@ type LightRepository interface { FindByInternalID(ctx context.Context, internalID string) (Light, error) List(ctx context.Context) ([]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) Update(ctx context.Context, light Light) error Remove(ctx context.Context, light Light) error