Compare commits

...
This repository has been archived on 2023-08-29. You can view files and clone it, but cannot push or open issues or pull requests.

45 Commits

Author SHA1 Message Date
d6bbd78bec
Quick fix for the lights turning on in the middle of the night
All checks were successful
continuous-integration/drone/push Build is passing
2022-12-29 02:39:43 +01:00
d42afecd67
Added option to activate computer through mqtt
All checks were successful
continuous-integration/drone/push Build is passing
2022-12-03 02:15:06 +01:00
51958a0542
Added automation for door that temporarily sets presence (15 min) if no one is present yet
All checks were successful
continuous-integration/drone/push Build is passing
2022-12-03 00:04:57 +01:00
2b4e66b978
Added missing prefix argument
All checks were successful
continuous-integration/drone/push Build is passing
2022-11-26 18:44:22 +01:00
eef5385f75
Mixer automation now listens to actual remote
All checks were successful
continuous-integration/drone/push Build is passing
2022-11-26 18:39:20 +01:00
8e0cc2140f
Adjusted illuminance thresholds once again
All checks were successful
continuous-integration/drone/push Build is passing
2022-11-25 23:24:44 +01:00
10351018c0
Adjusted illuminance value
All checks were successful
continuous-integration/drone/push Build is passing
2022-11-24 20:49:20 +01:00
00f623ab45
Actally enable setting the flag on the hue bridge
All checks were successful
continuous-integration/drone/push Build is passing
2022-11-23 22:33:13 +01:00
abda5cc24f
Also adjusted timer
All checks were successful
continuous-integration/drone/push Build is passing
2022-11-23 22:23:06 +01:00
ca976b1143
First tune to on off value for light sensor
All checks were successful
continuous-integration/drone/push Build is passing
2022-11-23 22:20:49 +01:00
f13ee65ead
Added light sensor
All checks were successful
continuous-integration/drone/push Build is passing
2022-11-23 01:16:46 +01:00
780f633c90
Updated .drone.yml to include oauth url
All checks were successful
continuous-integration/drone/push Build is passing
2022-11-22 04:07:20 +01:00
6f5b3d13f7
Made the url used to check the token a config option
All checks were successful
continuous-integration/drone/push Build is passing
2022-11-22 03:33:00 +01:00
501775654f
More refactoring, made zigbee2mqtt prefix a config option
All checks were successful
continuous-integration/drone/push Build is passing
2022-11-21 23:47:15 +01:00
4227bd92b5
Url is now actually passed to the computer struct for wol
All checks were successful
continuous-integration/drone/push Build is passing
2022-11-21 21:58:22 +01:00
301551596a
Fixed topic for overall presence automation
All checks were successful
continuous-integration/drone/push Build is passing
2022-11-19 04:54:12 +01:00
5aefcf0157
More refactoring, moved kettle auto off out of the kettle implementation and into a seperate automation
All checks were successful
continuous-integration/drone/push Build is passing
2022-11-19 04:48:05 +01:00
20e7e830a6
Renamed output binary to avoid conflict
Some checks failed
continuous-integration/drone/push Build is failing
2022-11-19 03:57:44 +01:00
c091ad0782
Finished major refactor
Some checks failed
continuous-integration/drone/push Build is failing
2022-11-19 03:50:12 +01:00
01b2d492ba
All devices are now stored in a central map, part of large refactor
All checks were successful
continuous-integration/drone/push Build is passing
2022-11-18 21:12:09 +01:00
6368fce40d
Pass parameters directly instead of through a config struct 2022-11-18 20:09:52 +01:00
2df59cdb17
Started work on significant refactor of the codebase 2022-11-18 19:51:58 +01:00
c49ee841fd
Added timestamp to presence state and use mqtt instead of channel for overall presence
All checks were successful
continuous-integration/drone/push Build is passing
2022-11-18 18:50:36 +01:00
07fa5fc986
Added caching for authorization
All checks were successful
continuous-integration/drone/push Build is passing
2022-11-18 05:30:21 +01:00
4c023ad933
Did some groundwork for setting up more complex interactions
All checks were successful
continuous-integration/drone/push Build is passing
2022-11-18 04:27:43 +01:00
2aff4d937a
Made the kasa integration more robust and added GetState function to get the current state 2022-11-18 04:05:13 +01:00
028ede0721
The current state of the kettle is now retained on mqtt, so we do not have to ask for it anymore 2022-11-18 03:43:34 +01:00
3dac6c66f6
Updated mqtt address to the correct address
All checks were successful
continuous-integration/drone/push Build is passing
2022-11-16 05:39:45 +01:00
32f3d013f8
Fixed Dockerfile and build script
All checks were successful
continuous-integration/drone/push Build is passing
2022-11-16 05:33:09 +01:00
9f4be2d76e
Implemented better config system
All checks were successful
continuous-integration/drone/push Build is passing
2022-11-16 05:20:59 +01:00
644f038732
Turn off speakers and mixer when no one is present
All checks were successful
continuous-integration/drone/push Build is passing
2022-11-15 04:42:32 +01:00
656b040cdc
Turn off all devices that we manage if no one is present 2022-11-15 04:00:48 +01:00
bfeedece77
Added new device: computer
All checks were successful
continuous-integration/drone/push Build is passing
2022-11-15 03:16:08 +01:00
ad5b8f9d29
Fixed network setup for docker deploy
All checks were successful
continuous-integration/drone/push Build is passing
2022-11-15 01:10:52 +01:00
dd03ae56ee
Reorganized code, added google home intergrations and added zigbee2mqtt kettle
Some checks failed
continuous-integration/drone/push Build is failing
2022-11-15 01:03:30 +01:00
dace0eba29
Refactored code and added support for kasa smart plugs
All checks were successful
continuous-integration/drone/push Build is passing
2022-09-24 00:42:42 +02:00
76a8c5e620
Added hue bridge discovery and check to make sure we are connected
All checks were successful
continuous-integration/drone/push Build is passing
2022-09-18 04:03:58 +02:00
8b9e139b36
Fixed ntfy not working when deployed
All checks were successful
continuous-integration/drone/push Build is passing
2022-09-17 03:55:10 +02:00
748e12b1e3
Added debugging prints
All checks were successful
continuous-integration/drone/push Build is passing
2022-09-17 03:53:00 +02:00
c8a61c0a8a
Fixed missing newline
All checks were successful
continuous-integration/drone/push Build is passing
2022-09-16 22:35:02 +02:00
0936242a41
Fixed wrong symbol in drone config
All checks were successful
continuous-integration/drone/push Build is passing
2022-09-16 22:30:59 +02:00
c6bf97c3db
Added error check
All checks were successful
continuous-integration/drone/push Build is passing
2022-09-16 22:29:11 +02:00
e5a6b75f62
Added notifcation when presence state changes
All checks were successful
continuous-integration/drone/push Build is passing
2022-09-16 22:24:32 +02:00
e3edee3e09
Added missing leaving home code
All checks were successful
continuous-integration/drone/push Build is passing
2022-09-16 21:40:36 +02:00
b4031e4198
Refactored code to make it more readable
All checks were successful
continuous-integration/drone/push Build is passing
2022-09-16 21:34:19 +02:00
42 changed files with 3660 additions and 168 deletions

View File

@ -1,5 +1,5 @@
.git/
storage/
automation
app
.env
tmp/

View File

@ -17,22 +17,25 @@ steps:
- name: socket
path: /var/run/docker.sock
environment:
MQTT_HOST:
from_secret: MQTT_HOST
MQTT_PORT:
from_secret: MQTT_PORT
MQTT_USER:
from_secret: MQTT_USER
MQTT_PASS:
from_secret: MQTT_PASS
HUE_BRIDGE:
from_secret: HUE_BRIDGE
MQTT_PASSWORD:
from_secret: MQTT_PASSWORD
HUE_TOKEN:
from_secret: HUE_TOKEN
NTFY_TOPIC:
from_secret: NTFY_TOPIC
GOOGLE_OAUTH_URL:
from_secret: GOOGLE_OAUTH_URL
GOOGLE_CREDENTIALS:
from_secret: GOOGLE_CREDENTIALS
commands:
- docker stop automation || true
- docker rm automation || true
- docker run -e MQTT_HOST=$MQTT_HOST -e MQTT_PORT=$MQTT_PORT -e MQTT_USER=$MQTT_USER -e MQTT_PASS=$MQTT_PASS -e MQTT_CLIENT_ID=$MQTT_CLIENT_ID -e HUE_BRIDGE=$HUE_BRIDGE --network mqtt --name automation -d automation
- docker create -e MQTT_PASSWORD=$MQTT_PASSWORD -e HUE_TOKEN=$HUE_TOKEN -e NTFY_TOPIC=$NTFY_TOPIC -e GOOGLE_OAUTH_URL=$GOOGLE_OAUTH_URL -e GOOGLE_CREDENTIALS=$GOOGLE_CREDENTIALS --network mqtt --name automation automation
- docker network connect mqtt automation
- docker network connect web automation
- docker start automation
when:
branch:

2
.gitignore vendored
View File

@ -1,4 +1,4 @@
storage/
automation
app
.env
tmp/

