Finished major refactor
Some checks failed
continuous-integration/drone/push Build is failing

This commit is contained in:
Dreaded_X 2022-11-19 03:50:12 +01:00
parent 01b2d492ba
commit c091ad0782
Signed by: Dreaded_X
GPG Key ID: 76BDEC4E165D8AD9
17 changed files with 512 additions and 599 deletions

View File

@ -13,7 +13,10 @@ kasa:
living_room/speakers: 10.0.0.182 living_room/speakers: 10.0.0.182
computers: computers:
zeus: living_room/zeus:
mac: 30:9c:23:60:9c:13 mac: 30:9c:23:60:9c:13
room: Living Room room: Living Room
url: http://10.0.0.2:9000/start-pc?mac=30:9c:23:60:9c:13 url: http://10.0.0.2:9000/start-pc?mac=30:9c:23:60:9c:13
google:
username: Dreaded_X

78
config/config.go Normal file
View File

@ -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
}

43
device/device.go Normal file
View File

@ -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
}

33
device/internal_name.go Normal file
View File

@ -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)
}

View File

@ -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
}

98
home/home.go Normal file
View File

@ -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
}

View File

@ -1,6 +1,7 @@
package google package google
import ( import (
"automation/device"
"encoding/json" "encoding/json"
"io" "io"
"log" "log"
@ -10,10 +11,11 @@ import (
) )
type DeviceInterface interface { type DeviceInterface interface {
device.Basic
Sync() *Device Sync() *Device
Query() DeviceState Query() DeviceState
Execute(execution Execution, updatedState *DeviceState) (errCode string, online bool) Execute(execution Execution, updatedState *DeviceState) (errCode string, online bool)
GetID() string
} }
// https://developers.google.com/assistant/smarthome/reference/intent/sync // https://developers.google.com/assistant/smarthome/reference/intent/sync

View File

@ -5,13 +5,16 @@ import (
"encoding/binary" "encoding/binary"
"encoding/json" "encoding/json"
"fmt" "fmt"
"log"
"net" "net"
) )
// This implementation is based on: // This implementation is based on:
// https://www.softscheck.com/en/blog/tp-link-reverse-engineering/ // https://www.softscheck.com/en/blog/tp-link-reverse-engineering/
type Device interface {
GetIP() string
}
func encrypt(data []byte) []byte { func encrypt(data []byte) []byte {
var key byte = 171 var key byte = 171
buf := new(bytes.Buffer) buf := new(bytes.Buffer)
@ -45,18 +48,8 @@ func decrypt(data []byte) ([]byte, error) {
return buf, nil return buf, nil
} }
func sendCmd(kasa Device, cmd cmd) (reply, error) {
type Kasa struct { con, err := net.Dial("tcp", fmt.Sprintf("%s:9999", kasa.GetIP()))
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))
if err != nil { if err != nil {
return reply{}, err return reply{}, err
} }
@ -93,45 +86,3 @@ func (kasa *Kasa) sendCmd(cmd cmd) (reply, error) {
return reply, err 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
}

View File

@ -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
}

View File

@ -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)
}

View File

