From e82981e3fc4b1142b01a9c7b8d7b9d737b3d79fb Mon Sep 17 00:00:00 2001
From: Arsen Musayelyan <moussaelianarsen@gmail.com>
Date: Mon, 21 Feb 2022 02:46:20 -0800
Subject: [PATCH] Rewrite connect/reconnect code

---
 go.mod        |   8 +-
 go.sum        |  10 +-
 infinitime.go | 372 +++++++++++++++++++++++++-------------------------
 3 files changed, 198 insertions(+), 192 deletions(-)

diff --git a/go.mod b/go.mod
index 7448154..dbc4ad5 100644
--- a/go.mod
+++ b/go.mod
@@ -3,8 +3,8 @@ module go.arsenm.dev/infinitime
 go 1.16
 
 require (
-	github.com/fxamacker/cbor/v2 v2.3.0
-	github.com/godbus/dbus/v5 v5.0.3
-	github.com/muka/go-bluetooth v0.0.0-20211122080231-b99792bbe62a
-	golang.org/x/sys v0.0.0-20210403161142-5e06dd20ab57 // indirect
+	github.com/fxamacker/cbor/v2 v2.4.0
+	github.com/godbus/dbus/v5 v5.0.6
+	github.com/muka/go-bluetooth v0.0.0-20220219050759-674a63b8741a
+	golang.org/x/sys v0.0.0-20220209214540-3681064d5158 // indirect
 )
diff --git a/go.sum b/go.sum
index 5b1b7bc..05eedb5 100644
--- a/go.sum
+++ b/go.sum
@@ -5,15 +5,19 @@ github.com/fatih/structs v1.1.0 h1:Q7juDM0QtcnhCpeyLGQKyg4TOIghuNXrkL32pHAUMxo=
 github.com/fatih/structs v1.1.0/go.mod h1:9NiDSp5zOcgEDl+j00MP/WkGVPOlPRLejGD8Ga6PJ7M=
 github.com/fxamacker/cbor/v2 v2.3.0 h1:aM45YGMctNakddNNAezPxDUpv38j44Abh+hifNuqXik=
 github.com/fxamacker/cbor/v2 v2.3.0/go.mod h1:TA1xS00nchWmaBnEIxPSE5oHLuJBAVvqrtAnWBwBCVo=
+github.com/fxamacker/cbor/v2 v2.4.0 h1:ri0ArlOR+5XunOP8CRUowT0pSJOwhW098ZCUyskZD88=
+github.com/fxamacker/cbor/v2 v2.4.0/go.mod h1:TA1xS00nchWmaBnEIxPSE5oHLuJBAVvqrtAnWBwBCVo=
 github.com/godbus/dbus/v5 v5.0.3 h1:ZqHaoEF7TBzh4jzPmqVhE/5A1z9of6orkAe5uHoAeME=
 github.com/godbus/dbus/v5 v5.0.3/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
+github.com/godbus/dbus/v5 v5.0.6 h1:mkgN1ofwASrYnJ5W6U/BxG15eXXXjirgZc7CLqkcaro=
+github.com/godbus/dbus/v5 v5.0.6/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
 github.com/google/uuid v1.1.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
 github.com/konsorten/go-windows-terminal-sequences v1.0.3 h1:CE8S1cTafDpPvMhIxNJKvHsGVBgn1xWYf1NbHQhywc8=
 github.com/konsorten/go-windows-terminal-sequences v1.0.3/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
 github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
 github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
-github.com/muka/go-bluetooth v0.0.0-20211122080231-b99792bbe62a h1:KxRXeSWoBM5FCPAnSUYxt1qwEzmoH/K7upb4fiSDwdc=
-github.com/muka/go-bluetooth v0.0.0-20211122080231-b99792bbe62a/go.mod h1:dMCjicU6vRBk34dqOmIZm0aod6gUwZXOXzBROqGous0=
+github.com/muka/go-bluetooth v0.0.0-20220219050759-674a63b8741a h1:fnzS9RRQW8B5AgNCxkN0vJ/AoX+Xfqk3sAYon3iVrzA=
+github.com/muka/go-bluetooth v0.0.0-20220219050759-674a63b8741a/go.mod h1:dMCjicU6vRBk34dqOmIZm0aod6gUwZXOXzBROqGous0=
 github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno=
 github.com/paypal/gatt v0.0.0-20151011220935-4ae819d591cf/go.mod h1:+AwQL2mK3Pd3S+TUwg0tYQjid0q1txyNUJuuSmz8Kdk=
 github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
