Refactored code and added support for kasa smart plugs
All checks were successful
continuous-integration/drone/push Build is passing

This commit is contained in:
Dreaded_X 2022-09-24 00:42:42 +02:00
parent 76a8c5e620
commit dace0eba29
Signed by: Dreaded_X
GPG Key ID: 76BDEC4E165D8AD9
10 changed files with 553 additions and 210 deletions

2
go.mod
View File

@ -5,9 +5,11 @@ go 1.17
require (
github.com/eclipse/paho.mqtt.golang v1.3.5
github.com/joho/godotenv v1.4.0
github.com/r3labs/sse/v2 v2.8.1
)
require (
github.com/gorilla/websocket v1.4.2 // indirect
golang.org/x/net v0.0.0-20210410081132-afb366fc7cd1 // indirect
gopkg.in/cenkalti/backoff.v1 v1.1.0 // indirect
)

15
go.sum
View File

@ -1,10 +1,20 @@
github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8=
github.com/davecgh/go-spew v1.1.0/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/gorilla/websocket v1.4.2 h1:+/TMaTYc4QFitKJxsQ7Yye35DkWvkdLcvGKqM+x0Ufc=
github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
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/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
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/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
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-20210410081132-afb366fc7cd1 h1:4qWs8cYYH6PoEFy4dfhDFgoMGkwAcETd+MmPdCPMzUc=
golang.org/x/net v0.0.0-20210410081132-afb366fc7cd1/go.mod h1:9tjilg8BloeKEkVJvy7fQ90B1CfIiPueXVOjqfkSzI8=
@ -16,3 +26,8 @@ golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9sn
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
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/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c h1:dUUwHk2QECo/6vqA44rthZ8ie2QXMNeKRTHCNY2nXvo=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

60
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"`
}

59
hue/hue.go Normal file
View File

@ -0,0 +1,59 @@
package hue
import (
"bytes"
"crypto/tls"
"fmt"
"net/http"
"os"
"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 Connect() Hue {
login, _ := os.LookupEnv("HUE_BRIDGE")
hue := Hue{ip: "10.0.0.146", login: login, 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
}

88
kasa/kasa.go Normal file
View File

@ -0,0 +1,88 @@
package kasa
import (
"bytes"
"encoding/binary"
"encoding/json"
"fmt"
"net"
)
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) string {
var key byte = 171
buf := new(bytes.Buffer)
binary.Write(buf, binary.BigEndian, uint32(len(data)))
for _, c := range data {
a := key ^ c
key = c
buf.WriteByte(a)
}
return string(buf.Bytes())
}
type Kasa struct {
ip string
}
func New(ip string) Kasa {
return Kasa{ip}
}
func (kasa *Kasa) sendCmd(cmd cmd) {
con, err := net.Dial("tcp", fmt.Sprintf("%s:9999", kasa.ip))
if err != nil {
panic(err)
}
defer con.Close()
b, err := json.Marshal(cmd)
if err != nil {
panic(err)
}
_, err = con.Write(encrypt(b))
if err != nil {
panic(err)
}
resp := make([]byte, 2048)
_, err = con.Read(resp)
if err != nil {
panic(err)
}
var reply reply
json.Unmarshal(resp, &reply)
if reply.System.SetRelayState.ErrCode != 0 {
fmt.Println(reply)
fmt.Println(resp)
}
}
func (kasa *Kasa) SetState(on bool) {
var cmd cmd
if on {
cmd.System.SetRelayState.State = 1
}
kasa.sendCmd(cmd)
}

19
kasa/reply.go Normal file
View File

@ -0,0 +1,19 @@
package kasa
type errCode struct {
ErrCode int
}
type reply struct {
System struct {
SetRelayState errCode `json:"set_relay_state"`
} `json:"system"`
}
type cmd struct {
System struct {
SetRelayState struct {
State int `json:"state"`
} `json:"set_relay_state"`
} `json:"system"`
}

229
main.go
View File