View File

@ -7,12 +7,13 @@ RUN go mod download
COPY . .
RUN go build
RUN go build -o app
FROM golang:alpine
WORKDIR /app
COPY --from=build-automation /src/automation /app/automation
COPY --from=build-automation /src/app /app/app
COPY --from=build-automation /src/config.yml /app/config.yml
CMD ["/app/automation"]
CMD ["/app/app"]

46
automation/automation.go Normal file
View File

@ -0,0 +1,46 @@
package automation
import (
"automation/home"
"automation/integration/hue"
"automation/integration/ntfy"
"automation/presence"
"encoding/json"
"log"
paho "github.com/eclipse/paho.mqtt.golang"
)
func on[M any](client paho.Client, topic string, onMessage func(message M)) {
var handler paho.MessageHandler = func(c paho.Client, m paho.Message) {
if len(m.Payload()) == 0 {
// In this case we clear the persistent message
// @TODO Maybe implement onClear as a callback? (Currently not needed)
return
}
var message M;
err := json.Unmarshal(m.Payload(), &message)
if err != nil {
log.Println(err)
return
}
if onMessage != nil {
onMessage(message)
}
}
if token := client.Subscribe(topic, 1, handler); token.Wait() && token.Error() != nil {
log.Println(token.Error())
}
}
func RegisterAutomations(client paho.Client, prefix string, hue *hue.Hue, notify *ntfy.Notify, home *home.Home, presence *presence.Presence) {
presenceAutomation(client, hue, notify, home)
mixerAutomation(client, prefix, home)
kettleAutomation(client, prefix, home)
darknessAutomation(client, hue)
frontdoorAutomation(client, prefix, presence)
zeusAutomation(client, home)
}

14
automation/dark.go Normal file
View File

@ -0,0 +1,14 @@
package automation
import (
"automation/integration/hue"
"automation/integration/zigbee"
paho "github.com/eclipse/paho.mqtt.golang"
)
func darknessAutomation(client paho.Client, hue *hue.Hue) {
on(client, "automation/darkness/living", func(message zigbee.DarknessPayload) {
hue.SetFlag(43, message.IsDark)
})
}

56
automation/frontdoor.go Normal file
View File

@ -0,0 +1,56 @@
package automation
import (
"automation/presence"
"encoding/json"
"fmt"
"log"
"time"
paho "github.com/eclipse/paho.mqtt.golang"
)
type Message struct {
Contact bool `json:"contact"`
}
func frontdoorAutomation(client paho.Client, prefix string, p *presence.Presence) {
const length = 15 * time.Minute
timer := time.NewTimer(length)
timer.Stop()
on(client, fmt.Sprintf("%s/hallway/frontdoor", prefix), func(message Message) {
// Always reset the timer if the door is opened
if !message.Contact {
timer.Reset(length)
}
// If the door opens an there is no one home
if !message.Contact && !p.Current() {
payload, err := json.Marshal(presence.Message{
State: true,
Updated: time.Now().UnixMilli(),
})
if err != nil {
log.Println(err)
}
token := client.Publish("automation/presence/frontdoor", 1, false, payload)
if token.Wait() && token.Error() != nil {
log.Println(token.Error())
}
}
})
go func() {
for {
<-timer.C
// Clear out the value
token := client.Publish("automation/presence/frontdoor", 1, false, "")
if token.Wait() && token.Error() != nil {
log.Println(token.Error())
}
}
}()
}

42
automation/kettle.go Normal file
View File

@ -0,0 +1,42 @@
package automation
import (
"automation/device"
"automation/home"
"automation/integration/zigbee"
"fmt"
"log"
"time"
paho "github.com/eclipse/paho.mqtt.golang"
)
func kettleAutomation(client paho.Client, prefix string, home *home.Home) {
const name = "kitchen/kettle"
const length = 5 * time.Minute
timer := time.NewTimer(length)
timer.Stop()
on(client, fmt.Sprintf("%s/%s", prefix, name), func(message zigbee.OnOffState) {
if message.State {
timer.Reset(length)
} else {
timer.Stop()
}
})
go func() {
for {
<-timer.C
log.Println("Turning kettle automatically off")
kettle, err := device.GetDevice[device.OnOff](&home.Devices, name)
if err != nil {
log.Println(err)
break
}
kettle.SetOnOff(false)
}
}()
}

43
automation/mixer.go Normal file
View File

@ -0,0 +1,43 @@
package automation
import (
"automation/device"
"automation/home"
"automation/integration/zigbee"
"fmt"
"log"
paho "github.com/eclipse/paho.mqtt.golang"
)
func mixerAutomation(client paho.Client, prefix string, home *home.Home) {
on(client, fmt.Sprintf("%s/living/remote", prefix), func(message zigbee.RemoteState) {
mixer, err := device.GetDevice[device.OnOff](&home.Devices, "living_room/mixer")
if err != nil {
log.Println(err)
return
}
speakers, err := device.GetDevice[device.OnOff](&home.Devices, "living_room/speakers")
if err != nil {
log.Println(err)
return
}
if message.Action == zigbee.ACTION_ON {
if mixer.GetOnOff() {
mixer.SetOnOff(false)
speakers.SetOnOff(false)
} else {
mixer.SetOnOff(true)
}
} else if message.Action == zigbee.ACTION_BRIGHTNESS_UP {
if speakers.GetOnOff() {
speakers.SetOnOff(false)
} else {
speakers.SetOnOff(true)
mixer.SetOnOff(true)
}
}
})
}

42
automation/presence.go Normal file
View File

@ -0,0 +1,42 @@
package automation
import (
"automation/device"
"automation/home"
"automation/integration/hue"
"automation/integration/ntfy"
"automation/presence"
"log"
paho "github.com/eclipse/paho.mqtt.golang"
)
func presenceAutomation(client paho.Client, hue *hue.Hue, notify *ntfy.Notify, home *home.Home) {
on(client, "automation/presence", func(message presence.Message) {
log.Printf("Presence changed: %t\n", message.State)
// Set presence on the hue bridge
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 home.Devices {
switch d := dev.(type) {
case device.OnOff:
d.SetOnOff(false)
}
}
// @TODO Turn off nest thermostat
} else {
// @TODO Turn on the nest thermostat again
}
// Notify users of presence update
notify.Presence(message.State)
})
}

23
automation/zeus.go Normal file
View File

@ -0,0 +1,23 @@
package automation
import (
"automation/device"
"automation/home"
"fmt"
"log"
paho "github.com/eclipse/paho.mqtt.golang"
)
func zeusAutomation(client paho.Client, home *home.Home) {
const name = "living_room/zeus"
on(client, fmt.Sprintf("automation/appliance/%s", name), func(message struct{Activate bool `json:"activate"`}) {
computer, err := device.GetDevice[device.Activate](&home.Devices, name)
if err != nil {
log.Println(err)
return
}
computer.Activate(message.Activate)
})
}

25
config.yml Normal file
View File

@ -0,0 +1,25 @@
hue:
ip: 10.0.0.146
mqtt:
host: mosquitto
port: 8883
username: mqtt
client_id: automation
zigbee:
prefix: zigbee2mqtt
kasa:
outlets:
living_room/mixer: 10.0.0.49
living_room/speakers: 10.0.0.182
computers:
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

83
config/config.go Normal file
View File

@ -0,0 +1,83 @@
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"`
Zigbee struct {
MQTTPrefix string `yaml:"prefix" envconfig:"ZIGBEE2MQTT_PREFIX"`
}
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"`
OAuthUrl string `yaml:"oauth_url" envconfig:"GOOGLE_OAUTH_URL"`
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
}

1153
data.csv Normal file

File diff suppressed because it is too large Load Diff

47
device/device.go Normal file
View File

@ -0,0 +1,47 @@
package device
import (
"fmt"
)
type Basic interface {
GetID() InternalName
}
type OnOff interface {
SetOnOff(state bool)
GetOnOff() bool
}
type Activate interface {
Activate(state 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
}

31
device/internal_name.go Normal file
View File

@ -0,0 +1,31 @@
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, "_", " ")
return room
}
func (n InternalName) Name() string {
s := strings.Split(string(n), "/")
name := s[0]
if len(s) > 1 {
name = s[1]
}
return name
}
func (n InternalName) String() string {
return string(n)
}

31
go.mod
View File

