diff --git a/cmd/lucifer-server/main.go b/cmd/lucifer-server/main.go index b1eb65e..a4490da 100644 --- a/cmd/lucifer-server/main.go +++ b/cmd/lucifer-server/main.go @@ -1,18 +1,24 @@ package main import ( + "context" "log" "net/http" + "git.aiterp.net/lucifer/lucifer/light" + "github.com/gorilla/mux" "git.aiterp.net/lucifer/lucifer/controllers" "git.aiterp.net/lucifer/lucifer/database/sqlite" "git.aiterp.net/lucifer/lucifer/internal/config" "git.aiterp.net/lucifer/lucifer/middlewares" + + _ "git.aiterp.net/lucifer/lucifer/light/hue" ) func main() { + // Setup conf, err := config.Load("./config.yaml", "/etc/lucifer/lucifer.yaml") if err != nil { log.Fatalln("Failed to load configuration:", err) @@ -23,12 +29,20 @@ func main() { log.Fatalln("Failed to set up database:", err) } + // Services + lightService := light.NewService(sqlite.BridgeRepository, sqlite.LightRepository) + + // Controllers userController := controllers.NewUserController(sqlite.UserRepository, sqlite.SessionRepository) + // Router router := mux.NewRouter() router.Use(middlewares.Session(sqlite.SessionRepository)) - userController.Mount(router, "/api/user/") + // Background Tasks + go lightService.SyncLoop(context.TODO()) + + // TODO: Listen in another goroutine and have SIGINT/SIGTERM handlers with graceful shutdown. http.ListenAndServe(conf.Server.Address, router) } diff --git a/database/sqlite/bridge-repository.go b/database/sqlite/bridge-repository.go new file mode 100644 index 0000000..3f6dc36 --- /dev/null +++ b/database/sqlite/bridge-repository.go @@ -0,0 +1,58 @@ +package sqlite + +import ( + "context" + + "git.aiterp.net/lucifer/lucifer/models" +) + +type bridgeRepository struct{} + +// BridgeRepository is a sqlite datbase repository for the Bridge model. +var BridgeRepository = &bridgeRepository{} + +func (b *bridgeRepository) FindByID(ctx context.Context, id int) (models.Bridge, error) { + bridge := models.Bridge{} + err := db.GetContext(ctx, &bridge, "SELECT * FROM bridge WHERE id=?", id) + + return bridge, err +} + +func (b *bridgeRepository) List(ctx context.Context) ([]models.Bridge, error) { + bridges := make([]models.Bridge, 0, 64) + err := db.SelectContext(ctx, &bridges, "SELECT * FROM bridge") + + return bridges, err +} + +func (b *bridgeRepository) ListByDriver(ctx context.Context, driver string) ([]models.Bridge, error) { + bridges := make([]models.Bridge, 0, 64) + err := db.SelectContext(ctx, &bridges, "SELECT * FROM bridge WHERE driver=?", driver) + + return bridges, err +} + +func (b *bridgeRepository) Insert(ctx context.Context, bridge models.Bridge) (models.Bridge, error) { + res, err := db.NamedExecContext(ctx, "INSERT INTO bridge (name, internal_id, driver, addr, key) VALUES(:name, :internal_id, :driver, :addr, :key)", bridge) + if err != nil { + return models.Bridge{}, err + } + + id, err := res.LastInsertId() + if err != nil { + return models.Bridge{}, err + } + + bridge.ID = int(id) + return bridge, nil +} + +func (b *bridgeRepository) Update(ctx context.Context, bridge models.Bridge) error { + _, err := db.NamedExecContext(ctx, "UPDATE bridge SET name=:name AND addr=:addr AND key=:key WHERE id=:id", bridge) + return err +} + +func (b *bridgeRepository) Remove(ctx context.Context, bridge models.Bridge) error { + _, err := db.NamedExecContext(ctx, "DELETE FROM bridge WHERE id=:id LIMIT 1", bridge) + return err +} diff --git a/database/sqlite/init.go b/database/sqlite/init.go index f2981ce..ff200ed 100644 --- a/database/sqlite/init.go +++ b/database/sqlite/init.go @@ -57,4 +57,21 @@ CREATE TABLE IF NOT EXISTS "session" ( expire_date DATE NOT NULL ); +CREATE TABLE IF NOT EXISTS "bridge" ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + internal_id VARCHAR(255) NOT NULL, + name VARCHAR(255) NOT NULL, + driver VARCHAR(255) NOT NULL, + addr VARCHAR(255) NOT NULL, + key BLOB NOT NULL +); + +CREATE TABLE IF NOT EXISTS "light" ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + bridge_id INTEGER NOT NULL, + internal_id VARCHAR(255) NOT NULL, + name VARCHAR(255) NOT NULL, + 'on' BOOLEAN NOT NULL, + color VARCHAR(255) NOT NULL +) ` diff --git a/database/sqlite/light-repository.go b/database/sqlite/light-repository.go new file mode 100644 index 0000000..831bd18 --- /dev/null +++ b/database/sqlite/light-repository.go @@ -0,0 +1,146 @@ +package sqlite + +import ( + "context" + + "git.aiterp.net/lucifer/lucifer/models" +) + +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 + } + + return light, nil +} + +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 + } + + return light, nil +} + +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) + } + + return lights, nil +} + +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 + } + + lights = append(lights, light) + } + + return lights, nil +} + +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) + if err != nil { + return models.Light{}, err + } + + id, err := res.LastInsertId() + if err != nil { + return models.Light{}, err + } + + light.ID = int(id) + return light, nil +} + +func (r *lightRepository) Update(ctx context.Context, light models.Light) error { + panic("not implemented") +} + +func (r *lightRepository) Remove(ctx context.Context, light models.Light) error { + panic("not implemented") +} diff --git a/go.mod b/go.mod index e053839..be32d0b 100644 --- a/go.mod +++ b/go.mod @@ -1,11 +1,15 @@ 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 github.com/mattn/go-sqlite3 v1.10.0 golang.org/x/crypto v0.0.0-20190123085648-057139ce5d2b + golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4 google.golang.org/appengine v1.4.0 // indirect gopkg.in/yaml.v2 v2.2.2 ) diff --git a/go.sum b/go.sum index a73ce62..45c001c 100644 --- a/go.sum +++ b/go.sum @@ -1,3 +1,9 @@ +github.com/collinux/GoHue v0.0.0-20181229002551-d259041d5eb8 h1:NOPuu1sMqBVC3iylE/C0KL90GQNLxc5UXnUCP1fs3Ds= +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= @@ -15,6 +21,8 @@ github.com/mattn/go-sqlite3 v1.10.0/go.mod h1:FPy6KqzDD04eiIsT53CuJW3U88zkxoIYsO golang.org/x/crypto v0.0.0-20190123085648-057139ce5d2b h1:Elez2XeF2p9uyVj0yEUDqQ56NFcDtcBNkYP7yv8YbUE= golang.org/x/crypto v0.0.0-20190123085648-057139ce5d2b/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4 h1:YUO/7uOKsKeq9UokNS62b8FYywz3ker1l1vDZRCRefw= +golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= google.golang.org/appengine v1.4.0 h1:/wp5JvzpHIxhs/dumFmF7BXTf3Z+dd4uXta4kVyO508= google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= diff --git a/light/driver.go b/light/driver.go new file mode 100644 index 0000000..e0a3a45 --- /dev/null +++ b/light/driver.go @@ -0,0 +1,29 @@ +package light + +import ( + "context" + + "git.aiterp.net/lucifer/lucifer/models" +) + +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(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) + + // DiscoverBridges lists all available bridges. + DiscoverBridges(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) +} + +// RegisterDriver registers a driver. This must happen in init() functions. +func RegisterDriver(name string, driver Driver) { + drivers[name] = driver +} diff --git a/light/hue/driver.go b/light/hue/driver.go new file mode 100644 index 0000000..ab7586f --- /dev/null +++ b/light/hue/driver.go @@ -0,0 +1,169 @@ +package hue + +import ( + "context" + "errors" + "log" + "sync" + "time" + + "git.aiterp.net/lucifer/lucifer/light" + "git.aiterp.net/lucifer/lucifer/models" + gohue "github.com/collinux/gohue" + "golang.org/x/sync/errgroup" +) + +// A driver is a driver for Phillips Hue lights. +type driver struct { + mutex sync.Mutex + bridges map[int]*gohue.Bridge +} + +func (d *driver) Apply(ctx context.Context, bridge models.Bridge, lights ...models.Light) error { + hueBridge, err := d.getBridge(bridge) + if err != nil { + return err + } + + hueLights, err := hueBridge.GetAllLights() + if err != nil { + return err + } + + eg, egCtx := errgroup.WithContext(ctx) + + for _, hueLight := range hueLights { + if !hueLight.State.Reachable { + continue + } + + for _, light := range lights { + if hueLight.UniqueID != light.InternalID { + continue + } + + // Prevent race condition since `hueLight` changes per iteration. + hl := hueLight + + eg.Go(func() error { + select { + case <-egCtx.Done(): + return egCtx.Err() + default: + } + + return hl.SetState(gohue.LightState{ + On: light.On, + Hue: light.Color.Hue, + Sat: light.Color.Sat, + Bri: light.Color.Bri, + }) + }) + + break + } + } + + return eg.Wait() +} + +func (d *driver) DiscoverLights(ctx context.Context, bridge models.Bridge) ([]models.Light, error) { + hueBridge, err := d.getBridge(bridge) + if err != nil { + return nil, err + } + + hueLights, err := hueBridge.GetAllLights() + if err != nil { + return nil, err + } + + lights := make([]models.Light, 0, len(hueLights)) + for _, hueLight := range hueLights { + lights = append(lights, 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, + }, + }) + } + + return lights, nil +} + +func (d *driver) DiscoverBridges(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) + } + + // Make 30 attempts (30 seconds) + attempts := 30 + for attempts > 0 { + key, err := hueBridge.CreateUser("Lucifer (git.aiterp.net/lucifer/lucifer)") + if len(key) > 0 && err == nil { + bridge.Key = []byte(key) + bridge.InternalID = hueBridge.Info.Device.SerialNumber + + return bridge, nil + } + + select { + case <-time.After(time.Second): + attempts-- + case <-ctx.Done(): + return models.Bridge{}, ctx.Err() + } + } + + return models.Bridge{}, errors.New("Failed to create bridge") +} + +func (d *driver) getBridge(bridge models.Bridge) (*gohue.Bridge, error) { + d.mutex.Lock() + defer d.mutex.Unlock() + + if hueBridge, ok := d.bridges[bridge.ID]; ok { + return hueBridge, nil + } + + hueBridge, err := gohue.NewBridge(bridge.Addr) + if err != nil { + return nil, err + } + + if err := hueBridge.GetInfo(); err != nil { + return nil, err + } + + if hueBridge.Info.Device.SerialNumber != bridge.InternalID { + return nil, errors.New("Serial number does not match hardware") + } + + err = hueBridge.Login(string(bridge.Key)) + if err != nil { + return nil, err + } + + d.bridges[bridge.ID] = hueBridge + + return hueBridge, nil +} + +func init() { + driver := &driver{ + bridges: make(map[int]*gohue.Bridge, 16), + } + + light.RegisterDriver("hue", driver) +} diff --git a/light/service.go b/light/service.go new file mode 100644 index 0000000..b86d44c --- /dev/null +++ b/light/service.go @@ -0,0 +1,144 @@ +package light + +import ( + "context" + "errors" + "log" + "time" + + "git.aiterp.net/lucifer/lucifer/models" +) + +// ErrUnknownDriver is returned by any function asking for a driver name if the driver specified doesn't exist. +var ErrUnknownDriver = errors.New("Unknown driver specified") + +// A Service wraps the repos for lights and bridges and takes care of the business logic. +type Service struct { + bridges models.BridgeRepository + lights models.LightRepository +} + +// DirectConnect connects to a bridge directly, without going through the discovery process to find them.. +func (s *Service) DirectConnect(ctx context.Context, driver string, addr string, name string) (models.Bridge, error) { + d, ok := drivers[driver] + if !ok { + return models.Bridge{}, ErrUnknownDriver + } + + bridge := models.Bridge{ + ID: -1, + Name: name, + Addr: addr, + Driver: driver, + } + + bridge, err := d.Connect(ctx, bridge) + if err != nil { + return models.Bridge{}, err + } + + bridge, err = s.bridges.Insert(ctx, bridge) + if err != nil { + return models.Bridge{}, err + } + + return bridge, nil +} + +// SyncLights syncs all lights in a bridge with the state in the database. +func (s *Service) SyncLights(ctx context.Context, bridge models.Bridge) error { + d, ok := drivers[bridge.Driver] + if !ok { + return ErrUnknownDriver + } + + bridgeLights, err := d.DiscoverLights(ctx, bridge) + if err != nil { + return err + } + + dbLights, err := s.lights.ListByBridge(ctx, bridge) + if err != nil { + return err + } + + // Sync with matching db light if it exists. + changedLights := make([]models.Light, 0, len(dbLights)) + +LightLoop: + for _, bridgeLight := range bridgeLights { + 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 + } + } + + // 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 len(changedLights) > 0 { + err := d.Apply(ctx, bridge, changedLights...) + if err != nil { + log.Printf("Failed to apply one or more of the changes on bridge %d (%s): %s", bridge.ID, bridge.Name, err) + } + } + + return nil +} + +// 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) + + for { + select { + case <-interval.C: + { + bridges, err := s.Bridges(context.Background()) + if err != nil { + log.Println("Could not get bridges:", err) + } + + for _, bridge := range bridges { + err = s.SyncLights(ctx, bridge) + if err != nil { + log.Println("Sync failed:", err) + } + } + } + case <-ctx.Done(): + { + log.Println("Sync loop stopped.") + return + } + } + } +} + +// Bridge gets a bridge by ID. +func (s *Service) Bridge(ctx context.Context, id int) (models.Bridge, error) { + return s.bridges.FindByID(ctx, id) +} + +// Bridges gets all known bridges. +func (s *Service) Bridges(ctx context.Context) ([]models.Bridge, error) { + return s.bridges.List(ctx) +} + +// NewService creates a new light.Service. +func NewService(bridges models.BridgeRepository, lights models.LightRepository) *Service { + return &Service{ + bridges: bridges, + lights: lights, + } +} diff --git a/models/bridge.go b/models/bridge.go new file mode 100644 index 0000000..9a3903f --- /dev/null +++ b/models/bridge.go @@ -0,0 +1,24 @@ +package models + +import "context" + +// A Bridge is a device or service that holds lights. +type Bridge struct { + ID int `json:"id" db:"id"` + Name string `json:"name" db:"name"` + Driver string `json:"driver" db:"driver"` + Addr string `json:"addr" db:"addr"` + InternalID string `json:"-" db:"internal_id"` + Key []byte `json:"-" db:"key"` +} + +// BridgeRepository is an interface for all database operations +// the Bridge model makes. +type BridgeRepository interface { + FindByID(ctx context.Context, id int) (Bridge, error) + List(ctx context.Context) ([]Bridge, error) + ListByDriver(ctx context.Context, driver string) ([]Bridge, error) + Insert(ctx context.Context, bridge Bridge) (Bridge, error) + Update(ctx context.Context, bridge Bridge) error + Remove(ctx context.Context, bridge Bridge) error +} diff --git a/models/light.go b/models/light.go new file mode 100644 index 0000000..e1d7a66 --- /dev/null +++ b/models/light.go @@ -0,0 +1,103 @@ +package models + +import ( + "context" + "errors" + "fmt" + "strconv" + "strings" + + goColor "github.com/gerow/go-color" +) + +// 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 +} + +// 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) +} + +// String prints the color as HSB. +func (color *LightColor) String() string { + return fmt.Sprintf("%d,%d,%d", color.Hue, color.Sat, color.Bri) +} + +// 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 +} + +// 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") + } + + 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 + } + + color.Hue = uint16(hue) + color.Sat = uint8(sat) + color.Bri = uint8(bri) + + // 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 + } + + return nil +} + +// LightRepository is an interface for all database operations +// the Light model makes. +type LightRepository interface { + FindByID(ctx context.Context, id int) (Light, error) + FindByInternalID(ctx context.Context, internalID string) (Light, error) + List(ctx context.Context) ([]Light, error) + ListByBridge(ctx context.Context, bridge Bridge) ([]Light, error) + Insert(ctx context.Context, light Light) (Light, error) + Update(ctx context.Context, light Light) error + Remove(ctx context.Context, light Light) error +}