@ -1,204 +1,18 @@
package main
import (
"bytes"
"encoding/json"
"automation/hue"
"automation/mqtt"
"automation/ntfy"
"fmt"
"io/ioutil"
"net/http"
"os"
"os/signal"
"strconv"
"strings"
"syscall"
MQTT "github.com/eclipse/paho.mqtt.golang"
"github.com/joho/godotenv"
)
// 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())
}
// Handler got automation/presence/+
func presenceHandler(presence chan bool) func(MQTT.Client, MQTT.Message) {
devices := make(map[string]bool)
var current *bool
return func(client MQTT.Client, msg MQTT.Message) {
name := strings.Split(msg.Topic(), "/")[2]
if len(msg.Payload()) == 0 {
// @TODO What happens if we delete a device that does not exist
delete(devices, name)
} else {
value, err := strconv.Atoi(string(msg.Payload()))
if err != nil {
panic(err)
}
devices[name] = value == 1
}
present := false
fmt.Println(devices)
for _, value := range devices {
if value {
present = true
break;
}
}
if current == nil || *current != present {
current = &present
presence <- present
}
}
}
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)
}
}
type ntfy struct{
topic string
}
func (ntfy *ntfy) notifyPresence(home bool) {
// @TODO Maybe add list the devices that are home currently?
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", ntfy.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 connectToHue() Hue {
login, _ := os.LookupEnv("HUE_BRIDGE")
resp, err := http.Get("https://discovery.meethue.com/")
if err != nil {
panic(err)
}
body, err := ioutil.ReadAll(resp.Body)
if err != nil {
panic(err)
}
var bridges []struct {
ID string `json:"id"`
InternalIPAddress string `json:"internalipaddress"`
Port int `json:"port"`
}
err = json.Unmarshal(body, &bridges)
if err != nil {
panic(err)
}
if len(bridges) != 1 {
fmt.Println(bridges)
panic("Expected one bridge!")
}
hue := Hue{ip: bridges[0].InternalIPAddress, login: login}
resp, err = http.Get("https://discovery.meethue.com/")
if err != nil {
panic(err)
}
if resp.Status != "200 OK" {
panic("Check failed")
}
return hue
}
func connectMQTT() MQTT.Client {
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"
}
opts := MQTT.NewClientOptions().AddBroker(fmt.Sprintf("%s:%s", host, port))
opts.SetClientID(clientID)
opts.SetDefaultPublishHandler(defaultHandler)
opts.SetUsername(user)
opts.SetPassword(pass)
client := MQTT.NewClient(opts)
if token := client.Connect(); token.Wait() && token.Error() != nil {
panic(token.Error())
}
return client
}
func connectNtfy() ntfy {
topic, _ := os.LookupEnv("NTFY_TOPIC")
ntfy := ntfy{topic}
// @TODO Make sure the topic is valid?
return ntfy
func SendCmd(cmd []byte) {
}
func main() {
@ -209,38 +23,33 @@ func main() {
signal.Notify(halt, os.Interrupt, syscall.SIGTERM)
// MQTT
client := connectMQTT()
presence := make(chan bool, 1)
if token := client.Subscribe("automation/presence/+", 0, presenceHandler(presence)); token.Wait() && token.Error() != nil {
fmt.Println(token.Error())
os.Exit(1)
}
m := mqtt.Connect()
defer m.Disconnect()
// Hue
hue := connectToHue()
h := hue.Connect()
// Kasa
// k := kasa.New("10.0.0.32")
// ntfy.sh
ntfy := connectNtfy()
n := ntfy.Connect()
// Event loop
fmt.Println("Starting event loop")
events:
for {
select {
case present := <-presence:
fmt.Printf("Present: %t\n", present)
hue.updateFlag(41, present)
ntfy.notifyPresence(present)
case present := <-m.Presence:
fmt.Printf("Presence: %t\n", present)
h.SetFlag(41, present)
n.Presence(present)
case <-h.Events:
break
case <-halt:
break events
}
}
// Cleanup
if token := client.Unsubscribe("automation/presence/+"); token.Wait() && token.Error() != nil {
fmt.Println(token.Error())
os.Exit(1)
}
client.Disconnect(250)
}

109
mqtt/mqtt.go Normal file
View File

@ -0,0 +1,109 @@
package mqtt
import (
"fmt"
"os"
"strconv"
"strings"
"github.com/eclipse/paho.mqtt.golang"
)
type MQTT struct {
Presence chan bool
client mqtt.Client
}
// 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())
}
// Handler got automation/presence/+
func presenceHandler(presence chan bool) func(mqtt.Client, mqtt.Message) {
devices := make(map[string]bool)
var current *bool
return func(client mqtt.Client, msg mqtt.Message) {
name := strings.Split(msg.Topic(), "/")[2]
if len(msg.Payload()) == 0 {
// @TODO What happens if we delete a device that does not exist
delete(devices, name)
} else {
value, err := strconv.Atoi(string(msg.Payload()))
if err != nil {
panic(err)
}
devices[name] = value == 1
}
present := false
fmt.Println(devices)
for _, value := range devices {
if value {
present = true
break
}
}
if current == nil || *current != present {
current = &present
presence <- present
}
}
}
func Connect() MQTT {
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"
}
opts := mqtt.NewClientOptions().AddBroker(fmt.Sprintf("%s:%s", host, port))
opts.SetClientID(clientID)
opts.SetDefaultPublishHandler(defaultHandler)
opts.SetUsername(user)
opts.SetPassword(pass)
client := mqtt.NewClient(opts)
if token := client.Connect(); token.Wait() && token.Error() != nil {
panic(token.Error())
}
m := MQTT{client: client, Presence: make(chan bool)}
if token := client.Subscribe("automation/presence/+", 0, presenceHandler(m.Presence)); token.Wait() && token.Error() != nil {
fmt.Println(token.Error())
os.Exit(1)
}
return m
}
func (m *MQTT) Disconnect() {
if token := m.client.Unsubscribe("automation/presence/+"); token.Wait() && token.Error() != nil {
fmt.Println(token.Error())
os.Exit(1)
}
m.client.Disconnect(250)
}