@ -1,14 +1,39 @@
module automation
go 1.17
go 1.19
require (
github.com/eclipse/paho.mqtt.golang v1.3.5
github.com/google/uuid v1.3.0
github.com/gorilla/mux v1.8.0
github.com/jellydator/ttlcache/v3 v3.0.0
github.com/joho/godotenv v1.4.0
github.com/kelvins/sunrisesunset v0.0.0-20210220141756-39fa1bd816d5
github.com/kelseyhightower/envconfig v1.4.0
github.com/kr/pretty v0.3.1
github.com/r3labs/sse/v2 v2.8.1
google.golang.org/api v0.103.0
gopkg.in/yaml.v3 v3.0.1
)
require (
cloud.google.com/go/compute v1.12.1 // indirect
cloud.google.com/go/compute/metadata v0.2.1 // indirect
github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e // indirect
github.com/golang/protobuf v1.5.2 // indirect
github.com/googleapis/enterprise-certificate-proxy v0.2.0 // indirect
github.com/googleapis/gax-go/v2 v2.7.0 // indirect
github.com/gorilla/websocket v1.4.2 // indirect
golang.org/x/net v0.0.0-20200425230154-ff2c4b7c35a0 // indirect
github.com/kr/text v0.2.0 // indirect
github.com/rogpeppe/go-internal v1.9.0 // indirect
go.opencensus.io v0.24.0 // indirect
golang.org/x/net v0.0.0-20221014081412-f15817d10f9b // indirect
golang.org/x/oauth2 v0.0.0-20221014153046-6fdb5e3db783 // indirect
golang.org/x/sync v0.1.0 // indirect
golang.org/x/sys v0.0.0-20220728004956-3c1f35247d10 // indirect
golang.org/x/text v0.4.0 // indirect
google.golang.org/appengine v1.6.7 // indirect
google.golang.org/genproto v0.0.0-20221027153422-115e99e71e1c // indirect
google.golang.org/grpc v1.50.1 // indirect
google.golang.org/protobuf v1.28.1 // indirect
gopkg.in/cenkalti/backoff.v1 v1.1.0 // indirect
)

164
go.sum
View File

@ -1,14 +1,172 @@
cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
cloud.google.com/go v0.105.0 h1:DNtEKRBAAzeS4KyIory52wWHuClNaXJ5x1F7xa4q+5Y=
cloud.google.com/go/compute v1.12.1 h1:gKVJMEyqV5c/UnpzjjQbo3Rjvvqpr9B1DFSbJC4OXr0=
cloud.google.com/go/compute v1.12.1/go.mod h1:e8yNOBcBONZU1vJKCvCoDw/4JQsA0dpM4x/6PIIOocU=
cloud.google.com/go/compute/metadata v0.2.1 h1:efOwf5ymceDhK6PKMnnrTHP4pppY5L22mle96M1yP48=
cloud.google.com/go/compute/metadata v0.2.1/go.mod h1:jgHgmJd2RKBGzXqF5LR2EZMGxBkeanZ9wwa75XHJgOM=
cloud.google.com/go/longrunning v0.1.1 h1:y50CXG4j0+qvEukslYFBCrzaXX0qpFbBzc3PchSu/LE=
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/eclipse/paho.mqtt.golang v1.3.5 h1:sWtmgNxYM9P2sP+xEItMozsR3w0cqZFlqnNN1bdl41Y=
github.com/eclipse/paho.mqtt.golang v1.3.5/go.mod h1:eTzb4gxwwyWpqBUHGQZ4ABAV7+Jgm1PklsYT/eo8Hcc=
github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98=
github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c=
github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=
github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e h1:1r7pUrabqp18hOBcwBwiTsbnFeTZHV9eER/QT5JVZxY=
github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A=
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8=
github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA=
github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs=
github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w=
github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0=
github.com/golang/protobuf v1.4.1/go.mod h1:U8fpvMrcmy5pZrNK1lt4xCsGvpyWQ/VVv6QDs8UjoX8=
github.com/golang/protobuf v1.4.3/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
github.com/golang/protobuf v1.5.2 h1:ROPKBNFfQgOUMifHyP+KYbvpjbdoFNs+aK7DXlji0Tw=
github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M=
github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.3/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38=
github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I=
github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/googleapis/enterprise-certificate-proxy v0.2.0 h1:y8Yozv7SZtlU//QXbezB6QkpuE6jMD2/gfzk4AftXjs=
github.com/googleapis/enterprise-certificate-proxy v0.2.0/go.mod h1:8C0jb7/mgJe/9KK8Lm7X9ctZC2t60YyIpYEI16jx0Qg=
github.com/googleapis/gax-go/v2 v2.7.0 h1:IcsPKeInNvYi7eqSaDjiZqDDKu5rsmunY0Y1YupQSSQ=
github.com/googleapis/gax-go/v2 v2.7.0/go.mod h1:TEop28CZZQ2y+c0VxMUmu1lV+fQx57QpBWsYpwqHJx8=
github.com/gorilla/mux v1.8.0 h1:i40aqfkR1h2SlN9hojwV5ZA91wcXFOvkdNIeFDP5koI=
github.com/gorilla/mux v1.8.0/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So=
github.com/gorilla/websocket v1.4.2 h1:+/TMaTYc4QFitKJxsQ7Yye35DkWvkdLcvGKqM+x0Ufc=
github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/jellydator/ttlcache/v3 v3.0.0 h1:zmFhqrB/4sKiEiJHhtseJsNRE32IMVmJSs4++4gaQO4=
github.com/jellydator/ttlcache/v3 v3.0.0/go.mod h1:WwTaEmcXQ3MTjOm4bsZoDFiCu/hMvNWLO1w67RXz6h4=
github.com/joho/godotenv v1.4.0 h1:3l4+N6zfMWnkbPEXKng2o2/MR5mSwTrBih4ZEkkz1lg=
github.com/joho/godotenv v1.4.0/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
github.com/kelvins/sunrisesunset v0.0.0-20210220141756-39fa1bd816d5 h1:ouekCqYkMw4QXFCaLyYqjBe99/MUW4Qf3DJhCRh1G18=
github.com/kelvins/sunrisesunset v0.0.0-20210220141756-39fa1bd816d5/go.mod h1:3oZ7G+fb8Z8KF+KPHxeDO3GWpEjgvk/f+d/yaxmDRT4=
github.com/kelseyhightower/envconfig v1.4.0 h1:Im6hONhd3pLkfDFsbRgu68RDNkGF1r3dvMUtDTo2cv8=
github.com/kelseyhightower/envconfig v1.4.0/go.mod h1:cccZRl6mQpaq41TPp5QxidR+Sa3axMbJDNb//FQX6Gg=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
github.com/r3labs/sse/v2 v2.8.1 h1:lZH+W4XOLIq88U5MIHOsLec7+R62uhz3bIi2yn0Sg8o=
github.com/r3labs/sse/v2 v2.8.1/go.mod h1:Igau6Whc+F17QUgML1fYe1VPZzTV6EMCnYktEmkNJ7I=
github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
go.opencensus.io v0.24.0 h1:y73uSU6J157QMP2kn2r30vwW1A2W2WFwSCGnAVxeaD0=
go.opencensus.io v0.24.0/go.mod h1:vNK8G9p7aAivkbmorf4v+7Hgx+Zs0yY+0fOtgBfjQKo=
go.uber.org/goleak v1.1.10 h1:z+mqJhf6ss6BSfSM671tgKyZBFPTTJM+HLxnhPC3wu0=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/net v0.0.0-20200425230154-ff2c4b7c35a0 h1:Jcxah/M+oLZ/R4/z5RzfPzGbPXnVDPkEDtf2JnuxN+U=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU=
golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
golang.org/x/lint v0.0.0-20201208152925-83fdc39ff7b5 h1:2M3HP5CCK1Si9FQhwnzYhXdG6DXeebvUHFpre8QvbyI=
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks=
golang.org/x/net v0.0.0-20191116160921-f9c825593386/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200425230154-ff2c4b7c35a0/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
golang.org/x/net v0.0.0-20201110031124-69a78807bb2b/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
golang.org/x/net v0.0.0-20221014081412-f15817d10f9b h1:tvrvnPFcdzp294diPnrdZZZ8XUt2Tyj7svb7X52iDuU=
golang.org/x/net v0.0.0-20221014081412-f15817d10f9b/go.mod h1:YDH+HFinaLZZlnHAfSS6ZXJJ9M9t4Dl22yv3iI2vPwk=
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
golang.org/x/oauth2 v0.0.0-20221014153046-6fdb5e3db783 h1:nt+Q6cXKz4MosCSpnbMtqiQ8Oz0pxTef2B4Vca2lvfk=
golang.org/x/oauth2 v0.0.0-20221014153046-6fdb5e3db783/go.mod h1:h4gKUeWbJ4rQPri7E0u6Gs4e9Ri2zaLxzw5DI5XGrYg=
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0 h1:wsuoTGHzEhffawBOhz5CYhcrV4IdKZbEyZjBMuTp12o=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20220728004956-3c1f35247d10 h1:WIoqL4EROvwiPdUtaip4VcDdpZ4kha7wBWZrbVKCIZg=
golang.org/x/sys v0.0.0-20220728004956-3c1f35247d10/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.4.0 h1:BrVqGRd7+k1DiOgtnFvAkoQEWQvBc25ouMJM6429SFg=
golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY=
golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
golang.org/x/tools v0.1.12 h1:VveCTK38A2rkS8ZqFY25HIDFscX5X9OoEhJd3quQmXU=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20220907171357-04be3eba64a2 h1:H2TDz8ibqkAF6YGhCdN3jS9O0/s90v0rJh3X/OLHEUk=
google.golang.org/api v0.103.0 h1:9yuVqlu2JCvcLg9p8S3fcFLZij8EPSyvODIY1rkMizQ=
google.golang.org/api v0.103.0/go.mod h1:hGtW6nK1AC+d9si/UBhw8Xli+QMOf6xyNAyJw4qU9w0=
google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
google.golang.org/appengine v1.6.7 h1:FZR1q0exgwxzPzp/aF+VccGrSfxfPpkBqjIIEq3ru6c=
google.golang.org/appengine v1.6.7/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc=
google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc=
google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo=
google.golang.org/genproto v0.0.0-20221027153422-115e99e71e1c h1:QgY/XxIAIeccR+Ca/rDdKubLIU9rcJ3xfy1DC/Wd2Oo=
google.golang.org/genproto v0.0.0-20221027153422-115e99e71e1c/go.mod h1:CGI5F/G+E5bKwmfYo09AXuVN4dD894kIKUFmVbP2/Fo=
google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg=
google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY=
google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk=
google.golang.org/grpc v1.33.2/go.mod h1:JMHMWHQWaTccqQQlmk3MJZS+GWXOdAesneDmEnv2fbc=
google.golang.org/grpc v1.50.1 h1:DS/BukOZWp8s6p4Dt/tOaJaTQyPyOoCcrjroHuCeLzY=
google.golang.org/grpc v1.50.1/go.mod h1:ZgQEeidpAuNRZ8iRrlBKXZQP1ghovWIVhdJRyCDK+GI=
google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=
google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM=
google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE=
google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo=
google.golang.org/protobuf v1.22.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
google.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c=
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
google.golang.org/protobuf v1.28.1 h1:d0NfwRgPtno5B1Wa6L2DAG+KivqkdutMf1UhdNx175w=
google.golang.org/protobuf v1.28.1/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
gopkg.in/cenkalti/backoff.v1 v1.1.0 h1:Arh75ttbsvlpVA7WtVpH4u9h6Zl46xuptxqLxPiSo4Y=
gopkg.in/cenkalti/backoff.v1 v1.1.0/go.mod h1:J6Vskwqd+OMVJl8C33mmtxTBs2gyzfv7UDAkHu8BrjI=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=

