diff options
author | xengineering <me@xengineering.eu> | 2022-05-15 11:53:40 +0200 |
---|---|---|
committer | xengineering <me@xengineering.eu> | 2022-05-15 11:56:00 +0200 |
commit | 466dd4caeac90dace337c6604e770f0470aee495 (patch) | |
tree | 0f9cbde1a5e67f62ca90e045cf1f3d0c7ea4ff18 | |
download | webiot-466dd4caeac90dace337c6604e770f0470aee495.tar webiot-466dd4caeac90dace337c6604e770f0470aee495.tar.zst webiot-466dd4caeac90dace337c6604e770f0470aee495.zip |
Publish code
-rw-r--r-- | .gitignore | 1 | ||||
-rw-r--r-- | .gitmodules | 3 | ||||
-rw-r--r-- | README.txt | 35 | ||||
-rw-r--r-- | config.json | 11 | ||||
-rw-r--r-- | hs100.go | 118 | ||||
-rw-r--r-- | index.html.tmpl | 50 | ||||
m--------- | libweb | 0 | ||||
-rw-r--r-- | main.go | 155 |
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 @@ -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] + +} |