19 changed files with 708 additions and 50 deletions
			
			
		- 
					25app/api/bridges.go
- 
					123app/api/devices.go
- 
					9app/config/driver.go
- 
					2app/config/repo.go
- 
					4app/server.go
- 
					34app/services/bridges.go
- 
					12app/services/events.go
- 
					60app/services/publish.go
- 
					95app/services/synclights.go
- 
					8cmd/goose/main.go
- 
					7go.mod
- 
					17go.sum
- 
					8internal/mysql/bridgerepo.go
- 
					313internal/mysql/devicerepo.go
- 
					17models/device.go
- 
					1models/shared.go
- 
					5scripts/20210522140146_device.sql
- 
					2scripts/20210522140148_device_state.sql
- 
					16scripts/20210918105052_device_tag.sql
| @ -0,0 +1,123 @@ | |||
| package api | |||
| 
 | |||
| import ( | |||
| 	"context" | |||
| 	"git.aiterp.net/lucifer/new-server/app/config" | |||
| 	"git.aiterp.net/lucifer/new-server/models" | |||
| 	"github.com/gin-gonic/gin" | |||
| 	"log" | |||
| 	"strings" | |||
| ) | |||
| 
 | |||
| func fetchDevices(ctx context.Context, fetchStr string) ([]models.Device, error) { | |||
| 	if strings.HasPrefix(fetchStr, "tag:") { | |||
| 		return config.DeviceRepository().FetchByReference(ctx, models.RKTag, fetchStr[4:]) | |||
| 	} else if strings.HasPrefix(fetchStr, "bridge:") { | |||
| 		return config.DeviceRepository().FetchByReference(ctx, models.RKBridgeID, fetchStr[7:]) | |||
| 	} else if fetchStr == "all" { | |||
| 		return config.DeviceRepository().FetchByReference(ctx, models.RKAll, "") | |||
| 	} else { | |||
| 		return config.DeviceRepository().FetchByReference(ctx, models.RKDeviceID, fetchStr) | |||
| 	} | |||
| } | |||
| 
 | |||
| func Devices(r gin.IRoutes) { | |||
| 	r.GET("", handler(func(c *gin.Context) (interface{}, error) { | |||
| 		return config.DeviceRepository().FetchByReference(ctxOf(c), models.RKAll, "") | |||
| 	})) | |||
| 
 | |||
| 	r.GET("/:fetch", handler(func(c *gin.Context) (interface{}, error) { | |||
| 		return fetchDevices(ctxOf(c), c.Param("fetch")) | |||
| 	})) | |||
| 
 | |||
| 	r.PUT("/:fetch/state", handler(func(c *gin.Context) (interface{}, error) { | |||
| 		state := models.NewDeviceState{} | |||
| 		err := parseBody(c, &state) | |||
| 		if err != nil { | |||
| 			return nil, err | |||
| 		} | |||
| 
 | |||
| 		devices, err := fetchDevices(ctxOf(c), c.Param("fetch")) | |||
| 		if err != nil { | |||
| 			return nil, err | |||
| 		} | |||
| 		if len(devices) == 0 { | |||
| 			return []models.Device{}, nil | |||
| 		} | |||
| 
 | |||
| 		for i := range devices { | |||
| 			err := devices[i].SetState(state) | |||
| 			if err != nil { | |||
| 				return nil, err | |||
| 			} | |||
| 		} | |||
| 
 | |||
| 		config.PublishChannel <- devices | |||
| 
 | |||
| 		go func() { | |||
| 			for _, device := range devices { | |||
| 				err := config.DeviceRepository().Save(context.Background(), &device) | |||
| 				if err != nil { | |||
| 					log.Println("Failed to save device for state:", err) | |||
| 					continue | |||
| 				} | |||
| 			} | |||
| 		}() | |||
| 
 | |||
| 		return devices, nil | |||
| 	})) | |||
| 
 | |||
| 	r.PUT("/:fetch/tags", handler(func(c *gin.Context) (interface{}, error) { | |||
| 		var body struct { | |||
| 			Add    []string `json:"add"` | |||
| 			Remove []string `json:"remove"` | |||
| 		} | |||
| 		err := parseBody(c, &body) | |||
| 		if err != nil { | |||
| 			return nil, err | |||
| 		} | |||
| 
 | |||
| 		devices, err := fetchDevices(ctxOf(c), c.Param("fetch")) | |||
| 		if err != nil { | |||
| 			return nil, err | |||
| 		} | |||
| 		if len(devices) == 0 { | |||
| 			return []models.Device{}, nil | |||
| 		} | |||
| 
 | |||
| 		for i := range devices { | |||
| 			device := &devices[i] | |||
| 
 | |||
| 			for _, tag := range body.Add { | |||
| 				found := false | |||
| 				for _, tag2 := range device.Tags { | |||
| 					if tag == tag2 { | |||
| 						found = true | |||
| 						break | |||
| 					} | |||
| 				} | |||
| 
 | |||
| 				if !found { | |||
| 					device.Tags = append(device.Tags, tag) | |||
| 				} | |||
| 			} | |||
| 			for _, tag := range body.Remove { | |||
| 				index := -1 | |||
| 				for i, tag2 := range device.Tags { | |||
| 					if tag == tag2 { | |||
| 						index = i | |||
| 					} | |||
| 				} | |||
| 
 | |||
| 				device.Tags = append(device.Tags[:index], device.Tags[index+1:]...) | |||
| 			} | |||
| 
 | |||
| 			err = config.DeviceRepository().Save(ctxOf(c), device) | |||
| 			if err != nil { | |||
| 				return nil, err | |||
| 			} | |||
| 		} | |||
| 
 | |||
| 		return devices, nil | |||
| 	})) | |||
| } | |||
| @ -0,0 +1,95 @@ | |||
| package services | |||
| 
 | |||
