Browse Source

Mill. Take 1.

feature-colorvalue2
Stian Fredrik Aune 3 years ago
parent
commit
88bd4dc073
  1. 2
      app/api/bridges.go
  2. 2
      app/config/driver.go
  3. 2
      cmd/bridgetest/main.go
  4. 14
      cmd/mill/main.go
  5. 2
      internal/drivers/hue/driver.go
  6. 2
      internal/drivers/lifx/driver.go
  7. 240
      internal/drivers/mill/bridge.go
  8. 100
      internal/drivers/mill/driver.go
  9. 77
      internal/drivers/mill/mill.go
  10. 2
      internal/drivers/nanoleaf/driver.go
  11. 7
      internal/mysql/devicerepo.go
  12. 1
      models/bridge.go
  13. 2
      models/device.go
  14. 2
      models/driver.go
  15. 1
      models/errors.go
  16. 2
      models/eventhandler.go
  17. 11
      scripts/20211106223238_device_state_temperature.sql

2
app/api/bridges.go

@ -52,7 +52,7 @@ func Bridges(r gin.IRoutes) {
return []models.Bridge{bridge}, nil
}
bridges, err := driver.SearchBridge(ctxOf(c), body.Address, body.DryRun)
bridges, err := driver.SearchBridge(ctxOf(c), body.Address, body.Token, body.DryRun)
if err != nil {
return nil, err
}

2
app/config/driver.go

@ -4,6 +4,7 @@ import (
"git.aiterp.net/lucifer/new-server/internal/drivers"
"git.aiterp.net/lucifer/new-server/internal/drivers/hue"
"git.aiterp.net/lucifer/new-server/internal/drivers/lifx"
"git.aiterp.net/lucifer/new-server/internal/drivers/mill"
"git.aiterp.net/lucifer/new-server/internal/drivers/nanoleaf"
"git.aiterp.net/lucifer/new-server/models"
"sync"
@ -18,6 +19,7 @@ func DriverProvider() models.DriverProvider {
models.DTNanoLeaf: &nanoleaf.Driver{},
models.DTHue: &hue.Driver{},
models.DTLIFX: &lifx.Driver{},
models.DTMill: &mill.Driver{},
}
})

2
cmd/bridgetest/main.go

@ -33,7 +33,7 @@ func main() {
}
// Find bridge
bridges, err := driver.SearchBridge(context.Background(), *flagAddress, !*flagPair)
bridges, err := driver.SearchBridge(context.Background(), *flagAddress, *flagToken, !*flagPair)
if err != nil {
log.Fatalln("Failed to search bridge:", err)
}

14
cmd/mill/main.go

@ -0,0 +1,14 @@
package main
import (
"crypto/rand"
"fmt"
"io"
)
func main() {
buf := make([]byte, 8)
_, _ = io.ReadFull(rand.Reader, buf)
fmt.Printf("%x", buf)
}

2
internal/drivers/hue/driver.go

