Initial Commit

This commit is contained in:
Elara Musayelyan 2021-08-19 17:41:09 -07:00
commit 1b10eba464
10 changed files with 1088 additions and 0 deletions

21
LICENSE Normal file
View file

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2021 Arsen Musayelyan
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

49
README.md Normal file
View file

@ -0,0 +1,49 @@
# InfiniTime
This is a go library for interfacing with InfiniTime firmware
over BLE on Linux.
---
### Dependencies
This library requires `dbus`, `bluez`, `playerctl`, and `pactl` to function. The first two are for bluetooth, and the last two for music control.
#### Arch
```shell
sudo pacman -S dbus bluez playerctl --needed
```
#### Debian/Ubuntu
```shell
sudo apt install dbus bluez playerctl
```
#### Fedora
```shell
sudo dnf install dbus bluez playerctl
```
`pactl` comes with `pulseaudio` or `pipewire-pulse` and should therefore be installed on most systems already.
---
### Features
This library currently supports the following features:
- Notifications
- Heart rate monitoring
- Setting time
- Battery level
- Music control
- OTA firmware upgrades
---
### Mentions
The DFU process used in this library was created with the help of [siglo](https://github.com/alexr4535/siglo)'s source code. Specifically, this file: [ble_dfu.py](https://github.com/alexr4535/siglo/blob/main/src/ble_dfu.py)

23
btsetup.go Normal file
View file

@ -0,0 +1,23 @@
package infinitime
import (
bt "github.com/muka/go-bluetooth/api"
"github.com/muka/go-bluetooth/bluez/profile/adapter"
)
var defaultAdapter *adapter.Adapter1
func init() {
// Get bluez default adapter
da, err := bt.GetDefaultAdapter()
if err != nil {
panic(err)
}
defaultAdapter = da
}
func Exit() error {
return bt.Exit()
}

363
dfu.go Normal file
View file

@ -0,0 +1,363 @@
package infinitime
import (
"archive/zip"
"bytes"
"encoding/binary"
"encoding/json"
"errors"
"io"
"io/fs"
"io/ioutil"
"os"
"time"
"github.com/muka/go-bluetooth/bluez"
"github.com/muka/go-bluetooth/bluez/profile/gatt"
)
const (
DFUCtrlPointChar = "00001531-1212-efde-1523-785feabcd123" // UUID of Control Point characteristic
DFUPacketChar = "00001532-1212-efde-1523-785feabcd123" // UUID of Packet characteristic
)
const (
DFUSegmentSize = 20 // Size of each firmware packet
DFUPktRecvInterval = 10 // Amount of packets to send before checking for receipt
)
var (
DFUCmdStart = []byte{0x01, 0x04}
DFUCmdRecvInitPkt = []byte{0x02, 0x00}
DFUCmdInitPktComplete = []byte{0x02, 0x01}
DFUCmdPktReceiptInterval = []byte{0x08, 0x0A}
DFUCmdRecvFirmware = []byte{0x03}
DFUCmdValidate = []byte{0x04}
DFUCmdActivateReset = []byte{0x05}
)
var (
DFUResponseStart = []byte{0x10, 0x01, 0x01}
DFUResponseInitParams = []byte{0x10, 0x02, 0x01}
DFUResponseRecvFwImgSuccess = []byte{0x10, 0x03, 0x01}
DFUResponseValidate = []byte{0x10, 0x04, 0x01}
)
var DFUNotifPktRecvd = []byte{0x11}
var (
ErrDFUInvalidInput = errors.New("input file invalid, must be a .bin file")
ErrDFUTimeout = errors.New("timed out waiting for response")
ErrDFUNoFilesLoaded = errors.New("no files are loaded")
ErrDFUInvalidResponse = errors.New("invalid response returned")
ErrDFUSizeMismatch = errors.New("amount of bytes sent does not match amount received")
)
var btOptsCmd = map[string]interface{}{"type": "command"}
// DFU stores everything required for doing firmware upgrades
type DFU struct {
initPacket fs.File
fwImage fs.File
ctrlRespCh <-chan *bluez.PropertyChanged
fwSize int64
bytesSent int
bytesRecvd int
fwSendDone bool
ctrlPointChar *gatt.GattCharacteristic1
packetChar *gatt.GattCharacteristic1
}
// LoadFiles loads an init packet (.dat) and firmware image (.bin)
func (dfu *DFU) LoadFiles(initPath, fwPath string) error {
// Open init packet file
initPktFl, err := os.Open(initPath)
if err != nil {
return err
}
dfu.initPacket = initPktFl
// Open firmware image file
fwImgFl, err := os.Open(fwPath)
if err != nil {
return err
}
dfu.fwImage = fwImgFl
// Get firmware file size
dfu.fwSize, err = getFlSize(dfu.fwImage)
if err != nil {
return err
}
return nil
}
type archiveManifest struct {
Manifest struct {
Application struct {
BinFile string `json:"bin_file"`
DatFile string `json:"dat_file"`
} `json:"application"`
} `json:"manifest"`
}
// LoadArchive loads an init packet and firmware image from a zip archive
// using a maifest.json also stored in the archive.
func (dfu *DFU) LoadArchive(archivePath string) error {
// Open archive file
archiveFl, err := os.Open(archivePath)
if err != nil {
return err
}
// Get archive size
archiveSize, err := getFlSize(archiveFl)
if err != nil {
return err
}
// Create zip reader from archive file
zipReader, err := zip.NewReader(archiveFl, archiveSize)
if err != nil {
return err
}
// Open manifest.json from zip archive
manifestFl, err := zipReader.Open("manifest.json")
if err != nil {
return err
}
var manifest archiveManifest
// Decode manifest file as JSON
err = json.NewDecoder(manifestFl).Decode(&manifest)
if err != nil {
return err
}
// Open init packet from zip archive
initPktFl, err := zipReader.Open(manifest.Manifest.Application.DatFile)
if err != nil {
return err
}
dfu.initPacket = initPktFl
// Open firmware image from zip archive
fwImgFl, err := zipReader.Open(manifest.Manifest.Application.BinFile)
if err != nil {
return err
}
dfu.fwImage = fwImgFl
// Get file size of firmware image
dfu.fwSize, err = getFlSize(dfu.fwImage)
if err != nil {
return err
}
return nil
}
// getFlSize uses Stat to get the size of a file
func getFlSize(fl fs.File) (int64, error) {
// Get file information
flInfo, err := fl.Stat()
if err != nil {
return 0, err
}
return flInfo.Size(), nil
}
// Start DFU process
func (dfu *DFU) Start() error {
if dfu.fwImage == nil || dfu.initPacket == nil {
return ErrDFUNoFilesLoaded
}
// Start notifications on control point
err := dfu.ctrlPointChar.StartNotify()
if err != nil {
return err
}
// Watch for property changes on control point
dfu.ctrlRespCh, err = dfu.ctrlPointChar.WatchProperties()
if err != nil {
return err
}
// Run step one
err = dfu.stepOne()
if err != nil {
return err
}
// Run step two
err = dfu.stepTwo()
if err != nil {
return err
}
// When 0x100101 received, run step three
err = dfu.on(DFUResponseStart, func(_ []byte) error {
return dfu.stepThree()
})
if err != nil {
return err
}
// Run step three
err = dfu.stepFour()
if err != nil {
return err
}
// When 0x100201 received. run step five
err = dfu.on(DFUResponseInitParams, func(_ []byte) error {
return dfu.stepFive()
})
if err != nil {
return err
}
// Run step six
err = dfu.stepSix()
if err != nil {
return err
}
// Run step seven
err = dfu.stepSeven()
if err != nil {
return err
}
// When 0x100301 received, run step eight
err = dfu.on(DFUResponseRecvFwImgSuccess, func(_ []byte) error {
return dfu.stepEight()
})
if err != nil {
return err
}
// When 0x100401 received, run step nine
err = dfu.on(DFUResponseValidate, func(_ []byte) error {
return dfu.stepNine()
})
if err != nil {
return err
}
return nil
}
// on waits for the given command to be received on
// the control point characteristic, then runs the callback.
func (dfu *DFU) on(cmd []byte, onCmdCb func(data []byte) error) error {
select {
case propChanged := <-dfu.ctrlRespCh:
if propChanged.Name != "Value" {
return ErrDFUInvalidResponse
}
// Assert propery value as byte slice
data := propChanged.Value.([]byte)
// If command has prefix of given command
if bytes.HasPrefix(data, cmd) {
// Return callback with data after command
return onCmdCb(data[len(cmd):])
}
return ErrDFUInvalidResponse
case <-time.After(50 * time.Second):
return ErrDFUTimeout
}
}
func (dfu *DFU) stepOne() error {
return dfu.ctrlPointChar.WriteValue(DFUCmdStart, nil)
}
func (dfu *DFU) stepTwo() error {
// Create byte slice with 4 bytes allocated
data := make([]byte, 4)
// Write little endian uint32 to data slice
binary.LittleEndian.PutUint32(data, uint32(dfu.fwSize))
// Pad data with 8 bytes
data = append(make([]byte, 8), data...)
// Write data to packet characteristic
return dfu.packetChar.WriteValue(data, nil)
}
func (dfu *DFU) stepThree() error {
return dfu.ctrlPointChar.WriteValue(DFUCmdRecvInitPkt, nil)
}
func (dfu *DFU) stepFour() error {
// Read init packet
data, err := ioutil.ReadAll(dfu.initPacket)
if err != nil {
return err
}
// Write init packet to packet characteristic
err = dfu.packetChar.WriteValue(data, nil)
if err != nil {
return err
}
// Write init packet complete command to control point
return dfu.ctrlPointChar.WriteValue(DFUCmdInitPktComplete, nil)
}
func (dfu *DFU) stepFive() error {
return dfu.ctrlPointChar.WriteValue(DFUCmdPktReceiptInterval, nil)
}
func (dfu *DFU) stepSix() error {
return dfu.ctrlPointChar.WriteValue(DFUCmdRecvFirmware, nil)
}
func (dfu *DFU) stepSeven() error {
// While send is not done
for !dfu.fwSendDone {
for i := 0; i < DFUPktRecvInterval; i++ {
// Create byte slice with segment size
segment := make([]byte, DFUSegmentSize)
// Write firmware image into slice
n, err := dfu.fwImage.Read(segment)
// If EOF, send is done
if err == io.EOF {
dfu.fwSendDone = true
return nil
} else if err != nil {
return err
}
// Write segment to packet characteristic
err = dfu.packetChar.WriteValue(segment, nil)
if err != nil {
return err
}
// Increment bytes sent by amount read
dfu.bytesSent += n
}
// On 0x11, verify packet receipt size
err := dfu.on(DFUNotifPktRecvd, func(data []byte) error {
// Set bytes received to data returned by InfiniTime
dfu.bytesRecvd = int(binary.LittleEndian.Uint32(data))
if dfu.bytesRecvd != dfu.bytesSent {
return ErrDFUSizeMismatch
}
return nil
})
if err != nil {
return err
}
}
return nil
}
func (dfu *DFU) stepEight() error {
return dfu.ctrlPointChar.WriteValue(DFUCmdValidate, nil)
}
func (dfu *DFU) stepNine() error {
return dfu.ctrlPointChar.WriteValue(DFUCmdActivateReset, btOptsCmd)
}

5
go.mod Normal file
View file

@ -0,0 +1,5 @@
module go.arsenm.dev/infinitime
go 1.16
require github.com/muka/go-bluetooth v0.0.0-20210812063148-b6c83362e27d

52
go.sum Normal file
View file

@ -0,0 +1,52 @@
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/fatih/structs v1.1.0 h1:Q7juDM0QtcnhCpeyLGQKyg4TOIghuNXrkL32pHAUMxo=
github.com/fatih/structs v1.1.0/go.mod h1:9NiDSp5zOcgEDl+j00MP/WkGVPOlPRLejGD8Ga6PJ7M=
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/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-20210812063148-b6c83362e27d h1:EG/xyWjHT19rkUpwsWSkyiCCmyqNwFovr9m10rhyOxU=
github.com/muka/go-bluetooth v0.0.0-20210812063148-b6c83362e27d/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=
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.6.0 h1:UBcNElsrwanuuMsnGSlYmtmgbb23qDR5dG+6X6Oo89I=
github.com/sirupsen/logrus v1.6.0/go.mod h1:7uNnSEd1DgxDLC74fIahvMZmmYsHGZGEOFrfsX/uA88=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
github.com/stretchr/testify v1.6.1 h1:hDPOHmpOpP40lSULcqw7IrRb/u7w6RpDC9399XyoNd0=
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/suapapa/go_eddystone v1.3.1/go.mod h1:bXC11TfJOS+3g3q/Uzd7FKd5g62STQEfeEIhcKe4Qy8=
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
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-20200822124328-c89045814202/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20200625203802-6e8e738ad208/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-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200728102440-3e129f6d46b1 h1:sIky/MyNRSHTrdxfsiUSS4WIAMvInbeXljJz+jDjeYE=
golang.org/x/sys v0.0.0-20200728102440-3e129f6d46b1/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
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=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c h1:dUUwHk2QECo/6vqA44rthZ8ie2QXMNeKRTHCNY2nXvo=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

387
infinitime.go Normal file
View file

@ -0,0 +1,387 @@
package infinitime
import (
"bytes"
"encoding/binary"
"errors"
"fmt"
"time"
bt "github.com/muka/go-bluetooth/api"
"github.com/muka/go-bluetooth/bluez/profile/adapter"
"github.com/muka/go-bluetooth/bluez/profile/device"
"github.com/muka/go-bluetooth/bluez/profile/gatt"
)
const BTName = "InfiniTime"
const (
NewAlertChar = "00002a46-0000-1000-8000-00805f9b34fb"
FirmwareVerChar = "00002a26-0000-1000-8000-00805f9b34fb"
CurrentTimeChar = "00002a2b-0000-1000-8000-00805f9b34fb"
BatteryLvlChar = "00002a19-0000-1000-8000-00805f9b34fb"
HeartRateChar = "00002a37-0000-1000-8000-00805f9b34fb"
)
type Device struct {
opts *Options
device *device.Device1
newAlertChar *gatt.GattCharacteristic1
fwVersionChar *gatt.GattCharacteristic1
currentTimeChar *gatt.GattCharacteristic1
battLevelChar *gatt.GattCharacteristic1
heartRateChar *gatt.GattCharacteristic1
onReconnect func()
Music MusicCtrl
DFU DFU
}
var ErrNoDevices = errors.New("no InfiniTime devices found")
var ErrNotFound = errors.New("could not find any advertising InfiniTime devices")
type Options struct {
AttemptReconnect bool
}
var DefaultOptions = &Options{
AttemptReconnect: true,
}
// 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.
func Connect(opts *Options) (*Device, error) {
if opts == nil {
opts = DefaultOptions
}
// Attempt to connect to paired device by name
dev, err := connectByName()
// If such device does not exist
if errors.Is(err, ErrNoDevices) {
// Attempt to pair device
dev, err = pair()
}
if err != nil {
return nil, err
}
dev.opts = opts
dev.onReconnect = func() {}
// Watch device properties
devEvtCh, err := dev.device.WatchProperties()
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 {
// Attempt to connect via bluetooth address
reConnDev, err := ConnectByAddress(dev.device.Properties.Address)
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() (*Device, error) {
// Create new device
out := &Device{}
// Get devices from default adapter
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 {
// Set outout device to discovered device
out.device = dev
break
}
}
if out.device == nil {
return nil, ErrNoDevices
}
// Connect to device
out.device.Connect()
// Resolve characteristics
err = out.resolveChars()
if err != nil {
return nil, err
}
return out, nil
}
// Pair attempts to discover and pair an InfiniTime device
func pair() (*Device, error) {
// Create new device
out := &Device{}
// Start bluetooth discovery
discovery, cancelDiscover, err := bt.Discover(defaultAdapter, &adapter.DiscoveryFilter{Transport: "le"})
if err != nil {
return nil, err
}
// Cancel discovery at end of function
defer cancelDiscover()
discoveryLoop:
for {
select {
case event := <-discovery:
// If device removed, skip event
if event.Type == adapter.DeviceRemoved {
continue
}
// Create new device with discovered path
dev, err := device.NewDevice1(event.Path)
if err != nil {
return nil, err
}
// If device name is InfiniTime
if dev.Properties.Name == BTName {
// Set output device
out.device = dev
// Break out of discoveryLoop
break discoveryLoop
}
case <-time.After(5 * time.Second):
break discoveryLoop
}
}
if out.device == nil {
return nil, ErrNotFound
}
// Pair device
out.device.Pair()
// Set connected to true
out.device.Properties.Connected = true
// Resolve characteristics
err = out.resolveChars()
if err != nil {
return nil, err
}
return out, nil
}
// ConnectByAddress tries to connect to an InifiniTime at
// the specified InfiniTime address
func ConnectByAddress(addr string) (*Device, error) {
var err error
// Create new device
out := &Device{}
// Get device from bluetooth address
out.device, err = defaultAdapter.GetDeviceByAddress(addr)
if err != nil {
return nil, err
}
// Connect to device
err = out.device.Connect()
if err != nil {
return nil, err
}
// Resolve characteristics
err = out.resolveChars()
if err != nil {
return nil, err
}
return out, nil
}
// 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 {
// Set correct characteristics
switch char.Properties.UUID {
case NewAlertChar:
i.newAlertChar = 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
}
}
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 !i.device.Properties.Connected {
return "", nil
}
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 !i.device.Properties.Connected {
return 0, nil
}
battLevel, err := i.battLevelChar.ReadValue(nil)
if err != nil {
return 0, err
}
return uint8(battLevel[0]), nil
}
func (i *Device) HeartRate() (uint8, error) {
if !i.device.Properties.Connected {
return 0, nil
}
heartRate, err := i.heartRateChar.ReadValue(nil)
if err != nil {
return 0, err
}
return uint8(heartRate[1]), nil
}
func (i *Device) WatchHeartRate() (<-chan uint8, error) {
if !i.device.Properties.Connected {
return make(<-chan uint8), nil
}
// 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)
go func() {
// For every event
for event := range ch {
// If value changed
if event.Name == "Value" {
// Send heart rate to channel
out <- uint8(event.Value.([]byte)[1])
}
}
}()
return out, nil
}
// SetTime sets the watch's time using the Current Time Service
func (i *Device) SetTime(t time.Time) error {
if !i.device.Properties.Connected {
return nil
}
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 !i.device.Properties.Connected {
return nil
}
return i.newAlertChar.WriteValue(
[]byte(fmt.Sprintf("00\x00%s\x00%s", title, body)),
nil,
)
}

