diff --git a/app/api/bridges.go b/app/api/bridges.go index 889fdf7..792feda 100644 --- a/app/api/bridges.go +++ b/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 } diff --git a/app/config/driver.go b/app/config/driver.go index 359e53e..9b08e6d 100644 --- a/app/config/driver.go +++ b/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{}, } }) diff --git a/cmd/bridgetest/main.go b/cmd/bridgetest/main.go index ffe29d1..9670c4d 100644 --- a/cmd/bridgetest/main.go +++ b/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) } diff --git a/cmd/mill/main.go b/cmd/mill/main.go new file mode 100644 index 0000000..d35798e --- /dev/null +++ b/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) +} diff --git a/internal/drivers/hue/driver.go b/internal/drivers/hue/driver.go index 7044d93..86d579a 100644 --- a/internal/drivers/hue/driver.go +++ b/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 diff --git a/internal/drivers/lifx/driver.go b/internal/drivers/lifx/driver.go index c6caa96..80e716c 100644 --- a/internal/drivers/lifx/driver.go +++ b/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 { diff --git a/internal/drivers/mill/bridge.go b/internal/drivers/mill/bridge.go new file mode 100644 index 0000000..46033e1 --- /dev/null +++ b/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") +} diff --git a/internal/drivers/mill/driver.go b/internal/drivers/mill/driver.go new file mode 100644 index 0000000..23f901c --- /dev/null +++ b/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 +} diff --git a/internal/drivers/mill/mill.go b/internal/drivers/mill/mill.go new file mode 100644 index 0000000..5a3cb23 --- /dev/null +++ b/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 +} diff --git a/internal/drivers/nanoleaf/driver.go b/internal/drivers/nanoleaf/driver.go index 9c244b6..e4a2be5 100644 --- a/internal/drivers/nanoleaf/driver.go +++ b/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 diff --git a/internal/mysql/devicerepo.go b/internal/mysql/devicerepo.go index 2b17574..f077ffb 100644 --- a/internal/mysql/devicerepo.go +++ b/internal/mysql/devicerepo.go @@ -23,12 +23,13 @@ type deviceRecord struct { } 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"` + 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"` + Temperature int `db:"temperature"` } type devicePropertyRecord struct { @@ -227,15 +228,16 @@ 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, - Saturation: device.State.Color.Saturation, - Kelvin: device.State.Color.Kelvin, - Power: device.State.Power, - Intensity: device.State.Intensity, + 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, + 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, } } } diff --git a/models/bridge.go b/models/bridge.go index d14f776..374697d 100644 --- a/models/bridge.go +++ b/models/bridge.go @@ -23,6 +23,7 @@ var ( DTHue DriverKind = "Hue" DTNanoLeaf DriverKind = "Nanoleaf" DTLIFX DriverKind = "LIFX" + DTMill DriverKind = "Mill" ) var ValidDriverKinds = []DriverKind{ diff --git a/models/device.go b/models/device.go index 446569a..b79a90a 100644 --- a/models/device.go +++ b/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 { diff --git a/models/driver.go b/models/driver.go index 5dd8253..291962f 100644 --- a/models/driver.go +++ b/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 diff --git a/models/errors.go b/models/errors.go index ee40e6b..cff288d 100644 --- a/models/errors.go +++ b/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") diff --git a/models/eventhandler.go b/models/eventhandler.go index 45dbf13..b62705a 100644 --- a/models/eventhandler.go +++ b/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 { diff --git a/scripts/20211106223238_device_state_temperature.sql b/scripts/20211106223238_device_state_temperature.sql new file mode 100644 index 0000000..febacfc --- /dev/null +++ b/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