96
home/home.go Normal file
View File

@ -0,0 +1,96 @@
package home
import (
"automation/config"
"automation/device"
"automation/integration/google"
"context"
"log"
"google.golang.org/api/homegraph/v1"
"google.golang.org/api/option"
)
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, oauthUrl string) *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, oauthUrl)
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

@ -0,0 +1,91 @@
package google
import (
"encoding/json"
"fmt"
)
type CommandName string
type Execution struct {
Name CommandName
OnOff *CommandOnOffData
StartStop *CommandStartStopData
GetCameraStream *CommandGetCameraStreamData
ActivateScene *CommandActivateSceneData
}
func (c *Execution) UnmarshalJSON(data []byte) error {
var tmp struct {
Name CommandName `json:"command"`
Params json.RawMessage `json:"params,omitempty"`
}
err := json.Unmarshal(data, &tmp)
if err != nil {
return err
}
c.Name = tmp.Name
var details interface{}
switch c.Name {
case CommandOnOff:
c.OnOff = &CommandOnOffData{}
details = c.OnOff
case CommandStartStop:
c.StartStop = &CommandStartStopData{}
details = c.StartStop
case CommandGetCameraStream:
c.GetCameraStream = &CommandGetCameraStreamData{}
details = c.GetCameraStream
case CommandActivateScene:
c.ActivateScene = &CommandActivateSceneData{}
details = c.ActivateScene
default:
return fmt.Errorf("Command (%s) is not implemented", c.Name)
}
err = json.Unmarshal(tmp.Params, details)
if err != nil {
return err
}
return nil
}
// https://developers.google.com/assistant/smarthome/traits/onoff
const CommandOnOff CommandName = "action.devices.commands.OnOff"
type CommandOnOffData struct {
On bool `json:"on"`
}
// https://developers.google.com/assistant/smarthome/traits/startstop
const CommandStartStop CommandName = "action.devices.commands.StartStop"
type CommandStartStopData struct {
Start bool `json:"start"`
Zone string `json:"zone,omitempty"`
MultipleZones []string `json:"multipleZones,omitempty"`
}
// https://developers.google.com/assistant/smarthome/traits/camerastream
const CommandGetCameraStream CommandName = "action.devices.commands.GetCameraStream"
type CommandGetCameraStreamData struct {
StreamToChromecast bool `json:"StreamToChromecast"`
SupportedStreamProtocols []string `json:"SupportedStreamProtocols"`
}
// https://developers.google.com/assistant/smarthome/traits/scene
const CommandActivateScene CommandName = "action.devices.commands.ActivateScene"
type CommandActivateSceneData struct {
Deactivate bool `json:"deactivate"`
}

View File

@ -0,0 +1,51 @@
package google
type DeviceName struct {
DefaultNames []string `json:"defaultNames,omitempty"`
Name string `json:"name"`
Nicknames []string `json:"nicknames,omitempty"`
}
type DeviceInfo struct {
Manufacturer string `json:"manufacturer,omitempty"`
Model string `json:"model,omitempty"`
HwVersion string `json:"hwVersion,omitempty"`
SwVersion string `json:"swVersion,omitempty"`
}
type OtherDeviceID struct {
AgentID string `json:"agentId,omitempty"`
DeviceID string `json:"deviceId,omitempty"`
}
type Device struct {
ID string `json:"id"`
Type Type `json:"type"`
Traits []Trait `json:"traits"`
Name DeviceName `json:"name"`
WillReportState bool `json:"willReportState"`
NotificationSupportedByAgent bool `json:"notificationSupportedByAgent,omitempty"`
RoomHint string `json:"roomHint,omitempty"`
DeviceInfo DeviceInfo `json:"deviceInfo,omitempty"`
Attributes map[string]interface{} `json:"attributes,omitempty"`
CustomData map[string]interface{} `json:"customDate,omitempty"`
OtherDeviceIDs []OtherDeviceID `json:"otherDeviceIds,omitempty"`
}
func NewDevice(id string, typ Type) *Device {
return &Device{
ID: id,
Type: typ,
Attributes: make(map[string]interface{}),
CustomData: make(map[string]interface{}),
}
}

View File

