package infinitime import ( "bytes" "context" "encoding/binary" "errors" "reflect" "strings" "time" "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" "github.com/rs/zerolog" "go.arsenm.dev/infinitime/blefs" ) // This global is used to store the logger. // log.Logger is not used as it would interfere // with the package importing the library var log zerolog.Logger const BTName = "InfiniTime" const ( NewAlertChar = "00002a46-0000-1000-8000-00805f9b34fb" NotifEventChar = "00020001-78fc-48fe-8e23-433b3a1942d0" StepCountChar = "00030001-78fc-48fe-8e23-433b3a1942d0" MotionValChar = "00030002-78fc-48fe-8e23-433b3a1942d0" FirmwareVerChar = "00002a26-0000-1000-8000-00805f9b34fb" CurrentTimeChar = "00002a2b-0000-1000-8000-00805f9b34fb" BatteryLvlChar = "00002a19-0000-1000-8000-00805f9b34fb" HeartRateChar = "00002a37-0000-1000-8000-00805f9b34fb" FSTransferChar = "adaf0200-4669-6c65-5472-616e73666572" FSVersionChar = "adaf0100-4669-6c65-5472-616e73666572" WeatherDataChar = "00040001-78fc-48fe-8e23-433b3a1942d0" ) var charNames = map[string]string{ NewAlertChar: "New Alert", NotifEventChar: "Notification Event", StepCountChar: "Step Count", MotionValChar: "Motion Values", FirmwareVerChar: "Firmware Version", CurrentTimeChar: "Current Time", BatteryLvlChar: "Battery Level", HeartRateChar: "Heart Rate", FSTransferChar: "Filesystem Transfer", FSVersionChar: "Filesystem Version", WeatherDataChar: "Weather Data", } type Device struct { device *device.Device1 newAlertChar *gatt.GattCharacteristic1 notifEventChar *gatt.GattCharacteristic1 stepCountChar *gatt.GattCharacteristic1 motionValChar *gatt.GattCharacteristic1 fwVersionChar *gatt.GattCharacteristic1 currentTimeChar *gatt.GattCharacteristic1 battLevelChar *gatt.GattCharacteristic1 heartRateChar *gatt.GattCharacteristic1 fsVersionChar *gatt.GattCharacteristic1 fsTransferChar *gatt.GattCharacteristic1 weatherDataChar *gatt.GattCharacteristic1 notifEventCh chan uint8 notifEventDone bool Music MusicCtrl DFU DFU } var ( ErrNoDevices = errors.New("no InfiniTime devices found") ErrNotFound = errors.New("could not find any advertising InfiniTime devices") ErrNotConnected = errors.New("not connected") ErrNoTimelineHeader = errors.New("events must contain the timeline header") ErrPairTimeout = errors.New("reached timeout while pairing") ) type ErrCharNotAvail struct { uuid string } func (e ErrCharNotAvail) Error() string { return "characteristic " + e.uuid + " (" + charNames[e.uuid] + ") not available" } type Options struct { AttemptReconnect bool WhitelistEnabled bool Whitelist []string OnReqPasskey func() (uint32, error) OnReconnect func() Logger zerolog.Logger LogLevel zerolog.Level } var DefaultOptions = &Options{ AttemptReconnect: true, WhitelistEnabled: false, Logger: zerolog.Nop(), LogLevel: zerolog.Disabled, } // Connect will attempt to connect to a // paired InfiniTime device. If none are paired, // it will attempt to discover and pair one. // // It will also attempt to reconnect to the device // if it disconnects and that is enabled in the options. func Connect(ctx context.Context, opts *Options) (*Device, error) { if opts == nil { opts = DefaultOptions } log = opts.Logger.Level(opts.LogLevel) // Set passkey request callback setOnPasskeyReq(opts.OnReqPasskey) // Connect to bluetooth device btDev, err := connect(ctx, opts, true) if err != nil { return nil, err } // Create new device 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(ctx context.Context, 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 _, listDev := range devs { // If device name does not match, skip if listDev.Properties.Name != BTName { continue } // If whitelist enabled and doesn't contain // device, skip if opts.WhitelistEnabled && !contains(opts.Whitelist, listDev.Properties.Address) { log.Debug(). Str("mac", listDev.Properties.Address). Msg("InfiniTime device skipped as it is not in whitelist") continue } // Set device dev = listDev log.Debug(). Str("mac", dev.Properties.Address). Msg("InfiniTime device found in list") break } // If device not set if dev == nil { log.Debug().Msg("No device found in list, attempting to discover") // Discover devices on adapter discoverCh, cancel, err := bt.Discover(defaultAdapter, &adapter.DiscoveryFilter{Transport: "le"}) if err != nil { return nil, err } discoverLoop: for { select { case event := <-discoverCh: // If event type is not device added, skip if event.Type != adapter.DeviceAdded { continue } // 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) { log.Debug(). Str("mac", discovered.Properties.Address). Msg("Discovered InfiniTime device skipped as it is not in whitelist") continue } // Set device dev = discovered log.Debug(). Str("mac", dev.Properties.Address). Msg("InfiniTime device discovered") break discoverLoop case <-ctx.Done(): break discoverLoop } } // Cancel 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 { log.Debug().Msg("Device not connected, connecting") // 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 { log.Debug().Msg("Device not paired, pairing") // Pair device err = dev.Pair() if err != nil { return nil, err } } // If this is the first connection and reconnect // is enabled, start reconnect goroutine if first && opts.AttemptReconnect { go reconnect(ctx, opts, dev) } // If this is not the first connection, a reonnect // was required, and the OnReconnect callback exists, // run it if !first && reconnRequired && opts.OnReconnect != nil { log.Debug().Msg("Reconnected to device, running OnReconnect callback") opts.OnReconnect() } return dev, nil } // reconnect reconnects to a device if it disconnects func reconnect(ctx context.Context, 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 passed and more than 6 // disconnects have occurred, remove the device and reset if secsSince <= 3 && amtDisconnects >= 6 { opts.Logger.Warn().Msg("At least 6 disconnects have occurred in the last three seconds. If this continues, try removing the InfiniTime device from bluetooth.") 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 { opts.Logger.Warn().Msg("Multiple connection attempts have failed. If this continues, try removing the InfiniTime device from bluetooth.") } // Connect to device newDev, err := connect(ctx, 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 { panic(err) } return bufferChannel(uPropCh) } // setOnPasskeyReq sets the callback for a passkey request. // It ensures the function will never be nil. func setOnPasskeyReq(onReqPasskey func() (uint32, error)) { itdAgent.ReqPasskey = onReqPasskey if itdAgent.ReqPasskey == nil { itdAgent.ReqPasskey = func() (uint32, error) { return 0, nil } } } // 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 false } // resolveChars attempts to set all required // characteristics in an InfiniTime struct func (i *Device) resolveChars() error { // Get device characteristics chars, err := i.device.GetCharacteristics() if err != nil { return err } // While no characteristics found for len(chars) == 0 { // Sleep one second time.Sleep(time.Second) // Attempt to retry getting characteristics chars, err = i.device.GetCharacteristics() if err != nil { return err } } // For every discovered characteristics for _, char := range chars { charResolved := true // Set correct characteristics switch char.Properties.UUID { case NewAlertChar: i.newAlertChar = char case NotifEventChar: i.notifEventChar = char case StepCountChar: i.stepCountChar = char case MotionValChar: i.motionValChar = char case FirmwareVerChar: i.fwVersionChar = char case CurrentTimeChar: i.currentTimeChar = char case BatteryLvlChar: i.battLevelChar = char case HeartRateChar: i.heartRateChar = char case MusicEventChar: i.Music.eventChar = char case MusicStatusChar: i.Music.statusChar = char case MusicArtistChar: i.Music.artistChar = char case MusicTrackChar: i.Music.trackChar = char case MusicAlbumChar: i.Music.albumChar = char case DFUCtrlPointChar: i.DFU.ctrlPointChar = char case DFUPacketChar: i.DFU.packetChar = char case FSTransferChar: i.fsTransferChar = char case FSVersionChar: i.fsVersionChar = char case WeatherDataChar: i.weatherDataChar = char default: charResolved = false } if charResolved { log.Debug(). Str("uuid", char.Properties.UUID). Str("name", charNames[char.Properties.UUID]). Msg("Resolved characteristic") } } return nil } // Address returns the InfiniTime's bluetooth address func (i *Device) Address() string { return i.device.Properties.Address } // Version returns InfiniTime's reported firmware version string func (i *Device) Version() (string, error) { if err := i.checkStatus(i.fwVersionChar, FirmwareVerChar); err != nil { return "", err } ver, err := i.fwVersionChar.ReadValue(nil) return string(ver), err } // BatteryLevel gets the watch's battery level via the Battery Service func (i *Device) BatteryLevel() (uint8, error) { if err := i.checkStatus(i.battLevelChar, BatteryLvlChar); err != nil { return 0, err } battLevel, err := i.battLevelChar.ReadValue(nil) if err != nil { return 0, err } return uint8(battLevel[0]), nil } func (i *Device) StepCount() (uint32, error) { if err := i.checkStatus(i.stepCountChar, StepCountChar); err != nil { return 0, err } stepCountData, err := i.stepCountChar.ReadValue(nil) if err != nil { return 0, err } return binary.LittleEndian.Uint32(stepCountData), nil } type MotionValues struct { X int16 Y int16 Z int16 } func (i *Device) Motion() (MotionValues, error) { out := MotionValues{} if err := i.checkStatus(i.motionValChar, MotionValChar); err != nil { return out, err } motionVals, err := i.motionValChar.ReadValue(nil) if err != nil { return out, nil } motionValReader := bytes.NewReader(motionVals) err = binary.Read(motionValReader, binary.LittleEndian, &out) if err != nil { return out, err } return out, nil } func (i *Device) HeartRate() (uint8, error) { if err := i.checkStatus(i.heartRateChar, HeartRateChar); err != nil { return 0, err } heartRate, err := i.heartRateChar.ReadValue(nil) if err != nil { return 0, err } return uint8(heartRate[1]), nil } func (i *Device) WatchHeartRate(ctx context.Context) (<-chan uint8, error) { if err := i.checkStatus(i.heartRateChar, HeartRateChar); err != nil { return nil, err } // Start notifications on heart rate characteristic err := i.heartRateChar.StartNotify() if err != nil { return nil, err } // Watch characteristics of heart rate characteristic ch, err := i.heartRateChar.WatchProperties() if err != nil { return nil, err } out := make(chan uint8, 2) currentHeartRate, err := i.HeartRate() if err != nil { return nil, err } out <- currentHeartRate go func() { // For every event for { select { case <-ctx.Done(): log.Debug().Str("func", "WatchMotion").Msg("Received done signal") close(out) i.heartRateChar.StopNotify() return case event := <-ch: // If value changed if event.Name == "Value" { // Send heart rate to channel out <- uint8(event.Value.([]byte)[1]) } else if event.Name == "Notifying" && !event.Value.(bool) { log.Debug().Str("func", "WatchMotion").Msg("Notifications stopped, restarting") i.heartRateChar.StartNotify() } } } }() return out, nil } func (i *Device) WatchBatteryLevel(ctx context.Context) (<-chan uint8, error) { if err := i.checkStatus(i.battLevelChar, BatteryLvlChar); err != nil { return nil, err } // Start notifications on heart rate characteristic err := i.battLevelChar.StartNotify() if err != nil { return nil, err } // Watch characteristics of heart rate characteristic ch, err := i.battLevelChar.WatchProperties() if err != nil { return nil, err } out := make(chan uint8, 2) currentBattLevel, err := i.BatteryLevel() if err != nil { return nil, err } out <- currentBattLevel go func() { // For every event for { select { case <-ctx.Done(): log.Debug().Str("func", "WatchMotion").Msg("Received done signal") close(out) i.battLevelChar.StopNotify() return case event := <-ch: // If value changed if event.Name == "Value" { // Send heart rate to channel out <- uint8(event.Value.([]byte)[0]) } else if event.Name == "Notifying" && !event.Value.(bool) { log.Debug().Str("func", "WatchMotion").Msg("Notifications stopped, restarting") i.battLevelChar.StartNotify() } } } }() return out, nil } func (i *Device) WatchStepCount(ctx context.Context) (<-chan uint32, error) { if err := i.checkStatus(i.stepCountChar, StepCountChar); err != nil { return nil, err } // Start notifications on step count characteristic err := i.stepCountChar.StartNotify() if err != nil { return nil, err } // Watch properties of step count characteristic ch, err := i.stepCountChar.WatchProperties() if err != nil { return nil, err } out := make(chan uint32, 2) currentStepCount, err := i.StepCount() if err != nil { return nil, err } out <- currentStepCount go func() { // For every event for { select { case <-ctx.Done(): log.Debug().Str("func", "WatchMotion").Msg("Received done signal") close(out) i.stepCountChar.StopNotify() return case event := <-ch: // If value changed if event.Name == "Value" { // Send step count to channel out <- binary.LittleEndian.Uint32(event.Value.([]byte)) } else if event.Name == "Notifying" && !event.Value.(bool) { log.Debug().Str("func", "WatchMotion").Msg("Notifications stopped, restarting") i.stepCountChar.StartNotify() } } } }() return out, nil } func (i *Device) WatchMotion(ctx context.Context) (<-chan MotionValues, error) { if err := i.checkStatus(i.motionValChar, MotionValChar); err != nil { return nil, err } // Start notifications on motion characteristic err := i.motionValChar.StartNotify() if err != nil { return nil, err } // Watch properties of motion characteristic ch, err := i.motionValChar.WatchProperties() if err != nil { return nil, err } out := make(chan MotionValues, 2) motionVals, err := i.Motion() if err != nil { return nil, err } out <- motionVals go func() { // For every event for { select { case <-ctx.Done(): log.Debug().Str("func", "WatchMotion").Msg("Received done signal") close(out) i.motionValChar.StopNotify() return case event := <-ch: // If value changed if event.Name == "Value" { // Read binary into MotionValues struct binary.Read(bytes.NewReader(event.Value.([]byte)), binary.LittleEndian, &motionVals) // Send step count to channel out <- motionVals } else if event.Name == "Notifying" && !event.Value.(bool) { log.Debug().Str("func", "WatchMotion").Msg("Notifications stopped, restarting") i.motionValChar.StartNotify() } } } }() return out, nil } // SetTime sets the watch's time using the Current Time Service func (i *Device) SetTime(t time.Time) error { if err := i.checkStatus(i.currentTimeChar, CurrentTimeChar); err != nil { return err } buf := &bytes.Buffer{} binary.Write(buf, binary.LittleEndian, uint16(t.Year())) binary.Write(buf, binary.LittleEndian, uint8(t.Month())) binary.Write(buf, binary.LittleEndian, uint8(t.Day())) binary.Write(buf, binary.LittleEndian, uint8(t.Hour())) binary.Write(buf, binary.LittleEndian, uint8(t.Minute())) binary.Write(buf, binary.LittleEndian, uint8(t.Second())) binary.Write(buf, binary.LittleEndian, uint8(t.Weekday())) binary.Write(buf, binary.LittleEndian, uint8((t.Nanosecond()/1000)/1e6*256)) binary.Write(buf, binary.LittleEndian, uint8(0b0001)) return i.currentTimeChar.WriteValue(buf.Bytes(), nil) } // Notify sends a notification to InfiniTime via // the Alert Notification Service (ANS) func (i *Device) Notify(title, body string) error { if err := i.checkStatus(i.newAlertChar, NewAlertChar); err != nil { return err } return i.newAlertChar.WriteValue( append([]byte{0x00, 0x01, 0x00}, []byte(title+"\x00"+body)...), nil, ) } // These constants represent the possible call statuses selected by the user const ( CallStatusDeclined uint8 = iota CallStatusAccepted CallStatusMuted ) // NotifyCall sends a call notification to the PineTime and returns a channel. // This channel will contain the user's response to the call notification. func (i *Device) NotifyCall(from string) (<-chan uint8, error) { if err := i.checkStatus(i.newAlertChar, NewAlertChar); err != nil { return nil, err } // Write call notification to new alert characteristic err := i.newAlertChar.WriteValue( append([]byte{0x03, 0x01, 0x00}, []byte(from)...), nil, ) if err != nil { return nil, err } if !i.notifEventDone { err = i.initNotifEvent() if err != nil { return nil, err } i.notifEventDone = true } return i.notifEventCh, nil } // initNotifEvent initializes the notification event channel func (i *Device) initNotifEvent() error { // Start notifications on notification event characteristic err := i.notifEventChar.StartNotify() if err != nil { return err } // Watch properties of notification event characteristic ch, err := i.notifEventChar.WatchProperties() if err != nil { return err } // Create new output channel for status i.notifEventCh = make(chan uint8, 1) go func() { // For every event for event := range ch { // If value changed if event.Name == "Value" { // Send status to channel i.notifEventCh <- uint8(event.Value.([]byte)[0]) } } }() return nil } // FS creates and returns a new filesystem from the device func (i *Device) FS() (*blefs.FS, error) { if err := i.checkStatus(i.fsTransferChar, FSTransferChar); err != nil { return nil, err } return blefs.New(i.fsTransferChar) } // AddWeatherEvent adds one of the event structs from // the weather package to the timeline. Input must be // a struct containing TimelineHeader. func (i *Device) AddWeatherEvent(event interface{}) error { if err := i.checkStatus(i.weatherDataChar, WeatherDataChar); err != nil { return err } // Get type of input inputType := reflect.TypeOf(event) // Check if input contains TimelineHeader _, hdrExists := inputType.FieldByName("TimelineHeader") // If header does not exist or input is not struct if !hdrExists || inputType.Kind() != reflect.Struct { return ErrNoTimelineHeader } // Encode event as CBOR data, err := cbor.Marshal(event) if err != nil { return err } log.Debug().Interface("event", event).Msg("Adding weather event") // Write data to weather data characteristic return i.weatherDataChar.WriteValue(data, nil) } func (i *Device) checkStatus(char *gatt.GattCharacteristic1, uuid string) error { log.Debug().Msg("Checking characteristic status") connected, err := i.device.GetConnected() if err != nil { return err } if !connected { return ErrNotConnected } if char == nil { log.Debug().Msg("Characteristic not available (nil)") return ErrCharNotAvail{uuid} } log.Debug(). Str("uuid", char.Properties.UUID). Str("name", charNames[char.Properties.UUID]). Msg("Characteristic available") return nil }