diff --git a/config.yml b/config.yml index 551f02e..ef8259d 100644 --- a/config.yml +++ b/config.yml @@ -13,7 +13,10 @@ kasa: living_room/speakers: 10.0.0.182 computers: - zeus: + living_room/zeus: mac: 30:9c:23:60:9c:13 room: Living Room url: http://10.0.0.2:9000/start-pc?mac=30:9c:23:60:9c:13 + +google: + username: Dreaded_X diff --git a/config/config.go b/config/config.go new file mode 100644 index 0000000..40f3756 --- /dev/null +++ b/config/config.go @@ -0,0 +1,78 @@ +package config + +import ( + "automation/device" + "encoding/base64" + "log" + "os" + + "github.com/kelseyhightower/envconfig" + "gopkg.in/yaml.v3" +) + +type config struct { + Hue struct { + Token string `yaml:"token" envconfig:"HUE_TOKEN"` + IP string `yaml:"ip" envconfig:"HUE_IP"` + } `yaml:"hue"` + + Ntfy struct { + Topic string `yaml:"topic" envconfig:"NTFY_TOPIC"` + } `yaml:"ntfy"` + + MQTT struct { + Host string `yaml:"host" envconfig:"MQTT_HOST"` + Port int `yaml:"port" envconfig:"MQTT_PORT"` + Username string `yaml:"username" envconfig:"MQTT_USERNAME"` + Password string `yaml:"password" envconfig:"MQTT_PASSWORD"` + ClientID string `yaml:"client_id" envconfig:"MQTT_CLIENT_ID"` + } `yaml:"mqtt"` + + Kasa struct { + Outlets map[device.InternalName]string `yaml:"outlets"` + } `yaml:"kasa"` + + Computer map[device.InternalName]struct { + MACAddress string `yaml:"mac"` + Url string `yaml:"url"` + } `yaml:"computers"` + + Google struct { + Username string `yaml:"username" envconfig:"GOOGLE_USERNAME"` + Credentials Credentials `yaml:"credentials" envconfig:"GOOGLE_CREDENTIALS"` + } `yaml:"google"` +} + +type Credentials []byte + +func (c *Credentials) Decode(value string) error { + b, err := base64.StdEncoding.DecodeString(value) + *c = b + + return err +} + +func Get() config { + // First load the config from the yaml file + f, err := os.Open("config.yml") + if err != nil { + log.Fatalln("Failed to open config file", err) + } + defer f.Close() + + var cfg config + decoder := yaml.NewDecoder(f) + err = decoder.Decode(&cfg) + if err != nil { + log.Fatalln("Failed to parse config file", err) + } + + // Then load values from environment + // This can be used to either override the config or pass in secrets + err = envconfig.Process("", &cfg) + if err != nil { + log.Fatalln("Failed to parse environmet config", err) + } + + return cfg +} diff --git a/device/device.go b/device/device.go new file mode 100644 index 0000000..3a8f2bd --- /dev/null +++ b/device/device.go @@ -0,0 +1,43 @@ +package device + +import ( + "fmt" +) + +type Basic interface { + GetID() InternalName +} + +type OnOff interface { + SetOnOff(state bool) + GetOnOff() bool +} + +func GetDevices[K any](devices *map[InternalName]Basic) map[InternalName]K { + devs := make(map[InternalName]K) + + for name, device := range *devices { + if dev, ok := device.(K); ok { + devs[name] = dev + } + } + + return devs +} + +func GetDevice[K any](devices *map[InternalName]Basic, name InternalName) (K, error) { + d, ok := (*devices)[name] + if !ok { + var noop K + return noop, fmt.Errorf("Device '%s' does not exist", name) + } + + dev, ok := d.(K) + if !ok { + var noop K + return noop, fmt.Errorf("Device '%s' is not the expected type", name) + } + + return dev, nil +} + diff --git a/device/internal_name.go b/device/internal_name.go new file mode 100644 index 0000000..fb5d0a1 --- /dev/null +++ b/device/internal_name.go @@ -0,0 +1,33 @@ +package device + +import "strings" + +type InternalName string + +func (n InternalName) Room() string { + s := strings.Split(string(n), "/") + room := "" + if len(s) > 1 { + room = s[0] + } + room = strings.ReplaceAll(room, "_", " ") + room = strings.Title(room) + + return room +} + +func (n InternalName) Name() string { + s := strings.Split(string(n), "/") + name := s[0] + if len(s) > 1 { + name = s[1] + } + name = strings.Title(name) + + return name +} + +func (n InternalName) String() string { + return string(n) +} + diff --git a/device/provider.go b/device/provider.go deleted file mode 100644 index 753dfb1..0000000 --- a/device/provider.go +++ /dev/null @@ -1,227 +0,0 @@ -package device - -import ( - "automation/integration/google" - "automation/integration/kasa" - "context" - "encoding/base64" - "encoding/json" - "fmt" - "log" - - "github.com/kr/pretty" - "google.golang.org/api/homegraph/v1" - "google.golang.org/api/option" - - paho "github.com/eclipse/paho.mqtt.golang" -) - -type BaseDevice interface { - GetName() string -} - -type Devices struct { - Devices map[string]interface{} -} - -func NewDevices() *Devices { - return &Devices{Devices: make(map[string]interface{})} -} - -func (d *Devices) GetGoogleDevices() map[string]google.DeviceInterface { - devices := make(map[string]google.DeviceInterface) - - for _, device := range d.Devices { - if gd, ok := device.(google.DeviceInterface); ok { - // Instead of using name we use the internal ID for google, that way devices can freely be renamed without causing issues with google home - devices[gd.GetID()] = gd - } - } - - return devices -} - -func (d *Devices) GetGoogleDevice(name string) (google.DeviceInterface, error) { - device, ok := d.GetGoogleDevices()[name] - if !ok { - return nil, fmt.Errorf("Device does not exist") - } - - return device, nil -} - -func (d *Devices) GetZigbeeDevices() map[string]ZigbeeDevice { - devices := make(map[string]ZigbeeDevice) - - for name, device := range d.Devices { - if zd, ok := device.(ZigbeeDevice); ok { - devices[name] = zd - } - } - - return devices -} - -func (d *Devices) GetKasaDevices() map[string]*kasa.Kasa { - devices := make(map[string]*kasa.Kasa) - - for _, device := range d.Devices { - if gd, ok := device.(*kasa.Kasa); ok { - // Instead of using name we use the internal ID for google, that way devices can freely be renamed without causing issues with google home - devices[gd.GetName()] = gd - } - } - - return devices -} - -func (d *Devices) GetKasaDevice(name string) (*kasa.Kasa, error) { - device, ok := d.GetKasaDevices()[name] - if !ok { - return nil, fmt.Errorf("Device does not exist") - } - - return device, nil -} - -type DeviceInfo struct { - IEEEAdress string `json:"ieee_address"` - FriendlyName string `json:"friendly_name"` - Description string `json:"description"` - Manufacturer string `json:"manufacturer"` - ModelID string `json:"model_id"` - SoftwareBuildID string `json:"software_build_id"` -} - -type ZigbeeDevice interface { - GetDeviceInfo() DeviceInfo - SetState(state bool) -} - -type DeviceInterface interface { - google.DeviceInterface - SetState(state bool) -} - -type Provider struct { - Service *google.Service - userID string - - Devices *Devices -} - -type credentials []byte -type Config struct { - Credentials credentials `yaml:"credentials" envconfig:"GOOGLE_CREDENTIALS"` -} - -func (c *credentials) Decode(value string) error { - b, err := base64.StdEncoding.DecodeString(value) - *c = b - - return err -} - -// Auto populate and update the device list -func (p *Provider) devicesHandler(client paho.Client, msg paho.Message) { - var devices []DeviceInfo - json.Unmarshal(msg.Payload(), &devices) - - log.Println("zigbee2mqtt devices:") - pretty.Logln(devices) - - for name := range p.Devices.GetZigbeeDevices() { - // Delete all zigbee devices from the device list - delete(p.Devices.Devices, name) - } - - for _, device := range devices { - switch device.Description { - case "Kettle": - kettle := NewKettle(device, client, p.Service) - p.Devices.Devices[kettle.GetDeviceInfo().FriendlyName] = kettle - log.Printf("Added Kettle (%s) %s\n", kettle.GetDeviceInfo().IEEEAdress, kettle.GetDeviceInfo().FriendlyName) - } - } - - // Send sync request - p.Service.RequestSync(context.Background(), p.userID) -} - -func NewProvider(config Config, client paho.Client) *Provider { - provider := &Provider{userID: "Dreaded_X", Devices: NewDevices()} - - homegraphService, err := homegraph.NewService(context.Background(), option.WithCredentialsJSON(config.Credentials)) - if err != nil { - panic(err) - } - - provider.Service = google.NewService(provider, homegraphService) - - if token := client.Subscribe("zigbee2mqtt/bridge/devices", 1, provider.devicesHandler); token.Wait() && token.Error() != nil { - log.Println(token.Error()) - } - - return provider -} - -func (p *Provider) AddDevice(device BaseDevice) { - p.Devices.Devices[device.GetName()] = device -} - -func (p *Provider) Sync(_ context.Context, _ string) ([]*google.Device, error) { - var devices []*google.Device - - for _, device := range p.Devices.GetGoogleDevices() { - devices = append(devices, device.Sync()) - } - - return devices, nil -} - -func (p *Provider) Query(_ context.Context, _ string, handles []google.DeviceHandle) (map[string]google.DeviceState, error) { - states := make(map[string]google.DeviceState) - - for _, handle := range handles { - if device, err := p.Devices.GetGoogleDevice(handle.ID); err == nil { - states[handle.ID] = device.Query() - } else { - log.Println(err) - } - } - - return states, nil -} - -func (p *Provider) Execute(_ context.Context, _ string, commands []google.Command) (*google.ExecuteResponse, error) { - resp := &google.ExecuteResponse{ - UpdatedState: google.NewDeviceState(true), - FailedDevices: make(map[string]struct{ Devices []string }), - } - - for _, command := range commands { - for _, execution := range command.Execution { - for _, handle := range command.Devices { - if device, err := p.Devices.GetGoogleDevice(handle.ID); err == nil { - errCode, online := device.Execute(execution, &resp.UpdatedState) - - // Update the state - p.Devices.Devices[handle.ID] = device - if !online { - resp.OfflineDevices = append(resp.OfflineDevices, handle.ID) - } else if len(errCode) == 0 { - resp.UpdatedDevices = append(resp.UpdatedDevices, handle.ID) - } else { - e := resp.FailedDevices[errCode] - e.Devices = append(e.Devices, handle.ID) - resp.FailedDevices[errCode] = e - } - } else { - log.Println(err) - } - } - } - } - - return resp, nil -} diff --git a/home/home.go b/home/home.go new file mode 100644 index 0000000..455fede --- /dev/null +++ b/home/home.go @@ -0,0 +1,98 @@ +package home + +import ( + "automation/config" + "automation/device" + "automation/integration/google" + "context" + "log" + + "google.golang.org/api/homegraph/v1" + "google.golang.org/api/option" + + paho "github.com/eclipse/paho.mqtt.golang" +) + +type Home struct { + Service *google.Service + Username string + + Devices map[device.InternalName]device.Basic +} + +// Auto populate and update the device list +func New(username string, credentials config.Credentials, client paho.Client) *Home { + home := &Home{Username: username, Devices: make(map[device.InternalName]device.Basic)} + + homegraphService, err := homegraph.NewService(context.Background(), option.WithCredentialsJSON(credentials)) + if err != nil { + panic(err) + } + + home.Service = google.NewService(home, homegraphService) + + return home +} + +func (h *Home) AddDevice(d device.Basic) { + h.Devices[d.GetID()] = d + + log.Printf("Added %s in %s (%s)\n", d.GetID().Name(), d.GetID().Room(), d.GetID()) +} + +func (h *Home) Sync(_ context.Context, _ string) ([]*google.Device, error) { + var devices []*google.Device + + for _, device := range device.GetDevices[google.DeviceInterface](&h.Devices) { + devices = append(devices, device.Sync()) + } + + return devices, nil +} + +func (h *Home) Query(_ context.Context, _ string, handles []google.DeviceHandle) (map[string]google.DeviceState, error) { + states := make(map[string]google.DeviceState) + + for _, handle := range handles { + if device, err := device.GetDevice[google.DeviceInterface](&h.Devices, device.InternalName(handle.ID)); err == nil { + states[device.GetID().String()] = device.Query() + } else { + log.Println(err) + } + } + + return states, nil +} + +func (h *Home) Execute(_ context.Context, _ string, commands []google.Command) (*google.ExecuteResponse, error) { + resp := &google.ExecuteResponse{ + UpdatedState: google.NewDeviceState(true), + FailedDevices: make(map[string]struct{ Devices []string }), + } + + for _, command := range commands { + for _, execution := range command.Execution { + for _, handle := range command.Devices { + if device, err := device.GetDevice[google.DeviceInterface](&h.Devices, device.InternalName(handle.ID)); err == nil { + errCode, online := device.Execute(execution, &resp.UpdatedState) + + // Update the state + h.Devices[device.GetID()] = device + if !online { + resp.OfflineDevices = append(resp.OfflineDevices, handle.ID) + } else if len(errCode) == 0 { + resp.UpdatedDevices = append(resp.UpdatedDevices, handle.ID) + } else { + e := resp.FailedDevices[errCode] + e.Devices = append(e.Devices, handle.ID) + resp.FailedDevices[errCode] = e + } + } else { + log.Println(err) + } + } + } + } + + return resp, nil +} diff --git a/integration/google/handler.go b/integration/google/handler.go index 4b1d138..594b836 100644 --- a/integration/google/handler.go +++ b/integration/google/handler.go @@ -1,6 +1,7 @@ package google import ( + "automation/device" "encoding/json" "io" "log" @@ -10,10 +11,11 @@ import ( ) type DeviceInterface interface { + device.Basic + Sync() *Device Query() DeviceState Execute(execution Execution, updatedState *DeviceState) (errCode string, online bool) - GetID() string } // https://developers.google.com/assistant/smarthome/reference/intent/sync diff --git a/integration/kasa/kasa.go b/integration/kasa/kasa.go index b49a588..0cc5c86 100644 --- a/integration/kasa/kasa.go +++ b/integration/kasa/kasa.go @@ -5,13 +5,16 @@ import ( "encoding/binary" "encoding/json" "fmt" - "log" "net" ) // This implementation is based on: // https://www.softscheck.com/en/blog/tp-link-reverse-engineering/ +type Device interface { + GetIP() string +} + func encrypt(data []byte) []byte { var key byte = 171 buf := new(bytes.Buffer) @@ -45,18 +48,8 @@ func decrypt(data []byte) ([]byte, error) { return buf, nil } - -type Kasa struct { - name string - ip string -} - -func New(name string, ip string) *Kasa { - return &Kasa{name, ip} -} - -func (kasa *Kasa) sendCmd(cmd cmd) (reply, error) { - con, err := net.Dial("tcp", fmt.Sprintf("%s:9999", kasa.ip)) +func sendCmd(kasa Device, cmd cmd) (reply, error) { + con, err := net.Dial("tcp", fmt.Sprintf("%s:9999", kasa.GetIP())) if err != nil { return reply{}, err } @@ -93,45 +86,3 @@ func (kasa *Kasa) sendCmd(cmd cmd) (reply, error) { return reply, err } -func (kasa *Kasa) SetState(on bool) { - var cmd cmd - cmd.System.SetRelayState = &SetRelayState{State: 0} - if on { - cmd.System.SetRelayState.State = 1 - } - - reply, err := kasa.sendCmd(cmd) - if err != nil { - log.Println(err) - return - } - - if reply.System.SetRelayState.ErrCode != 0 { - log.Printf("Failed to set relay state, error: %d\n", reply.System.SetRelayState.ErrCode) - } -} - -func (kasa *Kasa) GetState() bool { - cmd := cmd{} - - cmd.System.GetSysinfo = &GetSysinfo{} - - reply, err := kasa.sendCmd(cmd) - if err != nil { - log.Println(err) - return false - } - - if reply.System.GetSysinfo.ErrCode != 0 { - log.Printf("Failed to set relay state, error: %d\n", reply.System.GetSysinfo.ErrCode) - - return false - } - - return reply.System.GetSysinfo.RelayState == 1 -} - -func (kasa *Kasa) GetName() string { - return kasa.name -} - diff --git a/integration/kasa/outlet.go b/integration/kasa/outlet.go new file mode 100644 index 0000000..57e375e --- /dev/null +++ b/integration/kasa/outlet.go @@ -0,0 +1,68 @@ +package kasa + +import ( + "automation/device" + "log" +) + +type Outlet struct { + name device.InternalName + ip string +} + + +func NewOutlet(name device.InternalName, ip string) *Outlet { + return &Outlet{name, ip} +} + +// kasa.Device +var _ Device = (*Outlet)(nil) +func (o *Outlet) GetIP() string { + return o.ip +} + +// device.Basic +var _ device.Basic = (*Outlet)(nil) +func (o *Outlet) GetID() device.InternalName { + return o.name +} + +// device.OnOff +var _ device.OnOff = (*Outlet)(nil) +func (o *Outlet) SetOnOff(on bool) { + var cmd cmd + cmd.System.SetRelayState = &SetRelayState{State: 0} + if on { + cmd.System.SetRelayState.State = 1 + } + + reply, err := sendCmd(o, cmd) + if err != nil { + log.Println(err) + return + } + + if reply.System.SetRelayState.ErrCode != 0 { + log.Printf("Failed to set relay state, error: %d\n", reply.System.SetRelayState.ErrCode) + } +} + +func (o *Outlet) GetOnOff() bool { + cmd := cmd{} + + cmd.System.GetSysinfo = &GetSysinfo{} + + reply, err := sendCmd(o, cmd) + if err != nil { + log.Println(err) + return false + } + + if reply.System.GetSysinfo.ErrCode != 0 { + log.Printf("Failed to set relay state, error: %d\n", reply.System.GetSysinfo.ErrCode) + + return false + } + + return reply.System.GetSysinfo.RelayState == 1 +} diff --git a/integration/mqtt/mqtt.go b/integration/mqtt/mqtt.go deleted file mode 100644 index 0c7628d..0000000 --- a/integration/mqtt/mqtt.go +++ /dev/null @@ -1,37 +0,0 @@ -package mqtt - -import ( - "fmt" - - paho "github.com/eclipse/paho.mqtt.golang" -) - -// This is the default message handler, it just prints out the topic and message -var defaultHandler paho.MessageHandler = func(client paho.Client, msg paho.Message) { - fmt.Printf("TOPIC: %s\n", msg.Topic()) - fmt.Printf("MSG: %s\n", msg.Payload()) -} - -func New(host string, port int, clientID string, username string, password string) paho.Client { - opts := paho.NewClientOptions().AddBroker(fmt.Sprintf("%s:%d", host, port)) - opts.SetClientID(clientID) - opts.SetDefaultPublishHandler(defaultHandler) - opts.SetUsername(username) - opts.SetPassword(password) - opts.SetOrderMatters(false) - - client := paho.NewClient(opts) - if token := client.Connect(); token.Wait() && token.Error() != nil { - panic(token.Error()) - } - - return client -} - -func Delete(m paho.Client) { - if token := m.Unsubscribe("automation/presence/+"); token.Wait() && token.Error() != nil { - fmt.Println(token.Error()) - } - - m.Disconnect(250) -} diff --git a/integration/ntfy/ntfy.go b/integration/ntfy/ntfy.go index 1e78ab8..f5d7aae 100644 --- a/integration/ntfy/ntfy.go +++ b/integration/ntfy/ntfy.go @@ -11,7 +11,6 @@ type Notify struct { } func (n *Notify) Presence(home bool) { - // @TODO Maybe add list the devices that are home currently? var description string var actions string if home { diff --git a/device/computer.go b/integration/wol/computer.go similarity index 56% rename from device/computer.go rename to integration/wol/computer.go index 79508f9..f8582f7 100644 --- a/device/computer.go +++ b/integration/wol/computer.go @@ -1,6 +1,7 @@ -package device +package wol import ( + "automation/device" "automation/integration/google" "log" "net/http" @@ -8,32 +9,48 @@ import ( type computer struct { macAddress string - name string - room string + name device.InternalName url string } -func NewComputer(macAddress string, name string, room string, url string) *computer { - c := &computer{macAddress: macAddress, name: name, room: room} +func NewComputer(macAddress string, name device.InternalName, url string) *computer { + c := &computer{macAddress: macAddress, name: name} return c } +func (c *computer) Activate(state bool) { + if state { + http.Get(c.url) + } else { + // Scene does not implement this + } +} + +// device.Basic +var _ device.Basic = (*computer)(nil) +func (c *computer) GetID() device.InternalName { + return device.InternalName(c.name) +} + +// google.DeviceInterface +var _ google.DeviceInterface = (*computer)(nil) func (c *computer) Sync() *google.Device { - device := google.NewDevice(c.GetID(), google.TypeScene) + device := google.NewDevice(c.GetID().String(), google.TypeScene) device.AddSceneTrait(false) device.Name = google.DeviceName{ DefaultNames: []string{ "Computer", }, - Name: c.name, + Name: c.GetID().Name(), } - device.RoomHint = c.room + device.RoomHint = c.GetID().Room() return device } +// google.DeviceInterface func (c *computer) Query() google.DeviceState { state := google.NewDeviceState(true) state.Status = google.StatusSuccess @@ -41,12 +58,13 @@ func (c *computer) Query() google.DeviceState { return state } +// google.DeviceInterface func (c *computer) Execute(execution google.Execution, updateState *google.DeviceState) (string, bool) { errCode := "" switch execution.Name { case google.CommandActivateScene: - c.SetState(!execution.ActivateScene.Deactivate) + c.Activate(!execution.ActivateScene.Deactivate) default: errCode = "actionNotAvailable" log.Printf("Command (%s) not supported\n", execution.Name) @@ -54,19 +72,3 @@ func (c *computer) Execute(execution google.Execution, updateState *google.Devic return errCode, true } - -func (c *computer) GetID() string { - return c.macAddress -} - -func (c *computer) GetName() string { - return c.name -} - -func (c *computer) SetState(state bool) { - if state { - http.Get(c.url) - } else { - // Scene does not implement this - } -} diff --git a/integration/zigbee/devices.go b/integration/zigbee/devices.go new file mode 100644 index 0000000..1e1ebc7 --- /dev/null +++ b/integration/zigbee/devices.go @@ -0,0 +1,39 @@ +package zigbee + +import ( + "automation/device" + "automation/home" + "context" + "encoding/json" + "log" + + paho "github.com/eclipse/paho.mqtt.golang" +) + +func DevicesHandler(client paho.Client, home *home.Home) { + var handler paho.MessageHandler = func(client paho.Client, msg paho.Message) { + var devices []Info + json.Unmarshal(msg.Payload(), &devices) + + for name, d := range device.GetDevices[Device](&home.Devices) { + d.Delete() + // Delete all zigbee devices from the device list + delete(home.Devices, name) + } + + for _, d := range devices { + switch d.Description { + case "Kettle": + kettle := NewKettle(d, client, home.Service) + home.AddDevice(kettle) + } + } + + // Send sync request + home.Service.RequestSync(context.Background(), home.Username) + } + + if token := client.Subscribe("zigbee2mqtt/bridge/devices", 1, handler); token.Wait() && token.Error() != nil { + log.Println(token.Error()) + } +} diff --git a/device/kettle.go b/integration/zigbee/kettle.go similarity index 72% rename from device/kettle.go rename to integration/zigbee/kettle.go index 24ded9d..7b1169e 100644 --- a/device/kettle.go +++ b/integration/zigbee/kettle.go @@ -1,19 +1,23 @@ -package device +package zigbee import ( + "automation/device" "automation/integration/google" + "context" "encoding/json" "fmt" "log" - "strings" "time" paho "github.com/eclipse/paho.mqtt.golang" ) type kettle struct { - info DeviceInfo + info Info + client paho.Client + service *google.Service + updated chan bool timerLength time.Duration @@ -24,8 +28,19 @@ type kettle struct { online bool } -func (k *kettle) getState() google.DeviceState { - return google.NewDeviceState(k.online).RecordOnOff(k.isOn) +func NewKettle(info Info, client paho.Client, service *google.Service) *kettle { + k := &kettle{info: info, client: client, service: service, updated: make(chan bool, 1), timerLength: 5 * time.Minute, stop: make(chan interface{})} + k.timer = time.NewTimer(k.timerLength) + k.timer.Stop() + + // Start function + go k.timerFunc() + + if token := k.client.Subscribe(fmt.Sprintf("zigbee2mqtt/%s", k.info.FriendlyName), 1, k.stateHandler); token.Wait() && token.Error() != nil { + log.Println(token.Error()) + } + + return k } func (k *kettle) stateHandler(client paho.Client, msg paho.Message) { @@ -45,11 +60,10 @@ func (k *kettle) stateHandler(client paho.Client, msg paho.Message) { k.updated <- true // Notify google of the updated state - // @TODO Fix this - // id := k.GetID() - // s.ReportState(context.Background(), id, map[string]google.DeviceState{ - // id: k.getState(), - // }) + id := k.GetID().String() + k.service.ReportState(context.Background(), id, map[string]google.DeviceState{ + id: k.getState(), + }) if k.isOn { k.timer.Reset(k.timerLength) @@ -73,51 +87,42 @@ func (k *kettle) timerFunc() { } } -func (k *kettle) Delete() { - // The the timer function that it needs to stop - k.stop <- struct{}{} +func (k *kettle) getState() google.DeviceState { + return google.NewDeviceState(k.online).RecordOnOff(k.isOn) } -func NewKettle(info DeviceInfo, client paho.Client, s *google.Service) *kettle { - k := &kettle{info: info, client: client, updated: make(chan bool, 1), timerLength: 5 * time.Minute, stop: make(chan interface{})} - k.timer = time.NewTimer(k.timerLength) - k.timer.Stop() - // Start function - go k.timerFunc() +// zigbee.Device +var _ Device = (*kettle)(nil) +func (k *kettle) Delete() { + k.stop <- struct{}{} if token := k.client.Subscribe(fmt.Sprintf("zigbee2mqtt/%s", k.info.FriendlyName), 1, k.stateHandler); token.Wait() && token.Error() != nil { log.Println(token.Error()) } - - return k } -func (k *kettle) Sync() *google.Device { - device := google.NewDevice(k.GetID(), google.TypeKettle) - device.AddOnOffTrait(false, false) +func (k *kettle) IsZigbeeDevice() bool { + return true +} - s := strings.Split(k.info.FriendlyName, "/") - room := "" - name := s[0] - if len(s) > 1 { - room = s[0] - name = s[1] - } - room = strings.Title(room) - name = strings.Title(name) + +// google.DeviceInterface +var _ google.DeviceInterface = (*kettle)(nil) +func (k *kettle) Sync() *google.Device { + device := google.NewDevice(k.GetID().String(), google.TypeKettle) + device.AddOnOffTrait(false, false) device.Name = google.DeviceName{ DefaultNames: []string{ "Kettle", }, - Name: name, + Name: k.GetID().Name(), } - // @TODO Fix reporting - // device.WillReportState = true device.WillReportState = true - if len(name) > 1 { + room := k.GetID().Room() + if len(room) > 1 { device.RoomHint = room } @@ -130,10 +135,9 @@ func (k *kettle) Sync() *google.Device { return device } +// google.DeviceInterface func (k *kettle) Query() google.DeviceState { - // We just report out internal representation as it should always match the actual state state := k.getState() - // No /get needed if k.online { state.Status = google.StatusSuccess } else { @@ -143,6 +147,7 @@ func (k *kettle) Query() google.DeviceState { return state } +// google.DeviceInterface func (k *kettle) Execute(execution google.Execution, updatedState *google.DeviceState) (string, bool) { errCode := "" @@ -154,7 +159,7 @@ func (k *kettle) Execute(execution google.Execution, updatedState *google.Device <- k.updated } - k.SetState(execution.OnOff.On) + k.SetOnOff(execution.OnOff.On) // Start timeout timer timer := time.NewTimer(time.Second) @@ -179,19 +184,15 @@ func (k *kettle) Execute(execution google.Execution, updatedState *google.Device return errCode, k.online } -func (k *kettle) GetID() string { - return k.info.IEEEAdress -} - -func (k *kettle) GetName() string { +// device.Base +var _ device.Basic = (*kettle)(nil) +func (k *kettle) GetID() device.InternalName { return k.info.FriendlyName } -func (k *kettle) GetDeviceInfo() DeviceInfo { - return k.info -} - -func (k *kettle) SetState(state bool) { +// device.OnOff +var _ device.OnOff = (*kettle)(nil) +func (k *kettle) SetOnOff(state bool) { msg := "OFF" if state { msg = "ON" @@ -202,3 +203,7 @@ func (k *kettle) SetState(state bool) { } } +// device.OnOff +func (k *kettle) GetOnOff() bool { + return k.isOn +} diff --git a/integration/zigbee/zigbee.go b/integration/zigbee/zigbee.go new file mode 100644 index 0000000..aaec6bc --- /dev/null +++ b/integration/zigbee/zigbee.go @@ -0,0 +1,20 @@ +package zigbee + +import "automation/device" + +type Info struct { + IEEEAdress string `json:"ieee_address"` + FriendlyName device.InternalName `json:"friendly_name"` + Description string `json:"description"` + Manufacturer string `json:"manufacturer"` + ModelID string `json:"model_id"` + SoftwareBuildID string `json:"software_build_id"` +} + +type Device interface { + device.Basic + + // This function only exists to make this interface unique + IsZigbeeDevice() bool + Delete() +} diff --git a/main.go b/main.go index 6addd78..3e0f99a 100644 --- a/main.go +++ b/main.go @@ -1,178 +1,69 @@ package main import ( - "automation/device" + "automation/automation" + "automation/config" + "automation/home" "automation/integration/hue" "automation/integration/kasa" - "automation/integration/mqtt" "automation/integration/ntfy" + "automation/integration/wol" + "automation/integration/zigbee" "automation/presence" - "encoding/json" + "fmt" "log" "net/http" "os" "github.com/gorilla/mux" "github.com/joho/godotenv" - "github.com/kelseyhightower/envconfig" - "gopkg.in/yaml.v3" paho "github.com/eclipse/paho.mqtt.golang" ) -type config struct { - Hue struct { - Token string `yaml:"token" envconfig:"HUE_TOKEN"` - IP string `yaml:"ip" envconfig:"HUE_IP"` - } `yaml:"hue"` - - NTFY struct { - topic string `yaml:"topic" envconfig:"NTFY_TOPIC"` - } `yaml:"ntfy"` - - MQTT struct { - Host string `yaml:"host" envconfig:"MQTT_HOST"` - Port int `yaml:"port" envconfig:"MQTT_PORT"` - Username string `yaml:"username" envconfig:"MQTT_USERNAME"` - Password string `yaml:"password" envconfig:"MQTT_PASSWORD"` - ClientID string `yaml:"client_id" envconfig:"MQTT_CLIENT_ID"` - } `yaml:"mqtt"` - - Kasa struct { - Outlets map[string]string `yaml:"outlets"` - } `yaml:"kasa"` - - Computer map[string]struct { - MACAddress string `yaml:"mac"` - Room string `yaml:"room"` - Url string `yaml:"url"` - } `yaml:"computers"` - - Google device.Config `yaml:"google"` -} - -func GetConfig() config { - // First load the config from the yaml file - f, err := os.Open("config.yml") - if err != nil { - log.Fatalln("Failed to open config file", err) - } - defer f.Close() - - var cfg config - decoder := yaml.NewDecoder(f) - err = decoder.Decode(&cfg) - if err != nil { - log.Fatalln("Failed to parse config file", err) - } - - // Then load values from environment - // This can be used to either override the config or pass in secrets - err = envconfig.Process("", &cfg) - if err != nil { - log.Fatalln("Failed to parse environmet config", err) - } - - return cfg -} - -// // @TODO Implement this for the other devices as well -// func GetDeviceKasa(name string) (*kasa.Kasa, error) { -// deviceGeneric, ok := devices[name] -// if !ok { -// return nil, fmt.Errorf("Device does not exist") -// } - -// device, ok := deviceGeneric.(kasa.Kasa) -// if !ok { -// return nil, fmt.Errorf("Device is not a Kasa device") -// } - -// return &device, nil -// } - -func SetupBindings(client paho.Client, p *device.Provider) { - var handler paho.MessageHandler = func(client paho.Client, msg paho.Message) { - mixer, err := p.Devices.GetKasaDevice("living_room/mixer") - if err != nil { - log.Println(err) - return - } - speakers, err := p.Devices.GetKasaDevice("living_room/speakers") - if err != nil { - log.Println(err) - return - } - - var message struct { - Action string `json:"action"` - } - err = json.Unmarshal(msg.Payload(), &message) - if err != nil { - log.Println(err) - return - } - - if message.Action == "on" { - if mixer.GetState() { - mixer.SetState(false) - speakers.SetState(false) - } else { - mixer.SetState(true) - } - } else if message.Action == "brightness_move_up" { - if speakers.GetState() { - speakers.SetState(false) - } else { - speakers.SetState(true) - mixer.SetState(true) - } - } - } - - if token := client.Subscribe("test/remote", 1, handler); token.Wait() && token.Error() != nil { - log.Println(token.Error()) - } -} - func main() { _ = godotenv.Load() - config := GetConfig() + cfg := config.Get() // Setup all the connections to other services - client := mqtt.New(config.MQTT.Host, config.MQTT.Port, config.MQTT.ClientID, config.MQTT.Username, config.MQTT.Password) + opts := paho.NewClientOptions().AddBroker(fmt.Sprintf("%s:%d", cfg.MQTT.Host, cfg.MQTT.Port)) + opts.SetClientID(cfg.MQTT.ClientID) + opts.SetUsername(cfg.MQTT.Username) + opts.SetPassword(cfg.MQTT.Password) + opts.SetOrderMatters(false) + + client := paho.NewClient(opts) + if token := client.Connect(); token.Wait() && token.Error() != nil { + panic(token.Error()) + } defer client.Disconnect(250) - notify := ntfy.New(config.NTFY.topic) - hue := hue.New(config.Hue.IP, config.Hue.Token) + notify := ntfy.New(cfg.Ntfy.Topic) + hue := hue.New(cfg.Hue.IP, cfg.Hue.Token) // Devices that we control and expose to google home - provider := device.NewProvider(config.Google, client) + home := home.New(cfg.Google.Username, cfg.Google.Credentials, client) // Setup presence system - p := presence.New(client, hue, notify, provider) - defer p.Delete() + p := presence.New(client, hue, notify, home) + defer p.Delete(client) r := mux.NewRouter() - r.HandleFunc("/assistant", provider.Service.FullfillmentHandler) + r.HandleFunc("/assistant", home.Service.FullfillmentHandler) // Register computers - for name, info := range config.Computer { - provider.AddDevice(device.NewComputer(info.MACAddress, name, info.Room, info.Url)) + for name, info := range cfg.Computer { + home.AddDevice(wol.NewComputer(info.MACAddress, name, info.Url)) } // Register all kasa devies - for name, ip := range config.Kasa.Outlets { - provider.AddDevice(kasa.New(name, ip)) + for name, ip := range cfg.Kasa.Outlets { + home.AddDevice(kasa.NewOutlet(name, ip)) } - SetupBindings(client, provider) - - // time.Sleep(time.Second) - // pretty.Println(provider.Devices) - // pretty.Println(provider.Devices.GetGoogleDevices()) - // pretty.Println(provider.Devices.GetKasaDevices()) - // pretty.Println(provider.Devices.GetZigbeeDevices()) + // Setup handler that automatically registers and updates all zigbee devices + zigbee.DevicesHandler(client, home) + automation.RegisterAutomations(client, hue, notify, home) addr := ":8090" srv := http.Server{ diff --git a/presence/presence.go b/presence/presence.go index 6b2ca33..db1da1d 100644 --- a/presence/presence.go +++ b/presence/presence.go @@ -1,12 +1,10 @@ package presence import ( - "automation/device" + "automation/home" "automation/integration/hue" - "automation/integration/kasa" "automation/integration/ntfy" "encoding/json" - "fmt" "log" "strings" "time" @@ -16,11 +14,6 @@ import ( ) type Presence struct { - client paho.Client - hue *hue.Hue - ntfy *ntfy.Notify - provider *device.Provider - devices map[string]bool presence bool } @@ -47,7 +40,7 @@ func (p *Presence) devicePresenceHandler(client paho.Client, msg paho.Message) { } present := false - pretty.Println(p.devices) + pretty.Logf("Presence updated: %v\n", p.devices) for _, value := range p.devices { if value { present = true @@ -55,7 +48,7 @@ func (p *Presence) devicePresenceHandler(client paho.Client, msg paho.Message) { } } - log.Println(present) + log.Printf("Setting overall presence: %t\n", present) if p.presence != present { p.presence = present @@ -76,66 +69,18 @@ func (p *Presence) devicePresenceHandler(client paho.Client, msg paho.Message) { } } -func (p *Presence) overallPresenceHandler(client paho.Client, msg paho.Message) { - if len(msg.Payload()) == 0 { - // In this case we clear the persistent message - return - } - var message Message - err := json.Unmarshal(msg.Payload(), &message) - if err != nil { - log.Println(err) - return - } +func New(client paho.Client, hue *hue.Hue, ntfy *ntfy.Notify, home *home.Home) *Presence { + p := &Presence{devices: make(map[string]bool), presence: false} - fmt.Printf("Presence: %t\n", message.State) - // Notify users of presence update - p.ntfy.Presence(p.presence) - - // Set presence on the hue bridge - p.hue.SetFlag(41, message.State) - - if !message.State { - log.Println("Turn off all the devices") - - // Turn off all devices - // @TODO Maybe allow for exceptions, could be a list in the config that we check against? - for _, dev := range p.provider.Devices.Devices { - switch d := dev.(type) { - case *kasa.Kasa: - d.SetState(false) - case device.ZigbeeDevice: - d.SetState(false) - } - - } - - // @TODO Turn off nest thermostat - } else { - // @TODO Turn on the nest thermostat again - } -} - -func New(client paho.Client, hue *hue.Hue, ntfy *ntfy.Notify, provider *device.Provider) *Presence { - p := &Presence{client: client, hue: hue, ntfy: ntfy, provider: provider, devices: make(map[string]bool), presence: false} - - if token := p.client.Subscribe("automation/presence", 1, p.overallPresenceHandler); token.Wait() && token.Error() != nil { - log.Println(token.Error()) - } - - if token := p.client.Subscribe("automation/presence/+", 1, p.devicePresenceHandler); token.Wait() && token.Error() != nil { + if token := client.Subscribe("automation/presence/+", 1, p.devicePresenceHandler); token.Wait() && token.Error() != nil { log.Println(token.Error()) } return p } -func (p *Presence) Delete() { - if token := p.client.Unsubscribe("automation/presence"); token.Wait() && token.Error() != nil { - log.Println(token.Error()) - } - - if token := p.client.Unsubscribe("automation/presence/+"); token.Wait() && token.Error() != nil { +func (p *Presence) Delete(client paho.Client) { + if token := client.Unsubscribe("automation/presence/+"); token.Wait() && token.Error() != nil { log.Println(token.Error()) } }