@ -0,0 +1,246 @@
package google
import (
"automation/device"
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"github.com/jellydator/ttlcache/v3"
)
type DeviceInterface interface {
device.Basic
IsGoogleDevice()
Sync() *Device
Query() DeviceState
Execute(execution Execution, updatedState *DeviceState) (errCode string, online bool)
}
// https://developers.google.com/assistant/smarthome/reference/intent/sync
type syncResponse struct {
RequestID string `json:"requestId"`
Payload struct {
UserID string `json:"agentUserId"`
ErrorCode string `json:"errorCode,omitempty"`
DebugString string `json:"debugString,omitempty"`
Devices []*Device `json:"devices"`
} `json:"payload"`
}
// https://developers.google.com/assistant/smarthome/reference/intent/query
type queryResponse struct {
RequestID string `json:"requestId"`
Payload struct {
ErrorCode string `json:"errorCode,omitempty"`
DebugString string `json:"debugString,omitempty"`
Devices map[string]DeviceState `json:"devices"`
} `json:"payload"`
}
type executeRespPayload struct {
IDs []string `json:"ids"`
Status Status `json:"status"`
ErrorCode string `json:"errorCode,omitempty"`
States DeviceState `json:"states,omitempty"`
}
type executeResponse struct {
RequestID string `json:"requestId"`
Payload struct {
ErrorCode string `json:"errorCode,omitempty"`
DebugString string `json:"debugString,omitempty"`
Commands []executeRespPayload `json:"commands,omitempty"`
} `json:"payload"`
}
// @TODO We can also implement this as a cache loader function
// Note sure how to report the correct errors in that case?
func (s *Service) getUser(authorization string) (string, int) {
// @TODO Make oids url configurable
cached := s.cache.Get(authorization)
if cached != nil {
return cached.Value(), http.StatusOK
}
req, err := http.NewRequest("GET", fmt.Sprintf("%s/userinfo", s.oauthUrl), nil)
if err != nil {
log.Println("Failed to make request to to login server")
return "", http.StatusInternalServerError
}
req.Header.Set("Authorization", authorization)
client := &http.Client{}
resp, err := client.Do(req)
// If we get something other than 200, error out
if resp.StatusCode != http.StatusOK {
log.Println("Not logged in...")
return "", resp.StatusCode
}
// Get the preferred_username from the userinfo
bodyBytes, err := io.ReadAll(resp.Body)
if err != nil {
log.Println("Failed to read body")
return "", resp.StatusCode
}
var body struct {
PreferredUsername string `json:"preferred_username"`
}
err = json.Unmarshal(bodyBytes, &body)
if err != nil {
log.Println("Failed to marshal body")
return "", http.StatusInternalServerError
}
if len(body.PreferredUsername) == 0 {
log.Println("Received empty username from userinfo endpoint")
return "", http.StatusInternalServerError
}
s.cache.Set(authorization, body.PreferredUsername, ttlcache.DefaultTTL)
return body.PreferredUsername, http.StatusOK
}
func (s *Service) FullfillmentHandler(w http.ResponseWriter, r *http.Request) {
defer r.Body.Close()
// Check if we are logged in
var userID string
if auth, ok := r.Header["Authorization"]; ok && len(auth) > 0 {
var statusCode int
userID, statusCode = s.getUser(auth[0])
if statusCode != http.StatusOK {
w.WriteHeader(statusCode)
return
}
} else {
log.Println("No authorization provided")
w.WriteHeader(http.StatusBadRequest)
return
}
fullfimentReq := &FullfillmentRequest{}
err := json.NewDecoder(r.Body).Decode(&fullfimentReq)
if err != nil {
w.WriteHeader(http.StatusBadRequest)
w.Write([]byte("JSON Deserialization failed"))
return
}
if len(fullfimentReq.Inputs) != 1 {
w.WriteHeader(http.StatusBadRequest)
w.Write([]byte("Unsupported number of inputs"))
return
}
switch fullfimentReq.Inputs[0].Intent {
case IntentSync:
devices, err := s.provider.Sync(r.Context(), userID)
if err != nil {
w.WriteHeader(http.StatusServiceUnavailable)
w.Write([]byte("Failed to sync"))
}
syncResp := &syncResponse{
RequestID: fullfimentReq.RequestID,
}
syncResp.Payload.UserID = userID
syncResp.Payload.Devices = devices
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
err = json.NewEncoder(w).Encode(syncResp)
if err != nil {
log.Println("Error serializing", err)
}
case IntentQuery:
states, err := s.provider.Query(r.Context(), userID, fullfimentReq.Inputs[0].Query.Devices)
if err != nil {
w.WriteHeader(http.StatusServiceUnavailable)
w.Write([]byte("Failed to sync"))
}
queryResp := &queryResponse{
RequestID: fullfimentReq.RequestID,
}
queryResp.Payload.Devices = states
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
err = json.NewEncoder(w).Encode(queryResp)
if err != nil {
log.Println("Error serializing", err)
}
case IntentExecute:
response, err := s.provider.Execute(r.Context(), userID, fullfimentReq.Inputs[0].Execute.Commands)
if err != nil {
w.WriteHeader(http.StatusServiceUnavailable)
w.Write([]byte("Failed to sync"))
}
executeResp := &executeResponse{
RequestID: fullfimentReq.RequestID,
}
if len(response.UpdatedDevices) > 0 {
c := executeRespPayload{
Status: StatusSuccess,
States: response.UpdatedState,
}
for _, id := range response.UpdatedDevices {
c.IDs = append(c.IDs, id)
}
executeResp.Payload.Commands = append(executeResp.Payload.Commands, c)
}
if len(response.OfflineDevices) > 0 {
c := executeRespPayload{
Status: StatusOffline,
}
for _, id := range response.UpdatedDevices {
c.IDs = append(c.IDs, id)
}
executeResp.Payload.Commands = append(executeResp.Payload.Commands, c)
}
for errCode, details := range response.FailedDevices {
c := executeRespPayload{
Status: StatusError,
ErrorCode: errCode,
}
for _, id := range details.Devices {
c.IDs = append(c.IDs, id)
}
executeResp.Payload.Commands = append(executeResp.Payload.Commands, c)
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
err = json.NewEncoder(w).Encode(executeResp)
if err != nil {
log.Println("Error serializing", err)
}
default:
log.Println("Intent is not implemented")
w.WriteHeader(http.StatusBadRequest)
w.Write([]byte("Not implemented for now"))
}
}

View File

@ -0,0 +1,77 @@
package google
import (
"encoding/json"
)
type Intent string
const (
IntentSync Intent = "action.devices.SYNC"
IntentQuery = "action.devices.QUERY"
IntentExecute = "action.devices.EXECUTE"
)
type DeviceHandle struct {
ID string `json:"id"`
CustomData map[string]interface{} `json:"customData,omitempty"`
}
type queryPayload struct {
Devices []DeviceHandle `json:"devices"`
}
type Command struct {
Devices []DeviceHandle `json:"devices"`
Execution []Execution `json:"execution"`
}
type executePayload struct {
Commands []Command `json:"commands"`
}
type fullfilmentInput struct {
Intent Intent
Query *queryPayload
Execute *executePayload
}
type FullfillmentRequest struct {
RequestID string `json:"requestId"`
Inputs []fullfilmentInput `json:"inputs"`
}
func (i *fullfilmentInput) UnmarshalJSON(data []byte) error {
var tmp struct {
Intent Intent `json:"intent"`
Payload json.RawMessage `json:"payload"`
}
err := json.Unmarshal(data, &tmp)
if err != nil {
return err
}
i.Intent = tmp.Intent
switch i.Intent {
case IntentQuery:
payload := &queryPayload{}
err = json.Unmarshal(tmp.Payload, payload)
if err != nil {
return err
}
i.Query = payload
case IntentExecute:
payload := &executePayload{}
err = json.Unmarshal(tmp.Payload, payload)
if err != nil {
return err
}
i.Execute = payload
}
return nil
}

View File

@ -0,0 +1,101 @@
package google
import (
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"time"
"github.com/google/uuid"
"github.com/jellydator/ttlcache/v3"
"google.golang.org/api/homegraph/v1"
)
type ExecuteResponse struct {
UpdatedState DeviceState
UpdatedDevices []string
OfflineDevices []string
// The key is the errorCode that is associated with the devices
FailedDevices map[string]struct {
Devices []string
}
}
type Provider interface {
Sync(context.Context, string) ([]*Device, error)
Query(context.Context, string, []DeviceHandle) (map[string]DeviceState, error)
Execute(context.Context, string, []Command) (*ExecuteResponse, error)
}
type Service struct {
provider Provider
deviceService *homegraph.DevicesService
oauthUrl string
cache *ttlcache.Cache[string, string]
}
func NewService(provider Provider, service *homegraph.Service, oauthUrl string) *Service {
s := Service{
provider: provider,
deviceService: homegraph.NewDevicesService(service),
oauthUrl: oauthUrl,
cache: ttlcache.New(
ttlcache.WithTTL[string, string](30 * time.Minute),
),
}
go s.cache.Start()
return &s
}
func (s *Service) RequestSync(ctx context.Context, userID string) error {
call := s.deviceService.RequestSync(&homegraph.RequestSyncDevicesRequest{
AgentUserId: userID,
})
call.Context(ctx)
resp, err := call.Do()
if err != nil {
return err
}
if resp.ServerResponse.HTTPStatusCode != http.StatusOK {
return errors.New(fmt.Sprintf("sync failed: %d", resp.ServerResponse.HTTPStatusCode))
}
return nil
}
func (s *Service) ReportState(ctx context.Context, userID string, states map[string]DeviceState) error {
j, err := json.Marshal(states)
if err != nil {
return err
}
call := s.deviceService.ReportStateAndNotification(&homegraph.ReportStateAndNotificationRequest{
AgentUserId: userID,
EventId: uuid.New().String(),
RequestId: uuid.New().String(),
Payload: &homegraph.StateAndNotificationPayload{
Devices: &homegraph.ReportStateAndNotificationDevice{
States: j,
},
},
})
call.Context(ctx)
resp, err := call.Do()
if err != nil {
return err
}
if resp.ServerResponse.HTTPStatusCode != http.StatusOK {
return errors.New(fmt.Sprintf("report failed: %d", resp.ServerResponse.HTTPStatusCode))
}
return nil
}

View File

@ -0,0 +1,79 @@
package google
import (
"encoding/json"
)
type DeviceState struct {
Online bool
Status Status
state map[string]interface{}
}
func (ds DeviceState) MarshalJSON() ([]byte, error) {
payload := make(map[string]interface{})
payload["online"] = ds.Online
if len(ds.Status) > 0 {
payload["status"] = ds.Status
}
for k, v := range ds.state {
payload[k] = v
}
return json.Marshal(payload)
}
func NewDeviceState(online bool) DeviceState {
return DeviceState{
Online: online,
state: make(map[string]interface{}),
}
}
// https://developers.google.com/assistant/smarthome/traits/onoff
func (ds DeviceState) RecordOnOff(on bool) DeviceState {
ds.state["on"] = on
return ds
}
// https://developers.google.com/assistant/smarthome/traits/runcycle
func (ds DeviceState) RecordRunCycle(state int) DeviceState {
if state == 0 {
} else if state == 1 {
ds.state["currentRunCycle"] = []struct{
CurrentCycle string `json:"currentCycle"`
Lang string `json:"lang"`
}{
{
CurrentCycle: "Wash",
Lang: "en",
},
}
} else if state == 2 {
ds.state["currentTotalRemainingTime"] = 0
}
return ds
}
// https://developers.google.com/assistant/smarthome/traits/startstop
func (ds DeviceState) RecordStartStop(running bool, paused ...bool) DeviceState {
ds.state["isRunning"] = running
if len(paused) > 0 {
ds.state["isPaused"] = paused[0]
}
return ds
}
// https://developers.google.com/assistant/smarthome/traits/camerastream
func (ds DeviceState) RecordCameraStream(url string) DeviceState {
ds.state["cameraStreamProtocol"] = "progressive_mp4"
ds.state["cameraStreamAccessUrl"] = url
return ds
}

View File

@ -0,0 +1,10 @@
package google
type Status string
const (
StatusSuccess Status = "SUCCESS"
StatusOffline = "OFFLINE"
StatusException = "EXCEPTIONS"
StatusError = "ERROR"
)

View File

@ -0,0 +1,72 @@
package google
import "github.com/kr/pretty"
type Trait string
// https://developers.google.com/assistant/smarthome/traits/onoff
const TraitOnOff Trait = "action.devices.traits.OnOff"
func (d *Device) AddOnOffTrait(onlyCommand bool, onlyQuery bool) *Device {
d.Traits = append(d.Traits, TraitOnOff)
if onlyCommand {
d.Attributes["commandOnlyOnOff"] = true
}
if onlyQuery {
d.Attributes["queryOnlyOnOff"] = true
}
return d
}
// https://developers.google.com/assistant/smarthome/traits/startstop
const TraitStartStop = "action.devices.traits.StartStop"
func (d *Device) AddStartStopTrait(pausable bool) *Device {
d.Traits = append(d.Traits, TraitStartStop)
if pausable {
d.Attributes["pausable"] = true
}
return d
}
// https://developers.google.com/assistant/smarthome/traits/onoff
const TraitRunCycle = "action.devices.traits.RunCycle"
func (d *Device) AddRunCycleTrait() *Device {
d.Traits = append(d.Traits, TraitRunCycle)
return d
}
// https://developers.google.com/assistant/smarthome/traits/camerastream
const TraitCameraStream = "action.devices.traits.CameraStream"
func (d *Device) AddCameraStreamTrait(authTokenNeeded bool, supportedProtocols ...string) *Device {
d.Traits = append(d.Traits, TraitCameraStream)
if len(supportedProtocols) > 0 {
d.Attributes["cameraStreamSupportedProtocols"] = supportedProtocols
}
d.Attributes["cameraStreamNeedAuthToken"] = authTokenNeeded
pretty.Logln(d)
return d
}
// https://developers.google.com/assistant/smarthome/traits/scene
const TraitScene = "action.devices.traits.Scene"
func (d *Device) AddSceneTrait(reversible bool) *Device {
d.Traits = append(d.Traits, TraitScene)
if reversible {
d.Attributes["sceneReversible"] = true
}
return d
}

View File

@ -0,0 +1,9 @@
package google
type Type string
// https://developers.google.com/assistant/smarthome/guides
const (
TypeKettle = "action.devices.types.KETTLE"
TypeScene = "action.devices.types.SCENE"
)

60
integration/hue/events.go Normal file
View File

@ -0,0 +1,60 @@
package hue
import (
"time"
)
type EventType string
const (
Update EventType = "update"
)
type DeviceType string
const (
Light DeviceType = "light"
GroupedLight = "grouped_light"
Button = "button"
)
type LastEvent string
const (
InitialPress LastEvent = "initial_press"
ShortPress = "short_press"
)
type device struct {
ID string `json:"id"`
IDv1 string `json:"id_v1"`
Owner struct {
Rid string `json:"rid"`
Rtype string `json:"rtype"`
} `json:"owner"`
Type DeviceType `json:"type"`
On *struct {
On bool `json:"on"`
} `json:"on"`
Dimming *struct {
Brightness float32 `json:"brightness"`
} `json:"dimming"`
ColorTemperature *struct {
Mirek int `json:"mirek"`
MirekValid bool `json:"mirek_valid"`
} `json:"color_temperature"`
Button *struct {
LastEvent LastEvent `json:"last_event"`
}
}
type Event struct {
CreationTime time.Time `json:"creationtime"`
Data []device `json:"data"`
ID string `json:"id"`
Type EventType `json:"type"`
}

56
integration/hue/hue.go Normal file
View File

@ -0,0 +1,56 @@
package hue
import (
"bytes"
"crypto/tls"
"fmt"
"net/http"
"github.com/r3labs/sse/v2"
)
type Hue struct {
ip string
login string
Events chan *sse.Event
}
func (hue *Hue) SetFlag(id int, value bool) {
url := fmt.Sprintf("http://%s/api/%s/sensors/%d/state", hue.ip, hue.login, id)
var data []byte
if value {
data = []byte(`{ "flag": true }`)
} else {
data = []byte(`{ "flag": false }`)
}
client := &http.Client{}
req, err := http.NewRequest(http.MethodPut, url, bytes.NewBuffer(data))
if err != nil {
panic(err)
}
_, err = client.Do(req)
if err != nil {
panic(err)
}
}
func New(ip string, token string) *Hue {
hue := Hue{ip: ip, login: token, Events: make(chan *sse.Event)}
// Subscribe to eventstream
client := sse.NewClient(fmt.Sprintf("https://%s/eventstream/clip/v2", hue.ip))
client.Headers["hue-application-key"] = hue.login
client.Connection.Transport = &http.Transport{
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
}
err := client.SubscribeChanRaw(hue.Events)
if err != nil {
panic(err)
}
return &hue
}

92
integration/kasa/kasa.go Normal file
View File

@ -0,0 +1,92 @@
package kasa
import (
"automation/device"
"bytes"
"encoding/binary"
"encoding/json"
"fmt"
"net"
)
// This implementation is based on:
// https://www.softscheck.com/en/blog/tp-link-reverse-engineering/
type Device interface {
device.Basic
IsKasaDevice()
GetIP() string
}
func encrypt(data []byte) []byte {
var key byte = 171
buf := new(bytes.Buffer)
binary.Write(buf, binary.BigEndian, uint32(len(data)))
for _, c := range []byte(data) {
a := key ^ c
key = a
buf.WriteByte(a)
}
return buf.Bytes()
}
func decrypt(data []byte) ([]byte, error) {
var key byte = 171
if len(data) < 4 {
return nil, fmt.Errorf("Array has a minumun size of 4")
}
size := binary.BigEndian.Uint32(data[0:4])
buf := make([]byte, size)
for i := 0; i < int(size); i++ {
a := key ^ data[i+4]
key = data[i+4]
buf[i] = a
}
return buf, nil
}
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
}
defer con.Close()
b, err := json.Marshal(cmd)
if err != nil {
return reply{}, err
}
_, err = con.Write(encrypt(b))
if err != nil {
return reply{}, err
}
resp := make([]byte, 2048)
_, err = con.Read(resp)
if err != nil {
return reply{}, err
}
d, err := decrypt(resp)
if err != nil {
return reply{}, err
}
var reply reply
err = json.Unmarshal(d, &reply)
if err != nil {
return reply, err
}
return reply, err
}

