Add rewritten infinitime abstraction and integrate it into ITD
This commit is contained in:
142
infinitime/chars.go
Normal file
142
infinitime/chars.go
Normal file
@@ -0,0 +1,142 @@
|
||||
package infinitime
|
||||
|
||||
import "tinygo.org/x/bluetooth"
|
||||
|
||||
type btChar struct {
|
||||
Name string
|
||||
ID bluetooth.UUID
|
||||
ServiceID bluetooth.UUID
|
||||
}
|
||||
|
||||
var (
|
||||
musicServiceUUID = mustParse("00000000-78fc-48fe-8e23-433b3a1942d0")
|
||||
navigationServiceUUID = mustParse("00010000-78fc-48fe-8e23-433b3a1942d0")
|
||||
motionServiceUUID = mustParse("00030000-78fc-48fe-8e23-433b3a1942d0")
|
||||
weatherServiceUUID = mustParse("00050000-78fc-48fe-8e23-433b3a1942d0")
|
||||
)
|
||||
|
||||
var (
|
||||
newAlertChar = btChar{
|
||||
"New Alert",
|
||||
bluetooth.CharacteristicUUIDNewAlert,
|
||||
bluetooth.ServiceUUIDAlertNotification,
|
||||
}
|
||||
notifEventChar = btChar{
|
||||
"Notification Event",
|
||||
mustParse("00020001-78fc-48fe-8e23-433b3a1942d0"),
|
||||
bluetooth.ServiceUUIDAlertNotification,
|
||||
}
|
||||
stepCountChar = btChar{
|
||||
"Step Count",
|
||||
mustParse("00030001-78fc-48fe-8e23-433b3a1942d0"),
|
||||
motionServiceUUID,
|
||||
}
|
||||
rawMotionChar = btChar{
|
||||
"Raw Motion",
|
||||
mustParse("00030002-78fc-48fe-8e23-433b3a1942d0"),
|
||||
motionServiceUUID,
|
||||
}
|
||||
firmwareVerChar = btChar{
|
||||
"Firmware Version",
|
||||
bluetooth.CharacteristicUUIDFirmwareRevisionString,
|
||||
bluetooth.ServiceUUIDDeviceInformation,
|
||||
}
|
||||
currentTimeChar = btChar{
|
||||
"Current Time",
|
||||
bluetooth.CharacteristicUUIDCurrentTime,
|
||||
bluetooth.ServiceUUIDCurrentTime,
|
||||
}
|
||||
localTimeChar = btChar{
|
||||
"Local Time",
|
||||
bluetooth.CharacteristicUUIDLocalTimeInformation,
|
||||
bluetooth.ServiceUUIDCurrentTime,
|
||||
}
|
||||
batteryLevelChar = btChar{
|
||||
"Battery Level",
|
||||
bluetooth.CharacteristicUUIDBatteryLevel,
|
||||
bluetooth.ServiceUUIDBattery,
|
||||
}
|
||||
heartRateChar = btChar{
|
||||
"Heart Rate",
|
||||
bluetooth.CharacteristicUUIDHeartRateMeasurement,
|
||||
bluetooth.ServiceUUIDHeartRate,
|
||||
}
|
||||
fsVersionChar = btChar{
|
||||
"Filesystem Version",
|
||||
mustParse("adaf0200-4669-6c65-5472-616e73666572"),
|
||||
bluetooth.ServiceUUIDFileTransferByAdafruit,
|
||||
}
|
||||
fsTransferChar = btChar{
|
||||
"Filesystem Transfer",
|
||||
mustParse("adaf0200-4669-6c65-5472-616e73666572"),
|
||||
bluetooth.ServiceUUIDFileTransferByAdafruit,
|
||||
}
|
||||
dfuCtrlPointChar = btChar{
|
||||
"DFU Control Point",
|
||||
bluetooth.CharacteristicUUIDLegacyDFUControlPoint,
|
||||
bluetooth.ServiceUUIDLegacyDFU,
|
||||
}
|
||||
dfuPacketChar = btChar{
|
||||
"DFU Packet",
|
||||
bluetooth.CharacteristicUUIDLegacyDFUPacket,
|
||||
bluetooth.ServiceUUIDLegacyDFU,
|
||||
}
|
||||
navigationFlagsChar = btChar{
|
||||
"Navigation Flags",
|
||||
mustParse("00010001-78fc-48fe-8e23-433b3a1942d0"),
|
||||
navigationServiceUUID,
|
||||
}
|
||||
navigationNarrativeChar = btChar{
|
||||
"Navigation Narrative",
|
||||
mustParse("00010002-78fc-48fe-8e23-433b3a1942d0"),
|
||||
navigationServiceUUID,
|
||||
}
|
||||
navigationManDist = btChar{
|
||||
"Navigation Man Dist",
|
||||
mustParse("00010003-78fc-48fe-8e23-433b3a1942d0"),
|
||||
navigationServiceUUID,
|
||||
}
|
||||
navigationProgress = btChar{
|
||||
"Navigation Progress",
|
||||
mustParse("00010004-78fc-48fe-8e23-433b3a1942d0"),
|
||||
navigationServiceUUID,
|
||||
}
|
||||
weatherDataChar = btChar{
|
||||
"Weather Data",
|
||||
mustParse("00050001-78fc-48fe-8e23-433b3a1942d0"),
|
||||
weatherServiceUUID,
|
||||
}
|
||||
musicEventChar = btChar{
|
||||
"Music Event",
|
||||
mustParse("00000001-78fc-48fe-8e23-433b3a1942d0"),
|
||||
musicServiceUUID,
|
||||
}
|
||||
musicStatusChar = btChar{
|
||||
"Music Status",
|
||||
mustParse("00000002-78fc-48fe-8e23-433b3a1942d0"),
|
||||
musicServiceUUID,
|
||||
}
|
||||
musicArtistChar = btChar{
|
||||
"Music Artist",
|
||||
mustParse("00000003-78fc-48fe-8e23-433b3a1942d0"),
|
||||
musicServiceUUID,
|
||||
}
|
||||
musicTrackChar = btChar{
|
||||
"Music Track",
|
||||
mustParse("00000004-78fc-48fe-8e23-433b3a1942d0"),
|
||||
musicServiceUUID,
|
||||
}
|
||||
musicAlbumChar = btChar{
|
||||
"Music Album",
|
||||
mustParse("00000005-78fc-48fe-8e23-433b3a1942d0"),
|
||||
musicServiceUUID,
|
||||
}
|
||||
)
|
||||
|
||||
func mustParse(s string) bluetooth.UUID {
|
||||
uuid, err := bluetooth.ParseUUID(s)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return uuid
|
||||
}
|
||||
222
infinitime/dfu.go
Normal file
222
infinitime/dfu.go
Normal file
@@ -0,0 +1,222 @@
|
||||
package infinitime
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/binary"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/fs"
|
||||
|
||||
"tinygo.org/x/bluetooth"
|
||||
)
|
||||
|
||||
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}
|
||||
dfuCmdRecvFirmware = []byte{0x03}
|
||||
dfuCmdValidate = []byte{0x04}
|
||||
dfuCmdActivateReset = []byte{0x05}
|
||||
|
||||
dfuResponseStart = []byte{0x10, 0x01, 0x01}
|
||||
dfuResponseInitParams = []byte{0x10, 0x02, 0x01}
|
||||
dfuResponseRecvFwImgSuccess = []byte{0x10, 0x03, 0x01}
|
||||
dfuResponseValidate = []byte{0x10, 0x04, 0x01}
|
||||
)
|
||||
|
||||
// DFUOptions contains options for [UpgradeFirmware]
|
||||
type DFUOptions struct {
|
||||
InitPacket fs.File
|
||||
FirmwareImage fs.File
|
||||
ProgressFunc func(sent, received, total uint32)
|
||||
SegmentSize int
|
||||
ReceiveInterval uint8
|
||||
}
|
||||
|
||||
// UpgradeFirmware upgrades the firmware running on the PineTime.
|
||||
func (d *Device) UpgradeFirmware(opts DFUOptions) error {
|
||||
if opts.SegmentSize <= 0 {
|
||||
opts.SegmentSize = dfuSegmentSize
|
||||
}
|
||||
|
||||
if opts.ReceiveInterval <= 0 {
|
||||
opts.ReceiveInterval = dfuPktRecvInterval
|
||||
}
|
||||
|
||||
ctrlPoint, err := d.getChar(dfuCtrlPointChar)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
packet, err := d.getChar(dfuPacketChar)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
d.deviceMtx.Lock()
|
||||
defer d.deviceMtx.Unlock()
|
||||
|
||||
d.updating.Store(true)
|
||||
defer d.updating.Store(false)
|
||||
|
||||
_, err = ctrlPoint.WriteWithoutResponse(dfuCmdStart)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
fi, err := opts.FirmwareImage.Stat()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
size := uint32(fi.Size())
|
||||
|
||||
sizePacket := make([]byte, 8, 12)
|
||||
sizePacket = binary.LittleEndian.AppendUint32(sizePacket, size)
|
||||
_, err = packet.WriteWithoutResponse(sizePacket)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
_, err = awaitDFUResponse(ctrlPoint, dfuResponseStart)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
err = writeDFUInitPacket(ctrlPoint, packet, opts.InitPacket)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
err = setRecvInterval(ctrlPoint, opts.ReceiveInterval)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
err = sendFirmware(ctrlPoint, packet, opts, size)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return finalize(ctrlPoint)
|
||||
}
|
||||
|
||||
func finalize(ctrlPoint *bluetooth.DeviceCharacteristic) error {
|
||||
_, err := ctrlPoint.WriteWithoutResponse(dfuCmdValidate)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
_, err = awaitDFUResponse(ctrlPoint, dfuResponseValidate)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
_, _ = ctrlPoint.WriteWithoutResponse(dfuCmdActivateReset)
|
||||
return nil
|
||||
}
|
||||
|
||||
func sendFirmware(ctrlPoint, packet *bluetooth.DeviceCharacteristic, opts DFUOptions, totalSize uint32) error {
|
||||
_, err := ctrlPoint.WriteWithoutResponse(dfuCmdRecvFirmware)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var (
|
||||
chunksSinceReceipt uint8
|
||||
bytesSent uint32
|
||||
)
|
||||
|
||||
chunk := make([]byte, opts.SegmentSize)
|
||||
for {
|
||||
n, err := opts.FirmwareImage.Read(chunk)
|
||||
if err != nil && !errors.Is(err, io.EOF) {
|
||||
return err
|
||||
} else if n == 0 {
|
||||
break
|
||||
}
|
||||
|
||||
bytesSent += uint32(n)
|
||||
_, err = packet.WriteWithoutResponse(chunk[:n])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if errors.Is(err, io.EOF) {
|
||||
break
|
||||
}
|
||||
|
||||
chunksSinceReceipt += 1
|
||||
if chunksSinceReceipt == opts.ReceiveInterval {
|
||||
sizeData, err := awaitDFUResponse(ctrlPoint, []byte{0x11})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
size := binary.LittleEndian.Uint32(sizeData)
|
||||
if size != bytesSent {
|
||||
return fmt.Errorf("size mismatch: expected %d, got %d", bytesSent, size)
|
||||
}
|
||||
if opts.ProgressFunc != nil {
|
||||
opts.ProgressFunc(bytesSent, size, totalSize)
|
||||
}
|
||||
chunksSinceReceipt = 0
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func writeDFUInitPacket(ctrlPoint, packet *bluetooth.DeviceCharacteristic, initPkt fs.File) error {
|
||||
_, err := ctrlPoint.WriteWithoutResponse(dfuCmdRecvInitPkt)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
initData, err := io.ReadAll(initPkt)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
_, err = packet.WriteWithoutResponse(initData)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
_, err = ctrlPoint.WriteWithoutResponse(dfuCmdInitPktComplete)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
_, err = awaitDFUResponse(ctrlPoint, dfuResponseInitParams)
|
||||
return err
|
||||
}
|
||||
|
||||
func setRecvInterval(ctrlPoint *bluetooth.DeviceCharacteristic, interval uint8) error {
|
||||
_, err := ctrlPoint.WriteWithoutResponse(append(dfuCmdPktReceiptInterval, interval))
|
||||
return err
|
||||
}
|
||||
|
||||
func awaitDFUResponse(ctrlPoint *bluetooth.DeviceCharacteristic, expect []byte) ([]byte, error) {
|
||||
respCh := make(chan []byte, 1)
|
||||
err := ctrlPoint.EnableNotifications(func(buf []byte) {
|
||||
respCh <- buf
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
data := <-respCh
|
||||
ctrlPoint.EnableNotifications(nil)
|
||||
|
||||
if !bytes.HasPrefix(data, expect) {
|
||||
return nil, fmt.Errorf("unexpected dfu response %x (expected %x)", data, expect)
|
||||
}
|
||||
|
||||
return bytes.TrimPrefix(data, expect), nil
|
||||
}
|
||||
601
infinitime/fs.go
Normal file
601
infinitime/fs.go
Normal file
@@ -0,0 +1,601 @@
|
||||
package infinitime
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"io"
|
||||
"io/fs"
|
||||
"math"
|
||||
"path"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
|
||||
"go.elara.ws/itd/internal/fsproto"
|
||||
"tinygo.org/x/bluetooth"
|
||||
)
|
||||
|
||||
// FS represents a remote BLE filesystem
|
||||
type FS struct {
|
||||
mtx sync.Mutex
|
||||
dev *Device
|
||||
}
|
||||
|
||||
// Stat gets information about a file at the given path.
|
||||
//
|
||||
// WARNING: Since there's no stat command in the BLE FS protocol,
|
||||
// this function does a ReadDir and then finds the requested file
|
||||
// in the results, which makes it pretty slow.
|
||||
func (ifs *FS) Stat(p string) (fs.FileInfo, error) {
|
||||
dir := path.Dir(p)
|
||||
entries, err := ifs.ReadDir(dir)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for _, entry := range entries {
|
||||
if entry.Name() == path.Base(p) {
|
||||
return entry.Info()
|
||||
}
|
||||
}
|
||||
|
||||
return nil, fsproto.ErrFileNotExists
|
||||
}
|
||||
|
||||
// Remove removes a file or empty directory at the given path.
|
||||
//
|
||||
// For a function that removes directories recursively, see [FS.RemoveAll]
|
||||
func (ifs *FS) Remove(path string) error {
|
||||
ifs.mtx.Lock()
|
||||
defer ifs.mtx.Unlock()
|
||||
|
||||
char, err := ifs.dev.getChar(fsTransferChar)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return ifs.requestThenAwaitResponse(
|
||||
char,
|
||||
fsproto.DeleteFileOpcode,
|
||||
fsproto.DeleteFileRequest{
|
||||
PathLen: uint16(len(path)),
|
||||
Path: path,
|
||||
},
|
||||
func(buf []byte) (bool, error) {
|
||||
var mdr fsproto.DeleteFileResponse
|
||||
return true, fsproto.ReadResponse(buf, fsproto.DeleteFileResp, &mdr)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
// Rename moves a file or directory from an old path to a new path.
|
||||
func (ifs *FS) Rename(old, new string) error {
|
||||
ifs.mtx.Lock()
|
||||
defer ifs.mtx.Unlock()
|
||||
|
||||
char, err := ifs.dev.getChar(fsTransferChar)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return ifs.requestThenAwaitResponse(
|
||||
char,
|
||||
fsproto.MoveFileOpcode,
|
||||
fsproto.MoveFileRequest{
|
||||
OldPathLen: uint16(len(old)),
|
||||
OldPath: old,
|
||||
NewPathLen: uint16(len(new)),
|
||||
NewPath: new,
|
||||
},
|
||||
func(buf []byte) (bool, error) {
|
||||
var mfr fsproto.MoveFileResponse
|
||||
return true, fsproto.ReadResponse(buf, fsproto.MoveFileResp, &mfr)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
// Mkdir creates a new directory at the specified path.
|
||||
//
|
||||
// For a function that creates necessary parents as well, see [FS.MkdirAll]
|
||||
func (ifs *FS) Mkdir(path string) error {
|
||||
ifs.mtx.Lock()
|
||||
defer ifs.mtx.Unlock()
|
||||
|
||||
char, err := ifs.dev.getChar(fsTransferChar)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return ifs.requestThenAwaitResponse(
|
||||
char,
|
||||
fsproto.MakeDirectoryOpcode,
|
||||
fsproto.MkdirRequest{
|
||||
PathLen: uint16(len(path)),
|
||||
Path: path,
|
||||
},
|
||||
func(buf []byte) (bool, error) {
|
||||
var mdr fsproto.MkdirResponse
|
||||
return true, fsproto.ReadResponse(buf, fsproto.MakeDirectoryResp, &mdr)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
// ReadDir reads the directory at the specified path and returns a list of directory entries.
|
||||
func (ifs *FS) ReadDir(path string) ([]fs.DirEntry, error) {
|
||||
ifs.mtx.Lock()
|
||||
defer ifs.mtx.Unlock()
|
||||
|
||||
char, err := ifs.dev.getChar(fsTransferChar)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var out []fs.DirEntry
|
||||
return out, ifs.requestThenAwaitResponse(
|
||||
char,
|
||||
fsproto.ListDirectoryOpcode,
|
||||
fsproto.ListDirRequest{
|
||||
PathLen: uint16(len(path)),
|
||||
Path: path,
|
||||
},
|
||||
func(buf []byte) (bool, error) {
|
||||
var ldr fsproto.ListDirResponse
|
||||
err := fsproto.ReadResponse(buf, fsproto.ListDirectoryResp, &ldr)
|
||||
if err != nil {
|
||||
return true, err
|
||||
}
|
||||
|
||||
if ldr.EntryNum == ldr.TotalEntries {
|
||||
return true, nil
|
||||
}
|
||||
|
||||
out = append(out, DirEntry{
|
||||
flags: ldr.Flags,
|
||||
modtime: ldr.ModTime,
|
||||
size: ldr.FileSize,
|
||||
path: string(ldr.Path),
|
||||
})
|
||||
|
||||
return false, nil
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
// RemoveAll removes the file at the specified path and any children it contains,
|
||||
// similar to the rm -r command.
|
||||
func (ifs *FS) RemoveAll(p string) error {
|
||||
if p == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
if path.Clean(p) == "/" {
|
||||
return fsproto.ErrNoRemoveRoot
|
||||
}
|
||||
|
||||
fi, err := ifs.Stat(p)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
if fi.IsDir() {
|
||||
return ifs.removeWithChildren(p)
|
||||
} else {
|
||||
err = ifs.Remove(p)
|
||||
|
||||
var code int8
|
||||
if err, ok := err.(fsproto.Error); ok {
|
||||
code = err.Code
|
||||
}
|
||||
|
||||
if err != nil && code != -2 {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// removeWithChildren removes the directory at the given path and its children recursively.
|
||||
func (ifs *FS) removeWithChildren(p string) error {
|
||||
list, err := ifs.ReadDir(p)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, entry := range list {
|
||||
name := entry.Name()
|
||||
|
||||
if name == "." || name == ".." {
|
||||
continue
|
||||
}
|
||||
entryPath := path.Join(p, name)
|
||||
|
||||
if entry.IsDir() {
|
||||
err = ifs.removeWithChildren(entryPath)
|
||||
} else {
|
||||
err = ifs.Remove(entryPath)
|
||||
}
|
||||
|
||||
var code int8
|
||||
if err, ok := err.(fsproto.Error); ok {
|
||||
code = err.Code
|
||||
}
|
||||
|
||||
if err != nil && code != -2 {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return ifs.Remove(p)
|
||||
}
|
||||
|
||||
// MkdirAll creates a directory and any necessary parents in the file system,
|
||||
// similar to the mkdir -p command.
|
||||
func (ifs *FS) MkdirAll(path string) error {
|
||||
if path == "" || path == "/" {
|
||||
return nil
|
||||
}
|
||||
|
||||
splitPath := strings.Split(path, "/")
|
||||
for i := 1; i < len(splitPath); i++ {
|
||||
curPath := strings.Join(splitPath[0:i+1], "/")
|
||||
|
||||
err := ifs.Mkdir(curPath)
|
||||
|
||||
var code int8
|
||||
if err, ok := err.(fsproto.Error); ok {
|
||||
code = err.Code
|
||||
}
|
||||
|
||||
if err != nil && code != -17 {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
var _ fs.File = (*File)(nil)
|
||||
|
||||
// File represents a remote file on a BLE filesystem.
|
||||
//
|
||||
// If ProgressFunc is set, it will be called whenever a read or write happens
|
||||
// with the amount of bytes transferred and the total size of the file.
|
||||
type File struct {
|
||||
fs *FS
|
||||
path string
|
||||
offset uint32
|
||||
size uint32
|
||||
readOnly bool
|
||||
ProgressFunc func(transferred, total uint32)
|
||||
}
|
||||
|
||||
// Open opens an existing file at the specified path.
|
||||
// It returns a handle for the file and an error, if any.
|
||||
func (ifs *FS) Open(path string) (*File, error) {
|
||||
return &File{
|
||||
fs: ifs,
|
||||
path: path,
|
||||
offset: 0,
|
||||
readOnly: true,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Create creates a new file with the specified path and size.
|
||||
// It returns a handle for the created file and an error, if any.
|
||||
func (ifs *FS) Create(path string, size uint32) (*File, error) {
|
||||
return &File{
|
||||
fs: ifs,
|
||||
path: path,
|
||||
offset: 0,
|
||||
size: size,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Write writes data from the byte slice b to the file.
|
||||
// It returns the number of bytes written and an error, if any.
|
||||
func (fl *File) Write(b []byte) (int, error) {
|
||||
if fl.readOnly {
|
||||
return 0, fsproto.ErrFileReadOnly
|
||||
}
|
||||
|
||||
fl.fs.mtx.Lock()
|
||||
defer fl.fs.mtx.Unlock()
|
||||
|
||||
char, err := fl.fs.dev.getChar(fsTransferChar)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
defer char.EnableNotifications(nil)
|
||||
|
||||
var chunkLen uint32
|
||||
|
||||
dataLen := uint32(len(b))
|
||||
transferred := uint32(0)
|
||||
mtu := uint32(fl.fs.mtu(char))
|
||||
|
||||
// continueCh is used to prevent race conditions. When the
|
||||
// request loop starts, it reads from continueCh, blocking it
|
||||
// until it's "released" by the notification function after
|
||||
// the response is processed.
|
||||
continueCh := make(chan struct{}, 2)
|
||||
var notifErr error
|
||||
err = char.EnableNotifications(func(buf []byte) {
|
||||
var wfr fsproto.WriteFileResponse
|
||||
err = fsproto.ReadResponse(buf, fsproto.WriteFileResp, &wfr)
|
||||
if err != nil {
|
||||
notifErr = err
|
||||
char.EnableNotifications(nil)
|
||||
close(continueCh)
|
||||
return
|
||||
}
|
||||
|
||||
transferred += chunkLen
|
||||
fl.offset += chunkLen
|
||||
|
||||
if wfr.FreeSpace == 0 || transferred == dataLen {
|
||||
char.EnableNotifications(nil)
|
||||
close(continueCh)
|
||||
return
|
||||
}
|
||||
|
||||
if fl.ProgressFunc != nil {
|
||||
fl.ProgressFunc(transferred, fl.size)
|
||||
}
|
||||
|
||||
// Release the request loop
|
||||
continueCh <- struct{}{}
|
||||
})
|
||||
|
||||
err = fsproto.WriteRequest(char, fsproto.WriteFileHeaderOpcode, fsproto.WriteFileHeaderRequest{
|
||||
PathLen: uint16(len(fl.path)),
|
||||
Offset: fl.offset,
|
||||
FileSize: fl.size,
|
||||
Path: fl.path,
|
||||
})
|
||||
if err != nil {
|
||||
return int(transferred), err
|
||||
}
|
||||
|
||||
for range continueCh {
|
||||
if notifErr != nil {
|
||||
return int(transferred), notifErr
|
||||
}
|
||||
|
||||
amountLeft := dataLen - transferred
|
||||
chunkLen = mtu
|
||||
if amountLeft < mtu {
|
||||
chunkLen = amountLeft
|
||||
}
|
||||
|
||||
err = fsproto.WriteRequest(char, fsproto.WriteFileOpcode, fsproto.WriteFileRequest{
|
||||
Status: 0x01,
|
||||
Offset: fl.offset,
|
||||
ChunkLen: chunkLen,
|
||||
Data: b[transferred : transferred+chunkLen],
|
||||
})
|
||||
if err != nil {
|
||||
return int(transferred), err
|
||||
}
|
||||
}
|
||||
|
||||
return int(transferred), notifErr
|
||||
}
|
||||
|
||||
// Read reads data from the file into the byte slice b.
|
||||
// It returns the number of bytes read and an error, if any.
|
||||
func (fl *File) Read(b []byte) (int, error) {
|
||||
fl.fs.mtx.Lock()
|
||||
defer fl.fs.mtx.Unlock()
|
||||
|
||||
char, err := fl.fs.dev.getChar(fsTransferChar)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
defer char.EnableNotifications(nil)
|
||||
|
||||
transferred := uint32(0)
|
||||
maxLen := uint32(len(b))
|
||||
mtu := uint32(fl.fs.mtu(char))
|
||||
|
||||
var (
|
||||
notifErr error
|
||||
done bool
|
||||
)
|
||||
|
||||
// continueCh is used to prevent race conditions. When the
|
||||
// request loop starts, it reads from continueCh, blocking it
|
||||
// until it's "released" by the notification function after
|
||||
// the response is processed.
|
||||
continueCh := make(chan struct{}, 2)
|
||||
err = char.EnableNotifications(func(buf []byte) {
|
||||
var rfr fsproto.ReadFileResponse
|
||||
err = fsproto.ReadResponse(buf, fsproto.ReadFileResp, &rfr)
|
||||
if err != nil {
|
||||
notifErr = err
|
||||
char.EnableNotifications(nil)
|
||||
close(continueCh)
|
||||
return
|
||||
}
|
||||
|
||||
fl.size = rfr.FileSize
|
||||
|
||||
if rfr.Offset == rfr.FileSize || rfr.ChunkLen == 0 {
|
||||
notifErr = io.EOF
|
||||
done = true
|
||||
char.EnableNotifications(nil)
|
||||
close(continueCh)
|
||||
return
|
||||
}
|
||||
|
||||
n := copy(b[transferred:], rfr.Data[:rfr.ChunkLen])
|
||||
fl.offset += uint32(n)
|
||||
transferred += uint32(n)
|
||||
|
||||
if fl.ProgressFunc != nil {
|
||||
fl.ProgressFunc(transferred, rfr.FileSize)
|
||||
}
|
||||
|
||||
// Release the request loop
|
||||
continueCh <- struct{}{}
|
||||
})
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
defer char.EnableNotifications(nil)
|
||||
|
||||
amountLeft := maxLen - transferred
|
||||
chunkLen := mtu
|
||||
if amountLeft < mtu {
|
||||
chunkLen = amountLeft
|
||||
}
|
||||
|
||||
err = fsproto.WriteRequest(char, fsproto.ReadFileHeaderOpcode, fsproto.ReadFileHeaderRequest{
|
||||
PathLen: uint16(len(fl.path)),
|
||||
Offset: fl.offset,
|
||||
ReadLen: chunkLen,
|
||||
Path: fl.path,
|
||||
})
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
if notifErr != nil {
|
||||
return int(transferred), notifErr
|
||||
}
|
||||
|
||||
for !done {
|
||||
// Wait for the notification function to release the loop
|
||||
<-continueCh
|
||||
|
||||
if notifErr != nil {
|
||||
return int(transferred), notifErr
|
||||
}
|
||||
|
||||
amountLeft = maxLen - transferred
|
||||
chunkLen = mtu
|
||||
if amountLeft < mtu {
|
||||
chunkLen = amountLeft
|
||||
}
|
||||
|
||||
err = fsproto.WriteRequest(char, fsproto.ReadFileOpcode, fsproto.ReadFileRequest{
|
||||
Status: 0x01,
|
||||
Offset: fl.offset,
|
||||
ReadLen: chunkLen,
|
||||
})
|
||||
if err != nil {
|
||||
return int(transferred), err
|
||||
}
|
||||
}
|
||||
|
||||
return int(transferred), notifErr
|
||||
}
|
||||
|
||||
// Stat returns information about the file,
|
||||
func (fl *File) Stat() (fs.FileInfo, error) {
|
||||
return fl.fs.Stat(fl.path)
|
||||
}
|
||||
|
||||
// Seek sets the offset for the next Read or Write on the file to the specified offset.
|
||||
// The whence parameter specifies the seek reference point:
|
||||
//
|
||||
// io.SeekStart: offset is relative to the start of the file.
|
||||
// io.SeekCurrent: offset is relative to the current offset.
|
||||
// io.SeekEnd: offset is relative to the end of the file.
|
||||
//
|
||||
// Seek returns the new offset and an error, if any.
|
||||
func (fl *File) Seek(offset int64, whence int) (int64, error) {
|
||||
if offset > math.MaxUint32 {
|
||||
return 0, fsproto.ErrInvalidOffset
|
||||
}
|
||||
u32Offset := uint32(offset)
|
||||
|
||||
fl.fs.mtx.Lock()
|
||||
defer fl.fs.mtx.Unlock()
|
||||
|
||||
if fl.size == 0 {
|
||||
return 0, errors.New("file size unknown")
|
||||
}
|
||||
|
||||
var newOffset uint32
|
||||
switch whence {
|
||||
case io.SeekStart:
|
||||
newOffset = u32Offset
|
||||
case io.SeekCurrent:
|
||||
newOffset = fl.offset + u32Offset
|
||||
case io.SeekEnd:
|
||||
newOffset = fl.size + u32Offset
|
||||
}
|
||||
|
||||
if newOffset > fl.size || newOffset < 0 {
|
||||
return 0, fsproto.ErrInvalidOffset
|
||||
}
|
||||
fl.offset = newOffset
|
||||
|
||||
return int64(fl.offset), nil
|
||||
}
|
||||
|
||||
// Close always returns nil
|
||||
func (fl *File) Close() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// requestThenAwaitResponse executes a BLE FS request and then waits for one or more responses,
|
||||
// until fn returns true or an error is encountered.
|
||||
func (ifs *FS) requestThenAwaitResponse(char *bluetooth.DeviceCharacteristic, opcode fsproto.FSReqOpcode, req any, fn func(buf []byte) (bool, error)) error {
|
||||
var stopped atomic.Bool
|
||||
errCh := make(chan error, 1)
|
||||
char.EnableNotifications(func(buf []byte) {
|
||||
stop, err := fn(buf)
|
||||
if err != nil && !stopped.Load() {
|
||||
errCh <- err
|
||||
char.EnableNotifications(nil)
|
||||
return
|
||||
} else if !stopped.Load() {
|
||||
errCh <- nil
|
||||
}
|
||||
|
||||
if stop && !stopped.Load() {
|
||||
stopped.Store(true)
|
||||
close(errCh)
|
||||
char.EnableNotifications(nil)
|
||||
}
|
||||
})
|
||||
defer char.EnableNotifications(nil)
|
||||
|
||||
err := fsproto.WriteRequest(char, opcode, req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for err := range errCh {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (ifs *FS) mtu(char *bluetooth.DeviceCharacteristic) uint16 {
|
||||
mtuVal, _ := char.GetMTU()
|
||||
if mtuVal == 0 {
|
||||
mtuVal = 256
|
||||
}
|
||||
return mtuVal - 20
|
||||
}
|
||||
|
||||
var _ fs.FS = (*GoFS)(nil)
|
||||
var _ fs.StatFS = (*GoFS)(nil)
|
||||
var _ fs.ReadDirFS = (*GoFS)(nil)
|
||||
|
||||
// GoFS implements [io/fs.FS], [io/fs.StatFS], and [io/fs.ReadDirFS]
|
||||
// for the InfiniTime filesystem
|
||||
type GoFS struct {
|
||||
*FS
|
||||
}
|
||||
|
||||
// Open opens an existing file at the specified path.
|
||||
// It returns a handle for the file and an error, if any.
|
||||
func (gfs GoFS) Open(path string) (fs.File, error) {
|
||||
return gfs.FS.Open(path)
|
||||
}
|
||||
142
infinitime/fstypes.go
Normal file
142
infinitime/fstypes.go
Normal file
@@ -0,0 +1,142 @@
|
||||
package infinitime
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"strconv"
|
||||
"time"
|
||||
)
|
||||
|
||||
// DirEntry represents an entry from a directory listing
|
||||
type DirEntry struct {
|
||||
flags uint32
|
||||
modtime uint64
|
||||
size uint32
|
||||
path string
|
||||
}
|
||||
|
||||
// Name returns the name of the file described by the entry
|
||||
func (de DirEntry) Name() string {
|
||||
return de.path
|
||||
}
|
||||
|
||||
// IsDir reports whether the entry describes a directory.
|
||||
func (de DirEntry) IsDir() bool {
|
||||
return de.flags&0b1 == 1
|
||||
}
|
||||
|
||||
// Type returns the type bits for the entry.
|
||||
func (de DirEntry) Type() fs.FileMode {
|
||||
if de.IsDir() {
|
||||
return fs.ModeDir
|
||||
} else {
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
// Info returns the FileInfo for the file or subdirectory described by the entry.
|
||||
func (de DirEntry) Info() (fs.FileInfo, error) {
|
||||
return FileInfo{
|
||||
name: de.path,
|
||||
size: de.size,
|
||||
modtime: de.modtime,
|
||||
mode: de.Type(),
|
||||
isDir: de.IsDir(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (de DirEntry) String() string {
|
||||
var isDirChar rune
|
||||
if de.IsDir() {
|
||||
isDirChar = 'd'
|
||||
} else {
|
||||
isDirChar = '-'
|
||||
}
|
||||
|
||||
// Get human-readable value for file size
|
||||
val, unit := bytesHuman(de.size)
|
||||
prec := 0
|
||||
// If value is less than 10, set precision to 1
|
||||
if val < 10 {
|
||||
prec = 1
|
||||
}
|
||||
// Convert float to string
|
||||
valStr := strconv.FormatFloat(val, 'f', prec, 64)
|
||||
|
||||
// Return string formatted like so:
|
||||
// - 10 kB file
|
||||
// or:
|
||||
// d 0 B .
|
||||
return fmt.Sprintf(
|
||||
"%c %3s %-2s %s",
|
||||
isDirChar,
|
||||
valStr,
|
||||
unit,
|
||||
de.path,
|
||||
)
|
||||
}
|
||||
|
||||
func bytesHuman(b uint32) (float64, string) {
|
||||
const unit = 1000
|
||||
// Set possible unit prefixes (PineTime flash is 4MB)
|
||||
units := [2]rune{'k', 'M'}
|
||||
// If amount of bytes is less than smallest unit
|
||||
if b < unit {
|
||||
// Return unchanged with unit "B"
|
||||
return float64(b), "B"
|
||||
}
|
||||
|
||||
div, exp := uint32(unit), 0
|
||||
// Get decimal values and unit prefix index
|
||||
for n := b / unit; n >= unit; n /= unit {
|
||||
div *= unit
|
||||
exp++
|
||||
}
|
||||
|
||||
// Create string for full unit
|
||||
unitStr := string([]rune{units[exp], 'B'})
|
||||
|
||||
// Return decimal with unit string
|
||||
return float64(b) / float64(div), unitStr
|
||||
}
|
||||
|
||||
// FileInfo implements fs.FileInfo
|
||||
type FileInfo struct {
|
||||
name string
|
||||
size uint32
|
||||
modtime uint64
|
||||
mode fs.FileMode
|
||||
isDir bool
|
||||
}
|
||||
|
||||
// Name returns the base name of the file
|
||||
func (fi FileInfo) Name() string {
|
||||
return fi.name
|
||||
}
|
||||
|
||||
// Size returns the total size of the file
|
||||
func (fi FileInfo) Size() int64 {
|
||||
return int64(fi.size)
|
||||
}
|
||||
|
||||
// Mode returns the mode of the file
|
||||
func (fi FileInfo) Mode() fs.FileMode {
|
||||
return fi.mode
|
||||
}
|
||||
|
||||
// ModTime returns the modification time of the file
|
||||
// As of now, this is unimplemented in InfiniTime, and
|
||||
// will always return 0.
|
||||
func (fi FileInfo) ModTime() time.Time {
|
||||
return time.Unix(0, int64(fi.modtime))
|
||||
}
|
||||
|
||||
// IsDir returns whether the file is a directory
|
||||
func (fi FileInfo) IsDir() bool {
|
||||
return fi.isDir
|
||||
}
|
||||
|
||||
// Sys is unimplemented and returns nil
|
||||
func (fi FileInfo) Sys() any {
|
||||
return nil
|
||||
}
|
||||
173
infinitime/infinitime.go
Normal file
173
infinitime/infinitime.go
Normal file
@@ -0,0 +1,173 @@
|
||||
package infinitime
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"tinygo.org/x/bluetooth"
|
||||
)
|
||||
|
||||
type Options struct {
|
||||
Allowlist []string
|
||||
Blocklist []string
|
||||
ScanInterval time.Duration
|
||||
|
||||
OnDisconnect func(dev *Device)
|
||||
OnReconnect func(dev *Device)
|
||||
OnConnect func(dev *Device)
|
||||
}
|
||||
|
||||
func reconnect(opts Options, adapter *bluetooth.Adapter, device *Device, mac string) {
|
||||
if device == nil {
|
||||
return
|
||||
}
|
||||
|
||||
done := false
|
||||
for {
|
||||
adapter.Scan(func(a *bluetooth.Adapter, sr bluetooth.ScanResult) {
|
||||
if sr.Address.String() != mac {
|
||||
return
|
||||
}
|
||||
|
||||
dev, err := a.Connect(sr.Address, bluetooth.ConnectionParams{})
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
adapter.StopScan()
|
||||
|
||||
device.deviceMtx.Lock()
|
||||
device.device = dev
|
||||
device.deviceMtx.Unlock()
|
||||
|
||||
device.notifierMtx.Lock()
|
||||
for char, notifier := range device.notifierMap {
|
||||
c, err := device.getChar(char)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
err = c.EnableNotifications(nil)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
err = c.EnableNotifications(notifier.notify)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
}
|
||||
device.notifierMtx.Unlock()
|
||||
|
||||
done = true
|
||||
})
|
||||
|
||||
if done {
|
||||
return
|
||||
}
|
||||
|
||||
time.Sleep(opts.ScanInterval)
|
||||
}
|
||||
}
|
||||
|
||||
func Connect(opts Options) (device *Device, err error) {
|
||||
adapter := bluetooth.DefaultAdapter
|
||||
|
||||
if opts.ScanInterval == 0 {
|
||||
opts.ScanInterval = 2 * time.Minute
|
||||
}
|
||||
|
||||
var mac string
|
||||
adapter.SetConnectHandler(func(dev bluetooth.Device, connected bool) {
|
||||
if mac == "" || dev.Address.String() != mac {
|
||||
return
|
||||
}
|
||||
|
||||
if connected {
|
||||
if opts.OnReconnect != nil {
|
||||
opts.OnReconnect(device)
|
||||
}
|
||||
} else {
|
||||
if opts.OnDisconnect != nil {
|
||||
opts.OnDisconnect(device)
|
||||
}
|
||||
go reconnect(opts, adapter, device, mac)
|
||||
}
|
||||
})
|
||||
|
||||
err = adapter.Enable()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var scanErr error
|
||||
err = adapter.Scan(func(a *bluetooth.Adapter, sr bluetooth.ScanResult) {
|
||||
if sr.LocalName() != "InfiniTime" {
|
||||
return
|
||||
}
|
||||
|
||||
dev, err := a.Connect(sr.Address, bluetooth.ConnectionParams{})
|
||||
if err != nil {
|
||||
scanErr = err
|
||||
adapter.StopScan()
|
||||
return
|
||||
}
|
||||
mac = dev.Address.String()
|
||||
|
||||
device = &Device{adapter: a, device: dev, notifierMap: map[btChar]notifier{}}
|
||||
if opts.OnConnect != nil {
|
||||
opts.OnConnect(device)
|
||||
}
|
||||
adapter.StopScan()
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if scanErr != nil {
|
||||
return nil, scanErr
|
||||
}
|
||||
|
||||
return device, nil
|
||||
}
|
||||
|
||||
// Device represents an InfiniTime device
|
||||
type Device struct {
|
||||
adapter *bluetooth.Adapter
|
||||
|
||||
deviceMtx sync.Mutex
|
||||
device bluetooth.Device
|
||||
updating atomic.Bool
|
||||
|
||||
notifierMtx sync.Mutex
|
||||
notifierMap map[btChar]notifier
|
||||
}
|
||||
|
||||
// FS returns a handle for InifniTime's filesystem'
|
||||
func (d *Device) FS() *FS {
|
||||
return &FS{
|
||||
dev: d,
|
||||
}
|
||||
}
|
||||
|
||||
func (d *Device) getChar(c btChar) (*bluetooth.DeviceCharacteristic, error) {
|
||||
if d.updating.Load() {
|
||||
return nil, fmt.Errorf("device is currently updating")
|
||||
}
|
||||
|
||||
d.deviceMtx.Lock()
|
||||
defer d.deviceMtx.Unlock()
|
||||
|
||||
services, err := d.device.DiscoverServices([]bluetooth.UUID{c.ServiceID})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("characteristic %s (%s) not found", c.ID, c.Name)
|
||||
}
|
||||
|
||||
chars, err := services[0].DiscoverCharacteristics([]bluetooth.UUID{c.ID})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("characteristic %s (%s) not found", c.ID, c.Name)
|
||||
}
|
||||
|
||||
return chars[0], err
|
||||
}
|
||||
101
infinitime/info.go
Normal file
101
infinitime/info.go
Normal file
@@ -0,0 +1,101 @@
|
||||
package infinitime
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/binary"
|
||||
)
|
||||
|
||||
// Address returns the MAC address of the connected device.
|
||||
func (d *Device) Address() string {
|
||||
return d.device.Address.String()
|
||||
}
|
||||
|
||||
// Version returns the version of InifniTime that the connected device is running.
|
||||
func (d *Device) Version() (string, error) {
|
||||
c, err := d.getChar(firmwareVerChar)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
ver := make([]byte, 16)
|
||||
n, err := c.Read(ver)
|
||||
return string(ver[:n]), err
|
||||
}
|
||||
|
||||
// BatteryLevel returns the current battery level of the connected PineTime.
|
||||
func (d *Device) BatteryLevel() (lvl uint8, err error) {
|
||||
c, err := d.getChar(batteryLevelChar)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
err = binary.Read(c, binary.LittleEndian, &lvl)
|
||||
return lvl, err
|
||||
}
|
||||
|
||||
// WatchBatteryLevel calls fn whenever the battery level changes.
|
||||
func (d *Device) WatchBatteryLevel(ctx context.Context, fn func(level uint8, err error)) error {
|
||||
return watchChar(ctx, d, batteryLevelChar, fn)
|
||||
}
|
||||
|
||||
// StepCount returns the current step count recorded on the watch.
|
||||
func (d *Device) StepCount() (sc uint32, err error) {
|
||||
c, err := d.getChar(stepCountChar)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
err = binary.Read(c, binary.LittleEndian, &sc)
|
||||
return sc, err
|
||||
}
|
||||
|
||||
// WatchStepCount calls fn whenever the step count changes.
|
||||
func (d *Device) WatchStepCount(ctx context.Context, fn func(count uint32, err error)) error {
|
||||
return watchChar(ctx, d, stepCountChar, fn)
|
||||
}
|
||||
|
||||
// HeartRate returns the current heart rate recorded on the watch.
|
||||
func (d *Device) HeartRate() (uint8, error) {
|
||||
c, err := d.getChar(heartRateChar)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
data := make([]byte, 2)
|
||||
_, err = c.Read(data)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
return data[1], nil
|
||||
}
|
||||
|
||||
// WatchHeartRate calls fn whenever the heart rate changes.
|
||||
func (d *Device) WatchHeartRate(ctx context.Context, fn func(rate uint8, err error)) error {
|
||||
return watchChar(ctx, d, heartRateChar, func(rate [2]uint8, err error) {
|
||||
fn(rate[1], err)
|
||||
})
|
||||
}
|
||||
|
||||
// MotionValues represents gyroscope coordinates.
|
||||
type MotionValues struct {
|
||||
X int16
|
||||
Y int16
|
||||
Z int16
|
||||
}
|
||||
|
||||
// Motion returns the current gyroscope coordinates of the PineTime.
|
||||
func (d *Device) Motion() (mv MotionValues, err error) {
|
||||
c, err := d.getChar(rawMotionChar)
|
||||
if err != nil {
|
||||
return MotionValues{}, err
|
||||
}
|
||||
|
||||
err = binary.Read(c, binary.LittleEndian, &mv)
|
||||
return mv, err
|
||||
}
|
||||
|
||||
// WatchMotion calls fn whenever the gyroscope coordinates change.
|
||||
func (d *Device) WatchMotion(ctx context.Context, fn func(level MotionValues, err error)) error {
|
||||
return watchChar(ctx, d, rawMotionChar, fn)
|
||||
}
|
||||
68
infinitime/music.go
Normal file
68
infinitime/music.go
Normal file
@@ -0,0 +1,68 @@
|
||||
package infinitime
|
||||
|
||||
import "context"
|
||||
|
||||
type MusicEvent uint8
|
||||
|
||||
const (
|
||||
MusicEventOpen MusicEvent = 0xe0
|
||||
MusicEventPlay MusicEvent = 0x00
|
||||
MusicEventPause MusicEvent = 0x01
|
||||
MusicEventNext MusicEvent = 0x03
|
||||
MusicEventPrev MusicEvent = 0x04
|
||||
MusicEventVolUp MusicEvent = 0x05
|
||||
MusicEventVolDown MusicEvent = 0x06
|
||||
)
|
||||
|
||||
// SetMusicStatus sets whether the music is playing or paused.
|
||||
func (d *Device) SetMusicStatus(playing bool) error {
|
||||
char, err := d.getChar(musicStatusChar)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if playing {
|
||||
_, err = char.WriteWithoutResponse([]byte{0x1})
|
||||
} else {
|
||||
_, err = char.WriteWithoutResponse([]byte{0x0})
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// SetMusicArtist sets the music artist.
|
||||
func (d *Device) SetMusicArtist(artist string) error {
|
||||
char, err := d.getChar(musicArtistChar)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
_, err = char.WriteWithoutResponse([]byte(artist))
|
||||
return err
|
||||
}
|
||||
|
||||
// SetMusicTrack sets the music track name.
|
||||
func (d *Device) SetMusicTrack(track string) error {
|
||||
char, err := d.getChar(musicTrackChar)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
_, err = char.WriteWithoutResponse([]byte(track))
|
||||
return err
|
||||
}
|
||||
|
||||
// SetMusicAlbum sets the music album name.
|
||||
func (d *Device) SetMusicAlbum(album string) error {
|
||||
char, err := d.getChar(musicAlbumChar)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
_, err = char.WriteWithoutResponse([]byte(album))
|
||||
return err
|
||||
}
|
||||
|
||||
// WatchMusicEvents calls fn whenever the InfiniTime music app broadcasts an event.
|
||||
func (d *Device) WatchMusicEvents(ctx context.Context, fn func(event MusicEvent, err error)) error {
|
||||
return watchChar(ctx, d, musicEventChar, fn)
|
||||
}
|
||||
137
infinitime/navigation.go
Normal file
137
infinitime/navigation.go
Normal file
@@ -0,0 +1,137 @@
|
||||
package infinitime
|
||||
|
||||
type NavFlag string
|
||||
|
||||
const (
|
||||
NavFlagArrive NavFlag = "arrive"
|
||||
NavFlagArriveLeft NavFlag = "arrive-left"
|
||||
NavFlagArriveRight NavFlag = "arrive-right"
|
||||
NavFlagArriveStraight NavFlag = "arrive-straight"
|
||||
NavFlagClose NavFlag = "close"
|
||||
NavFlagContinue NavFlag = "continue"
|
||||
NavFlagContinueLeft NavFlag = "continue-left"
|
||||
NavFlagContinueRight NavFlag = "continue-right"
|
||||
NavFlagContinueSlightLeft NavFlag = "continue-slight-left"
|
||||
NavFlagContinueSlightRight NavFlag = "continue-slight-right"
|
||||
NavFlagContinueStraight NavFlag = "continue-straight"
|
||||
NavFlagContinueUturn NavFlag = "continue-uturn"
|
||||
NavFlagDepart NavFlag = "depart"
|
||||
NavFlagDepartLeft NavFlag = "depart-left"
|
||||
NavFlagDepartRight NavFlag = "depart-right"
|
||||
NavFlagDepartStraight NavFlag = "depart-straight"
|
||||
NavFlagEndOfRoadLeft NavFlag = "end-of-road-left"
|
||||
NavFlagEndOfRoadRight NavFlag = "end-of-road-right"
|
||||
NavFlagFerry NavFlag = "ferry"
|
||||
NavFlagFlag NavFlag = "flag"
|
||||
NavFlagFork NavFlag = "fork"
|
||||
NavFlagForkLeft NavFlag = "fork-left"
|
||||
NavFlagForkRight NavFlag = "fork-right"
|
||||
NavFlagForkSlightLeft NavFlag = "fork-slight-left"
|
||||
NavFlagForkSlightRight NavFlag = "fork-slight-right"
|
||||
NavFlagForkStraight NavFlag = "fork-straight"
|
||||
NavFlagInvalid NavFlag = "invalid"
|
||||
NavFlagInvalidLeft NavFlag = "invalid-left"
|
||||
NavFlagInvalidRight NavFlag = "invalid-right"
|
||||
NavFlagInvalidSlightLeft NavFlag = "invalid-slight-left"
|
||||
NavFlagInvalidSlightRight NavFlag = "invalid-slight-right"
|
||||
NavFlagInvalidStraight NavFlag = "invalid-straight"
|
||||
NavFlagInvalidUturn NavFlag = "invalid-uturn"
|
||||
NavFlagMergeLeft NavFlag = "merge-left"
|
||||
NavFlagMergeRight NavFlag = "merge-right"
|
||||
NavFlagMergeSlightLeft NavFlag = "merge-slight-left"
|
||||
NavFlagMergeSlightRight NavFlag = "merge-slight-right"
|
||||
NavFlagMergeStraight NavFlag = "merge-straight"
|
||||
NavFlagNewNameLeft NavFlag = "new-name-left"
|
||||
NavFlagNewNameRight NavFlag = "new-name-right"
|
||||
NavFlagNewNameSharpLeft NavFlag = "new-name-sharp-left"
|
||||
NavFlagNewNameSharpRight NavFlag = "new-name-sharp-right"
|
||||
NavFlagNewNameSlightLeft NavFlag = "new-name-slight-left"
|
||||
NavFlagNewNameSlightRight NavFlag = "new-name-slight-right"
|
||||
NavFlagNewNameStraight NavFlag = "new-name-straight"
|
||||
NavFlagNotificationLeft NavFlag = "notification-left"
|
||||
NavFlagNotificationRight NavFlag = "notification-right"
|
||||
NavFlagNotificationSharpLeft NavFlag = "notification-sharp-left"
|
||||
NavFlagNotificationSharpRight NavFlag = "notification-sharp-right"
|
||||
NavFlagNotificationSlightLeft NavFlag = "notification-slight-left"
|
||||
NavFlagNotificationSlightRight NavFlag = "notification-slight-right"
|
||||
NavFlagNotificationStraight NavFlag = "notification-straight"
|
||||
NavFlagOffRampLeft NavFlag = "off-ramp-left"
|
||||
NavFlagOffRampRight NavFlag = "off-ramp-right"
|
||||
NavFlagOffRampSharpLeft NavFlag = "off-ramp-sharp-left"
|
||||
NavFlagOffRampSharpRight NavFlag = "off-ramp-sharp-right"
|
||||
NavFlagOffRampSlightLeft NavFlag = "off-ramp-slight-left"
|
||||
NavFlagOffRampSlightRight NavFlag = "off-ramp-slight-right"
|
||||
NavFlagOffRampStraight NavFlag = "off-ramp-straight"
|
||||
NavFlagOnRampLeft NavFlag = "on-ramp-left"
|
||||
NavFlagOnRampRight NavFlag = "on-ramp-right"
|
||||
NavFlagOnRampSharpLeft NavFlag = "on-ramp-sharp-left"
|
||||
NavFlagOnRampSharpRight NavFlag = "on-ramp-sharp-right"
|
||||
NavFlagOnRampSlightLeft NavFlag = "on-ramp-slight-left"
|
||||
NavFlagOnRampSlightRight NavFlag = "on-ramp-slight-right"
|
||||
NavFlagOnRampStraight NavFlag = "on-ramp-straight"
|
||||
NavFlagRotary NavFlag = "rotary"
|
||||
NavFlagRotaryLeft NavFlag = "rotary-left"
|
||||
NavFlagRotaryRight NavFlag = "rotary-right"
|
||||
NavFlagRotarySharpLeft NavFlag = "rotary-sharp-left"
|
||||
NavFlagRotarySharpRight NavFlag = "rotary-sharp-right"
|
||||
NavFlagRotarySlightLeft NavFlag = "rotary-slight-left"
|
||||
NavFlagRotarySlightRight NavFlag = "rotary-slight-right"
|
||||
NavFlagRotaryStraight NavFlag = "rotary-straight"
|
||||
NavFlagRoundabout NavFlag = "roundabout"
|
||||
NavFlagRoundaboutLeft NavFlag = "roundabout-left"
|
||||
NavFlagRoundaboutRight NavFlag = "roundabout-right"
|
||||
NavFlagRoundaboutSharpLeft NavFlag = "roundabout-sharp-left"
|
||||
NavFlagRoundaboutSharpRight NavFlag = "roundabout-sharp-right"
|
||||
NavFlagRoundaboutSlightLeft NavFlag = "roundabout-slight-left"
|
||||
NavFlagRoundaboutSlightRight NavFlag = "roundabout-slight-right"
|
||||
NavFlagRoundaboutStraight NavFlag = "roundabout-straight"
|
||||
NavFlagTurnLeft NavFlag = "turn-left"
|
||||
NavFlagTurnRight NavFlag = "turn-right"
|
||||
NavFlagTurnSharpLeft NavFlag = "turn-sharp-left"
|
||||
NavFlagTurnSharpRight NavFlag = "turn-sharp-right"
|
||||
NavFlagTurnSlightLeft NavFlag = "turn-slight-left"
|
||||
NavFlagTurnSlightRight NavFlag = "turn-slight-right"
|
||||
NavFlagTurnStraight NavFlag = "turn-straight"
|
||||
NavFlagUpDown NavFlag = "updown"
|
||||
NavFlagUTurn NavFlag = "uturn"
|
||||
)
|
||||
|
||||
// SetNavFlag sets the navigation flag icon.
|
||||
func (d *Device) SetNavFlag(flag NavFlag) error {
|
||||
char, err := d.getChar(navigationFlagsChar)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = char.WriteWithoutResponse([]byte(flag))
|
||||
return err
|
||||
}
|
||||
|
||||
// SetNavNarrative sets the navigation narrative string.
|
||||
func (d *Device) SetNavNarrative(narrative string) error {
|
||||
char, err := d.getChar(navigationNarrativeChar)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = char.WriteWithoutResponse([]byte(narrative))
|
||||
return err
|
||||
}
|
||||
|
||||
// SetNavManeuverDistance sets the navigation maneuver distance.
|
||||
func (d *Device) SetNavManeuverDistance(manDist string) error {
|
||||
char, err := d.getChar(navigationManDist)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = char.WriteWithoutResponse([]byte(manDist))
|
||||
return err
|
||||
}
|
||||
|
||||
// SetNavProgress sets the navigation progress.
|
||||
func (d *Device) SetNavProgress(progress uint8) error {
|
||||
char, err := d.getChar(navigationProgress)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = char.WriteWithoutResponse([]byte{progress})
|
||||
return err
|
||||
}
|
||||
42
infinitime/notifs.go
Normal file
42
infinitime/notifs.go
Normal file
@@ -0,0 +1,42 @@
|
||||
package infinitime
|
||||
|
||||
var (
|
||||
regularNotifHeader = []byte{0x00, 0x01, 0x00}
|
||||
callNotifHeader = []byte{0x03, 0x01, 0x00}
|
||||
)
|
||||
|
||||
// Notify sends a notification to the PineTime using the Alert Notification Service
|
||||
func (d *Device) Notify(title, body string) error {
|
||||
c, err := d.getChar(newAlertChar)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
content := title + "\x00" + body
|
||||
_, err = c.WriteWithoutResponse(append(regularNotifHeader, content...))
|
||||
return err
|
||||
}
|
||||
|
||||
type CallStatus uint8
|
||||
|
||||
const (
|
||||
CallStatusDeclined CallStatus = iota
|
||||
CallStatusAccepted
|
||||
CallStatusMuted
|
||||
)
|
||||
|
||||
// NotifyCall sends a call to the PineTime using the Alert Notification Service,
|
||||
// then executes fn once the user presses a button on the watch.
|
||||
func (d *Device) NotifyCall(from string, fn func(CallStatus)) error {
|
||||
c, err := d.getChar(newAlertChar)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
_, err = c.WriteWithoutResponse(append(callNotifHeader, from...))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return watchCharOnce(d, notifEventChar, fn)
|
||||
}
|
||||
135
infinitime/resources.go
Normal file
135
infinitime/resources.go
Normal file
@@ -0,0 +1,135 @@
|
||||
package infinitime
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
type ResourceOperation int
|
||||
|
||||
const (
|
||||
// ResourceUpload represents the upload phase
|
||||
// of resource loading
|
||||
ResourceUpload = iota
|
||||
// ResourceRemove represents the obsolete
|
||||
// file removal phase of resource loading
|
||||
ResourceRemove
|
||||
)
|
||||
|
||||
// resourceManifest is the structure of the resource manifest file
|
||||
type resourceManifest struct {
|
||||
Resources []resource `json:"resources"`
|
||||
Obsolete []obsoleteResource `json:"obsolete_files"`
|
||||
}
|
||||
|
||||
// resource represents a resource entry in the manifest
|
||||
type resource struct {
|
||||
Name string `json:"filename"`
|
||||
Path string `json:"path"`
|
||||
}
|
||||
|
||||
// obsoleteResource represents an obsolete file entry in the manifest
|
||||
type obsoleteResource struct {
|
||||
Path string `json:"path"`
|
||||
Since string `json:"since"`
|
||||
}
|
||||
|
||||
// ResourceLoadProgress contains information on the progress of
|
||||
// a resource load
|
||||
type ResourceLoadProgress struct {
|
||||
Operation ResourceOperation
|
||||
Name string
|
||||
Total uint32
|
||||
Transferred uint32
|
||||
}
|
||||
|
||||
// LoadResources accepts the path of an InfiniTime resource archive and loads its contents to the watch's filesystem.
|
||||
func LoadResources(archivePath string, fs *FS, progress func(ResourceLoadProgress)) error {
|
||||
r, err := zip.OpenReader(archivePath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer r.Close()
|
||||
|
||||
manifestFl, err := r.Open("resources.json")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var manifest resourceManifest
|
||||
err = json.NewDecoder(manifestFl).Decode(&manifest)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
err = manifestFl.Close()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, file := range manifest.Obsolete {
|
||||
err := fs.RemoveAll(file.Path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
progress(ResourceLoadProgress{
|
||||
Operation: ResourceRemove,
|
||||
Name: filepath.Base(file.Path),
|
||||
})
|
||||
}
|
||||
|
||||
for _, file := range manifest.Resources {
|
||||
src, err := r.Open(file.Name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
fi, err := src.Stat()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
err = fs.MkdirAll(filepath.Dir(file.Path))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
dst, err := fs.Create(file.Path, uint32(fi.Size()))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
dst.ProgressFunc = func(transferred, total uint32) {
|
||||
progress(ResourceLoadProgress{
|
||||
Name: file.Name,
|
||||
Transferred: transferred,
|
||||
Total: total,
|
||||
})
|
||||
}
|
||||
|
||||
_, err = io.Copy(dst, src)
|
||||
if err != nil {
|
||||
return errors.Join(
|
||||
err,
|
||||
src.Close(),
|
||||
dst.Close(),
|
||||
)
|
||||
}
|
||||
|
||||
err = src.Close()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
err = dst.Close()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
58
infinitime/time.go
Normal file
58
infinitime/time.go
Normal file
@@ -0,0 +1,58 @@
|
||||
package infinitime
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/binary"
|
||||
"time"
|
||||
)
|
||||
|
||||
// SetTime sets the current time, and then sets the timezone data,
|
||||
// if the local time characteristic is available.
|
||||
func (d *Device) SetTime(t time.Time) error {
|
||||
c, err := d.getChar(currentTimeChar)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
buf := &bytes.Buffer{}
|
||||
binary.Write(buf, binary.LittleEndian, uint16(t.Year()))
|
||||
binary.Write(buf, binary.LittleEndian, uint8(t.Month()))
|
||||
binary.Write(buf, binary.LittleEndian, uint8(t.Day()))
|
||||
binary.Write(buf, binary.LittleEndian, uint8(t.Hour()))
|
||||
binary.Write(buf, binary.LittleEndian, uint8(t.Minute()))
|
||||
binary.Write(buf, binary.LittleEndian, uint8(t.Second()))
|
||||
binary.Write(buf, binary.LittleEndian, uint8(t.Weekday()))
|
||||
binary.Write(buf, binary.LittleEndian, uint8((t.Nanosecond()/1000)/1e6*256))
|
||||
binary.Write(buf, binary.LittleEndian, uint8(0b0001))
|
||||
|
||||
_, err = c.WriteWithoutResponse(buf.Bytes())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
ltc, err := d.getChar(localTimeChar)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
_, offset := t.Zone()
|
||||
dst := 0
|
||||
|
||||
// Local time expects two values: the timezone offset and the dst offset, both
|
||||
// expressed in quarters of an hour.
|
||||
// Timezone offset is to be constant over DST, with dst offset holding the offset != 0
|
||||
// when DST is in effect.
|
||||
// As there is no standard way in go to get the actual dst offset, we assume it to be 1h
|
||||
// when DST is in effect
|
||||
if t.IsDST() {
|
||||
dst = 3600
|
||||
offset -= 3600
|
||||
}
|
||||
|
||||
buf.Reset()
|
||||
binary.Write(buf, binary.LittleEndian, uint8(offset/3600*4))
|
||||
binary.Write(buf, binary.LittleEndian, uint8(dst/3600*4))
|
||||
|
||||
_, err = ltc.WriteWithoutResponse(buf.Bytes())
|
||||
return err
|
||||
}
|
||||
108
infinitime/watch.go
Normal file
108
infinitime/watch.go
Normal file
@@ -0,0 +1,108 @@
|
||||
package infinitime
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/binary"
|
||||
"sync"
|
||||
|
||||
"tinygo.org/x/bluetooth"
|
||||
)
|
||||
|
||||
type notifier interface {
|
||||
notify([]byte)
|
||||
}
|
||||
|
||||
type watcher[T any] struct {
|
||||
mu sync.Mutex
|
||||
nextFuncID int
|
||||
callbacks map[int]func(T, error)
|
||||
char *bluetooth.DeviceCharacteristic
|
||||
}
|
||||
|
||||
func (w *watcher[T]) addCallback(fn func(T, error)) int {
|
||||
w.mu.Lock()
|
||||
defer w.mu.Unlock()
|
||||
funcID := w.nextFuncID
|
||||
w.callbacks[funcID] = fn
|
||||
w.nextFuncID++
|
||||
return funcID
|
||||
}
|
||||
|
||||
func (w *watcher[T]) notify(b []byte) {
|
||||
var val T
|
||||
err := binary.Read(bytes.NewReader(b), binary.LittleEndian, &val)
|
||||
w.mu.Lock()
|
||||
for _, fn := range w.callbacks {
|
||||
go fn(val, err)
|
||||
}
|
||||
w.mu.Unlock()
|
||||
}
|
||||
|
||||
func (w *watcher[T]) cancelFn(d *Device, ch btChar, id int) func() {
|
||||
return func() {
|
||||
w.mu.Lock()
|
||||
delete(w.callbacks, id)
|
||||
w.mu.Unlock()
|
||||
|
||||
if len(w.callbacks) == 0 {
|
||||
d.notifierMtx.Lock()
|
||||
delete(d.notifierMap, ch)
|
||||
d.notifierMtx.Unlock()
|
||||
w.char.EnableNotifications(nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func watchChar[T any](ctx context.Context, d *Device, ch btChar, fn func(T, error)) error {
|
||||
d.notifierMtx.Lock()
|
||||
defer d.notifierMtx.Unlock()
|
||||
|
||||
if n, ok := d.notifierMap[ch]; ok {
|
||||
w := n.(*watcher[T])
|
||||
funcID := w.addCallback(fn)
|
||||
context.AfterFunc(ctx, w.cancelFn(d, ch, funcID))
|
||||
go func() {
|
||||
<-ctx.Done()
|
||||
w.cancelFn(d, ch, funcID)()
|
||||
}()
|
||||
return nil
|
||||
} else {
|
||||
c, err := d.getChar(ch)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
w := &watcher[T]{callbacks: map[int]func(T, error){}}
|
||||
err = c.EnableNotifications(w.notify)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
w.char = c
|
||||
funcID := w.addCallback(fn)
|
||||
d.notifierMap[ch] = w
|
||||
|
||||
context.AfterFunc(ctx, w.cancelFn(d, ch, funcID))
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func watchCharOnce[T any](d *Device, ch btChar, fn func(T)) error {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
|
||||
var watchErr error
|
||||
err := watchChar(ctx, d, ch, func(val T, err error) {
|
||||
defer cancel()
|
||||
if err != nil {
|
||||
watchErr = err
|
||||
return
|
||||
}
|
||||
fn(val)
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
<-ctx.Done()
|
||||
return watchErr
|
||||
}
|
||||
124
infinitime/weather.go
Normal file
124
infinitime/weather.go
Normal file
@@ -0,0 +1,124 @@
|
||||
package infinitime
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/binary"
|
||||
"errors"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
weatherVersion = 0
|
||||
|
||||
currentWeatherType = 0
|
||||
forecastWeatherType = 1
|
||||
)
|
||||
|
||||
type WeatherIcon uint8
|
||||
|
||||
const (
|
||||
WeatherIconClear WeatherIcon = iota
|
||||
WeatherIconFewClouds
|
||||
WeatherIconClouds
|
||||
WeatherIconHeavyClouds
|
||||
WeatherIconCloudsWithRain
|
||||
WeatherIconRain
|
||||
WeatherIconThunderstorm
|
||||
WeatherIconSnow
|
||||
WeatherIconMist
|
||||
)
|
||||
|
||||
// CurrentWeather represents the current weather
|
||||
type CurrentWeather struct {
|
||||
Time time.Time
|
||||
CurrentTemp float32
|
||||
MinTemp float32
|
||||
MaxTemp float32
|
||||
Location string
|
||||
Icon WeatherIcon
|
||||
}
|
||||
|
||||
// Bytes returns the [CurrentWeather] struct encoded using the InfiniTime
|
||||
// weather wire protocol.
|
||||
func (cw CurrentWeather) Bytes() []byte {
|
||||
buf := &bytes.Buffer{}
|
||||
|
||||
buf.WriteByte(currentWeatherType)
|
||||
buf.WriteByte(weatherVersion)
|
||||
|
||||
_, offset := cw.Time.Zone()
|
||||
binary.Write(buf, binary.LittleEndian, cw.Time.Unix()+int64(offset))
|
||||
|
||||
binary.Write(buf, binary.LittleEndian, int16(cw.CurrentTemp*100))
|
||||
binary.Write(buf, binary.LittleEndian, int16(cw.MinTemp*100))
|
||||
binary.Write(buf, binary.LittleEndian, int16(cw.MaxTemp*100))
|
||||
|
||||
location := make([]byte, 32)
|
||||
copy(location, cw.Location)
|
||||
buf.Write(location)
|
||||
|
||||
buf.WriteByte(byte(cw.Icon))
|
||||
|
||||
return buf.Bytes()
|
||||
}
|
||||
|
||||
// Forecast represents a weather forecast
|
||||
type Forecast struct {
|
||||
Time time.Time
|
||||
Days []ForecastDay
|
||||
}
|
||||
|
||||
// ForecastDay represents a forecast for a single day
|
||||
type ForecastDay struct {
|
||||
MinTemp int16
|
||||
MaxTemp int16
|
||||
Icon WeatherIcon
|
||||
}
|
||||
|
||||
// Bytes returns the [Forecast] struct encoded using the InfiniTime
|
||||
// weather wire protocol.
|
||||
func (f Forecast) Bytes() []byte {
|
||||
buf := &bytes.Buffer{}
|
||||
|
||||
buf.WriteByte(forecastWeatherType)
|
||||
buf.WriteByte(weatherVersion)
|
||||
|
||||
_, offset := f.Time.Zone()
|
||||
binary.Write(buf, binary.LittleEndian, f.Time.Unix()+int64(offset))
|
||||
|
||||
buf.WriteByte(uint8(len(f.Days)))
|
||||
|
||||
for _, day := range f.Days {
|
||||
binary.Write(buf, binary.LittleEndian, day.MinTemp*100)
|
||||
binary.Write(buf, binary.LittleEndian, day.MaxTemp*100)
|
||||
buf.WriteByte(byte(day.Icon))
|
||||
}
|
||||
|
||||
return buf.Bytes()
|
||||
}
|
||||
|
||||
// SetCurrentWeather updates the current weather data on the PineTime
|
||||
func (d *Device) SetCurrentWeather(cw CurrentWeather) error {
|
||||
c, err := d.getChar(weatherDataChar)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
_, err = c.WriteWithoutResponse(cw.Bytes())
|
||||
return err
|
||||
}
|
||||
|
||||
// SetForecast sets future forecast data on the PineTime
|
||||
func (d *Device) SetForecast(f Forecast) error {
|
||||
c, err := d.getChar(weatherDataChar)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if len(f.Days) > 5 {
|
||||
return errors.New("amount of forecast days exceeds maximum of 5")
|
||||
}
|
||||
|
||||
_, err = c.WriteWithoutResponse(f.Bytes())
|
||||
return err
|
||||
}
|
||||
Reference in New Issue
Block a user