| import ( | |||
| 	"context" | |||
| 	"git.aiterp.net/lucifer/new-server/app/config" | |||
| 	"git.aiterp.net/lucifer/new-server/models" | |||
| 	"log" | |||
| 	"strconv" | |||
| 	"time" | |||
| ) | |||
| 
 | |||
| func CheckNewDevices() { | |||
| 	go func() { | |||
| 		// Wait a bit before the first to let bridges connect.
 | |||
| 		time.Sleep(time.Second * 5) | |||
| 		err := checkNewDevices() | |||
| 		if err != nil { | |||
| 			log.Println("Failed to sync lights:", err) | |||
| 		} | |||
| 
 | |||
| 		for range time.NewTicker(time.Second * 30).C { | |||
| 			err := checkNewDevices() | |||
| 			if err != nil { | |||
| 				log.Println("Failed to sync lights:", err) | |||
| 			} | |||
| 		} | |||
| 	}() | |||
| } | |||
| 
 | |||
| func checkNewDevices() error { | |||
| 	ctx, cancel := context.WithTimeout(context.Background(), time.Second*27) | |||
| 	defer cancel() | |||
| 
 | |||
| 	bridges, err := config.BridgeRepository().FetchAll(ctx) | |||
| 	if err != nil { | |||
| 		return err | |||
| 	} | |||
| 
 | |||
| 	for _, bridge := range bridges { | |||
| 		driver, err := config.DriverProvider().Provide(bridge.Driver) | |||
| 		if err != nil { | |||
| 			log.Println("Unknown/unsupported driver:", bridge.Driver) | |||
| 			continue | |||
| 		} | |||
| 
 | |||
| 		savedDevices, err := config.DeviceRepository().FetchByReference(ctx, models.RKBridgeID, strconv.Itoa(bridge.ID)) | |||
| 		if err != nil { | |||
| 			log.Println("Failed to list devices from db:", err) | |||
| 			continue | |||
| 		} | |||
| 
 | |||
| 		driverDevices, err := driver.ListDevices(ctx, bridge) | |||
| 		if err != nil { | |||
| 			log.Println("Failed to list devices from driver:", err) | |||
| 			continue | |||
| 		} | |||
| 
 | |||
| 		foundNewDevices := false | |||
| 	SaveLoop: | |||
| 		for _, driverDevice := range driverDevices { | |||
| 			for _, savedDevice := range savedDevices { | |||
| 				if savedDevice.InternalID == driverDevice.InternalID { | |||
| 					continue SaveLoop | |||
| 				} | |||
| 			} | |||
| 
 | |||
| 			log.Println("Saving new device", driverDevice.InternalID) | |||
| 
 | |||
| 			err := config.DeviceRepository().Save(ctx, &driverDevice) | |||
| 			if err != nil { | |||
| 				log.Println("Failed to save device:", err) | |||
| 				continue | |||
| 			} | |||
| 
 | |||
| 			foundNewDevices = true | |||
| 		} | |||
| 
 | |||
| 		// If new devices were found, publish them so that the driver can be set up.
 | |||
| 		if foundNewDevices { | |||
| 			savedDevices, err := config.DeviceRepository().FetchByReference(ctx, models.RKBridgeID, strconv.Itoa(bridge.ID)) | |||
| 			if err != nil { | |||
| 				log.Println("Failed to fetch devices from db second time:", err) | |||
| 				continue | |||
| 			} | |||
| 
 | |||
| 			err = driver.Publish(ctx, bridge, savedDevices) | |||
| 			if err != nil { | |||
| 				log.Println("Failed to list devices from db:", err) | |||
| 				continue | |||
| 			} | |||
| 		} | |||
| 	} | |||
| 
 | |||
| 	return nil | |||
| } | |||
| @ -0,0 +1,313 @@ | |||
| package mysql | |||
| 
 | |||