View File

@ -0,0 +1,26 @@
package kasa
type reply struct {
System struct {
SetRelayState struct {
ErrCode int `json:"err_code"`
} `json:"set_relay_state"`
GetSysinfo struct {
RelayState int `json:"relay_state"`
ErrCode int `json:"err_code"`
} `json:"get_sysinfo"`
} `json:"system"`
}
type SetRelayState struct {
State int `json:"state"`
}
type GetSysinfo struct{}
type cmd struct {
System struct {
SetRelayState *SetRelayState `json:"set_relay_state,omitempty"`
GetSysinfo *GetSysinfo `json:"get_sysinfo,omitempty"`
} `json:"system"`
}

View File

@ -0,0 +1,71 @@
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 (*Outlet) IsKasaDevice() {}
// kasa.Device
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
}

43
integration/ntfy/ntfy.go Normal file
View File

@ -0,0 +1,43 @@
package ntfy
import (
"fmt"
"net/http"
"strings"
)
type Notify struct {
topic string
}
func (n *Notify) Presence(home bool) {
var description string
var actions string
if home {
description = "Home"
actions = "broadcast, Set as away, extras.cmd=presence, extras.state=0, clear=true"
} else {
description = "Away"
actions = "broadcast, Set as home, extras.cmd=presence, extras.state=1, clear=true"
}
req, err := http.NewRequest("POST", fmt.Sprintf("https://ntfy.sh/%s", n.topic), strings.NewReader(description))
if err != nil {
panic(err)
}
req.Header.Set("Title", "Presence")
req.Header.Set("Tags", "house")
req.Header.Set("Actions", actions)
req.Header.Set("Priority", "1")
http.DefaultClient.Do(req)
}
func New(topic string) *Notify {
ntfy := Notify{topic}
// @TODO Make sure the topic is valid?
return &ntfy
}

View File

