diff --git a/.gitignore b/.gitignore index f4d432a..1c8f618 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,7 @@ *.dll *.so *.dylib +/ot-browser # Test binary, built with `go test -c` *.test @@ -15,3 +16,5 @@ # Dependency directories (remove the comment below to include it) # vendor/ +# Config files +*.ini diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..091a4d3 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,11 @@ +FROM golang:alpine AS builder +WORKDIR /app +ADD . /app +RUN apk update && apk add build-base +RUN cd /app && make requirements && make ot-browser-static + +FROM scratch +WORKDIR /app +COPY --from=builder /app/www /www +COPY --from=builder /app/ot-recorder /usr/bin/ot-recorder +ENTRYPOINT ["/usr/bin/ot-browser", "/www"] diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..8422dd7 --- /dev/null +++ b/Makefile @@ -0,0 +1,21 @@ +default: all +all: ot-browser + +requirements: + go get github.com/eclipse/paho.mqtt.golang + +ot-browser: cmd/ot-browser/*.go + go build -o ot-browser $^ + +ot-browser-static: cmd/ot-browser/*.go + export CGO_ENABLED=0 + CGO_ENABLED=0 go build -ldflags="-w -s" -o ot-browser $^ + + +container: Dockerfile cmd/ot-browser/*.go + docker build . -t feldspaten.org/ot-browser + +#deploy: Dockerfile cmd/ot-browser/*.go +# docker build . -t grisu48/ot-browser +# docker push grisu48/ot-browser + diff --git a/cmd/ot-browser.go b/cmd/ot-browser.go deleted file mode 100644 index d3f912c..0000000 --- a/cmd/ot-browser.go +++ /dev/null @@ -1,7 +0,0 @@ -package main - -import "fmt" - -func main() { - fmt.Println("ot-browser") -} diff --git a/cmd/ot-browser/geojson.go b/cmd/ot-browser/geojson.go new file mode 100644 index 0000000..9673409 --- /dev/null +++ b/cmd/ot-browser/geojson.go @@ -0,0 +1,22 @@ +package main + +type GeoJSON struct { + Type string `json:"type"` + Geometry Geometry `json:"geometry"` + Properties map[string]string `json:"properties"` +} + +type Geometry struct { + Type string `json:"type"` + Coordinates [2]float32 `json:"coordinates"` +} + +func CreatePoint(lon float32, lat float32) GeoJSON { + var geojson GeoJSON + geojson.Type = "Feature" + geojson.Properties = make(map[string]string, 0) + geojson.Geometry.Type = "Point" + geojson.Geometry.Coordinates[0] = lon + geojson.Geometry.Coordinates[1] = lat + return geojson +} diff --git a/cmd/ot-browser/location.go b/cmd/ot-browser/location.go new file mode 100644 index 0000000..717c008 --- /dev/null +++ b/cmd/ot-browser/location.go @@ -0,0 +1,58 @@ +package main + +import ( + "fmt" + "math" +) + +type Location struct { + Timestamp int64 + Lon float32 + Lat float32 + Alt float32 + Acc float32 +} + +// Distance computes the distance (in meters) between two points +func (loc *Location) Distance(loc2 Location) float32 { + R := 6371e3 // metres + // Angles are in radians ( * math.Pi/180.0) + phi1 := loc.Lat * math.Pi / 180.0 + phi2 := loc.Lon * math.Pi / 180.0 + deltaPhi := (loc2.Lat - loc.Lat) * math.Pi / 180.0 + deltaLambda := (loc2.Lon - loc.Lon) * math.Pi / 180.0 + + a := math.Sin(float64(deltaPhi/2))*math.Sin(float64(deltaPhi/2.0)) + + math.Cos(float64(phi1))*math.Cos(float64(phi2))* + math.Sin(float64(deltaLambda/2))*math.Sin(float64(deltaLambda/2)) + c := 2 * math.Atan2(math.Sqrt(a), math.Sqrt(1-a)) + return float32(R * c) +} + +/* Basic plausability check for the values */ +func (l *Location) IsPlausible() bool { + // I keep those two comparisons for now + if l.Timestamp == 0 && l.Lon == 0 && l.Lat == 0 { + return false + } + // Sometimes I get updates with no location but a valid timestamp. Don't record those + if l.Lon == 0 && l.Lat == 0 { + return false + } + // Out of bounds + if l.Lon < -180 || l.Lon > 180 { + return false + } + if l.Lat < -90 || l.Lat > 90 { + return false + } + // Negative accuracy is not accepted + if l.Acc < 0 { + return false + } + return true +} + +func (l Location) String() string { + return fmt.Sprintf("Location{%d,%5.2f,%5.2f,%5.2f,%3.0f}", l.Timestamp, l.Lon, l.Lat, l.Alt, l.Acc) +} diff --git a/cmd/ot-browser/ot-browser.go b/cmd/ot-browser/ot-browser.go new file mode 100644 index 0000000..503c017 --- /dev/null +++ b/cmd/ot-browser/ot-browser.go @@ -0,0 +1,182 @@ +package main + +import ( + "encoding/json" + "fmt" + "log" + "net/http" + "os" + "strings" + + "gopkg.in/ini.v1" +) + +type Config struct { + wwwDir string + bindAddr string + mqttRemote string +} + +var config Config +var mqtt MQTTReceiver +var devices map[string]Location + +func (c *Config) SetDefaults() { + c.wwwDir = "" + c.bindAddr = "127.0.0.1:8090" + c.mqttRemote = "" +} + +func mqtt_recv(id string, loc Location) { + devices[id] = loc +} + +func parseProgramArguments() error { + args := os.Args[1:] + for i := 0; i < len(args); i++ { + arg := args[i] + if len(arg) == 0 { + continue + } + if arg == "-h" || arg == "--help" { + fmt.Printf("Usage: %s [OPTIONS]", os.Args[0]) + fmt.Println(" -h,--help Print this help message") + fmt.Println(" -w,--www DIR Set WWW content directory") + fmt.Println(" -c,--config FILE Read configuration from the ini FILE") + fmt.Println(" -b,--bind ADDR Bind webserver to ADDR") + os.Exit(0) + } else if arg == "-w" || arg == "--www" { + i++ + config.wwwDir = args[i] + } else if arg == "-c" || arg == "--config" { + i++ + if err := config.ReadFile(args[i]); err != nil { + return err + } + } else if arg == "-b" || arg == "--bind" { + i++ + config.bindAddr = args[i] + } else { + return fmt.Errorf("Invalid argument: %s\n", arg) + } + } + return nil +} + +func main() { + fmt.Println("ot-browser") + devices = make(map[string]Location, 0) + config.SetDefaults() + parseProgramArguments() + + // Connect mqtt + if config.mqttRemote == "" { + fmt.Fprintf(os.Stderr, "No mqtt remote set\n") + os.Exit(1) + } + mqtt.Received = mqtt_recv + if err := mqtt.Connect(config.mqttRemote, "owntracks/#", "", "", "ot-browser"); err != nil { + fmt.Fprintf(os.Stderr, "mqtt error: %s\n", err) + os.Exit(1) + } + + // Setup webserver + fs := http.FileServer(http.Dir(config.wwwDir)) + http.Handle("/", http.StripPrefix("/", fs)) + http.HandleFunc("/devices", handlerDevices) + http.HandleFunc("/devices.json", handlerDevices) + http.HandleFunc("/locations", handlerLocations) + http.HandleFunc("/devices/", handlerDeviceQuery) + fmt.Println("Serving: http://" + config.bindAddr) + log.Fatal(http.ListenAndServe(config.bindAddr, nil)) +} + +func (c *Config) ReadFile(filename string) error { + cfg, err := ini.InsensitiveLoad(filename) + if err != nil { + return err + } + + mqtt := cfg.Section("mqtt") + if val := mqtt.Key("remote").String(); val != "" { + c.mqttRemote = val + } + www := cfg.Section("www") + if val := www.Key("remote").String(); val != "" { + c.wwwDir = val + } + if val := mqtt.Key("bind").String(); val != "" { + c.bindAddr = val + } + return nil +} + +func handlerDevices(w http.ResponseWriter, r *http.Request) { + devs := make([]string, 0) + for dev := range devices { + devs = append(devs, dev) + } + + buf, err := json.Marshal(devs) + if err != nil { + w.WriteHeader(500) + w.Write([]byte("Server error")) + fmt.Fprintf(os.Stderr, "json marshal error: %s\n", err) + return + } + w.Write(buf) +} + +func handlerLocations(w http.ResponseWriter, r *http.Request) { + locations := make([]GeoJSON, 0) + for dev, loc := range devices { + point := CreatePoint(loc.Lon, loc.Lat) + point.Properties["name"] = dev + point.Properties["id"] = dev + point.Properties["time"] = fmt.Sprintf("%d", loc.Timestamp) + point.Properties["altitude"] = fmt.Sprintf("%d", loc.Alt) + point.Properties["accuracy"] = fmt.Sprintf("%d", loc.Acc) + locations = append(locations, point) + } + + buf, err := json.Marshal(locations) + if err != nil { + w.WriteHeader(500) + w.Write([]byte("Server error")) + fmt.Fprintf(os.Stderr, "json marshal error: %s\n", err) + return + } + w.Write([]byte("{ \"features\": ")) + w.Write(buf) + w.Write([]byte("}")) +} + +func handlerDeviceQuery(w http.ResponseWriter, r *http.Request) { + // extract device name from the path + path := r.URL.Path + device := "" + if i := strings.Index(path, "devices/"); i > 0 { + device = path[i+8:] + } else { + goto server_Error + } + + if loc, ok := devices[device]; ok { + buf, err := json.Marshal(loc) + if err != nil { + w.WriteHeader(500) + w.Write([]byte("Server error")) + fmt.Fprintf(os.Stderr, "json marshal error: %s\n", err) + return + } + w.Write(buf) + } else { + w.WriteHeader(404) + w.Write([]byte("device not found")) + } + return +server_Error: + w.WriteHeader(500) + w.Write([]byte("Server error")) + return +} diff --git a/cmd/ot-browser/receiver.go b/cmd/ot-browser/receiver.go new file mode 100644 index 0000000..3259a12 --- /dev/null +++ b/cmd/ot-browser/receiver.go @@ -0,0 +1,159 @@ +package main + +import ( + "encoding/json" + "fmt" + "log" + "os" + "strings" + "time" + + MQTT "github.com/eclipse/paho.mqtt.golang" +) + +type MQTTCallback func(id string, loc Location) + +type MQTTReceiver struct { + mqtt MQTT.Client + remote string + topic string + connected bool + Received MQTTCallback +} + +func (mqtt *MQTTReceiver) Connect(remote string, topic string, username string, password string, clientid string) error { + // Ensure topics ends with '#' + if !strings.HasSuffix(topic, "#") { + if !strings.HasSuffix(topic, "/") { + topic += "/#" + } else { + topic += "#" + } + } + + // Add default port to remote if not existing + // TODO: IPv6 handling is not yet implemented + if !strings.Contains(remote, ":") { + remote += ":1883" + } + + mqtt.remote = remote + mqtt.topic = topic + opts := MQTT.NewClientOptions().AddBroker("tcp://" + remote) + if username != "" { + opts.SetUsername(username) + } + if password != "" { + opts.SetPassword(password) + } + if clientid != "" { + opts.SetClientID(clientid) + } + opts.SetKeepAlive(20 * time.Second) + opts.SetConnectionLostHandler(mqtt.mqttConnectionLost) + opts.SetOnConnectHandler(mqtt.mqttConnected) + opts.SetAutoReconnect(true) + c := MQTT.NewClient(opts) + mqtt.mqtt = c + // TODO: Add listener also if initial connection fails (and attempt reconnects) + if token := c.Connect(); token.Wait() && token.Error() != nil { + log.Println(fmt.Sprintf("Error connecting to MQTT %s - %s", remote, token.Error())) + } else { + if token := c.Subscribe(topic, 0, mqtt.mqttReceive); token.Wait() && token.Error() != nil { + log.Println(fmt.Sprintf("Error subscribing listener %s - %s", remote, token.Error())) + } else { + log.Printf("MQTT %s subscribed to topic '%s'", remote, topic) + } + } + return nil +} + +func (mqtt *MQTTReceiver) mqttReceive(client MQTT.Client, msg MQTT.Message) { + payload := string(msg.Payload()) + topic := msg.Topic() + if strings.HasPrefix(topic, "owntracks/") { + // Topic: owntracks/USER/DEVICE + // Split topic to user and device + topic = topic[10:] + i := strings.Index(topic, "/") + if i <= 0 { + log.Printf("Illegal owntracks topic received\n") + return + } + username := topic[:i] + devicename := topic[i+1:] + + i = strings.Index(devicename, "/") + if i > 0 { // Ignore special device topics (e.g. 'cmd' or 'events') + //fmt.Printf("Ignoring topic %s \n", topic) + return + } + identifier := fmt.Sprintf("%s/%s", username, devicename) + loc, err := parseLocationJson(payload) + if err != nil { + log.Printf("Error processing JSON (MQTT) : %s", err) + return + } + if mqtt.Received != nil { + mqtt.Received(identifier, loc) + } + } else { + // Invalid topic. Ignore for now. + } +} + +func (mqtt *MQTTReceiver) mqttConnectionLost(client MQTT.Client, err error) { + mqtt.connected = false + options := client.OptionsReader() + remotes := options.Servers() + remote := "" + if len(remotes) > 0 { + // TODO:: What to do if there are more? + remote = remotes[0].String() + } + for { + fmt.Fprintf(os.Stderr, "Reconnecting to %s after failure: %s\n", remote, err) + token := client.Connect() + if token.Wait() { + break + } + } + log.Printf("MQTT connection %s established", remote) +} + +func (mqtt *MQTTReceiver) mqttConnected(client MQTT.Client) { + log.Printf("MQTT connected: %s", mqtt.remote) + mqtt.connected = true +} + +/** Parse json as location */ +func parseLocationJson(buf string) (Location, error) { + var dat map[string]interface{} + var loc Location + + if err := json.Unmarshal([]byte(buf), &dat); err != nil { + return loc, err + } + // Get contents from json message + /* FORMER check to ensure we get "location" types + if dat["_type"].(string) != "location" { + return loc, errors.New("Illegal json type") + } else { + */ + if dat["lon"] != nil { + loc.Lon = float32(dat["lon"].(float64)) + } + if dat["lat"] != nil { + loc.Lat = float32(dat["lat"].(float64)) + } + if dat["alt"] != nil { + loc.Alt = float32(dat["alt"].(float64)) + } + if dat["acc"] != nil { + loc.Acc = float32(dat["acc"].(float64)) + } + if dat["tst"] != nil { + loc.Timestamp = int64(dat["tst"].(float64)) + } + return loc, nil +} diff --git a/go.mod b/go.mod index 310526e..b8a5d99 100644 --- a/go.mod +++ b/go.mod @@ -1,3 +1,9 @@ module feldspaten.org/ot-browser/v2 go 1.15 + +require ( + github.com/eclipse/paho.mqtt.golang v1.3.4 + github.com/smartystreets/goconvey v1.6.4 // indirect + gopkg.in/ini.v1 v1.62.0 +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..a87eec9 --- /dev/null +++ b/go.sum @@ -0,0 +1,22 @@ +github.com/eclipse/paho.mqtt.golang v1.3.4 h1:/sS2PA+PgomTO1bfJSDJncox+U7X5Boa3AfhEywYdgI= +github.com/eclipse/paho.mqtt.golang v1.3.4/go.mod h1:eTzb4gxwwyWpqBUHGQZ4ABAV7+Jgm1PklsYT/eo8Hcc= +github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1 h1:EGx4pi6eqNxGaHF6qqu48+N2wcFQ5qg5FXgOdqsJ5d8= +github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY= +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/jtolds/gls v4.20.0+incompatible h1:xdiiI2gbIgH/gLH7ADydsJ1uDOEzR8yvV7C0MuV77Wo= +github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU= +github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d h1:zE9ykElWQ6/NYmHa3jpm/yHnI4xSofP+UP6SpjHcSeM= +github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d/go.mod h1:OnSkiWE9lh6wB0YB77sQom3nweQdgAjqCqsofrRNTgc= +github.com/smartystreets/goconvey v1.6.4 h1:fv0U8FUIMPNf1L9lnHLvLhgicrIVChEkdzIKYqbNC9s= +github.com/smartystreets/goconvey v1.6.4/go.mod h1:syvi0/a8iFYH4r/RixwvyeAJjdLS9QV7WQ/tjFTllLA= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +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/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/tools v0.0.0-20190328211700-ab21143f2384/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= +gopkg.in/ini.v1 v1.62.0 h1:duBzk771uxoUuOlyRLkHsygud9+5lrlGjdFBb4mSKDU= +gopkg.in/ini.v1 v1.62.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= diff --git a/ot-browser.ini.example b/ot-browser.ini.example new file mode 100644 index 0000000..a338c81 --- /dev/null +++ b/ot-browser.ini.example @@ -0,0 +1,8 @@ +# Configuration file for ot-browser + +[mqtt] +remote = 127.0.0.1 + +[www] +dir = ./www +bind = 127.0.0.1:8090 diff --git a/www/images/marker-icon.png b/www/images/marker-icon.png new file mode 100644 index 0000000..e2e9f75 Binary files /dev/null and b/www/images/marker-icon.png differ diff --git a/www/images/marker-shadow.png b/www/images/marker-shadow.png new file mode 100644 index 0000000..d1e773c Binary files /dev/null and b/www/images/marker-shadow.png differ diff --git a/www/index.html b/www/index.html new file mode 100644 index 0000000..94f4aa3 --- /dev/null +++ b/www/index.html @@ -0,0 +1,15 @@ + + +
+