Compare commits

..

1 Commits

Author SHA1 Message Date
ef91015005
Initial implementation for listening to philips hue event stream
All checks were successful
continuous-integration/drone/push Build is passing
2022-09-16 18:40:14 +02:00
42 changed files with 241 additions and 3648 deletions

View File

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

View File

@ -17,25 +17,22 @@ steps:
- name: socket
path: /var/run/docker.sock
environment:
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
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
commands:
- docker stop automation || true
- docker rm automation || true
- 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
- 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
when:
branch:

2
.gitignore vendored
View File

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

View File

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

View File

@ -1,46 +0,0 @@
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)
}

View File

@ -1,14 +0,0 @@
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)
})
}

View File

@ -1,56 +0,0 @@
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())
}
}
}()
}

View File

@ -1,42 +0,0 @@
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)
}
}()
}

View File

@ -1,43 +0,0 @@
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)
}
}
})
}

View File

@ -1,42 +0,0 @@
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)
})
}

View File

@ -1,23 +0,0 @@
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)
})
}

View File

@ -1,25 +0,0 @@
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

View File

@ -1,83 +0,0 @@
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

File diff suppressed because it is too large Load Diff

View File

@ -1,47 +0,0 @@
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
}

View File

@ -1,31 +0,0 @@
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,39 +1,16 @@
module automation
go 1.19
go 1.17
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/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
github.com/kelvins/sunrisesunset v0.0.0-20210220141756-39fa1bd816d5
)
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
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
github.com/r3labs/sse/v2 v2.8.1 // indirect
golang.org/x/net v0.0.0-20200425230154-ff2c4b7c35a0 // indirect
gopkg.in/cenkalti/backoff.v1 v1.1.0 // indirect
)

153
go.sum
View File

@ -1,172 +1,25 @@
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/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/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/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/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 h1:Jcxah/M+oLZ/R4/z5RzfPzGbPXnVDPkEDtf2JnuxN+U=
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=

View File

@ -1,96 +0,0 @@
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

@ -1,91 +0,0 @@
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

@ -1,51 +0,0 @@
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

@ -1,246 +0,0 @@
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

@ -1,77 +0,0 @@
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

@ -1,101 +0,0 @@
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

@ -1,79 +0,0 @@
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

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

View File

@ -1,72 +0,0 @@
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

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

View File

@ -1,60 +0,0 @@
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"`
}

View File

@ -1,56 +0,0 @@
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
}

View File

@ -1,92 +0,0 @@
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

@ -1,26 +0,0 @@
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

@ -1,71 +0,0 @@
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
}

View File

@ -1,43 +0,0 @@
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

@ -1,87 +0,0 @@
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

@ -1,52 +0,0 @@
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

@ -1,180 +0,0 @@
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

@ -1,142 +0,0 @@
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

@ -1,38 +0,0 @@
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

@ -1,25 +0,0 @@
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)
}

273
main.go
View File

@ -1,78 +1,241 @@
package main
import (
"automation/automation"
"automation/config"
"automation/home"
"automation/integration/hue"
"automation/integration/kasa"
"automation/integration/ntfy"
"automation/integration/wol"
"automation/integration/zigbee"
"automation/presence"
"bytes"
"crypto/tls"
"encoding/json"
"fmt"
"log"
"net/http"
"os"
"os/signal"
"strconv"
"strings"
"syscall"
"time"
"github.com/gorilla/mux"
MQTT "github.com/eclipse/paho.mqtt.golang"
"github.com/joho/godotenv"
paho "github.com/eclipse/paho.mqtt.golang"
"github.com/r3labs/sse/v2"
)
// 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) listenForEvents(events chan *sse.Event) {
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(events)
if err != nil {
panic(err)
}
}
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)
}
}
type lastEvent struct {
LastEvent string `json:"last_event"`
}
type owner struct {
Rid string `json:"rid"`
Rtype string `json:"rtype"`
}
type Button struct {
Button lastEvent `json:"button"`
Id string `json:"id"`
IdV1 string `json:"id_v1"`
Owner owner `json:"owner"`
Type string `json:"type"`
}
type on struct {
On bool `json:"on"`
}
type typeInfo struct {
Type string `json:"type"`
}
type Event struct {
CreationTime time.Time `json:"creationtime"`
Data []json.RawMessage `json:"data"`
ID string `json:"id"`
Type string `json:"type"`
}
func main() {
_ = godotenv.Load()
cfg := config.Get()
notify := ntfy.New(cfg.Ntfy.Topic)
hue := hue.New(cfg.Hue.IP, cfg.Hue.Token)
home := home.New(cfg.Google.Username, cfg.Google.Credentials, cfg.Google.OAuthUrl)
r := mux.NewRouter()
r.HandleFunc("/assistant", home.Service.FullfillmentHandler)
for name, info := range cfg.Computer {
home.AddDevice(wol.NewComputer(info.MACAddress, name, info.Url))
host, ok := os.LookupEnv("MQTT_HOST")
if !ok {
host = "localhost"
}
for name, ip := range cfg.Kasa.Outlets {
home.AddDevice(kasa.NewOutlet(name, ip))
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")
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)
halt := make(chan os.Signal, 1)
signal.Notify(halt, os.Interrupt, syscall.SIGTERM)
client := paho.NewClient(opts)
if token := client.Connect(); token.Wait() && token.Error() != nil {
// @TODO Discover the bridge here
hue := Hue{ip: "10.0.0.146", login: login}
events := make(chan *sse.Event)
hue.listenForEvents(events)
opts := MQTT.NewClientOptions().AddBroker(fmt.Sprintf("%s:%s", host, port))
opts.SetClientID(clientID)
opts.SetDefaultPublishHandler(defaultHandler)
opts.SetUsername(user)
opts.SetPassword(pass)
c := MQTT.NewClient(opts)
if token := c.Connect(); token.Wait() && token.Error() != nil {
panic(token.Error())
}
defer client.Disconnect(250)
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,
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)
}
log.Printf("Starting server on %s (PID: %d)\n", addr, os.Getpid())
srv.ListenAndServe()
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 message := <-events:
events := []Event{}
json.Unmarshal(message.Data, &events)
for _, event := range events {
if event.Type == "update" {
for _, data := range event.Data {
var typeInfo typeInfo
json.Unmarshal(data, &typeInfo)
switch typeInfo.Type {
case "button":
fmt.Println("Button")
var button Button
json.Unmarshal(data, &button)
fmt.Println(button)
}
}
}
}
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)
}

View File

@ -1,87 +0,0 @@
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())
}
}