| import ( | |||
| 	"context" | |||
| 	"encoding/json" | |||
| 	"git.aiterp.net/lucifer/new-server/models" | |||
| 	sq "github.com/Masterminds/squirrel" | |||
| 	"github.com/jmoiron/sqlx" | |||
| 	"log" | |||
| 	"strings" | |||
| ) | |||
| 
 | |||
| type deviceRecord struct { | |||
| 	ID           int    `db:"id"` | |||
| 	BridgeID     int    `db:"bridge_id"` | |||
| 	InternalID   string `db:"internal_id"` | |||
| 	Icon         string `db:"icon"` | |||
| 	Name         string `db:"name"` | |||
| 	Capabilities string `db:"capabilities"` | |||
| 	ButtonNames  string `db:"button_names"` | |||
| } | |||
| 
 | |||
| type deviceStateRecord struct { | |||
| 	DeviceID   int     `db:"device_id"` | |||
| 	Hue        float64 `db:"hue"` | |||
| 	Saturation float64 `db:"saturation"` | |||
| 	Kelvin     int     `db:"kelvin"` | |||
| 	Power      bool    `db:"power"` | |||
| 	Intensity  float64 `db:"intensity"` | |||
| } | |||
| 
 | |||
| type devicePropertyRecord struct { | |||
| 	DeviceID int    `db:"device_id"` | |||
| 	Key      string `db:"prop_key"` | |||
| 	Value    string `db:"prop_value"` | |||
| 	IsUser   bool   `db:"is_user"` | |||
| } | |||
| 
 | |||
| type deviceTagRecord struct { | |||
| 	DeviceID int    `db:"device_id"` | |||
| 	TagName  string `db:"tag_name"` | |||
| } | |||
| 
 | |||
| type DeviceRepo struct { | |||
| 	DBX *sqlx.DB | |||
| } | |||
| 
 | |||
| func (r *DeviceRepo) Find(ctx context.Context, id int) (*models.Device, error) { | |||
| 	var device deviceRecord | |||
| 	err := r.DBX.GetContext(ctx, &device, "SELECT * FROM device WHERE id = ?", id) | |||
| 	if err != nil { | |||
| 		return nil, dbErr(err) | |||
| 	} | |||
| 
 | |||
| 	return r.populateOne(ctx, device) | |||
| } | |||
| 
 | |||