@ -19,7 +19,7 @@ type Driver struct {
bridges []*Bridge
}
func (d *Driver) SearchBridge(ctx context.Context, address string, dryRun bool) ([]models.Bridge, error) {
func (d *Driver) SearchBridge(ctx context.Context, address, _ string, dryRun bool) ([]models.Bridge, error) {
if address == "" {
if !dryRun {
return nil, models.ErrAddressOnlyDryRunnable

2
internal/drivers/lifx/driver.go

@ -14,7 +14,7 @@ type Driver struct {
bridges []*Bridge
}
func (d *Driver) SearchBridge(ctx context.Context, address string, _ bool) ([]models.Bridge, error) {
func (d *Driver) SearchBridge(ctx context.Context, address, _ string, _ bool) ([]models.Bridge, error) {
if address == "" {
ifaces, err := net.Interfaces()
if err != nil {

240
internal/drivers/mill/bridge.go

@ -0,0 +1,240 @@
package mill
import (
"bytes"
"context"
"crypto/rand"
"crypto/sha1"
"encoding/json"
"fmt"
"git.aiterp.net/lucifer/new-server/models"
"io"
"log"
"net/http"
"strconv"
"sync"
"time"
)
type bridge struct {
mu sync.Mutex
luciferID int
username string
password string
token string
userId int
mustRefreshBy time.Time
luciferMillIDMap map[int]int
millLuciferIDMap map[int]int
}
func (b *bridge) listDevices(ctx context.Context) ([]models.Device, error) {
err := b.authenticate(ctx)
if err != nil {
return nil, err
}
var shlRes listHomeResBody
err = b.command(ctx, "selectHomeList", listHomeReqBody{}, &shlRes)
if err != nil {
return nil, err
}
devices := make([]millDevice, 0, 16)
for _, home := range shlRes.HomeList {
var gidRes listDeviceResBody
err = b.command(ctx, "getIndependentDevices", listDeviceReqBody{HomeID: home.HomeID}, &gidRes)
if err != nil {
return nil, err
}
devices = append(devices, gidRes.DeviceInfo...)
}
luciferDevices := make([]models.Device, len(devices), len(devices))
for i, device := range devices {
luciferDevices[i] = models.Device{
ID: b.millLuciferIDMap[device.DeviceID],
BridgeID: b.luciferID,
InternalID: fmt.Sprintf("%d", device.DeviceID),
Icon: "heater",
Name: device.DeviceName,
Capabilities: []models.DeviceCapability{models.DCTemperatureControl, models.DCPower},
ButtonNames: nil,
DriverProperties: map[string]interface{}{
"subDomain": fmt.Sprintf("%d", device.SubDomainID),
},
UserProperties: nil,
SceneAssignments: nil,
SceneState: nil,
State: models.DeviceState{
Power: device.PowerStatus > 0,
Temperature: device.HolidayTemp,
},
Tags: nil,
}
}
return luciferDevices, nil
}
func (b *bridge) pushStateChange(ctx context.Context, deviceModel models.Device) error {
b.mu.Lock()
if b.luciferMillIDMap == nil {
b.luciferMillIDMap = make(map[int]int, 4)
b.millLuciferIDMap = make(map[int]int, 4)
}
if b.luciferMillIDMap[deviceModel.ID] == 0 {
millID, _ := strconv.Atoi(deviceModel.InternalID)
b.luciferMillIDMap[deviceModel.ID] = millID
b.millLuciferIDMap[millID] = deviceModel.ID
}
b.mu.Unlock()
status := 0
if deviceModel.State.Power {
status = 1
}
powerReq := deviceControlReqBody{
SubDomain: deviceModel.DriverProperties["subDomain"].(string),
DeviceID: b.luciferMillIDMap[deviceModel.ID],
TestStatus: 1,
Status: status,
}
err := b.command(ctx, "deviceControl", powerReq, nil)
if err != nil {
return err
}
tempReq := changeInfoReqBody{
DeviceID: b.luciferMillIDMap[deviceModel.ID],
Value: deviceModel.State.Temperature,
TimeZoneNum: "+02:00",
Key: "holidayTemp",
}
err = b.command(ctx, "changeDeviceInfo", tempReq, nil)
if err != nil {
return err
}
return nil
}
func (b *bridge) command(ctx context.Context, command string, payload interface{}, target interface{}) error {
err := b.authenticate(ctx)
if err != nil {
return err
}
url := serviceEndpoint + command
method := "POST"
nonce := makeNonce()
timestamp := fmt.Sprintf("%d", time.Now().Unix())
timeout := "300"
h := sha1.New()
h.Write([]byte(timeout))
h.Write([]byte(timestamp))
h.Write([]byte(nonce))
h.Write([]byte(b.token))
signature := fmt.Sprintf("%x", h.Sum(nil))
body, err := json.Marshal(payload)
if err != nil {
return err
}
req, err := http.NewRequestWithContext(ctx, method, url, bytes.NewReader(body))
if err != nil {
return err
}
addDefaultHeaders(req)
req.Header.Add("X-Zc-Timestamp", timestamp)
req.Header.Add("X-Zc-Timeout", timeout)
req.Header.Add("X-Zc-Nonce", nonce)
req.Header.Add("X-Zc-User-Id", fmt.Sprintf("%d", b.userId))
req.Header.Add("X-Zc-User-Signature", signature)
req.Header.Add("X-Zc-Content-Length", fmt.Sprintf("%d", len(body)))
res, err := http.DefaultClient.Do(req)
if err != nil {
return models.ErrCannotForwardRequest
} else if res.StatusCode != 200 {
return models.ErrIncorrectToken
}
if target == nil {
return nil
}
err = json.NewDecoder(res.Body).Decode(&target)
if err != nil {
return models.ErrUnexpectedResponse
}
return nil
}
func (b *bridge) authenticate(ctx context.Context) error {
b.mu.Lock()
defer b.mu.Unlock()
if b.mustRefreshBy.Before(time.Now().Add(-1 * time.Minute)) {
body, err := json.Marshal(authReqBody{
Account: b.username,
Password: b.password,
})
if err != nil {
return models.ErrMissingToken
}
req, err := http.NewRequestWithContext(ctx, "POST", accountEndpoint + "login", bytes.NewReader(body))
if err != nil {
return models.ErrMissingToken
}
addDefaultHeaders(req)
res, err := http.DefaultClient.Do(req)
if err != nil {
return models.ErrCannotForwardRequest
} else if res.StatusCode != 200 {
return models.ErrIncorrectToken
}
var resBody authResBody
err = json.NewDecoder(res.Body).Decode(&resBody)
if err != nil {
return models.ErrBridgeSearchFailed
}
log.Printf("Mill: Authenticated as %s", resBody.NickName)
b.userId = resBody.UserID
b.token = resBody.Token
b.mustRefreshBy, err = time.ParseInLocation("2006-01-02 15:04:05", resBody.TokenExpire, location)
}
return nil
}
func makeNonce() string {
buf := make([]byte, 8)
_, _ = io.ReadFull(rand.Reader, buf)
return fmt.Sprintf("%x", buf)
}
func addDefaultHeaders(req *http.Request) {
req.Header.Add("Content-Type", "application/x-zc-object")
req.Header.Add("Connection", "Keep-Alive")
req.Header.Add("X-Zc-Major-Domain", "seanywell")
req.Header.Add("X-Zc-Msg-Name", "millService")
req.Header.Add("X-Zc-Sub-Domain", "milltype")
req.Header.Add("X-Zc-Seq-Id", "1")
req.Header.Add("X-Zc-Version", "1")
}

100
internal/drivers/mill/driver.go

@ -0,0 +1,100 @@
package mill
import (
"context"
"fmt"
"git.aiterp.net/lucifer/new-server/models"
"sync"
"time"
)
type Driver struct {
mu sync.Mutex
bridges map[int]*bridge
}
func (d *Driver) SearchBridge(ctx context.Context, address, token string, _ bool) ([]models.Bridge, error) {
bridgeData := models.Bridge{
Name: fmt.Sprintf("Mill account (%s)", address),
Driver: models.DTMill,
Address: address,
Token: token,
}
b, err := d.ensureBridge(bridgeData)
if err != nil {
return nil, err
}
err = b.authenticate(ctx)
if err != nil {
return nil, err
}
return []models.Bridge{bridgeData}, nil
}
func (d *Driver) SearchDevices(context.Context, models.Bridge, time.Duration) ([]models.Device, error) {
// You would have to configure devices with the Mill app, unfortunately.
return []models.Device{}, nil
}
func (d *Driver) ListDevices(ctx context.Context, bridge models.Bridge) ([]models.Device, error) {
b, err := d.ensureBridge(bridge)
if err != nil {
return nil, err
}
return b.listDevices(ctx)
}
func (d *Driver) Publish(ctx context.Context, bridge models.Bridge, devices []models.Device) error {
b, err := d.ensureBridge(bridge)
if err != nil {
return err
}
for _, device := range devices {
err = b.pushStateChange(ctx, device)
if err != nil {
return err
}
}
return nil
}
func (d *Driver) Run(ctx context.Context, _ models.Bridge, _ chan<- models.Event) error {
// TODO: Maybe do something with the thermostat on the device
for {
select {
case <-ctx.Done():
return ctx.Err()
}
}
}
func (d *Driver) ensureBridge(model models.Bridge) (*bridge, error) {
d.mu.Lock()
defer d.mu.Unlock()
if d.bridges == nil {
d.bridges = make(map[int]*bridge, 4)
}
if d.bridges[model.ID] == nil {
newBridge := &bridge{
luciferID: model.ID,
username: model.Address,
password: model.Token,
}
if model.ID <= 0 {
return newBridge, nil
}
d.bridges[model.ID] = newBridge
}
return d.bridges[model.ID], nil
}

77
internal/drivers/mill/mill.go

@ -0,0 +1,77 @@
package mill
import "time"
const accountEndpoint = "https://eurouter.ablecloud.cn:9005/zc-account/v1/"
const serviceEndpoint = "https://eurouter.ablecloud.cn:9005/millService/v1/"
type millHome struct {
HomeID int64 `json:"homeId"`
HomeName string `json:"homeName"`
}
type millDevice struct {
DeviceID int `json:"deviceId"`
DeviceName string `json:"deviceName"`
PowerStatus int `json:"powerStatus"`
HolidayTemp int `json:"holidayTemp"`
CurrentTemp float64 `json:"currentTemp"`
SubDomainID int `json:"subDomainId"`
}
type authReqBody struct {
Account string `json:"account"`
Password string `json:"password"`
}
type authResBody struct {
Token string `json:"token"`
UserID int `json:"userId"`
NickName string `json:"nickName"`
TokenExpire string `json:"tokenExpire"`
}
type listHomeReqBody struct{}
type listHomeResBody struct {
HomeList []millHome `json:"homeList"`
}
type listDeviceReqBody struct {
HomeID int64 `json:"homeId"`
}
type listDeviceResBody struct {
DeviceInfo []millDevice `json:"deviceInfo"`
}
type changeInfoReqBody struct {
HomeType int `json:"homeType"`
DeviceID int `json:"deviceId"`
Value int `json:"value"`
TimeZoneNum string `json:"timeZoneNum"`
Key string `json:"key"`
}
type deviceControlReqBody struct {
SubDomain string `json:"subDomain"`
DeviceID int `json:"deviceId"`
TestStatus int `json:"testStatus"`
Operation int `json:"operation"`
Status int `json:"status"`
WindStatus int `json:"windStatus"`
TempType int `json:"tempType"`
PowerLevel int `json:"powerLevel"`
}
var location *time.Location
func init() {
myLocation, err := time.LoadLocation("Europe/Oslo")
if err != nil {
panic(err.Error())
}
location = myLocation
}

2
internal/drivers/nanoleaf/driver.go

@ -17,7 +17,7 @@ type Driver struct {
// SearchBridge checks the bridge at the address. If it's not a dry-run, you must hold down the power button
// before calling this function and wait for the pattern.
func (d *Driver) SearchBridge(ctx context.Context, address string, dryRun bool) ([]models.Bridge, error) {
func (d *Driver) SearchBridge(ctx context.Context, address, _ string, dryRun bool) ([]models.Bridge, error) {
res, err := http.Get(fmt.Sprintf("http://%s/device_info", address))
if err != nil {
return nil, err

7
internal/mysql/devicerepo.go

@ -29,6 +29,7 @@ type deviceStateRecord struct {
Kelvin int `db:"kelvin"`
Power bool `db:"power"`
Intensity float64 `db:"intensity"`
Temperature int `db:"temperature"`
}
type devicePropertyRecord struct {
@ -227,8 +228,8 @@ func (r *DeviceRepo) SaveMany(ctx context.Context, mode models.SaveMode, devices
if mode == 0 || mode&models.SMState != 0 {
_, err = tx.NamedExecContext(ctx, `
REPLACE INTO device_state(device_id, hue, saturation, kelvin, power, intensity)
VALUES (:device_id, :hue, :saturation, :kelvin, :power, :intensity)
REPLACE INTO device_state(device_id, hue, saturation, kelvin, power, intensity, temperature)
VALUES (:device_id, :hue, :saturation, :kelvin, :power, :intensity, :temperature)
`, deviceStateRecord{
DeviceID: record.ID,
Hue: device.State.Color.Hue,
@ -236,6 +237,7 @@ func (r *DeviceRepo) SaveMany(ctx context.Context, mode models.SaveMode, devices
Kelvin: device.State.Color.Kelvin,
Power: device.State.Power,
Intensity: device.State.Intensity,
Temperature: device.State.Temperature,
})
if err != nil {
return dbErr(err)
@ -379,6 +381,7 @@ func (r *DeviceRepo) populate(ctx context.Context, records []deviceRecord) ([]mo
Kelvin: state.Kelvin,
},
Intensity: state.Intensity,
Temperature: state.Temperature,
}
}
}

1
models/bridge.go

@ -23,6 +23,7 @@ var (
DTHue DriverKind = "Hue"
DTNanoLeaf DriverKind = "Nanoleaf"
DTLIFX DriverKind = "LIFX"
DTMill DriverKind = "Mill"
)
var ValidDriverKinds = []DriverKind{

2
models/device.go

@ -37,7 +37,7 @@ type DeviceState struct {
Power bool `json:"power"`
Color ColorValue `json:"color,omitempty"`
Intensity float64 `json:"intensity,omitempty"`
Temperature float64 `json:"temperature"`
Temperature int `json:"temperature"`
}
type DeviceScene struct {

2
models/driver.go

@ -10,7 +10,7 @@ type DriverProvider interface {
}
type Driver interface {
SearchBridge(ctx context.Context, address string, dryRun bool) ([]Bridge, error)
SearchBridge(ctx context.Context, address, token string, dryRun bool) ([]Bridge, error)
SearchDevices(ctx context.Context, bridge Bridge, timeout time.Duration) ([]Device, error)
ListDevices(ctx context.Context, bridge Bridge) ([]Device, error)
Publish(ctx context.Context, bridge Bridge, devices []Device) error

1
models/errors.go

@ -15,6 +15,7 @@ var ErrIncorrectToken = errors.New("driver is not accepting authentication infor
var ErrUnexpectedResponse = errors.New("driver api returned unexpected response (wrong driver selected?)")
var ErrBridgeSearchFailed = errors.New("bridge search failed")
var ErrAddressOnlyDryRunnable = errors.New("this address may only be used for a dry run")
var ErrCannotForwardRequest = errors.New("driver is not able to forward requests")
var ErrInvalidAddress = errors.New("invalid mac address")
var ErrPayloadTooShort = errors.New("payload too short")

2
models/eventhandler.go

@ -168,7 +168,7 @@ func (c *EventCondition) checkDevice(key string, device Device) (matches bool, s
return false, true
}
return c.matches(strconv.FormatFloat(device.State.Temperature, 'f', -1, 64)), false
return c.matches(strconv.Itoa(device.State.Temperature)), false
case "scene":
sceneId := -1
for _, assignment := range device.SceneAssignments {

11
scripts/20211106223238_device_state_temperature.sql

@ -0,0 +1,11 @@
-- +goose Up
-- +goose StatementBegin
ALTER TABLE device_state
ADD COLUMN temperature INT NOT NULL DEFAULT 0;
-- +goose StatementEnd
-- +goose Down
-- +goose StatementBegin
ALTER TABLE device_state
DROP COLUMN temperature;
-- +goose StatementEnd
Loading…
Cancel
Save