Browse Source

Merge remote-tracking branch 'origin/master' into webui

webui
Stian Aune 5 years ago
parent
commit
aec6cd0b7d
  1. 16
      cmd/lucifer-server/main.go
  2. 58
      database/sqlite/bridge-repository.go
  3. 17
      database/sqlite/init.go
  4. 146
      database/sqlite/light-repository.go
  5. 4
      go.mod
  6. 8
      go.sum
  7. 29
      light/driver.go
  8. 169
      light/hue/driver.go
  9. 144
      light/service.go
  10. 24
      models/bridge.go
  11. 103
      models/light.go

16
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)
}

58
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
}

17
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
)
`

146
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")
}

4
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
)

8
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=

29
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
}

169
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)
}

144
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,
}
}

24
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
}

103
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
}
Loading…
Cancel
Save