| func (r *DeviceRepo) FetchByReference(ctx context.Context, kind models.ReferenceKind, value string) ([]models.Device, error) { | |||
| 	var err error | |||
| 	records := make([]deviceRecord, 0, 8) | |||
| 
 | |||
| 	switch kind { | |||
| 	case models.RKDeviceID: | |||
| 		err = r.DBX.SelectContext(ctx, &records, "SELECT * FROM device WHERE id=?", value) | |||
| 	case models.RKBridgeID: | |||
| 		err = r.DBX.SelectContext(ctx, &records, "SELECT * FROM device WHERE bridge_id=?", value) | |||
| 	case models.RKTag: | |||
| 		err = r.DBX.SelectContext(ctx, &records, "SELECT device.* FROM device JOIN device_tag dt ON device.id = dt.device_id WHERE dt.tag_name=?", value) | |||
| 	case models.RKAll: | |||
| 		err = r.DBX.SelectContext(ctx, &records, "SELECT device.* FROM device") | |||
| 	default: | |||
| 		log.Println("Unknown reference kind used for device fetch:", kind) | |||
| 		return []models.Device{}, nil | |||
| 	} | |||
| 	if err != nil { | |||
| 		return nil, dbErr(err) | |||
| 	} | |||
| 
 | |||
| 	return r.populate(ctx, records) | |||
| } | |||
| 
 | |||
| func (r *DeviceRepo) Save(ctx context.Context, device *models.Device) error { | |||
| 	tx, err := r.DBX.Beginx() | |||
| 	if err != nil { | |||
| 		return dbErr(err) | |||
| 	} | |||
| 	defer tx.Rollback() | |||
| 
 | |||
| 	record := deviceRecord{ | |||
| 		ID:           device.ID, | |||
| 		BridgeID:     device.BridgeID, | |||
| 		InternalID:   device.InternalID, | |||
| 		Icon:         device.Icon, | |||
| 		Name:         device.Name, | |||
| 		Capabilities: strings.Join(models.DeviceCapabilitiesToStrings(device.Capabilities), ","), | |||
| 		ButtonNames:  strings.Join(device.ButtonNames, ","), | |||
| 	} | |||
| 
 | |||
| 	if device.ID > 0 { | |||
| 		_, err := tx.NamedExecContext(ctx, ` | |||
| 			UPDATE device SET | |||
| 				internal_id = :internal_id, | |||
| 				icon = :icon, | |||
| 				name = :name, | |||
| 				capabilities = :capabilities, | |||
| 				button_names = :button_names | |||
| 			WHERE id=:id | |||
| 		`, record) | |||
| 		if err != nil { | |||
| 			return dbErr(err) | |||
| 		} | |||
| 
 | |||
| 		// Let's just be lazy for now, optimize later if need be.
 | |||
| 		_, err = tx.ExecContext(ctx, "DELETE FROM device_tag WHERE device_id=?", record.ID) | |||
| 		if err != nil { | |||
| 			return dbErr(err) | |||
| 		} | |||
| 		_, err = tx.ExecContext(ctx, "DELETE FROM device_property WHERE device_id=?", record.ID) | |||
| 		if err != nil { | |||
| 			return dbErr(err) | |||
| 		} | |||
| 	} else { | |||
| 		res, err := tx.NamedExecContext(ctx, ` | |||
| 			INSERT INTO device (bridge_id, internal_id, icon, name, capabilities, button_names) | |||
| 			VALUES (:bridge_id, :internal_id, :icon, :name, :capabilities, :button_names) | |||
| 		`, record) | |||
| 		if err != nil { | |||
| 			return dbErr(err) | |||
| 		} | |||
| 
 | |||
| 		lastID, err := res.LastInsertId() | |||
| 		if err != nil { | |||
| 			return dbErr(err) | |||
| 		} | |||
| 
 | |||
| 		record.ID = int(lastID) | |||
| 		device.ID = int(lastID) | |||
| 	} | |||
| 
 | |||
| 	for _, tag := range device.Tags { | |||
| 		_, err := tx.ExecContext(ctx, "INSERT INTO device_tag (device_id, tag_name) VALUES (?, ?)", record.ID, tag) | |||
| 		if err != nil { | |||
| 			return dbErr(err) | |||
| 		} | |||
| 	} | |||
| 
 | |||
| 	for key, value := range device.UserProperties { | |||
| 		_, err := tx.ExecContext(ctx, "INSERT INTO device_property (device_id, prop_key, prop_value, is_user) VALUES (?, ?, ?, 1)", | |||
| 			record.ID, key, value, | |||
| 		) | |||
| 		if err != nil { | |||
| 			return dbErr(err) | |||
| 		} | |||
| 	} | |||
| 
 | |||
| 	for key, value := range device.DriverProperties { | |||
| 		j, err := json.Marshal(value) | |||
| 		if err != nil { | |||
| 			// Eh, it'll get filled by the driver anyway
 | |||
| 			continue | |||
| 		} | |||
| 
 | |||
| 		_, err = tx.ExecContext(ctx, "INSERT INTO device_property (device_id, prop_key, prop_value, is_user) VALUES (?, ?, ?, 0)", | |||
| 			record.ID, key, string(j), | |||
| 		) | |||
| 		if err != nil { | |||
| 			// Return err here anyway, it might put the tx in a bad state to ignore it.
 | |||
| 			return dbErr(err) | |||
| 		} | |||
| 	} | |||
| 
 | |||
| 	_, err = tx.NamedExecContext(ctx, ` | |||
| 			REPLACE INTO device_state(device_id, hue, saturation, kelvin, power, intensity)  | |||
| 			VALUES (:device_id, :hue, :saturation, :kelvin, :power, :intensity) | |||
| 	`, deviceStateRecord{ | |||
| 		DeviceID:   record.ID, | |||
| 		Hue:        device.State.Color.Hue, | |||
| 		Saturation: device.State.Color.Saturation, | |||
| 		Kelvin:     device.State.Color.Kelvin, | |||
| 		Power:      device.State.Power, | |||
| 		Intensity:  device.State.Intensity, | |||
| 	}) | |||
| 	if err != nil { | |||
| 		return dbErr(err) | |||
| 	} | |||
| 
 | |||
| 	return tx.Commit() | |||
| } | |||
| 
 | |||
