commit
21078996c1
10 changed files with 1088 additions and 0 deletions
@ -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. |
@ -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) |
@ -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() |
|||
} |
@ -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) |
|||
} |
@ -0,0 +1,5 @@ |
|||
module go.arsenm.dev/infinitime |
|||
|
|||
go 1.16 |
|||
|
|||
require github.com/muka/go-bluetooth v0.0.0-20210812063148-b6c83362e27d |
@ -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= |
@ -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, |
|||
) |
|||
} |
@ -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 |
|||
} |
@ -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() |
|||
} |
@ -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 |
|||
} |
Loading…
Reference in new issue