From ffbe591b8e87315c6714512cffe82b03467fb435 Mon Sep 17 00:00:00 2001 From: "Tobias Wellnitz, DH1TW" Date: Fri, 8 Dec 2017 18:32:46 +0100 Subject: [PATCH] merged mdns_interface branch and mainly consists of bugfixes - websockets ping/pong - mdns more reliable on windows - minor webui improvements - better documentation - changed "tcp" command to "lan" --- cmd/enumerate.go | 37 ++---- cmd/{tcp_server.go => lan_server.go} | 190 ++++++++++++++++----------- cmd/server.go | 4 +- cmd/webserver.go | 27 ++-- discovery/discovery.go | 17 +-- html/index.html | 4 + html/static/css/style.css | 33 +++++ html/static/js/app.js | 8 +- hub/hub.go | 6 +- readme.md | 152 ++++++++++++++------- rotator/dummy/dummy.go | 32 +++-- rotator/proxy/rotator_proxy.go | 69 +++++++++- rotator/rotator.go | 1 + rotator/yaesu/concurrency_test.go | 11 +- rotator/yaesu/methods_test.go | 2 +- rotator/yaesu/yaesu.go | 47 ++++--- 16 files changed, 420 insertions(+), 220 deletions(-) rename cmd/{tcp_server.go => lan_server.go} (63%) diff --git a/cmd/enumerate.go b/cmd/enumerate.go index d993784..2e9d9f9 100644 --- a/cmd/enumerate.go +++ b/cmd/enumerate.go @@ -9,24 +9,27 @@ import ( "github.com/spf13/cobra" ) -var discoverCmd = &cobra.Command{ +var enumerateCmd = &cobra.Command{ Use: "enumerate", Short: "discover and list all available rotators on the network", Long: `discover and list all available rotators on the network This command performs a mDNS query on your local network and will report all found rotators with their parameters.`, - Run: discoverMDNS, + Run: enumerateMDNS, } func init() { - RootCmd.AddCommand(discoverCmd) + RootCmd.AddCommand(enumerateCmd) } -func discoverMDNS(cmd *cobra.Command, args []string) { +func enumerateMDNS(cmd *cobra.Command, args []string) { fmt.Println("\n...discovering rotators (please wait)") - rots := discovery.LookupRotators() + rots, err := discovery.LookupRotators() + if err != nil { + fmt.Println(err) + } if err := tmpl.Execute(os.Stdout, rots); err != nil { fmt.Println(err) @@ -40,27 +43,11 @@ Found {{. | len}} rotator(s) on this network {{range .}}Rotator: Name: {{.Name}} URL: {{.URL}} - Host: {{.Host}} - Address IPv6: {{.AddrV6}} - Address IPv4: {{.AddrV4}} + Host: {{.Host}}{{if .AddrV4}} + Address IPv4: {{.AddrV4}}{{else}} + Address IPv6: {{.AddrV6}}{{end}} Port: {{.Port}} - + {{end}} `, )) - -// var tmpl = template.Must(template.New("").Parse( -// ` -// Found {{. | len}} rotator(s) on this network - -// {{range .}}Rotator: -// Name: {{.Name}} -// URL: {{.URL}} -// Host: {{.Host}}{{if .AddrV6}} -// Address IPv6: {{.AddrV6}}{{else}} -// Address IPv4: {{.AddrV4}}{{end}} -// Port: {{.Port}} - -// {{end}} -// `, -// )) diff --git a/cmd/tcp_server.go b/cmd/lan_server.go similarity index 63% rename from cmd/tcp_server.go rename to cmd/lan_server.go index 90b7be4..1180d0c 100644 --- a/cmd/tcp_server.go +++ b/cmd/lan_server.go @@ -1,16 +1,16 @@ package cmd import ( - b64 "encoding/base64" - "encoding/json" + "bytes" "fmt" "log" + "net" "os" "os/signal" "strings" "time" - "github.com/hashicorp/mdns" + "github.com/micro/mdns" "github.com/spf13/cobra" "github.com/spf13/viper" @@ -21,38 +21,61 @@ import ( // _ "net/http/pprof" ) -var tcpServerCmd = &cobra.Command{ - Use: "tcp", - Short: "expose a rotator to the network", - Long: `expose a rotator to the network`, - Run: tcpServer, +var lanServerCmd = &cobra.Command{ + Use: "lan", + Short: "expose a rotator on your local network", + Long: ` +The local lan server allows you to expose a rotator to a local area network. +By default, the rotator will only be listening on the loopback adapter. In +order to make it available and discoverable on the local network, a network +connected adapter has to be selected. + +remoteRotator supports access via TCP, emulating the Yaesu GS232 protocol +(disabled by default) and through a web interface (HTTP + Websocket). + +You can select the following rotator types: +1. Yaesu (GS232 compatible) +2. Dummy (great for testing) + +remoteRotator allows to assign a series of meta data to a rotator: +1. Name +2. Azimuth/Elevation minimum value +3. Azimuth/Elevation maximum value +4. Azimuth Mechanical stop + +These metadata enhance the rotators view (e.g. showing overlap) in the web +interface and can also help to limit for example the rotators range if it does +not support full 360°. + +`, + Run: lanServer, } func init() { - serverCmd.AddCommand(tcpServerCmd) - - tcpServerCmd.Flags().BoolP("tcp-enabled", "", true, "enable TCP Server") - tcpServerCmd.Flags().StringP("tcp-host", "u", "127.0.0.1", "Host (use '0.0.0.0' to listen on all network adapters)") - tcpServerCmd.Flags().IntP("tcp-port", "p", 7373, "TCP Port") - tcpServerCmd.Flags().BoolP("http-enabled", "", true, "enable HTTP Server") - tcpServerCmd.Flags().StringP("http-host", "w", "127.0.0.1", "Host (use '0.0.0.0' to listen on all network adapters)") - tcpServerCmd.Flags().IntP("http-port", "k", 7070, "Port for the HTTP access to the rotator") - tcpServerCmd.Flags().BoolP("discovery-enabled", "", true, "make rotator discoverable on the network") - tcpServerCmd.Flags().StringP("portname", "P", "/dev/ttyACM0", "portname / path to the rotator (e.g. COM1)") - tcpServerCmd.Flags().IntP("baudrate", "b", 9600, "baudrate") - tcpServerCmd.Flags().StringP("type", "t", "yaesu", "Rotator type (supported: yaesu, dummy") - tcpServerCmd.Flags().StringP("name", "n", "myRotator", "Name tag for the rotator") - tcpServerCmd.Flags().BoolP("has-azimuth", "", true, "rotator supports Azimuth") - tcpServerCmd.Flags().BoolP("has-elevation", "", false, "rotator supports Elevation") - tcpServerCmd.Flags().DurationP("pollingrate", "", time.Second*1, "rotator polling rate") - tcpServerCmd.Flags().IntP("azimuth-min", "", 0, "metadata: minimum azimuth (in deg)") - tcpServerCmd.Flags().IntP("azimuth-max", "", 360, "metadata: maximum azimuth (in deg)") - tcpServerCmd.Flags().IntP("azimuth-stop", "", 0, "metadata: mechanical azimuth stop (in deg)") - tcpServerCmd.Flags().IntP("elevation-min", "", 0, "metadata: minimum elevation (in deg)") - tcpServerCmd.Flags().IntP("elevation-max", "", 180, "metadata: maximum elevation (in deg)") + serverCmd.AddCommand(lanServerCmd) + + lanServerCmd.Flags().BoolP("tcp-enabled", "", false, "enable TCP Server") + lanServerCmd.Flags().StringP("tcp-host", "u", "127.0.0.1", "Host (use '0.0.0.0' to listen on all network adapters)") + lanServerCmd.Flags().IntP("tcp-port", "p", 7373, "TCP Port") + lanServerCmd.Flags().BoolP("http-enabled", "", true, "enable HTTP Server") + lanServerCmd.Flags().StringP("http-host", "w", "127.0.0.1", "Host (use '0.0.0.0' to listen on all network adapters)") + lanServerCmd.Flags().IntP("http-port", "k", 7070, "Port for the HTTP access to the rotator") + lanServerCmd.Flags().BoolP("discovery-enabled", "", true, "make rotator discoverable on the network") + lanServerCmd.Flags().StringP("portname", "P", "/dev/ttyACM0", "portname / path to the rotator (e.g. COM1)") + lanServerCmd.Flags().IntP("baudrate", "b", 9600, "baudrate") + lanServerCmd.Flags().StringP("type", "t", "yaesu", "Rotator type (supported: yaesu, dummy") + lanServerCmd.Flags().StringP("name", "n", "myRotator", "Name tag for the rotator") + lanServerCmd.Flags().BoolP("has-azimuth", "", true, "rotator supports Azimuth") + lanServerCmd.Flags().BoolP("has-elevation", "", false, "rotator supports Elevation") + lanServerCmd.Flags().DurationP("pollingrate", "", time.Second*1, "rotator polling rate") + lanServerCmd.Flags().IntP("azimuth-min", "", 0, "metadata: minimum azimuth (in deg)") + lanServerCmd.Flags().IntP("azimuth-max", "", 360, "metadata: maximum azimuth (in deg)") + lanServerCmd.Flags().IntP("azimuth-stop", "", 0, "metadata: mechanical azimuth stop (in deg)") + lanServerCmd.Flags().IntP("elevation-min", "", 0, "metadata: minimum elevation (in deg)") + lanServerCmd.Flags().IntP("elevation-max", "", 180, "metadata: maximum elevation (in deg)") } -func tcpServer(cmd *cobra.Command, args []string) { +func lanServer(cmd *cobra.Command, args []string) { // Try to read config file if err := viper.ReadInConfig(); err == nil { @@ -134,13 +157,6 @@ func tcpServer(cmd *cobra.Command, args []string) { os.Exit(1) } - // check if values from config file / pflags are valid - // if !checkParameterValues() { - // --> tcp & http host must be valid hostnames -> catch panic & exit gracefully - - // os.Exit(-1) - // } - // go func() { // log.Println(http.ListenAndServe("0.0.0.0:6060", http.DefaultServeMux)) // }() @@ -166,7 +182,8 @@ func tcpServer(cmd *cobra.Command, args []string) { h := &hub.Hub{} rotatorError := make(chan struct{}) - rotatorShutdown := make(chan struct{}) + + var r rotator.Rotator switch strings.ToUpper(viper.GetString("rotator.type")) { @@ -183,20 +200,16 @@ func tcpServer(cmd *cobra.Command, args []string) { elMin := yaesu.ElevationMin(viper.GetInt("rotator.elevation-min")) elMax := yaesu.ElevationMax(viper.GetInt("rotator.elevation-max")) azStop := yaesu.AzimuthStop(viper.GetInt("rotator.azimuth-stop")) + errorCh := yaesu.ErrorCh(rotatorError) - yaesu, err := yaesu.NewYaesu(name, interval, evHandler, + yaesu, err := yaesu.New(name, interval, evHandler, spPortName, baudrate, hasAzimuth, hasElevation, azMin, azMax, elMin, - elMax, azStop) + elMax, azStop, errorCh) if err != nil { fmt.Println("unable to initialize YAESU rotator:", err) os.Exit(1) } - h, err = hub.NewHub(yaesu) - if err != nil { - fmt.Println(err) - os.Exit(1) - } - yaesu.Start(rotatorError, rotatorShutdown) + r = yaesu case "DUMMY": evHandler := dummy.EventHandler(yaesuEventHandler) @@ -209,24 +222,25 @@ func tcpServer(cmd *cobra.Command, args []string) { elMax := dummy.ElevationMax(viper.GetInt("rotator.elevation-max")) azStop := dummy.AzimuthStop(viper.GetInt("rotator.azimuth-stop")) - dummyRotator, err := dummy.NewDummyRotator(name, evHandler, hasAzimuth, hasElevation, azMin, azMax, azStop, elMin, elMax) + dummyRotator, err := dummy.New(name, evHandler, hasAzimuth, hasElevation, azMin, azMax, azStop, elMin, elMax) if err != nil { fmt.Println("unable to initialize Dummy rotator:", err) os.Exit(1) } - h, err = hub.NewHub(dummyRotator) - if err != nil { - fmt.Println(err) - os.Exit(1) - } - dummyRotator.Start(rotatorShutdown) + r = dummyRotator default: log.Printf("unknown rotator type (%v)\n", viper.GetString("rotator.type")) os.Exit(1) } + h, err := hub.NewHub(r) + if err != nil { + fmt.Println(err) + os.Exit(1) + } + tcpError := make(chan bool) // start TCP server @@ -241,25 +255,13 @@ func tcpServer(cmd *cobra.Command, args []string) { go h.ListenHTTP(viper.GetString("http.host"), viper.GetInt("http.port"), wsError) } - // shutdownWg := sync.WaitGroup{} // start mDNS server mDNSShutdown := make(chan struct{}) if viper.GetBool("discovery.enabled") { - go func() { - mDNSService, err := mdns.NewMDNSService(viper.GetString("rotator.name"), - "rotators.shackbus", "", "", viper.GetInt("http.port"), nil, nil) - - if err != nil { - log.Printf("unable to start mDNS discovery service: %s\n", err) - log.Println("mDNS discovery is disabled") - return - } - - mDNSServer, _ := mdns.NewServer(&mdns.Config{Zone: mDNSService}) - defer mDNSServer.Shutdown() - <-mDNSShutdown - }() + if err := startMdnsServer(mDNSShutdown); err != nil { + log.Println(err) + } } // Channel to handle OS signals @@ -272,7 +274,7 @@ func tcpServer(cmd *cobra.Command, args []string) { select { case sig := <-osSignals: if sig == os.Interrupt { - close(rotatorShutdown) + r.Close() close(mDNSShutdown) return } @@ -289,12 +291,52 @@ func tcpServer(cmd *cobra.Command, args []string) { } -func encodeInfo(i rotator.Info) (string, error) { - res, err := json.Marshal(i) +func startMdnsServer(shutdown <-chan struct{}) error { + + if !viper.GetBool("http.enabled") { + return fmt.Errorf("discovery disabled; the HTTP server must be enabled and accessible over a network interface (e.g. 0.0.0.0)") + } + + netif := net.ParseIP(viper.GetString("http.host")) + + if bytes.Compare(netif, net.IPv4zero) != 0 && + bytes.Compare(netif, net.IPv6zero) != 0 && + bytes.Compare(netif, getOutboundIP()) != 0 { + return fmt.Errorf("discovery disabled; the HTTP server must listen on an accessible network interface (e.g. 0.0.0.0)") + } + + go func() { + mDNSService, err := mdns.NewMDNSService(viper.GetString("rotator.name"), + "_rotator._tcp", "", "", viper.GetInt("http.port"), + []net.IP{getOutboundIP()}, nil) + + if err != nil { + log.Printf("discovery disabled; unable to start mDNS service: %s\n", err) + return + } + + mDNSServer, err := mdns.NewServer(&mdns.Config{Zone: mDNSService}) + if err != nil { + log.Printf("discovery disabled; unable to start mDNS service: %s\n", err) + return + } + defer mDNSServer.Shutdown() + <-shutdown + }() + + return nil +} + +// Get preferred outbound ip of this machine +func getOutboundIP() net.IP { + conn, err := net.Dial("udp", "8.8.8.8:80") if err != nil { - return "", err + log.Println("No network adapter detected; Using Loopback only") + return net.IPv4(127, 0, 0, 1) } + defer conn.Close() + + localAddr := conn.LocalAddr().(*net.UDPAddr) - uEnc := b64.URLEncoding.EncodeToString(res) - return uEnc, nil + return localAddr.IP } diff --git a/cmd/server.go b/cmd/server.go index 09f698f..1d2f5a8 100644 --- a/cmd/server.go +++ b/cmd/server.go @@ -12,10 +12,10 @@ var serverCmd = &cobra.Command{ Short: "remoteRotator Server", Long: `Run a remoteRotator server -Start a remoteRotator server using a specific transportation protocol. +Start a remoteRotator server using a specific transportation protocols. `, Run: func(cmd *cobra.Command, args []string) { - fmt.Println("Please select a transportation protocol (--help for available options)") + fmt.Println("Please select the server type (--help for available options)") }, } diff --git a/cmd/webserver.go b/cmd/webserver.go index c0ef7fa..50db882 100644 --- a/cmd/webserver.go +++ b/cmd/webserver.go @@ -97,7 +97,11 @@ var ev = func(r rotator.Rotator, ev rotator.Event, value ...interface{}) { func (w *webserver) update() { - dsvrdRotators := discovery.LookupRotators() + dsvrdRotators, err := discovery.LookupRotators() + if err != nil { + log.Println(err) + return + } // check if rotator(s) are not registered yet for _, dr := range dsvrdRotators { @@ -105,13 +109,15 @@ func (w *webserver) update() { // if the rotator is new, then add it if !w.HasRotator(dr.Name) { - done := make(chan struct{}) + doneCh := make(chan struct{}) + done := proxy.DoneCh(doneCh) host := proxy.Host(dr.AddrV4.String()) port := proxy.Port(dr.Port) eh := proxy.EventHandler(ev) r, err := proxy.New(done, host, port, eh) if err != nil { - log.Println(err) + log.Println("unable to create proxy object:", err) + r = nil continue } if err := w.AddRotator(r); err != nil { @@ -119,22 +125,9 @@ func (w *webserver) update() { continue } go func() { - <-done + <-doneCh w.RemoveRotator(r) }() } } - - // check if a rotator has to be removed - for _, r := range w.Rotators() { - found := false - for _, dr := range dsvrdRotators { - if dr.Name == r.Name() { - found = true - } - } - if !found { - w.RemoveRotator(r) - } - } } diff --git a/discovery/discovery.go b/discovery/discovery.go index b399d9b..a3530e0 100644 --- a/discovery/discovery.go +++ b/discovery/discovery.go @@ -19,28 +19,30 @@ type RotatorMdnsEntry struct { // LookupRotators will perform an mDNS query are lookup all available // rotators on the network. -func LookupRotators() []RotatorMdnsEntry { +// func LookupRotators() ([]RotatorMdnsEntry, error) { +// entriesCh := make(chan *mdns.ServiceEntry, 100) + +func LookupRotators() ([]RotatorMdnsEntry, error) { entriesCh := make(chan *mdns.ServiceEntry, 100) - // rotators := []*mdns.ServiceEntry{} rotators := []RotatorMdnsEntry{} go func() { for entry := range entriesCh { // ignore if not rotators.shackbus.local - if !strings.Contains(entry.Name, "rotators.shackbus.local") { + if !strings.Contains(entry.Name, "_rotator._tcp.local") { continue } - name := strings.TrimSuffix(entry.Name, ".rotators.shackbus.local.") + name := strings.TrimSuffix(entry.Name, "._rotator._tcp.local.") // replace '\' (escaping backslashes) name = strings.Replace(name, "\x5c", "", -1) r := RotatorMdnsEntry{ Name: name, URL: entry.Name, - Host: entry.Host, + Host: strings.TrimSuffix(entry.Host, "."), AddrV4: entry.AddrV4, AddrV6: entry.AddrV6, Port: entry.Port, @@ -49,9 +51,8 @@ func LookupRotators() []RotatorMdnsEntry { } }() - // Start the lookup - mdns.Lookup("rotators.shackbus", entriesCh) + mdns.Lookup("_rotator._tcp", entriesCh) close(entriesCh) - return rotators + return rotators, nil } diff --git a/html/index.html b/html/index.html index f4bca6b..5d98adb 100644 --- a/html/index.html +++ b/html/index.html @@ -14,6 +14,10 @@
+
+ +

Searching for rotators...

+
diff --git a/html/static/css/style.css b/html/static/css/style.css index 1e514a8..3b1c6dc 100644 --- a/html/static/css/style.css +++ b/html/static/css/style.css @@ -9,6 +9,39 @@ body { background-color: black; } +#loading{ + position:fixed; + padding:0; + margin:0; + top:0; + left:0; + width: 100%; + height: 100%; + background-color: black; + z-index: 1; +} + +#loading .spinner{ + color: white; + position:fixed; + top: 50%; + left: 50%; + margin-left: -64px; + margin-top: -70px; + font-size: 128px; +} + +#loading p{ + color: white; + font-size: 22px; + position: fixed; + top: 50%; + left: 50%; + margin-left: -128px; + margin-top: 100px; + +} + #app { width: fit-content; margin-left: auto; diff --git a/html/static/js/app.js b/html/static/js/app.js index bc57162..c0ff7d9 100644 --- a/html/static/js/app.js +++ b/html/static/js/app.js @@ -90,7 +90,7 @@ var vm = new Vue({ if (Object.keys(this.elRotators).length > 0) { if (this.selectedElRotator.name == rotator.name) { // pick the first one in the list - var nextRot = Object.keys(this.azRotators)[0]; + var nextRot = Object.keys(this.elRotators)[0]; this.selectedElRotator = this.elRotators[nextRot]; } } else { @@ -272,5 +272,11 @@ var vm = new Vue({ }); return ordered; }, + loading: function() { + if (Object.keys(this.rotators).length == 0){ + return false; + } + return true; + } } }); \ No newline at end of file diff --git a/hub/hub.go b/hub/hub.go index 8c112ce..68dda3c 100644 --- a/hub/hub.go +++ b/hub/hub.go @@ -78,7 +78,7 @@ func (hub *Hub) addRotator(r rotator.Rotator) error { if err := hub.broadcastToWsClients(ev); err != nil { fmt.Println(err) } - log.Printf("adding rotator (%s)\n", r.Name()) + log.Printf("added rotator (%s)\n", r.Name()) return nil } @@ -98,8 +98,8 @@ func (hub *Hub) RemoveRotator(r rotator.Rotator) { } delete(hub.rotators, r.Name()) - // TBD: Make sure rotator gets destroyed !!!!! (eg websocket closed, etc) - log.Printf("removing rotator (%s)\n", r.Name()) + r.Close() + log.Printf("removed rotator (%s)\n", r.Name()) } // HasRotator returns a bool if a given rotator is already registered. diff --git a/readme.md b/readme.md index 2ae35eb..4479c28 100644 --- a/readme.md +++ b/readme.md @@ -9,7 +9,7 @@ ![Alt text](https://i.imgur.com/lcHhslZ.png "remoteRotator WebUI") remoteRotator is a cross platform application which makes your azimuth / elevation -antenna rotators available on the network and accessible through a web interface. +antenna rotators available on the network. remoteRotator is written in the programing language [Go](https://golang.org). @@ -28,6 +28,7 @@ has been reached. - TCP - HTTP - Websockets +- MQTT / NATS (coming soon) ## License @@ -37,8 +38,11 @@ remoteRotator is published under the permissive [MIT license](https://github.com You can download a tarball / zip archive with the compiled binary for MacOS (AMD64), Linux (386/AMD64/ARM) and Windows (386/AMD64) from the -[releases](https://github.com/dh1tw/remoteRotator/releases) page. remoteRotator -is just a single executable. +[releases](https://github.com/dh1tw/remoteRotator/releases) page. + +remoteRotator works well on SoC boards like the Raspberry / Orange / Banana Pis. + +remoteRotator is just a single executable. ## Dependencies @@ -47,41 +51,89 @@ time. There are no runtime dependencies. ## Getting started -Identify the serial port to which your rotator is connected. On Windows -this will be something like COMx (e.g. COM3), on Linux / MacOS it will be -a device in the `/dev/` folder (e.g. /dev/ttyACM0). +remoteRotator provides a series of nested commands and flags. + +````bash +$ ./remoteRotator +```` + +```` +Network interface for Rotators + +Usage: + remoteRotator [command] + +Available Commands: + enumerate discover and list all available rotators on the network + help Help about any command + server remoteRotator Server + version Print the version number of remoteRotator + web webserver providing access to all rotators on the network + +Flags: + --config string config file (default is $HOME/.remoteRotator.yaml) + -h, --help help for remoteRotator + +Use "remoteRotator [command] --help" for more information about a command. +```` + +So let's fire up a remoteRotator server for your rotator: + +First, identify the serial port to which your rotator is connected. On Windows +this will be something like `COMx` (e.g. `COM3`), on Linux / MacOS it will be +a device in the `/dev/` folder (e.g. `/dev/ttyACM0`). All parameters can be set either in a config file (see below) or through pflags. -To get a list of supported flags for the tcp server, execute: +To get a list of supported flags for the lan server, execute: -```bash -$ remoteRotator server tcp --help +``` +$ ./remoteRotator server lan --help ``` ``` -expose a rotator to the network +The local lan server allows you to expose a rotator to a local area network. +By default, the rotator will only be listening on the loopback adapter. In +order to make it available and discoverable on the local network, a network +connected adapter has to be selected. + +remoteRotator supports access via TCP, emulating the Yaesu GS232 protocol +(disabled by default) and through a web interface (HTTP + Websocket). + +You can select the following rotator types: +1. Yaesu (GS232 compatible) +2. Dummy (great for testing) + +remoteRotator allows to assign a series of meta data to a rotator: +1. Name +2. Azimuth/Elevation minimum value +3. Azimuth/Elevation maximum value +4. Azimuth Mechanical stop + +These metadata enhance the rotators view (e.g. showing overlap) in the web +interface and can also help to limit for example the rotators range if it does +not support full 360°. Usage: - remoteRotator server tcp [flags] + remoteRotator server lan [flags] Flags: - --azimuth-max int metadata: maximum azimuth (in deg) (default 450) + --azimuth-max int metadata: maximum azimuth (in deg) (default 360) --azimuth-min int metadata: minimum azimuth (in deg) --azimuth-stop int metadata: mechanical azimuth stop (in deg) -b, --baudrate int baudrate (default 9600) --discovery-enabled make rotator discoverable on the network (default true) --elevation-max int metadata: maximum elevation (in deg) (default 180) --elevation-min int metadata: minimum elevation (in deg) - --has-azimuth Indicate if the rotator supports Azimuth (default true) - --has-elevation Indicate if the rotator supports Elevation - -h, --help help for tcp + --has-azimuth rotator supports Azimuth (default true) + --has-elevation rotator supports Elevation + -h, --help help for lan --http-enabled enable HTTP Server (default true) -w, --http-host string Host (use '0.0.0.0' to listen on all network adapters) (default "127.0.0.1") -k, --http-port int Port for the HTTP access to the rotator (default 7070) -n, --name string Name tag for the rotator (default "myRotator") --pollingrate duration rotator polling rate (default 1s) -P, --portname string portname / path to the rotator (e.g. COM1) (default "/dev/ttyACM0") - --tcp-enabled enable TCP Server (default true) + --tcp-enabled enable TCP Server -u, --tcp-host string Host (use '0.0.0.0' to listen on all network adapters) (default "127.0.0.1") -p, --tcp-port int TCP Port (default 7373) -t, --type string Rotator type (supported: yaesu, dummy (default "yaesu") @@ -91,35 +143,43 @@ Global Flags: ``` So in order to launch remoteRotator on Windows with a Yaesu rotator connected at -COM3 and having the server listening on the network port 5050, we would call: +COM3 an having the web HTTP server listening on your local network, we would call: ```bash -$ remoteRotator server tcp -u "0.0.0.0" -p 5050 -P "COM3" +$ remoteRotator.exe server lan -w "0.0.0.0" -P "COM3" -t yaesu ``` ``` no config file found -Listening on 0.0.0.0:5050 for TCP connections -Listening on 0.0.0.0:7070 for HTTP connections - +2017/12/08 16:50:25 added rotator (myRotator) +2017/12/08 16:50:25 Listening on 0.0.0.0:7070 for HTTP connections ``` -remoteRotator allows to set a few useful metadata: - -- azimuth/elevation min/max -- mechanical stop - ## Connecting via TCP / Telnet If you have an application (e.g. [arsvcom](https://ea4tx.com/en/arsvcom/) or [pstrotator](http://www.qsl.net/yo3dmu/index_Page346.htm)) which can talk to -a Yaesu compatible rotator, you can point that application to the selected -TCP port. +a Yaesu compatible rotator, you can point that application to remoteRotator's +builtin TCP server (although disabled by default). -You can also connect directly via telnet: +Start remoteRotator: + +```` bash +$ ./remoteRotator -t dummy --tcp-enabled +```` + +```` +no config file found +2017/12/08 16:50:25 added rotator (myRotator) +2017/12/08 16:50:25 listening on 127.0.0.1:7070 for HTTP connections +2017/12/08 16:50:25 listening on 127.0.0.1:7373 for TCP connections +2017/12/08 16:50:25 discovery disabled; the HTTP server must listen on an accessible network interface (e.g. 0.0.0.0) +```` + +For testing, we connect directly via telnet: ``` -$ telnet localhost 5050 +$ telnet localhost 7373 Trying ::1... Connected to localhost. Escape character is '^]'. @@ -149,28 +209,28 @@ You can specify the host and port in the settings above, or deactivate the built-in webserver if you don't need it. The red arrow indicates the heading of the rotator and the yellow arrow -indicates the preset value to which the rotator will turn to. +indicates the preset value to which the rotator will turn to. The yellow arrow +disappears when the desired direction has been reached. The dotted red line indicates the mechanical stop of the rotator. -The green arc segment indicates a limited turning radius for this rotator. -The blue arc segment indicates the mechanical overlap supported by this rotator. +A green arc segment indicates a limited turning radius for this rotator. +A blue arc segment indicates the mechanical overlap supported by this rotator. ## Web Interface (Aggregator) ![Alt text](https://i.imgur.com/lcHhslZ.png "remoteRotator WebUI") -If you have multiple rotators, you might want to use the dedicated web server. -The following example starts the webserver on port 6005 and listens on all -network interfaces. +If you have multiple rotators, you might want to use the dedicated aggregation +web server. The following example starts the webserver on port 6005 and listens +on all network interfaces. -``` +```` $ remoteRotator web -w "0.0.0.0" -k 6005 -``` +```` The Webserver automatically discovers the available remoteRotator instances -in your local network and adds them (or removes them) from the web interface. -Technically the discovery process is based on mDNS and doesn't require any -configuration. +in your local network and adds them from the web interface. Technically the +discovery process is based on mDNS and doesn't require any configuration. ## Config file @@ -213,14 +273,14 @@ the proper behaviour. If you file a bug report, please include always the version of remoteRotator you are running: -``` bash -$ remoteRotator version -``` +```` bash +$ remoteRotator.exe version +```` -``` +```` copyright Tobias Wellnitz, DH1TW, 2017 remoteRotator Version: 0.1.0, darwin/amd64, BuildDate: 2017-09-04T00:58:00+02:00, Commit: 338ff13 -``` +```` ## Documentation diff --git a/rotator/dummy/dummy.go b/rotator/dummy/dummy.go index cc6b17b..2cf0104 100644 --- a/rotator/dummy/dummy.go +++ b/rotator/dummy/dummy.go @@ -32,6 +32,7 @@ type Dummy struct { tickerInterval float32 //ms closeCh chan struct{} starter sync.Once + closer sync.Once } // Name is a functional option to set the name of the rotator @@ -112,7 +113,7 @@ func EventHandler(h func(rotator.Rotator, rotator.Event, ...interface{})) func(* } } -// NewDummyRotator creates a new dummy rotator which satisfies the +// New creates a new dummy rotator which satisfies the // rotator.Rotator interface. Options can be injected through functional // options. If the Dummy can not be initialized, nil and the corresponding error // will be returned. @@ -122,7 +123,7 @@ func EventHandler(h func(rotator.Rotator, rotator.Event, ...interface{})) func(* // elevationMax: 180, // azSpeed: 8, (deg/sec) // elSpeed: 5, (deg/sec) -func NewDummyRotator(options ...func(*Dummy)) (*Dummy, error) { +func New(options ...func(*Dummy)) (*Dummy, error) { r := &Dummy{ hasAzimuth: true, @@ -131,6 +132,7 @@ func NewDummyRotator(options ...func(*Dummy)) (*Dummy, error) { azSpeed: 8, elSpeed: 5, tickerInterval: 100, + closeCh: make(chan struct{}), } for _, opt := range options { @@ -147,20 +149,15 @@ func NewDummyRotator(options ...func(*Dummy)) (*Dummy, error) { r.elevation = float32(r.elevationMin) } - return r, nil -} + r.ticker = time.NewTicker(time.Millisecond * time.Duration(r.tickerInterval)) -// Start starts the main event loop for the dummy rotator. The event loop can be -// shutdown by closing the shutdown channel. -func (r *Dummy) Start(shutdown <-chan struct{}) { - // ensure that the event loop is only started once - r.starter.Do(func() { - go r.start(shutdown) - }) + go r.start() + + return r, nil } -// start the event loop -func (r *Dummy) start(shutdown <-chan struct{}) { +// // start the event loop +func (r *Dummy) start() { r.ticker = time.NewTicker(time.Millisecond * time.Duration(r.tickerInterval)) defer r.ticker.Stop() @@ -169,12 +166,19 @@ func (r *Dummy) start(shutdown <-chan struct{}) { select { case <-r.ticker.C: r.updateHeadings() - case <-shutdown: + case <-r.closeCh: return } } } +// Close shuts down the rotator and prepares it for garbage collection +func (r *Dummy) Close() { + r.closer.Do(func() { + close(r.closeCh) + }) +} + // Name returns the name of the rotator func (r *Dummy) Name() string { r.RLock() diff --git a/rotator/proxy/rotator_proxy.go b/rotator/proxy/rotator_proxy.go index b16e702..34445bb 100644 --- a/rotator/proxy/rotator_proxy.go +++ b/rotator/proxy/rotator_proxy.go @@ -5,7 +5,6 @@ import ( "fmt" "log" "net/http" - "strings" "sync" "time" @@ -15,6 +14,17 @@ import ( "github.com/dh1tw/remoteRotator/rotator" ) +const ( + // Time allowed to write a message to the peer. + wsWriteWait = 5 * time.Second + + // Time allowed to read the next pong message from the peer. + wsPongWait = 10 * time.Second + + // Send pings to peer with this period. Must be less than pongWait. + wsPingPeriod = 3 * time.Second +) + // Proxy is a proxy object representing a remote rotator. It implements // the rotator.Rotator interface. Behind the scenes it sychronizes itself // with the real rotator through a websocket. @@ -23,6 +33,9 @@ type Proxy struct { host string port int conn *websocket.Conn + wsWriteMutex sync.Mutex + wsTxTimeout time.Duration + wsRxTimeout time.Duration eventHandler func(rotator.Rotator, rotator.Event, ...interface{}) name string azimuthMin int @@ -37,6 +50,8 @@ type Proxy struct { azPreset int elevation int elPreset int + closeCh chan struct{} + doneCh chan struct{} } // Host is a functional option to set IP / dns name of the remote Rotators host. @@ -53,6 +68,14 @@ func Port(port int) func(*Proxy) { } } +// DoneCh is a functional option allows you to pass a channel to the proxy object. +// The channel will be closed and thus notifies you when the object has been deleted. +func DoneCh(ch chan struct{}) func(*Proxy) { + return func(r *Proxy) { + r.doneCh = ch + } +} + // EventHandler sets a callback function through which the proxy rotator // will report Events func EventHandler(h func(rotator.Rotator, rotator.Event, ...interface{})) func(*Proxy) { @@ -62,10 +85,11 @@ func EventHandler(h func(rotator.Rotator, rotator.Event, ...interface{})) func(* } // New returns the pointer to an initalized Rotator proxy object. -func New(done chan struct{}, opts ...func(*Proxy)) (*Proxy, error) { +func New(opts ...func(*Proxy)) (*Proxy, error) { r := &Proxy{ - name: "rotatorProxy", + name: "rotatorProxy", + closeCh: make(chan struct{}), } for _, opt := range opts { @@ -84,16 +108,41 @@ func New(done chan struct{}, opts ...func(*Proxy)) (*Proxy, error) { return nil, err } + conn.SetReadDeadline(time.Now().Add(wsPongWait)) + conn.SetPongHandler(func(string) error { + conn.SetReadDeadline(time.Now().Add(wsPongWait)) + return nil + }) + r.conn = conn go func() { - defer close(done) + ping := time.NewTicker(wsPingPeriod) + for { + select { + case <-ping.C: + r.wsWriteMutex.Lock() + r.conn.SetWriteDeadline(time.Now().Add(wsWriteWait)) + if err := r.conn.WriteMessage(websocket.PingMessage, []byte{}); err != nil { + // log.Println(err) + r.wsWriteMutex.Unlock() + return + } + r.wsWriteMutex.Unlock() + } + } + }() + + go func() { for { _, msg, err := conn.ReadMessage() if err != nil { - if !strings.Contains(err.Error(), "EOF") { - log.Println("disconnecting:", err) + if websocket.IsUnexpectedCloseError(err, + websocket.CloseAbnormalClosure, + websocket.CloseNormalClosure) { + log.Println("websocket error:", err) } + close(r.doneCh) return } @@ -142,6 +191,12 @@ func New(done chan struct{}, opts ...func(*Proxy)) (*Proxy, error) { return r, nil } +func (r *Proxy) Close() { + if r.conn != nil { + r.conn.Close() + } +} + func (r *Proxy) getInfo() error { infoURL := fmt.Sprintf("http://%s:%d/info", r.host, r.port) @@ -179,6 +234,8 @@ func (r *Proxy) getInfo() error { } func (r *Proxy) write(s rotator.Status) error { + r.wsWriteMutex.Lock() + defer r.wsWriteMutex.Unlock() return r.conn.WriteJSON(s) } diff --git a/rotator/rotator.go b/rotator/rotator.go index ccf7b32..b5edc7b 100644 --- a/rotator/rotator.go +++ b/rotator/rotator.go @@ -28,6 +28,7 @@ type Rotator interface { Status() Status ExecuteRequest(Request) error Info() Info + Close() } // Status contains the current information from a rotator. The struct diff --git a/rotator/yaesu/concurrency_test.go b/rotator/yaesu/concurrency_test.go index 691e5be..b556cdd 100644 --- a/rotator/yaesu/concurrency_test.go +++ b/rotator/yaesu/concurrency_test.go @@ -69,6 +69,8 @@ func TestYaesuMassiveConcurrentCalls(t *testing.T) { hasAzimuth: true, sp: dp, pollingInterval: time.Second * 2, + closeCh: make(chan struct{}), + errorCh: make(chan struct{}), } rand.Seed(time.Now().UTC().UnixNano()) @@ -76,24 +78,21 @@ func TestYaesuMassiveConcurrentCalls(t *testing.T) { d := time.Second * 5 wg := &sync.WaitGroup{} - yaesuError := make(chan struct{}) - shutdown := make(chan struct{}) - calls := &apiCallCounter{} - go yaesu.Start(yaesuError, shutdown) + go yaesu.start() for i := 0; i < 1000; i++ { go randomAccess(yaesu, d, calls, wg, t) wg.Add(1) } select { - case <-yaesuError: + case <-yaesu.errorCh: t.Errorf("unexpected error while reading from serial port") default: } wg.Wait() - close(shutdown) + yaesu.Close() time.Sleep(time.Second * 3) fmt.Println("Concurrent stress test summary:") fmt.Println(strings.Repeat("=", 30)) diff --git a/rotator/yaesu/methods_test.go b/rotator/yaesu/methods_test.go index ce50bc6..27a8063 100644 --- a/rotator/yaesu/methods_test.go +++ b/rotator/yaesu/methods_test.go @@ -459,7 +459,7 @@ func TestNewYaesuPortNotExist(t *testing.T) { for _, tc := range tt { t.Run(tc.name, func(t *testing.T) { - _, err := NewYaesu(tc.portName) + _, err := New(tc.portName) if err.Error() != tc.expError { if err.Error() != tc.altError { t.Fatalf("expected error '%s', got '%s'", tc.expError, err.Error()) diff --git a/rotator/yaesu/yaesu.go b/rotator/yaesu/yaesu.go index dc70bf8..4c7f286 100644 --- a/rotator/yaesu/yaesu.go +++ b/rotator/yaesu/yaesu.go @@ -38,8 +38,10 @@ type Yaesu struct { sp io.ReadWriteCloser spPortName string spBaudrate int + closeCh chan struct{} + errorCh chan struct{} starter sync.Once - spCloser sync.Once + closer sync.Once headingPattern *regexp.Regexp watchdogTs time.Time } @@ -131,6 +133,14 @@ func ElevationMax(max int) func(*Yaesu) { } } +// ErrorCh is a functional option allows you to pass a channel to the rotator. +// The channel will be closed when an internal error occures. +func ErrorCh(ch chan struct{}) func(*Yaesu) { + return func(r *Yaesu) { + r.errorCh = ch + } +} + // NewYaesu creates a new Yaesu object which satisfies implicitely the // rotator.Rotator interface. Configuration settings are set through functional // options. The the Yaesu can not be initialized nil and the corresponding error @@ -140,7 +150,7 @@ func ElevationMax(max int) func(*Yaesu) { // portname: /dev/ttyACM0, // pollingInterval: 5sec, // baudrate: 9600. -func NewYaesu(opts ...func(*Yaesu)) (*Yaesu, error) { +func New(opts ...func(*Yaesu)) (*Yaesu, error) { // regex Pattern [0-9]{4} -> 0310..etc headingPattern, err := regexp.Compile("[\\d]{4}") @@ -178,17 +188,23 @@ func NewYaesu(opts ...func(*Yaesu)) (*Yaesu, error) { r.sp = sp + go r.start() + return r, nil } -func (r *Yaesu) close() { +// Close shutdowns the rotator object and prepares it for garbage collection +func (r *Yaesu) Close() { r.Lock() defer r.Unlock() if r.pollingTicker != nil { r.pollingTicker.Stop() } - // makes sure that the serial port just gets closed once - r.spCloser.Do(func() { r.sp.Close() }) + // makes sure that the serial port and the event loop just gets closed once + r.closer.Do(func() { + close(r.closeCh) + r.sp.Close() + }) } // resetWatchdog resets the watchdog. This means that a packet has been @@ -214,17 +230,11 @@ func (r *Yaesu) checkWatchdog() bool { // It will query the Yaesu rotator for the current heading (azimuth + elevation) // with the pollingrate defined during initialization. // A watchdog detects if the Yaesu rotator does not respond anymore. -// If an error occures, the communication will be shut down and the -// yaesuError channel will be closed. -func (r *Yaesu) Start(yaesuError chan<- struct{}, shutdown <-chan struct{}) { - r.starter.Do(func() { - go r.start(yaesuError, shutdown) - }) -} - -func (r *Yaesu) start(yaesuError chan<- struct{}, shutdown <-chan struct{}) { - defer close(yaesuError) - defer r.close() +// If an error occures, the errorCh will be closed. +// Consequently the communication will be shut down and the object +// prepared for garbage collection. +func (r *Yaesu) start() { + defer r.Close() r.Lock() r.pollingTicker = time.NewTicker(r.pollingInterval) @@ -237,13 +247,15 @@ func (r *Yaesu) start(yaesuError chan<- struct{}, shutdown <-chan struct{}) { // fmt.Println("tick") if err := r.query(); err != nil { fmt.Println("serial port write error:", err) + close(r.errorCh) return } if r.checkWatchdog() { fmt.Println("communication lost with Yaesu rotator") + close(r.errorCh) return } - case <-shutdown: + case <-r.closeCh: return default: // pass @@ -257,6 +269,7 @@ func (r *Yaesu) start(yaesuError chan<- struct{}, shutdown <-chan struct{}) { } fmt.Printf("serial port read error (%s on %s): %s\n", r.name, r.spPortName, err) + close(r.errorCh) return // exit } r.resetWatchdog()