@ -11,7 +11,6 @@ type Notify struct {
} }
func (n *Notify) Presence(home bool) { func (n *Notify) Presence(home bool) {
// @TODO Maybe add list the devices that are home currently?
var description string var description string
var actions string var actions string
if home { if home {

View File

@ -1,6 +1,7 @@
package device package wol
import ( import (
"automation/device"
"automation/integration/google" "automation/integration/google"
"log" "log"
"net/http" "net/http"
@ -8,32 +9,48 @@ import (
type computer struct { type computer struct {
macAddress string macAddress string
name string name device.InternalName
room string
url string url string
} }
func NewComputer(macAddress string, name string, room string, url string) *computer { func NewComputer(macAddress string, name device.InternalName, url string) *computer {
c := &computer{macAddress: macAddress, name: name, room: room} c := &computer{macAddress: macAddress, name: name}
return c 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 { 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.AddSceneTrait(false)
device.Name = google.DeviceName{ device.Name = google.DeviceName{
DefaultNames: []string{ DefaultNames: []string{
"Computer", "Computer",
}, },
Name: c.name, Name: c.GetID().Name(),
} }
device.RoomHint = c.room device.RoomHint = c.GetID().Room()
return device return device
} }
// google.DeviceInterface
func (c *computer) Query() google.DeviceState { func (c *computer) Query() google.DeviceState {
state := google.NewDeviceState(true) state := google.NewDeviceState(true)
state.Status = google.StatusSuccess state.Status = google.StatusSuccess
@ -41,12 +58,13 @@ func (c *computer) Query() google.DeviceState {
return state return state
} }
// google.DeviceInterface
func (c *computer) Execute(execution google.Execution, updateState *google.DeviceState) (string, bool) { func (c *computer) Execute(execution google.Execution, updateState *google.DeviceState) (string, bool) {
errCode := "" errCode := ""
switch execution.Name { switch execution.Name {
case google.CommandActivateScene: case google.CommandActivateScene:
c.SetState(!execution.ActivateScene.Deactivate) c.Activate(!execution.ActivateScene.Deactivate)
default: default:
errCode = "actionNotAvailable" errCode = "actionNotAvailable"
log.Printf("Command (%s) not supported\n", execution.Name) 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 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
}
}

View File

@ -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())
}
}

View File

@ -1,19 +1,23 @@
package device package zigbee
import ( import (
"automation/device"
"automation/integration/google" "automation/integration/google"
"context"
"encoding/json" "encoding/json"
"fmt" "fmt"
"log" "log"
"strings"
"time" "time"
paho "github.com/eclipse/paho.mqtt.golang" paho "github.com/eclipse/paho.mqtt.golang"
) )
type kettle struct { type kettle struct {
info DeviceInfo info Info
client paho.Client client paho.Client
service *google.Service
updated chan bool updated chan bool
timerLength time.Duration timerLength time.Duration
@ -24,8 +28,19 @@ type kettle struct {
online bool online bool
} }
func (k *kettle) getState() google.DeviceState { func NewKettle(info Info, client paho.Client, service *google.Service) *kettle {
return google.NewDeviceState(k.online).RecordOnOff(k.isOn) 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) { 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 k.updated <- true
// Notify google of the updated state // Notify google of the updated state
// @TODO Fix this id := k.GetID().String()
// id := k.GetID() k.service.ReportState(context.Background(), id, map[string]google.DeviceState{
// s.ReportState(context.Background(), id, map[string]google.DeviceState{ id: k.getState(),
// id: k.getState(), })
// })
if k.isOn { if k.isOn {
k.timer.Reset(k.timerLength) k.timer.Reset(k.timerLength)
@ -73,51 +87,42 @@ func (k *kettle) timerFunc() {
} }
} }
func (k *kettle) Delete() { func (k *kettle) getState() google.DeviceState {
// The the timer function that it needs to stop return google.NewDeviceState(k.online).RecordOnOff(k.isOn)
k.stop <- struct{}{}
} }
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 // zigbee.Device
go k.timerFunc() 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 { if token := k.client.Subscribe(fmt.Sprintf("zigbee2mqtt/%s", k.info.FriendlyName), 1, k.stateHandler); token.Wait() && token.Error() != nil {
log.Println(token.Error()) log.Println(token.Error())
} }
return k
} }
func (k *kettle) IsZigbeeDevice() bool {
return true
}
// google.DeviceInterface
var _ google.DeviceInterface = (*kettle)(nil)
func (k *kettle) Sync() *google.Device { func (k *kettle) Sync() *google.Device {
device := google.NewDevice(k.GetID(), google.TypeKettle) device := google.NewDevice(k.GetID().String(), google.TypeKettle)
device.AddOnOffTrait(false, false) device.AddOnOffTrait(false, false)
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)
device.Name = google.DeviceName{ device.Name = google.DeviceName{
DefaultNames: []string{ DefaultNames: []string{
"Kettle", "Kettle",
}, },
Name: name, Name: k.GetID().Name(),
} }
// @TODO Fix reporting
// device.WillReportState = true
device.WillReportState = true device.WillReportState = true
if len(name) > 1 { room := k.GetID().Room()
if len(room) > 1 {
device.RoomHint = room device.RoomHint = room
} }
@ -130,10 +135,9 @@ func (k *kettle) Sync() *google.Device {
return device return device
} }
// google.DeviceInterface
func (k *kettle) Query() google.DeviceState { func (k *kettle) Query() google.DeviceState {
// We just report out internal representation as it should always match the actual state
state := k.getState() state := k.getState()
// No /get needed
if k.online { if k.online {
state.Status = google.StatusSuccess state.Status = google.StatusSuccess
} else { } else {
@ -143,6 +147,7 @@ func (k *kettle) Query() google.DeviceState {
return state return state
} }
// google.DeviceInterface
func (k *kettle) Execute(execution google.Execution, updatedState *google.DeviceState) (string, bool) { func (k *kettle) Execute(execution google.Execution, updatedState *google.DeviceState) (string, bool) {
errCode := "" errCode := ""
@ -154,7 +159,7 @@ func (k *kettle) Execute(execution google.Execution, updatedState *google.Device
<- k.updated <- k.updated
} }
k.SetState(execution.OnOff.On) k.SetOnOff(execution.OnOff.On)
// Start timeout timer // Start timeout timer
timer := time.NewTimer(time.Second) timer := time.NewTimer(time.Second)
@ -179,19 +184,15 @@ func (k *kettle) Execute(execution google.Execution, updatedState *google.Device
return errCode, k.online return errCode, k.online
} }
func (k *kettle) GetID() string { // device.Base
return k.info.IEEEAdress var _ device.Basic = (*kettle)(nil)
} func (k *kettle) GetID() device.InternalName {
func (k *kettle) GetName() string {
return k.info.FriendlyName return k.info.FriendlyName
} }
func (k *kettle) GetDeviceInfo() DeviceInfo { // device.OnOff
return k.info var _ device.OnOff = (*kettle)(nil)
} func (k *kettle) SetOnOff(state bool) {
func (k *kettle) SetState(state bool) {
msg := "OFF" msg := "OFF"
if state { if state {
msg = "ON" msg = "ON"
@ -202,3 +203,7 @@ func (k *kettle) SetState(state bool) {
} }
} }
// device.OnOff
func (k *kettle) GetOnOff() bool {
return k.isOn
}

View File

@ -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()
}

169
main.go
View File

@ -1,178 +1,69 @@
package main package main
import ( import (
"automation/device" "automation/automation"
"automation/config"
"automation/home"
"automation/integration/hue" "automation/integration/hue"
"automation/integration/kasa" "automation/integration/kasa"
"automation/integration/mqtt"
"automation/integration/ntfy" "automation/integration/ntfy"
"automation/integration/wol"
"automation/integration/zigbee"
"automation/presence" "automation/presence"
"encoding/json" "fmt"
"log" "log"
"net/http" "net/http"
"os" "os"
"github.com/gorilla/mux" "github.com/gorilla/mux"
"github.com/joho/godotenv" "github.com/joho/godotenv"
"github.com/kelseyhightower/envconfig"
"gopkg.in/yaml.v3"
paho "github.com/eclipse/paho.mqtt.golang" 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() { func main() {
_ = godotenv.Load() _ = godotenv.Load()
config := GetConfig() cfg := config.Get()
// Setup all the connections to other services // 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) defer client.Disconnect(250)
notify := ntfy.New(config.NTFY.topic) notify := ntfy.New(cfg.Ntfy.Topic)
hue := hue.New(config.Hue.IP, config.Hue.Token) hue := hue.New(cfg.Hue.IP, cfg.Hue.Token)
// Devices that we control and expose to google home // 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 // Setup presence system
p := presence.New(client, hue, notify, provider) p := presence.New(client, hue, notify, home)
defer p.Delete() defer p.Delete(client)
r := mux.NewRouter() r := mux.NewRouter()
r.HandleFunc("/assistant", provider.Service.FullfillmentHandler) r.HandleFunc("/assistant", home.Service.FullfillmentHandler)
// Register computers // Register computers
for name, info := range config.Computer { for name, info := range cfg.Computer {
provider.AddDevice(device.NewComputer(info.MACAddress, name, info.Room, info.Url)) home.AddDevice(wol.NewComputer(info.MACAddress, name, info.Url))
} }
// Register all kasa devies // Register all kasa devies
for name, ip := range config.Kasa.Outlets { for name, ip := range cfg.Kasa.Outlets {
provider.AddDevice(kasa.New(name, ip)) home.AddDevice(kasa.NewOutlet(name, ip))
} }
SetupBindings(client, provider) // Setup handler that automatically registers and updates all zigbee devices
zigbee.DevicesHandler(client, home)
// time.Sleep(time.Second) automation.RegisterAutomations(client, hue, notify, home)
// pretty.Println(provider.Devices)
// pretty.Println(provider.Devices.GetGoogleDevices())
// pretty.Println(provider.Devices.GetKasaDevices())
// pretty.Println(provider.Devices.GetZigbeeDevices())
addr := ":8090" addr := ":8090"
srv := http.Server{ srv := http.Server{

View File

@ -1,12 +1,10 @@
package presence package presence
import ( import (
"automation/device" "automation/home"
"automation/integration/hue" "automation/integration/hue"
"automation/integration/kasa"
"automation/integration/ntfy" "automation/integration/ntfy"
"encoding/json" "encoding/json"
"fmt"
"log" "log"
"strings" "strings"
"time" "time"
@ -16,11 +14,6 @@ import (
) )
type Presence struct { type Presence struct {
client paho.Client
hue *hue.Hue
ntfy *ntfy.Notify
provider *device.Provider
devices map[string]bool devices map[string]bool
presence bool presence bool
} }
@ -47,7 +40,7 @@ func (p *Presence) devicePresenceHandler(client paho.Client, msg paho.Message) {
} }
present := false present := false
pretty.Println(p.devices) pretty.Logf("Presence updated: %v\n", p.devices)
for _, value := range p.devices { for _, value := range p.devices {
if value { if value {
present = true 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 { if p.presence != present {
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) { func New(client paho.Client, hue *hue.Hue, ntfy *ntfy.Notify, home *home.Home) *Presence {
if len(msg.Payload()) == 0 { p := &Presence{devices: make(map[string]bool), presence: false}
// 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
}
fmt.Printf("Presence: %t\n", message.State) if token := client.Subscribe("automation/presence/+", 1, p.devicePresenceHandler); token.Wait() && token.Error() != nil {
// 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 {
log.Println(token.Error()) log.Println(token.Error())
} }
return p return p
} }
func (p *Presence) Delete() { func (p *Presence) Delete(client paho.Client) {
if token := p.client.Unsubscribe("automation/presence"); token.Wait() && token.Error() != nil { if token := 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 {
log.Println(token.Error()) log.Println(token.Error())
} }
} }