diff --git a/Changelog.md b/Changelog.md index a345cc5..b3b4c19 100644 --- a/Changelog.md +++ b/Changelog.md @@ -6,6 +6,7 @@ - Let firmware update command look for bootloader if no Senso in regular mode is found - Update build system and development environment +- Add support for triggering firmware updates via websocket ## [2.3.0] - 2022-10-01 diff --git a/go.mod b/go.mod index 52b4b98..d1f69b1 100644 --- a/go.mod +++ b/go.mod @@ -8,8 +8,16 @@ require ( github.com/denisbrodbeck/machineid v1.0.1 github.com/ebfe/scard v0.0.0-20190212122703-c3d1b1916a95 github.com/gorilla/websocket v1.4.2 - github.com/grandcat/zeroconf v1.0.0 github.com/kardianos/service v1.2.0 + + // `libp2p/zeroconf` is a fork of `grandcat/zeroconf`, which we previously used. + // This fork includes some stability improvements and bug fixes that are absent + // in the grandcat version. However, it is libp2p's internal maintenance fork, + // which while being public, does not accept community contributions. + // Both projects are dormant at the moment, but we might want to re-evaluate this + // dependency choice as these projects evolve in the future. + github.com/libp2p/zeroconf/v2 v2.2.0 + github.com/pin/tftp v2.1.0+incompatible github.com/sirupsen/logrus v1.8.1 go.bug.st/serial v1.6.1 diff --git a/go.sum b/go.sum index 43db8d7..22d9bd1 100644 --- a/go.sum +++ b/go.sum @@ -13,16 +13,14 @@ github.com/ebfe/scard v0.0.0-20190212122703-c3d1b1916a95 h1:OM0MnUcXBysj7ZtXvThV github.com/ebfe/scard v0.0.0-20190212122703-c3d1b1916a95/go.mod h1:8hHvF8DlEq5kE3KWOsZQezdWq1OTOVxZArZMscS954E= 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/grandcat/zeroconf v1.0.0 h1:uHhahLBKqwWBV6WZUDAT71044vwOTL+McW0mBJvo6kE= -github.com/grandcat/zeroconf v1.0.0/go.mod h1:lTKmG1zh86XyCoUeIHSA4FJMBwCJiQmGfcP2PdzytEs= github.com/kardianos/service v1.2.0 h1:bGuZ/epo3vrt8IPC7mnKQolqFeYJb7Cs8Rk4PSOBB/g= github.com/kardianos/service v1.2.0/go.mod h1:CIMRFEJVL+0DS1a3Nx06NaMn4Dz63Ng6O7dl0qH0zVM= -github.com/miekg/dns v1.1.27 h1:aEH/kqUzUxGJ/UHcEKdJY+ugH6WEzsEBBSPa8zuy1aM= -github.com/miekg/dns v1.1.27/go.mod h1:KNUDUusw/aVsxyTYZM1oqvCicbwhgbNgztCETuNZ7xM= +github.com/libp2p/zeroconf/v2 v2.2.0 h1:Cup06Jv6u81HLhIj1KasuNM/RHHrJ8T7wOTS4+Tv53Q= +github.com/libp2p/zeroconf/v2 v2.2.0/go.mod h1:fuJqLnUwZTshS3U/bMRJ3+ow/v9oid1n0DmyYyNO1Xs= +github.com/miekg/dns v1.1.43 h1:JKfpVSCB84vrAmHzyrsxB5NAr5kLoMXZArPSw7Qlgyg= +github.com/miekg/dns v1.1.43/go.mod h1:+evo5L0630/F6ca/Z9+GAqzhjGyn8/c+TBaOyfEl0V4= github.com/pin/tftp v2.1.0+incompatible h1:Yng4J7jv6lOc6IF4XoB5mnd3P7ZrF60XQq+my3FAMus= github.com/pin/tftp v2.1.0+incompatible/go.mod h1:xVpZOMCXTy+A5QMjEVN0Glwa1sUvaJhFXbr/aAxuxGY= -github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= -github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/sirupsen/logrus v1.8.1 h1:dJKuHgqk1NNQlqoA6BTlM1Wf9DOH3NBjQyu0h9+AZZE= @@ -33,27 +31,23 @@ github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5Cc github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= go.bug.st/serial v1.6.1 h1:VSSWmUxlj1T/YlRo2J104Zv3wJFrjHIl/T3NeruWAHY= go.bug.st/serial v1.6.1/go.mod h1:UABfsluHAiaNI+La2iESysd9Vetq7VRdpxvjx7CmmOE= -golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= -golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550 h1:ObdrDkeb4kJdCP557AjRjq69pTHfNouLtWZG7j9rPN8= -golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= -golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg= -golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= -golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20190923162816-aa69164e4478/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20200114155413-6afb5195e5aa h1:F+8P+gmewFQYRk6JoLQLwjBCTu3mcIURZfNkVweuRKA= -golang.org/x/net v0.0.0-20200114155413-6afb5195e5aa/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/sync v0.0.0-20190423024810-112230192c58 h1:8gQV6CLnAEikrhgkHFbMAEhagSSnXWGV915qUMm9mrU= -golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20190924154521-2837fb4f24fe/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20210423184538-5f58ad60dda6 h1:0PC75Fz/kyMGhL0e1QnypqK2kQMqKt9csD1GnMJR+Zk= +golang.org/x/net v0.0.0-20210423184538-5f58ad60dda6/go.mod h1:OJAsFXCWl8Ukc7SiCT/9KSuxbyM7479/AVlXFRxuMCk= +golang.org/x/sync v0.0.0-20210220032951-036812b2e83c h1:5KslGYwFpkhGh+Q16bwMP3cOontH8FOep7tGV86Y7SQ= +golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201015000850-e3ed0017c211/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210303074136-134d130e1a04/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210426080607-c94f62235c83/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20220829200755-d48e67d00261 h1:v6hYoSR9T5oet+pMXwUWkbiVqx/63mlHjefrHmxwfeY= golang.org/x/sys v0.0.0-20220829200755-d48e67d00261/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= -golang.org/x/tools v0.0.0-20191216052735-49a3e744a425/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= -golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c h1:dUUwHk2QECo/6vqA44rthZ8ie2QXMNeKRTHCNY2nXvo= diff --git a/src/dividat-driver/firmware/cli.go b/src/dividat-driver/firmware/cli.go new file mode 100644 index 0000000..8c9eb48 --- /dev/null +++ b/src/dividat-driver/firmware/cli.go @@ -0,0 +1,76 @@ +package firmware + +import ( + "context" + "flag" + "fmt" + "io" + "os" + + "github.com/dividat/driver/src/dividat-driver/service" +) + +// Command-line interface to running a firmware update +func Command(flags []string) { + updateFlags := flag.NewFlagSet("update", flag.ExitOnError) + imagePath := updateFlags.String("i", "", "Firmware image path") + sensoSerial := updateFlags.String("s", "", "Senso serial (optional)") + updateFlags.Parse(flags) + + if *imagePath == "" { + flag.PrintDefaults() + return + } + file, err := os.Open(*imagePath) + if err != nil { + fmt.Printf("Could not open image file: %v\n", err) + os.Exit(1) + } + + onProgress := func(progressMsg string) { + fmt.Println(progressMsg) + } + + tryPowerCycling := "Try turning the Senso off and on, waiting for 30 seconds and then running this update tool again." + suggestPowerCycling := false + + if *sensoSerial != "" { + err = UpdateBySerial(context.Background(), *sensoSerial, file, onProgress) + if err != nil { + suggestPowerCycling = true + } + } else { + err, suggestPowerCycling = updateByDiscovery(context.Background(), file, onProgress) + } + + if err != nil { + fmt.Println() + fmt.Printf("Update failed: %v \n", err) + if suggestPowerCycling { + fmt.Println(tryPowerCycling) + } + os.Exit(1) + } + + fmt.Println("Success! Firmware transmitted to Senso.") +} + +func updateByDiscovery(ctx context.Context, image io.Reader, onProgress OnProgress) (err error, suggestPowerCycling bool) { + onProgress("Discovering Sensos") + services := service.List(ctx, discoveryTimeout) + if len(services) == 1 { + target := services[0] + onProgress(fmt.Sprintf("Discovered Senso: %s (%s)", target.Text.Serial, target.Address)) + err = update(ctx, target, image, onProgress) + if err != nil { + suggestPowerCycling = true + } + } else if len(services) == 0 { + err = fmt.Errorf("Could not find any Sensos.") + suggestPowerCycling = true + } else { + err = fmt.Errorf("discovered multiple Sensos: %v, please specify a serial or IP", services) + suggestPowerCycling = false + } + return +} diff --git a/src/dividat-driver/firmware/main.go b/src/dividat-driver/firmware/main.go index 19d5180..ac535ed 100644 --- a/src/dividat-driver/firmware/main.go +++ b/src/dividat-driver/firmware/main.go @@ -1,134 +1,95 @@ package firmware +// Functions for performing a firmware update. +// The update procedure consists of the following high-level steps: +// +// 1. Discover Senso via mDNS +// +// 2. If the Senso is found to be in application mode, +// send a DFU (Device Firmware Update) command +// to make the Senso reboot into bootloader mode. +// +// 3. Transfer the firmware image via TFTP. + import ( "bytes" "context" "encoding/binary" - "flag" "fmt" "io" + "math" "net" - "os" - "strings" + "sync" "time" - "github.com/grandcat/zeroconf" + "github.com/cenkalti/backoff" "github.com/pin/tftp" + + "github.com/dividat/driver/src/dividat-driver/service" ) const tftpPort = "69" const controllerPort = "55567" +const discoveryTimeout = 60 * time.Second -// Command-line interface to Update -func Command(flags []string) { - updateFlags := flag.NewFlagSet("update", flag.ExitOnError) - imagePath := updateFlags.String("i", "", "Firmware image path") - configuredAddr := updateFlags.String("a", "", "Senso address (optional)") - sensoSerial := updateFlags.String("s", "", "Senso serial (optional)") - updateFlags.Parse(flags) - - var deviceSerial *string = nil - if *sensoSerial != "" { - deviceSerial = sensoSerial - } +type OnProgress func(msg string) - if *imagePath == "" { - flag.PrintDefaults() - return - } - file, err := os.Open(*imagePath) - if err != nil { - fmt.Printf("Could not open image file: %v\n", err) - os.Exit(1) +func UpdateBySerial(ctx context.Context, deviceSerial string, image io.Reader, onProgress OnProgress) error { + onProgress(fmt.Sprintf("Looking for Senso with specified serial %s", deviceSerial)) + match := service.Find(ctx, discoveryTimeout, service.SerialNumberFilter(deviceSerial)) + if match == nil { + return fmt.Errorf("Failed to find Senso with serial number %s", deviceSerial) } - err = Update(context.Background(), file, deviceSerial, configuredAddr) - if err != nil { - fmt.Println(err.Error()) - fmt.Println() - fmt.Println("Update failed. Try turning the Senso off and on, waiting for 30 seconds and then running this update tool again.") - os.Exit(1) - } + onProgress(fmt.Sprintf("Found Senso at %s", match.Address)) + return update(ctx, *match, image, onProgress) } -// Firmware update workhorse -func Update(parentCtx context.Context, image io.Reader, deviceSerial *string, configuredAddr *string) (fail error) { - // 1: Find address of a Senso in normal mode - var controllerHost string - if *configuredAddr != "" { - // Use specified controller address - controllerHost = *configuredAddr - fmt.Printf("Using specified controller address '%s'.\n", controllerHost) - } else { - // Discover controller address via mDNS - ctx, cancel := context.WithTimeout(parentCtx, 15*time.Second) - discoveredAddr, err := discover("_sensoControl._tcp", deviceSerial, ctx) - cancel() - if err != nil { - fmt.Printf("Error: %s\n", err) - } else { - controllerHost = discoveredAddr +func update(parentCtx context.Context, target service.Service, image io.Reader, onProgress OnProgress) error { + if !service.IsDfuService(target) { + trySendDfu := func() error { + err := sendDfuCommand(target.Address, controllerPort, onProgress) + return err } - } - // 2: Switch the Senso to bootloader mode - if controllerHost != "" { - err := sendDfuCommand(controllerHost, controllerPort) + backoffStrategy := backoff.NewExponentialBackOff() + backoffStrategy.MaxElapsedTime = 30 * time.Second + backoffStrategy.MaxInterval = 10 * time.Second + err := backoff.RetryNotify(trySendDfu, backoffStrategy, func(e error, d time.Duration) { + onProgress(fmt.Sprintf("%v\nRetrying in %v", e, d)) + }) + if err != nil { - // Log the failure, but continue anyway, as the Senso might have been left in - // bootloader mode when a previous update process failed. Not all versions of - // the firmware automtically exit from the bootloader mode upon restart. - fmt.Printf("Could not send DFU command to Senso at %s: %s\n", controllerHost, err) + return fmt.Errorf("Could not send DFU command to Senso at %s: %s", target.Address, err) } - } else { - fmt.Printf("Could not discover a Senso in regular mode, now trying to detect a Senso already in bootloader mode.\n") - } - // 3: Find address of Senso in bootloader mode - var dfuHost string - if *configuredAddr != "" { - dfuHost = *configuredAddr - } else { - ctx, cancel := context.WithTimeout(parentCtx, 60*time.Second) - discoveredAddr, err := discover("_sensoUpdate._udp", deviceSerial, ctx) - cancel() - if err != nil { - // Up to firmware 2.0.0.0 the bootloader advertised itself with the same - // service identifier as the application level firmware. To support such - // legacy devices, we look for `_sensoControl` again at this point, if - // the other service has not been found. - // We do need to rediscover, as the legacy device may still just have - // restarted into the bootloader and obtained a new IP address. - ctx, cancel := context.WithTimeout(parentCtx, 60*time.Second) - legacyDiscoveredAddr, err := discover("_sensoControl._tcp", deviceSerial, ctx) - cancel() - if err == nil { - dfuHost = legacyDiscoveredAddr - } else if controllerHost != "" { - fmt.Printf("Could not discover update service, trying to fall back to previous discovery %s.\n", controllerHost) - dfuHost = controllerHost - } else { - fail = fmt.Errorf("Could not find any Senso bootloader to transmit firmware to: %s", err) - return - } - } else { - dfuHost = discoveredAddr + onProgress("Looking for Senso in bootloader mode") + dfuService := service.Find(parentCtx, discoveryTimeout, func(discovered service.Service) bool { + return service.SerialNumberFilter(target.Text.Serial)(discovered) && service.IsDfuService(discovered) + }) + + if dfuService == nil { + return fmt.Errorf("Could not find Senso in bootloader mode") } + + target = *dfuService + onProgress(fmt.Sprintf("Found Senso in bootloader mode at %s", target.Address)) + onProgress("Waiting 10 seconds to ensure proper TFTP startup") + // Wait to ensure proper TFTP startup + time.Sleep(10 * time.Second) + } else { + onProgress("Found Senso in bootloader mode") } - // 4: Transmit firmware via TFTP - time.Sleep(5 * time.Second) // Wait to ensure proper TFTP startup - err := putTFTP(dfuHost, tftpPort, image) + err := putTFTP(target.Address, tftpPort, image, onProgress) if err != nil { - fail = err - return + return err } - fmt.Println("Success! Firmware transmitted to Senso.") - return + return nil } -func sendDfuCommand(host string, port string) error { +func sendDfuCommand(host string, port string, onProgress OnProgress) error { // Header const PROTOCOL_VERSION = 0x00 const NUMOFBLOCKS = 0x01 @@ -158,97 +119,76 @@ func sendDfuCommand(host string, port string) error { return fmt.Errorf("Could not send DFU command: %v", err) } - fmt.Printf("Sent DFU command to %s:%s.\n", host, port) + onProgress(fmt.Sprintf("Sent DFU command to %s:%s", host, port)) return nil } -func putTFTP(host string, port string, image io.Reader) error { +func putTFTP(host string, port string, image io.Reader, onProgress OnProgress) error { + onProgress("Creating TFTP client") client, err := tftp.NewClient(fmt.Sprintf("%s:%s", host, port)) if err != nil { return fmt.Errorf("Could not create tftp client: %v", err) } + + maxRetries := 5 + client.SetRetries(maxRetries) + // It can take a while for the Senso to respond to the TFTP write request. + // Setting timeout to 10 seconds prevents unnecessary messages about failed + // send attempts. + client.SetTimeout(10 * time.Second) + + expDelay := func(attempt int) time.Duration { + exp := math.Pow(2, float64(attempt)) + exp = math.Min(exp, 60) + return time.Duration(int(exp)) * time.Second + } + + client.SetBackoff(func(attempt int) time.Duration { + delay := expDelay(attempt) + msg := fmt.Sprintf("Failed on attempt %d, retrying in %v", attempt+1, delay) + onProgress(msg) + return delay + }) + + onProgress("Preparing transmission") rf, err := client.Send("controller-app.bin", "octet") if err != nil { return fmt.Errorf("Could not create send connection: %v", err) } + onProgress("Transmitting...") n, err := rf.ReadFrom(image) if err != nil { return fmt.Errorf("Could not read from file: %v", err) } - fmt.Printf("%d bytes sent\n", n) + onProgress(fmt.Sprintf("%d bytes sent", n)) return nil } -func discover(service string, deviceSerial *string, ctx context.Context) (addr string, err error) { - resolver, err := zeroconf.NewResolver(nil) - if err != nil { - err = fmt.Errorf("Initializing discovery failed: %v", err) - return - } - - fmt.Printf("Starting discovery: %s\n", service) - - entries := make(chan *zeroconf.ServiceEntry) - - err = resolver.Browse(ctx, service, "local.", entries) - if err != nil { - err = fmt.Errorf("Browsing failed: %v", err) - return - } +// State to keep track of when an update is in progress. +// This is used by the senso module, but is kept here to +// ensure privacy of internals. - devices := make(map[string][]string) - entriesWithoutSerial := 0 - select { - case entry := <-entries: - if entry == nil { - break - } +type Update struct { + stateMutex sync.Mutex + inProgress bool +} - var serial string - for ix, txt := range entry.Text { - if strings.HasPrefix(txt, "ser_no=") { - serial = cleanSerial(strings.TrimPrefix(txt, "ser_no=")) - break - } else if ix == len(entry.Text)-1 { - entriesWithoutSerial++ - serial = fmt.Sprintf("UNKNOWN-%d", entriesWithoutSerial) - } - } - if deviceSerial != nil && serial != *deviceSerial { - break - } - for _, addrCandidate := range entry.AddrIPv4 { - if addrCandidate.String() == "0.0.0.0" { - fmt.Printf("Skipping discovered address 0.0.0.0 for %s.\n", serial) - } else { - devices[serial] = append(devices[serial], addrCandidate.String()) - } - } +func InitialUpdateState() *Update { + return &Update{ + inProgress: false, + stateMutex: sync.Mutex{}, } +} - if len(devices) == 0 && deviceSerial == nil { - err = fmt.Errorf("Could not find any devices for service %s.", service) - } else if len(devices) == 0 && deviceSerial != nil { - err = fmt.Errorf("Could not find Senso %s.", *deviceSerial) - } else if len(devices) == 1 { - for serial, addrs := range devices { - addr = addrs[0] - fmt.Printf("Discovered %s at %v, using %s.\n", serial, addrs, addr) - return - } - } else { - err = fmt.Errorf("Discovered multiple Sensos: %v. Please specify a serial or IP.", devices) - return - } - return +func (u *Update) IsUpdating() bool { + u.stateMutex.Lock() + defer u.stateMutex.Unlock() + return u.inProgress } -func cleanSerial(serialStr string) string { - // Senso firmware up to 3.8.0 adds garbage at end of serial in mDNS - // entries due to improper string sizing. Because bootloader firmware - // will not be updated via Ethernet, the problem will stay around for a - // while and we clean up the serial here to produce readable output for - // older devices. - return strings.Split(serialStr, "\\000")[0] +func (u *Update) SetUpdating(state bool) { + u.stateMutex.Lock() + defer u.stateMutex.Unlock() + u.inProgress = state } diff --git a/src/dividat-driver/senso/discovery.go b/src/dividat-driver/senso/discovery.go deleted file mode 100644 index d173f65..0000000 --- a/src/dividat-driver/senso/discovery.go +++ /dev/null @@ -1,31 +0,0 @@ -package senso - -import ( - "context" - - "github.com/grandcat/zeroconf" -) - -// Discover Sensos for a certain duration -func (handle *Handle) Discover(ctx context.Context) chan *zeroconf.ServiceEntry { - - log := handle.log - - resolver, err := zeroconf.NewResolver(nil) - if err != nil { - log.WithError(err).Error("Initializing discovery failed.") - } - - log.Debug("Initialized discovery.") - - // create an intermediary channel for logging discoveries and handling the case when there is no reader - entries := make(chan *zeroconf.ServiceEntry) - - err = resolver.Browse(ctx, "_sensoControl._tcp", "local.", entries) - if err != nil { - log.WithError(err).Error("Browsing failed.") - } - - return entries - -} diff --git a/src/dividat-driver/senso/main.go b/src/dividat-driver/senso/main.go index 5064f0b..2b0ce4f 100644 --- a/src/dividat-driver/senso/main.go +++ b/src/dividat-driver/senso/main.go @@ -7,6 +7,8 @@ import ( "github.com/cskr/pubsub" "github.com/sirupsen/logrus" + + "github.com/dividat/driver/src/dividat-driver/firmware" ) // Handle for managing Senso @@ -20,6 +22,8 @@ type Handle struct { cancelCurrentConnection context.CancelFunc connectionChangeMutex *sync.Mutex + firmwareUpdate *firmware.Update + log *logrus.Entry } @@ -32,6 +36,7 @@ func New(ctx context.Context, log *logrus.Entry) *Handle { handle.log = log handle.connectionChangeMutex = &sync.Mutex{} + handle.firmwareUpdate = firmware.InitialUpdateState() // PubSub broker handle.broker = pubsub.New(32) diff --git a/src/dividat-driver/senso/update_firmware.go b/src/dividat-driver/senso/update_firmware.go new file mode 100644 index 0000000..b68bddd --- /dev/null +++ b/src/dividat-driver/senso/update_firmware.go @@ -0,0 +1,54 @@ +package senso + +import ( + "bytes" + "context" + "encoding/base64" + "fmt" + "io" + + "github.com/dividat/driver/src/dividat-driver/firmware" +) + +type SendMsg struct { + progress func(string) + failure func(string) + success func(string) +} + +// Disconnect from current connection +func (handle *Handle) ProcessFirmwareUpdateRequest(command UpdateFirmware, send SendMsg) { + handle.log.Info("Processing firmware update request.") + handle.firmwareUpdate.SetUpdating(true) + + if handle.cancelCurrentConnection != nil { + send.progress("Disconnecting from the Senso") + handle.cancelCurrentConnection() + } + + image, err := decodeImage(command.Image) + if err != nil { + msg := fmt.Sprintf("Error decoding base64 string: %v", err) + send.failure(msg) + handle.log.Error(msg) + return + } + + err = firmware.UpdateBySerial(context.Background(), command.SerialNumber, image, send.progress) + if err != nil { + failureMsg := fmt.Sprintf("Failed to update firmware: %v", err) + send.failure(failureMsg) + handle.log.Error(failureMsg) + } else { + send.success("Firmware successfully transmitted") + } + handle.firmwareUpdate.SetUpdating(false) +} + +func decodeImage(base64Str string) (io.Reader, error) { + data, err := base64.StdEncoding.DecodeString(base64Str) + if err != nil { + return nil, err + } + return bytes.NewReader(data), nil +} diff --git a/src/dividat-driver/senso/websocket.go b/src/dividat-driver/senso/websocket.go index 4d0bb72..ce19d83 100644 --- a/src/dividat-driver/senso/websocket.go +++ b/src/dividat-driver/senso/websocket.go @@ -10,8 +10,10 @@ import ( "time" "github.com/gorilla/websocket" - "github.com/grandcat/zeroconf" + "github.com/libp2p/zeroconf/v2" "github.com/sirupsen/logrus" + + "github.com/dividat/driver/src/dividat-driver/service" ) // WEBSOCKET PROTOCOL @@ -24,6 +26,7 @@ type Command struct { *Disconnect *Discover + *UpdateFirmware } func prettyPrintCommand(command Command) string { @@ -35,6 +38,8 @@ func prettyPrintCommand(command Command) string { return "Disconnect" } else if command.Discover != nil { return "Discover" + } else if command.UpdateFirmware != nil { + return "UpdateFirmware" } return "Unknown" } @@ -55,6 +60,11 @@ type Discover struct { Duration int `json:"duration"` } +type UpdateFirmware struct { + SerialNumber string `json:"serialNumber"` + Image string `json:"image"` +} + // UnmarshalJSON implements encoding/json Unmarshaler interface func (command *Command) UnmarshalJSON(data []byte) error { @@ -85,6 +95,11 @@ func (command *Command) UnmarshalJSON(data []byte) error { return err } + } else if temp.Type == "UpdateFirmware" { + err := json.Unmarshal(data, &command.UpdateFirmware) + if err != nil { + return err + } } else { return errors.New("can not decode unknown command") } @@ -95,8 +110,8 @@ func (command *Command) UnmarshalJSON(data []byte) error { // Message that can be sent to Play type Message struct { *Status - - Discovered *zeroconf.ServiceEntry + Discovered *zeroconf.ServiceEntry + FirmwareUpdateMessage *FirmwareUpdateMessage } // Status is a message containing status information @@ -104,6 +119,12 @@ type Status struct { Address *string } +type FirmwareUpdateMessage struct { + FirmwareUpdateProgress *string + FirmwareUpdateSuccess *string + FirmwareUpdateFailure *string +} + // MarshalJSON ipmlements JSON encoder for messages func (message *Message) MarshalJSON() ([]byte, error) { if message.Status != nil { @@ -126,6 +147,34 @@ func (message *Message) MarshalJSON() ([]byte, error) { IP: append(message.Discovered.AddrIPv4, message.Discovered.AddrIPv6...), }) + } else if message.FirmwareUpdateMessage != nil { + fwUpdate := struct { + Type string `json:"type"` + Message string `json:"message"` + }{} + + firmwareUpdateMessage := *message.FirmwareUpdateMessage + + if firmwareUpdateMessage.FirmwareUpdateProgress != nil { + + fwUpdate.Type = "FirmwareUpdateProgress" + fwUpdate.Message = *firmwareUpdateMessage.FirmwareUpdateProgress + + } else if firmwareUpdateMessage.FirmwareUpdateFailure != nil { + + fwUpdate.Type = "FirmwareUpdateFailure" + fwUpdate.Message = *firmwareUpdateMessage.FirmwareUpdateFailure + + } else if firmwareUpdateMessage.FirmwareUpdateSuccess != nil { + + fwUpdate.Type = "FirmwareUpdateSuccess" + fwUpdate.Message = *firmwareUpdateMessage.FirmwareUpdateSuccess + + } else { + return nil, errors.New("could not marshal firmware update message") + } + + return json.Marshal(fwUpdate) } return nil, errors.New("could not marshal message") @@ -221,6 +270,12 @@ func (handle *Handle) ServeHTTP(w http.ResponseWriter, r *http.Request) { } if messageType == websocket.BinaryMessage { + + if handle.firmwareUpdate.IsUpdating() { + handle.log.Debug("Ignoring Senso command during firmware update.") + continue + } + handle.broker.TryPub(msg, "tx") } else if messageType == websocket.TextMessage { @@ -233,6 +288,11 @@ func (handle *Handle) ServeHTTP(w http.ResponseWriter, r *http.Request) { } log.WithField("command", prettyPrintCommand(command)).Debug("Received command.") + if handle.firmwareUpdate.IsUpdating() && (command.GetStatus == nil || command.Discover == nil) { + log.WithField("command", prettyPrintCommand(command)).Debug("Ignoring command during firmware update.") + continue + } + err := handle.dispatchCommand(ctx, log, command, sendMessage) if err != nil { return @@ -273,14 +333,14 @@ func (handle *Handle) dispatchCommand(ctx context.Context, log *logrus.Entry, co discoveryCtx, _ := context.WithTimeout(ctx, time.Duration(command.Discover.Duration)*time.Second) - entries := handle.Discover(discoveryCtx) + entries := service.Scan(discoveryCtx) - go func(entries chan *zeroconf.ServiceEntry) { + go func(entries chan service.Service) { for entry := range entries { log.WithField("service", entry).Debug("Discovered service.") var message Message - message.Discovered = entry + message.Discovered = &entry.ServiceEntry err := sendMessage(message) if err != nil { @@ -293,10 +353,38 @@ func (handle *Handle) dispatchCommand(ctx context.Context, log *logrus.Entry, co return nil + } else if command.UpdateFirmware != nil { + go handle.ProcessFirmwareUpdateRequest(*command.UpdateFirmware, SendMsg{ + progress: func(msg string) { + sendMessage(firmwareUpdateProgress(msg)) + }, + failure: func(msg string) { + sendMessage(firmwareUpdateFailure(msg)) + }, + success: func(msg string) { + sendMessage(firmwareUpdateSuccess(msg)) + }, + }) } return nil } +func firmwareUpdateSuccess(msg string) Message { + return firmwareUpdateMessage(FirmwareUpdateMessage{FirmwareUpdateSuccess: &msg}) +} + +func firmwareUpdateFailure(msg string) Message { + return firmwareUpdateMessage(FirmwareUpdateMessage{FirmwareUpdateFailure: &msg}) +} + +func firmwareUpdateProgress(msg string) Message { + return firmwareUpdateMessage(FirmwareUpdateMessage{FirmwareUpdateProgress: &msg}) +} + +func firmwareUpdateMessage(msg FirmwareUpdateMessage) Message { + return Message{FirmwareUpdateMessage: &msg} +} + // rx_data_loop reads data from Senso and forwards it up the WebSocket func rx_data_loop(ctx context.Context, rx chan interface{}, send func([]byte) error) { var err error diff --git a/src/dividat-driver/service/main.go b/src/dividat-driver/service/main.go new file mode 100644 index 0000000..ea3478e --- /dev/null +++ b/src/dividat-driver/service/main.go @@ -0,0 +1,194 @@ +package service + +// This module contains functions to discover Sensos via mDNS. +import ( + "context" + "fmt" + "strings" + "sync" + "time" + + "github.com/libp2p/zeroconf/v2" +) + +// Represents a service that has been discovered. +// Relevant information about the service is lifted +// out of the zeroconf record for ease of use. +type Service struct { + Text Text + Address string + ServiceEntry zeroconf.ServiceEntry +} + +// Information parsed from services' txt records. +type Text struct { + Serial string + Mode string +} + +func (s Service) String() string { + return fmt.Sprintf("{ Serial: %s, Address: %s}", s.Text.Serial, s.Address) +} + +// Service type, indicating what mode a Senso is in. +// SensoUpdate means the Senso is in bootloader mode, +// while SensoControl means the Senso is in "normal" mode. +type ServiceType string + +const ( + SensoUpdate ServiceType = "_sensoUpdate._udp" + SensoControl ServiceType = "_sensoControl._tcp" +) + +// Up to firmware 2.0.0.0 the bootloader advertised itself +// with the same service identifier as the application level +// firmware. Because of this we also have to check the `mode` +// field on a service's txt records to determine what mode a +// Senso is in. The enum below represents the possible values +// of this field. +const ( + ApplicationMode = "Application" + BootloaderMode = "Bootloader" +) + +// Scan for services of a specific type, ie `SensoUpdate` or `SensoControl`. +func scanForType(ctx context.Context, t ServiceType, results chan<- Service, wg *sync.WaitGroup) { + wg.Add(2) + // Zeroconf closes the channel on context cancellation, + // so we cannot share channels between multiple browse calls. + // Doing so would lead to panic as one instance would try to close + // a channel that was already closed by another instance. + // To prevent this, we create an intermediate channel for each instance, + // then forward the discovered service entries to the main results channel in + // a separate goroutine. + localEntries := make(chan *zeroconf.ServiceEntry) + go func() { + defer wg.Done() + err := zeroconf.Browse(ctx, string(t), "local.", localEntries) + if err != nil { + fmt.Println("Discovery error:", err) + } + }() + + // Forward entries from localEntries to the main results channel + go func() { + defer wg.Done() + entriesWithoutSerial := 0 + for entry := range localEntries { + if entry != nil { + text := getText(*entry) + if text.Serial == "" { + text.Serial = fmt.Sprintf("UNKNOWN-%d", entriesWithoutSerial) + entriesWithoutSerial++ + } + var address string + if entry.AddrIPv4[0] != nil { + address = entry.AddrIPv4[0].String() + } else { + continue + } + results <- Service{ + Address: address, + Text: text, + ServiceEntry: *entry, + } + } + } + }() +} + +// Scan for both types of services concurrently. +func Scan(ctx context.Context) chan Service { + var wg sync.WaitGroup + services := make(chan Service) + scanForType(ctx, SensoUpdate, services, &wg) + scanForType(ctx, SensoControl, services, &wg) + go func() { + wg.Wait() + close(services) + }() + return services +} + +// Like `Scan`, but blocking. +// Returns a slice of services found within the specified timeout. +func List(ctx context.Context, timeout time.Duration) []Service { + ctx, cancel := context.WithTimeout(ctx, timeout) + defer cancel() + + var result []Service + services := Scan(ctx) + + for service := range services { + result = append(result, service) + } + + return result +} + +// Type alias to a filter function that can be used with `Find`. +// Helpers to construct commonly used filters are defined below. +type Filter = func(service Service) bool + +// Looks for a service specified by the filter function. +// As soon as a match is found, it cancels scanning +// and returns the match. +func Find(ctx context.Context, timeout time.Duration, filter Filter) *Service { + ctx, cancel := context.WithTimeout(ctx, timeout) + defer cancel() + + services := Scan(ctx) + + for service := range services { + if filter(service) { + cancel() + return &service + } + } + + return nil +} + +// Commonly used filters to look for services. + +func SerialNumberFilter(wantedSerial string) Filter { + return func(service Service) bool { + return service.Text.Serial == wantedSerial + } +} + +func AddressFilter(wantedAddress string) Filter { + return func(service Service) bool { + return service.Address == wantedAddress + } +} + +func IsDfuService(service Service) bool { + return service.ServiceEntry.Service == string(SensoUpdate) || service.Text.Mode == BootloaderMode +} + +// Helper to parse relevant information from the +// txt record of a service entry. +func getText(entry zeroconf.ServiceEntry) Text { + text := Text{ + Serial: "", + Mode: "", + } + for _, txtField := range entry.Text { + if strings.HasPrefix(txtField, "ser_no=") { + text.Serial = cleanSerial(strings.TrimPrefix(txtField, "ser_no=")) + } else if strings.HasPrefix(txtField, "mode=") { + text.Mode = strings.TrimPrefix(txtField, "mode=") + } + } + return text +} + +// Senso firmware up to 3.8.0 adds garbage at end of serial in mDNS +// entries due to improper string sizing. Because bootloader firmware +// will not be updated via Ethernet, the problem will stay around for a +// while and we clean up the serial here to produce readable output for +// older devices. +func cleanSerial(serialStr string) string { + return strings.Split(serialStr, "\\000")[0] +}