@ -0,0 +1,87 @@
package wol
import (
"automation/device"
"automation/integration/google"
"log"
"net/http"
"strings"
)
type computer struct {
macAddress string
name device.InternalName
url string
}
func NewComputer(macAddress string, name device.InternalName, url string) *computer {
c := &computer{macAddress, name, url}
return c
}
// device.Basic
var _ device.Basic = (*computer)(nil)
func (c *computer) GetID() device.InternalName {
return device.InternalName(c.name)
}
// device.Activate
var _ device.Activate = (*computer)(nil)
func (c *computer) Activate(state bool) {
if state {
log.Printf("Starting %s\n", c.name)
_, err := http.Get(c.url)
if err != nil {
log.Println(err)
}
} else {
// This is not implemented
}
}
// google.DeviceInterface
var _ google.DeviceInterface = (*computer)(nil)
func (*computer) IsGoogleDevice() {}
// google.DeviceInterface
func (c *computer) Sync() *google.Device {
device := google.NewDevice(c.GetID().String(), google.TypeScene)
device.AddSceneTrait(false)
device.Name = google.DeviceName{
DefaultNames: []string{
"Computer",
},
Name: strings.Title(c.GetID().Name()),
}
room := strings.Title(c.GetID().Room())
if len(room) > 1 {
device.RoomHint = room
}
return device
}
// google.DeviceInterface
func (c *computer) Query() google.DeviceState {
state := google.NewDeviceState(true)
state.Status = google.StatusSuccess
return state
}
// google.DeviceInterface
func (c *computer) Execute(execution google.Execution, updateState *google.DeviceState) (string, bool) {
errCode := ""
switch execution.Name {
case google.CommandActivateScene:
c.Activate(!execution.ActivateScene.Deactivate)
default:
errCode = "actionNotAvailable"
log.Printf("Command (%s) not supported\n", execution.Name)
}
return errCode, true
}

View File

@ -0,0 +1,52 @@
package zigbee
import (
"automation/device"
"automation/home"
"context"
"encoding/json"
"fmt"
"log"
paho "github.com/eclipse/paho.mqtt.golang"
)
func DevicesHandler(client paho.Client, prefix string, 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(client)
// Delete all zigbee devices from the device list
delete(home.Devices, name)
}
for _, d := range devices {
d.MQTTAddress = fmt.Sprintf("%s/%s", prefix, d.FriendlyName.String())
switch d.Description {
case "Kettle":
kettle := NewKettle(d, client, home.Service)
home.AddDevice(kettle)
case "LightSensor":
lightSensor := NewLightSensor(d, client)
home.AddDevice(lightSensor)
}
}
// Send sync request
// @TODO Instead of sending a sync request we should do something like home.sync <- interface{}
// This will then restart a timer, that way the sync will only trigger once everything has settled from multiple locations
home.Service.RequestSync(context.Background(), home.Username)
// Unsubscribe again, otherwise updates here will re-add all the devices which causes issues with the light sensor
if token := client.Unsubscribe(fmt.Sprintf("%s/bridge/devices", prefix)); token.Wait() && token.Error() != nil {
log.Println(token.Error())
}
}
if token := client.Subscribe(fmt.Sprintf("%s/bridge/devices", prefix), 1, handler); token.Wait() && token.Error() != nil {
log.Println(token.Error())
}
}

View File

@ -0,0 +1,180 @@
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 Info
client paho.Client
service *google.Service
updated chan bool
isOn bool
online bool
}
func NewKettle(info Info, client paho.Client, service *google.Service) *kettle {
k := &kettle{info: info, client: client, service: service, updated: make(chan bool, 1)}
if token := k.client.Subscribe(k.info.MQTTAddress, 1, k.stateHandler); token.Wait() && token.Error() != nil {
log.Println(token.Error())
}
return k
}
func (k *kettle) stateHandler(client paho.Client, msg paho.Message) {
var payload OnOffState
if err := json.Unmarshal(msg.Payload(), &payload); err != nil {
log.Println(err)
return
}
// Update the internal state
k.isOn = payload.State
k.online = true
// Notify that the state has updated
for len(k.updated) > 0 {
<- k.updated
}
k.updated <- true
// Notify google of the updated state
id := k.GetID().String()
k.service.ReportState(context.Background(), id, map[string]google.DeviceState{
id: k.getState(),
})
}
func (k *kettle) getState() google.DeviceState {
return google.NewDeviceState(k.online).RecordOnOff(k.isOn)
}
// zigbee.Device
var _ Device = (*kettle)(nil)
func (k *kettle) IsZigbeeDevice() {}
// zigbee.Device
func (k *kettle) Delete(client paho.Client) {
if token := client.Unsubscribe(k.info.MQTTAddress); token.Wait() && token.Error() != nil {
log.Println(token.Error())
}
}
// google.DeviceInterface
var _ google.DeviceInterface = (*kettle)(nil)
func (*kettle) IsGoogleDevice() {}
// google.DeviceInterface
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: strings.Title(k.GetID().Name()),
}
device.WillReportState = true
room := strings.Title(k.GetID().Room())
if len(room) > 1 {
device.RoomHint = room
}
device.DeviceInfo = google.DeviceInfo{
Manufacturer: k.info.Manufacturer,
Model: k.info.ModelID,
SwVersion: k.info.SoftwareBuildID,
}
return device
}
// google.DeviceInterface
func (k *kettle) Query() google.DeviceState {
state := k.getState()
if k.online {
state.Status = google.StatusSuccess
} else {
state.Status = google.StatusOffline
}
return state
}
// google.DeviceInterface
func (k *kettle) Execute(execution google.Execution, updatedState *google.DeviceState) (string, bool) {
errCode := ""
switch execution.Name {
case google.CommandOnOff:
// Clear the updated channel
for len(k.updated) > 0 {
<- k.updated
}
k.SetOnOff(execution.OnOff.On)
// Start timeout timer
timer := time.NewTimer(time.Second)
// Wait for the update or timeout
select {
case <- k.updated:
updatedState.RecordOnOff(k.isOn)
case <- timer.C:
// If we do not get a response in time mark the device as offline
log.Println("Device did not respond, marking as offline")
k.online = false
}
default:
// @TODO Should probably move the error codes to a enum
errCode = "actionNotAvailable"
log.Printf("Command (%s) not supported\n", execution.Name)
}
return errCode, k.online
}
// device.Base
var _ device.Basic = (*kettle)(nil)
func (k *kettle) GetID() device.InternalName {
return k.info.FriendlyName
}
// device.OnOff
var _ device.OnOff = (*kettle)(nil)
func (k *kettle) SetOnOff(state bool) {
msg := "OFF"
if state {
msg = "ON"
}
if token := k.client.Publish(fmt.Sprintf("%s/set", k.info.MQTTAddress), 1, false, fmt.Sprintf(`{ "state": "%s" }`, msg)); token.Wait() && token.Error() != nil {
log.Println(token.Error())
}
}
// device.OnOff
func (k *kettle) GetOnOff() bool {
return k.isOn
}

View File

@ -0,0 +1,142 @@
package zigbee
import (
"automation/device"
"encoding/json"
"fmt"
"log"
"time"
paho "github.com/eclipse/paho.mqtt.golang"
)
type lightSensor struct {
info Info
minValue int
maxValue int
timeout time.Duration
couldBeDark bool
isDark bool
initialized bool
timer *time.Timer
}
type DarknessPayload struct {
IsDark bool `json:"is_dark"`
Updated int64 `json:"updated"`
}
func NewLightSensor(info Info, client paho.Client) *lightSensor {
l := &lightSensor{info: info}
// 1: 15 000 - 16 000 (Turns on to late)
// 2: 22 000 - 30 000 (About 5-10 mins late)
// 3: 23 000 - 30 000
l.minValue = 23000
l.maxValue = 25000
l.timeout = 5 * time.Minute
l.timer = time.NewTimer(l.timeout)
l.timer.Stop()
if token := client.Subscribe(l.info.MQTTAddress, 1, l.stateHandler); token.Wait() && token.Error() != nil {
log.Println(token.Error())
}
go func() {
for {
<-l.timer.C
l.isDark = l.couldBeDark
log.Println("Is dark:", l.isDark)
payload, err := json.Marshal(DarknessPayload{
IsDark: l.isDark,
Updated: time.Now().UnixMilli(),
})
if err != nil {
log.Println(err)
}
if token := client.Publish(l.darknessTopic(), 1, true, payload); token.Wait() && token.Error() != nil {
log.Println(token.Error())
}
}
}()
return l
}
func (l *lightSensor) darknessTopic() string {
return fmt.Sprintf("automation/darkness/%s", l.info.FriendlyName.Room())
}
func (l *lightSensor) stateHandler(client paho.Client, msg paho.Message) {
var message LightSensorState
if err := json.Unmarshal(msg.Payload(), &message); err != nil {
log.Println(err)
return
}
fmt.Println(l.isDark, l.couldBeDark, message.Illuminance, l.maxValue, l.minValue)
if !l.initialized {
if message.Illuminance > l.maxValue {
l.couldBeDark = false
} else {
l.couldBeDark = true
}
l.initialized = true
l.timer.Reset(time.Millisecond)
return
}
if message.Illuminance > l.maxValue {
if l.isDark && l.couldBeDark {
log.Println("Could be light, starting timer")
l.couldBeDark = false
l.timer.Reset(l.timeout)
} else if !l.isDark {
log.Println("Is not dark, canceling timer")
l.couldBeDark = false
l.timer.Stop()
}
} else if message.Illuminance < l.minValue {
if !l.isDark && !l.couldBeDark {
log.Println("Could be dark, starting timer")
l.couldBeDark = true
l.timer.Reset(l.timeout)
} else if l.isDark {
log.Println("Is dark, canceling timer")
l.couldBeDark = true
l.timer.Stop()
}
} else {
// log.Println("In between the threshold, canceling timer for now keeping the current state")
l.couldBeDark = l.isDark
l.timer.Stop()
}
}
// zigbee.Device
var _ Device = (*lightSensor)(nil)
func (l *lightSensor) IsZigbeeDevice() {}
func (l *lightSensor) Delete(client paho.Client) {
if token := client.Unsubscribe(l.darknessTopic()); token.Wait() && token.Error() != nil {
log.Println(token.Error())
}
}
// device.Base
var _ device.Basic = (*lightSensor)(nil)
func (l *lightSensor) GetID() device.InternalName {
return l.info.FriendlyName
}