81
music.go Normal file
View file

@ -0,0 +1,81 @@
package infinitime
import "github.com/muka/go-bluetooth/bluez/profile/gatt"
type MusicEvent uint8
const (
MusicEventChar = "00000001-78fc-48fe-8e23-433b3a1942d0"
MusicStatusChar = "00000002-78fc-48fe-8e23-433b3a1942d0"
MusicArtistChar = "00000003-78fc-48fe-8e23-433b3a1942d0"
MusicTrackChar = "00000004-78fc-48fe-8e23-433b3a1942d0"
MusicAlbumChar = "00000005-78fc-48fe-8e23-433b3a1942d0"
)
const (
MusicEventOpen MusicEvent = 0xe0
MusicEventPlay MusicEvent = 0x00
MusicEventPause MusicEvent = 0x01
MusicEventNext MusicEvent = 0x03
MusicEventPrev MusicEvent = 0x04
MusicEventVolUp MusicEvent = 0x05
MusicEventVolDown MusicEvent = 0x06
)
// MusicCtrl stores everything required to control music
type MusicCtrl struct {
eventChar *gatt.GattCharacteristic1
statusChar *gatt.GattCharacteristic1
artistChar *gatt.GattCharacteristic1
trackChar *gatt.GattCharacteristic1
albumChar *gatt.GattCharacteristic1
}
// SetStatus sets the playing status
func (mc MusicCtrl) SetStatus(playing bool) error {
if playing {
return mc.statusChar.WriteValue([]byte{0x1}, nil)
}
return mc.statusChar.WriteValue([]byte{0x0}, nil)
}
// SetArtist sets the artist on InfniTime
func (mc MusicCtrl) SetArtist(artist string) error {
return mc.artistChar.WriteValue([]byte(artist), nil)
}
// SetTrack sets the track name on InfniTime
func (mc MusicCtrl) SetTrack(track string) error {
return mc.trackChar.WriteValue([]byte(track), nil)
}
// SetAlbum sets the album on InfniTime
func (mc MusicCtrl) SetAlbum(album string) error {
return mc.albumChar.WriteValue([]byte(album), nil)
}
// WatchEvents watches music events from InfiniTime
func (mc MusicCtrl) WatchEvents() (<-chan MusicEvent, error) {
// Start notifications on music event characteristic
err := mc.eventChar.StartNotify()
if err != nil {
return nil, err
}
// Watch music event properties
ch, err := mc.eventChar.WatchProperties()
if err != nil {
return nil, err
}
musicEventCh := make(chan MusicEvent, 5)
go func() {
// For every event
for event := range ch {
// If value changes
if event.Name == "Value" {
// Send music event to channel
musicEventCh <- MusicEvent(event.Value.([]byte)[0])
}
}
}()
return musicEventCh, nil
}

