Stian Fredrik Aune
3 years ago
17 changed files with 471 additions and 22 deletions
-
2app/api/bridges.go
-
2app/config/driver.go
-
2cmd/bridgetest/main.go
-
14cmd/mill/main.go
-
2internal/drivers/hue/driver.go
-
2internal/drivers/lifx/driver.go
-
240internal/drivers/mill/bridge.go
-
100internal/drivers/mill/driver.go
-
77internal/drivers/mill/mill.go
-
2internal/drivers/nanoleaf/driver.go
-
31internal/mysql/devicerepo.go
-
1models/bridge.go
-
2models/device.go
-
2models/driver.go
-
1models/errors.go
-
2models/eventhandler.go
-
11scripts/20211106223238_device_state_temperature.sql
@ -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) |
||||
|
} |
@ -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") |
||||
|
} |
@ -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 |
||||
|
} |
@ -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 |
||||
|
} |
@ -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 |
Write
Preview
Loading…
Cancel
Save
Reference in new issue