46
ntfy/ntfy.go Normal file
View File

@ -0,0 +1,46 @@
package ntfy
import (
"fmt"
"net/http"
"os"
"strings"
)
type ntfy struct {
topic string
}
func (ntfy *ntfy) Presence(home bool) {
// @TODO Maybe add list the devices that are home currently?
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", ntfy.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 Connect() ntfy {
topic, _ := os.LookupEnv("NTFY_TOPIC")
ntfy := ntfy{topic}
// @TODO Make sure the topic is valid?
return ntfy
}

136
tplink_smartplug.py Executable file
View File

@ -0,0 +1,136 @@
#!/usr/bin/env python3
#
# TP-Link Wi-Fi Smart Plug Protocol Client
# For use with TP-Link HS-100 or HS-110
#
# by Lubomir Stroetmann
# Copyright 2016 softScheck GmbH
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
import argparse
import socket
from struct import pack
version = 0.4
# Check if hostname is valid
def validHostname(hostname):
try:
socket.gethostbyname(hostname)
except socket.error:
parser.error("Invalid hostname.")
return hostname
# Check if port is valid
def validPort(port):
try:
port = int(port)
except ValueError:
parser.error("Invalid port number.")
if ((port <= 1024) or (port > 65535)):
parser.error("Invalid port number.")
return port
# Predefined Smart Plug Commands
# For a full list of commands, consult tplink_commands.txt
commands = {'info' : '{"system":{"get_sysinfo":{}}}',
'on' : '{"system":{"set_relay_state":{"state":1}}}',
'off' : '{"system":{"set_relay_state":{"state":0}}}',
'ledoff' : '{"system":{"set_led_off":{"off":1}}}',
'ledon' : '{"system":{"set_led_off":{"off":0}}}',
'cloudinfo': '{"cnCloud":{"get_info":{}}}',
'wlanscan' : '{"netif":{"get_scaninfo":{"refresh":0}}}',
'time' : '{"time":{"get_time":{}}}',
'schedule' : '{"schedule":{"get_rules":{}}}',
'countdown': '{"count_down":{"get_rules":{}}}',
'antitheft': '{"anti_theft":{"get_rules":{}}}',
'reboot' : '{"system":{"reboot":{"delay":1}}}',
'reset' : '{"system":{"reset":{"delay":1}}}',
'energy' : '{"emeter":{"get_realtime":{}}}'
}
# Encryption and Decryption of TP-Link Smart Home Protocol
# XOR Autokey Cipher with starting key = 171
def encrypt(string):
key = 171
result = pack(">I", len(string))
for i in string:
a = key ^ ord(i)
key = a
result += bytes([a])
return result
def decrypt(string):
key = 171
result = ""
for i in string:
a = key ^ i
key = i
result += chr(a)
return result
# Parse commandline arguments
parser = argparse.ArgumentParser(description=f"TP-Link Wi-Fi Smart Plug Client v{version}")
parser.add_argument("-t", "--target", metavar="<hostname>", required=True,
help="Target hostname or IP address", type=validHostname)
parser.add_argument("-p", "--port", metavar="<port>", default=9999,
required=False, help="Target port", type=validPort)
parser.add_argument("-q", "--quiet", dest="quiet", action="store_true",
help="Only show result")
parser.add_argument("--timeout", default=10, required=False,
help="Timeout to establish connection")
group = parser.add_mutually_exclusive_group(required=True)
group.add_argument("-c", "--command", metavar="<command>",
help="Preset command to send. Choices are: "+", ".join(commands), choices=commands)
group.add_argument("-j", "--json", metavar="<JSON string>",
help="Full JSON string of command to send")
args = parser.parse_args()
# Set target IP, port and command to send
ip = args.target
port = args.port
if args.command is None:
cmd = args.json
else:
cmd = commands[args.command]
# Send command and receive reply
try:
sock_tcp = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock_tcp.settimeout(int(args.timeout))
sock_tcp.connect((ip, port))
sock_tcp.settimeout(None)
sock_tcp.send(encrypt(cmd))
data = sock_tcp.recv(2048)
sock_tcp.close()
decrypted = decrypt(data[4:])
if args.quiet:
print(decrypted)
else:
print("Sent: ", cmd)
print("Received: ", decrypted)
except socket.error:
quit(f"Could not connect to host {ip}:{port}")