16
pkg/player/pactl.go Normal file
View file

@ -0,0 +1,16 @@
package player
import (
"fmt"
"os/exec"
)
// VolUp uses pactl to increase the volume of the default sink
func VolUp(percent uint) error {
return exec.Command("pactl", "set-sink-volume", "@DEFAULT_SINK@", fmt.Sprintf("+%d%%", percent)).Run()
}
// VolDown uses pactl to decrease the volume of the default sink
func VolDown(percent uint) error {
return exec.Command("pactl", "set-sink-volume", "@DEFAULT_SINK@", fmt.Sprintf("-%d%%", percent)).Run()
}

91
pkg/player/playerctl.go Normal file
View file

@ -0,0 +1,91 @@
package player
import (
"bufio"
"io"
"os/exec"
"strings"
)
// Play uses playerctl to play media
func Play() error {
return exec.Command("playerctl", "play").Run()
}
// Pause uses playerctl to pause media
func Pause() error {
return exec.Command("playerctl", "pause").Run()
}
// Next uses playerctl to skip to next media
func Next() error {
return exec.Command("playerctl", "next").Run()
}
// Prev uses playerctl to skip to previous media
func Prev() error {
return exec.Command("playerctl", "previous").Run()
}
// Metadata uses playerctl to detect music metadata changes
func Metadata(key string, onChange func(string)) error {
// Execute playerctl command with key and follow flag
cmd := exec.Command("playerctl", "metadata", key, "-F")
// Get stdout pipe
stdout, err := cmd.StdoutPipe()
if err != nil {
return err
}
go func() {
for {
// Read line from command stdout
line, _, err := bufio.NewReader(stdout).ReadLine()
if err == io.EOF {
continue
}
// Convert line to string
data := string(line)
// If key unknown, return suitable default
if data == "No player could handle this command" || data == "" {
data = "Unknown " + strings.Title(key)
}
// Run the onChange callback
onChange(data)
}
}()
// Start command asynchronously
err = cmd.Start()
if err != nil {
return err
}
return nil
}
func Status(onChange func(bool)) error {
// Execute playerctl status with follow flag
cmd := exec.Command("playerctl", "status", "-F")
// Get stdout pipe
stdout, err := cmd.StdoutPipe()
if err != nil {
return err
}
go func() {
for {
// Read line from command stdout
line, _, err := bufio.NewReader(stdout).ReadLine()
if err == io.EOF {
continue
}
// Convert line to string
data := string(line)
// Run the onChange callback
onChange(data == "Playing")
}
}()
// Start command asynchronously
err = cmd.Start()
if err != nil {
return err
}
return nil
}