From 7e68d5541c64b75a1833a52c9f014a70455fc076 Mon Sep 17 00:00:00 2001 From: Elara6331 Date: Sat, 13 Apr 2024 21:20:12 -0700 Subject: [PATCH] Add rewritten infinitime abstraction and integrate it into ITD --- api/resources.go | 8 +- calls.go | 47 ++- cmd/itctl/resources.go | 4 +- cmd/itgui/fs.go | 6 +- fuse.go | 2 +- go.mod | 15 +- go.sum | 34 +- infinitime/chars.go | 142 ++++++++ infinitime/dfu.go | 222 +++++++++++++ infinitime/fs.go | 601 ++++++++++++++++++++++++++++++++++ infinitime/fstypes.go | 142 ++++++++ infinitime/infinitime.go | 173 ++++++++++ infinitime/info.go | 101 ++++++ infinitime/music.go | 68 ++++ infinitime/navigation.go | 137 ++++++++ infinitime/notifs.go | 42 +++ infinitime/resources.go | 135 ++++++++ infinitime/time.go | 58 ++++ infinitime/watch.go | 108 ++++++ infinitime/weather.go | 124 +++++++ internal/fsproto/errors.go | 61 ++++ internal/fsproto/fsproto.go | 212 ++++++++++++ internal/fusefs/fuse.go | 27 +- internal/fusefs/syscallerr.go | 28 +- main.go | 66 ++-- maps.go | 18 +- metrics.go | 119 ++++--- music.go | 60 ++-- notifs.go | 2 +- socket.go | 336 +++++++++++-------- weather.go | 134 ++------ 31 files changed, 2758 insertions(+), 474 deletions(-) create mode 100644 infinitime/chars.go create mode 100644 infinitime/dfu.go create mode 100644 infinitime/fs.go create mode 100644 infinitime/fstypes.go create mode 100644 infinitime/infinitime.go create mode 100644 infinitime/info.go create mode 100644 infinitime/music.go create mode 100644 infinitime/navigation.go create mode 100644 infinitime/notifs.go create mode 100644 infinitime/resources.go create mode 100644 infinitime/time.go create mode 100644 infinitime/watch.go create mode 100644 infinitime/weather.go create mode 100644 internal/fsproto/errors.go create mode 100644 internal/fsproto/fsproto.go diff --git a/api/resources.go b/api/resources.go index 14ef8a7..0c48fa3 100644 --- a/api/resources.go +++ b/api/resources.go @@ -3,15 +3,15 @@ package api import ( "context" - "go.elara.ws/infinitime" + "go.elara.ws/itd/infinitime" "go.elara.ws/itd/internal/rpc" ) -type ResourceOperation uint8 +type ResourceOperation infinitime.ResourceOperation const ( - ResourceOperationRemoveObsolete = infinitime.ResourceOperationRemoveObsolete - ResourceOperationUpload = infinitime.ResourceOperationUpload + ResourceRemove = infinitime.ResourceRemove + ResourceUpload = infinitime.ResourceUpload ) type ResourceLoadProgress struct { diff --git a/calls.go b/calls.go index ad2bc46..6dfd4d3 100644 --- a/calls.go +++ b/calls.go @@ -2,10 +2,9 @@ package main import ( "context" - "sync" "github.com/godbus/dbus/v5" - "go.elara.ws/infinitime" + "go.elara.ws/itd/infinitime" "go.elara.ws/itd/internal/utils" "go.elara.ws/logger/log" ) @@ -50,7 +49,6 @@ func initCallNotifs(ctx context.Context, wg WaitGroup, dev *infinitime.Device) e // Notify channel upon received message monitorConn.Eavesdrop(callCh) - var respHandlerOnce sync.Once var callObj dbus.BusObject wg.Add(1) @@ -83,33 +81,28 @@ func initCallNotifs(ctx context.Context, wg WaitGroup, dev *infinitime.Device) e } // Send call notification to InfiniTime - resCh, err := dev.NotifyCall(phoneNum) + err = dev.NotifyCall(phoneNum, func(cs infinitime.CallStatus) { + switch cs { + case infinitime.CallStatusAccepted: + // Attempt to accept call + err = acceptCall(ctx, conn, callObj) + if err != nil { + log.Warn("Error accepting call").Err(err).Send() + } + case infinitime.CallStatusDeclined: + // Attempt to decline call + err = declineCall(ctx, conn, callObj) + if err != nil { + log.Warn("Error declining call").Err(err).Send() + } + case infinitime.CallStatusMuted: + // Warn about unimplemented muting + log.Warn("Muting calls is not implemented").Send() + } + }) if err != nil { continue } - - go respHandlerOnce.Do(func() { - // Wait for PineTime response - for res := range resCh { - switch res { - case infinitime.CallStatusAccepted: - // Attempt to accept call - err = acceptCall(ctx, conn, callObj) - if err != nil { - log.Warn("Error accepting call").Err(err).Send() - } - case infinitime.CallStatusDeclined: - // Attempt to decline call - err = declineCall(ctx, conn, callObj) - if err != nil { - log.Warn("Error declining call").Err(err).Send() - } - case infinitime.CallStatusMuted: - // Warn about unimplemented muting - log.Warn("Muting calls is not implemented").Send() - } - } - }) case <-ctx.Done(): return } diff --git a/cmd/itctl/resources.go b/cmd/itctl/resources.go index 93f2933..2f0364a 100644 --- a/cmd/itctl/resources.go +++ b/cmd/itctl/resources.go @@ -6,7 +6,7 @@ import ( "github.com/cheggaaa/pb/v3" "github.com/urfave/cli/v2" - "go.elara.ws/infinitime" + "go.elara.ws/itd/infinitime" ) func resourcesLoad(c *cli.Context) error { @@ -39,7 +39,7 @@ func resLoad(ctx context.Context, args []string) error { return evt.Err } - if evt.Operation == infinitime.ResourceOperationRemoveObsolete { + if evt.Operation == infinitime.ResourceRemove { bar.SetTemplateString(rmTmpl) bar.Set("filename", evt.Name) } else { diff --git a/cmd/itgui/fs.go b/cmd/itgui/fs.go index 8cb3325..1d7f3e5 100644 --- a/cmd/itgui/fs.go +++ b/cmd/itgui/fs.go @@ -11,8 +11,8 @@ import ( "fyne.io/fyne/v2/storage" "fyne.io/fyne/v2/theme" "fyne.io/fyne/v2/widget" - "go.elara.ws/infinitime" "go.elara.ws/itd/api" + "go.elara.ws/itd/infinitime" ) func fsTab(ctx context.Context, client *api.Client, w fyne.Window, opened chan struct{}) fyne.CanvasObject { @@ -77,9 +77,9 @@ func fsTab(ctx context.Context, client *api.Client, w fyne.Window, opened chan s for evt := range progCh { switch evt.Operation { - case infinitime.ResourceOperationRemoveObsolete: + case infinitime.ResourceRemove: progressDlg.SetText("Removing " + evt.Name) - case infinitime.ResourceOperationUpload: + case infinitime.ResourceUpload: progressDlg.SetText("Uploading " + evt.Name) progressDlg.SetTotal(float64(evt.Total)) progressDlg.SetValue(float64(evt.Sent)) diff --git a/fuse.go b/fuse.go index 1e9aa1d..205a875 100644 --- a/fuse.go +++ b/fuse.go @@ -6,7 +6,7 @@ import ( "github.com/hanwen/go-fuse/v2/fs" "github.com/hanwen/go-fuse/v2/fuse" - "go.elara.ws/infinitime" + "go.elara.ws/itd/infinitime" "go.elara.ws/itd/internal/fusefs" "go.elara.ws/logger/log" ) diff --git a/go.mod b/go.mod index 91d33b2..e9f8e59 100644 --- a/go.mod +++ b/go.mod @@ -4,6 +4,8 @@ go 1.18 replace fyne.io/x/fyne => github.com/metal3d/fyne-x v0.0.0-20220508095732-177117e583fb +replace tinygo.org/x/bluetooth => github.com/elara6331/bluetooth v0.9.1-0.20240413234149-a0e71474a768 + require ( fyne.io/fyne/v2 v2.3.0 fyne.io/x/fyne v0.0.0-20220107050838-c4a1de51d4ce @@ -16,12 +18,12 @@ require ( github.com/mozillazg/go-pinyin v0.19.0 github.com/urfave/cli/v2 v2.23.7 go.elara.ws/drpc v0.0.0-20230421021209-fe4c05460a3d - go.elara.ws/infinitime v0.0.0-20240402045329-bd2aa32354bb go.elara.ws/logger v0.0.0-20230928062203-85e135cf02ae golang.org/x/text v0.5.0 google.golang.org/protobuf v1.28.1 modernc.org/sqlite v1.20.1 storj.io/drpc v0.0.32 + tinygo.org/x/bluetooth v0.9.0 ) require ( @@ -32,15 +34,14 @@ require ( github.com/davecgh/go-spew v1.1.1 // indirect github.com/dustin/go-humanize v1.0.0 // indirect github.com/fatih/color v1.13.0 // indirect - github.com/fatih/structs v1.1.0 // indirect github.com/fredbi/uri v1.0.0 // indirect github.com/fsnotify/fsnotify v1.6.0 // indirect - github.com/fxamacker/cbor/v2 v2.4.0 // indirect github.com/fyne-io/gl-js v0.0.0-20220802150000-8e339395f381 // indirect github.com/fyne-io/glfw-js v0.0.0-20220517201726-bebc2019cd33 // indirect github.com/fyne-io/image v0.0.0-20221020213044-f609c6a24345 // indirect github.com/go-gl/gl v0.0.0-20211210172815-726fda9656d6 // indirect github.com/go-gl/glfw/v3.3/glfw v0.0.0-20221017161538-93cebf72946b // indirect + github.com/go-ole/go-ole v1.2.6 // indirect github.com/go-text/typesetting v0.0.0-20221219135543-5d0d724ee181 // indirect github.com/goki/freetype v0.0.0-20220119013949-7a161fd3728c // indirect github.com/google/uuid v1.3.0 // indirect @@ -54,18 +55,18 @@ require ( github.com/mitchellh/copystructure v1.2.0 // indirect github.com/mitchellh/mapstructure v1.5.0 // indirect github.com/mitchellh/reflectwalk v1.0.2 // indirect - github.com/muka/go-bluetooth v0.0.0-20220819140550-1d8857e3b268 // indirect github.com/pelletier/go-toml v1.9.5 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect github.com/remyoudompheng/bigfft v0.0.0-20220927061507-ef77025ab5aa // indirect github.com/rivo/uniseg v0.4.3 // indirect github.com/russross/blackfriday/v2 v2.1.0 // indirect - github.com/sirupsen/logrus v1.9.0 // indirect + github.com/saltosystems/winrt-go v0.0.0-20240320113951-a2e4fc03f5f4 // indirect + github.com/sirupsen/logrus v1.9.3 // indirect github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c // indirect github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef // indirect github.com/stretchr/testify v1.8.1 // indirect github.com/tevino/abool v1.2.0 // indirect - github.com/x448/float16 v0.8.4 // indirect + github.com/tinygo-org/cbgo v0.0.4 // indirect github.com/xo/terminfo v0.0.0-20210125001918-ca9a967f8778 // indirect github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673 // indirect github.com/yuin/goldmark v1.5.3 // indirect @@ -74,7 +75,7 @@ require ( golang.org/x/mobile v0.0.0-20221110043201-43a038452099 // indirect golang.org/x/mod v0.7.0 // indirect golang.org/x/net v0.4.0 // indirect - golang.org/x/sys v0.6.0 // indirect + golang.org/x/sys v0.11.0 // indirect golang.org/x/tools v0.4.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect honnef.co/go/js/dom v0.0.0-20221001195520-26252dedbe70 // indirect diff --git a/go.sum b/go.sum index e2004b4..781ed95 100644 --- a/go.sum +++ b/go.sum @@ -107,6 +107,8 @@ github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSs github.com/dustin/go-humanize v1.0.0 h1:VSnTsYCnlFHaM2/igO1h6X3HA71jcobQuxemgkq4zYo= github.com/dustin/go-humanize v1.0.0/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk= github.com/eclipse/paho.mqtt.golang v1.3.5/go.mod h1:eTzb4gxwwyWpqBUHGQZ4ABAV7+Jgm1PklsYT/eo8Hcc= +github.com/elara6331/bluetooth v0.9.1-0.20240413234149-a0e71474a768 h1:iWP52WinMhd+pQB+2GedWvUxkd4pMqFvV0S6MjMFQSc= +github.com/elara6331/bluetooth v0.9.1-0.20240413234149-a0e71474a768/go.mod h1:V9XwH/xQ2SmCIW+T0pmpL7VzijY53JRVsJcDM0YN6PI= github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98= @@ -119,7 +121,6 @@ github.com/fatih/color v1.9.0/go.mod h1:eQcE1qtQxscV5RaZvpXrrb8Drkc3/DdQ+uUYCNjL github.com/fatih/color v1.10.0/go.mod h1:ELkj/draVOlAH/xkhN6mQ50Qd0MPOk5AAr3maGEBuJM= github.com/fatih/color v1.13.0 h1:8LOYc1KYPPmyKMuN8QV2DNRWNbLo6LZ0iLs8+mlH53w= github.com/fatih/color v1.13.0/go.mod h1:kLAiJbzzSOZDVNGyDpeOxJ47H46qBXwg5ILebYFFOfk= -github.com/fatih/structs v1.1.0 h1:Q7juDM0QtcnhCpeyLGQKyg4TOIghuNXrkL32pHAUMxo= github.com/fatih/structs v1.1.0/go.mod h1:9NiDSp5zOcgEDl+j00MP/WkGVPOlPRLejGD8Ga6PJ7M= github.com/fredbi/uri v0.0.0-20181227131451-3dcfdacbaaf3/go.mod h1:CzM2G82Q9BDUvMTGHnXf/6OExw/Dz2ivDj48nVg7Lg8= github.com/fredbi/uri v0.1.0/go.mod h1:1xC40RnIOGCaQzswaOvrzvG/3M3F0hyDVb3aO/1iGy0= @@ -129,8 +130,6 @@ github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4 github.com/fsnotify/fsnotify v1.5.4/go.mod h1:OVB6XrOHzAwXMpEM7uPOzcehqUV2UqJxmVXmkdnm1bU= github.com/fsnotify/fsnotify v1.6.0 h1:n+5WquG0fcWoWp6xPWfHdbskMCQaFnG6PfBrh1Ky4HY= github.com/fsnotify/fsnotify v1.6.0/go.mod h1:sl3t1tCWJFWoRz9R8WJCbQihKKwmorjAbSClcnxKAGw= -github.com/fxamacker/cbor/v2 v2.4.0 h1:ri0ArlOR+5XunOP8CRUowT0pSJOwhW098ZCUyskZD88= -github.com/fxamacker/cbor/v2 v2.4.0/go.mod h1:TA1xS00nchWmaBnEIxPSE5oHLuJBAVvqrtAnWBwBCVo= github.com/fyne-io/gl-js v0.0.0-20220119005834-d2da28d9ccfe/go.mod h1:d4clgH0/GrRwWjRzJJQXxT/h1TyuNSfF/X64zb/3Ggg= github.com/fyne-io/gl-js v0.0.0-20220802150000-8e339395f381 h1:SFtj9yo9C7F4CxyJeSJi9AjT6x9c88gnY1tjlXWh9QU= github.com/fyne-io/gl-js v0.0.0-20220802150000-8e339395f381/go.mod h1:d4clgH0/GrRwWjRzJJQXxT/h1TyuNSfF/X64zb/3Ggg= @@ -161,15 +160,14 @@ github.com/go-logfmt/logfmt v0.3.0/go.mod h1:Qt1PoO58o5twSAckw1HlFXLmHsOX5/0LbT9 github.com/go-logfmt/logfmt v0.4.0/go.mod h1:3RMwSq7FuexP4Kalkev3ejPJsZTpXXBr9+V4qmtdjCk= github.com/go-logfmt/logfmt v0.5.0/go.mod h1:wCYkCAKZfumFQihp8CzCvQ3paCTfi41vtzG1KdI/P7A= github.com/go-ole/go-ole v1.2.5/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0= +github.com/go-ole/go-ole v1.2.6 h1:/Fpf6oFPoeFik9ty7siob0G6Ke8QvQEuVcuChpwXzpY= github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0= github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY= github.com/go-test/deep v1.0.2-0.20181118220953-042da051cf31/go.mod h1:wGDj63lr65AM2AQyKZd/NYHGb0R+1RLqB8NKt3aSFNA= github.com/go-text/typesetting v0.0.0-20221212183139-1eb938670a1f/go.mod h1:/cmOXaoTiO+lbCwkTZBgCvevJpbFsZ5reXIpEJVh5MI= github.com/go-text/typesetting v0.0.0-20221219135543-5d0d724ee181 h1:J6XG/Xx7uCCpskM71R6YAgPHd/E8FzhyPhL6Ll94uMY= github.com/go-text/typesetting v0.0.0-20221219135543-5d0d724ee181/go.mod h1:/cmOXaoTiO+lbCwkTZBgCvevJpbFsZ5reXIpEJVh5MI= -github.com/godbus/dbus/v5 v5.0.3/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= -github.com/godbus/dbus/v5 v5.0.6/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk= github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ= @@ -240,7 +238,6 @@ github.com/google/pprof v0.0.0-20210122040257-d980be63207e/go.mod h1:kpwsk12EmLe github.com/google/pprof v0.0.0-20210226084205-cbba55b83ad5/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26 h1:Xim43kblpZXfIBQsbuBVKCudVG457BR2GZFIz3uw3hQ= github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI= -github.com/google/uuid v1.1.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I= github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= @@ -398,8 +395,6 @@ github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lN github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= github.com/mozillazg/go-pinyin v0.19.0 h1:p+J8/kjJ558KPvVGYLvqBhxf8jbZA2exSLCs2uUVN8c= github.com/mozillazg/go-pinyin v0.19.0/go.mod h1:iR4EnMMRXkfpFVV5FMi4FNB6wGq9NV6uDWbUuPhP4Yc= -github.com/muka/go-bluetooth v0.0.0-20220819140550-1d8857e3b268 h1:kOnq7TfaAO2Vc/MHxPqFIXe00y1qBxJAvhctXdko6vo= -github.com/muka/go-bluetooth v0.0.0-20220819140550-1d8857e3b268/go.mod h1:dMCjicU6vRBk34dqOmIZm0aod6gUwZXOXzBROqGous0= github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U= github.com/mwitkow/go-conntrack v0.0.0-20190716064945-2f068394615f/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U= github.com/neelance/astrewrite v0.0.0-20160511093645-99348263ae86/go.mod h1:kHJEU3ofeGjhHklVoIGuVj85JJwZ6kWPaJwCIxgnFmo= @@ -411,7 +406,6 @@ github.com/npillmayer/nestext v0.1.3/go.mod h1:h2lrijH8jpicr25dFY+oAJLyzlya6jhnu github.com/oklog/run v1.0.0/go.mod h1:dlhp/R75TPv97u0XWUtDeV/lRKWPKSdTuV0TZvrmrQA= github.com/pascaldekloe/goe v0.0.0-20180627143212-57f6aae5913c/go.mod h1:lzWF7FIEvWOWxwDKqyGYQf6ZUaNfKdP144TG7ZOy1lc= github.com/pascaldekloe/goe v0.1.0/go.mod h1:lzWF7FIEvWOWxwDKqyGYQf6ZUaNfKdP144TG7ZOy1lc= -github.com/paypal/gatt v0.0.0-20151011220935-4ae819d591cf/go.mod h1:+AwQL2mK3Pd3S+TUwg0tYQjid0q1txyNUJuuSmz8Kdk= github.com/pelletier/go-toml v1.7.0/go.mod h1:vwGMzjaWMwyfHwgIBhI2YUM4fB6nL6lVAvS1LBMMhTE= github.com/pelletier/go-toml v1.9.3/go.mod h1:u1nR/EPcESfeI/szUZKdtJ0xRNbUoANCkoOuaOx1Y+c= github.com/pelletier/go-toml v1.9.5 h1:4yBQzkHv+7BHq2PQUZF3Mx0IYxG7LsP222s7Agd3ve8= @@ -456,6 +450,8 @@ github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQD github.com/ryanuber/columnize v0.0.0-20160712163229-9b3edd62028f/go.mod h1:sm1tb6uqfes/u+d4ooFouqFdy9/2g9QGwK3SQygK0Ts= github.com/ryanuber/columnize v2.1.0+incompatible/go.mod h1:sm1tb6uqfes/u+d4ooFouqFdy9/2g9QGwK3SQygK0Ts= github.com/ryanuber/go-glob v1.0.0/go.mod h1:807d1WSdnB0XRJzKNil9Om6lcp/3a0v4qIHxIXzX/Yc= +github.com/saltosystems/winrt-go v0.0.0-20240320113951-a2e4fc03f5f4 h1:zurEWtOr/OYiTb5bcD7eeHLOfj6vCR30uldlwse1cSM= +github.com/saltosystems/winrt-go v0.0.0-20240320113951-a2e4fc03f5f4/go.mod h1:CIltaIm7qaANUIvzr0Vmz71lmQMAIbGJ7cvgzX7FMfA= github.com/sean-/seed v0.0.0-20170313163322-e2103e2c3529/go.mod h1:DxrIzT+xaE7yg65j358z/aeFdxmN0P9QXhEzd20vsDc= github.com/shurcooL/go v0.0.0-20200502201357-93f07166e636/go.mod h1:TDJrrUr11Vxrven61rcy3hJMUqaf/CLWYhHNPmT14Lk= github.com/shurcooL/httpfs v0.0.0-20190707220628-8d4bc4ba7749/go.mod h1:ZY1cvUeJuFPAdZ/B6v7RHavJWZn2YPVFQ1OSXhCGOkg= @@ -463,10 +459,11 @@ github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeV github.com/shurcooL/vfsgen v0.0.0-20200824052919-0d455de96546/go.mod h1:TrYk7fJVaAttu97ZZKrO9UbRa8izdowaMIZcxYMbVaw= github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo= github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE= +github.com/sirupsen/logrus v1.5.0/go.mod h1:+F7Ogzej0PZc/94MaYx/nvG9jOFMD2osvC3s+Squfpo= github.com/sirupsen/logrus v1.6.0/go.mod h1:7uNnSEd1DgxDLC74fIahvMZmmYsHGZGEOFrfsX/uA88= github.com/sirupsen/logrus v1.8.1/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0= -github.com/sirupsen/logrus v1.9.0 h1:trlNQbNUG3OdDrDil03MCb1H2o9nJ1x4/5LYw7byDE0= -github.com/sirupsen/logrus v1.9.0/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= +github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= +github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d/go.mod h1:OnSkiWE9lh6wB0YB77sQom3nweQdgAjqCqsofrRNTgc= github.com/smartystreets/goconvey v1.6.4/go.mod h1:syvi0/a8iFYH4r/RixwvyeAJjdLS9QV7WQ/tjFTllLA= github.com/spf13/afero v1.1.2/go.mod h1:j4pytiNVoe2o6bmDsKpLACNPDBIoEAkihy7loJ1B0CQ= @@ -501,17 +498,16 @@ github.com/stretchr/testify v1.7.2/go.mod h1:R6va5+xMeoiuVRoj+gSkQ7d3FALtqAAGI1F github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk= github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= -github.com/suapapa/go_eddystone v1.3.1/go.mod h1:bXC11TfJOS+3g3q/Uzd7FKd5g62STQEfeEIhcKe4Qy8= github.com/subosito/gotenv v1.2.0/go.mod h1:N0PQaV/YGNqwC0u51sEeR/aUtSLEXKX9iv69rRypqCw= github.com/tevino/abool v1.2.0 h1:heAkClL8H6w+mK5md9dzsuohKeXHUpY7Vw0ZCKW+huA= github.com/tevino/abool v1.2.0/go.mod h1:qc66Pna1RiIsPa7O4Egxxs9OqkuxDX55zznh9K07Tzg= +github.com/tinygo-org/cbgo v0.0.4 h1:3D76CRYbH03Rudi8sEgs/YO0x3JIMdyq8jlQtk/44fU= +github.com/tinygo-org/cbgo v0.0.4/go.mod h1:7+HgWIHd4nbAz0ESjGlJ1/v9LDU1Ox8MGzP9mah/fLk= github.com/urfave/cli/v2 v2.3.0/go.mod h1:LJmUH05zAU44vOAcrfzZQKsZbVcdbOG8rtL3/XcUArI= github.com/urfave/cli/v2 v2.4.0/go.mod h1:NX9W0zmTvedE5oDoOMs2RTC8RvdK98NTYZE5LbaEYPg= github.com/urfave/cli/v2 v2.23.7 h1:YHDQ46s3VghFHFf1DdF+Sh7H4RqhcM+t0TmZRJx4oJY= github.com/urfave/cli/v2 v2.23.7/go.mod h1:GHupkWPMM0M/sj1a2b4wUrWBPzazNrIjouW6fmdJLxc= github.com/wagslane/go-password-validator v0.3.0/go.mod h1:TI1XJ6T5fRdRnHqHt14pvy1tNVnrwe7m3/f1f2fDphQ= -github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= -github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= github.com/xo/terminfo v0.0.0-20210125001918-ca9a967f8778 h1:QldyIu/L63oPpyvQmHgvgickp1Yw510KJOqX7H24mg8= github.com/xo/terminfo v0.0.0-20210125001918-ca9a967f8778/go.mod h1:2MuV+tbUrU1zIOPMxZ5EncGwgmMJsa+9ucAQZXxsObs= github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673 h1:bAn7/zixMGCfxrRTfdpNzjtPYqr8smhKouy9mxVdGPU= @@ -531,8 +527,6 @@ github.com/zeebo/errs v1.3.0 h1:hmiaKqgYZzcVgRL1Vkc1Mn2914BbzB0IBxs+ebeutGs= github.com/zeebo/errs v1.3.0/go.mod h1:sgbWHsvVuTPHcqJJGQ1WhI5KbWlHYz+2+2C/LSEtCw4= go.elara.ws/drpc v0.0.0-20230421021209-fe4c05460a3d h1:ANb8YPtcxPipwKgmnW688e5PGpNaLh+22nO2LBpIPOU= go.elara.ws/drpc v0.0.0-20230421021209-fe4c05460a3d/go.mod h1:NDprjiVqKXQKVGzX7jp2g/jctsUbvOxz1nN15QOBEGk= -go.elara.ws/infinitime v0.0.0-20240402045329-bd2aa32354bb h1:nBxUp/6BhVv5NcQnXP4lY2ytkiQTCaG3P/TaQL2MVs8= -go.elara.ws/infinitime v0.0.0-20240402045329-bd2aa32354bb/go.mod h1:zJx0h0bWsz7DFDuF1jrXtGmMG4i4+iciOc8L2oawIHU= go.elara.ws/logger v0.0.0-20230928062203-85e135cf02ae h1:d+gJUhEWSrOjrrfgeydYWEr8TTnx0DLvcVhghaOsFeE= go.elara.ws/logger v0.0.0-20230928062203-85e135cf02ae/go.mod h1:qng49owViqsW5Aey93lwBXONw20oGbJIoLVscB16mPM= go.etcd.io/etcd/api/v3 v3.5.0/go.mod h1:cbVKeC6lCfl7j/8jBhAK6aIYO9XOjdptoxU/nLQcPvs= @@ -726,7 +720,6 @@ golang.org/x/sys v0.0.0-20200515095857-1151b9dac4a9/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20200523222454-059865788121/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200615200032-f1bc736245b1/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200625212154-ddb9806d33ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200728102440-3e129f6d46b1/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200803210538-64077c9b5642/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200905004654-be1d3432aa8f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= @@ -748,8 +741,6 @@ golang.org/x/sys v0.0.0-20210603081109-ebe580a85c40/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20220209214540-3681064d5158/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220412211240-33da011f77ad/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220503163025-988cb79eb6c6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= @@ -757,8 +748,8 @@ golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220908164124-27713097b956/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.6.0 h1:MVltZSvRTcU2ljQOhs94SXPftV6DCNnZViHeQps87pQ= -golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.11.0 h1:eG7RXZHdqOJ1i+0lgLgCpSXAp6M3LYlAo6osgSi0xOM= +golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= @@ -821,7 +812,6 @@ golang.org/x/tools v0.0.0-20200729194436-6467de6f59a7/go.mod h1:njjCfa9FT2d7l9Bc golang.org/x/tools v0.0.0-20200804011535-6c149bb5ef0d/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA= golang.org/x/tools v0.0.0-20200825202427-b303f430e36d/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA= golang.org/x/tools v0.0.0-20200904185747-39188db58858/go.mod h1:Cj7w3i3Rnn0Xh82ur9kSqwfTHTeVxaDqrfMjpcNT6bE= -golang.org/x/tools v0.0.0-20200925191224-5d1fdd8fa346/go.mod h1:z6u4i615ZeAfBE4XtMziQW1fSVJXACjjbWkB/mvPzlU= golang.org/x/tools v0.0.0-20201110124207-079ba7bd75cd/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= golang.org/x/tools v0.0.0-20201201161351-ac6f37ff4c2a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= golang.org/x/tools v0.0.0-20201208233053-a543418bbed2/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= diff --git a/infinitime/chars.go b/infinitime/chars.go new file mode 100644 index 0000000..efac8f5 --- /dev/null +++ b/infinitime/chars.go @@ -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 +} diff --git a/infinitime/dfu.go b/infinitime/dfu.go new file mode 100644 index 0000000..19836f7 --- /dev/null +++ b/infinitime/dfu.go @@ -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 +} diff --git a/infinitime/fs.go b/infinitime/fs.go new file mode 100644 index 0000000..6d6ec11 --- /dev/null +++ b/infinitime/fs.go @@ -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) +} diff --git a/infinitime/fstypes.go b/infinitime/fstypes.go new file mode 100644 index 0000000..1752b69 --- /dev/null +++ b/infinitime/fstypes.go @@ -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 +} diff --git a/infinitime/infinitime.go b/infinitime/infinitime.go new file mode 100644 index 0000000..ed3d146 --- /dev/null +++ b/infinitime/infinitime.go @@ -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 +} diff --git a/infinitime/info.go b/infinitime/info.go new file mode 100644 index 0000000..3e898d4 --- /dev/null +++ b/infinitime/info.go @@ -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) +} diff --git a/infinitime/music.go b/infinitime/music.go new file mode 100644 index 0000000..982e049 --- /dev/null +++ b/infinitime/music.go @@ -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) +} diff --git a/infinitime/navigation.go b/infinitime/navigation.go new file mode 100644 index 0000000..fca70ac --- /dev/null +++ b/infinitime/navigation.go @@ -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 +} diff --git a/infinitime/notifs.go b/infinitime/notifs.go new file mode 100644 index 0000000..3547c0f --- /dev/null +++ b/infinitime/notifs.go @@ -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) +} diff --git a/infinitime/resources.go b/infinitime/resources.go new file mode 100644 index 0000000..628e4c6 --- /dev/null +++ b/infinitime/resources.go @@ -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 +} diff --git a/infinitime/time.go b/infinitime/time.go new file mode 100644 index 0000000..bb081b6 --- /dev/null +++ b/infinitime/time.go @@ -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 +} diff --git a/infinitime/watch.go b/infinitime/watch.go new file mode 100644 index 0000000..fc87c02 --- /dev/null +++ b/infinitime/watch.go @@ -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 +} diff --git a/infinitime/weather.go b/infinitime/weather.go new file mode 100644 index 0000000..c7176b8 --- /dev/null +++ b/infinitime/weather.go @@ -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 +} diff --git a/internal/fsproto/errors.go b/internal/fsproto/errors.go new file mode 100644 index 0000000..9c91ba7 --- /dev/null +++ b/internal/fsproto/errors.go @@ -0,0 +1,61 @@ +package fsproto + +import ( + "errors" + "fmt" +) + +var ( + ErrFileNotExists = errors.New("file does not exist") + ErrFileReadOnly = errors.New("file is read only") + ErrFileWriteOnly = errors.New("file is write only") + ErrInvalidOffset = errors.New("offset out of range") + ErrNoRemoveRoot = errors.New("refusing to remove root directory") +) + +// Error represents an error returned by BLE FS +type Error struct { + Code int8 +} + +// Error returns the string associated with the error code +func (err Error) Error() string { + switch err.Code { + case 0x02: + return "filesystem error" + case 0x05: + return "read-only filesystem" + case 0x03: + return "no such file" + case 0x04: + return "protocol error" + case -5: + return "input/output error" + case -84: + return "filesystem is corrupted" + case -2: + return "no such directory entry" + case -17: + return "entry already exists" + case -20: + return "entry is not a directory" + case -39: + return "directory is not empty" + case -9: + return "bad file number" + case -27: + return "file is too large" + case -22: + return "invalid parameter" + case -28: + return "no space left on device" + case -12: + return "no more memory available" + case -61: + return "no attr available" + case -36: + return "file name is too long" + default: + return fmt.Sprintf("unknown error (code %d)", err.Code) + } +} diff --git a/internal/fsproto/fsproto.go b/internal/fsproto/fsproto.go new file mode 100644 index 0000000..4310fb3 --- /dev/null +++ b/internal/fsproto/fsproto.go @@ -0,0 +1,212 @@ +package fsproto + +import ( + "bytes" + "encoding/binary" + "errors" + "fmt" + "io" + "reflect" + + "tinygo.org/x/bluetooth" +) + +type FSReqOpcode uint8 + +const ( + ReadFileHeaderOpcode FSReqOpcode = 0x10 + ReadFileOpcode FSReqOpcode = 0x12 + WriteFileHeaderOpcode FSReqOpcode = 0x20 + WriteFileOpcode FSReqOpcode = 0x22 + DeleteFileOpcode FSReqOpcode = 0x30 + MakeDirectoryOpcode FSReqOpcode = 0x40 + ListDirectoryOpcode FSReqOpcode = 0x50 + MoveFileOpcode FSReqOpcode = 0x60 +) + +type FSRespOpcode uint8 + +const ( + ReadFileResp FSRespOpcode = 0x11 + WriteFileResp FSRespOpcode = 0x21 + DeleteFileResp FSRespOpcode = 0x31 + MakeDirectoryResp FSRespOpcode = 0x41 + ListDirectoryResp FSRespOpcode = 0x51 + MoveFileResp FSRespOpcode = 0x61 +) + +type ReadFileHeaderRequest struct { + Padding byte + PathLen uint16 + Offset uint32 + ReadLen uint32 + Path string +} + +type ReadFileRequest struct { + Status uint8 + Padding [2]byte + Offset uint32 + ReadLen uint32 +} + +type ReadFileResponse struct { + Status int8 + Padding [2]byte + Offset uint32 + FileSize uint32 + ChunkLen uint32 + Data []byte +} + +type WriteFileHeaderRequest struct { + Padding byte + PathLen uint16 + Offset uint32 + ModTime uint64 + FileSize uint32 + Path string +} + +type WriteFileRequest struct { + Status uint8 + Padding [2]byte + Offset uint32 + ChunkLen uint32 + Data []byte +} + +type WriteFileResponse struct { + Status int8 + Padding [2]byte + Offset uint32 + ModTime uint64 + FreeSpace uint32 +} + +type DeleteFileRequest struct { + Padding byte + PathLen uint16 + Path string +} + +type DeleteFileResponse struct { + Status int8 +} + +type MkdirRequest struct { + Padding byte + PathLen uint16 + Padding2 [4]byte + Timestamp uint64 + Path string +} + +type MkdirResponse struct { + Status int8 + Padding [6]byte + ModTime uint64 +} + +type ListDirRequest struct { + Padding byte + PathLen uint16 + Path string +} + +type ListDirResponse struct { + Status int8 + PathLen uint16 + EntryNum uint32 + TotalEntries uint32 + Flags uint32 + ModTime uint64 + FileSize uint32 + Path []byte +} + +type MoveFileRequest struct { + Padding byte + OldPathLen uint16 + NewPathLen uint16 + OldPath string + Padding2 byte + NewPath string +} + +type MoveFileResponse struct { + Status int8 +} + +func WriteRequest(char *bluetooth.DeviceCharacteristic, opcode FSReqOpcode, req any) error { + buf := &bytes.Buffer{} + buf.WriteByte(byte(opcode)) + + rv := reflect.ValueOf(req) + for rv.Kind() == reflect.Pointer { + rv = rv.Elem() + } + + for i := 0; i < rv.NumField(); i++ { + switch field := rv.Field(i); field.Kind() { + case reflect.String: + io.WriteString(buf, field.String()) + case reflect.Slice: + if field.Type().Elem().Kind() == reflect.Uint8 { + buf.Write(field.Bytes()) + } + default: + binary.Write(buf, binary.LittleEndian, field.Interface()) + } + } + + _, err := char.WriteWithoutResponse(buf.Bytes()) + return err +} + +func ReadResponse(b []byte, expect FSRespOpcode, out interface{}) error { + if len(b) == 0 { + return errors.New("empty response packet") + } + if opcode := FSRespOpcode(b[0]); opcode != expect { + return fmt.Errorf("unexpected response opcode: expected %x, got %x", expect, opcode) + } + + r := bytes.NewReader(b[1:]) + + ot := reflect.TypeOf(out) + if ot.Kind() != reflect.Ptr || ot.Elem().Kind() != reflect.Struct { + return errors.New("out parameter must be a pointer to a struct") + } + + ov := reflect.ValueOf(out).Elem() + for i := 0; i < ot.Elem().NumField(); i++ { + field := ot.Elem().Field(i) + fieldValue := ov.Field(i) + + // If the last field is a byte slice, just read the remaining data into it and return. + if i == ot.Elem().NumField()-1 { + if field.Type.Kind() == reflect.Slice && field.Type.Elem().Kind() == reflect.Uint8 { + data, err := io.ReadAll(r) + if err != nil { + return err + } + fieldValue.SetBytes(data) + return nil + } + } + + if err := binary.Read(r, binary.LittleEndian, fieldValue.Addr().Interface()); err != nil { + return err + } + } + + if statusField := ov.FieldByName("Status"); !statusField.IsZero() { + code := statusField.Interface().(int8) + if code != 0x01 { + return Error{code} + } + } + + return nil +} diff --git a/internal/fusefs/fuse.go b/internal/fusefs/fuse.go index f0d1420..c36abdc 100644 --- a/internal/fusefs/fuse.go +++ b/internal/fusefs/fuse.go @@ -9,8 +9,7 @@ import ( "github.com/hanwen/go-fuse/v2/fs" "github.com/hanwen/go-fuse/v2/fuse" - "go.elara.ws/infinitime" - "go.elara.ws/infinitime/blefs" + "go.elara.ws/itd/infinitime" "go.elara.ws/logger/log" ) @@ -47,14 +46,14 @@ const ( ) var ( - myfs *blefs.FS = nil + myfs *infinitime.FS = nil inodemap map[string]uint64 = nil ) func BuildRootNode(dev *infinitime.Device) (*ITNode, error) { var err error inodemap = make(map[string]uint64) - myfs, err = dev.FS() + myfs = dev.FS() if err != nil { log.Error("FUSE Failed to get filesystem").Err(err).Send() return nil, err @@ -343,13 +342,10 @@ func (fh *bytesFileWriteHandle) Flush(ctx context.Context) (errno syscall.Errno) } return 0 } - - go func() { - // For every progress event - for sent := range fp.Progress() { - log.Debug("FUSE Flush progress").Int("bytes", int(sent)).Int("total", len(fh.content)).Send() - } - }() + + fp.ProgressFunc = func(transferred, total uint32) { + log.Debug("FUSE Read progress").Uint32("bytes", transferred).Uint32("total", total).Send() + } r := bytes.NewReader(fh.content) nread, err := io.Copy(fp, r) @@ -430,12 +426,9 @@ func (f *ITNode) Open(ctx context.Context, openFlags uint32) (fh fs.FileHandle, b := &bytes.Buffer{} - go func() { - // For every progress event - for sent := range fp.Progress() { - log.Debug("FUSE Read progress").Int("bytes", int(sent)).Int("total", int(f.self.size)).Send() - } - }() + fp.ProgressFunc = func(transferred, total uint32) { + log.Debug("FUSE Read progress").Uint32("bytes", transferred).Uint32("total", total).Send() + } _, err = io.Copy(b, fp) if err != nil { diff --git a/internal/fusefs/syscallerr.go b/internal/fusefs/syscallerr.go index fcf8ccd..b7f20e6 100644 --- a/internal/fusefs/syscallerr.go +++ b/internal/fusefs/syscallerr.go @@ -3,7 +3,7 @@ package fusefs import ( "syscall" - "go.elara.ws/infinitime/blefs" + "go.elara.ws/itd/internal/fsproto" ) func syscallErr(err error) syscall.Errno { @@ -12,10 +12,10 @@ func syscallErr(err error) syscall.Errno { } switch err := err.(type) { - case blefs.FSError: + case fsproto.Error: switch err.Code { case 0x02: // filesystem error - return syscall.EIO // TODO + return syscall.EIO case 0x05: // read-only filesystem return syscall.EROFS case 0x03: // no such file @@ -25,7 +25,7 @@ func syscallErr(err error) syscall.Errno { case -5: // input/output error return syscall.EIO case -84: // filesystem is corrupted - return syscall.ENOTRECOVERABLE // TODO + return syscall.ENOTRECOVERABLE case -2: // no such directory entry return syscall.ENOENT case -17: // entry already exists @@ -45,28 +45,24 @@ func syscallErr(err error) syscall.Errno { case -12: // no more memory available return syscall.ENOMEM case -61: // no attr available - return syscall.ENODATA // TODO + return syscall.ENODATA case -36: // file name is too long return syscall.ENAMETOOLONG } default: switch err { - case blefs.ErrFileNotExists: // file does not exist + case fsproto.ErrFileNotExists: // file does not exist return syscall.ENOENT - case blefs.ErrFileReadOnly: // file is read only + case fsproto.ErrFileReadOnly: // file is read only return syscall.EACCES - case blefs.ErrFileWriteOnly: // file is write only + case fsproto.ErrFileWriteOnly: // file is write only return syscall.EACCES - case blefs.ErrInvalidOffset: // invalid file offset + case fsproto.ErrInvalidOffset: // invalid file offset return syscall.EINVAL - case blefs.ErrOffsetChanged: // offset has already been changed - return syscall.ESPIPE - case blefs.ErrReadOpen: // only one file can be opened for reading at a time - return syscall.ENFILE - case blefs.ErrWriteOpen: // only one file can be opened for writing at a time - return syscall.ENFILE - case blefs.ErrNoRemoveRoot: // refusing to remove root directory + case fsproto.ErrNoRemoveRoot: // refusing to remove root directory return syscall.EPERM + default: + return syscall.EINVAL } } diff --git a/main.go b/main.go index 1aba050..56016cc 100644 --- a/main.go +++ b/main.go @@ -33,7 +33,7 @@ import ( "github.com/gen2brain/dlgs" "github.com/knadh/koanf" "github.com/mattn/go-isatty" - "go.elara.ws/infinitime" + "go.elara.ws/itd/infinitime" "go.elara.ws/logger" "go.elara.ws/logger/log" ) @@ -59,55 +59,43 @@ func main() { if err != nil { level = logger.LogLevelInfo } - - // Initialize infinitime library - infinitime.Init(k.String("bluetooth.adapter")) - // Cleanly exit after function - defer infinitime.Exit() + log.Logger.SetLevel(level) // Create infinitime options struct - opts := &infinitime.Options{ - AttemptReconnect: k.Bool("conn.reconnect"), - WhitelistEnabled: k.Bool("conn.whitelist.enabled"), - Whitelist: k.Strings("conn.whitelist.devices"), - OnReqPasskey: onReqPasskey, - Logger: log.Logger, - LogLevel: level, + opts := infinitime.Options{ + OnReconnect: func(dev *infinitime.Device) { + if k.Bool("on.reconnect.setTime") { + // Set time to current time + err = dev.SetTime(time.Now()) + if err != nil { + return + } + } + + // If config specifies to notify on reconnect + if k.Bool("on.reconnect.notify") { + // Send notification to InfiniTime + err = dev.Notify("itd", "Successfully reconnected") + if err != nil { + return + } + } + + // FS must be updated on reconnect + updateFS = true + // Resend weather on reconnect + sendWeatherCh <- struct{}{} + }, } ctx := context.Background() // Connect to InfiniTime with default options - dev, err := infinitime.Connect(ctx, opts) + dev, err := infinitime.Connect(opts) if err != nil { log.Fatal("Error connecting to InfiniTime").Err(err).Send() } - // When InfiniTime reconnects - opts.OnReconnect = func() { - if k.Bool("on.reconnect.setTime") { - // Set time to current time - err = dev.SetTime(time.Now()) - if err != nil { - return - } - } - - // If config specifies to notify on reconnect - if k.Bool("on.reconnect.notify") { - // Send notification to InfiniTime - err = dev.Notify("itd", "Successfully reconnected") - if err != nil { - return - } - } - - // FS must be updated on reconnect - updateFS = true - // Resend weather on reconnect - sendWeatherCh <- struct{}{} - } - // Get firmware version ver, err := dev.Version() if err != nil { diff --git a/maps.go b/maps.go index 2c1e2fc..f195ab7 100644 --- a/maps.go +++ b/maps.go @@ -5,7 +5,7 @@ import ( "strings" "github.com/godbus/dbus/v5" - "go.elara.ws/infinitime" + "go.elara.ws/itd/infinitime" "go.elara.ws/itd/internal/utils" "go.elara.ws/logger/log" ) @@ -100,7 +100,7 @@ func initPureMaps(ctx context.Context, wg WaitGroup, dev *infinitime.Device) err continue } - err = dev.Navigation.SetFlag(infinitime.NavFlag(icon)) + err = dev.SetNavFlag(infinitime.NavFlag(icon)) if err != nil { log.Error("Error setting flag").Err(err).Str("property", member).Send() continue @@ -113,7 +113,7 @@ func initPureMaps(ctx context.Context, wg WaitGroup, dev *infinitime.Device) err continue } - err = dev.Navigation.SetNarrative(narrative) + err = dev.SetNavNarrative(narrative) if err != nil { log.Error("Error setting flag").Err(err).Str("property", member).Send() continue @@ -126,7 +126,7 @@ func initPureMaps(ctx context.Context, wg WaitGroup, dev *infinitime.Device) err continue } - err = dev.Navigation.SetManDist(manDist) + err = dev.SetNavManeuverDistance(manDist) if err != nil { log.Error("Error setting flag").Err(err).Str("property", member).Send() continue @@ -139,7 +139,7 @@ func initPureMaps(ctx context.Context, wg WaitGroup, dev *infinitime.Device) err continue } - err = dev.Navigation.SetProgress(uint8(progress)) + err = dev.SetNavProgress(uint8(progress)) if err != nil { log.Error("Error setting flag").Err(err).Str("property", member).Send() continue @@ -165,7 +165,7 @@ func setAll(navigator dbus.BusObject, dev *infinitime.Device) error { return err } - err = dev.Navigation.SetFlag(infinitime.NavFlag(icon)) + err = dev.SetNavFlag(infinitime.NavFlag(icon)) if err != nil { return err } @@ -176,7 +176,7 @@ func setAll(navigator dbus.BusObject, dev *infinitime.Device) error { return err } - err = dev.Navigation.SetNarrative(narrative) + err = dev.SetNavNarrative(narrative) if err != nil { return err } @@ -187,7 +187,7 @@ func setAll(navigator dbus.BusObject, dev *infinitime.Device) error { return err } - err = dev.Navigation.SetManDist(manDist) + err = dev.SetNavManeuverDistance(manDist) if err != nil { return err } @@ -198,7 +198,7 @@ func setAll(navigator dbus.BusObject, dev *infinitime.Device) error { return err } - return dev.Navigation.SetProgress(uint8(progress)) + return dev.SetNavProgress(uint8(progress)) } // pureMapsExists checks to make sure the PureMaps service exists on the bus diff --git a/metrics.go b/metrics.go index fca555d..1f4670a 100644 --- a/metrics.go +++ b/metrics.go @@ -6,7 +6,7 @@ import ( "path/filepath" "time" - "go.elara.ws/infinitime" + "go.elara.ws/itd/infinitime" "go.elara.ws/logger/log" _ "modernc.org/sqlite" ) @@ -47,82 +47,95 @@ func initMetrics(ctx context.Context, wg WaitGroup, dev *infinitime.Device) erro return err } - // If heart rate metrics enabled in config + // Watch heart rate if k.Bool("metrics.heartRate.enabled") { - // Watch heart rate - heartRateCh, err := dev.WatchHeartRate(ctx) + err := dev.WatchHeartRate(ctx, func(heartRate uint8, err error) { + if err != nil { + // Handle error + return + } + // Get current time + unixTime := time.Now().UnixNano() + // Insert sample and time into database + db.Exec("INSERT INTO heartRate VALUES (?, ?);", unixTime, heartRate) + }) if err != nil { return err } - go func() { - // For every heart rate sample - for heartRate := range heartRateCh { - // Get current time - unixTime := time.Now().UnixNano() - // Insert sample and time into database - db.Exec("INSERT INTO heartRate VALUES (?, ?);", unixTime, heartRate) - } - }() } // If step count metrics enabled in config if k.Bool("metrics.stepCount.enabled") { // Watch step count - stepCountCh, err := dev.WatchStepCount(ctx) + err := dev.WatchStepCount(ctx, func(count uint32, err error) { + if err != nil { + return + } + // Get current time + unixTime := time.Now().UnixNano() + // Insert sample and time into database + db.Exec("INSERT INTO stepCount VALUES (?, ?);", unixTime, count) + }) if err != nil { return err } - go func() { - // For every step count sample - for stepCount := range stepCountCh { - // Get current time - unixTime := time.Now().UnixNano() - // Insert sample and time into database - db.Exec("INSERT INTO stepCount VALUES (?, ?);", unixTime, stepCount) - } - }() } - // If battery level metrics enabled in config + // Watch step count + if k.Bool("metrics.stepCount.enabled") { + err := dev.WatchStepCount(ctx, func(count uint32, err error) { + if err != nil { + // Handle error + return + } + // Get current time + unixTime := time.Now().UnixNano() + // Insert sample and time into database + db.Exec("INSERT INTO stepCount VALUES (?, ?);", unixTime, count) + }) + if err != nil { + return err + } + } + + // Watch battery level if k.Bool("metrics.battLevel.enabled") { - // Watch battery level - battLevelCh, err := dev.WatchBatteryLevel(ctx) + err := dev.WatchBatteryLevel(ctx, func(battLevel uint8, err error) { + if err != nil { + // Handle error + return + } + // Get current time + unixTime := time.Now().UnixNano() + // Insert sample and time into database + db.Exec("INSERT INTO battLevel VALUES (?, ?);", unixTime, battLevel) + }) if err != nil { return err } - go func() { - // For every battery level sample - for battLevel := range battLevelCh { - // Get current time - unixTime := time.Now().UnixNano() - // Insert sample and time into database - db.Exec("INSERT INTO battLevel VALUES (?, ?);", unixTime, battLevel) - } - }() } - // If motion metrics enabled in config + // Watch motion values if k.Bool("metrics.motion.enabled") { - // Watch motion values - motionCh, err := dev.WatchMotion(ctx) + err := dev.WatchMotion(ctx, func(motionVals infinitime.MotionValues, err error) { + if err != nil { + // Handle error + return + } + // Get current time + unixTime := time.Now().UnixNano() + // Insert sample values and time into database + db.Exec( + "INSERT INTO motion VALUES (?, ?, ?, ?);", + unixTime, + motionVals.X, + motionVals.Y, + motionVals.Z, + ) + }) if err != nil { return err } - go func() { - // For every motion sample - for motionVals := range motionCh { - // Get current time - unixTime := time.Now().UnixNano() - // Insert sample values and time into database - db.Exec( - "INSERT INTO motion VALUES (?, ?, ?, ?);", - unixTime, - motionVals.X, - motionVals.Y, - motionVals.Z, - ) - } - }() } wg.Add(1) diff --git a/music.go b/music.go index 0351b8d..f8e1acb 100644 --- a/music.go +++ b/music.go @@ -21,7 +21,7 @@ package main import ( "context" - "go.elara.ws/infinitime" + "go.elara.ws/itd/infinitime" "go.elara.ws/itd/mpris" "go.elara.ws/itd/translit" "go.elara.ws/logger/log" @@ -38,51 +38,43 @@ func initMusicCtrl(ctx context.Context, wg WaitGroup, dev *infinitime.Device) er if !firmwareUpdating { switch ct { case mpris.ChangeTypeStatus: - dev.Music.SetStatus(val == "Playing") + dev.SetMusicStatus(val == "Playing") case mpris.ChangeTypeTitle: - dev.Music.SetTrack(newVal) + dev.SetMusicTrack(newVal) case mpris.ChangeTypeAlbum: - dev.Music.SetAlbum(newVal) + dev.SetMusicAlbum(newVal) case mpris.ChangeTypeArtist: - dev.Music.SetArtist(newVal) + dev.SetMusicArtist(newVal) } } }) // Watch for music events - musicEvtCh, err := dev.Music.WatchEvents() + err := dev.WatchMusicEvents(ctx, func(event infinitime.MusicEvent, err error) { + if err != nil { + log.Error("Music event error").Err(err).Send() + } + + // Perform appropriate action based on event + switch event { + case infinitime.MusicEventPlay: + mpris.Play() + case infinitime.MusicEventPause: + mpris.Pause() + case infinitime.MusicEventNext: + mpris.Next() + case infinitime.MusicEventPrev: + mpris.Prev() + case infinitime.MusicEventVolUp: + mpris.VolUp(uint(k.Int("music.vol.interval"))) + case infinitime.MusicEventVolDown: + mpris.VolDown(uint(k.Int("music.vol.interval"))) + } + }) if err != nil { return err } - wg.Add(1) - go func() { - defer wg.Done("musicCtrl") - // For every music event received - for { - select { - case musicEvt := <-musicEvtCh: - // Perform appropriate action based on event - switch musicEvt { - case infinitime.MusicEventPlay: - mpris.Play() - case infinitime.MusicEventPause: - mpris.Pause() - case infinitime.MusicEventNext: - mpris.Next() - case infinitime.MusicEventPrev: - mpris.Prev() - case infinitime.MusicEventVolUp: - mpris.VolUp(uint(k.Int("music.vol.interval"))) - case infinitime.MusicEventVolDown: - mpris.VolDown(uint(k.Int("music.vol.interval"))) - } - case <-ctx.Done(): - return - } - } - }() - // Log completed initialization log.Info("Initialized InfiniTime music controls").Send() diff --git a/notifs.go b/notifs.go index 489bb87..422bb8f 100644 --- a/notifs.go +++ b/notifs.go @@ -23,7 +23,7 @@ import ( "fmt" "github.com/godbus/dbus/v5" - "go.elara.ws/infinitime" + "go.elara.ws/itd/infinitime" "go.elara.ws/itd/internal/utils" "go.elara.ws/itd/translit" "go.elara.ws/logger/log" diff --git a/socket.go b/socket.go index cfd6b6b..f34da10 100644 --- a/socket.go +++ b/socket.go @@ -19,6 +19,7 @@ package main import ( + "archive/zip" "context" "errors" "io" @@ -28,8 +29,7 @@ import ( "time" "go.elara.ws/drpc/muxserver" - "go.elara.ws/infinitime" - "go.elara.ws/infinitime/blefs" + "go.elara.ws/itd/infinitime" "go.elara.ws/itd/internal/rpc" "go.elara.ws/logger/log" "storj.io/drpc/drpcmux" @@ -60,11 +60,7 @@ func startSocket(ctx context.Context, wg WaitGroup, dev *infinitime.Device) erro return err } - fs, err := dev.FS() - if err != nil { - log.Warn("Error getting BLE filesystem").Err(err).Send() - } - + fs := dev.FS() mux := drpcmux.New() err = rpc.DRPCRegisterITD(mux, &ITD{dev}) @@ -98,19 +94,29 @@ func (i *ITD) HeartRate(_ context.Context, _ *rpc.Empty) (*rpc.IntResponse, erro } func (i *ITD) WatchHeartRate(_ *rpc.Empty, s rpc.DRPCITD_WatchHeartRateStream) error { - heartRateCh, err := i.dev.WatchHeartRate(s.Context()) + errCh := make(chan error) + + err := i.dev.WatchHeartRate(s.Context(), func(rate uint8, err error) { + if err != nil { + errCh <- err + return + } + + err = s.Send(&rpc.IntResponse{Value: uint32(rate)}) + if err != nil { + errCh <- err + } + }) if err != nil { return err } - for heartRate := range heartRateCh { - err = s.Send(&rpc.IntResponse{Value: uint32(heartRate)}) - if err != nil { - return err - } + select { + case <-errCh: + return err + case <-s.Context().Done(): + return nil } - - return nil } func (i *ITD) BatteryLevel(_ context.Context, _ *rpc.Empty) (*rpc.IntResponse, error) { @@ -119,19 +125,29 @@ func (i *ITD) BatteryLevel(_ context.Context, _ *rpc.Empty) (*rpc.IntResponse, e } func (i *ITD) WatchBatteryLevel(_ *rpc.Empty, s rpc.DRPCITD_WatchBatteryLevelStream) error { - battLevelCh, err := i.dev.WatchBatteryLevel(s.Context()) + errCh := make(chan error) + + err := i.dev.WatchBatteryLevel(s.Context(), func(level uint8, err error) { + if err != nil { + errCh <- err + return + } + + err = s.Send(&rpc.IntResponse{Value: uint32(level)}) + if err != nil { + errCh <- err + } + }) if err != nil { return err } - for battLevel := range battLevelCh { - err = s.Send(&rpc.IntResponse{Value: uint32(battLevel)}) - if err != nil { - return err - } + select { + case <-errCh: + return err + case <-s.Context().Done(): + return nil } - - return nil } func (i *ITD) Motion(_ context.Context, _ *rpc.Empty) (*rpc.MotionResponse, error) { @@ -144,23 +160,33 @@ func (i *ITD) Motion(_ context.Context, _ *rpc.Empty) (*rpc.MotionResponse, erro } func (i *ITD) WatchMotion(_ *rpc.Empty, s rpc.DRPCITD_WatchMotionStream) error { - motionValsCh, err := i.dev.WatchMotion(s.Context()) + errCh := make(chan error) + + err := i.dev.WatchMotion(s.Context(), func(motion infinitime.MotionValues, err error) { + if err != nil { + errCh <- err + return + } + + err = s.Send(&rpc.MotionResponse{ + X: int32(motion.X), + Y: int32(motion.Y), + Z: int32(motion.Z), + }) + if err != nil { + errCh <- err + } + }) if err != nil { return err } - for motionVals := range motionValsCh { - err = s.Send(&rpc.MotionResponse{ - X: int32(motionVals.X), - Y: int32(motionVals.Y), - Z: int32(motionVals.Z), - }) - if err != nil { - return err - } + select { + case <-errCh: + return err + case <-s.Context().Done(): + return nil } - - return nil } func (i *ITD) StepCount(_ context.Context, _ *rpc.Empty) (*rpc.IntResponse, error) { @@ -169,19 +195,29 @@ func (i *ITD) StepCount(_ context.Context, _ *rpc.Empty) (*rpc.IntResponse, erro } func (i *ITD) WatchStepCount(_ *rpc.Empty, s rpc.DRPCITD_WatchStepCountStream) error { - stepCountCh, err := i.dev.WatchStepCount(s.Context()) + errCh := make(chan error) + + err := i.dev.WatchStepCount(s.Context(), func(count uint32, err error) { + if err != nil { + errCh <- err + return + } + + err = s.Send(&rpc.IntResponse{Value: count}) + if err != nil { + errCh <- err + } + }) if err != nil { return err } - for stepCount := range stepCountCh { - err = s.Send(&rpc.IntResponse{Value: stepCount}) - if err != nil { - return err - } + select { + case <-errCh: + return err + case <-s.Context().Done(): + return nil } - - return nil } func (i *ITD) Version(_ context.Context, _ *rpc.Empty) (*rpc.StringResponse, error) { @@ -206,39 +242,34 @@ func (i *ITD) WeatherUpdate(context.Context, *rpc.Empty) (*rpc.Empty, error) { return &rpc.Empty{}, nil } -func (i *ITD) FirmwareUpgrade(data *rpc.FirmwareUpgradeRequest, s rpc.DRPCITD_FirmwareUpgradeStream) error { - i.dev.DFU.Reset() +func (i *ITD) FirmwareUpgrade(data *rpc.FirmwareUpgradeRequest, s rpc.DRPCITD_FirmwareUpgradeStream) (err error) { + var fwimg, initpkt *os.File switch data.Type { case rpc.FirmwareUpgradeRequest_Archive: - // If less than one file, return error - if len(data.Files) < 1 { - return ErrDFUNotEnoughFiles - } - // If file is not zip archive, return error - if filepath.Ext(data.Files[0]) != ".zip" { - return ErrDFUInvalidFile - } - // Load DFU archive - err := i.dev.DFU.LoadArchive(data.Files[0]) + fwimg, initpkt, err = extractDFU(data.Files[0]) if err != nil { return err } case rpc.FirmwareUpgradeRequest_Files: - // If less than two files, return error if len(data.Files) < 2 { return ErrDFUNotEnoughFiles } - // If first file is not init packet, return error + if filepath.Ext(data.Files[0]) != ".dat" { return ErrDFUInvalidFile } - // If second file is not firmware image, return error + if filepath.Ext(data.Files[1]) != ".bin" { return ErrDFUInvalidFile } - // Load individual DFU files - err := i.dev.DFU.LoadFiles(data.Files[0], data.Files[1]) + + initpkt, err = os.Open(data.Files[0]) + if err != nil { + return err + } + + fwimg, err = os.Open(data.Files[1]) if err != nil { return err } @@ -246,38 +277,33 @@ func (i *ITD) FirmwareUpgrade(data *rpc.FirmwareUpgradeRequest, s rpc.DRPCITD_Fi return ErrDFUInvalidUpgType } - go func() { - for event := range i.dev.DFU.Progress() { - _ = s.Send(&rpc.DFUProgress{ - Sent: int64(event.Sent), - Recieved: int64(event.Received), - Total: event.Total, - }) - } + defer os.Remove(fwimg.Name()) + defer os.Remove(initpkt.Name()) + defer fwimg.Close() + defer initpkt.Close() - firmwareUpdating = false - }() - - // Set firmwareUpdating firmwareUpdating = true + defer func() { firmwareUpdating = false }() - // Start DFU - err := i.dev.DFU.Start() - if err != nil { - firmwareUpdating = false - return err - } - - return nil + return i.dev.UpgradeFirmware(infinitime.DFUOptions{ + InitPacket: initpkt, + FirmwareImage: fwimg, + ProgressFunc: func(sent, received, total uint32) { + _ = s.Send(&rpc.DFUProgress{ + Sent: int64(sent), + Recieved: int64(received), + Total: int64(total), + }) + }, + }) } type FS struct { dev *infinitime.Device - fs *blefs.FS + fs *infinitime.FS } func (fs *FS) RemoveAll(_ context.Context, req *rpc.PathsRequest) (*rpc.Empty, error) { - fs.updateFS() for _, path := range req.Paths { err := fs.fs.RemoveAll(path) if err != nil { @@ -288,7 +314,6 @@ func (fs *FS) RemoveAll(_ context.Context, req *rpc.PathsRequest) (*rpc.Empty, e } func (fs *FS) Remove(_ context.Context, req *rpc.PathsRequest) (*rpc.Empty, error) { - fs.updateFS() for _, path := range req.Paths { err := fs.fs.Remove(path) if err != nil { @@ -299,12 +324,10 @@ func (fs *FS) Remove(_ context.Context, req *rpc.PathsRequest) (*rpc.Empty, erro } func (fs *FS) Rename(_ context.Context, req *rpc.RenameRequest) (*rpc.Empty, error) { - fs.updateFS() return &rpc.Empty{}, fs.fs.Rename(req.From, req.To) } func (fs *FS) MkdirAll(_ context.Context, req *rpc.PathsRequest) (*rpc.Empty, error) { - fs.updateFS() for _, path := range req.Paths { err := fs.fs.MkdirAll(path) if err != nil { @@ -315,7 +338,6 @@ func (fs *FS) MkdirAll(_ context.Context, req *rpc.PathsRequest) (*rpc.Empty, er } func (fs *FS) Mkdir(_ context.Context, req *rpc.PathsRequest) (*rpc.Empty, error) { - fs.updateFS() for _, path := range req.Paths { err := fs.fs.Mkdir(path) if err != nil { @@ -326,8 +348,6 @@ func (fs *FS) Mkdir(_ context.Context, req *rpc.PathsRequest) (*rpc.Empty, error } func (fs *FS) ReadDir(_ context.Context, req *rpc.PathRequest) (*rpc.DirResponse, error) { - fs.updateFS() - entries, err := fs.fs.ReadDir(req.Path) if err != nil { return nil, err @@ -349,8 +369,6 @@ func (fs *FS) ReadDir(_ context.Context, req *rpc.PathRequest) (*rpc.DirResponse } func (fs *FS) Upload(req *rpc.TransferRequest, s rpc.DRPCFS_UploadStream) error { - fs.updateFS() - localFile, err := os.Open(req.Source) if err != nil { return err @@ -366,15 +384,12 @@ func (fs *FS) Upload(req *rpc.TransferRequest, s rpc.DRPCFS_UploadStream) error return err } - go func() { - // For every progress event - for sent := range remoteFile.Progress() { - _ = s.Send(&rpc.TransferProgress{ - Total: remoteFile.Size(), - Sent: sent, - }) - } - }() + remoteFile.ProgressFunc = func(transferred, total uint32) { + _ = s.Send(&rpc.TransferProgress{ + Total: total, + Sent: transferred, + }) + } io.Copy(remoteFile, localFile) localFile.Close() @@ -384,8 +399,6 @@ func (fs *FS) Upload(req *rpc.TransferRequest, s rpc.DRPCFS_UploadStream) error } func (fs *FS) Download(req *rpc.TransferRequest, s rpc.DRPCFS_DownloadStream) error { - fs.updateFS() - localFile, err := os.Create(req.Destination) if err != nil { return err @@ -399,15 +412,12 @@ func (fs *FS) Download(req *rpc.TransferRequest, s rpc.DRPCFS_DownloadStream) er defer localFile.Close() defer remoteFile.Close() - go func() { - // For every progress event - for sent := range remoteFile.Progress() { - _ = s.Send(&rpc.TransferProgress{ - Total: remoteFile.Size(), - Sent: sent, - }) - } - }() + remoteFile.ProgressFunc = func(transferred, total uint32) { + _ = s.Send(&rpc.TransferProgress{ + Total: total, + Sent: transferred, + }) + } _, err = io.Copy(localFile, remoteFile) if err != nil { @@ -418,42 +428,86 @@ func (fs *FS) Download(req *rpc.TransferRequest, s rpc.DRPCFS_DownloadStream) er } func (fs *FS) LoadResources(req *rpc.PathRequest, s rpc.DRPCFS_LoadResourcesStream) error { - resFl, err := os.Open(req.Path) - if err != nil { - return err - } - - progCh, err := infinitime.LoadResources(resFl, fs.fs) - if err != nil { - return err - } - - for evt := range progCh { - err = s.Send(&rpc.ResourceLoadProgress{ + return infinitime.LoadResources(req.Path, fs.fs, func(evt infinitime.ResourceLoadProgress) { + _ = s.Send(&rpc.ResourceLoadProgress{ Name: evt.Name, - Total: evt.Total, - Sent: evt.Sent, + Total: int64(evt.Total), + Sent: int64(evt.Transferred), Operation: rpc.ResourceLoadProgress_Operation(evt.Operation), }) - if err != nil { - return err + }) +} + +func extractDFU(path string) (fwimg, initpkt *os.File, err error) { + zipReader, err := zip.OpenReader(path) + if err != nil { + return nil, nil, err + } + defer zipReader.Close() + + for _, file := range zipReader.File { + if fwimg != nil && initpkt != nil { + break + } + + switch filepath.Ext(file.Name) { + case ".bin": + fwimg, err = os.CreateTemp(os.TempDir(), "itd_dfu_fwimg_*.bin") + if err != nil { + return nil, nil, err + } + + zipFile, err := file.Open() + if err != nil { + return nil, nil, err + } + defer zipFile.Close() + + _, err = io.Copy(fwimg, zipFile) + if err != nil { + return nil, nil, err + } + + err = zipFile.Close() + if err != nil { + return nil, nil, err + } + + _, err = fwimg.Seek(0, io.SeekStart) + if err != nil { + return nil, nil, err + } + case ".dat": + initpkt, err = os.CreateTemp(os.TempDir(), "itd_dfu_initpkt_*.dat") + if err != nil { + return nil, nil, err + } + + zipFile, err := file.Open() + if err != nil { + return nil, nil, err + } + + _, err = io.Copy(initpkt, zipFile) + if err != nil { + return nil, nil, err + } + + err = zipFile.Close() + if err != nil { + return nil, nil, err + } + + _, err = initpkt.Seek(0, io.SeekStart) + if err != nil { + return nil, nil, err + } } } - return nil -} - -func (fs *FS) updateFS() { - if fs.fs == nil || updateFS { - // Get new FS - newFS, err := fs.dev.FS() - if err != nil { - log.Warn("Error updating BLE filesystem").Err(err).Send() - } else { - // Set FS pointer to new FS - fs.fs = newFS - // Reset updateFS - updateFS = false - } + if fwimg == nil || initpkt == nil { + return nil, nil, errors.New("invalid dfu archive") } + + return fwimg, initpkt, nil } diff --git a/weather.go b/weather.go index eb05382..3bf86d9 100644 --- a/weather.go +++ b/weather.go @@ -4,15 +4,13 @@ import ( "context" "encoding/json" "fmt" - "math" "net/http" "net/url" "strconv" "strings" "time" - "go.elara.ws/infinitime" - "go.elara.ws/infinitime/weather" + "go.elara.ws/itd/infinitime" "go.elara.ws/logger/log" ) @@ -32,7 +30,7 @@ type METData struct { Instant struct { Details struct { AirPressure float32 `json:"air_pressure_at_sea_level"` - AirTemperature float32 `json:"air_temperature"` + Temperature float32 `json:"air_temperature"` DewPoint float32 `json:"dew_point_temperature"` CloudAreaFraction float32 `json:"cloud_area_fraction"` FogAreaFraction float32 `json:"fog_area_fraction"` @@ -50,6 +48,12 @@ type METData struct { PrecipitationAmount float32 `json:"precipitation_amount"` } } `json:"next_1_hours"` + Next6Hours struct { + Details struct { + MaxTemp float32 `json:"air_temperature_max"` + MinTemp float32 `json:"air_temperature_min"` + } + } `json:"next_6_hours"` } // OSMData represents lat/long data from @@ -106,87 +110,28 @@ func initWeather(ctx context.Context, wg WaitGroup, dev *infinitime.Device) erro current := data.Properties.Timeseries[0] currentData := current.Data.Instant.Details - // Add temperature event - err = dev.AddWeatherEvent(weather.TemperatureEvent{ - TimelineHeader: weather.NewHeader( - time.Now(), - weather.EventTypeTemperature, - time.Hour, - ), - Temperature: int16(round(currentData.AirTemperature * 100)), - DewPoint: int16(round(currentData.DewPoint)), - }) - if err != nil { - log.Error("Error adding temperature event").Err(err).Send() + icon := parseSymbol(current.Data.NextHour.Summary.SymbolCode) + if icon == infinitime.WeatherIconClear { + switch { + case currentData.CloudAreaFraction > 0.5: + icon = infinitime.WeatherIconHeavyClouds + case currentData.CloudAreaFraction == 0.5: + icon = infinitime.WeatherIconClouds + case currentData.CloudAreaFraction > 0: + icon = infinitime.WeatherIconFewClouds + } } - // Add precipitation event - err = dev.AddWeatherEvent(weather.PrecipitationEvent{ - TimelineHeader: weather.NewHeader( - time.Now(), - weather.EventTypePrecipitation, - time.Hour, - ), - Type: parseSymbol(current.Data.NextHour.Summary.SymbolCode), - Amount: uint8(round(current.Data.NextHour.Details.PrecipitationAmount)), + err = dev.SetCurrentWeather(infinitime.CurrentWeather{ + Time: time.Now(), + CurrentTemp: currentData.Temperature, + MaxTemp: current.Data.Next6Hours.Details.MaxTemp, + MinTemp: current.Data.Next6Hours.Details.MinTemp, + Location: k.String("weather.location"), + Icon: icon, }) if err != nil { - log.Error("Error adding precipitation event").Err(err).Send() - } - - // Add wind event - err = dev.AddWeatherEvent(weather.WindEvent{ - TimelineHeader: weather.NewHeader( - time.Now(), - weather.EventTypeWind, - time.Hour, - ), - SpeedMin: uint8(round(currentData.WindSpeed)), - SpeedMax: uint8(round(currentData.WindSpeed)), - DirectionMin: uint8(round(currentData.WindDirection)), - DirectionMax: uint8(round(currentData.WindDirection)), - }) - if err != nil { - log.Error("Error adding wind event").Err(err).Send() - } - - // Add cloud event - err = dev.AddWeatherEvent(weather.CloudsEvent{ - TimelineHeader: weather.NewHeader( - time.Now(), - weather.EventTypeClouds, - time.Hour, - ), - Amount: uint8(round(currentData.CloudAreaFraction)), - }) - if err != nil { - log.Error("Error adding clouds event").Err(err).Send() - } - - // Add humidity event - err = dev.AddWeatherEvent(weather.HumidityEvent{ - TimelineHeader: weather.NewHeader( - time.Now(), - weather.EventTypeHumidity, - time.Hour, - ), - Humidity: uint8(round(currentData.RelativeHumidity)), - }) - if err != nil { - log.Error("Error adding humidity event").Err(err).Send() - } - - // Add pressure event - err = dev.AddWeatherEvent(weather.PressureEvent{ - TimelineHeader: weather.NewHeader( - time.Now(), - weather.EventTypePressure, - time.Hour, - ), - Pressure: int16(round(currentData.AirPressure)), - }) - if err != nil { - log.Error("Error adding pressure event").Err(err).Send() + log.Error("Error setting weather").Err(err).Send() } // Reset timer to 1 hour @@ -283,26 +228,19 @@ func getWeather(ctx context.Context, lat, lon float64) (*METResponse, error) { return out, nil } -// parseSymbol determines what type of precipitation a symbol code -// codes for. -func parseSymbol(symCode string) weather.PrecipitationType { +// parseSymbol determines what weather icon a symbol code codes for. +func parseSymbol(symCode string) infinitime.WeatherIcon { switch { case strings.Contains(symCode, "lightrain"): - return weather.PrecipitationTypeRain + return infinitime.WeatherIconRain case strings.Contains(symCode, "rain"): - return weather.PrecipitationTypeRain - case strings.Contains(symCode, "snow"): - return weather.PrecipitationTypeSnow - case strings.Contains(symCode, "sleet"): - return weather.PrecipitationTypeSleet - case strings.Contains(symCode, "snow"): - return weather.PrecipitationTypeSnow + return infinitime.WeatherIconCloudsWithRain + case strings.Contains(symCode, "snow"), + strings.Contains(symCode, "sleet"): + return infinitime.WeatherIconSnow + case strings.Contains(symCode, "thunder"): + return infinitime.WeatherIconThunderstorm default: - return weather.PrecipitationTypeNone + return infinitime.WeatherIconClear } } - -// round rounds 32-bit floats to 32-bit integers -func round(f float32) int32 { - return int32(math.Round(float64(f))) -}