diff --git a/cmd/bustest/main.go b/cmd/bustest/main.go index d7d5e6c..86f3205 100644 --- a/cmd/bustest/main.go +++ b/cmd/bustest/main.go @@ -4,6 +4,7 @@ import ( lucifer3 "git.aiterp.net/lucifer3/server" "git.aiterp.net/lucifer3/server/services" "git.aiterp.net/lucifer3/server/services/hue" + "git.aiterp.net/lucifer3/server/services/mill" "git.aiterp.net/lucifer3/server/services/nanoleaf" "git.aiterp.net/lucifer3/server/services/tradfri" "time" @@ -21,6 +22,7 @@ func main() { bus.Join(nanoleaf.NewService()) bus.Join(hue.NewService()) bus.Join(tradfri.NewService()) + bus.Join(mill.NewService()) time.Sleep(time.Hour) } diff --git a/services/mill/bridge.go b/services/mill/bridge.go new file mode 100644 index 0000000..e73fbbf --- /dev/null +++ b/services/mill/bridge.go @@ -0,0 +1,90 @@ +package mill + +import ( + "fmt" + lucifer3 "git.aiterp.net/lucifer3/server" + "git.aiterp.net/lucifer3/server/device" + "git.aiterp.net/lucifer3/server/events" + "strings" +) + +type Bridge interface { + // SetBus to apply a bus that changes will be sent to + SetBus(bus *lucifer3.EventBus) + + // SetState updates device state + SetState(id string, state device.State) bool + + // Start triggers the event emitting part, including device discovery + Start() + + // IsStarted returns true when it's running + IsStarted() bool +} + +func MakeBridge(id, apiKey string) (Bridge, bool) { + parts := strings.Split(id, ":") + if len(parts) < 3 { + return nil, false + } + + driver := parts[0] + version := parts[1] + target := parts[2] + + if driver != "mill" { + return nil, false + } + + id = fmt.Sprintf("%s:%s:%s", driver, version, target) + switch version { + case "2": + return &OnlineBridge{ + ID: id, + Email: target, + Password: apiKey, + Gen2: true, + Gen3: false, + }, true + case "23": + return &OnlineBridge{ + ID: id, + Email: target, + Password: apiKey, + Gen2: true, + Gen3: true, + }, true + case "3": + if strings.Contains(target, "@") { + return &OnlineBridge{ + ID: id, + Email: target, + Password: apiKey, + Gen2: false, + Gen3: true, + }, true + } else { + return &WifiBridge{ + ID: id, + IP: target, + }, true + } + } + + return nil, false +} + +func deviceFailed(id string, err any) events.DeviceFailed { + msg := "(no message)" + switch typed := err.(type) { + case string: + msg = typed + case error: + msg = typed.Error() + } + + return events.DeviceFailed{ + ID: id, + Error: msg, + } +} diff --git a/services/mill/online.go b/services/mill/online.go new file mode 100644 index 0000000..62c709a --- /dev/null +++ b/services/mill/online.go @@ -0,0 +1,365 @@ +package mill + +import ( + "bytes" + "crypto/sha1" + "encoding/json" + "errors" + "fmt" + lucifer3 "git.aiterp.net/lucifer3/server" + "git.aiterp.net/lucifer3/server/device" + "git.aiterp.net/lucifer3/server/events" + "git.aiterp.net/lucifer3/server/internal/gentools" + "log" + "math" + "net/http" + "strconv" + "sync" + "time" +) + +type OnlineBridge struct { + ID string + Email string + Password string + Gen2 bool + Gen3 bool + + genMap map[string]int + internalMap map[string]int + nameMap map[string]string + stateMap map[string]device.State + + mx sync.Mutex + started bool + bus *lucifer3.EventBus + token string + userId int + mustRefreshBy time.Time +} + +func (o *OnlineBridge) SetBus(bus *lucifer3.EventBus) { + o.bus = bus +} + +func (o *OnlineBridge) SetState(id string, state device.State) bool { + if !o.started { + return false + } + + if err := o.authenticate(); err != nil { + o.bus.RunEvent(deviceFailed(o.ID, err)) + return false + } + + currState := o.stateMap[id] + + if state.Power != nil { + currState.Power = state.Power + } + if state.Temperature != nil { + currState.Temperature = state.Temperature + } + + o.stateMap[id] = currState + + if err := o.refresh(); err != nil { + o.bus.RunEvent(deviceFailed(o.ID, err)) + return false + } + + return true +} + +func (o *OnlineBridge) Start() { + o.genMap = make(map[string]int, 16) + o.internalMap = make(map[string]int, 16) + o.nameMap = make(map[string]string, 16) + o.stateMap = make(map[string]device.State, 16) + + if err := o.refresh(); err != nil { + o.bus.RunEvent(deviceFailed(o.ID, err)) + return + } + + o.started = true + go func() { + defer func() { + log.Printf("Mill: %s stopped unexpectedly", o.ID) + o.started = false + }() + + for { + time.Sleep(5 * time.Minute) + + if err := o.refresh(); err != nil { + o.bus.RunEvent(deviceFailed(o.ID, err)) + break + } + } + }() +} + +func (o *OnlineBridge) IsStarted() bool { + return o.started +} + +func (o *OnlineBridge) refresh() error { + var shlRes listHomeResBody + err := o.command("selectHomeList", listHomeReqBody{}, &shlRes) + if err != nil { + return err + } + + devices := make([]millDevice, 0, 16) + for _, home := range shlRes.HomeList { + var gidRes listDeviceResBody + err = o.command("getIndependentDevices", listDeviceReqBody{HomeID: home.HomeID}, &gidRes) + if err != nil { + return err + } + + devices = append(devices, gidRes.DeviceInfo...) + } + + for _, mDevice := range devices { + id := fmt.Sprintf("%s:%d", o.ID, mDevice.DeviceID) + + if o.internalMap[id] == 0 { + o.genMap[id] = subDomainToGeneration(mDevice.SubDomainID) + o.internalMap[id] = mDevice.DeviceID + o.nameMap[id] = mDevice.DeviceName + o.stateMap[id] = device.State{ + Power: gentools.Ptr(mDevice.PowerStatus > 0), + Temperature: gentools.Ptr(float64(mDevice.HolidayTemp)), + } + + // Only register devices if generation is configured + if o.allowsGeneration(o.genMap[id]) { + o.bus.RunEvent(events.DeviceReady{ID: id}) + o.bus.RunEvent(events.HardwareMetadata{ + ID: id, + Icon: "heater", + }) + o.bus.RunEvent(events.HardwareState{ + ID: id, + InternalName: mDevice.DeviceName, + SupportFlags: device.SFlagPower | device.SFlagTemperature, + State: o.stateMap[id], + }) + } + } + + // Write to device if generation is configured + if o.allowsGeneration(o.genMap[id]) { + targetPower := *o.stateMap[id].Power + targetTemp := int(math.Round(*o.stateMap[id].Temperature)) + + currPower := mDevice.PowerStatus > 0 + currTemp := mDevice.HolidayTemp + + changed := false + + if targetPower != currPower { + err := o.setPower(o.genMap[id], mDevice.SubDomainID, mDevice.DeviceID, targetPower) + if err != nil { + return err + } + + changed = true + } + + if targetTemp != currTemp { + err := o.setTemperature(o.genMap[id], mDevice.SubDomainID, mDevice.DeviceID, targetTemp) + if err != nil { + return err + } + + changed = true + } + + if changed { + o.bus.RunEvent(events.HardwareState{ + ID: id, + InternalName: mDevice.DeviceName, + SupportFlags: device.SFlagPower | device.SFlagTemperature, + State: o.stateMap[id], + }) + } + } + } + + return nil +} + +func (o *OnlineBridge) setPower(gen, subDomainID, internalID int, power bool) error { + powerInt := 0 + if power { + powerInt = 1 + } + + if gen == 2 { + powerReq := deviceControlReqBody{ + SubDomain: strconv.Itoa(subDomainID), + DeviceID: internalID, + TestStatus: 1, + Status: powerInt, + } + err := o.command("deviceControl", powerReq, nil) + if err != nil { + return err + } + } else if gen == 3 { + powerReq := deviceControlGen3Body{ + Operation: "SWITCH", + Status: powerInt, + SubDomain: subDomainID, + DeviceId: internalID, + } + err := o.command("deviceControlGen3ForApp", powerReq, nil) + if err != nil { + return err + } + } + + return nil +} + +func (o *OnlineBridge) setTemperature(gen, subDomainID, internalID, temp int) error { + if gen == 2 { + tempReq := changeInfoReqBody{ + DeviceID: internalID, + Value: temp, + TimeZoneNum: "+02:00", + Key: "holidayTemp", + } + err := o.command("changeDeviceInfo", tempReq, nil) + if err != nil { + return err + } + } else if gen == 3 { + tempReq := deviceControlGen3Body{ + Operation: "SINGLE_CONTROL", + Status: 1, + SubDomain: subDomainID, + DeviceId: internalID, + HoldTemp: temp, + } + err := o.command("deviceControlGen3ForApp", tempReq, nil) + if err != nil { + return err + } + } + + return nil +} + +func (o *OnlineBridge) command(command string, payload interface{}, target interface{}) error { + err := o.authenticate() + 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(o.token)) + signature := fmt.Sprintf("%x", h.Sum(nil)) + + body, err := json.Marshal(payload) + if err != nil { + return err + } + + req, err := http.NewRequest(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", o.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 fmt.Errorf("command %s failed (%s)", command, err.Error()) + } else if res.StatusCode != 200 { + return fmt.Errorf("command %s failed (negative response)", command) + } + + if target == nil { + return nil + } + + err = json.NewDecoder(res.Body).Decode(&target) + if err != nil { + return fmt.Errorf("command %s failed (decoding response)", command) + } + + return nil +} + +func (o *OnlineBridge) authenticate() error { + o.mx.Lock() + defer o.mx.Unlock() + + if o.mustRefreshBy.Before(time.Now().Add(-1 * time.Minute)) { + body, err := json.Marshal(authReqBody{ + Account: o.Email, + Password: o.Password, + }) + if err != nil { + return errors.New("failed to authenticate (marshaling request body)") + } + + req, err := http.NewRequest("POST", accountEndpoint+"login", bytes.NewReader(body)) + if err != nil { + return errors.New("failed to authenticate (creating request)") + } + + addDefaultHeaders(req) + + res, err := http.DefaultClient.Do(req) + if err != nil { + return errors.New("failed to authenticate (internal error)") + } else if res.StatusCode != 200 { + return errors.New("failed to authenticate (negative result)") + } + + var resBody authResBody + err = json.NewDecoder(res.Body).Decode(&resBody) + if err != nil { + return errors.New("failed to authenticate (parsing response body)") + } + + log.Printf("Mill: Authenticated as %s", resBody.NickName) + o.userId = resBody.UserID + o.token = resBody.Token + o.mustRefreshBy, err = time.ParseInLocation("2006-01-02 15:04:05", resBody.TokenExpire, location) + } + + return nil +} + +func (o *OnlineBridge) allowsGeneration(gen int) bool { + if gen == 2 { + return o.Gen2 + } + + if gen == 3 { + return o.Gen3 + } + + return false +} diff --git a/services/mill/onlineutils.go b/services/mill/onlineutils.go new file mode 100644 index 0000000..0e93952 --- /dev/null +++ b/services/mill/onlineutils.go @@ -0,0 +1,120 @@ +package mill + +import ( + "crypto/rand" + "fmt" + "io" + "net/http" + "time" +) + +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") +} + +var gen2subDomains = []int{863, 5316, 5317, 5332, 5333, 6933} + +func subDomainToGeneration(subDomain int) int { + for _, gen2sd := range gen2subDomains { + if subDomain == gen2sd { + return 2 + } + } + + return 3 +} + +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"` +} + +type deviceControlGen3Body struct { + Operation string `json:"operation"` + Status int `json:"status"` + SubDomain int `json:"subDomain"` + DeviceId int `json:"deviceId"` + HoldTemp int `json:"holdTemp,omitempty"` +} + +var location *time.Location + +func init() { + myLocation, err := time.LoadLocation("Europe/Oslo") + if err != nil { + panic(err.Error()) + } + + location = myLocation +} diff --git a/services/mill/service.go b/services/mill/service.go new file mode 100644 index 0000000..cc8f985 --- /dev/null +++ b/services/mill/service.go @@ -0,0 +1,145 @@ +package mill + +import ( + "errors" + lucifer3 "git.aiterp.net/lucifer3/server" + "git.aiterp.net/lucifer3/server/commands" + "git.aiterp.net/lucifer3/server/device" + "git.aiterp.net/lucifer3/server/events" + "sync" +) + +func NewService() lucifer3.ActiveService { + return &service{ + bridges: make([]Bridge, 0, 8), + } +} + +type service struct { + mu sync.Mutex + + bridges []Bridge +} + +func (s *service) Active() bool { + return true +} + +func (s *service) HandleEvent(*lucifer3.EventBus, lucifer3.Event) { + // NOP +} + +func (s *service) HandleCommand(bus *lucifer3.EventBus, command lucifer3.Command) { + defer s.mu.Unlock() + s.mu.Lock() + + switch cmd := command.(type) { + case commands.PairDevice: + // Only mill + if _, ok := cmd.Matches("mill"); !ok { + return + } + + // Run connection if needed + err := s.connect(bus, cmd.ID, &cmd.APIKey, nil) + if err != nil { + bus.RunEvent(events.DeviceFailed{ + ID: cmd.ID, + Error: err.Error(), + }) + return + } + + // Connection successful + bus.RunEvent(events.DeviceAccepted{ + ID: cmd.ID, + APIKey: cmd.APIKey, + }) + case commands.ConnectDevice: + // Connect if necessary + err := s.connect(bus, cmd.ID, &cmd.APIKey, nil) + if err != nil { + bus.RunEvent(events.DeviceFailed{ + ID: cmd.ID, + Error: err.Error(), + }) + return + } + case commands.SetState: + err := s.connect(bus, cmd.ID, nil, &cmd.State) + if err != nil { + bus.RunEvent(events.DeviceFailed{ + ID: cmd.ID, + Error: err.Error(), + }) + return + } + case commands.SetStateBatch: + for id, state := range cmd { + err := s.connect(bus, id, nil, &state) + if err != nil { + bus.RunEvent(events.DeviceFailed{ + ID: id, + Error: err.Error(), + }) + return + } + } + } +} + +func (s *service) connect( + bus *lucifer3.EventBus, + id string, + apiKey *string, + state *device.State, +) error { + hasState := state != nil + if !hasState { + state = &device.State{} + } + + // Check if connection is active + for _, bridge := range s.bridges { + // Don't block with dead bridges + if !bridge.IsStarted() { + continue + } + + // Setting a dummy state to the bridge to check if it's online + bridge.SetBus(bus) + if ok := bridge.SetState(id, *state); ok { + return nil + } + } + + if apiKey == nil { + return errors.New("not connected to device " + id) + } + + // Make a bridge, if not a mill ID, this will return ok false + bridge, ok := MakeBridge(id, *apiKey) + if !ok { + return nil + } + + bridge.SetBus(bus) + + // Start bridge, and throw error if it failed to start + bridge.Start() + if !bridge.IsStarted() { + return errors.New("failed to connect to " + id) + } + + // Add bridge to lists + s.bridges = append(s.bridges, bridge) + + // Update state, if needed, after first connection + if hasState { + if ok := bridge.SetState(id, *state); !ok { + return errors.New("unable to set state to " + id + " after connecting") + } + } + + return nil +} diff --git a/services/mill/wifi.go b/services/mill/wifi.go new file mode 100644 index 0000000..84c3d3a --- /dev/null +++ b/services/mill/wifi.go @@ -0,0 +1,39 @@ +package mill + +import ( + lucifer3 "git.aiterp.net/lucifer3/server" + "git.aiterp.net/lucifer3/server/device" + "strings" + "sync" +) + +type WifiBridge struct { + ID string + IP string + + mx sync.Mutex + started bool + bus *lucifer3.EventBus +} + +func (w *WifiBridge) SetBus(bus *lucifer3.EventBus) { + w.bus = bus +} + +func (w *WifiBridge) SetState(id string, state device.State) bool { + if !strings.HasPrefix(id, "mill:3:"+w.IP) { + return false + } + + return true +} + +func (w *WifiBridge) Start() { + go func() { + + }() +} + +func (w *WifiBridge) IsStarted() bool { + return w.started +} diff --git a/services/tradfri/bridge.go b/services/tradfri/bridge.go index 1d62daf..43c8858 100644 --- a/services/tradfri/bridge.go +++ b/services/tradfri/bridge.go @@ -107,7 +107,10 @@ func (b *Bridge) listen(bus *lucifer3.EventBus) { b.stateMap[id] = currState bus.RunEvent(events.DeviceReady{ID: id}) - bus.RunEvent(events.HardwareMetadata{ID: id}) + bus.RunEvent(events.HardwareMetadata{ + ID: id, + Icon: "lightbulb", + }) bus.RunEvent(events.HardwareState{ ID: id, InternalName: ikeaDevice.Name,