| func (r *DeviceRepo) Delete(ctx context.Context, device *models.Device) error { | |||
| 	_, err := r.DBX.ExecContext(ctx, "DELETE FROM device WHERE Id=?", device.ID) | |||
| 	if err != nil { | |||
| 		return dbErr(err) | |||
| 	} | |||
| 
 | |||
| 	return nil | |||
| } | |||
| 
 | |||
| func (r *DeviceRepo) populateOne(ctx context.Context, record deviceRecord) (*models.Device, error) { | |||
| 	records, err := r.populate(ctx, []deviceRecord{record}) | |||
| 	if err != nil { | |||
| 		return nil, err | |||
| 	} | |||
| 
 | |||
| 	return &records[0], nil | |||
| } | |||
| 
 | |||
| func (r *DeviceRepo) populate(ctx context.Context, records []deviceRecord) ([]models.Device, error) { | |||
| 	if len(records) == 0 { | |||
| 		return []models.Device{}, nil | |||
| 	} | |||
| 
 | |||
| 	ids := make([]int, 0, len(records)) | |||
| 	for _, record := range records { | |||
| 		ids = append(ids, record.ID) | |||
| 	} | |||
| 
 | |||
| 	tagsQuery, tagsArgs, err := sq.Select("*").From("device_tag").Where(sq.Eq{"device_id": ids}).ToSql() | |||
| 	if err != nil { | |||
| 		return nil, dbErr(err) | |||
| 	} | |||
| 	propsQuery, propsArgs, err := sq.Select("*").From("device_property").Where(sq.Eq{"device_id": ids}).ToSql() | |||
| 	if err != nil { | |||
| 		return nil, dbErr(err) | |||
| 	} | |||
| 	stateQuery, stateArgs, err := sq.Select("*").From("device_state").Where(sq.Eq{"device_id": ids}).ToSql() | |||
| 	if err != nil { | |||
| 		return nil, dbErr(err) | |||
| 	} | |||
| 
 | |||
| 	states := make([]deviceStateRecord, 0, len(records)) | |||
| 	props := make([]devicePropertyRecord, 0, len(records)*8) | |||
| 	tags := make([]deviceTagRecord, 0, len(records)*4) | |||
| 
 | |||
| 	err = r.DBX.SelectContext(ctx, &states, stateQuery, stateArgs...) | |||
| 	if err != nil { | |||
| 		return nil, dbErr(err) | |||
| 	} | |||
| 	err = r.DBX.SelectContext(ctx, &props, propsQuery, propsArgs...) | |||
| 	if err != nil { | |||
| 		return nil, dbErr(err) | |||
| 	} | |||
| 	err = r.DBX.SelectContext(ctx, &tags, tagsQuery, tagsArgs...) | |||
| 	if err != nil { | |||
| 		return nil, dbErr(err) | |||
| 	} | |||
| 
 | |||
| 	devices := make([]models.Device, 0, len(records)) | |||
| 	for _, record := range records { | |||
| 		device := models.Device{ | |||
| 			ID:               record.ID, | |||
| 			BridgeID:         record.BridgeID, | |||
| 			InternalID:       record.InternalID, | |||
| 			Icon:             record.Icon, | |||
| 			Name:             record.Name, | |||
| 			ButtonNames:      strings.Split(record.ButtonNames, ","), | |||
| 			DriverProperties: make(map[string]interface{}, 8), | |||
| 			UserProperties:   make(map[string]string, 8), | |||
| 			Tags:             make([]string, 0, 8), | |||
| 		} | |||
| 
 | |||
| 		caps := make([]models.DeviceCapability, 0, 16) | |||
| 		for _, capStr := range strings.Split(record.Capabilities, ",") { | |||
| 			caps = append(caps, models.DeviceCapability(capStr)) | |||
| 		} | |||
| 		device.Capabilities = caps | |||
| 
 | |||
| 		for _, state := range states { | |||
| 			if state.DeviceID == record.ID { | |||
| 				device.State = models.DeviceState{ | |||
| 					Power: state.Power, | |||
| 					Color: models.ColorValue{ | |||
| 						Hue:        state.Hue, | |||
| 						Saturation: state.Saturation, | |||
| 						Kelvin:     state.Kelvin, | |||
| 					}, | |||
| 					Intensity: state.Intensity, | |||
| 				} | |||
| 			} | |||
| 		} | |||
| 
 | |||
| 		driverProps := make(map[string]json.RawMessage, 8) | |||
| 		for _, prop := range props { | |||
| 			if prop.DeviceID == record.ID { | |||
| 				if prop.IsUser { | |||
| 					device.UserProperties[prop.Key] = prop.Value | |||
| 				} else { | |||
| 					driverProps[prop.Key] = json.RawMessage(prop.Value) | |||
| 				} | |||
| 			} | |||
| 		} | |||
| 		if len(driverProps) > 0 { | |||
| 			j, err := json.Marshal(driverProps) | |||
| 			if err != nil { | |||
| 				return nil, dbErr(err) | |||
| 			} | |||
| 			err = json.Unmarshal(j, &device.DriverProperties) | |||
| 			if err != nil { | |||
| 				return nil, dbErr(err) | |||
| 			} | |||
| 		} | |||
| 
 | |||
| 		for _, tag := range tags { | |||
| 			if tag.DeviceID == record.ID { | |||
| 				device.Tags = append(device.Tags, tag.TagName) | |||
| 			} | |||
| 		} | |||
| 
 | |||
| 		devices = append(devices, device) | |||
| 	} | |||
| 
 | |||
| 	return devices, nil | |||
| } | |||
| @ -0,0 +1,16 @@ | |||
| -- +goose Up | |||
| -- +goose StatementBegin | |||
| CREATE TABLE device_tag | |||
| ( | |||
|     device_id  INT          NOT NULL, | |||
|     tag_name   VARCHAR(255) NOT NULL, | |||
| 
 | |||
|     PRIMARY KEY (device_id, tag_name), | |||
|     INDEX (tag_name) | |||
| ); | |||
| -- +goose StatementEnd | |||
| 
 | |||
| -- +goose Down | |||
| -- +goose StatementBegin | |||
| DROP TABLE device_tag; | |||
| -- +goose StatementEnd | |||
						Write
						Preview
					
					
					Loading…
					
					Cancel
						Save
					
		Reference in new issue