commit 1b10eba464309e60aefe8bccb6f758429996e91a Author: Elara Musayelyan Date: Thu Aug 19 17:41:09 2021 -0700 Initial Commit diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..ca2083e --- /dev/null +++ b/LICENSE @@ -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. diff --git a/README.md b/README.md new file mode 100644 index 0000000..7d0a6ad --- /dev/null +++ b/README.md @@ -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) \ No newline at end of file diff --git a/btsetup.go b/btsetup.go new file mode 100644 index 0000000..1da97b0 --- /dev/null +++ b/btsetup.go @@ -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() +} \ No newline at end of file diff --git a/dfu.go b/dfu.go new file mode 100644 index 0000000..9e990ce --- /dev/null +++ b/dfu.go @@ -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) +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..a532440 --- /dev/null +++ b/go.mod @@ -0,0 +1,5 @@ +module go.arsenm.dev/infinitime + +go 1.16 + +require github.com/muka/go-bluetooth v0.0.0-20210812063148-b6c83362e27d diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..b6a8d7b --- /dev/null +++ b/go.sum @@ -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= diff --git a/infinitime.go b/infinitime.go new file mode 100644 index 0000000..7735839 --- /dev/null +++ b/infinitime.go @@ -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, + ) +} diff --git a/music.go b/music.go new file mode 100644 index 0000000..a18b240 --- /dev/null +++ b/music.go @@ -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 +} diff --git a/pkg/player/pactl.go b/pkg/player/pactl.go new file mode 100644 index 0000000..3dc3441 --- /dev/null +++ b/pkg/player/pactl.go @@ -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() +} diff --git a/pkg/player/playerctl.go b/pkg/player/playerctl.go new file mode 100644 index 0000000..379b92b --- /dev/null +++ b/pkg/player/playerctl.go @@ -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 +}