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