summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorxengineering <me@xengineering.eu>2022-05-15 11:53:40 +0200
committerxengineering <me@xengineering.eu>2022-05-15 11:56:00 +0200
commit466dd4caeac90dace337c6604e770f0470aee495 (patch)
tree0f9cbde1a5e67f62ca90e045cf1f3d0c7ea4ff18
downloadwebiot-466dd4caeac90dace337c6604e770f0470aee495.tar
webiot-466dd4caeac90dace337c6604e770f0470aee495.tar.zst
webiot-466dd4caeac90dace337c6604e770f0470aee495.zip
Publish code
-rw-r--r--.gitignore1
-rw-r--r--.gitmodules3
-rw-r--r--README.txt35
-rw-r--r--config.json11
-rw-r--r--hs100.go118
-rw-r--r--index.html.tmpl50
m---------libweb0
-rw-r--r--main.go155
8 files changed, 373 insertions, 0 deletions
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..3e18ebf
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1 @@
+private
diff --git a/.gitmodules b/.gitmodules
new file mode 100644
index 0000000..774fbe7
--- /dev/null
+++ b/.gitmodules
@@ -0,0 +1,3 @@
+[submodule "libweb"]
+ path = libweb
+ url = https://cgit.xengineering.eu/libweb/
diff --git a/README.txt b/README.txt
new file mode 100644
index 0000000..a78de7c
--- /dev/null
+++ b/README.txt
@@ -0,0 +1,35 @@
+
+
+------
+webiot
+------
+
+A small webserver for my personal Internet of Things (IoT). This software is
+tailor-made for my personal need supporting only the devices I actually use.
+Nevertheless it could be interesting for you if you want to use my code for
+your project.
+
+
+Supported IoT devices
+---------------------
+
+Currently only this device is supported:
+
+- tp-link HS100 WiFi plug [1]
+
+
+Architecture
+------------
+
+There are three device categories relevant for the architecture:
+
+- IoT devices
+- webiot server
+- web clients connecting to webiot
+
+The basic idea is that the central webiot server is compatible with each IoT
+device via its native protocol (TCP, HTTP, MQTT, etc.). The user interaction is
+done only via the web interface.
+
+
+[1] https://www.tp-link.com/en/home-networking/smart-plug/hs100/
diff --git a/config.json b/config.json
new file mode 100644
index 0000000..9366364
--- /dev/null
+++ b/config.json
@@ -0,0 +1,11 @@
+{
+ "devices":{
+ "hs100":[
+ {"ip":"192.168.1.40","name":"LED strip living room"},
+ {"ip":"192.168.1.42","name":"TV"}
+ ]
+ },
+ "web":{
+ "listen":"127.0.0.1:9000"
+ }
+}
diff --git a/hs100.go b/hs100.go
new file mode 100644
index 0000000..c972f8a
--- /dev/null
+++ b/hs100.go
@@ -0,0 +1,118 @@
+// vim: shiftwidth=4 tabstop=4 noexpandtab
+
+package main
+
+import (
+ "context"
+ "encoding/binary"
+ "fmt"
+ "net"
+ "time"
+)
+
+const (
+ MAX_PAYLOAD = 4294967295 // TP-Link WiFi plug protocol: max. 2^32-1 bytes
+)
+
+// Hs100 bundles every data associated with one TP-Link HS100 smart plug.
+type Hs100 struct {
+ Config Hs100Conf
+}
+
+// Hs100Conf is the configuration of one TP-Link HS100 smart plug.
+type Hs100Conf struct {
+ Ip net.IP
+ Name string
+}
+
+// encrypt() encrypts data for a TP-Link WiFi plug.
+func encrypt(data []byte) ([]byte, error) {
+
+ // assert maximum payload size to cast data length safely
+ 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))
+
+ // encode payload length as header
+ out := make([]byte, 4) // header buffer
+ binary.BigEndian.PutUint32(out, length)
+
+ // encryption algorithm
+ key := byte(171)
+ for _, value := range data {
+ key = key ^ value
+ out = append(out, byte(key))
+ }
+
+ return out, nil
+}
+
+// decrypt() decrypts data coming from a TP-Link WiFi plug.
+func decrypt(data []byte) []byte {
+
+ // TODO check if length given in header is correct
+
+ // cut-off header
+ data = data[4:]
+
+ // decryption algorithm
+ key := byte(171)
+ for index, value := range data {
+ data[index] = key ^ value
+ key = value
+ }
+
+ return data
+}
+
+// send() sends data via TCP to an address (like "192.168.1.42:9999").
+func send(address string, data []byte) error {
+
+ // create a Dialer with context
+ var d net.Dialer
+ ctx, cancel := context.WithTimeout(context.Background(), 3 * time.Second)
+ defer cancel()
+
+ // establish connection
+ conn, err := d.DialContext(ctx, "tcp", address)
+ if err != nil {
+ return fmt.Errorf("Failed to dial: %v", err)
+ }
+ defer conn.Close()
+
+ // writing data
+ _, err = conn.Write(data)
+ if err != nil {
+ return fmt.Errorf("Could not write data: %v", err)
+ }
+
+ return nil
+
+}
+
+// set() sets the relay state of a TP-Link WiFi plug.
+func set(host string, state string) error {
+
+ cmd := ""
+
+ // modify command according to state
+ if state == "on" {
+ cmd = `{"system":{"set_relay_state":{"state":1}}}`
+ } else if state == "off" {
+ cmd = `{"system":{"set_relay_state":{"state":0}}}`
+ } else {
+ return fmt.Errorf("set() just accepts values 'on' and 'off'!")
+ }
+
+ // format address, encrypt data and send it
+ address := fmt.Sprintf("%s:9999", host)
+ data, err := encrypt([]byte(cmd))
+ if err != nil {
+ return err
+ }
+ err = send(address, data)
+
+ return err
+}
diff --git a/index.html.tmpl b/index.html.tmpl
new file mode 100644
index 0000000..0991771
--- /dev/null
+++ b/index.html.tmpl
@@ -0,0 +1,50 @@
+<!DOCTYPE html>
+
+<!--
+ vim: shiftwidth=4 tabstop=4 noexpandtab
+-->
+
+<html>
+
+ <head>
+
+ <title>IoT</title>
+
+ <meta charset="utf-8"/>
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
+ <link rel="stylesheet" href="./webiot.css" type="text/css">
+
+ </head>
+
+ <body>
+
+ <main>
+
+ <h1>IoT</h1>
+ <h3>WiFi plugs</h3>
+
+ {{range .Hs100}}
+ <div class="card">
+ <h3 class="card-first-item">{{.Name}}</h3>
+ <button onclick="api('{{.Ip}}', 'on')">on</button>
+ <button class="card-last-item" onclick="api('{{.Ip}}', 'off')">off</button>
+ </div>
+ {{end}}
+
+ <script>
+ function api(host, state) {
+ const xhttp = new XMLHttpRequest();
+ xhttp.open(
+ "POST",
+ "/api?host=" + host + "&state=" + state,
+ true
+ );
+ xhttp.send();
+ }
+ </script>
+
+ </main>
+
+ </body>
+
+</html>
diff --git a/libweb b/libweb
new file mode 160000
+Subproject d1e432e4bd53d5214fc2ac1fbb01393bf9c425f
diff --git a/main.go b/main.go
new file mode 100644
index 0000000..d6b59b9
--- /dev/null
+++ b/main.go
@@ -0,0 +1,155 @@
+// vim: shiftwidth=4 tabstop=4 noexpandtab
+
+package main
+
+import (
+ "bytes"
+ "encoding/json"
+ "flag"
+ "fmt"
+ "io/ioutil"
+ "log"
+ "net/http"
+ "net/netip"
+ "text/template"
+)
+
+type RuntimeConfig struct {
+ Devices DevicesConfig
+ Web WebConfig
+}
+
+type DevicesConfig struct {
+ Hs100 []Hs100Conf
+}
+
+type WebConfig struct {
+ Listen netip.AddrPort
+}
+
+// main() contains the control flow of this program.
+func main() {
+ configPath := parseFlags()
+ config := parseConfig(configPath)
+ http.HandleFunc("/", index(config.Devices))
+ http.HandleFunc("/api", api())
+ http.HandleFunc("/webiot.css", css())
+ fmt.Printf("Serving at http://%s\n", config.Web.Listen)
+ log.Fatal(http.ListenAndServe(config.Web.Listen.String(), nil))
+}
+
+// parseFlags() handles command line interface (CLI) flags.
+func parseFlags() string {
+
+ var r string // return value
+
+ flag.StringVar(&r, "c", "config.json",
+ "path to configuration file")
+ flag.Parse()
+
+ return r
+}
+
+// parseConfig() parses and validates the runtime configuration file and
+// returns it as Go datastructure.
+func parseConfig(path string) RuntimeConfig {
+
+ // read config file and ensure proper JSON formatting
+ data := mustRead(path)
+ if !json.Valid(data) {
+ log.Fatalf("%s contains invalid JSON!", path)
+ }
+
+ // read to RuntimeConfig struct and handle errors
+ config := RuntimeConfig{}
+ err := json.Unmarshal(data, &config)
+ if err != nil {
+ log.Fatalf("Could not parse configuration file:\n%s\n", err)
+ }
+
+ return config
+}
+
+// index() returns a HTTP handler for the index page.
+func index(config DevicesConfig) func(http.ResponseWriter, *http.Request) {
+
+ // prepare HTML
+ html := mustRender("index.html.tmpl", config)
+
+ return func(w http.ResponseWriter, r *http.Request) {
+ fmt.Fprint(w, html)
+ }
+}
+
+func css() func(http.ResponseWriter, *http.Request) {
+
+ // read CSS file
+ css := string(mustRead("./libweb/libweb.css"))
+
+ return func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "text/css; charset=utf-8")
+ fmt.Fprint(w, css)
+ }
+}
+
+// mustRead() reads a file and panics if this is not possible.
+func mustRead(path string) []byte {
+ data, err := ioutil.ReadFile(path)
+ if err != nil {
+ log.Fatalf("Could not read '%s'!", path)
+ }
+ return data
+}
+
+// mustRender() renders a template file with the given data and panics if this
+// is not possible.
+func mustRender(filepath string, data interface{}) string {
+
+ file := mustRead(filepath)
+ tmpl, err := template.New(filepath).Parse(string(file))
+ var buffer bytes.Buffer
+ err = tmpl.Execute(&buffer, data)
+ if err != nil {
+ fmt.Println(err)
+ log.Fatalf("Could not execute template for %s!", filepath)
+ }
+
+ return buffer.String()
+}
+
+// api() returns the HTTP handler for the API endpoint.
+func api() func(http.ResponseWriter, *http.Request) {
+ return func(w http.ResponseWriter, r *http.Request) {
+
+ // TODO assert correct HTTP method
+
+ // read parameters and handle errors
+ errHost, host := assertSingleParam(r, "host")
+ errState, state := assertSingleParam(r, "state")
+ if (errHost != nil) || (errState != nil) {
+ http.Error(w,
+ "Provide exactly one host and one state parameter!", 400)
+ return
+ }
+
+ // set WiFi plug
+ err := set(host, state)
+ if err != nil {
+ http.Error(w, "Could not set WiFi plug.", 500)
+ } else {
+ fmt.Fprint(w, "ok")
+ }
+ }
+}
+
+// assertSingleParam() returns the value of given URL key and panics if there
+// is not exactly one match for this key.
+func assertSingleParam(r *http.Request, key string) (error, string) {
+
+ values := r.URL.Query()[key]
+ if len(values) != 1 {
+ return fmt.Errorf("Provide exactly one '%s' parameter!", key), ""
+ }
+ return nil, values[0]
+
+}