diff options
| -rw-r--r-- | README.md | 21 | ||||
| -rw-r--r-- | config.go | 15 | ||||
| -rw-r--r-- | configs/valid/tplink.json | 17 | ||||
| -rw-r--r-- | main.go | 4 | ||||
| -rw-r--r-- | meson.build | 1 | ||||
| -rw-r--r-- | tplink.go | 125 |
6 files changed, 182 insertions, 1 deletions
@@ -21,6 +21,12 @@ these IoT products: The implemented API is documented [here][6]. +### TP-Link + +[TP-Link][10] is supported with the following product: + +- [HS100][11] - Wi-Fi plug to control lamps and other devices + ## Build instructions The Sia server is built and tested with the Meson build automation tool. @@ -126,6 +132,19 @@ For all terms not explained here see the [MQTT version 3.1.1 documentation][9]. - `retract`: cover decreases the covering surface - `stop`: cover stops current motion if given +### `/plug/<id>/action` + +- description: Implements control of tp-link HS100 Wi-Fi plugs +- direction: client to Sia server +- Quality of Service: QoS 2 (exactly once) +- retained: no +- receives will message: no +- topic parameters: + - `id`: ID of the TP-Link HS100 Wi-Fi plug +- payloads: + - `on`: turns the Wi-Fi plug on + - `off`: turns the Wi-Fi plug off + [1]: https://homematic-ip.com/ [2]: https://openccu.de/ [3]: https://homematic-ip.com/en/product/window-and-door-contact-optical @@ -135,3 +154,5 @@ For all terms not explained here see the [MQTT version 3.1.1 documentation][9]. [7]: https://www.shelly.com/de/products/shelly-2pm-gen3-1/ [8]: https://shelly.com/ [9]: https://docs.oasis-open.org/mqtt/mqtt/v3.1.1/os/mqtt-v3.1.1-os.html +[10]: https://tp-link.com/ +[11]: https://www.tp-link.com/en/home-networking/smart-plug/hs100/ @@ -52,10 +52,18 @@ type ShellyConfig struct { type ShellyConfigs []ShellyConfig +type TPLinkConfig struct { + ID string `json:"id"` + IP string `json:"ip"` +} + +type TPLinkConfigs []TPLinkConfig + type StartupConfig struct { MQTT MQTTConfig `json:"mqtt"` Homematic HomematicConfig `json:"homematic"` Shelly ShellyConfigs `json:"shelly"` + TPLink TPLinkConfigs `json:"tplink"` } func (sc StartupConfig) String() string { @@ -132,6 +140,13 @@ func (sc StartupConfig) Validate() error { } } + for _, tplink := range sc.TPLink { + ip := net.ParseIP(tplink.IP) + if ip == nil { + return fmt.Errorf("Failed to parse IP address '%s'.", tplink.IP) + } + } + return nil } diff --git a/configs/valid/tplink.json b/configs/valid/tplink.json new file mode 100644 index 0000000..f672fa4 --- /dev/null +++ b/configs/valid/tplink.json @@ -0,0 +1,17 @@ +{ + "mqtt": { + "broker": "tcp://127.0.0.1:1883", + "client-id": "siaserver", + "topic-prefix": "sia" + }, + "homematic": { + "ccu": "http://127.0.0.1:8080", + "polling-period": "50ms" + }, + "tplink": [ + { + "id": "tplink1", + "ip": "192.168.1.40" + } + ] +} @@ -20,10 +20,12 @@ func main() { tx := make(chan MQTTMessage) coverMovement := NewRoute("cover/+/movement", QoS2) + plugAction := NewRoute("plug/+/action", QoS2) - go MQTTRun(config.MQTT, tx, coverMovement) + go MQTTRun(config.MQTT, tx, coverMovement, plugAction) go HomematicRun(config.Homematic, tx) go ShellyRun(config.Shelly, coverMovement) + go TPLinkRun(config.TPLink, plugAction) Await(syscall.SIGTERM, syscall.SIGINT) } diff --git a/meson.build b/meson.build index e8878ce..0738894 100644 --- a/meson.build +++ b/meson.build @@ -24,6 +24,7 @@ sia_server_linux_amd64 = custom_target( meson.current_source_dir() / 'config.go', meson.current_source_dir() / 'flags.go', meson.current_source_dir() / 'shelly.go', + meson.current_source_dir() / 'tplink.go', ], output : 'sia-server-linux-amd64', env : {'GOOS': 'linux', 'GOARCH': 'amd64'}, diff --git a/tplink.go b/tplink.go new file mode 100644 index 0000000..11d8ab1 --- /dev/null +++ b/tplink.go @@ -0,0 +1,125 @@ +package main + +import ( + "context" + "encoding/binary" + "fmt" + "log" + "net" + "time" + "strings" +) + +const ( + MAX_PAYLOAD = 4294967295 // TP-Link WiFi plug protocol: max. 2^32-1 bytes + TPLink_HS100_ON = true + TPLink_HS100_OFF = false +) + +func TPLinkRun(config TPLinkConfigs, route Route) { + for message := range route.Destination { + ip, action, err := tplinkParseMessage(config, message) + if err != nil { + log.Println(err) + continue + } + + err = set(*ip, action) + if err != nil { + log.Printf("Could not send action '%v' to %v: %v", action, ip, err) + } + } +} + +func tplinkParseMessage(config TPLinkConfigs, m MQTTMessage) (ip *net.IP, action bool, err error) { + elements := strings.Split(m.Topic, "/") + + if len(elements) != 3 { + return nil, false, fmt.Errorf( + "Expected three topic levels but got %d in '%s'.", + len(elements), m.Topic, + ) + } + + if elements[0] != "plug" || elements[2] != "action" { + return nil, false, fmt.Errorf("Expected plug/<id>/action but got: %s", m.Topic) + } + + switch string(m.Payload) { + case "on": + action = TPLink_HS100_ON + case "off": + action = TPLink_HS100_OFF + default: + return nil, false, fmt.Errorf("Invalid payload '%s'.", m.Payload) + } + + id := elements[1] + + for _, c := range config { + if c.ID == id { + ip := net.ParseIP(c.IP) + return &ip, action, nil + } + } + + return nil, false, fmt.Errorf("Got message for unknown plug '%s'", id) +} + +func encrypt(data []byte) ([]byte, error) { + if len(data) > MAX_PAYLOAD { + return []byte{}, fmt.Errorf("Too many bytes to encrypt (%d > %d)!\n", + len(data), MAX_PAYLOAD) + } + length := uint32(len(data)) + + out := make([]byte, 4) + binary.BigEndian.PutUint32(out, length) + + key := byte(171) + for _, value := range data { + key = key ^ value + out = append(out, byte(key)) + } + + return out, nil +} + +func send(address string, data []byte) error { + var d net.Dialer + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + + conn, err := d.DialContext(ctx, "tcp", address) + if err != nil { + return fmt.Errorf("Failed to dial: %v", err) + } + defer conn.Close() + + _, err = conn.Write(data) + if err != nil { + return fmt.Errorf("Could not write data: %v", err) + } + + return nil +} + +func set(ip net.IP, state bool) error { + cmd := "" + + switch state { + case TPLink_HS100_ON: + cmd = `{"system":{"set_relay_state":{"state":1}}}` + case TPLink_HS100_OFF: + cmd = `{"system":{"set_relay_state":{"state":0}}}` + } + + address := fmt.Sprintf("%s:9999", ip.String()) + data, err := encrypt([]byte(cmd)) + if err != nil { + return err + } + err = send(address, data) + + return err +} |