View File

@ -0,0 +1,38 @@
package zigbee
import "encoding/json"
type OnOffState struct {
State bool
}
func (k *OnOffState) UnmarshalJSON(data []byte) error {
var payload struct {
State string `json:"state"`
}
if err := json.Unmarshal(data, &payload); err != nil {
return err
}
k.State = payload.State == "ON"
return nil
}
type RemoteAction string
const (
ACTION_ON RemoteAction = "on"
ACTION_OFF = "off"
ACTION_BRIGHTNESS_UP = "brightness_move_up"
ACTION_BRIGHTNESS_DOWN = "brightness_move_down"
ACTION_BRIGHTNESS_STOP = "brightness_move_down"
)
type RemoteState struct {
Action RemoteAction `json:"action"`
}
type LightSensorState struct {
Illuminance int `json:"illuminance"`
}

View File

@ -0,0 +1,25 @@
package zigbee
import (
"automation/device"
paho "github.com/eclipse/paho.mqtt.golang"
)
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"`
MQTTAddress string `json:"-"`
}
type Device interface {
device.Basic
IsZigbeeDevice()
Delete(client paho.Client)
}

201
main.go
View File

@ -1,169 +1,78 @@
package main
import (
"bytes"
"automation/automation"
"automation/config"
"automation/home"
"automation/integration/hue"
"automation/integration/kasa"
"automation/integration/ntfy"
"automation/integration/wol"
"automation/integration/zigbee"
"automation/presence"
"fmt"
"log"
"net/http"
"os"
"os/signal"
"strconv"
"strings"
"syscall"
MQTT "github.com/eclipse/paho.mqtt.golang"
"github.com/gorilla/mux"
"github.com/joho/godotenv"
paho "github.com/eclipse/paho.mqtt.golang"
)
// This is the default message handler, it just prints out the topic and message
var defaultHandler MQTT.MessageHandler = func(client MQTT.Client, msg MQTT.Message) {
fmt.Printf("TOPIC: %s\n", msg.Topic())
fmt.Printf("MSG: %s\n", msg.Payload())
}
type DeviceStatus struct {
Name string
Present bool
}
// Handler got automation/presence/+
func presenceHandler(status chan DeviceStatus) func(MQTT.Client, MQTT.Message) {
return func(client MQTT.Client, msg MQTT.Message) {
device := strings.Split(msg.Topic(), "/")[2]
value, err := strconv.Atoi(string(msg.Payload()))
if err != nil {
panic(err)
}
fmt.Println("presenceHandler", device, value)
status <- DeviceStatus{Name: device, Present: value == 1}
}
}
type Hue struct {
ip string
login string
}
func (hue *Hue) updateFlag(id int, value bool) {
url := fmt.Sprintf("http://%s/api/%s/sensors/%d/state", hue.ip, hue.login, id)
var data []byte
if value {
data = []byte(`{ "flag": true }`)
} else {
data = []byte(`{ "flag": false }`)
}
client := &http.Client{}
req, err := http.NewRequest(http.MethodPut, url, bytes.NewBuffer(data))
if err != nil {
panic(err)
}
_, err = client.Do(req)
if err != nil {
panic(err)
}
}
func main() {
_ = godotenv.Load()
host, ok := os.LookupEnv("MQTT_HOST")
if !ok {
host = "localhost"
}
port, ok := os.LookupEnv("MQTT_PORT")
if !ok {
port = "1883"
}
user, ok := os.LookupEnv("MQTT_USER")
if !ok {
user = "test"
}
pass, ok := os.LookupEnv("MQTT_PASS")
if !ok {
pass = "test"
}
clientID, ok := os.LookupEnv("MQTT_CLIENT_ID")
if !ok {
clientID = "automation"
}
login, _ := os.LookupEnv("HUE_BRIDGE")
cfg := config.Get()
halt := make(chan os.Signal, 1)
signal.Notify(halt, os.Interrupt, syscall.SIGTERM)
notify := ntfy.New(cfg.Ntfy.Topic)
hue := hue.New(cfg.Hue.IP, cfg.Hue.Token)
// @TODO Discover the bridge here
hue := Hue{ip: "10.0.0.146", login: login}
home := home.New(cfg.Google.Username, cfg.Google.Credentials, cfg.Google.OAuthUrl)
r := mux.NewRouter()
r.HandleFunc("/assistant", home.Service.FullfillmentHandler)
opts := MQTT.NewClientOptions().AddBroker(fmt.Sprintf("%s:%s", host, port))
opts.SetClientID(clientID)
opts.SetDefaultPublishHandler(defaultHandler)
opts.SetUsername(user)
opts.SetPassword(pass)
for name, info := range cfg.Computer {
home.AddDevice(wol.NewComputer(info.MACAddress, name, info.Url))
}
c := MQTT.NewClient(opts)
if token := c.Connect(); token.Wait() && token.Error() != nil {
for name, ip := range cfg.Kasa.Outlets {
home.AddDevice(kasa.NewOutlet(name, ip))
}
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)
status := make(chan DeviceStatus, 1)
if token := c.Subscribe("automation/presence/+", 0, presenceHandler(status)); token.Wait() && token.Error() != nil {
fmt.Println(token.Error())
os.Exit(1)
zigbee.DevicesHandler(client, cfg.Zigbee.MQTTPrefix, home)
p := presence.New(client, hue, notify, home)
defer p.Delete(client)
opts.SetClientID(fmt.Sprintf("%s-2", cfg.MQTT.ClientID))
automationClient := paho.NewClient(opts)
if token := automationClient.Connect(); token.Wait() && token.Error() != nil {
panic(token.Error())
}
defer automationClient.Disconnect(250)
automation.RegisterAutomations(automationClient, cfg.Zigbee.MQTTPrefix, hue, notify, home, p)
addr := ":8090"
srv := http.Server{
Addr: addr,
Handler: r,
}
devices := make(map[string]bool)
isHome := false
fmt.Println("Starting event loop")
// Event loop
events:
for {
select {
case state := <-status:
// Update the device state
devices[state.Name] = state.Present
// Check if there is any device home
temp := false
for key, value := range devices {
fmt.Println(key, value)
if value {
temp = true
break;
}
}
// Only do stuff if the state changes
if temp == isHome {
break
}
isHome = temp
if isHome {
fmt.Println("Coming home")
hue.updateFlag(41, true)
} else {
fmt.Println("Leaving home")
hue.updateFlag(41, false)
}
fmt.Println("Done")
case <-halt:
break events
}
}
// Cleanup
if token := c.Unsubscribe("automation/presence/+"); token.Wait() && token.Error() != nil {
fmt.Println(token.Error())
os.Exit(1)
}
c.Disconnect(250)
log.Printf("Starting server on %s (PID: %d)\n", addr, os.Getpid())
srv.ListenAndServe()
}

87
presence/presence.go Normal file
View File

@ -0,0 +1,87 @@
package presence
import (
"automation/home"
"automation/integration/hue"
"automation/integration/ntfy"
"encoding/json"
"log"
"strings"
"time"
paho "github.com/eclipse/paho.mqtt.golang"
"github.com/kr/pretty"
)
type Presence struct {
devices map[string]bool
presence bool
}
type Message struct {
State bool `json:"state"`
Updated int64 `json:"updated"`
}
func (p *Presence) Current() bool {
return p.presence
}
func (p *Presence) devicePresenceHandler(client paho.Client, msg paho.Message) {
name := strings.Split(msg.Topic(), "/")[2]
if len(msg.Payload()) == 0 {
delete(p.devices, name)
} else {
var message Message
err := json.Unmarshal(msg.Payload(), &message)
if err != nil {
log.Println(err)
return
}
p.devices[name] = message.State
}
present := false
pretty.Logf("Presence updated: %v\n", p.devices)
for _, value := range p.devices {
if value {
present = true
break
}
}
if p.presence != present {
p.presence = present
payload, err := json.Marshal(Message{
State: present,
Updated: time.Now().UnixMilli(),
})
if err != nil {
log.Println(err)
}
token := client.Publish("automation/presence", 1, true, payload)
if token.Wait() && token.Error() != nil {
log.Println(token.Error())
}
}
}
func New(client paho.Client, hue *hue.Hue, ntfy *ntfy.Notify, home *home.Home) *Presence {
p := &Presence{devices: make(map[string]bool), presence: false}
if token := client.Subscribe("automation/presence/+", 1, p.devicePresenceHandler); token.Wait() && token.Error() != nil {
log.Println(token.Error())
}
return p
}
func (p *Presence) Delete(client paho.Client) {
if token := client.Unsubscribe("automation/presence/+"); token.Wait() && token.Error() != nil {
log.Println(token.Error())
}
}