@@ -45,6 +49,8 @@ golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7w
 golang.org/x/sys v0.0.0-20200728102440-3e129f6d46b1/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20210403161142-5e06dd20ab57 h1:F5Gozwx4I1xtr/sr/8CFbb57iKi3297KFs0QDbGN60A=
 golang.org/x/sys v0.0.0-20210403161142-5e06dd20ab57/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20220209214540-3681064d5158 h1:rm+CHSpPEEW2IsXUib1ThaHIjuBVZjxNgSKmBLFfD4c=
+golang.org/x/sys v0.0.0-20220209214540-3681064d5158/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-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
 golang.org/x/tools v0.0.0-20200925191224-5d1fdd8fa346/go.mod h1:z6u4i615ZeAfBE4XtMziQW1fSVJXACjjbWkB/mvPzlU=
diff --git a/infinitime.go b/infinitime.go
index 42278f7..a90fb2b 100644
--- a/infinitime.go
+++ b/infinitime.go
@@ -10,6 +10,7 @@ import (
 
 	"github.com/fxamacker/cbor/v2"
 	bt "github.com/muka/go-bluetooth/api"
+	"github.com/muka/go-bluetooth/bluez"
 	"github.com/muka/go-bluetooth/bluez/profile/adapter"
 	"github.com/muka/go-bluetooth/bluez/profile/device"
 	"github.com/muka/go-bluetooth/bluez/profile/gatt"
@@ -33,7 +34,6 @@ const (
 )
 
 type Device struct {
-	opts            *Options
 	device          *device.Device1
 	newAlertChar    *gatt.GattCharacteristic1
 	notifEventChar  *gatt.GattCharacteristic1
@@ -48,7 +48,6 @@ type Device struct {
 	weatherDataChar *gatt.GattCharacteristic1
 	notifEventCh    chan uint8
 	notifEventDone  bool
-	onReconnect     func()
 	Music           MusicCtrl
 	DFU             DFU
 }
@@ -67,6 +66,7 @@ type Options struct {
 	WhitelistEnabled bool
 	Whitelist        []string
 	OnReqPasskey     func() (uint32, error)
+	OnReconnect      func()
 }
 
 var DefaultOptions = &Options{
@@ -79,207 +79,215 @@ var DefaultOptions = &Options{
 // it will attempt to discover and pair one.
 //
 // It will also attempt to reconnect to the device
-// if it disconnects.
+// if it disconnects and that is enabled in the options.
 func Connect(opts *Options) (*Device, error) {
 	if opts == nil {
 		opts = DefaultOptions
 	}
-	// Attempt to connect to paired device by name
-	dev, err := connectByName(opts)
-	// If such device does not exist
-	if errors.Is(err, ErrNoDevices) {
-		// Attempt to pair device
-		dev, err = pair(opts)
-	}
-	if err != nil {
-		return nil, err
-	}
-	dev.opts = opts
-	dev.onReconnect = func() {}
+
+	// Set passkey request callback
 	setOnPasskeyReq(opts.OnReqPasskey)
 
-	// Watch device properties
-	devEvtCh, err := dev.device.WatchProperties()
+	// Connect to bluetooth device
+	btDev, err := connect(opts, true)
 	if err != nil {
 		return nil, err
 	}
 
-	// If AttemptReconnect enabled
-	if dev.opts.AttemptReconnect {
-		go func() {
-			disconnEvtNum := 0
-			// For every event
-			for evt := range devEvtCh {
-				// If device disconnected
-				if evt.Name == "Connected" && evt.Value == false {
-					// Increment disconnect event number
-					disconnEvtNum++
-					// If more than one disconnect event
-					if disconnEvtNum > 1 {
-						// Decrement disconnect event number
-						disconnEvtNum--
-						// Skip loop
-						continue
-					}
-					// Set connected to false
-					dev.device.Properties.Connected = false
-					// While not connected
-					for !dev.device.Properties.Connected {
-						reConnDev := dev
-
-						paired, err := reConnDev.device.GetPaired()
-						if err != nil {
-							continue
-						}
-
-						if !paired {
-							err = reConnDev.pairTimeout()
-							if err != nil {
-								continue
-							}
-						} else {
-							// Attempt to connect via bluetooth address
-							reConnDev, err = connectByName(opts)
-							if err != nil {
-								// Decrement disconnect event number
-								disconnEvtNum--
-								// Skip rest of loop
-								continue
-							}
-						}
-
-						// Store onReconn callback
-						onReconn := dev.onReconnect
-						// Set device to new device
-						*dev = *reConnDev
-						// Run on reconnect callback
-						onReconn()
-						// Assign callback to new device
-						dev.onReconnect = onReconn
-					}
-					// Decrement disconnect event number
-					disconnEvtNum--
-				}
-			}
-		}()
-	}
-	return dev, nil
-}
-
-// OnReconnect sets the callback that runs on reconnect
-func (i *Device) OnReconnect(f func()) {
-	i.onReconnect = f
-}
-
-// Connect connects to a paired InfiniTime device
-func connectByName(opts *Options) (*Device, error) {
-	setOnPasskeyReq(opts.OnReqPasskey)
 	// Create new device
-	out := &Device{}
-	// Get devices from default adapter
+	out := &Device{device: btDev}
+
+	// Resolve characteristics
+	err = out.resolveChars()
+	if err != nil {
+		return nil, err
+	}
+
+	return out, nil
+}
+
+// connect connects to the InfiniTime bluez device
+func connect(opts *Options, first bool) (dev *device.Device1, err error) {
+	// Get devices
 	devs, err := defaultAdapter.GetDevices()
 	if err != nil {
 		return nil, err
 	}
+
 	// For every device
-	for _, dev := range devs {
-		// If device name is InfiniTime
-		if dev.Properties.Name == BTName {
-			if opts.WhitelistEnabled && !contains(opts.Whitelist, dev.Properties.Address) {
-				continue
-			}
-			// Set outout device to discovered device
-			out.device = dev
-			break
-		}
-	}
-	if out.device == nil {
-		return nil, ErrNoDevices
-	}
-	// Connect to device
-	err = out.device.Connect()
-	if err != nil {
-		return nil, err
-	}
-
-	out.device.Properties.Connected = true
-
-	// Resolve characteristics
-	err = out.resolveChars()
-	if err != nil {
-		return nil, err
-	}
-	return out, nil
-}
-
-func contains(ss []string, s string) bool {
-	for _, str := range ss {
-		if strings.EqualFold(str, s) {
-			return true
-		}
-	}
-	return false
-}
-
-// Pair attempts to discover and pair an InfiniTime device
-func pair(opts *Options) (*Device, error) {
-	setOnPasskeyReq(opts.OnReqPasskey)
-	// Create new device
-	out := &Device{}
-	// Start bluetooth discovery
-	// Ignore the cancel function as it blocks forever
-	discovery, _, err := bt.Discover(defaultAdapter, &adapter.DiscoveryFilter{Transport: "le"})
-	if err != nil {
-		return nil, err
-	}
-	// For every discovery event
-	for event := range discovery {
-		// If device removed, skip event
-		if event.Type == adapter.DeviceRemoved {
+	for _, listDev := range devs {
+		// If device name does not match, skip
+		if listDev.Properties.Name != BTName {
 			continue
 		}
-		// Create new device with discovered path
-		dev, err := device.NewDevice1(event.Path)
+		// If whitelist enabled and doesn't contain
+		// device, skip
+		if opts.WhitelistEnabled &&
+			!contains(opts.Whitelist, listDev.Properties.Address) {
+			continue
+		}
+
+		// Set device
+		dev = listDev
+		break
+	}
+
+	// If device not set
+	if dev == nil {
+		// Discover devices on adapter
+		discoverCh, cancel, err := bt.Discover(defaultAdapter, &adapter.DiscoveryFilter{Transport: "le"})
 		if err != nil {
 			return nil, err
 		}
-		// If device name is InfiniTime
-		if dev.Properties.Name == BTName {
-			if opts.WhitelistEnabled && !contains(opts.Whitelist, dev.Properties.Address) {
+
+		// For every discovery event
+		for event := range discoverCh {
+			// If event type is not device added, skip
+			if event.Type != adapter.DeviceAdded {
 				continue
 			}
-			// Set output device
-			out.device = dev
+
+			// Create new device from event path
+			discovered, err := device.NewDevice1(event.Path)
+			if err != nil {
+				return nil, err
+			}
+
+			// If device name does not match, skip
+			if discovered.Properties.Name != BTName {
+				continue
+			}
+			// If whitelist enabled and doesn't contain
+			// device, skip
+			if opts.WhitelistEnabled &&
+				!contains(opts.Whitelist, discovered.Properties.Address) {
+				continue
+			}
+
+			// Set device
+			dev = discovered
 			break
 		}
+		// Stop discovery
+		cancel()
+	}
+
+	// If device is still not set, return error
+	if dev == nil {
+		return nil, ErrNoDevices
+	}
+
+	// Create variable to track if reconnect
+	// was required
+	reconnRequired := false
+	// If device is not connected
+	if !dev.Properties.Connected {
+		// Connect to device
+		err = dev.Connect()
+		if err != nil {
+			return nil, err
+		}
+		// Set reconnect required to true
+		reconnRequired = true
+	}
+
+	// If device is not paired
+	if !dev.Properties.Paired {
+		// Pair device
+		err = dev.Pair()
+		if err != nil {
+			return nil, err
+		}
 	}
 
-	if out.device == nil {
-		return nil, ErrNotFound
+	// If this is the first connection and reconnect
+	// is enabled, start reconnect goroutine
+	if first && opts.AttemptReconnect {
+		go reconnect(opts, dev)
 	}
 
-	// Connect to device
-	err = out.device.Connect()
+	// If this is not the first connection, a reonnect
+	// was required, and the OnReconnect callback exists,
+	// run it
+	if !first && reconnRequired && opts.OnReconnect != nil {
+		opts.OnReconnect()
+	}
+
+	return dev, nil
+}
+
+// reconnect reconnects to a device if it disconnects
+func reconnect(opts *Options, dev *device.Device1) {
+	// Watch device properties
+	propCh := watchProps(dev)
+
+	// Create variables to store time of last disconnect
+	// and amount of diconnects
+	lastDisconnect := time.Unix(0, 0)
+	amtDisconnects := 0
+
+	for event := range propCh {
+		// If event name is not Connected and value is not false, skip
+		if event.Name != "Connected" && event.Value != false {
+			continue
+		}
+
+		// Store seconds since last disconnect
+		secsSince := time.Since(lastDisconnect).Seconds()
+		// If over 3 seconds have passed, reset disconnect count
+		if secsSince > 3 {
+			amtDisconnects = 0
+		}
+
+		// If less than 3 seconds have past and more than 6
+		// disconnects have occurred, remove the device and reset
+		if secsSince <= 3 && amtDisconnects >= 6 {
+			defaultAdapter.RemoveDevice(dev.Path())
+			lastDisconnect = time.Unix(0, 0)
+			amtDisconnects = 0
+		}
+
+		// Set disconnect variables
+		lastDisconnect = time.Now()
+		amtDisconnects++
+
+		for i := 0; i < 6; i++ {
+			// If three tries failed, remove device
+			if i == 3 {
+				defaultAdapter.RemoveDevice(dev.Path())
+			}
+			// Connect to device
+			newDev, err := connect(opts, false)
+			if err != nil {
+				time.Sleep(time.Second)
+				continue
+			}
+			// Replace device with new device
+			*dev = *newDev
+
+			break
+		}
+	}
+}
+
+// bufferChannel writes all events on propCh to a new, buffered channel
+func bufferChannel(propCh chan *bluez.PropertyChanged) <-chan *bluez.PropertyChanged {
+	out := make(chan *bluez.PropertyChanged, 10)
+	go func() {
+		for event := range propCh {
+			out <- event
+		}
+	}()
+	return out
+}
+
+// watchProps returns a buffered channel for the device properties
+func watchProps(dev *device.Device1) <-chan *bluez.PropertyChanged {
+	uPropCh, err := dev.WatchProperties()
 	if err != nil {
-		return nil, err
+		panic(err)
 	}
-
-	// Pair device
-	err = out.pairTimeout()
-	if err != nil {
-		return nil, err
-	}
-
-	// Set connected to true
-	out.device.Properties.Connected = true
-
-	// Resolve characteristics
-	err = out.resolveChars()
-	if err != nil {
-		return nil, err
-	}
-
-	return out, nil
+	return bufferChannel(uPropCh)
 }
 
 // setOnPasskeyReq sets the callback for a passkey request.
@@ -293,22 +301,14 @@ func setOnPasskeyReq(onReqPasskey func() (uint32, error)) {
 	}
 }
 
-// pairTimeout tries to pair with the device.
-// It will time out after 20 seconds.
-func (i *Device) pairTimeout() error {
-	errCh := make(chan error)
-	go func() {
-		errCh <- i.device.Pair()
-	}()
-	select {
-	case err := <-errCh:
-		return err
-	case <-time.After(20 * time.Second):
-		if err := i.device.CancelPairing(); err != nil {
-			return err
+// contains checks if s is contained within ss
+func contains(ss []string, s string) bool {
+	for _, str := range ss {
+		if strings.EqualFold(str, s) {
+			return true
 		}
-		return ErrPairTimeout
 	}
+	return false
 }
 
 // resolveChars attempts to set all required